evals-lab 0.1.2

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.
Files changed (110) hide show
  1. package/LICENSE.md +57 -0
  2. package/README.md +98 -0
  3. package/bin/evals-lab.js +196 -0
  4. package/lab/demo/CREDITS.md +92 -0
  5. package/lab/demo/datasets/demo-1.json +1839 -0
  6. package/lab/demo/datasets/demo-2.json +1683 -0
  7. package/lab/demo/manifest.json +12 -0
  8. package/lab/demo/pipelines/demo-1.json +104 -0
  9. package/lab/demo/pipelines/demo-2.json +104 -0
  10. package/lab/demo/sources/Demo 1/basketball-hoop.jpg +0 -0
  11. package/lab/demo/sources/Demo 1/blue-boardwalk.jpg +0 -0
  12. package/lab/demo/sources/Demo 1/busy-beach.jpg +0 -0
  13. package/lab/demo/sources/Demo 1/butcher-sign.jpg +0 -0
  14. package/lab/demo/sources/Demo 1/cactus-flower.jpg +0 -0
  15. package/lab/demo/sources/Demo 1/corner-shop.jpg +0 -0
  16. package/lab/demo/sources/Demo 1/crosswalk-cyclist-panorama.jpg +0 -0
  17. package/lab/demo/sources/Demo 1/cyanotype-room.jpg +0 -0
  18. package/lab/demo/sources/Demo 1/dead-end-sign.jpg +0 -0
  19. package/lab/demo/sources/Demo 1/dog-in-snow.jpg +0 -0
  20. package/lab/demo/sources/Demo 1/empty-bedroom.jpg +0 -0
  21. package/lab/demo/sources/Demo 1/farmers-market-stall.jpg +0 -0
  22. package/lab/demo/sources/Demo 1/four-jets.jpg +0 -0
  23. package/lab/demo/sources/Demo 1/german-shepherd.jpg +0 -0
  24. package/lab/demo/sources/Demo 1/harbour-bridge-dusk.jpg +0 -0
  25. package/lab/demo/sources/Demo 1/harrow-on-the-hill-sign.jpg +0 -0
  26. package/lab/demo/sources/Demo 1/hong-kong-street.jpg +0 -0
  27. package/lab/demo/sources/Demo 1/house-salisbury-street.jpg +0 -0
  28. package/lab/demo/sources/Demo 1/infrared-orchard.jpg +0 -0
  29. package/lab/demo/sources/Demo 1/keyboard-desk.jpg +0 -0
  30. package/lab/demo/sources/Demo 1/lighthouse-dunes.jpg +0 -0
  31. package/lab/demo/sources/Demo 1/mountain-lake.jpg +0 -0
  32. package/lab/demo/sources/Demo 1/museum-skeleton.jpg +0 -0
  33. package/lab/demo/sources/Demo 1/music-room.jpg +0 -0
  34. package/lab/demo/sources/Demo 1/old-red-car.jpg +0 -0
  35. package/lab/demo/sources/Demo 1/parked-car-plate.jpg +0 -0
  36. package/lab/demo/sources/Demo 1/phone-and-wallet.png +0 -0
  37. package/lab/demo/sources/Demo 1/phone-box.jpg +0 -0
  38. package/lab/demo/sources/Demo 1/pink-flamingo.jpg +0 -0
  39. package/lab/demo/sources/Demo 1/postcards.jpg +0 -0
  40. package/lab/demo/sources/Demo 1/red-roof-church.jpg +0 -0
  41. package/lab/demo/sources/Demo 1/roadside-mailboxes.jpg +0 -0
  42. package/lab/demo/sources/Demo 1/rodeo.jpg +0 -0
  43. package/lab/demo/sources/Demo 1/running-tap.tif +0 -0
  44. package/lab/demo/sources/Demo 1/snail-on-stem.jpg +0 -0
  45. package/lab/demo/sources/Demo 1/two-horses-field.jpg +0 -0
  46. package/lab/demo/sources/Demo 1/university-sign.jpg +0 -0
  47. package/lab/demo/sources/Demo 1/vegetable-crates.jpg +0 -0
  48. package/lab/demo/sources/Demo 1/watermarked-car.jpg +0 -0
  49. package/lab/demo/sources/Demo 1/watermarked-pills.jpg +0 -0
  50. package/lab/demo/sources/Demo 1/whiteboard-delegate.jpg +0 -0
  51. package/lab/demo/sources/Demo 1/whiteboard-germ-layers.jpg +0 -0
  52. package/lab/demo/sources/Demo 2/aerial-city.jpg +0 -0
  53. package/lab/demo/sources/Demo 2/alligator-pen.jpg +0 -0
  54. package/lab/demo/sources/Demo 2/arm-tattoo.jpg +0 -0
  55. package/lab/demo/sources/Demo 2/bed-and-plant.jpg +0 -0
  56. package/lab/demo/sources/Demo 2/bright-bedroom.jpg +0 -0
  57. package/lab/demo/sources/Demo 2/car-headlight.jpg +0 -0
  58. package/lab/demo/sources/Demo 2/child-in-surf.jpg +0 -0
  59. package/lab/demo/sources/Demo 2/city-highway.jpg +0 -0
  60. package/lab/demo/sources/Demo 2/corner-bakery.jpg +0 -0
  61. package/lab/demo/sources/Demo 2/cyclist-yellow-jacket.jpg +0 -0
  62. package/lab/demo/sources/Demo 2/excavator-street-signs.jpg +0 -0
  63. package/lab/demo/sources/Demo 2/globe-closeup.jpg +0 -0
  64. package/lab/demo/sources/Demo 2/harbour-village.jpg +0 -0
  65. package/lab/demo/sources/Demo 2/high-street-walkers.jpg +0 -0
  66. package/lab/demo/sources/Demo 2/hillside-rooftops.jpg +0 -0
  67. package/lab/demo/sources/Demo 2/house-at-night.jpg +0 -0
  68. package/lab/demo/sources/Demo 2/ivy-leaves.jpg +0 -0
  69. package/lab/demo/sources/Demo 2/man-with-alligator.jpg +0 -0
  70. package/lab/demo/sources/Demo 2/orange-mailbox-hedge.jpg +0 -0
  71. package/lab/demo/sources/Demo 2/pedal-boats.jpg +0 -0
  72. package/lab/demo/sources/Demo 2/pink-trees-vignette.jpg +0 -0
  73. package/lab/demo/sources/Demo 2/please-leave-quietly-sign.jpg +0 -0
  74. package/lab/demo/sources/Demo 2/railroad-crossing-sign.jpg +0 -0
  75. package/lab/demo/sources/Demo 2/roadworks-sign.jpg +0 -0
  76. package/lab/demo/sources/Demo 2/roundabout-sign.jpg +0 -0
  77. package/lab/demo/sources/Demo 2/sea-cave.jpg +0 -0
  78. package/lab/demo/sources/Demo 2/shadow-on-sand.jpg +0 -0
  79. package/lab/demo/sources/Demo 2/signpost-a404.jpg +0 -0
  80. package/lab/demo/sources/Demo 2/street-fruit-cart.jpg +0 -0
  81. package/lab/demo/sources/Demo 2/street-sign-parnassusweg.jpg +0 -0
  82. package/lab/demo/sources/Demo 2/two-horses-close.jpg +0 -0
  83. package/lab/demo/sources/Demo 2/victorian-house.jpg +0 -0
  84. package/lab/demo/sources/Demo 2/vintage-dashboard.jpg +0 -0
  85. package/lab/demo/sources/Demo 2/volcano-at-dusk.jpg +0 -0
  86. package/lab/demo/sources/Demo 2/wall-camera.jpg +0 -0
  87. package/lab/demo/sources/Demo 2/watch-for-rocks-sign.jpg +0 -0
  88. package/lab/demo/sources/Demo 2/waterfall.jpg +0 -0
  89. package/lab/demo/sources/Demo 2/white-domes.jpg +0 -0
  90. package/lab/demo/sources/Demo 2/whiteboard-meeting-notes.jpg +0 -0
  91. package/lab/demo/sources/Demo 2/whiteboard-messages.jpg +0 -0
  92. package/lab/demo/sources/Demo 2/wooden-house-fence.jpg +0 -0
  93. package/lab/evals-core.mjs +4526 -0
  94. package/lab/flows/wdl.mjs +373 -0
  95. package/lab/js-yaml.mjs +3851 -0
  96. package/lab/kinds/list.mjs +411 -0
  97. package/lab/metrics/builtin.mjs +353 -0
  98. package/lab/presets.json +62 -0
  99. package/lab/run-evals.js +1262 -0
  100. package/lab/server.py +5044 -0
  101. package/lab/web/dist/assets/dist-DI3ewZYj.js +1 -0
  102. package/lab/web/dist/assets/gallery-DipkRvqJ.js +3 -0
  103. package/lab/web/dist/assets/gallery-o7c4lfpn.css +1 -0
  104. package/lab/web/dist/assets/main-CpVssvrb.js +18 -0
  105. package/lab/web/dist/assets/main-DjQQums6.css +1 -0
  106. package/lab/web/dist/assets/tokens-B9intIuT.js +51 -0
  107. package/lab/web/dist/assets/tokens-s6I-RMVq.css +1 -0
  108. package/lab/web/dist/gallery.html +18 -0
  109. package/lab/web/dist/index.html +23 -0
  110. package/package.json +20 -0
@@ -0,0 +1,411 @@
1
+ // A reply that is a list of items: the `list` output kind and the modifiers
2
+ // that clean one, as entries in the lab's registries (docs/pipeline-model.md
3
+ // §2; docs/design/generic-lab.md §2).
4
+ //
5
+ // Nothing here is about any one app. A list is parsed from the reply -- CSV,
6
+ // one item a line, or a JSON array -- and every cleaning step is a modifier a
7
+ // pipeline chooses, in the order it chooses: reject the answer, tidy the
8
+ // items, drop items by rule, drop items that repeat the instruction, remove
9
+ // duplicates, keep the first n, change case. A particular app's parser is a
10
+ // particular configuration of these, stated in its pipeline -- never code
11
+ // here. `legacyTagsOut` is the one configuration this file names, because
12
+ // it is what a pipeline of the retired `tags` kind is upgraded to.
13
+ //
14
+ // Rules are data a modifier carries: an item matcher (terms, a regex, the
15
+ // item's length, its words, its whitespace-separated tokens, its share of
16
+ // digits), a why, and examples that are the rule's own tests.
17
+
18
+
19
+ // ---- text helpers ------------------------------------------------------------
20
+
21
+ /** Letters and digits, lower-cased: what "a word" means to a matcher. */
22
+ export const words = (s ) => String(s ?? "").toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
23
+
24
+ /** Both ends, those characters only: a regex over the whole value would take
25
+ them out of the middle as well. */
26
+ export function trimEnds(s , chars ) {
27
+ let a = 0, b = s.length;
28
+ while (a < b && chars.includes(s[a] )) a++;
29
+ while (b > a && chars.includes(s[b - 1] )) b--;
30
+ return s.slice(a, b);
31
+ }
32
+
33
+ /** Whitespace collapsed, ends trimmed, case ignored: what "equal" means. */
34
+ const normalise = (term ) => String(term).toLowerCase().replace(/\s+/g, " ").trim();
35
+
36
+ /** The reply with a thinking block and the preamble up to the first colon
37
+ taken off, or the whole reply when that would leave nothing. */
38
+ export function withoutPreamble(reply ) {
39
+ const after = String(reply ?? "").split("</think>").pop() ;
40
+ const i = after.indexOf(":");
41
+ const body = i < 0 ? after : after.slice(i + 1);
42
+ return body.trim() ? body : String(reply ?? "");
43
+ }
44
+
45
+ // ---- parsing a reply into items ------------------------------------------------
46
+
47
+ export const PARSE_FORMATS = ["csv", "lines", "json"] ;
48
+
49
+
50
+ /** A reply as the text its items come from, and the items, each trimmed and
51
+ none blank. Nothing else is done to them: cleaning is the modifiers'. */
52
+ export function splitItems(reply , parse = "csv", preamble = false) {
53
+ const body = preamble ? withoutPreamble(reply) : String(reply ?? "");
54
+ if (parse === "json") {
55
+ const at = body.search(/[[{]/);
56
+ let items = [];
57
+ try {
58
+ const doc = JSON.parse(at < 0 ? body : body.slice(at)) ;
59
+ const list = Array.isArray(doc) ? doc
60
+ : doc && typeof doc === "object" ? Object.values(doc).find(Array.isArray) : null;
61
+ items = (list || []).map((v ) => (typeof v === "string" ? v : JSON.stringify(v)).trim()).filter(Boolean);
62
+ } catch { /* not JSON: no items, which a Reject rule can say */ }
63
+ return { body, items };
64
+ }
65
+ const sep = parse === "lines" ? /\n/ : /[,\n;]/;
66
+ return { body, items: body.split(sep).map(s => s.trim()).filter(Boolean) };
67
+ }
68
+
69
+ // ---- matching an item ----------------------------------------------------------
70
+
71
+ /** A bound on a number: inclusive `min`/`max`, exclusive `above`/`below`. */
72
+
73
+ const inRange = (b ) => (v ) =>
74
+ (b.min == null || v >= b.min) && (b.max == null || v <= b.max)
75
+ && (b.above == null || v > b.above) && (b.below == null || v < b.below);
76
+
77
+ /** The predicates an item matcher takes: every one given must hold. */
78
+ export const MATCHER_KEYS = ["terms", "regex", "length", "words", "tokens", "digitShare"] ;
79
+
80
+ const regexCache = new Map ();
81
+ function regexMatch(pattern , item ) {
82
+ let re = regexCache.get(pattern);
83
+ if (re === undefined) {
84
+ try { re = new RegExp("^(?:" + pattern + ")$", "i"); } catch { re = false; }
85
+ regexCache.set(pattern, re);
86
+ }
87
+ return !!re && re.test(item);
88
+ }
89
+ const digitShare = (s ) => (s.length ? (s.match(/\d/g) || []).length / s.length : 0);
90
+
91
+ /** An item matcher as one test, or null when it states nothing -- a rule that
92
+ states nothing never fires, so a malformed rule drops nothing on a guess. */
93
+ export function matcher(m ) {
94
+ if (!m || typeof m !== "object") return null;
95
+ const ps = [];
96
+ if (typeof m.regex === "string") { const r = m.regex; ps.push(t => regexMatch(r, t)); }
97
+ if (Array.isArray(m.terms)) { const set = new Set(m.terms.map(normalise)); ps.push(t => set.has(normalise(t))); }
98
+ if (m.length && typeof m.length === "object") { const b = inRange(m.length ); ps.push(t => b(t.length)); }
99
+ if (m.words && typeof m.words === "object") { const b = inRange(m.words ); ps.push(t => b(words(t).length)); }
100
+ if (m.tokens && typeof m.tokens === "object") { const b = inRange(m.tokens ); ps.push(t => b(t.split(/\s+/).length)); }
101
+ if (m.digitShare && typeof m.digitShare === "object") { const b = inRange(m.digitShare ); ps.push(t => b(digitShare(t))); }
102
+ return ps.length ? (t ) => ps.every(p => p(t)) : null;
103
+ }
104
+
105
+ /** A rule a Drop items or a Reject the answer modifier carries. */
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+ /** The first rule that drops [item], or null. */
115
+ export function dropBy(rules , item ) {
116
+ for (const r of rules) {
117
+ const test = matcher(r.match);
118
+ if (test && test(item)) return r.id;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** The first rule that rejects an answer whose text is [body] and items are
124
+ [items], or null. A rule's conditions: `blank` (the text is blank),
125
+ `itemCount`, and `share` of the items `items` matches. Every one given must
126
+ hold; a rule that states none never fires. */
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
+ }
140
+ return null;
141
+ }
142
+
143
+ /** Why [rules] are not a rule list [scope] takes, one sentence each. */
144
+ export function itemRulesProblems(rules , scope , at ) {
145
+ if (!Array.isArray(rules)) return [`${at}: rules has to be a list`];
146
+ const bad = [], seen = new Set ();
147
+ const allowed = scope === "item" ? MATCHER_KEYS : ["blank", "itemCount", "share", "items"];
148
+ const rangeOk = (v ) => !!v && typeof v === "object" && !Array.isArray(v)
149
+ && Object.entries(v).every(([k, n]) => ["min", "max", "above", "below"].includes(k) && typeof n === "number" && isFinite(n));
150
+ for (const r of rules ) {
151
+ const id = r && typeof r.id === "string" && r.id.trim() ? r.id : null;
152
+ if (!id) { bad.push(`${at}: a rule has no name`); continue; }
153
+ if (seen.has(id)) bad.push(`${at}: ${id} is named twice`);
154
+ seen.add(id);
155
+ const m = r.match;
156
+ if (!m || typeof m !== "object" || Array.isArray(m)) { bad.push(`${at}: ${id} matches nothing`); continue; }
157
+ for (const [k, v] of Object.entries(m)) {
158
+ if (!allowed.includes(k)) { bad.push(`${at}: ${id} has "${k}", which is not a condition this rule takes`); continue; }
159
+ if (k === "terms" && !(Array.isArray(v) && v.every(x => typeof x === "string"))) bad.push(`${at}: ${id}'s terms have to be a list of words`);
160
+ if (k === "regex" && typeof v !== "string") bad.push(`${at}: ${id}'s regex has to be text`);
161
+ if (k === "regex" && typeof v === "string") { try { new RegExp(v); } catch { bad.push(`${at}: ${id}'s regex does not compile`); } }
162
+ if (k === "blank" && v !== true) bad.push(`${at}: ${id}'s blank can only be true`);
163
+ if (k === "items" && (itemRulesProblems([{ id, match: v }], "item", at).length)) bad.push(`${at}: ${id}'s items matcher is not one`);
164
+ if (["length", "words", "tokens", "digitShare", "itemCount", "share"].includes(k) && !rangeOk(v)) {
165
+ bad.push(`${at}: ${id}'s ${k} has to be a range of min, max, above and below`);
166
+ }
167
+ }
168
+ if (scope === "answer" && "share" in m && !("items" in m)) bad.push(`${at}: ${id} measures a share with nothing to count`);
169
+ }
170
+ return bad;
171
+ }
172
+
173
+ /** Each of [rule]'s examples, tried against the rule alone: a drop example is
174
+ dropped (or a reject example rejected), a keep example kept. */
175
+ export function tryExamples(rule , scope )
176
+ {
177
+ const hits = (text ) => (scope === "item"
178
+ ? dropBy([rule], text)
179
+ : (() => { const { body, items } = splitItems(text, "csv", true); return rejectBy([rule], body, items); })()) != null;
180
+ const ex = rule.examples || {};
181
+ return [
182
+ ...(scope === "item" ? ex.drops || [] : ex.rejects || []).map(text => ({ text, side: "drops" , ok: hits(text) })),
183
+ ...(ex.keeps || []).map(text => ({ text, side: "keeps" , ok: !hits(text) })),
184
+ ];
185
+ }
186
+
187
+ // ---- the modifiers -------------------------------------------------------------
188
+
189
+ /** What an item's numbering looks like: "[1] suv", "(2) car", "3. dirt",
190
+ "4) trees", "5: sky" -- and not "1950s car" or "5.1 surround". */
191
+ const NUMBERING = /^(?:\[\d+\]|\(\d+\)|\d+[.):](?!\d))\s*/;
192
+
193
+ /** One item as Tidy items leaves it: a leading bullet, its numbering, stray
194
+ quotes and stops at the ends, and runs of whitespace gone. */
195
+ export function tidy(item ) {
196
+ let t = item.trim();
197
+ for (const p of ["-", "*", "•"]) if (t.startsWith(p)) t = t.slice(p.length);
198
+ t = t.trim().replace(NUMBERING, "");
199
+ t = trimEnds(t.trim(), ".\"'`");
200
+ return t.replace(/\s+/g, " ").trim();
201
+ }
202
+
203
+ /** The name a Remove duplicates or Drop prompt echoes drop is recorded under. */
204
+ export const ECHO = "echoes the prompt";
205
+ export const DUPLICATE = "already there";
206
+ const pastN = (n ) => `past ${n}`;
207
+
208
+
209
+ const kept = (value , dropped ) => ({ value, dropped });
210
+
211
+ /** The casings a list is shown in. Items are compared ignoring case, so this
212
+ is presentation and never a match rule. */
213
+ export const CASE_MODES = ["LOWER", "UPPER", "NONE", "SENTENCE", "TITLE"];
214
+
215
+ export function applyCase(item , mode ) {
216
+ const lower = item.toLowerCase();
217
+ const cap = (s ) => s.charAt(0).toUpperCase() + s.slice(1);
218
+ switch (mode) {
219
+ case "LOWER": return lower;
220
+ case "UPPER": return item.toUpperCase();
221
+ case "NONE": return item;
222
+ case "SENTENCE": return cap(lower);
223
+ case "TITLE": return lower.split(" ").map(cap).join(" ");
224
+ default: return lower;
225
+ }
226
+ }
227
+
228
+ const LIST_MODIFIERS = {
229
+ reject: {
230
+ label: "Reject the answer if…", accepts: ["list"],
231
+ description: "Throws the whole reply away when a rule below matches it.",
232
+ options: [{ key: "rules", label: "Rules", type: "rules", scope: "answer" }],
233
+ defaults: () => ({ type: "reject", rules: [] }),
234
+ validate(m, at, bad) { bad.push(...itemRulesProblems(m.rules, "answer", at)); },
235
+ // Measured on the items as parsed, before any tidying: "1. a red car on
236
+ // the road" is five pieces of text as the model wrote it.
237
+ apply(items, m, ctx) {
238
+ const why = rejectBy((m.rules ) || [], ctx.body, items);
239
+ return why ? { value: items, reject: why } : items;
240
+ },
241
+ },
242
+ tidy: {
243
+ label: "Tidy items", accepts: ["list"],
244
+ description: "Cleans each item: bullets, numbering, stray quotes and full stops and extra spaces are removed, and empty items dropped.",
245
+ options: [],
246
+ defaults: () => ({ type: "tidy" }),
247
+ // A blank item is nothing at all, not one the list decided against.
248
+ apply: items => items.map(tidy).filter(Boolean),
249
+ },
250
+ drop: {
251
+ label: "Drop items", accepts: ["list"],
252
+ description: "Removes each item a rule below matches.",
253
+ options: [{ key: "rules", label: "Rules", type: "rules", scope: "item" }],
254
+ defaults: () => ({ type: "drop", rules: [] }),
255
+ validate(m, at, bad) { bad.push(...itemRulesProblems(m.rules, "item", at)); },
256
+ apply(items, m) {
257
+ const rules = (m.rules ) || [];
258
+ const out = [], dropped = [];
259
+ for (const t of items) {
260
+ const by = dropBy(rules, t);
261
+ if (by) dropped.push({ item: t, by }); else out.push(t);
262
+ }
263
+ return kept(out, dropped);
264
+ },
265
+ },
266
+ "drop-echoes": {
267
+ label: "Drop prompt echoes", accepts: ["list"],
268
+ description: "Removes items made only of words from the prompt, which the model repeated rather than answered with.",
269
+ options: [],
270
+ defaults: () => ({ type: "drop-echoes" }),
271
+ // Every word of the item is in the stage's own instruction: "wellington
272
+ // region" is wholly the prompt's; an item that shares one ordinary word
273
+ // with it is not.
274
+ apply(items, _m, ctx) {
275
+ const said = ctx.instruction.trim() ? new Set(words(ctx.instruction)) : new Set ();
276
+ const out = [], dropped = [];
277
+ for (const t of items) {
278
+ const w = words(t);
279
+ if (said.size && w.length && w.every(x => said.has(x))) dropped.push({ item: t, by: ECHO });
280
+ else out.push(t);
281
+ }
282
+ return kept(out, dropped);
283
+ },
284
+ },
285
+ dedupe: {
286
+ label: "Remove duplicates", accepts: ["list"],
287
+ description: "Keeps the first of items that differ only in case.",
288
+ options: [],
289
+ defaults: () => ({ type: "dedupe" }),
290
+ apply(items) {
291
+ const seen = new Set (), out = [], dropped = [];
292
+ for (const t of items) {
293
+ const k = t.toLowerCase();
294
+ if (seen.has(k)) dropped.push({ item: t, by: DUPLICATE });
295
+ else { seen.add(k); out.push(t); }
296
+ }
297
+ return kept(out, dropped);
298
+ },
299
+ },
300
+ case: {
301
+ label: "Case", accepts: ["list"], modes: CASE_MODES,
302
+ description: "Changes each item to the case chosen.",
303
+ options: [{ key: "mode", label: "Mode", type: "select", choices: [
304
+ { value: "LOWER", label: "lower case" }, { value: "SENTENCE", label: "Sentence case" },
305
+ { value: "TITLE", label: "Title Case" }, { value: "UPPER", label: "UPPER CASE" },
306
+ { value: "NONE", label: "No change" }] }],
307
+ defaults: () => ({ type: "case", mode: "LOWER" }),
308
+ validate(m, at, bad) {
309
+ if (!CASE_MODES.includes(m.mode )) bad.push(`${at} has to be one of ${CASE_MODES.join(", ")}`);
310
+ for (const k of Object.keys(m)) {
311
+ if (k !== "type" && k !== "mode") bad.push(`${at} has "${k}", which is not a pipeline field`);
312
+ }
313
+ },
314
+ apply: (items, m) => items.map(t => applyCase(t, m.mode )),
315
+ },
316
+ "keep-first": {
317
+ label: "Keep first", accepts: ["list"],
318
+ description: "Keeps only the first items, as many as set.",
319
+ options: [{ key: "n", label: "Items", type: "number" }],
320
+ defaults: () => ({ type: "keep-first", n: 10 }),
321
+ validate(m, at, bad) {
322
+ if (!Number.isInteger(m.n) || (m.n ) < 1) bad.push(`${at}: keep first needs a whole number of 1 or more`);
323
+ },
324
+ apply(items, m) {
325
+ const n = m.n ;
326
+ return kept(items.slice(0, n), items.slice(n).map(t => ({ item: t, by: pastN(n) })));
327
+ },
328
+ },
329
+ };
330
+
331
+ // ---- the kind --------------------------------------------------------------------
332
+
333
+ const LIST_KIND = {
334
+ label: "List", noun: "items", offers: ["items"],
335
+ description: "Splits the reply into items (by commas, lines or a JSON array) that tests and later jobs read one by one.",
336
+ settings: [
337
+ { key: "parse", label: "Parse as", type: "select", choices: [
338
+ { value: "csv", label: "CSV" }, { value: "lines", label: "One a line" }, { value: "json", label: "JSON array" }] },
339
+ { key: "preamble", label: "Ignore preamble", type: "checkbox" },
340
+ ],
341
+ settingDefaults: () => ({ parse: "csv", preamble: false }),
342
+ validateSettings(out, at, bad) {
343
+ if (out.parse != null && !PARSE_FORMATS.includes(out.parse )) {
344
+ bad.push(`${at}: parse has to be one of ${PARSE_FORMATS.join(", ")}`);
345
+ }
346
+ if (out.preamble != null && typeof out.preamble !== "boolean") bad.push(`${at}: preamble has to be true or false`);
347
+ },
348
+ read(reply, { settings }) {
349
+ const { body, items } = splitItems(reply, (settings?.parse ) || "csv", !!settings?.preamble);
350
+ return { value: items, body };
351
+ },
352
+ handsOn: items => (items.length ? null : "no items to hand on"),
353
+ render: items => ({ items: items.join(", ") }),
354
+ terms: items => items,
355
+ modifiers: () => [],
356
+ };
357
+
358
+ /**
359
+ * The List configuration that reproduces the retired `tags` kind exactly:
360
+ * what a v2 pipeline's `tags` job is upgraded to (docs/design/generic-lab.md
361
+ * §2.2). [rules] is the rules document of the dataset the pipeline was graded
362
+ * against, whose tag rules become Drop items and reply rules join Reject;
363
+ * [mode] the job's case.
364
+ *
365
+ * Reject goes first because the not-a-list test counts whitespace-separated
366
+ * pieces as the model wrote them, before numbering is tidied away; the rest
367
+ * are the old single pass's checks, in its order, as separate passes -- each
368
+ * only ever looked at what the one before it kept, so the same items go, for
369
+ * the same reasons.
370
+ */
371
+ export function legacyTagsOut(rules , mode = "LOWER")
372
+
373
+ {
374
+ const all = rules?.rules || [];
375
+ const asItem = ({ id, why, match, examples } ) =>
376
+ ({ id, ...(why != null ? { why } : {}), ...(match ? { match } : {}), ...(examples ? { examples } : {}) });
377
+ const replyRules = all.filter(r => r.scope === "reply").map((r) => {
378
+ const { tags, ...rest } = (r.match || {}) ;
379
+ const ex = (r.examples || {}) ;
380
+ return {
381
+ id: r.id, ...(r.why != null ? { why: r.why } : {}),
382
+ match: { ...rest, ...(tags ? { items: tags } : {}) },
383
+ examples: { rejects: ex.discards || ex.drops || [], keeps: ex.keeps || [] },
384
+ };
385
+ });
386
+ return {
387
+ kind: "list", parse: "csv", preamble: true,
388
+ modifiers: [
389
+ { type: "reject", rules: [
390
+ { id: "empty reply", why: "Nothing came back.", match: { blank: true } },
391
+ { id: "no values", why: "Separators and nothing between them.", match: { itemCount: { max: 0 } } },
392
+ { id: "prose, not a list", why: "More than half the items are five or more pieces of text: an answer, not a list.",
393
+ match: { items: { tokens: { min: 5 } }, share: { above: 0.5 } } },
394
+ ...replyRules,
395
+ ] },
396
+ { type: "tidy" },
397
+ { type: "drop", rules: all.filter(r => r.scope === "tag").map(asItem) },
398
+ { type: "drop-echoes" },
399
+ { type: "dedupe" },
400
+ { type: "keep-first", n: 24 },
401
+ { type: "case", mode },
402
+ ],
403
+ };
404
+ }
405
+
406
+ export default function list(core ) {
407
+ core.registerKinds({
408
+ outputKinds: { list: LIST_KIND },
409
+ modifiers: LIST_MODIFIERS ,
410
+ });
411
+ }