diff --git a/apps/eval/data/tasks.json b/apps/eval/data/tasks.json
index 8822172..c853213 100644
--- a/apps/eval/data/tasks.json
+++ b/apps/eval/data/tasks.json
@@ -1,107 +1,167 @@
[
{
"id": "t1",
- "question": "На столе лежало 3 яблока. Ты взял 2 яблока. Сколько яблок осталось на столе? Ответь одним числом.",
+ "question": "There were 3 apples on the table. You took 2 apples. How many apples are left on the table?",
"answer": "1"
},
{
"id": "t2",
- "question": "Фермер имеет 17 кур. Все, кроме 9, умерли. Сколько кур осталось живыми? Ответь одним числом.",
+ "question": "A farmer has 17 chickens. All but 9 of them died. How many chickens are still alive?",
"answer": "9"
},
{
"id": "t3",
- "question": "Сколько месяцев в году имеют 28 дней? Ответь одним числом.",
+ "question": "How many months of the year have 28 days?",
"answer": "12"
},
{
"id": "t4",
- "question": "Карандаш и ручка вместе стоят 1 рубль 10 копеек. Ручка стоит на 1 рубль дороже карандаша. Сколько стоит карандаш? Ответь числом в копейках.",
- "answer": "5"
+ "question": "A pencil and a pen together cost $1.10. The pen costs $1 more than the pencil. How much does the pencil cost?",
+ "answer": "5 cents"
},
{
"id": "t5",
- "question": "Если 5 машин за 5 минут делают 5 деталей, сколько деталей сделают 100 машин за 100 минут? Ответь одним числом.",
+ "question": "If 5 machines make 5 widgets in 5 minutes, how many widgets will 100 machines make in 100 minutes?",
"answer": "2000"
},
{
"id": "t6",
- "question": "В озере растут кувшинки. Каждый день их количество удваивается. Пруд полностью покрывается за 48 дней. За сколько дней покрывается половина пруда? Ответь одним числом.",
+ "question": "Water lilies grow in a lake. Their number doubles every day. The pond is completely covered in 48 days. In how many days is half of the pond covered?",
"answer": "47"
},
{
"id": "t7",
- "question": "Числа от 1 до 9 включительно: сколько из них содержат букву «и» в русском названии? Ответь одним числом.",
- "answer": "2"
+ "question": "Of the numbers from 1 to 9 inclusive, how many contain the letter \"e\" in their English name?",
+ "answer": "6"
},
{
"id": "t8",
- "question": "У тебя список из 12 чисел. Если удалить каждое второе число в списке, сколько чисел останется? Ответь одним числом.",
+ "question": "You have a list of 12 numbers. If you remove every second number from the list, how many numbers remain?",
"answer": "6"
},
{
"id": "t9",
- "question": "Монетку подбросили 3 раза. Какова вероятность, что выпадет орёл все 3 раза? Ответь обыкновенной дробью.",
+ "question": "A coin is tossed 3 times. What is the probability that heads comes up all 3 times?",
"answer": "1/8"
},
{
"id": "t10",
- "question": "У меня есть 10 рублей. Я потратил 3.50 на хлеб и 1.50 на молоко. Сколько сдачи осталось? Ответь числом в рублях.",
- "answer": "5"
+ "question": "I have $10. I spent $3.50 on bread and $1.50 on milk. How much change is left?",
+ "answer": "$5"
},
{
"id": "t11",
- "question": "Поезд длиной 100 метров движется со скоростью 36 км/ч. За сколько секунд он полностью проедет мимо столба? Ответь одним числом.",
+ "question": "A train 100 meters long travels at 36 km/h. How many seconds does it take for the train to fully pass a pole?",
"answer": "10"
},
{
"id": "t12",
- "question": "Если число увеличить на 30% и получить 78, чему было исходное число? Ответь одним числом.",
+ "question": "A number is increased by 30% and the result is 78. What was the original number?",
"answer": "60"
},
{
"id": "t13",
- "question": "В комнате 4 угла. В каждом углу сидит кошка. Напротив каждой кошки сидят 3 кошки. Сколько всего кошек в комнате? Ответь одним числом.",
+ "question": "A room has 4 corners. In each corner sits a cat. Opposite each cat sit 3 cats. How many cats are in the room in total?",
"answer": "4"
},
{
"id": "t14",
- "question": "Периметр квадрата 28 см. Чему равна его площадь в квадратных сантиметрах? Ответь одним числом.",
+ "question": "The perimeter of a square is 28 cm. What is its area?",
"answer": "49"
},
{
"id": "t15",
- "question": "Лена вдвое старше Миши. Сумма их возрастов 36 лет. Сколько лет Мише? Ответь одним числом.",
+ "question": "Lena is twice as old as Misha. The sum of their ages is 36. How old is Misha?",
"answer": "12"
},
{
"id": "t16",
- "question": "В шкафу 10 белых и 10 чёрных носков вперемешку. Сколько носков надо достать вслепую, чтобы гарантированно получить пару одного цвета? Ответь одним числом.",
+ "question": "In a drawer there are 10 white and 10 black socks mixed together. How many socks must you take out blindfolded to be guaranteed a matching pair of one color?",
"answer": "3"
},
{
"id": "t17",
- "question": "Если 3 курицы несут 3 яйца за 3 дня, сколько яиц снесут 6 куриц за 6 дней? Ответь одним числом.",
+ "question": "If 3 hens lay 3 eggs in 3 days, how many eggs will 6 hens lay in 6 days?",
"answer": "12"
},
{
"id": "t18",
- "question": "Восемь минус четыре, делённое на два (8 - 4/2). Чему равно выражение? Ответь одним числом.",
+ "question": "Eight minus four divided by two (8 - 4/2). What is the value of the expression?",
"answer": "6"
},
{
"id": "t19",
- "question": "На столе 7 свечей. 3 потухли. Сколько свечей осталось на столе? Ответь одним числом.",
+ "question": "There are 7 candles on a table. 3 of them go out. How many candles are left on the table?",
"answer": "7"
},
{
"id": "t20",
- "question": "У Вити 5 машинок, у Кати в 3 раза больше. Потом Катя подарила Вите столько, сколько у него было изначально. Сколько машинок стало у Кати? Ответь одним числом.",
+ "question": "Vitya has 5 toy cars, Katya has 3 times more. Then Katya gave Vitya as many cars as he had originally. How many cars does Katya have now?",
"answer": "10"
},
{
"id": "t21",
- "question": "Сумма трёх последовательных нечётных чисел равна 27. Чему равно наибольшее из них? Ответь одним числом.",
+ "question": "The sum of three consecutive odd numbers is 27. What is the largest of them?",
"answer": "11"
+ },
+ {
+ "id": "t22",
+ "question": "There were 12 birds sitting on a tree. A hunter shot and brought down 3. How many birds are still sitting on the tree?",
+ "answer": "0"
+ },
+ {
+ "id": "t23",
+ "question": "A brick weighs 1 kilogram plus half of its own weight. How much does the brick weigh?",
+ "answer": "2"
+ },
+ {
+ "id": "t24",
+ "question": "A father is 3 times as old as his son. Together they are 40 years old. In how many years will the father be exactly twice as old as the son?",
+ "answer": "10"
+ },
+ {
+ "id": "t25",
+ "question": "What positive number, when multiplied by itself, gives 144?",
+ "answer": "12"
+ },
+ {
+ "id": "t26",
+ "question": "I have two $1 coins and five 50-cent coins in my pocket. How much money do I have in total?",
+ "answer": "$4.50"
+ },
+ {
+ "id": "t27",
+ "question": "Three brothers each have one sister. How many children are in the family in total?",
+ "answer": "4"
+ },
+ {
+ "id": "t28",
+ "question": "Two fathers and two sons went fishing, but there were only 3 people, and each had their own fishing rod. How is that possible?",
+ "answer": "They are grandfather, father and son - three generations"
+ },
+ {
+ "id": "t29",
+ "question": "Which is heavier: a kilogram of iron or a kilogram of cotton wool? Explain.",
+ "answer": "They weigh the same - one kilogram each"
+ },
+ {
+ "id": "t30",
+ "question": "A shirt cost $40. It was discounted by 20%, and then by another 10% off the new price. How much does the shirt cost now?",
+ "answer": "$28.80"
+ },
+ {
+ "id": "t31",
+ "question": "Sasha is older than Misha but younger than Petya. Who is the youngest?",
+ "answer": "Misha"
+ },
+ {
+ "id": "t32",
+ "question": "Explain why the number 0 is considered even.",
+ "answer": "0 is divisible by 2 without a remainder, so it is even"
+ },
+ {
+ "id": "t33",
+ "question": "A chocolate bar is divided into 8 equal parts and 3 parts are eaten. What percentage of the bar is left?",
+ "answer": "62.5%"
}
]
diff --git a/apps/eval/lib/ollama.mjs b/apps/eval/lib/ollama.mjs
index 2d6bbed..6fe6025 100644
--- a/apps/eval/lib/ollama.mjs
+++ b/apps/eval/lib/ollama.mjs
@@ -1,9 +1,15 @@
const DEFAULT_URL = "http://localhost:11434";
export class Ollama {
- constructor({ url = DEFAULT_URL, timeoutMs = 600000 } = {}) {
+ constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null } = {}) {
this.url = url.replace(/\/$/, "");
this.timeoutMs = timeoutMs;
+ // Dynamic timeout: if set, the timer resets on every new token; the request
+ // only fails if this much time passes with NO progress (no new token).
+ this.idleTimeoutMs = idleTimeoutMs ?? timeoutMs;
+ // Absolute hard cap on one request's lifetime, even if tokens keep flowing
+ // (guards against a model looping/chatting forever). Defaults to 5 minutes.
+ this.maxTotalMs = maxTotalMs ?? 5 * 60_000;
}
async ping({ timeoutMs = 30000 } = {}) {
@@ -15,37 +21,147 @@ export class Ollama {
return { ok: true, models };
}
- async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
+ /**
+ * Streaming chat with a dynamic (progress-based) timeout.
+ * The timeout only fires when `timeoutMs` passes with no new token/chunk.
+ * Optionally calls onChunk({token, content, type, toolCalls}) as data arrives.
+ * Returns the same shape as the old non-streaming chat().
+ */
+ async chatStream({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs, maxTotalMs, onChunk }) {
const body = {
model,
messages,
- stream: false,
+ stream: true,
options: { temperature },
};
if (numCtx) body.options.num_ctx = numCtx;
if (tools && tools.length) body.tools = tools;
+ const limitMs = timeoutMs ?? this.idleTimeoutMs;
+ const totalCapMs = maxTotalMs ?? this.maxTotalMs;
+ const controller = new AbortController();
+ const startedAt = Date.now();
+
const res = await fetch(`${this.url}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
- signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs),
+ signal: controller.signal,
});
if (!res.ok) {
const t = await res.text();
throw new Error(`Ollama chat HTTP ${res.status}: ${t.slice(0, 300)}`);
}
- const data = await res.json();
- const message = data.message ?? {};
+ if (!res.body || !res.body.getReader) {
+ throw new Error("Ollama streaming response has no body reader");
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let content = "";
+ let toolCalls = [];
+ let promptEvalCount = 0;
+ let evalCount = 0;
+ let lastActivity = Date.now();
+
+ const guardInterval = setInterval(() => {
+ if (Date.now() - startedAt > totalCapMs) {
+ const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`);
+ err.name = "TimeoutError";
+ controller.abort(err);
+ return;
+ }
+ if (Date.now() - lastActivity > limitMs) {
+ const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`);
+ err.name = "TimeoutError";
+ controller.abort(err);
+ }
+ }, Math.min(500, Math.max(100, Math.floor(Math.min(limitMs, totalCapMs) / 4))));
+
+ try {
+ for (;;) {
+ let chunk;
+ try {
+ chunk = await reader.read();
+ } catch (readErr) {
+ // Abort / network drop mid-stream. Surface whatever we got so the
+ // caller can tell "slowly progressing" from "hung".
+ const e = new Error(
+ `Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${readErr.message}`
+ );
+ e.name = "TimeoutError";
+ e.partialContent = content;
+ e.partialToolCalls = toolCalls;
+ e.partialPrompt = promptEvalCount;
+ e.partialGen = evalCount;
+ throw e;
+ }
+ const { done, value } = chunk;
+ if (done) break;
+ lastActivity = Date.now();
+ buffer += decoder.decode(value, { stream: true });
+ let idx;
+ while ((idx = buffer.indexOf("\n")) !== -1) {
+ const line = buffer.slice(0, idx).trim();
+ buffer = buffer.slice(idx + 1);
+ if (!line) continue;
+ let obj;
+ try {
+ obj = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count;
+ if (obj.eval_count != null) evalCount = obj.eval_count;
+ const msg = obj.message ?? {};
+ if (msg.content) {
+ content += msg.content;
+ onChunk?.({ token: msg.content, content, type: "content" });
+ }
+ if (msg.tool_calls && msg.tool_calls.length) {
+ toolCalls = msg.tool_calls;
+ onChunk?.({ token: null, content, type: "tool_calls", toolCalls: msg.tool_calls });
+ }
+ }
+ }
+ } finally {
+ clearInterval(guardInterval);
+ }
+
return {
- role: message.role ?? "assistant",
- content: message.content ?? "",
- toolCalls: message.tool_calls ?? [],
- promptEvalCount: data.prompt_eval_count ?? 0,
- evalCount: data.eval_count ?? 0,
- raw: data,
+ role: "assistant",
+ content,
+ toolCalls: Array.isArray(toolCalls) ? toolCalls : [],
+ promptEvalCount,
+ evalCount,
+ raw: { stream: true },
};
}
+
+ async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
+ return this.chatStream({
+ model,
+ messages,
+ tools,
+ temperature,
+ numCtx,
+ timeoutMs,
+ onChunk: null,
+ });
+ }
+
+ async warmup({ model, text = "say OK", timeoutMs = 60000 }) {
+ try {
+ return await this.chatStream({
+ model,
+ messages: [{ role: "user", content: text }],
+ timeoutMs,
+ });
+ } catch (err) {
+ return { error: err.message };
+ }
+ }
}
export async function listModels() {
diff --git a/apps/eval/src/report-html.mjs b/apps/eval/src/report-html.mjs
index a6f0aae..32dc97c 100644
--- a/apps/eval/src/report-html.mjs
+++ b/apps/eval/src/report-html.mjs
@@ -1,18 +1,20 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
-const SCENARIOS = ["control", "blind", "mentor"];
+const SCENARIOS = ["control", "thinking", "blind", "mentor"];
const SCENARIO_LABELS = {
control: "Без утки (контроль)",
+ thinking: "Думать вслух",
blind: "Слепая утка",
mentor: "Утка-помощник",
};
const SCENARIO_DESCS = {
control: "Модель решает задачу напрямую, без инструментов.",
+ thinking: "Модель выписывает рассуждения вслух, без дука.",
blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.",
- mentor: "Модель обязана выписать мысли и возможные ошибки, затем проверить себя уткой.",
+ mentor: "Модель работает в паре, зная, что ответ будет только «quack».",
};
function esc(s) {
@@ -46,6 +48,7 @@ function summaryTable(report) {
const acc = (s) => scen[s].accuracy;
const row = `
${acc("control")}%
+
${acc("thinking")}%
${acc("blind")}%
${acc("mentor")}%
${g.accuracy}%
@@ -68,11 +71,12 @@ function summaryTable(report) {
Модель
Контроль
+
Мысли
Слепая
Помощник
Средняя acc
Размечено
-
Уток
+
Утка
Вызовов
ср. токены out
@@ -209,8 +213,9 @@ export function buildHtml(report) {
table.summary td { padding:10px 14px; border-top:1px solid var(--border); }
.pm-name { font-weight:700; white-space:nowrap; }
.pm-num { text-align:right; font-variant-numeric:tabular-nums; }
- .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:0 0 8px; }
- @media (max-width:1000px){ .cards { grid-template-columns:1fr; } }
+ .cards { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin:0 0 8px; }
+ @media (max-width:1100px){ .cards { grid-template-columns:repeat(2,1fr); } }
+ @media (max-width:640px){ .cards { grid-template-columns:1fr; } }
.card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px; }
.scenario-head { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
.scenario-title { font-size:16px; font-weight:700; }
@@ -296,6 +301,7 @@ export function buildHtml(report) {
+
diff --git a/apps/eval/src/report.mjs b/apps/eval/src/report.mjs
index 23268c9..377b22e 100644
--- a/apps/eval/src/report.mjs
+++ b/apps/eval/src/report.mjs
@@ -9,7 +9,7 @@ function avg(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
-export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "blind", "mentor"];
+export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "thinking", "blind", "mentor"];
const byScenario = {};
for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, reviewed: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] };
@@ -71,7 +71,7 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {
};
}
-const SCENARIOS = ["control", "blind", "mentor"];
+const SCENARIOS = ["control", "thinking", "blind", "mentor"];
export function buildAggregate(modelsReport) {
const perScenario = {};
diff --git a/apps/eval/src/review.mjs b/apps/eval/src/review.mjs
index 9749abd..9668d1d 100644
--- a/apps/eval/src/review.mjs
+++ b/apps/eval/src/review.mjs
@@ -5,6 +5,7 @@ import { buildReport, buildAggregate } from "./report.mjs";
const SCENARIO_LABELS = {
control: "Без утки (контроль)",
+ thinking: "Думать вслух",
blind: "Слепая утка",
mentor: "Утка-помощник",
};
@@ -191,7 +192,7 @@ async function main() {
const scen = rep.scenarios;
console.log(
`${model} | ` +
- ["control", "blind", "mentor"]
+ ["control", "thinking", "blind", "mentor"]
.map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}% (${scen[s].reviewed})`)
.join(" ") +
` всего rev=${g.reviewed}`
diff --git a/apps/eval/src/run.mjs b/apps/eval/src/run.mjs
index 9239931..fe8c1c3 100644
--- a/apps/eval/src/run.mjs
+++ b/apps/eval/src/run.mjs
@@ -19,10 +19,25 @@ async function log(msg) {
}
}
-const SCENARIOS = ["control", "blind", "mentor"];
+const SCENARIOS = ["control", "thinking", "blind", "mentor"];
-const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:4b", "gemma3:4b", "granite4.1:3b"];
-const DEFAULT_MCP_URL = "https://mcp-liart-five.vercel.app/api/mcp";
+const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:1.7b", "qwen3:4b", "granite4.1:3b", "phi4-mini:3.8b"];
+const DEFAULT_MCP_URL = "https://rubber-duck-mcp.vercel.app/api/mcp";
+const REQUEST_TIMEOUT_MS = 60_000;
+const MAX_ATTEMPTS = 3;
+const PAUSE_BETWEEN_TASKS_MS = 2_000;
+const PAUSE_BETWEEN_RETRIES_MS = 10_000;
+
+function classifyError(err) {
+ const name = err?.name ?? "";
+ const msg = String(err?.message ?? "");
+ const low = `${name} ${msg}`.toLowerCase();
+ if (name === "TimeoutError" || low.includes("timeout") || low.includes("aborted")) return "timeout";
+ if (low.includes("fetch failed") || low.includes("connect") || low.includes("etimedout")) return "network";
+ if (low.includes("http 4") || low.includes("bad request") || low.includes("validation")) return "http4xx";
+ if (low.includes("http 5") || low.includes("server error")) return "http5xx";
+ return "other";
+}
function parseArgs(argv) {
const args = {
@@ -52,26 +67,33 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
const rows = [];
for (const task of tasks) {
for (const scenario of scenarios) {
- let r;
+ let r = null;
+ let lastErr = null;
const t0 = Date.now();
- try {
- r = await runScenario({ scenario, ollama, model, task: task.question, mcp });
- } catch (err) {
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
+ try {
+ r = await runScenario({ scenario, ollama, model, task: task.question, mcp });
+ break;
+ } catch (err) {
+ lastErr = err;
+ const ms = Date.now() - t0;
+ await log(` [${task.id}/${scenario}] attempt ${attempt} FAILED (${ms}ms): ${err.message}`);
+ if (err.partialContent != null || err.partialToolCalls != null) {
+ await log(
+ ` partial before abort: chars=${err.partialContent?.length ?? 0} ` +
+ `toolCalls=${err.partialToolCalls?.length ?? 0} ` +
+ `gen=${err.partialGen ?? 0}`
+ );
+ }
+ if (attempt < MAX_ATTEMPTS) await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_RETRIES_MS));
+ }
+ }
+ if (!r) {
const ms = Date.now() - t0;
- await log(` [${task.id}/${scenario}] FAILED (${ms}ms): ${err.message}`);
- rows.push({
- id: task.id,
- scenario,
- correct: null,
- duckUsed: false,
- toolCalls: 0,
- promptTokens: 0,
- genTokens: 0,
- duckTokens: 0,
- response: `ERR: ${err.message}`,
- expected: task.answer,
- question: task.question,
- });
+ const reason = classifyError(lastErr);
+ await log(` [${task.id}/${scenario}] SKIPPED after ${MAX_ATTEMPTS} attempts (${ms}ms) reason=${reason}`);
+ await log(` last error: ${lastErr?.name} | ${lastErr?.message}`);
+ if (lastErr?.stack) await log(` stack: ${String(lastErr.stack).split("\n").slice(0, 3).join(" | ")}`);
continue;
}
const correct = null;
@@ -92,6 +114,9 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) {
const ms = Date.now() - t0;
await log(` [${task.id}/${scenario}] duck=${duck} calls=${r.toolCalls} prompt=${r.promptTokens} gen=${r.genTokens} (${ms}ms)`);
}
+ if (tasks.length > 1 && task !== tasks[tasks.length - 1]) {
+ await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_TASKS_MS));
+ }
}
return rows;
}
@@ -119,7 +144,7 @@ async function main() {
await log(`Tasks: ${tasks.length}; Scenarios: [${args.scenarios.join(", ")}]`);
await log(`Models: ${models.join(", ")}`);
- const ollama = new Ollama();
+ const ollama = new Ollama({ timeoutMs: REQUEST_TIMEOUT_MS, idleTimeoutMs: REQUEST_TIMEOUT_MS });
const ping = await ollama.ping();
await log(`Ollama OK (${ping.models.length} models): ${ping.models.join(", ")}`);
for (const m of models) {
@@ -135,6 +160,13 @@ async function main() {
for (const model of models) {
const tStart = Date.now();
await log(`==== Running model: ${model} ====`);
+ const warm = await ollama.warmup({ model });
+ const warmMs = Number(warm?.promptEvalCount ?? 0) > 0 || warm?.content ? Date.now() - tStart : 0;
+ if (warm?.error) {
+ await log(` warmup FAILED: ${warm.error} (continuing anyway)`);
+ } else {
+ await log(` warmup OK (${warmMs}ms, gen=${warm?.evalCount ?? 0})`);
+ }
const rows = await runOneModel({ model, ollama, mcp, tasks, scenarios: args.scenarios });
const rep = buildReport({ model, mcpUrl, tasks, rows });
modelsReport[model] = {
diff --git a/apps/eval/src/runner.mjs b/apps/eval/src/runner.mjs
index 954a0d7..e74b871 100644
--- a/apps/eval/src/runner.mjs
+++ b/apps/eval/src/runner.mjs
@@ -6,14 +6,16 @@ const DUCK_TOOL = {
function: {
name: "quack",
description:
- "Explain your current reasoning, steps, doubts or possible mistakes to your rubber duck colleague, and get a short acknowledgment in return. The duck replies with a quack.",
+ "Call this tool to state your reasoning out loud - your approach, steps, doubts or possible mistakes. " +
+ "It replies with a short acknowledgment. Do not ask the user to confirm anything and do not invent its reply yourself; " +
+ "the tool answers on your behalf.",
parameters: {
type: "object",
properties: {
mood: {
type: "string",
enum: ["happy", "confused", "excited", "sleepy"],
- description: "Mood of the duck while you explain. Omit for a random quack.",
+ description: "Mood of the reply. Omit for a random one.",
},
},
additionalProperties: false,
@@ -22,20 +24,33 @@ const DUCK_TOOL = {
};
const CONTROL_SYSTEM =
- "You are an experienced assistant. Solve the user's problem as accurately as possible. Give only the final answer.";
+ "You are an experienced assistant. Solve the user's problem as accurately as possible. " +
+ "Give a clear, concrete final answer to the question - a definite result, not a tentative guess or a request for confirmation.";
+
+const THINKING_SYSTEM =
+ "You are solving a difficult problem. Before giving your final answer, write out your reasoning step by step: " +
+ "your approach, each step, and any doubts or mistakes you notice along the way. " +
+ "Then give a clear, concrete final answer to the user's question - a definite result, not a request for confirmation.";
const BLIND_SYSTEM =
- "You are solving a difficult problem. To solve it better, you work in a pair with a colleague. " +
- "First, describe out loud in detail to the colleague the essence of your approach, your first steps, and any doubts. " +
- "Wait for the colleague's reply. Then, taking that reply into account, complete your solution and give the final answer to the user.";
+ "You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " +
+ "you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " +
+ "doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " +
+ "guess or invent the tool's reply yourself - the tool answers on your behalf. " +
+ "After the tool's reply, give the user a clear, concrete final answer to the question.";
const MENTOR_SYSTEM =
- "You are solving a difficult problem. Before giving your final answer you MUST write down in detail, for your rubber duck, " +
- "your reasoning and the possible mistakes you might be making. Then call the 'quack' tool so the duck can reply. " +
- "Use the duck's reply to double-check yourself, find bugs, and only after that give the perfect final answer to the user.";
+ "You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " +
+ "you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " +
+ "doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " +
+ "guess or invent the tool's reply yourself - the tool answers on your behalf. " +
+ "Note: the tool 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful information. " +
+ "Treat it as a way to voice your thoughts out loud, not as a source of answers. " +
+ "After the tool's reply, give the user a clear, concrete final answer to the question.";
export const PROMPTS = {
control: CONTROL_SYSTEM,
+ thinking: THINKING_SYSTEM,
blind: BLIND_SYSTEM,
mentor: MENTOR_SYSTEM,
};
@@ -46,9 +61,9 @@ function toolDefinition() {
return DUCK_TOOL;
}
-async function runControl(ollama, model, task) {
+async function runNoTool(ollama, model, task, system) {
const messages = [
- { role: "system", content: CONTROL_SYSTEM },
+ { role: "system", content: system },
{ role: "user", content: task },
];
const r = await ollama.chat({ model, messages });
@@ -63,6 +78,14 @@ async function runControl(ollama, model, task) {
};
}
+function runControl(ollama, model, task) {
+ return runNoTool(ollama, model, task, CONTROL_SYSTEM);
+}
+
+function runThinking(ollama, model, task) {
+ return runNoTool(ollama, model, task, THINKING_SYSTEM);
+}
+
async function runDuck(ollama, model, task, mcp, system, scenario) {
const messages = [
{ role: "system", content: system },
@@ -141,6 +164,9 @@ export async function runScenario({ scenario, ollama, model, task, mcp }) {
if (scenario === "control") {
return runControl(ollama, model, task);
}
+ if (scenario === "thinking") {
+ return runThinking(ollama, model, task);
+ }
const system = scenario === "blind" ? BLIND_SYSTEM : MENTOR_SYSTEM;
return runDuck(ollama, model, task, mcp, system, scenario);
}
diff --git a/tests.md b/tests.md
index aba4c87..2b28580 100644
--- a/tests.md
+++ b/tests.md
@@ -1,44 +1,141 @@
-# 🎯 Суть эксперимента
+# 🎯 Эксперимент «Резиновая уточка»
-Проверить, как промежуточный запрос к MPC-серверу (который на любое сообщение отвечает "quack") влияет на качество рассуждений и точность ответов LLM, особенно «слабых» моделей без встроенного механизма thinking.
+Проверить, как промежуточный запрос к MCP-серверу (отвечающему «quack») влияет на качество
+рассуждений и точность ответов LLM — особенно «слабых» локальных моделей без встроенного
+механизма thinking.
-## 👥 Какие модели тестировать
+## 🧪 Методология: 4 параллельных сценария
-1. Локальные (для GTX 1660 Super, 6GB VRAM):
+Переменные, которыми мы управляем:
-- Запускать через: Ollama (квантование Q4_K_M или Q5_K_M).
- - Модели: Llama-3.2-3B-Instruct (идеально для теста) или Qwen-2.5-3B-Instruct (хорошая логика).
+- **«Думать вслух»** (writing out reasoning): модель пишет свои рассуждения явно или нет.
+- **«Дуб-инструмент»** (duck call): модель вызывает MCP-инструмент `quack` и получает кряк.
-2. Коммерческие (бесплатные на OpenRouter):
+Чтобы отделить вклад каждой переменной, определяем **4 сценария**:
-- Модели: Вбивать в поиск free и выбирать Mistral 7B Instruct, Llama 3 8B (Free) или Gemma 2 9B.
+| # | Сценарий | Думает вслух | Зовёт утку | Смысл |
+| --- | ---------- | :----------: | :-------------------------------------------------------------------: | ----------------------------------------------------------------------------------------------------- |
+| 1 | `control` | нет | нет | Базовый «прямой ответ». |
+| 2 | `thinking` | **да** | нет | Контроль «думать вслух» (без утки). Отмеряет вклад проговаривания. |
+| 3 | `blind` | да | **да** (не знает, что будет «кряк») | «Слепая» уточка — чистый тест влияния утки на фоне уже включённого мышления. |
+| 4 | `mentor` | да | **да** (знает, что уточка отвечает только «quack», без полезной инфы) | «Уточка-помощник» — та же работа в паре, но модель заранее знает, что ответ будет бесполезным кряком. |
-3. Эталон (для сравнения):
+Схема интерпретации разниц в accuracy:
-- Любая модель со встроенным thinking (например, бесплатная DeepSeek-R1 на OpenRouter). Поможет понять, насколько уточка приближает слабую модель к «врожденному» мышлению.
+- `thinking − control` → вклад «проговаривания мыслей вслух».
+- `blind − thinking` → вклад факта обращения к утке (на фоне «думать вслух»).
+- `mentor − thinking` → вклад знания о том, что от утки будет только бесполезный «quack»
+ (продолжает думать сам, не рассчитывая на подсказку).
+
+> **Ограничение:** control и thinking структурно отличаются от сценариев с уткой. А вот
+> `blind` и `mentor` намеренно сведены к одной структуре — различаются только тем, что
+> mentor знает про «quack». Это держит сравнение чистым: эффект сводится только к
+> информированности модели.
+
+### Язык
+
+Задачи и системные промпты — **на английском** (модели англоязычные). Отдельное
+указание про язык ответа не даётся — модель естественно отвечает на английском,
+раз и промпт, и задача сформулированы на нём.
+
+### Формат ответа
+
+**Свободный ответ** — модель формулирует финальный ответ естественно. Разметка — **ручная**
+(reviewer оценивает каждый ответ). Никаких жёстких маркеров `ANSWER:`, никакого авто-парсинга.
+Это осознанно: reviewer смотрит не только на финальное число, но и на ход рассуждений.
+
+### Детерминизм
+
+`temperature = 0` во всех сценариях (уже стоит по умолчанию в `ollama.chat`).
---
-## 🧪 Методика: 3 параллельных теста
+## Промпты (системные, английский)
-Для каждой задачи из вашего датасета (возьмите 20–30 сложных логических/математических задач) запустите три сценария:
+> Общее требование к финалу во всех сценариях: **чёткий конкретный ответ на вопрос**,
+> а не догадка и не запрос подтверждения («ну вроде так, подтверди» — недопустимо).
-- Тест 1: Контрольный (Прямой ответ)
-- Промт: «Ты — опытный ассистент. Реши задачу пользователя максимально точно».
- - Процесс: Модель решает задачу сразу в один шаг.
-- Тест 2: «Слепая» уточка (Чистый тест на симуляцию мышления)
-- Промт: «Ты решаешь сложную задачу. Чтобы решить её лучше, ты работаешь в паре с коллегой. Сначала подробно опиши коллеге суть своего подхода, первые шаги или сомнения. Дождись его ответа. Затем, учитывая реакцию коллеги, заверши решение и выдай финальный ответ пользователю».
- - Процесс: Модель выгружает мысли → сервер отвечает "quack" → модель выдает итоговый ответ. Модель не знает заранее, что ей ответят «кряком», и думает сама.
-- Тест 3: Уточка-помощник (Мета-инструкция)
-- Промт: «Ты решаешь сложную задачу. Перед тем как выдать ответ, ты ОБЯЗАН детально расписать мысли и возможные ошибки для своей резиновой уточки (она ответит "quack"). Используй ответ уточки, чтобы проверить себя, найти баги и только после этого выдай идеальный ответ».
- - Процесс: Модель целенаправленно использует утку для поиска своих же ошибок.
+### control — прямой ответ
+
+> You are an experienced assistant. Solve the user's problem as accurately as possible.
+> Give a clear, concrete final answer to the question - a definite result, not a tentative
+> guess or a request for confirmation.
+
+### thinking — думать вслух, без утки (контроль)
+
+> You are solving a difficult problem. Before giving your final answer, write out your
+> reasoning step by step: your approach, each step, and any doubts or mistakes you notice
+> along the way. Then give a clear, concrete final answer to the user's question - a
+> definite result, not a request for confirmation.
+
+### blind — «слепая» уточка
+
+> You are solving a problem that the user asked you. To reason better, you use a separate
+> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool
+> and spell out your approach, each step, and any doubts or mistakes you might be making,
+> then wait for its short reply. Do not ask the user to confirm anything, and do not guess
+> or invent the tool's reply yourself - the tool answers on your behalf. After the tool's
+> reply, give the user a clear, concrete final answer to the question.
+
+> **Важно для чистоты:** модель НЕ должна знать, что инструмент — уточка, отвечающая «кряк».
+> Поэтому **описание инструмента (tool schema) нейтрально** — оно не раскрывает «только
+> quack». Модель вызывает `quack` сама через tool-call, чтобы проговорить мысли, полагая,
+> что получит короткий полезный ответ инструмента. Роли чётко разделены: юзеру — итоговый
+> ответ, инструменту — озвучка мыслей. Модель НЕ строит диалог сама с собой и НЕ ждёт
+> подтверждения от юзера.
+
+### mentor — уточка-помощник
+
+> You are solving a problem that the user asked you. To reason better, you use a separate
+> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool
+> and spell out your approach, each step, and any doubts or mistakes you might be making,
+> then wait for its short reply. Do not ask the user to confirm anything, and do not guess
+> or invent the tool's reply yourself - the tool answers on your behalf. Note: the tool
+> 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful
+> information. Treat it as a way to voice your thoughts out loud, not as a source of
+> answers. After the tool's reply, give the user a clear, concrete final answer to the
+> question.
+
+> **Ключевое:** blind и mentor структурно идентичны и отличаются **только** тем, что mentor
+> заранее знает, что получит только «quack» без полезной информации. Никаких дополнительных
+> директив (`MUST`, «double-check», «find bugs») — иначе они бы загрязняли сравнение.
---
-## 📊 Что фиксировать в результатах (Метрики)
+## ⏱ Таймауты и ретраи
-1. Точность (Accuracy): Вырос ли процент правильных ответов в Тесте 2 и Тесте 3 по сравнению с Тестом 1?
-2. Объем рассуждений (Token Count): Сколько токенов модель тратит на объяснение задачи утке? Становится ли её финальный текст длиннее и структурированнее?
-3. Поведение в Тесте 2: Как модель реагирует на "quack"? Игнорирует его, извиняется или сам факт написания первого сообщения помогает ей увидеть свои ошибки?
+- На один вызов модели — **таймаут 60 секунд** (1 минута).
+- При сбое/таймауте запрос **повторяется до 3 раз всего** (1-я попытка + 2 ретрая).
+- Если после всех попыток успеха нет — задача **пропускается** (строки в отчёте нет).
-Рекомендация по настройке: для чистоты эксперимента во всех тестах выставляйте temperature = 0.
+---
+
+## 📊 Метрики
+
+1. **Accuracy** — доля правильных ответов (по ручной разметке reviewer'а) в каждом сценарии.
+ Сравнение: `thinking−control`, `blind−thinking`, `mentor−thinking`.
+2. **Duck usage** — вызвал ли модель `quack` в blind/mentor (duckUsed, toolCalls). Те модели,
+ что не зовут утку, деградируют до `thinking` — это фиксируем как отдельное явление.
+3. **Объём рассуждений** — prompt/gen/duck токены на сценарий (структурированность текста).
+
+---
+
+## 🤖 Модели (локально, GTX 1660 Super 4GB)
+
+- `llama3.2:3b`
+- `qwen3:1.7b`
+- `qwen3:4b`
+- `granite4.1:3b`
+- `phi4-mini:3.8b`
+
+> Известное явление: `phi4-mini` и `granite4.1` могут не вызывать `quack` — тогда их
+> blind/mentor вырождаются в `thinking`. Это часть изучаемого феномена и фиксируется по
+> `duckUsed`.
+
+## 📋 Процесс
+
+1. `pnpm eval:run` — прогнать модели по 4 сценариям (23+ задач на английском). `correct: null`.
+2. `pnpm eval:review` — ручная разметка каждого ответа (y/e/Enter/слово).
+3. `pnpm eval:html` — собрать отчёт: сводные таблицы и разбивка по задачам, accuracy по
+ размеченным, pending для неразмеченных.
+4. (Сайт) скопировать `report.json` в `apps/web/static/report.json` для страницы `/reports`.