@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.
Files changed (58) hide show
  1. package/README.md +12 -12
  2. package/bench/README.md +94 -0
  3. package/bench/bfcl/checker.ts +313 -0
  4. package/bench/bfcl/data.ts +93 -0
  5. package/bench/bfcl/observe.ts +93 -0
  6. package/bench/bfcl/run.ts +213 -0
  7. package/bench/bfcl/tools.ts +72 -0
  8. package/bench/bridge/bridge.ts +392 -0
  9. package/bench/bridge/openai.ts +150 -0
  10. package/bench/bridge/python/qvac_bench/__init__.py +3 -0
  11. package/bench/bridge/python/qvac_bench/airline.py +42 -0
  12. package/bench/bridge/python/qvac_bench/approval.py +26 -0
  13. package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
  14. package/bench/bridge/python/qvac_bench/client.py +88 -0
  15. package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
  16. package/bench/bridge/python/qvac_bench/session.py +107 -0
  17. package/bench/bridge/python/qvac_bench/speed.py +30 -0
  18. package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
  19. package/bench/bridge/python/qvac_bench/tools.py +11 -0
  20. package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
  21. package/bench/bridge/python/run_tau2.py +150 -0
  22. package/bench/bridge/python/smoke.py +47 -0
  23. package/bench/bridge/python/test_airline.py +28 -0
  24. package/bench/bridge/python/test_approval.py +57 -0
  25. package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
  26. package/bench/bridge/python/test_harbor_agent.py +107 -0
  27. package/bench/bridge/serve.ts +52 -0
  28. package/bench/bridge/transcript.ts +52 -0
  29. package/bench/bun-tsconfig.json +15 -0
  30. package/bench/cpu-main.mjs +216 -0
  31. package/bench/cpu.ts +457 -0
  32. package/bench/evidence/2026-09-28-review15.md +225 -0
  33. package/bench/evidence/redact-bun1.3.14.json +92 -0
  34. package/bench/evidence/redact-node24.11.json +92 -0
  35. package/bench/graph-metrics.ts +43 -0
  36. package/bench/harness.ts +73 -0
  37. package/bench/issue129-matrix.mjs +722 -0
  38. package/bench/redact-adversarial.ts +256 -0
  39. package/bench/repeat-cpu.mjs +281 -0
  40. package/bench/report.ts +1075 -0
  41. package/bench/skill-eval/harness.ts +30 -0
  42. package/bench/skill-eval/laws.ts +37 -0
  43. package/bench/skill-eval/mermaid/client.ts +132 -0
  44. package/bench/skill-eval/mermaid/validate.mjs +115 -0
  45. package/bench/skill-eval/pack.ts +61 -0
  46. package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
  47. package/bench/skill-eval/packs/diagrams/index.ts +50 -0
  48. package/bench/skill-eval/packs/diagrams/score.ts +74 -0
  49. package/bench/skill-eval/packs/index.ts +5 -0
  50. package/bench/skill-eval/packs/weather/cases.ts +279 -0
  51. package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
  52. package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
  53. package/bench/skill-eval/packs/weather/index.ts +55 -0
  54. package/bench/skill-eval/packs/weather/score.ts +92 -0
  55. package/bench/skill-eval/report.ts +194 -0
  56. package/bench/skill-eval/run.ts +316 -0
  57. package/bench/speed.ts +80 -0
  58. package/package.json +4 -13
@@ -0,0 +1,84 @@
1
+ import type { FetchFn } from '../../../../lib/tools/http-request.ts'
2
+
3
+ export const SUFFIXES = ['format=3', '1T', '2T', 'T', 'bare', 'other'] as const
4
+
5
+ export type Suffix = (typeof SUFFIXES)[number]
6
+
7
+ export interface WttrRequest {
8
+ readonly url: string
9
+ readonly host: string
10
+ readonly location: string
11
+ readonly suffix: Suffix
12
+ }
13
+
14
+ export interface Fixture {
15
+ readonly status: number
16
+ readonly body: string
17
+ }
18
+
19
+ export type Fixtures = Record<string, Fixture>
20
+
21
+ export const FIXTURE_MISS: Fixture = { status: 599, body: 'eval fixture miss' }
22
+
23
+ // one key for fixtures and judging; wttr.in treats `Paris,+Texas`, `Paris+Texas` and `Zürich` alike
24
+ export function wttrRequest(url: URL): WttrRequest {
25
+ const location = decodeURIComponent(url.pathname.slice(1))
26
+ .normalize('NFD')
27
+ .replace(/[\u0300-\u036f]/g, '')
28
+ .replace(/[+,]/g, ' ')
29
+ .replace(/\s+/g, ' ')
30
+ .trim()
31
+ .toLowerCase()
32
+ return { url: url.href, host: url.hostname, location, suffix: suffixOf(url.search.slice(1)) }
33
+ }
34
+
35
+ export function wttrKey(request: WttrRequest): string {
36
+ return `${request.location}|${request.suffix}`
37
+ }
38
+
39
+ function suffixOf(query: string): Suffix {
40
+ if (query === '') return 'bare'
41
+ return SUFFIXES.find((suffix) => suffix === query) ?? 'other'
42
+ }
43
+
44
+ export interface FakeFetchOptions {
45
+ readonly onServe: (key: string, fixture: Fixture) => void
46
+ readonly onMiss: (key: string) => void
47
+ // present only under --record: a miss is fetched live and kept
48
+ readonly live?: FetchFn | undefined
49
+ }
50
+
51
+ // offline, an off-list suffix gets the one-liner: the scorer fails the suffix, the model sees weather
52
+ export function fakeFetch(fixtures: Fixtures, options: FakeFetchOptions): FetchFn {
53
+ return async (url, init) => {
54
+ const request = wttrRequest(url)
55
+ const key = wttrKey(request)
56
+ const offList = request.suffix === 'other' && !options.live
57
+ const known = fixtures[key] ?? (offList ? fixtures[`${request.location}|format=3`] : undefined)
58
+ if (known) {
59
+ options.onServe(key, known)
60
+ return response(known)
61
+ }
62
+
63
+ if (!options.live) {
64
+ options.onMiss(key)
65
+ return response(FIXTURE_MISS)
66
+ }
67
+
68
+ const recorded = await record(options.live, url, init)
69
+ fixtures[key] = recorded
70
+ options.onServe(key, recorded)
71
+
72
+ return response(recorded)
73
+ }
74
+ }
75
+
76
+ // wttr.in serves HTML to a browser agent; the product sends an empty one, so recording does too
77
+ async function record(live: FetchFn, url: URL, init: Parameters<FetchFn>[1]): Promise<Fixture> {
78
+ const res = await live(url, { ...init, headers: { ...init.headers, 'user-agent': '' } })
79
+ return { status: res.status, body: await res.text() }
80
+ }
81
+
82
+ function response(fixture: Fixture): Awaited<ReturnType<FetchFn>> {
83
+ return { status: fixture.status, headers: { get: () => null }, text: () => fixture.body }
84
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'fs'
2
+ import { fileURLToPath } from 'url'
3
+ import { createHttpRequestTool } from '../../../../lib/tools/http-request.ts'
4
+ import type { PackWorld, SkillPack, WorldOptions } from '../../pack.ts'
5
+ import { CASES, SKILL, TICKET_CASE, type Expect } from './cases.ts'
6
+ import { fakeFetch, type Fixture, type Fixtures } from './fixtures.ts'
7
+ import { requested, score } from './score.ts'
8
+
9
+ // any public address: the url guard resolves the host on every call, and the eval is offline
10
+ const WTTR_ADDRESSES = ['5.9.243.187']
11
+ const FIXTURES_PATH = fileURLToPath(new URL('./fixtures/wttr.json', import.meta.url))
12
+
13
+ function world(options: WorldOptions): PackWorld<Expect> {
14
+ const fixtures: Fixtures = JSON.parse(fs.readFileSync(FIXTURES_PATH, 'utf8'))
15
+ const served: Fixture[] = []
16
+ const misses: string[] = []
17
+ const fetch = fakeFetch(fixtures, {
18
+ onServe: (_key, fixture) => served.push(fixture),
19
+ onMiss: (key) => misses.push(key),
20
+ live: options.live
21
+ })
22
+ const http = createHttpRequestTool({
23
+ fetch,
24
+ resolveHost: () => Promise.resolve(WTTR_ADDRESSES)
25
+ })
26
+
27
+ return {
28
+ tools: [http],
29
+ reset() {
30
+ served.length = 0
31
+ misses.length = 0
32
+ },
33
+ judge(evalCase, observed) {
34
+ const calls = observed.calls
35
+ .filter((call) => call.name === http.name)
36
+ .map((call) => requested(call.args))
37
+ const verdict = score(evalCase, { calls, served, reply: observed.reply })
38
+ // a wrong call is judged on its own; only a right call the fixtures could not answer is a gap
39
+ const callWrong = verdict.some((failure) => /^(?:calls|host|location|suffix):/.test(failure))
40
+
41
+ return {
42
+ failures: [...misses.map((key) => `fixture miss: ${key}`), ...verdict],
43
+ gap: misses.length > 0 && !callWrong
44
+ }
45
+ },
46
+ save() {
47
+ const sorted = Object.fromEntries(
48
+ Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b))
49
+ )
50
+ fs.writeFileSync(FIXTURES_PATH, `${JSON.stringify(sorted, null, 2)}\n`)
51
+ }
52
+ }
53
+ }
54
+
55
+ export const weather: SkillPack<Expect> = { skill: SKILL, focus: TICKET_CASE, cases: CASES, world }
@@ -0,0 +1,92 @@
1
+ import { applyQuery, parseObjectArg } from '../../../../lib/tools/http-request/request.ts'
2
+ import { replyFailures } from '../../laws.ts'
3
+ import { TEMPERATURE, type EvalCase, type Expect } from './cases.ts'
4
+ import { wttrRequest, type Fixture, type WttrRequest } from './fixtures.ts'
5
+
6
+ export interface Observed {
7
+ readonly calls: ReadonlyArray<WttrRequest>
8
+ readonly served: ReadonlyArray<Fixture>
9
+ readonly reply: string
10
+ }
11
+
12
+ const WTTR_HOST = 'wttr.in'
13
+
14
+ export function score(evalCase: EvalCase, observed: Observed): string[] {
15
+ return [
16
+ ...callFailures(evalCase.expect, observed.calls),
17
+ ...replyChecks(evalCase.expect, observed)
18
+ ]
19
+ }
20
+
21
+ function callFailures(expect: Expect, calls: ReadonlyArray<WttrRequest>): string[] {
22
+ const failures: string[] = []
23
+ const [min, max] = typeof expect.calls === 'number' ? [expect.calls, expect.calls] : expect.calls
24
+ if (calls.length < min || calls.length > max) {
25
+ failures.push(`calls: ${calls.length}, expected ${min === max ? min : `${min}-${max}`}`)
26
+ }
27
+
28
+ for (const call of calls) {
29
+ if (call.host !== WTTR_HOST) {
30
+ failures.push(`host: ${call.host}`)
31
+ }
32
+
33
+ if (expect.location && !expect.location.includes(call.location)) {
34
+ failures.push(`location: ${call.location}`)
35
+ }
36
+
37
+ if (expect.suffix && !expect.suffix.includes(call.suffix)) {
38
+ failures.push(`suffix: ${call.suffix}`)
39
+ }
40
+ }
41
+
42
+ return failures
43
+ }
44
+
45
+ function replyChecks(expect: Expect, observed: Observed): string[] {
46
+ const failures = replyFailures(observed.reply, expect.reply)
47
+ if (failures.length === 1 && failures[0] === 'reply: empty') {
48
+ return failures
49
+ }
50
+
51
+ return expect.echoes ? [...echoFailures(observed), ...failures] : failures
52
+ }
53
+
54
+ // judged as the tool sends it: `query` merged into the url, an unparseable url failing on the host
55
+ export function requested(args: Record<string, unknown>): WttrRequest {
56
+ try {
57
+ const url = new URL(String(args.url))
58
+ const query = parseObjectArg(args.query, 'query')
59
+ if (query.ok && query.value) {
60
+ applyQuery(url, query.value)
61
+ }
62
+
63
+ return wttrRequest(url)
64
+ } catch {
65
+ return { url: String(args.url), host: 'invalid', location: '', suffix: 'other' }
66
+ }
67
+ }
68
+
69
+ // the fixture is the truth, so re-recording on another day never breaks a case
70
+ function echoFailures(observed: Observed): string[] {
71
+ const celsius = observed.served.flatMap((fixture) => temperatures(fixture.body))
72
+ if (celsius.length === 0) {
73
+ return ['echo: no temperature was served']
74
+ }
75
+
76
+ const accepted = [...celsius, ...celsius.map(fahrenheit)]
77
+ const stated = temperatures(observed.reply)
78
+
79
+ return stated.some((value) => accepted.some((known) => Math.abs(known - value) <= 1))
80
+ ? []
81
+ : [`echo: reply says ${stated.join(', ') || 'nothing'}, served ${celsius.join(', ')}°C`]
82
+ }
83
+
84
+ function fahrenheit(celsius: number): number {
85
+ return Math.round((celsius * 9) / 5 + 32)
86
+ }
87
+
88
+ function temperatures(text: string): number[] {
89
+ return Array.from(text.matchAll(new RegExp(TEMPERATURE.source, 'gi')), (match) =>
90
+ Number.parseFloat(match[0])
91
+ )
92
+ }
@@ -0,0 +1,194 @@
1
+ // prose is a turn the harness preloads the skill into; self-load makes the model fetch it first
2
+ export const ROUTES = ['mention', 'prose', 'self-load'] as const
3
+
4
+ export type Route = (typeof ROUTES)[number]
5
+
6
+ export interface RunRow {
7
+ readonly variant: string
8
+ readonly route: Route
9
+ readonly caseId: string
10
+ readonly category: string
11
+ readonly repeat: number
12
+ readonly passed: boolean
13
+ readonly gap: boolean
14
+ // null when the reply carried no diagram to parse, so it leaves the renders column
15
+ readonly renders: boolean | null
16
+ // the pack's user-facing verdict; null for a pack that has only its laws
17
+ readonly outcome: boolean | null
18
+ readonly failures: ReadonlyArray<string>
19
+ readonly reply: string
20
+ readonly calls: ReadonlyArray<string>
21
+ readonly skillLoaded: boolean
22
+ readonly thinkingChars: number
23
+ // the whole prompt the last round sent, and the share of it the skills index took
24
+ readonly contextTokens: number | null
25
+ readonly skillTokens: number | null
26
+ readonly ttftMs: number | null
27
+ readonly generatedTokens: number | null
28
+ readonly wallMs: number
29
+ readonly error: string | null
30
+ }
31
+
32
+ export interface SummaryOptions {
33
+ readonly focus: string
34
+ readonly categories: ReadonlyArray<string>
35
+ }
36
+
37
+ const CSV_COLUMNS = [
38
+ 'variant',
39
+ 'route',
40
+ 'caseId',
41
+ 'category',
42
+ 'repeat',
43
+ 'passed',
44
+ 'gap',
45
+ 'renders',
46
+ 'outcome',
47
+ 'failures',
48
+ 'calls',
49
+ 'skillLoaded',
50
+ 'thinkingChars',
51
+ 'contextTokens',
52
+ 'skillTokens',
53
+ 'ttftMs',
54
+ 'generatedTokens',
55
+ 'wallMs',
56
+ 'error'
57
+ ] as const
58
+
59
+ export function toJsonl(rows: ReadonlyArray<RunRow>): string {
60
+ return rows.map((row) => JSON.stringify(row)).join('\n') + '\n'
61
+ }
62
+
63
+ export function toCsv(rows: ReadonlyArray<RunRow>): string {
64
+ const lines = rows.map((row) => CSV_COLUMNS.map((column) => csvCell(row[column])).join(','))
65
+ return [CSV_COLUMNS.join(','), ...lines].join('\n') + '\n'
66
+ }
67
+
68
+ function csvCell(value: unknown): string {
69
+ const text = Array.isArray(value) ? value.join('; ') : String(value ?? '')
70
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
71
+ }
72
+
73
+ export function summaryMarkdown(rows: ReadonlyArray<RunRow>, options: SummaryOptions): string {
74
+ const variants = unique(rows.map((row) => row.variant))
75
+ const routes = ROUTES.filter((route) => rows.some((row) => row.route === route))
76
+ const cases = unique(rows.map((row) => row.caseId))
77
+ const used = options.categories.filter((category) =>
78
+ rows.some((row) => row.category === category)
79
+ )
80
+
81
+ const header = [
82
+ 'variant',
83
+ 'route',
84
+ 'overall',
85
+ 'outcome',
86
+ 'renders',
87
+ ...used,
88
+ options.focus,
89
+ 'ctx tok',
90
+ 'skills idx tok',
91
+ 'ttft ms',
92
+ 'wall ms',
93
+ 'gaps',
94
+ 'think',
95
+ 'errors'
96
+ ]
97
+ const summary = variants.flatMap((variant) =>
98
+ routes.map((route) => {
99
+ const mine = rows.filter((row) => row.variant === variant && row.route === route)
100
+ return [
101
+ variant,
102
+ route,
103
+ rate(mine),
104
+ outcomeRate(mine),
105
+ renderRate(mine),
106
+ ...used.map((category) => rate(mine.filter((row) => row.category === category))),
107
+ rate(mine.filter((row) => row.caseId === options.focus)),
108
+ mean(mine.map((row) => row.contextTokens)),
109
+ mean(mine.map((row) => row.skillTokens)),
110
+ mean(mine.map((row) => row.ttftMs)),
111
+ mean(mine.map((row) => row.wallMs)),
112
+ String(mine.filter((row) => row.gap).length),
113
+ String(mine.filter((row) => row.thinkingChars > 0).length),
114
+ String(mine.filter((row) => row.error !== null).length)
115
+ ]
116
+ })
117
+ )
118
+
119
+ const columns = variants.flatMap((variant) => routes.map((route) => ({ variant, route })))
120
+ const matrixHeader = [
121
+ 'case',
122
+ 'category',
123
+ ...columns.map((column) => `${column.variant}/${column.route}`)
124
+ ]
125
+ const matrix = cases.map((caseId) => {
126
+ const mine = rows.filter((row) => row.caseId === caseId)
127
+ return [
128
+ caseId,
129
+ mine[0]?.category ?? '',
130
+ ...columns.map((column) =>
131
+ count(mine.filter((row) => row.variant === column.variant && row.route === column.route))
132
+ )
133
+ ]
134
+ })
135
+
136
+ return `## Variants\n\n${table(header, summary)}\n\n## Cases\n\n${table(matrixHeader, matrix)}\n`
137
+ }
138
+
139
+ // a gap row is an eval defect, not a model result, so it leaves the denominator
140
+ export function rate(rows: ReadonlyArray<RunRow>): string {
141
+ const judged = rows.filter((row) => !row.gap)
142
+ if (judged.length === 0) {
143
+ return '-'
144
+ }
145
+
146
+ const passed = judged.filter((row) => row.passed).length
147
+
148
+ return `${Math.round((passed / judged.length) * 100)}% (${passed}/${judged.length})`
149
+ }
150
+
151
+ // what the user got, by the pack's own definition; '-' for a pack that defines none
152
+ export function outcomeRate(rows: ReadonlyArray<RunRow>): string {
153
+ const judged = rows.filter((row) => !row.gap && row.outcome !== null)
154
+ if (judged.length === 0) {
155
+ return '-'
156
+ }
157
+
158
+ const good = judged.filter((row) => row.outcome).length
159
+
160
+ return `${Math.round((good / judged.length) * 100)}% (${good}/${judged.length})`
161
+ }
162
+
163
+ // a diagram that does not parse shows as unavailable; a reply with no diagram leaves the denominator
164
+ export function renderRate(rows: ReadonlyArray<RunRow>): string {
165
+ const drawn = rows.filter((row) => !row.gap && row.renders !== null)
166
+ if (drawn.length === 0) {
167
+ return '-'
168
+ }
169
+
170
+ const parsed = drawn.filter((row) => row.renders).length
171
+
172
+ return `${Math.round((parsed / drawn.length) * 100)}% (${parsed}/${drawn.length})`
173
+ }
174
+
175
+ function count(rows: ReadonlyArray<RunRow>): string {
176
+ const judged = rows.filter((row) => !row.gap)
177
+ return judged.length === 0 ? '-' : `${judged.filter((row) => row.passed).length}/${judged.length}`
178
+ }
179
+
180
+ function mean(values: ReadonlyArray<number | null>): string {
181
+ const known = values.filter((value): value is number => value !== null)
182
+ return known.length === 0
183
+ ? '-'
184
+ : String(Math.round(known.reduce((a, b) => a + b, 0) / known.length))
185
+ }
186
+
187
+ function unique<T>(values: ReadonlyArray<T>): T[] {
188
+ return [...new Set(values)]
189
+ }
190
+
191
+ function table(header: ReadonlyArray<string>, body: ReadonlyArray<ReadonlyArray<string>>): string {
192
+ const line = (cells: ReadonlyArray<string>) => `| ${cells.join(' | ')} |`
193
+ return [line(header), line(header.map(() => '---')), ...body.map(line)].join('\n')
194
+ }