@fangorn-network/westmarch 0.0.9-dev → 0.0.10-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/consume/taste.js +26 -6
- package/dist/ts/eval.js +4 -1
- package/docs/app-to-agent.md +4 -0
- package/package.json +1 -1
- package/publish/eval.ts +5 -2
package/consume/taste.js
CHANGED
|
@@ -120,11 +120,22 @@ export const GAMMA = 0.6;
|
|
|
120
120
|
*/
|
|
121
121
|
export const score = (row, t) => {
|
|
122
122
|
if (!row?.vector || !t) return null;
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
// Called once per row of the catalog: the taste's own norms are the same for every one of them, so they are
|
|
124
|
+
// worked out once per taste (not per row, which doubled discover's time at 124k rows), and a row that already
|
|
125
|
+
// carries its norm (every shard row does) is used as it is rather than copied.
|
|
126
|
+
const n = normsOf(t);
|
|
127
|
+
const r = row.norm ? row : { vector: row.vector, norm: norm(row.vector) };
|
|
128
|
+
const pos = cosine(r, t.q, n.q);
|
|
125
129
|
if (!t.no) return pos;
|
|
126
|
-
return pos - GAMMA * Math.max(0, cosine(r, t.no,
|
|
130
|
+
return pos - GAMMA * Math.max(0, cosine(r, t.no, n.no));
|
|
127
131
|
};
|
|
132
|
+
// Keyed on the vectors, not the taste object: a caller that swaps `t.q` (importTaste, a drifted copy) gets fresh norms.
|
|
133
|
+
const _norms = new WeakMap();
|
|
134
|
+
function normsOf(t) {
|
|
135
|
+
let n = _norms.get(t);
|
|
136
|
+
if (!n || n.qv !== t.q || n.nov !== t.no) _norms.set(t, (n = { qv: t.q, nov: t.no, q: norm(t.q), no: t.no ? norm(t.no) : 1 }));
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
128
139
|
|
|
129
140
|
/** The best `limit` rows for a taste. `exclude` drops rows the taste was built
|
|
130
141
|
* from — recommending someone the thing they just told you they liked is the
|
|
@@ -164,9 +175,18 @@ export function discover(rows, likes = [], dislikes = [], { limit = 20, seed = 1
|
|
|
164
175
|
const t = taste(likes, dislikes, { drift: knobs.lookahead });
|
|
165
176
|
if (!t) return { taste: null, knobs: { ...knobs, seed }, picks: [] };
|
|
166
177
|
const skip = new Set([...exclude, ...likes.map((x) => x.id), ...dislikes.map((x) => x.id)]);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
// Only the top `start + 300` are ever looked at, so keep just those as the rows go by instead of sorting the
|
|
179
|
+
// whole catalog (the same order a stable sort gives: a tie stays behind the row it tied with).
|
|
180
|
+
const pool = rows.filter((r) => r.vector && !skip.has(r.id));
|
|
181
|
+
const start = Math.round(knobs.reach * Math.min(400, pool.length / 4)), keep = start + 300;
|
|
182
|
+
const top = [];
|
|
183
|
+
for (const row of pool) {
|
|
184
|
+
const s = score(row, t);
|
|
185
|
+
if (top.length === keep && !(s > top[keep - 1].s)) continue;
|
|
186
|
+
let i = top.length; while (i > 0 && top[i - 1].s < s) i--;
|
|
187
|
+
top.splice(i, 0, { row, s }); if (top.length > keep) top.pop();
|
|
188
|
+
}
|
|
189
|
+
let ranked = top.slice(start);
|
|
170
190
|
if (knobs.surprise) {
|
|
171
191
|
let a = (Number(seed) >>> 0) || 1;
|
|
172
192
|
const rand = () => { a = (a + 0x6d2b79f5) >>> 0; let x = Math.imul(a ^ (a >>> 15), 1 | a); x ^= x + Math.imul(x ^ (x >>> 7), 61 | x); return ((x ^ (x >>> 14)) >>> 0) / 4294967296; };
|
package/dist/ts/eval.js
CHANGED
|
@@ -94,7 +94,8 @@ async function grade(rows, roles, checks, qv, known) {
|
|
|
94
94
|
}
|
|
95
95
|
else {
|
|
96
96
|
const k = c.k ?? 5, ok = compile(c.expect);
|
|
97
|
-
|
|
97
|
+
// `of` scopes the search to the rows a surface searches (a page tab, a tool's filter).
|
|
98
|
+
const hits = search(c.of ? rows.filter(compile(c.of)) : rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id));
|
|
98
99
|
const score = round(hits.filter(ok).length / k);
|
|
99
100
|
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 });
|
|
100
101
|
}
|
|
@@ -271,6 +272,8 @@ async function selfcheck() {
|
|
|
271
272
|
assert(r.wells.score === 0 && !r.wells.pass, "a count with nothing to reach fails (min defaults to 1)");
|
|
272
273
|
assert(r.addresses.score === 25 && r.addresses.pass, `records is a percentage: ${r.addresses.score}`);
|
|
273
274
|
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);
|
|
275
|
+
const [sq] = await grade(rows, roles, [{ id: "sq", q: "rezoning", of: { where: { city: "Stevens Point" } }, expect: { match: { heading: "rezon" } }, k: 1 }], async () => null, 0);
|
|
276
|
+
assert(sq.score === 0 && !/Rezoning/.test(sq.top?.join() ?? ""), `\`of\` scopes a search check to its rows: ${JSON.stringify(sq)}`);
|
|
274
277
|
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
275
278
|
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
276
279
|
// Coverage, and goals scored apart.
|
package/docs/app-to-agent.md
CHANGED
|
@@ -134,6 +134,10 @@ any fall fails, because a recipe that loses a town has dropped its records.
|
|
|
134
134
|
`match` (a field, or several joined by `|`, to a regex; case-insensitive unless written
|
|
135
135
|
`/re/flags`), `not`. Relevance is a predicate and not a list of record ids, because ids
|
|
136
136
|
are content hashes and a recipe change that re-parses records changes every one.
|
|
137
|
+
The same predicate as `of` scopes a check to some rows: on a `records` or `coverage` check
|
|
138
|
+
it narrows what is counted, and on a search check it narrows what is searched, to match a
|
|
139
|
+
surface that filters (a page tab for people, a type an agent tool reads), so one type of
|
|
140
|
+
record growing cannot crowd the questions another surface answers.
|
|
137
141
|
Without labels, it also searches 200 sampled rows by their own titles (`known@1`, `known@10`).
|
|
138
142
|
|
|
139
143
|
Worse is: mean precision or `known@10` down more than 0.05, any question the base
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10-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
|
@@ -56,7 +56,7 @@ import { EMBED_MODEL, embedQueryDirect } from "@fangorn-network/westmarch/embed"
|
|
|
56
56
|
|
|
57
57
|
type Pred = { where?: Record<string, string>; match?: Record<string, string>; not?: Pred };
|
|
58
58
|
type Check = { id: string; goal?: string } & (
|
|
59
|
-
| { kind?: "search"; q: string; k?: number; expect: Pred; min?: number }
|
|
59
|
+
| { kind?: "search"; q: string; k?: number; of?: Pred; expect: Pred; min?: number }
|
|
60
60
|
| { kind: "count"; where?: Record<string, string>; expect?: Pred; min?: number; max?: number }
|
|
61
61
|
| { kind: "records"; of?: Pred; expect: Pred; max_pct: number; hard?: boolean }
|
|
62
62
|
| { kind: "coverage"; field: string; values: string[]; weights?: Record<string, number>; of?: Pred; min?: number });
|
|
@@ -107,7 +107,8 @@ async function grade(rows: Row[], roles: Roles, checks: Check[], qv: QueryVector
|
|
|
107
107
|
out.push({ id: c.id, kind: "coverage", score, pass: score >= (c.min ?? 0), top: missing.slice(0, 10), ...goal });
|
|
108
108
|
} else {
|
|
109
109
|
const k = c.k ?? 5, ok = compile(c.expect);
|
|
110
|
-
|
|
110
|
+
// `of` scopes the search to the rows a surface searches (a page tab, a tool's filter).
|
|
111
|
+
const hits = search(c.of ? rows.filter(compile(c.of)) : rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id)!);
|
|
111
112
|
const score = round(hits.filter(ok).length / k);
|
|
112
113
|
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 });
|
|
113
114
|
}
|
|
@@ -276,6 +277,8 @@ async function selfcheck() {
|
|
|
276
277
|
assert(r.wells.score === 0 && !r.wells.pass, "a count with nothing to reach fails (min defaults to 1)");
|
|
277
278
|
assert(r.addresses.score === 25 && r.addresses.pass, `records is a percentage: ${r.addresses.score}`);
|
|
278
279
|
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);
|
|
280
|
+
const [sq] = await grade(rows, roles, [{ id: "sq", q: "rezoning", of: { where: { city: "Stevens Point" } }, expect: { match: { heading: "rezon" } }, k: 1 }], async () => null, 0);
|
|
281
|
+
assert(sq.score === 0 && !/Rezoning/.test(sq.top?.join() ?? ""), `\`of\` scopes a search check to its rows: ${JSON.stringify(sq)}`);
|
|
279
282
|
assert(scoped.score === 50, `\`of\` narrows the rows a record check counts: ${scoped.score}`);
|
|
280
283
|
assert(r["known@10"].score === 1, "every title finds its own row");
|
|
281
284
|
|