// Rollout transcript: messages as a conversation, tool calls paired with their outputs, // plans as a mini berth chart + compact table, check results as violations + cost. import { BerthChart } from "./chart.js"; import { escapeHtml, evaluatePlan, fmtNum, hasCranes, parsePlanArgs, publishedPlan } from "./model.js"; const PLAN_TOOLS = new Set(["check_plan", "submit_plan"]); function parseJson(s) { if (s == null) return null; if (typeof s === "object") return s; try { return JSON.parse(s); } catch { return null; } } const planKey = (a) => (Array.isArray(a) ? JSON.stringify(a.map((p) => (p && typeof p === "object" ? [p.ship, p.berth_hour, p.section, p.cranes ?? null] : p))) : `raw:${String(a)}`); const samePlan = (a, b) => planKey(a) === planKey(b); /** Map each plan tool call (in message order) to its index in rollout.steps. */ export function mapCallsToSteps(rollout) { const calls = []; for (const m of rollout.messages || []) for (const tc of m.tool_calls || []) if (PLAN_TOOLS.has(tc.name)) calls.push(tc); const steps = rollout.steps || []; const map = new Map(); let j = 0; for (const tc of calls) { const plan = parsePlanArgs(tc.arguments); let k = -1; for (let i = j; i < steps.length; i++) { if (steps[i].tool === tc.name && (plan == null || !Array.isArray(steps[i].plan) || samePlan(plan, steps[i].plan))) { k = i; break; } } if (k < 0 && j < steps.length && steps[j].tool === tc.name) k = j; if (k >= 0) { map.set(tc.id, k); j = k + 1; } } return map; } function firstLine(s, n = 90) { const line = String(s || "").trim().split("\n")[0] || ""; return line.length > n ? `${line.slice(0, n - 1)}…` : line; } function shipName(task, id) { const s = task.ships.find((x) => x.id === Number(id)); return s ? s.name : `ship ${id}`; } export function checkSummary(task, res) { if (!res) return ""; if (res.grade) return gradeSummary(res.grade); if (res.submitted != null && res.grade == null) { const bits = [`Submitted${res.reward != null ? ` · reward ${fmtNum(res.reward, 2)}` : ""}`]; if (res.feasible != null) bits.push(res.feasible ? "feasible" : "infeasible"); if (res.cost != null) bits.push(`cost ${res.cost}${res.delay_cost != null ? ` = delay ${res.delay_cost} + ${res.moves} moves × 5` : ""}`); return `
${res.feasible != null ? `` : ""}${bits.join(" · ")}
`; } if (res.feasible == null) { if (res.error || res.parse_problems) return `
${escapeHtml(res.error || [].concat(res.parse_problems).join("; "))}
`; return ""; } const parts = []; if (res.feasible) { parts.push(`
Feasible · cost ${res.cost} = delay ${res.delay_cost} + ${res.moves} moves × 5
`); } else { const v = res.violations || []; parts.push(`
Infeasible · ${v.length} violation${v.length === 1 ? "" : "s"}${res.cost != null ? ` · cost ${res.cost}` : ""}
`); if (v.length) parts.push(``); } const late = (res.ships || []).filter((s) => s.cost > 0); if (late.length) { parts.push(`
Cost by ship${late.map((s) => `${escapeHtml(shipName(task, s.ship))} ${[s.delay_h ? `+${s.delay_h} h` : "", s.moved ? "moved" : ""].filter(Boolean).join(", ")} = ${s.cost}`).join("")}
`); } return parts.join(""); } function gradeSummary(g) { const v = g.violations || []; const pp = g.parse_problems || []; let html = `
Submitted · reward ${fmtNum(g.reward, 2)} · ${g.feasible ? "feasible" : "infeasible"} · cost ${fmtNum(g.cost)} (naive ${fmtNum(g.naive_cost)}, optimum ${fmtNum(g.optimal_cost)})
`; if (v.length) html += ``; if (pp.length) html += ``; return html; } /** A plan the model wrote into its message instead of a tool call: {before, plan, after} or null. */ function planInText(text) { const t = String(text || "").replace(/```(?:json)?/g, ""); const i = t.search(/[[{]/); const j = Math.max(t.lastIndexOf("]"), t.lastIndexOf("}")); if (i < 0 || j <= i) return null; let obj; try { obj = JSON.parse(t.slice(i, j + 1)); } catch { return null; } const plan = parsePlanArgs(obj); if (!Array.isArray(plan) || !plan.length || !plan.every((p) => p && typeof p === "object" && "ship" in p)) return null; return { before: t.slice(0, i).trim(), plan, after: t.slice(j + 1).trim() }; } export function planTable(task, plan, prev) { const cr = hasCranes(task); const prevBy = new Map((prev || []).map((p) => [Number(p.ship), p])); let changed = 0; const rows = [...plan] .sort((a, b) => Number(a.ship) - Number(b.ship)) .map((p) => { const s = task.ships.find((x) => x.id === Number(p.ship)); const q = prevBy.get(Number(p.ship)); const c = cr ? (p.cranes ?? (s ? s.std_cranes : null)) : null; let ch = ""; if (prev) { if (!q) ch = "new"; else { const bits = []; if (q.berth_hour !== p.berth_hour) bits.push(`h ${q.berth_hour}→${p.berth_hour}`); if (q.section !== p.section) bits.push(`sec ${q.section}→${p.section}`); const qc = cr ? (q.cranes ?? (s ? s.std_cranes : null)) : null; if (cr && qc !== c) bits.push(`cr ${qc}→${c}`); ch = bits.join(", "); } } if (ch) changed++; const last = s ? Number(p.section) + s.sections - 1 : p.section; return `${escapeHtml(p.ship)}${escapeHtml(s ? s.name : "?")}${escapeHtml(p.berth_hour)}${escapeHtml(p.section)}–${escapeHtml(last)}${cr ? `${escapeHtml(c ?? "–")}` : ""}${escapeHtml(ch)}`; }) .join(""); return { html: `${cr ? '' : ""}${rows}
#ShipDock hSectionsCranes${prev ? "Change" : ""}
`, changed }; } /** * Render into `root`. Returns {select(stepIndex)} to mark the selected step. * onStep(stepIndex) is called when the user clicks a tool call. */ export function renderTranscript(root, rollout, task, { onStep, horizon }) { root.innerHTML = ""; const outputs = new Map(); for (const m of rollout.messages || []) if (m.role === "tool" && m.tool_call_id) outputs.set(m.tool_call_id, m); const stepOf = mapCallsToSteps(rollout); const steps = rollout.steps || []; const callEls = new Map(); const charts = []; let turn = 0; let prevPlan = publishedPlan(task); const used = new Set(); for (const m of rollout.messages || []) { if (m.role === "tool") { if (m.tool_call_id && [...stepOf.keys()].includes(m.tool_call_id)) continue; if (m.tool_call_id && used.has(m.tool_call_id)) continue; const d = document.createElement("div"); d.className = "msg tool"; d.innerHTML = `
Tool · ${escapeHtml(m.name || "")}
${escapeHtml(m.content)}
`; root.appendChild(d); continue; } if (m.role === "system" || m.role === "user") { const d = document.createElement("details"); d.className = `msg ${m.role}`; d.innerHTML = `${m.role === "system" ? "System" : "User"} ${escapeHtml(firstLine(m.content))}
${escapeHtml(m.content)}
`; root.appendChild(d); continue; } turn++; const d = document.createElement("div"); d.className = "msg assistant"; let html = `
Assistant · turn ${turn}
`; if (m.reasoning) html += `
Reasoning ${escapeHtml(firstLine(m.reasoning, 70))}
${escapeHtml(m.reasoning)}
`; const inText = m.content ? planInText(m.content) : null; if (inText) { if (inText.before) html += `
${escapeHtml(inText.before)}
`; const tbl = planTable(task, inText.plan, null); html += `
plan written in the message, not a tool call · ${inText.plan.length} ships
Plan table${tbl.html}
`; if (inText.after) html += `
${escapeHtml(inText.after)}
`; } else if (m.content && String(m.content).trim()) html += `
${escapeHtml(m.content)}
`; d.innerHTML = html; if (inText) { const ch = new BerthChart(d.querySelector(".inline-plan .mini-chart"), { mini: true, onPick: () => {} }); ch.setData(task, evaluatePlan(task, inText.plan), horizon); charts.push(ch); } for (const tc of m.tool_calls || []) { used.add(tc.id); const out = outputs.get(tc.id); const k = stepOf.has(tc.id) ? stepOf.get(tc.id) : null; const step = k != null ? steps[k] : null; const parsed = parsePlanArgs(tc.arguments); const plan = Array.isArray(parsed) ? parsed : step && Array.isArray(step.plan) ? step.plan : null; const unreadable = !plan && (PLAN_TOOLS.has(tc.name)); const call = document.createElement("div"); call.className = `call${k != null ? " pick" : ""}`; if (k != null) call.dataset.step = k; const res = step && step.result ? step.result : parseJson(out && out.content); let head = `${escapeHtml(tc.name)}`; let body = ""; if (plan) { const ev = evaluatePlan(task, plan); const tbl = planTable(task, plan, prevPlan); head += ` ${plan.length} ships${prevPlan ? ` · ${tbl.changed} changed` : ""}`; if (k != null) head += `step ${k + 1}`; body += `
`; body += `
Plan table${tbl.html}
`; call._ev = ev; prevPlan = plan; } else if (unreadable) { head += ` unreadable plan`; if (k != null) head += `step ${k + 1}`; const raw = typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments); body += `
Arguments as sent
${escapeHtml(raw)}
`; } else { const args = parseJson(tc.arguments); if (!(args && typeof args === "object" && !Object.keys(args).length)) body += `
${escapeHtml(args ? JSON.stringify(args, null, 1) : tc.arguments)}
`; } const resHtml = res ? checkSummary(task, res) : ""; if (resHtml) body += `
${resHtml}
`; else if (out) { const txt = String(out.content ?? ""); body += txt.length > 300 || txt.includes("\n") ? `
Output ${escapeHtml(firstLine(txt, 70))}
${escapeHtml(txt)}
` : `
${escapeHtml(txt)}
`; } call.innerHTML = `
${head}
${body}`; d.appendChild(call); if (k != null) { callEls.set(k, call); call.addEventListener("click", (e) => { if (e.target.closest("summary, table, details[open] .plan-tbl")) return; onStep(k); }); } const mc = call.querySelector(".mini-chart"); if (mc && call._ev) { const ch = new BerthChart(mc, { mini: true, onPick: () => k != null && onStep(k) }); ch.setData(task, call._ev, horizon); charts.push(ch); } } root.appendChild(d); } return { select(k) { for (const [i, c] of callEls) c.classList.toggle("sel", i === k); }, scrollTo(k) { const c = callEls.get(k); if (c) c.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, destroy() { for (const c of charts) c.destroy(); }, }; }