Commit 408739c

2026-09-11 01:59:20
feat: unify model-lookup convention, relax dense-model assumptions
Provider now shares elelem-llama's ELELEM_GGUF_MODEL env var and ~/.agents/models/ auto-detect convention instead of a ggml-specific ELELEM_GGML_MODEL with no fallback. elelem.c drops three hard requirements that were never architectural necessities, just artifacts of only ever loading Qwen2: attention Q/K/V bias becomes optional, the output projection falls back to the tied token embedding when absent, and RoPE dimension is read from GGUF metadata instead of hardcoded to n_embd_head. All three are confirmed no-ops against Qwen2 (0.5B and 7B) via golden greedy-decode token IDs. Explicitly out of scope: RoPE ordering (NEOX vs NORMAL) is architecture-dependent with no GGUF key to distinguish it, confirmed by direct experiment -- Qwen2 requires NEOX, llama-arch requires NORMAL. Supporting both would mean branching on architecture, which is the line this gem doesn't cross; see README for the stated scope. Adds a committed fixture (spec/fixtures/tinystories-656k.gguf, 572KB, Apache 2.0) as a hermetic regression canary for the bias/tied-embedding changes, independent of any locally-installed test model.
1 parent e8a5ec2
Changed files (5)
ext
elelem
lib
elelem
spec
ext/elelem/ggml/elelem.c
@@ -20,6 +20,7 @@ struct el_hparams {
   int32_t n_head_kv;
   int32_t n_ff;
   int32_t n_vocab;
+  int32_t n_rot;
   float rope_freq_base;
   float rms_norm_eps;
 };
@@ -95,6 +96,9 @@ static void el_load_hparams(struct gguf_context *gguf, struct el_hparams *hp, in
   snprintf(key, sizeof(key), "%s.rope.freq_base", arch);
   hp->rope_freq_base = el_kv_f32(gguf, key, 10000.0f);
 
+  snprintf(key, sizeof(key), "%s.rope.dimension_count", arch);
+  hp->n_rot = el_kv_i32(gguf, key, hp->n_head > 0 ? hp->n_embd / hp->n_head : 0);
+
   snprintf(key, sizeof(key), "%s.attention.layer_norm_rms_epsilon", arch);
   hp->rms_norm_eps = el_kv_f32(gguf, key, 1e-6f);
 
@@ -229,6 +233,7 @@ static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens,
   struct ggml_tensor *tok_embd = el_weight(h->weights, "token_embd.weight", -1);
   struct ggml_tensor *output_norm = el_weight(h->weights, "output_norm.weight", -1);
   struct ggml_tensor *output = el_weight(h->weights, "output.weight", -1);
+  if (!output) output = tok_embd;
   if (!tok_embd || !output_norm || !output) {
     ggml_free(ctx);
     return -1;
@@ -270,7 +275,7 @@ static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens,
     struct ggml_tensor *w_up = el_weight(h->weights, "blk.%d.ffn_up.weight", il);
     struct ggml_tensor *w_down = el_weight(h->weights, "blk.%d.ffn_down.weight", il);
 
-    if (!attn_norm || !wq || !bq || !wk || !bk || !wv || !bv || !wo ||
+    if (!attn_norm || !wq || !wk || !wv || !wo ||
         !ffn_norm || !w_gate || !w_up || !w_down) {
       ggml_free(ctx);
       return -1;
@@ -278,17 +283,20 @@ static int32_t el_forward_argmax(struct el_handle *h, const int32_t *new_tokens,
 
     cur = el_rms_norm(ctx, cur, attn_norm, hp->rms_norm_eps);
 
-    struct ggml_tensor *q = ggml_add(ctx, ggml_mul_mat(ctx, wq, cur), bq);
-    struct ggml_tensor *k = ggml_add(ctx, ggml_mul_mat(ctx, wk, cur), bk);
-    struct ggml_tensor *v = ggml_add(ctx, ggml_mul_mat(ctx, wv, cur), bv);
+    struct ggml_tensor *q = ggml_mul_mat(ctx, wq, cur);
+    struct ggml_tensor *k = ggml_mul_mat(ctx, wk, cur);
+    struct ggml_tensor *v = ggml_mul_mat(ctx, wv, cur);
+    if (bq) q = ggml_add(ctx, q, bq);
+    if (bk) k = ggml_add(ctx, k, bk);
+    if (bv) v = ggml_add(ctx, v, bv);
 
     q = ggml_reshape_3d(ctx, q, n_embd_head, hp->n_head, n_new);
     k = ggml_reshape_3d(ctx, k, n_embd_head, hp->n_head_kv, n_new);
     v = ggml_reshape_3d(ctx, v, n_embd_head, hp->n_head_kv, n_new);
 
-    q = ggml_rope_ext(ctx, q, inp_pos, NULL, n_embd_head, GGML_ROPE_TYPE_NEOX, 0,
+    q = ggml_rope_ext(ctx, q, inp_pos, NULL, hp->n_rot, GGML_ROPE_TYPE_NEOX, 0,
                        hp->rope_freq_base, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
-    k = ggml_rope_ext(ctx, k, inp_pos, NULL, n_embd_head, GGML_ROPE_TYPE_NEOX, 0,
+    k = ggml_rope_ext(ctx, k, inp_pos, NULL, hp->n_rot, GGML_ROPE_TYPE_NEOX, 0,
                        hp->rope_freq_base, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
 
     struct ggml_tensor *k_cache = h->kv.k[il];
lib/elelem/ggml.rb
@@ -12,11 +12,25 @@ require_relative "ggml/provider"
 
 Elelem.configure do |config|
   config.provider(:ggml) do
+    default_model = -> {
+      dir = File.expand_path("~/.agents/models")
+      link = File.join(dir, "default.gguf")
+      return link if File.symlink?(link) || File.exist?(link)
+
+      models = Dir[File.join(dir, "*.gguf")].sort
+      case models.length
+      when 1 then models.first
+      when 0 then raise "no .gguf model found in #{dir}. Set ELELEM_GGUF_MODEL=/path/to/model.gguf"
+      else raise "multiple .gguf models found in #{dir}. Set ELELEM_GGUF_MODEL=/path/to/model.gguf or symlink #{link} to the one to use by default"
+      end
+    }
+
     Elelem::Ggml::Provider.new(
-      model: ENV.fetch("ELELEM_GGML_MODEL") { raise "set ELELEM_GGML_MODEL=/path/to/model.gguf" },
+      model: ENV.fetch("ELELEM_GGUF_MODEL") { default_model.call },
       n_ctx: 0,
       n_threads: 0,
-      n_gpu_layers: 0
+      n_gpu_layers: 0,
+      max_tokens: Integer(ENV.fetch("ELELEM_GGUF_MAX_TOKENS", "256"))
     )
   end
 end
spec/elelem/ggml/provider_spec.rb
@@ -35,4 +35,35 @@ RSpec.describe Elelem::Ggml::Provider do
       expect(messages.first[:text]).to start_with("Hello")
     end
   end
+
+  describe "loading a model with no attention bias and tied embeddings" do
+    let(:model) { File.expand_path("../../fixtures/tinystories-656k.gguf", __dir__) }
+
+    it "loads and reports llama-family hyperparameters" do
+      handle = described_class.functions[:open].call(model, 0, 0, 0)
+
+      text = Fiddle::Pointer.new(described_class.functions[:version].call(handle)).to_s
+
+      expect(text).to include("arch=llama")
+      expect(text).to include("n_layer=2")
+      expect(text).to include("n_embd=128")
+      expect(text).to include("n_vocab=2048")
+
+      described_class.functions[:close].call(handle)
+    end
+
+    it "produces deterministic ids as a regression canary (NEOX rope; llama-arch actually needs NORMAL, unsupported)" do
+      handle = described_class.functions[:open].call(model, 0, 0, 0)
+      prompt = [1, 3].pack("l*")
+      out = " " * (4 * 20)
+
+      n = described_class.functions[:generate].call(handle, prompt, 2, 20, 2, out)
+
+      expect(out.unpack("l*").first(n)).to eq(
+        [299, 286, 229, 521, 776, 115, 56, 696, 183, 92, 730, 532, 215, 1653, 544, 2041, 215, 93, 319, 115]
+      )
+
+      described_class.functions[:close].call(handle)
+    end
+  end
 end
spec/fixtures/tinystories-656k.gguf
Binary file
README.md
@@ -18,8 +18,15 @@ ChatML template live in Ruby (`lib/elelem/ggml/tokenizer.rb`,
 `lib/elelem/ggml/chat_ml.rb`), parsing vocab and merges straight out of the
 GGUF metadata.
 
-Other architectures, real sampling (top-k/top-p/temp), and tool-calling are
-not implemented yet -- see `spec/` for what's currently verified.
+This is a minimal, readable reference implementation of transformer
+inference on ggml -- CPU-only, dense Qwen2-family models only, greedy
+decoding only, no tool-calling. That scope is deliberate: the whole point is
+a forward pass small enough to read start to finish and know exactly what's
+happening to your tokens. For GPU backends, real sampling, tool-calling, or
+broad architecture support, use
+[elelem-llama](https://src.mokhan.ca/elelem/llama) instead.
+
+See `spec/` for what's currently verified.
 
 ## Setup
 
@@ -31,7 +38,10 @@ bundle exec rake compile
 
 ## Usage
 
+Set `ELELEM_GGUF_MODEL=/path/to/model.gguf`, or place a `.gguf` file (or a
+`default.gguf` symlink, if you keep more than one) in `~/.agents/models/`.
+
 ```
-export ELELEM_GGML_MODEL=/path/to/model.gguf
+export ELELEM_GGUF_MODEL=/path/to/model.gguf
 ruby -Ilib exe/elelem-ggml ask "hi"
 ```