@fangorn-network/westmarch 0.0.2-dev → 0.0.3-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 +244 -0
- package/docs/app-to-agent.md +42 -0
- package/package.json +17 -10
- package/publish/eval.ts +250 -0
- package/publish/ship.js +103 -19
- package/publish/view.js +43 -8
- package/publish/westmarch.d.ts +35 -0
- package/site/agent.js +8 -0
- package/site/dist/assets/index-CaHGhiKK.js +8 -0
- package/site/dist/index.html +10 -10
- package/site/store.js +87 -0
- package/site/worker.js +103 -0
- package/site/dist/assets/index-DlzVyQ_9.js +0 -6
package/dist/ts/eval.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// westmarch-eval: does a view answer what its app says it should?
|
|
3
|
+
//
|
|
4
|
+
// westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]
|
|
5
|
+
//
|
|
6
|
+
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
7
|
+
// westmarch-eval --selfcheck
|
|
8
|
+
//
|
|
9
|
+
// It grades the RECIPE — how records are shaped, which fields are embedded, how rows
|
|
10
|
+
// rank — never the data. New records are facts, and a view is not worse for holding
|
|
11
|
+
// them. So: build a base view (main's recipe) and a candidate (the change) from the
|
|
12
|
+
// same data, on the same machine, and run both through here. The exit code is the gate.
|
|
13
|
+
//
|
|
14
|
+
// Every check runs the reader's own code (consume/tools.js `search`, the same model),
|
|
15
|
+
// so a score is what an agent calling fangorn-mcp would get, not what a test harness
|
|
16
|
+
// with its own ranker would.
|
|
17
|
+
//
|
|
18
|
+
// golden.jsonl holds one check per line. It is data, not code, so a change proposed
|
|
19
|
+
// by an agent cannot also edit how it is graded:
|
|
20
|
+
//
|
|
21
|
+
// {"id":"plover","q":"Plover village board","expect":{"where":{"city":"Plover"}},"min":0.8}
|
|
22
|
+
// search: the share of the top k (default 5) that satisfies `expect`
|
|
23
|
+
// {"id":"portage-solar","kind":"count","where":{"county":"Portage"},"expect":{"match":{"heading":"solar"}},"min":3}
|
|
24
|
+
// count: rows the agent's `where` reaches AND that satisfy `expect` — can a filter get to them at all
|
|
25
|
+
// {"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ (St|Ave)\\b/"}},"max_pct":0.5,"hard":true}
|
|
26
|
+
// records: the % of rows that satisfy `expect`, lower is better; `hard`: any rise over the base fails.
|
|
27
|
+
// `of` (a predicate) narrows which rows count, e.g. only meeting items have a date to miss.
|
|
28
|
+
//
|
|
29
|
+
// A predicate is {where?, match?, not?}. `where` is fangorn's own whole-value,
|
|
30
|
+
// case-insensitive match — the filter agents use. `match` maps a field (or several
|
|
31
|
+
// joined by `|`) to a regex, case-insensitive unless written /like this/flags. `not`
|
|
32
|
+
// negates another predicate.
|
|
33
|
+
//
|
|
34
|
+
// Built in, with no labels: known items. N rows are sampled (seeded, so reruns agree)
|
|
35
|
+
// and searched by their own title; `known@1`/`known@10` are how often they come back.
|
|
36
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
37
|
+
import { createServer } from "node:http";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { configure, loadShard, trimView } from "@fangorn-network/westmarch/shard";
|
|
40
|
+
import { rolesFrom, textOf, titleOf, subtitleOf } from "@fangorn-network/westmarch/roles";
|
|
41
|
+
import { search, matches } from "@fangorn-network/westmarch/tools";
|
|
42
|
+
import { EMBED_MODEL, embedQueryDirect } from "@fangorn-network/westmarch/embed";
|
|
43
|
+
/** A drop below this, in mean precision@k or known@10, is "worse". canton-corpus's number. */
|
|
44
|
+
const TOLERANCE = 0.05;
|
|
45
|
+
const regex = (s) => {
|
|
46
|
+
const m = /^\/(.*)\/([a-z]*)$/s.exec(s);
|
|
47
|
+
return m ? new RegExp(m[1], m[2]) : new RegExp(s, "i");
|
|
48
|
+
};
|
|
49
|
+
const fieldText = (v) => (Array.isArray(v) ? v.join(" ") : String(v ?? ""));
|
|
50
|
+
/** A predicate, compiled once: it runs against every row. */
|
|
51
|
+
function compile(p = {}) {
|
|
52
|
+
const res = Object.entries(p.match ?? {}).map(([k, re]) => [k.split("|"), regex(re)]);
|
|
53
|
+
const not = p.not ? compile(p.not) : null;
|
|
54
|
+
return (r) => matches(r, p.where)
|
|
55
|
+
&& res.every(([fs, re]) => re.test(fs.map((f) => fieldText(r[f])).join(" ")))
|
|
56
|
+
&& !not?.(r);
|
|
57
|
+
}
|
|
58
|
+
const round = (x) => Math.round(x * 1000) / 1000;
|
|
59
|
+
async function grade(rows, roles, checks, qv, known) {
|
|
60
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const c of checks) {
|
|
63
|
+
if (c.kind === "count") {
|
|
64
|
+
const ok = compile(c.expect);
|
|
65
|
+
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) });
|
|
67
|
+
}
|
|
68
|
+
else if (c.kind === "records") {
|
|
69
|
+
const of = rows.filter(compile(c.of)), ok = compile(c.expect);
|
|
70
|
+
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 });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const k = c.k ?? 5, ok = compile(c.expect);
|
|
75
|
+
const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id));
|
|
76
|
+
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)) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ponytail: the pool is each view's own rows, so base and candidate sample different
|
|
81
|
+
// rows once their records differ. TOLERANCE absorbs it; sample ids from the base if it doesn't.
|
|
82
|
+
const pool = rows.filter((r) => titleOf(r, roles).split(/\s+/).length >= 4);
|
|
83
|
+
const n = Math.min(known, pool.length);
|
|
84
|
+
if (n) {
|
|
85
|
+
let seed = 42;
|
|
86
|
+
const rand = () => (seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 2 ** 32;
|
|
87
|
+
// Many rows share a title ("Approve the minutes"): the same title and subtitle is a find.
|
|
88
|
+
const same = (a, b) => a.id === b.id || (titleOf(a, roles) === titleOf(b, roles) && subtitleOf(a, roles) === subtitleOf(b, roles));
|
|
89
|
+
let at1 = 0, at10 = 0;
|
|
90
|
+
for (let i = 0; i < n; i++) {
|
|
91
|
+
const r = pool[Math.floor(rand() * pool.length)], t = titleOf(r, roles);
|
|
92
|
+
const at = search(rows, t, roles, { qv: await qv(t), limit: 10 }).findIndex((h) => same(byId.get(h.id), r));
|
|
93
|
+
at1 += +(at === 0);
|
|
94
|
+
at10 += +(at >= 0);
|
|
95
|
+
}
|
|
96
|
+
out.push({ id: "known@1", kind: "known", score: round(at1 / n), pass: true }, { id: "known@10", kind: "known", score: round(at10 / n), pass: true });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** The candidate against the base. Worse on any one rule is worse. */
|
|
101
|
+
function compare(cand, base) {
|
|
102
|
+
const was = new Map(base.map((r) => [r.id, r.score]));
|
|
103
|
+
const failures = [];
|
|
104
|
+
for (const r of cand) {
|
|
105
|
+
const b = was.get(r.id);
|
|
106
|
+
if (!r.pass)
|
|
107
|
+
failures.push(`${r.id}: ${r.score} is outside its bounds`);
|
|
108
|
+
// The ratchet: a question the base answered at all, the candidate must too.
|
|
109
|
+
if (r.kind === "search" && b !== undefined && b > 0 && r.score === 0)
|
|
110
|
+
failures.push(`${r.id}: answered before, nothing now`);
|
|
111
|
+
if (r.hard && b !== undefined && r.score > b)
|
|
112
|
+
failures.push(`${r.id}: ${b}% → ${r.score}%, and it may not rise`);
|
|
113
|
+
}
|
|
114
|
+
// Aggregates, over the checks both sides ran: one question moving a rank is noise.
|
|
115
|
+
const both = (kind) => cand.filter((r) => (kind === "known" ? r.id === "known@10" : r.kind === kind) && was.has(r.id));
|
|
116
|
+
const mean = (rs, f) => rs.reduce((a, r) => a + f(r), 0) / rs.length;
|
|
117
|
+
const deltas = ["search", "known"].flatMap((kind) => {
|
|
118
|
+
const rs = both(kind);
|
|
119
|
+
return rs.length ? [{ kind, cand: round(mean(rs, (r) => r.score)), base: round(mean(rs, (r) => was.get(r.id))) }] : [];
|
|
120
|
+
});
|
|
121
|
+
for (const d of deltas)
|
|
122
|
+
if (d.cand < d.base - TOLERANCE)
|
|
123
|
+
failures.push(`${d.kind}: ${d.base} → ${d.cand}`);
|
|
124
|
+
const verdict = failures.length ? "worse" : deltas.some((d) => d.cand > d.base + TOLERANCE) ? "better" : "unchanged";
|
|
125
|
+
return { verdict, deltas, failures };
|
|
126
|
+
}
|
|
127
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
128
|
+
if (process.argv[2] === "--selfcheck")
|
|
129
|
+
await selfcheck();
|
|
130
|
+
else if (realpathSync(process.argv[1]).match(/\/eval\.[jt]s$/))
|
|
131
|
+
await main();
|
|
132
|
+
async function main() {
|
|
133
|
+
const flags = {};
|
|
134
|
+
const pos = [];
|
|
135
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
136
|
+
const m = /^--([^=]+)(?:=(.*))?$/.exec(process.argv[i]);
|
|
137
|
+
if (!m)
|
|
138
|
+
pos.push(process.argv[i]);
|
|
139
|
+
else
|
|
140
|
+
flags[m[1]] = m[2] ?? (["json", "help"].includes(m[1]) ? true : process.argv[++i]);
|
|
141
|
+
}
|
|
142
|
+
if (flags.help || pos.length !== 1) {
|
|
143
|
+
console.error("usage: westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]");
|
|
144
|
+
process.exit(flags.help ? 0 : 2);
|
|
145
|
+
}
|
|
146
|
+
const checks = readFileSync(String(flags.golden ?? "eval/golden.jsonl"), "utf8")
|
|
147
|
+
.split("\n").filter((l) => l.trim() && !l.startsWith("//")).map((l) => JSON.parse(l));
|
|
148
|
+
const known = Number(flags.known ?? 200);
|
|
149
|
+
const { env } = await import("@huggingface/transformers");
|
|
150
|
+
env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`; // shared with fangorn-mcp and westmarch-view
|
|
151
|
+
const roles = new Map();
|
|
152
|
+
configure({ onManifests: (ms, v) => { roles.set(v, rolesFrom(ms)); }, rowText: (f, v) => textOf(f, roles.get(v)) });
|
|
153
|
+
// No fallback to word matching: a grade the reader would not get is not a grade.
|
|
154
|
+
const vectors = new Map();
|
|
155
|
+
const qv = (q) => vectors.get(q) ?? vectors.set(q, embedQueryDirect(q)).get(q);
|
|
156
|
+
const run = async (at) => {
|
|
157
|
+
const v = existsSync(at) ? await serve(at) : trimView(at), rows = await loadShard(v);
|
|
158
|
+
return { view: at, rows: rows.length, results: await grade(rows, roles.get(v), checks, qv, known) };
|
|
159
|
+
};
|
|
160
|
+
const cand = await run(pos[0]);
|
|
161
|
+
const base = flags.base ? await run(String(flags.base)) : null;
|
|
162
|
+
const cmp = base ? compare(cand.results, base.results) : null;
|
|
163
|
+
const failures = cmp?.failures ?? cand.results.filter((r) => !r.pass).map((r) => `${r.id}: ${r.score} is outside its bounds`);
|
|
164
|
+
// What reproduces this grade: the model, the runtime, and the views (whose manifests hash every shard).
|
|
165
|
+
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 };
|
|
167
|
+
if (flags.report)
|
|
168
|
+
writeFileSync(String(flags.report), JSON.stringify(report, null, 1));
|
|
169
|
+
if (flags.json)
|
|
170
|
+
console.log(JSON.stringify(report, null, 1));
|
|
171
|
+
else {
|
|
172
|
+
const was = new Map(base?.results.map((r) => [r.id, r.score]));
|
|
173
|
+
console.log(`${cand.view}: ${cand.rows} rows${base ? ` (base ${base.view}: ${base.rows} rows)` : ""}\n`);
|
|
174
|
+
for (const r of cand.results) {
|
|
175
|
+
const b = was.get(r.id);
|
|
176
|
+
console.log(`${r.pass ? " " : "✗"} ${r.kind.padEnd(7)} ${r.id.padEnd(32)} ${b === undefined ? "" : `${b} → `}${r.score}`);
|
|
177
|
+
}
|
|
178
|
+
for (const d of report.deltas)
|
|
179
|
+
console.log(`\n${d.kind}: ${d.base} → ${d.cand}`);
|
|
180
|
+
console.log(`\n${report.verdict ?? (report.pass ? "pass" : "fail")}${failures.length ? `\n ${failures.join("\n ")}` : ""}`);
|
|
181
|
+
}
|
|
182
|
+
process.exit(report.pass ? 0 : 1);
|
|
183
|
+
}
|
|
184
|
+
/** A view directory over HTTP on a free port, for this process's lifetime. */
|
|
185
|
+
async function serve(dir) {
|
|
186
|
+
const srv = createServer((q, s) => {
|
|
187
|
+
try {
|
|
188
|
+
s.end(readFileSync(`${dir}${decodeURIComponent(new URL(q.url ?? "/", "http://x").pathname)}`));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
s.statusCode = 404;
|
|
192
|
+
s.end();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
await new Promise((ok) => srv.listen(0, "127.0.0.1", ok));
|
|
196
|
+
srv.unref();
|
|
197
|
+
const a = srv.address();
|
|
198
|
+
return `http://127.0.0.1:${typeof a === "object" && a ? a.port : 0}`;
|
|
199
|
+
}
|
|
200
|
+
async function selfcheck() {
|
|
201
|
+
const assert = (ok, msg) => { if (!ok)
|
|
202
|
+
throw new Error(msg); };
|
|
203
|
+
const roles = rolesFrom([{ role_map: { title: "heading", tags: ["city"], text: ["text"] } }]);
|
|
204
|
+
const rows = [
|
|
205
|
+
{ id: "a", heading: "Rezoning of the lot on Main", city: "Plover", text: "Rezoning of the lot on Main. Approved 5-0." },
|
|
206
|
+
{ id: "b", heading: "Liquor license for the Brewing Company", city: "Plover", text: "Liquor license for the Brewing Company." },
|
|
207
|
+
{ id: "c", heading: "Water main replacement on Post Road", city: "Stevens Point", county: ["Portage", "Wood"], text: "Water main replacement on Post Road, 1200 Post Rd." },
|
|
208
|
+
{ id: "d", heading: "Snow removal contract for the winter", city: "Stevens Point", text: "Snow removal contract for the winter season." },
|
|
209
|
+
];
|
|
210
|
+
// Predicates: fangorn's where (whole value, any case, arrays), field-joined regex, /flags/, not.
|
|
211
|
+
assert(compile({ where: { city: "plover" } })(rows[0]), "where matches case-insensitively");
|
|
212
|
+
assert(!compile({ where: { city: "Plov" } })(rows[0]), "where matches whole values only");
|
|
213
|
+
assert(compile({ where: { county: "wood" } })(rows[2]), "where matches inside an array");
|
|
214
|
+
assert(compile({ match: { "heading|text": "approved" } })(rows[0]), "match joins the fields it names");
|
|
215
|
+
assert(!compile({ match: { heading: "/rezoning/" } })(rows[0]), "/re/ is case-sensitive");
|
|
216
|
+
assert(!compile({ match: { heading: "rezon" }, not: { where: { city: "Plover" } } })(rows[0]), "not negates");
|
|
217
|
+
const checks = [
|
|
218
|
+
{ id: "rezoning", q: "rezoning", expect: { match: { heading: "rezon" } }, k: 1, min: 1 },
|
|
219
|
+
{ id: "plover", kind: "count", where: { city: "Plover" }, min: 2, max: 2 },
|
|
220
|
+
{ id: "wells", kind: "count", where: { city: "Plover" }, expect: { match: { heading: "well" } } },
|
|
221
|
+
{ id: "addresses", kind: "records", expect: { match: { text: "/\\b\\d{2,6} [A-Z]\\w+ (Rd|St)\\b/" } }, max_pct: 30, hard: true },
|
|
222
|
+
];
|
|
223
|
+
const r = Object.fromEntries((await grade(rows, roles, checks, async () => null, 2)).map((x) => [x.id, x]));
|
|
224
|
+
assert(r.rezoning.score === 1 && r.rezoning.pass, `lexical search finds the rezoning: ${JSON.stringify(r.rezoning)}`);
|
|
225
|
+
assert(r.plover.score === 2 && r.plover.pass, "count reaches both Plover rows");
|
|
226
|
+
assert(r.wells.score === 0 && !r.wells.pass, "a count with nothing to reach fails (min defaults to 1)");
|
|
227
|
+
assert(r.addresses.score === 25 && r.addresses.pass, `records is a percentage: ${r.addresses.score}`);
|
|
228
|
+
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
|
+
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
230
|
+
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
231
|
+
// The verdict.
|
|
232
|
+
const res = (id, score, kind = "search", extra = {}) => ({ id, kind, score, pass: true, ...extra });
|
|
233
|
+
const base = [res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })];
|
|
234
|
+
assert(compare(base, base).verdict === "unchanged", "same is unchanged");
|
|
235
|
+
assert(compare([res("q1", 0.8), res("q2", 0.4), res("known@10", 0.93, "known"), res("pii", 0.1, "records", { hard: true })], base).verdict === "unchanged", "within tolerance is unchanged");
|
|
236
|
+
assert(compare([res("q1", 1), res("q2", 0.6), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })], base).verdict === "better", "a mean rise is better");
|
|
237
|
+
assert(compare([res("q1", 0.6), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })], base).verdict === "worse", "a mean drop is worse");
|
|
238
|
+
const ratchet = compare([res("q1", 1), res("q2", 0), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })], base);
|
|
239
|
+
assert(ratchet.verdict === "worse" && /answered before/.test(ratchet.failures[0]), "the ratchet holds even when the mean does not drop");
|
|
240
|
+
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
|
+
assert(compare([res("q1", 0.8, "search", { pass: false }), res("q2", 0.4)], base).verdict === "worse", "a floor fails whatever the base did");
|
|
242
|
+
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");
|
|
243
|
+
console.log("eval: selfcheck ok");
|
|
244
|
+
}
|
package/docs/app-to-agent.md
CHANGED
|
@@ -97,6 +97,48 @@ five towns' meetings crawled, published, embedded and deployed by `westmarch-shi
|
|
|
97
97
|
searched and bought (x402, settled on Arbitrum Sepolia) from `fangorn-mcp` in a fresh
|
|
98
98
|
session. Kingsfoil, the reference at the end, predates `ship` and was built by hand.
|
|
99
99
|
|
|
100
|
+
## Grading a change before it ships
|
|
101
|
+
|
|
102
|
+
New records are facts, and they always ship. What can make an app worse is its
|
|
103
|
+
**recipe**: how sources shape records, which fields `app.json`'s role maps embed, how a
|
|
104
|
+
view splits them. So a recipe change is graded against the recipe it replaces, over the
|
|
105
|
+
same records, before it merges.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
npx westmarch-ship app.json --local /tmp/base-view # a view from what the sources staged; no chain, no deploy
|
|
109
|
+
# …change app.json or a source…
|
|
110
|
+
npx westmarch-ship app.json --local /tmp/cand-view
|
|
111
|
+
npx westmarch-eval /tmp/cand-view --base /tmp/base-view # exit 1 = worse
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`--local` builds every record every time (a changed `text` role must reach them all) and
|
|
115
|
+
remembers vectors in `.ship/vectors.ndjson` by the exact text embedded, so the second
|
|
116
|
+
build embeds only what the change touched.
|
|
117
|
+
|
|
118
|
+
What is asked lives in `eval/golden.jsonl`, one check per line, as data rather than code:
|
|
119
|
+
|
|
120
|
+
```jsonc
|
|
121
|
+
{"id":"plover","q":"Plover village board","expect":{"where":{"city":"Plover"}},"min":0.8} // share of the top 5 that fits
|
|
122
|
+
{"id":"wells","kind":"count","where":{"county":"Portage"},"expect":{"match":{"heading":"well"}}} // can a filter reach them at all
|
|
123
|
+
{"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ St\\b/"}},"max_pct":0.5,"hard":true}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`expect` is a predicate over a row: `where` (the whole-value match agents filter with),
|
|
127
|
+
`match` (a field, or several joined by `|`, to a regex; case-insensitive unless written
|
|
128
|
+
`/re/flags`), `not`. Relevance is a predicate and not a list of record ids, because ids
|
|
129
|
+
are content hashes and a recipe change that re-parses records changes every one.
|
|
130
|
+
Without labels, it also searches 200 sampled rows by their own titles (`known@1`, `known@10`).
|
|
131
|
+
|
|
132
|
+
Worse is: mean precision or `known@10` down more than 0.05, any question the base
|
|
133
|
+
answered now answered by nothing, a `hard` record check rising, or any floor or ceiling
|
|
134
|
+
missed. Quorum's `.github/workflows/eval.yml` runs this on every pull request, from the
|
|
135
|
+
records and vectors its `ship.yml` leaves in the cache, with no secrets; make it a
|
|
136
|
+
required check on `main`, and put `eval/`, `.github/` and `app.json` under `CODEOWNERS`,
|
|
137
|
+
or a PR can change the gate along with what it gates.
|
|
138
|
+
|
|
139
|
+
What is not graded yet: a change to how a source *parses*. `--local` reads what the
|
|
140
|
+
sources last staged; it does not replay their parsing over the raw documents.
|
|
141
|
+
|
|
100
142
|
## The long way, by hand
|
|
101
143
|
|
|
102
144
|
What `ship` does, one step at a time: for understanding it, or for doing one step differently.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3-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",
|
|
@@ -34,14 +34,16 @@
|
|
|
34
34
|
"./package.json": "./package.json"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"
|
|
37
|
+
"build": "tsc -p .",
|
|
38
|
+
"prepare": "tsc -p .",
|
|
39
|
+
"test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/eval.ts --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js && node site/store.js",
|
|
38
40
|
"lint": "node consume/lint.js"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
|
-
"@huggingface/transformers": "^4.2.0",
|
|
42
|
-
"viem": "^2.55.1",
|
|
43
43
|
"@fangorn-network/sdk": "^2026.9.22-dev",
|
|
44
|
-
"@
|
|
44
|
+
"@huggingface/transformers": "^4.2.0",
|
|
45
|
+
"@noble/curves": "^1.9.1",
|
|
46
|
+
"viem": "^2.55.1"
|
|
45
47
|
},
|
|
46
48
|
"peerDependenciesMeta": {
|
|
47
49
|
"@huggingface/transformers": {
|
|
@@ -49,16 +51,18 @@
|
|
|
49
51
|
}
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
|
-
"@huggingface/transformers": "^4.2.0",
|
|
53
|
-
"viem": "^2.56.3",
|
|
54
54
|
"@fangorn-network/sdk": "^2026.9.22-dev",
|
|
55
|
-
"@
|
|
55
|
+
"@huggingface/transformers": "^4.2.0",
|
|
56
|
+
"@noble/curves": "^1.9.1",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"viem": "^2.56.3"
|
|
56
59
|
},
|
|
57
60
|
"bin": {
|
|
58
61
|
"westmarch-publish": "./publish/cli.js",
|
|
59
62
|
"fangorn-mcp": "./consume/mcp.js",
|
|
60
63
|
"westmarch-view": "./publish/view-cli.js",
|
|
61
|
-
"westmarch-ship": "./publish/ship.js"
|
|
64
|
+
"westmarch-ship": "./publish/ship.js",
|
|
65
|
+
"westmarch-eval": "./dist/ts/eval.js"
|
|
62
66
|
},
|
|
63
67
|
"files": [
|
|
64
68
|
"consume",
|
|
@@ -66,7 +70,10 @@
|
|
|
66
70
|
"docs/app-to-agent.md",
|
|
67
71
|
"site/agent.js",
|
|
68
72
|
"site/dist",
|
|
69
|
-
"site/feed.js"
|
|
73
|
+
"site/feed.js",
|
|
74
|
+
"site/worker.js",
|
|
75
|
+
"site/store.js",
|
|
76
|
+
"dist/ts"
|
|
70
77
|
],
|
|
71
78
|
"engines": {
|
|
72
79
|
"node": ">=20"
|
package/publish/eval.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// westmarch-eval: does a view answer what its app says it should?
|
|
3
|
+
//
|
|
4
|
+
// westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]
|
|
5
|
+
//
|
|
6
|
+
// A view is a URL or a directory (westmarch-ship --local writes one), served here as a reader fetches it.
|
|
7
|
+
// westmarch-eval --selfcheck
|
|
8
|
+
//
|
|
9
|
+
// It grades the RECIPE — how records are shaped, which fields are embedded, how rows
|
|
10
|
+
// rank — never the data. New records are facts, and a view is not worse for holding
|
|
11
|
+
// them. So: build a base view (main's recipe) and a candidate (the change) from the
|
|
12
|
+
// same data, on the same machine, and run both through here. The exit code is the gate.
|
|
13
|
+
//
|
|
14
|
+
// Every check runs the reader's own code (consume/tools.js `search`, the same model),
|
|
15
|
+
// so a score is what an agent calling fangorn-mcp would get, not what a test harness
|
|
16
|
+
// with its own ranker would.
|
|
17
|
+
//
|
|
18
|
+
// golden.jsonl holds one check per line. It is data, not code, so a change proposed
|
|
19
|
+
// by an agent cannot also edit how it is graded:
|
|
20
|
+
//
|
|
21
|
+
// {"id":"plover","q":"Plover village board","expect":{"where":{"city":"Plover"}},"min":0.8}
|
|
22
|
+
// search: the share of the top k (default 5) that satisfies `expect`
|
|
23
|
+
// {"id":"portage-solar","kind":"count","where":{"county":"Portage"},"expect":{"match":{"heading":"solar"}},"min":3}
|
|
24
|
+
// count: rows the agent's `where` reaches AND that satisfy `expect` — can a filter get to them at all
|
|
25
|
+
// {"id":"addresses","kind":"records","expect":{"match":{"text":"/\\b\\d{2,6} [A-Z]\\w+ (St|Ave)\\b/"}},"max_pct":0.5,"hard":true}
|
|
26
|
+
// records: the % of rows that satisfy `expect`, lower is better; `hard`: any rise over the base fails.
|
|
27
|
+
// `of` (a predicate) narrows which rows count, e.g. only meeting items have a date to miss.
|
|
28
|
+
//
|
|
29
|
+
// A predicate is {where?, match?, not?}. `where` is fangorn's own whole-value,
|
|
30
|
+
// case-insensitive match — the filter agents use. `match` maps a field (or several
|
|
31
|
+
// joined by `|`) to a regex, case-insensitive unless written /like this/flags. `not`
|
|
32
|
+
// negates another predicate.
|
|
33
|
+
//
|
|
34
|
+
// Built in, with no labels: known items. N rows are sampled (seeded, so reruns agree)
|
|
35
|
+
// and searched by their own title; `known@1`/`known@10` are how often they come back.
|
|
36
|
+
|
|
37
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { createServer } from "node:http";
|
|
39
|
+
import { homedir } from "node:os";
|
|
40
|
+
import { configure, loadShard, trimView } from "@fangorn-network/westmarch/shard";
|
|
41
|
+
import { rolesFrom, textOf, titleOf, subtitleOf } from "@fangorn-network/westmarch/roles";
|
|
42
|
+
import { search, matches } from "@fangorn-network/westmarch/tools";
|
|
43
|
+
import { EMBED_MODEL, embedQueryDirect } from "@fangorn-network/westmarch/embed";
|
|
44
|
+
|
|
45
|
+
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[] };
|
|
51
|
+
type QueryVector = (q: string) => Promise<Float32Array | null>;
|
|
52
|
+
|
|
53
|
+
/** A drop below this, in mean precision@k or known@10, is "worse". canton-corpus's number. */
|
|
54
|
+
const TOLERANCE = 0.05;
|
|
55
|
+
|
|
56
|
+
const regex = (s: string) => {
|
|
57
|
+
const m = /^\/(.*)\/([a-z]*)$/s.exec(s);
|
|
58
|
+
return m ? new RegExp(m[1], m[2]) : new RegExp(s, "i");
|
|
59
|
+
};
|
|
60
|
+
const fieldText = (v: unknown) => (Array.isArray(v) ? v.join(" ") : String(v ?? ""));
|
|
61
|
+
|
|
62
|
+
/** A predicate, compiled once: it runs against every row. */
|
|
63
|
+
function compile(p: Pred = {}): (r: Row) => boolean {
|
|
64
|
+
const res = Object.entries(p.match ?? {}).map(([k, re]) => [k.split("|"), regex(re)] as const);
|
|
65
|
+
const not = p.not ? compile(p.not) : null;
|
|
66
|
+
return (r) => matches(r, p.where)
|
|
67
|
+
&& res.every(([fs, re]) => re.test(fs.map((f) => fieldText(r[f])).join(" ")))
|
|
68
|
+
&& !not?.(r);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const round = (x: number) => Math.round(x * 1000) / 1000;
|
|
72
|
+
|
|
73
|
+
async function grade(rows: Row[], roles: Roles, checks: Check[], qv: QueryVector, known: number): Promise<Result[]> {
|
|
74
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
75
|
+
const out: Result[] = [];
|
|
76
|
+
for (const c of checks) {
|
|
77
|
+
if (c.kind === "count") {
|
|
78
|
+
const ok = compile(c.expect);
|
|
79
|
+
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) });
|
|
81
|
+
} else if (c.kind === "records") {
|
|
82
|
+
const of = rows.filter(compile(c.of)), ok = compile(c.expect);
|
|
83
|
+
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 });
|
|
85
|
+
} else {
|
|
86
|
+
const k = c.k ?? 5, ok = compile(c.expect);
|
|
87
|
+
const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id)!);
|
|
88
|
+
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)) });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ponytail: the pool is each view's own rows, so base and candidate sample different
|
|
93
|
+
// rows once their records differ. TOLERANCE absorbs it; sample ids from the base if it doesn't.
|
|
94
|
+
const pool = rows.filter((r) => titleOf(r, roles).split(/\s+/).length >= 4);
|
|
95
|
+
const n = Math.min(known, pool.length);
|
|
96
|
+
if (n) {
|
|
97
|
+
let seed = 42;
|
|
98
|
+
const rand = () => (seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 2 ** 32;
|
|
99
|
+
// Many rows share a title ("Approve the minutes"): the same title and subtitle is a find.
|
|
100
|
+
const same = (a: Row, b: Row) => a.id === b.id || (titleOf(a, roles) === titleOf(b, roles) && subtitleOf(a, roles) === subtitleOf(b, roles));
|
|
101
|
+
let at1 = 0, at10 = 0;
|
|
102
|
+
for (let i = 0; i < n; i++) {
|
|
103
|
+
const r = pool[Math.floor(rand() * pool.length)], t = titleOf(r, roles);
|
|
104
|
+
const at = search(rows, t, roles, { qv: await qv(t), limit: 10 }).findIndex((h) => same(byId.get(h.id)!, r));
|
|
105
|
+
at1 += +(at === 0); at10 += +(at >= 0);
|
|
106
|
+
}
|
|
107
|
+
out.push({ id: "known@1", kind: "known", score: round(at1 / n), pass: true },
|
|
108
|
+
{ id: "known@10", kind: "known", score: round(at10 / n), pass: true });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The candidate against the base. Worse on any one rule is worse. */
|
|
114
|
+
function compare(cand: Result[], base: Result[]) {
|
|
115
|
+
const was = new Map(base.map((r) => [r.id, r.score]));
|
|
116
|
+
const failures: string[] = [];
|
|
117
|
+
for (const r of cand) {
|
|
118
|
+
const b = was.get(r.id);
|
|
119
|
+
if (!r.pass) failures.push(`${r.id}: ${r.score} is outside its bounds`);
|
|
120
|
+
// The ratchet: a question the base answered at all, the candidate must too.
|
|
121
|
+
if (r.kind === "search" && b !== undefined && b > 0 && r.score === 0) failures.push(`${r.id}: answered before, nothing now`);
|
|
122
|
+
if (r.hard && b !== undefined && r.score > b) failures.push(`${r.id}: ${b}% → ${r.score}%, and it may not rise`);
|
|
123
|
+
}
|
|
124
|
+
// Aggregates, over the checks both sides ran: one question moving a rank is noise.
|
|
125
|
+
const both = (kind: Result["kind"]) => cand.filter((r) => (kind === "known" ? r.id === "known@10" : r.kind === kind) && was.has(r.id));
|
|
126
|
+
const mean = (rs: Result[], f: (r: Result) => number) => rs.reduce((a, r) => a + f(r), 0) / rs.length;
|
|
127
|
+
const deltas = (["search", "known"] as const).flatMap((kind) => {
|
|
128
|
+
const rs = both(kind);
|
|
129
|
+
return rs.length ? [{ kind, cand: round(mean(rs, (r) => r.score)), base: round(mean(rs, (r) => was.get(r.id)!)) }] : [];
|
|
130
|
+
});
|
|
131
|
+
for (const d of deltas) if (d.cand < d.base - TOLERANCE) failures.push(`${d.kind}: ${d.base} → ${d.cand}`);
|
|
132
|
+
const verdict = failures.length ? "worse" : deltas.some((d) => d.cand > d.base + TOLERANCE) ? "better" : "unchanged";
|
|
133
|
+
return { verdict, deltas, failures };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
if (process.argv[2] === "--selfcheck") await selfcheck();
|
|
139
|
+
else if (realpathSync(process.argv[1]).match(/\/eval\.[jt]s$/)) await main();
|
|
140
|
+
|
|
141
|
+
async function main() {
|
|
142
|
+
const flags: Record<string, string | boolean> = {};
|
|
143
|
+
const pos: string[] = [];
|
|
144
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
145
|
+
const m = /^--([^=]+)(?:=(.*))?$/.exec(process.argv[i]);
|
|
146
|
+
if (!m) pos.push(process.argv[i]);
|
|
147
|
+
else flags[m[1]] = m[2] ?? (["json", "help"].includes(m[1]) ? true : process.argv[++i]);
|
|
148
|
+
}
|
|
149
|
+
if (flags.help || pos.length !== 1) {
|
|
150
|
+
console.error("usage: westmarch-eval <view> [--golden eval/golden.jsonl] [--base <view>] [--known 200] [--json] [--report out.json]");
|
|
151
|
+
process.exit(flags.help ? 0 : 2);
|
|
152
|
+
}
|
|
153
|
+
const checks: Check[] = readFileSync(String(flags.golden ?? "eval/golden.jsonl"), "utf8")
|
|
154
|
+
.split("\n").filter((l) => l.trim() && !l.startsWith("//")).map((l) => JSON.parse(l));
|
|
155
|
+
const known = Number(flags.known ?? 200);
|
|
156
|
+
|
|
157
|
+
const { env } = await import("@huggingface/transformers");
|
|
158
|
+
env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`; // shared with fangorn-mcp and westmarch-view
|
|
159
|
+
const roles = new Map<string, Roles>();
|
|
160
|
+
configure({ onManifests: (ms, v) => { roles.set(v, rolesFrom(ms)); }, rowText: (f, v) => textOf(f, roles.get(v)!) });
|
|
161
|
+
// No fallback to word matching: a grade the reader would not get is not a grade.
|
|
162
|
+
const vectors = new Map<string, Promise<Float32Array>>();
|
|
163
|
+
const qv = (q: string) => vectors.get(q) ?? vectors.set(q, embedQueryDirect(q)).get(q)!;
|
|
164
|
+
const run = async (at: string) => {
|
|
165
|
+
const v = existsSync(at) ? await serve(at) : trimView(at), rows = await loadShard(v);
|
|
166
|
+
return { view: at, rows: rows.length, results: await grade(rows, roles.get(v)!, checks, qv, known) };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const cand = await run(pos[0]);
|
|
170
|
+
const base = flags.base ? await run(String(flags.base)) : null;
|
|
171
|
+
const cmp = base ? compare(cand.results, base.results) : null;
|
|
172
|
+
const failures = cmp?.failures ?? cand.results.filter((r) => !r.pass).map((r) => `${r.id}: ${r.score} is outside its bounds`);
|
|
173
|
+
// What reproduces this grade: the model, the runtime, and the views (whose manifests hash every shard).
|
|
174
|
+
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 };
|
|
176
|
+
|
|
177
|
+
if (flags.report) writeFileSync(String(flags.report), JSON.stringify(report, null, 1));
|
|
178
|
+
if (flags.json) console.log(JSON.stringify(report, null, 1));
|
|
179
|
+
else {
|
|
180
|
+
const was = new Map(base?.results.map((r) => [r.id, r.score]));
|
|
181
|
+
console.log(`${cand.view}: ${cand.rows} rows${base ? ` (base ${base.view}: ${base.rows} rows)` : ""}\n`);
|
|
182
|
+
for (const r of cand.results) {
|
|
183
|
+
const b = was.get(r.id);
|
|
184
|
+
console.log(`${r.pass ? " " : "✗"} ${r.kind.padEnd(7)} ${r.id.padEnd(32)} ${b === undefined ? "" : `${b} → `}${r.score}`);
|
|
185
|
+
}
|
|
186
|
+
for (const d of report.deltas) console.log(`\n${d.kind}: ${d.base} → ${d.cand}`);
|
|
187
|
+
console.log(`\n${report.verdict ?? (report.pass ? "pass" : "fail")}${failures.length ? `\n ${failures.join("\n ")}` : ""}`);
|
|
188
|
+
}
|
|
189
|
+
process.exit(report.pass ? 0 : 1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A view directory over HTTP on a free port, for this process's lifetime. */
|
|
193
|
+
async function serve(dir: string): Promise<string> {
|
|
194
|
+
const srv = createServer((q, s) => {
|
|
195
|
+
try { s.end(readFileSync(`${dir}${decodeURIComponent(new URL(q.url ?? "/", "http://x").pathname)}`)); }
|
|
196
|
+
catch { s.statusCode = 404; s.end(); }
|
|
197
|
+
});
|
|
198
|
+
await new Promise<void>((ok) => srv.listen(0, "127.0.0.1", ok));
|
|
199
|
+
srv.unref();
|
|
200
|
+
const a = srv.address();
|
|
201
|
+
return `http://127.0.0.1:${typeof a === "object" && a ? a.port : 0}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function selfcheck() {
|
|
205
|
+
const assert = (ok: unknown, msg: string) => { if (!ok) throw new Error(msg); };
|
|
206
|
+
const roles = rolesFrom([{ role_map: { title: "heading", tags: ["city"], text: ["text"] } }]);
|
|
207
|
+
const rows: Row[] = [
|
|
208
|
+
{ id: "a", heading: "Rezoning of the lot on Main", city: "Plover", text: "Rezoning of the lot on Main. Approved 5-0." },
|
|
209
|
+
{ id: "b", heading: "Liquor license for the Brewing Company", city: "Plover", text: "Liquor license for the Brewing Company." },
|
|
210
|
+
{ id: "c", heading: "Water main replacement on Post Road", city: "Stevens Point", county: ["Portage", "Wood"], text: "Water main replacement on Post Road, 1200 Post Rd." },
|
|
211
|
+
{ id: "d", heading: "Snow removal contract for the winter", city: "Stevens Point", text: "Snow removal contract for the winter season." },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
// Predicates: fangorn's where (whole value, any case, arrays), field-joined regex, /flags/, not.
|
|
215
|
+
assert(compile({ where: { city: "plover" } })(rows[0]), "where matches case-insensitively");
|
|
216
|
+
assert(!compile({ where: { city: "Plov" } })(rows[0]), "where matches whole values only");
|
|
217
|
+
assert(compile({ where: { county: "wood" } })(rows[2]), "where matches inside an array");
|
|
218
|
+
assert(compile({ match: { "heading|text": "approved" } })(rows[0]), "match joins the fields it names");
|
|
219
|
+
assert(!compile({ match: { heading: "/rezoning/" } })(rows[0]), "/re/ is case-sensitive");
|
|
220
|
+
assert(!compile({ match: { heading: "rezon" }, not: { where: { city: "Plover" } } })(rows[0]), "not negates");
|
|
221
|
+
|
|
222
|
+
const checks: Check[] = [
|
|
223
|
+
{ id: "rezoning", q: "rezoning", expect: { match: { heading: "rezon" } }, k: 1, min: 1 },
|
|
224
|
+
{ id: "plover", kind: "count", where: { city: "Plover" }, min: 2, max: 2 },
|
|
225
|
+
{ id: "wells", kind: "count", where: { city: "Plover" }, expect: { match: { heading: "well" } } },
|
|
226
|
+
{ id: "addresses", kind: "records", expect: { match: { text: "/\\b\\d{2,6} [A-Z]\\w+ (Rd|St)\\b/" } }, max_pct: 30, hard: true },
|
|
227
|
+
];
|
|
228
|
+
const r = Object.fromEntries((await grade(rows, roles, checks, async () => null, 2)).map((x) => [x.id, x]));
|
|
229
|
+
assert(r.rezoning.score === 1 && r.rezoning.pass, `lexical search finds the rezoning: ${JSON.stringify(r.rezoning)}`);
|
|
230
|
+
assert(r.plover.score === 2 && r.plover.pass, "count reaches both Plover rows");
|
|
231
|
+
assert(r.wells.score === 0 && !r.wells.pass, "a count with nothing to reach fails (min defaults to 1)");
|
|
232
|
+
assert(r.addresses.score === 25 && r.addresses.pass, `records is a percentage: ${r.addresses.score}`);
|
|
233
|
+
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);
|
|
234
|
+
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
235
|
+
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
236
|
+
|
|
237
|
+
// The verdict.
|
|
238
|
+
const res = (id: string, score: number, kind: Result["kind"] = "search", extra = {}): Result => ({ id, kind, score, pass: true, ...extra });
|
|
239
|
+
const base = [res("q1", 0.8), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })];
|
|
240
|
+
assert(compare(base, base).verdict === "unchanged", "same is unchanged");
|
|
241
|
+
assert(compare([res("q1", 0.8), res("q2", 0.4), res("known@10", 0.93, "known"), res("pii", 0.1, "records", { hard: true })], base).verdict === "unchanged", "within tolerance is unchanged");
|
|
242
|
+
assert(compare([res("q1", 1), res("q2", 0.6), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })], base).verdict === "better", "a mean rise is better");
|
|
243
|
+
assert(compare([res("q1", 0.6), res("q2", 0.4), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })], base).verdict === "worse", "a mean drop is worse");
|
|
244
|
+
const ratchet = compare([res("q1", 1), res("q2", 0), res("known@10", 0.9, "known"), res("pii", 0.1, "records", { hard: true })], base);
|
|
245
|
+
assert(ratchet.verdict === "worse" && /answered before/.test(ratchet.failures[0]), "the ratchet holds even when the mean does not drop");
|
|
246
|
+
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
|
+
assert(compare([res("q1", 0.8, "search", { pass: false }), res("q2", 0.4)], base).verdict === "worse", "a floor fails whatever the base did");
|
|
248
|
+
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");
|
|
249
|
+
console.log("eval: selfcheck ok");
|
|
250
|
+
}
|