@fangorn-network/westmarch 0.0.1-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/README.md +2 -2
- package/consume/apps.js +4 -2
- package/consume/corpora.js +1 -1
- package/consume/directory.js +1 -1
- package/consume/embed.js +1 -1
- package/consume/lint.js +1 -1
- package/consume/mcp-bin.js +6 -0
- package/consume/mcp.js +193 -34
- package/consume/ort-preload.js +25 -0
- package/consume/rank.js +1 -1
- package/consume/roles.js +6 -4
- package/consume/shard.js +1 -1
- package/consume/steam.js +1 -1
- package/consume/taste-doc.js +1 -1
- package/consume/taste.js +1 -1
- package/consume/tools.js +28 -5
- package/consume/ui.js +1 -1
- package/consume/view-tools.js +202 -0
- package/consume/x402.js +94 -0
- package/dist/ts/eval.js +244 -0
- package/docs/app-to-agent.md +595 -185
- package/package.json +24 -10
- package/publish/agent-card.js +12 -2
- package/publish/cohort.js +1 -1
- package/publish/demand.js +1 -1
- package/publish/enrich.js +1 -1
- package/publish/envelope.js +1 -1
- package/publish/eval.ts +250 -0
- package/publish/graph.js +1 -1
- package/publish/manifest.js +1 -1
- package/publish/reactions.js +4 -2
- package/publish/settle.js +1 -1
- package/publish/ship.js +462 -0
- package/publish/terms.js +1 -1
- package/publish/vault.js +1 -1
- package/publish/view-cli.js +39 -0
- package/publish/view.js +434 -0
- package/publish/westmarch.d.ts +35 -0
- package/site/agent.js +44 -0
- package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
- package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
- package/site/dist/assets/index-CaHGhiKK.js +8 -0
- package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
- package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
- package/site/dist/assets/roles-C9V_uDr_.js +1 -0
- package/site/dist/assets/roles-YDRjv_PY.js +1 -0
- package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
- package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
- package/site/dist/index.html +20 -0
- package/site/feed.js +62 -0
- package/site/store.js +87 -0
- package/site/worker.js +103 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// An app's views, served as tools, with no browser.
|
|
2
|
+
//
|
|
3
|
+
// A Fangorn app's card names its views: static, content-addressed shards any
|
|
4
|
+
// HTTP client can read. The page's WebMCP tools are one way in, but they need a
|
|
5
|
+
// browser, and a browser costs seconds and gigabytes before the first answer.
|
|
6
|
+
// These tools read the views directly with westmarch's own search, browse,
|
|
7
|
+
// count, get and similar, so the only code that runs is ours. The app's data is
|
|
8
|
+
// verified by its manifest's sha256, not trusted.
|
|
9
|
+
//
|
|
10
|
+
// Time to first answer is the point, so:
|
|
11
|
+
// - a search goes to the 3 views whose coverage sketch best matches the query,
|
|
12
|
+
// not to all of them (Kingsfoil: ~3 of 23 views, ~92% of the true top 10 with
|
|
13
|
+
// a 32-vector sketch). The result names what was searched and what was not.
|
|
14
|
+
// - `warm()` starts the embedder the moment an app is opened, and a search
|
|
15
|
+
// never waits on a first-time model download.
|
|
16
|
+
//
|
|
17
|
+
// `get`/`similar`/`count` answer over what is loaded, and say so.
|
|
18
|
+
|
|
19
|
+
import { configure, loadShard, trimView } from "./shard.js";
|
|
20
|
+
import { rolesFrom, textOf } from "./roles.js";
|
|
21
|
+
import { browse, facet, getRow, neighbors, search } from "./tools.js";
|
|
22
|
+
import { lexScore, rankDomains } from "./rank.js";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { EMBED_MODEL, embedQueryDirect, warmDirect } from "./embed.js";
|
|
25
|
+
|
|
26
|
+
const ROUTE_TOP = 3;
|
|
27
|
+
|
|
28
|
+
// One roles table per view, set from its manifests before its first row parses.
|
|
29
|
+
const rolesByView = new Map();
|
|
30
|
+
const DEFAULT_ROLES = rolesFrom([]);
|
|
31
|
+
const rolesOf = (view) => rolesByView.get(view) ?? DEFAULT_ROLES;
|
|
32
|
+
configure({
|
|
33
|
+
onManifests: (manifests, view) => rolesByView.set(view, rolesFrom(manifests)),
|
|
34
|
+
rowText: (f, view) => textOf(f, rolesOf(view)),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ponytail: an embedder that fails to load is remembered as failed, and search
|
|
38
|
+
// ranks by words for the rest of the process. Retry when that matters.
|
|
39
|
+
let embedder = null, ready = false, downloading = false;
|
|
40
|
+
/** `cacheDir`: where the model is kept. Outside node_modules, or every new
|
|
41
|
+
* install of this package downloads the same 131MB again. */
|
|
42
|
+
export const warm = ({ cacheDir } = {}) => (embedder ??= (async () => {
|
|
43
|
+
if (cacheDir) {
|
|
44
|
+
downloading = !existsSync(`${cacheDir}/${EMBED_MODEL}/onnx/model_quantized.onnx`); // before any await
|
|
45
|
+
(await import("@huggingface/transformers")).env.cacheDir = cacheDir;
|
|
46
|
+
}
|
|
47
|
+
await warmDirect();
|
|
48
|
+
})().then(() => (ready = true), (e) => { warmError = e?.message ?? String(e); return false; })
|
|
49
|
+
.finally(() => { downloading = false; }));
|
|
50
|
+
/** Why the embedder did not load, if it did not. */
|
|
51
|
+
export let warmError = null;
|
|
52
|
+
// A model on disk loads in ~0.5s and is worth the wait. A download is 131MB, so
|
|
53
|
+
// until it lands, search ranks by words and says so, rather than stall ~10s.
|
|
54
|
+
const queryVector = async (q) => {
|
|
55
|
+
if (!ready && downloading) return null;
|
|
56
|
+
return (await warm()) ? embedQueryDirect(q).catch(() => null) : null;
|
|
57
|
+
};
|
|
58
|
+
const rankedBy = (qv) => qv ? "meaning"
|
|
59
|
+
: downloading ? "words (the embedding model is downloading; meaning once it lands)"
|
|
60
|
+
: "words (embedder unavailable)";
|
|
61
|
+
|
|
62
|
+
const text = (o) => ({ content: [{ type: "text", text: JSON.stringify(o, null, 1) }] });
|
|
63
|
+
const str = (description) => ({ type: "string", description });
|
|
64
|
+
const num = (description) => ({ type: "number", description });
|
|
65
|
+
const VIEWS = { type: "array", items: { type: "string" }, description: 'View names from describe, or ["all"]. Omit to let the query choose.' };
|
|
66
|
+
const WHERE = { type: "object", description: 'Equality filters, e.g. {"phase":"PHASE3"}' };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The tools for one app, or null when its card names no views.
|
|
70
|
+
* `app` is `{ name, desc, views }` from a verified card. `fetchCatalog(url)` may
|
|
71
|
+
* answer from a stale copy: the survey only describes and routes, and loading a
|
|
72
|
+
* view re-reads its catalog through shard.js either way.
|
|
73
|
+
*/
|
|
74
|
+
export function viewTools(app, { fetchCatalog = fetch } = {}) {
|
|
75
|
+
if (!app.views?.length) return null;
|
|
76
|
+
const names = new Map(); // view url → its name in every result
|
|
77
|
+
const label = (url) => names.get(url) ?? url.split("/").pop();
|
|
78
|
+
let surveyed;
|
|
79
|
+
// The catalogs: what each view holds and its coverage sketch. Small, and no rows.
|
|
80
|
+
const survey = () => (surveyed ??= Promise.all(app.views.map(async (url) => {
|
|
81
|
+
const view = trimView(url);
|
|
82
|
+
const { domains = [] } = await (await fetchCatalog(`${view}/cdn/catalog`)).json();
|
|
83
|
+
names.set(view, domains.length === 1 ? domains[0].name : label(view));
|
|
84
|
+
return {
|
|
85
|
+
view, name: label(view),
|
|
86
|
+
description: domains.map((d) => d.description).filter(Boolean).join(" "),
|
|
87
|
+
count: domains.reduce((s, d) => s + (d.count ?? 0), 0),
|
|
88
|
+
coverage: { vectors: domains.flatMap((d) => d.coverage?.vectors ?? []) },
|
|
89
|
+
};
|
|
90
|
+
})).catch((e) => { surveyed = null; throw e; }));
|
|
91
|
+
|
|
92
|
+
survey().catch(() => {}); // started now, so describe and the first search do not wait on it
|
|
93
|
+
const loaded = new Map(); // view → rows
|
|
94
|
+
const load = async (v) => { if (!loaded.has(v.view)) loaded.set(v.view, await loadShard(v.view)); return loaded.get(v.view); };
|
|
95
|
+
const pick = async (names) => {
|
|
96
|
+
const all = await survey();
|
|
97
|
+
if (names?.includes("all")) return all;
|
|
98
|
+
const bad = names.filter((n) => !all.some((v) => v.name === n || label(v.view) === n));
|
|
99
|
+
if (bad.length) throw new Error(`unknown view(s) ${bad.join(", ")}; views: ${all.map((v) => v.name).join(", ")}`);
|
|
100
|
+
return all.filter((v) => names.includes(v.name) || names.includes(label(v.view)));
|
|
101
|
+
};
|
|
102
|
+
// The query decides: coverage sketch by meaning, else the descriptions by words.
|
|
103
|
+
const route = async (query, qv) => {
|
|
104
|
+
const all = await survey();
|
|
105
|
+
return qv ? rankDomains(all, qv)
|
|
106
|
+
: all.map((v) => ({ ...v, affinity: lexScore({ text: `${v.name} ${v.description}` }, query) }))
|
|
107
|
+
.sort((a, b) => b.affinity - a.affinity);
|
|
108
|
+
};
|
|
109
|
+
const loadedRows = () => [...loaded.entries()].flatMap(([view, rows]) => rows.map((r) => ({ r, view })));
|
|
110
|
+
|
|
111
|
+
const run = {
|
|
112
|
+
describe: async () => ({
|
|
113
|
+
app: app.name, description: app.desc,
|
|
114
|
+
views: (await survey()).map((v) => ({ view: v.name, rows: v.count, description: v.description, loaded: loaded.has(v.view) })),
|
|
115
|
+
}),
|
|
116
|
+
search: async ({ query, limit = 10, views, where: w }) => {
|
|
117
|
+
if (!query) throw new Error("query is required");
|
|
118
|
+
const qv = await queryVector(query);
|
|
119
|
+
const ranked = views?.length ? await pick(views) : (await route(query, qv)).slice(0, ROUTE_TOP);
|
|
120
|
+
const others = views?.length ? [] : (await route(query, qv)).slice(ROUTE_TOP, ROUTE_TOP + 5).map((v) => v.name);
|
|
121
|
+
const per = await Promise.all(ranked.map(async (v) =>
|
|
122
|
+
search(await load(v), query, rolesOf(v.view), { qv, limit, where: w }).map((h) => ({ ...h, view: v.name }))));
|
|
123
|
+
const hits = per.flat().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, limit);
|
|
124
|
+
return { query, ranked_by: rankedBy(qv), searched: ranked.map((v) => v.name),
|
|
125
|
+
next_views: others, hits };
|
|
126
|
+
},
|
|
127
|
+
get: async ({ id, view }) => {
|
|
128
|
+
if (view) for (const v of await pick([view])) await load(v);
|
|
129
|
+
for (const [v, rows] of loaded) { const r = getRow(rows, id, rolesOf(v)); if (r) return r; }
|
|
130
|
+
return { error: `${id} is not in a loaded view. Search for it, or pass its view.`, loaded: [...loaded.keys()].map(label) };
|
|
131
|
+
},
|
|
132
|
+
similar: async ({ id, limit = 10, views }) => {
|
|
133
|
+
const hit = loadedRows().find(({ r }) => r.id === id);
|
|
134
|
+
if (!hit) return { error: `${id} is not loaded. Search for it first.` };
|
|
135
|
+
// Neighbours by design live anywhere, so route on the row's own vector.
|
|
136
|
+
const targets = views?.length ? await pick(views)
|
|
137
|
+
: hit.r.vector ? (await route("", hit.r.vector)).slice(0, ROUTE_TOP) : [];
|
|
138
|
+
await Promise.all(targets.map(load));
|
|
139
|
+
const rows = [...loaded.values()].flat();
|
|
140
|
+
const { seed, near } = neighbors(rows, id, rolesOf(hit.view), { limit });
|
|
141
|
+
return { id, seed, searched: [...loaded.keys()].map(label), hits: near };
|
|
142
|
+
},
|
|
143
|
+
count: async ({ field, views, limit = 20, where: w }) => {
|
|
144
|
+
if (!field) throw new Error("field is required");
|
|
145
|
+
if (views?.length) await Promise.all((await pick(views)).map(load));
|
|
146
|
+
if (!loaded.size) return { error: 'nothing loaded yet. Pass views (or ["all"]) to count over.' };
|
|
147
|
+
return { field, over: [...loaded.keys()].map(label), ...facet([...loaded.values()].flat(), field, { limit, where: w }) };
|
|
148
|
+
},
|
|
149
|
+
browse: async ({ view, limit = 20, offset = 0, sort, where: w }) => {
|
|
150
|
+
const [v] = await pick([view]);
|
|
151
|
+
return { view: v.name, ...browse(await load(v), rolesOf(v.view), { limit, offset, sort, where: w }) };
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const n = app.name;
|
|
156
|
+
const tools = [
|
|
157
|
+
{ name: "describe", description: `What ${n} holds: its views, each with a description and row count. Downloads no rows; call it first.`,
|
|
158
|
+
inputSchema: { type: "object", properties: {} } },
|
|
159
|
+
{ name: "search", description: `Search ${n} by meaning. Without views, searches the ${ROUTE_TOP} views whose coverage best matches the query and names the next candidates; pass views to widen.`,
|
|
160
|
+
inputSchema: { type: "object", properties: { query: str("A question in natural language"), limit: num("Max hits (default 10)"), views: VIEWS, where: WHERE }, required: ["query"] } },
|
|
161
|
+
{ name: "get", description: `One record of ${n} in full, by id.`,
|
|
162
|
+
inputSchema: { type: "object", properties: { id: str("The record id, as search returns it"), view: str("Its view, if it has not been loaded") }, required: ["id"] } },
|
|
163
|
+
{ name: "similar", description: `Records of ${n} nearest to one record, across the views its vector points to.`,
|
|
164
|
+
inputSchema: { type: "object", properties: { id: str("A record id from search"), limit: num("Max results (default 10)"), views: VIEWS }, required: ["id"] } },
|
|
165
|
+
{ name: "count", description: `Count the distinct values of a field in ${n}: the shape of a result set before reading it.`,
|
|
166
|
+
inputSchema: { type: "object", properties: { field: str("Field name, e.g. from get"), views: VIEWS, limit: num("Max values (default 20)"), where: WHERE }, required: ["field"] } },
|
|
167
|
+
{ name: "browse", description: `List one view of ${n} without a query, with filters, sorting and paging.`,
|
|
168
|
+
inputSchema: { type: "object", properties: { view: str("View name from describe"), limit: num("default 20"), offset: num("default 0"), sort: str("Field to sort by"), where: WHERE }, required: ["view"] } },
|
|
169
|
+
];
|
|
170
|
+
// Records the app sells over x402 (its card's `paid`). Paid from this server's wallet,
|
|
171
|
+
// never above FANGORN_MCP_MAX_PRICE, and checked against the sha256 the app published
|
|
172
|
+
// for that record, so what was paid for is what was committed.
|
|
173
|
+
if (app.paid) {
|
|
174
|
+
const p = app.paid, dec = p.decimals ?? 6;
|
|
175
|
+
const price = `${(Number(p.price) / 10 ** dec).toFixed(dec).replace(/0+$/, "").replace(/\.$/, "")} ${p.symbol ?? "USDC"}`;
|
|
176
|
+
const cap = BigInt(Math.round(Number(process.env.FANGORN_MCP_MAX_PRICE ?? "0.10") * 10 ** dec));
|
|
177
|
+
run.buy = async ({ id }) => {
|
|
178
|
+
if (!id) throw new Error("id is required");
|
|
179
|
+
const hit = loadedRows().find(({ r, view }) => r.id === id || r[rolesOf(view).identity] === id);
|
|
180
|
+
const key = hit ? hit.r[rolesOf(hit.view).identity] ?? id : id;
|
|
181
|
+
const { payAndFetch } = await import("./x402.js");
|
|
182
|
+
const r = await payAndFetch(p.url.replace("{id}", encodeURIComponent(key)),
|
|
183
|
+
{ privateKey: process.env.FANGORN_MCP_WALLET_KEY, maxPrice: cap });
|
|
184
|
+
if (r.status !== 200) return { id: key, error: `HTTP ${r.status}: ${r.body.slice(0, 200)}` };
|
|
185
|
+
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(r.body)))]
|
|
186
|
+
.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
187
|
+
const want = hit?.r.paid_sha256;
|
|
188
|
+
return { id: key, paid: r.price ? price : "free", transaction: r.receipt?.transaction ?? null, network: r.receipt?.network ?? p.network,
|
|
189
|
+
verified: want ? (want === digest ? "matches the published sha256" : `MISMATCH: published ${want}, got ${digest}`) : "not checked (search for the record first)",
|
|
190
|
+
record: JSON.parse(r.body) };
|
|
191
|
+
};
|
|
192
|
+
tools.push({ name: "buy", description: `Buy one ${n} record's paid detail for ${price} (x402 on ${p.network}): ${p.description || "the structured record"}. ` +
|
|
193
|
+
"Pass an id from search. Paid from this server's wallet (FANGORN_MCP_WALLET_KEY), never above FANGORN_MCP_MAX_PRICE.",
|
|
194
|
+
inputSchema: { type: "object", properties: { id: str("A record id from search or get") }, required: ["id"] } });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { tools, call: async (name, args = {}) => {
|
|
198
|
+
if (!run[name]) throw new Error(`no tool ${name}`);
|
|
199
|
+
return text(await run[name](args));
|
|
200
|
+
} };
|
|
201
|
+
}
|
|
202
|
+
|
package/consume/x402.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Paying for a resource over x402 (v1, "exact" scheme): the buyer's half.
|
|
2
|
+
//
|
|
3
|
+
// const { body, receipt, price } = await payAndFetch(url, { privateKey, maxPrice: 50000n });
|
|
4
|
+
//
|
|
5
|
+
// 1. GET url → 402 with `accepts`: what it costs, in which token, on which chain, to whom.
|
|
6
|
+
// 2. Sign an EIP-3009 transferWithAuthorization for exactly that (EIP-712, the token's own
|
|
7
|
+
// domain). Nothing is sent on chain by the buyer: the seller's facilitator submits it.
|
|
8
|
+
// 3. GET url again with X-PAYMENT: the seller verifies, settles, and returns the resource,
|
|
9
|
+
// with the settlement (the tx hash) in X-PAYMENT-RESPONSE.
|
|
10
|
+
//
|
|
11
|
+
// `maxPrice` (token base units) is checked BEFORE signing: a seller cannot ask for more
|
|
12
|
+
// than the caller agreed to, whatever its 402 says.
|
|
13
|
+
|
|
14
|
+
const CHAIN_IDS = { "arbitrum-sepolia": 421614, arbitrum: 42161, "base-sepolia": 84532, base: 8453 };
|
|
15
|
+
const b64 = (s) => (typeof btoa === "function" ? btoa(s) : Buffer.from(s, "utf8").toString("base64"));
|
|
16
|
+
const unb64 = (s) => (typeof atob === "function" ? atob(s) : Buffer.from(s, "base64").toString("utf8"));
|
|
17
|
+
|
|
18
|
+
/** The one requirement we can pay: exact scheme, a chain we know, within `maxPrice`. */
|
|
19
|
+
export function choose(accepts = [], { maxPrice, networks = Object.keys(CHAIN_IDS) } = {}) {
|
|
20
|
+
const ok = accepts.filter((a) => a.scheme === "exact" && networks.includes(a.network));
|
|
21
|
+
if (!ok.length) throw new Error(`no payable option: ${JSON.stringify(accepts.map((a) => `${a.scheme}/${a.network}`))}`);
|
|
22
|
+
const cheapest = ok.sort((a, b) => (BigInt(a.maxAmountRequired) < BigInt(b.maxAmountRequired) ? -1 : 1))[0];
|
|
23
|
+
if (maxPrice != null && BigInt(cheapest.maxAmountRequired) > BigInt(maxPrice))
|
|
24
|
+
throw new Error(`price ${cheapest.maxAmountRequired} exceeds the cap ${maxPrice} (token base units)`);
|
|
25
|
+
return cheapest;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The X-PAYMENT header value for requirement `req`, signed by `account` (a viem account). */
|
|
29
|
+
export async function paymentHeader(req, account, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
30
|
+
const chainId = CHAIN_IDS[req.network];
|
|
31
|
+
if (!chainId) throw new Error(`unknown network ${req.network}`);
|
|
32
|
+
const nonce = `0x${[...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
33
|
+
const authorization = {
|
|
34
|
+
from: account.address, to: req.payTo, value: String(req.maxAmountRequired),
|
|
35
|
+
validAfter: String(now - 600), validBefore: String(now + (req.maxTimeoutSeconds ?? 120)), nonce,
|
|
36
|
+
};
|
|
37
|
+
const signature = await account.signTypedData({
|
|
38
|
+
domain: { name: req.extra?.name ?? "USD Coin", version: req.extra?.version ?? "2", chainId, verifyingContract: req.asset },
|
|
39
|
+
types: { TransferWithAuthorization: [
|
|
40
|
+
{ name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" },
|
|
41
|
+
{ name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }] },
|
|
42
|
+
primaryType: "TransferWithAuthorization",
|
|
43
|
+
message: { ...authorization, value: BigInt(authorization.value), validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) },
|
|
44
|
+
});
|
|
45
|
+
return b64(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.network, payload: { signature, authorization } }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** GET `url`, paying once if it answers 402. Returns { status, body, price, receipt }. */
|
|
49
|
+
export async function payAndFetch(url, { privateKey, account, maxPrice, fetchImpl = fetch } = {}) {
|
|
50
|
+
const first = await fetchImpl(url);
|
|
51
|
+
if (first.status !== 402) return { status: first.status, body: await first.text(), price: null, receipt: null };
|
|
52
|
+
const offer = await first.json();
|
|
53
|
+
const req = choose(offer.accepts, { maxPrice });
|
|
54
|
+
if (!account) {
|
|
55
|
+
if (!privateKey) throw new Error(`${url} costs ${req.maxAmountRequired} (base units of ${req.asset} on ${req.network}); no wallet configured to pay`);
|
|
56
|
+
account = (await import("viem/accounts")).privateKeyToAccount(privateKey);
|
|
57
|
+
}
|
|
58
|
+
const paid = await fetchImpl(url, { headers: { "X-PAYMENT": await paymentHeader(req, account) } });
|
|
59
|
+
const receiptHeader = paid.headers.get("X-PAYMENT-RESPONSE");
|
|
60
|
+
const body = await paid.text();
|
|
61
|
+
if (paid.status === 402) throw new Error(`payment refused: ${JSON.parse(body).error ?? body}`);
|
|
62
|
+
return { status: paid.status, body, price: req.maxAmountRequired, receipt: receiptHeader ? JSON.parse(unb64(receiptHeader)) : null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── self-check: `node consume/x402.js` — a stand-in seller that checks the signature ──
|
|
66
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/x402.js")) {
|
|
67
|
+
const { privateKeyToAccount, generatePrivateKey } = await import("viem/accounts");
|
|
68
|
+
const { verifyTypedData } = await import("viem");
|
|
69
|
+
const buyer = privateKeyToAccount(generatePrivateKey());
|
|
70
|
+
const USDC = "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", SELLER = `0x${"5e".repeat(20)}`;
|
|
71
|
+
const req = { scheme: "exact", network: "arbitrum-sepolia", maxAmountRequired: "10000", resource: "https://x/paid/1",
|
|
72
|
+
payTo: SELLER, asset: USDC, maxTimeoutSeconds: 120, extra: { name: "USD Coin", version: "2" } };
|
|
73
|
+
let settled = null;
|
|
74
|
+
const seller = async (url, init = {}) => {
|
|
75
|
+
const h = init.headers?.["X-PAYMENT"];
|
|
76
|
+
if (!h) return new Response(JSON.stringify({ x402Version: 1, accepts: [req] }), { status: 402 });
|
|
77
|
+
const p = JSON.parse(unb64(h)).payload, a = p.authorization;
|
|
78
|
+
const ok = await verifyTypedData({ address: a.from, signature: p.signature,
|
|
79
|
+
domain: { name: "USD Coin", version: "2", chainId: 421614, verifyingContract: USDC },
|
|
80
|
+
types: { TransferWithAuthorization: [{ name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" },
|
|
81
|
+
{ name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }] },
|
|
82
|
+
primaryType: "TransferWithAuthorization",
|
|
83
|
+
message: { ...a, value: BigInt(a.value), validAfter: BigInt(a.validAfter), validBefore: BigInt(a.validBefore) } });
|
|
84
|
+
if (!ok || a.to !== SELLER || a.value !== "10000") return new Response(JSON.stringify({ error: "bad payment" }), { status: 402 });
|
|
85
|
+
settled = a;
|
|
86
|
+
return new Response('{"secret":42}', { status: 200, headers: { "X-PAYMENT-RESPONSE": b64(JSON.stringify({ success: true, transaction: "0xabc" })) } });
|
|
87
|
+
};
|
|
88
|
+
const r = await payAndFetch("https://x/paid/1", { account: buyer, maxPrice: 10000n, fetchImpl: seller });
|
|
89
|
+
if (r.body !== '{"secret":42}' || r.receipt.transaction !== "0xabc" || settled.from !== buyer.address) throw new Error(`paid fetch: ${JSON.stringify(r)}`);
|
|
90
|
+
let refused = false;
|
|
91
|
+
try { await payAndFetch("https://x/paid/1", { account: buyer, maxPrice: 9999n, fetchImpl: seller }); } catch (e) { refused = /exceeds the cap/.test(e.message); }
|
|
92
|
+
if (!refused) throw new Error("a price over the cap must be refused before signing");
|
|
93
|
+
console.log("x402.js self-check ok — a signed EIP-3009 authorization the seller can verify, exactly the asked amount to the asked payee, the receipt read back, and a price over the cap refused before signing");
|
|
94
|
+
}
|
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
|
+
}
|