Module: Trevosdk::Core

Defined in:
lib/trevosdk/core.rb

Overview

Pure evaluation core — no I/O, no clock reads; time is a parameter. Frozen contract: must match packages/contracts/src/corpus/vectors.json exactly.

Defined Under Namespace

Classes: BuildEventResult, ParseResult

Constant Summary collapse

CONTROL_VARIANT =
"control"
BUCKET_SPACE =
10_000
"trevo_id"
MAX_EVENT_NAME_LENGTH =
500
EVENT_NAME_RE =
/\A[A-Za-z0-9_.$-]+\z/
MAX_PROPERTIES_BYTES =
8_192
EXPOSURE_DEDUP_WINDOW_MS =
600_000
EXPOSURE_DEDUP_MAX_KEYS =
1_000
FNV_OFFSET_BASIS_32 =
0x811c9dc5
FNV_PRIME_32 =
0x01000193
MASK_32 =
0xffffffff

Class Method Summary collapse

Class Method Details

.assign_variant(bucketing_id, config, on_warn = nil) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/trevosdk/core.rb', line 41

def assign_variant(bucketing_id, config, on_warn = nil)
  experiment_key = config["experimentKey"]
  variants = config["variants"]

  if variants.empty?
    on_warn&.call(%([TrevoSDK] Experiment "#{experiment_key}" has no variants. Returning "control".))
    return CONTROL_VARIANT
  end

  total = variants.sum { |variant| variant["trafficSplit"] }
  if (total - 100).abs > 0.01
    on_warn&.call(
      %([TrevoSDK] Experiment "#{experiment_key}" trafficSplit sums to #{total}, ) +
        "expected 100. Assignment may be biased."
    )
  end

  bucket = fnv1a32("#{bucketing_id}:#{experiment_key}") % BUCKET_SPACE

  cumulative = 0
  last_variant_name = nil
  variants.each do |variant|
    cumulative += variant["trafficSplit"] * 100
    return variant["name"].to_s if bucket < cumulative
    last_variant_name = variant["name"].to_s
  end

  last_variant_name || CONTROL_VARIANT
end

.build_event(event_name, timestamp:, user_id: nil, anonymous_id: nil, properties: nil, sdk_version: nil, insert_id: nil) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/trevosdk/core.rb', line 99

def build_event(event_name, timestamp:, user_id: nil, anonymous_id: nil,
  properties: nil, sdk_version: nil, insert_id: nil)
  unless event_name.is_a?(String) && !event_name.strip.empty?
    return BuildEventResult.new(false, nil, "empty-name")
  end
  return BuildEventResult.new(false, nil, "name-too-long") if event_name.length > MAX_EVENT_NAME_LENGTH
  return BuildEventResult.new(false, nil, "name-charset") unless EVENT_NAME_RE.match?(event_name)

  snapshot = nil
  unless properties.nil?
    begin
      serialized = JSON.generate(properties)
    rescue JSON::NestingError, JSON::GeneratorError, SystemStackError
      return BuildEventResult.new(false, nil, "properties-unserializable")
    end
    return BuildEventResult.new(false, nil, "properties-too-large") if serialized.length > MAX_PROPERTIES_BYTES
    # Round trip both validates and deep-copies, isolating the caller from later mutation.
    snapshot = JSON.parse(serialized)
  end

  if (user_id.nil? || user_id.empty?) && (anonymous_id.nil? || anonymous_id.empty?)
    return BuildEventResult.new(false, nil, "no-identity")
  end

  event = {"event" => event_name}
  event["userId"] = user_id unless user_id.nil?
  event["anonymousId"] = anonymous_id unless anonymous_id.nil?
  event["properties"] = snapshot unless snapshot.nil?
  event["timestamp"] = timestamp
  event["sdkVersion"] = sdk_version unless sdk_version.nil?
  event["insertId"] = insert_id unless insert_id.nil?
  BuildEventResult.new(true, event, nil)
end

.fnv1a32(value) ⇒ Object

FNV-1a over UTF-16 code units (not bytes) — matches the reference JS SDK. Invalid bytes hash as U+FFFD instead of raising: such strings have no JS equivalent, so deterministic assignment beats crashing the request.



32
33
34
35
36
37
38
39
# File 'lib/trevosdk/core.rb', line 32

def fnv1a32(value)
  hash = FNV_OFFSET_BASIS_32
  units = value.encode(Encoding::UTF_16LE, invalid: :replace, undef: :replace).unpack("v*")
  units.each do |unit|
    hash = ((hash ^ unit) * FNV_PRIME_32) & MASK_32
  end
  hash
end

.parse_config_response(body) ⇒ Object



89
90
91
92
93
94
95
96
97
# File 'lib/trevosdk/core.rb', line 89

def parse_config_response(body)
  return ParseResult.new(false, [], "Response is not an object") unless body.is_a?(Hash)
  experiments = body["experiments"]
  return ParseResult.new(false, [], "Missing experiments array") unless experiments.is_a?(Array)
  unless experiments.all? { |experiment| valid_experiment_config?(experiment) }
    return ParseResult.new(false, [], "Invalid experiment config in response")
  end
  ParseResult.new(true, experiments, nil)
end

.valid_experiment_config?(value) ⇒ Boolean

Returns:

  • (Boolean)


80
81
82
83
84
85
86
87
# File 'lib/trevosdk/core.rb', line 80

def valid_experiment_config?(value)
  return false unless value.is_a?(Hash)
  key = value["experimentKey"]
  variants = value["variants"]
  return false unless key.is_a?(String) && !key.empty?
  return false unless variants.is_a?(Array) && variants.length >= 2
  variants.all? { |variant| valid_variant?(variant) }
end

.valid_variant?(value) ⇒ Boolean

Returns:

  • (Boolean)


71
72
73
74
75
76
77
78
# File 'lib/trevosdk/core.rb', line 71

def valid_variant?(value)
  return false unless value.is_a?(Hash)
  name = value["name"]
  split = value["trafficSplit"]
  return false unless name.is_a?(String) && !name.empty?
  # An integral float (50.0) is accepted because JS cannot distinguish it from 50.
  split.is_a?(Integer) || (split.is_a?(Float) && split % 1 == 0)
end