vpp-ai-platform/docs/external/diagrams/gen.mjs
Thomas Bayes e9c1f3d9b7
Some checks are pending
ci / typescript (push) Waiting to run
ci / python (push) Waiting to run
ci / evals (push) Blocked by required conditions
docs: add Chinese architecture brief with localized diagrams
Diagram generator takes a locale (en|zh) via a label dictionary; render.sh
now emits img/ and img/zh/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bg6jx9vHNHzB91qyV64GQ7
2026-09-02 13:09:07 -04:00

296 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { writeFileSync } from 'node:fs'
import zh from './zh.mjs'
// usage: node gen.mjs <outdir> [en|zh]
const LOCALE = process.argv[3] || 'en'
const T = (s) => (LOCALE === 'zh' ? (zh[s] ?? s) : s)
// approximate rendered width: CJK glyphs are ~1em, Latin ~0.55em
const tw = (s, size) => [...s].reduce((w, ch) => w + (/[\u3000-\u9fff\uff00-\uffef]/.test(ch) ? size * 1.0 : size * 0.55), 0)
const C = {
ink: '#1B2331', ink2: '#4A5568', ink3: '#7A8494', line: '#B9C2CE',
teal: '#2C6B70', tealSoft: '#E4F0F0', amber: '#B86A14', amberSoft: '#F7ECDD',
slate: '#EEF1F5', red: '#A23B3B', redSoft: '#F6E3E3', white: '#FFFFFF',
}
const FONT = 'font-family="Helvetica Neue,Helvetica,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Arial,sans-serif"'
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
function text(x, y, s, { size = 13, fill = C.ink, anchor = 'middle', weight = 400, mono = false, italic = false } = {}) {
const fam = mono ? 'font-family="Menlo,SFMono-Regular,Consolas,PingFang SC,monospace"' : ''
return `<text x="${x}" y="${y}" ${fam} font-size="${size}" fill="${fill}" text-anchor="${anchor}" font-weight="${weight}"${italic ? ' font-style="italic"' : ''}>${esc(T(s))}</text>`
}
// title + sub lines centered in box
function box(x, y, w, h, title, sub = [], o = {}) {
const { fill = C.white, stroke = C.line, sw = 1.2, dash = '', tcolor = C.ink, tsize = 14, ssize = 12, scolor = C.ink2, r = 5, tag = null, tagColor = C.amber, mono = false } = o
const lines = 1 + sub.length
const lh = 16
const total = tsize + 4 + sub.length * lh
let cy = y + h / 2 - total / 2 + tsize - 2
let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`
s += text(x + w / 2, cy, title, { size: tsize, fill: tcolor, weight: 600, mono })
for (const l of sub) { cy += lh; s += text(x + w / 2, cy, l, { size: ssize, fill: scolor }) }
if (tag) {
const tagw = tw(T(tag), 10) * 1.15 + 14
s += `<rect x="${x + w - tagw - 6}" y="${y - 9}" width="${tagw}" height="18" rx="9" fill="${tagColor}"/>` + text(x + w - tagw / 2 - 6, y + 4, tag, { size: 10, fill: C.white, weight: 700 })
}
return s
}
function group(x, y, w, h, label, o = {}) {
const { stroke = C.line, fill = 'none', color = C.ink3 } = o
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${stroke}" stroke-width="1" stroke-dasharray="5 4"/>` +
text(x + 12, y + 18, LOCALE === 'zh' ? label : label.toUpperCase(), { size: LOCALE === 'zh' ? 11.5 : 10.5, fill: color, anchor: 'start', weight: 700 })
}
function arrow(x1, y1, x2, y2, label = '', o = {}) {
const { dashed = false, both = false, color = C.ink2, lx = 0, ly = -6, path = null, lsize = 11.5, lfill = C.ink2 } = o
const d = path || `M${x1},${y1} L${x2},${y2}`
let s = `<path d="${d}" fill="none" stroke="${color}" stroke-width="1.4"${dashed ? ' stroke-dasharray="5 4"' : ''} marker-end="url(#ah)"${both ? ' marker-start="url(#ahs)"' : ''}/>`
if (label) {
const mx = (x1 + x2) / 2 + lx, my = (y1 + y2) / 2 + ly
const lw = tw(T(label), lsize) + 8
s += `<rect x="${mx - lw / 2}" y="${my - 10}" width="${lw}" height="15" fill="${C.white}" opacity=".92"/>` + text(mx, my + 2, label, { size: lsize, fill: lfill })
}
return s
}
function svg(w, h, body) {
return `<!doctype html><body style="margin:0;background:#fff"><svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ${FONT}>
<defs>
<marker id="ah" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,1 L9,5 L0,9 z" fill="${C.ink2}"/></marker>
<marker id="ahs" markerWidth="10" markerHeight="10" refX="1" refY="5" orient="auto" markerUnits="userSpaceOnUse"><path d="M10,1 L1,5 L10,9 z" fill="${C.ink2}"/></marker>
</defs>
<rect width="${w}" height="${h}" fill="#fff"/>
${body}
</svg></body>`
}
const legend = (x, y, items) => items.map(([fill, stroke, lbl], i) =>
`<rect x="${x + i * 150}" y="${y - 9}" width="14" height="12" rx="2" fill="${fill}" stroke="${stroke}"/>` + text(x + i * 150 + 20, y + 1, lbl, { size: 11, fill: C.ink2, anchor: 'start' })).join('')
const out = {}
// ---------- 1. system context ----------
{
let b = ''
b += group(30, 40, 250, 420, 'External systems')
const ext = [['Trading platform', ['bids · clearing · disclosures']], ['Dispatch / load mgmt', ['regulation requests']], ['Metering / settlement', ['meter data · statements']], ['Weather & market data', ['read-only feeds']]]
ext.forEach(([t, s], i) => { b += box(50, 70 + i * 95, 210, 70, t, s, { fill: C.slate }) })
b += group(330, 40, 600, 490, 'VPP AI Platform')
b += box(360, 70, 540, 90, 'Cognitive plane', ['five LLM agents · agent runtime · workflows', 'minutes to days'], { fill: C.amberSoft, stroke: C.amber, tag: 'LLM' })
b += box(360, 215, 540, 90, 'Safety chain', ['rule check → simulation → envelope / human approval → fresh check → permit', 'deterministic · the only path between planes'], { fill: C.tealSoft, stroke: C.teal, tag: 'NO LLM', tagColor: C.teal })
b += box(360, 360, 540, 80, 'Control plane', ['execution engine · edge terminals · seconds'], { fill: C.tealSoft, stroke: C.teal, tag: 'NO LLM', tagColor: C.teal })
b += box(360, 465, 540, 45, 'Data foundation', [], { fill: C.slate, tsize: 13 })
b += text(630, 500, 'time-series · relational · knowledge/vector · feature store · event log', { size: 11.5, fill: C.ink2 })
b += arrow(630, 160, 630, 215, 'Proposal (digest + lineage)', { lx: 110 })
b += arrow(630, 305, 630, 360, '(Proposal, ExecutionPermit)', { lx: 110 })
b += arrow(280, 115, 360, 115, '', { both: true })
b += arrow(360, 260, 280, 100, '', { path: 'M360,260 L300,260 L300,130 L280,130' })
b += text(292, 253, 'bid + permit', { size: 10.5, fill: C.ink2, anchor: 'end' })
b += arrow(900, 400, 980, 400, '')
b += arrow(980, 125, 900, 125, '', { both: true })
b += box(980, 90, 190, 70, 'Operators', ['Case Desk · approvals', 'scenario forks · AI cards'], { fill: C.white })
b += box(980, 360, 190, 80, 'Flexible resources', ['PV · storage · EV charging', 'HVAC · industrial · microgrids'], { fill: C.white })
b += arrow(630, 440, 630, 465)
b += arrow(360, 400, 360, 140, '', { path: 'M360,400 L345,400 L345,140 L360,140', dashed: true })
b += `<text transform="translate(340,330) rotate(-90)" font-size="10.5" fill="${C.ink2}" text-anchor="middle">${T('execution feedback')}</text>`
b += text(1075, 470, '31 MW today → ≥ 80 MW target', { size: 11, fill: C.ink3 })
out['01-system-context'] = svg(1200, 550, b)
}
// ---------- 2. agent runtime ----------
{
let b = ''
b += group(30, 40, 250, 330, 'Task triggers')
b += box(50, 70, 210, 70, 'Scheduled', ['day-ahead 06:00 · bid window · D+1 review'], { fill: C.tealSoft, stroke: C.teal })
b += box(50, 165, 210, 70, 'Event', ['deviation · resource offline · clearing'], { fill: C.tealSoft, stroke: C.teal })
b += box(50, 260, 210, 70, 'Manual', ['operator question or task'], { fill: C.slate })
b += box(330, 260, 190, 70, 'Router agent', ['intent → template id + params', 'schema-constrained enum'], { fill: C.amberSoft, stroke: C.amber, tag: 'LLM' })
b += arrow(260, 295, 330, 295)
b += box(580, 120, 240, 130, 'Workflow engine', ['deterministic step chains', 'durable · suspend/resume', 'one template per business process'], { fill: C.tealSoft, stroke: C.teal, tag: 'NO LLM', tagColor: C.teal })
b += arrow(260, 105, 580, 160, 'no LLM in the path', { path: 'M260,105 L420,105 L420,160 L580,160', lx: 110, ly: -37 })
b += arrow(260, 200, 580, 200, 'rule-matched', { lx: -20 })
b += arrow(520, 295, 580, 240, '', { path: 'M520,295 L560,295 L560,240 L580,240' })
b += box(880, 70, 260, 80, 'Agent step', ['one of five agents, invoked at a step', 'tools whitelisted per agent'], { fill: C.amberSoft, stroke: C.amber, tag: 'LLM' })
b += arrow(820, 150, 880, 110, '', { path: 'M820,150 L850,150 L850,110 L880,110' })
b += box(880, 190, 260, 80, 'Tool registry', ['typed, versioned skill contracts', 'every call snapshotted into lineage'], { fill: C.tealSoft, stroke: C.teal })
b += arrow(1010, 150, 1010, 190)
b += box(880, 310, 260, 60, 'Python skill services', ['forecast · MILP · simulation'], { fill: C.slate })
b += arrow(1010, 270, 1010, 310, 'HTTP / JSON', { lx: 60 })
b += box(580, 300, 240, 70, 'Proposal → safety chain', ['numbers dereferenced from lineage'], { fill: C.white, stroke: C.ink2 })
b += arrow(700, 250, 700, 300)
b += box(330, 400, 810, 46, 'Event log (event-sourced): triggers, steps, tool calls, transitions, approvals, permits, receipts', [], { fill: C.slate, tsize: 12.5, dash: '' })
b += arrow(700, 370, 700, 400, '', { dashed: true })
b += legend(30, 445, [[C.amberSoft, C.amber, 'LLM involved'], [C.tealSoft, C.teal, 'Deterministic']])
b += text(30, 480, 'Workflows are the entry point, not agents. The LLM decides what to do (pick a template); the engine decides how, step by step.', { size: 12, fill: C.ink2, anchor: 'start', italic: true })
out['02-agent-runtime'] = svg(1200, 500, b)
}
// ---------- 3. context & memory ----------
{
let b = ''
b += group(30, 40, 1140, 275, 'Context assembly for one agent step · deterministic baseline + progressive disclosure')
const src = [['Position ledger', 'versioned read'], ['Time-series / snapshots', 'immutable, checksummed'], ['Semantic memory', 'lessons declared by template'], ['Knowledge base (RAG)', 'effective rules only']]
src.forEach(([t, s], i) => { b += box(50 + i * 180, 70, 165, 60, t, [s], { fill: C.slate, tsize: 12.5, ssize: 11 }) })
b += box(50, 170, 705, 55, 'fetchContext step (deterministic, reproducible)', ['compact baseline: summaries, identifiers, the few figures a step needs'], { fill: C.tealSoft, stroke: C.teal, tsize: 13 })
b += box(50, 240, 705, 55, 'Agentic retrieval tools · read-only · whitelisted · every call logged into lineage', ['rule retrieval · ledger read · snapshot fetch · forecast lookup — detail disclosed on demand, retrieved content tagged as data'], { fill: C.white, stroke: C.teal, tsize: 12.5, ssize: 11 })
b += arrow(755, 267, 790, 267, '', { both: true })
for (let i = 0; i < 4; i++) b += arrow(132 + i * 180, 130, 132 + i * 180, 170)
b += box(790, 70, 170, 225, 'Agent step', ['prose + tool calls', 'numbers only as', '{toolCallId, path}', 'references'], { fill: C.amberSoft, stroke: C.amber, tag: 'LLM' })
b += arrow(755, 197, 790, 197)
b += box(995, 70, 160, 225, 'Assembler', ['dereferences refs', 'from recorded lineage', '→ Proposal', 'LLM text cannot', 'carry a number'], { fill: C.tealSoft, stroke: C.teal })
b += arrow(960, 147, 995, 147)
b += group(30, 345, 1140, 165, 'Three memory layers')
b += box(50, 380, 355, 105, 'Working memory', ['current task context, intermediate results', 'runtime memory · task lifetime', 'step-to-step parameters preferred over memory'], { fill: C.white })
b += box(423, 380, 355, 105, 'Episodic memory', ['operating states, trading, control, responses', 'event log + time-series · permanent (audit)', 'queried through fetchContext, not recalled'], { fill: C.white })
b += box(796, 380, 355, 105, 'Semantic memory', ['structured lessons from D+1 review', 'knowledge + strategy store · versioned', 'written by ReviewFinding, injected explicitly'], { fill: C.white })
b += text(30, 540, 'Authoritative facts live in the domain database, never in agent memory (invariant I8). No automatic memory recall feeds production decisions.', { size: 12, fill: C.ink2, anchor: 'start', italic: true })
out['03-context-memory'] = svg(1200, 560, b)
}
// ---------- 4. safety chain state machine ----------
{
let b = ''
const Y = 120, H = 56, W = 118, G = 32
const stages = [
['DRAFT', 'agent output'], ['RULE_CHECK', 'policy pack'], ['SIMULATION', 'backtest / balance'], ['ENVELOPE_CHECK', 'within bounds?'],
['APPROVED', 'auto or human'], ['FRESH_CHECK', 're-verify state'], ['AUTHORIZED', 'permit issued'], ['RELEASED', 'gateway accepts'],
]
const xs = stages.map((_, i) => 30 + i * (W + G))
stages.forEach(([t, s], i) => {
const hi = i >= 1 && i <= 6
b += box(xs[i], Y, W, H, t, [s], { fill: hi ? C.tealSoft : C.white, stroke: hi ? C.teal : C.line, tsize: 12, ssize: 10.5, mono: true })
if (i < stages.length - 1) b += arrow(xs[i] + W, Y + H / 2, xs[i + 1], Y + H / 2)
})
b += group(xs[1] - 12, 52, xs[6] + W + 12 - xs[1] + 12, 148, 'Safety chain · deterministic · shared by every proposal type', { stroke: C.teal, color: C.teal })
// human branch
b += box(xs[3] + 20, 250, 200, 56, 'PENDING_HUMAN', ['suspended run · Case Desk inbox'], { fill: C.amberSoft, stroke: C.amber, tsize: 12, ssize: 10.5, mono: true })
b += arrow(xs[3] + W / 2, Y + H, xs[3] + 60, 250, 'outside envelope', { path: `M${xs[3] + W / 2},${Y + H} L${xs[3] + W / 2},250`, lx: -70, ly: 8 })
b += arrow(xs[2] + W / 2, Y + H, xs[3] + 20, 278, 'sim warning', { path: `M${xs[2] + W / 2},${Y + H} L${xs[2] + W / 2},278 L${xs[3] + 20},278`, lx: -45, ly: 40 })
b += arrow(xs[3] + 220, 278, xs[4] + W / 2, Y + H, 'human resume', { path: `M${xs[3] + 220},278 L${xs[4] + W / 2},278 L${xs[4] + W / 2},${Y + H}`, lx: 40, ly: 40 })
b += text(xs[4] + W / 2 + 16, 300, 'approver ≠ originator (I1)', { size: 10.5, fill: C.ink3, anchor: 'start' })
b += arrow(xs[3] + W / 2 + 30, Y, xs[4] + W / 2 - 30, Y, 'within envelope → AUTO_APPROVED', { path: `M${xs[3] + W - 10},${Y} L${xs[3] + W - 10},${Y - 24} L${xs[4] + 10},${Y - 24} L${xs[4] + 10},${Y}`, ly: -26, lx: 0 })
// rejected
b += box(xs[1], 350, W, 44, 'REJECTED', [], { fill: C.redSoft, stroke: C.red, tsize: 12, mono: true })
b += arrow(xs[1] + W / 2, Y + H, xs[1] + W / 2, 350, 'rule failure', { lx: 44 })
b += arrow(xs[3] + 60, 306, xs[1] + W, 372, 'human rejects', { path: `M${xs[3] + 60},306 L${xs[3] + 60},372 L${xs[1] + W},372`, lx: -30, ly: 45 })
// stale
b += box(xs[5], 350, W, 44, 'STALE', [], { fill: C.redSoft, stroke: C.red, tsize: 12, mono: true })
b += arrow(xs[5] + W / 2, Y + H, xs[5] + W / 2, 350, 'evidence changed', { lx: 60 })
b += arrow(xs[5] + W / 2, 350, xs[1] + W - 15, Y + H, 're-enter chain', { path: `M${xs[5] + W / 2 - 30},350 L${xs[5] + W / 2 - 30},330 L${xs[1] + W - 15},330 L${xs[1] + W - 15},${Y + H + 2}`, dashed: true, lx: -172, ly: 59 })
// after release
b += text(xs[7] + W / 2, 210, 'EXECUTING → COMPLETED', { size: 11, fill: C.ink2, mono: true })
b += text(xs[7] + W / 2, 226, 'or ROLLED_BACK', { size: 11, fill: C.ink2, mono: true })
b += text(xs[7] + W / 2, 244, '→ ExecutionReport → review', { size: 10.5, fill: C.ink3 })
// notes
b += text(30, 430, 'Approval binds to the proposal digest and a validity window (I3). Gateways accept only (Proposal, ExecutionPermit): short-lived, revocable, digest-matched (I4).', { size: 12, fill: C.ink2, anchor: 'start' })
b += text(30, 452, 'Simulation warnings always escalate to a human. No code path lets the AI reach APPROVED. Envelopes start empty and widen only on shadow-run evidence.', { size: 12, fill: C.ink2, anchor: 'start' })
out['04-safety-chain'] = svg(1290, 475, b)
}
// ---------- 5. tech stack ----------
{
let b = ''
const row = (y, h, label, items, o = {}) => {
b += text(30, y + h / 2 + 4, label, { size: 11, fill: C.ink3, anchor: 'start', weight: 700 })
const x0 = 190, W = 980, n = items.length, g = 12, w = (W - g * (n - 1)) / n
items.forEach(([t, s], i) => { b += box(x0 + i * (w + g), y, w, h, t, s, { fill: o.fill || C.white, stroke: o.stroke || C.line, tsize: 13, ssize: 11, tag: o.tag && i === 0 ? o.tag : null, tagColor: o.tagColor }) })
}
row(40, 64, 'OPERATOR UI', [['Case Desk', ['approval inbox · case view · lineage expansion']], ['AI insight cards', ['read-only projections in existing business pages']]])
row(120, 80, 'RUNTIME', [['TypeScript + Mastra', ['workflows (durable suspend/resume)', 'agents as controlled steps · tool registry']], ['Trigger service', ['cron · event bus consumers', 'router agent for manual requests']], ['LLM port', ['capability interface + model router', 'LLM-down mode for degraded runs']]], { fill: C.amberSoft, stroke: C.amber })
row(226, 80, 'SERVICES', [['Policy engine', ['versioned, tested policy packs']], ['Envelope + authority', ['envelope match · fresh check · permits']], ['Ledger + lineage', ['position cascade · snapshots · assembler']], ['Gateways + Case Desk', ['file export · simulation · cases']]], { fill: C.tealSoft, stroke: C.teal, tag: 'NO LLM', tagColor: C.teal })
row(326, 64, 'CONTRACTS', [['Domain schemas (zod)', ['single source of truth']], ['JSON Schema', ['exported, committed, versioned']], ['Golden fixtures', ['validated by both CIs']]])
row(406, 72, 'SKILLS', [['Python services (FastAPI)', ['load / PV / price forecasts with quantiles', 'bid MILP · dispatch MILP · potential · report']], ['Generated pydantic models', ['never hand-edited']]], { fill: C.slate })
row(494, 72, 'DATA', [['PostgreSQL + pgvector', ['relational · knowledge · outbox events']], ['TimescaleDB / IoTDB', ['telemetry curves']], ['Object storage', ['snapshots · archives']], ['Kafka (phase 2)', ['outbox relay, same interface']]])
row(582, 72, 'LLM BACKENDS', [['Commercial API', ['development']], ['Local open model', ['pre-production']], ['GuangMing Power LLM', ['production, on-prem adapter']]], { fill: C.amberSoft, stroke: C.amber })
b += text(30, 688, 'Backend swaps are gated by the L1 evaluation set and an L4 shadow comparison, not judgment. About a third of the platform is framework; the differentiating parts are the deterministic services.', { size: 12, fill: C.ink2, anchor: 'start', italic: true })
out['05-tech-stack'] = svg(1200, 706, b)
}
// ---------- 6. interfaces & contracts ----------
{
let b = ''
b += group(30, 40, 560, 260, 'Contract pipeline (one source of truth)')
b += box(55, 75, 220, 60, 'zod schemas', ['packages/domain · hand-written'], { fill: C.tealSoft, stroke: C.teal, mono: true, tsize: 13 })
b += box(55, 165, 220, 60, 'JSON Schema', ['contracts/ · committed · versioned'], { fill: C.slate, mono: true, tsize: 13 })
b += arrow(165, 135, 165, 165, 'build export', { lx: 60 })
b += box(340, 165, 225, 60, 'pydantic models', ['generated · never hand-edited'], { fill: C.slate, mono: true, tsize: 13 })
b += arrow(275, 195, 340, 195, 'codegen', { ly: -8 })
b += box(340, 75, 225, 60, 'Golden fixtures', ['valid + invalid samples per object'], { fill: C.white })
b += arrow(275, 105, 340, 105, '', {})
b += arrow(452, 135, 452, 165, '', { dashed: true })
b += box(55, 245, 510, 40, 'TS CI and Python CI both validate every fixture; any disagreement fails the build', [], { fill: C.white, stroke: C.ink2, tsize: 12 })
b += arrow(165, 225, 165, 245, '', { dashed: true })
b += arrow(452, 225, 452, 245, '', { dashed: true })
b += group(620, 40, 550, 260, 'Ports = trust boundaries (deterministic, not exposed to the LLM)')
const ports = [
['ProposalSubmissionPort', 'the only way into the safety chain'],
['PolicyEnginePort · SimulationPort', 'rule check and simulation'],
['EnvelopePort · AuthorityPort', 'envelope match · fresh check · permits · revoke'],
['GatewayPort', 'accepts (Proposal, ExecutionPermit) only'],
['LedgerPort · EvidencePort', 'versioned ledger · event-sourced evidence'],
['CaseDeskPort', 'open · read · apply command · watch'],
]
ports.forEach(([p, d], i) => {
const y = 78 + i * 36
b += text(640, y + 12, p, { size: 12, fill: C.teal, anchor: 'start', mono: true, weight: 600 })
b += text(1150, y + 12, d, { size: 11.5, fill: C.ink2, anchor: 'end' })
if (i < ports.length - 1) b += `<line x1="640" y1="${y + 24}" x2="1150" y2="${y + 24}" stroke="${C.slate}"/>`
})
b += group(30, 330, 1140, 150, 'Cross-process interfaces and data representation rules')
const ifs = [['TS runtime → Python skills', 'HTTP/JSON, schema-validated both ends'], ['Case Desk / front end → runtime', 'HTTP/JSON approvals and case API'], ['External systems', 'anti-corruption adapters, raw + translated logged'], ['Edge control link', 'grid protocol stack, outside the JSON domain']]
ifs.forEach(([t, s], i) => { b += box(50 + i * 280, 360, 265, 56, t, [s], { fill: C.white, tsize: 12.5, ssize: 11 }) })
b += text(50, 448, 'Money, energy and prices as fixed-point decimal strings · units in field names (power_mw, price_yuan_per_mwh) · ISO 8601 UTC · intervals as {date, interval_index}', { size: 11.5, fill: C.ink2, anchor: 'start' })
b += text(50, 466, 'all IDs strings · enums UPPER_SNAKE · breaking schema changes bump the major version and ship a migration note', { size: 11.5, fill: C.ink2, anchor: 'start' })
out['06-contracts'] = svg(1200, 500, b)
}
// ---------- 7. eval-driven development ----------
{
let b = ''
b += group(30, 40, 470, 345, 'Four evaluation layers')
const layers = [
['L4', 'End-to-end decision quality', 'shadow run · historical backtest · counterfactual attribution'],
['L3', 'Safety chain correctness', 'policy pack tests · invariant tests I1–I8 · red-team cases'],
['L2', 'Professional-model skills', 'MAPE · interval coverage · solver quality vs. bounds'],
['L1', 'LLM tasks', 'routing accuracy · schema compliance · numeric consistency'],
]
layers.forEach(([l, t, s], i) => {
const y = 70 + i * 72
b += `<rect x="50" y="${y}" width="430" height="60" rx="5" fill="${i === 0 ? C.tealSoft : C.white}" stroke="${i === 0 ? C.teal : C.line}"/>`
b += text(72, y + 36, l, { size: 16, fill: C.teal, weight: 700, mono: true })
b += text(105, y + 26, t, { size: 13, fill: C.ink, anchor: 'start', weight: 600 })
b += text(105, y + 45, s, { size: 11, fill: C.ink2, anchor: 'start' })
})
b += text(50, 370, 'A problem at L4 is localised to the layer that caused it.', { size: 11.5, fill: C.ink3, anchor: 'start', italic: true })
b += group(530, 40, 640, 345, 'Change gates: what must pass before a change ships')
const gates = [
['Prompt / agent instructions', 'L1 task set + L4 fixed-day backtest, no regression'],
['LLM backend switch', 'full L1 set + L4 shadow comparison vs. baseline'],
['Skill model upgrade', 'all L2 metrics for that skill + downstream L4 backtest'],
['Policy pack upgrade', 'L3 pack tests green + impact analysis on pending proposals'],
['Contract schema change', 'golden fixtures on both sides + compatibility check'],
['Envelope widening', 'L4 shadow / online evidence only — no data, no widening'],
]
gates.forEach(([c, g], i) => {
const y = 78 + i * 46
b += text(550, y + 14, c, { size: 12.5, fill: C.ink, anchor: 'start', weight: 600 })
b += text(550, y + 31, g, { size: 11.5, fill: i === 5 ? C.amber : C.ink2, anchor: 'start' })
if (i < gates.length - 1) b += `<line x1="550" y1="${y + 40}" x2="1150" y2="${y + 40}" stroke="${C.slate}"/>`
})
// loop
b += group(30, 410, 1140, 130, 'The audit trail is the evaluation dataset')
const loop = [['Production run', ['event log + immutable snapshots']], ['Replay datasets', ['representative days, frozen']], ['Eval run (archived)', ['committed baseline, CI --check']], ['ReviewFinding', ['D+1 attribution']], ['New eval cases', ['every finding → candidate case']]]
loop.forEach(([t, s], i) => {
b += box(50 + i * 228, 445, 200, 60, t, s, { fill: i === 2 ? C.tealSoft : C.white, stroke: i === 2 ? C.teal : C.line, tsize: 12.5, ssize: 11 })
if (i < loop.length - 1) b += arrow(250 + i * 228, 475, 278 + i * 228, 475)
})
b += arrow(1062, 505, 150, 505, '', { path: 'M1062,505 L1062,525 L150,525 L150,506', dashed: true })
out['07-eval-driven'] = svg(1200, 560, b)
}
for (const [name, html] of Object.entries(out)) writeFileSync(`${process.argv[2]}/${name}.html`, html)
console.log(Object.keys(out).join('\n'))