From 72ebd2a27cfe7141914fb3fc5cd710bae0faef4f Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 11:57:21 +0700 Subject: [PATCH 1/7] Analyse the module root in full, and give this package a CI that can be read src/root.zig is what every consumer imports, and no test target rooted it, so the one surface that matters was the one never compiled. Zig analyses top-level declarations lazily: a green test run proved only that the declarations the other tests happened to reference compile. That gap is not hypothetical. The same omission in gHashTag/zig-hdc hid five distinct API-drift errors against the version of THIS package that it pins, while its CI stayed green throughout. The new workflow is separate from test-bindings.yml on purpose. That one has been failing for its own reasons, and a verdict arriving into an already-red workflow carries no information -- the point of this file is to produce a verdict that can be read. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ build.zig | 11 +++++++++++ src/root.zig | 14 ++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6969810 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +# Builds and tests this package on the version its consumers target. +# +# Deliberately separate from test-bindings.yml, which has been failing for its +# own reasons: a verdict that arrives into a workflow already red carries no +# information, and the whole point of this file is to produce a verdict that can +# be read. + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + - run: zig build + - name: Test, including the module root in full + # src/root.zig is what every consumer imports, and until now no test + # target rooted it -- so the one surface that matters was the one never + # compiled. refAllDeclsRecursive in that file forces it through. + run: zig build test diff --git a/build.zig b/build.zig index e8d2134..5ee6d8b 100644 --- a/build.zig +++ b/build.zig @@ -74,6 +74,16 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // The module root, analysed in full. Nothing rooted src/root.zig before, so + // the surface consumers actually import was the one part never compiled. + const root_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }), + }); + const c_abi_tests = b.addTest(.{ .name = "c-abi-tests", .root_module = c_abi_test_module, @@ -231,4 +241,5 @@ pub fn build(b: *std.Build) void { const run_igla_bench = b.addRunArtifact(igla_bench); const igla_bench_step = b.step("bench-igla", "Run IGLA-GF16 architecture verification (Module 7)"); igla_bench_step.dependOn(&run_igla_bench.step); + test_step.dependOn(&b.addRunArtifact(root_tests).step); } diff --git a/src/root.zig b/src/root.zig index de656a4..58fb8fa 100644 --- a/src/root.zig +++ b/src/root.zig @@ -97,3 +97,17 @@ pub const PHI_INV_SQ = formats.PHI_INV_SQ; /// Trinity Identity: φ² + 1/φ² = 3 pub const TRINITY = formats.TRINITY; + +test "every public declaration of this module is analysed" { + // src/root.zig is the module root every consumer gets, and until now no test + // target rooted it -- so its declarations were never all handed to the + // compiler. Zig analyses top-level declarations lazily, which means a green + // `zig build test` proved only that the decls the other tests happened to + // reference compile, and a consumer touching anything else could get errors + // this repository's own CI had no way to see. + // + // That is not hypothetical. The same omission in gHashTag/zig-hdc hid five + // distinct API-drift errors against the version of this package it pins, + // while its CI stayed green throughout (zig-hdc#2). + @import("std").testing.refAllDeclsRecursive(@This()); +} From 4dff48994bc4e8783cbf759cf6249eabf1431d41 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 12:08:54 +0700 Subject: [PATCH 2/7] The version this package declares is now the version it builds on zig build failed on 0.15.2, the version declared in minimum_zig_version and the one both consumers use. The library was never the problem: the only failure was tools/gen/tri_gen.zig, which is written against Zig 0.16 on purpose -- its own comments say so, and it uses std.Io, std.Io.Dir and std.process.Init, none of which exist in 0.15. Installing a 0.16-only tool by default made the LIBRARY unbuildable for everybody in order to keep a tool nobody can run at that version. It moves behind an explicit `zig build tools` step, so the version claim becomes true rather than aspirational. This is not turning a build green by deleting what failed. What failed is still built, by a step that names the toolchain it needs, and CI asserts that step still exists so it cannot be silently dropped. None of this was visible before, because nothing in this repository ran zig build at all. --- .github/workflows/ci.yml | 11 ++++++++++- build.zig | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6969810..d6a80bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,18 @@ jobs: - uses: mlugg/setup-zig@v2 with: version: 0.15.2 - - run: zig build + - name: The library builds on the version this package declares + run: zig build - name: Test, including the module root in full # src/root.zig is what every consumer imports, and until now no test # target rooted it -- so the one surface that matters was the one never # compiled. refAllDeclsRecursive in that file forces it through. run: zig build test + + - name: The 0.16-only tools are still buildable, on 0.16 + run: | + set -euo pipefail + echo "zig build tools needs 0.16; this job pins 0.15.2 to match consumers." + echo "Checked here only that the step exists, so it cannot be silently dropped:" + zig build --help 2>&1 | grep -q '^ tools ' && echo " tools step present" || { + echo "::error::the tools step is gone"; exit 1; } diff --git a/build.zig b/build.zig index 5ee6d8b..a746228 100644 --- a/build.zig +++ b/build.zig @@ -34,7 +34,24 @@ pub fn build(b: *std.Build) void { .root_module = tri_gen_module, }); - b.installArtifact(tri_gen); + // NOT installed by default, and not because it is broken. + // + // tri_gen and tri_reader are written against Zig 0.16 on purpose -- their own + // comments say so, and they use std.Io, std.Io.Dir and std.process.Init, + // none of which exist in 0.15. This package declares + // minimum_zig_version 0.15.0 and both of its consumers build with 0.15.2, so + // installing a 0.16-only tool by default made the LIBRARY unbuildable for + // everybody in order to keep a tool nobody can run at that version. + // + // The library itself compiles on 0.15 -- the only failure was here. So the + // tool moves behind an explicit step and the version claim becomes true + // rather than aspirational. `zig build gen` still builds and runs it, on a + // toolchain that has the API it was written for. + // + // This is not making a build green by deleting what failed: what failed is + // still built, by a step that names the toolchain it needs. + const tools_step = b.step("tools", "Build the .tri code generator (requires Zig 0.16)"); + tools_step.dependOn(&b.addInstallArtifact(tri_gen, .{}).step); const run_tri_gen = b.addRunArtifact(tri_gen); const gen_step = b.step("gen", "Generate code from .tri specs"); From 42a0a7425bacd15f4fac2a4ec36556cc2d2d3da0 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 12:59:28 +0700 Subject: [PATCH 3/7] Make the public surface compile on 0.15, the version this package declares Eleven errors across five files, from the full-surface analysis added in the previous commit. They fall into two kinds and it is worth keeping them apart. Four were APIs that existed in 0.14 and were removed in 0.15, so this code had been left behind by a version bump nobody could see because nothing ran zig build: std.fmt.fmtSliceHexLower -> {x} on the slice std.io.getStdOut -> std.fs.File.stdout().writer(&buf).interface std.atomic.fence(.acquire)-> folded into fetchSub(1, .acq_rel), which is where that acquire always belonged OpenFlags .read/.write -> .mode = .read_write Seven were plain defects, none of them about versions: sacredChecksum multiplied a u64 by the f64 PHI and then called @intFromFloat on the u64 result. It has never compiled, so no stored checksum can depend on it; it now multiplies by 2^64/phi, which is what phi IS in integer hashing rather than a number I picked. getEffectivePriority switched on a runtime value into comptime_float arms. Annotated f64. qbind declared *const and called bind, which calls ensureUnpacked, which fills a cache inside the value and so must mutate it. qbind takes mutable pointers now: making bind const would have been a lie about what it does, and qbind has no callers yet. applyPhase took @abs of an i32, which is a u32, and asked for it modulo an i32. The modulus is unsigned now. Three sites assigned an i8 from unpacked_cache into an i2 without a cast. The compiler named three; there are six, the other three differing only in whether they read a or b. Fixing the named three would have left half the defect standing. --- src/math/constants.zig | 13 ++++++++++--- src/vsa/10k_vsa.zig | 11 +++++++++-- src/vsa/concurrency.zig | 12 +++++++++--- src/vsa/core.zig | 16 +++++++++++++--- src/vsa/fpga_bind.zig | 16 ++++++++-------- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/math/constants.zig b/src/math/constants.zig index eb0fb4c..e4183c9 100644 --- a/src/math/constants.zig +++ b/src/math/constants.zig @@ -85,11 +85,18 @@ pub const SacredMath = struct { /// Generate sacred checksum for validation pub fn sacredChecksum(data: []const u8) u64 { - var hash: u64 = LAMBDA_10; + // The multiplier is 2^64/φ, which is what φ IS in integer hashing -- + // Knuth's multiplicative constant. The previous line multiplied a u64 + // by the f64 PHI and then called @intFromFloat on the u64 result, so + // this function has never compiled and no stored checksum can depend + // on it. Taking the standard constant keeps the golden ratio rather + // than inventing a number. + const PHI_U64: u64 = 0x9E3779B97F4A7C15; + var hash: u64 = @intFromFloat(LAMBDA_10); for (data) |byte| { - hash = hash *% PHI + byte; + hash = hash *% PHI_U64 +% byte; } - return @intFromFloat(hash); + return hash; } /// Verify Trinity alignment diff --git a/src/vsa/10k_vsa.zig b/src/vsa/10k_vsa.zig index 4453139..cbbe2ff 100644 --- a/src/vsa/10k_vsa.zig +++ b/src/vsa/10k_vsa.zig @@ -240,7 +240,9 @@ pub const HyperVector10K = struct { /// Format as hex string pub fn formatHex(self: *const Self, allocator: std.mem.Allocator) ![]u8 { - return std.fmt.allocPrint(allocator, "{s}", .{std.fmt.fmtSliceHexLower(&self.data)}); + // std.fmt.fmtSliceHexLower was removed in 0.15; {x} on the slice is the + // replacement and produces the same lowercase hex. + return std.fmt.allocPrint(allocator, "{x}", .{&self.data}); } }; @@ -322,7 +324,12 @@ pub fn benchmark(_: std.mem.Allocator, iterations: usize) !BenchmarkResult { /// Print benchmark results pub fn printBenchmark(result: BenchmarkResult) void { - const stdout = std.io.getStdOut().writer(); + // std.io.getStdOut() was removed in 0.15. A File writer needs a buffer it + // does not own, so the buffer lives here and the interface borrows it. + var stdout_buffer: [4096]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + defer stdout.flush() catch {}; stdout.print( \\╔════════════════════════════════════════════════════════════════════════════╗ diff --git a/src/vsa/concurrency.zig b/src/vsa/concurrency.zig index c21cfe8..29aa4be 100644 --- a/src/vsa/concurrency.zig +++ b/src/vsa/concurrency.zig @@ -64,16 +64,22 @@ pub const TaskNode = struct { return true; } pub fn satisfyDependency(self: *TaskNode) bool { - const remaining = self.wait_count.fetchSub(1, .release) - 1; + // std.atomic.fence was removed in 0.15. The acquire it supplied belongs + // on the operation itself: acq_rel gives the release for this decrement + // and the acquire for observing the last one, which is exactly what the + // separate fence was there to add. + const remaining = self.wait_count.fetchSub(1, .acq_rel) - 1; if (remaining == 0) { - std.atomic.fence(.acquire); self.state = .ready; return true; } return false; } pub fn getEffectivePriority(self: *const TaskNode) f64 { - const base = switch (self.priority) { + // Annotated because the arms are comptime_float and the switch is on a + // runtime value: a comptime-only type cannot depend on runtime control + // flow, which is what the compiler was saying. + const base: f64 = switch (self.priority) { .critical => 1.0, .high => 0.8, .normal => 0.6, diff --git a/src/vsa/core.zig b/src/vsa/core.zig index f3e3434..ec13a59 100644 --- a/src/vsa/core.zig +++ b/src/vsa/core.zig @@ -498,7 +498,13 @@ test "cosineSimilarityF16 zero vectors" { /// /// In VSA framework: bind = XOR-like operation on hypervectors /// Quantum extension: track phase information for interference -pub fn qbind(a: *const HybridBigInt, b: *const HybridBigInt) HybridBigInt { +// Mutable pointers, matching bind. It would read better as *const, but bind +// calls ensureUnpacked, which fills a cache inside the value and therefore +// needs to mutate it. Declaring const here and delegating to something that +// mutates is the mismatch the compiler was reporting; making bind const would +// have been a lie about what it does. No caller exists yet, so nothing else +// moves with this. +pub fn qbind(a: *HybridBigInt, b: *HybridBigInt) HybridBigInt { // Classical bind (XOR-like for balanced ternary) const result = bind(a, b); @@ -653,9 +659,13 @@ pub fn applyPhase(vec: *const HybridBigInt, phase_shift: f32, allocator: std.mem // For ternary VSA, we simulate via permute-like operation // Number of trit positions to shift - const shift_amount = @abs(@as(i32, @intFromFloat(phase_shift * 10.0))) % @as(i32, @intCast(vec.trit_len)); + // @abs of an i32 is a u32, so the modulus has to be unsigned as well; it was + // an i32 and the two sides could not meet. The result is already + // non-negative, which is why the second @abs below is now redundant and + // kept only so the line reads the same as it did. + const shift_amount = @abs(@as(i32, @intFromFloat(phase_shift * 10.0))) % @as(u32, @intCast(vec.trit_len)); - const abs_shift = @abs(shift_amount); + const abs_shift = shift_amount; if (abs_shift > 0) { return permute(vec, @as(usize, @intCast(abs_shift))); diff --git a/src/vsa/fpga_bind.zig b/src/vsa/fpga_bind.zig index 5fff7ca..ab053ea 100644 --- a/src/vsa/fpga_bind.zig +++ b/src/vsa/fpga_bind.zig @@ -38,9 +38,9 @@ pub const FPGAInterface = struct { return error.UnsupportedOS; // Try to open the UART device + // OpenFlags dropped the separate .read/.write booleans for .mode. const port = std.fs.openFileAbsolute(device_path, .{ - .read = true, - .write = true, + .mode = .read_write, }) catch |err| { std.log.err("Failed to open FPGA UART device: {}", .{err}); return err; @@ -146,7 +146,7 @@ pub const FPGAInterface = struct { // Pack vector A for (0..dim) |i| { - const trit_val: i2 = if (i < a.trit_len) a.unpacked_cache[i] else 0; + const trit_val: i2 = if (i < a.trit_len) @intCast(a.unpacked_cache[i]) else 0; const encoded = encodeTrit(trit_val); const byte_idx = (i * 2) / 8; const bit_offset = (i * 2) % 8; @@ -159,7 +159,7 @@ pub const FPGAInterface = struct { // Pack vector B (offset by bytes_needed) const b_offset = bytes_needed; for (0..dim) |i| { - const trit_val: i2 = if (i < b.trit_len) b.unpacked_cache[i] else 0; + const trit_val: i2 = if (i < b.trit_len) @intCast(b.unpacked_cache[i]) else 0; const encoded = encodeTrit(trit_val); const byte_idx = b_offset + (i * 2) / 8; const bit_offset = (i * 2) % 8; @@ -209,7 +209,7 @@ pub const FPGAInterface = struct { // Pack vectors (same as bind) for (0..dim) |i| { - const trit_val: i2 = if (i < a.trit_len) a.unpacked_cache[i] else 0; + const trit_val: i2 = if (i < a.trit_len) @intCast(a.unpacked_cache[i]) else 0; const encoded = encodeTrit(trit_val); const byte_idx = (i * 2) / 8; const bit_offset = (i * 2) % 8; @@ -221,7 +221,7 @@ pub const FPGAInterface = struct { const b_offset = bytes_needed; for (0..dim) |i| { - const trit_val: i2 = if (i < b.trit_len) b.unpacked_cache[i] else 0; + const trit_val: i2 = if (i < b.trit_len) @intCast(b.unpacked_cache[i]) else 0; const encoded = encodeTrit(trit_val); const byte_idx = b_offset + (i * 2) / 8; const bit_offset = (i * 2) % 8; @@ -264,7 +264,7 @@ pub const FPGAInterface = struct { // Pack vectors for (0..dim) |i| { - const trit_val: i2 = if (i < a.trit_len) a.unpacked_cache[i] else 0; + const trit_val: i2 = if (i < a.trit_len) @intCast(a.unpacked_cache[i]) else 0; const encoded = encodeTrit(trit_val); const byte_idx = (i * 2) / 8; const bit_offset = (i * 2) % 8; @@ -276,7 +276,7 @@ pub const FPGAInterface = struct { const b_offset = bytes_needed; for (0..dim) |i| { - const trit_val: i2 = if (i < b.trit_len) b.unpacked_cache[i] else 0; + const trit_val: i2 = if (i < b.trit_len) @intCast(b.unpacked_cache[i]) else 0; const encoded = encodeTrit(trit_val); const byte_idx = b_offset + (i * 2) / 8; const bit_offset = (i * 2) % 8; From 23805363530deca7c670f6d9af7ab8cbff41a717 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 13:05:15 +0700 Subject: [PATCH 4/7] The five that were standing behind the first eleven Fixing eleven errors uncovered five more, which is the normal shape: a compiler stops at the first failure in a unit and the ones behind it are invisible until it does not. applyPhase declared *const and called permute, which takes a mutable pointer for the same reason bind does -- the value caches its own unpacked form. Mutable now, and it has no callers, so nothing else moves. Three shift sites packed a u2 into a byte with `encoded << bit_offset`. Zig types a shift amount by the width of what is being shifted, so a u2 admits only a u1 -- meaning a bit_offset of 0, 2, 4 or 6 could not be used there at all. The value is going into a u8, so it is widened first and the amount typed to match. init bound the interface with const and then called ping, which takes *Self. A const binding cannot hand out the mutable pointer a method asks for. I left one thing alone deliberately. The `if (bit_offset >= 6)` branch writes the spill of a 2-bit code into the next byte, and a 2-bit code at offset 6 occupies bits 6 and 7 without spilling, so that branch looks dead. That is a question about the packing, not about the types, and answering it by changing behaviour while fixing a compile error is how a repair becomes a regression. --- src/vsa/core.zig | 5 ++++- src/vsa/fpga_bind.zig | 52 ++++++++++++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/vsa/core.zig b/src/vsa/core.zig index ec13a59..e514b50 100644 --- a/src/vsa/core.zig +++ b/src/vsa/core.zig @@ -653,7 +653,10 @@ pub fn similarity_quantum(a: *const HybridBigInt, b: *const HybridBigInt, phase_ /// Apply phase shift to hypervector (for quantum interference) /// Rotates the vector in the complex phase plane -pub fn applyPhase(vec: *const HybridBigInt, phase_shift: f32, allocator: std.mem.Allocator) !HybridBigInt { +// Mutable, for the same reason qbind is: it delegates to permute, which takes a +// mutable pointer because the value caches its own unpacked form. It has no +// callers yet, so nothing else moves with this. +pub fn applyPhase(vec: *HybridBigInt, phase_shift: f32, allocator: std.mem.Allocator) !HybridBigInt { _ = allocator; // Reserved for future allocation needs // In a full quantum VSA, this would rotate complex amplitudes // For ternary VSA, we simulate via permute-like operation diff --git a/src/vsa/fpga_bind.zig b/src/vsa/fpga_bind.zig index ab053ea..a7ff7cf 100644 --- a/src/vsa/fpga_bind.zig +++ b/src/vsa/fpga_bind.zig @@ -150,9 +150,13 @@ pub const FPGAInterface = struct { const encoded = encodeTrit(trit_val); const byte_idx = (i * 2) / 8; const bit_offset = (i * 2) % 8; - buffer[byte_idx] |= encoded << bit_offset; + // encoded is a u2, and Zig types a shift amount by the width of what + // is being shifted: a u2 admits only a u1, which is why a bit_offset + // of 0, 2, 4 or 6 could not be used here at all. The value is going + // into a u8, so it is widened first and the amount typed to match. + buffer[byte_idx] |= @as(u8, encoded) << @as(u3, @intCast(bit_offset)); if (bit_offset >= 6) { - buffer[byte_idx + 1] |= encoded >> (8 - bit_offset); + buffer[byte_idx + 1] |= @as(u8, encoded) >> @as(u3, @intCast(8 - bit_offset)); } } @@ -163,9 +167,13 @@ pub const FPGAInterface = struct { const encoded = encodeTrit(trit_val); const byte_idx = b_offset + (i * 2) / 8; const bit_offset = (i * 2) % 8; - buffer[byte_idx] |= encoded << bit_offset; + // encoded is a u2, and Zig types a shift amount by the width of what + // is being shifted: a u2 admits only a u1, which is why a bit_offset + // of 0, 2, 4 or 6 could not be used here at all. The value is going + // into a u8, so it is widened first and the amount typed to match. + buffer[byte_idx] |= @as(u8, encoded) << @as(u3, @intCast(bit_offset)); if (bit_offset >= 6) { - buffer[byte_idx + 1] |= encoded >> (8 - bit_offset); + buffer[byte_idx + 1] |= @as(u8, encoded) >> @as(u3, @intCast(8 - bit_offset)); } } @@ -213,9 +221,13 @@ pub const FPGAInterface = struct { const encoded = encodeTrit(trit_val); const byte_idx = (i * 2) / 8; const bit_offset = (i * 2) % 8; - buffer[byte_idx] |= encoded << bit_offset; + // encoded is a u2, and Zig types a shift amount by the width of what + // is being shifted: a u2 admits only a u1, which is why a bit_offset + // of 0, 2, 4 or 6 could not be used here at all. The value is going + // into a u8, so it is widened first and the amount typed to match. + buffer[byte_idx] |= @as(u8, encoded) << @as(u3, @intCast(bit_offset)); if (bit_offset >= 6) { - buffer[byte_idx + 1] |= encoded >> (8 - bit_offset); + buffer[byte_idx + 1] |= @as(u8, encoded) >> @as(u3, @intCast(8 - bit_offset)); } } @@ -225,9 +237,13 @@ pub const FPGAInterface = struct { const encoded = encodeTrit(trit_val); const byte_idx = b_offset + (i * 2) / 8; const bit_offset = (i * 2) % 8; - buffer[byte_idx] |= encoded << bit_offset; + // encoded is a u2, and Zig types a shift amount by the width of what + // is being shifted: a u2 admits only a u1, which is why a bit_offset + // of 0, 2, 4 or 6 could not be used here at all. The value is going + // into a u8, so it is widened first and the amount typed to match. + buffer[byte_idx] |= @as(u8, encoded) << @as(u3, @intCast(bit_offset)); if (bit_offset >= 6) { - buffer[byte_idx + 1] |= encoded >> (8 - bit_offset); + buffer[byte_idx + 1] |= @as(u8, encoded) >> @as(u3, @intCast(8 - bit_offset)); } } @@ -268,9 +284,13 @@ pub const FPGAInterface = struct { const encoded = encodeTrit(trit_val); const byte_idx = (i * 2) / 8; const bit_offset = (i * 2) % 8; - buffer[byte_idx] |= encoded << bit_offset; + // encoded is a u2, and Zig types a shift amount by the width of what + // is being shifted: a u2 admits only a u1, which is why a bit_offset + // of 0, 2, 4 or 6 could not be used here at all. The value is going + // into a u8, so it is widened first and the amount typed to match. + buffer[byte_idx] |= @as(u8, encoded) << @as(u3, @intCast(bit_offset)); if (bit_offset >= 6) { - buffer[byte_idx + 1] |= encoded >> (8 - bit_offset); + buffer[byte_idx + 1] |= @as(u8, encoded) >> @as(u3, @intCast(8 - bit_offset)); } } @@ -280,9 +300,13 @@ pub const FPGAInterface = struct { const encoded = encodeTrit(trit_val); const byte_idx = b_offset + (i * 2) / 8; const bit_offset = (i * 2) % 8; - buffer[byte_idx] |= encoded << bit_offset; + // encoded is a u2, and Zig types a shift amount by the width of what + // is being shifted: a u2 admits only a u1, which is why a bit_offset + // of 0, 2, 4 or 6 could not be used here at all. The value is going + // into a u8, so it is widened first and the amount typed to match. + buffer[byte_idx] |= @as(u8, encoded) << @as(u3, @intCast(bit_offset)); if (bit_offset >= 6) { - buffer[byte_idx + 1] |= encoded >> (8 - bit_offset); + buffer[byte_idx + 1] |= @as(u8, encoded) >> @as(u3, @intCast(8 - bit_offset)); } } @@ -377,7 +401,9 @@ pub const AutoVSA = struct { /// Initialize with auto-detection pub fn init(config: Config, allocator: std.mem.Allocator) Self { - const fpga = FPGAInterface.init(config, allocator) catch |err| { + // var, because ping takes *Self and a const binding cannot hand out the + // mutable pointer the method asks for. + var fpga = FPGAInterface.init(config, allocator) catch |err| { std.log.warn("FPGA unavailable ({}), using CPU fallback", .{err}); return Self{ .fpga = null, From 8bae9363587701b528c1468e0173b5adf25729fc Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 13:07:34 +0700 Subject: [PATCH 5/7] Shift amounts on the unpacking side, and a sign extension that was already done Two more shift sites, this time reading rather than writing: response[byte_idx] is a u8, so the amount has to be a u3. Same rule as the packing side. And the sign extension in similarity. `if (dot_msb & 0x04)` handed a u8 to an `if`, which takes a bool, so this line never compiled -- and what it reached for had already happened: @bitCast from u11 to i11 reads the top bit as the sign, and bit 2 of dot_msb IS that bit. The line also or-ed 0xF800 into an i11, a value that does not fit in one, so it could not have run even with a well typed condition. Removed rather than repaired, because there is nothing left for it to do. --- src/vsa/fpga_bind.zig | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/vsa/fpga_bind.zig b/src/vsa/fpga_bind.zig index a7ff7cf..1e1a861 100644 --- a/src/vsa/fpga_bind.zig +++ b/src/vsa/fpga_bind.zig @@ -191,7 +191,9 @@ pub const FPGAInterface = struct { for (0..dim) |i| { const byte_idx = 2 + (i * 2) / 8; const bit_offset = (i * 2) % 8; - const encoded = (response[byte_idx] >> bit_offset) & 0x03; + // Same rule as the packing side: response[byte_idx] is a u8, so the + // shift amount has to be a u3. + const encoded = (response[byte_idx] >> @as(u3, @intCast(bit_offset))) & 0x03; result.unpacked_cache[i] = decodeTrit(encoded); } @@ -261,7 +263,9 @@ pub const FPGAInterface = struct { for (0..dim) |i| { const byte_idx = 2 + (i * 2) / 8; const bit_offset = (i * 2) % 8; - const encoded = (response[byte_idx] >> bit_offset) & 0x03; + // Same rule as the packing side: response[byte_idx] is a u8, so the + // shift amount has to be a u3. + const encoded = (response[byte_idx] >> @as(u3, @intCast(bit_offset))) & 0x03; result.unpacked_cache[i] = decodeTrit(encoded); } @@ -319,9 +323,13 @@ pub const FPGAInterface = struct { // Parse dot product (signed 11-bit: -256 to +256) const dot_lsb = response[1]; const dot_msb = response[2] & 0x07; - var dot: i11 = @as(i11, @bitCast(@as(u11, @intCast(dot_msb)) << 8 | dot_lsb)); - // Sign extend - if (dot_msb & 0x04) dot |= 0xF800; + const dot: i11 = @as(i11, @bitCast(@as(u11, @intCast(dot_msb)) << 8 | dot_lsb)); + // No manual sign extension. Zig's `if` takes a bool and `dot_msb & 0x04` + // is a u8, so this never compiled -- and the operation it was reaching + // for is already done: @bitCast from u11 to i11 reads the top bit as the + // sign, which is exactly what bit 2 of dot_msb is. The old line also + // or-ed 0xF800 into an i11, a value that does not fit in one, so it + // could not have run even if the condition had been well typed. // Normalize by dimension (cosine similarity for unit vectors) // For raw similarity: just return dot / dim From 9ee36577c1cb70ccec3ec88bda76a86498ec67b9 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 13:09:32 +0700 Subject: [PATCH 6/7] Give decodeTrit the u2 it asks for The mask already narrows the value to two bits; the cast states what the mask guarantees, and decodeTrit takes a u2. --- src/vsa/fpga_bind.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vsa/fpga_bind.zig b/src/vsa/fpga_bind.zig index 1e1a861..a6427f7 100644 --- a/src/vsa/fpga_bind.zig +++ b/src/vsa/fpga_bind.zig @@ -193,7 +193,10 @@ pub const FPGAInterface = struct { const bit_offset = (i * 2) % 8; // Same rule as the packing side: response[byte_idx] is a u8, so the // shift amount has to be a u3. - const encoded = (response[byte_idx] >> @as(u3, @intCast(bit_offset))) & 0x03; + // Typed u2 rather than left as u8: decodeTrit takes a u2, and the + // mask has already narrowed the value to two bits, so the cast + // states what the mask guarantees. + const encoded: u2 = @intCast((response[byte_idx] >> @as(u3, @intCast(bit_offset))) & 0x03); result.unpacked_cache[i] = decodeTrit(encoded); } @@ -265,7 +268,10 @@ pub const FPGAInterface = struct { const bit_offset = (i * 2) % 8; // Same rule as the packing side: response[byte_idx] is a u8, so the // shift amount has to be a u3. - const encoded = (response[byte_idx] >> @as(u3, @intCast(bit_offset))) & 0x03; + // Typed u2 rather than left as u8: decodeTrit takes a u2, and the + // mask has already narrowed the value to two bits, so the cast + // states what the mask guarantees. + const encoded: u2 = @intCast((response[byte_idx] >> @as(u3, @intCast(bit_offset))) & 0x03); result.unpacked_cache[i] = decodeTrit(encoded); } From 68e278e7e9042c44cca539aa8776a345efb1c302 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Tue, 11 Aug 2026 13:13:08 +0700 Subject: [PATCH 7/7] The cpu-fallback test subtracted one from an unsigned zero With the surface compiling, the tests ran, and 266 of 267 passed. The one that did not panicked on integer overflow at its first iteration: i is a usize, so @rem(i, 3) is a usize, and 0 - 1 in unsigned arithmetic is not -1. The cycle it wanted is -1, 0, +1, which needs a signed type to live in. This test has never run. It could not have: the file it is in did not compile until four commits ago. --- src/vsa/fpga_bind.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vsa/fpga_bind.zig b/src/vsa/fpga_bind.zig index a6427f7..46da2e1 100644 --- a/src/vsa/fpga_bind.zig +++ b/src/vsa/fpga_bind.zig @@ -515,8 +515,14 @@ test "fpga bind: cpu fallback" { // Fill with test data for (0..16) |i| { - a.unpacked_cache[i] = @intCast(@rem(i, 3) - 1); - b.unpacked_cache[i] = @intCast(@rem(i + 1, 3) - 1); + // The subtraction has to happen in a signed type. i is a usize, so + // @rem(i, 3) is a usize too, and the first iteration computed 0 - 1 in + // unsigned arithmetic and panicked on the overflow. The cycle intended + // is -1, 0, +1, which needs somewhere for the -1 to live. + const idx: i8 = @intCast(@rem(i, 3)); + const idx_b: i8 = @intCast(@rem(i + 1, 3)); + a.unpacked_cache[i] = idx - 1; + b.unpacked_cache[i] = idx_b - 1; } const result = try cpu.bind(&a, &b);