Module: Familia::Features::Relationships::ScoreEncoding

Included in:
ModelClassMethods
Defined in:
lib/familia/features/relationships/score_encoding.rb

Overview

Score encoding using bit flags for permissions

Encodes permissions as bit flags in the decimal portion of Valkey/Redis sorted set scores:

  • Integer part: Unix timestamp for time-based ordering
  • Decimal part: 8-bit permission flags (0-255)

Format: [timestamp].[permission_bits] Example: 1704067200.037 = Jan 1, 2024 with read(1) + write(4) + delete(32) = 37

Bit positions: 0: read - View/list items 1: append - Add new items 2: write - Modify existing items 3: edit - Edit metadata 4: configure - Change settings 5: delete - Remove items 6: transfer - Change ownership 7: admin - Full control

This allows combining permissions (read + delete without write) and efficient permission checking using bitwise operations while maintaining time-based ordering.

Constant Summary collapse

MAX_METADATA =

Maximum value for metadata to preserve precision (3 decimal places) For 8-bit permission system, max value is 255

255
METADATA_PRECISION =
1000.0
PERMISSION_FLAGS =

Permission bit flags (8-bit system)

{
  none:      0b00000000,  # 0   - No permissions
  read:      0b00000001,  # 1   - View/list
  append:    0b00000010,  # 2   - Add new items
  write:     0b00000100,  # 4   - Modify existing
  edit:      0b00001000,  # 8   - Edit metadata
  configure: 0b00010000,  # 16  - Change settings
  delete:    0b00100000,  # 32  - Remove items
  transfer:  0b01000000,  # 64  - Change ownership
  admin:     0b10000000,  # 128 - Full control
}.freeze
PERMISSION_ROLES =

Predefined permission combinations

{
  viewer:     PERMISSION_FLAGS[:read],
  editor:     PERMISSION_FLAGS[:read] | PERMISSION_FLAGS[:write] | PERMISSION_FLAGS[:edit],
  moderator:  PERMISSION_FLAGS[:read] | PERMISSION_FLAGS[:write] | PERMISSION_FLAGS[:edit] | PERMISSION_FLAGS[:delete],
  admin:      0b11111111, # All permissions
}.freeze
PERMISSION_CATEGORIES =

Categorical masks for efficient broad queries

{
  readable:       0b00000001,  # Has basic access
  content_editor: 0b00001110,  # Can modify content (append|write|edit)
  administrator:  0b11110000,  # Has any admin powers
  privileged:     0b11111110,  # Has beyond read-only
  owner:          0b11111111, # All permissions
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.add_permissions(score, *permissions) ⇒ Float

Add permissions to existing score

Examples:

add_permissions(1704067200.001, :write, :delete)  # add write(4) + delete(32) to read(1)
#=> 1704067200.037

Parameters:

  • score (Float)

    The existing encoded score

  • permissions (Array<Symbol>)

    Permissions to add

Returns:

  • (Float)

    New score with added permissions



181
182
183
184
185
186
187
188
189
190
# File 'lib/familia/features/relationships/score_encoding.rb', line 181

def add_permissions(score, *permissions)
  decoded = decode_score(score)
  current_bits = decoded[:permissions]

  new_bits = permissions.reduce(current_bits) do |acc, perm|
    acc | (PERMISSION_FLAGS[perm] || 0)
  end

  encode_score(decoded[:timestamp], new_bits)
end

.categorize_scores(scores) ⇒ Hash

Efficient bulk categorization

Parameters:

  • scores (Array<Float>)

    Array of scores to categorize

Returns:

  • (Hash)

    Hash mapping tiers to arrays of scores



342
343
344
# File 'lib/familia/features/relationships/score_encoding.rb', line 342

def categorize_scores(scores)
  scores.group_by { |score| permission_tier(score) }
end

.category?(score, category) ⇒ Boolean

Check broad permission categories

Parameters:

  • score (Float)

    The encoded score

  • category (Symbol)

    Category to check (:readable, :content_editor, :administrator, etc.)

Returns:

  • (Boolean)

    True if score meets the category requirements



294
295
296
297
298
299
300
301
302
# File 'lib/familia/features/relationships/score_encoding.rb', line 294

def category?(score, category)
  decoded = decode_score(score)
  permission_bits = decoded[:permissions]

  mask = PERMISSION_CATEGORIES[category]
  return false unless mask

  permission_bits.anybits?(mask)
end

.category_score_range(category, start_time = nil, end_time = nil) ⇒ Array<String>

Range queries for categorical filtering

Parameters:

  • category (Symbol)

    Category to create range for

  • start_time (Time, nil) (defaults to: nil)

    Optional start time filter

  • end_time (Time, nil) (defaults to: nil)

    Optional end time filter

Returns:

  • (Array<String>)

    Min and max range strings for Valkey/Redis queries



373
374
375
376
377
378
379
380
381
382
# File 'lib/familia/features/relationships/score_encoding.rb', line 373

def category_score_range(category, start_time = nil, end_time = nil)
  PERMISSION_CATEGORIES[category] || 0

  # Any permission matching the category mask
  min_score = start_time ? start_time.to_i : 0
  max_score = end_time ? end_time.to_i : Familia.now.to_i

  # Return range that includes any matching permissions
  ["#{min_score}.000", "#{max_score}.999"]
end

.current_scoreFloat

Get current timestamp as score (no permissions)

Returns:

  • (Float)

    Current time as Valkey/Redis score



237
238
239
# File 'lib/familia/features/relationships/score_encoding.rb', line 237

def current_score
  encode_score(Familia.now, 0)
end

.decode_permission_flags(bits) ⇒ Array<Symbol>

Decode permission bits into array of permission symbols

Parameters:

  • bits (Integer)

    Permission bits to decode

Returns:

  • (Array<Symbol>)

    Array of permission symbols



285
286
287
# File 'lib/familia/features/relationships/score_encoding.rb', line 285

def decode_permission_flags(bits)
  PERMISSION_FLAGS.select { |_name, flag| bits.anybits?(flag) }.keys
end

.decode_score(score) ⇒ Hash

Decode a Valkey/Redis score back into timestamp and permissions

Examples:

Basic decoding

decode_score(1704067200.037)
#=> { timestamp: 1704067200, permissions: 37, permission_list: [:read, :write, :delete] }

Parameters:

  • score (Float)

    The encoded score

Returns:

  • (Hash)

    Hash with :timestamp, :permissions, and :permission_list keys



140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/familia/features/relationships/score_encoding.rb', line 140

def decode_score(score)
  return { timestamp: 0, permissions: 0, permission_list: [] } unless score.is_a?(Numeric)

  time_part = score.to_i
  permission_bits = ((score - time_part) * METADATA_PRECISION).round

  {
    timestamp: time_part,
    permissions: permission_bits,
    permission_list: decode_permission_flags(permission_bits),
  }
end

.encode_score(timestamp, permissions = 0) ⇒ Float

Encode a timestamp and permissions into a Valkey/Redis score

Examples:

Basic encoding with bit flag

encode_score(Familia.now, 5)  # read(1) + write(4) = 5
#=> 1704067200.005

Permission symbol encoding

encode_score(Familia.now, :read)
#=> 1704067200.001

Multiple permissions

encode_score(Familia.now, [:read, :write, :delete])
#=> 1704067200.037

Parameters:

  • timestamp (Time, Integer)

    The timestamp to encode

  • permissions (Integer, Symbol, Array) (defaults to: 0)

    Permissions to encode

Returns:

  • (Float)

    Encoded score suitable for Valkey/Redis sorted sets



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/familia/features/relationships/score_encoding.rb', line 114

def encode_score(timestamp, permissions = 0)
  time_part = timestamp.respond_to?(:to_i) ? timestamp.to_i : timestamp

  permission_bits = case permissions
                    when Symbol
                      PERMISSION_ROLES[permissions] || PERMISSION_FLAGS[permissions] || 0
                    when Array
                      # Support array of permission symbols
                      permissions.reduce(0) { |acc, p| acc | (PERMISSION_FLAGS[p] || 0) }
                    when Integer
                      validate_permission_bits(permissions)
                    else
                      0
                    end

  time_part + (permission_bits / METADATA_PRECISION)
end

.filter_by_category(scores, category) ⇒ Array<Float>

Filter collection by permission category

Parameters:

  • scores (Array<Float>)

    Array of scores to filter

  • category (Symbol)

    Category to filter by

Returns:

  • (Array<Float>)

    Scores matching the category



309
310
311
312
313
314
315
316
317
# File 'lib/familia/features/relationships/score_encoding.rb', line 309

def filter_by_category(scores, category)
  mask = PERMISSION_CATEGORIES[category]
  return [] unless mask

  scores.select do |score|
    permission_bits = ((score % 1) * METADATA_PRECISION).round
    permission_bits.anybits?(mask)
  end
end

.meets_category?(permission_bits, category) ⇒ Boolean

Check if permissions meet minimum category

Parameters:

  • permission_bits (Integer)

    Permission bits to check

  • category (Symbol)

    Category to check against

Returns:

  • (Boolean)

    True if permissions meet the category requirements



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/familia/features/relationships/score_encoding.rb', line 351

def meets_category?(permission_bits, category)
  mask = PERMISSION_CATEGORIES[category]
  return false unless mask

  case category
  when :readable
    permission_bits.positive? # Any permission implies read
  when :privileged
    permission_bits > 1 # More than just read
  when :administrator
    permission_bits.anybits?(PERMISSION_CATEGORIES[:administrator])
  else
    permission_bits.anybits?(mask)
  end
end

.permission?(score, *permissions) ⇒ Boolean

Check if score has specific permissions

Examples:

permission?(1704067200.005, :read)  # score has read(1) + write(4)
#=> true

Parameters:

  • score (Float)

    The encoded score

  • permissions (Array<Symbol>)

    Permissions to check

Returns:

  • (Boolean)

    True if all permissions are present



162
163
164
165
166
167
168
169
170
# File 'lib/familia/features/relationships/score_encoding.rb', line 162

def permission?(score, *permissions)
  decoded = decode_score(score)
  permission_bits = decoded[:permissions]

  permissions.all? do |perm|
    flag = PERMISSION_FLAGS[perm]
    flag && permission_bits.anybits?(flag)
  end
end

.permission_decode(score) ⇒ Hash

Decode score into permission information

Parameters:

  • score (Float)

    The encoded score

Returns:

  • (Hash)

    Hash with timestamp, permissions bits, and permission list



88
89
90
91
92
93
94
95
# File 'lib/familia/features/relationships/score_encoding.rb', line 88

def permission_decode(score)
  decoded = decode_score(score)
  {
    timestamp: decoded[:timestamp],
    permissions: decoded[:permissions],
    permission_list: decoded[:permission_list],
  }
end

.permission_encode(timestamp, permission) ⇒ Float

Encode timestamp and permission (alias for encode_score)

Parameters:

  • timestamp (Time, Integer)

    The timestamp to encode

  • permission (Symbol, Integer, Array)

    Permission(s) to encode

Returns:

  • (Float)

    Encoded score suitable for Valkey/Redis sorted sets



80
81
82
# File 'lib/familia/features/relationships/score_encoding.rb', line 80

def permission_encode(timestamp, permission)
  encode_score(timestamp, permission)
end

.permission_level_value(permission) ⇒ Integer

Get permission bit flag value for a permission symbol

Parameters:

  • permission (Symbol)

    Permission symbol to get value for

Returns:

  • (Integer)

    Bit flag value for the permission

Raises:

  • (ArgumentError)

    If permission is unknown



71
72
73
# File 'lib/familia/features/relationships/score_encoding.rb', line 71

def permission_level_value(permission)
  PERMISSION_FLAGS[permission] || raise(ArgumentError, "Unknown permission: #{permission.inspect}")
end

.permission_range(min_permissions = [], max_permissions = nil) ⇒ Array<Float>

Create score range for permissions

Examples:

permission_range([:read], [:read, :write])
#=> [0.001, 0.005]

Parameters:

  • min_permissions (Array<Symbol>, nil) (defaults to: [])

    Minimum required permissions

  • max_permissions (Array<Symbol>, nil) (defaults to: nil)

    Maximum allowed permissions

Returns:

  • (Array<Float>)

    Min and max scores for Valkey/Redis range queries



221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/familia/features/relationships/score_encoding.rb', line 221

def permission_range(min_permissions = [], max_permissions = nil)
  min_bits = Array(min_permissions).reduce(0) { |acc, p| acc | (PERMISSION_FLAGS[p] || 0) }
  max_bits = if max_permissions
               Array(max_permissions).reduce(0) do |acc, p|
                 acc | (PERMISSION_FLAGS[p] || 0)
               end
             else
               255
             end

  [min_bits / METADATA_PRECISION, max_bits / METADATA_PRECISION]
end

.permission_tier(score) ⇒ Symbol

Get permission tier for score

Parameters:

  • score (Float)

    The encoded score

Returns:

  • (Symbol)

    Permission tier (:administrator, :content_editor, :viewer, :none)



323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/familia/features/relationships/score_encoding.rb', line 323

def permission_tier(score)
  decoded = decode_score(score)
  bits = decoded[:permissions]

  if bits.anybits?(PERMISSION_CATEGORIES[:administrator])
    :administrator
  elsif bits.anybits?(PERMISSION_CATEGORIES[:content_editor])
    :content_editor
  elsif bits.anybits?(PERMISSION_CATEGORIES[:readable])
    :viewer
  else
    :none
  end
end

.remove_permissions(score, *permissions) ⇒ Float

Remove permissions from existing score

Examples:

remove_permissions(1704067200.037, :write)  # remove write(4) from read(1)+write(4)+delete(32)
#=> 1704067200.033

Parameters:

  • score (Float)

    The existing encoded score

  • permissions (Array<Symbol>)

    Permissions to remove

Returns:

  • (Float)

    New score with removed permissions



201
202
203
204
205
206
207
208
209
210
# File 'lib/familia/features/relationships/score_encoding.rb', line 201

def remove_permissions(score, *permissions)
  decoded = decode_score(score)
  current_bits = decoded[:permissions]

  new_bits = permissions.reduce(current_bits) do |acc, perm|
    acc & ~(PERMISSION_FLAGS[perm] || 0)
  end

  encode_score(decoded[:timestamp], new_bits)
end

.score_range(start_time = nil, end_time = nil, min_permissions: nil) ⇒ Array

Create score range for db operations based on time bounds

Examples:

Time range

score_range(1.hour.ago, Familia.now)
#=> [1704063600.0, 1704067200.255]

Permission filter

score_range(nil, nil, min_permissions: [:read])
#=> [0.001, "+inf"]

Parameters:

  • start_time (Time, nil) (defaults to: nil)

    Start time (nil for -inf)

  • end_time (Time, nil) (defaults to: nil)

    End time (nil for +inf)

  • min_permissions (Array<Symbol>, nil) (defaults to: nil)

    Minimum required permissions

Returns:

  • (Array)

    Array suitable for Valkey/Redis ZRANGEBYSCORE operations



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/familia/features/relationships/score_encoding.rb', line 255

def score_range(start_time = nil, end_time = nil, min_permissions: nil)
  min_bits = if min_permissions
               Array(min_permissions).reduce(0) do |acc, p|
                 acc | (PERMISSION_FLAGS[p] || 0)
               end
             else
               0
             end

  min_score = if start_time
                encode_score(start_time, min_bits)
              elsif min_permissions
                encode_score(0, min_bits)
              else
                '-inf'
              end

  max_score = if end_time
                encode_score(end_time, 255) # Use max valid permission bits
              else
                '+inf'
              end

  [min_score, max_score]
end

Instance Method Details

#add_permissions(score, *permissions) ⇒ Object



411
412
413
# File 'lib/familia/features/relationships/score_encoding.rb', line 411

def add_permissions(score, *permissions)
  ScoreEncoding.add_permissions(score, *permissions)
end

#current_scoreObject



423
424
425
# File 'lib/familia/features/relationships/score_encoding.rb', line 423

def current_score
  ScoreEncoding.current_score
end

#decode_score(score) ⇒ Object



403
404
405
# File 'lib/familia/features/relationships/score_encoding.rb', line 403

def decode_score(score)
  ScoreEncoding.decode_score(score)
end

#encode_score(timestamp, permissions = 0) ⇒ Object

Instance methods for classes that include this module



399
400
401
# File 'lib/familia/features/relationships/score_encoding.rb', line 399

def encode_score(timestamp, permissions = 0)
  ScoreEncoding.encode_score(timestamp, permissions)
end

#permission?(score, *permissions) ⇒ Boolean

Returns:

  • (Boolean)


407
408
409
# File 'lib/familia/features/relationships/score_encoding.rb', line 407

def permission?(score, *permissions)
  ScoreEncoding.permission?(score, *permissions)
end

#permission_decode(score) ⇒ Object



436
437
438
# File 'lib/familia/features/relationships/score_encoding.rb', line 436

def permission_decode(score)
  ScoreEncoding.permission_decode(score)
end

#permission_encode(timestamp, permission) ⇒ Object

Legacy method aliases for backward compatibility



432
433
434
# File 'lib/familia/features/relationships/score_encoding.rb', line 432

def permission_encode(timestamp, permission)
  ScoreEncoding.permission_encode(timestamp, permission)
end

#permission_range(min_permissions = [], max_permissions = nil) ⇒ Object



419
420
421
# File 'lib/familia/features/relationships/score_encoding.rb', line 419

def permission_range(min_permissions = [], max_permissions = nil)
  ScoreEncoding.permission_range(min_permissions, max_permissions)
end

#remove_permissions(score, *permissions) ⇒ Object



415
416
417
# File 'lib/familia/features/relationships/score_encoding.rb', line 415

def remove_permissions(score, *permissions)
  ScoreEncoding.remove_permissions(score, *permissions)
end

#score_range(start_time = nil, end_time = nil, min_permissions: nil) ⇒ Object



427
428
429
# File 'lib/familia/features/relationships/score_encoding.rb', line 427

def score_range(start_time = nil, end_time = nil, min_permissions: nil)
  ScoreEncoding.score_range(start_time, end_time, min_permissions: min_permissions)
end