Files

353 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}