a2a1392f01
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
353 lines
16 KiB
TypeScript
353 lines
16 KiB
TypeScript
import { copyFileSync, mkdirSync, statSync } from "node:fs";
|
||
import { basename, dirname, join } from "node:path";
|
||
import { DatabaseSync } from "node:sqlite";
|
||
import type { LedgerRecord } from "../../src/ledger/index.ts";
|
||
import type { DedupOutput, FrozenProtocol } from "../../src/materials/index.ts";
|
||
import { finalSubmissions, ofKind, stages } from "../lib/ledger.ts";
|
||
|
||
// 一次运行第 1 轮(开轮到选批)的记录,读成同一种形状,导出文本和 Jev 检验都用它(任务书 14 第 3.2 节)。
|
||
// 两种来源:我们的账本(经 src/ledger 读,像用户一样)和参考项目的账本(只读,先复制到输出目录再打开)。
|
||
// 内容按原样存 Json,字段名不统一(参考项目叫 points,我们叫 factors),只把检验要用的几样取到固定的键上。
|
||
// 按名字读,读不到就报错停下:flow 第 2 部分还在写,读错了要立刻看出来,不猜。
|
||
|
||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||
export type JsonObject = { [key: string]: Json };
|
||
export interface Range3 {
|
||
q1: number;
|
||
q5: number;
|
||
q9: number;
|
||
}
|
||
export interface Prediction {
|
||
researcher: string;
|
||
candidate: string;
|
||
range: Range3;
|
||
/** 预测附的理由(我们的 body、参考项目 14 的 rationale);没有就是 null。 */
|
||
note: Json;
|
||
}
|
||
|
||
export interface RoundData {
|
||
/** 来历(运行名、库路径),只进 data.json,不进给审阅者的文本。 */
|
||
source: string;
|
||
/** 要从文本里去掉的名字:运行名、研究编号。 */
|
||
hiddenNames: string[];
|
||
proposals: Array<{
|
||
id: string;
|
||
author: string;
|
||
content: JsonObject;
|
||
/** 判重看的"改了什么":我们是因子的名称、做法、关键设定;参考项目是 points 或 modification。 */
|
||
change: Json;
|
||
authorEstimate: Range3 | null;
|
||
}>;
|
||
dedup: {
|
||
/** 本轮新候选各合并了哪几条原提案。 */
|
||
groups: Array<{ candidate: string; proposals: string[] }>;
|
||
/** 并进留池候选的新提案。 */
|
||
carried: Json;
|
||
/** 程序判重时 Jev 每一对的判断,原样。 */
|
||
judgments: Json;
|
||
};
|
||
critiques: Array<{ id: string; author: string; proposal: string; content: JsonObject }>;
|
||
responses: Array<{
|
||
candidate: string;
|
||
author: string;
|
||
kind: string;
|
||
replies: Array<{ critique: string; text: string }>;
|
||
/** 回应里逐条答复以外的部分(改动、实现条款、正文)。 */
|
||
rest: JsonObject;
|
||
}>;
|
||
protocols: Array<{ candidate: string; content: JsonObject }>;
|
||
/** null:这次运行没有第一次预测这个阶段。 */
|
||
firstPrediction: Prediction[] | null;
|
||
/** null:这次运行用投票,不是范围预测,投票原样在 votes 里。 */
|
||
secondPrediction: Prediction[] | null;
|
||
votes: Json;
|
||
batch: { chosen: string[]; detail: Json };
|
||
rejections: Array<{ stage: string; author: string; target: string | null; reason: string }>;
|
||
}
|
||
|
||
/** 中性的阶段名,两种来源共用。 */
|
||
export const STAGE_NAMES = {
|
||
proposal: "提案",
|
||
firstPrediction: "第一次预测",
|
||
critique: "挑刺",
|
||
response: "回应与统一",
|
||
secondPrediction: "第二次预测",
|
||
vote: "投票",
|
||
} as const;
|
||
type StageName = keyof typeof STAGE_NAMES;
|
||
|
||
function need<T>(value: T | undefined, what: string): T {
|
||
if (value === undefined) throw new Error(`读不到:${what}`);
|
||
return value;
|
||
}
|
||
function field(obj: JsonObject, key: string, where: string): Json {
|
||
return need(obj[key], `${where} 的字段 ${key}`);
|
||
}
|
||
const omit = (obj: JsonObject, keys: string[]): JsonObject => Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k)));
|
||
const asObject = (v: unknown) => v as JsonObject;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 我们的账本
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 我们的研究第 round 轮。records 是 readLedger 读出的全部记录,studyId 是研究编号(要从文本里去掉)。 */
|
||
export function readOurs(records: LedgerRecord[], round: number, source: string, studyId: string): RoundData {
|
||
const inRound = stages(records).filter((s) => s.round === round);
|
||
const stageOf = (name: string) => need(inRound.find((s) => s.stage === name), `第 ${round} 轮 ${name} 阶段的开记录`).stageId;
|
||
const stageName = new Map(inRound.map((s) => [s.stageId, s.stage]));
|
||
const finals = finalSubmissions(records);
|
||
const subs = (stage: string, kinds: string[]) => {
|
||
const id = stageOf(stage);
|
||
return finals.filter((s) => s.content.stageId === id && kinds.includes(s.content.kind));
|
||
};
|
||
const seals = ofKind(records, "seal");
|
||
|
||
const proposals = subs("proposal", ["proposal"]).map((s) => {
|
||
const content = asObject(s.content.content);
|
||
const factors = field(content, "factors", `提案 ${s.content.item}`) as JsonObject[];
|
||
const seal = need(seals.find((x) => x.content.proposal === s.seq), `提案 ${s.content.item}(序号 ${s.seq})的作者自评封存`);
|
||
return {
|
||
id: need(s.content.item ?? undefined, `提案序号 ${s.seq} 的编号 item`),
|
||
author: s.content.author,
|
||
content,
|
||
change: factors.map((f) => ({ name: f["name"]!, method: f["method"]!, keySettings: f["keySettings"]! })),
|
||
authorEstimate: seal.content.range,
|
||
};
|
||
});
|
||
|
||
const decision = (kind: string) =>
|
||
need(ofKind(records, "decision").find((d) => d.content.decision === kind && d.content.round === round), `第 ${round} 轮的 ${kind} 决定`).content;
|
||
const dedup = decision("dedup");
|
||
const dedupOut = dedup.output as unknown as DedupOutput;
|
||
need(dedupOut.fresh, "判重决定输出的 fresh");
|
||
need(dedupOut.carried, "判重决定输出的 carried");
|
||
|
||
const critiques = subs("critique", ["critique"]).map((s) => ({
|
||
id: need(s.content.item ?? undefined, `挑刺序号 ${s.seq} 的编号 item`),
|
||
author: s.content.author,
|
||
proposal: need(s.content.target ?? undefined, `挑刺序号 ${s.seq} 的对象 target`),
|
||
content: asObject(s.content.content),
|
||
}));
|
||
|
||
const responses = subs("response", ["response", "unification"]).map((s) => {
|
||
const content = asObject(s.content.content);
|
||
const replies = field(content, "replies", `回应序号 ${s.seq}`) as JsonObject[];
|
||
return {
|
||
candidate: need(s.content.target ?? undefined, `回应序号 ${s.seq} 的对象 target`),
|
||
author: s.content.author,
|
||
kind: s.content.kind,
|
||
replies: replies.map((r) => ({ critique: r["critique"] as string, text: r["reply"] as string })),
|
||
rest: omit(content, ["replies"]),
|
||
};
|
||
});
|
||
|
||
const fresh = new Set(dedupOut.fresh.map((g) => g.candidate));
|
||
const protocols = ofKind(records, "protocol")
|
||
.filter((p) => fresh.has(p.content.candidate) && (p.content.protocol as unknown as FrozenProtocol).round === round)
|
||
.map((p) => ({ candidate: p.content.candidate, content: asObject(p.content.protocol) }));
|
||
|
||
const predictions = (stage: string): Prediction[] =>
|
||
subs(stage, [stage]).map((s) => {
|
||
const content = asObject(s.content.content);
|
||
return {
|
||
researcher: s.content.author,
|
||
candidate: need(s.content.target ?? undefined, `${stage} 序号 ${s.seq} 的对象 target`),
|
||
range: field(content, "range", `${stage} 序号 ${s.seq}`) as unknown as Range3,
|
||
note: content["body"] ?? null,
|
||
};
|
||
});
|
||
|
||
const batch = decision("batch");
|
||
const chosen = need(asObject(batch.output)["chosen"], "选批决定输出的 chosen") as string[];
|
||
|
||
const rejections = ofKind(records, "rejection")
|
||
.filter((r) => stageName.has(r.content.stageId))
|
||
.map((r) => ({
|
||
stage: STAGE_NAMES[need(ourStage(stageName.get(r.content.stageId)!), `阶段 ${r.content.stageId} 的中性名`)],
|
||
author: r.content.author,
|
||
target: r.content.target,
|
||
reason: r.content.reason,
|
||
}));
|
||
|
||
return {
|
||
source,
|
||
hiddenNames: [studyId],
|
||
proposals,
|
||
dedup: { groups: dedupOut.fresh, carried: dedupOut.carried as unknown as Json, judgments: dedup.input as Json },
|
||
critiques,
|
||
responses,
|
||
protocols,
|
||
firstPrediction: predictions("firstPrediction"),
|
||
secondPrediction: predictions("secondPrediction"),
|
||
votes: null,
|
||
batch: { chosen, detail: batch.output as Json },
|
||
rejections,
|
||
};
|
||
}
|
||
|
||
function ourStage(stage: string): StageName | undefined {
|
||
return stage in STAGE_NAMES ? (stage as StageName) : undefined;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 参考项目的账本(/home/iomgaa/rb-acceptance/<运行名>/board.sqlite)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 参考项目的阶段名(stage 编号的最后一段)→ 中性名。挑刺和回应都在 phase review 里,按编号区分。 */
|
||
const REF_STAGES: Record<string, StageName> = {
|
||
proposal: "proposal",
|
||
"predict-blind": "firstPrediction",
|
||
critique: "critique",
|
||
response: "response",
|
||
predict: "secondPrediction",
|
||
vote: "vote",
|
||
};
|
||
|
||
/** 原库只读:库文件(和非空的 -wal)先复制到 copyDir,再以只读方式打开复制件,原目录里不写任何东西。 */
|
||
function openCopy(board: string, copyDir: string): DatabaseSync {
|
||
mkdirSync(copyDir, { recursive: true });
|
||
const copy = join(copyDir, "board.sqlite");
|
||
copyFileSync(board, copy);
|
||
if (statSync(`${board}-wal`, { throwIfNoEntry: false })?.size) copyFileSync(`${board}-wal`, `${copy}-wal`);
|
||
return new DatabaseSync(copy, { readOnly: true });
|
||
}
|
||
|
||
const refRange = (r: JsonObject): Range3 => ({ q1: r["q10"] as number, q5: r["q50"] as number, q9: r["q90"] as number });
|
||
|
||
export function readReference(board: string, copyDir: string): RoundData {
|
||
const run = basename(dirname(board));
|
||
const roundId = `${run}.r1`;
|
||
const db = openCopy(board, copyDir);
|
||
try {
|
||
const all = <T>(sql: string, ...args: Array<string | number>) => db.prepare(sql).all(...args) as T[];
|
||
const stageRows = all<{ id: string }>("SELECT id FROM stages WHERE round_id = ? ORDER BY created_seq", roundId);
|
||
const stageOf = new Map<StageName, string>();
|
||
for (const s of stageRows) {
|
||
const name = REF_STAGES[s.id.split(".").at(-1)!];
|
||
if (name !== undefined) {
|
||
if (stageOf.has(name)) throw new Error(`${board}:第 1 轮 ${name} 阶段不止一个(${stageOf.get(name)}、${s.id})`);
|
||
stageOf.set(name, s.id);
|
||
}
|
||
}
|
||
// 14、12 那两版的 submissions 表还没有 version 列(一人一个 slot 只有一份),按第 1 版读。
|
||
const versioned = all<{ name: string }>("PRAGMA table_info(submissions)").some((c) => c.name === "version");
|
||
/** 每份提交(阶段、人、slot)的最后一版,按第一次出现的先后排。 */
|
||
const subs = (name: StageName) => {
|
||
const stage = need(stageOf.get(name), `${board} 第 1 轮 ${name} 阶段`);
|
||
const rows = all<{ actor: string; slot: string; version: number; payload: string }>(
|
||
`SELECT actor, slot, ${versioned ? "version" : "1 AS version"}, payload FROM submissions WHERE stage_id = ? ORDER BY created_seq`,
|
||
stage,
|
||
);
|
||
const latest = new Map<string, { stage: string; actor: string; slot: string; version: number; payload: JsonObject }>();
|
||
for (const r of rows) {
|
||
const key = `${r.actor}\n${r.slot}`;
|
||
const seen = latest.get(key);
|
||
if (seen === undefined || seen.version < r.version) latest.set(key, { stage, actor: r.actor, slot: r.slot, version: r.version, payload: JSON.parse(r.payload) });
|
||
}
|
||
return [...latest.values()];
|
||
};
|
||
const fact = (kind: string) => {
|
||
const rows = all<{ payload: string }>(
|
||
"SELECT payload FROM facts WHERE kind = ? AND id LIKE ? ORDER BY created_seq",
|
||
kind,
|
||
`${run}.${kind}.${roundId}.%`,
|
||
);
|
||
return JSON.parse(need(rows.at(-1), `${board} 第 1 轮的 ${kind}`).payload) as JsonObject;
|
||
};
|
||
|
||
const proposals = subs("proposal").map((s) => {
|
||
const p = s.payload;
|
||
const points = p["points"] as JsonObject[] | undefined;
|
||
const change: Json =
|
||
points !== undefined
|
||
? points.map((x) => ({ name: x["name"]!, method: x["method"]!, ...(x["keySettings"] === undefined ? {} : { keySettings: x["keySettings"] }) }))
|
||
: { modification: field(p, "modification", `${board} 提案 ${s.slot}`) };
|
||
const estimate = (p["authorRange"] ?? p["range"]) as JsonObject | undefined;
|
||
return {
|
||
id: s.slot,
|
||
author: s.actor,
|
||
content: omit(p, ["authorId", "id", "roundId", "authorRange", "range"]),
|
||
change,
|
||
authorEstimate: estimate === undefined ? null : refRange(estimate),
|
||
};
|
||
});
|
||
|
||
const dedup = fact("dedup-record");
|
||
const groups = (field(dedup, "candidates", `${board} dedup-record`) as JsonObject[]).map((c) => ({
|
||
candidate: c["candidateId"] as string,
|
||
proposals: c["proposalIds"] as string[],
|
||
}));
|
||
|
||
// 一份挑刺提交(一人对一条原提案)是一条挑刺,编号是它的 review-submission 事实编号:回应里的 critiqueId 指的就是它。
|
||
const critiqueStage = need(stageOf.get("critique"), `${board} 第 1 轮挑刺阶段`);
|
||
const critiqueIds = new Map(
|
||
all<{ id: string; slot: string }>("SELECT id, slot FROM facts WHERE kind = 'review-submission' AND slot LIKE ?", `${critiqueStage}/%`).map((f) => [f.slot, f.id]),
|
||
);
|
||
const critiques = subs("critique").map((s) => ({
|
||
id: need(critiqueIds.get(`${s.stage}/${s.actor}/${s.slot}`), `${board} 挑刺 ${s.actor}/${s.slot} 的事实编号`),
|
||
author: s.actor,
|
||
proposal: s.slot,
|
||
content: omit(s.payload, ["authorId", "proposalId", "roundId"]),
|
||
}));
|
||
|
||
const responses = subs("response").map((s) => ({
|
||
candidate: s.slot,
|
||
author: s.actor,
|
||
kind: "response",
|
||
replies: (field(s.payload, "critiqueResponses", `${board} 回应 ${s.slot}`) as JsonObject[]).map((r) => ({
|
||
critique: r["critiqueId"] as string,
|
||
text: r["response"] as string,
|
||
})),
|
||
rest: omit(s.payload, ["authorId", "candidateId", "roundId", "critiqueResponses"]),
|
||
}));
|
||
|
||
const protocols = all<{ payload: string }>(
|
||
"SELECT payload FROM facts WHERE kind = 'candidate-protocol' AND id LIKE ? ORDER BY created_seq",
|
||
`${run}.candidate-protocol.${roundId}.%`,
|
||
).map((f) => {
|
||
const p = JSON.parse(f.payload) as JsonObject;
|
||
return { candidate: field(p, "candidateId", `${board} candidate-protocol`) as string, content: omit(p, ["author", "candidateId", "baselineCodeHash"]) };
|
||
});
|
||
|
||
const predictions = (name: StageName): Prediction[] =>
|
||
subs(name).map((s) => ({
|
||
researcher: s.actor,
|
||
candidate: s.slot,
|
||
range: refRange(field(s.payload, "range", `${board} ${name} ${s.actor}/${s.slot}`) as JsonObject),
|
||
note: s.payload["rationale"] ?? null,
|
||
}));
|
||
const voting = stageOf.has("vote");
|
||
|
||
const batch = voting ? fact("vote-tally") : fact("batch-selection");
|
||
const chosen = (field(batch, "candidates", `${board} 选批`) as JsonObject[]).filter((c) => c["selected"] === true).map((c) => c["candidateId"] as string);
|
||
|
||
// 退回:audit 里的 submission-rejected,taskId 是"<轮编号>.v1.<阶段>.<人>.<对象>",只取开轮到选批这几个阶段的。
|
||
const rejections = all<{ payload: string }>("SELECT payload FROM audit WHERE event = 'submission-rejected' ORDER BY seq").flatMap((a) => {
|
||
const p = JSON.parse(a.payload) as JsonObject;
|
||
const task = p["taskId"] as string;
|
||
const m = new RegExp(`^${roundId.replace(/\./g, "\\.")}\\.v\\d+\\.([a-z-]+)\\.([^.]+)\\.(.+)$`).exec(task);
|
||
const stage = m === null ? undefined : REF_STAGES[m[1]!];
|
||
if (stage === undefined) return [];
|
||
const target = m![3]!;
|
||
return [{ stage: STAGE_NAMES[stage], author: m![2]!, target: target === m![2] ? null : target, reason: p["reason"] as string }];
|
||
});
|
||
|
||
return {
|
||
source: board,
|
||
hiddenNames: [run],
|
||
proposals,
|
||
dedup: { groups, carried: dedup["pooled"] ?? null, judgments: dedup["pairs"] ?? null },
|
||
critiques,
|
||
responses,
|
||
protocols,
|
||
firstPrediction: stageOf.has("firstPrediction") ? predictions("firstPrediction") : null,
|
||
secondPrediction: voting ? null : predictions("secondPrediction"),
|
||
votes: voting ? subs("vote").map((s) => ({ researcher: s.actor, candidate: s.slot, ...omit(s.payload, ["voterId", "candidateId", "protocolId"]) })) : null,
|
||
batch: { chosen, detail: batch },
|
||
rejections,
|
||
};
|
||
} finally {
|
||
db.close();
|
||
}
|
||
}
|