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