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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RPEFT
6
+ # @api private
7
+ module Directory
8
+ module_function
9
+
10
+ # Moves source to dest. An existing destination is replaced only when it contains one of the marker files,
11
+ # i.e. when it looks like a previously saved artifact of the same kind.
12
+ # Anything else is refused so that a mistyped path never deletes unrelated data.
13
+ def move(source, dest, replace_markers: [])
14
+ # A Model made from a Hugging Face Hub id has no local directory to move.
15
+ raise Error, "nothing to save: #{source} is not a local directory" unless File.directory?(source)
16
+
17
+ dest = File.expand_path(dest)
18
+ return dest if canonical(source) == canonical(dest)
19
+ if inside?(source, dest) || inside?(dest, source)
20
+ raise ConfigurationError, "cannot save #{File.expand_path(source)} to #{dest}: one directory contains the other"
21
+ end
22
+
23
+ if File.exist?(dest)
24
+ unless replaceable?(dest, replace_markers)
25
+ raise ConfigurationError, "destination already exists: #{dest} (RPEFT only replaces " \
26
+ 'directories it previously saved; remove it or pick another path)'
27
+ end
28
+
29
+ FileUtils.rm_rf(dest)
30
+ end
31
+
32
+ FileUtils.mkdir_p(File.dirname(dest))
33
+ FileUtils.mv(source, dest)
34
+
35
+ dest
36
+ end
37
+
38
+ # Makes dir ready to be written into, under the same rule as move: it must be missing, empty,
39
+ # or an artifact RPEFT saved there before.
40
+ def prepare(dir, markers:)
41
+ dir = File.expand_path(dir)
42
+ if File.exist?(dir) && !(File.directory?(dir) && (Dir.empty?(dir) || replaceable?(dir, markers)))
43
+ raise ConfigurationError, "output directory is not empty: #{dir} (RPEFT only writes into empty directories " \
44
+ 'or ones it previously wrote; remove it or pick another path)'
45
+ end
46
+
47
+ FileUtils.mkdir_p(dir)
48
+ dir
49
+ end
50
+
51
+ def replaceable?(dest, markers)
52
+ markers.any? { |marker| File.exist?(File.join(dest, marker)) }
53
+ end
54
+
55
+ # Resolves symlinks in the part of the path that exists, so that two spellings of the same location
56
+ # (such as /var and /private/var on macOS) compare equal even when the last components do not exist yet.
57
+ def canonical(path)
58
+ path = File.expand_path(path)
59
+ return File.realpath(path) if File.exist?(path)
60
+
61
+ parent = File.dirname(path)
62
+ parent == path ? path : File.join(canonical(parent), File.basename(path))
63
+ end
64
+
65
+ def inside?(path, dir)
66
+ canonical(path).start_with?("#{canonical(dir).chomp(File::SEPARATOR)}#{File::SEPARATOR}")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RPEFT
4
+ class Error < StandardError; end
5
+
6
+ # Python, PyCall, or a required Python package is missing or unusable.
7
+ class DependencyError < Error; end
8
+
9
+ # Invalid options were passed to the Ruby API.
10
+ class ConfigurationError < Error; end
11
+
12
+ # The dataset file is missing or not in the expected JSONL format.
13
+ class DatasetError < Error; end
14
+
15
+ # The base model or an adapter directory could not be loaded.
16
+ class ModelLoadError < Error; end
17
+
18
+ # LoRA training failed. The original Python error is available via #cause.
19
+ class TrainingError < Error; end
20
+
21
+ # Merging the adapter into the base model failed.
22
+ class MergeError < Error; end
23
+
24
+ # Text generation failed.
25
+ class GenerationError < Error; end
26
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RPEFT
4
+ # @api private
5
+ #
6
+ # Shared request building for text generation. Model#generate runs
7
+ # a standalone model; Adapter#generate adds adapter_path so the backend
8
+ # applies the adapter on top of the base model without merging.
9
+ #
10
+ # Exactly one of prompt: and messages: is given. With messages: the backend renders
11
+ # the transcript with the tokenizer's chat template before generating.
12
+ module Generation
13
+ module_function
14
+
15
+ def run(model:, prompt: nil, messages: nil, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil, adapter_path: nil)
16
+ # Everything is validated before Device.resolve, which reaches the backend:
17
+ # a bad argument should not cost a Python round trip.
18
+ validate(max_new_tokens, temperature, top_p, seed)
19
+ input = input_for(prompt, messages)
20
+
21
+ request = {
22
+ 'model' => model,
23
+ 'max_new_tokens' => max_new_tokens,
24
+ 'device' => Device.resolve(device).to_s,
25
+ 'trust_remote_code' => false
26
+ }.merge(input)
27
+ request['adapter_path'] = adapter_path if adapter_path
28
+ request['temperature'] = temperature if temperature
29
+ request['top_p'] = top_p if top_p
30
+ request['seed'] = seed if seed
31
+
32
+ RPEFT.backend.generate(request)['completion']
33
+ end
34
+
35
+ def input_for(prompt, messages)
36
+ unless messages
37
+ return {'prompt' => prompt} if prompt.is_a?(String) && !prompt.empty?
38
+
39
+ raise ConfigurationError, "prompt must be a non-empty String, got #{prompt.inspect}"
40
+ end
41
+ return {'messages' => messages} if Messages.valid?(messages)
42
+
43
+ raise ConfigurationError, "messages must be a non-empty Array of Hashes with a role of system, user, or assistant and String content, got #{messages.inspect}"
44
+ end
45
+
46
+ def validate(max_new_tokens, temperature, top_p, seed)
47
+ if !max_new_tokens.is_a?(Integer) || !max_new_tokens.positive?
48
+ raise ConfigurationError, "max_new_tokens must be a positive Integer, got #{max_new_tokens.inspect}"
49
+ end
50
+
51
+ if temperature && !(temperature.is_a?(Numeric) && temperature.positive?)
52
+ raise ConfigurationError, "temperature must be a positive number, got #{temperature.inspect}"
53
+ end
54
+
55
+ if top_p && !(top_p.is_a?(Numeric) && top_p.positive? && top_p <= 1)
56
+ raise ConfigurationError, "top_p must be within (0.0, 1.0], got #{top_p.inspect}"
57
+ end
58
+ return if seed.nil? || seed.is_a?(Integer)
59
+
60
+ raise ConfigurationError, "seed must be an Integer, got #{seed.inspect}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ module RPEFT
8
+ module LoRA
9
+ # A trained LoRA adapter directory: the learned B A matrices plus the configuration
10
+ # and metadata needed to merge them back into the base model.
11
+ class Adapter
12
+ METADATA_FILE = 'rpeft_metadata.json'
13
+ PEFT_CONFIG_FILE = 'adapter_config.json'
14
+ # Where the backend keeps checkpoints while training; see python/rpeft_backend/_training.py.
15
+ TRAINER_DIR = '.trainer'
16
+ REPLACE_MARKERS = [METADATA_FILE, PEFT_CONFIG_FILE].freeze
17
+
18
+ attr_reader :path, :base_model, :rank, :alpha, :dropout, :target_modules, :metadata
19
+
20
+ class << self
21
+ def load(path, base_model: nil)
22
+ dir = File.expand_path(Arguments.path(path, 'path'))
23
+ base_model = Arguments.model(base_model, 'base_model') if base_model
24
+ metadata_file = File.join(dir, METADATA_FILE)
25
+ peft_file = File.join(dir, PEFT_CONFIG_FILE)
26
+
27
+ if File.file?(metadata_file)
28
+ from_metadata(dir, parse_json(metadata_file), base_model)
29
+ elsif File.file?(peft_file)
30
+ from_peft_config(dir, parse_json(peft_file), base_model)
31
+ else
32
+ raise ModelLoadError, "#{dir} is not a LoRA adapter directory (no #{METADATA_FILE} or #{PEFT_CONFIG_FILE} found)"
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def from_metadata(dir, metadata, base_model)
39
+ lora = metadata['lora'] || {}
40
+ new(
41
+ path: dir,
42
+ base_model: base_model || metadata['base_model'],
43
+ rank: lora['rank'],
44
+ alpha: lora['alpha'],
45
+ dropout: lora['dropout'],
46
+ target_modules: lora['target_modules'],
47
+ metadata: metadata
48
+ )
49
+ end
50
+
51
+ # Fallback for adapter directories produced by plain PEFT,
52
+ # which have no rpeft_metadata.json.
53
+ def from_peft_config(dir, config, base_model)
54
+ new(
55
+ path: dir,
56
+ base_model: base_model || config['base_model_name_or_path'],
57
+ rank: config['r'],
58
+ alpha: config['lora_alpha'],
59
+ dropout: config['lora_dropout'],
60
+ target_modules: config['target_modules'],
61
+ metadata: {}
62
+ )
63
+ end
64
+
65
+ def parse_json(file)
66
+ JSON.parse(File.read(file))
67
+ rescue JSON::ParserError => e
68
+ raise ModelLoadError, "failed to parse #{file}: #{e.message}"
69
+ end
70
+ end
71
+
72
+ def initialize(path:, base_model:, rank: nil, alpha: nil, dropout: nil, target_modules: nil, metadata: {})
73
+ @path = File.expand_path(path)
74
+ @base_model = base_model
75
+ @rank = rank
76
+ @alpha = alpha
77
+ @dropout = dropout
78
+ @target_modules = target_modules
79
+ @metadata = metadata.freeze
80
+ end
81
+
82
+ def save(dest)
83
+ @path = Directory.move(@path, Arguments.path(dest, 'dest'), replace_markers: REPLACE_MARKERS)
84
+ self
85
+ end
86
+
87
+ # Merges the learned B A update into the base model weights
88
+ # (W = W0 + B A) and returns the resulting standalone Model.
89
+ def merge(output_dir: nil)
90
+ unless base_model
91
+ raise MergeError, 'base model is unknown; pass base_model: to Adapter.load ' \
92
+ '(or --model to the CLI)'
93
+ end
94
+
95
+ output_dir = Arguments.path(output_dir, 'output_dir') if output_dir
96
+ empty_dir = output_dir && File.directory?(output_dir) && Dir.empty?(output_dir)
97
+ fresh = output_dir.nil? || !File.exist?(output_dir) || empty_dir
98
+ merged_dir = output_dir ? Directory.prepare(output_dir, markers: Model::REPLACE_MARKERS) : Dir.mktmpdir('rpeft-merged-')
99
+ result = RPEFT.backend.merge_lora(
100
+ 'adapter_path' => path,
101
+ 'output_dir' => merged_dir,
102
+ 'base_model' => base_model
103
+ )
104
+
105
+ model_dir = result['model_path'] || merged_dir
106
+ merged_base_model = result['base_model'] || base_model
107
+ FileUtils.mkdir_p(model_dir)
108
+ Model.write_metadata(model_dir, base_model: merged_base_model)
109
+
110
+ Model.new(path: model_dir, base_model: merged_base_model)
111
+ rescue StandardError
112
+ # A partial merge has weights but no marker, which would make Directory.prepare refuse the retry.
113
+ # Only what this merge created is removed; a model merged there before keeps its marker.
114
+ discard_partial_merge(merged_dir, keep_dir: empty_dir) if merged_dir && fresh
115
+ raise
116
+ end
117
+
118
+ # Generates with the adapter applied on top of the base model, without merging.
119
+ # Useful for verifying an adapter before producing a merged model.
120
+ def generate(prompt, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
121
+ require_base_model
122
+
123
+ Generation.run(
124
+ model: base_model,
125
+ prompt: prompt,
126
+ adapter_path: path,
127
+ max_new_tokens: max_new_tokens,
128
+ device: device,
129
+ temperature: temperature,
130
+ top_p: top_p,
131
+ seed: seed
132
+ )
133
+ end
134
+
135
+ # Continues a chat transcript with the adapter applied on top of the base model.
136
+ def chat(messages, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
137
+ require_base_model
138
+
139
+ Generation.run(
140
+ model: base_model,
141
+ messages: messages,
142
+ adapter_path: path,
143
+ max_new_tokens: max_new_tokens,
144
+ device: device,
145
+ temperature: temperature,
146
+ top_p: top_p,
147
+ seed: seed
148
+ )
149
+ end
150
+
151
+ private
152
+
153
+ # An adapter is a delta against specific base weights, so it cannot be applied without knowing which model it belongs to.
154
+ def require_base_model
155
+ return if base_model
156
+
157
+ raise GenerationError, 'base model is unknown; pass base_model: to Adapter.load (or --model to the CLI)'
158
+ end
159
+
160
+ def discard_partial_merge(dir, keep_dir:)
161
+ if keep_dir
162
+ Dir.children(dir).each do |child|
163
+ FileUtils.rm_rf(File.join(dir, child))
164
+ end
165
+ else
166
+ FileUtils.rm_rf(dir)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
data/lib/rpeft/lora.rb ADDED
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'tmpdir'
7
+
8
+ module RPEFT
9
+ # LoRA fine-tuning. Keeps the base model weights W0 frozen and learns
10
+ # a low-rank update delta W ~= B A of rank r:
11
+ #
12
+ # W = W0 + B A
13
+ #
14
+ # The `rank:` option is exactly that r.
15
+ module LoRA
16
+ POSITIVE_INTEGER_OPTIONS = %i(rank epochs batch_size max_length).freeze
17
+ POSITIVE_NUMBER_OPTIONS = %i(alpha learning_rate).freeze
18
+
19
+ class << self
20
+ def train(model:, dataset:, eval_dataset: nil, rank: 8, alpha: 16, dropout: 0.05, target_modules: nil, epochs: 3, batch_size: 4, learning_rate: 2e-4, max_length: 512, output_dir: nil, device: :auto, checkpoints: false, resume: false)
21
+ options = {
22
+ rank: rank,
23
+ alpha: alpha,
24
+ dropout: dropout,
25
+ target_modules: target_modules,
26
+ epochs: epochs,
27
+ batch_size: batch_size,
28
+ learning_rate: learning_rate,
29
+ max_length: max_length,
30
+ checkpoints: checkpoints,
31
+ resume: resume
32
+ }
33
+ model = Arguments.model(model, 'model')
34
+ output_dir = Arguments.path(output_dir, 'output_dir') if output_dir
35
+ validate_options(options)
36
+ validate_resume(resume, output_dir)
37
+ training_dataset = Dataset.coerce(dataset)
38
+ evaluation_dataset = coerce_eval_dataset(eval_dataset, training_dataset)
39
+ adapter_dir = prepare_output_dir(output_dir)
40
+ resolved_device = Device.resolve(device)
41
+
42
+ options[:eval_dataset] = evaluation_dataset
43
+ result = RPEFT.backend.train_lora(
44
+ request_payload(model, training_dataset, adapter_dir, resolved_device, options)
45
+ )
46
+
47
+ build_adapter(
48
+ adapter_dir: adapter_dir,
49
+ model: model,
50
+ dataset_name: training_dataset.name,
51
+ device: resolved_device,
52
+ options: options,
53
+ result: result
54
+ )
55
+ rescue StandardError
56
+ # A temporary directory is never returned to the caller, so a failed run leaves nothing anyone could
57
+ # resume from. An explicit output_dir is kept for resume: true.
58
+ FileUtils.rm_rf(adapter_dir) if adapter_dir && output_dir.nil?
59
+ raise
60
+ ensure
61
+ # Datasets given as Dataset objects belong to the caller and may be reused for another run.
62
+ training_dataset.discard if training_dataset && !training_dataset.equal?(dataset)
63
+ evaluation_dataset.discard if evaluation_dataset && !evaluation_dataset.equal?(eval_dataset)
64
+ end
65
+
66
+ private
67
+
68
+ def validate_options(options)
69
+ POSITIVE_INTEGER_OPTIONS.each do |key|
70
+ value = options[key]
71
+ if !value.is_a?(Integer) || !value.positive?
72
+ raise ConfigurationError, "#{key} must be a positive Integer, got #{value.inspect}"
73
+ end
74
+ end
75
+ POSITIVE_NUMBER_OPTIONS.each do |key|
76
+ value = options[key]
77
+ if !value.is_a?(Numeric) || !value.positive?
78
+ raise ConfigurationError, "#{key} must be a positive number, got #{value.inspect}"
79
+ end
80
+ end
81
+ validate_dropout(options[:dropout])
82
+ validate_target_modules(options[:target_modules])
83
+ end
84
+
85
+ def validate_dropout(dropout)
86
+ return if dropout.is_a?(Numeric) && dropout >= 0 && dropout < 1
87
+
88
+ raise ConfigurationError, "dropout must be a number within 0.0...1.0, got #{dropout.inspect}"
89
+ end
90
+
91
+ def validate_target_modules(target_modules)
92
+ return if target_modules.nil?
93
+ return if target_modules.is_a?(Array) && !target_modules.empty? && target_modules.all?(String)
94
+
95
+ raise ConfigurationError, "target_modules must be nil (auto) or a non-empty Array of Strings, got #{target_modules.inspect}"
96
+ end
97
+
98
+ # Without an explicit output_dir every run gets a fresh temporary directory, so there would never be a checkpoint of an earlier run to resume from.
99
+ def validate_resume(resume, output_dir)
100
+ return if !resume || !output_dir.nil?
101
+
102
+ raise ConfigurationError, 'resume: true requires an explicit output_dir: to find the previous checkpoints'
103
+ end
104
+
105
+ # The backend tokenizes both datasets the same way, so mixing formats would silently mis-render the evaluation set.
106
+ def coerce_eval_dataset(eval_dataset, training_dataset)
107
+ return nil unless eval_dataset
108
+
109
+ dataset = Dataset.coerce(eval_dataset)
110
+ return dataset if dataset.backend_format == training_dataset.backend_format
111
+
112
+ # Raised before train assigns the dataset, so its ensure clause cannot discard it.
113
+ dataset.discard unless dataset.equal?(eval_dataset)
114
+ raise ConfigurationError, "eval_dataset format (#{dataset.format}) must match dataset format (#{training_dataset.format})"
115
+ end
116
+
117
+ def prepare_output_dir(output_dir)
118
+ return Dir.mktmpdir('rpeft-adapter-') unless output_dir
119
+
120
+ # TRAINER_DIR marks a run that stopped before saving the adapter, which resume: true picks up.
121
+ Directory.prepare(output_dir, markers: [*Adapter::REPLACE_MARKERS, Adapter::TRAINER_DIR])
122
+ end
123
+
124
+ def request_payload(model, dataset, adapter_dir, device, options)
125
+ payload = {
126
+ 'base_model' => model,
127
+ 'dataset_path' => dataset.path,
128
+ 'dataset_format' => dataset.backend_format,
129
+ 'output_dir' => adapter_dir,
130
+ 'text_field' => 'text',
131
+ 'device' => device.to_s,
132
+ 'max_length' => options[:max_length],
133
+ 'epochs' => options[:epochs],
134
+ 'batch_size' => options[:batch_size],
135
+ 'learning_rate' => options[:learning_rate],
136
+ 'logging_steps' => 5,
137
+ 'seed' => 42,
138
+ 'trust_remote_code' => false,
139
+ 'save_strategy' => options[:checkpoints] ? 'epoch' : 'no',
140
+ 'resume_from_checkpoint' => options[:resume],
141
+ 'lora' => {
142
+ 'r' => options[:rank],
143
+ 'alpha' => options[:alpha],
144
+ 'dropout' => options[:dropout],
145
+ 'target_modules' => options[:target_modules]
146
+ }
147
+ }
148
+ payload['eval_dataset_path'] = options[:eval_dataset].path if options[:eval_dataset]
149
+
150
+ payload
151
+ end
152
+
153
+ def build_adapter(adapter_dir:, model:, dataset_name:, device:, options:, result:)
154
+ target_modules = result.dig('effective_config', 'lora', 'target_modules') || options[:target_modules]
155
+ metadata = build_metadata(
156
+ model: model,
157
+ dataset_name: dataset_name,
158
+ device: device,
159
+ options: options,
160
+ target_modules: target_modules,
161
+ result: result
162
+ )
163
+ File.write(File.join(adapter_dir, Adapter::METADATA_FILE), "#{JSON.pretty_generate(metadata)}\n")
164
+
165
+ Adapter.new(
166
+ path: adapter_dir,
167
+ base_model: model,
168
+ rank: options[:rank],
169
+ alpha: options[:alpha],
170
+ dropout: options[:dropout],
171
+ target_modules: target_modules,
172
+ metadata: metadata
173
+ )
174
+ end
175
+
176
+ def build_metadata(model:, dataset_name:, device:, options:, target_modules:, result:)
177
+ {
178
+ 'schema_version' => 1,
179
+ 'rpeft_version' => VERSION,
180
+ 'kind' => 'lora_adapter',
181
+ 'base_model' => model,
182
+ 'created_at' => Time.now.iso8601,
183
+ 'lora' => {
184
+ 'rank' => options[:rank],
185
+ 'alpha' => options[:alpha],
186
+ 'dropout' => options[:dropout],
187
+ 'target_modules' => target_modules
188
+ },
189
+ 'training' => {
190
+ 'dataset' => dataset_name,
191
+ 'epochs' => options[:epochs],
192
+ 'batch_size' => options[:batch_size],
193
+ 'learning_rate' => options[:learning_rate],
194
+ 'max_length' => options[:max_length],
195
+ 'device' => result['device'] || device.to_s,
196
+ 'train_loss' => result['final_loss'],
197
+ 'eval_loss' => result['eval_loss'],
198
+ 'duration_seconds' => result['train_runtime_seconds']
199
+ }
200
+ }
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RPEFT
4
+ # @api private
5
+ #
6
+ # Shape check for a chat transcript, shared by the dataset reader and the chat API.
7
+ # Only the predicate is shared: each caller raises the error that fits its context.
8
+ # String and Symbol keys are both accepted, since transcripts read from JSONL have
9
+ # String keys while ones written in Ruby naturally have Symbol keys.
10
+ module Messages
11
+ # Chat templates write the role into the prompt as is, and some drop turns whose role they do not know,
12
+ # so only the roles every template understands are accepted.
13
+ ROLES = %w(system user assistant).freeze
14
+
15
+ module_function
16
+
17
+ def valid?(messages)
18
+ return false if !messages.is_a?(Array) || messages.empty?
19
+
20
+ messages.all? { |message|
21
+ next false unless message.is_a?(Hash)
22
+
23
+ role = message['role'] || message[:role]
24
+ content = message['content'] || message[:content]
25
+
26
+ ROLES.include?(role) && content.is_a?(String)
27
+ }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module RPEFT
7
+ # A full model: either a local directory (typically the result of merging a LoRA adapter)
8
+ # or a Hugging Face Hub model id.
9
+ class Model
10
+ METADATA_FILE = 'rpeft_metadata.json'
11
+ # Not config.json: it is a common name outside model directories, and a mistyped save destination
12
+ # holding one would otherwise be deleted.
13
+ REPLACE_MARKERS = [METADATA_FILE].freeze
14
+
15
+ attr_reader :path, :base_model
16
+
17
+ # Marks dir as a merged model saved by RPEFT, which is what lets a later #save replace it.
18
+ def self.write_metadata(dir, base_model:)
19
+ metadata = {
20
+ 'schema_version' => 1,
21
+ 'rpeft_version' => VERSION,
22
+ 'kind' => 'merged_model',
23
+ 'base_model' => base_model,
24
+ 'created_at' => Time.now.iso8601
25
+ }
26
+ File.write(File.join(dir, METADATA_FILE), "#{JSON.pretty_generate(metadata)}\n")
27
+ end
28
+
29
+ def initialize(path:, base_model: nil)
30
+ path = Arguments.path(path, 'path')
31
+ # A Hub id such as "Qwen/Qwen2.5-Coder-0.5B" must stay verbatim; only real local directories are expanded.
32
+ @path = File.directory?(path) ? File.expand_path(path) : path
33
+ @base_model = base_model
34
+ end
35
+
36
+ def save(dest)
37
+ @path = Directory.move(@path, Arguments.path(dest, 'dest'), replace_markers: REPLACE_MARKERS)
38
+ self
39
+ end
40
+
41
+ # Generates a completion for the prompt. Decoding is greedy (and thus deterministic for a given model and prompt)
42
+ # unless temperature or top_p turns sampling on.
43
+ def generate(prompt, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
44
+ Generation.run(
45
+ model: path,
46
+ prompt: prompt,
47
+ max_new_tokens: max_new_tokens,
48
+ device: device,
49
+ temperature: temperature,
50
+ top_p: top_p,
51
+ seed: seed
52
+ )
53
+ end
54
+
55
+ # Continues a chat transcript and returns the assistant's reply. The transcript is
56
+ # rendered with the model's own chat template, which is what a model fine-tuned on
57
+ # chat-format data expects to see.
58
+ def chat(messages, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
59
+ Generation.run(
60
+ model: path,
61
+ messages: messages,
62
+ max_new_tokens: max_new_tokens,
63
+ device: device,
64
+ temperature: temperature,
65
+ top_p: top_p,
66
+ seed: seed
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RPEFT
4
+ VERSION = '0.1.0'
5
+ end
data/lib/rpeft.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rpeft/version'
4
+ require_relative 'rpeft/errors'
5
+ require_relative 'rpeft/arguments'
6
+ require_relative 'rpeft/device'
7
+ require_relative 'rpeft/directory'
8
+ require_relative 'rpeft/dataset'
9
+ require_relative 'rpeft/messages'
10
+ require_relative 'rpeft/generation'
11
+ require_relative 'rpeft/model'
12
+ require_relative 'rpeft/lora'
13
+ require_relative 'rpeft/lora/adapter'
14
+ require_relative 'rpeft/backend'
15
+ require_relative 'rpeft/diagnosis'
16
+
17
+ module RPEFT
18
+ DEVICES = %i(cuda mps cpu).freeze
19
+
20
+ class << self
21
+ # The backend seam. Assign a fake in tests; assign nil to restore the default PyCall-based backend.
22
+ attr_writer :backend
23
+
24
+ def backend
25
+ @backend ||= Backend.default
26
+ end
27
+
28
+ # The compute device that training will use, preferring CUDA over MPS over CPU.
29
+ def device
30
+ backend.detect_device({})['device'].to_sym
31
+ end
32
+
33
+ def check
34
+ Diagnosis.new(backend: backend).report
35
+ end
36
+ end
37
+ end