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

Included in:
ClassMethods
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 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



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

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



340
341
342
# File 'lib/familia/features/relationships/score_encoding.rb', line 340

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



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

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 Redis queries



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

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 : Time.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 Redis score



235
236
237
# File 'lib/familia/features/relationships/score_encoding.rb', line 235

def current_score
  encode_score(Time.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



283
284
285
# File 'lib/familia/features/relationships/score_encoding.rb', line 283

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

.decode_score(score) ⇒ Hash

Decode a 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



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

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 Redis score

Examples:

Basic encoding with bit flag

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

Permission symbol encoding

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

Multiple permissions

encode_score(Time.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 Redis sorted sets



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

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



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

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



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

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



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

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



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

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 Redis sorted sets



78
79
80
# File 'lib/familia/features/relationships/score_encoding.rb', line 78

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



69
70
71
# File 'lib/familia/features/relationships/score_encoding.rb', line 69

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 Redis range queries



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

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)



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

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



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

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 Redis operations based on time bounds

Examples:

Time range

score_range(1.hour.ago, Time.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 Redis ZRANGEBYSCORE operations



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

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



409
410
411
# File 'lib/familia/features/relationships/score_encoding.rb', line 409

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

#current_scoreObject



421
422
423
# File 'lib/familia/features/relationships/score_encoding.rb', line 421

def current_score
  ScoreEncoding.current_score
end

#decode_score(score) ⇒ Object



401
402
403
# File 'lib/familia/features/relationships/score_encoding.rb', line 401

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

#encode_score(timestamp, permissions = 0) ⇒ Object

Instance methods for classes that include this module



397
398
399
# File 'lib/familia/features/relationships/score_encoding.rb', line 397

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

#permission?(score, *permissions) ⇒ Boolean

Returns:

  • (Boolean)


405
406
407
# File 'lib/familia/features/relationships/score_encoding.rb', line 405

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

#permission_decode(score) ⇒ Object



434
435
436
# File 'lib/familia/features/relationships/score_encoding.rb', line 434

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

#permission_encode(timestamp, permission) ⇒ Object

Legacy method aliases for backward compatibility



430
431
432
# File 'lib/familia/features/relationships/score_encoding.rb', line 430

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

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



417
418
419
# File 'lib/familia/features/relationships/score_encoding.rb', line 417

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

#remove_permissions(score, *permissions) ⇒ Object



413
414
415
# File 'lib/familia/features/relationships/score_encoding.rb', line 413

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

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



425
426
427
# File 'lib/familia/features/relationships/score_encoding.rb', line 425

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