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.
- checksums.yaml +7 -0
- data/.ace-defaults/b36ts/config.yml +13 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-b36ts.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-b36ts.yml +19 -0
- data/CHANGELOG.md +283 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/Rakefile +17 -0
- data/exe/ace-b36ts +14 -0
- data/handbook/agents/b36ts.ag.md +93 -0
- data/handbook/skills/as-b36ts/SKILL.md +20 -0
- data/handbook/workflow-instructions/b36ts.wf.md +127 -0
- data/lib/ace/b36ts/atoms/compact_id_encoder.rb +656 -0
- data/lib/ace/b36ts/atoms/format_codecs.rb +661 -0
- data/lib/ace/b36ts/atoms/format_specs.rb +178 -0
- data/lib/ace/b36ts/atoms/formats.rb +110 -0
- data/lib/ace/b36ts/cli/commands/config.rb +29 -0
- data/lib/ace/b36ts/cli/commands/decode.rb +47 -0
- data/lib/ace/b36ts/cli/commands/encode.rb +52 -0
- data/lib/ace/b36ts/cli.rb +71 -0
- data/lib/ace/b36ts/commands/config_command.rb +52 -0
- data/lib/ace/b36ts/commands/decode_command.rb +104 -0
- data/lib/ace/b36ts/commands/encode_command.rb +179 -0
- data/lib/ace/b36ts/molecules/config_resolver.rb +166 -0
- data/lib/ace/b36ts/version.rb +11 -0
- data/lib/ace/b36ts.rb +203 -0
- metadata +157 -0
|
@@ -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
|