Skip to content

Commit a360080

Browse files
dnd-character: chi-squared tests (#584)
1 parent d3d77df commit a360080

7 files changed

Lines changed: 299 additions & 42 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Instructions append
2+
3+
## Randomness in Zig
4+
5+
The Zig standard library avoids hidden global state, so rather than keeping a random number generator of your own, your functions receive a [`std.Random`][random] interface to draw values from.
6+
The tests pass in a generator seeded with [`std.testing.random_seed`][random-seed], and check that generators with the same seed produce the same characters.
7+
8+
## Checking randomness
9+
10+
The distribution of your ability scores is checked by [chi-squared tests][chi-squared-test] at a [significance level][p-value] of `p < 0.0001`:
11+
12+
- the scores returned by `ability` are compared with the expected distribution;
13+
- each of a character's six abilities is compared with the expected distribution;
14+
- the pattern of odd and even scores across a character's six abilities is compared with what independent abilities would produce.
15+
16+
A correct implementation has less than a 0.01% chance of failing each test.
17+
18+
See the instructions above for a definition of what an ability score is.
19+
20+
[random]: https://ziglang.org/documentation/0.16.0/std/#std.Random
21+
[random-seed]: https://ziglang.org/documentation/0.16.0/std/#std.testing.random_seed
22+
[chi-squared-test]: https://en.wikipedia.org/wiki/Pearson%27s_chi-squared_test
23+
[p-value]: https://en.wikipedia.org/wiki/P-value

exercises/practice/dnd-character/.meta/config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"authors": [
33
"ee7"
44
],
5+
"contributors": [
6+
"keiravillekode"
7+
],
58
"files": {
69
"solution": [
710
"dnd_character.zig"
Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
const std = @import("std");
22

3-
var prng = std.Random.DefaultPrng.init(42);
4-
const random = prng.random();
3+
pub fn modifier(score: u8) i8 {
4+
return @divFloor(@as(i8, @intCast(score)) - 10, 2);
5+
}
56

6-
pub fn ability() u8 {
7+
pub fn ability(random: std.Random) u8 {
78
var lowest: u8 = std.math.maxInt(u8);
89
var result: u8 = 0;
910
for (0..4) |_| {
1011
const roll = random.intRangeAtMost(u8, 1, 6);
1112
result += roll;
1213
lowest = @min(lowest, roll);
1314
}
14-
result -= lowest;
15-
return result;
16-
}
17-
18-
pub fn modifier(score: u8) i8 {
19-
return @divFloor(@as(i8, @intCast(score)) - 10, 2);
15+
return result - lowest;
2016
}
2117

2218
pub const Character = struct {
@@ -28,16 +24,16 @@ pub const Character = struct {
2824
charisma: u8,
2925
hitpoints: u8,
3026

31-
pub fn init() Character {
32-
const constitution = ability();
27+
pub fn init(random: std.Random) Character {
28+
const constitution = ability(random);
3329
return .{
34-
.strength = ability(),
35-
.dexterity = ability(),
30+
.strength = ability(random),
31+
.dexterity = ability(random),
3632
.constitution = constitution,
37-
.intelligence = ability(),
38-
.wisdom = ability(),
39-
.charisma = ability(),
40-
.hitpoints = @as(u8, @intCast(10 + modifier(constitution))),
33+
.intelligence = ability(random),
34+
.wisdom = ability(random),
35+
.charisma = ability(random),
36+
.hitpoints = @intCast(10 + modifier(constitution)),
4137
};
4238
}
4339
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"cases": [
3+
{
4+
"description": "random ability is distributed correctly",
5+
"property": "abilityDistribution",
6+
"input": {},
7+
"expected": "chi-squared test against the 4d6-drop-lowest distribution passes at p < 0.0001"
8+
},
9+
{
10+
"description": "each character ability is distributed correctly",
11+
"property": "characterDistribution",
12+
"input": {},
13+
"expected": "chi-squared test of each ability against the 4d6-drop-lowest distribution passes at p < 0.0001"
14+
},
15+
{
16+
"description": "character abilities are independent",
17+
"property": "characterParity",
18+
"input": {},
19+
"expected": "chi-squared test of the odd/even pattern of the six abilities passes at p < 0.0001"
20+
},
21+
{
22+
"description": "character depends only on the random number generator",
23+
"property": "sameSeed",
24+
"input": {},
25+
"expected": "generators with the same seed produce the same characters"
26+
}
27+
]
28+
}
Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
pub fn modifier(score: i8) i8 {
1+
const std = @import("std");
2+
3+
pub fn modifier(score: u8) i8 {
24
_ = score;
35
@compileError("please implement the modifier function");
46
}
57

6-
pub fn ability() i8 {
8+
pub fn ability(random: std.Random) u8 {
9+
_ = random;
710
@compileError("please implement the ability function");
811
}
912

1013
pub const Character = struct {
11-
strength: i8,
12-
dexterity: i8,
13-
constitution: i8,
14-
intelligence: i8,
15-
wisdom: i8,
16-
charisma: i8,
17-
hitpoints: i8,
14+
strength: u8,
15+
dexterity: u8,
16+
constitution: u8,
17+
intelligence: u8,
18+
wisdom: u8,
19+
charisma: u8,
20+
hitpoints: u8,
1821

19-
pub fn init() Character {
22+
pub fn init(random: std.Random) Character {
23+
_ = random;
2024
@compileError("please implement the init method");
2125
}
2226
};

exercises/practice/dnd-character/test_dnd_character.zig

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,54 @@ fn isValid(c: Character) bool {
1818
(c.hitpoints == 10 + dnd_character.modifier(c.constitution));
1919
}
2020

21+
fn abilityScores(c: Character) [6]u8 {
22+
return .{ c.strength, c.dexterity, c.constitution, c.intelligence, c.wisdom, c.charisma };
23+
}
24+
25+
/// The number of times each score from 3 to 18 arises among the
26+
/// 6 * 6 * 6 * 6 = 1296 equally likely rolls of four dice.
27+
const score_weights = [16]u64{ 1, 4, 10, 21, 38, 62, 91, 122, 148, 167, 172, 160, 131, 94, 54, 21 };
28+
29+
/// The number of times each pattern of odd (1) and even (0) scores arises
30+
/// among the 1296 ^ 6 equally likely ways of rolling a character's six abilities.
31+
const parity_weights = blk: {
32+
var odd: u64 = 0;
33+
var even: u64 = 0;
34+
for (score_weights, 3..) |weight, score| {
35+
if (score % 2 == 1) odd += weight else even += weight;
36+
}
37+
var weights: [64]u64 = undefined;
38+
for (&weights, 0..) |*weight, pattern| {
39+
const odd_count = @popCount(@as(u6, @intCast(pattern)));
40+
weight.* = std.math.pow(u64, odd, odd_count) * std.math.pow(u64, even, 6 - odd_count);
41+
}
42+
break :blk weights;
43+
};
44+
45+
/// Number of samples in each statistical test.
46+
const sample_size = 100 * 1296;
47+
48+
/// Upper critical values of the chi-squared distribution at p = 0.0001,
49+
/// for 16 - 1 and 64 - 1 degrees of freedom.
50+
const critical_value_15: f64 = 44.2633;
51+
const critical_value_63: f64 = 113.505;
52+
53+
/// Pearson's chi-squared statistic for the observed `counts`, when the
54+
/// expected counts are proportional to `weights`.
55+
fn chiSquared(counts: []const u64, weights: []const u64) f64 {
56+
var total_count: u64 = 0;
57+
for (counts) |count| total_count += count;
58+
var total_weight: u64 = 0;
59+
for (weights) |weight| total_weight += weight;
60+
var statistic: f64 = 0;
61+
for (counts, weights) |count, weight| {
62+
const expected = @as(f64, @floatFromInt(total_count)) * @as(f64, @floatFromInt(weight)) / @as(f64, @floatFromInt(total_weight));
63+
const difference = @as(f64, @floatFromInt(count)) - expected;
64+
statistic += difference * difference / expected;
65+
}
66+
return statistic;
67+
}
68+
2169
test "ability modifier for score 3 is -4" {
2270
const expected: i8 = -4;
2371
const actual = dnd_character.modifier(3);
@@ -115,15 +163,72 @@ test "ability modifier for score 18 is +4" {
115163
}
116164

117165
test "random ability is within range" {
166+
var prng = std.Random.DefaultPrng.init(testing.random_seed);
167+
const random = prng.random();
118168
for (0..20) |_| {
119-
const actual = dnd_character.ability();
169+
const actual = dnd_character.ability(random);
120170
try testing.expect(isValidAbilityScore(actual));
121171
}
122172
}
123173

174+
test "random ability is distributed correctly" {
175+
var prng = std.Random.DefaultPrng.init(testing.random_seed);
176+
const random = prng.random();
177+
var counts: [16]u64 = @splat(0);
178+
for (0..sample_size) |_| {
179+
const score = dnd_character.ability(random);
180+
try testing.expect(isValidAbilityScore(score));
181+
counts[score - 3] += 1;
182+
}
183+
try testing.expect(chiSquared(&counts, &score_weights) < critical_value_15);
184+
}
185+
124186
test "random character is valid" {
187+
var prng = std.Random.DefaultPrng.init(testing.random_seed);
188+
const random = prng.random();
125189
for (0..20) |_| {
126-
const character = Character.init();
190+
const character = Character.init(random);
127191
try testing.expect(isValid(character));
128192
}
129193
}
194+
195+
test "each character ability is distributed correctly" {
196+
var prng = std.Random.DefaultPrng.init(testing.random_seed);
197+
const random = prng.random();
198+
var counts: [6][16]u64 = @splat(@splat(0));
199+
for (0..sample_size) |_| {
200+
const character = Character.init(random);
201+
for (abilityScores(character), &counts) |score, *ability_counts| {
202+
try testing.expect(isValidAbilityScore(score));
203+
ability_counts[score - 3] += 1;
204+
}
205+
}
206+
for (counts) |ability_counts| {
207+
try testing.expect(chiSquared(&ability_counts, &score_weights) < critical_value_15);
208+
}
209+
}
210+
211+
test "character abilities are independent" {
212+
var prng = std.Random.DefaultPrng.init(testing.random_seed);
213+
const random = prng.random();
214+
var counts: [64]u64 = @splat(0);
215+
for (0..sample_size) |_| {
216+
const character = Character.init(random);
217+
var pattern: usize = 0;
218+
for (abilityScores(character)) |score| {
219+
pattern = 2 * pattern + score % 2;
220+
}
221+
counts[pattern] += 1;
222+
}
223+
try testing.expect(chiSquared(&counts, &parity_weights) < critical_value_63);
224+
}
225+
226+
test "character depends only on the random number generator" {
227+
var prng = std.Random.DefaultPrng.init(testing.random_seed);
228+
var other_prng = std.Random.DefaultPrng.init(testing.random_seed);
229+
for (0..20) |_| {
230+
const character = Character.init(prng.random());
231+
const other_character = Character.init(other_prng.random());
232+
try testing.expectEqual(character, other_character);
233+
}
234+
}

0 commit comments

Comments
 (0)