evals-lab 0.8.0 → 0.9.0

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.
@@ -125,21 +125,26 @@ export function dropBy(rules , item )
125
125
  `itemCount`, and `share` of the items `items` matches. Every one given must
126
126
  hold; a rule that states none never fires. */
127
127
  export function rejectBy(rules , body , items ) {
128
- for (const r of rules) {
129
- const m = r.match || {};
130
- const conds = [];
131
- if (m.blank === true) conds.push(!body.trim());
132
- if (m.itemCount && typeof m.itemCount === "object") conds.push(inRange(m.itemCount )(items.length));
133
- if (m.share && typeof m.share === "object") {
134
- const test = matcher(m.items );
135
- // A share with nothing to measure cannot fire.
136
- conds.push(!!test && inRange(m.share )(items.length ? items.filter(test).length / items.length : 0));
137
- }
138
- if (conds.length && conds.every(Boolean)) return r.id;
139
- }
128
+ for (const r of rules) if (rejectHolds(r.match || {}, body, items)) return r.id;
140
129
  return null;
141
130
  }
142
131
 
132
+ /** Whether an answer whose text is [body] and items are [items] meets the
133
+ reject conditions [m]: `blank`, `itemCount` and the `share` of `items` the
134
+ items match. Every one given must hold; one that states none never fires.
135
+ The condition half of rejectBy, shared with the reject-reply filter. */
136
+ function rejectHolds(m , body , items ) {
137
+ const conds = [];
138
+ if (m.blank === true) conds.push(!body.trim());
139
+ if (m.itemCount && typeof m.itemCount === "object") conds.push(inRange(m.itemCount )(items.length));
140
+ if (m.share && typeof m.share === "object") {
141
+ const test = matcher(m.items );
142
+ // A share with nothing to measure cannot fire.
143
+ conds.push(!!test && inRange(m.share )(items.length ? items.filter(test).length / items.length : 0));
144
+ }
145
+ return conds.length > 0 && conds.every(Boolean);
146
+ }
147
+
143
148
  /** Why [rules] are not a rule list [scope] takes, one sentence each. */
144
149
  export function itemRulesProblems(rules , scope , at ) {
145
150
  if (!Array.isArray(rules)) return [`${at}: rules has to be a list`];
@@ -184,6 +189,226 @@ export function tryExamples(rule , scope )
184
189
  ];
185
190
  }
186
191
 
192
+ // ---- filters: the generic reply filter (issue #335) ----------------------------
193
+ //
194
+ // A Filter is the reusable unit #334 introduces: it supersedes the inline
195
+ // ItemRule a Drop items or Reject the answer modifier carries today. Where a
196
+ // rule was a bare matcher with examples, a filter has a base TYPE from a
197
+ // registry (not an enum) -- "drop-item" cleans each item of a list reply,
198
+ // "reject-reply" gates the whole answer -- generic conditions reusing the
199
+ // matcher above (ANDed), Options, and Tests that are its own checks. The two
200
+ // base types evaluate here, beside the matcher and rejectHolds they are built
201
+ // from; the Library-stored filter-set body, the pipeline link that carries
202
+ // one, and their version live in evals-core.ts, as DatasetBody and the
203
+ // eval-group link do. Nothing here replaces the Drop items / Reject modifiers:
204
+ // Runs wires a filter set into Responses and migrates the inline rules in a
205
+ // later phase (#339), so both base types and the ItemRule shape stand.
206
+
207
+ /** How a filter test splits its sample reply into items. "none" keeps the
208
+ reply whole, for a reject-reply filter that reads the text. */
209
+ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"] ;
210
+
211
+
212
+ /** A sample reply and what the filter should leave of it: the editor splits
213
+ the sample by `separator`, applies the filter, and shows whether the result
214
+ reads as `expectedOutput`. A drop-item filter's expected output is the
215
+ items it keeps; a reject-reply filter's is blank when the reply should be
216
+ rejected, or left non-blank when it should pass. */
217
+
218
+
219
+
220
+
221
+
222
+
223
+ /** A filter's Options: keep going when it errors (rather than failing the
224
+ run), and how many passes it makes over the reply. */
225
+
226
+
227
+
228
+
229
+
230
+ /** A filter (issue #335): a base TYPE fixed at creation (a FILTER_TYPES id),
231
+ generic conditions in `match` (the matcher's keys for its type, ANDed),
232
+ Options and Tests. `id` is its name, as an ItemRule's was. */
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+ /** A reply a filter works over: the text it came as, and the items parsed
243
+ from it (as the List kind parses a run's reply). */
244
+
245
+
246
+ /** What applying a filter or a set left: the items kept, what was dropped and
247
+ by which filter, and -- the first reject-reply filter that fired -- the
248
+ reply's rejection (null when none did). */
249
+
250
+
251
+ /** A base filter type (#334's decision: a registry, not an enum). A reader
252
+ asks the entry by `filter.type`; nothing branches on the id. */
253
+
254
+
255
+
256
+
257
+
258
+
259
+
260
+
261
+
262
+
263
+
264
+
265
+
266
+ /** The lab's base filter types. Generic code asks this registry by
267
+ `filter.type`; it never names "drop-item" or "reject-reply". */
268
+ export const FILTER_TYPES = {
269
+ "drop-item": {
270
+ label: "Drop item", scope: "item", conditions: MATCHER_KEYS,
271
+ apply(filter, reply) {
272
+ const test = matcher(filter.match);
273
+ if (!test) return { items: reply.items, dropped: [], reject: null };
274
+ const items = [], dropped = [];
275
+ for (const t of reply.items) {
276
+ if (test(t)) dropped.push({ item: t, by: filter.id });
277
+ else items.push(t);
278
+ }
279
+ return { items, dropped, reject: null };
280
+ },
281
+ },
282
+ "reject-reply": {
283
+ label: "Reject reply", scope: "answer", conditions: ["blank", "itemCount", "share", "items"],
284
+ apply(filter, reply) {
285
+ return { items: reply.items, dropped: [], reject: rejectHolds(filter.match, reply.body, reply.items) ? filter.id : null };
286
+ },
287
+ },
288
+ };
289
+
290
+ /** Apply one filter to [reply], honouring its Options: `repeat` passes over
291
+ the reply (a reject-reply filter stops at the first that fires), and
292
+ `ignoreFailures` leaves the reply as it was if the filter errors -- a
293
+ condition that cannot be read -- rather than failing the run. Pure. */
294
+ export function applyFilter(filter , reply ) {
295
+ const entry = FILTER_TYPES[filter.type];
296
+ if (!entry) return { items: reply.items, dropped: [], reject: null };
297
+ const passes = Number.isInteger(filter.options?.repeat) && filter.options.repeat > 0 ? filter.options.repeat : 1;
298
+ let items = reply.items;
299
+ const dropped = [];
300
+ let reject = null;
301
+ for (let pass = 0; pass < passes && reject == null; pass++) {
302
+ try {
303
+ const out = entry.apply(filter, { body: reply.body, items });
304
+ items = out.items;
305
+ dropped.push(...out.dropped);
306
+ reject = out.reject;
307
+ } catch (e) {
308
+ // Ignore failures: leave the reply as this pass found it; else the run
309
+ // records the fault.
310
+ if (!filter.options?.ignoreFailures) throw e;
311
+ break;
312
+ }
313
+ }
314
+ return { items, dropped, reject };
315
+ }
316
+
317
+ /** Apply a filter set's filters to [reply], in order: each drop-item filter
318
+ narrows the items the next reads, and the first reject-reply filter that
319
+ fires rejects the answer and stops the rest. Pure. */
320
+ export function applyFilterSet(filters , reply ) {
321
+ let items = reply.items;
322
+ const dropped = [];
323
+ for (const filter of filters) {
324
+ const out = applyFilter(filter, { body: reply.body, items });
325
+ items = out.items;
326
+ dropped.push(...out.dropped);
327
+ if (out.reject != null) return { items, dropped, reject: out.reject };
328
+ }
329
+ return { items, dropped, reject: null };
330
+ }
331
+
332
+ const SEPARATOR_RE = { comma: /,/, lines: /\n/, semicolon: /;/, none: null };
333
+
334
+ /** [sample] split into items by [separator]: each trimmed, none blank. "none"
335
+ keeps the reply whole as one item. */
336
+ export function splitSample(sample , separator ) {
337
+ const re = SEPARATOR_RE[separator];
338
+ const parts = re ? String(sample ?? "").split(re) : [String(sample ?? "")];
339
+ return parts.map(s => s.trim()).filter(Boolean);
340
+ }
341
+
342
+ /** Each of [filter]'s tests, tried against the filter alone: the sample split
343
+ by the test's separator, the filter applied, and the result compared with
344
+ the expected output. A drop-item test passes when the items kept read as
345
+ the expected items; a reject-reply test passes when the reply is rejected
346
+ exactly if the expected output is blank. Supersedes tryExamples. */
347
+ export function tryFilter(filter ) {
348
+ const scope = FILTER_TYPES[filter.type]?.scope ?? "item";
349
+ return (filter.tests || []).map(test => {
350
+ const out = applyFilter(filter, { body: test.sampleInput, items: splitSample(test.sampleInput, test.separator) });
351
+ if (scope === "answer") {
352
+ const rejected = out.reject != null;
353
+ return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : out.items.join(", ") };
354
+ }
355
+ const want = splitSample(test.expectedOutput, test.separator);
356
+ const ok = out.items.length === want.length && out.items.every((t, i) => t === want[i]);
357
+ return { test, ok, got: out.items.join(", ") };
358
+ });
359
+ }
360
+
361
+ /** Why [filters] are not a filter list the lab reads, one sentence each: each
362
+ has a name, no name twice, a base type it has, whole-number repeat of 1 or
363
+ more, a boolean ignore-failures, and a matcher its type's scope takes
364
+ (itemRulesProblems). */
365
+ export function filterProblems(filters , at ) {
366
+ if (!Array.isArray(filters)) return [`${at}: filters has to be a list`];
367
+ const bad = [], seen = new Set ();
368
+ for (const f of filters ) {
369
+ const id = f && typeof f.id === "string" && f.id.trim() ? f.id : null;
370
+ if (!id) { bad.push(`${at}: a filter has no name`); continue; }
371
+ if (seen.has(id)) bad.push(`${at}: ${id} is named twice`);
372
+ seen.add(id);
373
+ const entry = typeof f.type === "string" ? FILTER_TYPES[f.type] : undefined;
374
+ if (!entry) { bad.push(`${at}: ${id} has no base type`); continue; }
375
+ const o = f.options;
376
+ if (!o || typeof o !== "object" || Array.isArray(o)) bad.push(`${at}: ${id} has no options`);
377
+ else {
378
+ if (!Number.isInteger(o.repeat) || o.repeat < 1) bad.push(`${at}: ${id}'s repeat has to be a whole number of 1 or more`);
379
+ if (typeof o.ignoreFailures !== "boolean") bad.push(`${at}: ${id}'s ignore failures has to be true or false`);
380
+ }
381
+ bad.push(...itemRulesProblems([{ id, match: f.match }], entry.scope, at));
382
+ }
383
+ return bad;
384
+ }
385
+
386
+ /** The filter an inline ItemRule becomes (issue #335): its name, why and
387
+ matcher kept as they are, its base [type] the one the modifier carrying it
388
+ fixed ("drop-item" for Drop items, "reject-reply" for Reject the answer),
389
+ its Options the defaults, and its examples its tests -- a caught example
390
+ becomes a sample the filter should empty (drop-item) or reject
391
+ (reject-reply), a kept example one it should leave. Pure: the match is
392
+ carried unchanged, so the filter cleans a reply exactly as the rule did.
393
+ The only reader of the old shape, for Runs to migrate its inline rules
394
+ (#339). */
395
+ export function filterFromItemRule(rule , type ) {
396
+ const scope = FILTER_TYPES[type]?.scope ?? "item";
397
+ const ex = rule.examples || {};
398
+ const caught = scope === "item" ? ex.drops || [] : ex.rejects || [];
399
+ const tests = [
400
+ ...caught.map((s) => ({ separator: "comma", sampleInput: s, expectedOutput: "" })),
401
+ ...(ex.keeps || []).map((s) => ({ separator: "comma", sampleInput: s, expectedOutput: s })),
402
+ ];
403
+ return {
404
+ id: rule.id, type,
405
+ ...(rule.why != null ? { why: rule.why } : {}),
406
+ match: rule.match ? { ...rule.match } : {},
407
+ options: { ignoreFailures: false, repeat: 1 },
408
+ tests,
409
+ };
410
+ }
411
+
187
412
  // ---- the modifiers -------------------------------------------------------------
188
413
 
189
414
  /** What an item's numbering looks like: "[1] suv", "(2) car", "3. dirt",
@@ -225,9 +450,28 @@ export function applyCase(item , mode ) {
225
450
  }
226
451
  }
227
452
 
453
+ /** The id of the synthetic modifier a linked or private filter set applies
454
+ under at run time (#339): it is never in a document or the Add menu -- a
455
+ job carries a `filters` step, which `stageModifiers` turns into one of
456
+ these where the step sits, so a set cleans and gates a list reply exactly
457
+ as the Drop items / Reject modifiers it replaced did, in the same order. */
458
+ export const FILTER_MODIFIER = "filterSet";
459
+
228
460
  const LIST_MODIFIERS = {
461
+ // A filter set's filters, applied where its link sits: the run's only reader
462
+ // of a resolved set, hidden from the Add menu (no options/defaults) because
463
+ // a set is linked in Responses, not added as a modifier.
464
+ [FILTER_MODIFIER]: {
465
+ accepts: ["list"], hidden: true,
466
+ apply(items, m, ctx) {
467
+ const out = applyFilterSet((m.filters ) || [], { body: ctx.body, items });
468
+ return out.reject != null
469
+ ? { value: out.items, dropped: out.dropped, reject: out.reject }
470
+ : { value: out.items, dropped: out.dropped };
471
+ },
472
+ },
229
473
  reject: {
230
- label: "Reject the answer if…", accepts: ["list"],
474
+ label: "Reject the answer if…", accepts: ["list"], hidden: true,
231
475
  description: "Throws the whole reply away when a rule below matches it.",
232
476
  options: [{ key: "rules", label: "Rules", type: "rules", scope: "answer" }],
233
477
  defaults: () => ({ type: "reject", rules: [] }),
@@ -248,7 +492,7 @@ const LIST_MODIFIERS = {
248
492
  apply: items => items.map(tidy).filter(Boolean),
249
493
  },
250
494
  drop: {
251
- label: "Drop items", accepts: ["list"],
495
+ label: "Drop items", accepts: ["list"], hidden: true,
252
496
  description: "Removes each item a rule below matches.",
253
497
  options: [{ key: "rules", label: "Rules", type: "rules", scope: "item" }],
254
498
  defaults: () => ({ type: "drop", rules: [] }),
package/lab/run-evals.js CHANGED
@@ -127,6 +127,10 @@ const USAGE = `Usage: node run-evals.js [options]
127
127
  grades against each one its evals link (§17), so a run
128
128
  linking several groups is handed them here instead of the
129
129
  one --dataset. For --run and --rescore; not with --dataset.
130
+ --filter-sets <file> the filter-set bodies a --run kept, by "<id>@<n>" (§18):
131
+ a Library set a job's Responses links cleans and gates
132
+ its reply by. A private set carries its body in the
133
+ document, so only linked sets are here. For --run.
130
134
  --source <dir> the same, named the way a run names it: the directory a
131
135
  Source is stored at. --samples and --source are one
132
136
  flag by two names; both together are refused.
@@ -208,7 +212,7 @@ function broken(msg) {
208
212
 
209
213
  function parseArgs(argv) {
210
214
  const o = { model: "", url: "", prompt: null, pipeline: "", samples: "", dataset: "",
211
- groups: "", replies: "", json: "", source: "", files: "", item: "", resume: "",
215
+ groups: "", filterSets: "", replies: "", json: "", source: "", files: "", item: "", resume: "",
212
216
  timeout: null, progress: false, cancelFile: "",
213
217
  run: "", progressFile: "", resultsFile: "", from: null, only: null,
214
218
  rescore: false, tokens: "", plugins: "", minPass: null, junit: "" };
@@ -230,6 +234,7 @@ function parseArgs(argv) {
230
234
  case "--samples": o.samples = value(); break;
231
235
  case "--dataset": o.dataset = value(); break;
232
236
  case "--groups": o.groups = value(); break;
237
+ case "--filter-sets": o.filterSets = value(); break;
233
238
  case "--replies": o.replies = value(); break;
234
239
  case "--source": o.source = value(); break;
235
240
  case "--files": o.files = value(); break;
@@ -395,6 +400,30 @@ function gradingContext(o) {
395
400
  return { single, bodies: null, primary: () => single, resolve: () => single ?? undefined };
396
401
  }
397
402
 
403
+ // The filter-set bodies a run kept, as --filter-sets hands them (§18): a JSON
404
+ // object of `<id>@<n>` to body -- a Library set's id and the version the run
405
+ // cleaned and gated with -- each read as today's (upgradeFilterSetBody). A
406
+ // private (`own`) set carries its body in the document, so only Library links
407
+ // are here. Returns a resolver a job's filters step reads a link's body by,
408
+ // mirroring the server's filter_set_ref / group_key.
409
+ function filterContext(o) {
410
+ if (!o.filterSets) return () => undefined;
411
+ let raw;
412
+ try { raw = JSON.parse(fs.readFileSync(o.filterSets, "utf8")); }
413
+ catch (e) { broken(`${o.filterSets}: ${e.message}`); }
414
+ if (!isObject(raw)) broken(`${o.filterSets}: the kept filter sets are a JSON object of "<id>@<n>": body`);
415
+ const bodies = new Map();
416
+ for (const [key, body] of Object.entries(raw)) {
417
+ const at = `${o.filterSets} [${key}]`;
418
+ const got = core.upgradeFilterSetBody(body);
419
+ const bad = core.filterSetBodyProblems(got, at);
420
+ if (bad.length) broken(bad[0]);
421
+ bodies.set(key, got);
422
+ }
423
+ const key = (ref) => (ref && Number.isInteger(ref.n) ? `${ref.id}@${ref.n}` : `${ref && ref.id}`);
424
+ return (ref) => bodies.get(key(ref)) ?? bodies.get(String(ref && ref.id)) ?? undefined;
425
+ }
426
+
398
427
  // The cases a graded run is scored against, by the item each names: exactly,
399
428
  // as the Source names it. Built from the one group a single-group run names,
400
429
  // for an eval type of its own that reads a case (a plugin's); a `group` eval
@@ -900,9 +929,11 @@ async function runSnapshot(o) {
900
929
 
901
930
  // Each scenario once: its stages, the jobs' token sets, and a transport
902
931
  // per stage to the profile that stage resolves to, keyed by that
903
- // profile's id.
932
+ // profile's id. Its stages carry each linked or private filter set as a
933
+ // modifier that cleans and gates the reply where the link sits (#339).
934
+ const resolveFilters = filterContext(o);
904
935
  const plans = core.targetsOf(run).map((_, i) => {
905
- const { stages, tokens, connections, calls, cells } = core.stagesFor(run, i);
936
+ const { stages, tokens, connections, calls, cells } = core.stagesFor(run, i, resolveFilters);
906
937
  const links = connections.map(c => reach(c, core.keyVar(c.id, c.slug)));
907
938
  // The Recorded target replays the recorded reply, so a metric that compares
908
939
  // with the recorded reply has nothing to say of it (n/a).