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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/bin/plai_testr +5 -0
- data/docs/frame_queue.md +123 -0
- data/docs/referee.md +59 -0
- data/lib/plai_testr/agents/ollama_client.rb +177 -0
- data/lib/plai_testr/agents/player_agent.rb +216 -0
- data/lib/plai_testr/agents/referee_agent.rb +56 -0
- data/lib/plai_testr/botrun.rb +60 -0
- data/lib/plai_testr/cli.rb +190 -0
- data/lib/plai_testr/config.rb +39 -0
- data/lib/plai_testr/deck.rb +11 -0
- data/lib/plai_testr/errors.rb +9 -0
- data/lib/plai_testr/event_log.rb +24 -0
- data/lib/plai_testr/game.rb +50 -0
- data/lib/plai_testr/generator.rb +50 -0
- data/lib/plai_testr/model_settings.rb +25 -0
- data/lib/plai_testr/names.rb +7 -0
- data/lib/plai_testr/personas.rb +97 -0
- data/lib/plai_testr/preflight.rb +70 -0
- data/lib/plai_testr/reporter/botrun_report.rb +141 -0
- data/lib/plai_testr/reporter/model_comparison.rb +104 -0
- data/lib/plai_testr/reporter/report_builder.rb +154 -0
- data/lib/plai_testr/reporter/think_log.rb +32 -0
- data/lib/plai_testr/rules_loader.rb +40 -0
- data/lib/plai_testr/session/frame_queue.rb +47 -0
- data/lib/plai_testr/session/manager.rb +77 -0
- data/lib/plai_testr/session/runner.rb +555 -0
- data/lib/plai_testr/state/base.rb +13 -0
- data/lib/plai_testr/state/game_state.rb +102 -0
- data/lib/plai_testr/state/player_state.rb +61 -0
- data/lib/plai_testr/version.rb +3 -0
- data/lib/plai_testr.rb +61 -0
- data/templates/app/.gitignore +3 -0
- data/templates/app/.rspec +3 -0
- data/templates/app/AGENTS.md +158 -0
- data/templates/app/CLAUDE.md +1 -0
- data/templates/app/Gemfile.erb +7 -0
- data/templates/app/README.md.erb +91 -0
- data/templates/app/config/plai_testr.rb +15 -0
- data/templates/app/spec/spec_helper.rb +25 -0
- data/templates/examples/gin_rummy/NOTES.md +12 -0
- data/templates/examples/gin_rummy/data/bots.yml +18 -0
- data/templates/examples/gin_rummy/data/personas.yml +26 -0
- data/templates/examples/gin_rummy/data/rules.json +76 -0
- data/templates/examples/gin_rummy/data/rules.md +44 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/bot.rb +34 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/game.rb +8 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/melds.rb +51 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/session/runner.rb +197 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/state/game_state.rb +19 -0
- data/templates/examples/gin_rummy/lib/gin_rummy.rb +9 -0
- data/templates/examples/gin_rummy/spec/melds_spec.rb +26 -0
- data/templates/examples/gin_rummy/spec/session/runner_spec.rb +105 -0
- data/templates/examples/hearts/data/bots.yml +22 -0
- data/templates/examples/hearts/data/personas.yml +27 -0
- data/templates/examples/hearts/data/rules.json +73 -0
- data/templates/examples/hearts/data/rules.md +30 -0
- data/templates/examples/hearts/lib/hearts/bot.rb +53 -0
- data/templates/examples/hearts/lib/hearts/game.rb +16 -0
- data/templates/examples/hearts/lib/hearts/reporter/botrun_report.rb +29 -0
- data/templates/examples/hearts/lib/hearts/session/runner.rb +255 -0
- data/templates/examples/hearts/lib/hearts/state/game_state.rb +19 -0
- data/templates/examples/hearts/lib/hearts.rb +11 -0
- data/templates/examples/hearts/spec/session/runner_spec.rb +122 -0
- data/templates/game/data/bots.yml +13 -0
- data/templates/game/data/personas.yml +16 -0
- data/templates/game/data/rules.json.erb +16 -0
- data/templates/game/data/rules.md.erb +11 -0
- data/templates/game/lib/__name__/bot.rb.erb +7 -0
- data/templates/game/lib/__name__/session/runner.rb.erb +36 -0
- data/templates/game/lib/__name__/state/game_state.rb.erb +11 -0
- data/templates/game/lib/__name__.rb.erb +10 -0
- data/templates/game/spec/session/runner_spec.rb.erb +29 -0
- 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,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
|