Files
InfoRSI/src/ledger/kinds.ts
T
iomgaa 852600df02 feat: 诊断(任务书 18 第一块):诊断数学、四种提交、一次运行跑多个实验、账本与材料、flow 诊断各步
消融默认关,关掉时解释阶段一并跳过;选诊断实验用信息价值减成本。
eval/diagnosis/research.json 为第三块配置。vocab 字段说明的出口文字随提示词一起改,见下一次提交的 prompts/ 与同文件改动(本次含在内)。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 10:36:20 -04:00

261 lines
13 KiB
TypeScript
Raw 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 { Type, type Static, type TSchema } from "typebox";
import { Value } from "typebox/value";
import {
Id,
JevJudgment,
JudgedResult,
Measured,
ModelUsage,
ProcessResult,
Range,
RunDefinition,
STAGES,
SUBMISSION_KINDS,
SUBMISSION_SCHEMAS,
type Json,
type Submission,
type SubmissionKind,
} from "../vocab/index.ts";
// 账本记录的种类(docs/结构.md 第四部分的 11 种记录,细分成下面 20 种),每种一份内容定义(TypeBox)。
// 下面写"只有一条""只一次"的,由 schema.ts 的唯一约束保证。
// 配置、冻结协议、决定、结算的内容由别的模块产生,这里只检查最少的标识字段,其余只检查是 Json。
const CLOSED = { additionalProperties: false } as const;
const Text = Type.String({ minLength: 1 });
const AnyJson = Type.Unsafe<Json>({});
const Round = Type.Integer({ minimum: 0 });
const Version = Type.Integer({ minimum: 1 });
const Seq = Type.Integer({ minimum: 1 });
const OptionalId = Type.Union([Id, Type.Null()]);
/**
* 这件事用到的 Jev 判断,原样存(docs/结构.md 第四部分:Jev 不单独成记录,记在它判断的那件事里)。
* 一次可能问好几问(如冻结条款三问、代码改动五问),所以是数组,至少一条;没用到 Jev 的写 null。
*/
const JevUsed = Type.Union([Type.Array(JevJudgment, { minItems: 1 }), Type.Null()]);
// ---------------------------------------------------------------------------
// 提交(记录 3)
// ---------------------------------------------------------------------------
/**
* 一人在同一阶段对同一对象能交好几份的提交种类:提案(每人 1 至 M 条,方法.md 4.3 ②)、
* 挑刺(回应时按挑刺编号逐条答复,方法.md 第五部分)。这两种的每一份带一个由程序分配的编号 item,
* 替换时按 SubmissionSlot 找同一份的最新一版(docs/结构.md 第四部分"写入时由 ledger 拒绝的几种情况")。
* 其余种类一人对一个对象只交一份,item 在字段定义里就只能为空,所以不能另起一个编号绕开"不能替换"。
*/
const MULTI_ITEM_KINDS: ReadonlySet<SubmissionKind> = new Set(["proposal", "critique"]);
/**
* 一份提交不论第几版:哪个阶段、谁交的、哪种提交、针对谁(候选或格,没有就空)、这一份的编号(见 MULTI_ITEM_KINDS)。
* 提交种类在位置里:版本号只在同一种提交里往下接,别的种类顶替不了不能替换的实现,提案和挑刺用了同一个编号也接不上。
*/
export interface SubmissionSlot {
stageId: string;
author: string;
kind: SubmissionKind;
target: string | null;
item: string | null;
}
/**
* 账本里存的提案不带作者自评:写入时 ledger 把它拆出来另存成一条封存(方法.md 第三部分"作者自评从交上来那一刻起就封存")。
* 这样给别人看提案时不用去掉什么。
*/
const STORED_BODY: Record<SubmissionKind, TSchema> = {
...SUBMISSION_SCHEMAS,
proposal: Type.Omit(SUBMISSION_SCHEMAS.proposal, ["authorEstimate"]),
};
type StoredBody<K extends SubmissionKind> = K extends "proposal"
? Omit<Submission<"proposal">, "authorEstimate">
: Submission<K>;
/**
* 一份提交的一版。version 从 1 起,替换时加 1,被替换的就是上一版。
* jev 是收下这一版时用到的 Jev 判断(判"否"才收下,原始概率也存),没用到 Jev 的为 null。
*/
type SubmissionOf<Body extends Record<SubmissionKind, unknown>> = {
[K in SubmissionKind]: Omit<SubmissionSlot, "kind"> & {
version: number;
kind: K;
content: Body[K];
jev: Static<typeof JevUsed>;
};
}[SubmissionKind];
export type StoredSubmission = SubmissionOf<{ [K in SubmissionKind]: StoredBody<K> }>;
/** 写入时交给 ledger 的提交:提案带着作者自评。 */
export type SubmissionInput = SubmissionOf<{ [K in SubmissionKind]: Submission<K> }>;
/** 某种提交一版的完整字段定义:外层的位置、版本号和 Jev 判断,加上按提交种类检查的 content。 */
function submissionSchema(kind: SubmissionKind, body: TSchema): TSchema {
return Type.Object(
{
stageId: Id,
author: Id,
target: OptionalId,
item: MULTI_ITEM_KINDS.has(kind) ? Id : Type.Null(),
version: Version,
kind: Type.Literal(kind),
content: body,
jev: JevUsed,
},
CLOSED,
);
}
const SUBMISSION_KIND_NAMES = Object.keys(SUBMISSION_KINDS) as SubmissionKind[];
const SUBMISSION_FORMS = {
input: Object.fromEntries(SUBMISSION_KIND_NAMES.map((k) => [k, submissionSchema(k, SUBMISSION_SCHEMAS[k])])),
stored: Object.fromEntries(SUBMISSION_KIND_NAMES.map((k) => [k, submissionSchema(k, STORED_BODY[k])])),
} as Record<"input" | "stored", Record<SubmissionKind, TSchema>>;
// ---------------------------------------------------------------------------
// 种类表
// ---------------------------------------------------------------------------
export const KINDS = {
/** 1 研究配置:冻结的配置、步骤清单、规则、方法参数、提示词哈希。内容由配置模块产生,这里只检查是 Json。 */
config: AnyJson,
/** 2 阶段开:阶段名、第几轮(第零轮是 0)、这个阶段的应交名单。阶段编号由 flow 起,一个编号只开一次;同一个阶段名可以开好几次(如讨论)。 */
stageOpen: Type.Object(
{ stageId: Id, stage: Type.Enum(STAGES, { type: "string" }), round: Round, roster: Type.Array(Id, { minItems: 1 }) },
CLOSED,
),
/** 2 阶段关:关闭即公开,此后这个阶段不再收提交。一个阶段只关一次。 */
stageClose: Type.Object({ stageId: Id }, CLOSED),
/** 3 提交的每一版(经 submissions 收下后由 flow 写入)。字段定义按提交种类选,见 checkContent。 */
submission: Type.Unsafe<StoredSubmission>({}),
/**
* 3 退回:被退回的那份内容原样存(它可能正是因为不合格式才被退回),连同退回理由。
* target 是这次任务的对象,与提交的 target 同义(候选、原提案、消融格、本轮诊断,没有对象的写 null):
* 同一人在同一阶段可能对几个对象各做一次同种任务,重新发起时按它找本人这个对象的上一次退回。
* jev 是这次退回用到的 Jev 判断(见 JevUsed),判"否"的几问也一起存;没用到 Jev 的写 null。
*/
rejection: Type.Object(
{
stageId: Id,
author: Id,
kind: Type.Enum(SUBMISSION_KIND_NAMES, { type: "string" }),
target: OptionalId,
submitted: AnyJson,
reason: Text,
jev: JevUsed,
},
CLOSED,
),
/** 3 私人笔记:不属于任何阶段,每次修改追加一条,以最后写入的一条(序号最大)为准。 */
note: Type.Object({ author: Id, body: Text }, CLOSED),
/** 4 冻结协议:每个候选一份。内容由 flow 合成,先存 Json。 */
protocol: Type.Object({ candidate: Id, protocol: AnyJson }, CLOSED),
/** 5 作者自评的封存:proposal 是那一版提案记录的序号。只由 ledger 在写提案时一并写入。 */
seal: Type.Object({ proposal: Seq, range: Range }, CLOSED),
/** 5 解封:放出哪几条封存(封存记录的序号)。合并而成的候选有几位作者就有几条。 */
unseal: Type.Object({ candidate: Id, seals: Type.Array(Seq, { minItems: 1 }) }, CLOSED),
/** 6 冻结代码:候选或消融格的代码版本号(由 workspace 给出)和因子位置表。位置表先存 Json。 */
code: Type.Object({ object: Id, version: Text, locations: AnyJson }, CLOSED),
/** 7 运行·登记要跑(占名额):运行编号由 flow 起,一个编号只登记一次;用途、轮次、对象(候选、格或本轮诊断;基线没有)、运行定义。 */
runPlanned: Type.Object(
{
run: Id,
purpose: Type.Enum(["baseline", "forward", "ablation", "diagnosis"], { type: "string" }),
round: Round,
object: OptionalId,
definition: RunDefinition,
},
CLOSED,
),
/** 7 运行·确认启动(扣次)。一次运行只有一条,而且和"结果是没启动"只能有一样。 */
runStarted: Type.Object({ run: Id }, CLOSED),
/** 7 运行·结果:进程层面的结果,以及拿到产物时的独立判分(没有就空)。一次运行只有一条。 */
runResult: Type.Object({ run: Id, process: ProcessResult, judged: Type.Union([JudgedResult, Type.Null()]) }, CLOSED),
/**
* 8 决定:选诊断实验、选批、采纳、选格、押的因子、入口分配、判重合并(提案和诊断实验)。decision 是决定种类。
* 输入输出存 Json:ledger 不认识 rules。判重合并的输入里带 Jev 的原始概率。
*/
decision: Type.Object(
{
decision: Id,
round: Round,
rule: Text,
ruleVersion: Type.Integer({ minimum: 1 }),
params: AnyJson,
seed: Type.Union([Text, Type.Null()]),
input: AnyJson,
output: AnyJson,
},
CLOSED,
),
/** 9 结算:每轮最多采纳一个改法,所以最多一次消融,结算按轮次一轮一条。各格结局、配合关系、解释的确认或否定等先存 Json。 */
settlement: Type.Object({ round: Round, result: AnyJson }, CLOSED),
/** 9 诊断结算:每轮最多一次诊断,结算也按轮次一轮一条(方法.md 6.12)。每个实验的结局与实测、解释的确认或否定等先存 Json。 */
diagnosisSettlement: Type.Object({ round: Round, result: AnyJson }, CLOSED),
/** 10 模型调用开始:先登记再调用(方法.md 第九部分)。附这次任务的全部材料,用来重现模型当时看到了什么。一次调用只有一条。 */
callStarted: Type.Object({ call: Id, actor: Id, stageId: OptionalId, model: Text, materials: AnyJson }, CLOSED),
/** 10 模型调用每次回复的用量,reply 从 1 起。 */
callUsage: Type.Object({ call: Id, reply: Type.Integer({ minimum: 1 }), usage: ModelUsage }, CLOSED),
/**
* 10 模型调用结束,一次调用只有一条。status 与 agents 的会话结局(SessionEnding)同名同义:
* submitted 至少有一份被收下;notSubmitted 模型自己停下、一份都没收下;failed 模型服务报错等;stopped 用户停下。
* wallSeconds 是这次调用的墙钟时间(秒),四种结局都有:正常结束时由 agents 测出;
* 控制进程中断、恢复时补写的结束记录量不到,写 failed、原因"进程中断",墙钟时间记测不到(docs/结构.md 第四部分,CLAUDE.md"中断后怎么恢复")。
*/
callEnded: Type.Union([
Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("submitted") }, CLOSED),
Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("notSubmitted"), reason: Text }, CLOSED),
Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("failed"), reason: Text }, CLOSED),
Type.Object({ call: Id, wallSeconds: Measured, status: Type.Literal("stopped") }, CLOSED),
]),
/** 11 事件:违规、"按配置跳过"、"没测"及原因、停下的原因、运行状态未知等。event 是事件种类。 */
event: Type.Object({ event: Id, reason: Text, objects: Type.Array(Id) }, CLOSED),
} as const;
export type Kind = keyof typeof KINDS;
export type Content<K extends Kind> = Static<(typeof KINDS)[K]>;
/** 调用方能追加的种类:封存只由 ledger 在写提案时一并写。 */
export type AppendKind = Exclude<Kind, "seal">;
/** 写入时交的内容:除了提交(提案带作者自评),都和存下的内容相同。 */
export type Input<K extends AppendKind> = K extends "submission" ? SubmissionInput : Content<K>;
/** 一条记录。序号就是写入的先后。 */
export type LedgerRecord<K extends Kind = Kind> = {
[P in K]: { seq: number; kind: P; hash: string; content: Content<P> };
}[K];
function errorsOf(schema: TSchema, value: unknown): string {
return Value.Errors(schema, value)
.slice(0, 3)
.map((e) => `${e.instancePath || "/"} ${e.message}`)
.join(";");
}
/**
* 按种类检查内容。提交按提交种类选字段定义:写入时用带作者自评的那份(input),存下和读出时用不带的(stored)。
*/
export function checkContent(kind: Kind, content: unknown, form: "input" | "stored"): void {
let schema: TSchema = KINDS[kind];
let label = kind as string;
if (kind === "submission") {
const sub = (content as { kind?: unknown } | null)?.kind;
if (typeof sub !== "string" || !Object.hasOwn(SUBMISSION_KINDS, sub)) {
throw new Error(`账本记录"submission"的提交种类不认识:${String(sub)}`);
}
schema = SUBMISSION_FORMS[form][sub as SubmissionKind];
label = `submission(提交种类 ${sub})`;
}
if (!Value.Check(schema, content)) {
throw new Error(`账本记录"${label}"的内容不符合定义:${errorsOf(schema, content)}`);
}
}