plai_testr 0.1.1

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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +176 -0
  5. data/bin/plai_testr +5 -0
  6. data/docs/frame_queue.md +123 -0
  7. data/docs/referee.md +59 -0
  8. data/lib/plai_testr/agents/ollama_client.rb +177 -0
  9. data/lib/plai_testr/agents/player_agent.rb +216 -0
  10. data/lib/plai_testr/agents/referee_agent.rb +56 -0
  11. data/lib/plai_testr/botrun.rb +60 -0
  12. data/lib/plai_testr/cli.rb +190 -0
  13. data/lib/plai_testr/config.rb +39 -0
  14. data/lib/plai_testr/deck.rb +11 -0
  15. data/lib/plai_testr/errors.rb +9 -0
  16. data/lib/plai_testr/event_log.rb +24 -0
  17. data/lib/plai_testr/game.rb +50 -0
  18. data/lib/plai_testr/generator.rb +50 -0
  19. data/lib/plai_testr/model_settings.rb +25 -0
  20. data/lib/plai_testr/names.rb +7 -0
  21. data/lib/plai_testr/personas.rb +97 -0
  22. data/lib/plai_testr/preflight.rb +70 -0
  23. data/lib/plai_testr/reporter/botrun_report.rb +141 -0
  24. data/lib/plai_testr/reporter/model_comparison.rb +104 -0
  25. data/lib/plai_testr/reporter/report_builder.rb +154 -0
  26. data/lib/plai_testr/reporter/think_log.rb +32 -0
  27. data/lib/plai_testr/rules_loader.rb +40 -0
  28. data/lib/plai_testr/session/frame_queue.rb +47 -0
  29. data/lib/plai_testr/session/manager.rb +77 -0
  30. data/lib/plai_testr/session/runner.rb +555 -0
  31. data/lib/plai_testr/state/base.rb +13 -0
  32. data/lib/plai_testr/state/game_state.rb +102 -0
  33. data/lib/plai_testr/state/player_state.rb +61 -0
  34. data/lib/plai_testr/version.rb +3 -0
  35. data/lib/plai_testr.rb +61 -0
  36. data/templates/app/.gitignore +3 -0
  37. data/templates/app/.rspec +3 -0
  38. data/templates/app/AGENTS.md +158 -0
  39. data/templates/app/CLAUDE.md +1 -0
  40. data/templates/app/Gemfile.erb +7 -0
  41. data/templates/app/README.md.erb +91 -0
  42. data/templates/app/config/plai_testr.rb +15 -0
  43. data/templates/app/spec/spec_helper.rb +25 -0
  44. data/templates/examples/gin_rummy/NOTES.md +12 -0
  45. data/templates/examples/gin_rummy/data/bots.yml +18 -0
  46. data/templates/examples/gin_rummy/data/personas.yml +26 -0
  47. data/templates/examples/gin_rummy/data/rules.json +76 -0
  48. data/templates/examples/gin_rummy/data/rules.md +44 -0
  49. data/templates/examples/gin_rummy/lib/gin_rummy/bot.rb +34 -0
  50. data/templates/examples/gin_rummy/lib/gin_rummy/game.rb +8 -0
  51. data/templates/examples/gin_rummy/lib/gin_rummy/melds.rb +51 -0
  52. data/templates/examples/gin_rummy/lib/gin_rummy/session/runner.rb +197 -0
  53. data/templates/examples/gin_rummy/lib/gin_rummy/state/game_state.rb +19 -0
  54. data/templates/examples/gin_rummy/lib/gin_rummy.rb +9 -0
  55. data/templates/examples/gin_rummy/spec/melds_spec.rb +26 -0
  56. data/templates/examples/gin_rummy/spec/session/runner_spec.rb +105 -0
  57. data/templates/examples/hearts/data/bots.yml +22 -0
  58. data/templates/examples/hearts/data/personas.yml +27 -0
  59. data/templates/examples/hearts/data/rules.json +73 -0
  60. data/templates/examples/hearts/data/rules.md +30 -0
  61. data/templates/examples/hearts/lib/hearts/bot.rb +53 -0
  62. data/templates/examples/hearts/lib/hearts/game.rb +16 -0
  63. data/templates/examples/hearts/lib/hearts/reporter/botrun_report.rb +29 -0
  64. data/templates/examples/hearts/lib/hearts/session/runner.rb +255 -0
  65. data/templates/examples/hearts/lib/hearts/state/game_state.rb +19 -0
  66. data/templates/examples/hearts/lib/hearts.rb +11 -0
  67. data/templates/examples/hearts/spec/session/runner_spec.rb +122 -0
  68. data/templates/game/data/bots.yml +13 -0
  69. data/templates/game/data/personas.yml +16 -0
  70. data/templates/game/data/rules.json.erb +16 -0
  71. data/templates/game/data/rules.md.erb +11 -0
  72. data/templates/game/lib/__name__/bot.rb.erb +7 -0
  73. data/templates/game/lib/__name__/session/runner.rb.erb +36 -0
  74. data/templates/game/lib/__name__/state/game_state.rb.erb +11 -0
  75. data/templates/game/lib/__name__.rb.erb +10 -0
  76. data/templates/game/spec/session/runner_spec.rb.erb +29 -0
  77. metadata +153 -0
@@ -0,0 +1,50 @@
1
+ require 'erb'
2
+ require 'pathname'
3
+
4
+ module PlaiTestr
5
+ class Generator
6
+ TEMPLATES = File.expand_path('../../templates', __dir__)
7
+ GAME_NAME = /\A[a-z][a-z0-9_]*\z/
8
+
9
+ def initialize(out: $stdout)
10
+ @out = out
11
+ end
12
+
13
+ def app(dir)
14
+ root = File.expand_path(dir)
15
+ raise UsageError, "#{dir} already exists and is not empty" if Dir.exist?(root) && !Dir.empty?(root)
16
+
17
+ copy(File.join(TEMPLATES, 'app'), root, app_name: File.basename(root), version: VERSION.split('.').first(2).join('.'))
18
+ Dir.children(File.join(TEMPLATES, 'examples')).sort.each do |example|
19
+ copy(File.join(TEMPLATES, 'examples', example), File.join(root, 'games', example))
20
+ end
21
+ root
22
+ end
23
+
24
+ def game(name, games_dir: PlaiTestr.games_dir)
25
+ raise UsageError, "Game names are lowercase snake_case, like my_game (got #{name.inspect})" unless name.match?(GAME_NAME)
26
+
27
+ root = File.join(games_dir, name)
28
+ words = name.split('_').map(&:capitalize)
29
+ raise UsageError, "#{root} already exists" if File.exist?(root)
30
+
31
+ copy(File.join(TEMPLATES, 'game'), root, name: name, module_name: words.join, title: words.join(' '))
32
+ root
33
+ end
34
+
35
+ private
36
+
37
+ def copy(source, target, **locals)
38
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: source).sort.each do |path|
39
+ from = File.join(source, path)
40
+ next if File.directory?(from)
41
+
42
+ to = File.join(target, path.gsub('__name__', locals[:name].to_s).delete_suffix('.erb'))
43
+ FileUtils.mkdir_p(File.dirname(to))
44
+ File.write(to, path.end_with?('.erb') ? ERB.new(File.read(from), trim_mode: '-').result_with_hash(locals) : File.read(from))
45
+ File.chmod(File.stat(from).mode, to)
46
+ @out.puts " create #{Pathname(to).relative_path_from(Dir.pwd)}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ module PlaiTestr
2
+ module ModelSettings
3
+ MIN_CONTEXT = 2048
4
+
5
+ def self.resolve(game_settings, model_max: nil)
6
+ settings = PlaiTestr.config.model_settings.merge(game_settings)
7
+ unknown = settings.keys - Config::MODEL_SETTINGS.keys
8
+ raise UsageError, "Unknown model setting(s): #{unknown.join(', ')} (known: #{Config::MODEL_SETTINGS.keys.join(', ')})" if unknown.any?
9
+
10
+ notes = []
11
+ clamp = lambda do |key, min, max|
12
+ bounded = settings[key].clamp(min, max)
13
+ notes << "#{key} #{settings[key]} is outside #{min}โ€“#{max}; using #{bounded}" unless bounded == settings[key]
14
+ settings[key] = bounded
15
+ end
16
+
17
+ %i[context_size long_context_size].each { |key| clamp.call(key, MIN_CONTEXT, model_max || [settings[key], MIN_CONTEXT].max) }
18
+ clamp.call(:temperature, 0.0, 2.0)
19
+ %i[menu_budget referee_budget].each { |key| clamp.call(key, 1, settings[:context_size] - 1) }
20
+ %i[think_budget debrief_budget].each { |key| clamp.call(key, 1, settings[:long_context_size] - 1) }
21
+
22
+ [settings, notes]
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ module PlaiTestr
2
+ def self.rename(text, names)
3
+ return text if names.empty?
4
+
5
+ text.gsub(Regexp.union(names.keys.sort_by { |id| -id.length })) { |id| names[id] }
6
+ end
7
+ end
@@ -0,0 +1,97 @@
1
+ require 'yaml'
2
+
3
+ module PlaiTestr
4
+ class Personas
5
+ WILDCARD = '*'
6
+ ALL = 'all'
7
+
8
+ def self.load(dir)
9
+ personas_file = read(File.join(dir, 'personas.yml'))
10
+ bots_file = read(File.join(dir, 'bots.yml'))
11
+
12
+ new(basic_strategy: section(personas_file, 'basic_strategy'), personas: section(personas_file, 'personas'),
13
+ bot_strategy: section(bots_file, 'bot_strategy'), bots: section(bots_file, 'bots'))
14
+ end
15
+
16
+ def self.read(path)
17
+ return {} unless File.exist?(path)
18
+
19
+ content = YAML.safe_load_file(path, aliases: true) || {}
20
+ raise PersonaError, "#{path}: expected keys at the top level, got #{content.class}" unless content.is_a?(Hash)
21
+
22
+ content.merge('_path' => path)
23
+ rescue Psych::SyntaxError => e
24
+ raise PersonaError, "#{path}: #{e.message}"
25
+ end
26
+
27
+ def self.section(file, key)
28
+ value = file[key] || {}
29
+ raise PersonaError, "#{file['_path']}: '#{key}' must be a set of key: value entries, got #{value.class}" unless value.is_a?(Hash)
30
+
31
+ value
32
+ end
33
+
34
+ attr_reader :basic_strategy, :bot_strategy
35
+
36
+ def initialize(basic_strategy: {}, personas: {}, bot_strategy: {}, bots: {})
37
+ @basic_strategy = basic_strategy
38
+ @personas = personas
39
+ @bot_strategy = bot_strategy
40
+ @bots = bots
41
+
42
+ @personas.each do |key, persona|
43
+ raise PersonaError, "personas.yml: persona '#{key}' must be a set of key: value entries" unless persona.is_a?(Hash)
44
+ end
45
+ orphans = @bots.keys - keys
46
+ raise PersonaError, "bots.yml: bots #{orphans.join(', ')} match no persona in personas.yml (personas: #{keys.join(', ')})" if orphans.any?
47
+ end
48
+
49
+ def keys = @personas.keys
50
+
51
+ def any? = @personas.any?
52
+
53
+ def name(key) = key.split('_').map(&:capitalize).join(' ')
54
+
55
+ def strategy(key) = @personas.fetch(key).values
56
+
57
+ def bot(key) = @bots.fetch(key, @bot_strategy)
58
+
59
+ def lint
60
+ @personas.filter_map do |key, persona|
61
+ missing = @basic_strategy.keys - persona.keys
62
+ "personas.yml: persona '#{key}' is missing basic_strategy #{missing.join(', ')} (add <<: *basics?)" if missing.any?
63
+ end
64
+ end
65
+
66
+ def check_request(request, players:, all: false)
67
+ request = Array(request)
68
+ return request if request.empty?
69
+ raise PersonaError, 'This game defines no personas (add data/personas.yml)' unless any?
70
+ return request if all && request == [ALL]
71
+ raise PersonaError, "#{request.length} personas given for #{players} players" if request.length > players
72
+
73
+ unknown = request - keys - [WILDCARD]
74
+ raise PersonaError, "Unknown persona #{unknown.first.inspect} (choose from #{keys.join(', ')}, or #{WILDCARD})" if unknown.any?
75
+
76
+ request
77
+ end
78
+
79
+ def assign(players, request, rng)
80
+ request = Array(request)
81
+ spare = keys - request
82
+ spare = keys if spare.empty?
83
+ pool = []
84
+
85
+ players.each_with_index.to_h do |player, i|
86
+ key = request[i]
87
+ if key.nil? || key == WILDCARD
88
+ pool = spare.shuffle(random: rng) if pool.empty?
89
+ key = pool.shift
90
+ end
91
+ [player, key]
92
+ end
93
+ end
94
+
95
+ def pairings(players) = keys.permutation(players).to_a
96
+ end
97
+ end
@@ -0,0 +1,70 @@
1
+ module PlaiTestr
2
+ class Preflight
3
+ LINT_SEEDS = (1..5).to_a.freeze
4
+ PROMPT_LIMIT = 0.9
5
+ START_COMMAND = { /darwin/ => 'brew services start ollama', /linux/ => 'sudo systemctl start ollama' }.freeze
6
+
7
+ def initialize(game:, players:, models:, personas: nil, out: $stdout)
8
+ @game = game
9
+ @players = players
10
+ @models = models
11
+ @personas = personas
12
+ @out = out
13
+ @client = Agents::OllamaClient.new(out: out)
14
+ end
15
+
16
+ def run(seed)
17
+ ollama!
18
+ prompt_size!(seed)
19
+ lint!
20
+ end
21
+
22
+ def ollama!
23
+ available = begin
24
+ @client.model_names
25
+ rescue ModelError => e
26
+ start = START_COMMAND.find { |platform, _| RUBY_PLATFORM.match?(platform) }&.last || 'ollama serve'
27
+ raise ModelError, "#{e.message}\nStart it with: #{start}"
28
+ end
29
+ @out.puts " Ollama at #{PlaiTestr.config.ollama_host}:#{PlaiTestr.config.ollama_port}: reachable"
30
+
31
+ wanted.each do |model|
32
+ @client.pull(model) unless available.include?(model) || available.include?("#{model}:latest")
33
+ @client.chat(model: model, messages: [{ role: 'user', content: 'Reply with OK.' }], think: false, num_predict: 1, keep_alive: '30m')
34
+ @out.puts " #{model}: ready"
35
+ end
36
+ end
37
+
38
+ def prompt_size!(seed)
39
+ probe = runner(seed)
40
+ probe.setup
41
+ player = probe.next_player
42
+ settings, = ModelSettings.resolve(@game.model_settings, model_max: @client.context_length(@models[:player]))
43
+ agent = Agents::PlayerAgent.new(name: player, model: @models[:player], client: @client, game_name: @game.rules.dig('game', 'name'),
44
+ settings: settings, think: @models[:think] == true)
45
+ used = agent.prompt_tokens(rules_text: probe.rules_text_for(player), view_text: probe.view_text_for(player),
46
+ labels: probe.menu(player).map { |m| m[:label] }, recent: probe.recent_events_for(player),
47
+ prompt_note: probe.prompt_note_for(player), persona: probe.persona_for(player))
48
+ limit = agent.context_size
49
+
50
+ if used > limit * PROMPT_LIMIT
51
+ raise ModelError, "The first prompt needs #{used} tokens, over #{(PROMPT_LIMIT * 100).round}% of the #{limit}-token context. " \
52
+ "Raise context_size#{' (long_context_size with --think)' if @models[:think]} in config/plai_testr.rb or in the game's registration."
53
+ end
54
+ @out.puts " First prompt: #{used} of #{limit} tokens"
55
+ end
56
+
57
+ def lint!
58
+ LINT_SEEDS.each { |seed| runner(seed).play_out }
59
+ @out.puts " Lint: #{LINT_SEEDS.length} bot games played without errors"
60
+ end
61
+
62
+ private
63
+
64
+ def wanted = @models.values_at(:player, :referee).compact.uniq
65
+
66
+ def runner(seed)
67
+ @game.runner_class.new(game: @game, players: @players, seed: seed, personas: @personas, mode: 'bot', persist: false, out: StringIO.new, echo_warnings: false)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,141 @@
1
+ module PlaiTestr
2
+ module Reporter
3
+ class BotrunReport
4
+ def initialize(results, game:, version:, seconds:, personas: nil, planned: results.length)
5
+ @results = results
6
+ @planned = planned
7
+ @played = results.reject { |r| r['error'] }
8
+ @game = game
9
+ @version = version
10
+ @seconds = seconds
11
+ @personas = personas
12
+ end
13
+
14
+ def to_markdown
15
+ [header, endings, persona_wins, matchups, seat_wins, lengths, rules_fired, *extra_sections, outliers, warnings, errors].compact.join("\n\n") + "\n"
16
+ end
17
+
18
+ private
19
+
20
+ def extra_sections = []
21
+
22
+ def header
23
+ seeds = @results.map { |r| r['seed'] }
24
+ games = ["#{@results.length}"]
25
+ games << " of #{@planned}, stopped early" if @results.length < @planned
26
+ games << " (seeds #{seeds.min}โ€“#{seeds.max})" if seeds.any?
27
+ games << ", #{@results.length - @played.length} crashed" if @played.length < @results.length
28
+
29
+ rows = [
30
+ "| **Rules version** | #{@version} |",
31
+ "| **Games** | #{games.join} |",
32
+ "| **Players** | #{@played.first&.dig('names')&.length || 'โ€”'} |"
33
+ ]
34
+ rows << "| **Personas** | #{@personas ? @personas.join(', ') : 'random per seed'} |" if personas?
35
+ rows << "| **Time** | #{@seconds.round(1)} s (#{(@seconds * 1000 / [@results.length, 1].max).round} ms per game) |"
36
+ rows << "| **Generated** | #{Time.now.strftime('%Y-%m-%d %H:%M')} |"
37
+
38
+ "# Botrun: #{@game}\n\n| | |\n|---|---|\n#{rows.join("\n")}\n\n" \
39
+ "Every decision was made by the game's bot rules; no model was called. Results describe how the bot plays, so confirm anything surprising with a few model games."
40
+ end
41
+
42
+ def personas? = @personas || @played.any? { |r| r['personas'] }
43
+
44
+ def endings
45
+ rows = @played.group_by { |r| r['end_reason'] || 'unknown' }.sort_by { |_, list| -list.length }
46
+ .map { |reason, list| "| #{reason} | #{list.length} | #{pct(list.length, @played.length)} |" }
47
+ table('## How games ended', %w[Ending Games Share], rows)
48
+ end
49
+
50
+ def persona_wins
51
+ seats = @played.flat_map { |r| r['personas'].to_h.map { |player, persona| [persona, r['winner'] == player, r['winner'].nil?] } }
52
+ return nil if seats.empty?
53
+
54
+ rows = seats.group_by(&:first).sort.map do |persona, list|
55
+ wins = list.count { |_, won, _| won }
56
+ "| #{persona} | #{list.length} | #{wins} | #{list.count { |_, _, draw| draw }} | #{pct(wins, list.length)} | #{wilson(wins, list.length)} |"
57
+ end
58
+ table('## Wins by persona', ['Persona', 'Games', 'Wins', 'Draws', 'Win rate', '95% range'], rows,
59
+ 'A persona playing itself counts once per seat. The 95% range is how far the true win rate could plausibly be from this sample; ranges that overlap are not a real difference.')
60
+ end
61
+
62
+ def matchups
63
+ pairs = @played.select { |r| r['personas'].to_h.length == 2 && r['personas'].values.uniq.length == 2 }
64
+ return nil if pairs.empty?
65
+
66
+ rows = pairs.group_by { |r| r['personas'].values.sort }.sort.map do |(a, b), list|
67
+ wins_a = list.count { |r| r['personas'][r['winner']] == a }
68
+ wins_b = list.count { |r| r['personas'][r['winner']] == b }
69
+ "| #{a} vs #{b} | #{list.length} | #{wins_a} | #{wins_b} | #{list.length - wins_a - wins_b} | #{pct(wins_a, list.length)} #{wilson(wins_a, list.length)} |"
70
+ end
71
+ table('## Matchups', ['Matchup', 'Games', 'First wins', 'Second wins', 'Draws', 'First win rate (95% range)'], rows)
72
+ end
73
+
74
+ def seat_wins
75
+ seats = @played.first&.dig('names')&.keys or return nil
76
+ rows = seats.map do |seat|
77
+ wins = @played.count { |r| r['winner'] == seat }
78
+ "| #{seat} | #{wins} | #{pct(wins, @played.length)} | #{wilson(wins, @played.length)} |"
79
+ end
80
+ table('## Wins by seat', ['Seat', 'Wins', 'Win rate', '95% range'], rows)
81
+ end
82
+
83
+ def lengths
84
+ counts = @played.map { |r| r['decisions'] }.sort
85
+ return nil if counts.empty?
86
+
87
+ stats = [counts.sum.fdiv(counts.length).round, percentile(counts, 50), percentile(counts, 90), counts.max]
88
+ table('## Game length (decisions)', %w[Mean Median 90th Max], ["| #{stats.join(' | ')} |"])
89
+ end
90
+
91
+ def rules_fired
92
+ tally = @played.each_with_object(Hash.new(0)) { |r, sum| r['rules'].each { |rule, n| sum[rule] += n } }
93
+ total = tally.values.sum
94
+ rows = tally.sort_by { |_, n| -n }.map { |rule, n| "| #{rule} | #{n} | #{pct(n, total)} |" }
95
+ table('## Bot rules used', %w[Rule Decisions Share], rows,
96
+ '`none` means the bot had no rule for that decision and took option 1; a large share points at a gap in the bot.')
97
+ end
98
+
99
+ def outliers
100
+ longest = @played.max_by(5) { |r| r['decisions'] }.map { |r| "seed #{r['seed']} (#{r['decisions']} decisions, #{r['end_reason']})" }
101
+ common = @played.map { |r| r['end_reason'] }.tally.max_by { |_, n| n }&.first
102
+ rare = @played.reject { |r| r['end_reason'] == common }.group_by { |r| r['end_reason'] }
103
+ .map { |reason, list| "- #{reason}: seeds #{list.first(10).map { |r| r['seed'] }.join(', ')}#{' โ€ฆ' if list.length > 10}" }
104
+ "## Games to look at\n\n- Longest: #{longest.join('; ')}\n#{rare.join("\n")}".strip
105
+ end
106
+
107
+ def warnings
108
+ tally = @played.flat_map { |r| r['warnings'].to_a.uniq }.tally
109
+ return nil if tally.empty?
110
+
111
+ rows = tally.sort_by { |_, games| -games }.map { |text, games| "| #{text.gsub('|', '\\|')} | #{games} |" }
112
+ table('## Warnings', %w[Warning Games], rows)
113
+ end
114
+
115
+ def errors
116
+ crashed = @results.select { |r| r['error'] }
117
+ return nil if crashed.empty?
118
+
119
+ "## Crashes\n\n#{crashed.first(20).map { |r| "- seed #{r['seed']}: #{r['error']}" }.join("\n")}"
120
+ end
121
+
122
+ def table(title, headers, rows, note = nil)
123
+ ["#{title}\n", "| #{headers.join(' | ')} |", "|#{'---|' * headers.length}", *rows, (note && "\n#{note}")].compact.join("\n")
124
+ end
125
+
126
+ def pct(count, total) = total.zero? ? 'โ€”' : "#{(100.0 * count / total).round}%"
127
+
128
+ def percentile(sorted, p) = sorted[((p / 100.0) * (sorted.length - 1)).round]
129
+
130
+ def wilson(wins, total)
131
+ return 'โ€”' if total.zero?
132
+
133
+ z = 1.96
134
+ rate = wins.fdiv(total)
135
+ center = (rate + z**2 / (2 * total)) / (1 + z**2 / total)
136
+ half = z * Math.sqrt(rate * (1 - rate) / total + z**2 / (4 * total**2)) / (1 + z**2 / total)
137
+ "#{((center - half) * 100).round}โ€“#{((center + half) * 100).round}%"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,104 @@
1
+ module PlaiTestr
2
+ module Reporter
3
+ class ModelComparison
4
+ BASE_COLUMNS = [
5
+ 'Model', 'Host', 'Games', 'Seeds', 'Avg minutes', 'Avg s / call', 'Prompt tok/s', 'Gen tok/s',
6
+ 'Avg / max prompt tokens', 'Retries', 'Fallbacks', 'Endings', 'Wins by persona'
7
+ ].freeze
8
+
9
+ BASE_NOTES = [
10
+ '*Avg s / call, Prompt tok/s, Gen tok/s*: Ollama\'s own timings per model call. Compare hosts on the same model and seeds. Sessions from before these were logged show โ€”.',
11
+ '*Retries / fallbacks*: reliability. A fallback means the model never gave a valid choice and the bot (or a random pick) chose instead.',
12
+ '*Endings*: each game\'s end reason and how often it happened.',
13
+ '*Wins by persona*: the winning player\'s persona in each decided game, for games that assign personas.',
14
+ '*Debrief answers given*: non-empty, non-"nothing" answers to the end-of-game questions. Read them in the session reports; count alone says little.'
15
+ ].freeze
16
+
17
+ def initialize(sessions_dir, since: nil)
18
+ @sessions_dir = sessions_dir
19
+ @since = since
20
+ end
21
+
22
+ def to_markdown
23
+ groups = sessions.group_by { |s| [s[:model], s[:host]] }
24
+ return "No completed sessions with a recorded player model#{" since #{@since}" if @since}.\n" if groups.empty?
25
+
26
+ headers = BASE_COLUMNS + extra_columns.map(&:first) + ['Debrief answers given']
27
+ rows = groups.map { |(model, host), list| "| #{row(model, host, list).join(' | ')} |" }
28
+
29
+ <<~MD
30
+ # #{title}
31
+
32
+ Generated #{Time.now.strftime('%Y-%m-%d %H:%M')}#{" โ€” sessions since #{@since}" if @since}.
33
+
34
+ | #{headers.join(' | ')} |
35
+ |#{'---|' * headers.length}
36
+ #{rows.join("\n")}
37
+
38
+ **How to read it**
39
+ #{(BASE_NOTES + notes).map { |n| "- #{n}" }.join("\n")}
40
+ MD
41
+ end
42
+
43
+ private
44
+
45
+ def title = 'Model comparison'
46
+
47
+ def extra_columns = []
48
+
49
+ def notes = []
50
+
51
+ def sessions
52
+ Dir.glob(File.join(@sessions_dir, '*', 'state.json')).filter_map do |path|
53
+ id = File.basename(File.dirname(path))
54
+ next if @since && id < @since
55
+
56
+ data = JSON.parse(File.read(path))
57
+ state = data['game_state']
58
+ model = state.dig('data', 'player_model')
59
+ next unless data['status'] == 'completed' && model
60
+
61
+ { model: "#{model}#{' +think' if state.dig('data', 'think')}", host: state.dig('data', 'host') || 'unknown', seed: state.dig('data', 'seed'), state: state,
62
+ turns: data['turn_log'].reject { |t| t['auto'] }, log: ThinkLog.load(File.join(File.dirname(path), 'think.log')) }
63
+ rescue JSON::ParserError
64
+ nil
65
+ end
66
+ end
67
+
68
+ def row(model, host, list)
69
+ logs = list.map { |s| s[:log] }
70
+ turns = list.flat_map { |s| s[:turns] }
71
+ tokens = logs.flat_map { |l| l.values('prompt_tokens') }
72
+ answers = list.flat_map { |s| s[:state].dig('data', 'debrief').to_h.values.flatten }
73
+ given = answers.count { |a| !a['answer'].to_s.strip.empty? && !a['answer'].to_s.strip.match?(/\Anothing\.?\z/i) }
74
+
75
+ [
76
+ "`#{model}`", host, list.length, list.map { |s| s[:seed] }.sort.join(', '),
77
+ avg(logs.filter_map(&:minutes), 1),
78
+ avg(logs.flat_map { |l| l.values('total_s') }, 1),
79
+ avg(logs.flat_map { |l| l.values('prompt_tps') }, 0),
80
+ avg(logs.flat_map { |l| l.values('eval_tps') }, 1),
81
+ tokens.empty? ? 'โ€”' : "#{avg(tokens, 0)} / #{tokens.max.round}",
82
+ turns.count { |t| t['attempts'].to_i > 1 },
83
+ turns.count { |t| t['fallback'] },
84
+ list.map { |s| s[:state].dig('status', 'end_reason') || 'unknown' }.tally.map { |r, c| "#{r} #{c}" }.join(', '),
85
+ persona_wins(list),
86
+ *extra_columns.map { |_, value| value.call(list) },
87
+ answers.empty? ? 'โ€”' : "#{given}/#{answers.length}"
88
+ ]
89
+ end
90
+
91
+ def persona_wins(list)
92
+ wins = list.filter_map { |s| s[:state].dig('data', 'personas', s[:state].dig('status', 'winner').to_s) }
93
+ wins.empty? ? 'โ€”' : wins.tally.map { |label, count| "#{label} #{count}" }.join(', ')
94
+ end
95
+
96
+ def avg(values, digits)
97
+ return 'โ€”' if values.empty?
98
+
99
+ mean = values.sum.to_f / values.length
100
+ digits.zero? ? mean.round : mean.round(digits)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,154 @@
1
+ module PlaiTestr
2
+ module Reporter
3
+ class ReportBuilder
4
+ def initialize(session_id:, rules:, game_state:, turn_log:, think_log: ThinkLog.new(''), sections: [], names: {}, debrief: true, warnings: [])
5
+ @session_id = session_id
6
+ @rules = rules
7
+ @game_state = game_state
8
+ @turn_log = turn_log
9
+ @think_log = think_log
10
+ @sections = sections
11
+ @names = names
12
+ @debrief = debrief
13
+ @warnings = warnings
14
+ end
15
+
16
+ def build
17
+ report = [header, result, reliability, bot_agreement, speed, warnings, *@sections, debrief, turn_log_section].compact.join("\n\n---\n\n")
18
+ path = File.join(PlaiTestr.reports_dir, "#{@session_id}.md")
19
+ File.write(path, report)
20
+ path
21
+ end
22
+
23
+ private
24
+
25
+ def header
26
+ data = @game_state.data
27
+ settings = data['model_settings']
28
+ bot_only = data['mode'] == 'bot'
29
+ rows = [
30
+ "| **Session** | `#{@session_id}` |",
31
+ "| **Rules version** | #{@rules.dig('game', 'version')} |",
32
+ "| **Players** | #{@game_state.players.map { |p| name(p).include?(p) ? name(p) : "#{name(p)} (#{p})" }.join(', ')} |",
33
+ bot_only ? '| **Player model** | none โ€” mode: bot |' : "| **Player model** | #{data['player_model'] || 'unknown'}#{' (thinking)' if data['think']}#{" โ€” mode: #{data['mode']}" if data['mode'] && data['mode'] != 'llm'} |",
34
+ "| **Host** | #{data['host'] || 'unknown'} |",
35
+ "| **Seed** | #{data['seed']} |"
36
+ ]
37
+ rows << "| **Personas** | #{data['personas'].map { |p, label| "#{p}: #{label}" }.join('; ')} |" if data['personas']
38
+ rows << "| **Model settings** | #{settings.map { |key, value| "#{key} #{value}" }.join(', ')} |" if settings && !bot_only
39
+ rows << "| **Decisions** | #{@game_state.turn} |"
40
+ rows << "| **Generated** | #{Time.now.strftime('%Y-%m-%d %H:%M')} |"
41
+
42
+ "# Playtest Report: #{@rules.dig('game', 'name')}\n\n| | |\n|---|---|\n#{rows.join("\n")}"
43
+ end
44
+
45
+ def result
46
+ status = @game_state.status
47
+ winner = status['winner'] || leader
48
+ scores = @game_state.scores.map { |p, s| "| #{name(p)} | #{s} |" }.join("\n")
49
+
50
+ <<~MD.strip
51
+ ## Result
52
+
53
+ **Winner: #{winner ? name(winner) : 'none'}**#{" (#{status['end_reason'].to_s.tr('_', ' ')})" if status['end_reason']}
54
+
55
+ | Player | Score |
56
+ |---|---|
57
+ #{scores}
58
+ MD
59
+ end
60
+
61
+ def name(player) = @names.fetch(player, player)
62
+
63
+ def leader
64
+ top = @game_state.scores.select { |_, s| s.to_i.positive? }.max_by { |_, s| s.to_i }
65
+ top && @game_state.scores.values.count(top.last) == 1 ? top.first : nil
66
+ end
67
+
68
+ def reliability
69
+ bot = @turn_log.count { |t| t['decider'] == 'BOT' && !t['fallback'] }
70
+ asked = @turn_log.reject { |t| t['auto'] || (t['decider'] == 'BOT' && !t['fallback']) }
71
+ fallbacks = asked.count { |t| t['fallback'] }
72
+ rate = asked.empty? ? 0 : (100.0 * fallbacks / asked.length).round(1)
73
+ full = @turn_log.count { |t| t['context_full'] }
74
+
75
+ <<~MD.strip
76
+ ## Model reliability
77
+
78
+ | Decisions | Asked the model | Bot rules | Only one option | Needed a retry | Fallbacks (no valid choice โ†’ ๐Ÿค– bot or ๐ŸŽฒ random) | Fallback rate | Context nearly full |
79
+ |---|---|---|---|---|---|---|---|
80
+ | #{@turn_log.length} | #{asked.length} | #{bot} | #{@turn_log.length - asked.length - bot} | #{asked.count { |t| t['attempts'].to_i > 1 }} | #{fallbacks} | #{rate}% | #{full} |
81
+ MD
82
+ end
83
+
84
+ def bot_agreement
85
+ shadowed = @turn_log.select { |t| t['bot_rule'] }
86
+ return nil if shadowed.empty?
87
+
88
+ rows = shadowed.group_by { |t| [t['bot_rule'], t['bot_obvious']] }.sort_by { |(rule, obvious), _| [obvious ? 0 : 1, rule] }.map do |(rule, obvious), list|
89
+ agreed = list.count { |t| Array(t['choice']).sort == Array(t['bot_choice']).sort }
90
+ "| #{rule} | #{obvious ? 'obvious' : 'default'} | #{list.length} | #{agreed} | #{(100.0 * agreed / list.length).round}% |"
91
+ end
92
+
93
+ <<~MD.strip
94
+ ## Bot agreement
95
+
96
+ On model turns the bot's choice is recorded too. Low agreement on an *obvious* rule means the model missed a clearly best move (or the rule is wrong); *default* rules are only the bot's guess. Fallback turns are left out.
97
+
98
+ | Bot rule | Kind | Model turns | Model agreed | Agreement |
99
+ |---|---|---|---|---|
100
+ #{rows.join("\n")}
101
+ MD
102
+ end
103
+
104
+ def speed
105
+ log = @think_log
106
+ return "## Speed\n\nNo model calls were logged." if log.calls.empty?
107
+
108
+ cell = ->(value, digits = 1) { value ? value.round(digits) : 'โ€”' }
109
+
110
+ <<~MD.strip
111
+ ## Speed
112
+
113
+ | Model calls | Minutes | Avg s / call | Avg prompt tokens | Prompt tok/s | Gen tok/s | Model load s (total) |
114
+ |---|---|---|---|---|---|---|
115
+ | #{log.calls.length} | #{cell.call(log.minutes)} | #{cell.call(log.average('total_s'))} | #{cell.call(log.average('prompt_tokens'), 0)} | #{cell.call(log.average('prompt_tps'), 0)} | #{cell.call(log.average('eval_tps'))} | #{cell.call(log.values('load_s').sum)} |
116
+ MD
117
+ end
118
+
119
+ def warnings
120
+ return nil if @warnings.empty?
121
+
122
+ "## Warnings\n\n#{@warnings.tally.map { |text, count| "- #{text}#{" (ร—#{count})" if count > 1}" }.join("\n")}"
123
+ end
124
+
125
+ def debrief
126
+ answers = @game_state.data['debrief']
127
+ return nil unless @debrief && answers&.any?
128
+
129
+ players = answers.map do |player, list|
130
+ "**#{name(player)}**\n\n#{list.map { |a| "- *#{a['question']}*\n #{a['answer'].to_s.empty? ? '_(no answer)_' : a['answer']}" }.join("\n")}"
131
+ end
132
+
133
+ "## Debrief\n\n#{players.join("\n\n")}"
134
+ end
135
+
136
+ def turn_line(turn)
137
+ parts = ["- **#{turn['turn']}. #{name(turn['player'])}"]
138
+ parts << " ยท #{turn['phase']}" if turn['phase']
139
+ parts << ':** '
140
+ parts << "#{DECIDER_ICONS[turn['decider']]} " if turn['decider']
141
+ parts << PlaiTestr.rename((turn['log'] || turn['label']).to_s, @names)
142
+ parts << ' _(only option)_' if turn['auto']
143
+ parts << " _(rule: #{turn['rule']})_" if turn['decider'] == 'BOT'
144
+ parts << ' **[FALLBACK]**' if turn['fallback']
145
+ parts << "\n > #{turn['reasoning']}" unless turn['reasoning'].to_s.empty?
146
+ parts.join
147
+ end
148
+
149
+ def turn_log_section
150
+ "## Turn log\n\n<details><summary>#{@turn_log.length} decisions</summary>\n\n#{@turn_log.map { |t| turn_line(t) }.join("\n")}\n\n</details>"
151
+ end
152
+ end
153
+ end
154
+ end