返回 CodeWhale
validate_survival_contract.mjs
根目录 / crates / tui / src / compaction / validate_survival_contract.mjs
1 #!/usr/bin/env node
2 // Language-invariant coverage floor for the compaction survival contract.
3 // Later TypeScript strategies should keep this check; Rust remains the B1
4 // enforcement path. Run: node validate_survival_contract.mjs
5
6 import { readFileSync } from "node:fs";
7 import { dirname, join } from "node:path";
8 import { fileURLToPath } from "node:url";
9
10 // Mirrors compaction.rs: a new checkpoint is the handoff-note header block
11 // plus the provenance block; only the two legacy markers match by substring,
12 // because older checkpoints may lack provenance.
13 const MARKER = "Codewhale handoff note";
14 const LEGACY_V2_MARKER = "Another language model started to solve this problem";
15 const LEGACY_MARKERS = [LEGACY_V2_MARKER, "Conversation Summary (Auto-Generated)"];
16 const PROVENANCE = "<!-- codewhale.compaction-checkpoint.v1 -->";
17
18 const root = dirname(fileURLToPath(import.meta.url));
19 const matrix = JSON.parse(
20 readFileSync(join(root, "fixtures/matrix.json"), "utf8"),
21 );
22
23 function userTextOf(message) {
24 if (message.role !== "user") return null;
25 const text = (message.content ?? [])
26 .filter((block) => block.type === "text")
27 .map((block) => block.text)
28 .join("\n")
29 .trim();
30 return text || null;
31 }
32
33 function isWireCheckpoint(message) {
34 const blocks = message.content ?? [];
35 if (message.role !== "user" || blocks.length !== 2) return false;
36 const [header, provenance] = blocks;
37 return (
38 header.type === "text" &&
39 provenance.type === "text" &&
40 provenance.text === PROVENANCE &&
41 (header.text.startsWith(MARKER) || header.text.startsWith(LEGACY_V2_MARKER))
42 );
43 }
44
45 function isCheckpoint(message) {
46 if (isWireCheckpoint(message)) return true;
47 const text = userTextOf(message);
48 return Boolean(text && LEGACY_MARKERS.some((marker) => text.includes(marker)));
49 }
50
51 function isPlainUserText(message) {
52 return !isCheckpoint(message) && Boolean(userTextOf(message));
53 }
54
55 function lastPlainUserIndex(messages, end) {
56 for (let idx = end - 1; idx >= 0; idx -= 1) {
57 if (isPlainUserText(messages[idx])) return idx;
58 }
59 return null;
60 }
61
62 function sliceHasToolResult(messages, start) {
63 return messages.slice(start).some((message) =>
64 (message.content ?? []).some((block) => block.type === "tool_result"),
65 );
66 }
67
68 export function lastRoundStart(messages) {
69 const lastUser = lastPlainUserIndex(messages, messages.length);
70 if (lastUser === null) return 0;
71 if (sliceHasToolResult(messages, lastUser)) return lastUser;
72 let candidate = lastUser;
73 for (;;) {
74 const prev = lastPlainUserIndex(messages, candidate);
75 if (prev === null) return lastUser;
76 if (sliceHasToolResult(messages, prev)) return prev;
77 candidate = prev;
78 }
79 }
80
81 function toolResultIds(message) {
82 return (message.content ?? [])
83 .filter((block) => block.type === "tool_result")
84 .map((block) => block.tool_use_id);
85 }
86
87 function toolUseIds(message) {
88 return (message.content ?? [])
89 .filter((block) => block.type === "tool_use")
90 .map((block) => block.id);
91 }
92
93 function isAssistantLike(message) {
94 return message.role === "assistant" || message.role === "assistant_interrupted";
95 }
96
97 function assistantTextOf(message) {
98 if (!isAssistantLike(message)) return null;
99 const text = (message.content ?? [])
100 .filter((block) => block.type === "text")
101 .map((block) => block.text)
102 .join("\n")
103 .trim();
104 return text || null;
105 }
106
107 // A retained copy may be truncated, so a prefix either way counts as survival
108 // -- but nothing weaker does.
109 function survives(text, replacement, of) {
110 return replacement.some((message) => {
111 const kept = of(message);
112 return (
113 kept &&
114 (kept === text || text.startsWith(kept) || kept.startsWith(text))
115 );
116 });
117 }
118
119 export function validateSurvivalContract(original, replacement, anchors) {
120 const start = lastRoundStart(original);
121 const round = original.slice(start);
122 const pending = new Set();
123 const boundaries = [];
124 for (const [idx, message] of round.entries()) {
125 const calls = toolUseIds(message);
126 if (calls.length && pending.size === 0) boundaries.push(idx);
127 for (const id of calls) pending.add(id);
128 for (const id of toolResultIds(message)) pending.delete(id);
129 }
130 const tail = boundaries.length > 2 ? boundaries.at(-2) : 0;
131 const lastRound = round.filter((message, idx) => !isCheckpoint(message) && (idx >= tail || isPlainUserText(message)));
132 // Every user turn in the round, not the first one `find` reaches: the round
133 // spans a tool-bearing turn plus the toolless tail after it, so checking one
134 // let a rewrite drop the latest turn.
135 for (const text of lastRound.map(userTextOf).filter(Boolean)) {
136 if (!survives(text, replacement, userTextOf)) {
137 return "a last-round user message was dropped";
138 }
139 }
140 for (const id of lastRound.flatMap(toolResultIds)) {
141 const kept = replacement.some((message) =>
142 (message.content ?? []).some(
143 (block) => block.type === "tool_result" && block.tool_use_id === id,
144 ),
145 );
146 if (!kept) {
147 return `last-round tool result ${id} was dropped`;
148 }
149 }
150 // The call, not just its result: a tool_result whose tool_use was summarized
151 // away is an orphan providers reject.
152 for (const id of lastRound.flatMap(toolUseIds)) {
153 const kept = replacement.some((message) =>
154 (message.content ?? []).some(
155 (block) => block.type === "tool_use" && block.id === id,
156 ),
157 );
158 if (!kept) {
159 return `last-round tool call ${id} was dropped`;
160 }
161 }
162 // Match the text: "some assistant message survived" is satisfied by the
163 // summary the rewrite itself just wrote.
164 for (const text of lastRound.map(assistantTextOf).filter(Boolean)) {
165 if (!survives(text, replacement, assistantTextOf)) {
166 return "last-round assistant output was dropped";
167 }
168 }
169 if (
170 lastRound.some(isAssistantLike) &&
171 !replacement.some(isAssistantLike)
172 ) {
173 return "last-round assistant output was dropped";
174 }
175 const checkpoints = replacement.filter(isCheckpoint).length;
176 if (checkpoints === 0) return "checkpoint receipt was dropped";
177 if (checkpoints > 1) return "prior summaries were duplicated";
178 if (anchors && !replacement.some((message) =>
179 (message.content ?? []).some((block) => {
180 if (block.type === "text") return (block.text ?? "").includes(anchors);
181 if (block.type === "tool_result") {
182 return (block.content ?? "").includes(anchors);
183 }
184 return false;
185 }),
186 )) {
187 return "pinned /anchor text was dropped";
188 }
189 return null;
190 }
191
192 function main() {
193 if (matrix.schema_version !== 2) {
194 throw new Error(`unexpected schema_version ${matrix.schema_version}`);
195 }
196 let failed = 0;
197 for (const fixture of matrix.cases) {
198 if (typeof fixture.last_round_start === "number") {
199 const start = lastRoundStart(fixture.original);
200 if (start !== fixture.last_round_start) {
201 failed += 1;
202 console.error(
203 `${fixture.id}: last_round_start ${start} != ${fixture.last_round_start}`,
204 );
205 }
206 }
207 const error = validateSurvivalContract(
208 fixture.original,
209 fixture.replacement,
210 fixture.anchors,
211 );
212 const passed = error === null;
213 if (fixture.expect === "pass" && !passed) {
214 failed += 1;
215 console.error(`${fixture.id}: expected pass, got ${error}`);
216 } else if (fixture.expect === "fail" && passed) {
217 failed += 1;
218 console.error(`${fixture.id}: expected fail closed`);
219 }
220 }
221 if (failed > 0) {
222 console.error(`${failed} fixture(s) failed`);
223 process.exit(1);
224 }
225 console.log(`ok ${matrix.cases.length} survival-contract fixtures`);
226 }
227
228 const entry = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
229 if (entry || process.argv[1]?.endsWith("validate_survival_contract.mjs")) {
230 main();
231 }
232
232 lines Plain Text