diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6a80bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +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 + - 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 e8d2134..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"); @@ -74,6 +91,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 +258,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/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/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()); +} 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..e514b50 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); @@ -647,15 +653,22 @@ 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 // 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..46da2e1 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,26 +146,34 @@ 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; - 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)); } } // 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; - 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)); } } @@ -183,7 +191,12 @@ 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. + // 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); } @@ -209,25 +222,33 @@ 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; - 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)); } } 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; - 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)); } } @@ -245,7 +266,12 @@ 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. + // 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); } @@ -264,25 +290,33 @@ 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; - 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)); } } 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; - 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)); } } @@ -295,9 +329,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 @@ -377,7 +415,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, @@ -475,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);