rpeft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 74527e43eb7c5d06b33fa44b6bc80fc57d7af6b1039248b0dd889f64cd6a7a5b
4
+ data.tar.gz: 497bff43821c0b8b25059de10321ac93d61ca3eacf3428d40e2de8db37b86b37
5
+ SHA512:
6
+ metadata.gz: 5b1d932dc735db4961c1c6532139c750f423e1ca83404bca9f7a7e041a680e42037be253ec75198bcd5cef501da1174d6673ef1c8ec4a3c0f28de8d8b85be78e
7
+ data.tar.gz: 6d0bca19e47a92cbc975ee0fb73c06f5e4d337b3d6164371f43fcbd2ab7b1065cdd0b1a4730068e895312435c6f2d6bc91a64b62ff45f87a54fa16ca2d3fe32a
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Koichi ITO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # RPEFT
2
+
3
+ RPEFT brings LoRA fine-tuning of large language models to Ruby. It provides an API and CLI that model the LoRA lifecycle,
4
+ while the heavy lifting is done by the Python ecosystem (PyTorch, Hugging Face Transformers, PEFT, Datasets, Accelerate) through PyCall.
5
+ It is an independent project, not affiliated with Hugging Face.
6
+
7
+ ## Features
8
+
9
+ - LoRA fine-tuning from JSONL files or Ruby Enumerables, in plain text, instruction, and chat formats
10
+ - Merging an adapter into its base model to produce a standalone model
11
+ - Generation and chat with a merged model, a Hugging Face model id, or an adapter on top of its base model
12
+ - Apple Silicon (MPS) as a first-class target, with CUDA and CPU auto-detected
13
+ - Validation loss, per-epoch checkpoints, and resuming
14
+ - A CLI for training, merging, and generation, and `rpeft doctor` to check the Python environment
15
+
16
+ ## The model behind the API
17
+
18
+ LoRA keeps the base model weights $W_0$ frozen and learns a low-rank update of rank $r$:
19
+
20
+ ```math
21
+ \begin{aligned}
22
+ W &= W_0 + \Delta W \\
23
+ \Delta W &\approx BA
24
+ \end{aligned}
25
+ ```
26
+
27
+ $A$ and $B$ are small matrices that together typically hold well under 1% of the model's parameters. Training produces them; merging adds $BA$ back into $W_0$.
28
+ The API is the same shape:
29
+
30
+ ```mermaid
31
+ flowchart LR
32
+ base[Base Model] -- train --> adapter[LoRA Adapter] -. merge .-> merged[Merged Model]
33
+ ```
34
+
35
+ Merging is optional: an adapter can also generate on top of its base model as is.
36
+
37
+ The [`rank:` option](#training-options) of `RPEFT::LoRA.train` is exactly the $r$ above.
38
+
39
+ ## Requirements
40
+
41
+ - Ruby 3.0+
42
+ - Python 3.10+ with a shared libpython (static Python builds such as pyenv's default do not work; tested with Python 3.12)
43
+ - Ruby and Python built for the same CPU architecture. On Apple Silicon, an x86_64 Ruby running under Rosetta cannot load an arm64 libpython;
44
+ a symbol error such as `_Py_NoneStruct` from `rpeft doctor` usually means this mismatch
45
+ - The Python packages `torch` (2.6 or later), `transformers` (4.56 or later), `peft`, `datasets`, and `accelerate`
46
+
47
+ ## Installation
48
+
49
+ Add the gem to your Gemfile:
50
+
51
+ ```console
52
+ $ bundle add rpeft
53
+ ```
54
+
55
+ Set up a Python environment. With venv:
56
+
57
+ ```console
58
+ $ python3 -m venv .venv
59
+ $ .venv/bin/pip install torch transformers peft datasets accelerate
60
+ ```
61
+
62
+ Or with uv:
63
+
64
+ ```console
65
+ $ uv venv
66
+ $ uv pip install torch transformers peft datasets accelerate
67
+ ```
68
+
69
+ Note for pyenv users: PyCall needs a shared libpython, so install the interpreter with:
70
+
71
+ ```console
72
+ $ PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.12
73
+ ```
74
+
75
+ When a `.venv` directory sits next to the Gemfile, RPEFT uses it automatically under `bundle exec` or `bundler/setup`. Set the `PYTHON` environment variable to override the interpreter,
76
+ then check the environment:
77
+
78
+ ```console
79
+ $ bundle exec rpeft doctor
80
+ ```
81
+
82
+ `rpeft doctor` reports the Python interpreter, each required package, and the available devices, and tells you what is missing or outdated.
83
+
84
+ ## Usage
85
+
86
+ ```ruby
87
+ require 'rpeft'
88
+
89
+ adapter = RPEFT::LoRA.train(
90
+ model: 'Qwen/Qwen2.5-Coder-0.5B',
91
+ dataset: 'ruby_answers.jsonl',
92
+ rank: 8, # The LoRA rank r
93
+ alpha: 16,
94
+ dropout: 0.05,
95
+ epochs: 2
96
+ )
97
+ adapter.save('./ruby-lora')
98
+
99
+ model = adapter.merge # W = W0 + B A
100
+ model.save('./ruby-model')
101
+ ```
102
+
103
+ With these settings, training updates about 540 thousand of the model's 494 million parameters (0.11%).
104
+
105
+ Passing `eval_dataset:` holds a second dataset out of training and evaluates against it after every epoch.
106
+ The final validation loss lands in the adapter metadata, which is how you tell learning apart from memorizing:
107
+
108
+ ```ruby
109
+ adapter = RPEFT::LoRA.train(model: ..., dataset: 'train.jsonl', eval_dataset: 'validation.jsonl')
110
+ adapter.metadata.dig('training', 'train_loss') # => 0.74
111
+ adapter.metadata.dig('training', 'eval_loss') # => 0.91
112
+ ```
113
+
114
+ `RPEFT::LoRA.train` returns a `RPEFT::LoRA::Adapter` that knows its `path`, `base_model`, `rank`, `alpha`, `dropout`, `target_modules`, and training `metadata`.
115
+ `adapter.merge` returns a `RPEFT::Model` with `path` and `base_model`. `save` moves the artifact to the given path; a destination produced by a previous `save` is replaced,
116
+ so scripts can be re-run, while any other existing path is refused.
117
+
118
+ ### Generation
119
+
120
+ `RPEFT::Model#generate` produces a completion for a prompt. It uses greedy decoding, so the output is deterministic for a given model and prompt.
121
+ The model can be a merged model directory or a Hugging Face model id, which makes a before and after comparison one call each:
122
+
123
+ ```ruby
124
+ prompt = "User: How do I find the minimum value in an array?\nAssistant:"
125
+
126
+ base = RPEFT::Model.new(path: 'Qwen/Qwen2.5-Coder-0.5B')
127
+ base.generate(prompt, max_new_tokens: 64) # A step-by-step explanation from the base model
128
+
129
+ model = adapter.merge
130
+ model.generate(prompt, max_new_tokens: 64) # Ruby code from the fine-tuned model
131
+ ```
132
+
133
+ A model name is looked up as a local directory first, as Transformers does, so run RPEFT only in directories you trust.
134
+
135
+ Passing `temperature:` or `top_p:` switches decoding from greedy to sampling, which makes the model produce
136
+ different wording on each call. `seed:` makes sampled output reproducible again:
137
+
138
+ ```ruby
139
+ model.generate(prompt, temperature: 0.7, top_p: 0.9, seed: 42)
140
+ ```
141
+
142
+ `RPEFT::LoRA::Adapter#generate` runs the same generation with the adapter applied on top of its base model, without merging.
143
+ Since an adapter is a few MB against roughly a gigabyte for a merged model, this is the cheap way to try an adapter,
144
+ including one received from someone else:
145
+
146
+ ```ruby
147
+ adapter = RPEFT::LoRA::Adapter.load('./ruby-lora')
148
+ adapter.generate(prompt, max_new_tokens: 64) # the same output, no merged model needed
149
+ ```
150
+
151
+ The base model is read from the metadata recorded in the adapter directory, so it does not have to be given again.
152
+ When the base model is a local directory, its absolute path is recorded there and in `adapter_config.json`,
153
+ since a relative path would point elsewhere once the adapter is loaded from another directory.
154
+ Check that path before sharing such an adapter; `Adapter.load` takes `base_model:` to load it against a model somewhere else.
155
+
156
+ `RPEFT::Model#chat` takes a transcript instead of a prompt and renders it with the model's own chat template, including the marker
157
+ that tells the model where its turn begins. Pass the whole transcript on every call to continue a conversation; roles are limited to `system`, `user`, and `assistant`:
158
+
159
+ ```ruby
160
+ chat_model = RPEFT::Model.new(path: 'Qwen/Qwen2.5-Coder-0.5B-Instruct')
161
+
162
+ chat_model.chat([
163
+ {role: 'user', content: 'How do I reverse an array in Ruby?'},
164
+ {role: 'assistant', content: '[1, 2, 3].reverse'},
165
+ {role: 'user', content: 'And the last element?'}
166
+ ])
167
+ # => "The last element of the array is `3`."
168
+ ```
169
+
170
+ `RPEFT::LoRA::Adapter#chat` does the same with the adapter applied on top of its base model.
171
+
172
+ Reach for `#chat` when the model understands a chat template: an instruction-tuned model, or one fine-tuned on chat-format data.
173
+ A base model is a different matter. `Qwen/Qwen2.5-Coder-0.5B`, for instance, ships a chat template but was never instruction-tuned,
174
+ so prompting it in chat form produces poor output while `#generate` with a plain prompt works well.
175
+ The rule behind this also applies to choosing a dataset format: the prompt at inference should look like the text the model was trained on.
176
+
177
+ ### Training options
178
+
179
+ | Option | Default | Meaning |
180
+ | ----------------- | ------- | --------------------------------------------------------------------------------------- |
181
+ | `rank:` | `8` | LoRA rank $r$, the rank of the low-rank update $BA$ |
182
+ | `alpha:` | `16` | LoRA scaling factor (`lora_alpha` in PEFT) |
183
+ | `dropout:` | `0.05` | LoRA dropout (`lora_dropout` in PEFT) |
184
+ | `target_modules:` | `nil` | Modules to adapt, e.g. `%w(q_proj v_proj)`. `nil` uses PEFT's per-architecture defaults |
185
+ | `epochs:` | `3` | Training epochs |
186
+ | `batch_size:` | `4` | Per-device batch size |
187
+ | `learning_rate:` | `2e-4` | Learning rate |
188
+ | `max_length:` | `512` | Max token length per example |
189
+ | `eval_dataset:` | `nil` | Held-out dataset, in the same format as `dataset:`. Enables per-epoch evaluation |
190
+ | `checkpoints:` | `false` | Checkpoint every epoch so an interrupted run can resume; removed once training ends |
191
+ | `resume:` | `false` | Resume from the last checkpoint in `output_dir`, which must be given explicitly |
192
+ | `output_dir:` | `nil` | Adapter output directory: new, empty, or an earlier output. `nil` uses a temporary one |
193
+ | `device:` | `:auto` | `:auto`, `:cuda`, `:mps`, or `:cpu` |
194
+
195
+ ### Dataset
196
+
197
+ Each line of a JSONL dataset is a JSON object in one of three shapes. The simplest has a `text` key:
198
+
199
+ ```json
200
+ {"text":"User: How do I reverse an array?\nAssistant: [1, 2, 3].reverse\n# => [3, 2, 1]"}
201
+ {"text":"User: How do I get the length of a string?\nAssistant: 'hello'.length\n# => 5"}
202
+ ```
203
+
204
+ Lines with `instruction`/`output` keys (and an optional `input`) are also accepted and rendered into a text prompt automatically:
205
+
206
+ ```json
207
+ {"instruction":"Explain what Array#map does","output":"It returns a new array with the block applied to each element."}
208
+ ```
209
+
210
+ Chat transcripts are accepted as a `messages` array. These are rendered with the tokenizer's own chat template,
211
+ so the conversation is formatted the way the model expects. Roles are limited to `system`, `user`, and `assistant`,
212
+ the ones every chat template understands:
213
+
214
+ ```json
215
+ {"messages":[{"role":"user","content":"How do I reverse an array?"},{"role":"assistant","content":"[1, 2, 3].reverse"}]}
216
+ ```
217
+
218
+ `dataset:` also accepts any Enumerable of record Hashes in the same three shapes, so data built in Ruby needs no file of its own:
219
+
220
+ ```ruby
221
+ RPEFT::LoRA.train(
222
+ model: 'Qwen/Qwen2.5-Coder-0.5B',
223
+ dataset: Article.published.map { |article| {text: article.body} }
224
+ )
225
+ ```
226
+
227
+ A useful fine-tuning dataset teaches the model something it does not already do. The `examples/ruby_answers.jsonl` dataset in the repository teaches the model to answer programming questions with Ruby code only:
228
+ the base model replies with an explanation, often followed by Python code, while the fine-tuned model replies with a bare snippet and a `# =>` comment. The effect is visible at a glance in any spoken language,
229
+ because the difference is the shape of the output rather than its wording.
230
+
231
+ ### Devices and Apple Silicon
232
+
233
+ The device is auto-detected with CUDA > MPS > CPU priority:
234
+
235
+ ```ruby
236
+ RPEFT.device # => :mps
237
+ ```
238
+
239
+ Apple Silicon Macs (M1 and later) are a first-class target: small models such as `Qwen/Qwen2.5-Coder-0.5B` fine-tune on MPS.
240
+ Training uses float32 on MPS (half precision there is numerically fragile), bfloat16 on capable CUDA GPUs, and `PYTORCH_ENABLE_MPS_FALLBACK=1` is set automatically for
241
+ operators MPS does not implement. Merging always runs on CPU, which is the memory-safest place for it.
242
+
243
+ ### Environment check
244
+
245
+ ```ruby
246
+ RPEFT.check # => a Hash describing Python, packages, and devices
247
+ ```
248
+
249
+ ```console
250
+ $ rpeft doctor
251
+ Ruby: 4.0.6 (rpeft 0.1.0)
252
+ Python: 3.12.13 (/path/to/.venv/bin/python)
253
+ torch: OK 2.13.0
254
+ transformers: OK 5.15.0
255
+ peft: OK 0.20.0
256
+ datasets: OK 5.0.1
257
+ accelerate: OK 1.14.0
258
+ Device: mps (CUDA: no, MPS: yes)
259
+ Status: OK
260
+ ```
261
+
262
+ ### Errors
263
+
264
+ Python exceptions never surface raw. They are translated into `RPEFT::DependencyError`, `RPEFT::ModelLoadError`, `RPEFT::DatasetError`, `RPEFT::TrainingError`,
265
+ `RPEFT::MergeError`, or `RPEFT::GenerationError`, and the original Python error (with its traceback) stays reachable via `error.cause`.
266
+ Invalid arguments are rejected in Ruby with `RPEFT::ConfigurationError` before the backend is called.
267
+
268
+ ## CLI
269
+
270
+ The CLI covers the core of the lifecycle: training, merging, and generation from a prompt.
271
+ Validation datasets, checkpoints, and chat are available from the Ruby API only.
272
+
273
+ ```console
274
+ $ rpeft lora train \
275
+ --model Qwen/Qwen2.5-Coder-0.5B \
276
+ --dataset ruby_answers.jsonl \
277
+ --rank 8 \
278
+ --epochs 2 \
279
+ --output ruby-lora
280
+ ```
281
+
282
+ ```console
283
+ $ rpeft lora merge \
284
+ --adapter ruby-lora \
285
+ --output ruby-model
286
+ ```
287
+
288
+ `lora merge` reads the base model from the metadata recorded in the adapter directory; pass `--model` to override it (for example, for adapters produced by plain PEFT).
289
+
290
+ ```console
291
+ $ rpeft generate \
292
+ --model ruby-model \
293
+ --prompt $'User: How do I find the minimum value in an array?\nAssistant:'
294
+ ```
295
+
296
+ `--model` also accepts a Hugging Face model id, so the same command shows the base model's output for comparison.
297
+ `--temperature`, `--top-p`, and `--seed` are the CLI equivalents of the sampling options above.
298
+ Passing `--adapter` instead generates with the adapter applied on top of its base model, without merging:
299
+
300
+ ```console
301
+ $ rpeft generate \
302
+ --adapter ruby-lora \
303
+ --prompt $'User: How do I find the minimum value in an array?\nAssistant:'
304
+ ```
305
+
306
+ `rpeft doctor` and `rpeft version` are also available.
307
+
308
+ ## Examples
309
+
310
+ A runnable end-to-end example, which trains an adapter, merges it, and compares the model's answers before and after,
311
+ is available in [`examples`](https://github.com/koic/rpeft/tree/master/examples).
312
+
313
+ ## Architecture
314
+
315
+ The Ruby side is a thin layer. Each call goes through PyCall to the RPEFT backend, a Python package bundled with the gem and not a public API,
316
+ which drives the Python libraries that do the actual work on the selected device:
317
+
318
+ ```mermaid
319
+ flowchart LR
320
+ subgraph rubyside [Ruby]
321
+ ruby[Ruby application] --> rpeft[RPEFT] --> pycall[PyCall]
322
+ cli[RPEFT CLI] --> rpeft
323
+ end
324
+ subgraph pythonside [Python]
325
+ backend[RPEFT backend]
326
+ subgraph hf [Model libraries]
327
+ direction TB
328
+ peft[PEFT] --> transformers[Transformers] --> accelerate[Accelerate]
329
+ end
330
+ backend --> datasets[Datasets] & hf & torch[PyTorch]
331
+ hf --> torch
332
+ end
333
+ subgraph devices [Device]
334
+ device[CUDA / MPS / CPU]
335
+ end
336
+ pycall --> backend
337
+ torch --> device
338
+ ```
339
+
340
+ RPEFT CLI, RPEFT, and the RPEFT backend ship in the rpeft gem.
341
+ Arrows show what each part uses at run time, not package dependencies.
342
+
343
+ RPEFT does not reimplement Transformers or LoRA in Ruby, and it does not hide what fine-tuning is. It hides Python plumbing, not concepts.
344
+
345
+ For the same reason, adapters are saved and loaded by PEFT itself. An adapter directory is a standard PEFT adapter (`adapter_config.json` and `adapter_model.safetensors`) with
346
+ RPEFT's own metadata in `rpeft_metadata.json` alongside, so adapters trained with RPEFT load in Python with PEFT's `PeftModel.from_pretrained`, and `RPEFT::LoRA::Adapter.load` accepts
347
+ adapters produced by plain PEFT.
348
+
349
+ ## Process model and limitations
350
+
351
+ RPEFT is designed for CLI, Rake tasks, batch jobs, and offline experiments. It is not designed to run inside web application workers:
352
+
353
+ - PyCall embeds a single Python interpreter in the Ruby process. Backend calls hold both the Python GIL and Ruby's GVL, so every Ruby thread in the process stalls until the call returns.
354
+ - Do not fork the process after the backend has been initialized (Spring, Puma workers, and so on). The backend loads lazily, so forking before the first call is safe.
355
+ - Training keeps no checkpoints unless `checkpoints: true` is passed, so by default a crash during training loses that run's progress.
356
+
357
+ ## Name
358
+
359
+ RPEFT stands for Ruby and PEFT, Parameter-Efficient Fine-Tuning: training a small number of added parameters
360
+ instead of the whole model. Of the PEFT methods, RPEFT covers LoRA, through Hugging Face's PEFT library.
361
+
362
+ ## License
363
+
364
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/rpeft ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift("#{__dir__}/../lib")
5
+
6
+ require 'rpeft/cli'
7
+
8
+ exit RPEFT::CLI.run(ARGV)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module RPEFT
6
+ # @api private
7
+ #
8
+ # Argument checks shared by the public API, so that a wrong argument raises ConfigurationError
9
+ # in Ruby instead of an arbitrary error from deep inside Ruby or the backend.
10
+ module Arguments
11
+ module_function
12
+
13
+ # A local path or a Hugging Face Hub id, given as a String or a Pathname.
14
+ def path(value, name)
15
+ path = value.is_a?(Pathname) ? value.to_s : value
16
+ return path if path.is_a?(String) && !path.empty?
17
+
18
+ raise ConfigurationError, "#{name} must be a non-empty String or Pathname, got #{value.inspect}"
19
+ end
20
+
21
+ # A base model: a Hub id is kept verbatim, while a local directory is made absolute. The model is
22
+ # recorded in the adapter and loaded again later, possibly from another directory, where a relative
23
+ # path would name something else.
24
+ def model(value, name)
25
+ model = path(value, name)
26
+ File.directory?(model) ? File.expand_path(model) : model
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RPEFT
4
+ module Backend
5
+ # Maps the "[stage:xxx]" token that the bundled Python backend prefixes to its error messages
6
+ # onto the Ruby exception hierarchy.
7
+ # Parsing arbitrary Python exception class names would be brittle across library versions;
8
+ # parsing a sentinel we emit ourselves is deterministic.
9
+ module ErrorTranslation
10
+ STAGE_MAP = {
11
+ 'dependency' => DependencyError,
12
+ 'device' => DependencyError,
13
+ 'model_load' => ModelLoadError,
14
+ 'dataset' => DatasetError,
15
+ 'training' => TrainingError,
16
+ 'adapter_save' => TrainingError,
17
+ 'merge' => MergeError,
18
+ 'generation' => GenerationError
19
+ }.freeze
20
+
21
+ def self.translate(error, default:)
22
+ stage = error.message[/\[stage:(\w+)\]/, 1]
23
+ error_class = STAGE_MAP.fetch(stage, default)
24
+ error_class.new(error.message.sub(/\[stage:\w+\]\s*/, ''))
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RPEFT
6
+ module Backend
7
+ # Default backend. Bridges to the bundled python/rpeft_backend package through PyCall.
8
+ # Only JSON strings cross the PyCall boundary, so no Python object ever leaks into the Ruby API.
9
+ #
10
+ # PyCall and the Python interpreter are loaded lazily on the first call: `require "rpeft"`
11
+ # must keep working without a Python environment.
12
+ class Python
13
+ PYTHON_DIR = File.expand_path('../../../python', __dir__)
14
+
15
+ # The interpreter to use: the PYTHON environment variable when set, otherwise .venv/bin/python next to
16
+ # the Gemfile of the running bundle, otherwise nil (PyCall falls back to python3 on PATH).
17
+ # The bundle root rather than the current directory, so the choice does not depend on where a command
18
+ # happens to be run.
19
+ def self.default_python
20
+ python = ENV.fetch('PYTHON', nil)
21
+ return python if python && !python.empty?
22
+
23
+ gemfile = ENV.fetch('BUNDLE_GEMFILE', nil)
24
+ return nil if gemfile.nil? || gemfile.empty?
25
+
26
+ bundle_venv = File.join(File.dirname(File.expand_path(gemfile)), '.venv', 'bin', 'python')
27
+
28
+ File.executable?(bundle_venv) ? bundle_venv : nil
29
+ end
30
+
31
+ # When PYTHON points into a venv, returns its site-packages directory; nil otherwise.
32
+ # Embedded CPython reports the Ruby binary as sys.executable, so the interpreter never detects
33
+ # pyvenv.cfg on its own and the venv packages would be invisible without this.
34
+ def self.venv_site_packages(python_path)
35
+ return nil unless python_path
36
+
37
+ venv_root = File.dirname(File.dirname(File.expand_path(python_path)))
38
+ return nil unless File.file?(File.join(venv_root, 'pyvenv.cfg'))
39
+
40
+ Dir[File.join(venv_root, 'lib', 'python*', 'site-packages')].first
41
+ end
42
+
43
+ # PyCall never finalizes the embedded interpreter, so without this Python's atexit handlers never run.
44
+ # multiprocessing relies on them to unregister its semaphores (tqdm creates one for its progress bars),
45
+ # and resource_tracker would otherwise warn about leaked semaphores when the process exits.
46
+ def self.run_exit_handlers(atexit)
47
+ atexit._run_exitfuncs
48
+ rescue StandardError
49
+ # The Ruby process is already exiting; a failing Python handler must not turn that into an error.
50
+ nil
51
+ end
52
+
53
+ def initialize
54
+ @mutex = Mutex.new
55
+ end
56
+
57
+ def train_lora(config)
58
+ call(:train_lora, config, default_error: TrainingError)
59
+ end
60
+
61
+ def merge_lora(config)
62
+ call(:merge_lora, config, default_error: MergeError)
63
+ end
64
+
65
+ def generate(config)
66
+ call(:generate, config, default_error: GenerationError)
67
+ end
68
+
69
+ def detect_device(config = {})
70
+ call(:detect_device, config, default_error: DependencyError)
71
+ end
72
+
73
+ def diagnostics
74
+ call(:diagnostics, nil, default_error: DependencyError)
75
+ end
76
+
77
+ private
78
+
79
+ def call(name, config, default_error:)
80
+ facade = backend_module
81
+ result = if config
82
+ facade.public_send(name, JSON.generate(config))
83
+ else
84
+ facade.public_send(name)
85
+ end
86
+
87
+ JSON.parse(result.to_s)
88
+ rescue Error
89
+ raise
90
+ rescue StandardError => e
91
+ # Raising inside the rescue sets #cause to the original error,
92
+ # so the Python traceback stays reachable for debugging.
93
+ raise ErrorTranslation.translate(e, default: default_error)
94
+ end
95
+
96
+ def backend_module
97
+ @mutex.synchronize do
98
+ @backend_module ||= import_backend
99
+ end
100
+ end
101
+
102
+ def import_backend
103
+ # Both must be exported before the embedded Python imports torch/tokenizers,
104
+ # so they are set here rather than in Python.
105
+ ENV['TOKENIZERS_PARALLELISM'] ||= 'false'
106
+ ENV['PYTORCH_ENABLE_MPS_FALLBACK'] ||= '1'
107
+ python = self.class.default_python
108
+ ENV['PYTHON'] = python if python
109
+
110
+ configure_venv
111
+
112
+ require_pycall
113
+
114
+ import_backend_module
115
+ end
116
+
117
+ # PYTHONPATH is read at interpreter initialization, so this must run
118
+ # before PyCall first touches Python.
119
+ def configure_venv
120
+ site_packages = self.class.venv_site_packages(ENV.fetch('PYTHON', nil))
121
+ return unless site_packages
122
+
123
+ entries = ENV['PYTHONPATH'].to_s.split(File::PATH_SEPARATOR)
124
+ return if entries.include?(site_packages)
125
+
126
+ ENV['PYTHONPATH'] = [site_packages, *entries].join(File::PATH_SEPARATOR)
127
+ end
128
+
129
+ def require_pycall
130
+ require 'pycall'
131
+ rescue LoadError
132
+ raise DependencyError, 'PyCall is not installed. Add `gem "pycall"` to your Gemfile and run `bundle install`.'
133
+ end
134
+
135
+ # Referencing PyCall constants is only safe once require_pycall has succeeded,
136
+ # hence the separate method.
137
+ def import_backend_module
138
+ # Unconditional insert: this runs once per process (memoized in backend_module),
139
+ # and checking membership via sys.path.to_a triggers PyCall list-conversion warnings.
140
+ sys = PyCall.import_module('sys')
141
+ sys.path.insert(0, PYTHON_DIR)
142
+
143
+ backend = PyCall.import_module('rpeft_backend')
144
+ install_exit_hook(PyCall.import_module('atexit'))
145
+ backend
146
+ rescue PyCall::PyError => e
147
+ raise DependencyError, "failed to import the RPEFT Python backend: #{e.message}"
148
+ rescue PyCall::Error => e
149
+ raise DependencyError, "Python interpreter not found or PyCall failed to initialize (#{e.message}). " \
150
+ 'PyCall needs a Python with a shared libpython built for the same CPU ' \
151
+ "architecture as Ruby; static builds (pyenv's default) and an x86_64 Ruby " \
152
+ 'under Rosetta with an arm64 Python do not work. Set the PYTHON environment ' \
153
+ 'variable to a compatible interpreter, e.g. PYTHON=$(command -v python3.12).'
154
+ end
155
+
156
+ def install_exit_hook(atexit)
157
+ at_exit { self.class.run_exit_handlers(atexit) }
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'backend/error_translation'
4
+
5
+ module RPEFT
6
+ # Seam between the Ruby API and the Python backend.
7
+ #
8
+ # A backend implements four methods, each taking and returning plain Hashes with String keys:
9
+ #
10
+ # train_lora(config) -> Hash
11
+ # merge_lora(config) -> Hash
12
+ # generate(config) -> Hash
13
+ # detect_device(config) -> Hash
14
+ # diagnostics -> Hash
15
+ #
16
+ # Backend::Python is the default implementation. Tests inject a fake via RPEFT.backend=.
17
+ module Backend
18
+ def self.default
19
+ require_relative 'backend/python'
20
+
21
+ Python.new
22
+ end
23
+ end
24
+ end