Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/decay/__tests__/core.test.ts

Raw
import { describe, expect, it } from "vitest";
import {
	allocateAtoms,
	atomWeight,
	combineUsage,
	createSourceChunks,
	type DecayDetails,
	findPreviousDecayDetails,
	formatFileAppendix,
	isTransientFailure,
	type MemoryAtom,
	mergeFileLists,
	mergeMemory,
	missingChunks,
	parseDecayDetails,
	parseModelReference,
	type RecordChunk,
	reconcileRecords,
	renderFallbackSummary,
	type SourceChunk,
	validateRecordChunk,
} from "../core";

function user(text: string) {
	return { role: "user", content: [{ type: "text", text }], timestamp: 1 };
}

function record(
	chunk: number,
	of: number,
	key: string,
	overrides: Partial<RecordChunk["atoms"][number]> = {},
): RecordChunk {
	return {
		chunk,
		of,
		atoms: [
			{
				key,
				text: `fact ${key}`,
				kind: "context",
				status: "active",
				...overrides,
			},
		],
	};
}

function memory(overrides: Partial<MemoryAtom> = {}): MemoryAtom {
	return {
		key: "fact",
		text: "fact",
		kind: "context",
		firstSeen: 1,
		lastSeen: 1,
		seenCount: 1,
		status: "active",
		sourcePosition: 1,
		...overrides,
	};
}

function chunk(
	chunkNumber: number,
	of: number,
	source: SourceChunk["source"] = "history",
): SourceChunk {
	return {
		chunk: chunkNumber,
		of,
		source,
		firstPosition: chunkNumber,
		lastPosition: chunkNumber,
		text: "source",
		estimatedTokens: 1,
	};
}

const zeroUsage = {
	input: 0,
	output: 0,
	cacheRead: 0,
	cacheWrite: 0,
	totalTokens: 0,
	cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};

describe("model references", () => {
	it("splits provider from model id", () => {
		expect(parseModelReference("provider/model/variant")).toEqual({
			provider: "provider",
			modelId: "model/variant",
		});
		expect(parseModelReference("broken")).toBeUndefined();
	});
});

describe("source chunking", () => {
	it("keeps legacy summary, history, and split prefix in distinct ordered chunks", () => {
		const chunks = createSourceChunks({
			legacySummary: "old checkpoint",
			messages: [user("history one"), user("history two")] as never,
			turnPrefixMessages: [user("split") as never],
			targetTokens: 100,
		});

		expect(chunks.map((item) => item.source)).toEqual([
			"legacy-summary",
			"history",
			"split-prefix",
		]);
		expect(chunks.map((item) => item.chunk)).toEqual([1, 2, 3]);
		expect(chunks.every((item) => item.of === 3)).toBe(true);
		expect(chunks[1].text).toContain("MESSAGE 1 SOURCE history");
		expect(chunks[1].text).toContain("MESSAGE 2 SOURCE history");
	});

	it("never splits one oversized message", () => {
		const chunks = createSourceChunks({
			messages: [user("x".repeat(1_000))] as never,
			turnPrefixMessages: [],
			targetTokens: 10,
		});
		expect(chunks).toHaveLength(1);
		expect(chunks[0].text).toContain("x".repeat(1_000));
	});

	it("keeps short exchanges separate and groups consecutive user inputs", () => {
		const chunks = createSourceChunks({
			messages: [
				user("first"),
				user("clarification"),
				{ role: "assistant", content: [{ type: "text", text: "done" }] },
				user("second"),
			] as never,
			turnPrefixMessages: [],
			targetTokens: 10_000,
		});
		expect(chunks).toHaveLength(2);
		expect(chunks[0].text).toContain("clarification");
		expect(chunks[0].text).toContain("done");
		expect(chunks[1].text).toContain("second");
		expect(chunks[1].text).not.toContain("first");
	});

	it("keeps multi-tool batches intact and separates steered requests", () => {
		const chunks = createSourceChunks({
			messages: [
				user("investigate"),
				{
					role: "assistant",
					content: [
						{ type: "text", text: "checking" },
						{ type: "toolCall", id: "one", name: "read", arguments: {} },
						{ type: "toolCall", id: "two", name: "read", arguments: {} },
					],
				},
				{
					role: "toolResult",
					toolCallId: "one",
					toolName: "read",
					content: [{ type: "text", text: "a".repeat(800) }],
				},
				{
					role: "toolResult",
					toolCallId: "two",
					toolName: "read",
					content: [{ type: "text", text: "b".repeat(800) }],
				},
				{
					role: "assistant",
					content: [{ type: "text", text: "second result is stale" }],
				},
				user("actually, stop"),
			] as never,
			turnPrefixMessages: [],
			targetTokens: 100,
		});
		expect(chunks.at(-2)?.text).toContain("b".repeat(800));
		expect(chunks.at(-2)?.text).toContain("a".repeat(800));
		expect(chunks.at(-2)?.text).toContain("second result is stale");
		expect(chunks.at(-1)?.text).toContain("actually, stop");
	});
});

describe("classifier records", () => {
	it("accepts more than 16 atoms without dropping facts", () => {
		const many = record(1, 1, "fact-0");
		many.atoms = Array.from({ length: 20 }, (_, index) => ({
			...many.atoms[0],
			key: `fact-${index}`,
		}));
		expect(validateRecordChunk(many)?.atoms).toHaveLength(20);
		expect(
			mergeMemory([], reconcileRecords([many], 1), [chunk(1, 1)]),
		).toHaveLength(20);
	});

	it("strictly validates schema and rejects unknown fields", () => {
		expect(validateRecordChunk(record(1, 1, "one"))).toBeDefined();
		expect(
			validateRecordChunk({ ...record(1, 1, "one"), extra: true }),
		).toBeUndefined();
		expect(
			validateRecordChunk({
				...record(1, 1, "one"),
				atoms: [{ ...record(1, 1, "one").atoms[0], extra: true }],
			}),
		).toBeUndefined();
		expect(
			validateRecordChunk({
				...record(1, 1, "one"),
				atoms: [{ ...record(1, 1, "one").atoms[0], sourcePosition: 99 }],
			}),
		).toBeUndefined();
	});

	it("sorts complete out-of-order records", () => {
		expect(
			reconcileRecords([record(2, 2, "two"), record(1, 2, "one")], 2),
		).toEqual([record(1, 2, "one"), record(2, 2, "two")]);
	});

	it("tolerates exact duplicates and reports missing chunks", () => {
		expect(
			reconcileRecords(
				[record(1, 2, "one"), record(1, 2, "one"), record(2, 2, "two")],
				2,
			),
		).toHaveLength(2);
		expect(missingChunks([record(2, 3, "two")], 3)).toEqual([1, 3]);
		expect(missingChunks([record(1, 1, "one")], 1)).toEqual([]);
	});

	it("rejects conflicts, missing chunks, wrong totals, and invalid indexes", () => {
		expect(() =>
			reconcileRecords([record(1, 2, "one"), record(1, 2, "again")], 2),
		).toThrow("reclassified chunk 1");
		expect(() => reconcileRecords([record(1, 2, "one")], 2)).toThrow(
			"omitted chunks: 2",
		);
		expect(() => reconcileRecords([record(1, 3, "one")], 2)).toThrow(
			"reported 3 chunks, expected 2",
		);
		expect(() => reconcileRecords([record(3, 2, "three")], 2)).toThrow(
			"invalid chunk 3",
		);
	});
});

describe("structured memory", () => {
	it("migrates legacy summary at epoch zero and counts semantic recurrence", () => {
		const records = [
			record(1, 3, "same", { text: "old wording" }),
			record(2, 3, "same", { text: "new wording" }),
			record(3, 3, "same", { text: "newest wording", kind: "decision" }),
		];
		const atoms = mergeMemory([], records, [
			chunk(1, 3, "legacy-summary"),
			chunk(2, 3),
			chunk(3, 3),
		]);
		expect(atoms).toEqual([
			expect.objectContaining({
				key: "same",
				text: "newest wording",
				kind: "decision",
				firstSeen: 0,
				lastSeen: 2,
				seenCount: 3,
				sourcePosition: 3,
			}),
		]);
	});

	it("does not count duplicate atoms inside one chunk as recurrence", () => {
		const duplicated = record(1, 1, "same");
		duplicated.atoms.push({ ...duplicated.atoms[0], text: "latest" });
		const atoms = mergeMemory([], [duplicated], [chunk(1, 1)]);
		expect(atoms[0]).toEqual(
			expect.objectContaining({ seenCount: 1, text: "latest" }),
		);
	});

	it("updates prior atoms and lets latest current status supersede", () => {
		const atoms = mergeMemory(
			[memory({ key: " Rule ", seenCount: 2, lastSeen: 4 })],
			[record(1, 1, "rule", { status: "superseded", text: "replaced" })],
			[chunk(1, 1)],
		);
		expect(atoms[0]).toEqual(
			expect.objectContaining({
				key: "rule",
				text: "replaced",
				seenCount: 3,
				lastSeen: 5,
				status: "superseded",
			}),
		);
	});

	it("weights recency and logarithmically bounded recurrence", () => {
		const newest = memory({ lastSeen: 4, seenCount: 1 });
		const oldRecurring = memory({ lastSeen: 1, seenCount: 16 });
		expect(atomWeight(newest, 4)).toBe(1);
		expect(atomWeight(oldRecurring, 4)).toBe(0.75);
	});

	it("records recency inside the current discarded history", () => {
		const atoms = mergeMemory(
			[],
			[record(1, 2, "old"), record(2, 2, "new")],
			[chunk(1, 2), chunk(2, 2)],
		);
		expect(atoms.map((atom) => [atom.key, atom.lastSeen])).toEqual([
			["old", 1],
			["new", 2],
		]);
		const allocated = new Map(
			allocateAtoms(atoms, 200).map((atom) => [atom.key, atom.allocation]),
		);
		expect(allocated.get("new")).toBeGreaterThan(allocated.get("old") ?? 0);
	});

	it("always allocates protected active atoms and favors newer ordinary atoms", () => {
		const allocated = allocateAtoms(
			[
				memory({ key: "law", kind: "constraint", lastSeen: 1 }),
				memory({ key: "old", lastSeen: 1 }),
				memory({ key: "new", lastSeen: 3 }),
				memory({ key: "gone", status: "superseded", lastSeen: 4 }),
			],
			200,
		);
		const byKey = new Map(allocated.map((atom) => [atom.key, atom]));
		expect(byKey.get("law")?.protected).toBe(true);
		expect(byKey.get("law")?.allocation).toBeGreaterThanOrEqual(24);
		expect(byKey.get("new")?.allocation).toBeGreaterThan(
			byKey.get("old")?.allocation ?? 0,
		);
		expect(byKey.has("gone")).toBe(false);
	});

	it("reads only valid details from the latest compaction boundary", () => {
		const details: DecayDetails = {
			decay: {
				version: 1,
				model: "p/m",
				elapsedMs: 10,
				chunkCount: 1,
				keepRecentTokens: 20_000,
				atoms: [memory()],
				readFiles: ["read"],
				modifiedFiles: ["write"],
			},
		};
		expect(parseDecayDetails(details)).toEqual(details);
		expect(
			findPreviousDecayDetails([
				{ type: "compaction", details, id: "1", parentId: null } as never,
			]),
		).toEqual(details.decay);
		expect(
			findPreviousDecayDetails([
				{ type: "compaction", details, id: "1", parentId: null } as never,
				{ type: "compaction", details: {}, id: "2", parentId: "1" } as never,
			]),
		).toBeUndefined();
	});
});

describe("deterministic appendices and usage", () => {
	it("merges files, moves later modifications out of read-only, and sorts", () => {
		const files = mergeFileLists(
			{ readFiles: ["z", "both"], modifiedFiles: ["old"] },
			{
				read: new Set(["a"]),
				written: new Set(["both"]),
				edited: new Set(["new"]),
			},
		);
		expect(files).toEqual({
			readFiles: ["a", "z"],
			modifiedFiles: ["both", "new", "old"],
		});
		expect(formatFileAppendix(files.readFiles, files.modifiedFiles)).toBe(
			"\n\n<read-files>\na\nz\n</read-files>\n\n<modified-files>\nboth\nnew\nold\n</modified-files>",
		);
	});

	it("combines every usage and cost field", () => {
		const combined = combineUsage(
			{ ...zeroUsage, input: 2, totalTokens: 2, reasoning: 1 },
			{
				...zeroUsage,
				output: 3,
				totalTokens: 3,
				cacheWrite1h: 2,
				cost: { ...zeroUsage.cost, output: 0.2, total: 0.2 },
			},
		);
		expect(combined).toEqual(
			expect.objectContaining({
				input: 2,
				output: 3,
				totalTokens: 5,
				reasoning: 1,
				cacheWrite1h: 2,
				cost: expect.objectContaining({ output: 0.2, total: 0.2 }),
			}),
		);
	});
});

describe("degraded summarizer", () => {
	function atom(overrides: Partial<MemoryAtom>): MemoryAtom {
		return {
			key: "k",
			text: "t",
			kind: "context",
			firstSeen: 1,
			lastSeen: 1,
			seenCount: 1,
			status: "active",
			sourcePosition: 0,
			...overrides,
		};
	}

	it("renders every heading and only classified facts", () => {
		const summary = renderFallbackSummary(
			allocateAtoms(
				[
					atom({ key: "goal", text: "Ship decay", kind: "goal" }),
					atom({ key: "blocked", text: "Waiting on auth", kind: "blocker" }),
					atom({ key: "path", text: "src/main.ts", kind: "exact" }),
					atom({ key: "dropped", text: "Old idea", status: "superseded" }),
				],
				4_000,
			),
		);
		expect(summary).toContain("Decay summarizer unavailable");
		for (const heading of [
			"## Goal",
			"## Constraints & Preferences",
			"## Progress",
			"### Done",
			"### In Progress",
			"### Blocked",
			"## Key Decisions",
			"## Next Steps",
			"## Critical Context",
		])
			expect(summary).toContain(heading);
		expect(summary).toContain("- Ship decay");
		expect(summary).toContain("- Waiting on auth");
		expect(summary).toContain("- src/main.ts");
		expect(summary).not.toContain("Old idea");
	});

	it("classifies transient failures without treating aborts as retryable", () => {
		for (const message of [
			"429 rate limit exceeded",
			"upstream returned 503",
			"socket hang up",
			"fetch failed",
			"Decay classifier stream ended without completion",
		])
			expect(isTransientFailure(new Error(message))).toBe(true);
		const abort = new Error("The operation was aborted");
		abort.name = "AbortError";
		expect(isTransientFailure(abort)).toBe(false);
		expect(
			isTransientFailure(new Error("Decay classifier called unknown tool: x")),
		).toBe(false);
		expect(
			isTransientFailure(new Error("Decay model authentication failed")),
		).toBe(false);
	});
});