Files

98 lines
4.8 KiB
TypeScript

import type { Json } from "./round-data.ts";
// 给审阅者的文本去掉来历(任务书 14 第 3.2 节第 1 步):运行名、模型名、研究编号去掉;
// 研究者、提案、候选、挑刺、入口的编号改写成"研究者 1""提案 1"这类中性写法,按出现顺序编,一份文本一张映射表。
// 两个项目的编号写法都认:我们的 r1-researcher-2-1、r1-c1、e-researcher-1-4,参考项目的 r1-v1-p-researcher-2-1、r1-v1-c1、
// researcher-1-e4、挑刺的事实编号(uuid)。字段名(points 对 factors)不改。
type Family = "研究者" | "提案" | "候选" | "挑刺" | "入口";
/** 认编号的写法,按先后试:长的、带别的编号在里面的写法放前面。 */
const PATTERNS: Array<[Family | "实验员" | "核验员", string]> = [
["挑刺", String.raw`r\d+-researcher-\d+-\d+\.researcher-\d+\.\d+`],
["提案", String.raw`r\d+-v\d+-p-researcher-\d+-\d+`],
["提案", String.raw`r\d+-researcher-\d+-\d+`],
["实验员", String.raw`implementer-[A-Za-z0-9-]+`],
["核验员", String.raw`verifier-[A-Za-z0-9-]+`],
["入口", String.raw`e-researcher-\d+-\d+`],
["入口", String.raw`researcher-\d+-e\d+`],
["候选", String.raw`r\d+-v\d+-c\d+`],
["候选", String.raw`r\d+-c\d+`],
["研究者", String.raw`researcher-\d+`],
["挑刺", String.raw`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`],
];
const ID_RE = new RegExp(PATTERNS.map(([, p]) => `(?<![A-Za-z0-9])(${p})(?![0-9])`).join("|"), "g");
/** 认不成编号、但能看出来历的东西。 */
const SCRUB: Array<[RegExp, string]> = [
[/(?<![A-Za-z0-9])\d+[a-z]?-(?:real|tight|evidence|headless|probe|replay)-\d+(?![0-9])/g, "某次运行"],
[/\b(?:glm|claude|gpt|kimi|deepseek|qwen|gemini|minimax)[-\w.]*/gi, "某模型"],
[/iomgaa|rivalbits|inforsi/gi, "某项目"],
[/req_[0-9a-f]{16,}/g, "某请求"],
[/\b[0-9a-f]{32,}\b/g, "某哈希"],
];
export class Neutral {
private readonly names = new Map<string, string>();
private readonly counts = new Map<string, number>();
private readonly hidden: RegExp | null;
constructor(hiddenNames: string[]) {
const esc = hiddenNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
this.hidden = esc.length === 0 ? null : new RegExp(esc.sort((a, b) => b.length - a.length).join("|"), "g");
}
/** 一个编号的中性名:第一次见到时按这一类的出现顺序编号。 */
name(family: Family | "实验员" | "核验员", raw: string): string {
const seen = this.names.get(raw);
if (seen !== undefined) return seen;
const n = (this.counts.get(family) ?? 0) + 1;
this.counts.set(family, n);
const named = `${family} ${n}`;
this.names.set(raw, named);
return named;
}
/** 一段文字里的编号和来历都换掉。研究编号、运行名先换:我们的研究编号里可能带着像编号的片段。 */
text(s: string): string {
let t = this.hidden === null ? s : s.replace(this.hidden, "某次运行");
t = t.replace(ID_RE, (match, ...groups: unknown[]) => {
const i = groups.findIndex((g, k) => k < PATTERNS.length && g !== undefined);
return this.name(PATTERNS[i]![0], match);
});
for (const [re, to] of SCRUB) t = t.replace(re, to);
return t;
}
/** 映射表(原编号 → 中性名),存进 data 旁边,审阅者看不到。 */
table(): Record<string, string> {
return Object.fromEntries(this.names);
}
}
/** 渲染时去掉的键:只说明来历的哈希、请求编号、轮次编号(编号类的值照常经 text 改写成中性名)。 */
const DROP = new Set(["baselineCodeHash", "materialHash", "questionHash", "requestId", "roundId", "protocolId"]);
/** 把 Json 写成缩进的纯文本:对象一键一行,多行文字缩进成块,数组逐项编号。 */
export function render(value: Json, nt: Neutral, indent = ""): string {
if (value === null) return `${indent}(空)`;
if (typeof value !== "object") return block(nt.text(String(value)), indent);
if (Array.isArray(value)) {
if (value.length === 0) return `${indent}(无)`;
return value.map((v, i) => (isScalar(v) ? `${indent}${i + 1}. ${inline(v, nt)}` : `${indent}${i + 1}.\n${render(v, nt, `${indent} `)}`)).join("\n");
}
const entries = Object.entries(value).filter(([k]) => !DROP.has(k));
if (entries.length === 0) return `${indent}(无)`;
return entries
.map(([k, v]) => (isScalar(v) && !String(v).includes("\n") ? `${indent}${k}: ${inline(v, nt)}` : `${indent}${k}:\n${render(v, nt, `${indent} `)}`))
.join("\n");
}
const isScalar = (v: Json) => v === null || typeof v !== "object";
const inline = (v: Json, nt: Neutral) => (v === null ? "(空)" : nt.text(String(v)));
const block = (s: string, indent: string) =>
s
.split("\n")
.map((l) => `${indent}${l}`)
.join("\n");