Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions tools/simulator/archive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Data-layer tests for the simulation harness (issue #452): archive
* parsing, the synthetic generator, price lookups, and the seeded PRNG.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive";
import { createPrng } from "./prng";
import { PriceBook } from "./prices";

const FIXTURE_DIR = join(__dirname, "fixtures");

function fixture(name: string): string {
return readFileSync(join(FIXTURE_DIR, name), "utf8");
}

describe("parseIntentEvents (issue #452)", () => {
it("parses the sample fixture, preserving order and dispatch kinds", () => {
const events = parseIntentEvents(fixture("sample-intents.jsonl"));
expect(events).toHaveLength(6);
for (let i = 1; i < events.length; i += 1) {
expect(events[i].ts).toBeGreaterThanOrEqual(events[i - 1].ts);
}
expect(events[0].intent.intentId).toBe("sample-1");
expect(events[5].intent.event).toBe("quote_request");
expect(events[2].intent.auction?.decayEnd).toBe(1759248360);
expect(events[1].intent.usdValueAtCreate).toBe(1000);
});

it("ignores blank lines and # comments", () => {
const events = parseIntentEvents('# header\n\n{"intentId":"a","createdAt":10,"deadline":20,"srcChain":"base","srcAmount":"1","minDstAmount":"1"}\n');
expect(events).toHaveLength(1);
});

it("reports the offending line for invalid JSON", () => {
expect(() => parseIntentEvents('{"intentId":"a"}\n{oops\n')).toThrow(/line 2: invalid JSON/);
});

it("reports the missing field for incomplete rows", () => {
expect(() => parseIntentEvents('{"intentId":"a","deadline":20}\n')).toThrow(/line 1: "createdAt" must be a finite number/);
});
});

describe("generateSyntheticArchive (issue #452)", () => {
it("is deterministic for a given seed and sorted by time", () => {
const a = generateSyntheticArchive(500, 7);
const b = generateSyntheticArchive(500, 7);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
for (let i = 1; i < a.length; i += 1) {
expect(a[i].ts).toBeGreaterThanOrEqual(a[i - 1].ts);
}
expect(a[0].intent.intentId).toBe("syn-0");
expect(a[499].intent.intentId).toBe("syn-499");
});

it("rejects negative counts", () => {
expect(() => generateSyntheticArchive(-1, 0)).toThrow(/non-negative integer/);
});
});

describe("PriceBook (issue #452)", () => {
const book = new PriceBook([
{ ts: 100, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 },
{ ts: 200, chain: "ethereum", symbol: "USDC", priceUsd: 1.5, decimals: 6 },
{ ts: 50, chain: "stellar", symbol: "USDC", priceUsd: 1 },
]);

it("returns the latest snapshot at or before the requested time", () => {
expect(book.lookup("ethereum", "USDC", 99)).toBeNull();
expect(book.lookup("ethereum", "USDC", 100)).toEqual({ priceUsd: 1, decimals: 6 });
expect(book.lookup("ethereum", "USDC", 199)).toEqual({ priceUsd: 1, decimals: 6 });
expect(book.lookup("ethereum", "USDC", 1_000)).toEqual({ priceUsd: 1.5, decimals: 6 });
});

it("returns null for unknown pairs", () => {
expect(book.lookup("ethereum", "WETH", 500)).toBeNull();
expect(new PriceBook().lookup("ethereum", "USDC", 500)).toBeNull();
});

it("converts base units with per-chain default decimals", () => {
// ethereum row has explicit decimals: 6 → 2_500_000_000 = 2500.
expect(book.usdValue("ethereum", "USDC", "2500000000", 150)).toBe(2500);
// stellar row has no decimals → default 7 → 100000000 = 10.
expect(book.usdValue("stellar", "USDC", "100000000", 60)).toBe(10);
// Non-numeric amounts are unknown, never zero.
expect(book.usdValue("stellar", "USDC", "not-a-number", 60)).toBeNull();
});
});

describe("parsePriceBook (issue #452)", () => {
it("builds a working book from JSONL", () => {
const book = parsePriceBook(fixture("sample-prices.jsonl"));
expect(book.lookup("stellar", "USDC", 1759248000)).toEqual({ priceUsd: 1, decimals: 7 });
expect(book.lookup("base", "USDC", 1759248000)).toEqual({ priceUsd: 1, decimals: 6 });
});

it("validates required fields", () => {
expect(() => parsePriceBook('{"ts":1,"chain":"ethereum"}\n')).toThrow(/"symbol" must be a non-empty string/);
});
});

describe("createPrng (issue #452)", () => {
it("reproduces the same sequence for the same seed", () => {
const a = createPrng(123);
const b = createPrng(123);
const seqA = Array.from({ length: 32 }, () => a());
const seqB = Array.from({ length: 32 }, () => b());
expect(seqA).toEqual(seqB);
});

it("stays in [0, 1)", () => {
const random = createPrng(9);
for (let i = 0; i < 1_000; i += 1) {
const value = random();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
});
});
139 changes: 139 additions & 0 deletions tools/simulator/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Archive I/O for the simulation harness (issue #452).
*
* Input is JSON Lines: one archived intent event (or price snapshot) per
* line. Rows in the public `intents` dataset schema replay directly; rows
* without an explicit `event` field default to `"intent"`. `#` comment
* lines and blank lines are ignored, which keeps fixtures hand-editable.
*/
import { createPrng } from "./prng";
import { PriceBook } from "./prices";
import type { PriceRow } from "./prices";
import type { ArchivedIntent, SimEvent } from "./types";

function parseJsonl(text: string, label: string): Array<{ lineNo: number; value: unknown }> {
const out: Array<{ lineNo: number; value: unknown }> = [];
const lines = text.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].trim();
if (line === "" || line.startsWith("#")) continue;
try {
out.push({ lineNo: i + 1, value: JSON.parse(line) });
} catch (err) {
throw new Error(`${label} line ${i + 1}: invalid JSON (${(err as Error).message})`);
}
}
return out;
}

function requireNumber(row: Record<string, unknown>, key: string, lineNo: number, label: string): number {
const value = row[key];
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${label} line ${lineNo}: "${key}" must be a finite number`);
}
return value;
}

function requireString(row: Record<string, unknown>, key: string, lineNo: number, label: string): string {
const value = row[key];
if (typeof value !== "string" || value === "") {
throw new Error(`${label} line ${lineNo}: "${key}" must be a non-empty string`);
}
return value;
}

/**
* Parse an archived intent stream (JSONL) into timestamped events.
*
* @param text Raw file contents.
* @throws When a row is not valid JSON or lacks the required fields
* (`intentId`, `createdAt`, `deadline`, `srcChain`, `srcAmount`, `minDstAmount`).
* @returns Events sorted by timestamp (stable for equal timestamps).
*/
export function parseIntentEvents(text: string): SimEvent[] {
const events: SimEvent[] = [];
for (const { lineNo, value } of parseJsonl(text, "intents archive")) {
const row = value as Record<string, unknown>;
const intent: ArchivedIntent = {
intentId: requireString(row, "intentId", lineNo, "intents archive"),
createdAt: requireNumber(row, "createdAt", lineNo, "intents archive"),
deadline: requireNumber(row, "deadline", lineNo, "intents archive"),
srcChain: requireString(row, "srcChain", lineNo, "intents archive"),
srcAmount: requireString(row, "srcAmount", lineNo, "intents archive"),
minDstAmount: requireString(row, "minDstAmount", lineNo, "intents archive"),
srcToken: typeof row.srcToken === "string" ? row.srcToken : undefined,
srcTokenSymbol: typeof row.srcTokenSymbol === "string" ? row.srcTokenSymbol : undefined,
dstToken: typeof row.dstToken === "string" ? row.dstToken : undefined,
dstTokenSymbol: typeof row.dstTokenSymbol === "string" ? row.dstTokenSymbol : undefined,
dstChain: typeof row.dstChain === "string" ? row.dstChain : undefined,
state: typeof row.state === "string" ? row.state : undefined,
usdValueAtCreate: typeof row.usdValueAtCreate === "number" ? row.usdValueAtCreate : undefined,
auction: (row.auction as ArchivedIntent["auction"]) ?? undefined,
event: row.event === "quote_request" ? "quote_request" : "intent",
};
events.push({ ts: intent.createdAt, intent });
}
return events.sort((a, b) => a.ts - b.ts);
}

/**
* Parse archived price snapshots (JSONL) into a {@link PriceBook}.
*
* @throws When a row lacks `ts`/`chain`/`symbol`/`priceUsd`.
*/
export function parsePriceBook(text: string): PriceBook {
const rows: PriceRow[] = [];
for (const { lineNo, value } of parseJsonl(text, "prices archive")) {
const row = value as Record<string, unknown>;
rows.push({
ts: requireNumber(row, "ts", lineNo, "prices archive"),
chain: requireString(row, "chain", lineNo, "prices archive"),
symbol: requireString(row, "symbol", lineNo, "prices archive"),
priceUsd: requireNumber(row, "priceUsd", lineNo, "prices archive"),
decimals: typeof row.decimals === "number" ? row.decimals : undefined,
});
}
return new PriceBook(rows);
}

const SYNTHETIC_SRC_CHAINS = ["ethereum", "base", "polygon", "arbitrum", "optimism", "avalanche"];

/**
* Generate a deterministic synthetic archive — used by benchmarks, the
* determinism tests, and `cli.ts --generate` for smoke runs without data.
*
* The stream mixes zero-width bursts (several intents in the same second)
* with one-second gaps, so equal-timestamp ordering and clock advancement
* are both exercised.
*
* @param count Number of intents to generate.
* @param seed PRNG seed; identical inputs yield identical archives.
* @returns Events already sorted by timestamp.
*/
export function generateSyntheticArchive(count: number, seed: number): SimEvent[] {
if (!Number.isInteger(count) || count < 0) {
throw new Error(`count must be a non-negative integer, got ${count}`);
}
const random = createPrng(seed);
const events: SimEvent[] = new Array(count);
let ts = 1_700_000_000;
for (let i = 0; i < count; i += 1) {
if (random() < 0.3) ts += 1; // ~70% of events share the previous second
const intent: ArchivedIntent = {
intentId: `syn-${i}`,
createdAt: ts,
deadline: ts + 60 + Math.floor(random() * 120),
srcChain: SYNTHETIC_SRC_CHAINS[i % SYNTHETIC_SRC_CHAINS.length],
srcToken: "0xsimulated-src",
srcTokenSymbol: "TOK",
srcAmount: "1000000000000000000",
dstToken: "CSIMDST",
dstTokenSymbol: "USDC",
minDstAmount: String(1_000_000 + (i % 100) * 1_000),
state: "open",
event: "intent",
};
events[i] = { ts, intent };
}
return events;
}
Loading
Loading