Commit cee27f1

2026-09-08 14:14:56
fix: build the rust extension (magnus RubyMethod2 argument order, block->Proc)
Two bugs found now that clang is installed and rb_sys/bindgen can actually run this scaffold for the first time: 1. Model::generate was written as `fn generate(&self, ruby: &Ruby, ...)`. magnus's method! macro expects the &Ruby-aware variant's signature as `Fn(&Ruby, RbSelf, T1, T2) -> Res` -- &Ruby first, self second, not the other way around. The compiler's trait-bound error pointed at RubyMethod2 not being satisfied; fixed by taking `ruby: &Ruby, rb_self: &Model` in that order instead of a &self receiver. 2. magnus's Proc TryConvert impl converts a positional Value -- it does not implicitly bind a Ruby block the way Ruby's own &block does. Provider#fetch was calling `@model.generate(prompt) { |piece| ... }`, which Ruby saw as one argument (a block) against a two-argument native method, raising ArgumentError. Fixed by converting the block to an explicit Proc and passing it positionally. Verified: `bundle exec rake compile` succeeds from a fully clean target+tmp removal, and Provider#fetch actually streams thinking events per token then a final saying event, exercising the real Ruby<->Rust FFI boundary end to end (still against the stub generation loop -- real candle-transformers inference is unchanged, still a TODO).
1 parent a2b31cf
Changed files (2)
ext
elelem
candle
lib
elelem
ext/elelem/candle/src/lib.rs
@@ -31,8 +31,15 @@ impl Model {
     /// piece. This is the interesting FFI boundary: Rust owns the generation
     /// loop, Ruby supplies a block, and each token crosses the boundary as
     /// it's produced rather than buffering the whole response.
-    fn generate(&self, ruby: &Ruby, prompt: String, token_cb: Proc) -> Result<(), Error> {
-        if self.path.is_empty() {
+    ///
+    /// `&Ruby` must come before `self` here -- that's the signature
+    /// `RubyMethod2` (what `method!(_, 2)` resolves to for a 2-Ruby-arg
+    /// method that also wants the `&Ruby` handle) actually requires:
+    /// `Fn(&Ruby, RbSelf, T1, T2) -> Res`. A `&self` receiver puts `self`
+    /// first instead, which is why the original shape failed to satisfy
+    /// the trait bound at compile time.
+    fn generate(ruby: &Ruby, rb_self: &Model, prompt: String, token_cb: Proc) -> Result<(), Error> {
+        if rb_self.path.is_empty() {
             return Err(Error::new(
                 ruby.exception_runtime_error(),
                 "Elelem::Candle::Model: no model path configured",
@@ -45,7 +52,7 @@ impl Model {
         // exercised end to end before a real model is wired in.
         let stub = format!(
             "[stub:{} on {}] {}",
-            self.path, self.device, prompt
+            rb_self.path, rb_self.device, prompt
         );
         for word in stub.split_whitespace() {
             token_cb.call::<_, magnus::Value>((format!("{word} "),))?;
lib/elelem/candle/provider.rb
@@ -16,10 +16,14 @@ module Elelem
         prompt = messages.map { |m| "#{m[:role] || m["role"]}: #{m[:content] || m["content"]}" }.join("\n")
 
         content = +""
-        @model.generate(prompt) do |piece|
+        # Model#generate (Rust) takes token_cb as an explicit Proc argument,
+        # not an implicit Ruby block -- magnus's Proc TryConvert impl
+        # converts a positional argument, it doesn't bind an implicit block.
+        token_cb = proc do |piece|
           content << piece
           block&.call(type: "thinking", text: piece)
         end
+        @model.generate(prompt, token_cb)
 
         block&.call(type: "saying", text: content)
         []