Files

301 lines
8.6 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import {
simulateGraph,
estimateNode,
validateGraph,
validateDocument,
DEFAULT_GLOBALS,
autoLayout,
} from "../src/engine.js";
import { makeDocument, nodeData, PRESETS } from "../src/presets.js";
import { RECORDS, SOURCES } from "../src/catalog.js";
const g = {
...DEFAULT_GLOBALS,
networkMs: 0,
vadMs: 0,
queueMs: 0,
speechSeconds: 1,
};
const node = (id, kind, extra = {}) => ({
id,
type: "stage",
position: { x: 0, y: 0 },
data: { ...nodeData(kind, id), ...extra },
});
const edge = (source, target, release = "complete", extra = {}) => ({
id: `${source}-${target}`,
source,
target,
data: { release, ...extra },
});
const near = (a, b) => assert.ok(Math.abs(a - b) < 0.00001, `${a} != ${b}`);
test("serial stages add their service times", () => {
const n = [
node("a", "program", { durationMs: 100 }),
node("b", "program", { durationMs: 200 }),
node("out", "output", { durationMs: 0, outputType: "text" }),
];
const s = simulateGraph(n, [edge("a", "b"), edge("b", "out")], g);
near(s.first, 300);
near(s.complete, 300);
});
test("parallel all-join waits for longest branch rather than summing branches", () => {
const n = [
node("in", "input"),
node("a", "program", { durationMs: 100 }),
node("b", "program", { durationMs: 300 }),
node("join", "merge", { durationMs: 20 }),
node("out", "output", { durationMs: 40, outputType: "text" }),
];
const e = [
edge("in", "a"),
edge("in", "b"),
edge("a", "join"),
edge("b", "join"),
edge("join", "out"),
];
const s = simulateGraph(n, e, g);
near(s.first, 360);
assert.ok(s.criticalNodes.has("b"));
assert.ok(!s.criticalNodes.has("a"));
const any = n.map((x) =>
x.id === "join" ? { ...x, data: { ...x.data, join: "any" } } : x,
);
near(simulateGraph(any, e, g).first, 160);
});
test("streaming starts early, completion remains bounded by final upstream data", () => {
const n = [
node("llm", "llm", {
mode: "custom",
customFirstMs: 100,
customTps: 100,
outputTokens: 101,
}),
node("tts", "tts", { mode: "custom", customFirstMs: 50, customRtf: 0.1 }),
node("out", "output", { durationMs: 0 }),
];
const e = [edge("llm", "tts", "first"), edge("tts", "out", "first")],
s = simulateGraph(n, e, g);
near(s.first, 150);
near(s.results.llm.end, 1100);
assert.ok(s.results.tts.end >= s.results.llm.end);
assert.ok(s.complete >= s.first + 1000);
const full = simulateGraph(
n,
[edge("llm", "tts"), edge("tts", "out", "first")],
g,
);
assert.ok(full.first > s.first);
});
test("token gate adds k-1 decoding intervals", () => {
const n = [
node("llm", "llm", {
mode: "custom",
customFirstMs: 100,
customTps: 100,
outputTokens: 101,
}),
node("out", "output", { durationMs: 0, outputType: "text" }),
];
near(
simulateGraph(n, [edge("llm", "out", "chunk", { tokens: 8 })], g).first,
170,
);
});
test("loop maximum, inter-step tools, final-only output and context growth are explicit", () => {
const d = {
...nodeData("llm", "agent"),
mode: "custom",
customFirstMs: 100,
customTps: 100,
outputTokens: 11,
maxSteps: 3,
expectedSteps: 2,
toolMs: 50,
contextGrowth: 20,
inputTokens: 100,
finalOnly: true,
};
const r = estimateNode(d, g);
near(r.end, 700);
near(r.first, 600);
assert.deepEqual(
r.steps.map((x) => x.input),
[100, 120, 140],
);
const partial = estimateNode({ ...d, finalOnly: false }, g);
near(partial.first, 100);
near(partial.end, 700);
const s = simulateGraph(
[
node("agent", "llm", d),
node("out", "output", { outputType: "text", durationMs: 0 }),
],
[edge("agent", "out", "first")],
{ ...g, loopPlan: "expected" },
);
near(s.first, 350);
near(s.maxFirst, 600);
near(s.maxComplete, 700);
near(s.results.agent.end, 450);
});
test("hidden reasoning delays the first visible token", () => {
const d = {
...nodeData("llm", "reasoner"),
mode: "custom",
customFirstMs: 100,
customTps: 100,
outputTokens: 11,
reasoningTokens: 50,
};
const r = estimateNode(d, g);
near(r.first, 600);
near(r.end, 700);
});
test("shared model resources serialize parallel requests and change critical dependency", () => {
const n = [
node("in", "input"),
node("a", "llm", {
mode: "custom",
customFirstMs: 100,
customTps: 100,
outputTokens: 41,
}),
node("b", "llm", {
mode: "custom",
customFirstMs: 100,
customTps: 100,
outputTokens: 41,
}),
node("out", "output", { outputType: "text", durationMs: 0 }),
];
const e = [
edge("in", "a"),
edge("in", "b"),
edge("a", "out"),
edge("b", "out"),
];
near(simulateGraph(n, e, g).complete, 500);
const s = simulateGraph(n, e, { ...g, sharedGPU: true });
near(s.complete, 1000);
near(s.results.b.resourceWait, 500);
});
test("source text TTFT is not interpreted as first playable audio", () => {
const r = estimateNode(
{
...nodeData("speech", "LFM3"),
mode: "reference",
profileId: "lfm3",
size: 2,
},
g,
);
assert.notEqual(r.first, 50);
assert.ok(r.assumptions.some((x) => x.includes("只给首token")));
});
test("RTF-only reference uses its real factor without claiming first audio", () => {
const r = estimateNode(
{
...nodeData("speech", "MiniCPM"),
mode: "reference",
profileId: "minicpm",
size: 9,
},
g,
);
near(r.rtf, 0.21);
assert.ok(r.assumptions.some((x) => x.includes("来源只给RTF")));
assert.notEqual(r.first, 210);
});
test("program scaling is global, and disconnected work is called out", () => {
const n = [
node("a", "program", { durationMs: 100 }),
node("orphan", "program", { durationMs: 20 }),
node("out", "output", { durationMs: 0, outputType: "text" }),
];
const s = simulateGraph(n, [edge("a", "out")], { ...g, programScale: 2 });
near(s.first, 200);
assert.ok(s.warnings.some((x) => x.includes("orphan未连到主输出")));
});
test("audio component size and hardware settings affect engineering estimate", () => {
const a = estimateNode(
{ ...nodeData("asr", "ASR"), hardware: "h20", size: 0.6 },
g,
);
const b = estimateNode(
{ ...nodeData("asr", "ASR"), hardware: "h20", size: 2 },
g,
);
assert.ok(b.end > a.end);
const t1 = estimateNode({ ...nodeData("tts", "TTS"), cards: 1 }, g),
t2 = estimateNode({ ...nodeData("tts", "TTS"), cards: 2 }, g);
assert.ok(t2.end < t1.end);
});
test("network and endpoint wait are counted once at their boundaries", () => {
const n = [
node("in", "input"),
node("out", "output", { durationMs: 60, outputType: "text" }),
];
near(
simulateGraph(n, [edge("in", "out", "first")], {
...g,
vadMs: 120,
networkMs: 20,
}).first,
200,
);
});
test("invalid cycles and dangling edges are rejected including imports", () => {
const n = [node("a", "program"), node("b", "program")];
assert.ok(
validateGraph(n, [edge("a", "b"), edge("b", "a")]).some((x) =>
x.includes("环"),
),
);
assert.ok(validateGraph(n, [edge("a", "missing")]).length);
assert.ok(
validateDocument({
version: 1,
nodes: n,
edges: [],
globals: { computeScale: -1 },
}).length,
);
});
test("memory over-capacity is surfaced rather than silently claiming deployability", () => {
const r = estimateNode(
{ ...nodeData("llm", "405B"), size: 405, hardware: "4090", cards: 1 },
g,
);
assert.ok(r.warnings.some((x) => x.includes("显存")));
});
test("source records have provenance and do not confuse the units of metrics", () => {
const ids = new Set();
for (const r of RECORDS) {
assert.ok(!ids.has(r.id));
ids.add(r.id);
assert.ok(SOURCES[r.source]?.url.startsWith("https://"));
assert.ok(r.scope);
if (r.evidence === "unknown") assert.equal(r.value, null);
else assert.ok(r.value > 0);
}
const q = RECORDS.find((r) => r.id === "q3-8");
near(q.value, 150.25);
assert.equal(q.metric, "tps");
});
test("all presets compute finite targets and survive JSON round trip", () => {
for (const key of Object.keys(PRESETS)) {
const d = makeDocument(key);
assert.deepEqual(validateDocument(d), []);
const s = simulateGraph(d.nodes, d.edges, d.globals);
assert.deepEqual(s.errors, []);
assert.ok(Number.isFinite(s.first));
assert.ok(Number.isFinite(s.complete));
assert.deepEqual(validateDocument(JSON.parse(JSON.stringify(d))), []);
assert.equal(autoLayout(d.nodes, d.edges).length, d.nodes.length);
}
});