@fangorn-network/westmarch 0.0.4-dev → 0.0.5-dev
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/dist/ts/eval.js +61 -7
- package/docs/app-to-agent.md +7 -0
- package/package.json +1 -1
- package/publish/eval.ts +67 -12
package/dist/ts/eval.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]
|
|
5
5
|
//
|
|
6
|
-
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
7
6
|
// westmarch-eval --selfcheck
|
|
8
7
|
//
|
|
8
|
+
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
9
|
+
//
|
|
9
10
|
// It grades the RECIPE — how records are shaped, which fields are embedded, how rows
|
|
10
11
|
// rank — never the data. New records are facts, and a view is not worse for holding
|
|
11
12
|
// them. So: build a base view (main's recipe) and a candidate (the change) from the
|
|
@@ -25,6 +26,16 @@
|
|
|
25
26
|
// {"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ (St|Ave)\\b/"}},"max_pct":0.5,"hard":true}
|
|
26
27
|
// records: the % of rows that satisfy `expect`, lower is better; `hard`: any rise over the base fails.
|
|
27
28
|
// `of` (a predicate) narrows which rows count, e.g. only meeting items have a date to miss.
|
|
29
|
+
// {"id":"basin-towns","kind":"coverage","field":"city","values":["Plover","Stevens Point"],"min":0.9}
|
|
30
|
+
// coverage: the share of `values` that some row holds in `field`, and the missing ones.
|
|
31
|
+
// `values` may instead be a path (relative to the golden file) to a JSON list, or to an
|
|
32
|
+
// object {value: weight} such as each town's population: the share is then by weight, and
|
|
33
|
+
// the missing are listed heaviest first, so the report names the next places to add.
|
|
34
|
+
// With a base, any fall fails: a recipe that loses a town has dropped its records.
|
|
35
|
+
//
|
|
36
|
+
// Any check may carry a `goal`: the owner's bet it serves (eval/goals.md says what each one
|
|
37
|
+
// is). The report scores each goal apart, so "trades 0.4, general 0.9" shows which bet the
|
|
38
|
+
// app is losing, instead of one mean that hides it.
|
|
28
39
|
//
|
|
29
40
|
// A predicate is {where?, match?, not?}. `where` is fangorn's own whole-value,
|
|
30
41
|
// case-insensitive match — the filter agents use. `match` maps a field (or several
|
|
@@ -36,8 +47,9 @@
|
|
|
36
47
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
37
48
|
import { createServer } from "node:http";
|
|
38
49
|
import { homedir } from "node:os";
|
|
50
|
+
import { dirname, join } from "node:path";
|
|
39
51
|
import { configure, loadShard, trimView } from "@fangorn-network/westmarch/shard";
|
|
40
|
-
import { rolesFrom, textOf, titleOf, subtitleOf } from "@fangorn-network/westmarch/roles";
|
|
52
|
+
import { rolesFrom, textOf, titleOf, subtitleOf, values } from "@fangorn-network/westmarch/roles";
|
|
41
53
|
import { search, matches } from "@fangorn-network/westmarch/tools";
|
|
42
54
|
import { EMBED_MODEL, embedQueryDirect } from "@fangorn-network/westmarch/embed";
|
|
43
55
|
/** A drop below this, in mean precision@k or known@10, is "worse". canton-corpus's number. */
|
|
@@ -60,21 +72,31 @@ async function grade(rows, roles, checks, qv, known) {
|
|
|
60
72
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
61
73
|
const out = [];
|
|
62
74
|
for (const c of checks) {
|
|
75
|
+
const goal = c.goal ? { goal: c.goal } : {};
|
|
63
76
|
if (c.kind === "count") {
|
|
64
77
|
const ok = compile(c.expect);
|
|
65
78
|
const n = rows.filter((r) => matches(r, c.where) && ok(r)).length;
|
|
66
|
-
out.push({ id: c.id, kind: "count", score: n, pass: n >= (c.min ?? 1) && n <= (c.max ?? Infinity) });
|
|
79
|
+
out.push({ id: c.id, kind: "count", score: n, pass: n >= (c.min ?? 1) && n <= (c.max ?? Infinity), ...goal });
|
|
67
80
|
}
|
|
68
81
|
else if (c.kind === "records") {
|
|
69
82
|
const of = rows.filter(compile(c.of)), ok = compile(c.expect);
|
|
70
83
|
const pct = of.length ? round((100 * of.filter(ok).length) / of.length) : 0;
|
|
71
|
-
out.push({ id: c.id, kind: "records", score: pct, pass: pct <= c.max_pct, hard: c.hard });
|
|
84
|
+
out.push({ id: c.id, kind: "records", score: pct, pass: pct <= c.max_pct, hard: c.hard, ...goal });
|
|
85
|
+
}
|
|
86
|
+
else if (c.kind === "coverage") {
|
|
87
|
+
// The same whole-value, any-case reading `where` uses, so covered means an agent's filter finds it.
|
|
88
|
+
const held = new Set(rows.filter(compile(c.of)).flatMap((r) => values(r[c.field] ?? "").map((v) => v.toLowerCase())));
|
|
89
|
+
const w = (v) => c.weights?.[v] ?? 1;
|
|
90
|
+
const missing = c.values.filter((v) => !held.has(v.toLowerCase())).sort((a, b) => w(b) - w(a));
|
|
91
|
+
const total = c.values.reduce((a, v) => a + w(v), 0);
|
|
92
|
+
const score = total ? round(1 - missing.reduce((a, v) => a + w(v), 0) / total) : 1;
|
|
93
|
+
out.push({ id: c.id, kind: "coverage", score, pass: score >= (c.min ?? 0), top: missing.slice(0, 10), ...goal });
|
|
72
94
|
}
|
|
73
95
|
else {
|
|
74
96
|
const k = c.k ?? 5, ok = compile(c.expect);
|
|
75
97
|
const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id));
|
|
76
98
|
const score = round(hits.filter(ok).length / k);
|
|
77
|
-
out.push({ id: c.id, kind: "search", score, pass: score >= (c.min ?? 0), top: hits.slice(0, 3).map((r) => titleOf(r, roles).slice(0, 90)) });
|
|
99
|
+
out.push({ id: c.id, kind: "search", score, pass: score >= (c.min ?? 0), top: hits.slice(0, 3).map((r) => titleOf(r, roles).slice(0, 90)), ...goal });
|
|
78
100
|
}
|
|
79
101
|
}
|
|
80
102
|
// ponytail: the pool is each view's own rows, so base and candidate sample different
|
|
@@ -97,6 +119,18 @@ async function grade(rows, roles, checks, qv, known) {
|
|
|
97
119
|
}
|
|
98
120
|
return out;
|
|
99
121
|
}
|
|
122
|
+
/** Each goal's checks, scored apart: how many pass, and the mean of its searches and coverages. */
|
|
123
|
+
function byGoal(results) {
|
|
124
|
+
const out = {};
|
|
125
|
+
const mean = (xs) => (xs.length ? round(xs.reduce((a, x) => a + x, 0) / xs.length) : null);
|
|
126
|
+
for (const g of new Set(results.map((r) => r.goal).filter((g) => !!g))) {
|
|
127
|
+
const rs = results.filter((r) => r.goal === g);
|
|
128
|
+
out[g] = { checks: rs.length, passing: rs.filter((r) => r.pass).length,
|
|
129
|
+
search: mean(rs.filter((r) => r.kind === "search").map((r) => r.score)),
|
|
130
|
+
coverage: mean(rs.filter((r) => r.kind === "coverage").map((r) => r.score)) };
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
100
134
|
/** The candidate against the base. Worse on any one rule is worse. */
|
|
101
135
|
function compare(cand, base) {
|
|
102
136
|
const was = new Map(base.map((r) => [r.id, r.score]));
|
|
@@ -110,6 +144,8 @@ function compare(cand, base) {
|
|
|
110
144
|
failures.push(`${r.id}: answered before, nothing now`);
|
|
111
145
|
if (r.hard && b !== undefined && r.score > b)
|
|
112
146
|
failures.push(`${r.id}: ${b}% → ${r.score}%, and it may not rise`);
|
|
147
|
+
if (r.kind === "coverage" && b !== undefined && r.score < b)
|
|
148
|
+
failures.push(`${r.id}: coverage ${b} → ${r.score}; missing ${r.top?.join(", ")}`);
|
|
113
149
|
}
|
|
114
150
|
// Aggregates, over the checks both sides ran: one question moving a rank is noise.
|
|
115
151
|
const both = (kind) => cand.filter((r) => (kind === "known" ? r.id === "known@10" : r.kind === kind) && was.has(r.id));
|
|
@@ -143,8 +179,15 @@ async function main() {
|
|
|
143
179
|
console.error("usage: westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]");
|
|
144
180
|
process.exit(flags.help ? 0 : 2);
|
|
145
181
|
}
|
|
146
|
-
const
|
|
182
|
+
const golden = String(flags.golden ?? "eval/golden.jsonl");
|
|
183
|
+
const checks = readFileSync(golden, "utf8")
|
|
147
184
|
.split("\n").filter((l) => l.trim() && !l.startsWith("//")).map((l) => JSON.parse(l));
|
|
185
|
+
// A coverage list kept in its own file: a list, or {value: weight}.
|
|
186
|
+
for (const c of checks)
|
|
187
|
+
if (c.kind === "coverage" && typeof c.values === "string") {
|
|
188
|
+
const list = JSON.parse(readFileSync(join(dirname(golden), c.values), "utf8"));
|
|
189
|
+
Object.assign(c, Array.isArray(list) ? { values: list } : { values: Object.keys(list), weights: list });
|
|
190
|
+
}
|
|
148
191
|
const known = Number(flags.known ?? 200);
|
|
149
192
|
const { env } = await import("@huggingface/transformers");
|
|
150
193
|
env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`; // shared with fangorn-mcp and westmarch-view
|
|
@@ -163,7 +206,7 @@ async function main() {
|
|
|
163
206
|
const failures = cmp?.failures ?? cand.results.filter((r) => !r.pass).map((r) => `${r.id}: ${r.score} is outside its bounds`);
|
|
164
207
|
// What reproduces this grade: the model, the runtime, and the views (whose manifests hash every shard).
|
|
165
208
|
const report = { model: EMBED_MODEL, node: process.version, platform: `${process.platform}-${process.arch}`,
|
|
166
|
-
candidate: cand, base, verdict: cmp?.verdict ?? null, deltas: cmp?.deltas ?? [], failures, pass: !failures.length };
|
|
209
|
+
goals: byGoal(cand.results), candidate: cand, base, verdict: cmp?.verdict ?? null, deltas: cmp?.deltas ?? [], failures, pass: !failures.length };
|
|
167
210
|
if (flags.report)
|
|
168
211
|
writeFileSync(String(flags.report), JSON.stringify(report, null, 1));
|
|
169
212
|
if (flags.json)
|
|
@@ -177,6 +220,8 @@ async function main() {
|
|
|
177
220
|
}
|
|
178
221
|
for (const d of report.deltas)
|
|
179
222
|
console.log(`\n${d.kind}: ${d.base} → ${d.cand}`);
|
|
223
|
+
for (const [g, s] of Object.entries(report.goals))
|
|
224
|
+
console.log(`\ngoal ${g}: ${s.passing}/${s.checks} passing${s.search === null ? "" : `, search ${s.search}`}${s.coverage === null ? "" : `, coverage ${s.coverage}`}`);
|
|
180
225
|
console.log(`\n${report.verdict ?? (report.pass ? "pass" : "fail")}${failures.length ? `\n ${failures.join("\n ")}` : ""}`);
|
|
181
226
|
}
|
|
182
227
|
process.exit(report.pass ? 0 : 1);
|
|
@@ -228,6 +273,14 @@ async function selfcheck() {
|
|
|
228
273
|
const [scoped] = await grade(rows, roles, [{ id: "s", kind: "records", of: { where: { city: "Stevens Point" } }, expect: { match: { text: "Rd" } }, max_pct: 100 }], async () => null, 0);
|
|
229
274
|
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
230
275
|
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
276
|
+
// Coverage, and goals scored apart.
|
|
277
|
+
const [cov] = await grade(rows, roles, [{ id: "towns", kind: "coverage", field: "city", values: ["plover", "Stevens Point", "Wausau"], min: 0.5, goal: "towns" }], async () => null, 0);
|
|
278
|
+
assert(cov.score === 0.667 && cov.pass && cov.top?.[0] === "Wausau", `coverage is the share present, the rest listed: ${JSON.stringify(cov)}`);
|
|
279
|
+
const [pop] = await grade(rows, roles, [{ id: "pop", kind: "coverage", field: "city", values: ["Plover", "Wausau", "Adell"],
|
|
280
|
+
weights: { Plover: 30, Wausau: 60, Adell: 10 } }], async () => null, 0);
|
|
281
|
+
assert(pop.score === 0.3 && pop.top?.join() === "Wausau,Adell", `weighted coverage, the heaviest gap first: ${JSON.stringify(pop)}`);
|
|
282
|
+
const goals = byGoal([cov, { ...r.rezoning, goal: "towns" }, r.plover]);
|
|
283
|
+
assert(goals.towns.checks === 2 && goals.towns.coverage === 0.667 && goals.towns.search === 1 && !("undefined" in goals), `goals: ${JSON.stringify(goals)}`);
|
|
231
284
|
// The verdict.
|
|
232
285
|
const res = (id, score, kind = "search", extra = {}) => ({ id, kind, score, pass: true, ...extra });
|
|
233
286
|
const base = [res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })];
|
|
@@ -240,5 +293,6 @@ async function selfcheck() {
|
|
|
240
293
|
assert(compare([res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.2, "records", { hard: true })], base).verdict === "worse", "a hard record may not rise");
|
|
241
294
|
assert(compare([res("q1", 0.8, "search", { pass: false }), res("q2", 0.4)], base).verdict === "worse", "a floor fails whatever the base did");
|
|
242
295
|
assert(compare([res("q1", 0.8), res("q2", 0.4), res("q3", 0)], base).verdict === "unchanged", "a new question has no base to fall from");
|
|
296
|
+
assert(compare([res("cov", 0.9, "coverage")], [res("cov", 1, "coverage")]).verdict === "worse", "coverage may not fall");
|
|
243
297
|
console.log("eval: selfcheck ok");
|
|
244
298
|
}
|
package/docs/app-to-agent.md
CHANGED
|
@@ -123,6 +123,13 @@ What is asked lives in `eval/golden.jsonl`, one check per line, as data rather t
|
|
|
123
123
|
{"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ St\\b/"}},"max_pct":0.5,"hard":true}
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
Which questions go in is the owner's bet: who would pay for this, for which answers.
|
|
127
|
+
Write it down in `eval/goals.md` (the customer, their questions, the coverage they need, a
|
|
128
|
+
date, and what would count as failure), tag each check with the `goal` it serves, and the
|
|
129
|
+
report scores each goal on its own. A `coverage` check (`{"kind":"coverage","field":"city",
|
|
130
|
+
"values":[…]}`) measures how much of the list a goal needs is present at all; with a base,
|
|
131
|
+
any fall fails, because a recipe that loses a town has dropped its records.
|
|
132
|
+
|
|
126
133
|
`expect` is a predicate over a row: `where` (the whole-value match agents filter with),
|
|
127
134
|
`match` (a field, or several joined by `|`, to a regex; case-insensitive unless written
|
|
128
135
|
`/re/flags`), `not`. Relevance is a predicate and not a list of record ids, because ids
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5-dev",
|
|
4
4
|
"description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/publish/eval.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]
|
|
5
5
|
//
|
|
6
|
-
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
7
6
|
// westmarch-eval --selfcheck
|
|
8
7
|
//
|
|
8
|
+
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
9
|
+
//
|
|
9
10
|
// It grades the RECIPE — how records are shaped, which fields are embedded, how rows
|
|
10
11
|
// rank — never the data. New records are facts, and a view is not worse for holding
|
|
11
12
|
// them. So: build a base view (main's recipe) and a candidate (the change) from the
|
|
@@ -25,6 +26,16 @@
|
|
|
25
26
|
// {"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ (St|Ave)\\b/"}},"max_pct":0.5,"hard":true}
|
|
26
27
|
// records: the % of rows that satisfy `expect`, lower is better; `hard`: any rise over the base fails.
|
|
27
28
|
// `of` (a predicate) narrows which rows count, e.g. only meeting items have a date to miss.
|
|
29
|
+
// {"id":"basin-towns","kind":"coverage","field":"city","values":["Plover","Stevens Point"],"min":0.9}
|
|
30
|
+
// coverage: the share of `values` that some row holds in `field`, and the missing ones.
|
|
31
|
+
// `values` may instead be a path (relative to the golden file) to a JSON list, or to an
|
|
32
|
+
// object {value: weight} such as each town's population: the share is then by weight, and
|
|
33
|
+
// the missing are listed heaviest first, so the report names the next places to add.
|
|
34
|
+
// With a base, any fall fails: a recipe that loses a town has dropped its records.
|
|
35
|
+
//
|
|
36
|
+
// Any check may carry a `goal`: the owner's bet it serves (eval/goals.md says what each one
|
|
37
|
+
// is). The report scores each goal apart, so "trades 0.4, general 0.9" shows which bet the
|
|
38
|
+
// app is losing, instead of one mean that hides it.
|
|
28
39
|
//
|
|
29
40
|
// A predicate is {where?, match?, not?}. `where` is fangorn's own whole-value,
|
|
30
41
|
// case-insensitive match — the filter agents use. `match` maps a field (or several
|
|
@@ -37,17 +48,20 @@
|
|
|
37
48
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
38
49
|
import { createServer } from "node:http";
|
|
39
50
|
import { homedir } from "node:os";
|
|
51
|
+
import { dirname, join } from "node:path";
|
|
40
52
|
import { configure, loadShard, trimView } from "@fangorn-network/westmarch/shard";
|
|
41
|
-
import { rolesFrom, textOf, titleOf, subtitleOf } from "@fangorn-network/westmarch/roles";
|
|
53
|
+
import { rolesFrom, textOf, titleOf, subtitleOf, values } from "@fangorn-network/westmarch/roles";
|
|
42
54
|
import { search, matches } from "@fangorn-network/westmarch/tools";
|
|
43
55
|
import { EMBED_MODEL, embedQueryDirect } from "@fangorn-network/westmarch/embed";
|
|
44
56
|
|
|
45
57
|
type Pred = { where?: Record<string, string>; match?: Record<string, string>; not?: Pred };
|
|
46
|
-
type Check =
|
|
47
|
-
| {
|
|
48
|
-
| {
|
|
49
|
-
| {
|
|
50
|
-
|
|
58
|
+
type Check = { id: string; goal?: string } & (
|
|
59
|
+
| { kind?: "search"; q: string; k?: number; expect: Pred; min?: number }
|
|
60
|
+
| { kind: "count"; where?: Record<string, string>; expect?: Pred; min?: number; max?: number }
|
|
61
|
+
| { kind: "records"; of?: Pred; expect: Pred; max_pct: number; hard?: boolean }
|
|
62
|
+
| { kind: "coverage"; field: string; values: string[]; weights?: Record<string, number>; of?: Pred; min?: number });
|
|
63
|
+
type Result = { id: string; kind: "search" | "count" | "records" | "coverage" | "known"; score: number; pass: boolean;
|
|
64
|
+
goal?: string; hard?: boolean; top?: string[] };
|
|
51
65
|
type QueryVector = (q: string) => Promise<Float32Array | null>;
|
|
52
66
|
|
|
53
67
|
/** A drop below this, in mean precision@k or known@10, is "worse". canton-corpus's number. */
|
|
@@ -74,19 +88,28 @@ async function grade(rows: Row[], roles: Roles, checks: Check[], qv: QueryVector
|
|
|
74
88
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
75
89
|
const out: Result[] = [];
|
|
76
90
|
for (const c of checks) {
|
|
91
|
+
const goal = c.goal ? { goal: c.goal } : {};
|
|
77
92
|
if (c.kind === "count") {
|
|
78
93
|
const ok = compile(c.expect);
|
|
79
94
|
const n = rows.filter((r) => matches(r, c.where) && ok(r)).length;
|
|
80
|
-
out.push({ id: c.id, kind: "count", score: n, pass: n >= (c.min ?? 1) && n <= (c.max ?? Infinity) });
|
|
95
|
+
out.push({ id: c.id, kind: "count", score: n, pass: n >= (c.min ?? 1) && n <= (c.max ?? Infinity), ...goal });
|
|
81
96
|
} else if (c.kind === "records") {
|
|
82
97
|
const of = rows.filter(compile(c.of)), ok = compile(c.expect);
|
|
83
98
|
const pct = of.length ? round((100 * of.filter(ok).length) / of.length) : 0;
|
|
84
|
-
out.push({ id: c.id, kind: "records", score: pct, pass: pct <= c.max_pct, hard: c.hard });
|
|
99
|
+
out.push({ id: c.id, kind: "records", score: pct, pass: pct <= c.max_pct, hard: c.hard, ...goal });
|
|
100
|
+
} else if (c.kind === "coverage") {
|
|
101
|
+
// The same whole-value, any-case reading `where` uses, so covered means an agent's filter finds it.
|
|
102
|
+
const held = new Set(rows.filter(compile(c.of)).flatMap((r) => values(r[c.field] ?? "").map((v) => v.toLowerCase())));
|
|
103
|
+
const w = (v: string) => c.weights?.[v] ?? 1;
|
|
104
|
+
const missing = c.values.filter((v) => !held.has(v.toLowerCase())).sort((a, b) => w(b) - w(a));
|
|
105
|
+
const total = c.values.reduce((a, v) => a + w(v), 0);
|
|
106
|
+
const score = total ? round(1 - missing.reduce((a, v) => a + w(v), 0) / total) : 1;
|
|
107
|
+
out.push({ id: c.id, kind: "coverage", score, pass: score >= (c.min ?? 0), top: missing.slice(0, 10), ...goal });
|
|
85
108
|
} else {
|
|
86
109
|
const k = c.k ?? 5, ok = compile(c.expect);
|
|
87
110
|
const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id)!);
|
|
88
111
|
const score = round(hits.filter(ok).length / k);
|
|
89
|
-
out.push({ id: c.id, kind: "search", score, pass: score >= (c.min ?? 0), top: hits.slice(0, 3).map((r) => titleOf(r, roles).slice(0, 90)) });
|
|
112
|
+
out.push({ id: c.id, kind: "search", score, pass: score >= (c.min ?? 0), top: hits.slice(0, 3).map((r) => titleOf(r, roles).slice(0, 90)), ...goal });
|
|
90
113
|
}
|
|
91
114
|
}
|
|
92
115
|
// ponytail: the pool is each view's own rows, so base and candidate sample different
|
|
@@ -110,6 +133,19 @@ async function grade(rows: Row[], roles: Roles, checks: Check[], qv: QueryVector
|
|
|
110
133
|
return out;
|
|
111
134
|
}
|
|
112
135
|
|
|
136
|
+
/** Each goal's checks, scored apart: how many pass, and the mean of its searches and coverages. */
|
|
137
|
+
function byGoal(results: Result[]) {
|
|
138
|
+
const out: Record<string, { checks: number; passing: number; search: number | null; coverage: number | null }> = {};
|
|
139
|
+
const mean = (xs: number[]) => (xs.length ? round(xs.reduce((a, x) => a + x, 0) / xs.length) : null);
|
|
140
|
+
for (const g of new Set(results.map((r) => r.goal).filter((g): g is string => !!g))) {
|
|
141
|
+
const rs = results.filter((r) => r.goal === g);
|
|
142
|
+
out[g] = { checks: rs.length, passing: rs.filter((r) => r.pass).length,
|
|
143
|
+
search: mean(rs.filter((r) => r.kind === "search").map((r) => r.score)),
|
|
144
|
+
coverage: mean(rs.filter((r) => r.kind === "coverage").map((r) => r.score)) };
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
113
149
|
/** The candidate against the base. Worse on any one rule is worse. */
|
|
114
150
|
function compare(cand: Result[], base: Result[]) {
|
|
115
151
|
const was = new Map(base.map((r) => [r.id, r.score]));
|
|
@@ -120,6 +156,7 @@ function compare(cand: Result[], base: Result[]) {
|
|
|
120
156
|
// The ratchet: a question the base answered at all, the candidate must too.
|
|
121
157
|
if (r.kind === "search" && b !== undefined && b > 0 && r.score === 0) failures.push(`${r.id}: answered before, nothing now`);
|
|
122
158
|
if (r.hard && b !== undefined && r.score > b) failures.push(`${r.id}: ${b}% → ${r.score}%, and it may not rise`);
|
|
159
|
+
if (r.kind === "coverage" && b !== undefined && r.score < b) failures.push(`${r.id}: coverage ${b} → ${r.score}; missing ${r.top?.join(", ")}`);
|
|
123
160
|
}
|
|
124
161
|
// Aggregates, over the checks both sides ran: one question moving a rank is noise.
|
|
125
162
|
const both = (kind: Result["kind"]) => cand.filter((r) => (kind === "known" ? r.id === "known@10" : r.kind === kind) && was.has(r.id));
|
|
@@ -150,8 +187,14 @@ async function main() {
|
|
|
150
187
|
console.error("usage: westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]");
|
|
151
188
|
process.exit(flags.help ? 0 : 2);
|
|
152
189
|
}
|
|
153
|
-
const
|
|
190
|
+
const golden = String(flags.golden ?? "eval/golden.jsonl");
|
|
191
|
+
const checks: Check[] = readFileSync(golden, "utf8")
|
|
154
192
|
.split("\n").filter((l) => l.trim() && !l.startsWith("//")).map((l) => JSON.parse(l));
|
|
193
|
+
// A coverage list kept in its own file: a list, or {value: weight}.
|
|
194
|
+
for (const c of checks) if (c.kind === "coverage" && typeof c.values === "string") {
|
|
195
|
+
const list: string[] | Record<string, number> = JSON.parse(readFileSync(join(dirname(golden), c.values), "utf8"));
|
|
196
|
+
Object.assign(c, Array.isArray(list) ? { values: list } : { values: Object.keys(list), weights: list });
|
|
197
|
+
}
|
|
155
198
|
const known = Number(flags.known ?? 200);
|
|
156
199
|
|
|
157
200
|
const { env } = await import("@huggingface/transformers");
|
|
@@ -172,7 +215,7 @@ async function main() {
|
|
|
172
215
|
const failures = cmp?.failures ?? cand.results.filter((r) => !r.pass).map((r) => `${r.id}: ${r.score} is outside its bounds`);
|
|
173
216
|
// What reproduces this grade: the model, the runtime, and the views (whose manifests hash every shard).
|
|
174
217
|
const report = { model: EMBED_MODEL, node: process.version, platform: `${process.platform}-${process.arch}`,
|
|
175
|
-
candidate: cand, base, verdict: cmp?.verdict ?? null, deltas: cmp?.deltas ?? [], failures, pass: !failures.length };
|
|
218
|
+
goals: byGoal(cand.results), candidate: cand, base, verdict: cmp?.verdict ?? null, deltas: cmp?.deltas ?? [], failures, pass: !failures.length };
|
|
176
219
|
|
|
177
220
|
if (flags.report) writeFileSync(String(flags.report), JSON.stringify(report, null, 1));
|
|
178
221
|
if (flags.json) console.log(JSON.stringify(report, null, 1));
|
|
@@ -184,6 +227,8 @@ async function main() {
|
|
|
184
227
|
console.log(`${r.pass ? " " : "✗"} ${r.kind.padEnd(7)} ${r.id.padEnd(32)} ${b === undefined ? "" : `${b} → `}${r.score}`);
|
|
185
228
|
}
|
|
186
229
|
for (const d of report.deltas) console.log(`\n${d.kind}: ${d.base} → ${d.cand}`);
|
|
230
|
+
for (const [g, s] of Object.entries(report.goals))
|
|
231
|
+
console.log(`\ngoal ${g}: ${s.passing}/${s.checks} passing${s.search === null ? "" : `, search ${s.search}`}${s.coverage === null ? "" : `, coverage ${s.coverage}`}`);
|
|
187
232
|
console.log(`\n${report.verdict ?? (report.pass ? "pass" : "fail")}${failures.length ? `\n ${failures.join("\n ")}` : ""}`);
|
|
188
233
|
}
|
|
189
234
|
process.exit(report.pass ? 0 : 1);
|
|
@@ -234,6 +279,15 @@ async function selfcheck() {
|
|
|
234
279
|
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
235
280
|
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
236
281
|
|
|
282
|
+
// Coverage, and goals scored apart.
|
|
283
|
+
const [cov] = await grade(rows, roles, [{ id: "towns", kind: "coverage", field: "city", values: ["plover", "Stevens Point", "Wausau"], min: 0.5, goal: "towns" }], async () => null, 0);
|
|
284
|
+
assert(cov.score === 0.667 && cov.pass && cov.top?.[0] === "Wausau", `coverage is the share present, the rest listed: ${JSON.stringify(cov)}`);
|
|
285
|
+
const [pop] = await grade(rows, roles, [{ id: "pop", kind: "coverage", field: "city", values: ["Plover", "Wausau", "Adell"],
|
|
286
|
+
weights: { Plover: 30, Wausau: 60, Adell: 10 } }], async () => null, 0);
|
|
287
|
+
assert(pop.score === 0.3 && pop.top?.join() === "Wausau,Adell", `weighted coverage, the heaviest gap first: ${JSON.stringify(pop)}`);
|
|
288
|
+
const goals = byGoal([cov, { ...r.rezoning, goal: "towns" }, r.plover]);
|
|
289
|
+
assert(goals.towns.checks === 2 && goals.towns.coverage === 0.667 && goals.towns.search === 1 && !("undefined" in goals), `goals: ${JSON.stringify(goals)}`);
|
|
290
|
+
|
|
237
291
|
// The verdict.
|
|
238
292
|
const res = (id: string, score: number, kind: Result["kind"] = "search", extra = {}): Result => ({ id, kind, score, pass: true, ...extra });
|
|
239
293
|
const base = [res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })];
|
|
@@ -246,5 +300,6 @@ async function selfcheck() {
|
|
|
246
300
|
assert(compare([res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.2, "records", { hard: true })], base).verdict === "worse", "a hard record may not rise");
|
|
247
301
|
assert(compare([res("q1", 0.8, "search", { pass: false }), res("q2", 0.4)], base).verdict === "worse", "a floor fails whatever the base did");
|
|
248
302
|
assert(compare([res("q1", 0.8), res("q2", 0.4), res("q3", 0)], base).verdict === "unchanged", "a new question has no base to fall from");
|
|
303
|
+
assert(compare([res("cov", 0.9, "coverage")], [res("cov", 1, "coverage")]).verdict === "worse", "coverage may not fall");
|
|
249
304
|
console.log("eval: selfcheck ok");
|
|
250
305
|
}
|