@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 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 checks = readFileSync(String(flags.golden ?? "eval/golden.jsonl"), "utf8")
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
  }
@@ -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.4-dev",
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
- | { id: string; kind?: "search"; q: string; k?: number; expect: Pred; min?: number }
48
- | { id: string; kind: "count"; where?: Record<string, string>; expect?: Pred; min?: number; max?: number }
49
- | { id: string; kind: "records"; of?: Pred; expect: Pred; max_pct: number; hard?: boolean };
50
- type Result = { id: string; kind: "search" | "count" | "records" | "known"; score: number; pass: boolean; hard?: boolean; top?: string[] };
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 checks: Check[] = readFileSync(String(flags.golden ?? "eval/golden.jsonl"), "utf8")
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
  }