cohere-transcribe 0.1.1 → 0.1.2

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.
@@ -28,6 +28,7 @@ module Cohere
28
28
  ▁ <|startofcontext|> <|startoftranscript|> <|emo:undefined|>
29
29
  <|ar|> <|pnc|> <|noitn|> <|notimestamp|> <|nodiarize|> <|endoftext|>
30
30
  ].freeze
31
+ MAX_VOCABULARY_SIZE = (1 << 32) - 1
31
32
  OUTPUT_TYPES = %i[f16 f32 bf16].freeze
32
33
  REQUIRED_ARTIFACT_FILENAMES = %w[config.json tokenizer.json].freeze
33
34
  WEIGHT_ARTIFACT_FILENAMES = %w[
@@ -291,7 +292,9 @@ module Cohere
291
292
 
292
293
  def load_vocabulary
293
294
  embedding = fetch_source_tensor("transf_decoder._embedding.token_embedding.weight")
294
- unless embedding.shape.length == 2 && embedding.shape[0].positive?
295
+ unless embedding.shape.length == 2 &&
296
+ embedding.shape.all? { |dimension| dimension.is_a?(Integer) && dimension.positive? } &&
297
+ embedding.shape[0] <= MAX_VOCABULARY_SIZE
295
298
  raise Error, "Tensor #{embedding.name.inspect} has an invalid vocabulary shape #{embedding.shape.inspect}"
296
299
  end
297
300
 
@@ -320,13 +323,35 @@ module Cohere
320
323
  )
321
324
  end
322
325
 
323
- missing = (0...expected_size).reject { |id| tokens_by_id.key?(id) }
324
- raise Error, "tokenizer.json vocabulary has missing token IDs: #{missing.first(8).inspect}" unless missing.empty?
326
+ ordered_ids = tokens_by_id.keys.sort
327
+ if ordered_ids.length != expected_size
328
+ missing = first_missing_vocabulary_ids(ordered_ids, expected_size, limit: 8)
329
+ raise Error, "tokenizer.json vocabulary has missing token IDs: #{missing.inspect}"
330
+ end
325
331
 
326
332
  missing_prompt = REQUIRED_PROMPT_TOKENS.reject { |token| ids_by_token.key?(token) }
327
333
  raise Error, "tokenizer.json is missing Cohere prompt tokens: #{missing_prompt.join(", ")}" unless missing_prompt.empty?
328
334
 
329
- (0...expected_size).map { |id| tokens_by_id.fetch(id) }
335
+ ordered_ids.map { |id| tokens_by_id.fetch(id) }
336
+ end
337
+
338
+ def first_missing_vocabulary_ids(ordered_ids, expected_size, limit:)
339
+ missing = []
340
+ expected_id = 0
341
+ ordered_ids.each do |id|
342
+ while expected_id < id && missing.length < limit
343
+ missing << expected_id
344
+ expected_id += 1
345
+ end
346
+ break if missing.length == limit
347
+
348
+ expected_id = id + 1
349
+ end
350
+ while expected_id < expected_size && missing.length < limit
351
+ missing << expected_id
352
+ expected_id += 1
353
+ end
354
+ missing
330
355
  end
331
356
 
332
357
  def add_vocabulary_entry!(tokens_by_id, ids_by_token, token, id, expected_size:)
@@ -282,7 +282,7 @@ module Cohere
282
282
  library = native_library || ASR::NativeLibrary.load
283
283
  results.ok("native Cohere ASR runtime: #{library.path}")
284
284
  report_native_device_capabilities(results, library)
285
- rescue StandardError => e
285
+ rescue LoadError, StandardError => e
286
286
  results.fail("native Cohere ASR runtime: #{e.class}: #{e.message}")
287
287
  end
288
288
 
@@ -347,7 +347,7 @@ module Cohere
347
347
  "word aligner: #{Alignment::ModelProvider::REPOSITORY}@#{Alignment::ModelProvider::REVISION}, " \
348
348
  "FP32 SHA-256 #{fp32.sha256}, bounded per-segment uniform fallback"
349
349
  )
350
- rescue StandardError => e
350
+ rescue LoadError, StandardError => e
351
351
  results.fail("word alignment: #{e.class}: #{e.message}")
352
352
  end
353
353
 
@@ -14,6 +14,7 @@ module Cohere
14
14
  # clients are reused without copying multi-gigabyte model weights.
15
15
  class Hub
16
16
  class Error < StandardError; end
17
+ class ConnectionError < Error; end
17
18
  class AuthenticationError < Error; end
18
19
  class NotFoundError < Error; end
19
20
 
@@ -22,12 +23,17 @@ module Cohere
22
23
 
23
24
  attr_reader :cache_dir, :endpoint
24
25
 
25
- def initialize(cache_dir: nil, endpoint: nil, token: nil)
26
+ def initialize(cache_dir: nil, endpoint: nil, token: nil, offline: nil)
26
27
  hf_home = ENV.fetch("HF_HOME", File.expand_path("~/.cache/huggingface"))
27
28
  @cache_dir = Pathname(cache_dir || ENV.fetch("HF_HUB_CACHE", File.join(hf_home, "hub"))).expand_path
28
29
  @endpoint = (endpoint || ENV.fetch("HF_ENDPOINT", DEFAULT_ENDPOINT)).sub(%r{/+\z}, "")
29
30
  @endpoint_uri = URI(@endpoint)
30
31
  @token = token || ENV["HF_TOKEN"] || cached_token(hf_home)
32
+ @offline = offline.nil? ? truthy_environment?(ENV.fetch("HF_HUB_OFFLINE", nil)) : !!offline
33
+ end
34
+
35
+ def offline?
36
+ @offline
31
37
  end
32
38
 
33
39
  def resolve_revision(repo_id, revision = nil, filename: "config.json")
@@ -36,13 +42,23 @@ module Cohere
36
42
  validate_revision!(requested)
37
43
  return requested.downcase if COMMIT_PATTERN.match?(requested)
38
44
 
39
- if (cached = cached_revision(repo_id, requested, filename: filename))
40
- return cached
45
+ cached = cached_revision(repo_id, requested, filename: filename)
46
+ if offline?
47
+ return cached if cached
48
+
49
+ raise Error,
50
+ "Hub offline mode has no cached #{filename} snapshot for #{repo_id.inspect} at #{requested.inspect}"
41
51
  end
42
52
 
43
53
  encoded_repo = repo_id.split("/").map { |part| URI.encode_www_form_component(part) }.join("/")
44
54
  encoded_revision = URI.encode_www_form_component(requested)
45
- response = request(URI("#{endpoint}/api/models/#{encoded_repo}/revision/#{encoded_revision}"))
55
+ begin
56
+ response = request(URI("#{endpoint}/api/models/#{encoded_repo}/revision/#{encoded_revision}"))
57
+ rescue ConnectionError
58
+ raise unless cached
59
+
60
+ return cached
61
+ end
46
62
  payload = JSON.parse(response.body)
47
63
  commit = payload["sha"]
48
64
  unless commit.is_a?(String) && COMMIT_PATTERN.match?(commit)
@@ -132,6 +148,7 @@ module Cohere
132
148
  private
133
149
 
134
150
  def request(uri, stream: nil, redirects: 0)
151
+ raise Error, "Hub offline mode prevents a request to #{uri}" if offline?
135
152
  raise Error, "Too many redirects while fetching #{uri}" if redirects > 8
136
153
 
137
154
  request = Net::HTTP::Get.new(uri)
@@ -185,7 +202,7 @@ module Cohere
185
202
  raise_http_error!(response, uri)
186
203
  rescue Timeout::Error, SocketError, SystemCallError, EOFError,
187
204
  Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, OpenSSL::SSL::SSLError => e
188
- raise Error, "Cannot reach Hugging Face Hub at #{uri}: #{e.class}: #{e.message}"
205
+ raise ConnectionError, "Cannot reach Hugging Face Hub at #{uri}: #{e.class}: #{e.message}"
189
206
  end
190
207
 
191
208
  def response_content_length(response, uri)
@@ -382,6 +399,10 @@ module Cohere
382
399
  nil
383
400
  end
384
401
 
402
+ def truthy_environment?(value)
403
+ %w[1 ON YES TRUE].include?(value.to_s.upcase)
404
+ end
405
+
385
406
  def cache_repo_name(repo_id)
386
407
  "models--#{repo_id.gsub("/", "--")}"
387
408
  end
@@ -34,12 +34,12 @@ module Cohere
34
34
  raise TranscriptionInputError, "#{label} must not be empty" if PythonText.blank?(text)
35
35
 
36
36
  text.dup.freeze
37
- rescue TypeError, ArgumentError, SystemCallError => e
37
+ rescue EncodingError, TypeError, ArgumentError, SystemCallError => e
38
38
  raise e if e.is_a?(TranscriptionInputError)
39
39
 
40
40
  raise TranscriptionInputError, "#{label} is not a valid text path: #{e.message}"
41
41
  end.freeze
42
- rescue TypeError, ArgumentError, SystemCallError => e
42
+ rescue EncodingError, TypeError, ArgumentError, SystemCallError => e
43
43
  raise e if e.is_a?(TranscriptionInputError)
44
44
 
45
45
  raise TranscriptionInputError, "audio is not a valid ordered path sequence: #{e.message}"
@@ -86,7 +86,7 @@ module Cohere
86
86
  paths.sort_by { |path| path.to_s.downcase(:fold) }.map do |path|
87
87
  [strict_realpath(path), path.relative_path_from(source)]
88
88
  end
89
- rescue SystemCallError, ArgumentError => e
89
+ rescue EncodingError, SystemCallError, ArgumentError => e
90
90
  raise TranscriptionInputError, "Cannot inspect input #{source}: #{e.message}"
91
91
  end
92
92
  private_class_method :directory_candidates
@@ -95,7 +95,7 @@ module Cohere
95
95
  Pathname(value).expand_path.realpath
96
96
  rescue Errno::ENOENT
97
97
  raise TranscriptionInputError, "Input does not exist: #{Pathname(value).expand_path}"
98
- rescue SystemCallError, ArgumentError => e
98
+ rescue EncodingError, SystemCallError, ArgumentError => e
99
99
  detail = e.is_a?(Errno::ELOOP) ? "Symlink loop" : e.message
100
100
  raise TranscriptionInputError, "Invalid input path #{value.inspect}: #{detail}"
101
101
  end
@@ -10,6 +10,10 @@ module Cohere
10
10
  LEADING_WHITESPACE = /\A#{WHITESPACE_CLASS}+/u
11
11
  TRAILING_WHITESPACE = /#{WHITESPACE_CLASS}+\z/u
12
12
  WHITESPACE_RUN = /#{WHITESPACE_CLASS}+/u
13
+ BINARY_WHITESPACE_CLASS = "[\x09-\x0D\x1C-\x20]"
14
+ BINARY_LEADING_WHITESPACE = /\A#{BINARY_WHITESPACE_CLASS}+/n
15
+ BINARY_TRAILING_WHITESPACE = /#{BINARY_WHITESPACE_CLASS}+\z/n
16
+ BINARY_WHITESPACE_RUN = /#{BINARY_WHITESPACE_CLASS}+/n
13
17
  # Python 3.12 uses Unicode 15.0. Ruby 4 recognizes a small Unicode 16
14
18
  # set whose newly assigned case/compatibility mappings would otherwise
15
19
  # make the same input normalize differently from the reference package.
@@ -21,12 +25,15 @@ module Cohere
21
25
  *(0x1CCD6..0x1CCF9)
22
26
  ].to_h { |codepoint| [codepoint, true] }.freeze
23
27
  private_constant :WHITESPACE_CLASS, :LEADING_WHITESPACE, :TRAILING_WHITESPACE, :WHITESPACE_RUN,
28
+ :BINARY_WHITESPACE_CLASS, :BINARY_LEADING_WHITESPACE, :BINARY_TRAILING_WHITESPACE,
29
+ :BINARY_WHITESPACE_RUN,
24
30
  :UNICODE_15_NORMALIZATION_BOUNDARIES
25
31
 
26
32
  module_function
27
33
 
28
34
  def strip(value)
29
- value.sub(LEADING_WHITESPACE, "").sub(TRAILING_WHITESPACE, "")
35
+ leading, trailing, = whitespace_patterns(value)
36
+ value.sub(leading, "").sub(trailing, "")
30
37
  end
31
38
 
32
39
  def blank?(value)
@@ -35,11 +42,13 @@ module Cohere
35
42
 
36
43
  def split(value)
37
44
  stripped = strip(value)
38
- stripped.empty? ? [] : stripped.split(WHITESPACE_RUN)
45
+ _, _, run = whitespace_patterns(stripped)
46
+ stripped.empty? ? [] : stripped.split(run)
39
47
  end
40
48
 
41
49
  def collapse(value)
42
- strip(value.gsub(WHITESPACE_RUN, " "))
50
+ _, _, run = whitespace_patterns(value)
51
+ strip(value.gsub(run, " "))
43
52
  end
44
53
 
45
54
  def nfkc_lower(value)
@@ -64,6 +73,15 @@ module Cohere
64
73
  def whitespace_class
65
74
  WHITESPACE_CLASS
66
75
  end
76
+
77
+ def whitespace_patterns(value)
78
+ if value.encoding == Encoding::ASCII_8BIT
79
+ [BINARY_LEADING_WHITESPACE, BINARY_TRAILING_WHITESPACE, BINARY_WHITESPACE_RUN]
80
+ else
81
+ [LEADING_WHITESPACE, TRAILING_WHITESPACE, WHITESPACE_RUN]
82
+ end
83
+ end
84
+ private_class_method :whitespace_patterns
67
85
  end
68
86
  private_constant :PythonText
69
87
  end
@@ -25,11 +25,14 @@ module Cohere
25
25
  ZIP_CENTRAL_LIMIT = 256 * 1024 * 1024
26
26
  ZIP_COMPRESSED_OVERHEAD = 1024 * 1024
27
27
  ZIP_INFLATE_CHUNK = 16 * 1024
28
+ STORAGE_CRC_CHUNK_BYTES = 4 * 1024 * 1024
28
29
  ZIP_EOCD = 0x0605_4b50
29
30
  ZIP64_EOCD = 0x0606_4b50
30
31
  ZIP64_LOCATOR = 0x0706_4b50
31
32
  ZIP_CENTRAL = 0x0201_4b50
32
33
  ZIP_LOCAL = 0x0403_4b50
34
+ ZIP64_UINT16_MARKER = 0xffff
35
+ ZIP64_UINT32_MARKER = 0xffff_ffff
33
36
 
34
37
  STORAGE_DTYPES = {
35
38
  "FloatStorage" => ["F32", 4],
@@ -41,6 +44,7 @@ module Cohere
41
44
 
42
45
  Global = Data.define(:module_name, :name)
43
46
  StorageRef = Data.define(:dtype, :key, :elements, :base_offset)
47
+ StoragePayload = Data.define(:data_offset, :size, :crc32)
44
48
  TensorSpec = Data.define(:storage, :storage_offset, :shape, :stride)
45
49
  Entry = Data.define(
46
50
  :name, :compression_method, :flags, :crc32, :compressed_size, :size,
@@ -176,10 +180,12 @@ module Cohere
176
180
  raise Error, "Multi-disk PyTorch ZIP archives are not supported"
177
181
  end
178
182
 
179
- if [disk_entries, total_entries].include?(0xffff) ||
180
- [central_size, central_offset].include?(0xffff_ffff)
181
- total_entries, central_size, central_offset = zip64_directory(eocd_offset)
182
- end
183
+ zip64_marker = disk_entries == ZIP64_UINT16_MARKER ||
184
+ total_entries == ZIP64_UINT16_MARKER ||
185
+ central_size == ZIP64_UINT32_MARKER ||
186
+ central_offset == ZIP64_UINT32_MARKER
187
+ total_entries, central_size, central_offset = zip64_directory(eocd_offset) if
188
+ zip64_marker && zip64_locator_present?(eocd_offset)
183
189
  if total_entries > ZIP_ENTRY_LIMIT || central_size > ZIP_CENTRAL_LIMIT ||
184
190
  central_size > size || central_offset > size - central_size
185
191
  raise Error, "PyTorch archive #{path} has invalid central-directory bounds"
@@ -215,6 +221,12 @@ module Cohere
215
221
  [total_entries, central_size, central_offset]
216
222
  end
217
223
 
224
+ def zip64_locator_present?(eocd_offset)
225
+ return false if eocd_offset < 20
226
+
227
+ read_range(eocd_offset - 20, 4).unpack1("V") == ZIP64_LOCATOR
228
+ end
229
+
218
230
  def parse_central_directory(bytes, expected_entries:, archive_size:, data_limit:)
219
231
  offset = 0
220
232
  result = {}
@@ -301,9 +313,8 @@ module Cohere
301
313
  cursor += length
302
314
  end
303
315
 
304
- required = [size, compressed_size, local_offset, disk].count do |value|
305
- [0xffff_ffff, 0xffff].include?(value)
306
- end
316
+ required = [size, compressed_size, local_offset].count(ZIP64_UINT32_MARKER)
317
+ required += 1 if disk == ZIP64_UINT16_MARKER
307
318
  return [size, compressed_size, local_offset, disk] if required.zero?
308
319
  raise Error, "PyTorch archive #{path} lacks required ZIP64 size data" unless values
309
320
 
@@ -322,10 +333,10 @@ module Cohere
322
333
  cursor += 4
323
334
  value
324
335
  end
325
- size = take_qword.call if size == 0xffff_ffff
326
- compressed_size = take_qword.call if compressed_size == 0xffff_ffff
327
- local_offset = take_qword.call if local_offset == 0xffff_ffff
328
- disk = take_dword.call if disk == 0xffff
336
+ size = take_qword.call if size == ZIP64_UINT32_MARKER
337
+ compressed_size = take_qword.call if compressed_size == ZIP64_UINT32_MARKER
338
+ local_offset = take_qword.call if local_offset == ZIP64_UINT32_MARKER
339
+ disk = take_dword.call if disk == ZIP64_UINT16_MARKER
329
340
  [size, compressed_size, local_offset, disk]
330
341
  end
331
342
 
@@ -771,6 +782,9 @@ module Cohere
771
782
  def initialize(path)
772
783
  @path = Pathname(path).expand_path
773
784
  @storages = {}
785
+ @storage_payloads = {}
786
+ @verified_storage_payloads = {}
787
+ @storage_verification_mutex = Mutex.new
774
788
  @tensors = read_checkpoint.freeze
775
789
  end
776
790
 
@@ -798,6 +812,8 @@ module Cohere
798
812
  end
799
813
  raise ArgumentError, "chunk_bytes must be positive" unless chunk_bytes.positive?
800
814
 
815
+ verify_storage_payload!(tensor)
816
+
801
817
  source_width = Safetensors::DTYPE_BYTES.fetch(tensor.dtype)
802
818
  elements_per_chunk = [chunk_bytes / source_width, 1].max
803
819
  bytes_per_chunk = elements_per_chunk * source_width
@@ -874,6 +890,12 @@ module Cohere
874
890
  raise Error, "PyTorch storage #{storage.key.inspect} has #{entry.size} bytes; expected #{expected}"
875
891
  end
876
892
 
893
+ @storage_payloads[storage.key] ||= StoragePayload.new(
894
+ data_offset: entry.data_offset,
895
+ size: entry.size,
896
+ crc32: entry.crc32
897
+ )
898
+
877
899
  entry.data_offset
878
900
  end
879
901
  end
@@ -1013,6 +1035,41 @@ module Cohere
1013
1035
  true
1014
1036
  end
1015
1037
 
1038
+ def verify_storage_payload!(tensor)
1039
+ payload = @storage_payloads[tensor.storage_key]
1040
+ return unless payload
1041
+
1042
+ @storage_verification_mutex.synchronize do
1043
+ return if @verified_storage_payloads.key?(tensor.storage_key)
1044
+
1045
+ verify_storage_crc!(tensor.storage_key, payload)
1046
+ @verified_storage_payloads[tensor.storage_key] = true
1047
+ end
1048
+ end
1049
+
1050
+ def verify_storage_crc!(storage_key, payload)
1051
+ crc32 = 0
1052
+ remaining = payload.size
1053
+ File.open(path, "rb") do |source|
1054
+ source.seek(payload.data_offset)
1055
+ while remaining.positive?
1056
+ requested = [remaining, STORAGE_CRC_CHUNK_BYTES].min
1057
+ chunk = source.read(requested)
1058
+ if chunk.nil? || chunk.bytesize != requested
1059
+ raise Error, "Unexpected end of #{path} while checking storage #{storage_key.inspect}"
1060
+ end
1061
+
1062
+ crc32 = Zlib.crc32(chunk, crc32)
1063
+ remaining -= requested
1064
+ end
1065
+ end
1066
+ return if crc32 == payload.crc32
1067
+
1068
+ raise Error, "PyTorch tensor storage #{storage_key.inspect} failed its CRC check"
1069
+ rescue Errno::ENOENT, Errno::EACCES => e
1070
+ raise Error, "Cannot read PyTorch checkpoint #{path}: #{e.message}"
1071
+ end
1072
+
1016
1073
  # General strided tensors occur in legitimate torch state dictionaries
1017
1074
  # (notably transposed projection weights). A read-only file mapping
1018
1075
  # keeps heap usage bounded while preserving logical row-major order.
@@ -249,12 +249,22 @@ module Cohere
249
249
  nil
250
250
  end
251
251
  memory_byte_limit = [(resolved_options.audio_memory_gb * (1024**3)).to_i, 1].max
252
+ estimate_bytes = if @decoder.respond_to?(:estimate_decoded_bytes)
253
+ lambda do |item|
254
+ @decoder.estimate_decoded_bytes(
255
+ item.entry.path,
256
+ backend: resolved_options.audio_backend,
257
+ sample_rate: SAMPLE_RATE
258
+ )
259
+ end
260
+ end
252
261
  pipeline = Preparation::Pipeline.new(
253
262
  preparation_items,
254
263
  memory_byte_limit: memory_byte_limit,
255
264
  requested_workers: resolved_options.preprocess_workers,
256
265
  enabled: resolved_options.pipeline_preparation,
257
- worker_limit: vad_file_concurrency_limit(resolved_options)
266
+ worker_limit: vad_file_concurrency_limit(resolved_options),
267
+ estimate_bytes: estimate_bytes
258
268
  ) do |item, decoded_byte_limit, worker_slot|
259
269
  prepare_entry(
260
270
  item,
@@ -10,6 +10,7 @@ module Cohere
10
10
  module Preparation
11
11
  MAX_PIPELINE_GROUP_BYTES = 512 * (1024**2)
12
12
  MAX_GROUP_JOBS = 128
13
+ ESTIMATE_HEADROOM = 0.05
13
14
 
14
15
  # The result of decode and VAD preparation. Timings are accumulated by
15
16
  # the engine on the consuming thread, keeping statistics deterministic.
@@ -29,13 +30,17 @@ module Cohere
29
30
  # Bounded ordered preparation with at most one group ahead of ASR.
30
31
  #
31
32
  # A pipelined group receives at most half of the configured decoded-PCM
32
- # budget (and never more than 512 MiB). Jobs inside it divide that cap,
33
- # so the current and one in-flight next group cannot together exceed the
34
- # configured PCM budget. Native decoder implementation transients are
35
- # outside this retained-PCM accounting, as they are in the Python path.
33
+ # budget (and never more than 512 MiB). Estimated decode sizes determine
34
+ # group membership and per-file ceilings. A file that cannot fit that cap
35
+ # is prepared alone with the full configured ceiling and without overlap.
36
+ # Native decoder implementation transients are outside this retained-PCM
37
+ # accounting, as they are in the Python path.
36
38
  class Pipeline
37
39
  include Enumerable
38
40
 
41
+ Group = Data.define(:items, :limits, :exclusive)
42
+ private_constant :Group
43
+
39
44
  class WorkerPool
40
45
  STOP = Object.new.freeze
41
46
 
@@ -60,10 +65,10 @@ module Cohere
60
65
  end
61
66
  end
62
67
 
63
- def prepare(group, per_file_limit)
64
- responses = group.each_with_index.map do |item, slot|
68
+ def prepare(group)
69
+ responses = group.items.each_with_index.map do |item, slot|
65
70
  response = Queue.new
66
- @queues.fetch(slot) << [item, per_file_limit, response]
71
+ @queues.fetch(slot) << [item, group.limits.fetch(slot), response]
67
72
  response
68
73
  end
69
74
  responses.map do |response|
@@ -88,7 +93,8 @@ module Cohere
88
93
 
89
94
  attr_reader :effective_workers, :group_byte_limit, :memory_byte_limit, :wait_seconds
90
95
 
91
- def initialize(items, memory_byte_limit:, requested_workers:, enabled:, worker_limit: nil, &prepare)
96
+ def initialize(items, memory_byte_limit:, requested_workers:, enabled:, worker_limit: nil,
97
+ estimate_bytes: nil, &prepare)
92
98
  raise ArgumentError, "prepare block is required" unless prepare
93
99
 
94
100
  @items = items.to_a.freeze
@@ -96,6 +102,7 @@ module Cohere
96
102
  raise ArgumentError, "memory_byte_limit must be positive" unless @memory_byte_limit.positive?
97
103
 
98
104
  @prepare = prepare
105
+ @estimate_bytes = estimate_bytes
99
106
  @wait_seconds = 0.0
100
107
  @enabled = enabled && @items.length > 1
101
108
  @worker_limit = worker_limit.nil? ? nil : Integer(worker_limit)
@@ -122,14 +129,16 @@ module Cohere
122
129
  return
123
130
  end
124
131
 
125
- groups = @items.each_slice(@effective_workers).to_a
132
+ groups = build_groups
126
133
  pool = WorkerPool.new(@effective_workers, &@prepare)
127
134
  pending = submit(groups.first, 0, pool)
128
- groups.each_with_index do |_group, group_index|
135
+ groups.each_with_index do |group, group_index|
129
136
  prepared = resolve(pending)
130
137
  pending = nil
131
- pending = submit(groups.fetch(group_index + 1), group_index + 1, pool) if group_index + 1 < groups.length
138
+ next_group = groups[group_index + 1]
139
+ pending = submit(next_group, group_index + 1, pool) if next_group && !group.exclusive && !next_group.exclusive
132
140
  consume(prepared, &block)
141
+ pending ||= submit(next_group, group_index + 1, pool) if next_group
133
142
  end
134
143
  completed = true
135
144
  ensure
@@ -179,8 +188,67 @@ module Cohere
179
188
  end
180
189
 
181
190
  def prepare_group(group, pool)
182
- per_file_limit = [@group_byte_limit / group.length, 1].max
183
- pool.prepare(group, per_file_limit)
191
+ pool.prepare(group)
192
+ end
193
+
194
+ def build_groups
195
+ return equal_groups unless @estimate_bytes
196
+
197
+ groups = []
198
+ items = []
199
+ estimates = []
200
+ @items.each do |item|
201
+ estimate = estimated_reservation(item)
202
+ if estimate.nil? || estimate > @group_byte_limit
203
+ groups << bounded_group(items, estimates) unless items.empty?
204
+ groups << Group.new(items: [item].freeze, limits: [@memory_byte_limit].freeze, exclusive: true)
205
+ items = []
206
+ estimates = []
207
+ next
208
+ end
209
+
210
+ if items.length >= @effective_workers || estimates.sum + estimate > @group_byte_limit
211
+ groups << bounded_group(items, estimates)
212
+ items = []
213
+ estimates = []
214
+ end
215
+ items << item
216
+ estimates << estimate
217
+ end
218
+ groups << bounded_group(items, estimates) unless items.empty?
219
+ groups
220
+ end
221
+
222
+ def equal_groups
223
+ @items.each_slice(@effective_workers).map do |items|
224
+ limit = [@group_byte_limit / items.length, 1].max
225
+ Group.new(items: items.freeze, limits: Array.new(items.length, limit).freeze, exclusive: false)
226
+ end
227
+ end
228
+
229
+ def estimated_reservation(item)
230
+ value = @estimate_bytes.call(item)
231
+ return if value.nil?
232
+
233
+ bytes = Integer(value)
234
+ return unless bytes.positive?
235
+
236
+ [(bytes * (1.0 + ESTIMATE_HEADROOM)).ceil, 1].max
237
+ rescue ArgumentError, TypeError, SystemCallError
238
+ nil
239
+ end
240
+
241
+ def bounded_group(items, estimates)
242
+ limits = estimates.dup
243
+ remaining = @group_byte_limit - limits.sum
244
+ if remaining.positive?
245
+ weight = limits.sum
246
+ extras = limits.map { |limit| (remaining * limit).div(weight) }
247
+ leftover = remaining - extras.sum
248
+ extras[limits.each_index.max_by { |index| limits.fetch(index) }] += leftover
249
+ limits = limits.each_index.map { |index| limits.fetch(index) + extras.fetch(index) }
250
+ end
251
+ Group.new(items: items.freeze, limits: limits.freeze, exclusive: false)
184
252
  end
185
253
 
186
254
  def consume(prepared, &block)