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.

Flags versus roles

Four symbol namespaces exist and they are NOT interchangeable. Two are inputs that carry bits (flags, roles), one is an input that carries a mask (categories), and one is output only (tiers):

  • PERMISSION_FLAGS are the atomic bits (:read, :write, :delete, ...). They are the currency of every API that inspects, mutates, or ranges over permission bits: permission?, add_permissions, remove_permissions, permission_range, score_range.
  • PERMISSION_ROLES are named bundles of flags (:viewer, :editor, :moderator, :admin). They are an encode-time convenience accepted ONLY by encode_score and permission_encode, and only in the bare-symbol form: encode_score(ts, :editor) resolves the bundle, but encode_score(ts, [:editor]) does NOT -- the array form routes every element through permission_level_value like everything else, so it takes atomic flags only.
  • PERMISSION_CATEGORIES are bitmasks for broad overlap queries (:readable, :content_editor, :administrator, :privileged, :owner), accepted only by category?, filter_by_category and meets_category?. They are not bundles to grant -- they answer "does this score touch this mask", and several are true at once for the same score.
  • Tiers are the return values of permission_tier (:administrator, :content_editor, :viewer, :none). They are never valid input. Two of the names are borrowed from PERMISSION_CATEGORIES but mean a single exclusive bucket rather than an overlap -- see permission_tier.

Passing a role to a flag-taking method raises ArgumentError rather than resolving it -- except :admin, which names a flag too and so resolves to bit 7 (see the overlap note below). Bundles do not survive the bit operations those methods perform: permission? tests bits individually, so a bundle would answer "holds any of these" where callers mean "holds all of these", and remove_permissions(score, :editor) would silently revoke three permissions where the caller named one thing. Expand the role at the call site (PERMISSION_ROLES.fetch(:editor)) when you want its bits.

Note the deliberate overlap on :admin -- the FLAG is bit 7 (128) while the ROLE is all eight bits (255). encode_score(t, :admin) therefore grants everything, but :admin reaching a flag-taking method means bit 7 alone. Because the two differ, roles are never silently resolved in those methods: doing so would widen add_permissions(score, :admin) from one bit to all eight.

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



242
243
244
245
246
247
248
249
250
251
# File 'lib/familia/features/relationships/score_encoding.rb', line 242

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

  new_bits = permissions.reduce(current_bits) do |acc, perm|
    acc | permission_level_value(perm)
  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



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

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

.category?(score, category) ⇒ Boolean

Check broad permission categories

Overlap test, not a classification: true when the score shares ANY bit with the category mask. The masks are not mutually exclusive, so one score answers true to several categories at once -- a moderator score (read|write|edit|delete) is true for all five. Use permission_tier when you want a single bucket instead.

Takes a PERMISSION_CATEGORIES key. Flags and roles are not categories; an unknown symbol returns false rather than raising, because there is no bit to misinterpret.

Parameters:

  • score (Float)

    The encoded score

  • category (Symbol)

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

Returns:

  • (Boolean)

    True if score shares any bit with the category mask



365
366
367
368
369
370
371
372
373
# File 'lib/familia/features/relationships/score_encoding.rb', line 365

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

.current_scoreFloat

Get current timestamp as score (no permissions)

Returns:

  • (Float)

    Current time as Valkey/Redis score



298
299
300
# File 'lib/familia/features/relationships/score_encoding.rb', line 298

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



346
347
348
# File 'lib/familia/features/relationships/score_encoding.rb', line 346

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



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

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



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/familia/features/relationships/score_encoding.rb', line 176

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_level_value(permissions)
                    when Array
                      # Support array of permission symbols
                      permissions.reduce(0) { |acc, p| acc | permission_level_value(p) }
                    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



380
381
382
383
384
385
386
387
388
# File 'lib/familia/features/relationships/score_encoding.rb', line 380

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



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/familia/features/relationships/score_encoding.rb', line 443

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



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

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

  permissions.all? do |perm|
    permission_bits.anybits?(permission_level_value(perm))
  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



150
151
152
153
154
155
156
157
# File 'lib/familia/features/relationships/score_encoding.rb', line 150

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



142
143
144
# File 'lib/familia/features/relationships/score_encoding.rb', line 142

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

.permission_level_value(permission) ⇒ Integer

Get permission bit flag value for a permission symbol

Accepts atomic PERMISSION_FLAGS only. Role symbols are rejected with a message naming the alternative -- see the "Flags versus roles" section in the module documentation for why they are not resolved here.

Parameters:

  • permission (Symbol)

    Permission symbol to get value for

Returns:

  • (Integer)

    Bit flag value for the permission

Raises:

  • (ArgumentError)

    If permission is a role or is unknown



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/familia/features/relationships/score_encoding.rb', line 119

def permission_level_value(permission)
  flag = PERMISSION_FLAGS[permission]
  return flag if flag

  if PERMISSION_ROLES.key?(permission)
    expansion = decode_permission_flags(PERMISSION_ROLES[permission])
    raise ArgumentError,
          "#{permission.inspect} is a permission role, not a permission flag. " \
          'A role is accepted only by encode_score/permission_encode, and only in ' \
          "the bare-symbol form -- encode_score(timestamp, #{permission.inspect}). " \
          "Everywhere else, including the array form, pass atomic flags: #{expansion.map(&:inspect).join(', ')}."
  end

  raise ArgumentError,
        "Unknown permission: #{permission.inspect}. Valid flags: " \
        "#{PERMISSION_FLAGS.keys.map(&:inspect).join(', ')}."
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



282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/familia/features/relationships/score_encoding.rb', line 282

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

  [min_bits / METADATA_PRECISION, max_bits / METADATA_PRECISION]
end

.permission_tier(score) ⇒ Symbol

Get permission tier for score

Classification, not an overlap test: returns exactly one bucket, checking most-privileged first. The three masks it consults partition all eight bits (0b11110000 | 0b00001110 | 0b00000001 == 0xFF), so the highest set bit alone decides the answer.

Tier names are a FOURTH symbol namespace -- output only, never valid input to any method here. Two of them collide with PERMISSION_CATEGORIES keys while asking the opposite question, so a score can be true for category X and still tier as something else:

score = encode_score(t, PERMISSION_ROLES[:moderator]) # bits 45 category?(score, :content_editor) #=> true (overlaps the mask) permission_tier(score) #=> :administrator

The moderator lands in :administrator because :delete (bit 5) sits inside the administrator mask. Do not read a tier as "this user is a PERMISSION_ROLES[:administrator]" -- the role namespace does not even contain that name. Tier :viewer is the one exact correspondence: its mask arithmetic makes it reachable only at bits == 1, which is PERMISSION_ROLES[:viewer].

Parameters:

  • score (Float)

    The encoded score

Returns:

  • (Symbol)

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



415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/familia/features/relationships/score_encoding.rb', line 415

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



262
263
264
265
266
267
268
269
270
271
# File 'lib/familia/features/relationships/score_encoding.rb', line 262

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

  new_bits = permissions.reduce(current_bits) do |acc, perm|
    acc & ~permission_level_value(perm)
  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



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/familia/features/relationships/score_encoding.rb', line 316

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_level_value(p)
               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



492
493
494
# File 'lib/familia/features/relationships/score_encoding.rb', line 492

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

#current_scoreObject



504
505
506
# File 'lib/familia/features/relationships/score_encoding.rb', line 504

def current_score
  ScoreEncoding.current_score
end

#decode_score(score) ⇒ Object



484
485
486
# File 'lib/familia/features/relationships/score_encoding.rb', line 484

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

#encode_score(timestamp, permissions = 0) ⇒ Object

Instance methods for classes that include this module



480
481
482
# File 'lib/familia/features/relationships/score_encoding.rb', line 480

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

#permission?(score, *permissions) ⇒ Boolean

Returns:

  • (Boolean)


488
489
490
# File 'lib/familia/features/relationships/score_encoding.rb', line 488

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

#permission_decode(score) ⇒ Object



517
518
519
# File 'lib/familia/features/relationships/score_encoding.rb', line 517

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

#permission_encode(timestamp, permission) ⇒ Object

Legacy method aliases for backward compatibility



513
514
515
# File 'lib/familia/features/relationships/score_encoding.rb', line 513

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

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



500
501
502
# File 'lib/familia/features/relationships/score_encoding.rb', line 500

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

#remove_permissions(score, *permissions) ⇒ Object



496
497
498
# File 'lib/familia/features/relationships/score_encoding.rb', line 496

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

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



508
509
510
# File 'lib/familia/features/relationships/score_encoding.rb', line 508

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