Files
Martin VogelandBernhard Jackiewicz 67e39d16c0 feat(ui): replace graph-ui with CodeAtlasWeb by Bernhard Jackiewicz
Imports the CodeAtlasWeb companion frontend verbatim from
BernhardJackiewicz/CodeAtlasWeb at commit
1825f9c850ac794066e02606012508b9d393c6aa as the new graph-ui and removes
the previous React frontend.

This is Bernhard Jackiewicz's work, contributed with his agreement and
squashed here from his development history (41 commits, cycles W0
through W15). The maintainer only relocates it: every product decision,
line of code and test below graph-ui/ is his. The seven galaxy files
that originate in this repository's previous graph-ui, and the pinned
3D libraries, are listed in graph-ui/THIRD_PARTY.md.

What it is: a local reading IDE on the CBM read surface. A read-only
Monaco source reader, a semantic twin that follows the caret, entry,
bug-hunt, change-scope and flow reading modes, deterministic
source-cited explanations with an optional local-model wording pass
(llama-server sidecar, off unless the reader starts one; no model or
binary ships), a persistent galaxy with focus following in both
directions, a live-agent overlay fed by a loopback bridge, explicit
air-gap, provenance and index-limit messaging, and an offline
acceptance and browser verification harness.

It consumes POST /rpc (the read-only tool allowlist), GET /api/layout,
and the atlas routes /api/tree, /api/trace, /api/flows and /api/flow,
plus the real ingest_traces store for observed calls; all of these are
on feat/atlas-r1 (#1860), which this change stacks on.

Not imported: CLAUDE.md (an agent-instruction file, which stays
local-only in this repository), and the recorded proof artifacts under
graph-ui/verification/, which follow in a separate commit so they can
be reviewed and dropped on their own.

Build wiring, the dev-proxy port contract, the CSP allowance for the
sidecar and bridge ports, and the control surfaces the previous
frontend had (project indexing, ADR editor, logs) follow in separate
maintainer commits.

Design and tracking: #1964.

Co-authored-by: Bernhard Jackiewicz <bj@techport.io>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-05 20:05:43 +02:00

763 lines
36 KiB
JavaScript

#!/usr/bin/env node
/*
* The head-to-head: forty-four golden questions, six candidates, one card set
* each, temperature zero and a fixed seed.
*
* This is the measurement PLAN paragraph 5 promises ("der Sieger der eigenen
* Eval schlaegt den Benchmark-Ruf") and the one the model ADR was left open
* for. It answers one question and refuses to answer any other: **given the
* same compiled cards, which of these six models phrases the most answers
* correctly without inventing anything.**
*
* ## What is held constant, and why that is the whole design
*
* Every model sees the SAME cards for a question of its class. The cards are
* compiled once per model class (A gets the 3000-token budget, B gets 8000),
* before any model starts, by the product's own compiler through
* tools/lib/compiler-bundle.mjs. Nothing about the graph, the classifier, the
* recipe or the card wording can therefore differ between two candidates, which
* means a difference in score is a difference between the models and not
* between two runs of an index.
*
* Temperature zero and seed 42 are set in src/chat/chat-client.ts, which is the
* same file the product's chat uses. Non-thinking is the per-model switch
* measured in src/compiler/prompt-contract.ts.
*
* ## What is scored
*
* Three things per question, and an answer has to survive all three to pass:
*
* 1. every expected substring appears in the answer,
* 2. no forbidden substring appears,
* 3. the citation contract holds: every claim line carries a [K], and every
* cited card was actually handed over.
*
* Rule three is scored separately as well, because it is the one that decides
* whether this product can ship a model at all. A model that is right and
* uncited is a model whose answers nobody can check.
*
* ## Citation compliance counts the answers it could read, and says how many it could not
*
* Since W10, `citationCompliance` is the share of MEASURED checks, and
* `citationUnmeasured` names how many were not measured. The difference is the
* finding from W7c (see `measured` in src/compiler/answer-contract.ts): an
* answer of exactly one line that the token ceiling cut off leaves the check
* with nothing to look at, and zero violations out of zero lines is not
* compliance. Counting it as clean would flatter the model; counting it as a
* violation would score the ceiling. It falls out of the rate, and the number
* that fell out stands beside the rate so nobody reads a recommendation off a
* sample that quietly shrank. `passRate` is untouched by this: an answer that
* could not be measured for citations still misses its expected substrings and
* still fails there.
*
* ## The bounds are a gate, not a remark
*
* Since 2026-08-29 (audit finding 6) this run ENFORCES the two bounds the ADR
* has always claimed: a class winner under passRate 0.6 or under citation 0.9
* ends this run with a non-zero exit and a named reason. The numbers live in
* tools/lib/eval-bounds.mjs, they are written into the report as `bounds`, and
* tools/eval-check.mjs holds the recorded winners to the same two lines. Before
* that they existed only inside a frozen acceptance test, which reads a file and
* cannot stop a run: a compiler change that sank the citation rate would have
* crowned a winner anyway and reported success.
*
* ## Two questions are answered without a model, on purpose
*
* `honest-01-de` and `honest-02-en` name nothing the index holds, so the
* compiler produces no cards and the chat returns the agreed sentence without
* sending a request. That is the product's behaviour, so it is measured as the
* product's behaviour; the count is reported per model as
* `deterministicAnswers` so nobody has to work out why six models agree exactly
* twice.
*
* ## Ports
*
* The C-server takes a free port from 4310 up. Each model takes a free port
* from 4400 up: the product port 4141 stays free throughout, so the UI keeps
* working and a crashed run cannot leave a model sitting on the port the
* sidecar manager polls.
*
* Run: node tools/eval-llm.mjs [--dry] [--only <id-prefix>] [--models a,b]
* --dry compile the cards and score nothing: no model is started
* --only run a subset of the questions, by id prefix
* --models run a subset of the candidates, by key
*/
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
countListeners,
findFreePort,
indexRepository,
sleep,
startServer,
stopServer,
} from './lib/cbm-server.mjs';
import { BUNDLE_DIR, loadCompiler } from './lib/compiler-bundle.mjs';
import { EVAL_BOUNDS, boundViolations } from './lib/eval-bounds.mjs';
import { EVAL_PORT_FLOOR, llamaProps, startLlama, stopLlama } from './lib/llama.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const BINARY = join(ROOT, 'cbm', 'build', 'c', 'codebase-memory-mcp');
const FIXTURE = join(ROOT, 'fixtures', 'atlas-sample');
const QUESTIONS = join(ROOT, 'eval', 'questions.json');
const OUT_DIR = join(ROOT, 'verification', 'w5');
const OUT_JSON = join(OUT_DIR, 'eval.json');
const PROJECT = 'codeatlasweb-w5b-eval';
const SERVER_PORT_FLOOR = 4310;
/**
* The six candidates: the four of ADR 0001 plus the two the user nominated.
*
* `class` is the budget class and it is the same split llm/start.sh makes, for
* the same reason: the context window decides how much context there is.
*
* `key` is what `--models` selects by, and it is the same word llm/start.sh
* takes. It used to be `1b` and `4b`; audit finding 17 of 2026-08-29 pointed out
* that the class A winner is a 2B model, so calling its selector "1b" states a
* size the file does not have. `was` keeps the old word working, because a note
* or a shell history that says `--models 1b` should not break over a label.
*/
const CANDIDATES = [
{ key: 'class-a', was: '1b', name: 'Qwen3.5-2B', file: 'Qwen3.5-2B-Q4_K_M.gguf', class: 'A', ctx: 3072 },
{ key: 'class-a-lfm', was: '1b-lfm', name: 'LFM2.5-1.2B', file: 'LFM2.5-1.2B-Instruct-Q4_K_M.gguf', class: 'A', ctx: 3072 },
{ key: 'class-a-minicpm', was: '1b-minicpm', name: 'MiniCPM5-1B', file: 'MiniCPM5-1B-Q4_K_M.gguf', class: 'A', ctx: 3072 },
{ key: 'class-a-coder', was: '1b-coder', name: 'Qwen2.5-Coder-1.5B', file: 'qwen2.5-coder-1.5b-instruct-q4_k_m.gguf', class: 'A', ctx: 3072 },
{ key: 'class-b', was: '4b', name: 'Qwen3.5-4B', file: 'Qwen3.5-4B-Q4_K_M.gguf', class: 'B', ctx: 8192 },
{ key: 'class-b-gemma', was: '4b-gemma', name: 'gemma-4-E4B', file: 'gemma-4-E4B-it-Q4_K_M.gguf', class: 'B', ctx: 8192 },
];
/**
* What was changed between measured passes, and what it cost or bought.
*
* Written into the report because the numbers below are meaningless without it:
* the interesting finding of this cycle is not that one model scored 0.68, it
* is which changes to the CARDS and the PROMPT moved the whole field, and in
* which direction. Every entry names a change to the compiler, never to a
* question: the golden questions were fixed before the first model started and
* were not touched afterwards.
*/
const ITERATIONS = [
{
pass: 1,
change: 'first prompt: numbered rules plus a worked example built from fixture names',
measured: 'Qwen3.5-2B on the caller questions: passRate 0.625, citation 0.75',
finding: 'headings ("The symbols that call listUsers are:") and closing lines carried no '
+ 'citation; one model wrote "userCreate" where the card said "createUser".',
},
{
pass: 2,
change: 'added rules against headings and closing lines, against re-spelling a name, and '
+ 'against answering with cards about something else',
measured: 'Qwen3.5-2B over all 44: passRate 0.705, citation 0.841',
finding: 'the worked example was being copied: models put the fixture names from the '
+ 'example into answers about entirely different symbols.',
},
{
pass: 3,
change: 'example replaced with placeholder names (alphaWorker, betaHandler)',
measured: 'Qwen3.5-2B: passRate 0.114, citation 0.227',
finding: 'the placeholders were copied verbatim as well. An example is a pattern to '
+ 'continue, whatever it contains.',
},
{
pass: 4,
change: 'example removed entirely; the shape stated as a rule',
measured: 'Qwen3.5-2B: passRate 0.205, citation 0.295',
finding: 'with no shape to follow the models copied the shape of the cards and wrote '
+ '"K2: registerUserRoutes ...", where the number is not a citation.',
},
{
pass: 5,
change: 'CARDS now labelled [K1], [K2] in citation syntax instead of "K1:"; a slot '
+ 'template for the answer line; the counting sentence moved to the last line of '
+ 'the subject card; repeat penalty 1.1 and a truncation-aware citation check',
measured: 'Qwen3.5-2B: passRate 0.636, citation 1.0',
finding: 'the cheapest continuation of a labelled card list is now a compliant citation. '
+ 'The slot text itself still leaked into some answers.',
},
{
pass: 6,
change: 'rule against writing the slot markers; the fallback sentence described instead '
+ 'of quoted; the fallback exemption in the checker narrowed to a lone line',
measured: 'Qwen3.5-2B: passRate 0.682, citation 0.932; the reported run',
finding: 'both class winners clear the acceptance bounds. The remaining failures are '
+ 'model limitations, not contract violations: names dropped from an answer, or one '
+ 'caller named where the cards list three.',
},
];
const log = (...parts) => console.log('[eval-llm]', ...parts);
function parseArgs(argv) {
const args = { dry: false, only: '', models: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--dry') {
args.dry = true;
} else if (argv[i] === '--only') {
args.only = argv[i + 1] ?? '';
i += 1;
} else if (argv[i] === '--models') {
args.models = argv[i + 1] ?? '';
i += 1;
}
}
return args;
}
/** Case-insensitive containment. Names, paths and numbers, never phrasing. */
const contains = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
/** Mean of a list, or zero for an empty one. Never a division by nothing. */
const mean = (values) =>
values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
/** Rounded to three places, so a report does not carry sixteen digits of noise. */
const round = (value) => Math.round(value * 1000) / 1000;
async function main() {
const args = parseArgs(process.argv.slice(2));
const startedAt = Date.now();
let serverChild = null;
let home = null;
let runtimeDir = null;
let serverPort = 0;
let failure = null;
const serverLog = [];
const models = [];
const extras = { compile: {}, iterations: ITERATIONS, leftovers: [] };
try {
if (!existsSync(BINARY)) {
throw new Error(`Binary fehlt: ${BINARY} (erst 'make -f Makefile.cbm cbm-with-ui' im cbm-Clone bauen)`);
}
if (!existsSync(FIXTURE)) {
throw new Error(`Fixture fehlt: ${FIXTURE}`);
}
const questions = JSON.parse(await readFile(QUESTIONS, 'utf8'));
const selected = args.only.length === 0
? questions
: questions.filter((entry) => entry.id.startsWith(args.only));
log(`${selected.length} von ${questions.length} Fragen`);
// ------------------------------------------------- HOME und Index --
home = await mkdtemp(join(tmpdir(), 'codeatlasweb-w5b-eval-home-'));
runtimeDir = await mkdtemp('/private/tmp/codeatlasweb-w5b-eval-run-');
process.env.CBM_RUNTIME_DIR = runtimeDir;
const indexed = await indexRepository(BINARY, { home, repoPath: FIXTURE, project: PROJECT });
extras.indexed = { nodes: indexed.nodes, edges: indexed.edges };
log(`indiziert: ${PROJECT} (${indexed.nodes} Knoten, ${indexed.edges} Kanten)`);
serverPort = await findFreePort(SERVER_PORT_FLOOR);
const started = await startServer(BINARY, { home, port: serverPort, log: serverLog });
serverChild = started.child;
log(`C-Server auf ${serverPort}`);
// ------------------------------------------- der Context-Compiler --
const { module: compiler, inputs } = await loadCompiler();
extras.compilerModules = inputs;
const provider = compiler.makeProvider(`http://127.0.0.1:${serverPort}`, 1);
const root = compiler.ATLAS_WORKSPACE_ROOT;
const opts = { projectName: PROJECT, generation: 1 };
// Der Fokus jeder Frage, einmal aufgeloest. `focusQn` ist
// projektrelativ notiert, damit die Datei nicht den Projektnamen dieses
// Laufs traegt; das Praefix kommt hier davor.
const focusOf = new Map();
for (const entry of selected) {
const resolved = await compiler.resolveSubject(
provider, root, `${PROJECT}.${entry.focusQn}`, opts,
);
if (resolved === undefined) {
throw new Error(`focusQn nicht aufloesbar: ${entry.focusQn} (${entry.id})`);
}
focusOf.set(entry.id, resolved.symbol);
}
log(`${focusOf.size} Fokus-Symbole aufgeloest`);
/*
* Die Fakten einmal je Frage, die Karten einmal je Modellklasse.
*
* Der Schnitt liegt genau dort, weil das Budget die Fakten nicht
* beeinflusst: der Graph wird gefragt, was die Frageklasse braucht, und
* erst der Kartenschreiber kappt auf 3000 oder 8000 Token. Ein Packet je
* Klasse zu holen hiesse, denselben Graphen zweimal zu befragen, und
* schlimmer: zwei Klassen koennten dann auf zwei verschiedenen Lesungen
* desselben Index sitzen.
*/
const packetStarted = Date.now();
const packets = new Map();
for (const entry of selected) {
const focus = focusOf.get(entry.id);
packets.set(entry.id, await compiler.compileFacts(provider, root, entry.question, {
depth: entry.depth,
context: {
focusName: focus.name,
focusQualifiedName: focus.qualifiedName,
mode: 'none',
},
focus,
...opts,
}));
}
extras.packetMs = Date.now() - packetStarted;
log(`${packets.size} Fakten-Pakete in ${extras.packetMs} ms`);
const compiled = { A: new Map(), B: new Map() };
for (const modelClass of ['A', 'B']) {
const compileStarted = Date.now();
for (const entry of selected) {
const packet = packets.get(entry.id);
const cards = compiler.compileCards(packet, {
budget: compiler.cardBudgetOf(modelClass),
});
compiled[modelClass].set(entry.id, {
packet,
cards,
plan: cards.cards.length === 0 ? undefined : compiler.buildPrompt({
question: entry.question,
klass: packet.klass,
cards,
modelName: 'compile-only',
}),
});
}
extras.compile[modelClass] = {
ms: Date.now() - compileStarted,
budget: compiler.cardBudgetOf(modelClass),
meanCards: round(mean([...compiled[modelClass].values()].map((c) => c.cards.cards.length))),
meanTokens: round(mean([...compiled[modelClass].values()].map((c) => c.cards.tokens))),
maxTokens: Math.max(...[...compiled[modelClass].values()].map((c) => c.cards.tokens)),
droppedCards: [...compiled[modelClass].values()].reduce((sum, c) => sum + c.cards.dropped, 0),
noCardQuestions: [...compiled[modelClass].entries()]
.filter(([, c]) => c.cards.cards.length === 0)
.map(([id]) => id),
classes: [...compiled[modelClass].entries()]
.map(([id, c]) => ({ id, klass: c.packet.klass })),
};
log(`Karten fuer Klasse ${modelClass}: `
+ `${extras.compile[modelClass].meanCards} im Schnitt, `
+ `${extras.compile[modelClass].meanTokens} Token`);
}
/*
* Ein Vorabbefund, der keinen Punkt vergibt und trotzdem der wichtigste
* ist: steht jede erwartete Kernaussage ueberhaupt in den Karten?
* Steht sie nicht drin, misst die Frage den Index und nicht das Modell,
* und das waere eine Frage, die kein Modell bestehen kann.
*/
extras.expectedInCards = selected.map((entry) => {
const modelClass = 'A';
const plan = compiled[modelClass].get(entry.id);
const text = compiler.renderCards(plan.cards.cards);
const missing = entry.expected.filter(
(needle) => !contains(text, needle) && !contains(plan.packet.notes.join(' '), needle),
);
return { id: entry.id, klass: plan.packet.klass, cards: plan.cards.cards.length, missing };
});
const unanswerable = extras.expectedInCards.filter(
(entry) => entry.missing.length > 0 && entry.cards > 0,
);
if (unanswerable.length > 0) {
log('WARNUNG: erwartete Kernaussagen fehlen in den Karten:',
JSON.stringify(unanswerable));
}
// ------------------------------------------------- die Kandidaten --
/*
* Der Trockenlauf faellt NICHT frueh aus dieser Funktion heraus.
*
* Ein `return` hier uebersprang das Abraeumen unter dem catch, und ein
* nicht beendeter C-Server haelt die Ereignisschleife von Node offen:
* der Lauf war fertig, das Programm endete nie, und von aussen sah
* beides gleich aus. Also entscheidet eine leere Kandidatenliste, und
* der Weg nach draussen ist fuer jeden Lauf derselbe.
*/
const wanted = args.dry
? []
: args.models.length === 0
? CANDIDATES
: CANDIDATES.filter((entry) => {
const wanted = args.models.split(',').map((word) => word.trim());
return wanted.includes(entry.key) || wanted.includes(entry.was);
});
if (args.dry) {
// Der Trockenlauf schreibt NICHT nach verification/: dort stehen
// Beweise, und ein Lauf ohne Modell beweist nichts ueber Modelle.
log('--dry: kein Modell wird gefahren.');
await writeFile(
join(BUNDLE_DIR, 'eval-dry.json'),
JSON.stringify({ extras, generatedAt: new Date().toISOString() }, null, 2) + '\n',
'utf8',
);
}
for (const candidate of wanted) {
const modelStarted = Date.now();
const port = await findFreePort(EVAL_PORT_FLOOR, [serverPort]);
const llamaLog = [];
log(`starte ${candidate.name} auf ${port} (Kontext ${candidate.ctx})`);
const running = await startLlama({
modelFile: candidate.file,
contextTokens: candidate.ctx,
port,
log: llamaLog,
});
const props = await llamaProps(port);
const origin = `http://127.0.0.1:${port}`;
const nonThinking = compiler.nonThinkingFor(candidate.name);
const results = [];
for (const entry of selected) {
const plan = compiled[candidate.class].get(entry.id);
const cardIds = plan.cards.cards.map((card) => card.id);
if (plan.plan === undefined) {
// Keine Karte, also keine Anfrage. Der vereinbarte Satz ist
// die Antwort des Produkts, und genau die wird bewertet.
const answer = compiler.NO_CARD_SENTENCE;
const check = compiler.checkCitations(answer, cardIds);
results.push({
id: entry.id,
klass: plan.packet.klass,
deterministic: true,
answered: true,
answer,
cards: cardIds.length,
citationOk: check.ok,
citationMeasured: check.measured,
missing: entry.expected.filter((needle) => !contains(answer, needle)),
forbiddenHits: (entry.forbidden ?? []).filter((needle) => contains(answer, needle)),
tokensPerSecond: 0,
completionTokens: 0,
promptTokens: 0,
answerChars: answer.length,
});
continue;
}
let reply;
try {
reply = await compiler.askModel({
origin,
system: plan.plan.system,
user: plan.plan.user,
chatTemplateKwargs: nonThinking.chatTemplateKwargs,
fetch: (url, init) => fetch(url, init),
});
} catch (error) {
results.push({
id: entry.id,
klass: plan.packet.klass,
deterministic: false,
answered: false,
answer: '',
error: error instanceof Error ? error.message : String(error),
cards: cardIds.length,
citationOk: false,
/*
* Ein Lauf, der keine Antwort hervorgebracht hat, hat
* auch keine Zitierdisziplin hervorgebracht. Er faellt
* aus der Zitattreue heraus und wird als nicht gemessen
* ausgewiesen; die Trefferquote zaehlt ihn weiter als
* nicht bestanden, denn dort fehlen alle erwarteten
* Namen.
*/
citationMeasured: false,
missing: entry.expected,
forbiddenHits: [],
tokensPerSecond: 0,
completionTokens: 0,
promptTokens: 0,
answerChars: 0,
});
continue;
}
const answer = reply.content;
/*
* Die Kappung wird mitgegeben und nicht verschwiegen: eine
* Zeile, die das Token-Limit halbiert hat, ist kein Satz, den
* das Modell ohne Beleg stehenlassen wollte. Alles davor wird
* voll geprueft.
*/
const check = compiler.checkCitations(answer, cardIds, {
truncated: reply.truncated,
});
results.push({
id: entry.id,
klass: plan.packet.klass,
deterministic: false,
answered: answer.length > 0,
thoughtOnly: reply.thoughtOnly,
truncated: reply.truncated,
finishReason: reply.finishReason,
answer,
cards: cardIds.length,
citationOk: check.ok,
citationMeasured: check.measured,
unknownCitations: check.unknown,
uncitedLines: check.violations.filter((v) => v.reason === 'no-citation').length,
missing: entry.expected.filter((needle) => !contains(answer, needle)),
forbiddenHits: (entry.forbidden ?? []).filter((needle) => contains(answer, needle)),
tokensPerSecond: round(
compiler.tokensPerSecond(reply.completionTokens, reply.durationMs) ?? 0,
),
completionTokens: reply.completionTokens ?? 0,
promptTokens: reply.promptTokens ?? 0,
estimatedPromptTokens: plan.plan.estimatedTokens,
answerChars: answer.length,
durationMs: reply.durationMs,
});
}
await stopLlama(running.child);
await sleep(1500);
const passed = results.filter(
(entry) => entry.missing.length === 0
&& entry.forbiddenHits.length === 0
&& entry.citationOk,
);
const speeds = results.filter((entry) => entry.tokensPerSecond > 0)
.map((entry) => entry.tokensPerSecond);
/*
* Die Zitattreue misst nur, was sie lesen konnte. Siehe den Kopf
* dieser Datei und `measured` in src/compiler/answer-contract.ts.
*
* Der Nenner ist die Zahl der gemessenen Pruefungen und nicht die
* Zahl der Fragen. Ist keine einzige gemessen, ist die Quote 0 und
* nicht "keine Fundstelle also perfekt": eine Division ohne Nenner
* ist keine Zahl, und die freundlichere der beiden Auslegungen waere
* genau die Schoenung, gegen die dieses Feld geschrieben ist.
*/
const measuredResults = results.filter((entry) => entry.citationMeasured === true);
const unmeasured = results.length - measuredResults.length;
const model = {
name: candidate.name,
file: candidate.file,
modelClass: candidate.class,
mode: nonThinking.note,
nonThinking: 'chat_template_kwargs {"enable_thinking": false}',
passRate: round(passed.length / results.length),
citationCompliance: measuredResults.length === 0
? 0
: round(
measuredResults.filter((entry) => entry.citationOk).length
/ measuredResults.length,
),
citationMeasuredAnswers: measuredResults.length,
citationUnmeasured: unmeasured,
meanTokPerSec: round(mean(speeds)),
answered: results.filter((entry) => entry.answered).length,
notes: [
`context ${props.contextTokens ?? candidate.ctx} tokens, `
+ `quantisation ${props.quantization || 'unreported'}, `
+ `ready in ${running.readyMs} ms on port ${port}`,
`${results.filter((entry) => entry.deterministic).length} answers came from the `
+ 'compiler without a request, because no card covered the question',
`${results.filter((entry) => entry.thoughtOnly === true).length} answers were a `
+ 'monologue with no content',
`${results.filter((entry) => entry.truncated === true).length} answers stopped at `
+ 'the token limit; their last line is not counted as a claim',
`${unmeasured} answers left the citation check with no line to look at and are `
+ `out of the rate above; it is the share of the remaining ${measuredResults.length}`,
`mean answer length ${Math.round(mean(results.map((entry) => entry.answerChars)))} characters`,
],
deterministicAnswers: results.filter((entry) => entry.deterministic).length,
meanAnswerChars: Math.round(mean(results.map((entry) => entry.answerChars))),
meanPromptTokens: Math.round(mean(
results.filter((entry) => entry.promptTokens > 0).map((entry) => entry.promptTokens),
)),
meanEstimatedPromptTokens: Math.round(mean(
results.filter((entry) => entry.estimatedPromptTokens > 0)
.map((entry) => entry.estimatedPromptTokens),
)),
totalMs: Date.now() - modelStarted,
perQuestion: results,
};
models.push(model);
log(`${candidate.name}: passRate ${model.passRate}, `
+ `citation ${model.citationCompliance} von ${model.citationMeasuredAnswers} gemessenen `
+ `(${model.citationUnmeasured} nicht gemessen), ${model.meanTokPerSec} tok/s, `
+ `${model.answered} beantwortet, ${Math.round(model.totalMs / 1000)} s`);
}
} catch (err) {
failure = err;
console.error('[eval-llm] FEHLER:', err.message);
if (serverLog.length > 0) {
console.error('[eval-llm] Server-Log:\n' + serverLog.slice(-15).join('\n'));
}
}
await stopServer(serverChild);
await sleep(600);
for (const port of [serverPort, 4141].filter((value) => value > 0)) {
extras.leftovers.push({ port, listeners: await countListeners(port) });
}
if (!models.length) {
if (home) {
await rm(home, { recursive: true, force: true });
}
if (runtimeDir) {
await rm(runtimeDir, { recursive: true, force: true });
}
if (failure !== null) {
process.exitCode = 1;
}
return;
}
/*
* Der Sieger je Klasse: Score ist passRate, Gleichstand entscheidet die
* Zitattreue, dann das Tempo. Genau die Reihenfolge des Contracts, und sie
* ist nicht beliebig: ein Modell, das mehr Fragen richtig beantwortet, aber
* seine Quellen nicht nennt, ist fuer dieses Produkt unbrauchbar, und ein
* schnelleres Modell mit derselben Trefferquote und derselben Zitattreue
* ist schlicht das bessere.
*/
const winnerOf = (modelClass) => {
const field = models.filter((model) => model.modelClass === modelClass);
if (field.length === 0) {
return undefined;
}
const ranked = [...field].sort((a, b) =>
(b.passRate - a.passRate)
|| (b.citationCompliance - a.citationCompliance)
|| (b.meanTokPerSec - a.meanTokPerSec));
const winner = ranked[0];
const runnerUp = ranked[1];
return {
name: winner.name,
file: winner.file,
passRate: winner.passRate,
citationCompliance: winner.citationCompliance,
meanTokPerSec: winner.meanTokPerSec,
begruendung: runnerUp === undefined
? `einziger Kandidat der Klasse ${modelClass}.`
: `passRate ${winner.passRate} gegen ${runnerUp.passRate} (${runnerUp.name}); `
+ `Zitattreue ${winner.citationCompliance} gegen ${runnerUp.citationCompliance}; `
+ `${winner.meanTokPerSec} gegen ${runnerUp.meanTokPerSec} tok/s. `
+ 'Score ist die Trefferquote, Gleichstand entscheidet die Zitattreue, dann das Tempo.',
};
};
const report = {
temperature: 0,
seed: 42,
questionCount: JSON.parse(await readFile(QUESTIONS, 'utf8')).length,
maxTokens: 220,
repeatPenalty: 1.1,
fixture: 'fixtures/atlas-sample (nur gelesen)',
project: PROJECT,
serverPort,
evalPortFloor: EVAL_PORT_FLOOR,
sidecarPortUntouched: 4141,
budgets: { classA: 3000, classB: 8000 },
/*
* Die Grenzen stehen mit im Bericht, und zwar als Zahlen und nicht als
* Satz: die Datei ist die Quelle, aus der eval:check spaeter prueft, und
* eine Grenze, die nur im Werkzeug steht, koennte ein spaeterer Lauf
* senken, ohne dass die Aufzeichnung es zeigt.
*/
bounds: EVAL_BOUNDS,
/*
* Erzwungen wird nur an einem VOLLEN Lauf: alle Kandidaten, alle Fragen.
* Ein `--only`- oder `--models`-Ausschnitt misst die Modellwahl nicht,
* und ein Gate, das an einem Ausschnitt zuschlaegt, waere ein Gate, das
* man beim Debuggen abstellt. Das Feld steht in der Datei, damit ein
* Leser einen Ausschnitt nicht fuer eine Messung haelt.
*/
boundsEnforced: args.only.length === 0 && args.models.length === 0 && !args.dry,
models: models.map((model) => ({
name: model.name,
file: model.file,
modelClass: model.modelClass,
mode: model.mode,
nonThinking: model.nonThinking,
passRate: model.passRate,
citationCompliance: model.citationCompliance,
/*
* Der Nenner der Zeile darueber und die Zahl, die herausgefallen
* ist. Beide stehen mit in der Datei, weil eine Quote ohne ihren
* Nenner nicht nachpruefbar ist.
*/
citationMeasuredAnswers: model.citationMeasuredAnswers,
citationUnmeasured: model.citationUnmeasured,
meanTokPerSec: model.meanTokPerSec,
answered: model.answered,
notes: model.notes,
deterministicAnswers: model.deterministicAnswers,
meanAnswerChars: model.meanAnswerChars,
meanPromptTokens: model.meanPromptTokens,
meanEstimatedPromptTokens: model.meanEstimatedPromptTokens,
totalMs: model.totalMs,
})),
winnerClassA: winnerOf('A'),
winnerClassB: winnerOf('B'),
generatedAt: new Date().toISOString(),
totalMs: Date.now() - startedAt,
error: failure ? failure.message : null,
extras: { ...extras, perQuestion: models.map((model) => ({
name: model.name,
results: model.perQuestion,
})) },
};
await mkdir(OUT_DIR, { recursive: true });
await writeFile(OUT_JSON, JSON.stringify(report, null, 2) + '\n', 'utf8');
log('geschrieben:', OUT_JSON);
if (home) {
await rm(home, { recursive: true, force: true });
}
if (runtimeDir) {
await rm(runtimeDir, { recursive: true, force: true });
}
/*
* Das Gate, nach dem Schreiben und vor dem Ende.
*
* Nach dem Schreiben, weil ein gerissener Grenzwert erst recht nachlesbar
* sein muss: die Datei mit den schlechten Zahlen ist der Beweis, aus dem der
* naechste Zyklus lernt. Und trotzdem ein Abbruch, weil ein Lauf, der eine
* unbrauchbare Modellwahl mit 0 quittiert, dieselbe Auskunft gibt wie ein
* Lauf, dessen Sieger taugen.
*/
const violations = report.boundsEnforced
? boundViolations({ A: report.winnerClassA, B: report.winnerClassB })
: [];
if (!report.boundsEnforced) {
log('Teillauf: die Grenzen werden NICHT erzwungen, und die Datei sagt das '
+ '(boundsEnforced false). Ein Feld- oder Fragen-Ausschnitt misst die Modellwahl nicht.');
}
if (violations.length > 0) {
console.error('[eval-llm] Die harten Grenzen der Modellwahl sind gerissen '
+ `(passRate >= ${EVAL_BOUNDS.passRate}, Zitattreue >= ${EVAL_BOUNDS.citationCompliance}):`);
for (const reason of violations) {
console.error(' -', reason);
}
console.error(`[eval-llm] Herkunft der Grenzen: ${EVAL_BOUNDS.source}`);
console.error(`[eval-llm] Die Zahlen stehen in ${OUT_JSON}.`);
process.exitCode = 1;
return;
}
log(`Grenzen gehalten: beide Klassensieger ueber passRate ${EVAL_BOUNDS.passRate} `
+ `und Zitattreue ${EVAL_BOUNDS.citationCompliance}.`);
if (failure !== null) {
process.exitCode = 1;
}
}
main().catch((err) => {
console.error('[eval-llm] unerwarteter Fehler:', err);
process.exitCode = 1;
});