@monotykamary/pi-vcc 0.3.17 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Stress test: 100 successive compactions measuring information degradation.
3
+ *
4
+ * Each compaction cycle:
5
+ * 1. Adds a fresh "round" of messages (user request → edits → reads → response)
6
+ * 2. Compiles with the previous summary as previousSummary
7
+ * 3. Measures what survived and what was lost
8
+ *
9
+ * Degradation metrics per round:
10
+ * - Output size (chars, lines)
11
+ * - Section counts (how many of the 7 header sections present)
12
+ * - Files known (paths in Files And Changes)
13
+ * - Breadcrumbs present (count of - ...recall: lines and +recall: markers)
14
+ * - Goals known (Session Goal lines)
15
+ * - Type catalog entries
16
+ * - Brief transcript size (lines after ---)
17
+ * - Recall recoverability: vcc_recall search for a given file/goal term
18
+ */
19
+
20
+ import { describe, test, expect } from "bun:test";
21
+ import { compile } from "../src/core/summarize";
22
+ import type { Message } from "@earendil-works/pi-ai";
23
+
24
+ // Deterministic message factories
25
+
26
+ const makeUserMsg = (text: string): Message => ({
27
+ role: "user",
28
+ content: text,
29
+ });
30
+
31
+ const makeAssistantMsg = (text: string): Message => ({
32
+ role: "assistant",
33
+ content: text,
34
+ });
35
+
36
+ const makeToolCall = (
37
+ name: string,
38
+ id: string,
39
+ args: Record<string, unknown>,
40
+ ): Message => ({
41
+ role: "assistant",
42
+ content: [
43
+ { type: "text", text: "" },
44
+ { type: "toolCall", name, id, arguments: args },
45
+ ],
46
+ });
47
+
48
+ const makeToolResult = (
49
+ toolCallId: string,
50
+ toolName: string,
51
+ content: string,
52
+ isError = false,
53
+ ): Message => ({
54
+ role: "toolResult",
55
+ toolCallId,
56
+ toolName,
57
+ content,
58
+ isError,
59
+ } as any);
60
+
61
+ // Each "round" simulates a realistic compaction-worthy conversation chunk.
62
+ // We vary the files and goals to create accumulation pressure.
63
+
64
+ const FILES = [
65
+ "src/auth.ts", "src/users.ts", "src/api.ts", "src/db.ts",
66
+ "src/utils.ts", "src/config.ts", "src/routes.ts", "src/middleware.ts",
67
+ "src/models.ts", "src/services.ts", "src/handlers.ts", "src/types.ts",
68
+ "src/errors.ts", "src/validators.ts", "src/cache.ts", "src/logger.ts",
69
+ "src/session.ts", "src/token.ts", "src/oauth.ts", "src/crypto.ts",
70
+ ];
71
+
72
+ const GOALS = [
73
+ "Build an authentication system",
74
+ "Add user management endpoints",
75
+ "Create the REST API layer",
76
+ "Set up the database connection pool",
77
+ "Implement utility functions",
78
+ "Load configuration from environment",
79
+ "Define route handlers",
80
+ "Add authentication middleware",
81
+ "Create data models",
82
+ "Build the service layer",
83
+ "Implement request handlers",
84
+ "Add TypeScript type definitions",
85
+ "Create error handling utilities",
86
+ "Build input validators",
87
+ "Add caching layer",
88
+ "Set up structured logging",
89
+ "Implement session management",
90
+ "Add JWT token handling",
91
+ "Build OAuth2 integration",
92
+ "Add cryptographic utilities",
93
+ "Refactor auth to support SSO",
94
+ "Add rate limiting to API",
95
+ "Migrate database to PostgreSQL",
96
+ "Add integration tests",
97
+ "Set up CI pipeline",
98
+ ];
99
+
100
+ /** Generate messages for one "round" of work */
101
+ const makeRound = (roundIdx: number): Message[] => {
102
+ const file1 = FILES[roundIdx % FILES.length];
103
+ const file2 = FILES[(roundIdx + 7) % FILES.length];
104
+ const goal = GOALS[roundIdx % GOALS.length];
105
+ const id1 = `r${roundIdx}-1`;
106
+ const id2 = `r${roundIdx}-2`;
107
+ const id3 = `r${roundIdx}-3`;
108
+
109
+ return [
110
+ makeUserMsg(goal),
111
+ makeToolCall("Edit", id1, {
112
+ file_path: file1,
113
+ oldText: "// placeholder",
114
+ newText: `export function fn${roundIdx}() { return ${roundIdx}; }`,
115
+ }),
116
+ makeToolResult(id1, "Edit", "OK"),
117
+ makeAssistantMsg(`Edited ${file1} with function fn${roundIdx}.`),
118
+ makeToolCall("read", id2, { file_path: file2 }),
119
+ makeToolResult(id2, "read", `// ${file2}\nexport const DATA = ${roundIdx};`),
120
+ makeAssistantMsg(`Read ${file2} to understand the interface.`),
121
+ // Sometimes add a commit
122
+ ...(roundIdx % 3 === 0
123
+ ? [
124
+ makeToolCall("bash", id3, { command: `git commit -m "feat: ${goal.toLowerCase()}"` }),
125
+ makeToolResult(id3, "bash", `[main abc${roundIdx.toString().padStart(4, "0")}] feat: ${goal.toLowerCase()}`),
126
+ makeAssistantMsg(`Committed: feat: ${goal.toLowerCase()}.`),
127
+ ]
128
+ : []),
129
+ ];
130
+ };
131
+
132
+ // Metrics extraction
133
+
134
+ const countBreadcrumbs = (text: string): number => {
135
+ let count = 0;
136
+ // - ...recall: lines in header sections
137
+ for (const line of text.split("\n")) {
138
+ if (line.startsWith("- ...recall:")) count++;
139
+ }
140
+ // +recall: markers in Files And Changes
141
+ const plusRecall = text.match(/\+recall:/g);
142
+ count += plusRecall ? plusRecall.length : 0;
143
+ return count;
144
+ };
145
+
146
+ const extractFilePaths = (text: string): Set<string> => {
147
+ const paths = new Set<string>();
148
+ for (const line of text.split("\n")) {
149
+ const match = line.match(/^-\s*(?:Modified|Created|Read):\s*(.*)/);
150
+ if (!match) continue;
151
+ const rest = match[1]
152
+ .replace(/\+recall:\s*/g, "")
153
+ .replace(/\s*\([^)]*\)/g, "");
154
+ for (const p of rest.split(",")) {
155
+ const trimmed = p.trim();
156
+ if (trimmed && !trimmed.startsWith("+")) paths.add(trimmed);
157
+ }
158
+ }
159
+ return paths;
160
+ };
161
+
162
+ const extractGoals = (text: string): string[] => {
163
+ const section = text.indexOf("[Session Goal]");
164
+ if (section < 0) return [];
165
+ const after = text.slice(section);
166
+ const nextSection = after.indexOf("\n[", 1);
167
+ const block = (nextSection > 0 ? after.slice(0, nextSection) : after);
168
+ return block.split("\n")
169
+ .filter(l => l.startsWith("- ") && !l.startsWith("- ...recall:"))
170
+ .map(l => l.slice(2));
171
+ };
172
+
173
+ const countTypeCatalogEntries = (text: string): number => {
174
+ const section = text.indexOf("[Type Catalog]");
175
+ if (section < 0) return 0;
176
+ const after = text.slice(section);
177
+ const nextSection = after.indexOf("\n\n[", 1);
178
+ const nextSep = after.indexOf("\n\n---\n\n", 1);
179
+ const end = Math.min(
180
+ nextSection > 0 ? nextSection : Infinity,
181
+ nextSep > 0 ? nextSep : Infinity,
182
+ );
183
+ const block = after.slice(0, end);
184
+ return block.split("\n").filter(l => l.match(/^\s*src\//) || l.match(/^\s*\S+\.\w{1,12}:$/)).length;
185
+ };
186
+
187
+ const countSections = (text: string): number => {
188
+ const matches = text.match(/^\[.+\]/gm);
189
+ return matches ? matches.length : 0;
190
+ };
191
+
192
+ const briefTranscriptSize = (text: string): { lines: number; chars: number } => {
193
+ const idx = text.indexOf("\n\n---\n\n");
194
+ if (idx < 0) return { lines: 0, chars: 0 };
195
+ const brief = text.slice(idx + 6);
196
+ return { lines: brief.split("\n").filter(Boolean).length, chars: brief.length };
197
+ };
198
+
199
+ const previousSummaryChars = (text: string): number => text.length;
200
+
201
+ /** Check if a file path is recoverable via breadcrumb or section content */
202
+ const isFileRecoverable = (text: string, filePath: string): boolean => {
203
+ if (text.includes(filePath)) return true;
204
+ // Check breadcrumbs for partial path match
205
+ const shortName = filePath.split("/").pop() ?? filePath;
206
+ for (const line of text.split("\n")) {
207
+ if (line.includes("+recall:") || line.startsWith("- ...recall:")) {
208
+ if (line.includes(shortName)) return true;
209
+ }
210
+ }
211
+ return false;
212
+ };
213
+
214
+ /** Check if a goal is recoverable via breadcrumb or section content */
215
+ const isGoalRecoverable = (text: string, goal: string): boolean => {
216
+ if (text.includes(goal)) return true;
217
+ // Check breadcrumbs for keywords from the goal
218
+ const keywords = goal.split(/\s+/).filter(w => w.length > 3).slice(0, 3);
219
+ for (const line of text.split("\n")) {
220
+ if (line.startsWith("- ...recall:")) {
221
+ const allPresent = keywords.every(kw => line.toLowerCase().includes(kw.toLowerCase()));
222
+ if (allPresent) return true;
223
+ }
224
+ }
225
+ return false;
226
+ };
227
+
228
+ describe("stress: 100 compactions", () => {
229
+ test("100 successive compactions with degradation metrics", () => {
230
+ const TOTAL_ROUNDS = 100;
231
+ const previousSummary: string[] = [""];
232
+ const metrics: Array<{
233
+ round: number;
234
+ outputChars: number;
235
+ outputLines: number;
236
+ sections: number;
237
+ breadcrumbs: number;
238
+ filesKnown: number;
239
+ goalsKnown: number;
240
+ typeCatalogEntries: number;
241
+ briefLines: number;
242
+ briefChars: number;
243
+ // Cumulative tracking
244
+ totalFilesTouched: number;
245
+ totalGoalsSet: number;
246
+ fileRecallRate: number;
247
+ goalRecallRate: number;
248
+ }> = [];
249
+
250
+ const allFilesEver = new Set<string>();
251
+ const allGoalsEver: string[] = [];
252
+
253
+ for (let round = 0; round < TOTAL_ROUNDS; round++) {
254
+ const messages = makeRound(round);
255
+
256
+ // Track what was introduced this round
257
+ const goal = GOALS[round % GOALS.length];
258
+ allGoalsEver.push(goal);
259
+ allFilesEver.add(FILES[round % FILES.length]);
260
+ allFilesEver.add(FILES[(round + 7) % FILES.length]);
261
+
262
+ const result = compile({
263
+ messages,
264
+ previousSummary: previousSummary[0] || undefined,
265
+ });
266
+
267
+ previousSummary[0] = result;
268
+
269
+ // Extract metrics
270
+ const filePaths = extractFilePaths(result);
271
+ const goals = extractGoals(result);
272
+ const bc = countBreadcrumbs(result);
273
+ const tcEntries = countTypeCatalogEntries(result);
274
+ const sections = countSections(result);
275
+ const brief = briefTranscriptSize(result);
276
+
277
+ // Recall rates: how many of all-ever files/goals can be found?
278
+ let filesRecoverable = 0;
279
+ for (const f of allFilesEver) {
280
+ if (isFileRecoverable(result, f)) filesRecoverable++;
281
+ }
282
+ let goalsRecoverable = 0;
283
+ for (const g of allGoalsEver) {
284
+ if (isGoalRecoverable(result, g)) goalsRecoverable++;
285
+ }
286
+
287
+ metrics.push({
288
+ round: round + 1,
289
+ outputChars: result.length,
290
+ outputLines: result.split("\n").length,
291
+ sections,
292
+ breadcrumbs: bc,
293
+ filesKnown: filePaths.size,
294
+ goalsKnown: goals.length,
295
+ typeCatalogEntries: tcEntries,
296
+ briefLines: brief.lines,
297
+ briefChars: brief.chars,
298
+ totalFilesTouched: allFilesEver.size,
299
+ totalGoalsSet: allGoalsEver.length,
300
+ fileRecallRate: allFilesEver.size > 0 ? filesRecoverable / allFilesEver.size : 1,
301
+ goalRecallRate: allGoalsEver.length > 0 ? goalsRecoverable / allGoalsEver.length : 1,
302
+ });
303
+ }
304
+
305
+ // Assertions on degradation
306
+
307
+ // 1. Output should never be empty
308
+ for (const m of metrics) {
309
+ expect(m.outputChars).toBeGreaterThan(0);
310
+ }
311
+
312
+ // 2. Sections should stabilize (at least Session Goal + Files + brief)
313
+ const lateMetrics = metrics.slice(-20);
314
+ for (const m of lateMetrics) {
315
+ expect(m.sections).toBeGreaterThanOrEqual(2);
316
+ }
317
+
318
+ // 3. File recall rate should not collapse to 0
319
+ // With +recall: breadcrumbs, even capped files should be partially recoverable
320
+ const finalMetric = metrics[metrics.length - 1];
321
+ expect(finalMetric.fileRecallRate).toBeGreaterThan(0);
322
+
323
+ // 4. Goal recall should not collapse to 0
324
+ expect(finalMetric.goalRecallRate).toBeGreaterThan(0);
325
+
326
+ // 5. Output size should be bounded — not growing unboundedly
327
+ // The output should plateau, not grow linearly
328
+ const charGrowthRate = (finalMetric.outputChars - metrics[49].outputChars) / 50;
329
+ const firstHalfGrowthRate = (metrics[49].outputChars - metrics[0].outputChars) / 50;
330
+ // Second half growth rate should be <= first half (plateauing)
331
+ expect(charGrowthRate).toBeLessThanOrEqual(firstHalfGrowthRate * 1.5);
332
+
333
+ // 6. Breadcrumbs should appear by round 20+ (sections start capping)
334
+ const breadcrumbsBy20 = metrics[19].breadcrumbs;
335
+ expect(breadcrumbsBy20).toBeGreaterThanOrEqual(0); // might be 0 if no section capped yet
336
+
337
+ // 7. By round 50, breadcrumbs should definitely exist
338
+ const breadcrumbsBy50 = metrics[49].breadcrumbs;
339
+ expect(breadcrumbsBy50).toBeGreaterThan(0);
340
+
341
+ // Print summary table for manual inspection
342
+ const sampleRounds = [1, 10, 25, 50, 75, 100];
343
+ console.log("\n╔══════════════════════════════════════════════════════════════════════════════════════════╗");
344
+ console.log("║ 100-COMPACTION STRESS TEST — DEGRADATION METRICS ║");
345
+ console.log("╠═══════╦═════════╦═════════╦═══════════╦═══════════╦══════════╦══════════╦══════════════╣");
346
+ console.log("║ Round ║ Chars ║ Lines ║ Sections ║ Breadcrumbs║ Files ║ Goals ║ Recall Rate ║");
347
+ console.log("║ ║ ║ ║ ║ ║ Known ║ Known ║ F / G ║");
348
+ console.log("╠═══════╬═════════╬═════════╬═══════════╬═══════════╬══════════╬══════════╬══════════════╣");
349
+
350
+ for (const r of sampleRounds) {
351
+ const m = metrics[r - 1];
352
+ const fileRate = `${(m.fileRecallRate * 100).toFixed(0)}%`;
353
+ const goalRate = `${(m.goalRecallRate * 100).toFixed(0)}%`;
354
+ console.log(
355
+ `║ ${String(r).padStart(5)} ║ ${String(m.outputChars).padStart(7)} ║ ${String(m.outputLines).padStart(7)} ║ ${String(m.sections).padStart(9)} ║ ${String(m.breadcrumbs).padStart(9)} ║ ${String(m.filesKnown).padStart(7)}/${String(m.totalFilesTouched).padStart(2)} ║ ${String(m.goalsKnown).padStart(7)}/${String(m.totalGoalsSet).padStart(3)} ║ ${fileRate.padStart(4)} / ${goalRate.padStart(4)} ║`,
356
+ );
357
+ }
358
+
359
+ console.log("╚═══════╩═════════╩═════════╩═══════════╩═══════════╩══════════╩══════════╩══════════════╝");
360
+
361
+ // Brief transcript degradation
362
+ console.log("\nBrief transcript size over time:");
363
+ for (const r of sampleRounds) {
364
+ const m = metrics[r - 1];
365
+ console.log(` Round ${String(r).padStart(3)}: ${m.briefLines} lines, ${m.briefChars} chars`);
366
+ }
367
+
368
+ // Final state check
369
+ console.log(`\nFinal output (${finalMetric.outputChars} chars, ${finalMetric.outputLines} lines):`);
370
+ console.log(previousSummary[0].slice(0, 800));
371
+ console.log("...");
372
+ });
373
+ });