@fangorn-network/westmarch 0.0.8-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/embed.js CHANGED
@@ -152,12 +152,14 @@ export function packVec(vec) {
152
152
  return btoa(s);
153
153
  }
154
154
 
155
- /** base64 int8 → Float32Array. Returns null on anything malformed — a bad vector
155
+ /** base64 int8 → Float16Array (Float32Array where there is none). Returns null on anything malformed — a bad vector
156
156
  * must degrade that row to lexical, never throw mid-search. */
157
157
  export function unpackVec(b64) {
158
158
  try {
159
159
  const s = atob(b64);
160
- const out = new Float32Array(s.length);
160
+ // Half the memory of Float32 where the browser has it: every row holds one of these, and
161
+ // an int8 source loses nothing at 16 bits. Consumers read plain numbers either way.
162
+ const out = new (globalThis.Float16Array ?? Float32Array)(s.length);
161
163
  for (let i = 0; i < s.length; i++) {
162
164
  const b = s.charCodeAt(i);
163
165
  out[i] = (b > 127 ? b - 256 : b) / 127;
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
- const r = { vector: row.vector, norm: row.norm ?? norm(row.vector) };
124
- const pos = cosine(r, t.q, norm(t.q));
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, norm(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
- let ranked = rows.filter((r) => r.vector && !skip.has(r.id)).map((row) => ({ row, s: score(row, t) })).sort((a, b) => b.s - a.s);
168
- const start = Math.round(knobs.reach * Math.min(400, ranked.length / 4));
169
- ranked = ranked.slice(start, start + 300);
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
- const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id));
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.
@@ -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.8-dev",
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
- const hits = search(rows, c.q, roles, { qv: await qv(c.q), limit: k }).map((h) => byId.get(h.id)!);
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