Skip to content

Commit 09fd8b1

Browse files
committed
model/needle: pure-Go host-f32 reference forward for the Needle encoder-decoder
Needle is Cactus-Compute's 26M-param encoder-decoder ("Simple Attention Network") for on-device function calling. This is a self-contained, pure-Go host reference (no engine, no cgo, no GPU) that loads the published safetensors weights, widens bf16 to f32, and runs the full encoder + cross-attending decoder to de-risk the encoder-decoder + cross-attention direction before any accelerated port. Ported field-for-field from the cactus modeling_needle.py: - ZCRMSNorm: RMSNorm with no mean-centring, weight enters as (1 + weight). - Gated residuals: clip(h + sigmoid(gate_scalar) * attn_branch), pre-norm. - GQA (8 query / 4 kv heads) with per-head QK-norm before RoPE (self-attn only). - Decoder cross-attends to the FINAL encoder output, own k/v per layer, no RoPE. - Shared embed_tokens scaled by sqrt(d); tied unscaled lm_head. - Minimal SentencePiece BPE encode/decode (byte-fallback) via the shared reader. Reuses model/safetensors (weights) and decode/tokenizer (SPM proto reader); all forward maths is local f32. Registration and the engine port are later slices. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent da0be76 commit 09fd8b1

10 files changed

Lines changed: 902 additions & 0 deletions

File tree

go/model/needle/attention.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package needle
4+
5+
import "math"
6+
7+
// attention is the one attention block Needle reuses for encoder self-attention,
8+
// decoder masked self-attention and decoder cross-attention (NeedleAttention).
9+
// It is grouped-query (8 query heads share 4 key/value heads, kvHead = h/group),
10+
// applies per-head QK-norm before RoPE, and scales scores by 1/sqrt(head_dim).
11+
//
12+
// - prefix names the weight group, e.g. "model.decoder.layers.0.encoder_attn".
13+
// - xq/xkv are already-normalised hidden states ([qLen,hidden] / [kvLen,hidden]);
14+
// for self-attention they are the same slice.
15+
// - rope is nil for cross-attention (no positional rotation there).
16+
// - causal masks key j>i (decoder self-attention); false is bidirectional.
17+
//
18+
// Returns the out_proj'd result [qLen, hidden].
19+
func (m *Model) attention(prefix string, xq []float32, qLen int, xkv []float32, kvLen int, rope *ropeTable, causal bool) []float32 {
20+
c := m.cfg
21+
hidden := c.HiddenSize
22+
headDim := c.HeadDim()
23+
kvDim := c.KVDim()
24+
group := c.NumHeads / c.NumKVHeads
25+
scale := float32(1.0 / math.Sqrt(float64(headDim)))
26+
27+
qProj := m.w.get(prefix + ".q_proj.weight")
28+
kProj := m.w.get(prefix + ".k_proj.weight")
29+
vProj := m.w.get(prefix + ".v_proj.weight")
30+
oProj := m.w.get(prefix + ".out_proj.weight")
31+
qNorm := m.w.get(prefix + ".q_norm.weight")
32+
kNorm := m.w.get(prefix + ".k_norm.weight")
33+
34+
// Project then reshape to [len, heads, head_dim] (flattened row-major).
35+
q := linearRows(xq, qLen, qProj, hidden, hidden) // [qLen, numHeads*headDim]
36+
k := linearRows(xkv, kvLen, kProj, kvDim, hidden) // [kvLen, numKV*headDim]
37+
v := linearRows(xkv, kvLen, vProj, kvDim, hidden)
38+
39+
// Per-head QK-norm, then RoPE (self-attention only).
40+
for i := range qLen {
41+
for h := range c.NumHeads {
42+
head := q[i*hidden+h*headDim : i*hidden+h*headDim+headDim]
43+
copy(head, zcRMSNorm(head, qNorm, c.RMSNormEps))
44+
if rope != nil {
45+
rope.apply(head, i)
46+
}
47+
}
48+
}
49+
for j := range kvLen {
50+
for h := range c.NumKVHeads {
51+
head := k[j*kvDim+h*headDim : j*kvDim+h*headDim+headDim]
52+
copy(head, zcRMSNorm(head, kNorm, c.RMSNormEps))
53+
if rope != nil {
54+
rope.apply(head, j)
55+
}
56+
}
57+
}
58+
59+
// Attention per query head against its grouped kv head.
60+
attnOut := make([]float32, qLen*hidden)
61+
scores := make([]float32, kvLen)
62+
for h := range c.NumHeads {
63+
kvHead := h / group
64+
for i := range qLen {
65+
qv := q[i*hidden+h*headDim : i*hidden+h*headDim+headDim]
66+
limit := kvLen
67+
if causal {
68+
limit = i + 1 // key j > i is masked out
69+
}
70+
for j := range kvLen {
71+
if j >= limit {
72+
scores[j] = float32(math.Inf(-1))
73+
continue
74+
}
75+
kv := k[j*kvDim+kvHead*headDim : j*kvDim+kvHead*headDim+headDim]
76+
var dot float32
77+
for d := range headDim {
78+
dot += qv[d] * kv[d]
79+
}
80+
scores[j] = dot * scale
81+
}
82+
softmaxInPlace(scores[:kvLen])
83+
out := attnOut[i*hidden+h*headDim : i*hidden+h*headDim+headDim]
84+
for j := range kvLen {
85+
p := scores[j]
86+
if p == 0 {
87+
continue
88+
}
89+
vv := v[j*kvDim+kvHead*headDim : j*kvDim+kvHead*headDim+headDim]
90+
for d := range headDim {
91+
out[d] += p * vv[d]
92+
}
93+
}
94+
}
95+
}
96+
return linearRows(attnOut, qLen, oProj, hidden, hidden)
97+
}

go/model/needle/config.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
// Package needle is a pure-Go, host-side f32 reference forward for Cactus
4+
// Compute's "Needle" model — a 26M-parameter encoder-decoder ("Simple Attention
5+
// Network") distilled for on-device function calling. It has no engine, no cgo
6+
// and no GPU: it loads the published safetensors weights, widens bf16 to f32,
7+
// and runs the encoder + cross-attending decoder in plain Go. Its purpose is to
8+
// de-risk the encoder-decoder + cross-attention direction before any accelerated
9+
// port — a readable oracle whose only claim to correctness is that it reproduces
10+
// the reference model's tokens.
11+
//
12+
// m, err := needle.Load("/path/to/needle-snapshot")
13+
// if err != nil {
14+
// return err
15+
// }
16+
// out := m.Generate("What is the weather in San Francisco?",
17+
// `[{"name":"get_weather","parameters":{"location":"string"}}]`, 64)
18+
// // out == ` [{"name":"get_weather","arguments":{"location":"San Francisco"}}]`
19+
//
20+
// The architecture (228 weights, verified against the safetensors header):
21+
// - Shared embed_tokens[vocab,d] scaled by sqrt(d); tied lm_head.
22+
// - Encoder x12: input_layernorm -> GQA self-attn (per-head QK-norm, RoPE,
23+
// bidirectional) -> scalar sigmoid gate on the residual. No FFN. final_norm.
24+
// - Decoder x8: input_layernorm -> masked self-attn (gate) ->
25+
// encoder_attn_layer_norm -> cross-attn to the FINAL encoder output (own
26+
// k/v, no RoPE, gate). No FFN. norm.
27+
package needle
28+
29+
import (
30+
core "dappco.re/go"
31+
coreio "dappco.re/go/io"
32+
)
33+
34+
// Config holds the Needle hyper-parameters read from a checkpoint's config.json.
35+
// The zero value is not usable; construct via LoadConfig or DefaultConfig.
36+
type Config struct {
37+
VocabSize int // token count (8192)
38+
HiddenSize int // d_model (512)
39+
NumHeads int // query heads (8)
40+
NumKVHeads int // key/value heads for GQA (4)
41+
NumEncoderLayers int // encoder depth (12)
42+
NumDecoderLayers int // decoder depth (8)
43+
RopeTheta float64 // RoPE base (10000)
44+
RMSNormEps float64 // ZCRMSNorm epsilon (1e-6)
45+
PadTokenID int // 0
46+
EosTokenID int // 1 — also the decoder start token
47+
BosTokenID int // 2
48+
ToolsTokenID int // 5 — <tools> separator between query and tools
49+
}
50+
51+
// HeadDim is the per-head width (hidden / heads = 64).
52+
//
53+
// cfg.HeadDim() // 64
54+
func (c Config) HeadDim() int { return c.HiddenSize / c.NumHeads }
55+
56+
// KVDim is the concatenated key/value projection width (kv_heads * head_dim = 256).
57+
//
58+
// cfg.KVDim() // 256
59+
func (c Config) KVDim() int { return c.NumKVHeads * c.HeadDim() }
60+
61+
// DefaultConfig returns the published Needle configuration. It is the fallback
62+
// when a checkpoint ships no config.json, and the source of truth for the token
63+
// ids the tokenizer and decoder rely on.
64+
//
65+
// cfg := needle.DefaultConfig()
66+
// cfg.HiddenSize // 512
67+
func DefaultConfig() Config {
68+
return Config{
69+
VocabSize: 8192,
70+
HiddenSize: 512,
71+
NumHeads: 8,
72+
NumKVHeads: 4,
73+
NumEncoderLayers: 12,
74+
NumDecoderLayers: 8,
75+
RopeTheta: 10000,
76+
RMSNormEps: 1e-6,
77+
PadTokenID: 0,
78+
EosTokenID: 1,
79+
BosTokenID: 2,
80+
ToolsTokenID: 5,
81+
}
82+
}
83+
84+
// configJSON mirrors the subset of config.json this reference needs. Fields the
85+
// checkpoint omits keep their DefaultConfig value.
86+
type configJSON struct {
87+
VocabSize *int `json:"vocab_size"`
88+
HiddenSize *int `json:"hidden_size"`
89+
NumHeads *int `json:"num_heads"`
90+
NumKVHeads *int `json:"num_kv_heads"`
91+
NumEncoderLayers *int `json:"num_encoder_layers"`
92+
NumDecoderLayers *int `json:"num_decoder_layers"`
93+
RopeTheta *float64 `json:"rope_theta"`
94+
RMSNormEps *float64 `json:"rms_norm_eps"`
95+
PadTokenID *int `json:"pad_token_id"`
96+
EosTokenID *int `json:"eos_token_id"`
97+
BosTokenID *int `json:"bos_token_id"`
98+
}
99+
100+
// LoadConfig reads config.json from a checkpoint directory, falling back to
101+
// DefaultConfig for any field the file omits. A missing config.json is not an
102+
// error — the published defaults are returned — so the reference runs against a
103+
// bare weights+tokenizer pack.
104+
//
105+
// cfg, err := needle.LoadConfig("/models/needle")
106+
func LoadConfig(dir string) (Config, error) {
107+
cfg := DefaultConfig()
108+
path := dir + "/config.json"
109+
raw, err := coreio.Local.Read(path)
110+
if err != nil {
111+
// A checkpoint without config.json is served by the published defaults.
112+
return cfg, nil
113+
}
114+
var j configJSON
115+
if r := core.JSONUnmarshal(core.AsBytes(raw), &j); !r.OK {
116+
return cfg, core.E("needle.LoadConfig", "parse "+path, r.Err())
117+
}
118+
if j.VocabSize != nil {
119+
cfg.VocabSize = *j.VocabSize
120+
}
121+
if j.HiddenSize != nil {
122+
cfg.HiddenSize = *j.HiddenSize
123+
}
124+
if j.NumHeads != nil {
125+
cfg.NumHeads = *j.NumHeads
126+
}
127+
if j.NumKVHeads != nil {
128+
cfg.NumKVHeads = *j.NumKVHeads
129+
}
130+
if j.NumEncoderLayers != nil {
131+
cfg.NumEncoderLayers = *j.NumEncoderLayers
132+
}
133+
if j.NumDecoderLayers != nil {
134+
cfg.NumDecoderLayers = *j.NumDecoderLayers
135+
}
136+
if j.RopeTheta != nil {
137+
cfg.RopeTheta = *j.RopeTheta
138+
}
139+
if j.RMSNormEps != nil {
140+
cfg.RMSNormEps = *j.RMSNormEps
141+
}
142+
if j.PadTokenID != nil {
143+
cfg.PadTokenID = *j.PadTokenID
144+
}
145+
if j.EosTokenID != nil {
146+
cfg.EosTokenID = *j.EosTokenID
147+
}
148+
if j.BosTokenID != nil {
149+
cfg.BosTokenID = *j.BosTokenID
150+
}
151+
return cfg, nil
152+
}

go/model/needle/decoder.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package needle
4+
5+
import core "dappco.re/go"
6+
7+
// decode runs the full decoder over the current decoder token ids, cross-attending
8+
// to the encoder output. Each of the 8 layers is masked self-attention then
9+
// cross-attention, both pre-norm with their own scalar sigmoid gate, no FFN:
10+
//
11+
// normed = input_layernorm(h)
12+
// h = clip(h + sigmoid(self_attn_gate) * self_attn(normed, normed)) # causal, RoPE
13+
// cross = encoder_attn(encoder_attn_layer_norm(h), encoderHidden) # cross, NO RoPE
14+
// h = clip(h + sigmoid(cross_attn_gate) * cross)
15+
//
16+
// The cross-attention keys/values come from the FINAL encoder output (the same
17+
// encoderHidden for every layer, each with its own k/v projection). Returns the
18+
// decoder hidden states [decLen, hidden] after the trailing norm.
19+
func (m *Model) decode(decIDs []int, encoderHidden []float32, encLen int) []float32 {
20+
c := m.cfg
21+
hidden := c.HiddenSize
22+
decLen := len(decIDs)
23+
24+
h := m.embed(decIDs) // [decLen, hidden], scaled
25+
rope := newRopeTable(c.HeadDim(), c.RopeTheta, decLen)
26+
27+
for layer := range c.NumDecoderLayers {
28+
p := core.Sprintf("model.decoder.layers.%d", layer)
29+
30+
normed := zcRMSNormRows(h, decLen, hidden, m.w.get(p+".input_layernorm.weight"), c.RMSNormEps)
31+
selfAttn := m.attention(p+".self_attn", normed, decLen, normed, decLen, rope, true)
32+
selfGate := sigmoid(m.w.get(p + ".self_attn_gate")[0])
33+
for i := range decLen * hidden {
34+
h[i] = clipAdd(h[i], selfGate*selfAttn[i])
35+
}
36+
37+
crossNormed := zcRMSNormRows(h, decLen, hidden, m.w.get(p+".encoder_attn_layer_norm.weight"), c.RMSNormEps)
38+
cross := m.attention(p+".encoder_attn", crossNormed, decLen, encoderHidden, encLen, nil, false)
39+
crossGate := sigmoid(m.w.get(p + ".cross_attn_gate")[0])
40+
for i := range decLen * hidden {
41+
h[i] = clipAdd(h[i], crossGate*cross[i])
42+
}
43+
}
44+
return zcRMSNormRows(h, decLen, hidden, m.w.get("model.decoder.norm.weight"), c.RMSNormEps)
45+
}

go/model/needle/encoder.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package needle
4+
5+
import core "dappco.re/go"
6+
7+
// encode runs the full bidirectional encoder over token ids: embed (scaled by
8+
// sqrt(hidden)), then 12 gated self-attention layers with no FFN, then final_norm.
9+
// Each layer is pre-norm with a scalar sigmoid gate on the attention branch:
10+
//
11+
// normed = input_layernorm(h)
12+
// attn = self_attn(normed, normed) # bidirectional, RoPE
13+
// h = clip(h + sigmoid(attn_gate) * attn) # no FFN
14+
//
15+
// Returns the encoder hidden states [seqLen, hidden] the decoder cross-attends to.
16+
func (m *Model) encode(ids []int) []float32 {
17+
c := m.cfg
18+
hidden := c.HiddenSize
19+
seqLen := len(ids)
20+
21+
h := m.embed(ids) // [seqLen, hidden], already scaled
22+
rope := newRopeTable(c.HeadDim(), c.RopeTheta, seqLen)
23+
24+
for layer := range c.NumEncoderLayers {
25+
p := core.Sprintf("model.encoder.layers.%d", layer)
26+
normed := zcRMSNormRows(h, seqLen, hidden, m.w.get(p+".input_layernorm.weight"), c.RMSNormEps)
27+
attn := m.attention(p+".self_attn", normed, seqLen, normed, seqLen, rope, false)
28+
gate := sigmoid(m.w.get(p + ".attn_gate")[0])
29+
for i := range seqLen * hidden {
30+
h[i] = clipAdd(h[i], gate*attn[i])
31+
}
32+
}
33+
return zcRMSNormRows(h, seqLen, hidden, m.w.get("model.encoder.final_norm.weight"), c.RMSNormEps)
34+
}

0 commit comments

Comments
 (0)