feat: flow 第 2 部分,第 r 轮开轮到选批(任务书 14 第二块)
- next-step 按"第 r 轮"列出提案、第一次预测、挑刺、回应与统一、第二次预测的任务 (一人对一个对象),判重、冻结协议、选批三步,走到 implementation 停下(任务书 15) - steps/dispatch.ts:同阶段一人任务先后跑、不同人同时跑;提案、挑刺按 proposalId、critiqueId 分份 - steps/round.ts:判重两两问 Jev、连通成组、并进留池候选;冻结协议在回应阶段关闭后一个事务写全; 选批照 rules 的 BatchInput 拼,种子用 decisionSeed 版本 1(选格预留版本 2) - materials 导出 Board(构造改收记录数组,给纯函数用);讨论原文里已作废的入口编号就地标"(已作废)" - 删 SOURCES.md 里参考项目实测事实那一段(用户 2026-10-03 同意) - 测试:下一步做什么 21 条、判重连组 4 条、冻结与选批 2 条、作废标注 1 条;npm test 437 通过 全新上下文审阅一轮(只看三个程序步骤与恢复):没有会污染账本的问题。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+13
-2
@@ -8,7 +8,9 @@ import { EVENTS, nextStep, type Next } from "./next-step.ts";
|
|||||||
import { ledgerFile } from "./paths.ts";
|
import { ledgerFile } from "./paths.ts";
|
||||||
import { recover } from "./recover.ts";
|
import { recover } from "./recover.ts";
|
||||||
import { baselineRun, saveBaseline } from "./steps/baseline.ts";
|
import { baselineRun, saveBaseline } from "./steps/baseline.ts";
|
||||||
import { assignEntries, dispatchStage } from "./steps/round-zero.ts";
|
import { dispatchStage } from "./steps/dispatch.ts";
|
||||||
|
import { dedupe, freezeProtocols, selectBatch } from "./steps/round.ts";
|
||||||
|
import { assignEntries } from "./steps/round-zero.ts";
|
||||||
import { freezeConfig, halt, openStudy, USER_STOPPED, type Study, type StudyEnding } from "./study.ts";
|
import { freezeConfig, halt, openStudy, USER_STOPPED, type Study, type StudyEnding } from "./study.ts";
|
||||||
|
|
||||||
// 主循环:读账本 → 问下一步 → 做这一步 → 回到开头(任务书 12 第 2.4 节)。每做完一步都重新读账本。
|
// 主循环:读账本 → 问下一步 → 做这一步 → 回到开头(任务书 12 第 2.4 节)。每做完一步都重新读账本。
|
||||||
@@ -83,7 +85,7 @@ async function doStep(s: Study, next: Exclude<Next, { do: "freezeConfig" }>): Pr
|
|||||||
s.ledger.append("event", { event: EVENTS.skipped, reason: next.reason, objects: [next.step] });
|
s.ledger.append("event", { event: EVENTS.skipped, reason: next.reason, objects: [next.step] });
|
||||||
return null;
|
return null;
|
||||||
case "openStage":
|
case "openStage":
|
||||||
s.ledger.append("stageOpen", { stageId: next.stageId, stage: next.stage, round: 0, roster: next.roster });
|
s.ledger.append("stageOpen", { stageId: next.stageId, stage: next.stage, round: next.round, roster: next.roster });
|
||||||
return null;
|
return null;
|
||||||
case "closeStage":
|
case "closeStage":
|
||||||
s.ledger.append("stageClose", { stageId: next.stageId });
|
s.ledger.append("stageClose", { stageId: next.stageId });
|
||||||
@@ -93,6 +95,15 @@ async function doStep(s: Study, next: Exclude<Next, { do: "freezeConfig" }>): Pr
|
|||||||
case "assignEntries":
|
case "assignEntries":
|
||||||
assignEntries(s);
|
assignEntries(s);
|
||||||
return null;
|
return null;
|
||||||
|
case "dedupe":
|
||||||
|
await dedupe(s, next.round);
|
||||||
|
return null;
|
||||||
|
case "freezeProtocols":
|
||||||
|
freezeProtocols(s, next.round);
|
||||||
|
return null;
|
||||||
|
case "selectBatch":
|
||||||
|
selectBatch(s, next.round);
|
||||||
|
return null;
|
||||||
case "halt":
|
case "halt":
|
||||||
return halt(s.ledger, next.reason, next.objects);
|
return halt(s.ledger, next.reason, next.objects);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-23
@@ -10,13 +10,16 @@ import type { Study } from "./study.ts";
|
|||||||
// 最后写"模型调用结束",前后顺序由这个入口保证(方法.md 第九部分"先登记、再执行、再登记结果")。
|
// 最后写"模型调用结束",前后顺序由这个入口保证(方法.md 第九部分"先登记、再执行、再登记结果")。
|
||||||
// 提交收不收只由 submissions 判;形状不合格的由 agents 报上来,这里直接记退回("给 flow")。
|
// 提交收不收只由 submissions 判;形状不合格的由 agents 报上来,这里直接记退回("给 flow")。
|
||||||
|
|
||||||
/** 一个研究者任务:哪种提交、谁、哪个阶段、对象;known 是交上来之后才知道的东西(submissions 的 KnownOf)。 */
|
/** 一个研究者任务:哪种提交、谁、哪个阶段、对象。 */
|
||||||
export type ModelTask<K extends KindOf<"researcher">> = {
|
export type ModelTask<K extends KindOf<"researcher">> = {
|
||||||
kind: K;
|
kind: K;
|
||||||
actor: string;
|
actor: string;
|
||||||
stageId: string;
|
stageId: string;
|
||||||
object: string | null;
|
object: string | null;
|
||||||
known(content: Submission<K>): KnownOf<K>;
|
/** 一个任务里能交几份的种类(提案、挑刺):第 n 份的编号(materials 的 proposalId、critiqueId),收下时分配;只交一份的为 null。 */
|
||||||
|
item: ((n: number) => string) | null;
|
||||||
|
/** 交上来之后才知道的东西(submissions 的 KnownOf)。others 是本任务已收下、而且不是这次要替换的那几份的份数。 */
|
||||||
|
known(content: Submission<K>, others: number): KnownOf<K>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 派一次任务,等会话结束,交回调用编号和结局。 */
|
/** 派一次任务,等会话结束,交回调用编号和结局。 */
|
||||||
@@ -81,41 +84,58 @@ function researcherModel(s: Study, actor: string) {
|
|||||||
return models[i]!;
|
return models[i]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 一份提交收不收:先定写到哪一份,形状合格的再交给 submissions 判。 */
|
||||||
* 一份提交收不收。这个任务只有一份提交(第零轮每人每阶段一个任务、一份提交):
|
|
||||||
* 回给模型的提交编号就是这一份的位置,不分版本;替换时写它,接在最新一版后面。
|
|
||||||
* 第一次交以后不写 replaces 又交,或 replaces 写的不是这一份,都退回并说明该怎么写。
|
|
||||||
*/
|
|
||||||
async function submit<K extends KindOf<"researcher">>(s: Study, t: ModelTask<K>, material: TaskMaterial, attempt: SubmitAttempt<K>): Promise<SubmitReceipt> {
|
async function submit<K extends KindOf<"researcher">>(s: Study, t: ModelTask<K>, material: TaskMaterial, attempt: SubmitAttempt<K>): Promise<SubmitReceipt> {
|
||||||
const { ledger } = s;
|
const { ledger } = s;
|
||||||
const slot = { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, item: null };
|
|
||||||
const reject = (submitted: Json, reason: string, jev: JevJudgment[] | null): SubmitReceipt => {
|
const reject = (submitted: Json, reason: string, jev: JevJudgment[] | null): SubmitReceipt => {
|
||||||
ledger.append("rejection", { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, submitted, reason, jev });
|
ledger.append("rejection", { stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, submitted, reason, jev });
|
||||||
return { accepted: false, reason };
|
return { accepted: false, reason };
|
||||||
};
|
};
|
||||||
if (attempt.shape === "invalid") return reject(attempt.raw, attempt.reason, null);
|
if (attempt.shape === "invalid") return reject(attempt.raw, attempt.reason, null);
|
||||||
const id = receiptId(t);
|
|
||||||
const latest = ledger.latestSubmission(slot)?.content.version ?? 0;
|
|
||||||
const content = attempt.content as unknown as Json;
|
const content = attempt.content as unknown as Json;
|
||||||
if (attempt.replaces === null && latest > 0) {
|
const slot = slotOf(s, t, attempt.replaces);
|
||||||
return reject(content, `本阶段你已经交过这一份(提交编号 ${id})。要改就在 replaces 里写 ${id} 整份替换,不要另交`, null);
|
if ("refuse" in slot) return reject(content, slot.refuse, null);
|
||||||
}
|
|
||||||
if (attempt.replaces !== null && (attempt.replaces !== id || latest === 0)) {
|
|
||||||
const which = latest === 0 ? "你本阶段还没有收下的提交,第一次交时不要带 replaces" : `要替换就写 ${id}`;
|
|
||||||
return reject(content, `replaces 写的 ${attempt.replaces} 不是你本阶段交过的提交:${which}`, null);
|
|
||||||
}
|
|
||||||
const verdict = await receive({
|
const verdict = await receive({
|
||||||
kind: t.kind,
|
kind: t.kind,
|
||||||
content: attempt.content,
|
content: attempt.content,
|
||||||
checklist: material.checklist,
|
checklist: material.checklist,
|
||||||
known: t.known(attempt.content),
|
known: t.known(attempt.content, slot.others),
|
||||||
jevModel: s.frozen.config.models.jev,
|
jevModel: s.frozen.config.models.jev,
|
||||||
} as Incoming);
|
} as Incoming);
|
||||||
if (!verdict.accepted) return reject(content, verdict.reason, verdict.jev);
|
if (!verdict.accepted) return reject(content, verdict.reason, verdict.jev);
|
||||||
ledger.append("submission", { ...slot, version: latest + 1, content: attempt.content, jev: verdict.jev } as never);
|
const { stageId, actor: author, kind, object: target } = t;
|
||||||
return { accepted: true, id };
|
ledger.append("submission", { stageId, author, kind, target, item: slot.item, version: slot.version, content: attempt.content, jev: verdict.jev } as never);
|
||||||
|
return { accepted: true, id: slot.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回给模型的提交编号:阶段、本人、提交种类、对象。 */
|
/**
|
||||||
const receiptId = (t: { stageId: string; actor: string; kind: string; object: string | null }): string =>
|
* 这次提交写到哪一份:回给模型的提交编号、记录的 item、版本、本任务其余已收下几份。
|
||||||
`${t.stageId}.${t.actor}.${t.kind}.${t.object ?? "none"}`;
|
* 提交编号:能交几份的种类就是这一份的编号,只交一份的是"阶段.本人.种类.对象"。
|
||||||
|
* 不写 replaces 是交新的一份,只交一份的种类已经交过就退回并说明该怎么写;写了 replaces 就必须是本任务已收下的某一份。
|
||||||
|
*/
|
||||||
|
function slotOf<K extends KindOf<"researcher">>(
|
||||||
|
s: Study,
|
||||||
|
t: ModelTask<K>,
|
||||||
|
replaces: string | null,
|
||||||
|
): { id: string; item: string | null; version: number; others: number } | { refuse: string } {
|
||||||
|
const mine = s.ledger
|
||||||
|
.records()
|
||||||
|
.flatMap((r) => (r.kind === "submission" && r.content.stageId === t.stageId && r.content.author === t.actor && r.content.kind === t.kind && r.content.target === t.object ? [r.content.item] : []));
|
||||||
|
const items = [...new Set(mine)];
|
||||||
|
const idOf = (item: string | null): string => item ?? `${t.stageId}.${t.actor}.${t.kind}.${t.object ?? "none"}`;
|
||||||
|
if (replaces === null) {
|
||||||
|
if (t.item === null && items.length > 0) {
|
||||||
|
return { refuse: `本阶段你已经交过这一份(提交编号 ${idOf(null)})。要改就在 replaces 里写 ${idOf(null)} 整份替换,不要另交` };
|
||||||
|
}
|
||||||
|
const item = t.item === null ? null : t.item(items.length + 1);
|
||||||
|
return { id: idOf(item), item, version: 1, others: items.length };
|
||||||
|
}
|
||||||
|
const at = items.findIndex((item) => idOf(item) === replaces);
|
||||||
|
if (at < 0) {
|
||||||
|
const which = items.length === 0 ? "你本阶段还没有收下的提交,第一次交时不要带 replaces" : `能替换的提交编号:${items.map(idOf).join("、")}`;
|
||||||
|
return { refuse: `replaces 写的 ${replaces} 不是你本阶段交过的提交:${which}` };
|
||||||
|
}
|
||||||
|
const item = items[at] as string | null;
|
||||||
|
const latest = s.ledger.latestSubmission({ stageId: t.stageId, author: t.actor, kind: t.kind, target: t.object, item })!;
|
||||||
|
return { id: replaces, item, version: latest.content.version + 1, others: items.length - 1 };
|
||||||
|
}
|
||||||
|
|||||||
+159
-41
@@ -1,6 +1,8 @@
|
|||||||
import type { StudyStepId } from "../config/index.ts";
|
import type { KindOf } from "../agents/index.ts";
|
||||||
|
import type { RoundStepId, StudyStepId } from "../config/index.ts";
|
||||||
import type { Content, LedgerRecord } from "../ledger/index.ts";
|
import type { Content, LedgerRecord } from "../ledger/index.ts";
|
||||||
import { ORIGINAL_BASELINE, researcherId } from "../materials/index.ts";
|
import { Board, ORIGINAL_BASELINE, researcherId } from "../materials/index.ts";
|
||||||
|
import { compareIds } from "../rules/index.ts";
|
||||||
import type { Stage } from "../vocab/index.ts";
|
import type { Stage } from "../vocab/index.ts";
|
||||||
import { readFrozen, type Frozen } from "./frozen.ts";
|
import { readFrozen, type Frozen } from "./frozen.ts";
|
||||||
|
|
||||||
@@ -19,16 +21,18 @@ const ROUND_ZERO_STAGES = {
|
|||||||
claim: { stage: "claim", kind: "claim" },
|
claim: { stage: "claim", kind: "claim" },
|
||||||
} as const satisfies Partial<Record<StudyStepId, { stage: Stage; kind: string }>>;
|
} as const satisfies Partial<Record<StudyStepId, { stage: Stage; kind: string }>>;
|
||||||
type RoundZeroStep = keyof typeof ROUND_ZERO_STAGES;
|
type RoundZeroStep = keyof typeof ROUND_ZERO_STAGES;
|
||||||
export type RoundZeroKind = (typeof ROUND_ZERO_STAGES)[RoundZeroStep]["kind"];
|
|
||||||
|
|
||||||
/** 阶段编号由 flow 起:第零轮是 r0-<步骤>,一个步骤一个阶段。 */
|
/** 第 r 轮的研究者阶段:步骤名就是阶段名(vocab STAGES)。 */
|
||||||
export const stageIdOf = (step: RoundZeroStep): string => `r0-${step}`;
|
type RoundStageStep = Extract<RoundStepId, "proposal" | "firstPrediction" | "critique" | "response" | "secondPrediction">;
|
||||||
|
|
||||||
|
/** 阶段编号由 flow 起:r<轮>-<步骤>,一个步骤一个阶段。第 r 轮按配置跳过的步骤也用这个编号记"按配置跳过"。 */
|
||||||
|
export const stageIdOf = (round: number, step: string): string => `r${round}-${step}`;
|
||||||
/** 基线运行编号:没启动就起下一个编号重跑(docs/结构.md 第四部分)。 */
|
/** 基线运行编号:没启动就起下一个编号重跑(docs/结构.md 第四部分)。 */
|
||||||
export const baselineRunId = (n: number): string => `base-${n}`;
|
export const baselineRunId = (n: number): string => `base-${n}`;
|
||||||
|
|
||||||
/** flow 写的事件种类(账本记录 event)。 */
|
/** flow 写的事件种类(账本记录 event)。 */
|
||||||
export const EVENTS = {
|
export const EVENTS = {
|
||||||
/** 按配置跳过的步骤,objects 是步骤编号,reason 是 config 给的原因。 */
|
/** 按配置跳过的步骤,objects 是步骤编号(第 r 轮的步骤写 r<轮>-<步骤>),reason 是 config 给的原因。 */
|
||||||
skipped: "skippedByConfig",
|
skipped: "skippedByConfig",
|
||||||
/** 研究停下,reason 是停下的原因,objects 是相关的调用或运行。 */
|
/** 研究停下,reason 是停下的原因,objects 是相关的调用或运行。 */
|
||||||
halted: "halted",
|
halted: "halted",
|
||||||
@@ -36,15 +40,21 @@ export const EVENTS = {
|
|||||||
runStatusUnknown: "runStatusUnknown",
|
runStatusUnknown: "runStatusUnknown",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** 一个研究者任务:一个人对一个对象(候选、原提案;提案和第零轮的任务没有对象)交一种提交。 */
|
||||||
|
export type TaskRef = { actor: string; kind: KindOf<"researcher">; object: string | null };
|
||||||
|
|
||||||
export type Next =
|
export type Next =
|
||||||
| { do: "freezeConfig" }
|
| { do: "freezeConfig" }
|
||||||
| { do: "saveBaseline" }
|
| { do: "saveBaseline" }
|
||||||
| { do: "baselineRun"; run: string }
|
| { do: "baselineRun"; run: string }
|
||||||
| { do: "skip"; step: StudyStepId; reason: string }
|
| { do: "skip"; step: string; reason: string }
|
||||||
| { do: "openStage"; stageId: string; stage: Stage; roster: string[] }
|
| { do: "openStage"; stageId: string; stage: Stage; round: number; roster: string[] }
|
||||||
| { do: "closeStage"; stageId: string }
|
| { do: "closeStage"; stageId: string }
|
||||||
| { do: "dispatch"; stageId: string; kind: RoundZeroKind; actors: string[] }
|
| { do: "dispatch"; stageId: string; tasks: TaskRef[] }
|
||||||
| { do: "assignEntries" }
|
| { do: "assignEntries" }
|
||||||
|
| { do: "dedupe"; round: number }
|
||||||
|
| { do: "freezeProtocols"; round: number }
|
||||||
|
| { do: "selectBatch"; round: number }
|
||||||
| { do: "halt"; reason: string; objects: string[] };
|
| { do: "halt"; reason: string; objects: string[] };
|
||||||
|
|
||||||
export function nextStep(records: readonly LedgerRecord[]): Next {
|
export function nextStep(records: readonly LedgerRecord[]): Next {
|
||||||
@@ -53,19 +63,20 @@ export function nextStep(records: readonly LedgerRecord[]): Next {
|
|||||||
if (frozen === null) return { do: "freezeConfig" };
|
if (frozen === null) return { do: "freezeConfig" };
|
||||||
const view = new View(records);
|
const view = new View(records);
|
||||||
for (const step of frozen.steps.study) {
|
for (const step of frozen.steps.study) {
|
||||||
if (step.status === "skip") {
|
const next =
|
||||||
// 按配置跳过的步骤 → 一条"按配置跳过"事件,写明步骤和原因。
|
step.status === "skip" ? skipNext(step.id, step.reason, view) : step.id === "rounds" ? roundsNext(view, frozen) : studyStepNext(step.id, view, frozen);
|
||||||
if (!view.events(EVENTS.skipped).some((e) => e.objects[0] === step.id)) return { do: "skip", step: step.id, reason: step.reason };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const next = stepNext(step.id, view, frozen);
|
|
||||||
if (next !== null) return next;
|
if (next !== null) return next;
|
||||||
}
|
}
|
||||||
throw new Error("步骤清单走完了却没有停下:rounds 一步应当停下");
|
throw new Error("步骤清单走完了却没有停下:rounds 一步应当停下");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一个要执行的步骤还要做什么;做完了交回 null。 */
|
/** 按配置跳过的步骤 → 一条"按配置跳过"事件,写明步骤和原因。 */
|
||||||
function stepNext(step: StudyStepId, view: View, frozen: Frozen): Next | null {
|
function skipNext(step: string, reason: string, view: View): Next | null {
|
||||||
|
return view.events(EVENTS.skipped).some((e) => e.objects[0] === step) ? null : { do: "skip", step, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整份研究的一个要执行的步骤还要做什么;做完了交回 null。 */
|
||||||
|
function studyStepNext(step: Exclude<StudyStepId, "rounds">, view: View, frozen: Frozen): Next | null {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case "freezeConfig":
|
case "freezeConfig":
|
||||||
return null;
|
return null;
|
||||||
@@ -74,18 +85,107 @@ function stepNext(step: StudyStepId, view: View, frozen: Frozen): Next | null {
|
|||||||
case "entryDraft":
|
case "entryDraft":
|
||||||
case "discussion1":
|
case "discussion1":
|
||||||
case "discussion2":
|
case "discussion2":
|
||||||
case "claim":
|
case "claim": {
|
||||||
return stageNext(step, view, frozen);
|
const { stage, kind } = ROUND_ZERO_STAGES[step];
|
||||||
|
return stageNext(stageIdOf(0, step), stage, 0, (roster) => roster.map((actor) => ({ actor, kind, object: null })), view, frozen);
|
||||||
|
}
|
||||||
case "assignEntries":
|
case "assignEntries":
|
||||||
// 分配 → 第 0 轮的入口分配决定(decision,种类 entries)。
|
// 分配 → 第 0 轮的入口分配决定(decision,种类 entries)。
|
||||||
return view.of("decision").some((d) => d.decision === "entries" && d.round === 0) ? null : { do: "assignEntries" };
|
return view.board.decision("entries", 0) === undefined ? { do: "assignEntries" } : null;
|
||||||
case "rounds":
|
|
||||||
return { do: "halt", reason: "第 1 轮还没写(任务书 13)", objects: [] };
|
|
||||||
case "export":
|
case "export":
|
||||||
throw new Error("走到了导出:rounds 一步应当先停下");
|
throw new Error("走到了导出:rounds 一步应当先停下");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一轮接一轮(结构.md 第三部分"每一轮"):第一个没做完的轮次里第一个没做完的步骤。
|
||||||
|
* 每一步做完的记录都带轮次,做完的轮次整轮跳过;现在走到实现一步就停下,所以循环一定会返回。
|
||||||
|
*/
|
||||||
|
function roundsNext(view: View, frozen: Frozen): Next {
|
||||||
|
for (let round = 1; ; round++) {
|
||||||
|
for (const step of frozen.steps.round) {
|
||||||
|
const next = step.status === "skip" ? skipNext(stageIdOf(round, step.id), step.reason, view) : roundStepNext(step.id, round, view, frozen);
|
||||||
|
if (next !== null) return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第 round 轮的一个要执行的步骤还要做什么;做完了交回 null。 */
|
||||||
|
function roundStepNext(step: RoundStepId, round: number, view: View, frozen: Frozen): Next | null {
|
||||||
|
switch (step) {
|
||||||
|
case "openRound":
|
||||||
|
case "carryOverPool":
|
||||||
|
// 不写记录:本轮名单和提案上限就是冻结的配置(方法.md 4.3 ①),留池候选由 materials 从记录算出(Board.carried)。
|
||||||
|
return null;
|
||||||
|
case "proposal":
|
||||||
|
case "firstPrediction":
|
||||||
|
case "critique":
|
||||||
|
case "response":
|
||||||
|
case "secondPrediction":
|
||||||
|
return stageNext(stageIdOf(round, step), step, round, (roster) => roundTasks(step, round, roster, view.board), view, frozen);
|
||||||
|
case "dedupe":
|
||||||
|
// 判重合并 → 本轮的判重合并决定(decision,种类 dedup)。
|
||||||
|
return view.board.decision("dedup", round) === undefined ? { do: "dedupe", round } : null;
|
||||||
|
case "freezeProtocol": {
|
||||||
|
// 冻结协议 → 本轮每个新候选都有冻结协议记录(protocol)。全部候选在同一个事务里写;
|
||||||
|
// 这一步排在回应阶段关闭之后,materials 把冻结协议的出现当作"已公开",早写会泄露。
|
||||||
|
if (frozen.steps.round.some((s) => s.id === "response" && s.status === "skip")) {
|
||||||
|
return { do: "halt", reason: "配置关掉了挑刺与回应时怎么冻结协议还没写:submissions 的 protocolBody 只收回应和统一", objects: [] };
|
||||||
|
}
|
||||||
|
const frozenIds = new Set(view.board.protocols().map((p) => p.candidate));
|
||||||
|
return freshOf(view.board, round).every((c) => frozenIds.has(c.candidate)) ? null : { do: "freezeProtocols", round };
|
||||||
|
}
|
||||||
|
case "selectBatch":
|
||||||
|
// 选批 → 本轮的选批决定(decision,种类 batch)。
|
||||||
|
return view.board.decision("batch", round) === undefined ? { do: "selectBatch", round } : null;
|
||||||
|
case "implementation":
|
||||||
|
return { do: "halt", reason: "实现还没写(任务书 15)", objects: [] };
|
||||||
|
default:
|
||||||
|
throw new Error(`走到了第 ${round} 轮的 ${step}:implementation 一步应当先停下`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第 round 轮判重合并出的新候选,以及各合并了哪几条原提案。 */
|
||||||
|
export function freshOf(board: Board, round: number): Array<{ candidate: string; proposals: string[] }> {
|
||||||
|
return board.decision("dedup", round)!.output.fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第 round 轮的候选池:新候选加留池候选(方法.md 4.3 ①⑦)。第二次预测和选批都用这一份。 */
|
||||||
|
export function poolOf(board: Board, round: number): string[] {
|
||||||
|
return [...freshOf(board, round).map((c) => c.candidate), ...board.carried(round)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 r 轮一个研究者阶段的任务(任务书 14 第 2.3 节)。同一个人的几个任务按这里的先后跑。
|
||||||
|
* - 提案:每人一个任务,没有对象;
|
||||||
|
* - 第一次预测:每人 × 本轮每个新候选(含自己的;留池候选不做,方法.md 4.3 ④);
|
||||||
|
* - 挑刺:每人 × 本轮每条不是自己写的原提案(监督者定,照参考项目);
|
||||||
|
* - 回应与统一:每个新候选一个任务,一条原提案的由作者交回应,几条合成的由其中编号最小的作者交统一(方法.md 4.3 ③⑥);
|
||||||
|
* - 第二次预测:每人 × 候选池里每个候选。
|
||||||
|
*/
|
||||||
|
function roundTasks(step: RoundStageStep, round: number, roster: readonly string[], board: Board): TaskRef[] {
|
||||||
|
const each = (kind: TaskRef["kind"], objects: readonly string[]): TaskRef[] =>
|
||||||
|
roster.flatMap((actor) => objects.map((object) => ({ actor, kind, object })));
|
||||||
|
switch (step) {
|
||||||
|
case "proposal":
|
||||||
|
return roster.map((actor) => ({ actor, kind: "proposal", object: null }));
|
||||||
|
case "firstPrediction":
|
||||||
|
return each("firstPrediction", freshOf(board, round).map((c) => c.candidate));
|
||||||
|
case "critique": {
|
||||||
|
const proposals = board.published("proposal").filter((p) => p.round === round).sort((a, b) => compareIds(a.item!, b.item!));
|
||||||
|
return roster.flatMap((actor) => proposals.filter((p) => p.author !== actor).map((p) => ({ actor, kind: "critique" as const, object: p.item! })));
|
||||||
|
}
|
||||||
|
case "response":
|
||||||
|
return freshOf(board, round).map((c) => ({
|
||||||
|
actor: board.authorsOf(c.candidate)[0]!,
|
||||||
|
kind: c.proposals.length === 1 ? "response" : "unification",
|
||||||
|
object: c.candidate,
|
||||||
|
}));
|
||||||
|
case "secondPrediction":
|
||||||
|
return each("secondPrediction", poolOf(board, round));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基线 → 基线代码记录(对象 ORIGINAL_BASELINE),而且最近一次基线运行的结果不是"没启动"。
|
* 基线 → 基线代码记录(对象 ORIGINAL_BASELINE),而且最近一次基线运行的结果不是"没启动"。
|
||||||
* 没启动的起新编号重跑;判为不算数的停下(方法.md 4.1)。
|
* 没启动的起新编号重跑;判为不算数的停下(方法.md 4.1)。
|
||||||
@@ -104,28 +204,34 @@ function baselineNext(view: View): Next | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 研究者阶段 → 这个阶段的关闭记录(stageClose)。没开就开;名单上的人都有被收下的提交才关;
|
* 研究者阶段 → 这个阶段的关闭记录(stageClose)。没开就开,名单是全部研究者(本轮名单就是冻结的配置);
|
||||||
* 没交上的人按最近一次调用怎么结束的来定派不派(任务书 12 第 2.3 节的表)。能派的先全派,派完还有人停在"没交"才停下。
|
* 每个任务(一个人对一个对象)都有被收下的提交才关;没做完的任务按它最近一次调用怎么结束的来定派不派
|
||||||
|
* (任务书 12 第 2.3 节的表)。能派的先全派,派完还有任务停在"没交"才停下。
|
||||||
*/
|
*/
|
||||||
function stageNext(step: RoundZeroStep, view: View, frozen: Frozen): Next | null {
|
function stageNext(
|
||||||
const stageId = stageIdOf(step);
|
stageId: string,
|
||||||
const { stage, kind } = ROUND_ZERO_STAGES[step];
|
stage: Stage,
|
||||||
const open = view.of("stageOpen").find((s) => s.stageId === stageId);
|
round: number,
|
||||||
|
tasksOf: (roster: readonly string[]) => TaskRef[],
|
||||||
|
view: View,
|
||||||
|
frozen: Frozen,
|
||||||
|
): Next | null {
|
||||||
|
const open = view.board.stages.get(stageId);
|
||||||
if (open === undefined) {
|
if (open === undefined) {
|
||||||
return { do: "openStage", stageId, stage, roster: frozen.config.models.researchers.map((_, i) => researcherId(i)) };
|
return { do: "openStage", stageId, stage, round, roster: frozen.config.models.researchers.map((_, i) => researcherId(i)) };
|
||||||
}
|
}
|
||||||
if (view.of("stageClose").some((s) => s.stageId === stageId)) return null;
|
if (open.closed) return null;
|
||||||
const missing = open.roster.filter((a) => !view.of("submission").some((s) => s.stageId === stageId && s.author === a));
|
const todo = tasksOf(open.roster).filter((t) => !view.done(stageId, t));
|
||||||
if (missing.length === 0) return { do: "closeStage", stageId };
|
if (todo.length === 0) return { do: "closeStage", stageId };
|
||||||
const actors: string[] = [];
|
const tasks: TaskRef[] = [];
|
||||||
const stuck: Array<{ call: string; reason: string }> = [];
|
const stuck: Array<{ call: string; reason: string }> = [];
|
||||||
for (const actor of missing) {
|
for (const t of todo) {
|
||||||
const last = view.lastCall(stageId, actor);
|
const last = view.lastCall(stageId, t);
|
||||||
// 没有调用、被用户停下、出错(含恢复时补写的"进程中断")都派;没交的只在开发入口开着时派(方法.md 第九部分"开发入口")。
|
// 没有调用、被用户停下、出错(含恢复时补写的"进程中断")都派;没交的只在开发入口开着时派(方法.md 第九部分"开发入口")。
|
||||||
if (last?.status === "notSubmitted" && !frozen.config.devEntry) stuck.push({ call: last.call, reason: last.reason });
|
if (last?.status === "notSubmitted" && !frozen.config.devEntry) stuck.push({ call: last.call, reason: last.reason });
|
||||||
else actors.push(actor);
|
else tasks.push(t);
|
||||||
}
|
}
|
||||||
if (actors.length > 0) return { do: "dispatch", stageId, kind, actors };
|
if (tasks.length > 0) return { do: "dispatch", stageId, tasks };
|
||||||
return {
|
return {
|
||||||
do: "halt",
|
do: "halt",
|
||||||
reason: `交不出合格提交(方法.md 4.3 ②:按内部错误停下):${stuck.map((s) => `调用 ${s.call},${s.reason}`).join(";")}`,
|
reason: `交不出合格提交(方法.md 4.3 ②:按内部错误停下):${stuck.map((s) => `调用 ${s.call},${s.reason}`).join(";")}`,
|
||||||
@@ -133,12 +239,14 @@ function stageNext(step: RoundZeroStep, view: View, frozen: Frozen): Next | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按种类取记录内容的小工具。 */
|
/** 按种类取记录内容的小工具,加上 materials 读账本的那一份(Board)。 */
|
||||||
class View {
|
class View {
|
||||||
readonly #records: readonly LedgerRecord[];
|
readonly #records: readonly LedgerRecord[];
|
||||||
|
readonly board: Board;
|
||||||
|
|
||||||
constructor(records: readonly LedgerRecord[]) {
|
constructor(records: readonly LedgerRecord[]) {
|
||||||
this.#records = records;
|
this.#records = records;
|
||||||
|
this.board = new Board(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
of<K extends LedgerRecord["kind"]>(kind: K): Array<Content<K>> {
|
of<K extends LedgerRecord["kind"]>(kind: K): Array<Content<K>> {
|
||||||
@@ -149,9 +257,19 @@ class View {
|
|||||||
return this.of("event").filter((e) => e.event === event);
|
return this.of("event").filter((e) => e.event === event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 某人在某阶段最近一次模型调用的结束记录,没有调用为 null。开始了没结束的,恢复入口应当已经补写。 */
|
/** 一个任务做完了:这个人在这个阶段对这个对象有一份被收下的提交(任务书 14 第 2.3 节)。 */
|
||||||
lastCall(stageId: string, actor: string): Content<"callEnded"> | null {
|
done(stageId: string, t: TaskRef): boolean {
|
||||||
const started = this.of("callStarted").filter((c) => c.stageId === stageId && c.actor === actor).at(-1);
|
return this.of("submission").some((s) => s.stageId === stageId && s.author === t.actor && s.target === t.object);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个任务最近一次模型调用的结束记录,没有调用为 null。"模型调用开始"里存的材料带着对象,按它认出是哪个任务。
|
||||||
|
* 开始了没结束的,恢复入口应当已经补写。
|
||||||
|
*/
|
||||||
|
lastCall(stageId: string, t: TaskRef): Content<"callEnded"> | null {
|
||||||
|
const started = this.of("callStarted")
|
||||||
|
.filter((c) => c.stageId === stageId && c.actor === t.actor && (c.materials as { object: string | null }).object === t.object)
|
||||||
|
.at(-1);
|
||||||
if (started === undefined) return null;
|
if (started === undefined) return null;
|
||||||
const ended = this.of("callEnded").find((e) => e.call === started.call);
|
const ended = this.of("callEnded").find((e) => e.call === started.call);
|
||||||
if (ended === undefined) throw new Error(`模型调用 ${started.call} 开始了还没有结束记录:恢复入口应当已经补写`);
|
if (ended === undefined) throw new Error(`模型调用 ${started.call} 开始了还没有结束记录:恢复入口应当已经补写`);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Board, critiqueId, proposalId } from "../../materials/index.ts";
|
||||||
|
import { runModelTask, type ModelTask } from "../model-task.ts";
|
||||||
|
import type { Next, TaskRef } from "../next-step.ts";
|
||||||
|
import { halt, USER_STOPPED, type Study, type StudyEnding } from "../study.ts";
|
||||||
|
|
||||||
|
// 派一个研究者阶段里要派的任务(任务书 12 第 2.6 节、任务书 14 第 2.3 节):
|
||||||
|
// 同一个人的几个任务按先后跑,不同的人同时跑,各自一个会话。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派这些任务。有任务没交上(没交或出错)、或有会话抛出内部错误后,不再开新的会话;
|
||||||
|
* 已经在跑的不打断(别人已经在花钱了),等它们全部结束后再停下或把错误抛出去(任务书 12 第 2.4 节)。
|
||||||
|
*/
|
||||||
|
export async function dispatchStage(s: Study, next: Extract<Next, { do: "dispatch" }>): Promise<StudyEnding | null> {
|
||||||
|
const byActor = new Map<string, TaskRef[]>();
|
||||||
|
for (const t of next.tasks) byActor.set(t.actor, [...(byActor.get(t.actor) ?? []), t]);
|
||||||
|
let halting = false;
|
||||||
|
const missed: Array<{ call: string; text: string }> = [];
|
||||||
|
const runActor = async (tasks: readonly TaskRef[]): Promise<void> => {
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (halting || s.stop.aborted) return;
|
||||||
|
const { call, ending } = await runModelTask(s, modelTask(s, next.stageId, t));
|
||||||
|
if (ending.status === "notSubmitted" || ending.status === "failed") {
|
||||||
|
halting = true;
|
||||||
|
missed.push({ call, text: `${ending.status === "failed" ? "出错" : "没交"}:${ending.reason}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
[...byActor.values()].map((tasks) =>
|
||||||
|
runActor(tasks).catch((error: unknown) => {
|
||||||
|
halting = true;
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const failure = settled.find((r) => r.status === "rejected");
|
||||||
|
if (failure !== undefined) throw failure.reason;
|
||||||
|
if (s.stop.aborted) return USER_STOPPED;
|
||||||
|
if (missed.length === 0) return null;
|
||||||
|
return halt(s.ledger, `阶段 ${next.stageId} 有任务没交上,等这次派发的会话全部结束后停下:${missed.map((m) => `调用 ${m.call},${m.text}`).join(";")}`, missed.map((m) => m.call));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个任务交上来之后才知道的东西和分份编号(submissions 的 KnownOf、任务书 14 第 2.3 节):
|
||||||
|
* 提案是本人本阶段已收下几条,按 proposalId 分份;挑刺按 critiqueId 分份;回应是作者那条原提案;其余没有。
|
||||||
|
*/
|
||||||
|
function modelTask(s: Study, stageId: string, t: TaskRef): ModelTask<TaskRef["kind"]> {
|
||||||
|
const base = { kind: t.kind, actor: t.actor, stageId, object: t.object };
|
||||||
|
const board = new Board(s.ledger.records());
|
||||||
|
switch (t.kind) {
|
||||||
|
case "proposal": {
|
||||||
|
const round = board.stage(stageId).round;
|
||||||
|
return { ...base, item: (n) => proposalId(round, t.actor, n), known: (_, others) => ({ accepted: others }) } as ModelTask<"proposal">;
|
||||||
|
}
|
||||||
|
case "critique":
|
||||||
|
return { ...base, item: (n) => critiqueId(t.object!, t.actor, n), known: () => null };
|
||||||
|
case "response": {
|
||||||
|
// 回应的候选只由一条原提案而来(几条合成的交统一)。
|
||||||
|
const original = board.proposal(board.proposalsOf(t.object!)[0]!).content;
|
||||||
|
return { ...base, item: null, known: () => ({ original }) } as ModelTask<"response">;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { ...base, item: null, known: () => null };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,9 @@
|
|||||||
import { finalEntries } from "../../materials/index.ts";
|
import { finalEntries } from "../../materials/index.ts";
|
||||||
import { assignEntries as assign } from "../../rules/index.ts";
|
import { assignEntries as assign } from "../../rules/index.ts";
|
||||||
import type { Json } from "../../vocab/index.ts";
|
import type { Json } from "../../vocab/index.ts";
|
||||||
import { runModelTask } from "../model-task.ts";
|
import type { Study } from "../study.ts";
|
||||||
import type { Next } from "../next-step.ts";
|
|
||||||
import { halt, USER_STOPPED, type Study, type StudyEnding } from "../study.ts";
|
|
||||||
|
|
||||||
// 第零轮(方法.md 4.2):三个研究者阶段派任务,认领之后由程序分配入口。
|
// 第零轮(方法.md 4.2):三个研究者阶段由 dispatch.ts 派任务,认领之后由程序分配入口。
|
||||||
|
|
||||||
/**
|
|
||||||
* 派一个研究者阶段里要派的人,同时跑,各自一个会话(任务书 12 第 2.6 节)。第零轮的提交种类交上来之后没有要另算的东西。
|
|
||||||
* 有人没交上(没交或出错),等这次派发的会话全部结束后停下,不打断别人(第 2.4 节)。
|
|
||||||
* 某个会话抛出内部错误时,也等别人结束再把它抛出去。
|
|
||||||
*/
|
|
||||||
export async function dispatchStage(s: Study, next: Extract<Next, { do: "dispatch" }>): Promise<StudyEnding | null> {
|
|
||||||
const settled = await Promise.allSettled(
|
|
||||||
next.actors.map((actor) => runModelTask(s, { kind: next.kind, actor, stageId: next.stageId, object: null, known: () => null })),
|
|
||||||
);
|
|
||||||
const failure = settled.find((r) => r.status === "rejected");
|
|
||||||
if (failure !== undefined) throw failure.reason;
|
|
||||||
if (s.stop.aborted) return USER_STOPPED;
|
|
||||||
const results = settled.map((r) => (r as PromiseFulfilledResult<Awaited<ReturnType<typeof runModelTask>>>).value);
|
|
||||||
const missed = results.flatMap(({ call, ending }) =>
|
|
||||||
ending.status === "notSubmitted" || ending.status === "failed" ? [{ call, text: `${ending.status === "failed" ? "出错" : "没交"}:${ending.reason}` }] : [],
|
|
||||||
);
|
|
||||||
if (missed.length === 0) return null;
|
|
||||||
return halt(s.ledger, `阶段 ${next.stageId} 有人没交上,等这次派发的会话全部结束后停下:${missed.map((m) => `调用 ${m.call},${m.text}`).join(";")}`, missed.map((m) => m.call));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分配(方法.md 4.2"分配规则"):每人最后一份认领和最终有效的入口交给 rules 的 assignEntries,写一条入口分配决定。
|
* 分配(方法.md 4.2"分配规则"):每人最后一份认领和最终有效的入口交给 rules 的 assignEntries,写一条入口分配决定。
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { judgeSameChange, type DedupFactor } from "../../jev/index.ts";
|
||||||
|
import { Board, candidateId, freezeProtocol, type DedupOutput } from "../../materials/index.ts";
|
||||||
|
import { compareIds, decisionSeed, runBalance, runBatchRule, type BatchInput, type BatchRuleName } from "../../rules/index.ts";
|
||||||
|
import { protocolBody } from "../../submissions/index.ts";
|
||||||
|
import type { JevJudgment, Json } from "../../vocab/index.ts";
|
||||||
|
import { freshOf, poolOf, stageIdOf } from "../next-step.ts";
|
||||||
|
import type { Study } from "../study.ts";
|
||||||
|
|
||||||
|
// 第 r 轮里程序自己做的三步(方法.md 4.3 ③⑥⑧):判重合并、冻结协议、选批。每步写一条记录(冻结协议一个事务),
|
||||||
|
// 写下之前被打断就从账本重新算一遍;写下之后恢复时照原样用,不重算(方法.md 第九部分)。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一轮里几个要随机的决定各用一个"版本"算种子(方法.md 第九部分):选批和选格都用随机规则时,
|
||||||
|
* 两次抽取不会用同一串随机数。选格由 flow 第 4 部分接上。
|
||||||
|
*/
|
||||||
|
const SEED_VERSIONS = { batch: 1, cells: 2 } as const;
|
||||||
|
|
||||||
|
/** 一对提案(或提案与留池候选)的 Jev 判断,原样存进判重合并决定的输入。 */
|
||||||
|
export type SamePair = { a: string; b: string; judgment: JevJudgment };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ③ 判重合并:本轮新提案两两问一次 Jev,每条新提案再和每个留池候选问一次(留池候选看它冻结协议里的因子),
|
||||||
|
* 按顺序发。全部问完才写决定:中途被打断就整轮重问,判重不扣运行次数。
|
||||||
|
*/
|
||||||
|
export async function dedupe(s: Study, round: number): Promise<void> {
|
||||||
|
const board = new Board(s.ledger.records());
|
||||||
|
const proposals = board.published("proposal").filter((p) => p.round === round).sort((a, b) => compareIds(a.item!, b.item!));
|
||||||
|
const carried = board.carried(round);
|
||||||
|
const model = s.frozen.config.models.jev;
|
||||||
|
const factorsOf = new Map<string, readonly DedupFactor[]>([
|
||||||
|
...proposals.map((p) => [p.item!, p.content.factors] as const),
|
||||||
|
...carried.map((c) => [c, board.protocol(c).factors] as const),
|
||||||
|
]);
|
||||||
|
const asks = [
|
||||||
|
...proposals.flatMap((p, i) => proposals.slice(i + 1).map((q) => [p.item!, q.item!] as const)),
|
||||||
|
...proposals.flatMap((p) => carried.map((c) => [p.item!, c] as const)),
|
||||||
|
];
|
||||||
|
const pairs: SamePair[] = [];
|
||||||
|
for (const [a, b] of asks) pairs.push({ a, b, judgment: await judgeSameChange(model, factorsOf.get(a)!, factorsOf.get(b)!) });
|
||||||
|
const output = groupSameChange(round, proposals.map((p) => p.item!), carried, pairs);
|
||||||
|
s.ledger.append("decision", {
|
||||||
|
decision: "dedup",
|
||||||
|
round,
|
||||||
|
rule: "jevSameChange",
|
||||||
|
ruleVersion: 1,
|
||||||
|
params: { jevModel: model },
|
||||||
|
seed: null,
|
||||||
|
input: { pairs } as unknown as Json,
|
||||||
|
output: output as unknown as Json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判为相同的连成一组(监督者定:A 和 B 相同、B 和 C 相同,A、B、C 就是一个候选;方法没写这种情况)。
|
||||||
|
* 一组里有留池候选,整组并进它(方法.md 4.3 ③);一组连到两个留池候选按内部错误停下(真实运行里没出现过)。
|
||||||
|
* 其余每组是一个新候选,按组里编号最小的原提案排序,编号用 candidateId。每个留池候选都列出,没并进提案的是空数组。
|
||||||
|
*/
|
||||||
|
export function groupSameChange(round: number, proposals: readonly string[], carried: readonly string[], pairs: readonly SamePair[]): DedupOutput {
|
||||||
|
const parent = new Map([...proposals, ...carried].map((x) => [x, x]));
|
||||||
|
const root = (x: string): string => {
|
||||||
|
while (parent.get(x) !== x) x = parent.get(x)!;
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
for (const p of pairs) if (p.judgment.yes) parent.set(root(p.a), root(p.b));
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
for (const x of [...proposals, ...carried]) groups.set(root(x), [...(groups.get(root(x)) ?? []), x]);
|
||||||
|
const into = new Map(carried.map((c) => [c, [] as string[]]));
|
||||||
|
const fresh: string[][] = [];
|
||||||
|
for (const members of groups.values()) {
|
||||||
|
const old = members.filter((m) => into.has(m));
|
||||||
|
const added = members.filter((m) => !into.has(m)).sort(compareIds);
|
||||||
|
if (old.length > 1) {
|
||||||
|
throw new Error(`判重合并:提案 ${added.join("、")} 把留池候选 ${old.join("、")} 连成了一组,方法没写该并进哪一个(真实运行里没出现过),按内部错误停下`);
|
||||||
|
}
|
||||||
|
if (old.length === 1) into.get(old[0]!)!.push(...added);
|
||||||
|
else fresh.push(added);
|
||||||
|
}
|
||||||
|
fresh.sort((a, b) => compareIds(a[0]!, b[0]!));
|
||||||
|
return {
|
||||||
|
fresh: fresh.map((ids, i) => ({ candidate: candidateId(round, i + 1), proposals: ids })),
|
||||||
|
carried: [...into].sort(([a], [b]) => compareIds(a, b)).map(([candidate, ids]) => ({ candidate, proposals: ids })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⑥ 冻结协议:回应阶段关闭后,每个新候选取它最后一份回应或统一,用 protocolBody 拼主体,freezeProtocol 补上候选编号、
|
||||||
|
* 轮次、本批基线、合并了哪些原提案。全部候选在同一个事务里写:materials 把冻结协议的出现当作"已公开"。
|
||||||
|
* 留池候选沿用原来的冻结协议(方法.md 4.3 ①),不重写。
|
||||||
|
*/
|
||||||
|
export function freezeProtocols(s: Study, round: number): void {
|
||||||
|
const board = new Board(s.ledger.records());
|
||||||
|
const baseline = board.baselineOf(round);
|
||||||
|
s.ledger.transaction(() => {
|
||||||
|
for (const { candidate, proposals } of freshOf(board, round)) {
|
||||||
|
const answer = board.answerOf(candidate);
|
||||||
|
const body =
|
||||||
|
answer.kind === "response"
|
||||||
|
? protocolBody({ kind: "response", content: answer.content, original: board.proposal(proposals[0]!).content })
|
||||||
|
: protocolBody({ kind: "unification", content: answer.content });
|
||||||
|
s.ledger.append("protocol", { candidate, protocol: freezeProtocol(body, candidate, round, baseline, proposals) as unknown as Json });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⑧ 选批(方法.md 6.4):池里每个候选、各人最后一份第二次预测,加本批基线分数、方向、ε、λ、每批上限、余额和种子,
|
||||||
|
* 交给步骤清单里 selectBatch 那一步的规则,写一条选批决定。
|
||||||
|
*/
|
||||||
|
export function selectBatch(s: Study, round: number): void {
|
||||||
|
const board = new Board(s.ledger.records());
|
||||||
|
const { config, facts, steps } = s.frozen;
|
||||||
|
const stageId = stageIdOf(round, "secondPrediction");
|
||||||
|
const predictions = board.published("secondPrediction").filter((p) => p.stageId === stageId);
|
||||||
|
const range = (researcher: string, candidate: string) => predictions.find((p) => p.author === researcher && p.target === candidate)!.content.range;
|
||||||
|
const seed = decisionSeed({ studyId: config.studyId, round, version: SEED_VERSIONS.batch });
|
||||||
|
const input: BatchInput = {
|
||||||
|
candidates: poolOf(board, round).map((id) => ({ id, ranges: board.stage(stageId).roster.map((researcher) => ({ researcher, range: range(researcher, id) })) })),
|
||||||
|
baseline: board.baselineScore(board.baselineOf(round)),
|
||||||
|
direction: facts.finalMetric.direction,
|
||||||
|
epsilon: facts.epsilon,
|
||||||
|
lambda: config.flow.openReserve,
|
||||||
|
batchCap: config.batchLimit,
|
||||||
|
balance: runBalance(config.runs, board.runStates()).balance,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
const rule = steps.round.find((x) => x.id === "selectBatch")!.rule!.name as BatchRuleName;
|
||||||
|
const { rule: used, ...output } = runBatchRule(rule, input);
|
||||||
|
s.ledger.append("decision", {
|
||||||
|
decision: "batch",
|
||||||
|
round,
|
||||||
|
rule: used.name,
|
||||||
|
ruleVersion: used.version,
|
||||||
|
params: output.params,
|
||||||
|
seed,
|
||||||
|
input: input as unknown as Json,
|
||||||
|
output: output as unknown as Json,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ function rangeToBelief(lambda: number): string {
|
|||||||
|
|
||||||
/** 信念本全文。round 是读者所在的轮次:之前各轮没选中、现在留在候选池的候选,不附它们之前的合并范围;导出时写 null。 */
|
/** 信念本全文。round 是读者所在的轮次:之前各轮没选中、现在留在候选池的候选,不附它们之前的合并范围;导出时写 null。 */
|
||||||
export function beliefBook(ledger: Ledger, study: StudyFacts, round: number | null): string {
|
export function beliefBook(ledger: Ledger, study: StudyFacts, round: number | null): string {
|
||||||
return beliefBookOf(new Board(ledger), study, round);
|
return beliefBookOf(new Board(ledger.records()), study, round);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beliefBookOf(board: Board, study: StudyFacts, round: number | null): string {
|
export function beliefBookOf(board: Board, study: StudyFacts, round: number | null): string {
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export function validEntries(board: Board): Array<{ id: string; author: string;
|
|||||||
|
|
||||||
/** 按账本取的有效入口:flow 分配入口时用,和认领的对号清单是同一份(方法.md 4.2)。 */
|
/** 按账本取的有效入口:flow 分配入口时用,和认领的对号清单是同一份(方法.md 4.2)。 */
|
||||||
export function finalEntries(ledger: Ledger): Array<{ id: string; author: string; name: string; body: string }> {
|
export function finalEntries(ledger: Ledger): Array<{ id: string; author: string; name: string; body: string }> {
|
||||||
return validEntries(new Board(ledger));
|
return validEntries(new Board(ledger.records()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const BUILDERS: { [K in SubmissionKind]: (d: Draft) => Extract<Checklist, { kind
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildMaterial(req: TaskRequest): TaskMaterial {
|
export function buildMaterial(req: TaskRequest): TaskMaterial {
|
||||||
const board = new Board(req.ledger);
|
const board = new Board(req.ledger.records());
|
||||||
const d = new Draft(req, board);
|
const d = new Draft(req, board);
|
||||||
const checklist = BUILDERS[req.kind](d);
|
const checklist = BUILDERS[req.kind](d);
|
||||||
// 重新发起时放上一次被退回的原因(只放本人这一任务的退回,任务书 09 第五部分)。
|
// 重新发起时放上一次被退回的原因(只放本人这一任务的退回,任务书 09 第五部分)。
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export { finalEntries, type MaterialRecord, type MaterialSection, type ReadableV
|
|||||||
export { buildMaterial } from "./build.ts";
|
export { buildMaterial } from "./build.ts";
|
||||||
export { type CodeChanges, type PromptFile, type Prompts, type StudyFacts, type TaskRequest } from "./inputs.ts";
|
export { type CodeChanges, type PromptFile, type Prompts, type StudyFacts, type TaskRequest } from "./inputs.ts";
|
||||||
export { queryMaterial, readPrompts } from "./prompts.ts";
|
export { queryMaterial, readPrompts } from "./prompts.ts";
|
||||||
|
// flow 推进时读账本也用这一份(本批基线、基线分数、留池候选、各候选合并了哪些原提案),不另写一份。
|
||||||
|
export { Board } from "./read.ts";
|
||||||
export {
|
export {
|
||||||
callId,
|
callId,
|
||||||
candidateId,
|
candidateId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Content, Ledger, LedgerRecord, StoredSubmission } from "../ledger/index.ts";
|
import type { Content, LedgerRecord, StoredSubmission } from "../ledger/index.ts";
|
||||||
import { compareIds, type RunState } from "../rules/index.ts";
|
import { compareIds, type RunState } from "../rules/index.ts";
|
||||||
import type { JudgedResult, Range, Stage, SubmissionKind } from "../vocab/index.ts";
|
import type { JudgedResult, Range, Stage, SubmissionKind } from "../vocab/index.ts";
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +26,12 @@ type StageInfo = { stage: Stage; round: number; roster: string[]; closed: boolea
|
|||||||
|
|
||||||
export class Board {
|
export class Board {
|
||||||
readonly stages = new Map<string, StageInfo>();
|
readonly stages = new Map<string, StageInfo>();
|
||||||
readonly #records: LedgerRecord[];
|
readonly #records: readonly LedgerRecord[];
|
||||||
readonly #subs: Sub[] = [];
|
readonly #subs: Sub[] = [];
|
||||||
readonly #runs = new Map<string, Run>();
|
readonly #runs = new Map<string, Run>();
|
||||||
|
|
||||||
constructor(ledger: Ledger) {
|
constructor(records: readonly LedgerRecord[]) {
|
||||||
this.#records = ledger.records();
|
this.#records = records;
|
||||||
for (const r of this.#records) {
|
for (const r of this.#records) {
|
||||||
if (r.kind === "stageOpen") this.stages.set(r.content.stageId, { ...r.content, closed: false });
|
if (r.kind === "stageOpen") this.stages.set(r.content.stageId, { ...r.content, closed: false });
|
||||||
if (r.kind === "stageClose") this.stage(r.content.stageId).closed = true;
|
if (r.kind === "stageClose") this.stage(r.content.stageId).closed = true;
|
||||||
|
|||||||
@@ -66,14 +66,19 @@ export function entry(d: Draft): ChecklistOf<"entry"> {
|
|||||||
return { kind: "entry", entries: { min: 2, max: 3 } };
|
return { kind: "entry", entries: { min: 2, max: 3 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 入口和之前各次讨论:只读已关阶段的(写入口阶段关了才有讨论)。 */
|
/**
|
||||||
|
* 入口和之前各次讨论:只读已关阶段的(写入口阶段关了才有讨论)。讨论原文里引用的入口编号已经作废的,
|
||||||
|
* 就地标"(已作废)":讨论时整份替换过清单,原文还引用旧编号,读的人分不清哪些还有效(用户 2026-10-03 同意)。
|
||||||
|
*/
|
||||||
function entriesAndDiscussions(d: Draft): void {
|
function entriesAndDiscussions(d: Draft): void {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const e of validEntries(d.board)) {
|
const valid = validEntries(d.board);
|
||||||
|
for (const e of valid) {
|
||||||
d.record(e.id, `入口 ${e.id}:${e.name}(作者 ${e.author})`, e.body);
|
d.record(e.id, `入口 ${e.id}:${e.name}(作者 ${e.author})`, e.body);
|
||||||
lines.push(`### 入口 ${e.id}:${e.name}(作者 ${e.author})`, "", e.body, "");
|
lines.push(`### 入口 ${e.id}:${e.name}(作者 ${e.author})`, "", e.body, "");
|
||||||
}
|
}
|
||||||
for (const s of d.board.published("discussion")) lines.push(`### ${s.author} 的讨论(阶段 ${s.stageId})`, "", s.content.body, "");
|
const voided = [...entryVersions(d.board).values()].flatMap((vs) => vs.flatMap((v) => v.ids)).filter((id) => !valid.some((e) => e.id === id));
|
||||||
|
for (const s of d.board.published("discussion")) lines.push(`### ${s.author} 的讨论(阶段 ${s.stageId})`, "", markVoided(s.content.body, voided), "");
|
||||||
d.section("本任务要处理的东西", lines);
|
d.section("本任务要处理的东西", lines);
|
||||||
const table: string[] = ['程序给的号;整份替换后的新清单接着往后编,旧编号作废,不会指到新入口。'];
|
const table: string[] = ['程序给的号;整份替换后的新清单接着往后编,旧编号作废,不会指到新入口。'];
|
||||||
for (const [author, versions] of entryVersions(d.board)) {
|
for (const [author, versions] of entryVersions(d.board)) {
|
||||||
@@ -86,6 +91,12 @@ function entriesAndDiscussions(d: Draft): void {
|
|||||||
d.section("入口编号对照", table);
|
d.section("入口编号对照", table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在正文里每个作废的入口编号后面标"(已作废)"。编号前后紧挨字母、数字或"-"的不算(e-x-1 不会匹配到 e-x-12 里)。 */
|
||||||
|
function markVoided(body: string, voided: readonly string[]): string {
|
||||||
|
if (voided.length === 0) return body;
|
||||||
|
return body.replace(new RegExp(`(?<![\\w-])(${voided.join("|")})(?![\\w-])`, "g"), "$1(已作废)");
|
||||||
|
}
|
||||||
|
|
||||||
/** 0.2 讨论:全部入口(标明作者)、之前各次的讨论。 */
|
/** 0.2 讨论:全部入口(标明作者)、之前各次的讨论。 */
|
||||||
export function discussion(d: Draft): ChecklistOf<"discussion"> {
|
export function discussion(d: Draft): ChecklistOf<"discussion"> {
|
||||||
frame(d, [], () => entriesAndDiscussions(d));
|
frame(d, [], () => entriesAndDiscussions(d));
|
||||||
|
|||||||
@@ -18,10 +18,6 @@
|
|||||||
`evaluate.py` / `LICENSE`,成绩一位不变,别的测试都不会挂。`evaluate.py` 和 `LICENSE`
|
`evaluate.py` / `LICENSE`,成绩一位不变,别的测试都不会挂。`evaluate.py` 和 `LICENSE`
|
||||||
根本不在运行路径上。
|
根本不在运行路径上。
|
||||||
|
|
||||||
一个实测过的事实(参考项目里跑出来的):把 `compute_max_radii` 那个白跑 105625 次的冗余双循环删掉,
|
|
||||||
半径和一位不差(都是 0.9597642169962064),但第 (7, 8) 对圆会重叠 2.7755575615628914e-17,
|
|
||||||
容差 0 下判不合法,要经合法化才收下。那个冗余循环仍然是本研究的改进起点。
|
|
||||||
|
|
||||||
## 已知的基线事实(刻意保留,不是缺陷)
|
## 已知的基线事实(刻意保留,不是缺陷)
|
||||||
|
|
||||||
- `initial.py` 的 `compute_max_radii` 含嵌套的冗余缩半径循环(外层 `for i/for j`
|
- `initial.py` 的 `compute_max_radii` 含嵌套的冗余缩半径循环(外层 `for i/for j`
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { groupSameChange, type SamePair } from "../src/flow/steps/round.ts";
|
||||||
|
|
||||||
|
// 判重合并怎么连组(任务书 14 第 2.4 节):判为相同的连成一组,组里有留池候选就并进它,
|
||||||
|
// 新候选按组里编号最小的原提案编号。预期值按规则手写。
|
||||||
|
|
||||||
|
const pair = (a: string, b: string, yes: boolean): SamePair => ({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
judgment: { question: "same_change", questionHash: "h", threshold: 0.5, probability: yes ? 0.9 : 0.1, yes, requestId: "req" },
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A 与 B 相同、B 与 C 相同:A、B、C 连成一个候选,即使 A 与 C 判为不同;新候选按组里最小的原提案编号", () => {
|
||||||
|
const ps = ["r1-researcher-1-1", "r1-researcher-1-2", "r1-researcher-2-1", "r1-researcher-3-1"];
|
||||||
|
const pairs = [
|
||||||
|
pair("r1-researcher-1-1", "r1-researcher-1-2", false),
|
||||||
|
pair("r1-researcher-1-1", "r1-researcher-2-1", false),
|
||||||
|
pair("r1-researcher-1-1", "r1-researcher-3-1", false),
|
||||||
|
pair("r1-researcher-1-2", "r1-researcher-2-1", false),
|
||||||
|
pair("r1-researcher-1-2", "r1-researcher-3-1", true),
|
||||||
|
pair("r1-researcher-2-1", "r1-researcher-3-1", true),
|
||||||
|
];
|
||||||
|
assert.deepEqual(groupSameChange(1, ps, [], pairs), {
|
||||||
|
fresh: [
|
||||||
|
{ candidate: "r1-c1", proposals: ["r1-researcher-1-1"] },
|
||||||
|
{ candidate: "r1-c2", proposals: ["r1-researcher-1-2", "r1-researcher-2-1", "r1-researcher-3-1"] },
|
||||||
|
],
|
||||||
|
carried: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("编号按数字大小比:researcher-10 排在 researcher-2 后面", () => {
|
||||||
|
const ps = ["r1-researcher-10-1", "r1-researcher-2-1"];
|
||||||
|
assert.deepEqual(groupSameChange(1, ps, [], [pair(ps[0]!, ps[1]!, false)]).fresh, [
|
||||||
|
{ candidate: "r1-c1", proposals: ["r1-researcher-2-1"] },
|
||||||
|
{ candidate: "r1-c2", proposals: ["r1-researcher-10-1"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("和留池候选相同的提案并进它,连带和这条提案相同的别的提案;没并进提案的留池候选列空数组", () => {
|
||||||
|
const ps = ["r2-researcher-1-1", "r2-researcher-2-1", "r2-researcher-3-1"];
|
||||||
|
const pairs = [
|
||||||
|
pair("r2-researcher-1-1", "r2-researcher-2-1", true),
|
||||||
|
pair("r2-researcher-1-1", "r2-researcher-3-1", false),
|
||||||
|
pair("r2-researcher-2-1", "r2-researcher-3-1", false),
|
||||||
|
pair("r2-researcher-1-1", "r1-c1", false),
|
||||||
|
pair("r2-researcher-1-1", "r1-c3", false),
|
||||||
|
pair("r2-researcher-2-1", "r1-c1", false),
|
||||||
|
pair("r2-researcher-2-1", "r1-c3", true),
|
||||||
|
pair("r2-researcher-3-1", "r1-c1", false),
|
||||||
|
pair("r2-researcher-3-1", "r1-c3", false),
|
||||||
|
];
|
||||||
|
assert.deepEqual(groupSameChange(2, ps, ["r1-c3", "r1-c1"], pairs), {
|
||||||
|
fresh: [{ candidate: "r2-c1", proposals: ["r2-researcher-3-1"] }],
|
||||||
|
carried: [
|
||||||
|
{ candidate: "r1-c1", proposals: [] },
|
||||||
|
{ candidate: "r1-c3", proposals: ["r2-researcher-1-1", "r2-researcher-2-1"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("一组连到两个留池候选:按内部错误停下", () => {
|
||||||
|
const pairs = [pair("r2-researcher-1-1", "r1-c1", true), pair("r2-researcher-1-1", "r1-c2", true)];
|
||||||
|
assert.throws(() => groupSameChange(2, ["r2-researcher-1-1"], ["r1-c1", "r1-c2"], pairs), /留池候选 r1-c1、r1-c2 连成了一组/);
|
||||||
|
});
|
||||||
+180
-29
@@ -6,7 +6,7 @@ import { stepList, type ResearchConfig } from "../src/config/index.ts";
|
|||||||
import type { AppendKind, Content, LedgerRecord } from "../src/ledger/index.ts";
|
import type { AppendKind, Content, LedgerRecord } from "../src/ledger/index.ts";
|
||||||
import type { StudyRecord } from "../src/materials/index.ts";
|
import type { StudyRecord } from "../src/materials/index.ts";
|
||||||
import { nextStep } from "../src/flow/next-step.ts";
|
import { nextStep } from "../src/flow/next-step.ts";
|
||||||
import type { Json } from "../src/vocab/index.ts";
|
import type { Json, SubmissionKind } from "../src/vocab/index.ts";
|
||||||
import { fullConfig } from "./config-helpers.ts";
|
import { fullConfig } from "./config-helpers.ts";
|
||||||
|
|
||||||
// flow 的"下一步做什么"(任务书 12 第六部分):纯函数,手写账本记录,看它给出的下一步。
|
// flow 的"下一步做什么"(任务书 12 第六部分):纯函数,手写账本记录,看它给出的下一步。
|
||||||
@@ -73,8 +73,8 @@ class Book {
|
|||||||
return this.add("runResult", { run, process, judged });
|
return this.add("runResult", { run, process, judged });
|
||||||
}
|
}
|
||||||
|
|
||||||
open(stageId: string, stage: Content<"stageOpen">["stage"], roster: string[]): this {
|
open(stageId: string, stage: Content<"stageOpen">["stage"], round: number, roster: string[]): this {
|
||||||
return this.add("stageOpen", { stageId, stage, round: 0, roster });
|
return this.add("stageOpen", { stageId, stage, round, roster });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一份被收下的提交。nextStep 只看这个人在这个阶段有没有被收下的提交,种类一律写成入口。 */
|
/** 一份被收下的提交。nextStep 只看这个人在这个阶段有没有被收下的提交,种类一律写成入口。 */
|
||||||
@@ -83,15 +83,21 @@ class Book {
|
|||||||
return this.add("submission", { stageId, author, target: null, item: null, version: 1, kind: "entry", content, jev: null });
|
return this.add("submission", { stageId, author, target: null, item: null, version: 1, kind: "entry", content, jev: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一次模型调用:开始和结束。 */
|
/** 一份被收下的提交,内容 nextStep 不读。 */
|
||||||
call(stageId: string, actor: string, n: number, ended: Ended): this {
|
sub(stageId: string, author: string, kind: SubmissionKind, target: string | null, item: string | null): this {
|
||||||
const call = `${stageId}.${actor}.entry.none.${n}`;
|
return this.add("submission", { stageId, author, target, item, version: 1, kind, content: {}, jev: null } as unknown as Content<"submission">);
|
||||||
this.add("callStarted", { call, actor, stageId, model: "iomgaa/glm-5.3", materials: null });
|
}
|
||||||
|
|
||||||
|
/** 一次模型调用:开始和结束。"模型调用开始"里的材料只写 nextStep 认任务用的种类和对象。 */
|
||||||
|
call(stageId: string, actor: string, kind: SubmissionKind, object: string | null, n: number, ended: Ended): this {
|
||||||
|
const call = `${stageId}.${actor}.${kind}.${object ?? "none"}.${n}`;
|
||||||
|
this.add("callStarted", { call, actor, stageId, model: "iomgaa/glm-5.3", materials: { kind, object } });
|
||||||
return this.add("callEnded", { call, ...ended } as Content<"callEnded">);
|
return this.add("callEnded", { call, ...ended } as Content<"callEnded">);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const R = ["researcher-1", "researcher-2", "researcher-3"];
|
const R = ["researcher-1", "researcher-2", "researcher-3"];
|
||||||
|
const entryTasks = (actors: string[]) => actors.map((actor) => ({ actor, kind: "entry", object: null }));
|
||||||
|
|
||||||
/** 冻结了配置、基线跑完算数的账本。 */
|
/** 冻结了配置、基线跑完算数的账本。 */
|
||||||
function afterBaseline(config: ResearchConfig = fullConfig()): Book {
|
function afterBaseline(config: ResearchConfig = fullConfig()): Book {
|
||||||
@@ -124,20 +130,20 @@ test("基线判为不算数:停下,原因写明", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("基线算数之后:开写入口阶段,名单是全部研究者", () => {
|
test("基线算数之后:开写入口阶段,名单是全部研究者", () => {
|
||||||
assert.deepEqual(nextStep(afterBaseline().records), { do: "openStage", stageId: "r0-entryDraft", stage: "entryDraft", roster: R });
|
assert.deepEqual(nextStep(afterBaseline().records), { do: "openStage", stageId: "r0-entryDraft", stage: "entryDraft", round: 0, roster: R });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("阶段开着、一人已交、两人没交:派那两人", () => {
|
test("阶段开着、一人已交、两人没交:派那两人", () => {
|
||||||
const book = afterBaseline().open("r0-entryDraft", "entryDraft", R).entry("r0-entryDraft", "researcher-1");
|
const book = afterBaseline().open("r0-entryDraft", "entryDraft", 0, R).entry("r0-entryDraft", "researcher-1");
|
||||||
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2", "researcher-3"] });
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-2", "researcher-3"]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("都交了:关阶段;关了以后开第 1 次讨论", () => {
|
test("都交了:关阶段;关了以后开第 1 次讨论", () => {
|
||||||
const book = afterBaseline().open("r0-entryDraft", "entryDraft", R);
|
const book = afterBaseline().open("r0-entryDraft", "entryDraft", 0, R);
|
||||||
for (const r of R) book.entry("r0-entryDraft", r);
|
for (const r of R) book.entry("r0-entryDraft", r);
|
||||||
assert.deepEqual(nextStep(book.records), { do: "closeStage", stageId: "r0-entryDraft" });
|
assert.deepEqual(nextStep(book.records), { do: "closeStage", stageId: "r0-entryDraft" });
|
||||||
book.add("stageClose", { stageId: "r0-entryDraft" });
|
book.add("stageClose", { stageId: "r0-entryDraft" });
|
||||||
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-discussion1", stage: "discussion", roster: R });
|
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-discussion1", stage: "discussion", round: 0, roster: R });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("有人最近一次是\"没交\":开发入口关着先派能派的、再停下;开着重新派", () => {
|
test("有人最近一次是\"没交\":开发入口关着先派能派的、再停下;开着重新派", () => {
|
||||||
@@ -146,11 +152,11 @@ test("有人最近一次是\"没交\":开发入口关着先派能派的、再
|
|||||||
.config({ ...fullConfig(), devEntry })
|
.config({ ...fullConfig(), devEntry })
|
||||||
.baselineCode()
|
.baselineCode()
|
||||||
.baselineRun("base-1", "valid")
|
.baselineRun("base-1", "valid")
|
||||||
.open("r0-entryDraft", "entryDraft", R)
|
.open("r0-entryDraft", "entryDraft", 0, R)
|
||||||
.entry("r0-entryDraft", "researcher-1")
|
.entry("r0-entryDraft", "researcher-1")
|
||||||
.call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" });
|
.call("r0-entryDraft", "researcher-2", "entry", null, 1, { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" });
|
||||||
// researcher-3 还没派过,先派他。
|
// researcher-3 还没派过,先派他。
|
||||||
assert.deepEqual(nextStep(stuck(false).records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-3"] });
|
assert.deepEqual(nextStep(stuck(false).records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-3"]) });
|
||||||
// researcher-3 交上以后只剩"没交"的 researcher-2:停下,附调用编号。
|
// researcher-3 交上以后只剩"没交"的 researcher-2:停下,附调用编号。
|
||||||
const closed = stuck(false).entry("r0-entryDraft", "researcher-3");
|
const closed = stuck(false).entry("r0-entryDraft", "researcher-3");
|
||||||
const next = nextStep(closed.records);
|
const next = nextStep(closed.records);
|
||||||
@@ -159,34 +165,35 @@ test("有人最近一次是\"没交\":开发入口关着先派能派的、再
|
|||||||
assert.deepEqual((next as { objects: string[] }).objects, ["r0-entryDraft.researcher-2.entry.none.1"]);
|
assert.deepEqual((next as { objects: string[] }).objects, ["r0-entryDraft.researcher-2.entry.none.1"]);
|
||||||
// 开发入口开着:重新派。
|
// 开发入口开着:重新派。
|
||||||
const dev = stuck(true).entry("r0-entryDraft", "researcher-3");
|
const dev = stuck(true).entry("r0-entryDraft", "researcher-3");
|
||||||
assert.deepEqual(nextStep(dev.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2"] });
|
assert.deepEqual(nextStep(dev.records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-2"]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("有人最近一次是\"出错\"或\"进程中断\":重新派;只看最近一次调用", () => {
|
test("有人最近一次是\"出错\"或\"进程中断\":重新派;只看最近一次调用", () => {
|
||||||
const book = afterBaseline()
|
const book = afterBaseline()
|
||||||
.open("r0-entryDraft", "entryDraft", R)
|
.open("r0-entryDraft", "entryDraft", 0, R)
|
||||||
.entry("r0-entryDraft", "researcher-1")
|
.entry("r0-entryDraft", "researcher-1")
|
||||||
.call("r0-entryDraft", "researcher-2", 1, { wallSeconds: measured(5), status: "notSubmitted", reason: "没交" })
|
.call("r0-entryDraft", "researcher-2", "entry", null, 1, { wallSeconds: measured(5), status: "notSubmitted", reason: "没交" })
|
||||||
.call("r0-entryDraft", "researcher-2", 2, { wallSeconds: measured(5), status: "failed", reason: "模型服务报错" })
|
.call("r0-entryDraft", "researcher-2", "entry", null, 2, { wallSeconds: measured(5), status: "failed", reason: "模型服务报错" })
|
||||||
.call("r0-entryDraft", "researcher-3", 1, { wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断" });
|
.call("r0-entryDraft", "researcher-3", "entry", null, 1, { wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断" });
|
||||||
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", kind: "entry", actors: ["researcher-2", "researcher-3"] });
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r0-entryDraft", tasks: entryTasks(["researcher-2", "researcher-3"]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("只有一位研究者:两次讨论各写一条按配置跳过,接着开认领", () => {
|
test("只有一位研究者:两次讨论各写一条按配置跳过,接着开认领", () => {
|
||||||
const one = fullConfig();
|
const one = fullConfig();
|
||||||
one.models.researchers = [one.models.researchers[0]!];
|
one.models.researchers = [one.models.researchers[0]!];
|
||||||
const book = afterBaseline(one).open("r0-entryDraft", "entryDraft", ["researcher-1"]).entry("r0-entryDraft", "researcher-1");
|
const book = afterBaseline(one).open("r0-entryDraft", "entryDraft", 0, ["researcher-1"]).entry("r0-entryDraft", "researcher-1");
|
||||||
book.add("stageClose", { stageId: "r0-entryDraft" });
|
book.add("stageClose", { stageId: "r0-entryDraft" });
|
||||||
const reason = "只有一位研究者,第零轮不讨论(方法.md 4.2)";
|
const reason = "只有一位研究者,第零轮不讨论(方法.md 4.2)";
|
||||||
assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion1", reason });
|
assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion1", reason });
|
||||||
book.add("event", { event: "skippedByConfig", reason, objects: ["discussion1"] });
|
book.add("event", { event: "skippedByConfig", reason, objects: ["discussion1"] });
|
||||||
assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion2", reason });
|
assert.deepEqual(nextStep(book.records), { do: "skip", step: "discussion2", reason });
|
||||||
book.add("event", { event: "skippedByConfig", reason, objects: ["discussion2"] });
|
book.add("event", { event: "skippedByConfig", reason, objects: ["discussion2"] });
|
||||||
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-claim", stage: "claim", roster: ["researcher-1"] });
|
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r0-claim", stage: "claim", round: 0, roster: ["researcher-1"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("认领关了:分配;分配做完:停下,原因\"第 1 轮还没写\"", () => {
|
/** 第零轮做完:四个阶段都交齐关闭,分配决定已写。 */
|
||||||
const book = afterBaseline();
|
function afterRoundZero(config: ResearchConfig): Book {
|
||||||
|
const book = afterBaseline(config);
|
||||||
const stages = [
|
const stages = [
|
||||||
["r0-entryDraft", "entryDraft"],
|
["r0-entryDraft", "entryDraft"],
|
||||||
["r0-discussion1", "discussion"],
|
["r0-discussion1", "discussion"],
|
||||||
@@ -194,12 +201,156 @@ test("认领关了:分配;分配做完:停下,原因\"第 1 轮还没写
|
|||||||
["r0-claim", "claim"],
|
["r0-claim", "claim"],
|
||||||
] as const;
|
] as const;
|
||||||
for (const [stageId, stage] of stages) {
|
for (const [stageId, stage] of stages) {
|
||||||
book.open(stageId, stage, R);
|
book.open(stageId, stage, 0, R);
|
||||||
for (const r of R) book.entry(stageId, r);
|
for (const r of R) book.entry(stageId, r);
|
||||||
book.add("stageClose", { stageId });
|
book.add("stageClose", { stageId });
|
||||||
}
|
}
|
||||||
assert.deepEqual(nextStep(book.records), { do: "assignEntries" });
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分配决定。 */
|
||||||
|
function assigned(book: Book): Book {
|
||||||
const output = R.map((researcher, i) => ({ researcher, entry: `e-${researcher}-1`, rank: i + 1 }));
|
const output = R.map((researcher, i) => ({ researcher, entry: `e-${researcher}-1`, rank: i + 1 }));
|
||||||
book.add("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input: null, output });
|
return book.add("decision", { decision: "entries", round: 0, rule: "assignEntries", ruleVersion: 1, params: null, seed: null, input: null, output });
|
||||||
assert.deepEqual(nextStep(book.records), { do: "halt", reason: "第 1 轮还没写(任务书 13)", objects: [] });
|
}
|
||||||
|
|
||||||
|
test("认领关了:分配;分配做完:开第 1 轮的提案阶段,名单是全部研究者", () => {
|
||||||
|
const book = afterRoundZero(fullConfig());
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "assignEntries" });
|
||||||
|
assigned(book);
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-proposal", stage: "proposal", round: 1, roster: R });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 第 1 轮:开轮到选批(任务书 14 第 2.2、2.3 节)
|
||||||
|
// 三条原提案:researcher-1 的单独成候选 r1-c1;researcher-2、researcher-3 的判为同一个改法,合成 r1-c2。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const P1 = "r1-researcher-1-1";
|
||||||
|
const P2 = "r1-researcher-2-1";
|
||||||
|
const P3 = "r1-researcher-3-1";
|
||||||
|
const C = ["r1-c1", "r1-c2"];
|
||||||
|
|
||||||
|
/** 一个研究者阶段:开、每个任务交一份、关。tasks 是 [作者, 种类, 对象, 编号]。 */
|
||||||
|
function stage(book: Book, stageId: string, kind: Content<"stageOpen">["stage"], tasks: Array<[string, SubmissionKind, string | null, string | null]>): Book {
|
||||||
|
book.open(stageId, kind, 1, R);
|
||||||
|
for (const [author, k, target, item] of tasks) book.sub(stageId, author, k, target, item);
|
||||||
|
return book.add("stageClose", { stageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每人 × 每个对象的任务。 */
|
||||||
|
const each = (kind: SubmissionKind, objects: string[]) => R.flatMap((actor) => objects.map((object) => ({ actor, kind, object })));
|
||||||
|
|
||||||
|
/** 第 1 轮写到 last 这一步做完为止。 */
|
||||||
|
function roundOne(last: "proposal" | "dedupe" | "firstPrediction" | "critique" | "response" | "freezeProtocol" | "secondPrediction" | "selectBatch"): Book {
|
||||||
|
const book = assigned(afterRoundZero(fullConfig()));
|
||||||
|
const order = ["proposal", "dedupe", "firstPrediction", "critique", "response", "freezeProtocol", "secondPrediction", "selectBatch"];
|
||||||
|
const steps: Record<string, () => void> = {
|
||||||
|
proposal: () => stage(book, "r1-proposal", "proposal", [["researcher-1", "proposal", null, P1], ["researcher-2", "proposal", null, P2], ["researcher-3", "proposal", null, P3]]),
|
||||||
|
dedupe: () => {
|
||||||
|
const output = { fresh: [{ candidate: "r1-c1", proposals: [P1] }, { candidate: "r1-c2", proposals: [P2, P3] }], carried: [] };
|
||||||
|
book.add("decision", { decision: "dedup", round: 1, rule: "jevSameChange", ruleVersion: 1, params: null, seed: null, input: null, output });
|
||||||
|
},
|
||||||
|
firstPrediction: () => stage(book, "r1-firstPrediction", "firstPrediction", R.flatMap((a) => C.map((c) => [a, "firstPrediction", c, null] as [string, SubmissionKind, string, null]))),
|
||||||
|
critique: () =>
|
||||||
|
stage(book, "r1-critique", "critique", [
|
||||||
|
["researcher-1", "critique", P2, `${P2}.researcher-1.1`],
|
||||||
|
["researcher-1", "critique", P3, `${P3}.researcher-1.1`],
|
||||||
|
["researcher-2", "critique", P1, `${P1}.researcher-2.1`],
|
||||||
|
["researcher-2", "critique", P3, `${P3}.researcher-2.1`],
|
||||||
|
["researcher-3", "critique", P1, `${P1}.researcher-3.1`],
|
||||||
|
["researcher-3", "critique", P2, `${P2}.researcher-3.1`],
|
||||||
|
]),
|
||||||
|
response: () => stage(book, "r1-response", "response", [["researcher-1", "response", "r1-c1", null], ["researcher-2", "unification", "r1-c2", null]]),
|
||||||
|
freezeProtocol: () => {
|
||||||
|
for (const candidate of C) book.add("protocol", { candidate, protocol: { round: 1, baseline: "base", proposals: [] } });
|
||||||
|
},
|
||||||
|
secondPrediction: () => stage(book, "r1-secondPrediction", "secondPrediction", R.flatMap((a) => C.map((c) => [a, "secondPrediction", c, null] as [string, SubmissionKind, string, null]))),
|
||||||
|
selectBatch: () => book.add("decision", { decision: "batch", round: 1, rule: "bestExpected", ruleVersion: 1, params: null, seed: "s", input: null, output: { chosen: ["r1-c1"] } }),
|
||||||
|
};
|
||||||
|
for (const step of order.slice(0, order.indexOf(last) + 1)) steps[step]!();
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("提案阶段一人交了两条、两人没交:只派没交的两人,提案没有对象", () => {
|
||||||
|
const book = assigned(afterRoundZero(fullConfig())).open("r1-proposal", "proposal", 1, R).sub("r1-proposal", "researcher-1", "proposal", null, P1);
|
||||||
|
book.sub("r1-proposal", "researcher-1", "proposal", null, "r1-researcher-1-2");
|
||||||
|
const tasks = ["researcher-2", "researcher-3"].map((actor) => ({ actor, kind: "proposal", object: null }));
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-proposal", tasks });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("提案交齐关闭后:判重合并", () => {
|
||||||
|
assert.deepEqual(nextStep(roundOne("proposal").records), { do: "dedupe", round: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("判重做完:开第一次预测;任务是每人 × 每个新候选(含自己的),同一个人的按候选编号排", () => {
|
||||||
|
const book = roundOne("dedupe");
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-firstPrediction", stage: "firstPrediction", round: 1, roster: R });
|
||||||
|
book.open("r1-firstPrediction", "firstPrediction", 1, R);
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-firstPrediction", tasks: each("firstPrediction", C) });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同一个人的任务按对象分开算:对 r1-c1 没交的停住,r1-c2 照派;能派的派完才停下", () => {
|
||||||
|
const notSubmitted = { wallSeconds: measured(30), status: "notSubmitted", reason: "模型结束了回复,没有一份提交被收下" } as const;
|
||||||
|
const book = roundOne("dedupe").open("r1-firstPrediction", "firstPrediction", 1, R);
|
||||||
|
for (const a of ["researcher-1", "researcher-3"]) for (const c of C) book.sub("r1-firstPrediction", a, "firstPrediction", c, null);
|
||||||
|
book.call("r1-firstPrediction", "researcher-2", "firstPrediction", "r1-c1", 1, notSubmitted);
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-firstPrediction", tasks: [{ actor: "researcher-2", kind: "firstPrediction", object: "r1-c2" }] });
|
||||||
|
book.sub("r1-firstPrediction", "researcher-2", "firstPrediction", "r1-c2", null);
|
||||||
|
const next = nextStep(book.records);
|
||||||
|
assert.equal(next.do, "halt");
|
||||||
|
assert.deepEqual((next as { objects: string[] }).objects, ["r1-firstPrediction.researcher-2.firstPrediction.r1-c1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("挑刺阶段:每人 × 每条不是自己写的原提案,没有挑自己的", () => {
|
||||||
|
const book = roundOne("firstPrediction").open("r1-critique", "critique", 1, R);
|
||||||
|
const expected = [
|
||||||
|
{ actor: "researcher-1", kind: "critique", object: P2 },
|
||||||
|
{ actor: "researcher-1", kind: "critique", object: P3 },
|
||||||
|
{ actor: "researcher-2", kind: "critique", object: P1 },
|
||||||
|
{ actor: "researcher-2", kind: "critique", object: P3 },
|
||||||
|
{ actor: "researcher-3", kind: "critique", object: P1 },
|
||||||
|
{ actor: "researcher-3", kind: "critique", object: P2 },
|
||||||
|
];
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-critique", tasks: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("回应阶段:单条原提案的候选派作者交回应,合并而成的派编号最小的作者交统一", () => {
|
||||||
|
const book = roundOne("critique").open("r1-response", "response", 1, R);
|
||||||
|
const expected = [
|
||||||
|
{ actor: "researcher-1", kind: "response", object: "r1-c1" },
|
||||||
|
{ actor: "researcher-2", kind: "unification", object: "r1-c2" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "dispatch", stageId: "r1-response", tasks: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("回应阶段关闭、还没有冻结协议:写冻结协议;写完开第二次预测,任务是每人 × 池里每个候选", () => {
|
||||||
|
const book = roundOne("response");
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "freezeProtocols", round: 1 });
|
||||||
|
const frozen = roundOne("freezeProtocol");
|
||||||
|
assert.deepEqual(nextStep(frozen.records), { do: "openStage", stageId: "r1-secondPrediction", stage: "secondPrediction", round: 1, roster: R });
|
||||||
|
frozen.open("r1-secondPrediction", "secondPrediction", 1, R);
|
||||||
|
assert.deepEqual(nextStep(frozen.records), { do: "dispatch", stageId: "r1-secondPrediction", tasks: each("secondPrediction", C) });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("第二次预测关闭:选批", () => {
|
||||||
|
assert.deepEqual(nextStep(roundOne("secondPrediction").records), { do: "selectBatch", round: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选批做完:停下,原因\"实现还没写\"", () => {
|
||||||
|
assert.deepEqual(nextStep(roundOne("selectBatch").records), { do: "halt", reason: "实现还没写(任务书 15)", objects: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("关掉第一次预测:第 1 轮写一条按配置跳过(对象 r1-firstPrediction),接着开挑刺", () => {
|
||||||
|
const config = fullConfig();
|
||||||
|
if (config.flow.preset !== "full") throw new Error("测试配置应是完整流程");
|
||||||
|
config.flow.firstPrediction = false;
|
||||||
|
const book = assigned(afterRoundZero(config));
|
||||||
|
stage(book, "r1-proposal", "proposal", [["researcher-1", "proposal", null, P1], ["researcher-2", "proposal", null, P2], ["researcher-3", "proposal", null, P3]]);
|
||||||
|
const output = { fresh: [{ candidate: "r1-c1", proposals: [P1] }, { candidate: "r1-c2", proposals: [P2, P3] }], carried: [] };
|
||||||
|
book.add("decision", { decision: "dedup", round: 1, rule: "jevSameChange", ruleVersion: 1, params: null, seed: null, input: null, output });
|
||||||
|
const reason = "配置关掉了第一次预测";
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "skip", step: "r1-firstPrediction", reason });
|
||||||
|
book.add("event", { event: "skippedByConfig", reason, objects: ["r1-firstPrediction"] });
|
||||||
|
assert.deepEqual(nextStep(book.records), { do: "openStage", stageId: "r1-critique", stage: "critique", round: 1, roster: R });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { test, type TestContext } from "node:test";
|
||||||
|
import { stepList } from "../src/config/index.ts";
|
||||||
|
import { fullConfig as checkedFull } from "../src/flow/frozen.ts";
|
||||||
|
import { freezeProtocols, selectBatch } from "../src/flow/steps/round.ts";
|
||||||
|
import type { Study } from "../src/flow/study.ts";
|
||||||
|
import { Ledger, type LedgerRecord } from "../src/ledger/index.ts";
|
||||||
|
import { decisionSeed, runBatchRule } from "../src/rules/index.ts";
|
||||||
|
import { checkTaskFacts } from "../src/tasks/index.ts";
|
||||||
|
import { fullConfig } from "./config-helpers.ts";
|
||||||
|
import { tempDir } from "./ledger-helpers.ts";
|
||||||
|
import { judged, P_A1, P_A2, r, Writer } from "./materials-helpers.ts";
|
||||||
|
|
||||||
|
// 冻结协议和选批两步(任务书 14 第 2.4 节):在真实账本里写到回应阶段关闭、第二次预测关闭,跑这一步,看写下的记录。
|
||||||
|
// 预期值按条文手写:协议主体取回应叠原提案、指标改全名、基线和合并的原提案;选批输入照 rules 的 BatchInput 逐项手拼。
|
||||||
|
// 这两步只读账本和冻结的配置,Study 里别的几样(Docker、提示词)用不到。
|
||||||
|
|
||||||
|
const config = fullConfig();
|
||||||
|
const facts = checkTaskFacts(JSON.parse(readFileSync(join(import.meta.dirname, "..", "tasks", "circle_packing", "task.json"), "utf8")));
|
||||||
|
const CRITIQUE = "r1-a1-1.a2.1";
|
||||||
|
|
||||||
|
function studyAfterResponse(t: TestContext): Study {
|
||||||
|
const ledger = Ledger.open(join(tempDir(t), "ledger.db"));
|
||||||
|
t.after(() => ledger.close());
|
||||||
|
const w = new Writer(ledger);
|
||||||
|
ledger.append("code", { object: "base", version: "v-base", locations: null });
|
||||||
|
w.run("base-1", "baseline", 0, null, judged(0.96, "main"));
|
||||||
|
w.open("r1-proposal", "proposal", 1, ["a1", "a2"]);
|
||||||
|
w.sub("r1-proposal", "a1", "proposal", null, "r1-a1-1", P_A1);
|
||||||
|
w.sub("r1-proposal", "a2", "proposal", null, "r1-a2-1", P_A2);
|
||||||
|
w.close("r1-proposal");
|
||||||
|
w.decision("dedup", 1, null, { fresh: [{ candidate: "r1-c1", proposals: ["r1-a1-1"] }, { candidate: "r1-c2", proposals: ["r1-a2-1"] }], carried: [] });
|
||||||
|
w.open("r1-critique", "critique", 1, ["a1", "a2"]);
|
||||||
|
w.sub("r1-critique", "a2", "critique", "r1-a1-1", CRITIQUE, { target: "p2", body: "乙的挑刺" });
|
||||||
|
w.close("r1-critique");
|
||||||
|
w.open("r1-response", "response", 1, ["a1", "a2"]);
|
||||||
|
// a1 第一版只答复,第二版改了正文:冻结取最后一版。
|
||||||
|
w.sub("r1-response", "a1", "response", "r1-c1", null, { replies: [{ critique: CRITIQUE, reply: "保留" }], changes: {}, clauses: ["旧条款"], body: "无" });
|
||||||
|
ledger.append("submission", {
|
||||||
|
stageId: "r1-response",
|
||||||
|
author: "a1",
|
||||||
|
kind: "response",
|
||||||
|
target: "r1-c1",
|
||||||
|
item: null,
|
||||||
|
version: 2,
|
||||||
|
content: { replies: [{ critique: CRITIQUE, reply: "改了正文" }], changes: { body: "甲改过的正文" }, clauses: ["加多起点", "加扰动"], body: "答复正文" },
|
||||||
|
jev: null,
|
||||||
|
});
|
||||||
|
w.sub("r1-response", "a2", "response", "r1-c2", null, { replies: [], changes: {}, clauses: ["加修复"], body: "无" });
|
||||||
|
w.close("r1-response");
|
||||||
|
const frozen = { config: checkedFull(config), steps: stepList(config), facts, prompts: null, image: "", seed: 1 };
|
||||||
|
return { ledger, frozen } as unknown as Study;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocols = (records: readonly LedgerRecord[]) => records.filter((x) => x.kind === "protocol").map((x) => x.content);
|
||||||
|
|
||||||
|
test("冻结协议:回应阶段关闭后,每个新候选取最后一版回应叠在原提案上,指标改全名,写明基线和合并的原提案;同一个事务", (t) => {
|
||||||
|
const s = studyAfterResponse(t);
|
||||||
|
freezeProtocols(s, 1);
|
||||||
|
assert.deepEqual(protocols(s.ledger.records()), [
|
||||||
|
{
|
||||||
|
candidate: "r1-c1",
|
||||||
|
protocol: {
|
||||||
|
factors: P_A1.factors,
|
||||||
|
safeguards: ["guard"],
|
||||||
|
metrics: { defined: [{ ...P_A1.metrics.defined[0]!, id: "r1-c1.restart-count" }], reused: [] },
|
||||||
|
body: "甲改过的正文",
|
||||||
|
clauses: ["加多起点", "加扰动"],
|
||||||
|
round: 1,
|
||||||
|
baseline: "base",
|
||||||
|
proposals: ["r1-a1-1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
candidate: "r1-c2",
|
||||||
|
protocol: {
|
||||||
|
factors: P_A2.factors,
|
||||||
|
safeguards: [],
|
||||||
|
metrics: { defined: [{ ...P_A2.metrics.defined[0]!, id: "r1-c2.restart-count" }], reused: [] },
|
||||||
|
body: "乙的提案正文",
|
||||||
|
clauses: ["加修复"],
|
||||||
|
round: 1,
|
||||||
|
baseline: "base",
|
||||||
|
proposals: ["r1-a2-1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("选批:输入照 BatchInput 拼(各人最后一版第二次预测、本批基线分数、余额从运行记录算、种子用选批的版本),规则取步骤清单", (t) => {
|
||||||
|
const s = studyAfterResponse(t);
|
||||||
|
freezeProtocols(s, 1);
|
||||||
|
const w = new Writer(s.ledger);
|
||||||
|
w.open("r1-secondPrediction", "secondPrediction", 1, ["a1", "a2"]);
|
||||||
|
w.sub("r1-secondPrediction", "a1", "secondPrediction", "r1-c1", null, { range: r(1, 1.1, 1.2), body: "第一版" });
|
||||||
|
s.ledger.append("submission", {
|
||||||
|
stageId: "r1-secondPrediction",
|
||||||
|
author: "a1",
|
||||||
|
kind: "secondPrediction",
|
||||||
|
target: "r1-c1",
|
||||||
|
item: null,
|
||||||
|
version: 2,
|
||||||
|
content: { range: r(1.05, 1.15, 1.25), body: "改过" },
|
||||||
|
jev: null,
|
||||||
|
});
|
||||||
|
w.sub("r1-secondPrediction", "a2", "secondPrediction", "r1-c1", null, { range: r(1, 1.1, 1.2), body: "理由" });
|
||||||
|
w.sub("r1-secondPrediction", "a1", "secondPrediction", "r1-c2", null, { range: r(0.9, 0.97, 1), body: "理由" });
|
||||||
|
w.sub("r1-secondPrediction", "a2", "secondPrediction", "r1-c2", null, { range: r(0.9, 0.96, 1), body: "理由" });
|
||||||
|
w.close("r1-secondPrediction");
|
||||||
|
selectBatch(s, 1);
|
||||||
|
const decision = s.ledger.records().find((x) => x.kind === "decision" && x.content.decision === "batch")!.content as Record<string, unknown>;
|
||||||
|
const seed = decisionSeed({ studyId: "circle-full-1", round: 1, version: 1 });
|
||||||
|
const input = {
|
||||||
|
candidates: [
|
||||||
|
{ id: "r1-c1", ranges: [{ researcher: "a1", range: r(1.05, 1.15, 1.25) }, { researcher: "a2", range: r(1, 1.1, 1.2) }] },
|
||||||
|
{ id: "r1-c2", ranges: [{ researcher: "a1", range: r(0.9, 0.97, 1) }, { researcher: "a2", range: r(0.9, 0.96, 1) }] },
|
||||||
|
],
|
||||||
|
baseline: 0.96,
|
||||||
|
direction: "maximize",
|
||||||
|
epsilon: facts.epsilon,
|
||||||
|
lambda: 0.1,
|
||||||
|
batchCap: 2,
|
||||||
|
// 13 次里基线运行确认启动、扣了 1 次。
|
||||||
|
balance: 12,
|
||||||
|
seed,
|
||||||
|
} as const;
|
||||||
|
const { rule: _rule, ...output } = runBatchRule("bestExpected", input);
|
||||||
|
assert.deepEqual(decision, { decision: "batch", round: 1, rule: "bestExpected", ruleVersion: 1, params: { epsilon: facts.epsilon, lambda: 0.1, batchCap: 2, balance: 12 }, seed, input, output });
|
||||||
|
});
|
||||||
@@ -46,6 +46,21 @@ test("认领:整份替换后新清单接着编号,旧编号作废;至少
|
|||||||
has(m, "乙的讨论");
|
has(m, "乙的讨论");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("讨论原文里引用的已作废入口编号就地标\"(已作废)\",有效编号和相近的编号不标(用户 2026-10-03 同意)", (t) => {
|
||||||
|
// a1 在第 1 次讨论里把 e-a1-1、e-a1-2 整份换成 e-a1-3、e-a1-4;a2 在同一次讨论里还引用旧编号。
|
||||||
|
const l = studyUpTo(t, "entryClose");
|
||||||
|
l.append("stageOpen", { stageId: "s0-d1", stage: "discussion", round: 0, roster: ["a1", "a2"] });
|
||||||
|
const entries = [{ name: "入口甲三", body: "甲三起点" }, { name: "入口甲四", body: "甲四起点" }];
|
||||||
|
l.append("submission", { stageId: "s0-d1", author: "a1", kind: "discussion", target: null, item: null, version: 1, content: { body: "换掉 e-a1-1", entries }, jev: null });
|
||||||
|
const body = "我支持e-a1-1,也看好 e-a1-3 和 e-a2-1;e-a1-12 不存在。(e-a1-2)";
|
||||||
|
l.append("submission", { stageId: "s0-d1", author: "a2", kind: "discussion", target: null, item: null, version: 1, content: { body }, jev: null });
|
||||||
|
l.append("stageClose", { stageId: "s0-d1" });
|
||||||
|
l.append("stageOpen", { stageId: "s0-claim", stage: "claim", round: 0, roster: ["a1", "a2"] });
|
||||||
|
const m = material(l, "claim", "a1", "s0-claim", null, NONE);
|
||||||
|
has(m, "我支持e-a1-1(已作废),也看好 e-a1-3 和 e-a2-1;e-a1-12 不存在。(e-a1-2(已作废))");
|
||||||
|
has(m, "换掉 e-a1-1(已作废)");
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 第 r 轮(方法.md 4.3)
|
// 第 r 轮(方法.md 4.3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user