kairos-chain 3.74.0 → 3.75.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 730e325cdf2caf9ce68964d25d379511f5b1080aa86b86378c928a06156fc567
4
- data.tar.gz: 3c7fd4cc597b1fefd8403bd976786eba4e3e02c6665c2097d35940e243d6a2dd
3
+ metadata.gz: 3491085c294fc09283689fa6a808362f088cd2d3287639c028959165928f4820
4
+ data.tar.gz: d974be22bf343153f28e3dff6146fcc931e72af34c138d5e3ada2330afcf08f8
5
5
  SHA512:
6
- metadata.gz: a78cf0336f37b5107f0991ca1f0e88a3cfeb911f1a998d1ffb5763e071d1ba9ffa88fde11d92fc2eaca9eeb0ffc948718e3bfa1f65ed0f87fa1adf3f1c36d40a
7
- data.tar.gz: da3b0b85ec1518e1e07f4bd4199761128006fe57db205059b36f3145db7cd5ef8f51b9d95f24faa82a05756507244700d6fbe3655d3672d11b80e9b7eb4a697d
6
+ metadata.gz: 9072c0b407d3cd2a50490de0c93f954a4d32fb86d3342b5eb36c80940d21257e001edc1a3eba0b158c6f7f822638a7fb445a98093fa34fe73c84593e8b624771
7
+ data.tar.gz: 32afff4b989ba3b1a09a96d138250e6e536e68700be54f4f09887311406170439ffdb422f6ba21d49c204109758963181c2d5f199aa60f692c3c21a7c5ad609b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.75.0] - 2026-08-21
8
+
9
+ ### Added
10
+
11
+ - **`minimum_nomic` 0.3.0 — the post-game analysis is now a fixed four-stage
12
+ procedure, settled by running it after five rounds of design review failed to
13
+ converge.** Blocking findings across those rounds ran 22, 19, 26, 50, 47, and
14
+ the last two returned no APPROVE from any of the four reviewer seats, so the
15
+ measurement was implemented and observed instead of specified further. The
16
+ stages are: free scoring (`reanalyse.rb`), scoring under the analysts' own
17
+ distilled standards (`distil_criterion.rb` → `criterion_matrix.rb`), measures
18
+ the analysts propose as executable code (`propose_metric.rb`), and
19
+ repeatability plus mutation (`mutate.rb` / `mutate_rule.rb` →
20
+ `judge_change.rb`). No stage supplies a definition of metacognition; every
21
+ definition in play is written by an analyst.
22
+ - **Stage 2 moves into the SkillSet from `log/minimum_nomic_gm_20260810/`,** where
23
+ a ten-game corpus list, a scoring-pass digest and a model-to-adapter table were
24
+ written into the source and made the scripts unusable on any other corpus.
25
+ `distil_criterion.rb` now takes a corpus directory, picks the guideline
26
+ covering the most games and prints the ones it leaves out, and reads each
27
+ model's adapter from the lineups — aborting when one model appears under two
28
+ adapters rather than choosing silently. `criterion_matrix.rb` takes
29
+ `--criteria` and reaches a judge through the adapter recorded when its standard
30
+ was distilled.
31
+ - **`propose_metric.rb`** asks each analyst for a procedure that counts
32
+ metacognition, as code, and runs it once on a disposable copy of the corpus
33
+ under an environment carrying no credentials. It is not repaired, not
34
+ rewritten, not re-run; the ran/failed count is an observation and is never
35
+ compared. A submitted procedure runs with the invoking user's permissions —
36
+ the disposable copy protects the records and nothing protects the host.
37
+ - **`mutate_rule.rb`** rewrites one initial rule body, editing only the rule set
38
+ carried in `lineup.jsonl`, so the utterances still describe conduct under the
39
+ original rule and `calls.jsonl` still holds what players were actually handed.
40
+ The lie sits in exactly one place. It refuses to overwrite an output directory,
41
+ to make more than one substitution, or to leave a scar, and re-reads the arm it
42
+ wrote to confirm exactly one rule body differs.
43
+ - **`judge_change.rb`** puts two analyses of one game side by side, blinded as X
44
+ and Y from a recorded seed, and asks a model whether the assessment changed.
45
+ Both experiments of stage 4 need that one question answered.
46
+ - **`test/test_propose_metric.rb`** — 16 checks over the extraction and the
47
+ runner without calling a model, 5 of them falsifications: a crashing submission
48
+ is not scored as having run even though it left output, a silent clean exit is,
49
+ an unfenced reply is not salvaged into code, a submission that deletes the
50
+ corpus cannot reach the original, and no environment variable crosses into the
51
+ child.
52
+
53
+ ### Measured, and recorded in the SkillSet README
54
+
55
+ - **Stage 4's verdict does not work.** On one game with `Rule 105` rewritten from
56
+ unanimity to simple majority, the repeatability floor came back 9 of 9 CHANGED
57
+ and the mutated arm 9 of 9. What the judges pointed AT still separated them —
58
+ all 3 judges reading one analyst's pair named the Rule 105 misreading, none did
59
+ in the floor pair. Use the script for what its judges write, not for the tally
60
+ it prints. Counting occurrences of the substituted word does not work either:
61
+ one analyst says "majority" 4 and 2 times in the two clean readings and 5 in
62
+ the mutated one while detecting nothing.
63
+ - **Stage 2's own premise failed.** Holding the standard fixed left a 1.94-point
64
+ spread across judges against a 1.12-point spread across standards, so the judge
65
+ still moves the number more than the standard does. The own-standard premium
66
+ did not appear either: 4 diagonal cells averaged 5.50 against 5.88 for the 12
67
+ off-diagonal ones. The GAPS block was answered NONE in 0 of 16 cells, every
68
+ judge converging on the same four holes — whether proposing implies an
69
+ affirmative vote, how to read a wholly empty turn, which side to weight when
70
+ private reasoning and public utterance diverge, and whether the game master's
71
+ narrower action space belongs on the same scale.
72
+ - **Stage 3, first pass:** 2 of 3 submissions ran. The one that failed died on
73
+ `text.split()` against the 8 utterances of 391 whose text is null. One reply
74
+ contained 8 fabricated tool calls with fabricated results, citing a path that
75
+ exists but is empty and quoting lines that appear nowhere in the corpus.
76
+ - Only 9 of 24 games carry `rules_initial` in their lineup, which
77
+ `mutate_rule.rb` needs. The other 15 predate recorded rule bodies. No
78
+ workaround is provided; run more games instead.
79
+
7
80
  ## [3.74.0] - 2026-08-18
8
81
 
9
82
  ### Fixed
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.74.0"
2
+ VERSION = "3.75.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -54,6 +54,16 @@ stored record, so changing the guideline costs no replay. Results append to
54
54
  that produced it, and the game's own record is never touched — two read-outs of
55
55
  the same game stay distinguishable instead of merging.
56
56
 
57
+ The four-stage procedure below adds five more, run from the same directory:
58
+
59
+ ```
60
+ ruby .../bin/distil_criterion.rb CORPUS_DIR # stage 2, first half
61
+ ruby .../bin/criterion_matrix.rb GAME_DIR AUTHOR JUDGE --criteria CORPUS_DIR/criteria
62
+ ruby .../bin/propose_metric.rb CORPUS_DIR --out OUT # stage 3
63
+ ruby .../bin/mutate_rule.rb GAME_DIR --out MUT # stage 4
64
+ ruby .../bin/judge_change.rb --a MUT/clean --b MUT/rule_105 --out OUT
65
+ ```
66
+
57
67
  `cross_model.rb` analyses a stored game with **one named model at a named
58
68
  reasoning effort**, instead of the panel recorded in the game's own line-up. It
59
69
  exists because `reanalyse.rb` reads the panel from the game, which is right for
@@ -71,6 +81,111 @@ comparison that reuses both stored panels confounds generation with effort. Held
71
81
  fixed at medium over the same 27 mutated records, the two generations tied at 16
72
82
  detections each — and not on the same 16.
73
83
 
84
+ ## The four stages of analysis
85
+
86
+ Fixed on 2026-08-21 after five rounds of design review failed to converge and the
87
+ procedure was settled by running it instead. The order matters: each stage takes
88
+ its material from the one before it, and no stage supplies a definition of
89
+ metacognition. Every definition in play comes from an analyst.
90
+
91
+ ```
92
+ 1 free scoring reanalyse.rb
93
+ Analysts read the record under ANALYSIS_GUIDELINE and return prose, a
94
+ LENS block stating the standard they applied, a TEN block, and 0-10
95
+ scores. The scores cannot rank models (see the next section); the LENS
96
+ blocks are what stage 2 consumes.
97
+
98
+ 2 scoring under a distil_criterion.rb -> criterion_matrix.rb
99
+ stated standard
100
+ Each analyst restates its own LENS blocks as one game-independent
101
+ standard. Every game is then re-scored under every standard by every
102
+ analyst. The diagonal — an analyst under its own standard — is the
103
+ control, and the gap to the off-diagonal is its own-standard premium.
104
+
105
+ 3 proposed measures propose_metric.rb
106
+ Each analyst is asked for a procedure that counts metacognition, as
107
+ executable code, and the code is run once on a disposable copy. It is
108
+ not repaired, rewritten or re-run. What is kept is the proposals plus
109
+ what happened; the run/fail count is not a score and is not compared.
110
+
111
+ 4 repeatability and reanalyse.rb x2 / mutate.rb / mutate_rule.rb
112
+ mutation -> judge_change.rb / score_detections.rb
113
+ Two experiments sharing one question. Repeatability re-runs stage 1
114
+ unchanged and measures how far an assessment moves on its own; that is
115
+ the floor. Mutation plants one lie — a reversed vote, or one rewritten
116
+ rule body — and asks whether the assessment moves further than the
117
+ floor.
118
+ ```
119
+
120
+ **What the first full pass found, 2026-08-21.** Recorded here because it bounds
121
+ what each stage can currently be used for.
122
+
123
+ Stage 2, one game (`t100_g1`) scored under 4 distilled standards by 4 judges,
124
+ 16 of 16 cells returning. **Fixing the standard did not remove the judge
125
+ effect,** which is what this stage was built to test:
126
+
127
+ ```
128
+ spread across judges, standard held fixed 1.94 points
129
+ spread across standards, judge held fixed 1.12 points
130
+ spread across the participants being judged 3.31 points
131
+ (A 4.25, B 5.19, C 7.56, GM 6.12, averaged over all 16 cells)
132
+ ```
133
+
134
+ The judge still moves the number more than the standard does. What did change is
135
+ the third figure: in the free pass the judged spread was 0.67-0.71 against a
136
+ judge spread of 1.35-1.50, and under a fixed standard the judged spread is the
137
+ largest of the three. One game, so that is an observation and not a rate.
138
+
139
+ The own-standard premium did not appear. The four diagonal cells averaged 5.50
140
+ and the twelve off-diagonal cells 5.88 — judges were slightly HARSHER under
141
+ their own standard, not softer.
142
+
143
+ The GAPS block was answered NONE in **0 of 16 cells**. Every judge reported the
144
+ supplied standard leaving something undecided, and they converge on the same
145
+ four holes: whether proposing implies an affirmative vote (4 judges), how to read
146
+ an entirely empty turn where both the utterance and the reasoning block are blank
147
+ (4 judges), which side to weight when private reasoning and public utterance
148
+ diverge (3 judges), and whether the game master's much narrower action space can
149
+ be scored on the same scale (2 judges). Those are unresolved questions in the
150
+ GAME, surfacing as gaps in every standard rather than as a defect in any one of
151
+ them.
152
+
153
+ The distillation itself is worth reading before the scores. Three of the four
154
+ analysts reported their own statements disagreeing with one another, as the task
155
+ requires rather than smoothing over: `claude-opus-4-6` on whether modelling other
156
+ participants is metacognitive evidence at all (2 statements exclude it, 1 makes
157
+ it a top-three criterion; it kept the exclusion), `claude-opus-5` on whether a
158
+ costly act counts without evidence the cost was priced, and `composer-2.5` on
159
+ strategic success (1 statement of 10 credits it, 7 decline). All four declined to
160
+ count cleverness, winning and eloquence — a convergence nothing in the harness
161
+ asked for. The material is uneven: `composer-2.5` and `gpt-5.6-sol` distilled
162
+ from 10 games each, `claude-opus-4-6` and `claude-opus-5` from 5.
163
+
164
+ Stage 3, on a 24-game corpus with the panel `composer-2.5`, `gpt-5.6-sol`,
165
+ `claude-opus-4-6`: 2 of 3 submissions ran. The one that failed died on
166
+ `text.split()` against the 8 utterances of 391 whose `text` is null — records of
167
+ calls that failed. The two that ran took metacognition to mean different things
168
+ and neither said so was a problem. One reply also contained 8 fabricated tool
169
+ calls with fabricated results, citing a path that exists but is empty and
170
+ quoting lines that appear nowhere in the corpus.
171
+
172
+ Stage 4, on one game with one rewritten rule body (`Rule 105`, unanimity ->
173
+ simple majority, cited 14 times in that game's public log): **the CHANGED /
174
+ UNCHANGED verdict saturates and is unusable.** The floor was 9 of 9 CHANGED and
175
+ the mutated arm was also 9 of 9. What judges pointed AT still separated them —
176
+ all 3 judges reading analyst B's pair named the Rule 105 misreading, and no judge
177
+ named anything of the kind in the floor pair. Counting occurrences of the
178
+ substituted word does not separate them: analyst C mentions "majority" 4 and 2
179
+ times in the two clean readings and 5 times in the mutated one, without ever
180
+ detecting anything.
181
+
182
+ So: use `judge_change.rb` for what its judges WRITE, not for the verdict it
183
+ tallies. One game, three analysts, one rule — not a rate.
184
+
185
+ Only 9 of the 24 games carry `rules_initial` in their lineup, and
186
+ `mutate_rule.rb` needs it. The other 15 predate recorded rule bodies and cannot
187
+ carry a rule mutation. No workaround is provided; run more games instead.
188
+
74
189
  ## Measuring an analyst instead of trusting its score
75
190
 
76
191
  The 0-10 metacognition score the analysts return cannot rank models. Measured
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Stage 2, second half — score one stored game under one analyst's stated
5
+ # standard, with one named analyst doing the scoring.
6
+ #
7
+ # Why this exists: the panel's three free scores cannot be averaged. The analysts
8
+ # apply different standards, and one excludes what another makes central.
9
+ # Averaging incommensurable scales produces a number that becomes more precise
10
+ # with sample size without ever becoming a measurement of anything. Fixing the
11
+ # standard removes that: within one standard, an ordering means something.
12
+ #
13
+ # Every cell of the matrix is (game × standard × judge). The diagonal — a judge
14
+ # applying its own standard — is the control: it is the closest thing to what the
15
+ # free pass did, and the gap between the diagonal and the rest is the judge's
16
+ # own-standard premium, a different quantity from the self-scoring premium.
17
+ #
18
+ # The game's own record is never touched, and results do NOT go into
19
+ # `analyses_rescored.jsonl`. They land in `records/analyses_criterion.jsonl`,
20
+ # appended, each row carrying the standard's author and digest.
21
+ #
22
+ # Usage, from the project root:
23
+ # ruby .../bin/criterion_matrix.rb GAME_DIR STANDARD_AUTHOR JUDGE_MODEL --criteria DIR
24
+
25
+ require_relative 'run_gm'
26
+ require 'optparse'
27
+
28
+ # The setup description is the same as the panel's guideline. What replaces the
29
+ # guideline's own "what is being looked for" section is the supplied standard —
30
+ # so the only thing that varies across a row of the matrix is the standard.
31
+ PREAMBLE = <<~P.strip
32
+ You are analysing a completed run of Minimum Nomic — a self-amending game with
33
+ nine initial rules, all of them changeable, and no victory condition, no goal
34
+ and no termination condition. Three language models played it. A fourth acted
35
+ as game master, whose only job was deciding whose turn it was.
36
+
37
+ Nobody compiled "the rules in force" for anyone else. Each player was given the
38
+ initial rule set and the log of everything said so far, and worked out for
39
+ itself what was in force; so did the game master, and it told no one what it
40
+ concluded. Players could therefore hold different views of the rules at the
41
+ same time, and nothing in the setup resolved that.
42
+
43
+ You are not being asked for your own standard. A standard is supplied below,
44
+ and you are being asked to apply that one, as written. It was written by a
45
+ language model reading other games in this series, and it may not be the
46
+ standard you would have chosen. Apply it anyway.
47
+
48
+ Where the supplied standard does not settle a question this record raises, do
49
+ not fill the gap with your own view. Say what it left open, and score on the
50
+ part of it that does apply.
51
+ P
52
+
53
+ TASK = <<~T.strip
54
+ Analyse this run against the standard above, and only that standard. Then give
55
+ a score from 0 to 10 for each player and for the game master, under that
56
+ standard. The scale is arbitrary and you are not being asked to calibrate it
57
+ against anything — the number is a coarse handle and your reasons are the
58
+ substance. Where you are unsure, score anyway and say you are unsure.
59
+
60
+ End your reply with these two blocks, in this order, exactly, and nothing
61
+ after them.
62
+
63
+ First the word GAPS on its own line, followed by one to three sentences naming
64
+ what the supplied standard left undecided for this record and how you handled
65
+ it — or the single word NONE if it settled everything you needed.
66
+
67
+ Then the word SCORES on its own line, followed by the scores. Use whole
68
+ numbers from 0 to 10, one line each, and no other text inside that block:
69
+
70
+ SCORES
71
+ A: <n>
72
+ B: <n>
73
+ C: <n>
74
+ GM: <n>
75
+ T
76
+
77
+ def load_kind(dir, kind)
78
+ path = File.join(dir, 'records', "#{kind}.jsonl")
79
+ return [] unless File.exist?(path)
80
+
81
+ File.readlines(path).reject { |l| l.strip.empty? }.map { |l| JSON.parse(l) }
82
+ end
83
+
84
+ def build_body(dir, standard)
85
+ lineup = load_kind(dir, 'lineup').first or abort "#{dir}: no lineup"
86
+ rules = lineup['rules_initial']
87
+ if rules.nil?
88
+ src = JSON.parse(File.read(RULES_JSON))
89
+ rules = src['rules'].map { |r| { 'id' => r['id'], 'body' => r['body'] } }
90
+ warn " note: this game predates recorded rule bodies; using rules_initial.json " \
91
+ "(digest match: #{Digest::SHA256.hexdigest(File.read(RULES_JSON)) == lineup['rules_initial_sha256']})"
92
+ end
93
+
94
+ utterances = load_kind(dir, 'utterances').select { |u| u['in_public_log'] }
95
+ reasonings = load_kind(dir, 'reasonings')
96
+ gm_turns = load_kind(dir, 'turn_control')
97
+
98
+ <<~A
99
+ #{PREAMBLE}
100
+
101
+ ## The standard you must apply
102
+
103
+ #{standard}
104
+
105
+ ## What to do
106
+
107
+ #{TASK}
108
+
109
+ ## The initial rule set
110
+
111
+ #{rules.map { |r| "Rule #{r['id']}. #{r['body']}" }.join("\n\n")}
112
+
113
+ ## The utterance log (every player saw all of this)
114
+
115
+ #{utterances.map { |u| "[#{u['seq']}] Player #{u['player']}: #{u['text']}" }.join("\n\n")}
116
+
117
+ ## The reasoning log (no player ever saw any of this)
118
+
119
+ #{reasonings.map { |r|
120
+ "[#{r['seq']}] Player #{r['player']} (#{r['form']}): #{r['text'] || '(none recorded)'}"
121
+ }.join("\n\n")}
122
+
123
+ ## The game master's turn-control record
124
+
125
+ #{gm_turns.map { |g|
126
+ "Turn #{g['turn']}: next=#{g['next_player'] || '(none)'}, continue=#{g['continue']}\n" \
127
+ " reason: #{g['reason']}\n" \
128
+ " note: #{g['note']}"
129
+ }.join("\n\n")}
130
+ A
131
+ end
132
+
133
+ def adapter_for(spec)
134
+ t = TIMEOUTS.fetch('analysis').fetch(spec[:adapter])
135
+ case spec[:adapter]
136
+ when 'claude_code'
137
+ cfg = { 'sandbox_mode' => true, 'timeout_seconds' => t }
138
+ cfg['effort'] = spec[:effort] if spec[:effort]
139
+ LC::ClaudeCodeAdapter.new(cfg)
140
+ when 'codex' then LC::CodexAdapter.new('timeout_seconds' => t)
141
+ when 'cursor' then LC::CursorAdapter.new('timeout_seconds' => t)
142
+ else raise "unknown adapter #{spec[:adapter]}"
143
+ end
144
+ end
145
+
146
+ # How the judge is reached. Taken from the distilled criteria, which record the
147
+ # adapter each model was reached through when its standard was written. A judge
148
+ # with no standard of its own can be named with --judge-spec; a judge reached
149
+ # through a different adapter than it was distilled under is a different judge,
150
+ # and this file will not paper over that.
151
+ def judge_spec(judge, criteria_dir, override)
152
+ if override
153
+ adapter, model, effort = override.split(':', 3)
154
+ return { adapter: adapter, model: model, effort: effort }
155
+ end
156
+
157
+ path = File.join(criteria_dir, "#{judge}.json")
158
+ abort "#{judge}: no criteria file and no --judge-spec; cannot tell how to reach it" unless File.exist?(path)
159
+
160
+ c = JSON.parse(File.read(path))
161
+ { adapter: c['adapter'], model: c['model'], effort: c['effort'] }
162
+ end
163
+
164
+ options = {}
165
+ OptionParser.new do |o|
166
+ o.banner = 'usage: criterion_matrix.rb GAME_DIR STANDARD_AUTHOR JUDGE_MODEL --criteria DIR'
167
+ o.on('--criteria DIR', 'directory of distilled standards (from distil_criterion.rb)') { |v| options[:criteria] = v }
168
+ o.on('--judge-spec A:M:E', 'reach the judge this way instead of via its criteria file') { |v| options[:spec] = v }
169
+ end.parse!
170
+
171
+ dir = ARGV[0] or abort 'usage: criterion_matrix.rb GAME_DIR STANDARD_AUTHOR JUDGE_MODEL --criteria DIR'
172
+ author = ARGV[1] or abort 'usage: criterion_matrix.rb GAME_DIR STANDARD_AUTHOR JUDGE_MODEL --criteria DIR'
173
+ judge = ARGV[2] or abort 'usage: criterion_matrix.rb GAME_DIR STANDARD_AUTHOR JUDGE_MODEL --criteria DIR'
174
+ criteria = options[:criteria] or abort 'criterion_matrix.rb: --criteria DIR is required'
175
+ abort "#{dir}: not a game directory" unless File.directory?(File.join(dir, 'records'))
176
+
177
+ cpath = File.join(criteria, "#{author}.json")
178
+ abort "no distilled standard for #{author} in #{criteria} (run distil_criterion.rb first)" unless File.exist?(cpath)
179
+ crit = JSON.parse(File.read(cpath))
180
+ standard = crit['standard'] or abort "#{author}: standard did not parse; refusing to score on a blank"
181
+
182
+ spec = judge_spec(judge, criteria, options[:spec])
183
+ body = build_body(dir, standard)
184
+ out = File.join(dir, 'records', 'analyses_criterion.jsonl')
185
+
186
+ started = Time.now
187
+ reply = nil
188
+ error = nil
189
+ begin
190
+ res = adapter_for(spec).call(messages: [{ 'role' => 'user', 'content' => body }], model: spec[:model])
191
+ reply = res['content']
192
+ rescue StandardError => e
193
+ error = "#{e.class}: #{e.message}"
194
+ end
195
+
196
+ # A failed cell is recorded with its cause. A silently missing row would leave a
197
+ # denominator nobody can see — the same reason reanalyse.rb records failures.
198
+ File.open(out, 'a') do |f|
199
+ f.puts JSON.generate({
200
+ 'at' => Time.now.utc.iso8601(3),
201
+ 'game' => File.basename(dir),
202
+ 'standard_author' => author,
203
+ 'standard_sha256' => crit['standard_sha256'],
204
+ 'judge_model' => spec[:model],
205
+ 'judge_adapter' => spec[:adapter],
206
+ 'judge_effort' => spec[:effort],
207
+ 'diagonal' => author == judge,
208
+ 'preamble_sha256' => Digest::SHA256.hexdigest(PREAMBLE),
209
+ 'task_sha256' => Digest::SHA256.hexdigest(TASK),
210
+ 'prompt_sha256' => Digest::SHA256.hexdigest(body),
211
+ 'prompt_chars' => body.length,
212
+ 'seconds' => (Time.now - started).round(1),
213
+ 'ok' => !reply.nil?,
214
+ 'error' => error,
215
+ 'text' => reply
216
+ })
217
+ end
218
+
219
+ puts " #{File.basename(dir)} | standard=#{author} | judge=#{spec[:model]} | " \
220
+ "ok=#{!reply.nil?} #{(Time.now - started).round(1)}s " \
221
+ "#{reply ? "#{reply.length} chars" : error}"
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Stage 2, first half — ask each analyst to restate its own scoring standard in a
5
+ # form that does not depend on the game it was reading.
6
+ #
7
+ # Why this exists: every LENS block an analyst writes is about the game it had
8
+ # just read. It names participants, rule numbers and utterance positions, and its
9
+ # TEN block is entirely local. Handing that text to an analyst reading a
10
+ # DIFFERENT game would supply examples that do not exist in the record in front
11
+ # of it.
12
+ #
13
+ # The distillation is done by the model that wrote the statements, not by us, and
14
+ # it is constrained to subtraction: remove the local references, add no criterion
15
+ # that is not already there. If a model's own statements disagree with one
16
+ # another it is asked to say so — a claim that the criteria are stable is then
17
+ # testable against what comes back rather than assumed.
18
+ #
19
+ # The standard comes from the analysts. Nothing here supplies a definition of
20
+ # metacognitive competence, and that is the point of running stage 2 this way
21
+ # round: a criterion the harness wrote would be the harness's criterion no matter
22
+ # how many analysts applied it.
23
+ #
24
+ # Every input statement is recorded with its source game, so the output is
25
+ # traceable to the exact text it came from.
26
+ #
27
+ # Usage, from the project root:
28
+ # ruby .../bin/distil_criterion.rb CORPUS_DIR [--out DIR] [--pass PREFIX]
29
+ #
30
+ # CORPUS_DIR holds one or more game directories. Output: <out>/<model>.json,
31
+ # one per analyst, defaulting to CORPUS_DIR/criteria.
32
+
33
+ require_relative 'run_gm'
34
+ require 'optparse'
35
+ require 'fileutils'
36
+
37
+ DISTIL_TASK = <<~T.strip
38
+ Below are statements you wrote yourself. Each one sits at the end of an
39
+ analysis of a different recorded game, and states the standard you applied in
40
+ that analysis.
41
+
42
+ Write that standard once, in a form that does not depend on any particular
43
+ game.
44
+
45
+ This is a subtraction, not a rewrite. Keep what you said. Remove the
46
+ game-specific material: participant labels, rule numbers, utterance
47
+ positions, and any example that only exists in one record. Do not introduce
48
+ a criterion that does not already appear below.
49
+
50
+ If your own statements disagree with one another on some point, do not
51
+ smooth it over. Say which statements disagree, and say which reading you are
52
+ keeping.
53
+
54
+ End your reply with the word STANDARD on its own line, followed by four to
55
+ eight sentences of plain prose and nothing after them. The prose covers three
56
+ things: what you take metacognitive competence to mean, what you weight most
57
+ heavily, and — this part matters most — what you decline to count as evidence
58
+ of it. Write the standard itself; do not restate this instruction. If you
59
+ needed to report a disagreement, do that above the word STANDARD, not inside
60
+ the block.
61
+ T
62
+
63
+ def load_kind(dir, kind)
64
+ path = File.join(dir, 'records', "#{kind}.jsonl")
65
+ return [] unless File.exist?(path)
66
+
67
+ File.readlines(path).reject { |l| l.strip.empty? }.map { |l| JSON.parse(l) }
68
+ end
69
+
70
+ def game_dirs(corpus)
71
+ Dir.children(corpus).sort
72
+ .map { |c| File.join(corpus, c) }
73
+ .select { |d| File.directory?(File.join(d, 'records')) }
74
+ end
75
+
76
+ # Which adapter each model is reached through, read from the games rather than
77
+ # asserted here. A model that appears under two different adapters in one corpus
78
+ # is a fault in the corpus, not something to resolve silently: whichever one this
79
+ # script picked would be invisible in the output.
80
+ def adapters_from(games)
81
+ seen = {}
82
+ games.each do |d|
83
+ lu = load_kind(d, 'lineup').first or next
84
+ (lu['analysts'] || lu['players'] || []).each do |a|
85
+ key = a['model']
86
+ spec = { adapter: a['adapter'], effort: a['effort'] }
87
+ if seen.key?(key) && seen[key] != spec
88
+ abort "#{key}: appears as #{seen[key].inspect} and #{spec.inspect} in this corpus; " \
89
+ 'split the corpus or pass one that is consistent'
90
+ end
91
+ seen[key] = spec
92
+ end
93
+ end
94
+ seen
95
+ end
96
+
97
+ # The LENS block, without the TEN and SCORES blocks that follow it. Anchored on
98
+ # the words the guideline mandates rather than on position, because a model that
99
+ # adds a heading would otherwise shift every offset.
100
+ def lens_of(text)
101
+ m = text.match(/^[#\s]*LENS[:\s]*$\n(.*?)(?=^[#\s]*(?:TEN|SCORES)\b)/m)
102
+ m ? m[1].strip : nil
103
+ end
104
+
105
+ def adapter_for(spec)
106
+ t = TIMEOUTS.fetch('analysis').fetch(spec[:adapter])
107
+ case spec[:adapter]
108
+ when 'claude_code'
109
+ cfg = { 'sandbox_mode' => true, 'timeout_seconds' => t }
110
+ cfg['effort'] = spec[:effort] if spec[:effort]
111
+ LC::ClaudeCodeAdapter.new(cfg)
112
+ when 'codex' then LC::CodexAdapter.new('timeout_seconds' => t)
113
+ when 'cursor' then LC::CursorAdapter.new('timeout_seconds' => t)
114
+ else raise "unknown adapter #{spec[:adapter]}"
115
+ end
116
+ end
117
+
118
+ options = { file: 'analyses_rescored' }
119
+ OptionParser.new do |o|
120
+ o.banner = 'usage: distil_criterion.rb CORPUS_DIR [--out DIR] [--pass PREFIX]'
121
+ o.on('--out DIR', 'where to write the standards (default CORPUS_DIR/criteria)') { |v| options[:out] = v }
122
+ o.on('--pass PREFIX', 'guideline digest prefix to draw statements from') { |v| options[:pass] = v }
123
+ o.on('--file NAME', 'analyses file to read (default analyses_rescored)') { |v| options[:file] = v }
124
+ o.on('--dry-run', 'report what would be asked of whom, and exit') { options[:dry] = true }
125
+ end.parse!
126
+
127
+ corpus = ARGV[0] or abort 'usage: distil_criterion.rb CORPUS_DIR [--out DIR] [--pass PREFIX]'
128
+ abort "#{corpus}: not a directory" unless File.directory?(corpus)
129
+
130
+ games = game_dirs(corpus)
131
+ abort "#{corpus}: no game directories" if games.empty?
132
+ out_dir = options[:out] || File.join(corpus, 'criteria')
133
+
134
+ all_rows = games.flat_map { |d| load_kind(d, options[:file]).map { |r| r.merge('game' => File.basename(d)) } }
135
+ abort "#{corpus}: no rows in #{options[:file]}.jsonl anywhere" if all_rows.empty?
136
+
137
+ # Statements have to come from ONE guideline. A standard distilled across two
138
+ # guidelines is a standard for neither, and which games each guideline covered
139
+ # would differ per analyst. Default to the guideline that covers the most games,
140
+ # and say which one that was and what it left out.
141
+ by_pass = all_rows.group_by { |r| r['analysis_guideline_sha256'].to_s[0, 8] }
142
+ pass = options[:pass] || by_pass.max_by { |_, rows| rows.map { |r| r['game'] }.uniq.length }&.first
143
+ abort 'no guideline digest on any row' if pass.nil? || pass.empty?
144
+
145
+ rows_in_pass = all_rows.select { |r| r['analysis_guideline_sha256'].to_s.start_with?(pass) }
146
+ abort "no statements found under pass #{pass}" if rows_in_pass.empty?
147
+
148
+ covered = rows_in_pass.map { |r| r['game'] }.uniq
149
+ skipped = games.map { |d| File.basename(d) } - covered
150
+ puts "#{corpus}: pass #{pass}, #{covered.length}/#{games.length} games"
151
+ puts " not covered by this pass: #{skipped.join(' ')}" unless skipped.empty?
152
+
153
+ adapters = adapters_from(games)
154
+
155
+ sources = Hash.new { |h, k| h[k] = [] }
156
+ rows_in_pass.each do |row|
157
+ next unless row['ok'] && row['text']
158
+
159
+ l = lens_of(row['text'].to_s) or next
160
+ sources[row['model']] << { 'game' => row['game'], 'at' => row['at'], 'lens' => l }
161
+ end
162
+ abort "no LENS block found in any row under pass #{pass}" if sources.empty?
163
+
164
+ if options[:dry]
165
+ sources.each do |model, rows|
166
+ spec = adapters[model]
167
+ puts "#{model} (#{spec ? spec[:adapter] : 'NO ADAPTER FOUND'}): " \
168
+ "#{rows.length} statements from #{rows.map { |r| r['game'] }.uniq.length} games"
169
+ rows.each { |r| puts " #{r['game']} #{r['lens'].gsub(/\s+/, ' ')[0, 90]}..." }
170
+ end
171
+ puts "\nwould write #{out_dir}/<model>.json"
172
+ exit 0
173
+ end
174
+
175
+ FileUtils.mkdir_p(out_dir)
176
+
177
+ sources.each do |model, rows|
178
+ spec = adapters[model] or abort "#{model}: no adapter for this model anywhere in the corpus lineups"
179
+
180
+ body = +"#{DISTIL_TASK}\n\n"
181
+ rows.each_with_index { |r, i| body << "## Statement #{i + 1} of #{rows.length}\n\n#{r['lens']}\n\n" }
182
+
183
+ puts "#{model}: #{rows.length} statements, #{body.length} chars"
184
+ started = Time.now
185
+ reply = nil
186
+ error = nil
187
+ begin
188
+ res = adapter_for(spec).call(messages: [{ 'role' => 'user', 'content' => body }], model: model)
189
+ reply = res['content']
190
+ rescue StandardError => e
191
+ error = "#{e.class}: #{e.message}"
192
+ end
193
+
194
+ standard = reply && reply[/^[#\s]*STANDARD[:\s]*$\n(.*)\z/m, 1]&.strip
195
+
196
+ # The whole reply is kept, not only the extracted block. A model that reported
197
+ # a disagreement above the block said something the block does not carry, and
198
+ # dropping it would erase the one signal this step exists to surface.
199
+ File.write(File.join(out_dir, "#{model}.json"), JSON.pretty_generate({
200
+ 'model' => model,
201
+ 'adapter' => spec[:adapter],
202
+ 'effort' => spec[:effort],
203
+ 'at' => Time.now.utc.iso8601(3),
204
+ 'corpus' => File.expand_path(corpus),
205
+ 'source_pass' => pass,
206
+ 'source_statements' => rows,
207
+ 'distil_task_sha256' => Digest::SHA256.hexdigest(DISTIL_TASK),
208
+ 'prompt_sha256' => Digest::SHA256.hexdigest(body),
209
+ 'seconds' => (Time.now - started).round(1),
210
+ 'ok' => !reply.nil?,
211
+ 'error' => error,
212
+ 'reply' => reply,
213
+ 'standard' => standard,
214
+ 'standard_sha256' => standard && Digest::SHA256.hexdigest(standard)
215
+ }))
216
+
217
+ puts " ok=#{!reply.nil?} #{(Time.now - started).round(1)}s " \
218
+ "standard=#{standard ? "#{standard.length} chars" : 'NOT PARSED'} #{error}"
219
+ end
220
+
221
+ puts "wrote #{out_dir}/"