games_dice 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,445 +0,0 @@
1
- # This class models probability distributions for dice systems.
2
- #
3
- # An object of this class represents a single distribution, which might be the result of a complex
4
- # combination of dice.
5
- #
6
- # @example Distribution for a six-sided die
7
- # probs = GamesDice::Probabilities.for_fair_die( 6 )
8
- # probs.min # => 1
9
- # probs.max # => 6
10
- # probs.expected # => 3.5
11
- # probs.p_ge( 4 ) # => 0.5
12
- #
13
- # @example Adding two distributions
14
- # pd6 = GamesDice::Probabilities.for_fair_die( 6 )
15
- # probs = GamesDice::Probabilities.add_distributions( pd6, pd6 )
16
- # probs.min # => 2
17
- # probs.max # => 12
18
- # probs.expected # => 7.0
19
- # probs.p_ge( 10 ) # => 0.16666666666666669
20
- #
21
- class GamesDice::Probabilities
22
-
23
- # Creates new instance of GamesDice::Probabilities.
24
- # @param [Array<Float>] probs Each entry in the array is the probability of getting a result
25
- # @param [Integer] offset The result associated with index of 0 in the array
26
- # @return [GamesDice::Probabilities]
27
- def initialize( probs = [1.0], offset = 0 )
28
- # This should *probably* be validated in future, but that would impact performance
29
- @probs = check_probs_array probs.clone
30
- @offset = Integer(offset)
31
- end
32
-
33
- # @!visibility private
34
- # the Array, Offset representation of probabilities.
35
- def to_ao
36
- [ @probs, @offset ]
37
- end
38
-
39
- # Iterates through value, probability pairs
40
- # @yieldparam [Integer] result A result that may be possible in the dice scheme
41
- # @yieldparam [Float] probability Probability of result, in range 0.0..1.0
42
- # @return [GamesDice::Probabilities] this object
43
- def each
44
- @probs.each_with_index { |p,i| yield( i+@offset, p ) if p > 0.0 }
45
- return self
46
- end
47
-
48
- # A hash representation of the distribution. Each key is an integer result,
49
- # and the matching value is probability of getting that result. A new hash is generated on each
50
- # call to this method.
51
- # @return [Hash]
52
- def to_h
53
- GamesDice::Probabilities.prob_ao_to_h( @probs, @offset )
54
- end
55
-
56
- # @!attribute [r] min
57
- # Minimum result in the distribution
58
- # @return [Integer]
59
- def min
60
- @offset
61
- end
62
-
63
- # @!attribute [r] max
64
- # Maximum result in the distribution
65
- # @return [Integer]
66
- def max
67
- @offset + @probs.count() - 1
68
- end
69
-
70
- # @!attribute [r] expected
71
- # Expected value of distribution.
72
- # @return [Float]
73
- def expected
74
- @expected ||= calc_expected
75
- end
76
-
77
- # Probability of result equalling specific target
78
- # @param [Integer] target
79
- # @return [Float] in range (0.0..1.0)
80
- def p_eql target
81
- i = Integer(target) - @offset
82
- return 0.0 if i < 0 || i >= @probs.count
83
- @probs[ i ]
84
- end
85
-
86
- # Probability of result being greater than specific target
87
- # @param [Integer] target
88
- # @return [Float] in range (0.0..1.0)
89
- def p_gt target
90
- p_ge( Integer(target) + 1 )
91
- end
92
-
93
- # Probability of result being equal to or greater than specific target
94
- # @param [Integer] target
95
- # @return [Float] in range (0.0..1.0)
96
- def p_ge target
97
- target = Integer(target)
98
- return @prob_ge[target] if @prob_ge && @prob_ge[target]
99
- @prob_ge = {} unless @prob_ge
100
-
101
- return 1.0 if target <= min
102
- return 0.0 if target > max
103
- @prob_ge[target] = @probs[target-@offset,@probs.count-1].inject(0.0) {|so_far,p| so_far + p }
104
- end
105
-
106
- # Probability of result being equal to or less than specific target
107
- # @param [Integer] target
108
- # @return [Float] in range (0.0..1.0)
109
- def p_le target
110
- target = Integer(target)
111
- return @prob_le[target] if @prob_le && @prob_le[target]
112
- @prob_le = {} unless @prob_le
113
-
114
- return 1.0 if target >= max
115
- return 0.0 if target < min
116
- @prob_le[target] = @probs[0,1+target-@offset].inject(0.0) {|so_far,p| so_far + p }
117
- end
118
-
119
- # Probability of result being less than specific target
120
- # @param [Integer] target
121
- # @return [Float] in range (0.0..1.0)
122
- def p_lt target
123
- p_le( Integer(target) - 1 )
124
- end
125
-
126
- # Probability distribution derived from this one, where we know (or are only interested in
127
- # situations where) the result is greater than or equal to target.
128
- # @param [Integer] target
129
- # @return [GamesDice::Probabilities] new distribution.
130
- def given_ge target
131
- target = Integer(target)
132
- target = min if min > target
133
- p = p_ge(target)
134
- raise "There is no valid distribution given a result >= #{target}" unless p > 0.0
135
- mult = 1.0/p
136
- new_probs = @probs[target-@offset,@probs.count-1].map { |x| x * mult }
137
- GamesDice::Probabilities.new( new_probs, target )
138
- end
139
-
140
- # Probability distribution derived from this one, where we know (or are only interested in
141
- # situations where) the result is less than or equal to target.
142
- # @param [Integer] target
143
- # @return [GamesDice::Probabilities] new distribution.
144
- def given_le target
145
- target = Integer(target)
146
- target = max if max < target
147
- p = p_le(target)
148
- raise "There is no valid distribution given a result <= #{target}" unless p > 0.0
149
- mult = 1.0/p
150
- new_probs = @probs[0..target-@offset].map { |x| x * mult }
151
- GamesDice::Probabilities.new( new_probs, @offset )
152
- end
153
-
154
- # Creates new instance of GamesDice::Probabilities.
155
- # @param [Hash] prob_hash A hash representation of the distribution, each key is an integer result,
156
- # and the matching value is probability of getting that result
157
- # @return [GamesDice::Probabilities]
158
- def self.from_h prob_hash
159
- raise TypeError, "from_h expected a Hash" unless prob_hash.is_a? Hash
160
- probs, offset = prob_h_to_ao( prob_hash )
161
- GamesDice::Probabilities.new( probs, offset )
162
- end
163
-
164
- # Distribution for a die with equal chance of rolling 1..N
165
- # @param [Integer] sides Number of sides on die
166
- # @return [GamesDice::Probabilities]
167
- def self.for_fair_die sides
168
- sides = Integer(sides)
169
- raise ArgumentError, "sides must be at least 1" unless sides > 0
170
- raise ArgumentError, "sides can be at most 100000" if sides > 100000
171
- GamesDice::Probabilities.new( Array.new( sides, 1.0/sides ), 1 )
172
- end
173
-
174
- # Combines two distributions to create a third, that represents the distribution created when adding
175
- # results together.
176
- # @param [GamesDice::Probabilities] pd_a First distribution
177
- # @param [GamesDice::Probabilities] pd_b Second distribution
178
- # @return [GamesDice::Probabilities]
179
- def self.add_distributions pd_a, pd_b
180
- unless pd_a.is_a?( GamesDice::Probabilities ) && pd_b.is_a?( GamesDice::Probabilities )
181
- raise TypeError, "parameter to add_distributions is not a GamesDice::Probabilities"
182
- end
183
-
184
- combined_min = pd_a.min + pd_b.min
185
- combined_max = pd_a.max + pd_b.max
186
-
187
- add_distributions_internal combined_min, combined_max, 1, pd_a, 1, pd_b
188
- end
189
-
190
- # Combines two distributions with multipliers to create a third, that represents the distribution
191
- # created when adding weighted results together.
192
- # @param [Integer] m_a Weighting for first distribution
193
- # @param [GamesDice::Probabilities] pd_a First distribution
194
- # @param [Integer] m_b Weighting for second distribution
195
- # @param [GamesDice::Probabilities] pd_b Second distribution
196
- # @return [GamesDice::Probabilities]
197
- def self.add_distributions_mult m_a, pd_a, m_b, pd_b
198
- unless pd_a.is_a?( GamesDice::Probabilities ) && pd_b.is_a?( GamesDice::Probabilities )
199
- raise TypeError, "parameter to add_distributions_mult is not a GamesDice::Probabilities"
200
- end
201
-
202
- m_a = Integer(m_a)
203
- m_b = Integer(m_b)
204
-
205
- combined_min, combined_max = [
206
- m_a * pd_a.min + m_b * pd_b.min, m_a * pd_a.max + m_b * pd_b.min,
207
- m_a * pd_a.min + m_b * pd_b.max, m_a * pd_a.max + m_b * pd_b.max,
208
- ].minmax
209
-
210
- add_distributions_internal combined_min, combined_max, m_a, pd_a, m_b, pd_b
211
- end
212
-
213
- # Returns a symbol for the language name that this class is implemented in. The C version of the
214
- # code is noticeably faster when dealing with larger numbers of possible results.
215
- # @return [Symbol] Either :c or :ruby
216
- def self.implemented_in
217
- :ruby
218
- end
219
-
220
- # Adds a distribution to itself repeatedly, to simulate a number of dice
221
- # results being summed.
222
- # @param [Integer] n Number of repetitions, must be at least 1
223
- # @return [GamesDice::Probabilities] new distribution
224
- def repeat_sum n
225
- n = Integer( n )
226
- raise "Cannot combine probabilities less than once" if n < 1
227
- raise "Probability distribution too large" if ( n * @probs.count ) > 1000000
228
- pd_power = self
229
- pd_result = nil
230
-
231
- use_power = 1
232
- loop do
233
- if ( use_power & n ) > 0
234
- if pd_result
235
- pd_result = GamesDice::Probabilities.add_distributions( pd_result, pd_power )
236
- else
237
- pd_result = pd_power
238
- end
239
- end
240
- use_power = use_power << 1
241
- break if use_power > n
242
- pd_power = GamesDice::Probabilities.add_distributions( pd_power, pd_power )
243
- end
244
- pd_result
245
- end
246
-
247
- # Calculates distribution generated by summing best k results of n iterations
248
- # of the distribution.
249
- # @param [Integer] n Number of repetitions, must be at least 1
250
- # @param [Integer] k Number of best results to keep and sum
251
- # @return [GamesDice::Probabilities] new distribution
252
- def repeat_n_sum_k n, k, kmode = :keep_best
253
- n = Integer( n )
254
- k = Integer( k )
255
- raise "Cannot combine probabilities less than once" if n < 1
256
- # Technically this is a limitation of C code, but Ruby version is most likely slow and inaccurate beyond 170
257
- raise "Too many dice to calculate numbers of arrangements" if n > 170
258
- check_keep_mode( kmode )
259
-
260
- if k >= n
261
- return repeat_sum( n )
262
- end
263
- new_probs = Array.new( @probs.count * k, 0.0 )
264
- new_offset = @offset * k
265
- d = n - k
266
-
267
- each do | q, p_maybe |
268
- next unless p_maybe > 0.0
269
-
270
- # keep_distributions is array of Probabilities, indexed by number of keepers > q, which is in 0...k
271
- keep_distributions = calc_keep_distributions( k, q, kmode )
272
- p_table = calc_p_table( q, p_maybe, kmode )
273
-
274
- (0...k).each do |n|
275
- keepers = [2] * n + [1] * (k-n)
276
- p_so_far = keepers.inject(1.0) { |p,idx| p * p_table[idx] }
277
- next unless p_so_far > 0.0
278
- (0..d).each do |dn|
279
- discards = [1] * (d-dn) + [0] * dn
280
- sequence = keepers + discards
281
- p_sequence = discards.inject( p_so_far ) { |p,idx| p * p_table[idx] }
282
- next unless p_sequence > 0.0
283
- p_sequence *= GamesDice::Combinations.count_variations( sequence )
284
- kd = keep_distributions[n]
285
- kd.each { |r,p_r| new_probs[r-new_offset] += p_r * p_sequence }
286
- end
287
- end
288
- end
289
- GamesDice::Probabilities.new( new_probs, new_offset )
290
- end
291
-
292
- private
293
-
294
- def self.add_distributions_internal combined_min, combined_max, m_a, pd_a, m_b, pd_b
295
- new_probs = Array.new( 1 + combined_max - combined_min, 0.0 )
296
- probs_a, offset_a = pd_a.to_ao
297
- probs_b, offset_b = pd_b.to_ao
298
-
299
- probs_a.each_with_index do |pa,i|
300
- probs_b.each_with_index do |pb,j|
301
- k = m_a * (i + offset_a) + m_b * (j + offset_b) - combined_min
302
- pc = pa * pb
303
- new_probs[ k ] += pc
304
- end
305
- end
306
- GamesDice::Probabilities.new( new_probs, combined_min )
307
- end
308
-
309
- def check_probs_array probs_array
310
- raise TypeError unless probs_array.is_a?( Array )
311
- probs_array.map!{ |n| Float(n) }
312
- total = probs_array.inject(0.0) do |t,x|
313
- if x < 0.0 || x > 1.0
314
- raise ArgumentError, "Found probability value #{x} which is not in range 0.0..1.0"
315
- end
316
- t+x
317
- end
318
- if (total-1.0).abs > 1e-6
319
- raise ArgumentError, "Total probabilities too far from 1.0 for a valid distribution"
320
- end
321
- probs_array
322
- end
323
-
324
- def calc_keep_distributions k, q, kmode
325
- kd_probabilities = calc_keep_definite_distributions q, kmode
326
-
327
- keep_distributions = [ GamesDice::Probabilities.new( [1.0], q * k ) ]
328
- if kd_probabilities && k > 1
329
- (1...k).each do |n|
330
- extra_o = GamesDice::Probabilities.new( [1.0], q * ( k - n ) )
331
- n_probs = kd_probabilities.repeat_sum( n )
332
- keep_distributions[n] = GamesDice::Probabilities.add_distributions( extra_o, n_probs )
333
- end
334
- end
335
-
336
- keep_distributions
337
- end
338
-
339
- def calc_keep_definite_distributions q, kmode
340
- kd_probabilities = nil
341
- case kmode
342
- when :keep_best
343
- p_definites = p_gt(q)
344
- kd_probabilities = given_ge( q + 1 ) if p_definites > 0.0
345
- when :keep_worst
346
- p_definites = p_lt(q)
347
- kd_probabilities = given_le( q - 1 ) if p_definites > 0.0
348
- end
349
- kd_probabilities
350
- end
351
-
352
- def calc_p_table q, p_maybe, kmode
353
- case kmode
354
- when :keep_best
355
- p_kept = p_gt(q)
356
- p_rejected = p_lt(q)
357
- when :keep_worst
358
- p_kept = p_lt(q)
359
- p_rejected = p_gt(q)
360
- end
361
- [ p_rejected, p_maybe, p_kept ]
362
- end
363
-
364
- # Convert hash to array,offset notation
365
- def self.prob_h_to_ao h
366
- rmin,rmax = h.keys.minmax
367
- o = rmin
368
- s = 1 + rmax - rmin
369
- raise ArgumentError, "Range of possible results too large" if s > 1000000
370
- a = Array.new( s, 0.0 )
371
- h.each { |k,v| a[k-rmin] = Float(v) }
372
- [a,o]
373
- end
374
-
375
- # Convert array,offset notation to hash
376
- def self.prob_ao_to_h a, o
377
- h = Hash.new
378
- a.each_with_index { |v,i| h[i+o] = v if v > 0.0 }
379
- h
380
- end
381
-
382
- def calc_expected
383
- total = 0.0
384
- @probs.each_with_index { |v,i| total += (i+@offset)*v }
385
- total
386
- end
387
-
388
- def check_keep_mode kmode
389
- raise "Keep mode #{kmode.inspect} not recognised" unless [:keep_best,:keep_worst].member?( kmode )
390
- end
391
-
392
- end # class GamesDice::Probabilities
393
-
394
- # @!visibility private
395
- # Helper module with optimised Ruby for counting variations of arrays, such as those returned by
396
- # Array#repeated_combination
397
- #
398
- # @example How many ways can [3,3,6] be arranged?
399
- # GamesDice::Combinations.count_variations( [3,3,6] )
400
- # => 3
401
- #
402
- # @example When prob( a ) and result( a ) are same for any arrangement of Array a
403
- # items = [1,2,3,4,5,6]
404
- # items.repeated_combination(5).each do |a|
405
- # this_result = result( a )
406
- # this_prob = prob( a ) * GamesDice::Combinations.count_variations( a )
407
- # # Do something useful with this knowledge! E.g. save it to probability array.
408
- # end
409
- #
410
- module GamesDice::Combinations
411
- @@variations_cache = {}
412
- @@factorial_cache = [1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]
413
-
414
- # Counts variations of an array. A unique variation is an arrangement of the array elements which
415
- # is detectably different (using ==) from any other. So [1,1,1] has only 1 unique arrangement,
416
- # but [1,2,3] has 6 possibilities.
417
- # @param [Array] array List of things that can be arranged
418
- # @return [Integer] Number of unique arrangements
419
- def self.count_variations array
420
- all_count = array.count
421
- group_sizes = group_counts( array )
422
- cache_key = all_count.to_s + ":" + group_sizes.join(',')
423
- @@variations_cache[cache_key] ||= variations_of( all_count, group_sizes )
424
- end
425
-
426
- private
427
-
428
- def self.variations_of all_count, groups
429
- all_arrangements = factorial( all_count )
430
- # The reject is an optimisation to avoid calculating and multplying by factorial(1) (==1)
431
- identical_arrangements = groups.reject {|x| x==1 }.inject(1) { |prod,g| prod * factorial(g) }
432
- all_arrangements/identical_arrangements
433
- end
434
-
435
- # Returns counts of unique items in array e.g. [8,8,8,7,6,6] returns [1,2,3]
436
- # Sort is for caching
437
- def self.group_counts array
438
- array.group_by {|x| x}.values.map {|v| v.count}.sort
439
- end
440
-
441
- def self.factorial n
442
- # Can start range from 2 because we have pre-cached the result for n=1
443
- @@factorial_cache[n] ||= (2..n).inject(:*)
444
- end
445
- end