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 +4 -4
- data/CHANGELOG.md +73 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/minimum_nomic/README.md +115 -0
- data/templates/skillsets/minimum_nomic/bin/criterion_matrix.rb +221 -0
- data/templates/skillsets/minimum_nomic/bin/distil_criterion.rb +221 -0
- data/templates/skillsets/minimum_nomic/bin/judge_change.rb +238 -0
- data/templates/skillsets/minimum_nomic/bin/mutate_rule.rb +179 -0
- data/templates/skillsets/minimum_nomic/bin/propose_metric.rb +426 -0
- data/templates/skillsets/minimum_nomic/skillset.json +2 -2
- data/templates/skillsets/minimum_nomic/test/test_propose_metric.rb +161 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3491085c294fc09283689fa6a808362f088cd2d3287639c028959165928f4820
|
|
4
|
+
data.tar.gz: d974be22bf343153f28e3dff6146fcc931e72af34c138d5e3ada2330afcf08f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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}/"
|