mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
graph 58.0 -> 99.1, graphvm 0 -> 100, ai 90.0 -> 96.2, inference 87.3 -> 98.0, modelregistry 70.2 -> 98.9, aivmbridge 93.1 -> 99.8, anchor 98.1 -> 99.1, fhe 33.0 -> 80.1. graph charged one flat fee for any amount of work. The sharpest form was not response size but TargetChains: decoded from caller JSON with no cap, one GraphQL query per entry, so seven bytes of calldata bought an unbounded fan-out. Response size is only known after the work, so the fee now reserves the worst case up front and refunds down -- there is no deduction that can come up short, and a failed run consumes the reservation rather than everything supplied, so malformed input is not a cheap probe. ai halted the chain from eight bytes. Two handlers bounded their length prefixes in uint32, so near 2^32 the sum wrapped, the check passed and the slice panicked. Reproduced at both 96 and 8 bytes. The correct int-based parser already existed in the same package; the handlers just did not call it, and the existing test only probed 0xFFFF, which never wraps. fhe never enforced readOnly. It was threaded through Run and all 42 handlers and read by none, so every op but decrypt and sealOutput wrote ciphertext, metadata and ownership to the trie under STATICCALL. Decided once at dispatch; an unknown selector counts as a writer. fhe also allocated before it checked: five bytes of calldata asked for 34 GB behind a flat 50,000 gas fee. div and rem were registered and priced at 655M gas with no case in the switch, so both fell through and returned the zero handle with a nil error -- success for a guaranteed no-op, with the real implementations written and never wired. select decoded its control as a bare Ciphertext when every ebool the precompile stores is a BitCiphertext, so it could not have worked for any input; it surfaced only once failures stopped being reported as success. graph decodeArgs accepted truncated calldata as a shorter argument list, so trimming bytes changed which variables a query expanded with. graphvm chose resolvers by map iteration order, which is consensus-visible. Deleted a dead second gas table that disagreed with the real one, an address constant appearing only at its own declaration, and ten other symbols with no reader.
122 lines
4.8 KiB
Go
122 lines
4.8 KiB
Go
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// kernel_test.go — the integer kernels' edges: the two guards that stop a degenerate
|
|
// input from dividing by zero (which on the consensus path panics a validator), the
|
|
// causal mask itself, and the saturation that keeps requantisation from wrapping.
|
|
|
|
package inference
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestMaxu32(t *testing.T) {
|
|
for _, c := range [][2]uint32{{0, 0}, {3, 7}, {7, 3}, {5, 5}, {0, math.MaxUint32}, {math.MaxUint32, 0}} {
|
|
a, b := c[0], c[1]
|
|
got := maxu32(a, b)
|
|
require.GreaterOrEqual(t, got, a)
|
|
require.GreaterOrEqual(t, got, b)
|
|
require.True(t, got == a || got == b, "must return one of its arguments")
|
|
require.Equal(t, got, maxu32(b, a), "must be symmetric")
|
|
}
|
|
}
|
|
|
|
// TestRmsnormZeroRow: with eps 0 an all-zero activation row drives the RMS denominator
|
|
// to zero. The kernel must clamp it — an integer divide by zero panics, and a panic
|
|
// inside a precompile takes the validator down.
|
|
func TestRmsnormZeroRow(t *testing.T) {
|
|
const d = 4
|
|
out := make([]int8, d)
|
|
require.NotPanics(t, func() {
|
|
rmsnormI8(make([]int8, d), []int8{1, 1, 1, 1}, out, d, 5, 0)
|
|
})
|
|
require.Equal(t, make([]int8, d), out, "a zero row normalises to zeros")
|
|
}
|
|
|
|
// TestRmsnormDividesByTheRootMeanSquare pins the arithmetic itself: ss = 4·80² = 25600
|
|
// and isqrt(25600) = 160, so each output is (x·w·mult)/160.
|
|
func TestRmsnormDividesByTheRootMeanSquare(t *testing.T) {
|
|
const d = 4
|
|
x := []int8{80, 80, 80, 80}
|
|
out := make([]int8, d)
|
|
|
|
rmsnormI8(x, []int8{1, 1, 1, 1}, out, d, 8, 0)
|
|
require.Equal(t, []int8{4, 4, 4, 4}, out, "80·1·8/160")
|
|
|
|
rmsnormI8(x, []int8{2, 2, 2, 2}, out, d, 8, 0)
|
|
require.Equal(t, []int8{8, 8, 8, 8}, out, "doubling the weight doubles the output; the divisor is unchanged")
|
|
|
|
rmsnormI8(x, []int8{127, 127, 127, 127}, out, d, 8, 0)
|
|
require.Equal(t, []int8{127, 127, 127, 127}, out, "an over-range result saturates, it must never wrap")
|
|
|
|
// eps only enlarges the divisor, so raising it can only shrink the result.
|
|
prev := int8(math.MaxInt8)
|
|
for _, eps := range []int32{0, 39936, 1_000_000} {
|
|
rmsnormI8(x, []int8{1, 1, 1, 1}, out, d, 8, eps)
|
|
require.LessOrEqual(t, out[0], prev, "a larger eps must not enlarge the result")
|
|
prev = out[0]
|
|
}
|
|
require.Zero(t, prev, "a large enough eps drives the row to zero")
|
|
}
|
|
|
|
// TestAttentionAtLengthOne: with a single token there is exactly one key, so the
|
|
// softmax weight is 1 whatever the scores are. The output must therefore depend on v
|
|
// alone, and the causal flag must make no difference.
|
|
func TestAttentionAtLengthOne(t *testing.T) {
|
|
const seq, d = 1, 2
|
|
v := []int8{100, -100}
|
|
|
|
zeroScores := make([]int8, d)
|
|
loudScores := []int8{120, -7}
|
|
|
|
a := make([]int8, d)
|
|
b := make([]int8, d)
|
|
attentionI8(zeroScores, zeroScores, v, aivmExpLUT[:], a, seq, d, 9, 7, true)
|
|
attentionI8(loudScores, []int8{-60, 33}, v, aivmExpLUT[:], b, seq, d, 9, 7, true)
|
|
require.Equal(t, a, b, "at length one the scores cannot matter")
|
|
|
|
open := make([]int8, d)
|
|
attentionI8(zeroScores, zeroScores, v, aivmExpLUT[:], open, seq, d, 9, 7, false)
|
|
require.Equal(t, a, open, "at length one the mask has nothing to mask")
|
|
|
|
require.NotEqual(t, make([]int8, d), a, "the value vector must actually reach the output")
|
|
}
|
|
|
|
// TestAttentionMaskHidesTheFuture: under the causal mask row 0 is computed from token 0
|
|
// alone, so rewriting token 1's value must leave it untouched — and must not, with the
|
|
// mask off. Scores are all equal (q and k are zero) so any leak shows up in the output.
|
|
func TestAttentionMaskHidesTheFuture(t *testing.T) {
|
|
const seq, d = 2, 2
|
|
q := make([]int8, seq*d)
|
|
k := make([]int8, seq*d)
|
|
v := []int8{100, 100, -100, -100}
|
|
vFuture := []int8{100, 100, 7, 55} // same first row, different second
|
|
|
|
masked := make([]int8, seq*d)
|
|
maskedFuture := make([]int8, seq*d)
|
|
attentionI8(q, k, v, aivmExpLUT[:], masked, seq, d, 9, 7, true)
|
|
attentionI8(q, k, vFuture, aivmExpLUT[:], maskedFuture, seq, d, 9, 7, true)
|
|
require.Equal(t, masked[:d], maskedFuture[:d], "row 0 must not see row 1")
|
|
require.NotEqual(t, masked[d:], maskedFuture[d:], "row 1 must see row 1")
|
|
|
|
open := make([]int8, seq*d)
|
|
attentionI8(q, k, v, aivmExpLUT[:], open, seq, d, 9, 7, false)
|
|
require.NotEqual(t, masked[:d], open[:d], "with the mask off row 0 must see row 1")
|
|
}
|
|
|
|
// TestAttentionZeroTable: a probability table of all zeros drives the softmax
|
|
// denominator to zero. The kernel must clamp it rather than divide by zero.
|
|
func TestAttentionZeroTable(t *testing.T) {
|
|
const seq, d = 2, 2
|
|
out := make([]int8, seq*d)
|
|
require.NotPanics(t, func() {
|
|
attentionI8(make([]int8, seq*d), make([]int8, seq*d), []int8{100, 100, -100, -100},
|
|
make([]int32, 256), out, seq, d, 9, 7, true)
|
|
})
|
|
require.Equal(t, make([]int8, seq*d), out, "no probability mass, no output")
|
|
}
|