From e93e1e47f53d156ac0fa77712a72fea38b128aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 12:46:16 +0000 Subject: [PATCH 1/3] Fix broken bindings and add missing llama.cpp API coverage with examples - Fix batch struct size bug: allocate 56 bytes (not 48) for llama_batch struct to correctly account for all 6 pointer fields after n_tokens - Add missing FFI declarations: grammar sampling, logit bias, infill/FIM, LoRA adapters, state save/load, pooling type, vocab score/attr, model quantization - Add high-level wrappers for all new bindings with ergonomic APIs - Add constants for token attributes and quantization types - Add 7 new examples demonstrating different capabilities: - embeddings.hml: text embeddings with cosine similarity - chat_template.hml: proper chat template formatting - grammar.hml: GBNF grammar-constrained JSON generation - tokenizer.hml: vocabulary and tokenization exploration - streaming.hml: streaming generation with performance metrics - infill.hml: code fill-in-the-middle (FIM) completion - model_info.hml: model metadata and system inspection - Update README with new API reference and example documentation https://claude.ai/code/session_01PQRPJWjp2HfT41sKmVaSsy --- README.md | 97 ++++++++++++- examples/chat_template.hml | 143 +++++++++++++++++++ examples/embeddings.hml | 139 +++++++++++++++++++ examples/grammar.hml | 149 ++++++++++++++++++++ examples/infill.hml | 166 ++++++++++++++++++++++ examples/model_info.hml | 203 +++++++++++++++++++++++++++ examples/streaming.hml | 124 +++++++++++++++++ examples/tokenizer.hml | 207 ++++++++++++++++++++++++++++ llama.hml | 272 ++++++++++++++++++++++++++++++++++++- 9 files changed, 1490 insertions(+), 10 deletions(-) create mode 100644 examples/chat_template.hml create mode 100644 examples/embeddings.hml create mode 100644 examples/grammar.hml create mode 100644 examples/infill.hml create mode 100644 examples/model_info.hml create mode 100644 examples/streaming.hml create mode 100644 examples/tokenizer.hml diff --git a/README.md b/README.md index d036865..0638426 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,33 @@ llama.backend_free(); - `sample(sampler, ctx, idx)` - Sample a token - `sampler_accept(sampler, token)` - Accept a token (for penalties) +### Advanced Samplers + +- `sampler_init_grammar(model, grammar_str, root)` - Grammar-constrained sampling (GBNF) +- `sampler_init_logit_bias(model, biases)` - Logit bias (`[{ token, bias }]`) +- `sampler_init_infill(model)` - Fill-in-the-middle sampling +- `sampler_init_top_k(k)` - Top-k sampler +- `sampler_init_top_p(p, min_keep)` - Nucleus sampler +- `sampler_init_min_p(p, min_keep)` - Min-p sampler +- `sampler_init_temp(t)` - Temperature sampler +- `sampler_init_temp_ext(t, delta, exp)` - Dynamic temperature +- `sampler_init_typical(p, min_keep)` - Typical probability +- `sampler_init_mirostat(n_vocab, seed, tau, eta, m)` - Mirostat v1 +- `sampler_init_mirostat_v2(seed, tau, eta)` - Mirostat v2 +- `sampler_init_penalties(last_n, repeat, freq, presence)` - Repetition penalties +- `sampler_init_greedy()` - Greedy selection +- `sampler_init_dist(seed)` - Distribution-based selection + +### Sampler Chain + +- `sampler_chain_init()` - Create empty sampler chain +- `sampler_chain_add(chain, sampler)` - Add sampler to chain +- `sampler_chain_n(chain)` - Number of samplers in chain +- `sampler_chain_get(chain, i)` - Get sampler at index +- `sampler_chain_remove(chain, i)` - Remove sampler at index +- `sampler_clone(sampler)` - Clone a sampler +- `sampler_name(sampler)` - Get sampler name + ### Inference - `decode(ctx, tokens, pos)` - Decode tokens @@ -186,9 +213,46 @@ llama.backend_free(); - `kv_cache_seq_pos_min(ctx, seq_id)` - Get min position - `kv_cache_seq_pos_max(ctx, seq_id)` - Get max position +### Embeddings + +- `encode(ctx, tokens)` - Encode tokens for embedding models +- `get_embeddings(ctx)` - Get all embeddings +- `get_embeddings_ith(ctx, i)` - Get embedding at index +- `embed(ctx, model, text)` - Convenience: tokenize, encode, extract embedding +- `pooling_type(ctx)` - Get pooling type (none/mean/cls/last) + +### LoRA Adapters + +- `lora_adapter_init(model, path)` - Load a LoRA adapter +- `lora_adapter_free(adapter)` - Free a LoRA adapter +- `lora_adapter_set(ctx, adapter, scale)` - Apply adapter (scale: 0.0-1.0) +- `lora_adapter_remove(ctx, adapter)` - Remove a specific adapter +- `lora_adapter_clear(ctx)` - Remove all adapters + +### State Save/Load + +- `state_save(ctx)` - Save full context state +- `state_load(ctx, state)` - Restore full context state +- `state_free(state)` - Free saved state +- `state_seq_save(ctx, seq_id)` - Save single sequence +- `state_seq_load(ctx, state, seq_id)` - Restore single sequence +- `state_get_size(ctx)` - Get state buffer size + +### Vocabulary Inspection + +- `vocab_get_text(model, token)` - Get token text +- `vocab_get_score(model, token)` - Get token score +- `vocab_get_attr(model, token)` - Get token attributes +- `is_control(model, token)` - Check if control token + +### Model Quantization + +- `model_quantize(input, output, options)` - Quantize a model + - Options: `ftype`, `n_threads`, `allow_requantize`, `quantize_output_tensor` + ### High-Level -- `generate(ctx, model, prompt, options)` - Generate text +- `generate(ctx, model, prompt, options)` - Generate text with streaming callback - Options: `n_predict`, `sampler`, `stop_tokens`, `callback` - `complete(model_path, prompt, options)` - One-shot completion @@ -196,20 +260,45 @@ llama.backend_free(); - `perf_print(ctx)` - Print context performance stats - `perf_reset(ctx)` - Reset performance stats +- `perf_sampler_print(sampler)` - Print sampler performance stats +- `perf_sampler_reset(sampler)` - Reset sampler performance stats - `time_us()` - Get current time in microseconds ## Examples See the `examples/` directory: -- `simple.hml` - Basic text generation -- `chat.hml` - Interactive chat interface -- `oneshot.hml` - One-shot completion +| Example | Description | +|---------|-------------| +| `simple.hml` | Basic text generation | +| `chat.hml` | Interactive chat interface | +| `oneshot.hml` | One-shot completion using `complete()` | +| `streaming.hml` | Streaming generation with performance metrics | +| `chat_template.hml` | Chat template formatting (ChatML, Llama, etc.) | +| `grammar.hml` | Grammar-constrained JSON generation | +| `embeddings.hml` | Text embeddings and similarity comparison | +| `tokenizer.hml` | Tokenizer/vocabulary exploration | +| `infill.hml` | Code infill / fill-in-the-middle (FIM) | +| `model_info.hml` | Model metadata and system inspection | Run examples: ```bash export LD_LIBRARY_PATH="$(pwd)/lib:$LD_LIBRARY_PATH" + +# Basic generation hemlock examples/simple.hml ~/models/llama-7b.gguf "What is the meaning of life?" + +# Interactive chat +hemlock examples/chat.hml ~/models/llama-7b-chat.gguf + +# Grammar-constrained JSON output +hemlock examples/grammar.hml ~/models/llama-7b.gguf "Describe a fictional character" + +# Text embeddings +hemlock examples/embeddings.hml ~/models/nomic-embed-text.gguf + +# Model inspection +hemlock examples/model_info.hml ~/models/llama-7b.gguf ``` ## License diff --git a/examples/chat_template.hml b/examples/chat_template.hml new file mode 100644 index 0000000..259fcc3 --- /dev/null +++ b/examples/chat_template.hml @@ -0,0 +1,143 @@ +#!/usr/bin/env hemlock +// Chat template example: Use the model's built-in chat template for proper +// message formatting (supports ChatML, Llama, Mistral, etc.) +// +// Usage: +// hemlock examples/chat_template.hml + +import "llama" from "../llama.hml"; + +fn main(args) { + if (args.length < 2) { + print("Usage: hemlock examples/chat_template.hml "); + return 1; + } + + let model_path = args[1]; + + print("Loading model: " + model_path); + print(""); + + // Initialize backend + llama.backend_init(); + + // Load model + let model = llama.model_load(model_path, { + n_gpu_layers: 0 + }); + + let info = llama.model_info(model); + print("Model: " + info.description); + + // Retrieve and display the model's chat template + let tmpl = llama.chat_template(model, null); + if (tmpl == "") { + print("Warning: Model has no built-in chat template."); + print("Using a default ChatML template instead."); + tmpl = null; + } else { + print("Chat template found (" + tmpl.length + " chars)"); + print(""); + print("--- Raw Template ---"); + // Show just the first part + if (tmpl.length > 200) { + print(tmpl.slice(0, 200) + "..."); + } else { + print(tmpl); + } + print("--- End Template ---"); + } + + print(""); + + // Format a multi-turn conversation + let messages = [ + { role: "system", content: "You are a helpful assistant that answers concisely." }, + { role: "user", content: "What is the capital of France?" }, + { role: "assistant", content: "Paris." }, + { role: "user", content: "And what about Germany?" } + ]; + + // Apply the chat template + let formatted = llama.apply_chat_template(model, messages, { + add_generation_prompt: true, + template: tmpl + }); + + print("=== Formatted Conversation ==="); + print(formatted); + print("=== End ==="); + print(""); + + // Create context for generation + let ctx = llama.context_create(model, { + n_ctx: 2048, + n_batch: 512 + }); + + // Create sampler + let sampler = llama.sampler_create({ + temp: 0.7, + top_k: 40, + top_p: 0.9 + }); + + // Tokenize the formatted prompt + let tokens = llama.tokenize(model, formatted, { + add_special: false, // Template already includes special tokens + parse_special: true + }); + + print("Prompt tokens: " + tokens.length); + print(""); + + // Decode prompt + let result = llama.decode(ctx, tokens, 0); + if (result != 0) { + print("Error decoding prompt: " + result); + return 1; + } + + // Generate response + print("=== Model Response ==="); + print(""); + + let n_cur = tokens.length; + let max_tokens = 256; + + for (let i = 0; i < max_tokens; i = i + 1) { + let token = llama.sample(sampler, ctx, -1); + + if (llama.is_eog(model, token)) { + break; + } + + let piece = llama.token_to_piece(model, token, false); + print_inline(piece); + + result = llama.decode(ctx, [token], n_cur); + if (result != 0) { + break; + } + + n_cur = n_cur + 1; + } + + print(""); + print(""); + + // Cleanup + llama.sampler_free(sampler); + llama.context_free(ctx); + llama.model_free(model); + llama.backend_free(); + + return 0; +} + +fn print_inline(text) { + io.write(io.stdout, text); + io.flush(io.stdout); +} + +main(args); diff --git a/examples/embeddings.hml b/examples/embeddings.hml new file mode 100644 index 0000000..8c453f5 --- /dev/null +++ b/examples/embeddings.hml @@ -0,0 +1,139 @@ +#!/usr/bin/env hemlock +// Embeddings example: Generate text embeddings for similarity comparison +// +// Usage: +// hemlock examples/embeddings.hml +// +// Requires an embedding model (e.g., nomic-embed, all-MiniLM, bge-small, etc.) + +import "llama" from "../llama.hml"; + +fn main(args) { + if (args.length < 2) { + print("Usage: hemlock examples/embeddings.hml "); + print(""); + print("Example:"); + print(" hemlock examples/embeddings.hml ~/models/nomic-embed-text-v1.5.Q4_K_M.gguf"); + return 1; + } + + let model_path = args[1]; + + print("Loading embedding model: " + model_path); + print(""); + + // Initialize backend + llama.backend_init(); + + // Load model + let model = llama.model_load(model_path, { + n_gpu_layers: 0 + }); + + let info = llama.model_info(model); + print("Model: " + info.description); + print("Embedding dimensions: " + info.n_embd); + print(""); + + // Create context with embeddings enabled + let ctx = llama.context_create(model, { + n_ctx: 512, + n_batch: 512, + embeddings: true + }); + + // Check pooling type + let pool_type = llama.pooling_type(ctx); + let pool_names = ["none", "mean", "cls", "last"]; + let pool_name = pool_type >= 0 && pool_type < pool_names.length ? pool_names[pool_type] : "unknown"; + print("Pooling type: " + pool_name); + print(""); + + // Texts to embed and compare + let texts = [ + "The cat sat on the mat.", + "A kitten was sitting on the rug.", + "The dog played in the park.", + "Machine learning is a subset of artificial intelligence.", + "Neural networks can learn from data." + ]; + + // Generate embeddings for each text + print("Generating embeddings..."); + print(""); + + let embeddings = []; + for (let i = 0; i < texts.length; i = i + 1) { + let embd = llama.embed(ctx, model, texts[i]); + embeddings.push(embd); + print(" [" + i + "] \"" + texts[i] + "\" -> " + embd.length + " dims"); + + // Clear KV cache between texts + llama.kv_cache_clear(ctx); + } + + print(""); + print("=== Cosine Similarity Matrix ==="); + print(""); + + // Compute cosine similarity between all pairs + for (let i = 0; i < texts.length; i = i + 1) { + for (let j = i + 1; j < texts.length; j = j + 1) { + let sim = cosine_similarity(embeddings[i], embeddings[j]); + let sim_pct = math_floor(sim * 1000) / 10; + print(" [" + i + "] vs [" + j + "]: " + sim_pct + "%"); + } + } + + print(""); + print("Higher similarity = more semantically related."); + + // Cleanup + llama.context_free(ctx); + llama.model_free(model); + llama.backend_free(); + + return 0; +} + +// Compute cosine similarity between two vectors +fn cosine_similarity(a: array, b: array): f32 { + let dot = 0.0; + let norm_a = 0.0; + let norm_b = 0.0; + + for (let i = 0; i < a.length; i = i + 1) { + dot = dot + a[i] * b[i]; + norm_a = norm_a + a[i] * a[i]; + norm_b = norm_b + b[i] * b[i]; + } + + let denom = math_sqrt(norm_a) * math_sqrt(norm_b); + if (denom == 0.0) { + return 0.0; + } + + return dot / denom; +} + +fn math_sqrt(x: f32): f32 { + // Newton's method for square root + if (x <= 0.0) { + return 0.0; + } + let guess = x / 2.0; + for (let i = 0; i < 20; i = i + 1) { + guess = (guess + x / guess) / 2.0; + } + return guess; +} + +fn math_floor(x: f32): i32 { + let n = x; + if (n < 0) { + return n - 1; + } + return n; +} + +main(args); diff --git a/examples/grammar.hml b/examples/grammar.hml new file mode 100644 index 0000000..e402d23 --- /dev/null +++ b/examples/grammar.hml @@ -0,0 +1,149 @@ +#!/usr/bin/env hemlock +// Grammar-constrained generation example: Force the model to output valid JSON +// +// Usage: +// hemlock examples/grammar.hml "Describe a person" + +import "llama" from "../llama.hml"; + +fn main(args) { + if (args.length < 3) { + print("Usage: hemlock examples/grammar.hml "); + print(""); + print("Example:"); + print(" hemlock examples/grammar.hml ~/models/llama-7b.gguf \"Describe a fictional character\""); + return 1; + } + + let model_path = args[1]; + let user_prompt = args[2]; + + print("Loading model: " + model_path); + print(""); + + // Initialize backend + llama.backend_init(); + + // Load model + let model = llama.model_load(model_path, { + n_gpu_layers: 0 + }); + + let info = llama.model_info(model); + print("Model: " + info.description); + print(""); + + // Create context + let ctx = llama.context_create(model, { + n_ctx: 2048, + n_batch: 512 + }); + + // Define a GBNF grammar for JSON output + // This grammar constrains output to a JSON object with specific fields + let json_grammar = "root ::= \"{\" ws members ws \"}\" ws\n" + + "members ::= pair (\",\" ws pair)*\n" + + "pair ::= string ws \":\" ws value\n" + + "string ::= \"\\\"\" ([^\"\\\\] | \"\\\\\" [\"\\\\/bfnrt])* \"\\\"\"\n" + + "value ::= string | number | \"true\" | \"false\" | \"null\" | object | array\n" + + "object ::= \"{\" ws (pair (\",\" ws pair)*)? ws \"}\"\n" + + "array ::= \"[\" ws (value (\",\" ws value)*)? ws \"]\"\n" + + "number ::= \"-\"? [0-9]+ (\".\" [0-9]+)?\n" + + "ws ::= [ \\t\\n]*\n"; + + print("=== GBNF Grammar ==="); + print(json_grammar); + print("===================="); + print(""); + + // Build the prompt that asks the model to produce JSON + let prompt = "You must respond with a JSON object.\n\n" + + "Task: " + user_prompt + "\n\n" + + "Include the fields: name, age, occupation, description.\n\n" + + "JSON:\n"; + + // Tokenize prompt + let tokens = llama.tokenize(model, prompt, { add_special: true }); + print("Prompt tokens: " + tokens.length); + + // Decode prompt + let result = llama.decode(ctx, tokens, 0); + if (result != 0) { + print("Error decoding prompt: " + result); + return 1; + } + + // Create a sampler chain with grammar constraint + let chain = llama.sampler_chain_init(); + + // Add temperature + llama.sampler_chain_add(chain, llama.sampler_init_temp(0.7)); + + // Add top-k and top-p + llama.sampler_chain_add(chain, llama.sampler_init_top_k(40)); + llama.sampler_chain_add(chain, llama.sampler_init_top_p(0.9, 1)); + + // Add the grammar sampler - this constrains output to valid JSON + let grammar_sampler = llama.sampler_init_grammar(model, json_grammar, "root"); + llama.sampler_chain_add(chain, grammar_sampler); + + // Add distribution sampler for final selection + llama.sampler_chain_add(chain, llama.sampler_init_dist(42)); + + // Print sampler chain info + let n_samplers = llama.sampler_chain_n(chain); + print("Sampler chain has " + n_samplers + " samplers:"); + for (let i = 0; i < n_samplers; i = i + 1) { + let s = llama.sampler_chain_get(chain, i); + print(" [" + i + "] " + llama.sampler_name(s)); + } + print(""); + + // Generate + print("=== Grammar-Constrained JSON Output ==="); + print(""); + + let output = ""; + let n_cur = tokens.length; + let max_tokens = 512; + + for (let i = 0; i < max_tokens; i = i + 1) { + let token = llama.sample(chain, ctx, -1); + + if (llama.is_eog(model, token)) { + break; + } + + let piece = llama.token_to_piece(model, token, false); + output = output + piece; + print_inline(piece); + + result = llama.decode(ctx, [token], n_cur); + if (result != 0) { + break; + } + + n_cur = n_cur + 1; + } + + print(""); + print(""); + print("=== End Output ==="); + print(""); + print("The output above is guaranteed to be valid JSON by the grammar constraint."); + + // Cleanup + llama.sampler_free(chain); + llama.context_free(ctx); + llama.model_free(model); + llama.backend_free(); + + return 0; +} + +fn print_inline(text) { + io.write(io.stdout, text); + io.flush(io.stdout); +} + +main(args); diff --git a/examples/infill.hml b/examples/infill.hml new file mode 100644 index 0000000..bbd556e --- /dev/null +++ b/examples/infill.hml @@ -0,0 +1,166 @@ +#!/usr/bin/env hemlock +// Code infill (fill-in-the-middle) example: Use FIM tokens to complete code +// +// Usage: +// hemlock examples/infill.hml +// +// Requires a model trained with FIM support (e.g., CodeLlama, DeepSeek-Coder, StarCoder) + +import "llama" from "../llama.hml"; + +fn main(args) { + if (args.length < 2) { + print("Usage: hemlock examples/infill.hml "); + print(""); + print("Example:"); + print(" hemlock examples/infill.hml ~/models/codellama-7b.gguf"); + return 1; + } + + let model_path = args[1]; + + print("Loading model: " + model_path); + print(""); + + // Initialize backend + llama.backend_init(); + + // Load model + let model = llama.model_load(model_path, { + n_gpu_layers: 0 + }); + + let info = llama.model_info(model); + print("Model: " + info.description); + print(""); + + // Show some code infill examples + let examples = [ + { + description: "Complete a function body", + prefix: "def fibonacci(n):\n \"\"\"Return the nth Fibonacci number.\"\"\"\n", + suffix: "\n\nprint(fibonacci(10))\n" + }, + { + description: "Fill in a loop condition", + prefix: "def binary_search(arr, target):\n left, right = 0, len(arr) - 1\n while ", + suffix: ":\n mid = (left + right) // 2\n if arr[mid] == target:\n return mid\n elif arr[mid] < target:\n left = mid + 1\n else:\n right = mid - 1\n return -1\n" + }, + { + description: "Complete an import block", + prefix: "import os\nimport sys\n", + suffix: "\n\ndef main():\n data = json.loads(sys.stdin.read())\n df = pd.DataFrame(data)\n print(df.describe())\n" + } + ]; + + // Create context + let ctx = llama.context_create(model, { + n_ctx: 2048, + n_batch: 512 + }); + + for (let ex = 0; ex < examples.length; ex = ex + 1) { + let example = examples[ex]; + + print("=== Example " + (ex + 1) + ": " + example.description + " ==="); + print(""); + print("--- Prefix ---"); + print(example.prefix); + print("--- [FILL HERE] ---"); + print(""); + print("--- Suffix ---"); + print(example.suffix); + print(""); + + // Build infill prompt using FIM tokens + // Format:
 prefix  suffix 
+        // The model then generates the middle part
+        let fim_prompt = "
" + example.prefix + "" + example.suffix + "";
+
+        // Tokenize with special token parsing
+        let tokens = llama.tokenize(model, fim_prompt, {
+            add_special: true,
+            parse_special: true
+        });
+
+        print("Infill prompt: " + tokens.length + " tokens");
+
+        // Clear KV cache for fresh start
+        llama.kv_cache_clear(ctx);
+
+        // Decode prompt
+        let result = llama.decode(ctx, tokens, 0);
+        if (result != 0) {
+            print("Error decoding: " + result);
+            continue;
+        }
+
+        // Create sampler chain with infill sampler
+        let chain = llama.sampler_chain_init();
+        llama.sampler_chain_add(chain, llama.sampler_init_temp(0.2));  // Low temp for code
+        llama.sampler_chain_add(chain, llama.sampler_init_top_k(20));
+        llama.sampler_chain_add(chain, llama.sampler_init_top_p(0.9, 1));
+        llama.sampler_chain_add(chain, llama.sampler_init_infill(model));
+        llama.sampler_chain_add(chain, llama.sampler_init_dist(42));
+
+        // Generate
+        print("");
+        print("--- Generated Fill ---");
+
+        let output = "";
+        let n_cur = tokens.length;
+        let max_tokens = 256;
+
+        for (let i = 0; i < max_tokens; i = i + 1) {
+            let token = llama.sample(chain, ctx, -1);
+
+            if (llama.is_eog(model, token)) {
+                break;
+            }
+
+            let piece = llama.token_to_piece(model, token, false);
+
+            // Stop if we see the end-of-middle marker
+            if (piece == "
+" || piece == "") {
+                break;
+            }
+
+            output = output + piece;
+            print_inline(piece);
+
+            result = llama.decode(ctx, [token], n_cur);
+            if (result != 0) {
+                break;
+            }
+
+            n_cur = n_cur + 1;
+        }
+
+        print("");
+        print("--- End Fill ---");
+        print("");
+
+        // Show the complete result
+        print("--- Complete Code ---");
+        print(example.prefix + output + example.suffix);
+        print("--- End ---");
+        print("");
+
+        llama.sampler_free(chain);
+    }
+
+    // Cleanup
+    llama.context_free(ctx);
+    llama.model_free(model);
+    llama.backend_free();
+
+    return 0;
+}
+
+fn print_inline(text) {
+    io.write(io.stdout, text);
+    io.flush(io.stdout);
+}
+
+main(args);
diff --git a/examples/model_info.hml b/examples/model_info.hml
new file mode 100644
index 0000000..35d0a02
--- /dev/null
+++ b/examples/model_info.hml
@@ -0,0 +1,203 @@
+#!/usr/bin/env hemlock
+// Model info example: Inspect model metadata, architecture, and system capabilities
+//
+// Usage:
+//   hemlock examples/model_info.hml 
+
+import "llama" from "../llama.hml";
+
+fn main(args) {
+    if (args.length < 2) {
+        print("Usage: hemlock examples/model_info.hml ");
+        print("");
+        print("Example:");
+        print("  hemlock examples/model_info.hml ~/models/llama-7b.gguf");
+        return 1;
+    }
+
+    let model_path = args[1];
+
+    // Initialize backend
+    llama.backend_init();
+
+    // =============================================
+    // System Information
+    // =============================================
+    print("=== System Information ===");
+    print("");
+
+    let sys_info = llama.system_info();
+    print("System: " + sys_info);
+    print("");
+
+    print("GPU offload:   " + (llama.supports_gpu() ? "supported" : "not available"));
+    print("Memory map:    " + (llama.supports_mmap() ? "supported" : "not available"));
+    print("Memory lock:   " + (llama.supports_mlock() ? "supported" : "not available"));
+    print("Max devices:   " + llama.max_devices());
+    print("");
+
+    // =============================================
+    // Model Loading
+    // =============================================
+    print("=== Loading Model ===");
+    print("Path: " + model_path);
+    print("");
+
+    let load_start = llama.time_us();
+
+    let model = llama.model_load(model_path, {
+        n_gpu_layers: 0,
+        use_mmap: true
+    });
+
+    let load_time = (llama.time_us() - load_start) / 1000;
+    print("Load time: " + load_time + " ms");
+    print("");
+
+    // =============================================
+    // Model Architecture
+    // =============================================
+    let info = llama.model_info(model);
+
+    print("=== Model Architecture ===");
+    print("");
+    print("Description:     " + info.description);
+    print("Parameters:      " + format_count(info.n_params));
+    print("Model size:      " + format_bytes(info.size));
+    print("Context (train): " + info.n_ctx_train + " tokens");
+    print("Embedding dim:   " + info.n_embd);
+    print("Layers:          " + info.n_layer);
+    print("Attention heads: " + info.n_head);
+    print("Has encoder:     " + info.has_encoder);
+    print("Has decoder:     " + info.has_decoder);
+    print("Is recurrent:    " + info.is_recurrent);
+    print("");
+
+    // =============================================
+    // Vocabulary
+    // =============================================
+    let vocab = llama.vocab_info(model);
+
+    let vocab_type_names = ["None", "SPM (SentencePiece)", "BPE (Byte-Pair)", "WPM (WordPiece)", "UGM (Unigram)", "RWKV"];
+    let type_name = vocab.type < vocab_type_names.length ? vocab_type_names[vocab.type] : "Unknown (" + vocab.type + ")";
+
+    print("=== Vocabulary ===");
+    print("");
+    print("Type:            " + type_name);
+    print("Total tokens:    " + vocab.n_tokens);
+    print("BOS token:       " + vocab.bos_token);
+    print("EOS token:       " + vocab.eos_token);
+    print("EOT token:       " + vocab.eot_token);
+    print("Newline token:   " + vocab.nl_token);
+    print("Pad token:       " + vocab.pad_token);
+    print("Auto-add BOS:    " + vocab.add_bos);
+    print("Auto-add EOS:    " + vocab.add_eos);
+    print("");
+
+    // =============================================
+    // Chat Template
+    // =============================================
+    print("=== Chat Template ===");
+    print("");
+
+    let tmpl = llama.chat_template(model, null);
+    if (tmpl == "") {
+        print("No chat template found in model.");
+    } else {
+        print("Template found (" + tmpl.length + " characters):");
+        print("");
+        // Show a preview
+        if (tmpl.length > 500) {
+            print(tmpl.slice(0, 500));
+            print("... (" + (tmpl.length - 500) + " more characters)");
+        } else {
+            print(tmpl);
+        }
+    }
+    print("");
+
+    // =============================================
+    // Context Test
+    // =============================================
+    print("=== Context Information ===");
+    print("");
+
+    let ctx = llama.context_create(model, {
+        n_ctx: 2048,
+        n_batch: 512,
+        n_threads: 0
+    });
+
+    let ctx_info = llama.context_info(ctx);
+    print("Context size:    " + ctx_info.n_ctx + " tokens");
+    print("Batch size:      " + ctx_info.n_batch);
+    print("Micro-batch:     " + ctx_info.n_ubatch);
+    print("Max sequences:   " + ctx_info.n_seq_max);
+    print("Threads:         " + ctx_info.n_threads);
+    print("Threads (batch): " + ctx_info.n_threads_batch);
+    print("");
+
+    // =============================================
+    // Quick Tokenization Benchmark
+    // =============================================
+    print("=== Tokenization Benchmark ===");
+    print("");
+
+    let bench_text = "The quick brown fox jumps over the lazy dog. " +
+        "In the beginning, there was nothing. Then, something happened. " +
+        "A long time ago in a galaxy far, far away... " +
+        "To be or not to be, that is the question.";
+
+    let bench_iters = 100;
+    let bench_start = llama.time_us();
+
+    for (let i = 0; i < bench_iters; i = i + 1) {
+        let tokens = llama.tokenize(model, bench_text, { add_special: true });
+    }
+
+    let bench_time = (llama.time_us() - bench_start) / 1000;
+    let tokens = llama.tokenize(model, bench_text, { add_special: true });
+
+    print("Text length:     " + bench_text.length + " chars");
+    print("Token count:     " + tokens.length + " tokens");
+    print("Iterations:      " + bench_iters);
+    print("Total time:      " + bench_time + " ms");
+    print("Per iteration:   " + (bench_time / bench_iters) + " ms");
+    print("");
+
+    // Cleanup
+    llama.context_free(ctx);
+    llama.model_free(model);
+    llama.backend_free();
+
+    print("Done.");
+    return 0;
+}
+
+fn format_bytes(bytes: u64): string {
+    if (bytes >= 1073741824) {
+        return (bytes / 1073741824) + " GB";
+    }
+    if (bytes >= 1048576) {
+        return (bytes / 1048576) + " MB";
+    }
+    if (bytes >= 1024) {
+        return (bytes / 1024) + " KB";
+    }
+    return bytes + " B";
+}
+
+fn format_count(n: u64): string {
+    if (n >= 1000000000) {
+        return (n / 1000000000) + "." + ((n / 100000000) % 10) + "B";
+    }
+    if (n >= 1000000) {
+        return (n / 1000000) + "." + ((n / 100000) % 10) + "M";
+    }
+    if (n >= 1000) {
+        return (n / 1000) + "." + ((n / 100) % 10) + "K";
+    }
+    return "" + n;
+}
+
+main(args);
diff --git a/examples/streaming.hml b/examples/streaming.hml
new file mode 100644
index 0000000..0d22987
--- /dev/null
+++ b/examples/streaming.hml
@@ -0,0 +1,124 @@
+#!/usr/bin/env hemlock
+// Streaming generation example: Generate text with real-time output and
+// performance measurement using the generate() callback API
+//
+// Usage:
+//   hemlock examples/streaming.hml  "Your prompt here"
+
+import "llama" from "../llama.hml";
+
+fn main(args) {
+    if (args.length < 3) {
+        print("Usage: hemlock examples/streaming.hml  ");
+        print("");
+        print("Example:");
+        print("  hemlock examples/streaming.hml ~/models/llama-7b.gguf \"Write a short poem about coding:\"");
+        return 1;
+    }
+
+    let model_path = args[1];
+    let prompt = args[2];
+
+    print("Loading model: " + model_path);
+    print("");
+
+    // Initialize backend
+    llama.backend_init();
+
+    // Load model
+    let model = llama.model_load(model_path, {
+        n_gpu_layers: 0
+    });
+
+    let info = llama.model_info(model);
+    print("Model: " + info.description);
+    print("");
+
+    // Create context
+    let ctx = llama.context_create(model, {
+        n_ctx: 2048,
+        n_batch: 512
+    });
+
+    // Reset performance counters
+    llama.perf_reset(ctx);
+
+    // Track generation statistics
+    let token_count = 0;
+    let start_time = llama.time_us();
+    let first_token_time = 0;
+
+    print("Prompt: \"" + prompt + "\"");
+    print("");
+    print("=== Streaming Output ===");
+    print("");
+
+    // Use the high-level generate() with a streaming callback
+    let result = llama.generate(ctx, model, prompt, {
+        n_predict: 512,
+        temp: 0.8,
+        top_k: 40,
+        top_p: 0.95,
+        min_p: 0.05,
+        repeat_penalty: 1.1,
+
+        // This callback fires for each generated token
+        callback: fn(token, piece) {
+            token_count = token_count + 1;
+
+            if (first_token_time == 0) {
+                first_token_time = llama.time_us();
+            }
+
+            // Stream the token to stdout
+            print_inline(piece);
+
+            // Return true to continue generating
+            return true;
+        }
+    });
+
+    let end_time = llama.time_us();
+
+    print("");
+    print("");
+
+    // Compute and display performance metrics
+    let total_us = end_time - start_time;
+    let total_ms = total_us / 1000;
+
+    print("=== Performance ===");
+    print("Tokens generated:    " + token_count);
+    print("Total time:          " + total_ms + " ms");
+
+    if (first_token_time > 0) {
+        let ttft_ms = (first_token_time - start_time) / 1000;
+        print("Time to first token: " + ttft_ms + " ms");
+    }
+
+    if (token_count > 0 && total_us > 0) {
+        // Tokens per second = tokens / (microseconds / 1000000)
+        let tps = token_count * 1000000 / total_us;
+        print("Tokens/second:       " + tps);
+    }
+
+    print("");
+
+    // Also print llama.cpp's internal performance counters
+    print("=== llama.cpp Performance Counters ===");
+    llama.perf_print(ctx);
+
+    // Cleanup
+    llama.context_free(ctx);
+    llama.model_free(model);
+    llama.backend_free();
+
+    return 0;
+}
+
+fn print_inline(text) {
+    io.write(io.stdout, text);
+    io.flush(io.stdout);
+}
+
+main(args);
diff --git a/examples/tokenizer.hml b/examples/tokenizer.hml
new file mode 100644
index 0000000..af0be68
--- /dev/null
+++ b/examples/tokenizer.hml
@@ -0,0 +1,207 @@
+#!/usr/bin/env hemlock
+// Tokenizer exploration example: Inspect tokens, vocabulary, and tokenization
+//
+// Usage:
+//   hemlock examples/tokenizer.hml  ["text to tokenize"]
+
+import "llama" from "../llama.hml";
+
+fn main(args) {
+    if (args.length < 2) {
+        print("Usage: hemlock examples/tokenizer.hml  [\"text to tokenize\"]");
+        print("");
+        print("Example:");
+        print("  hemlock examples/tokenizer.hml ~/models/llama-7b.gguf \"Hello, world!\"");
+        return 1;
+    }
+
+    let model_path = args[1];
+    let input_text = args.length >= 3 ? args[2] : "The quick brown fox jumps over the lazy dog.";
+
+    print("Loading model: " + model_path);
+    print("");
+
+    // Initialize backend
+    llama.backend_init();
+
+    // Load model (vocab only would be ideal, but we load full model)
+    let model = llama.model_load(model_path, {
+        n_gpu_layers: 0,
+        use_mmap: true
+    });
+
+    // Display vocabulary information
+    let vocab = llama.vocab_info(model);
+
+    let vocab_type_names = ["None", "SPM (SentencePiece)", "BPE (Byte-Pair)", "WPM (WordPiece)", "UGM (Unigram)", "RWKV"];
+    let type_name = vocab.type < vocab_type_names.length ? vocab_type_names[vocab.type] : "Unknown";
+
+    print("=== Vocabulary Info ===");
+    print("  Type:       " + type_name);
+    print("  Tokens:     " + vocab.n_tokens);
+    print("  BOS token:  " + vocab.bos_token + " -> \"" + llama.vocab_get_text(model, vocab.bos_token) + "\"");
+    print("  EOS token:  " + vocab.eos_token + " -> \"" + llama.vocab_get_text(model, vocab.eos_token) + "\"");
+
+    if (vocab.eot_token != -1) {
+        print("  EOT token:  " + vocab.eot_token + " -> \"" + llama.vocab_get_text(model, vocab.eot_token) + "\"");
+    }
+
+    print("  NL token:   " + vocab.nl_token + " -> \"" + llama.vocab_get_text(model, vocab.nl_token) + "\"");
+
+    if (vocab.pad_token != -1) {
+        print("  PAD token:  " + vocab.pad_token + " -> \"" + llama.vocab_get_text(model, vocab.pad_token) + "\"");
+    }
+
+    print("  Add BOS:    " + vocab.add_bos);
+    print("  Add EOS:    " + vocab.add_eos);
+    print("");
+
+    // Show some sample vocab entries
+    print("=== Sample Vocabulary Entries ===");
+    let n_show = 20;
+    if (n_show > vocab.n_tokens) {
+        n_show = vocab.n_tokens;
+    }
+
+    print("First " + n_show + " tokens:");
+    for (let i = 0; i < n_show; i = i + 1) {
+        let text = llama.vocab_get_text(model, i);
+        let score = llama.vocab_get_score(model, i);
+        let attr = llama.vocab_get_attr(model, i);
+        let is_ctrl = llama.is_control(model, i);
+        let attr_str = format_attr(attr);
+
+        print("  [" + pad_num(i, 5) + "] " +
+              "score=" + pad_float(score, 10) + "  " +
+              "attr=" + attr_str + "  " +
+              (is_ctrl ? "(ctrl) " : "       ") +
+              "\"" + escape_str(text) + "\"");
+    }
+    print("");
+
+    // Tokenize the input text
+    print("=== Tokenization ===");
+    print("Input: \"" + input_text + "\"");
+    print("");
+
+    // Tokenize with special tokens
+    let tokens_with = llama.tokenize(model, input_text, {
+        add_special: true,
+        parse_special: false
+    });
+
+    // Tokenize without special tokens
+    let tokens_without = llama.tokenize(model, input_text, {
+        add_special: false,
+        parse_special: false
+    });
+
+    print("With special tokens:    " + tokens_with.length + " tokens");
+    print("Without special tokens: " + tokens_without.length + " tokens");
+    print("");
+
+    // Show detailed token breakdown
+    print("Token breakdown (with specials):");
+    for (let i = 0; i < tokens_with.length; i = i + 1) {
+        let token = tokens_with[i];
+        let piece = llama.token_to_piece(model, token, true);
+        let is_ctrl = llama.is_control(model, token);
+        let is_eog = llama.is_eog(model, token);
+
+        let flags = "";
+        if (is_ctrl) { flags = flags + " [ctrl]"; }
+        if (is_eog) { flags = flags + " [eog]"; }
+
+        print("  [" + i + "] token=" + pad_num(token, 6) + "  \"" + escape_str(piece) + "\"" + flags);
+    }
+    print("");
+
+    // Round-trip test: detokenize and compare
+    print("=== Round-Trip Test ===");
+    let detokenized = llama.detokenize(model, tokens_without, {
+        remove_special: false,
+        unparse_special: true
+    });
+    print("Original:     \"" + input_text + "\"");
+    print("Detokenized:  \"" + detokenized + "\"");
+    print("Match:        " + (input_text == detokenized ? "YES" : "NO"));
+    print("");
+
+    // Token frequency analysis - show byte coverage
+    print("=== Token Length Distribution ===");
+    let lengths = {};
+    for (let i = 0; i < tokens_without.length; i = i + 1) {
+        let piece = llama.token_to_piece(model, tokens_without[i], false);
+        let len = piece.length;
+        if (lengths[len] == null) {
+            lengths[len] = 0;
+        }
+        lengths[len] = lengths[len] + 1;
+    }
+
+    for (let len = 1; len <= 20; len = len + 1) {
+        if (lengths[len] != null) {
+            let bar = "";
+            for (let j = 0; j < lengths[len]; j = j + 1) {
+                bar = bar + "#";
+            }
+            print("  " + len + " chars: " + bar + " (" + lengths[len] + ")");
+        }
+    }
+
+    // Cleanup
+    llama.model_free(model);
+    llama.backend_free();
+
+    return 0;
+}
+
+fn format_attr(attr: i32): string {
+    let parts = [];
+    if (attr & llama.TOKEN_ATTR_CONTROL) { parts.push("ctrl"); }
+    if (attr & llama.TOKEN_ATTR_NORMAL) { parts.push("normal"); }
+    if (attr & llama.TOKEN_ATTR_BYTE) { parts.push("byte"); }
+    if (attr & llama.TOKEN_ATTR_UNKNOWN) { parts.push("unk"); }
+    if (attr & llama.TOKEN_ATTR_UNUSED) { parts.push("unused"); }
+    if (attr & llama.TOKEN_ATTR_USER_DEFINED) { parts.push("udef"); }
+
+    if (parts.length == 0) {
+        return "none";
+    }
+    return parts.join(",");
+}
+
+fn escape_str(s: string): string {
+    let result = "";
+    for (let i = 0; i < s.length; i = i + 1) {
+        let c = s[i];
+        if (c == "\n") {
+            result = result + "\\n";
+        } else if (c == "\t") {
+            result = result + "\\t";
+        } else if (c == "\r") {
+            result = result + "\\r";
+        } else {
+            result = result + c;
+        }
+    }
+    return result;
+}
+
+fn pad_num(n: i32, width: i32): string {
+    let s = "" + n;
+    while (s.length < width) {
+        s = " " + s;
+    }
+    return s;
+}
+
+fn pad_float(f: f32, width: i32): string {
+    let s = "" + f;
+    while (s.length < width) {
+        s = " " + s;
+    }
+    return s;
+}
+
+main(args);
diff --git a/llama.hml b/llama.hml
index c840891..bfc0d41 100644
--- a/llama.hml
+++ b/llama.hml
@@ -46,6 +46,36 @@ export let ROPE_SCALING_NONE = 0;
 export let ROPE_SCALING_LINEAR = 1;
 export let ROPE_SCALING_YARN = 2;
 
+// Token attributes
+export let TOKEN_ATTR_UNDEFINED = 0;
+export let TOKEN_ATTR_UNKNOWN = 1;
+export let TOKEN_ATTR_UNUSED = 2;
+export let TOKEN_ATTR_NORMAL = 4;
+export let TOKEN_ATTR_CONTROL = 8;
+export let TOKEN_ATTR_USER_DEFINED = 16;
+export let TOKEN_ATTR_BYTE = 32;
+export let TOKEN_ATTR_LSTRIP = 64;
+export let TOKEN_ATTR_RSTRIP = 128;
+export let TOKEN_ATTR_SINGLE_WORD = 256;
+
+// Quantization types
+export let FTYPE_ALL_F32 = 0;
+export let FTYPE_MOSTLY_F16 = 1;
+export let FTYPE_MOSTLY_Q4_0 = 2;
+export let FTYPE_MOSTLY_Q4_1 = 3;
+export let FTYPE_MOSTLY_Q5_0 = 8;
+export let FTYPE_MOSTLY_Q5_1 = 9;
+export let FTYPE_MOSTLY_Q8_0 = 7;
+export let FTYPE_MOSTLY_Q2_K = 10;
+export let FTYPE_MOSTLY_Q3_K_S = 11;
+export let FTYPE_MOSTLY_Q3_K_M = 12;
+export let FTYPE_MOSTLY_Q3_K_L = 13;
+export let FTYPE_MOSTLY_Q4_K_S = 14;
+export let FTYPE_MOSTLY_Q4_K_M = 15;
+export let FTYPE_MOSTLY_Q5_K_S = 16;
+export let FTYPE_MOSTLY_Q5_K_M = 17;
+export let FTYPE_MOSTLY_Q6_K = 18;
+
 // ============================================================================
 // Low-level FFI declarations
 // ============================================================================
@@ -171,6 +201,41 @@ extern fn llama_sampler_get_seed(sampler: ptr): u32;
 extern fn llama_model_chat_template(model: ptr, name: ptr): ptr;
 extern fn llama_chat_apply_template(tmpl: ptr, chat: ptr, n_msg: u64, add_ass: i32, buf: ptr, length: i32): i32;
 
+// Grammar-based sampling
+extern fn llama_sampler_init_grammar(vocab: ptr, grammar_str: string, grammar_root: string): ptr;
+
+// Logit bias sampling
+extern fn llama_sampler_init_logit_bias(n_vocab: i32, n_logit_bias: i32, logit_bias: ptr): ptr;
+
+// Infill (fill-in-the-middle) sampling
+extern fn llama_sampler_init_infill(vocab: ptr): ptr;
+
+// LoRA adapter functions
+extern fn llama_lora_adapter_init(model: ptr, path: string): ptr;
+extern fn llama_lora_adapter_free(adapter: ptr): void;
+extern fn llama_lora_adapter_set(ctx: ptr, adapter: ptr, scale: f32): i32;
+extern fn llama_lora_adapter_remove(ctx: ptr, adapter: ptr): i32;
+extern fn llama_lora_adapter_clear(ctx: ptr): void;
+
+// State save/load
+extern fn llama_state_get_size(ctx: ptr): u64;
+extern fn llama_state_get_data(ctx: ptr, dst: ptr, size: u64): u64;
+extern fn llama_state_set_data(ctx: ptr, src: ptr, size: u64): u64;
+extern fn llama_state_seq_get_size(ctx: ptr, seq_id: i32): u64;
+extern fn llama_state_seq_get_data(ctx: ptr, dst: ptr, size: u64, seq_id: i32): u64;
+extern fn llama_state_seq_set_data(ctx: ptr, src: ptr, size: u64, seq_id: i32): u64;
+
+// Pooling type
+extern fn llama_pooling_type(ctx: ptr): i32;
+
+// Vocab score/attributes
+extern fn llama_vocab_get_score(vocab: ptr, token: i32): f32;
+extern fn llama_vocab_get_attr(vocab: ptr, token: i32): i32;
+
+// Model quantization
+extern fn llama_model_quantize(fname_inp: string, fname_out: string, params: ptr): i32;
+extern fn llama_model_quantize_default_params(): ptr;
+
 // Utilities
 extern fn llama_time_us(): i64;
 extern fn llama_print_system_info(): ptr;
@@ -851,6 +916,40 @@ export fn sampler_init_penalties(last_n: i32, repeat: f32, freq: f32, presence:
     return llama_sampler_init_penalties(last_n, repeat, freq, presence);
 }
 
+// Create grammar-based sampler for constrained generation
+// grammar_str: GBNF grammar string
+// grammar_root: root rule name (typically "root")
+export fn sampler_init_grammar(model: ptr, grammar_str: string, grammar_root: string): ptr {
+    let vocab = llama_model_get_vocab(model);
+    return llama_sampler_init_grammar(vocab, grammar_str, grammar_root);
+}
+
+// Create logit bias sampler
+// biases: array of { token: i32, bias: f32 }
+export fn sampler_init_logit_bias(model: ptr, biases: array): ptr {
+    let vocab = llama_model_get_vocab(model);
+    let n_vocab = llama_vocab_n_tokens(vocab);
+    let n_biases = biases.length;
+
+    // Each logit_bias entry is 8 bytes (i32 token + f32 bias)
+    let bias_buf = alloc(n_biases * 8);
+    for (let i = 0; i < n_biases; i = i + 1) {
+        let entry = biases[i];
+        ptr_write_i32(ptr_offset(bias_buf, i * 8, 1), entry.token);
+        ptr_write_f32(ptr_offset(bias_buf, i * 8 + 4, 1), entry.bias);
+    }
+
+    let sampler = llama_sampler_init_logit_bias(n_vocab, n_biases, bias_buf);
+    free(bias_buf);
+    return sampler;
+}
+
+// Create infill sampler (for fill-in-the-middle / code completion)
+export fn sampler_init_infill(model: ptr): ptr {
+    let vocab = llama_model_get_vocab(model);
+    return llama_sampler_init_infill(vocab);
+}
+
 // Create empty sampler chain
 export fn sampler_chain_init(): ptr {
     let chain_params = alloc(8);
@@ -881,9 +980,9 @@ export fn decode(ctx: ptr, tokens: array, pos: i32): i32 {
     }
 
     // Create batch using llama_batch_get_one for simple single-sequence use
-    // The batch struct is 48 bytes
-    let batch = alloc(48);
-    memset(batch, 0, 48);
+    // The batch struct is 56 bytes (i32 n_tokens + 4 padding + 6 pointers)
+    let batch = alloc(56);
+    memset(batch, 0, 56);
 
     // n_tokens at offset 0
     ptr_write_i32(batch, n_tokens);
@@ -933,9 +1032,9 @@ export fn encode(ctx: ptr, tokens: array): i32 {
         ptr_write_i32(ptr_offset(tokens_buf, i, 4), tokens[i]);
     }
 
-    // Create batch struct (48 bytes)
-    let batch = alloc(48);
-    memset(batch, 0, 48);
+    // Create batch struct (56 bytes: i32 n_tokens + 4 padding + 6 pointers)
+    let batch = alloc(56);
+    memset(batch, 0, 56);
 
     // n_tokens at offset 0
     ptr_write_i32(batch, n_tokens);
@@ -1080,6 +1179,167 @@ export fn time_us(): i64 {
     return llama_time_us();
 }
 
+// ============================================================================
+// LoRA Adapter functions
+// ============================================================================
+
+// Load a LoRA adapter from file
+// Returns adapter pointer (null on failure)
+export fn lora_adapter_init(model: ptr, path: string): ptr {
+    let adapter = llama_lora_adapter_init(model, path);
+    if (adapter == null) {
+        throw "Failed to load LoRA adapter: " + path;
+    }
+    return adapter;
+}
+
+// Free a LoRA adapter
+export fn lora_adapter_free(adapter: ptr) {
+    if (adapter != null) {
+        llama_lora_adapter_free(adapter);
+    }
+}
+
+// Apply a LoRA adapter to a context with a given scale
+// scale: 1.0 for full effect, 0.0 to disable
+export fn lora_adapter_set(ctx: ptr, adapter: ptr, scale: f32): i32 {
+    return llama_lora_adapter_set(ctx, adapter, scale);
+}
+
+// Remove a specific LoRA adapter from a context
+export fn lora_adapter_remove(ctx: ptr, adapter: ptr): i32 {
+    return llama_lora_adapter_remove(ctx, adapter);
+}
+
+// Remove all LoRA adapters from a context
+export fn lora_adapter_clear(ctx: ptr) {
+    llama_lora_adapter_clear(ctx);
+}
+
+// ============================================================================
+// State Save/Load functions
+// ============================================================================
+
+// Get the size needed to save the full context state
+export fn state_get_size(ctx: ptr): u64 {
+    return llama_state_get_size(ctx);
+}
+
+// Save full context state to a buffer
+// Returns the number of bytes written
+export fn state_save(ctx: ptr): ptr {
+    let size = llama_state_get_size(ctx);
+    let buf = alloc(size);
+    let written = llama_state_get_data(ctx, buf, size);
+    if (written == 0) {
+        free(buf);
+        throw "Failed to save state";
+    }
+    return { data: buf, size: written };
+}
+
+// Restore full context state from a buffer
+export fn state_load(ctx: ptr, state: object) {
+    let read = llama_state_set_data(ctx, state.data, state.size);
+    if (read == 0) {
+        throw "Failed to load state";
+    }
+}
+
+// Free a saved state
+export fn state_free(state: object) {
+    if (state != null && state.data != null) {
+        free(state.data);
+    }
+}
+
+// Get the size needed to save a single sequence state
+export fn state_seq_get_size(ctx: ptr, seq_id: i32): u64 {
+    return llama_state_seq_get_size(ctx, seq_id);
+}
+
+// Save a single sequence state
+export fn state_seq_save(ctx: ptr, seq_id: i32): object {
+    let size = llama_state_seq_get_size(ctx, seq_id);
+    let buf = alloc(size);
+    let written = llama_state_seq_get_data(ctx, buf, size, seq_id);
+    if (written == 0) {
+        free(buf);
+        throw "Failed to save sequence state";
+    }
+    return { data: buf, size: written };
+}
+
+// Restore a single sequence state
+export fn state_seq_load(ctx: ptr, state: object, seq_id: i32) {
+    let read = llama_state_seq_set_data(ctx, state.data, state.size, seq_id);
+    if (read == 0) {
+        throw "Failed to load sequence state";
+    }
+}
+
+// ============================================================================
+// Vocabulary inspection functions
+// ============================================================================
+
+// Get the score of a token in the vocabulary
+export fn vocab_get_score(model: ptr, token: i32): f32 {
+    let vocab = llama_model_get_vocab(model);
+    return llama_vocab_get_score(vocab, token);
+}
+
+// Get the attributes of a token
+export fn vocab_get_attr(model: ptr, token: i32): i32 {
+    let vocab = llama_model_get_vocab(model);
+    return llama_vocab_get_attr(vocab, token);
+}
+
+// Get the pooling type used by the context
+export fn pooling_type(ctx: ptr): i32 {
+    return llama_pooling_type(ctx);
+}
+
+// ============================================================================
+// Model Quantization functions
+// ============================================================================
+
+// Quantize a model to a different format
+// Options:
+//   ftype: target quantization type (e.g., FTYPE_MOSTLY_Q4_0)
+//   n_threads: number of threads (default: 0 = auto)
+export fn model_quantize(input_path: string, output_path: string, options): i32 {
+    let opts = options || {};
+
+    // Allocate quantize params struct (approximately 64 bytes)
+    let params = alloc(64);
+    memset(params, 0, 64);
+
+    // nthread at offset 0
+    let n_threads = opts.n_threads || 0;
+    ptr_write_i32(params, n_threads);
+
+    // ftype at offset 4
+    let ftype = opts.ftype || FTYPE_MOSTLY_Q4_0;
+    ptr_write_i32(ptr_offset(params, 4, 1), ftype);
+
+    // allow_requantize at offset 8
+    let allow_requantize = opts.allow_requantize == true ? 1 : 0;
+    ptr_write_u8(ptr_offset(params, 8, 1), allow_requantize);
+
+    // quantize_output_tensor at offset 9
+    let quantize_output = opts.quantize_output_tensor != false ? 1 : 0;
+    ptr_write_u8(ptr_offset(params, 9, 1), quantize_output);
+
+    let result = llama_model_quantize(input_path, output_path, params);
+    free(params);
+
+    if (result != 0) {
+        throw "Failed to quantize model: error code " + result;
+    }
+
+    return result;
+}
+
 // ============================================================================
 // Convenience/High-level functions
 // ============================================================================

From ef3a8036e2ae9c3b0bd71c45f4f72378a8764849 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 7 Feb 2026 13:43:32 +0000
Subject: [PATCH 2/3] Add test infrastructure with tiny model support

- Expand test_basic.hml with 40+ assertions covering constants,
  sampler creation/chain/introspection, and backend functions
  (all without requiring a model file)
- Add test_model.hml with comprehensive model-dependent tests:
  model info, vocab, tokenization round-trips, decode, sampling,
  KV cache, chat templates, grammar, logit bias, generation
- Add scripts/download_test_model.sh to fetch a ~15MB TinyStories
  model from HuggingFace for quick testing
- Add models/ to .gitignore
- Update CI to validate all new example files

https://claude.ai/code/session_01PQRPJWjp2HfT41sKmVaSsy
---
 .github/workflows/ci.yml       |   8 +
 .gitignore                     |   3 +
 scripts/download_test_model.sh |  47 +++++
 test_basic.hml                 | 199 +++++++++++++++++++--
 test_model.hml                 | 316 +++++++++++++++++++++++++++++++++
 5 files changed, 558 insertions(+), 15 deletions(-)
 create mode 100755 scripts/download_test_model.sh
 create mode 100644 test_model.hml

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f90271..5cc449c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,9 +74,17 @@ jobs:
           echo "Checking Hemlock source files exist..."
           test -f llama.hml && echo "llama.hml: OK"
           test -f test_basic.hml && echo "test_basic.hml: OK"
+          test -f test_model.hml && echo "test_model.hml: OK"
           test -f examples/simple.hml && echo "examples/simple.hml: OK"
           test -f examples/chat.hml && echo "examples/chat.hml: OK"
           test -f examples/oneshot.hml && echo "examples/oneshot.hml: OK"
+          test -f examples/streaming.hml && echo "examples/streaming.hml: OK"
+          test -f examples/chat_template.hml && echo "examples/chat_template.hml: OK"
+          test -f examples/grammar.hml && echo "examples/grammar.hml: OK"
+          test -f examples/embeddings.hml && echo "examples/embeddings.hml: OK"
+          test -f examples/tokenizer.hml && echo "examples/tokenizer.hml: OK"
+          test -f examples/infill.hml && echo "examples/infill.hml: OK"
+          test -f examples/model_info.hml && echo "examples/model_info.hml: OK"
           echo "All source files present!"
 
   lint:
diff --git a/.gitignore b/.gitignore
index b082d47..17c7744 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@ llama.cpp/build/
 # Test outputs
 *.log
 
+# Downloaded models
+models/
+
 # Editor/IDE
 .vscode/
 *.swp
diff --git a/scripts/download_test_model.sh b/scripts/download_test_model.sh
new file mode 100755
index 0000000..0e08272
--- /dev/null
+++ b/scripts/download_test_model.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Download a tiny GGUF model for testing hemllama bindings
+#
+# This downloads a ~15MB "stories" model that's good enough
+# to test tokenization, decoding, and generation.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MODELS_DIR="$SCRIPT_DIR/../models"
+MODEL_FILE="$MODELS_DIR/tinyllama-stories-15m-q4_0.gguf"
+
+# TinyStories 15M model quantized to Q4_0 (~15MB)
+MODEL_URL="https://huggingface.co/ggml-org/models/resolve/main/tinyllamas/stories15M-q4_0.gguf"
+
+if [ -f "$MODEL_FILE" ]; then
+    echo "Test model already exists: $MODEL_FILE"
+    echo "Size: $(du -h "$MODEL_FILE" | cut -f1)"
+    exit 0
+fi
+
+echo "Downloading tiny test model (~15MB)..."
+echo "Source: $MODEL_URL"
+echo ""
+
+mkdir -p "$MODELS_DIR"
+
+if command -v curl &> /dev/null; then
+    curl -L -o "$MODEL_FILE" "$MODEL_URL" --progress-bar
+elif command -v wget &> /dev/null; then
+    wget -O "$MODEL_FILE" "$MODEL_URL" --show-progress
+else
+    echo "Error: curl or wget required"
+    exit 1
+fi
+
+echo ""
+echo "Downloaded: $MODEL_FILE"
+echo "Size: $(du -h "$MODEL_FILE" | cut -f1)"
+echo ""
+echo "Run tests with:"
+echo "  hemlock test_model.hml $MODEL_FILE"
+echo ""
+echo "Or try the examples:"
+echo "  hemlock examples/simple.hml $MODEL_FILE \"Once upon a time\""
+echo "  hemlock examples/model_info.hml $MODEL_FILE"
+echo "  hemlock examples/tokenizer.hml $MODEL_FILE"
diff --git a/test_basic.hml b/test_basic.hml
index fb149a1..ff1ff86 100644
--- a/test_basic.hml
+++ b/test_basic.hml
@@ -1,27 +1,196 @@
-// Basic test - just test that FFI loading works
+// Basic tests - verify FFI loading and non-model APIs work
 import * as llama from "./llama.hml";
 
-print("Testing hemllama bindings...");
+let passed = 0;
+let failed = 0;
+
+fn assert(condition, name) {
+    if (condition) {
+        print("  PASS: " + name);
+        passed = passed + 1;
+    } else {
+        print("  FAIL: " + name);
+        failed = failed + 1;
+    }
+}
+
+// ============================
+// Backend tests
+// ============================
+print("=== Backend ===");
 
-// Test backend init/free
-print("Initializing backend...");
 llama.backend_init();
-print("Backend initialized!");
+assert(true, "backend_init()");
+
+assert(llama.supports_gpu() == true || llama.supports_gpu() == false, "supports_gpu() returns bool");
+assert(llama.supports_mmap() == true || llama.supports_mmap() == false, "supports_mmap() returns bool");
+assert(llama.supports_mlock() == true || llama.supports_mlock() == false, "supports_mlock() returns bool");
+assert(llama.max_devices() >= 1, "max_devices() >= 1");
+
+let t = llama.time_us();
+assert(t > 0, "time_us() > 0");
+
+let t2 = llama.time_us();
+assert(t2 >= t, "time_us() is monotonic");
+
+let info = llama.system_info();
+assert(info.length > 0, "system_info() returns non-empty string");
+
+// ============================
+// Constants tests
+// ============================
+print("");
+print("=== Constants ===");
+
+assert(llama.TOKEN_NULL == -1, "TOKEN_NULL == -1");
+assert(llama.DEFAULT_SEED == 0xFFFFFFFF, "DEFAULT_SEED is set");
+
+assert(llama.VOCAB_TYPE_NONE == 0, "VOCAB_TYPE_NONE == 0");
+assert(llama.VOCAB_TYPE_SPM == 1, "VOCAB_TYPE_SPM == 1");
+assert(llama.VOCAB_TYPE_BPE == 2, "VOCAB_TYPE_BPE == 2");
+
+assert(llama.SPLIT_MODE_NONE == 0, "SPLIT_MODE_NONE == 0");
+assert(llama.SPLIT_MODE_LAYER == 1, "SPLIT_MODE_LAYER == 1");
+assert(llama.SPLIT_MODE_ROW == 2, "SPLIT_MODE_ROW == 2");
+
+assert(llama.POOLING_TYPE_NONE == 0, "POOLING_TYPE_NONE == 0");
+assert(llama.POOLING_TYPE_MEAN == 1, "POOLING_TYPE_MEAN == 1");
+assert(llama.POOLING_TYPE_CLS == 2, "POOLING_TYPE_CLS == 2");
+
+assert(llama.TOKEN_ATTR_NORMAL == 4, "TOKEN_ATTR_NORMAL == 4");
+assert(llama.TOKEN_ATTR_CONTROL == 8, "TOKEN_ATTR_CONTROL == 8");
+
+assert(llama.FTYPE_MOSTLY_Q4_0 == 2, "FTYPE_MOSTLY_Q4_0 == 2");
+assert(llama.FTYPE_MOSTLY_Q8_0 == 7, "FTYPE_MOSTLY_Q8_0 == 7");
+
+// ============================
+// Sampler tests (no model needed)
+// ============================
+print("");
+print("=== Samplers ===");
+
+// Greedy sampler
+let greedy = llama.sampler_greedy();
+assert(greedy != null, "sampler_greedy() returns non-null");
+llama.sampler_free(greedy);
+assert(true, "sampler_free(greedy) succeeds");
+
+// Sampler chain
+let chain = llama.sampler_chain_init();
+assert(chain != null, "sampler_chain_init() returns non-null");
+assert(llama.sampler_chain_n(chain) == 0, "empty chain has 0 samplers");
 
-// Skip system_info for now - has FFI type conversion issues
-// let info = llama.system_info();
-// print("System info: " + info);
+// Add individual samplers
+let temp = llama.sampler_init_temp(0.8);
+assert(temp != null, "sampler_init_temp() returns non-null");
+llama.sampler_chain_add(chain, temp);
+assert(llama.sampler_chain_n(chain) == 1, "chain has 1 sampler after add");
 
-// Test GPU support check
-print("GPU support: " + llama.supports_gpu());
+let top_k = llama.sampler_init_top_k(40);
+assert(top_k != null, "sampler_init_top_k() returns non-null");
+llama.sampler_chain_add(chain, top_k);
+assert(llama.sampler_chain_n(chain) == 2, "chain has 2 samplers after add");
 
-// Test time function
-print("Current time (us): " + llama.time_us());
+let top_p = llama.sampler_init_top_p(0.9, 1);
+assert(top_p != null, "sampler_init_top_p() returns non-null");
+llama.sampler_chain_add(chain, top_p);
 
+let min_p = llama.sampler_init_min_p(0.05, 1);
+assert(min_p != null, "sampler_init_min_p() returns non-null");
+llama.sampler_chain_add(chain, min_p);
+
+let typical = llama.sampler_init_typical(1.0, 1);
+assert(typical != null, "sampler_init_typical() returns non-null");
+llama.sampler_chain_add(chain, typical);
+
+let penalties = llama.sampler_init_penalties(64, 1.1, 0.0, 0.0);
+assert(penalties != null, "sampler_init_penalties() returns non-null");
+llama.sampler_chain_add(chain, penalties);
+
+let dist = llama.sampler_init_dist(42);
+assert(dist != null, "sampler_init_dist() returns non-null");
+llama.sampler_chain_add(chain, dist);
+
+assert(llama.sampler_chain_n(chain) == 7, "chain has 7 samplers total");
+
+// Introspect chain
+let s0 = llama.sampler_chain_get(chain, 0);
+assert(s0 != null, "sampler_chain_get(0) returns non-null");
+let name0 = llama.sampler_name(s0);
+assert(name0.length > 0, "sampler_name() returns non-empty string");
+print("    First sampler name: " + name0);
+
+// Clone
+let cloned = llama.sampler_clone(chain);
+assert(cloned != null, "sampler_clone() returns non-null");
+assert(llama.sampler_chain_n(cloned) == 7, "cloned chain has 7 samplers");
+llama.sampler_free(cloned);
+
+// Reset
+llama.sampler_reset(chain);
+assert(true, "sampler_reset() succeeds");
+
+// Remove
+let removed = llama.sampler_chain_remove(chain, 0);
+assert(removed != null, "sampler_chain_remove() returns non-null");
+assert(llama.sampler_chain_n(chain) == 6, "chain has 6 after remove");
+llama.sampler_free(removed);
+
+llama.sampler_free(chain);
+assert(true, "sampler_free(chain) succeeds");
+
+// High-level sampler_create
+let sampler = llama.sampler_create({
+    temp: 0.7,
+    top_k: 40,
+    top_p: 0.9,
+    min_p: 0.05,
+    repeat_penalty: 1.1,
+    seed: 42
+});
+assert(sampler != null, "sampler_create() returns non-null");
+assert(llama.sampler_chain_n(sampler) >= 3, "sampler_create() builds chain with multiple samplers");
+
+let seed = llama.sampler_get_seed(sampler);
+assert(seed >= 0, "sampler_get_seed() returns valid seed");
+
+llama.sampler_free(sampler);
+assert(true, "sampler_free(created sampler) succeeds");
+
+// Extended temperature
+let temp_ext = llama.sampler_init_temp_ext(0.8, 0.1, 1.0);
+assert(temp_ext != null, "sampler_init_temp_ext() returns non-null");
+llama.sampler_free(temp_ext);
+
+// Mirostat v2
+let miro = llama.sampler_init_mirostat_v2(42, 5.0, 0.1);
+assert(miro != null, "sampler_init_mirostat_v2() returns non-null");
+llama.sampler_free(miro);
+
+// Greedy init
+let greedy2 = llama.sampler_init_greedy();
+assert(greedy2 != null, "sampler_init_greedy() returns non-null");
+llama.sampler_free(greedy2);
+
+// ============================
 // Cleanup
-print("Freeing backend...");
+// ============================
+print("");
+print("=== Cleanup ===");
 llama.backend_free();
-print("Backend freed!");
+assert(true, "backend_free()");
 
+// ============================
+// Results
+// ============================
 print("");
-print("All basic tests passed!");
+print("=============================");
+print("Results: " + passed + " passed, " + failed + " failed");
+print("=============================");
+
+if (failed > 0) {
+    print("SOME TESTS FAILED!");
+    exit(1);
+} else {
+    print("All tests passed!");
+}
diff --git a/test_model.hml b/test_model.hml
new file mode 100644
index 0000000..c9514e1
--- /dev/null
+++ b/test_model.hml
@@ -0,0 +1,316 @@
+#!/usr/bin/env hemlock
+// Model tests - requires a GGUF model file
+//
+// Usage:
+//   hemlock test_model.hml 
+//
+// To get a tiny test model (~15MB):
+//   ./scripts/download_test_model.sh
+
+import * as llama from "./llama.hml";
+
+let passed = 0;
+let failed = 0;
+
+fn assert(condition, name) {
+    if (condition) {
+        print("  PASS: " + name);
+        passed = passed + 1;
+    } else {
+        print("  FAIL: " + name);
+        failed = failed + 1;
+    }
+}
+
+fn main(args) {
+    if (args.length < 2) {
+        print("Usage: hemlock test_model.hml ");
+        print("");
+        print("Get a tiny test model with:");
+        print("  ./scripts/download_test_model.sh");
+        return 1;
+    }
+
+    let model_path = args[1];
+
+    llama.backend_init();
+
+    // ============================
+    // Model loading
+    // ============================
+    print("=== Model Loading ===");
+
+    let model = llama.model_load(model_path, {
+        n_gpu_layers: 0,
+        use_mmap: true
+    });
+    assert(model != null, "model_load() returns non-null");
+
+    // ============================
+    // Model info
+    // ============================
+    print("");
+    print("=== Model Info ===");
+
+    let info = llama.model_info(model);
+    assert(info.description.length > 0, "model_info().description is non-empty");
+    assert(info.n_embd > 0, "model_info().n_embd > 0");
+    assert(info.n_layer > 0, "model_info().n_layer > 0");
+    assert(info.n_ctx_train > 0, "model_info().n_ctx_train > 0");
+    assert(info.n_params > 0, "model_info().n_params > 0");
+    assert(info.size > 0, "model_info().size > 0");
+    print("    Description: " + info.description);
+    print("    Params: " + info.n_params + ", Layers: " + info.n_layer);
+
+    // ============================
+    // Vocabulary
+    // ============================
+    print("");
+    print("=== Vocabulary ===");
+
+    let vocab = llama.vocab_info(model);
+    assert(vocab.n_tokens > 0, "vocab_info().n_tokens > 0");
+    assert(vocab.bos_token >= 0, "vocab_info().bos_token >= 0");
+    assert(vocab.eos_token >= 0, "vocab_info().eos_token >= 0");
+    print("    Vocab size: " + vocab.n_tokens + ", BOS: " + vocab.bos_token + ", EOS: " + vocab.eos_token);
+
+    let bos_text = llama.vocab_get_text(model, vocab.bos_token);
+    assert(bos_text.length > 0, "vocab_get_text(BOS) returns non-empty");
+
+    let bos_ctrl = llama.is_control(model, vocab.bos_token);
+    assert(bos_ctrl == true, "BOS is a control token");
+
+    let eos_eog = llama.is_eog(model, vocab.eos_token);
+    assert(eos_eog == true, "EOS is end-of-generation");
+
+    // Vocab score and attributes
+    let score = llama.vocab_get_score(model, 0);
+    assert(score != null, "vocab_get_score() returns a value");
+
+    let attr = llama.vocab_get_attr(model, vocab.bos_token);
+    assert(attr >= 0, "vocab_get_attr() returns >= 0");
+
+    // ============================
+    // Tokenization
+    // ============================
+    print("");
+    print("=== Tokenization ===");
+
+    let text = "Hello, world!";
+    let tokens = llama.tokenize(model, text, { add_special: true });
+    assert(tokens.length > 0, "tokenize() returns tokens");
+    print("    \"" + text + "\" -> " + tokens.length + " tokens");
+
+    // First token should be BOS (with add_special: true)
+    if (vocab.add_bos) {
+        assert(tokens[0] == vocab.bos_token, "first token is BOS with add_special");
+    }
+
+    let tokens_no_special = llama.tokenize(model, text, { add_special: false });
+    assert(tokens_no_special.length > 0, "tokenize(no special) returns tokens");
+    if (vocab.add_bos) {
+        assert(tokens_no_special.length < tokens.length, "fewer tokens without specials");
+    }
+
+    // Token to piece
+    for (let i = 0; i < tokens.length; i = i + 1) {
+        let piece = llama.token_to_piece(model, tokens[i], true);
+        assert(piece.length >= 0, "token_to_piece() returns string for token " + tokens[i]);
+    }
+
+    // Detokenize round-trip
+    let detokenized = llama.detokenize(model, tokens_no_special, { remove_special: false });
+    assert(detokenized == text, "detokenize(tokenize(text)) == text");
+    print("    Round-trip: \"" + detokenized + "\"");
+
+    // ============================
+    // Context
+    // ============================
+    print("");
+    print("=== Context ===");
+
+    let ctx = llama.context_create(model, {
+        n_ctx: 512,
+        n_batch: 256,
+        n_threads: 2
+    });
+    assert(ctx != null, "context_create() returns non-null");
+
+    let ctx_info = llama.context_info(ctx);
+    assert(ctx_info.n_ctx == 512, "context n_ctx matches");
+    assert(ctx_info.n_batch == 256, "context n_batch matches");
+    print("    ctx: " + ctx_info.n_ctx + " ctx, " + ctx_info.n_batch + " batch, " + ctx_info.n_threads + " threads");
+
+    let ctx_model = llama.get_model(ctx);
+    assert(ctx_model != null, "get_model() returns non-null");
+
+    llama.set_threads(ctx, 4, 4);
+    let new_info = llama.context_info(ctx);
+    assert(new_info.n_threads == 4, "set_threads() works");
+
+    // ============================
+    // Decode
+    // ============================
+    print("");
+    print("=== Decode ===");
+
+    let result = llama.decode(ctx, tokens, 0);
+    assert(result == 0, "decode() returns 0 (success)");
+
+    let logits = llama.get_logits(ctx);
+    assert(logits != null, "get_logits() returns non-null");
+
+    let logits_0 = llama.get_logits_ith(ctx, tokens.length - 1);
+    assert(logits_0 != null, "get_logits_ith() returns non-null");
+
+    // ============================
+    // Sampling with model
+    // ============================
+    print("");
+    print("=== Sample ===");
+
+    let sampler = llama.sampler_create({ temp: 0.8, seed: 42 });
+    let token = llama.sample(sampler, ctx, -1);
+    assert(token >= 0, "sample() returns valid token");
+    assert(token < vocab.n_tokens, "sample() returns token in vocab range");
+    print("    Sampled token: " + token + " -> \"" + llama.token_to_piece(model, token, false) + "\"");
+
+    llama.sampler_accept(sampler, token);
+    assert(true, "sampler_accept() succeeds");
+
+    llama.sampler_free(sampler);
+
+    // ============================
+    // KV Cache
+    // ============================
+    print("");
+    print("=== KV Cache ===");
+
+    let pos_max = llama.kv_cache_seq_pos_max(ctx, 0);
+    assert(pos_max >= 0, "kv_cache_seq_pos_max() >= 0");
+    print("    Max position: " + pos_max);
+
+    let pos_min = llama.kv_cache_seq_pos_min(ctx, 0);
+    assert(pos_min >= 0, "kv_cache_seq_pos_min() >= 0");
+
+    // Copy sequence
+    llama.kv_cache_seq_cp(ctx, 0, 1, 0, -1);
+    assert(true, "kv_cache_seq_cp() succeeds");
+
+    // Keep only seq 0
+    llama.kv_cache_seq_keep(ctx, 0);
+    assert(true, "kv_cache_seq_keep() succeeds");
+
+    // Clear
+    llama.kv_cache_clear(ctx);
+    assert(true, "kv_cache_clear() succeeds");
+
+    // ============================
+    // Chat template
+    // ============================
+    print("");
+    print("=== Chat Template ===");
+
+    let tmpl = llama.chat_template(model, null);
+    if (tmpl != "") {
+        assert(tmpl.length > 0, "chat_template() returns template");
+        print("    Template found (" + tmpl.length + " chars)");
+
+        let messages = [
+            { role: "user", content: "Hello" }
+        ];
+        let formatted = llama.apply_chat_template(model, messages, {
+            add_generation_prompt: true
+        });
+        assert(formatted.length > 0, "apply_chat_template() returns formatted text");
+        print("    Formatted: " + formatted.length + " chars");
+    } else {
+        print("    No chat template (skipping template tests)");
+    }
+
+    // ============================
+    // Grammar sampler (model-dependent)
+    // ============================
+    print("");
+    print("=== Grammar Sampler ===");
+
+    let grammar = "root ::= \"hello\" | \"world\"";
+    let grammar_sampler = llama.sampler_init_grammar(model, grammar, "root");
+    assert(grammar_sampler != null, "sampler_init_grammar() returns non-null");
+    llama.sampler_free(grammar_sampler);
+
+    // ============================
+    // Logit bias sampler
+    // ============================
+    print("");
+    print("=== Logit Bias ===");
+
+    let biases = [
+        { token: vocab.eos_token, bias: -100.0 }
+    ];
+    let bias_sampler = llama.sampler_init_logit_bias(model, biases);
+    assert(bias_sampler != null, "sampler_init_logit_bias() returns non-null");
+    llama.sampler_free(bias_sampler);
+
+    // ============================
+    // Performance
+    // ============================
+    print("");
+    print("=== Performance ===");
+
+    llama.perf_print(ctx);
+    assert(true, "perf_print() succeeds");
+    llama.perf_reset(ctx);
+    assert(true, "perf_reset() succeeds");
+
+    // ============================
+    // Generate (high-level)
+    // ============================
+    print("");
+    print("=== Generate ===");
+
+    // Re-create context fresh for generation
+    llama.context_free(ctx);
+    ctx = llama.context_create(model, { n_ctx: 512, n_batch: 256 });
+
+    let output = llama.generate(ctx, model, "The answer is", {
+        n_predict: 10,
+        temp: 0.5,
+        seed: 42
+    });
+    assert(output.length > 0, "generate() returns non-empty output");
+    print("    Generated: \"" + output + "\"");
+
+    // ============================
+    // Cleanup
+    // ============================
+    print("");
+    print("=== Cleanup ===");
+
+    llama.context_free(ctx);
+    assert(true, "context_free()");
+
+    llama.model_free(model);
+    assert(true, "model_free()");
+
+    llama.backend_free();
+    assert(true, "backend_free()");
+
+    // ============================
+    // Results
+    // ============================
+    print("");
+    print("=============================");
+    print("Results: " + passed + " passed, " + failed + " failed");
+    print("=============================");
+
+    if (failed > 0) {
+        print("SOME TESTS FAILED!");
+        return 1;
+    }
+
+    print("All tests passed!");
+    return 0;
+}
+
+main(args);

From 8e167ae58d21c355a43315006c5f90cc2be5bf4a Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 7 Feb 2026 13:52:17 +0000
Subject: [PATCH 3/3] Add model tests and tiny model download to CI pipeline

- Split test step into basic tests (no model) and model tests
- Download ~15MB TinyStories Q4_0 model in CI for model-dependent tests
- Model download and tests only run when Hemlock compiler is available
- Lint job also checks download_test_model.sh syntax

https://claude.ai/code/session_01PQRPJWjp2HfT41sKmVaSsy
---
 .github/workflows/ci.yml | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5cc449c..bf5c3f1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,13 +62,26 @@ jobs:
             echo "hemlock_available=false" >> $GITHUB_OUTPUT
           fi
 
-      - name: Run tests
+      - name: Run basic tests (no model required)
         if: steps.install-hemlock.outputs.hemlock_available == 'true'
         run: |
           export LD_LIBRARY_PATH="$(pwd)/lib:$LD_LIBRARY_PATH"
           export DYLD_LIBRARY_PATH="$(pwd)/lib:$DYLD_LIBRARY_PATH"
           hemlock test_basic.hml
 
+      - name: Download tiny test model
+        if: steps.install-hemlock.outputs.hemlock_available == 'true'
+        run: |
+          chmod +x scripts/download_test_model.sh
+          ./scripts/download_test_model.sh
+
+      - name: Run model tests
+        if: steps.install-hemlock.outputs.hemlock_available == 'true'
+        run: |
+          export LD_LIBRARY_PATH="$(pwd)/lib:$LD_LIBRARY_PATH"
+          export DYLD_LIBRARY_PATH="$(pwd)/lib:$DYLD_LIBRARY_PATH"
+          hemlock test_model.hml models/tinyllama-stories-15m-q4_0.gguf
+
       - name: Validate Hemlock source files
         run: |
           echo "Checking Hemlock source files exist..."
@@ -97,8 +110,9 @@ jobs:
 
       - name: Check shell scripts
         run: |
-          # Check build.sh for syntax errors
+          # Check build.sh and helper scripts for syntax errors
           bash -n build.sh
+          bash -n scripts/download_test_model.sh
           echo "Shell scripts OK"
 
       - name: Check for common issues