evals-lab 0.14.0 → 0.16.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.
@@ -87,7 +87,7 @@ const inRange = (b ) => (v ) =>
87
87
  /** The stateless predicates an item matcher takes, on the item alone. */
88
88
  export const MATCHER_KEYS = ["terms", "regex", "length", "words", "tokens", "digitShare"] ;
89
89
 
90
- /** Every condition a Drop item (and a Rewrite item's limit) may hold: the
90
+ /** Every condition a Remove of the items that match may hold: the
91
91
  stateless matcher keys, and the two that read the item in context --
92
92
  `duplicate` (a case-insensitive repeat of one kept earlier in this pass)
93
93
  and `echoes` (made only of the stage instruction's words), #405. */
@@ -225,29 +225,25 @@ export function tryExamples(rule , scope )
225
225
  // ---- filters: the generic reply filter (issue #335) ----------------------------
226
226
  //
227
227
  // A Filter is the reusable unit #334 introduces: it supersedes the inline
228
- // ItemRule a Drop items or Reject the answer modifier carries today. Where a
229
- // rule was a bare matcher with examples, a filter has a base TYPE from a
230
- // registry (not an enum) -- "drop-item" cleans each item of a list reply,
231
- // "reject-reply" gates the whole answer -- generic conditions reusing the
232
- // matcher above (ANDed), Options, and Tests that are its own checks. The two
233
- // base types evaluate here, beside the matcher and rejectHolds they are built
234
- // from; the Library-stored filter-set body, the pipeline link that carries
235
- // one, and their version live in evals-core.ts, as DatasetBody and the
236
- // eval-group link do. Nothing here replaces the Drop items / Reject modifiers:
237
- // Runs wires a filter set into Responses and migrates the inline rules in a
238
- // later phase (#339), so both base types and the ItemRule shape stand.
228
+ // ItemRule a Drop items or Reject the answer modifier carried. Where a rule
229
+ // was a bare matcher with examples, a filter has a base TYPE from a registry
230
+ // (not an enum) -- one of seven verbs (#615) -- generic conditions reusing the
231
+ // matcher above (ANDed), Options, and Tests that are its own checks. The verbs
232
+ // evaluate here, beside the matcher they are built from; the Library-stored
233
+ // filter-set body, the pipeline link that carries one, and their version live
234
+ // in evals-core.ts, as DatasetBody and the eval-group link do.
239
235
 
240
236
  /** How a filter test splits its sample reply into items: by the named
241
237
  delimiter and always by newline, as a run's parse does. "none" keeps the
242
- reply whole, for a reject-reply filter that reads the text. */
238
+ reply whole: a test of the filter on a reply's text. */
243
239
  export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"] ;
244
240
 
245
241
 
246
242
  /** A sample reply and what the filter should leave of it: the editor splits
247
243
  the sample by `separator`, applies the filter, and shows whether the result
248
- reads as `expectedOutput`. A drop-item filter's expected output is the
249
- items it keeps; a reject-reply filter's is blank when the reply should be
250
- rejected, or left non-blank when it should pass. */
244
+ reads as `expectedOutput`: the items or text it leaves, or for a Reject,
245
+ blank when the reply should be rejected and anything else when it should
246
+ pass. */
251
247
 
252
248
 
253
249
 
@@ -261,31 +257,35 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
261
257
 
262
258
 
263
259
 
264
- /** A filter (issue #335, #405): a base TYPE fixed at creation (a FILTER_TYPES
265
- id), generic conditions in `match` (the matcher's keys for its type, ANDed),
266
- an optional `op` carrying the type's own operation (Rewrite's and Keep
267
- first's -- Drop item and the rejects hold none), Options and Tests. `id` is
268
- its name, as an ItemRule's was. */
260
+ /** A filter (issue #335, #615): a base TYPE fixed at creation -- one of the
261
+ FILTER_TYPES verbs -- generic conditions in `match` (the matcher's keys
262
+ for its type, ANDed), an optional `op` carrying the verb's own settings,
263
+ Options and Tests. `id` is its name, as an ItemRule's was. */
269
264
 
270
265
 
271
266
 
272
267
 
273
268
 
274
-
275
-
276
-
277
-
269
+
270
+
271
+
278
272
 
279
273
 
280
274
 
281
275
 
282
276
 
283
- /** What a filter works over: the text the reply came as (which a text-scope
284
- filter rewrites, and the items are parsed from), the items parsed from it,
285
- and the stage's instruction a filter may read (an item that echoes it). */
277
+ /** What a filter runs on (#615): the reply's `text` -- above a format that
278
+ reads items, or anywhere in a job whose format reads text -- or each of
279
+ its `items`, below a format that reads them. A text reply is a list of one
280
+ item, so every verb means the same on either. */
281
+
282
+
283
+ /** What a filter works over: the text the reply came as (which a filter on
284
+ the text rewrites, and the items are parsed from), the items parsed from
285
+ it, and the stage's instruction a filter may read (an item that echoes it). */
286
286
 
287
287
 
288
- /** What applying a filter or a set left: the reply text (a Rewrite text filter
288
+ /** What applying a filter or a set left: the reply text (a filter on the text
289
289
  changes it), the items kept, what was dropped and by which filter, and --
290
290
  the first filter that rejected -- the reply's rejection (null when none). */
291
291
 
@@ -293,141 +293,198 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
293
293
 
294
294
 
295
295
 
296
-
296
+
297
297
 
298
298
 
299
299
 
300
- /** A base filter type (#334's decision: a registry, not an enum). A reader
301
- asks the entry by `filter.type`; nothing branches on the id. */
300
+ /** A base filter type: a verb (#334's decision: a registry, not an enum; #615
301
+ made them seven verbs). A reader asks the entry by `filter.type`; nothing
302
+ branches on the id. */
302
303
 
303
-
304
+
304
305
 
305
-
306
+
306
307
 
307
-
308
-
309
-
310
-
311
-
308
+
309
+
310
+
311
+
312
+
313
+
312
314
 
313
315
 
314
316
 
317
+
318
+
319
+
315
320
 
316
321
 
317
322
 
323
+
324
+
325
+
326
+
327
+
328
+
329
+
330
+
331
+
332
+
333
+
318
334
 
319
335
 
320
336
 
321
337
 
338
+
339
+
340
+
322
341
 
323
-
324
-
325
342
 
326
343
 
327
- /** The whole-answer conditions a Reject text filter reads over the raw text:
328
- `blank`, and `regex` searched anywhere in it (unanchored, case-insensitive
329
- -- a text reject looks for a phrase, not a whole-string match). */
330
- function rejectTextHolds(m , text ) {
344
+ /** Whether a reply whose text is [body] and items are [items] meets Reject's
345
+ conditions [m]: `blank` (the text is blank), `regex` (searched anywhere in
346
+ the text, case-insensitive -- a reject looks for a phrase, not a whole
347
+ string), `itemCount`, and the `share` of the items `items` matches. Every
348
+ one given must hold; one that states none never fires. */
349
+ function rejectFires(m , body , items ) {
331
350
  const conds = [];
332
- if (m.blank === true) conds.push(!text.trim());
333
- if (typeof m.regex === "string") { const r = m.regex; let re ; try { re = new RegExp(r, "i"); } catch { re = null; } conds.push(!!re && re.test(text)); }
351
+ if (m.blank === true) conds.push(!body.trim());
352
+ if (typeof m.regex === "string") { let re ; try { re = new RegExp(m.regex, "i"); } catch { re = null; } conds.push(!!re && re.test(body)); }
353
+ if (m.itemCount && typeof m.itemCount === "object") conds.push(inRange(m.itemCount )(items.length));
354
+ if (m.share && typeof m.share === "object") {
355
+ const test = matcher(m.items );
356
+ // A share with nothing to measure cannot fire.
357
+ conds.push(!!test && inRange(m.share )(items.length ? items.filter(t => test(t)).length / items.length : 0));
358
+ }
334
359
  return conds.length > 0 && conds.every(Boolean);
335
360
  }
336
361
 
337
- /** The function a Rewrite item filter's `op` names: Tidy, or Case in its
338
- mode. An unknown op leaves the item as it is. */
339
- function rewriteItem(op ) {
340
- if (op?.kind === "tidy") return tidy;
341
- if (op?.kind === "case") { const mode = op.mode ; return t => applyCase(t, mode); }
342
- return t => t;
343
- }
344
-
345
- /** The function a Rewrite text filter's `op` names (#557): Remove thinking,
346
- Remove preamble, Trim, Replace text (literal, every occurrence), Replace
347
- pattern (a regex, global, and its replacement), Strip code fences, Change
348
- case, Keep first (N lines or characters), or Extract (a pattern's first
349
- match, its first group if it has one). It answers null where nothing is
350
- left to keep -- Extract found no match -- which rejects the reply. An
351
- unknown op leaves the text as it is; a pattern that will not compile
352
- throws, so Ignore failures can carry a run past it. */
353
- function rewriteText(op ) {
354
- switch (op?.kind) {
355
- case "thinking": return removeThinking;
356
- case "preamble": return removePreamble;
357
- case "trim": return text => text.trim();
358
- case "literal": {
359
- const find = String(op.find ?? "");
360
- const to = typeof op.replacement === "string" ? op.replacement : "";
361
- return text => (find ? text.split(find).join(to) : text);
362
- }
363
- case "replace": {
364
- const re = new RegExp(String(op.pattern ?? ""), "g");
365
- const to = typeof op.replacement === "string" ? op.replacement : "";
366
- return text => text.replace(re, to);
367
- }
368
- // A fence's own lines go -- the opening one with its language, and the
369
- // closing one -- wherever they sit, so a reply fenced whole or a fence
370
- // after a line of preamble both read as what was inside.
371
- case "fences": return text => text.replace(FENCE_LINE, "");
372
- case "case": { const mode = op.mode ; return text => applyCase(text, mode); }
373
- case "first": {
374
- const n = op.n ;
375
- return op.unit === "chars" ? text => text.slice(0, n) : text => text.split("\n").slice(0, n).join("\n");
376
- }
377
- case "extract": {
378
- const re = new RegExp(String(op.pattern ?? ""));
379
- return text => { const m = re.exec(text); return m ? (m.length > 1 ? m[1] ?? "" : m[0]) : null; };
380
- }
381
- default: return text => text;
362
+ /** A reply's text as the one item it is (#615): none when it is blank. */
363
+ const oneItem = (body ) => (body.trim() ? [body.trim()] : []);
364
+
365
+ /** A verb that changes one piece of text at a time, applied to [reply]: to
366
+ its text, or to each item its `match` limits it to (none: every one). Run
367
+ on the text, a [change] that leaves nothing (null) rejects the reply, as an
368
+ Extract that matched nothing always has; run on the items, the item goes,
369
+ as does one it leaves blank (as Tidy always dropped one it emptied). */
370
+ function eachPiece(filter , reply , on , change ) {
371
+ const test = matcher(filter.match);
372
+ if (on === "text") {
373
+ if (test && !test(reply.body)) return outcome(reply);
374
+ const body = change(reply.body);
375
+ return { body: body ?? reply.body, items: reply.items, dropped: [], reject: body == null ? filter.id : null };
376
+ }
377
+ const items = [], dropped = [];
378
+ for (const t of reply.items) {
379
+ const next = !test || test(t) ? change(t) : t;
380
+ if (next == null) dropped.push({ item: t, by: filter.id });
381
+ else if (next) items.push(next);
382
382
  }
383
+ return { body: reply.body, items, dropped, reject: null };
383
384
  }
384
385
 
386
+ /** One item as Trim with bullets leaves it (tidy); a text, each line so. */
387
+ const tidyLines = (text ) => text.split("\n").map(tidy).filter(Boolean).join("\n");
388
+
389
+ /** What Remove takes out of a piece of text, by its `kind`: a thinking
390
+ block, a preamble, code fences. "items" is Remove's own reading of its
391
+ matcher, not a rewrite. */
392
+ const REMOVALS = {
393
+ thinking: removeThinking,
394
+ preamble: removePreamble,
395
+ // A fence's own lines go -- the opening one with its language, and the
396
+ // closing one -- wherever they sit, so a reply fenced whole or a fence
397
+ // after a line of preamble both read as what was inside.
398
+ fences: text => text.replace(FENCE_LINE, ""),
399
+ };
400
+
385
401
  const FENCE_LINE = /^[ \t]*```[^`\n]*(?:\n|$)/gm;
386
402
 
387
- /** Keep first's units, for a Rewrite text: lines, or characters. */
388
- const TEXT_UNITS = ["lines", "chars"] ;
403
+ /** Keep first's units. */
404
+ const KEEP_UNITS = [{ value: "items", label: "Items" }, { value: "lines", label: "Lines" }, { value: "chars", label: "Characters" }];
389
405
 
390
- /** Why a Rewrite text op's pattern does not compile, as a sentence, or none. */
406
+ /** Why a pattern does not compile, as a sentence, or none. */
391
407
  const patternProblems = (pattern , at ) => {
392
408
  if (typeof pattern !== "string" || !pattern) return [`${at} needs a pattern`];
393
409
  try { new RegExp(pattern); return []; } catch { return [`${at}'s pattern does not compile`]; }
394
410
  };
395
411
 
396
- /** The lab's base filter types (#405). Generic code asks this registry by
397
- `filter.type`; it never names an id. `rewrite-text`/`reject-text` read the
398
- reply's text: Pre-format, or Post-format after a kind that reads no items
399
- (#557); the rest read the items, Post-format after a kind that does. */
412
+ const caseProblems = (op , at ) =>
413
+ (CASE_MODES.includes(op?.mode ) ? [] : [`${at}'s case needs a mode`]);
414
+
415
+ /** The lab's base filter types: seven verbs (#615), A to Z. Generic code asks
416
+ this registry by `filter.type`; it never names an id. Where a filter runs
417
+ -- on the text or on each item -- is its place in the list, around the
418
+ job's Response Format, never its type. */
400
419
  export const FILTER_TYPES = {
401
- "rewrite-text": {
402
- label: "Rewrite text", about: "Trim, replace, unfence, recase, shorten or extract from the reply's text.", scope: "text", conditions: [],
403
- opProblems: (f, at) => opKindProblems(f.op, at, {
404
- thinking: [], preamble: [], trim: [], literal: ["find", "replacement"], replace: ["pattern", "replacement"],
405
- fences: [], case: ["mode"], first: ["n", "unit"], extract: ["pattern"],
406
- }, op => {
407
- if (op.kind === "literal" && (typeof op.find !== "string" || !op.find)) return [`${at} needs text to find`];
408
- if (op.kind === "extract") return patternProblems(op.pattern, at);
409
- if (op.kind === "case" && !CASE_MODES.includes(op.mode )) return [`${at}'s case needs a mode`];
410
- if (op.kind === "first") {
411
- return [...(Number.isInteger(op.n) && (op.n ) >= 1 ? [] : [`${at} needs a whole number of 1 or more`]),
412
- ...(TEXT_UNITS.includes(op.unit ) ? [] : [`${at} keeps lines or characters`])];
420
+ "change-case": {
421
+ label: "Change case", about: "Lower, upper, sentence or title case.", icon: "a-large-small",
422
+ matches: "limit", conditions: MATCHER_KEYS,
423
+ defaultOp: () => ({ mode: "LOWER" }),
424
+ opProblems: (f, at) => [...caseProblems(f.op, at), ...extraOpKeys(f.op, ["mode"], at)],
425
+ apply: (filter, reply, on) => eachPiece(filter, reply, on, t => applyCase(t, filter.op?.mode )),
426
+ },
427
+ "extract": {
428
+ label: "Extract", about: "The first match of a pattern.", icon: "scan-search",
429
+ conditions: [], rawReply: () => true,
430
+ defaultOp: () => ({ pattern: "" }),
431
+ opProblems: (f, at) => [...patternProblems(f.op?.pattern, at), ...extraOpKeys(f.op, ["pattern"], at)],
432
+ apply(filter, reply, on) {
433
+ // The first match, or its first group if it has one; none, nothing left.
434
+ const re = new RegExp(String(filter.op?.pattern ?? ""));
435
+ return eachPiece(filter, reply, on, t => { const m = re.exec(t); return m ? (m.length > 1 ? m[1] ?? "" : m[0]) : null; });
436
+ },
437
+ },
438
+ "keep-first": {
439
+ label: "Keep first", about: "The first N items, lines or characters.", icon: "text-search",
440
+ conditions: [], choices: { unit: KEEP_UNITS },
441
+ defaultOp: () => ({ n: 10, unit: "items" }),
442
+ opProblems: (f, at) => [
443
+ ...(Number.isInteger(f.op?.n) && (f.op .n ) >= 1 ? [] : [`${at} needs a whole number of 1 or more`]),
444
+ ...(KEEP_UNITS.some(u => u.value === f.op?.unit) ? [] : [`${at} keeps items, lines or characters`]),
445
+ ...extraOpKeys(f.op, ["n", "unit"], at)],
446
+ apply(filter, reply, on) {
447
+ const n = filter.op?.n ;
448
+ if (filter.op?.unit === "items") {
449
+ // A text is one item, which the first N always keep.
450
+ if (on === "text") return outcome(reply);
451
+ return { body: reply.body, items: reply.items.slice(0, n), dropped: reply.items.slice(n).map(t => ({ item: t, by: pastN(n) })), reject: null };
413
452
  }
414
- return [];
415
- }),
416
- apply(filter, reply) {
417
- const body = rewriteText(filter.op)(reply.body);
418
- return { body: body ?? reply.body, items: reply.items, dropped: [], reject: body == null ? filter.id : null };
453
+ return eachPiece(filter, reply, on, filter.op?.unit === "chars" ? t => t.slice(0, n) : t => t.split("\n").slice(0, n).join("\n"));
419
454
  },
420
455
  },
421
- "reject-text": {
422
- label: "Reject text", about: "Discard the reply when it is blank or matches a pattern.", scope: "text", conditions: ["blank", "regex"], rejects: true,
423
- apply: (filter, reply) => ({ body: reply.body, items: reply.items, dropped: [], reject: rejectTextHolds(filter.match, reply.body) ? filter.id : null }),
456
+ "reject": {
457
+ label: "Reject", about: "Discard the response when it matches its conditions.", icon: "trash",
458
+ matches: "reply", conditions: ["blank", "regex", "itemCount", "share", "items"], rejects: true,
459
+ apply: (filter, reply, on) => ({ body: reply.body, items: reply.items, dropped: [],
460
+ reject: rejectFires(filter.match, reply.body, on === "text" ? oneItem(reply.body) : reply.items) ? filter.id : null }),
424
461
  },
425
- "drop-item": {
426
- label: "Drop item", about: "Remove the items that match its conditions.", scope: "item", conditions: ITEM_CONDITIONS,
427
- apply(filter, reply) {
462
+ "remove": {
463
+ label: "Remove", about: "A thinking block, a preamble, code fences, or the items that match.", icon: "eraser",
464
+ matches: "item", conditions: ITEM_CONDITIONS,
465
+ kinds: [
466
+ { kind: "thinking", label: "Thinking block", about: "A <think>…</think> block" },
467
+ { kind: "preamble", label: "Preamble", about: "Everything up to the first colon" },
468
+ { kind: "fences", label: "Code fences", about: "Each ``` line, keeping what it fences" },
469
+ { kind: "items", label: "Items that match" },
470
+ ],
471
+ defaultOp: (kind = "items") => ({ kind }),
472
+ matchesWith: op => op?.kind === "items",
473
+ rawReply: f => f.op?.kind !== "items",
474
+ opProblems(f, at) {
475
+ const kind = f.op?.kind ;
476
+ if (kind !== "items" && !(kind in REMOVALS)) return [`${at} has nothing to remove`];
477
+ return [...extraOpKeys(f.op, ["kind"], at),
478
+ ...(kind !== "items" && Object.keys(f.match ?? {}).length ? [`${at} takes no conditions`] : [])];
479
+ },
480
+ apply(filter, reply, on) {
481
+ const kind = filter.op?.kind ;
482
+ if (kind !== "items") return eachPiece({ ...filter, match: {} }, reply, on, REMOVALS[kind] ?? (t => t));
428
483
  const test = matcher(filter.match);
429
484
  if (!test) return outcome(reply);
430
485
  const said = reply.instruction && reply.instruction.trim() ? new Set(words(reply.instruction)) : new Set ();
486
+ // A text is one item: matched, nothing of it is left.
487
+ if (on === "text") return test(reply.body, { seen: new Set(), said }) ? { ...outcome(reply), body: "" } : outcome(reply);
431
488
  const seen = new Set ();
432
489
  const items = [], dropped = [];
433
490
  for (const t of reply.items) {
@@ -437,58 +494,61 @@ export const FILTER_TYPES = {
437
494
  return { body: reply.body, items, dropped, reject: null };
438
495
  },
439
496
  },
440
- "rewrite-item": {
441
- label: "Rewrite item", about: "Tidy each item, or change its case.", scope: "item", conditions: MATCHER_KEYS,
442
- opProblems: (f, at) => opKindProblems(f.op, at, { tidy: [], case: ["mode"] }, op =>
443
- op.kind === "case" && !CASE_MODES.includes(op.mode ) ? [`${at}'s case needs a mode`] : []),
444
- apply(filter, reply) {
445
- const test = matcher(filter.match);
446
- const rewrite = rewriteItem(filter.op);
447
- // Only matched items are rewritten; a blank result is dropped, as Tidy
448
- // has always dropped an item it emptied.
449
- const items = reply.items.map(t => (!test || test(t) ? rewrite(t) : t)).filter(Boolean);
450
- return { body: reply.body, items, dropped: [], reject: null };
497
+ "replace": {
498
+ label: "Replace", about: "Text or a pattern, with other text.", icon: "replace",
499
+ conditions: [],
500
+ kinds: [
501
+ { kind: "text", label: "Text", about: "Every occurrence, matched exactly" },
502
+ { kind: "pattern", label: "Pattern", about: "Every match of a regular expression" },
503
+ ],
504
+ defaultOp: (kind = "text") => (kind === "pattern" ? { kind, pattern: "", replacement: "" } : { kind, find: "", replacement: "" }),
505
+ opProblems(f, at) {
506
+ const op = f.op;
507
+ if (op?.kind === "text") {
508
+ return [...(typeof op.find === "string" && op.find ? [] : [`${at} needs text to find`]),
509
+ ...extraOpKeys(op, ["kind", "find", "replacement"], at)];
510
+ }
511
+ if (op?.kind === "pattern") return [...patternProblems(op.pattern, at), ...extraOpKeys(op, ["kind", "pattern", "replacement"], at)];
512
+ return [`${at} replaces text or a pattern`];
451
513
  },
452
- },
453
- "keep-first": {
454
- label: "Keep first", about: "Keep the first N items.", scope: "list", conditions: [],
455
- opProblems: (f, at) => (Number.isInteger(f.op?.n) && (f.op .n ) >= 1
456
- ? extraOpKeys(f.op, ["n"], at) : [`${at} needs a whole number of 1 or more`]),
457
- apply(filter, reply) {
458
- const n = filter.op?.n ;
459
- return { body: reply.body, items: reply.items.slice(0, n), dropped: reply.items.slice(n).map(t => ({ item: t, by: pastN(n) })), reject: null };
514
+ apply(filter, reply, on) {
515
+ const op = filter.op ?? {};
516
+ const to = typeof op.replacement === "string" ? op.replacement : "";
517
+ if (op.kind === "pattern") {
518
+ const re = new RegExp(String(op.pattern ?? ""), "g");
519
+ return eachPiece(filter, reply, on, t => t.replace(re, to));
520
+ }
521
+ const find = String(op.find ?? "");
522
+ return eachPiece(filter, reply, on, t => (find ? t.split(find).join(to) : t));
460
523
  },
461
524
  },
462
- "reject-reply": {
463
- label: "Reject reply", about: "Discard the whole reply when its items match its conditions.", scope: "answer", conditions: ["blank", "itemCount", "share", "items"], rejects: true,
464
- apply: (filter, reply) => ({ body: reply.body, items: reply.items, dropped: [], reject: rejectHolds(filter.match, reply.body, reply.items) ? filter.id : null }),
525
+ "trim": {
526
+ label: "Trim", about: "Whitespace and blank lines, and bullets and numbering if you choose.", icon: "scissors",
527
+ matches: "limit", conditions: MATCHER_KEYS,
528
+ defaultOp: () => ({ bullets: false }),
529
+ opProblems: (f, at) => [...(typeof f.op?.bullets === "boolean" ? [] : [`${at}'s bullets has to be true or false`]),
530
+ ...extraOpKeys(f.op, ["bullets"], at)],
531
+ // With bullets, an item is tidied -- its bullet, numbering, stray quotes
532
+ // and stops gone, its spaces collapsed -- and a text each of its lines.
533
+ apply: (filter, reply, on) => eachPiece(filter, reply, on,
534
+ filter.op?.bullets ? (on === "text" ? tidyLines : tidy) : t => t.trim()),
465
535
  },
466
536
  };
467
537
 
468
538
  /** The reply unchanged -- a filter that matches nothing. */
469
539
  const outcome = (reply ) => ({ body: reply.body, items: reply.items, dropped: [], reject: null });
470
540
 
471
- /** Why [op]'s `kind` is not one of [kinds] (and its extra keys beyond that
472
- kind's), one sentence each. [more] adds a kind's own checks. */
473
- function opKindProblems(op , at ,
474
- kinds , more ) {
475
- if (!op || typeof op !== "object" || Array.isArray(op)) return [`${at} has no operation`];
476
- const kind = op.kind ;
477
- if (typeof kind !== "string" || !(kind in kinds)) return [`${at}'s operation is not one this filter has`];
478
- return [...extraOpKeys(op, ["kind", ...kinds[kind] ], at), ...(more ? more(op) : [])];
479
- }
480
-
481
541
  /** Why [op] holds a key that is not [allowed], one sentence each. */
482
542
  function extraOpKeys(op , allowed , at ) {
483
543
  return op ? Object.keys(op).filter(k => !allowed.includes(k)).map(k => `${at} has "${k}", which is not one of its settings`) : [];
484
544
  }
485
545
 
486
- /** Apply one filter to [reply], honouring its Options: `repeat` passes over
487
- the reply (a filter that rejects stops at the first that fires), and
488
- `ignoreFailures` leaves the reply as it was if the filter errors -- a
489
- condition or pattern that cannot be read -- rather than failing the run.
490
- Pure. */
491
- export function applyFilter(filter , reply ) {
546
+ /** Apply one filter to [reply] [on] its text or its items, honouring its
547
+ Options: `repeat` passes over the reply (a filter that rejects stops at the
548
+ first that fires), and `ignoreFailures` leaves the reply as it was if the
549
+ filter errors -- a condition or pattern that cannot be read -- rather than
550
+ failing the run. Pure. */
551
+ export function applyFilter(filter , reply , on = "items") {
492
552
  const entry = FILTER_TYPES[filter.type];
493
553
  if (!entry) return outcome(reply);
494
554
  const passes = Number.isInteger(filter.options?.repeat) && filter.options.repeat > 0 ? filter.options.repeat : 1;
@@ -498,7 +558,7 @@ export function applyFilter(filter , reply ) {
498
558
  let reject = null;
499
559
  for (let pass = 0; pass < passes && reject == null; pass++) {
500
560
  try {
501
- const out = entry.apply(filter, { body, items, instruction });
561
+ const out = entry.apply(filter, { body, items, instruction }, on);
502
562
  body = out.body;
503
563
  items = out.items;
504
564
  dropped.push(...out.dropped);
@@ -513,21 +573,17 @@ export function applyFilter(filter , reply ) {
513
573
  return { body, items, dropped, reject };
514
574
  }
515
575
 
516
- /** Apply a filter set's filters to [reply], in order: a Rewrite text filter
517
- reshapes the text the next reads, each item filter narrows or rewrites the
518
- items, and the first filter that rejects gates the answer and stops the
519
- rest. Given a [stage], only the filters whose type runs there apply: a set
520
- belongs to no stage (#432), so one linked both Pre-format and Post-format runs
521
- its text filters on the raw reply and the rest on the items. Pure. */
522
- export function applyFilterSet(filters , reply , stage ) {
576
+ /** Apply [filters] to [reply] in order, [on] its text or its items (#615:
577
+ where they sit around the job's Response Format, never their type): each
578
+ reshapes the text or narrows the items the next reads, and the first that
579
+ rejects gates the answer and stops the rest. Pure. */
580
+ export function applyFilterSet(filters , reply , on = "items") {
523
581
  const instruction = reply.instruction ?? "";
524
582
  let body = reply.body, items = reply.items;
525
583
  const dropped = [], rewrote = [];
526
584
  let emptied ;
527
585
  for (const filter of filters) {
528
- const scope = FILTER_TYPES[filter.type]?.scope;
529
- if (stage && scope && (scope === "text") !== (stage === "text")) continue;
530
- const out = applyFilter(filter, { body, items, instruction });
586
+ const out = applyFilter(filter, { body, items, instruction }, on);
531
587
  if (out.body !== body) rewrote.push(filter.id);
532
588
  body = out.body;
533
589
  if (items.length && !out.items.length) emptied = filter.id;
@@ -553,25 +609,25 @@ export function splitSample(sample , separator )
553
609
  return parts.map(s => s.trim()).filter(Boolean);
554
610
  }
555
611
 
556
- /** Each of [filter]'s tests, tried against the filter alone: the sample read
557
- the way the filter's scope reads it, the filter applied, and the result
558
- compared with the expected output. A rejecting filter passes when the reply
559
- is rejected exactly if the expected output is blank; a Rewrite text filter
560
- when the text it leaves reads as the expected text; an item or list filter
561
- when the items it keeps read as the expected items. */
612
+ /** Each of [filter]'s tests, tried against the filter alone. A test's
613
+ separator says what its sample is: "none" a reply's text, which the filter
614
+ runs on as text; any other, items, which it runs on each of. A rejecting
615
+ filter passes when the reply is rejected exactly if the expected output is
616
+ blank; on the text, when the text it leaves reads as the expected text (or,
617
+ where it rejects, the expected output is blank); on items, when the items
618
+ it keeps read as the expected items. */
562
619
  export function tryFilter(filter ) {
563
620
  const entry = FILTER_TYPES[filter.type];
564
- const scope = entry?.scope ?? "item";
565
621
  return (filter.tests || []).map(test => {
566
- // A text filter reads the whole sample; an item or list filter splits it.
567
- const items = scope === "text" ? [] : splitSample(test.sampleInput, test.separator);
568
- const out = applyFilter(filter, { body: test.sampleInput, items, instruction: "" });
622
+ const on = test.separator === "none" ? "text" : "items";
623
+ const items = on === "text" ? [] : splitSample(test.sampleInput, test.separator);
624
+ const out = applyFilter(filter, { body: test.sampleInput, items, instruction: "" }, on);
569
625
  if (entry?.rejects) {
570
626
  const rejected = out.reject != null;
571
- return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : (scope === "text" ? out.body : out.items.join(", ")) };
627
+ return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : (on === "text" ? out.body : out.items.join(", ")) };
572
628
  }
573
- if (scope === "text") {
574
- // A Rewrite text that rejects (Extract, matching nothing) leaves nothing.
629
+ if (on === "text") {
630
+ // A filter on the text that rejects (Extract, matching nothing) leaves nothing.
575
631
  if (out.reject != null) return { test, ok: !test.expectedOutput.trim(), got: "rejected" };
576
632
  const got = out.body.trim();
577
633
  return { test, ok: got === test.expectedOutput.trim(), got };
@@ -609,7 +665,7 @@ const isObj = (v ) => !!v && typeof v ===
609
665
 
610
666
  /** Why [filters] are not a filter list the lab reads, one sentence each: each
611
667
  has a name, no name twice, a base type it has, whole-number repeat of 1 or
612
- more, a boolean ignore-failures, conditions its type's scope takes, and an
668
+ more, a boolean ignore-failures, conditions its type takes, and an
613
669
  operation its type takes (or none, for a type with none). */
614
670
  export function filterProblems(filters , at ) {
615
671
  if (!Array.isArray(filters)) return [`${at}: filters has to be a list`];
@@ -628,25 +684,24 @@ export function filterProblems(filters , at ) {
628
684
  if (typeof o.ignoreFailures !== "boolean") bad.push(`${at}: ${id}'s ignore failures has to be true or false`);
629
685
  }
630
686
  bad.push(...filterMatchProblems(f.match, entry.conditions, at, id));
631
- if (entry.opProblems) bad.push(...entry.opProblems(f, `${at}: ${id}`));
632
- else if (f.op != null) bad.push(`${at}: ${id} takes no operation`);
687
+ if (entry.opProblems) {
688
+ bad.push(...(isObj(f.op) ? entry.opProblems(f, `${at}: ${id}`) : [`${at}: ${id} has no operation`]));
689
+ } else if (f.op != null) bad.push(`${at}: ${id} takes no operation`);
633
690
  }
634
691
  return bad;
635
692
  }
636
693
 
637
694
  /** The filter an inline ItemRule becomes (issue #335): its name, why and
638
- matcher kept as they are, its base [type] the one the modifier carrying it
639
- fixed ("drop-item" for Drop items, "reject-reply" for Reject the answer),
640
- its Options the defaults, and its examples its tests -- a caught example
641
- becomes a sample the filter should empty (drop-item) or reject
642
- (reject-reply), a kept example one it should leave. Pure: the match is
643
- carried unchanged, so the filter cleans a reply exactly as the rule did.
644
- The only reader of the old shape, for Runs to migrate its inline rules
645
- (#339). */
646
- export function filterFromItemRule(rule , type ) {
647
- const scope = FILTER_TYPES[type]?.scope ?? "item";
695
+ matcher kept as they are, its verb the one the modifier carrying it fixed
696
+ -- Remove (its items that match) for Drop items, Reject for Reject the
697
+ answer -- its Options the defaults, and its examples its tests: a caught
698
+ example becomes a sample the filter should empty or reject, a kept example
699
+ one it should leave. Pure: the match is carried unchanged, so the filter
700
+ cleans a reply exactly as the rule did. The only reader of the old shape,
701
+ for Runs to migrate its inline rules (#339). */
702
+ export function filterFromItemRule(rule , type ) {
648
703
  const ex = rule.examples || {};
649
- const caught = scope === "item" ? ex.drops || [] : ex.rejects || [];
704
+ const caught = type === "remove" ? ex.drops || [] : ex.rejects || [];
650
705
  const tests = [
651
706
  ...caught.map((s) => ({ separator: "comma", sampleInput: s, expectedOutput: "" })),
652
707
  ...(ex.keeps || []).map((s) => ({ separator: "comma", sampleInput: s, expectedOutput: s })),
@@ -655,11 +710,59 @@ export function filterFromItemRule(rule , type ) {
655
710
  id: rule.id, type,
656
711
  ...(rule.why != null ? { why: rule.why } : {}),
657
712
  match: rule.match ? { ...rule.match } : {},
713
+ ...(type === "remove" ? { op: { kind: "items" } } : {}),
658
714
  options: { ignoreFailures: false, repeat: 1 },
659
715
  tests,
660
716
  };
661
717
  }
662
718
 
719
+ /** A version-3 filter's types (#405's six), and whether each read the
720
+ reply's text (Pre-format, or after a format that reads text) or its items
721
+ (after a format that reads them). Only the upgrade to version 4 reads them. */
722
+ export const V3_TEXT_TYPES = ["rewrite-text", "reject-text"];
723
+
724
+ /** A version-3 filter as the version-4 verb that reads a reply exactly as it
725
+ did (#615): Rewrite text's operations each their verb (Remove thinking /
726
+ preamble / code fences a Remove, Replace text / pattern a Replace, Trim,
727
+ Change case, Keep first and Extract their own), Reject text and Reject
728
+ reply a Reject, Drop item a Remove of the items that match, Rewrite item's
729
+ Tidy a Trim with bullets and its Case a Change case (each keeping the items
730
+ it limits to), and Keep first a Keep first of items. A text filter's tests
731
+ read the sample whole ("none"), as a text filter's always did. A filter of
732
+ no version-3 type comes back as it was. Pure; server.py's filter_v4 is its
733
+ twin. */
734
+ export function filterV4(f ) {
735
+ if (!isObj(f)) return f;
736
+ const op = isObj(f.op) ? f.op : {};
737
+ const as = (type , next ) => {
738
+ const { op: _op, ...rest } = f;
739
+ const out = { ...rest, type, ...next } ;
740
+ if (V3_TEXT_TYPES.includes(f.type) && Array.isArray(f.tests)) {
741
+ out.tests = f.tests.map(t => (isObj(t) ? { ...t, separator: "none" } : t));
742
+ }
743
+ return out;
744
+ };
745
+ switch (f.type) {
746
+ case "rewrite-text":
747
+ switch (op.kind) {
748
+ case "thinking": case "preamble": case "fences": return as("remove", { op: { kind: op.kind } });
749
+ case "trim": return as("trim", { op: { bullets: false } });
750
+ case "literal": return as("replace", { op: { kind: "text", find: op.find, replacement: op.replacement ?? "" } });
751
+ case "replace": return as("replace", { op: { kind: "pattern", pattern: op.pattern, replacement: op.replacement ?? "" } });
752
+ case "case": return as("change-case", { op: { mode: op.mode } });
753
+ case "first": return as("keep-first", { op: { n: op.n, unit: op.unit } });
754
+ case "extract": return as("extract", { op: { pattern: op.pattern } });
755
+ default: return f;
756
+ }
757
+ case "reject-text": case "reject-reply": return as("reject", {});
758
+ case "drop-item": return as("remove", { op: { kind: "items" } });
759
+ case "rewrite-item":
760
+ return op.kind === "case" ? as("change-case", { op: { mode: op.mode } }) : as("trim", { op: { bullets: true } });
761
+ case "keep-first": return as("keep-first", { op: { n: op.n, unit: "items" } });
762
+ default: return f;
763
+ }
764
+ }
765
+
663
766
  // ---- the modifiers -------------------------------------------------------------
664
767
 
665
768
  /** What an item's numbering looks like: "[1] suv", "(2) car", "3. dirt",
@@ -702,17 +805,17 @@ export function applyCase(item , mode ) {
702
805
  }
703
806
  }
704
807
 
705
- /** The id of the synthetic modifier a linked or private Post-format filter
706
- set applies under at run time (#339, #405): it is never in a document or
707
- the Add menu -- a job carries a `filters` step, which `stageModifiers`
708
- turns into one of these where the step sits, so a set cleans and gates a
709
- list reply exactly as it reads. The Pre-format (text) stage runs its
710
- filters on the raw reply before it is read, not as a modifier. */
808
+ /** The id of the synthetic modifier the filters below a job's Response Format
809
+ apply under at run time (#339, #615): it is never in a document or the Add
810
+ menu -- a job carries a `filters` step, which `stageModifiers` turns into
811
+ these where the step sits, so a set cleans and gates a list reply's items
812
+ exactly as it reads. The filters above the format run on the raw reply
813
+ before it is read, not as a modifier. */
711
814
  export const FILTER_MODIFIER = "filterSet";
712
815
 
713
816
  const LIST_MODIFIERS = {
714
- // A Post-format filter set's filters, applied where its link sits: the
715
- // run's only reader of a resolved items-stage set, hidden from the Add menu
817
+ // The filters below the format, applied to the items where the step sits:
818
+ // the run's only reader of them, hidden from the Add menu
716
819
  // (a set is linked in Responses, not added as a modifier). The lab's own
717
820
  // cleaning modifiers are retired (#405) -- every cleaning step is a filter.
718
821
  [FILTER_MODIFIER]: {
@@ -730,7 +833,7 @@ const LIST_MODIFIERS = {
730
833
 
731
834
  const LIST_KIND = {
732
835
  label: "List", noun: "items", offers: ["items"],
733
- description: "Splits the reply into items (by commas, lines or a JSON array) that evals and later jobs read one by one.",
836
+ description: "Splits the response into items (by commas, lines or a JSON array) that evals and later jobs read one by one.",
734
837
  settings: [
735
838
  { key: "parse", label: "Parse as", type: "select", choices: [
736
839
  { value: "csv", label: "CSV" }, { value: "lines", label: "One a line" }, { value: "json", label: "JSON array" }] },
@@ -766,31 +869,32 @@ export const CLEANING_MODIFIERS = ["reject", "drop", "tidy", "drop-echoes", "ded
766
869
  const baseFilter = (id , type , extra = {}) =>
767
870
  ({ id, type, match: {}, options: { ignoreFailures: false, repeat: 1 }, tests: [], ...extra });
768
871
 
769
- /** The Post-format filter(s) a retired cleaning modifier [m] becomes, in the
770
- same order and reading a reply the same way: Tidy and Case are a Rewrite
771
- item, Drop prompt echoes and Remove duplicates a Drop item on the `echoes`
772
- and `duplicate` conditions, Keep first its own type, and Drop items / Reject
773
- each of their rules a Drop item / Reject reply (filterFromItemRule). The
774
- drop reasons a run shows stay the names they read as before. */
872
+ /** The filter(s) below the format a retired cleaning modifier [m] becomes,
873
+ in the same order and reading a reply the same way: Tidy is a Trim with
874
+ bullets, Case a Change case, Drop prompt echoes and Remove duplicates a
875
+ Remove on the `echoes` and `duplicate` conditions, Keep first a Keep first
876
+ of items, and Drop items / Reject each of their rules a Remove / Reject
877
+ (filterFromItemRule). The drop reasons a run shows stay the names they read
878
+ as before. */
775
879
  export function cleaningFilters(m ) {
776
880
  switch (m.type) {
777
- case "reject": return ((m.rules ) || []).map(r => filterFromItemRule(r, "reject-reply"));
778
- case "drop": return ((m.rules ) || []).map(r => filterFromItemRule(r, "drop-item"));
779
- case "tidy": return [baseFilter("tidy", "rewrite-item", { op: { kind: "tidy" } })];
780
- case "drop-echoes": return [baseFilter(ECHO, "drop-item", { match: { echoes: true } })];
781
- case "dedupe": return [baseFilter(DUPLICATE, "drop-item", { match: { duplicate: true } })];
782
- case "keep-first": return [baseFilter("keep first", "keep-first", { op: { n: m.n } })];
783
- case "case": return [baseFilter("case", "rewrite-item", { op: { kind: "case", mode: (m.mode ) ?? "LOWER" } })];
881
+ case "reject": return ((m.rules ) || []).map(r => filterFromItemRule(r, "reject"));
882
+ case "drop": return ((m.rules ) || []).map(r => filterFromItemRule(r, "remove"));
883
+ case "tidy": return [baseFilter("tidy", "trim", { op: { bullets: true } })];
884
+ case "drop-echoes": return [baseFilter(ECHO, "remove", { match: { echoes: true }, op: { kind: "items" } })];
885
+ case "dedupe": return [baseFilter(DUPLICATE, "remove", { match: { duplicate: true }, op: { kind: "items" } })];
886
+ case "keep-first": return [baseFilter("keep first", "keep-first", { op: { n: m.n , unit: "items" } })];
887
+ case "case": return [baseFilter("case", "change-case", { op: { mode: (m.mode ) ?? "LOWER" } })];
784
888
  default: return [];
785
889
  }
786
890
  }
787
891
 
788
- /** The Pre-format (text) filters the List `preamble` setting becomes:
789
- Remove thinking then Remove preamble, which together read a reply exactly as
892
+ /** The filters above the format the List `preamble` setting becomes: Remove
893
+ thinking then Remove preamble, which together read a reply exactly as
790
894
  `preamble: true` did (#405). */
791
895
  export function preambleFilters() {
792
- return [baseFilter("remove thinking", "rewrite-text", { op: { kind: "thinking" } }),
793
- baseFilter("remove preamble", "rewrite-text", { op: { kind: "preamble" } })];
896
+ return [baseFilter("remove thinking", "remove", { op: { kind: "thinking" } }),
897
+ baseFilter("remove preamble", "remove", { op: { kind: "preamble" } })];
794
898
  }
795
899
 
796
900
  /**