@qvac/harness 1.0.0-rc.2 → 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/bench/README.md +94 -0
- package/bench/bfcl/checker.ts +313 -0
- package/bench/bfcl/data.ts +93 -0
- package/bench/bfcl/observe.ts +93 -0
- package/bench/bfcl/run.ts +213 -0
- package/bench/bfcl/tools.ts +72 -0
- package/bench/bridge/bridge.ts +392 -0
- package/bench/bridge/openai.ts +150 -0
- package/bench/bridge/python/qvac_bench/__init__.py +3 -0
- package/bench/bridge/python/qvac_bench/airline.py +42 -0
- package/bench/bridge/python/qvac_bench/approval.py +26 -0
- package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
- package/bench/bridge/python/qvac_bench/client.py +88 -0
- package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
- package/bench/bridge/python/qvac_bench/session.py +107 -0
- package/bench/bridge/python/qvac_bench/speed.py +30 -0
- package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
- package/bench/bridge/python/qvac_bench/tools.py +11 -0
- package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
- package/bench/bridge/python/run_tau2.py +150 -0
- package/bench/bridge/python/smoke.py +47 -0
- package/bench/bridge/python/test_airline.py +28 -0
- package/bench/bridge/python/test_approval.py +57 -0
- package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
- package/bench/bridge/python/test_harbor_agent.py +107 -0
- package/bench/bridge/serve.ts +52 -0
- package/bench/bridge/transcript.ts +52 -0
- package/bench/bun-tsconfig.json +15 -0
- package/bench/cpu-main.mjs +216 -0
- package/bench/cpu.ts +457 -0
- package/bench/evidence/2026-09-28-review15.md +225 -0
- package/bench/evidence/redact-bun1.3.14.json +92 -0
- package/bench/evidence/redact-node24.11.json +92 -0
- package/bench/graph-metrics.ts +43 -0
- package/bench/harness.ts +73 -0
- package/bench/issue129-matrix.mjs +722 -0
- package/bench/redact-adversarial.ts +256 -0
- package/bench/repeat-cpu.mjs +281 -0
- package/bench/report.ts +1075 -0
- package/bench/skill-eval/harness.ts +30 -0
- package/bench/skill-eval/laws.ts +37 -0
- package/bench/skill-eval/mermaid/client.ts +132 -0
- package/bench/skill-eval/mermaid/validate.mjs +115 -0
- package/bench/skill-eval/pack.ts +61 -0
- package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
- package/bench/skill-eval/packs/diagrams/index.ts +50 -0
- package/bench/skill-eval/packs/diagrams/score.ts +74 -0
- package/bench/skill-eval/packs/index.ts +5 -0
- package/bench/skill-eval/packs/weather/cases.ts +279 -0
- package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
- package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
- package/bench/skill-eval/packs/weather/index.ts +55 -0
- package/bench/skill-eval/packs/weather/score.ts +92 -0
- package/bench/skill-eval/report.ts +194 -0
- package/bench/skill-eval/run.ts +316 -0
- package/bench/speed.ts +80 -0
- package/package.json +4 -13
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import process from 'process'
|
|
2
|
+
import { Harness } from '../../index.ts'
|
|
3
|
+
import type { ToolDefinition } from '../../lib/contract/index.ts'
|
|
4
|
+
import { createSkillTool } from '../../lib/tools/skill.ts'
|
|
5
|
+
import { configureSdkStorage } from '../../lib/sdk-storage.ts'
|
|
6
|
+
import { MODEL_CACHE_PATH_ENV, resolveStorage } from '../../lib/storage.ts'
|
|
7
|
+
|
|
8
|
+
export interface EvalHarnessOptions {
|
|
9
|
+
readonly root: string
|
|
10
|
+
readonly skillsDir: string
|
|
11
|
+
readonly tools: ReadonlyArray<ToolDefinition>
|
|
12
|
+
readonly ctxSize: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// storage first: the skill tool captures the overlay dir from QVAC_CONFIG_PATH when it is built
|
|
16
|
+
export async function openEvalHarness(options: EvalHarnessOptions): Promise<Harness> {
|
|
17
|
+
const storage = resolveStorage('dev', {
|
|
18
|
+
root: options.root,
|
|
19
|
+
models: process.env[MODEL_CACHE_PATH_ENV]
|
|
20
|
+
})
|
|
21
|
+
configureSdkStorage(storage.sdk)
|
|
22
|
+
|
|
23
|
+
const { skillsDir } = options
|
|
24
|
+
const tools = [...options.tools, createSkillTool({ skillsDir })]
|
|
25
|
+
const harness = new Harness({ tools, skillsDir, contextSize: options.ctxSize })
|
|
26
|
+
await harness.ready()
|
|
27
|
+
await harness.clearKvCache()
|
|
28
|
+
|
|
29
|
+
return harness
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { check, type Laws } from 'asenion'
|
|
2
|
+
|
|
3
|
+
// asenion laws compare on whole words; the regex lists cover what a word law cannot say
|
|
4
|
+
export interface ReplyLaws {
|
|
5
|
+
readonly laws?: Laws | undefined
|
|
6
|
+
readonly all?: ReadonlyArray<RegExp> | undefined
|
|
7
|
+
readonly none?: ReadonlyArray<RegExp> | undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function replyFailures(reply: string, laws: ReplyLaws | undefined): string[] {
|
|
11
|
+
if (reply.trim() === '') {
|
|
12
|
+
return ['reply: empty']
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const failures: string[] = []
|
|
16
|
+
if (laws?.laws) {
|
|
17
|
+
for (const failure of check(reply, laws.laws).failures) {
|
|
18
|
+
failures.push(
|
|
19
|
+
`${failure.rule}${failure.term === undefined ? '' : ` "${failure.term}"`}: ${failure.detail}`
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const pattern of laws?.all ?? []) {
|
|
25
|
+
if (!pattern.test(reply)) {
|
|
26
|
+
failures.push(`missing: ${pattern.source}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const pattern of laws?.none ?? []) {
|
|
31
|
+
if (pattern.test(reply)) {
|
|
32
|
+
failures.push(`forbidden: ${pattern.source}`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return failures
|
|
37
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import { fileURLToPath } from 'url'
|
|
3
|
+
|
|
4
|
+
const SIDECAR = fileURLToPath(new URL('./validate.mjs', import.meta.url))
|
|
5
|
+
|
|
6
|
+
// mermaid needs a DOM, so the parse runs under node even when the sweep runs under Bare
|
|
7
|
+
const NODE = 'node'
|
|
8
|
+
|
|
9
|
+
// a parse takes milliseconds: a request this old means the sidecar is gone or wedged
|
|
10
|
+
const PARSE_TIMEOUT_MS = 30_000
|
|
11
|
+
|
|
12
|
+
export interface Verdict {
|
|
13
|
+
readonly ok: boolean
|
|
14
|
+
readonly message?: string | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MermaidOracle {
|
|
18
|
+
// the version the sidecar loaded, so a sweep's summary records what judged it
|
|
19
|
+
readonly version: Promise<string>
|
|
20
|
+
parse(code: string): Promise<Verdict>
|
|
21
|
+
close(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Waiting {
|
|
25
|
+
resolve(verdict: Verdict): void
|
|
26
|
+
reject(reason: Error): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SidecarMessage {
|
|
30
|
+
ready?: string
|
|
31
|
+
id?: number
|
|
32
|
+
ok?: boolean
|
|
33
|
+
message?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// one sidecar per sweep: mermaid takes ~1s to load; a dead one fails every request loudly
|
|
37
|
+
export function openMermaidOracle(): MermaidOracle {
|
|
38
|
+
const child = spawn(NODE, [SIDECAR], { stdio: ['pipe', 'pipe', 'inherit'] })
|
|
39
|
+
const waiting = new Map<number, Waiting>()
|
|
40
|
+
let ready: (version: string) => void
|
|
41
|
+
let notReady: (reason: Error) => void
|
|
42
|
+
const version = new Promise<string>((resolve, reject) => {
|
|
43
|
+
ready = resolve
|
|
44
|
+
notReady = reject
|
|
45
|
+
})
|
|
46
|
+
version.catch(noop)
|
|
47
|
+
let failure: Error | null = null
|
|
48
|
+
let closed = false
|
|
49
|
+
let next = 0
|
|
50
|
+
let pending = ''
|
|
51
|
+
|
|
52
|
+
child.stdout.setEncoding('utf8')
|
|
53
|
+
child.stdout.on('data', (chunk: string) => {
|
|
54
|
+
pending += chunk
|
|
55
|
+
for (;;) {
|
|
56
|
+
const newline = pending.indexOf('\n')
|
|
57
|
+
if (newline === -1) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const line = pending.slice(0, newline)
|
|
62
|
+
pending = pending.slice(newline + 1)
|
|
63
|
+
if (line.trim()) {
|
|
64
|
+
receive(JSON.parse(line))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
child.on('error', (err: Error) => fail(`mermaid oracle could not start node: ${err.message}`))
|
|
69
|
+
child.on('exit', (code: number | null, signal: string | null) => {
|
|
70
|
+
if (!closed) {
|
|
71
|
+
fail(`mermaid oracle exited (${code ?? signal})`)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
function fail(why: string): void {
|
|
76
|
+
failure = new Error(why)
|
|
77
|
+
notReady(failure)
|
|
78
|
+
for (const entry of waiting.values()) {
|
|
79
|
+
entry.reject(failure)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
waiting.clear()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function receive(message: SidecarMessage): void {
|
|
86
|
+
if (message.ready !== undefined) {
|
|
87
|
+
return ready(message.ready)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (message.id === undefined) return
|
|
91
|
+
const entry = waiting.get(message.id)
|
|
92
|
+
waiting.delete(message.id)
|
|
93
|
+
entry?.resolve({ ok: message.ok === true, message: message.message })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
version,
|
|
98
|
+
parse(code) {
|
|
99
|
+
if (failure) {
|
|
100
|
+
return Promise.reject(failure)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const id = next++
|
|
104
|
+
|
|
105
|
+
return new Promise<Verdict>((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
waiting.delete(id)
|
|
108
|
+
reject(new Error(`mermaid oracle did not answer request ${id} in ${PARSE_TIMEOUT_MS}ms`))
|
|
109
|
+
}, PARSE_TIMEOUT_MS)
|
|
110
|
+
waiting.set(id, {
|
|
111
|
+
resolve: (verdict) => {
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
resolve(verdict)
|
|
114
|
+
},
|
|
115
|
+
reject: (reason) => {
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
reject(reason)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
child.stdin.write(`${JSON.stringify({ id, code })}\n`)
|
|
121
|
+
})
|
|
122
|
+
},
|
|
123
|
+
close() {
|
|
124
|
+
closed = true
|
|
125
|
+
failure = new Error('mermaid oracle closed')
|
|
126
|
+
child.stdin.end()
|
|
127
|
+
child.kill()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function noop(): void {}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// the app's diagram acceptance test, out of process: mermaid needs a DOM and the eval runs on Bare
|
|
2
|
+
import { createRequire } from 'module'
|
|
3
|
+
import { JSDOM } from 'jsdom'
|
|
4
|
+
|
|
5
|
+
const MERMAID_CONFIG = {
|
|
6
|
+
startOnLoad: false,
|
|
7
|
+
securityLevel: 'strict',
|
|
8
|
+
suppressErrorRendering: true,
|
|
9
|
+
htmlLabels: false,
|
|
10
|
+
maxTextSize: 50000,
|
|
11
|
+
maxEdges: 500,
|
|
12
|
+
theme: 'base',
|
|
13
|
+
flowchart: { htmlLabels: false }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DIRECTIVE_OPEN = '%%{'
|
|
17
|
+
const DIRECTIVE_CLOSE = '}%%'
|
|
18
|
+
|
|
19
|
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { pretendToBeVisual: true })
|
|
20
|
+
for (const key of ['window', 'document', 'Element', 'SVGElement', 'Node', 'HTMLElement']) {
|
|
21
|
+
const value = key === 'window' ? dom.window : dom.window[key]
|
|
22
|
+
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
|
|
26
|
+
|
|
27
|
+
const mermaid = (await import('mermaid')).default
|
|
28
|
+
mermaid.initialize(MERMAID_CONFIG)
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url)
|
|
31
|
+
send({ ready: require('mermaid/package.json').version })
|
|
32
|
+
|
|
33
|
+
let pending = ''
|
|
34
|
+
process.stdin.setEncoding('utf8')
|
|
35
|
+
process.stdin.on('data', (chunk) => {
|
|
36
|
+
pending += chunk
|
|
37
|
+
for (;;) {
|
|
38
|
+
const newline = pending.indexOf('\n')
|
|
39
|
+
if (newline === -1) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const line = pending.slice(0, newline)
|
|
44
|
+
pending = pending.slice(newline + 1)
|
|
45
|
+
if (line.trim()) {
|
|
46
|
+
void answer(JSON.parse(line))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
async function answer(request) {
|
|
52
|
+
send({ id: request.id, ...(await verdictFor(request.code)) })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function verdictFor(code) {
|
|
56
|
+
try {
|
|
57
|
+
await mermaid.parse(sanitize(code))
|
|
58
|
+
return { ok: true }
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// the first line is the diagnosis; mermaid echoes the whole block back after it
|
|
61
|
+
return { ok: false, message: firstLine(error) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function firstLine(error) {
|
|
66
|
+
return String(error?.message ?? error)
|
|
67
|
+
.split('\n')[0]
|
|
68
|
+
.slice(0, 200)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function send(message) {
|
|
72
|
+
process.stdout.write(`${JSON.stringify(message)}\n`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sanitize(code) {
|
|
76
|
+
return stripFrontmatterConfig(stripDirectives(code))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stripDirectives(code) {
|
|
80
|
+
let out = ''
|
|
81
|
+
let cursor = 0
|
|
82
|
+
for (;;) {
|
|
83
|
+
const start = code.indexOf(DIRECTIVE_OPEN, cursor)
|
|
84
|
+
if (start === -1) {
|
|
85
|
+
return out + code.slice(cursor)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const end = code.indexOf(DIRECTIVE_CLOSE, start + DIRECTIVE_OPEN.length)
|
|
89
|
+
if (end === -1) {
|
|
90
|
+
return out + code.slice(cursor)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
out += code.slice(cursor, start)
|
|
94
|
+
cursor = end + DIRECTIVE_CLOSE.length
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stripFrontmatterConfig(code) {
|
|
99
|
+
const lines = code.split(/\r?\n/)
|
|
100
|
+
const open = lines.findIndex((line) => line.trim() !== '')
|
|
101
|
+
if (open === -1 || lines[open] !== '---') {
|
|
102
|
+
return code
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const close = lines.indexOf('---', open + 1)
|
|
106
|
+
if (close === -1) {
|
|
107
|
+
return code
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hasConfig = lines
|
|
111
|
+
.slice(open + 1, close)
|
|
112
|
+
.some((line) => /^config\s*:/.test(line.trimStart()))
|
|
113
|
+
|
|
114
|
+
return hasConfig ? lines.slice(close + 1).join('\n') : code
|
|
115
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Message, ToolDefinition } from '../../lib/contract/index.ts'
|
|
2
|
+
import type { FetchFn } from '../../lib/tools/http-request.ts'
|
|
3
|
+
|
|
4
|
+
export interface EvalCase<Expect> {
|
|
5
|
+
readonly id: string
|
|
6
|
+
readonly category: string
|
|
7
|
+
readonly messages: ReadonlyArray<Message>
|
|
8
|
+
readonly expect: Expect
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ToolCall {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly args: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// what one run produced, before any pack-specific reading of it
|
|
17
|
+
export interface Observed {
|
|
18
|
+
readonly calls: ReadonlyArray<ToolCall>
|
|
19
|
+
readonly reply: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Judgement {
|
|
23
|
+
readonly failures: ReadonlyArray<string>
|
|
24
|
+
// the fake world could not answer a correct call: the row says nothing about the model
|
|
25
|
+
readonly gap: boolean
|
|
26
|
+
// every diagram in the reply parsed; null when the reply carried none to judge
|
|
27
|
+
readonly renders?: boolean | null | undefined
|
|
28
|
+
// the user-facing result when a pack defines one apart from its laws: the reply did the job
|
|
29
|
+
readonly outcome?: boolean | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// the fake tools a pack stands up around the model, and the verdict they let it reach
|
|
33
|
+
export interface PackWorld<Expect> {
|
|
34
|
+
readonly tools: ReadonlyArray<ToolDefinition>
|
|
35
|
+
// called before every run so per-run traces start empty
|
|
36
|
+
reset(): void
|
|
37
|
+
judge(evalCase: EvalCase<Expect>, observed: Observed): Judgement | Promise<Judgement>
|
|
38
|
+
// persists anything --record captured
|
|
39
|
+
save(): void
|
|
40
|
+
// releases whatever world() opened, once the sweep is done
|
|
41
|
+
close?(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorldOptions {
|
|
45
|
+
// present only under --record: a pack may fetch what its fixtures lack
|
|
46
|
+
readonly live?: FetchFn | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SkillPack<Expect> {
|
|
50
|
+
// the skill directory name; also the @mention and the overlay slot variants are written to
|
|
51
|
+
readonly skill: string
|
|
52
|
+
// one case reported on its own column, usually the ticket's prompt
|
|
53
|
+
readonly focus: string
|
|
54
|
+
readonly cases: ReadonlyArray<EvalCase<Expect>>
|
|
55
|
+
world(options: WorldOptions): PackWorld<Expect>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AnyPack = SkillPack<unknown>
|
|
59
|
+
|
|
60
|
+
export const user = (content: string): Message => ({ role: 'user', content })
|
|
61
|
+
export const assistant = (content: string): Message => ({ role: 'assistant', content })
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { skillLoadedTurn } from '../../../../lib/capabilities/tool-turns.ts'
|
|
2
|
+
import type { ReplyLaws } from '../../laws.ts'
|
|
3
|
+
import { assistant, user, type EvalCase as Case } from '../../pack.ts'
|
|
4
|
+
|
|
5
|
+
export const CATEGORIES = [
|
|
6
|
+
'flowchart',
|
|
7
|
+
'sequence',
|
|
8
|
+
'state',
|
|
9
|
+
'er',
|
|
10
|
+
'class',
|
|
11
|
+
'gantt',
|
|
12
|
+
'pie',
|
|
13
|
+
'mindmap',
|
|
14
|
+
'timeline',
|
|
15
|
+
'hard-label',
|
|
16
|
+
'iterate',
|
|
17
|
+
'route-away',
|
|
18
|
+
'long-context'
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
export type Category = (typeof CATEGORIES)[number]
|
|
22
|
+
|
|
23
|
+
export interface Expect {
|
|
24
|
+
// the Mermaid keyword the first line inside the fence must be; '' when no diagram is wanted
|
|
25
|
+
readonly type: string
|
|
26
|
+
readonly blocks: number | readonly [number, number]
|
|
27
|
+
readonly reply?: ReplyLaws | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type EvalCase = Case<Expect>
|
|
31
|
+
|
|
32
|
+
export const SKILL = 'diagrams'
|
|
33
|
+
// QVAC-24436's own prompt, reported verbatim
|
|
34
|
+
export const TICKET_CASE = 'vegetable-inventory-state'
|
|
35
|
+
|
|
36
|
+
const FENCE_FIRST = /^```mermaid/
|
|
37
|
+
const ASCII_ART = /^[ \t]*[+|][-+|_ ]{4,}/m
|
|
38
|
+
const CANNOT = /\b(?:cannot|can't|unable to|I'm sorry|sorry,|as a text)\b/i
|
|
39
|
+
// QVAC-24436's second symptom: the model's own framing printed as the answer
|
|
40
|
+
export const LEAKED_TOOL = /"arguments"\s*:|rag_search|"error"\s*:|<tool_call>/
|
|
41
|
+
const STYLING = /%%\{|classDef|^\s*style\s/m
|
|
42
|
+
|
|
43
|
+
// every diagram case wants the same reply shape, so the laws live in one place
|
|
44
|
+
const DRAWN: ReplyLaws = { all: [FENCE_FIRST], none: [ASCII_ART, CANNOT, LEAKED_TOOL, STYLING] }
|
|
45
|
+
const NOT_DRAWN: ReplyLaws = { none: [/```mermaid/, LEAKED_TOOL] }
|
|
46
|
+
|
|
47
|
+
interface Drawn {
|
|
48
|
+
readonly category: Category
|
|
49
|
+
readonly prompt: string
|
|
50
|
+
readonly type: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const draws = (id: string, spec: Drawn): EvalCase => ({
|
|
54
|
+
id,
|
|
55
|
+
category: spec.category,
|
|
56
|
+
messages: [user(spec.prompt)],
|
|
57
|
+
expect: { type: spec.type, blocks: 1, reply: DRAWN }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const FLOW = { type: 'flowchart' } as const
|
|
61
|
+
const STATE = { type: 'stateDiagram-v2' } as const
|
|
62
|
+
|
|
63
|
+
// QVAC-24502: the doc is already in the window, so the skill body and answer share what is left
|
|
64
|
+
const ONBOARDING_DOC = [
|
|
65
|
+
'ACME onboarding runbook, revision 7.',
|
|
66
|
+
'',
|
|
67
|
+
'A new hire is created in the HR system by the people team. That record syncs to the',
|
|
68
|
+
'directory overnight. The following morning IT receives a ticket, provisions a laptop from',
|
|
69
|
+
'stock, and images it with the standard build. The manager is emailed a checklist and must',
|
|
70
|
+
'confirm the desk assignment before the laptop is released. On day one the hire collects',
|
|
71
|
+
'the laptop from the front desk, signs the equipment register, and sets their password at',
|
|
72
|
+
'first login. Access to production systems is not granted at this point: the hire completes',
|
|
73
|
+
'security training first, which takes two days, and their manager then requests elevation.',
|
|
74
|
+
'The security team reviews the request against the role matrix and either grants access,',
|
|
75
|
+
'asks for more detail, or refuses it. A refusal returns to the manager with a reason.',
|
|
76
|
+
'Once access is granted the hire is added to the on-call rota after their first month.'
|
|
77
|
+
].join('\n')
|
|
78
|
+
|
|
79
|
+
export const CASES: ReadonlyArray<EvalCase> = [
|
|
80
|
+
// the ticket prompt, and the type it names
|
|
81
|
+
draws(TICKET_CASE, {
|
|
82
|
+
category: 'state',
|
|
83
|
+
prompt: 'Create a state diagram for vegetable inventory',
|
|
84
|
+
...STATE
|
|
85
|
+
}),
|
|
86
|
+
draws('order-states', {
|
|
87
|
+
category: 'state',
|
|
88
|
+
prompt: 'show me the states an order goes through from checkout to delivery',
|
|
89
|
+
...STATE
|
|
90
|
+
}),
|
|
91
|
+
draws('traffic-light', {
|
|
92
|
+
category: 'state',
|
|
93
|
+
prompt: 'diagram of a traffic light and how it changes',
|
|
94
|
+
...STATE
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
draws('coffee-flow', {
|
|
98
|
+
category: 'flowchart',
|
|
99
|
+
prompt: 'draw a flowchart of making coffee',
|
|
100
|
+
...FLOW
|
|
101
|
+
}),
|
|
102
|
+
draws('login-flow', {
|
|
103
|
+
category: 'flowchart',
|
|
104
|
+
prompt: 'map out how a user logs in, including the failure case',
|
|
105
|
+
...FLOW
|
|
106
|
+
}),
|
|
107
|
+
draws('deploy-pipeline', {
|
|
108
|
+
category: 'flowchart',
|
|
109
|
+
prompt: 'can you sketch our deploy pipeline left to right',
|
|
110
|
+
...FLOW
|
|
111
|
+
}),
|
|
112
|
+
draws('support-triage', {
|
|
113
|
+
category: 'flowchart',
|
|
114
|
+
prompt: 'visualize how a support ticket gets triaged',
|
|
115
|
+
...FLOW
|
|
116
|
+
}),
|
|
117
|
+
draws('org-chart', {
|
|
118
|
+
category: 'flowchart',
|
|
119
|
+
prompt: 'draw the reporting lines for a team of eight',
|
|
120
|
+
...FLOW
|
|
121
|
+
}),
|
|
122
|
+
|
|
123
|
+
draws('rest-call', {
|
|
124
|
+
category: 'sequence',
|
|
125
|
+
prompt: 'show who calls whom when the app fetches a profile from the API',
|
|
126
|
+
type: 'sequenceDiagram'
|
|
127
|
+
}),
|
|
128
|
+
draws('oauth-handshake', {
|
|
129
|
+
category: 'sequence',
|
|
130
|
+
prompt: 'diagram the oauth handshake between browser, our server and the provider',
|
|
131
|
+
type: 'sequenceDiagram'
|
|
132
|
+
}),
|
|
133
|
+
|
|
134
|
+
draws('shop-schema', {
|
|
135
|
+
category: 'er',
|
|
136
|
+
prompt: 'draw the tables for a shop: customers, orders, line items',
|
|
137
|
+
type: 'erDiagram'
|
|
138
|
+
}),
|
|
139
|
+
draws('blog-schema', {
|
|
140
|
+
category: 'er',
|
|
141
|
+
prompt: 'diagram the relationships between posts, authors and comments',
|
|
142
|
+
type: 'erDiagram'
|
|
143
|
+
}),
|
|
144
|
+
|
|
145
|
+
draws('animal-classes', {
|
|
146
|
+
category: 'class',
|
|
147
|
+
prompt: 'show the class hierarchy for animals, dogs and cats',
|
|
148
|
+
type: 'classDiagram'
|
|
149
|
+
}),
|
|
150
|
+
draws('payment-classes', {
|
|
151
|
+
category: 'class',
|
|
152
|
+
prompt: 'diagram the payment classes and what they inherit from',
|
|
153
|
+
type: 'classDiagram'
|
|
154
|
+
}),
|
|
155
|
+
|
|
156
|
+
draws('sprint-plan', {
|
|
157
|
+
category: 'gantt',
|
|
158
|
+
prompt: 'give me a schedule for a three week sprint: design, build, test, release',
|
|
159
|
+
type: 'gantt'
|
|
160
|
+
}),
|
|
161
|
+
draws('house-move', {
|
|
162
|
+
category: 'gantt',
|
|
163
|
+
prompt: 'plan my house move over six weeks with durations',
|
|
164
|
+
type: 'gantt'
|
|
165
|
+
}),
|
|
166
|
+
|
|
167
|
+
draws('browser-share', {
|
|
168
|
+
category: 'pie',
|
|
169
|
+
prompt: 'chart the browser split: chrome 65, firefox 20, safari 15',
|
|
170
|
+
type: 'pie'
|
|
171
|
+
}),
|
|
172
|
+
draws('budget-split', {
|
|
173
|
+
category: 'pie',
|
|
174
|
+
prompt: 'show what share of my budget goes to rent, food and savings',
|
|
175
|
+
type: 'pie'
|
|
176
|
+
}),
|
|
177
|
+
|
|
178
|
+
draws('pm-mindmap', {
|
|
179
|
+
category: 'mindmap',
|
|
180
|
+
prompt: 'brainstorm project management as a mindmap',
|
|
181
|
+
type: 'mindmap'
|
|
182
|
+
}),
|
|
183
|
+
draws('launch-ideas', {
|
|
184
|
+
category: 'mindmap',
|
|
185
|
+
prompt: 'mindmap the ideas around our product launch',
|
|
186
|
+
type: 'mindmap'
|
|
187
|
+
}),
|
|
188
|
+
|
|
189
|
+
draws('apollo-timeline', {
|
|
190
|
+
category: 'timeline',
|
|
191
|
+
prompt: 'timeline of the apollo program from 1961 to 1972',
|
|
192
|
+
type: 'timeline'
|
|
193
|
+
}),
|
|
194
|
+
draws('company-history', {
|
|
195
|
+
category: 'timeline',
|
|
196
|
+
prompt: 'show the dated milestones of the company in order',
|
|
197
|
+
type: 'timeline'
|
|
198
|
+
}),
|
|
199
|
+
|
|
200
|
+
// the three failures the repair prompt names, in prompts whose natural labels contain them
|
|
201
|
+
draws('http-retry', {
|
|
202
|
+
category: 'hard-label',
|
|
203
|
+
prompt: 'draw how we send a request (HTTP) and retry it on failure',
|
|
204
|
+
...FLOW
|
|
205
|
+
}),
|
|
206
|
+
draws('finish-step', {
|
|
207
|
+
category: 'hard-label',
|
|
208
|
+
prompt: 'flowchart: start, do the work, end',
|
|
209
|
+
...FLOW
|
|
210
|
+
}),
|
|
211
|
+
draws('ratio-step', {
|
|
212
|
+
category: 'hard-label',
|
|
213
|
+
prompt: 'diagram a step that checks the ratio 2:1 before continuing',
|
|
214
|
+
...FLOW
|
|
215
|
+
}),
|
|
216
|
+
draws('quoted-stage', {
|
|
217
|
+
category: 'hard-label',
|
|
218
|
+
prompt: 'draw a pipeline where one stage is called "review & sign-off"',
|
|
219
|
+
...FLOW
|
|
220
|
+
}),
|
|
221
|
+
|
|
222
|
+
// a diagram already drawn, then an edit: the model must redraw the whole block
|
|
223
|
+
{
|
|
224
|
+
id: 'add-2fa',
|
|
225
|
+
category: 'iterate',
|
|
226
|
+
messages: [
|
|
227
|
+
user('draw a flowchart of the login flow'),
|
|
228
|
+
{ role: 'tool', content: skillLoadedTurn(SKILL) },
|
|
229
|
+
assistant(
|
|
230
|
+
'```mermaid\nflowchart TD\n A["User submits credentials"] --> B{"Valid?"}\n B -->|yes| C["Start session"]\n B -->|no| D["Show error"]\n```\n\nThe login flow.'
|
|
231
|
+
),
|
|
232
|
+
user('add a two-factor step between validating and starting the session')
|
|
233
|
+
],
|
|
234
|
+
expect: { type: 'flowchart', blocks: 1, reply: DRAWN }
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: 'turn-horizontal',
|
|
238
|
+
category: 'iterate',
|
|
239
|
+
messages: [
|
|
240
|
+
user('draw a flowchart of making coffee'),
|
|
241
|
+
{ role: 'tool', content: skillLoadedTurn(SKILL) },
|
|
242
|
+
assistant(
|
|
243
|
+
'```mermaid\nflowchart TD\n A["Boil water"] --> B["Add grounds"]\n B --> C["Brew"]\n C --> D["Pour"]\n```\n\nMaking coffee.'
|
|
244
|
+
),
|
|
245
|
+
user('same thing but horizontal please')
|
|
246
|
+
],
|
|
247
|
+
expect: { type: 'flowchart', blocks: 1, reply: DRAWN }
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// the control every category needs: asks a diagram must NOT answer with a fence
|
|
251
|
+
{
|
|
252
|
+
id: 'onboarding-from-doc',
|
|
253
|
+
category: 'long-context',
|
|
254
|
+
messages: [user(`${ONBOARDING_DOC}\n\nGenerate a state diagram based on the doc above.`)],
|
|
255
|
+
expect: { type: 'stateDiagram-v2', blocks: 1, reply: DRAWN }
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: 'onboarding-flow-from-doc',
|
|
259
|
+
category: 'long-context',
|
|
260
|
+
messages: [user(`${ONBOARDING_DOC}\n\nDraw the onboarding process above as a flowchart.`)],
|
|
261
|
+
expect: { type: 'flowchart', blocks: 1, reply: DRAWN }
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'logo-request',
|
|
265
|
+
category: 'route-away',
|
|
266
|
+
messages: [user('design a logo for my coffee shop')],
|
|
267
|
+
expect: { type: '', blocks: 0, reply: NOT_DRAWN }
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'photo-request',
|
|
271
|
+
category: 'route-away',
|
|
272
|
+
messages: [user('make me a picture of a mountain at sunset')],
|
|
273
|
+
expect: { type: '', blocks: 0, reply: NOT_DRAWN }
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'revenue-plot',
|
|
277
|
+
category: 'route-away',
|
|
278
|
+
messages: [user('plot our monthly revenue for the last year')],
|
|
279
|
+
expect: { type: '', blocks: 0, reply: NOT_DRAWN }
|
|
280
|
+
}
|
|
281
|
+
]
|