ace-b36ts 0.13.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,656 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ require_relative "format_specs"
7
+ require_relative "format_codecs"
8
+
9
+ module Ace
10
+ module B36ts
11
+ module Atoms
12
+ # Encodes and decodes timestamps to/from variable-length Base36 compact IDs.
13
+ #
14
+ # Supports 7 format types with varying precision and length:
15
+ # - 2sec (6 chars, ~1.85s precision) - default
16
+ # - month (2 chars, month precision)
17
+ # - week (3 chars, week precision)
18
+ # - day (3 chars, day precision)
19
+ # - 40min (4 chars, 40-minute block precision)
20
+ # - 50ms (7 chars, ~50ms precision)
21
+ # - ms (8 chars, ~1.4ms precision)
22
+ #
23
+ # Compact format design (6 Base36 digits):
24
+ # - Positions 1-2: Month offset from year_zero (0-1295 = 108 years of months)
25
+ # - Position 3: Day of month (0-30 maps to 1-31 calendar days)
26
+ # - Position 4: 40-minute hour block (0-35 = 36 blocks covering 24 hours)
27
+ # - Positions 5-6: Precision within 40-minute window (~1.85s precision)
28
+ #
29
+ # Total capacity: 36^6 = 2,176,782,336 unique IDs over 108 years
30
+ #
31
+ # @example Encode a time
32
+ # CompactIdEncoder.encode(Time.utc(2025, 1, 6, 12, 30, 0))
33
+ # # => "i50jj3"
34
+ #
35
+ # @example Decode a compact ID
36
+ # CompactIdEncoder.decode("i50jj3")
37
+ # # => 2025-01-06 12:30:00 UTC (approximately)
38
+ #
39
+ # @example Validate format
40
+ # CompactIdEncoder.valid?("i50jj3") # => true
41
+ # CompactIdEncoder.valid?("invalid") # => false
42
+ #
43
+ class CompactIdEncoder
44
+ DEFAULT_YEAR_ZERO = 2000
45
+ DEFAULT_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
46
+ DEFAULT_ALPHABET_SET = DEFAULT_ALPHABET.chars.to_set.freeze
47
+
48
+ # 40-minute block duration (36 blocks per day = 24 * 60 / 40)
49
+ BLOCK_MINUTES = 40
50
+ BLOCK_SECONDS = BLOCK_MINUTES * 60 # 2400 seconds per block
51
+
52
+ # Precision values within a 40-minute block
53
+ # 36^2 = 1296 combinations for 2400 seconds = ~1.85s precision
54
+ PRECISION_DIVISOR = 1296 # 36^2
55
+
56
+ # Additional precision for high-7 and high-8 formats
57
+ PRECISION_DIVISOR_3 = 46_656 # 36^3 for high-7 (~50ms precision)
58
+ PRECISION_DIVISOR_4 = 1_679_616 # 36^4 for high-8 (~1.4ms precision)
59
+
60
+ # Maximum values for component validation
61
+ MAX_MONTHS_OFFSET = 1295 # 108 years * 12 months
62
+ MAX_DAY = 30 # Calendar days 1-31 map to 0-30
63
+ MAX_BLOCK = 35 # 36 blocks per day (0-35)
64
+ MAX_PRECISION = 1295 # 36^2 - 1
65
+
66
+ class << self
67
+ include FormatCodecs
68
+
69
+ # Encode a Time object to a 6-character compact ID
70
+ #
71
+ # @param time [Time] The time to encode
72
+ # @param year_zero [Integer] Base year for encoding (default: 2000)
73
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
74
+ # @return [String] 6-character compact ID
75
+ # @raise [ArgumentError] If time is outside supported range
76
+ def encode(time, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
77
+ encode_with_format(time, format: :"2sec", year_zero: year_zero, alphabet: alphabet)
78
+ end
79
+
80
+ # Encode a Time object to a compact ID with specified format
81
+ #
82
+ # @param time [Time] The time to encode
83
+ # @param format [Symbol] Output format (:"2sec", :month, :week, :day, :"40min", :"50ms", :ms)
84
+ # @param year_zero [Integer] Base year for encoding (default: 2000)
85
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
86
+ # @return [String] Variable-length compact ID (2-8 characters depending on format)
87
+ # @raise [ArgumentError] If time is outside supported range or format is invalid
88
+ def encode_with_format(time, format:, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
89
+ time = time.utc if time.respond_to?(:utc)
90
+
91
+ case format
92
+ when :"2sec"
93
+ encode_2sec(time, year_zero: year_zero, alphabet: alphabet)
94
+ when :month
95
+ encode_month(time, year_zero: year_zero, alphabet: alphabet)
96
+ when :week
97
+ encode_week(time, year_zero: year_zero, alphabet: alphabet)
98
+ when :day
99
+ encode_day(time, year_zero: year_zero, alphabet: alphabet)
100
+ when :"40min"
101
+ encode_40min(time, year_zero: year_zero, alphabet: alphabet)
102
+ when :"50ms"
103
+ encode_50ms(time, year_zero: year_zero, alphabet: alphabet)
104
+ when :ms
105
+ encode_ms(time, year_zero: year_zero, alphabet: alphabet)
106
+ else
107
+ suggestion = suggest_format_name(format)
108
+ msg = "Invalid format: #{format}. Must be one of #{FormatSpecs.all_formats.join(", ")}"
109
+ msg += ". Did you mean '#{suggestion}'?" if suggestion
110
+ raise ArgumentError, msg
111
+ end
112
+ end
113
+
114
+ # Decode a 6-character compact ID to a Time object
115
+ #
116
+ # @param compact_id [String] The 6-character compact ID
117
+ # @param year_zero [Integer] Base year for decoding (default: 2000)
118
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
119
+ # @return [Time] The decoded time (UTC)
120
+ # @raise [ArgumentError] If compact_id format is invalid or components out of range
121
+ def decode(compact_id, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
122
+ decode_with_format(compact_id, format: :"2sec", year_zero: year_zero, alphabet: alphabet)
123
+ end
124
+
125
+ # Decode a compact ID to a Time object with specified format
126
+ #
127
+ # @param compact_id [String] The compact ID to decode
128
+ # @param format [Symbol] Format of the compact ID
129
+ # @param year_zero [Integer] Base year for decoding (default: 2000)
130
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
131
+ # @return [Time] The decoded time (UTC)
132
+ # @raise [ArgumentError] If compact_id format is invalid or components out of range
133
+ def decode_with_format(compact_id, format:, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
134
+ case format
135
+ when :"2sec"
136
+ decode_2sec(compact_id, year_zero: year_zero, alphabet: alphabet)
137
+ when :month
138
+ decode_month(compact_id, year_zero: year_zero, alphabet: alphabet)
139
+ when :week
140
+ decode_week(compact_id, year_zero: year_zero, alphabet: alphabet)
141
+ when :day
142
+ decode_day(compact_id, year_zero: year_zero, alphabet: alphabet)
143
+ when :"40min"
144
+ decode_40min(compact_id, year_zero: year_zero, alphabet: alphabet)
145
+ when :"50ms"
146
+ decode_50ms(compact_id, year_zero: year_zero, alphabet: alphabet)
147
+ when :ms
148
+ decode_ms(compact_id, year_zero: year_zero, alphabet: alphabet)
149
+ else
150
+ suggestion = suggest_format_name(format)
151
+ msg = "Invalid format: #{format}. Must be one of #{FormatSpecs.all_formats.join(", ")}"
152
+ msg += ". Did you mean '#{suggestion}'?" if suggestion
153
+ raise ArgumentError, msg
154
+ end
155
+ end
156
+
157
+ # Detect the format of a compact ID string
158
+ #
159
+ # @param encoded_id [String] The encoded ID string
160
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
161
+ # @return [Symbol, nil] Detected format or nil if unrecognized
162
+ def detect_format(encoded_id, alphabet: DEFAULT_ALPHABET)
163
+ FormatSpecs.detect_from_id(encoded_id, alphabet: alphabet)
164
+ end
165
+
166
+ # Decode a compact ID with automatic format detection
167
+ #
168
+ # @param encoded_id [String] The compact ID to decode (2-8 characters)
169
+ # @param year_zero [Integer] Base year for decoding (default: 2000)
170
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
171
+ # @return [Time] The decoded time (UTC)
172
+ # @raise [ArgumentError] If format cannot be detected or components out of range
173
+ def decode_auto(encoded_id, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
174
+ format = detect_format(encoded_id, alphabet: alphabet)
175
+
176
+ if format.nil?
177
+ raise ArgumentError, "Cannot detect format for compact ID: #{encoded_id} (unsupported length or invalid characters)"
178
+ end
179
+
180
+ decode_with_format(encoded_id, format: format, year_zero: year_zero, alphabet: alphabet)
181
+ end
182
+
183
+ # Encode a Time object into split components for hierarchical paths
184
+ #
185
+ # @param time [Time] The time to encode
186
+ # @param levels [Array<Symbol>, String] Split levels (month, week, day, block)
187
+ # @param year_zero [Integer] Base year for encoding (default: 2000)
188
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
189
+ # @return [Hash] Hash of split components, rest, path, and full
190
+ def encode_split(time, levels:, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
191
+ time = time.utc if time.respond_to?(:utc)
192
+ levels = normalize_split_levels(levels)
193
+ validate_split_levels!(levels)
194
+
195
+ full_compact = encode_2sec(time, year_zero: year_zero, alphabet: alphabet)
196
+ components = {
197
+ month: full_compact[0..1],
198
+ day: full_compact[2],
199
+ block: full_compact[3],
200
+ precision: full_compact[4..5]
201
+ }
202
+
203
+ if levels.include?(:week)
204
+ iso_year, iso_month, week_in_month = iso_week_month_and_number(time)
205
+ iso_months_offset = calculate_months_offset_ym(iso_year, iso_month, year_zero)
206
+ components[:month] = encode_value(iso_months_offset, 2, alphabet)
207
+ week_token = encode_value(week_in_month + 30, 1, alphabet)
208
+ end
209
+
210
+ output = {}
211
+ levels.each do |level|
212
+ output[level] = (level == :week) ? week_token : components[level]
213
+ end
214
+
215
+ rest = split_rest_for(levels, full_compact)
216
+ output[:rest] = rest
217
+
218
+ path_components = levels.map { |level| output[level] } + [rest]
219
+ output[:path] = path_components.join("/")
220
+ output[:full] = path_components.join("")
221
+
222
+ output
223
+ end
224
+
225
+ # Decode a hierarchical split path into a Time object
226
+ #
227
+ # @param path_string [String] Split path string (with or without separators)
228
+ # @param year_zero [Integer] Base year for decoding (default: 2000)
229
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
230
+ # @return [Time] The decoded time (UTC)
231
+ def decode_path(path_string, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
232
+ raise ArgumentError, "Split path must be a string" unless path_string.is_a?(String)
233
+
234
+ segments = path_string.split(/[\/\\:]+/).reject(&:empty?)
235
+ full = segments.join
236
+
237
+ # 7-char format: month(2) + week(1) + day(1) + block(1) + precision(2) = MMWDBRR
238
+ # Strip week token (position 2) to get standard 6-char 2sec format: MMDBRRR
239
+ if full.length == 7
240
+ full = full[0..1] + full[3..-1]
241
+ elsif full.length != 6
242
+ raise ArgumentError, "Split path must resolve to 6 or 7 characters, got #{full.length}"
243
+ end
244
+
245
+ decode_2sec(full, year_zero: year_zero, alphabet: alphabet)
246
+ end
247
+
248
+ # ===================
249
+ # Validation Methods
250
+ # ===================
251
+
252
+ # Validate a 6-character compact ID string (legacy method)
253
+ #
254
+ # NOTE: This method only validates the 6-character "2sec" compact format.
255
+ # For validating IDs of any format, use valid_any_format? instead.
256
+ #
257
+ # @param compact_id [String] The ID to validate
258
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
259
+ # @return [Boolean] true if valid 6-char compact ID, false otherwise
260
+ # @see valid_any_format? for validating all format lengths
261
+ def valid?(compact_id, alphabet: DEFAULT_ALPHABET)
262
+ return false unless compact_id.is_a?(String)
263
+ return false unless compact_id.length == 6
264
+
265
+ # Use Set for faster character validation (O(1) vs O(n))
266
+ alphabet_set = (alphabet == DEFAULT_ALPHABET) ? DEFAULT_ALPHABET_SET : alphabet.chars.to_set
267
+ return false unless compact_id.downcase.chars.all? { |c| alphabet_set.include?(c) }
268
+
269
+ # Also validate semantic ranges
270
+ id = compact_id.downcase
271
+ months_offset = decode_value(id[0..1], alphabet)
272
+ day = decode_value(id[2], alphabet)
273
+ block = decode_value(id[3], alphabet)
274
+ precision = decode_value(id[4..5], alphabet)
275
+
276
+ # Check component ranges (day must be 0-30 for calendar days 1-31)
277
+ months_offset <= 1295 && day <= 30 && block <= 35 && precision <= 1295
278
+ end
279
+
280
+ # Generate a sequence of sequential compact IDs starting from a time
281
+ #
282
+ # @param time [Time] The starting time
283
+ # @param count [Integer] Number of IDs to generate
284
+ # @param format [Symbol] Output format (:"2sec", :month, :week, :day, :"40min", :"50ms", :ms)
285
+ # @param year_zero [Integer] Base year for encoding (default: 2000)
286
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
287
+ # @return [Array<String>] Array of sequential compact IDs
288
+ # @raise [ArgumentError] If count <= 0 or format is invalid
289
+ def encode_sequence(time, count:, format:, year_zero: DEFAULT_YEAR_ZERO, alphabet: DEFAULT_ALPHABET)
290
+ raise ArgumentError, "count must be greater than 0" if count <= 0
291
+
292
+ time = time.utc if time.respond_to?(:utc)
293
+
294
+ # Generate the first ID
295
+ first_id = encode_with_format(time, format: format, year_zero: year_zero, alphabet: alphabet)
296
+
297
+ return [first_id] if count == 1
298
+
299
+ # Generate subsequent IDs by incrementing
300
+ result = [first_id]
301
+ current_id = first_id
302
+
303
+ (count - 1).times do
304
+ current_id = increment_id(current_id, format: format, alphabet: alphabet)
305
+ result << current_id
306
+ end
307
+
308
+ result
309
+ end
310
+
311
+ # Increment a compact ID to the next sequential value
312
+ #
313
+ # Increments the smallest unit for the format, handling overflow cascade:
314
+ # ms → 50ms → 2sec → block → day → month
315
+ #
316
+ # @param compact_id [String] The compact ID to increment
317
+ # @param format [Symbol] Format of the compact ID
318
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
319
+ # @return [String] The next sequential compact ID
320
+ # @raise [ArgumentError] If overflow would exceed month range
321
+ def increment_id(compact_id, format:, alphabet: DEFAULT_ALPHABET)
322
+ base = alphabet.length
323
+ id = compact_id.downcase
324
+
325
+ case format
326
+ when :month
327
+ increment_month_id(id, alphabet, base)
328
+ when :week
329
+ increment_week_id(id, alphabet, base)
330
+ when :day
331
+ increment_day_id(id, alphabet, base)
332
+ when :"40min"
333
+ increment_40min_id(id, alphabet, base)
334
+ when :"2sec"
335
+ increment_2sec_id(id, alphabet, base)
336
+ when :"50ms"
337
+ increment_50ms_id(id, alphabet, base)
338
+ when :ms
339
+ increment_ms_id(id, alphabet, base)
340
+ else
341
+ raise ArgumentError, "Invalid format: #{format}"
342
+ end
343
+ end
344
+
345
+ # Validate a compact ID string of any supported format
346
+ #
347
+ # Supports all 7 formats: month (2 chars), week (3 chars), day (3 chars),
348
+ # 40min (4 chars), 2sec (6 chars), 50ms (7 chars), ms (8 chars).
349
+ #
350
+ # @param compact_id [String] The ID to validate (2-8 characters)
351
+ # @param alphabet [String] Base36 alphabet (default: 0-9a-z)
352
+ # @return [Boolean] true if valid compact ID of any format, false otherwise
353
+ def valid_any_format?(compact_id, alphabet: DEFAULT_ALPHABET)
354
+ return false unless compact_id.is_a?(String)
355
+
356
+ # Use Set for faster character validation (O(1) vs O(n))
357
+ alphabet_set = (alphabet == DEFAULT_ALPHABET) ? DEFAULT_ALPHABET_SET : alphabet.chars.to_set
358
+ return false unless compact_id.downcase.chars.all? { |c| alphabet_set.include?(c) }
359
+
360
+ # Try to detect format
361
+ format = detect_format(compact_id, alphabet: alphabet)
362
+ return false if format.nil?
363
+
364
+ # Try to decode - if it succeeds, it's valid
365
+ begin
366
+ decode_with_format(compact_id, format: format, alphabet: alphabet)
367
+ true
368
+ rescue ArgumentError
369
+ false
370
+ end
371
+ end
372
+
373
+ private
374
+
375
+ # ===================
376
+ # Helper Methods
377
+ # ===================
378
+
379
+ # Deprecated format name mappings (old name => new name)
380
+ DEPRECATED_FORMAT_NAMES = {
381
+ compact: :"2sec",
382
+ hour: :"40min",
383
+ high_7: :"50ms",
384
+ high_8: :ms
385
+ }.freeze
386
+
387
+ # Suggest a format name for deprecated/mistyped formats
388
+ #
389
+ # @param format [Symbol] The invalid format name
390
+ # @return [Symbol, nil] Suggested format name or nil
391
+ def suggest_format_name(format)
392
+ DEPRECATED_FORMAT_NAMES[format]
393
+ end
394
+
395
+ # Calculate months offset from year_zero
396
+ #
397
+ # @param time [Time] The time to calculate offset for
398
+ # @param year_zero [Integer] Base year for encoding
399
+ # @return [Integer] Months since year_zero (0-1295)
400
+ # @raise [ArgumentError] If time is outside supported range
401
+ def calculate_months_offset(time, year_zero)
402
+ months_offset = ((time.year - year_zero) * 12) + (time.month - 1)
403
+
404
+ if months_offset.negative? || months_offset > MAX_MONTHS_OFFSET
405
+ raise ArgumentError, "Time #{time} is outside supported range (#{year_zero} to #{year_zero + 107})"
406
+ end
407
+
408
+ months_offset
409
+ end
410
+
411
+ # Simple day-based week number within month (1-5)
412
+ # days 1-7 = week 1, 8-14 = week 2, etc.
413
+ # Used by encode_split for organizational path buckets only.
414
+ #
415
+ # @param time [Time] The time to calculate week for
416
+ # @return [Integer] Week number in month (1-5)
417
+ def simple_week_in_month(time)
418
+ ((time.day - 1) / 7) + 1
419
+ end
420
+
421
+ # ISO Thursday-based week-in-month calculation.
422
+ # A week belongs to the month containing its Thursday.
423
+ #
424
+ # @param time [Time] The time to calculate week for
425
+ # @return [Array<Integer>] [year, month, week_in_month]
426
+ def iso_week_month_and_number(time)
427
+ date = Date.new(time.year, time.month, time.day)
428
+ days_since_monday = (date.wday - 1) % 7 # Mon=0..Sun=6
429
+ thursday = date + (3 - days_since_monday)
430
+
431
+ # Week belongs to Thursday's month
432
+ first_of_month = Date.new(thursday.year, thursday.month, 1)
433
+ days_until_thu = (4 - first_of_month.wday) % 7
434
+ first_thursday = first_of_month + days_until_thu
435
+
436
+ week_in_month = ((thursday - first_thursday).to_i / 7) + 1
437
+ [thursday.year, thursday.month, week_in_month]
438
+ end
439
+
440
+ # Calculate months offset from year_zero using explicit year/month
441
+ #
442
+ # @param year [Integer] The year
443
+ # @param month [Integer] The month (1-12)
444
+ # @param year_zero [Integer] Base year for encoding
445
+ # @return [Integer] Months since year_zero (0-1295)
446
+ # @raise [ArgumentError] If outside supported range
447
+ def calculate_months_offset_ym(year, month, year_zero)
448
+ months_offset = ((year - year_zero) * 12) + (month - 1)
449
+
450
+ if months_offset.negative? || months_offset > MAX_MONTHS_OFFSET
451
+ raise ArgumentError, "Time #{year}-#{month} is outside supported range (#{year_zero} to #{year_zero + 107})"
452
+ end
453
+
454
+ months_offset
455
+ end
456
+
457
+ # Normalize split levels into symbol list
458
+ #
459
+ # @param levels [Array<Symbol>, String] Level list or comma-separated string
460
+ # @return [Array<Symbol>] Normalized levels
461
+ def normalize_split_levels(levels)
462
+ list = levels.is_a?(String) ? levels.split(",") : Array(levels)
463
+ list.map { |level| level.to_s.strip }
464
+ .reject(&:empty?)
465
+ .map(&:to_sym)
466
+ end
467
+
468
+ # Validate split levels ordering and hierarchy
469
+ #
470
+ # @param levels [Array<Symbol>] Normalized split levels
471
+ # @raise [ArgumentError] If validation fails
472
+ def validate_split_levels!(levels)
473
+ raise ArgumentError, "split levels must be provided" if levels.empty?
474
+
475
+ unknown = levels - FormatSpecs::SPLIT_LEVELS
476
+ unless unknown.empty?
477
+ raise ArgumentError, "unknown level: #{unknown.first} (valid: #{FormatSpecs::SPLIT_LEVELS.join(", ")})"
478
+ end
479
+
480
+ unless levels.first == :month
481
+ raise ArgumentError, "levels must start with month"
482
+ end
483
+
484
+ indices = levels.map { |level| FormatSpecs::SPLIT_LEVELS.index(level) }
485
+ unless indices == indices.sort && indices.uniq.length == indices.length
486
+ raise ArgumentError, "levels must be in order: month -> week -> day -> block"
487
+ end
488
+
489
+ if levels.include?(:block) && !levels.include?(:day)
490
+ raise ArgumentError, "block requires day"
491
+ end
492
+ end
493
+
494
+ # Determine rest component based on final split level
495
+ #
496
+ # @param levels [Array<Symbol>] Split levels
497
+ # @param full_compact [String] Full 2sec compact ID
498
+ # @return [String] Remaining precision component
499
+ def split_rest_for(levels, full_compact)
500
+ # Determine the "rest" portion of the compact ID based on the deepest split level
501
+ # Levels are validated by validate_split_levels! before this is called
502
+ case levels.last
503
+ when :month, :week
504
+ full_compact[2..5] # After month (2 chars), rest is day+block+precision
505
+ when :day
506
+ full_compact[3..5] # After day (3 chars total), rest is block+precision
507
+ when :block
508
+ full_compact[4..5] # After block (4 chars total), rest is precision only
509
+ else
510
+ # Defensive fallback: treat as month/week level (should not be reached
511
+ # after validation, but provides safe default if called directly)
512
+ full_compact[2..5]
513
+ end
514
+ end
515
+
516
+ # Normalize minute overflow when minute >= 60
517
+ #
518
+ # @param hour [Integer] Hour value (0-23)
519
+ # @param minute [Integer] Minute value (may be >= 60)
520
+ # @return [Array<Integer>] Normalized [hour, minute] pair
521
+ def normalize_minute_overflow(hour, minute)
522
+ if minute >= 60
523
+ [hour + minute / 60, minute % 60]
524
+ else
525
+ [hour, minute]
526
+ end
527
+ end
528
+
529
+ # Encode a numeric value to base36 with specified width
530
+ #
531
+ # @param value [Integer] Value to encode
532
+ # @param width [Integer] Number of characters
533
+ # @param alphabet [String] Encoding alphabet
534
+ # @return [String] Encoded string
535
+ def encode_value(value, width, alphabet)
536
+ base = alphabet.length
537
+ result = ""
538
+
539
+ width.times do
540
+ result = alphabet[value % base] + result
541
+ value /= base
542
+ end
543
+
544
+ result
545
+ end
546
+
547
+ # Decode a base36 string to numeric value
548
+ #
549
+ # @param str [String] String to decode
550
+ # @param alphabet [String] Encoding alphabet
551
+ # @return [Integer] Decoded value
552
+ # @raise [ArgumentError] If character not found in alphabet
553
+ def decode_value(str, alphabet)
554
+ base = alphabet.length
555
+ value = 0
556
+
557
+ str.each_char do |c|
558
+ idx = alphabet.index(c)
559
+ # Defensive check - validate_format! should catch this, but be safe
560
+ raise ArgumentError, "Invalid character in compact ID: #{c}" unless idx
561
+
562
+ value = (value * base) + idx
563
+ end
564
+
565
+ value
566
+ end
567
+
568
+ # ===================
569
+ # Validation Helpers
570
+ # ===================
571
+
572
+ # Validate length of encoded ID
573
+ #
574
+ # @param encoded_id [String] The encoded ID
575
+ # @param expected_length [Integer] Expected length
576
+ # @raise [ArgumentError] If length is incorrect
577
+ def validate_length!(encoded_id, expected_length)
578
+ raise ArgumentError, "Compact ID must be a string" unless encoded_id.is_a?(String)
579
+ raise ArgumentError, "Compact ID must be #{expected_length} characters, got #{encoded_id.length}" unless encoded_id.length == expected_length
580
+ end
581
+
582
+ # Validate alphabet of encoded ID
583
+ #
584
+ # @param encoded_id [String] The encoded ID
585
+ # @param alphabet [String] Expected alphabet
586
+ # @raise [ArgumentError] If invalid characters found
587
+ def validate_alphabet!(encoded_id, alphabet)
588
+ # Use Set for faster character validation (O(1) vs O(n))
589
+ alphabet_set = (alphabet == DEFAULT_ALPHABET) ? DEFAULT_ALPHABET_SET : alphabet.chars.to_set
590
+ invalid_chars = encoded_id.downcase.chars.reject { |c| alphabet_set.include?(c) }
591
+ unless invalid_chars.empty?
592
+ raise ArgumentError, "Invalid characters in compact ID: #{invalid_chars.join(", ")}"
593
+ end
594
+ end
595
+
596
+ # Validate precision range for high-precision formats
597
+ #
598
+ # @param precision [Integer] Precision value
599
+ # @param max_precision [Integer] Maximum allowed precision
600
+ # @raise [ArgumentError] If precision exceeds maximum
601
+ def validate_precision_range!(precision, max_precision)
602
+ if precision > max_precision
603
+ raise ArgumentError, "Precision value #{precision} exceeds maximum (#{max_precision})"
604
+ end
605
+ end
606
+
607
+ # Validate decoded component ranges
608
+ #
609
+ # @param months_offset [Integer] Months since year_zero (max 1295 = 108 years)
610
+ # @param day [Integer] Day of month 0-indexed (max 30 for days 1-31)
611
+ # @param block [Integer] 40-minute block (max 35 for 36 blocks/day)
612
+ # @param precision [Integer] Precision within block (max 1295 = 36^2 - 1)
613
+ # @raise [ArgumentError] If any component is out of range
614
+ def validate_component_ranges!(months_offset, day, block, precision)
615
+ if months_offset > MAX_MONTHS_OFFSET
616
+ raise ArgumentError, "Month offset #{months_offset} exceeds maximum (#{MAX_MONTHS_OFFSET} = 108 years)"
617
+ end
618
+
619
+ if day > MAX_DAY
620
+ raise ArgumentError, "Day value #{day} exceeds maximum (#{MAX_DAY} for calendar days 1-31)"
621
+ end
622
+
623
+ if block > MAX_BLOCK
624
+ raise ArgumentError, "Block value #{block} exceeds maximum (#{MAX_BLOCK} for 36 blocks/day)"
625
+ end
626
+
627
+ if precision > MAX_PRECISION
628
+ raise ArgumentError, "Precision value #{precision} exceeds maximum (#{MAX_PRECISION} = 36^2 - 1)"
629
+ end
630
+ end
631
+
632
+ # Validate base components (month offset, day, block) without precision check
633
+ # Used for 50ms and ms formats where precision is validated separately
634
+ #
635
+ # @param months_offset [Integer] Months from year_zero (max 1295 = 108 years)
636
+ # @param day [Integer] Day of month (max 30 for calendar days 1-31)
637
+ # @param block [Integer] 40-minute block (max 35 for 36 blocks/day)
638
+ # @raise [ArgumentError] If any component is out of range
639
+ def validate_base_components!(months_offset, day, block)
640
+ if months_offset > MAX_MONTHS_OFFSET
641
+ raise ArgumentError, "Month offset #{months_offset} exceeds maximum (#{MAX_MONTHS_OFFSET} = 108 years)"
642
+ end
643
+
644
+ if day > MAX_DAY
645
+ raise ArgumentError, "Day value #{day} exceeds maximum (#{MAX_DAY} for calendar days 1-31)"
646
+ end
647
+
648
+ if block > MAX_BLOCK
649
+ raise ArgumentError, "Block value #{block} exceeds maximum (#{MAX_BLOCK} for 36 blocks/day)"
650
+ end
651
+ end
652
+ end
653
+ end
654
+ end
655
+ end
656
+ end