@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,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
|
+
}
|