amicus 4.7.0 → 4.8.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +948 -1
- package/README.md +3 -2
- package/docs/CITATIONS.md +114 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/SHIMS.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/council.md +181 -34
- package/docs/doc-system.md +1 -1
- package/docs/publishing.md +2 -0
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +15 -11
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +9 -0
- package/electron/workspace-ui/live-dead-seats.js +228 -0
- package/electron/workspace-ui/live-model.js +10 -236
- package/electron/workspace-ui/live-seats.js +126 -0
- package/electron/workspace-ui/workspace-app.js +6 -41
- package/electron/workspace-ui/workspace-banners.js +95 -0
- package/electron/workspace-ui/workspace-lazy.js +55 -12
- package/electron/workspace-ui/workspace-matrix.js +2 -2
- package/electron/workspace-ui/workspace-panels.js +42 -10
- package/electron/workspace-ui/workspace-render.js +2 -2
- package/electron/workspace-ui/workspace-seats.js +101 -17
- package/package.json +6 -3
- package/schemas/council-run-live.schema.json +1 -0
- package/schemas/council-run.schema.json +19 -0
- package/schemas/council-tally.schema.json +34 -2
- package/schemas/council-verdict.schema.json +15 -0
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/second-opinion/COUNCIL-DESIGN.md +9 -4
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/SKILL.md +25 -11
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/anonymize.js +80 -11
- package/src/council/briefings-chair.js +272 -0
- package/src/council/briefings-stage2.js +12 -140
- package/src/council/debate.js +120 -15
- package/src/council/ledger-join.js +284 -0
- package/src/council/ledger-stats.js +100 -0
- package/src/council/ledger.js +177 -88
- package/src/council/peer-split.js +196 -0
- package/src/council/report-html.js +12 -5
- package/src/council/report-md.js +146 -0
- package/src/council/report.js +188 -112
- package/src/council/run-assemble.js +100 -79
- package/src/council/run-chair.js +17 -1
- package/src/council/run-debate-revote.js +268 -0
- package/src/council/run-debate.js +92 -102
- package/src/council/run-finish.js +70 -0
- package/src/council/run-launch.js +56 -18
- package/src/council/run-retry-group.js +266 -0
- package/src/council/run-retry-keys.js +74 -0
- package/src/council/run-retry-launch.js +55 -0
- package/src/council/run-retry-notes.js +65 -13
- package/src/council/run-retry.js +179 -162
- package/src/council/run-stage1-launch.js +27 -10
- package/src/council/run-stage1-rows.js +220 -0
- package/src/council/run-stage1-superseded.js +156 -0
- package/src/council/run-stage2.js +65 -5
- package/src/council/run-stages.js +72 -69
- package/src/council/run-state.js +1 -1
- package/src/council/run-stats-entry.js +71 -0
- package/src/council/run-verdict-files.js +52 -0
- package/src/council/run.js +42 -52
- package/src/council/seats.js +262 -0
- package/src/council/stage1-bind.js +142 -0
- package/src/council/street-cred.js +258 -0
- package/src/council/tally.js +100 -49
- package/src/council/verdict.js +49 -1
- package/src/headless.js +83 -6
- package/src/mcp-tools.js +71 -1
- package/src/observe/council-legs.js +7 -1
- package/src/observe/live-doc.js +3 -3
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-leg.js +22 -1
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/fanout-wave-io.js +26 -1
- package/src/sidecar/fanout.js +4 -10
- package/src/sidecar/leg-ids.js +19 -0
- package/src/sidecar/models-probe.js +7 -4
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/sidecar/setup.js +13 -2
- package/src/utils/config.js +109 -11
- package/src/utils/curated-models.js +17 -2
- package/src/utils/degrade.js +5 -0
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/no-output-backstop.js +1 -1
- package/src/utils/remediation-hints.js +42 -0
- package/src/utils/session-index-prune.js +297 -0
- package/src/utils/session-metadata-tmp-sweep.js +1 -1
- package/src/workspace/artifact-guard.js +8 -114
- package/src/workspace/artifact-names.js +222 -0
- package/src/workspace/fold-format.js +9 -6
- package/src/workspace/live-normalize.js +6 -2
- package/src/workspace/matrix-model.js +141 -19
- package/src/workspace/run-detail.js +27 -1
- package/src/workspace/seat-space.js +143 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// src/council/seats.js
|
|
2
|
+
'use strict';
|
|
3
|
+
// Seat identity (v4.8 workstream A, spec §4). A SEAT is one bench position:
|
|
4
|
+
// derived, never minted, stable for the life of a run. For every bench that
|
|
5
|
+
// has ever run — no alias repeated — a seat id equals its alias byte-for-byte,
|
|
6
|
+
// which is what keeps run.json, tally.json, verdict.json, report.html,
|
|
7
|
+
// artifact filenames and ledger rows unchanged.
|
|
8
|
+
//
|
|
9
|
+
// This module requires NOTHING, deliberately: its consumers (run-stages,
|
|
10
|
+
// run-launch, run-retry, run-stage2, run-assemble, run.js) all require IT, so
|
|
11
|
+
// any back-require would be a cycle. That is why slug and sanitizeName live
|
|
12
|
+
// here and are re-exported from their previous homes.
|
|
13
|
+
|
|
14
|
+
/** URL/role-safe token from free text (moved from run-stages.js, v4.8 PR1). */
|
|
15
|
+
function slug(text) {
|
|
16
|
+
return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Filesystem-safe model name for review-/judge- artifact filenames. */
|
|
20
|
+
function sanitizeName(model) {
|
|
21
|
+
return String(model).replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derive the seat table from data run.json already persists (run-state.js
|
|
26
|
+
* seeds bench/critic/lenses). Pure and TOTAL: critic and lenses are
|
|
27
|
+
* schema-optional and a legacy dir may carry a mismatched pair, so this never
|
|
28
|
+
* throws and never guesses.
|
|
29
|
+
*
|
|
30
|
+
* Seat id (spec §4.2): the alias when it occupies exactly one bench seat, else
|
|
31
|
+
* `alias#N` with N 1-based among the seats sharing that alias.
|
|
32
|
+
*
|
|
33
|
+
* Roles follow run-stages.js roleFor's PRECEDENCE — under lenses every seat is
|
|
34
|
+
* a lens seat and 'critic' is unreachable — and match it for every bench the
|
|
35
|
+
* CLI can produce (no repeated alias; lenses.length === bench.length, enforced
|
|
36
|
+
* at cli-handlers-council-run.js:161). They DELIBERATELY diverge where roleFor
|
|
37
|
+
* is wrong: roles here are POSITIONAL, so twins under lenses get their own
|
|
38
|
+
* lens instead of both getting the first twin's (roleFor uses
|
|
39
|
+
* o.models.indexOf). An empty lenses array counts as no lenses (roleFor treats
|
|
40
|
+
* [] as truthy and yields 'lens:undefined'), and a lenses array shorter than
|
|
41
|
+
* the bench leaves the trailing seats plain for the same reason. Do not
|
|
42
|
+
* "restore parity" — the divergence IS the feature, and it is pinned by tests.
|
|
43
|
+
*
|
|
44
|
+
* The `claude` seat is deliberately absent: 'claude' is rejected from --models
|
|
45
|
+
* (run-assemble.js:104-132) and synthesized onto meta.models downstream (:226).
|
|
46
|
+
* seats[] is bench-only — never assume meta.models.length === seats.length.
|
|
47
|
+
*
|
|
48
|
+
* @param {?Array<string>} bench run.json's bench (o.models)
|
|
49
|
+
* @param {?string} critic run.json's critic
|
|
50
|
+
* @param {?Array<string>} lenses positionally bound to bench
|
|
51
|
+
* @returns {Array<{id: string, alias: string, role: string, lens: ?string, position: number}>}
|
|
52
|
+
*/
|
|
53
|
+
function buildSeats(bench, critic, lenses) {
|
|
54
|
+
const aliases = Array.isArray(bench) ? bench : [];
|
|
55
|
+
const lensed = Array.isArray(lenses) && lenses.length > 0;
|
|
56
|
+
const counts = new Map();
|
|
57
|
+
for (const a of aliases) { counts.set(a, (counts.get(a) || 0) + 1); }
|
|
58
|
+
const seen = new Map();
|
|
59
|
+
return aliases.map((alias, i) => {
|
|
60
|
+
const n = (seen.get(alias) || 0) + 1;
|
|
61
|
+
seen.set(alias, n);
|
|
62
|
+
const lens = lensed && lenses[i] !== undefined ? lenses[i] : null;
|
|
63
|
+
const role = lensed
|
|
64
|
+
? (lens === null ? 'seat' : `lens:${slug(lens)}`)
|
|
65
|
+
: (alias === critic ? 'critic' : 'seat');
|
|
66
|
+
return {
|
|
67
|
+
id: counts.get(alias) > 1 ? `${alias}#${n}` : alias,
|
|
68
|
+
alias, role, lens, position: i + 1,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Role of a seat by its id. Returns 'seat' for an unknown id, matching
|
|
75
|
+
* roleFor's fallthrough for any alias that is not the critic. (roleFor returns
|
|
76
|
+
* 'critic' for an OFF-BENCH critic because run-stages.js:40 is not gated on
|
|
77
|
+
* bench membership; buildSeats never mints a seat for one and preflightSeats
|
|
78
|
+
* rejects that state pre-spend, so 'seat' is right for every run v4.8 creates.)
|
|
79
|
+
* @param {?Array<object>} seats
|
|
80
|
+
* @param {string} seatId
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function roleAt(seats, seatId) {
|
|
84
|
+
const hit = (Array.isArray(seats) ? seats : []).find(s => s && s.id === seatId);
|
|
85
|
+
return hit ? hit.role : 'seat';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a wave's legs to its seats. Pure: it reports, it never emits a
|
|
90
|
+
* degrade and never guesses — silent mis-attribution is the failure this whole
|
|
91
|
+
* mechanism exists to kill (spec §4.4).
|
|
92
|
+
*
|
|
93
|
+
* `seats` is THE WAVE'S LAUNCH ROSTER, in launch order — not necessarily the
|
|
94
|
+
* full seat table. The -s1 wave is critic-filtered (run-stage1-launch.js:54) and
|
|
95
|
+
* a retry wave is the loss subset (`run-retry.js :: retryStage1Losses` launches
|
|
96
|
+
* `unit.models`), so a legId's `-N` suffix indexes this roster, never bench position.
|
|
97
|
+
*
|
|
98
|
+
* Callers legitimately hold legs from several waves at once, so a leg stamped
|
|
99
|
+
* with a DIFFERENT waveId is ignored rather than reported — call once per wave
|
|
100
|
+
* over the same array. A leg with NO waveId (result-schema.js:61 falls back to
|
|
101
|
+
* `metadata.parentWave || null`, and the council fixtures omit it) may bind
|
|
102
|
+
* ONLY by an exact roster-slot id: adopting it by alias would silently claim a
|
|
103
|
+
* foreign wave's leg.
|
|
104
|
+
*
|
|
105
|
+
* Resolution order per leg:
|
|
106
|
+
* 1. `leg.legId || leg.taskId` matching `${waveId}-${n}` → roster slot n.
|
|
107
|
+
* Both are read because legId is never persisted, so every disk-rebuilt
|
|
108
|
+
* wave is taskId-only.
|
|
109
|
+
* 2. alias (`leg.modelInput || leg.model`), only for a wave-stamped leg and
|
|
110
|
+
* only when that alias holds exactly one seat in this roster — for every
|
|
111
|
+
* bench that has ever run, this is today's exact behaviour.
|
|
112
|
+
* 3. neither → the leg is an orphan and the seat stays unbound.
|
|
113
|
+
*
|
|
114
|
+
* The greedy `.*` needs no invariant about waveId shapes: it takes the
|
|
115
|
+
* LONGEST prefix before a trailing `-<digits>`, and the `m[1] === waveId`
|
|
116
|
+
* equality then rejects anything that is not precisely this wave. A waveId
|
|
117
|
+
* that itself ends in digits (`r-2`) parses correctly — verified by brute
|
|
118
|
+
* force over adversarial (waveId, legId) pairs. Two CI reviewers read the
|
|
119
|
+
* old wording as a live hazard; it was a defensive note, not a dependency.
|
|
120
|
+
*
|
|
121
|
+
* `bound` says nothing about USABILITY: a leg that ran and died still binds
|
|
122
|
+
* (run-launch.js :: materializeReviews drops non-complete legs later).
|
|
123
|
+
* PR2's dead-seat set is `unbound ∪ deadWave.seats ∪ {bound seats
|
|
124
|
+
* materializeReviews rejected}`.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} waveId
|
|
127
|
+
* @param {?Array<object>} seats the wave's launch roster, in launch order
|
|
128
|
+
* @param {?Array<object>} legs
|
|
129
|
+
* @returns {{bound: Array<{seat: object, leg: object}>, unbound: Array<object>, orphanLegs: Array<object>}}
|
|
130
|
+
*/
|
|
131
|
+
function bindSeats(waveId, seats, legs) {
|
|
132
|
+
const roster = Array.isArray(seats) ? seats.filter(Boolean) : [];
|
|
133
|
+
const all = Array.isArray(legs) ? legs.filter(Boolean) : [];
|
|
134
|
+
const mine = all.filter(l => !l.waveId || l.waveId === waveId);
|
|
135
|
+
const takenBy = new Map();
|
|
136
|
+
const bound = [];
|
|
137
|
+
const orphanLegs = [];
|
|
138
|
+
for (const leg of mine) {
|
|
139
|
+
const id = leg.legId || leg.taskId;
|
|
140
|
+
const m = typeof id === 'string' ? id.match(/^(.*)-(\d+)$/) : null;
|
|
141
|
+
let seat = (m && m[1] === waveId) ? roster[Number(m[2]) - 1] : undefined;
|
|
142
|
+
if (!seat && leg.waveId === waveId) {
|
|
143
|
+
const alias = leg.modelInput || leg.model;
|
|
144
|
+
const hits = roster.filter(s => s.alias === alias);
|
|
145
|
+
seat = hits.length === 1 ? hits[0] : undefined;
|
|
146
|
+
}
|
|
147
|
+
if (!seat || takenBy.has(seat.id)) { orphanLegs.push(leg); continue; }
|
|
148
|
+
takenBy.set(seat.id, leg);
|
|
149
|
+
bound.push({ seat, leg });
|
|
150
|
+
}
|
|
151
|
+
return { bound, unbound: roster.filter(s => !takenBy.has(s.id)), orphanLegs };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The artifact filename for one seat. Reproduces the four shapes shipping
|
|
156
|
+
* today — review-/judge-/rebuttal-/revote-<name>.md, the exact key set
|
|
157
|
+
* workspace/artifact-guard.js enumerates — with the seat id in place of the
|
|
158
|
+
* alias. Identical output for every bench that has ever run; a twin sanitizes
|
|
159
|
+
* to `review-deepseek-2.md`, which is the collision surface preflightSeats
|
|
160
|
+
* rejects pre-spend.
|
|
161
|
+
* @param {{id: string}} seat
|
|
162
|
+
* @param {'review'|'judge'|'rebuttal'|'revote'} kind
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function artifactName(seat, kind) {
|
|
166
|
+
return `${kind}-${sanitizeName(seat && seat.id)}.md`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* How a seat is named to a human — chair packet review headers today.
|
|
171
|
+
* Deliberately the seat id and nothing more: spec §4.2's byte-identical
|
|
172
|
+
* promise means a unique-alias seat MUST render as its bare alias, so lens
|
|
173
|
+
* text, position and role can never be appended unconditionally. It exists as
|
|
174
|
+
* a named seam so a later rev changes presentation in one place instead of at
|
|
175
|
+
* every call site.
|
|
176
|
+
* @param {{id: string}} seat
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function displayName(seat) {
|
|
180
|
+
return seat && seat.id;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const SEATS_ERROR = 'COUNCIL_SEATS_INVALID';
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Mint the seat table and validate it, pre-spend. Runs AFTER initRun (so the
|
|
187
|
+
* error doc lands in a run dir that exists) and BEFORE any launch, exactly like
|
|
188
|
+
* its sibling preflightClaudeReview.
|
|
189
|
+
*
|
|
190
|
+
* Rejects five ways, all zero-spend:
|
|
191
|
+
* - two bench entries resolving to the SAME seat id (a bench alias spelling
|
|
192
|
+
* another alias's disambiguated id, e.g. 'deepseek#2' beside twin
|
|
193
|
+
* 'deepseek' entries) — that table would be incoherent as a join key,
|
|
194
|
+
* - a collision in which a DISAMBIGUATED (#N) id participates, i.e. two seats
|
|
195
|
+
* whose review files would be the same name. Deliberately narrow: a
|
|
196
|
+
* pure-alias collision ('vendor/a' vs 'vendor?a') runs today and
|
|
197
|
+
* workspace/artifact-guard.js exists to detect and surface it, so v4.8
|
|
198
|
+
* refuses only the collisions its own id scheme creates
|
|
199
|
+
* (any '#' in a seat id counts — a literal alias containing '#' is the
|
|
200
|
+
* same collision surface as a minted #N id),
|
|
201
|
+
* - a --critic alias occupying more than one seat,
|
|
202
|
+
* - a critic that is not on the bench at all. runCouncil never checked this
|
|
203
|
+
* (only the CLI/MCP handlers did), so a direct require() caller silently
|
|
204
|
+
* launched an N+1th leg meta.models never mentioned,
|
|
205
|
+
* - --critic together with a non-empty --lenses. Under lenses every seat
|
|
206
|
+
* already carries its own lens role, so criticSeat would name a seat
|
|
207
|
+
* buildSeats gave a `lens:*` role — both handlers already enforce this
|
|
208
|
+
* XOR; the engine now does too. An empty lenses array is not lenses
|
|
209
|
+
* anywhere else in this module (buildSeats' `lensed` check above), so it
|
|
210
|
+
* does not trip this guard.
|
|
211
|
+
*
|
|
212
|
+
* Remedies ride INSIDE message: the engine error is {code, message} and both
|
|
213
|
+
* render paths discard anything else. The message never suggests naming a seat
|
|
214
|
+
* id — every entry point requires bench.includes(critic), so that spelling
|
|
215
|
+
* cannot work until PR2 teaches the handlers.
|
|
216
|
+
*
|
|
217
|
+
* @param {{models: ?Array<string>, critic: ?string, lenses: ?Array<string>}} o
|
|
218
|
+
* @returns {{seats: ?Array<object>, criticSeat: ?string, error: ?{code: string, message: string}}}
|
|
219
|
+
*/
|
|
220
|
+
function preflightSeats(o) {
|
|
221
|
+
const bad = (detail) => ({ seats: null, criticSeat: null,
|
|
222
|
+
error: { code: SEATS_ERROR, message: `council_seats_invalid: ${detail}` } });
|
|
223
|
+
const seats = buildSeats(o.models, o.critic, o.lenses);
|
|
224
|
+
|
|
225
|
+
const byId = new Set();
|
|
226
|
+
const byFile = new Map();
|
|
227
|
+
for (const s of seats) {
|
|
228
|
+
if (byId.has(s.id)) {
|
|
229
|
+
return bad(`two bench entries both resolve to seat id '${s.id}' — a bench alias may not `
|
|
230
|
+
+ "spell another alias's disambiguated seat id; rename one entry");
|
|
231
|
+
}
|
|
232
|
+
byId.add(s.id);
|
|
233
|
+
const file = artifactName(s, 'review');
|
|
234
|
+
const prev = byFile.get(file);
|
|
235
|
+
// Only reject collisions this id scheme created: at least one side must be
|
|
236
|
+
// a disambiguated id. Pure-alias collisions are artifact-guard's to surface.
|
|
237
|
+
if (prev && (prev.includes('#') || s.id.includes('#'))) {
|
|
238
|
+
return bad(`seats '${prev}' and '${s.id}' would both write ${file} — rename one bench entry`);
|
|
239
|
+
}
|
|
240
|
+
if (!prev) { byFile.set(file, s.id); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (o.critic && Array.isArray(o.lenses) && o.lenses.length > 0) {
|
|
244
|
+
return bad('--critic and --lenses are mutually exclusive: under lenses every seat carries its '
|
|
245
|
+
+ 'own lens role and no seat can be the critic — drop one of the two');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (o.critic) {
|
|
249
|
+
const hits = seats.filter(s => s.alias === o.critic);
|
|
250
|
+
if (hits.length === 0) {
|
|
251
|
+
return bad(`--critic '${o.critic}' is not on the bench (${seats.map(s => s.id).join(', ') || 'empty'})`);
|
|
252
|
+
}
|
|
253
|
+
if (hits.length > 1) {
|
|
254
|
+
return bad(`--critic '${o.critic}' is ambiguous: that alias occupies ${hits.length} bench seats `
|
|
255
|
+
+ '— remove the duplicate bench entry, or use two distinct aliases');
|
|
256
|
+
}
|
|
257
|
+
return { seats, criticSeat: hits[0].id, error: null };
|
|
258
|
+
}
|
|
259
|
+
return { seats, criticSeat: null, error: null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { buildSeats, roleAt, bindSeats, artifactName, displayName, preflightSeats, slug, sanitizeName };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// src/council/stage1-bind.js
|
|
2
|
+
'use strict';
|
|
3
|
+
// Stage-1 seat binding (v4.8 workstream A, spec §4.4). Lives here rather than in
|
|
4
|
+
// run-stages.js because that file is at 292/300 and this is where the leg<->seat
|
|
5
|
+
// join and its two failure shapes belong together.
|
|
6
|
+
|
|
7
|
+
const { bindSeats } = require('./seats');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bind every Stage-1 wave to its own roster, ONE CALL PER WAVE.
|
|
11
|
+
*
|
|
12
|
+
* Never call bindSeats once over the flattened leg array: seats.js:133 admits a
|
|
13
|
+
* leg with no waveId, so a concatenated array makes wave B's legs candidates for
|
|
14
|
+
* wave A's slots. The waves[] entries from launchStage1 are already partitioned
|
|
15
|
+
* by construction (run-stage1-launch.js captures each wave's own `got`).
|
|
16
|
+
*
|
|
17
|
+
* @param {Array<{waveId: string, roster: Array<object>, legs: Array<object>}>} waves
|
|
18
|
+
* @returns {{seatOf: Map<object, object>,
|
|
19
|
+
* missingSeats: Array<{waveId: string, seat: object, returned: number, expected: number}>,
|
|
20
|
+
* orphanLegs: Array<{waveId: string, leg: object}>}}
|
|
21
|
+
*/
|
|
22
|
+
function bindStage1Waves(waves) {
|
|
23
|
+
const seatOf = new Map();
|
|
24
|
+
const missingSeats = [];
|
|
25
|
+
const orphanLegs = [];
|
|
26
|
+
for (const w of (Array.isArray(waves) ? waves : [])) {
|
|
27
|
+
const legs = Array.isArray(w.legs) ? w.legs : [];
|
|
28
|
+
const roster = Array.isArray(w.roster) ? w.roster : [];
|
|
29
|
+
const { bound, unbound, orphanLegs: strays } = bindSeats(w.waveId, roster, legs);
|
|
30
|
+
for (const b of bound) { seatOf.set(b.leg, b.seat); }
|
|
31
|
+
for (const leg of strays) { orphanLegs.push({ waveId: w.waveId, leg }); }
|
|
32
|
+
// A wave that returned ZERO legs contributes no missing seats: it is already a
|
|
33
|
+
// dead wave (or a budget refusal, or an abort), each with its own louder
|
|
34
|
+
// channel. A wave that returned legs we could NOT attribute contributes none
|
|
35
|
+
// either: an orphan leg is a review that LANDED — materializeReviews writes it
|
|
36
|
+
// under its alias name — for a seat we cannot name. Retrying that seat would
|
|
37
|
+
// buy a SECOND paid leg and put two reviews on one seat, breaking the
|
|
38
|
+
// invariant run-stages.js:142-151 states. The orphan is already announced on
|
|
39
|
+
// `seat-unbound` at bind time (R-B: orphans are not a loss and not retryable).
|
|
40
|
+
if (legs.length === 0 || strays.length > 0) { continue; }
|
|
41
|
+
for (const seat of unbound) {
|
|
42
|
+
missingSeats.push({ waveId: w.waveId, seat, returned: legs.length, expected: roster.length });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { seatOf, missingSeats, orphanLegs };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A returned leg that matches no roster slot. Announced immediately — unlike a
|
|
50
|
+
* missing seat it is not a loss and there is nothing to retry, and unlike a
|
|
51
|
+
* mis-binding it is exactly the case where guessing would be wrong.
|
|
52
|
+
*/
|
|
53
|
+
function orphanLegNote(waveId, leg) {
|
|
54
|
+
const legId = (leg && (leg.legId || leg.taskId)) || 'unidentified';
|
|
55
|
+
const alias = (leg && (leg.modelInput || leg.model)) || 'unknown';
|
|
56
|
+
return {
|
|
57
|
+
channel: 'seat-unbound',
|
|
58
|
+
what: `leg ${legId} in wave ${waveId} matches no seat on that wave's roster`,
|
|
59
|
+
why: `its id names no roster slot of ${waveId}, and its model '${alias}' does not identify `
|
|
60
|
+
+ 'exactly one seat there',
|
|
61
|
+
effect: 'Its review is kept under its model name and is NOT attributed to a seat; nothing was '
|
|
62
|
+
+ 'guessed and nothing was dropped',
|
|
63
|
+
data: { waveId, legId, seat: alias },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Turn a missing seat into a single-seat dead-wave record so the ordinary SL-2
|
|
69
|
+
* retry machinery relaunches it (owner ruling R-B). `partial: true` is what keeps
|
|
70
|
+
* the prose honest downstream: the wave DID produce legs, so the plain dead-wave
|
|
71
|
+
* sentence would be false.
|
|
72
|
+
*/
|
|
73
|
+
function missingSeatDeadWave(m) {
|
|
74
|
+
return {
|
|
75
|
+
waveId: m.waveId,
|
|
76
|
+
models: [m.seat.alias],
|
|
77
|
+
seats: [m.seat],
|
|
78
|
+
// ASCII apostrophe deliberately: this string reaches a terminal through
|
|
79
|
+
// formatDegrade, and a Windows console can mangle U+2019. It was the only
|
|
80
|
+
// curly quote in src/ — and had no caller until v4.8 PR2b Task 7 shipped it.
|
|
81
|
+
reason: `the wave returned ${m.returned} of ${m.expected} legs and none of them was this seat's`,
|
|
82
|
+
partial: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bind ONE wave whose roster may have HOLES: pad the holes, bind, then drop
|
|
88
|
+
* every placeholder bind. Appended (never inserted) by v4.8 SI-27 / R14, which
|
|
89
|
+
* consolidated the block that stood byte-identical at three call sites ONCE `waveId`
|
|
90
|
+
* and the alias lookup are parameterised — site 2's callback was literally
|
|
91
|
+
* `(r, i) => { if (r.seat) … }`, reading the seat off a review, not off a roster
|
|
92
|
+
* slot. The three sites: `run-retry-launch.js :: bindRetryWave`,
|
|
93
|
+
* `run-stage2.js :: runStage2` and `run-debate-revote.js :: runRevoteWave`.
|
|
94
|
+
*
|
|
95
|
+
* How this differs from `bindStage1Waves` above: that one takes MANY waves, each
|
|
96
|
+
* with a REAL roster — no padding, no placeholders, and it owns its own missing/
|
|
97
|
+
* orphan bookkeeping. This one takes ONE wave and a roster source that may carry
|
|
98
|
+
* falsy slots, and owns nothing past the bind.
|
|
99
|
+
*
|
|
100
|
+
* A falsy entry means "we could not identify this seat"; pad it with a
|
|
101
|
+
* position-stable placeholder carrying a UNIQUE id so no slot shifts, then drop
|
|
102
|
+
* the placeholder binds so nothing is guessed. ⚠️ Never pass `rosterSource` raw
|
|
103
|
+
* and never use a null-id sentinel: `seats.js :: bindSeats` filters falsy roster
|
|
104
|
+
* entries internally (so raw === filtered, and both slide every later slot into
|
|
105
|
+
* a hole), and two `{id: null}` sentinels collide on the id-keyed dedup.
|
|
106
|
+
*
|
|
107
|
+
* Placeholders are tracked by IDENTITY, never by an id-name prefix test: a bench
|
|
108
|
+
* alias that literally began `__unbound-` would make a name test drop a REAL
|
|
109
|
+
* seat's binding — a name-collision channel inside the one mechanism whose whole
|
|
110
|
+
* contract is "never guess".
|
|
111
|
+
*
|
|
112
|
+
* `bindRes` and `placeholders` come back raw because the ORPHAN/MISSING tail
|
|
113
|
+
* differs at every call site and STAYS there: site 1 returns `orphanLegs` to its
|
|
114
|
+
* caller, site 2 notes orphans and walks `bindRes.unbound` (skipping
|
|
115
|
+
* `placeholders`) for missing seats, site 3 has no tail at all.
|
|
116
|
+
*
|
|
117
|
+
* No argument guards by design (R27-3): each call site keeps the guard it has.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} waveId
|
|
120
|
+
* @param {Array<?object>} rosterSource one entry per launched slot; falsy where the seat is unknown
|
|
121
|
+
* @param {(i: number) => string} aliasAt the alias that launched in slot i
|
|
122
|
+
* @param {Array<object>} legs the wave's returned legs
|
|
123
|
+
* @returns {{seatOf: Map<object, object>,
|
|
124
|
+
* bindRes: {bound: Array<object>, unbound: Array<object>, orphanLegs: Array<object>},
|
|
125
|
+
* placeholders: Set<object>}}
|
|
126
|
+
*/
|
|
127
|
+
function bindPaddedWave(waveId, rosterSource, aliasAt, legs) {
|
|
128
|
+
const placeholders = new Set();
|
|
129
|
+
const roster = rosterSource.map((s, i) => {
|
|
130
|
+
if (s) { return s; }
|
|
131
|
+
const p = { id: `__unbound-${waveId}-${i + 1}`, alias: aliasAt(i), role: 'seat', lens: null, position: i + 1 };
|
|
132
|
+
placeholders.add(p);
|
|
133
|
+
return p;
|
|
134
|
+
});
|
|
135
|
+
const bindRes = bindSeats(waveId, roster, legs);
|
|
136
|
+
const seatOf = new Map(bindRes.bound
|
|
137
|
+
.filter(b => !placeholders.has(b.seat))
|
|
138
|
+
.map(b => [b.leg, b.seat]));
|
|
139
|
+
return { seatOf, bindRes, placeholders };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { bindStage1Waves, orphanLegNote, missingSeatDeadWave, bindPaddedWave };
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// src/council/street-cred.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module council/street-cred
|
|
6
|
+
* The street-cred half of the tally: rank positions, the seat universe, and
|
|
7
|
+
* `computeStreetCred` itself. Split out of ./tally at v4.8 T3.3 because the
|
|
8
|
+
* seat-keying work took that file to 301 of 300 lines — release Constraint 6
|
|
9
|
+
* is EXTRACT, never shave a comment to fit. ./tally re-exports
|
|
10
|
+
* `computeStreetCred`, so no import path in the tree moved.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ REQUIRE-FREE by design, the ./seats · ./run-stats-entry · ./peer-split
|
|
13
|
+
* precedent: this is a leaf, so the dependency runs one way (tally.js ->
|
|
14
|
+
* street-cred.js) and cannot cycle.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ EIGHT named mutants guard the expressions below — RANKALIAS, ALIASSELF,
|
|
17
|
+
* JUDGEALIAS, SEATALWAYS, ALIASDRIVER, NOFALLBACK, EXPANDONCE (v4.8
|
|
18
|
+
* follow-up) and PROTORANK (SI-24). Each mutation and its MEASURED red set is
|
|
19
|
+
* recorded beside the others, in tests/council/street-cred-mutants.js ::
|
|
20
|
+
* RANKALIAS and its seven siblings there, following the
|
|
21
|
+
* tests/council/peer-split-mutants.js :: SPLITDROP precedent. RE-RUN them,
|
|
22
|
+
* never renumber them, whenever anything here or any of its consumers
|
|
23
|
+
* changes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function mean(arr) { return arr.reduce((s, x) => s + x, 0) / arr.length; }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map each ranked IDENTITY to its (possibly fractional) rank position in one
|
|
30
|
+
* judge's order — its SEAT id where the parallel `orderSeats` channel names
|
|
31
|
+
* one, its alias otherwise.
|
|
32
|
+
*
|
|
33
|
+
* v4.8 T3.3, the first of SI-20's three collapse sites. `pos.set(m, …)` was
|
|
34
|
+
* last-wins BY ALIAS, so a twin bench's `order ['a','a','b']` overwrote the
|
|
35
|
+
* first twin's position 1 with the second's 2 instead of giving each its own
|
|
36
|
+
* entry. MEASURED at BASE `b341b273` on that bench: both `a` street-cred rows
|
|
37
|
+
* came back `withSelf 2.667 / peersOnly 3` off the SAME collapsed map, and
|
|
38
|
+
* `JSON.stringify(row0) === JSON.stringify(row1)` was `true`. Seat keys are
|
|
39
|
+
* what make those two rows diverge — which is why the ledger's join had to be
|
|
40
|
+
* seat-aware in the same commit; the hazard is written out in full at
|
|
41
|
+
* ledger-join.js :: credFor.
|
|
42
|
+
*
|
|
43
|
+
* `order` itself is UNTOUCHED: still alias-valued, still the array the tally
|
|
44
|
+
* schema reads, and still what `briefings-chair.js :: buildChairPacket` falls
|
|
45
|
+
* back to per slot.
|
|
46
|
+
* ⚠️ DO NOT READ THAT AS "orderSeats cannot affect what the chair reads" — it
|
|
47
|
+
* could not when this was written, and v4.8 SI-25 changed it.
|
|
48
|
+
* `briefings-chair.js :: seatKeyedOrder` now zips `orderSeats` OVER `order` to
|
|
49
|
+
* render the chair packet's rankings block, so this channel has a live PROSE
|
|
50
|
+
* consumer besides this function, and a shape change here reaches the one
|
|
51
|
+
* artifact a paid chair reads as authoritative. Check that renderer and its
|
|
52
|
+
* pins (tests/council/chair-packet-seats.test.js) before altering the shape.
|
|
53
|
+
* `orderSeats` mirrors `order` slot for slot — anonymize.js :: rankingToOrder
|
|
54
|
+
* builds both from the same `slots` array in the same pass, so a tie group is
|
|
55
|
+
* an array in both. Where it is absent, shorter, or holds `null` (every
|
|
56
|
+
* pre-T3.2 caller, every unique-alias bench, and any hand-assembled input the
|
|
57
|
+
* seat machinery never touched) `|| group[k]` falls back to the alias and this
|
|
58
|
+
* function returns byte-for-byte what it returned before.
|
|
59
|
+
* @param {Array<string|string[]>} order alias-valued rank slots
|
|
60
|
+
* @param {?Array<?string|Array<?string>>} orderSeats parallel seat ids, null per unseated slot
|
|
61
|
+
*/
|
|
62
|
+
function rankPositions(order, orderSeats) {
|
|
63
|
+
const pos = new Map();
|
|
64
|
+
let p = 1;
|
|
65
|
+
for (let i = 0; i < order.length; i += 1) {
|
|
66
|
+
const group = Array.isArray(order[i]) ? order[i] : [order[i]];
|
|
67
|
+
const seatSlot = orderSeats ? orderSeats[i] : null;
|
|
68
|
+
const seatGroup = Array.isArray(seatSlot) ? seatSlot : [seatSlot];
|
|
69
|
+
const meanPos = p + (group.length - 1) / 2;
|
|
70
|
+
for (let k = 0; k < group.length; k += 1) { pos.set(seatGroup[k] || group[k], meanPos); }
|
|
71
|
+
p += group.length;
|
|
72
|
+
}
|
|
73
|
+
return pos;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The street-cred universe: exactly one entry per `models` OCCURRENCE, never
|
|
78
|
+
* fewer and never more. `seats` NAMES rows; it never changes how many exist.
|
|
79
|
+
*
|
|
80
|
+
* v4.8 follow-up (2026-08-21) — council findings 1+2 on PR #176, reproduced at
|
|
81
|
+
* BASE `13cd0a2d`. The row count used to follow `seats` wherever it disagreed
|
|
82
|
+
* with `models`'s repeat count for an alias: the FIRST occurrence the table
|
|
83
|
+
* named expanded into ALL of that alias's registered ids at once, and every
|
|
84
|
+
* LATER occurrence of the same alias was skipped outright. So an alias the
|
|
85
|
+
* table OVER-registers (more ids than `models` repeats it) INVENTED an extra
|
|
86
|
+
* row on its first occurrence, and an alias it UNDER-registers (fewer ids than
|
|
87
|
+
* `models` repeats it) DROPPED a row on its later occurrence(s) once the
|
|
88
|
+
* table's ids for it ran out. Both are reachable only on the two
|
|
89
|
+
* hand-assembled `appendRun` paths — `mcp-tools.js :: amicus_council_tally`
|
|
90
|
+
* declares `meta.seats` independently of `meta.models` and
|
|
91
|
+
* `cli-handlers-council.js` passes user JSON through verbatim — never on the
|
|
92
|
+
* engine's own output. ⚠️ NOT because `seats[]` and `models` always agree —
|
|
93
|
+
* they do not: `meta.models` carries a `claude` tail on a `claudeInCouncil`
|
|
94
|
+
* run (synthesized downstream of `buildSeats`, seats.js:44-45) that `seats[]`
|
|
95
|
+
* never names, so the two can differ in LENGTH. What DOES hold, for every
|
|
96
|
+
* alias `seats[]` was built FROM: `seats.js :: buildSeats` is
|
|
97
|
+
* `aliases.map(...)` over the bench array `models` is built from, so it is
|
|
98
|
+
* structurally one-to-one with that bench and cannot itself over- or
|
|
99
|
+
* under-register any bench alias, independent of anything `preflightSeats`
|
|
100
|
+
* separately rejects (id collisions, critic placement). The `claude` tail
|
|
101
|
+
* falls to the `!ids` branch below exactly like an alien alias does — a
|
|
102
|
+
* shortfall this function already handles by design, not a counterexample to
|
|
103
|
+
* it (pinned as the "claude tail" case, both here and in the invariant table
|
|
104
|
+
* at street-cred.test.js).
|
|
105
|
+
*
|
|
106
|
+
* FIX: the k-th occurrence of alias `m` in `models` takes the k-th id
|
|
107
|
+
* `byAlias.get(m)` registered for it, in table order. Once that list is
|
|
108
|
+
* exhausted (or the table never named `m` at all) the occurrence gets an
|
|
109
|
+
* ALIAS-KEYED row (`key: m, seat: null`) instead of being dropped, and a
|
|
110
|
+
* surplus registered id simply goes unused instead of being invented into an
|
|
111
|
+
* extra row — so `credSeats(models, seats).length === models.length` always.
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ ROW ORDER, SEPARATELY — review round 1 of this task, Important 1. This
|
|
114
|
+
* function's row order is now `models` order, always: each occurrence pushes
|
|
115
|
+
* exactly where its own index puts it. The pre-fix loop ALSO happened to
|
|
116
|
+
* group an alias's rows at its first occurrence (`expanded.has(m) ->
|
|
117
|
+
* continue` skipped every later one), so on a NON-ADJACENT repeat — an
|
|
118
|
+
* ordinary engine bench, e.g. `['a','b','a']`, `buildSeats` handles it fine —
|
|
119
|
+
* this function's order now differs from BASE's even though every ADJACENT
|
|
120
|
+
* case in this file's own tests (and the ones street-cred.test.js pins) never
|
|
121
|
+
* showed it. Content is identical either way, only order moves; RULING
|
|
122
|
+
* (owner): the new `models`-order form is correct — BASE's grouping was an
|
|
123
|
+
* accident of the buggy loop, never a stated doctrine, and this order already
|
|
124
|
+
* agrees with `meta.seats`/`position`, which are in `models` order too. Full
|
|
125
|
+
* measurement, the mutant that pins it (EXPANDONCE), and the ruling's
|
|
126
|
+
* reasoning are at street-cred.test.js's "non-adjacent repeat" tests and
|
|
127
|
+
* tests/council/street-cred-mutants.js :: EXPANDONCE.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ JOINED BY VALUE — never positionally. run-assemble.js :: buildTallyInput
|
|
130
|
+
* forbids the positional join in-code and the reasons are all live here:
|
|
131
|
+
* `seats[]` is BENCH-ONLY, `meta.models` carries a `claude` tail it never has,
|
|
132
|
+
* and two of appendRun's three call sites feed hand-assembled input no seat
|
|
133
|
+
* machinery touches — exactly the input shape where `models` and `seats` can
|
|
134
|
+
* disagree in COUNT, not merely in content.
|
|
135
|
+
* @param {string[]} models meta.models (the ALIAS list)
|
|
136
|
+
* @param {?Array<{id: string, alias: string}>} seats meta.seats, absent on most runs
|
|
137
|
+
* @returns {Array<{model: string, key: string, seat: ?string}>} always
|
|
138
|
+
* `models.length` entries. `key` is what rankPositions keyed by; `seat` is
|
|
139
|
+
* the id, null unless it DIFFERS from the alias — the emit-when-DIFFERENT
|
|
140
|
+
* predicate every seat producer shares (run-stats-entry.js ::
|
|
141
|
+
* buildRunStatsEntry), so a unique-alias bench emits no seat field anywhere
|
|
142
|
+
* and its documents stay byte-identical.
|
|
143
|
+
*/
|
|
144
|
+
function credSeats(models, seats) {
|
|
145
|
+
const byAlias = new Map();
|
|
146
|
+
for (const s of (Array.isArray(seats) ? seats : [])) {
|
|
147
|
+
if (!s || typeof s.alias !== 'string' || typeof s.id !== 'string') { continue; }
|
|
148
|
+
if (!byAlias.has(s.alias)) { byAlias.set(s.alias, []); }
|
|
149
|
+
byAlias.get(s.alias).push(s.id);
|
|
150
|
+
}
|
|
151
|
+
// Named mutant "EXPANDONCE": revert this whole loop to the pre-fix form —
|
|
152
|
+
// the first occurrence of a named alias expands into EVERY id it has
|
|
153
|
+
// registered, and `expanded.has(m) -> continue` skips every later
|
|
154
|
+
// occurrence outright. That is a wholesale swap rather than a token flip
|
|
155
|
+
// because the two algorithms track different state (a Set of aliases
|
|
156
|
+
// already expanded vs. a per-alias occurrence count) — there is no smaller
|
|
157
|
+
// edit that reproduces the drop/invent pair. tests/council/street-cred-mutants.js :: EXPANDONCE.
|
|
158
|
+
const seen = new Map(), rows = [];
|
|
159
|
+
for (const m of models) {
|
|
160
|
+
const k = seen.get(m) || 0;
|
|
161
|
+
seen.set(m, k + 1);
|
|
162
|
+
// Named mutant "ALIASDRIVER": force `id` to always be falsy, so the table
|
|
163
|
+
// built above is never consulted and every row is alias-keyed —
|
|
164
|
+
// tests/council/street-cred-mutants.js :: ALIASDRIVER.
|
|
165
|
+
const id = (byAlias.get(m) || [])[k];
|
|
166
|
+
rows.push(id ? { model: m, key: id, seat: id === m ? null : id }
|
|
167
|
+
: { model: m, key: m, seat: null });
|
|
168
|
+
}
|
|
169
|
+
return rows;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Both-numbers street-cred, ONE ROW PER SEAT. Lower mean rank = better.
|
|
174
|
+
*
|
|
175
|
+
* v4.8 T3.3 closes SI-06 and the rest of SI-20 here. Three things move, and
|
|
176
|
+
* all three are no-ops on a bench with no repeated alias:
|
|
177
|
+
*
|
|
178
|
+
* 1. THE DRIVER is `credSeats` above, not `models.map`. A twin bench used to
|
|
179
|
+
* emit two BYTE-IDENTICAL rows for the one alias (measured at BASE); it now
|
|
180
|
+
* emits one row per seat, each carrying its own numbers and its own `seat`.
|
|
181
|
+
* 2. THE PEER SPLIT was `judge !== m`, the THIRD alias comparison in the engine
|
|
182
|
+
* (SI-06). It now compares SEATS when both sides carry one and aliases
|
|
183
|
+
* otherwise — the same two-branch shape as peer-split.js :: peersOf (P0 then
|
|
184
|
+
* P3), reused rather than re-derived. ⚠️ CONTROLLER RULING (ledger C-2):
|
|
185
|
+
* self-exclusion drops only the judge that IS this seat; the twin's OTHER
|
|
186
|
+
* seat COUNTS AS A PEER. That matches peersOf's P0 branch, where a vote
|
|
187
|
+
* whose seat differs from the raiser's is a real peer, and the
|
|
188
|
+
* `sameModelCorroboration` stamp in tally.js :: tally already exists to mark
|
|
189
|
+
* such support as concurrence rather than independence. Counting-and-marking
|
|
190
|
+
* is this release's settled shape (owner ruling R2). The alias arm is what a
|
|
191
|
+
* bench with no seat channel keeps, so today's numbers do not move there.
|
|
192
|
+
* 3. `perJudgeRank` was `[judge] = rank` — last-wins by judge alias while
|
|
193
|
+
* `all`/`peers` accumulate into ARRAYS, so on a twin-JUDGE bench the map and
|
|
194
|
+
* the averages disagreed about the SAME row (the phasing doc's row `| 24 |`
|
|
195
|
+
* data-loss site). MEASURED at BASE on judges `['a','a','b']` all ranking
|
|
196
|
+
* `a`: `perJudgeRank {"a":3,"b":3}` — 2 entries for 3 judges, implying mean
|
|
197
|
+
* 3, while `withSelf` reported 2.667. Keying on the judge's SEAT closes it
|
|
198
|
+
* wherever a seat channel exists, which is every engine-produced twin bench
|
|
199
|
+
* (run-assemble.js emits `rankings[].seat` for both twins).
|
|
200
|
+
* ⚠️ RESIDUAL, stated rather than left to be discovered: hand-assembled or
|
|
201
|
+
* MCP input that repeats a judge alias and carries NO seat still collapses,
|
|
202
|
+
* because nothing in that document can tell the two judges apart. Ruling R2
|
|
203
|
+
* governs — attribute nothing where there is nothing to attribute.
|
|
204
|
+
* 4. `perJudgeRank` is ALSO `Object.create(null)` now, not a bare `{}` — a
|
|
205
|
+
* WRITE-site defect, separate from item 3's alias-collapse one and unlike
|
|
206
|
+
* a READ-site one (this release's other prototype fix, tally.js ::
|
|
207
|
+
* VERDICTS). `perJudgeRank[j.seat || j.judge] = rank` assigning through
|
|
208
|
+
* the string key `__proto__` invokes the INHERITED `Object.prototype`
|
|
209
|
+
* `__proto__` setter instead of creating an own property; the setter
|
|
210
|
+
* silently ignores a non-object value, so the rank is discarded — no
|
|
211
|
+
* throw, no own key to read back. `toString`/`constructor`/`valueOf` are
|
|
212
|
+
* unaffected — they shadow harmlessly and DO create own keys — so only
|
|
213
|
+
* `__proto__` was ever lost here. MEASURED at BASE, judge `__proto__`:
|
|
214
|
+
* `perJudgeRank` came back `{}` (`Object.keys` empty) while `all`/
|
|
215
|
+
* `withSelf` still counted the vote (`withSelf` 1) — map and average
|
|
216
|
+
* disagreeing about the SAME row, exactly as item 3 describes for the
|
|
217
|
+
* alias-collapse half. A `__proto__` SEAT id is lost the same way: both
|
|
218
|
+
* channels feed the one assignment.
|
|
219
|
+
*
|
|
220
|
+
* @param {Array<{judge:string, order:Array<string|string[]>, seat?:string,
|
|
221
|
+
* orderSeats?:Array<?string|Array<?string>>}>} rankings
|
|
222
|
+
* @param {string[]} models all reviewed models (incl. claude when in-council)
|
|
223
|
+
* @param {?Array<{id: string, alias: string}>} seats meta.seats, when the document has one
|
|
224
|
+
*/
|
|
225
|
+
function computeStreetCred(rankings, models, seats) {
|
|
226
|
+
const judgePos = rankings.map(r => ({
|
|
227
|
+
judge: r.judge, seat: r.seat || null, pos: rankPositions(r.order, r.orderSeats),
|
|
228
|
+
}));
|
|
229
|
+
return credSeats(models, seats).map(({ model, key, seat }) => {
|
|
230
|
+
const all = [], peers = [], perJudgeRank = Object.create(null);
|
|
231
|
+
for (const j of judgePos) {
|
|
232
|
+
// Seat key first, ALIAS SECOND. `meta.seats` and `rankings[].orderSeats`
|
|
233
|
+
// are independent channels: a document can carry the seat table while its
|
|
234
|
+
// rankings are alias-only (both hand-assembled `appendRun` paths, and an
|
|
235
|
+
// MCP caller that omits orderSeats). Without the fallback both seat rows
|
|
236
|
+
// would find nothing in an alias-keyed map and report null street cred —
|
|
237
|
+
// strictly worse than the collapse this task exists to fix. With it they
|
|
238
|
+
// read the same collapsed position today's code gives, and only the new
|
|
239
|
+
// `seat` field distinguishes them.
|
|
240
|
+
const rank = j.pos.has(key) ? j.pos.get(key) : j.pos.get(model);
|
|
241
|
+
if (rank === undefined) { continue; } // absent from this judge's ranking → skip
|
|
242
|
+
perJudgeRank[j.seat || j.judge] = rank;
|
|
243
|
+
all.push(rank);
|
|
244
|
+
if ((j.seat && seat) ? j.seat !== seat : j.judge !== model) { peers.push(rank); }
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
model,
|
|
248
|
+
withSelf: all.length ? mean(all) : null,
|
|
249
|
+
peersOnly: peers.length ? mean(peers) : null,
|
|
250
|
+
perJudgeRank,
|
|
251
|
+
// Last, matching verdict.js :: buildVerdict's streetCred literal, which
|
|
252
|
+
// already carries this field through under the same predicate (T3.2).
|
|
253
|
+
...(seat ? { seat } : {}),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = { computeStreetCred, rankPositions, credSeats };
|