statsig 1.34.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/evaluator.rb CHANGED
@@ -40,12 +40,13 @@ module Statsig
40
40
  end
41
41
 
42
42
  def lookup_gate_override(gate_name)
43
- if @gate_overrides.key?(gate_name)
43
+ gate_name_sym = gate_name.to_sym
44
+ if @gate_overrides.key?(gate_name_sym)
44
45
  return ConfigResult.new(
45
46
  name: gate_name,
46
- gate_value: @gate_overrides[gate_name],
47
+ gate_value: @gate_overrides[gate_name_sym],
47
48
  rule_id: Const::OVERRIDE,
48
- id_type: @spec_store.has_gate?(gate_name) ? @spec_store.get_gate(gate_name).id_type : Const::EMPTY_STR,
49
+ id_type: @spec_store.has_gate?(gate_name) ? @spec_store.get_gate(gate_name)[:idType] : Const::EMPTY_STR,
49
50
  evaluation_details: EvaluationDetails.local_override(
50
51
  @spec_store.last_config_sync_time,
51
52
  @spec_store.initial_config_sync_time
@@ -56,12 +57,13 @@ module Statsig
56
57
  end
57
58
 
58
59
  def lookup_config_override(config_name)
59
- if @config_overrides.key?(config_name)
60
+ config_name_sym = config_name.to_sym
61
+ if @config_overrides.key?(config_name_sym)
60
62
  return ConfigResult.new(
61
63
  name: config_name,
62
- json_value: @config_overrides[config_name],
64
+ json_value: @config_overrides[config_name_sym],
63
65
  rule_id: Const::OVERRIDE,
64
- id_type: @spec_store.has_config?(config_name) ? @spec_store.get_config(config_name).id_type : Const::EMPTY_STR,
66
+ id_type: @spec_store.has_config?(config_name) ? @spec_store.get_config(config_name)[:idType] : Const::EMPTY_STR,
65
67
  evaluation_details: EvaluationDetails.local_override(
66
68
  @spec_store.last_config_sync_time,
67
69
  @spec_store.initial_config_sync_time
@@ -96,7 +98,7 @@ module Statsig
96
98
  return
97
99
  end
98
100
 
99
- eval_spec(user, @spec_store.get_gate(gate_name), end_result, is_nested: is_nested)
101
+ eval_spec(gate_name, user, @spec_store.get_gate(gate_name), end_result, is_nested: is_nested)
100
102
  end
101
103
 
102
104
  def get_config(user, config_name, end_result, user_persisted_values: nil, ignore_local_overrides: false)
@@ -128,7 +130,7 @@ module Statsig
128
130
  config = @spec_store.get_config(config_name)
129
131
 
130
132
  # If persisted values is provided and the experiment is active, return sticky values if exists.
131
- if !user_persisted_values.nil? && config.is_active == true
133
+ if !user_persisted_values.nil? && config[:isActive] == true
132
134
  sticky_values = user_persisted_values[config_name]
133
135
  unless sticky_values.nil?
134
136
  end_result.gate_value = sticky_values[Statsig::Const::GATE_VALUE]
@@ -148,16 +150,16 @@ module Statsig
148
150
  end
149
151
 
150
152
  # If it doesn't exist, then save to persisted storage if the user was assigned to an experiment group.
151
- eval_spec(user, config, end_result)
153
+ eval_spec(config_name, user, config, end_result)
152
154
  if end_result.is_experiment_group
153
155
  @persistent_storage_utils.add_evaluation_to_user_persisted_values(user_persisted_values, config_name,
154
156
  end_result)
155
- @persistent_storage_utils.save_to_storage(user, config.id_type, user_persisted_values)
157
+ @persistent_storage_utils.save_to_storage(user, config[:idType], user_persisted_values)
156
158
  end
157
159
  # Otherwise, remove from persisted storage
158
160
  else
159
- @persistent_storage_utils.remove_experiment_from_storage(user, config.id_type, config_name)
160
- eval_spec(user, config, end_result)
161
+ @persistent_storage_utils.remove_experiment_from_storage(user, config[:idType], config_name)
162
+ eval_spec(config_name, user, config, end_result)
161
163
  end
162
164
  end
163
165
 
@@ -174,27 +176,44 @@ module Statsig
174
176
  return
175
177
  end
176
178
 
177
- eval_spec(user, @spec_store.get_layer(layer_name), end_result)
179
+ eval_spec(layer_name, user, @spec_store.get_layer(layer_name), end_result)
178
180
  end
179
181
 
180
182
  def list_gates
181
- @spec_store.gates.map { |name, _| name }
183
+ @spec_store.gates.keys.map(&:to_s)
182
184
  end
183
185
 
184
186
  def list_configs
185
- @spec_store.configs.map { |name, config| name if config.entity == :dynamic_config }.compact
187
+ keys = []
188
+ @spec_store.configs.each do |key, value|
189
+ if value[:entity] == Const::TYPE_DYNAMIC_CONFIG
190
+ keys << key.to_s
191
+ end
192
+ end
193
+ keys
186
194
  end
187
195
 
188
196
  def list_experiments
189
- @spec_store.configs.map { |name, config| name if config.entity == :experiment }.compact
197
+ keys = []
198
+ @spec_store.configs.each do |key, value|
199
+ if value[:entity] == Const::TYPE_EXPERIMENT
200
+ keys << key.to_s
201
+ end
202
+ end
203
+ keys
190
204
  end
191
205
 
192
206
  def list_autotunes
193
- @spec_store.configs.map { |name, config| name if config.entity == :autotune }.compact
194
- end
207
+ keys = []
208
+ @spec_store.configs.each do |key, value|
209
+ if value[:entity] == Const::TYPE_AUTOTUNE
210
+ keys << key.to_s
211
+ end
212
+ end
213
+ keys end
195
214
 
196
215
  def list_layers
197
- @spec_store.layers.map { |name, _| name }
216
+ @spec_store.layers.keys.map(&:to_s)
198
217
  end
199
218
 
200
219
  def get_client_initialize_response(user, hash_algo, client_sdk_key, include_local_overrides)
@@ -258,11 +277,11 @@ module Statsig
258
277
  end
259
278
 
260
279
  def override_gate(gate, value)
261
- @gate_overrides[gate] = value
280
+ @gate_overrides[gate.to_sym] = value
262
281
  end
263
282
 
264
283
  def remove_gate_override(gate)
265
- @gate_overrides.delete(gate)
284
+ @gate_overrides.delete(gate.to_sym)
266
285
  end
267
286
 
268
287
  def clear_gate_overrides
@@ -270,33 +289,28 @@ module Statsig
270
289
  end
271
290
 
272
291
  def override_config(config, value)
273
- @config_overrides[config] = value
292
+ @config_overrides[config.to_sym] = value
274
293
  end
275
294
 
276
295
  def remove_config_override(config)
277
- @config_overrides.delete(config)
296
+ @config_overrides.delete(config.to_sym)
278
297
  end
279
298
 
280
299
  def clear_config_overrides
281
300
  @config_overrides.clear
282
301
  end
283
302
 
284
- def eval_spec(user, config, end_result, is_nested: false)
285
- unless config.enabled
286
- finalize_eval_result(config, end_result, did_pass: false, rule: nil, is_nested: is_nested)
287
- return
288
- end
289
-
290
- config.rules.each do |rule|
303
+ def eval_spec(config_name, user, config, end_result, is_nested: false)
304
+ config[:rules].each do |rule|
291
305
  eval_rule(user, rule, end_result)
292
306
 
293
307
  if end_result.gate_value
294
- if eval_delegate(config.name, user, rule, end_result)
308
+ if eval_delegate(config_name, user, rule, end_result)
295
309
  finalize_secondary_exposures(end_result)
296
310
  return
297
311
  end
298
312
 
299
- pass = eval_pass_percent(user, rule, config.salt)
313
+ pass = eval_pass_percent(user, rule, config[:salt])
300
314
  finalize_eval_result(config, end_result, did_pass: pass, rule: rule, is_nested: is_nested)
301
315
  return
302
316
  end
@@ -308,20 +322,20 @@ module Statsig
308
322
  private
309
323
 
310
324
  def finalize_eval_result(config, end_result, did_pass:, rule:, is_nested: false)
311
- end_result.id_type = config.id_type
312
- end_result.target_app_ids = config.target_app_ids
313
- end_result.gate_value = did_pass
325
+ end_result.id_type = config[:idType]
326
+ end_result.target_app_ids = config[:targetAppIDs]
327
+ end_result.gate_value = did_pass ? rule[:returnValue] == true : config[:defaultValue] == true
314
328
 
315
329
  if rule.nil?
316
- end_result.json_value = config.default_value
330
+ end_result.json_value = config[:defaultValue]
317
331
  end_result.group_name = nil
318
332
  end_result.is_experiment_group = false
319
- end_result.rule_id = config.enabled ? Const::DEFAULT : Const::DISABLED
333
+ end_result.rule_id = config[:enabled] ? Const::DEFAULT : Const::DISABLED
320
334
  else
321
- end_result.json_value = did_pass ? rule.return_value : config.default_value
322
- end_result.group_name = rule.group_name
323
- end_result.is_experiment_group = rule.is_experiment_group == true
324
- end_result.rule_id = rule.id
335
+ end_result.json_value = did_pass ? rule[:returnValue] : config[:defaultValue]
336
+ end_result.group_name = rule[:groupName]
337
+ end_result.is_experiment_group = rule[:isExperimentGroup] == true
338
+ end_result.rule_id = rule[:id]
325
339
  end
326
340
 
327
341
  unless end_result.disable_evaluation_details
@@ -345,7 +359,7 @@ module Statsig
345
359
  def clean_exposures(exposures)
346
360
  seen = {}
347
361
  exposures.reject do |exposure|
348
- if exposure[:gate].to_s.start_with?('segment:')
362
+ if exposure[:gate].to_s.start_with?(Const::SEGMENT_PREFIX)
349
363
  should_reject = true
350
364
  else
351
365
  key = "#{exposure[:gate]}|#{exposure[:gateValue]}|#{exposure[:ruleID]}}"
@@ -360,19 +374,10 @@ module Statsig
360
374
  pass = true
361
375
  i = 0
362
376
 
363
- memo = user.get_memo
364
- until i >= rule.conditions.length
365
-
366
- condition = rule.conditions[i]
367
-
368
- if condition.type == :fail_gate || condition.type == :pass_gate
369
- result = eval_condition(user, condition, end_result)
370
- else
371
- result = Memo.for(memo, :eval_rule, condition.hash) do
372
- eval_condition(user, condition, end_result)
373
- end
374
- end
375
-
377
+ until i >= rule[:conditions].length
378
+ condition_hash = rule[:conditions][i]
379
+ condition = @spec_store.get_condition(condition_hash)
380
+ result = eval_condition(user, condition, end_result)
376
381
  pass = false if result != true
377
382
  i += 1
378
383
  end
@@ -381,82 +386,74 @@ module Statsig
381
386
  end
382
387
 
383
388
  def eval_delegate(name, user, rule, end_result)
384
- return false unless (delegate = rule.config_delegate)
385
- return false unless (config = @spec_store.get_config(delegate))
389
+ return false unless (delegate = rule[:configDelegate])
390
+ return false unless (delegate_config = @spec_store.get_config(delegate))
386
391
 
387
392
  end_result.undelegated_sec_exps = end_result.secondary_exposures.dup
388
393
 
389
- eval_spec(user, config, end_result, is_nested: true)
394
+ eval_spec(delegate, user, delegate_config, end_result, is_nested: true)
390
395
 
391
396
  end_result.name = name
392
397
  end_result.config_delegate = delegate
393
- end_result.explicit_parameters = config.explicit_parameters
398
+ end_result.explicit_parameters = delegate_config[:explicitParameters]
394
399
 
395
400
  true
396
401
  end
397
402
 
398
403
  def eval_condition(user, condition, end_result)
399
404
  value = nil
400
- field = condition.field
401
- target = condition.target_value
402
- type = condition.type
403
- operator = condition.operator
404
- additional_values = condition.additional_values
405
- id_type = condition.id_type
405
+ field = condition[:field]
406
+ target = condition[:targetValue]
407
+ type = condition[:type]
408
+ operator = condition[:operator]
409
+ additional_values = condition[:additionalValues]
410
+ id_type = condition[:idType]
406
411
 
407
412
  case type
408
- when :public
413
+ when Const::CND_PUBLIC
409
414
  return true
410
- when :fail_gate, :pass_gate
411
- check_gate(user, target, end_result, is_nested: true)
412
- gate_value = end_result.gate_value
413
-
414
- unless end_result.disable_exposures
415
- new_exposure = {
416
- gate: target,
417
- gateValue: gate_value ? Const::TRUE : Const::FALSE,
418
- ruleID: end_result.rule_id
419
- }
420
- end_result.secondary_exposures.append(new_exposure)
421
- end
422
- return type == :pass_gate ? gate_value : !gate_value
423
- when :ip_based
415
+ when Const::CND_PASS_GATE, Const::CND_FAIL_GATE
416
+ result = eval_nested_gate(target, user, end_result)
417
+ return type == Const::CND_PASS_GATE ? result : !result
418
+ when Const::CND_MULTI_PASS_GATE, Const::CND_MULTI_FAIL_GATE
419
+ return eval_nested_gates(target, type, user, end_result)
420
+ when Const::CND_IP_BASED
424
421
  value = get_value_from_user(user, field) || get_value_from_ip(user, field)
425
- when :ua_based
422
+ when Const::CND_UA_BASED
426
423
  value = get_value_from_user(user, field) || get_value_from_ua(user, field)
427
- when :user_field
424
+ when Const::CND_USER_FIELD
428
425
  value = get_value_from_user(user, field)
429
- when :environment_field
426
+ when Const::CND_ENVIRONMENT_FIELD
430
427
  value = get_value_from_environment(user, field)
431
- when :current_time
428
+ when Const::CND_CURRENT_TIME
432
429
  value = Time.now.to_i # epoch time in seconds
433
- when :user_bucket
430
+ when Const::CND_USER_BUCKET
434
431
  begin
435
432
  salt = additional_values[:salt]
436
433
  unit_id = user.get_unit_id(id_type) || Const::EMPTY_STR
437
434
  # there are only 1000 user buckets as opposed to 10k for gate pass %
438
- value = compute_user_hash("#{salt}.#{unit_id}") % 1000
435
+ value = (compute_user_hash("#{salt}.#{unit_id}") % 1000).to_s
439
436
  rescue StandardError
440
437
  return false
441
438
  end
442
- when :unit_id
439
+ when Const::CND_UNIT_ID
443
440
  value = user.get_unit_id(id_type)
444
441
  end
445
442
 
446
443
  case operator
447
444
  # numerical comparison
448
- when :gt
445
+ when Const::OP_GREATER_THAN
449
446
  return EvaluationHelpers.compare_numbers(value, target, ->(a, b) { a > b })
450
- when :gte
447
+ when Const::OP_GREATER_THAN_OR_EQUAL
451
448
  return EvaluationHelpers.compare_numbers(value, target, ->(a, b) { a >= b })
452
- when :lt
449
+ when Const::OP_LESS_THAN
453
450
  return EvaluationHelpers.compare_numbers(value, target, ->(a, b) { a < b })
454
- when :lte
451
+ when Const::OP_LESS_THAN_OR_EQUAL
455
452
  return EvaluationHelpers.compare_numbers(value, target, ->(a, b) { a <= b })
456
453
 
457
454
  # version comparison
458
455
  # need to check for nil or empty value because Version takes them as valid values
459
- when :version_gt
456
+ when Const::OP_VERSION_GREATER_THAN
460
457
  return false if value.to_s.empty?
461
458
 
462
459
  return begin
@@ -464,7 +461,7 @@ module Statsig
464
461
  rescue StandardError
465
462
  false
466
463
  end
467
- when :version_gte
464
+ when Const::OP_VERSION_GREATER_THAN_OR_EQUAL
468
465
  return false if value.to_s.empty?
469
466
 
470
467
  return begin
@@ -472,7 +469,7 @@ module Statsig
472
469
  rescue StandardError
473
470
  false
474
471
  end
475
- when :version_lt
472
+ when Const::OP_VERSION_LESS_THAN
476
473
  return false if value.to_s.empty?
477
474
 
478
475
  return begin
@@ -480,7 +477,7 @@ module Statsig
480
477
  rescue StandardError
481
478
  false
482
479
  end
483
- when :version_lte
480
+ when Const::OP_VERSION_LESS_THAN_OR_EQUAL
484
481
  return false if value.to_s.empty?
485
482
 
486
483
  return begin
@@ -488,7 +485,7 @@ module Statsig
488
485
  rescue StandardError
489
486
  false
490
487
  end
491
- when :version_eq
488
+ when Const::OP_VERSION_EQUAL
492
489
  return false if value.to_s.empty?
493
490
 
494
491
  return begin
@@ -496,7 +493,7 @@ module Statsig
496
493
  rescue StandardError
497
494
  false
498
495
  end
499
- when :version_neq
496
+ when Const::OP_VERSION_NOT_EQUAL
500
497
  return false if value.to_s.empty?
501
498
 
502
499
  return begin
@@ -506,45 +503,47 @@ module Statsig
506
503
  end
507
504
 
508
505
  # array operations
509
- when :any
506
+ when Const::OP_ANY
510
507
  return EvaluationHelpers::equal_string_in_array(target, value, true)
511
- when :none
508
+ when Const::OP_NONE
512
509
  return !EvaluationHelpers::equal_string_in_array(target, value, true)
513
- when :any_case_sensitive
510
+ when Const::OP_ANY_CASE_SENSITIVE
514
511
  return EvaluationHelpers::equal_string_in_array(target, value, false)
515
- when :none_case_sensitive
512
+ when Const::OP_NONE_CASE_SENSITIVE
516
513
  return !EvaluationHelpers::equal_string_in_array(target, value, false)
517
514
 
518
515
  # string
519
- when :str_starts_with_any
516
+ when Const::OP_STR_STARTS_WITH_ANY
520
517
  return EvaluationHelpers.match_string_in_array(target, value, true, ->(a, b) { a.start_with?(b) })
521
- when :str_ends_with_any
518
+ when Const::OP_STR_END_WITH_ANY
522
519
  return EvaluationHelpers.match_string_in_array(target, value, true, ->(a, b) { a.end_with?(b) })
523
- when :str_contains_any
520
+ when Const::OP_STR_CONTAINS_ANY
524
521
  return EvaluationHelpers.match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
525
- when :str_contains_none
522
+ when Const::OP_STR_CONTAINS_NONE
526
523
  return !EvaluationHelpers.match_string_in_array(target, value, true, ->(a, b) { a.include?(b) })
527
- when :str_matches
524
+ when Const::OP_STR_MATCHES
528
525
  return begin
529
526
  value&.is_a?(String) && !(value =~ Regexp.new(target)).nil?
530
527
  rescue StandardError
531
528
  false
532
529
  end
533
- when :eq
530
+ when Const::OP_EQUAL
534
531
  return value == target
535
- when :neq
532
+ when Const::OP_NOT_EQUAL
536
533
  return value != target
537
534
 
538
535
  # dates
539
- when :before
536
+ when Const::OP_BEFORE
540
537
  return EvaluationHelpers.compare_times(value, target, ->(a, b) { a < b })
541
- when :after
538
+ when Const::OP_AFTER
542
539
  return EvaluationHelpers.compare_times(value, target, ->(a, b) { a > b })
543
- when :on
540
+ when Const::OP_ON
544
541
  return EvaluationHelpers.compare_times(value, target, lambda { |a, b|
545
542
  a.year == b.year && a.month == b.month && a.day == b.day
546
543
  })
547
- when :in_segment_list, :not_in_segment_list
544
+
545
+ # segments
546
+ when Const::OP_IN_SEGMENT_LIST, Const::OP_NOT_IN_SEGMENT_LIST
548
547
  begin
549
548
  is_in_list = false
550
549
  id_list = @spec_store.get_id_list(target)
@@ -552,7 +551,7 @@ module Statsig
552
551
  hashed_id = Digest::SHA256.base64digest(value.to_s)[0, 8]
553
552
  is_in_list = id_list.ids.include?(hashed_id)
554
553
  end
555
- return is_in_list if operator == :in_segment_list
554
+ return is_in_list if operator == Const::OP_IN_SEGMENT_LIST
556
555
 
557
556
  return !is_in_list
558
557
  rescue StandardError
@@ -562,6 +561,37 @@ module Statsig
562
561
  return false
563
562
  end
564
563
 
564
+ def eval_nested_gate(gate_name, user, end_result)
565
+ check_gate(user, gate_name, end_result, is_nested: true)
566
+ gate_value = end_result.gate_value
567
+
568
+ unless end_result.disable_exposures
569
+ new_exposure = {
570
+ gate: gate_name,
571
+ gateValue: gate_value ? Const::TRUE : Const::FALSE,
572
+ ruleID: end_result.rule_id
573
+ }
574
+ end_result.secondary_exposures.append(new_exposure)
575
+ end
576
+
577
+ gate_value
578
+ end
579
+
580
+ def eval_nested_gates(gate_names, condition_type, user, end_result)
581
+ has_passing_gate = false
582
+ is_multi_pass_gate_type = condition_type == Const::CND_MULTI_PASS_GATE
583
+ gate_names.each { |gate_name|
584
+ result = eval_nested_gate(gate_name, user, end_result)
585
+
586
+ if is_multi_pass_gate_type == result
587
+ has_passing_gate = true
588
+ break
589
+ end
590
+ }
591
+
592
+ has_passing_gate
593
+ end
594
+
565
595
  def get_value_from_user(user, field)
566
596
  return nil unless field.is_a?(String)
567
597
 
@@ -640,10 +670,10 @@ module Statsig
640
670
  end
641
671
 
642
672
  def eval_pass_percent(user, rule, config_salt)
643
- unit_id = user.get_unit_id(rule.id_type) || Const::EMPTY_STR
644
- rule_salt = rule.salt || rule.id || Const::EMPTY_STR
673
+ unit_id = user.get_unit_id(rule[:idType]) || Const::EMPTY_STR
674
+ rule_salt = rule[:salt] || rule[:id] || Const::EMPTY_STR
645
675
  hash = compute_user_hash("#{config_salt}.#{rule_salt}.#{unit_id}")
646
- return (hash % 10_000) < (rule.pass_percentage * 100)
676
+ return (hash % 10_000) < (rule[:passPercentage] * 100)
647
677
  end
648
678
 
649
679
  def compute_user_hash(user_hash)
@@ -2,7 +2,7 @@
2
2
  module Statsig
3
3
  module Interfaces
4
4
  class IDataStore
5
- CONFIG_SPECS_KEY = "statsig.cache"
5
+ CONFIG_SPECS_V2_KEY = "statsig.dcs_v2"
6
6
  ID_LISTS_KEY = "statsig.id_lists"
7
7
 
8
8
  def init
data/lib/layer.rb CHANGED
@@ -28,13 +28,16 @@ class Layer
28
28
  # @param index The name of parameter being fetched
29
29
  # @param default_value The fallback value if the name cannot be found
30
30
  def get(index, default_value)
31
- return default_value if @value.nil? || !@value.key?(index)
31
+ return default_value if @value.nil?
32
+
33
+ index_sym = index.to_sym
34
+ return default_value unless @value.key?(index_sym)
32
35
 
33
36
  if @exposure_log_func.is_a? Proc
34
37
  @exposure_log_func.call(self, index)
35
38
  end
36
39
 
37
- @value[index]
40
+ @value[index_sym]
38
41
  end
39
42
 
40
43
  ##
@@ -44,13 +47,17 @@ class Layer
44
47
  # @param index The name of parameter being fetched
45
48
  # @param default_value The fallback value if the name cannot be found
46
49
  def get_typed(index, default_value)
47
- return default_value if @value.nil? || !@value.key?(index)
48
- return default_value if @value[index].class != default_value.class and default_value.class != TrueClass and default_value.class != FalseClass
50
+ return default_value if @value.nil?
51
+
52
+ index_sym = index.to_sym
53
+ return default_value unless @value.key?(index_sym)
54
+
55
+ return default_value if @value[index_sym].class != default_value.class and default_value.class != TrueClass and default_value.class != FalseClass
49
56
 
50
57
  if @exposure_log_func.is_a? Proc
51
58
  @exposure_log_func.call(self, index)
52
59
  end
53
60
 
54
- @value[index]
61
+ @value[index_sym]
55
62
  end
56
63
  end
data/lib/memo.rb CHANGED
@@ -4,13 +4,15 @@ module Statsig
4
4
  @global_memo = {}
5
5
 
6
6
  def self.for(hash, method, key)
7
- method_hash = hash[method]
8
- unless method_hash
9
- method_hash = hash[method] = {}
7
+ if key != nil
8
+ method_hash = hash[method]
9
+ unless method_hash
10
+ method_hash = hash[method] = {}
11
+ end
12
+
13
+ return method_hash[key] if method_hash.key?(key)
10
14
  end
11
-
12
- return method_hash[key] if method_hash.key?(key)
13
-
15
+
14
16
  method_hash[key] = yield
15
17
  end
16
18