evals-lab 0.13.0 → 0.15.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -2
  3. package/bin/run.js +4 -2
  4. package/lab/VERSION +1 -1
  5. package/lab/demo/CREDITS.md +14 -0
  6. package/lab/demo/datasets/demo-1.json +2 -2
  7. package/lab/demo/datasets/demo-2.json +2 -2
  8. package/lab/demo/datasets/documents-type.json +79 -0
  9. package/lab/demo/datasets/documents-valid.json +117 -0
  10. package/lab/demo/datasets/sentiment.json +2 -2
  11. package/lab/demo/datasets/weather-alerts.json +2 -2
  12. package/lab/demo/datasets/wordings.json +1 -1
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +17 -14
  15. package/lab/demo/pipelines/demo-2.json +17 -14
  16. package/lab/demo/pipelines/documents.json +101 -0
  17. package/lab/demo/pipelines/sentiment.json +4 -4
  18. package/lab/demo/pipelines/weather-alerts.json +9 -5
  19. package/lab/demo/pipelines/wordings.json +3 -3
  20. package/lab/demo/sources/Demo 1/source.json +1 -1
  21. package/lab/demo/sources/Demo 2/source.json +1 -1
  22. package/lab/demo/sources/documents/invoice.pdf +65 -0
  23. package/lab/demo/sources/documents/letter.pdf +63 -0
  24. package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
  25. package/lab/demo/sources/documents/receipt.pdf +62 -0
  26. package/lab/demo/sources/documents/report.pdf +59 -0
  27. package/lab/demo/sources/documents/source.json +3 -0
  28. package/lab/demo/sources/documents/truncated.pdf +19 -0
  29. package/lab/demo/sources/sentiment/source.json +1 -1
  30. package/lab/demo/sources/weather-alerts/source.json +1 -1
  31. package/lab/evals-core.mjs +408 -160
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/kinds/list.mjs +337 -182
  34. package/lab/run-evals.js +3 -1
  35. package/lab/server.py +661 -108
  36. package/lab/web/dist/assets/gallery-Cy8oycht.js +5 -0
  37. package/lab/web/dist/assets/gallery-eVJoaK-6.css +1 -0
  38. package/lab/web/dist/assets/main-B-LGmhPu.css +1 -0
  39. package/lab/web/dist/assets/main-wqNvXpWp.js +15 -0
  40. package/lab/web/dist/assets/tokens-CWVzmtid.css +1 -0
  41. package/lab/web/dist/assets/tokens-mCS6IZGh.js +64 -0
  42. package/lab/web/dist/gallery.html +4 -4
  43. package/lab/web/dist/index.html +4 -4
  44. package/package.json +1 -1
  45. package/lab/web/dist/assets/gallery-B_-TH0F-.css +0 -1
  46. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
  47. package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
  48. package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
  49. package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
  50. package/lab/web/dist/assets/tokens-DDOONQon.css +0 -1
@@ -33,7 +33,7 @@
33
33
  // and that is a measurement.
34
34
 
35
35
  import yaml from "./js-yaml.mjs";
36
-
36
+
37
37
  import { evaluate, asText, templateProblem, WdlError, } from "./flows/wdl.mjs";
38
38
  import { stepsOf as flowStepsOf, actionsByName, readerFor, readsOf, } from "./flows/record.mjs";
39
39
  import { flowApi, } from "./flows/flowApi.mjs";
@@ -41,7 +41,7 @@ import registerMetrics from "./metrics/builtin.mjs";
41
41
  import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFilters, preambleFilters, CLEANING_MODIFIERS,
42
42
  splitItems, tidy, tryExamples, itemRulesProblems, dropBy, rejectBy, matcher,
43
43
  FILTER_TYPES, FILTER_SEPARATORS, FILTER_MODIFIER, applyFilter, applyFilterSet, tryFilter, splitSample, filterProblems, filterFromItemRule,
44
- } from "./kinds/list.mjs";
44
+ filterV4, V3_TEXT_TYPES, } from "./kinds/list.mjs";
45
45
 
46
46
  // ---- The types -------------------------------------------------------------
47
47
  // docs/pipeline-model.md as types, and the shapes of the registries every
@@ -141,16 +141,18 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
141
141
 
142
142
 
143
143
 
144
- /** The filter sets a job cleans and gates its reply by (#339, #405): each a
144
+ /** The filter sets a job cleans and gates its reply by (#339, #615): each a
145
145
  link to a Library filter set (followed latest or pinned) or a private one
146
- held inline, run at the step's `stage` (#432: a set has no stage of its
147
- own; the step runs its filters of that stage). A job may hold one `filters` step of
148
- each stage -- a `text` one run on the raw reply before it is read into
149
- items (Pre-format), an `items` one run on the items after (Post-format). */
146
+ held inline, one list in run order. `format` is where the job's Response
147
+ Format reads the reply, when it reads items: before `filterSets[format]`
148
+ -- the filters above it run on the raw text, those below on each item.
149
+ Absent, a linked set's own `format` marks it (the first that has one),
150
+ or else the format reads after them all. A format that reads text reads
151
+ after them all whatever it says. A job holds one `filters` step. */
150
152
 
151
153
 
152
-
153
154
 
155
+
154
156
 
155
157
 
156
158
  /** A job's steps: its Content stage, then its Responses (§16). */
@@ -199,12 +201,23 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
199
201
 
200
202
 
201
203
 
204
+
205
+
206
+
207
+
208
+
202
209
   
203
210
 
204
211
 
205
212
 
206
213
 
207
214
 
215
+ /** One kept response of a dynamic Source's item (#596). */
216
+
217
+
218
+
219
+
220
+
208
221
 
209
222
 
210
223
 
@@ -347,23 +360,20 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
347
360
  * at. `filters` are tried in order -- each drop-item narrows the items the
348
361
  * next reads, and the first reject-reply that fires gates the answer.
349
362
  */
350
- /** Which side of Response Format a filter runs on (#405): `text` the raw reply before
351
- it is read into items (rewrite-text / reject-text), `items` the items after
352
- (drop-item, rewrite-item, keep-first, reject-reply). A filter's type decides
353
- its stage; a set belongs to none (#432), and runs its share of filters in
354
- each stage it is linked in. */
355
-
356
-
357
- /** The stage a filter type's scope belongs to: text filters read the raw
358
- reply; item, list and answer filters read the items it is read into. */
359
- export const stageOfScope = (scope ) => (scope === "text" ? "text" : "items");
360
-
361
- /** A filter stage as the page names it: before the reply is formatted, or after. */
362
- export const FILTER_STAGE_LABEL = { text: "Pre-format", items: "Post-format" };
363
+ /** Whether a job answering as [kind] reads its reply into items, so that
364
+ where its Response Format sits among its filters matters (#615): the
365
+ filters above it run on the raw text, those below on each item. A format
366
+ that reads text reads after every filter. */
367
+ export const formatReadsItems = (kind ) =>
368
+ !!OUTPUT_KINDS[kind ?? ""]?.offers?.includes("items");
363
369
 
364
370
 
365
371
 
366
372
 
373
+
374
+
375
+
376
+
367
377
 
368
378
 
369
379
  /** A Library filter set by reference; a run's copy says the version it used:
@@ -551,9 +561,9 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
551
561
 
552
562
 
553
563
 
564
+
554
565
 
555
-
556
-
566
+
557
567
 
558
568
 
559
569
 
@@ -586,7 +596,15 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
586
596
 
587
597
 
588
598
 
599
+
600
+
601
+
602
+
589
603
 
604
+
605
+ /** A reply the reply-token limit stopped: the limit the request sent, or
606
+ null where it sent none and the server's own applied. */
607
+
590
608
 
591
609
  /** The transport: a stage's prompt and image in, its reply out. */
592
610
 
@@ -607,9 +625,15 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
607
625
 
608
626
 
609
627
 
610
-
611
628
 
629
+
630
+
612
631
 
632
+
633
+
634
+
635
+
636
+
613
637
 
614
638
 
615
639
  /** How much of one text a run keeps -- a reply's body, a grader's prompt or
@@ -630,17 +654,16 @@ export function kept(text , max = KEPT_CHARS) {
630
654
 
631
655
 
632
656
 
633
-
634
-
657
+
635
658
 
636
659
 
637
660
 
638
661
 
639
662
 
640
- /** Which filter discarded a job's reply, or emptied it, and where (#435). */
663
+ /** Which filter discarded a job's reply, or emptied it, and where (#435). A
664
+ run kept before #615 also says the stage it ran in, which nothing reads. */
641
665
 
642
666
 
643
-
644
667
 
645
668
 
646
669
 
@@ -1204,10 +1227,14 @@ const platformOf = (src
1204
1227
 
1205
1228
 
1206
1229
 
1207
-
1208
-
1209
-
1230
+
1231
+
1232
+
1210
1233
 
1234
+
1235
+
1236
+
1237
+
1211
1238
 
1212
1239
 
1213
1240
 
@@ -1506,24 +1533,42 @@ function upgradeDatasetBody (body ) {
1506
1533
  `{ version, filters }` body #334 introduces. A later format adds a step to
1507
1534
  `upgradeFilterSetBody`, as `upgradeDatasetBody` grew, with server.py's
1508
1535
  `upgrade_filter_set_body` its twin. */
1509
- const FILTER_SET_VERSION = 3 ;
1510
-
1511
- /** [body] as this version of a filter set (3), from any earlier one. Version 2
1512
- (#405) named the stage a set shaped; version 3 (#432) names none -- each
1513
- filter's type decides where it runs -- so a version-1 or -2 body loses its
1514
- `stage` and reads as this one. Anything else comes back untouched for
1515
- `filterSetBodyProblems` to refuse. Pure: the same body gives the same
1516
- answer, and server.py's upgrade_filter_set_body is its twin. */
1536
+ const FILTER_SET_VERSION = 4 ;
1537
+
1538
+ /** [body] as this version of a filter set (4), from any earlier one. Version 2
1539
+ (#405) named the stage a set shaped, which version 3 (#432) dropped: each
1540
+ filter's type decided where it ran. Version 4 (#615) has seven verbs that
1541
+ run wherever they sit, around a Response Format a set may straddle: each
1542
+ filter becomes the verb that reads a reply as it did (`filterV4`), and a
1543
+ set that held both text and item filters puts its text ones first, in
1544
+ their order, then its `format` marker, then its item ones -- what it ran
1545
+ linked both before and after the format. Anything else comes back
1546
+ untouched for `filterSetBodyProblems` to refuse. Pure: the same body gives
1547
+ the same answer, and server.py's upgrade_filter_set_body is its twin. */
1517
1548
  function upgradeFilterSetBody (body ) {
1518
1549
  if (!isObj(body)) return body;
1519
1550
  if (body.version === FILTER_SET_VERSION) return body;
1520
- if (body.version === 1 || body.version === 2) {
1551
+ if (body.version === 1 || body.version === 2 || body.version === 3) {
1521
1552
  const { stage: _stage, ...rest } = body;
1522
- return { ...rest, version: FILTER_SET_VERSION } ;
1553
+ if (!Array.isArray(rest.filters)) return { ...rest, version: FILTER_SET_VERSION } ;
1554
+ const isText = (f ) => isObj(f) && V3_TEXT_TYPES.includes(f.type );
1555
+ const text = rest.filters.filter(isText), items = rest.filters.filter((f ) => !isText(f));
1556
+ return { ...rest, version: FILTER_SET_VERSION, filters: [...text, ...items].map(filterV4),
1557
+ ...(text.length && items.length ? { format: text.length } : {}) } ;
1523
1558
  }
1524
1559
  return body;
1525
1560
  }
1526
1561
 
1562
+ /** A version-1 to -3 private filter set (#615) as a version-4 one that runs
1563
+ on the side of the format its link sits: each filter its verb, in its
1564
+ order, and no marker -- a private set held one stage's filters. A current
1565
+ body comes back as it was. */
1566
+ function ownBodyV4(own ) {
1567
+ if (!isObj(own) || ![1, 2, 3].includes(own.version )) return own;
1568
+ const { stage: _stage, ...rest } = own;
1569
+ return { ...rest, version: FILTER_SET_VERSION, ...(Array.isArray(rest.filters) ? { filters: rest.filters.map(filterV4) } : {}) };
1570
+ }
1571
+
1527
1572
  /** A filter set with no filters, at this version: what New filter set starts
1528
1573
  from, and what a lab with none holds. */
1529
1574
  function blankFilterSetBody() {
@@ -1538,7 +1583,16 @@ function filterSetBodyProblems(body , at = "the filter set") {
1538
1583
  if (body.version !== FILTER_SET_VERSION) {
1539
1584
  return [`${at} is version ${JSON.stringify(body.version)}, and the lab reads version ${FILTER_SET_VERSION}`];
1540
1585
  }
1541
- return filterProblems(body.filters, at);
1586
+ return [...filterProblems(body.filters, at),
1587
+ ...formatProblems(body.format, Array.isArray(body.filters) ? body.filters.length : 0, at)];
1588
+ }
1589
+
1590
+ /** Why [format] is not a place among [n] entries the Response Format can
1591
+ read at (#615): a whole number from 0 to [n], or none. */
1592
+ function formatProblems(format , n , at ) {
1593
+ if (format == null) return [];
1594
+ return Number.isInteger(format) && (format ) >= 0 && (format ) <= n
1595
+ ? [] : [`${at}: Response Format has to sit among its filters`];
1542
1596
  }
1543
1597
 
1544
1598
  /** Why [link] is not a filter-set link a run's Responses step may carry, one
@@ -1697,6 +1751,10 @@ function datasetRules(body ) {
1697
1751
 
1698
1752
 
1699
1753
 
1754
+
1755
+
1756
+
1757
+
1700
1758
 
1701
1759
 
1702
1760
 
@@ -2106,6 +2164,7 @@ function ollamaType(id , label , cloud ) {
2106
2164
  const r = j ;
2107
2165
  return { raw: r?.message?.content ?? "", finishReason: r?.done_reason ?? null };
2108
2166
  },
2167
+ cutOff: { reason: "length", limit: (body) => asNumber((body.options )?.num_predict) },
2109
2168
  listModels: (base, key) => listModels(base, cloud ? key : null),
2110
2169
  parseModels: idsFrom,
2111
2170
  ...(cloud ? { url: "https://ollama.com" } : { keyless: true }),
@@ -2220,6 +2279,7 @@ CONNECTION_TYPES["openai-compatible"] = {
2220
2279
  documents: true,
2221
2280
  structured: chatSchema,
2222
2281
  parseReply: chatReply,
2282
+ cutOff: { reason: "length", limit: (body) => asNumber(body.max_completion_tokens ?? body.max_tokens) },
2223
2283
  listModels, parseModels: idsFrom,
2224
2284
  };
2225
2285
 
@@ -2271,6 +2331,7 @@ CONNECTION_TYPES.anthropic = {
2271
2331
  const raw = (r?.content || []).filter(b => b.type === "text").map(b => b.text).join("");
2272
2332
  return { raw, finishReason: r?.stop_reason ?? null };
2273
2333
  },
2334
+ cutOff: { reason: "max_tokens", limit: (body) => asNumber(body.max_tokens) },
2274
2335
  // A flow's request, keyed as the Messages API is -- what the HTTP endpoint
2275
2336
  // type's Anthropic template sets. [base] is the /v1 base words hang off;
2276
2337
  // the step's path is the whole path production called, so it goes to the
@@ -2354,6 +2415,7 @@ CONNECTION_TYPES["llama.cpp"] = {
2354
2415
  // llama-server takes OpenAI's response_format, a schema and all.
2355
2416
  structured: chatSchema,
2356
2417
  parseReply: chatReply,
2418
+ cutOff: { reason: "length", limit: (body) => asNumber(body.n_predict) },
2357
2419
  listModels, parseModels: idsFrom,
2358
2420
  // A reply that arrived is still a failure when this profile asked for the
2359
2421
  // Speed looping check (today's speedReplyError, now an opt-in setting).
@@ -2728,6 +2790,18 @@ function connectionReply(conn , j )
2728
2790
  }
2729
2791
 
2730
2792
  /** Why a reply [conn] received is a failure though it arrived, or null. */
2793
+ /** How a reply finished, as a stage keeps it (#597): the reason [conn]'s
2794
+ API gave, and -- where that reason is its type's reply-token limit -- the
2795
+ limit [body], the request sent, carried. A type that says no reason
2796
+ keeps nothing. */
2797
+ function replyFinish(conn , body , finishReason ) {
2798
+ if (!finishReason) return {};
2799
+ const cut = typeEntry(conn).cutOff;
2800
+ if (!cut || finishReason !== cut.reason) return { finish: finishReason };
2801
+ const limit = body && typeof body === "object" ? cut.limit(body ) : null;
2802
+ return { finish: finishReason, stopped: { limit } };
2803
+ }
2804
+
2731
2805
  function connectionReplyProblem(conn , raw , finishReason ) {
2732
2806
  return typeEntry(conn).replyProblem?.(conn, raw, finishReason) ?? null;
2733
2807
  }
@@ -3253,34 +3327,37 @@ async function runPipeline(stages , dataUrl , cal
3253
3327
  total += res.ms;
3254
3328
  transcript.push({ n: i + 1, kind, conn: res.conn ?? null, withImage, sent: res.sent ?? sent,
3255
3329
  got: res.raw ?? "", ms: res.ms, error: res.error, ...(res.said != null ? { said: res.said } : {}),
3256
- ...(res.body != null ? { body: kept(res.body) } : {}) });
3330
+ ...(res.body != null ? { body: kept(res.body) } : {}),
3331
+ ...(res.finish ? { finish: res.finish } : {}), ...(res.stopped ? { stopped: res.stopped } : {}) });
3257
3332
  if (res.error) return result({ error: named(res.error) });
3258
3333
  // A stage that failed is the end of the job: nothing after it is asked.
3259
3334
  // Handed nothing, the next stage still answers -- about an empty list --
3260
3335
  // and that answer is graded as though the job had worked.
3261
3336
  const handsOn = i < stages.length - 1;
3262
3337
  const against = res.readAgainst ?? echo;
3263
- // The Pre-format (text) filters shape the raw reply before the kind
3264
- // reads it (#405): a Rewrite text reshapes it, a Reject text ends the job.
3338
+ // The filters above the format shape the raw reply before the kind
3339
+ // reads it (#405, #615): they reshape it, or a Reject ends the job.
3265
3340
  const pre = applyFilterSet(st.before || [], { body: res.raw ?? "", items: [], instruction: against }, "text");
3266
- const by = (filter , stage , rejected ) => ({ filter, stage, job: i + 1, rejected });
3267
- if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, "text", true) });
3341
+ const by = (filter , rejected ) => ({ filter, job: i + 1, rejected });
3342
+ if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, true) });
3268
3343
  if (pre.rewrote) transcript[transcript.length - 1] .filtered = { got: pre.body, by: pre.rewrote };
3269
3344
  const read = reader.read(pre.body, { echo: against, parsing: opts.parsing, settings: st.settings });
3270
3345
  if (read.error) return result({ error: named(read.error) });
3271
3346
  const mod = applyModifiers(mods[i], kind, read.value, { instruction: against, body: read.body ?? res.raw ?? "" });
3272
3347
  if (mod.reject) {
3273
- return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, "items", true) } : {}) });
3348
+ return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, true) } : {}) });
3274
3349
  }
3275
3350
  // A filter that dropped the last item: what a zero result is explained by.
3276
- const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, "items", false) } : {};
3351
+ const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, false) } : {};
3277
3352
  const nothing = handsOn ? reader.handsOn?.(mod.value) : null;
3278
3353
  if (nothing) return result({ error: named(nothing), ...emptied });
3279
3354
  const value = mod.value;
3280
3355
  held = { kind, value };
3281
3356
  const gone = [...(read.dropped || []), ...mod.dropped];
3282
3357
  echoed = (read.echoed || 0) + mod.dropped.filter(d => d.by === ECHO).length;
3283
- done.push({ reply: res.raw ?? "", rendered: reader.render?.(value) || {} });
3358
+ // {reply} and {stageN} hand on the text as its filters left it -- what
3359
+ // was graded -- never the raw reply a filter cleaned (#557).
3360
+ done.push({ reply: transcript[transcript.length - 1] .filtered?.got ?? res.raw ?? "", rendered: reader.render?.(value) || {} });
3284
3361
  if (!handsOn) {
3285
3362
  dropped = gone;
3286
3363
  return result({ echoed, dropped, ...emptied });
@@ -3301,8 +3378,8 @@ function readList(reply , out , instruction =
3301
3378
  const reader = OUTPUT_KINDS[out.kind];
3302
3379
  if (!reader) return { items: [], dropped: [], echoed: 0, error: `"${out.kind}" is not an output kind this lab has` };
3303
3380
  const { kind, modifiers, before, ...settings } = out;
3304
- // The Pre-format (text) filters shape the raw reply before the kind
3305
- // reads it (#405); a Reject text ends the job.
3381
+ // The filters above the format shape the raw reply before the kind reads
3382
+ // it (#405, #615); a Reject ends the job.
3306
3383
  const pre = applyFilterSet(before || [], { body: reply, items: [], instruction }, "text");
3307
3384
  if (pre.reject != null) return { items: [], dropped: [], echoed: 0, error: `discarded: ${pre.reject}` };
3308
3385
  const read = reader.read(pre.body, { echo: instruction, settings });
@@ -3380,7 +3457,11 @@ function applyModifiers (list , kind ,
3380
3457
  // 16: a target names an AI Connection and, optionally, a Connection Profile,
3381
3458
  // where it named one Target profile; a run's table is keyed by the pair
3382
3459
  // (answerKey) -- the Target profile split in the store as it opens (#445).
3383
- const PIPELINE_VERSION = 17 ;
3460
+ // 17: a request reads by its job's HTTP API; 18: a saved-mode Source content
3461
+ // pins one response (#596).
3462
+ // 19: a job's filters are one list, run in order around its Response Format,
3463
+ // and filters are seven verbs (#615).
3464
+ const PIPELINE_VERSION = 19 ;
3384
3465
 
3385
3466
  // Plain objects, so an entry is added by assignment and a reader never needs
3386
3467
  // to know which registered it.
@@ -3662,9 +3743,10 @@ function splitProfile(p )
3662
3743
  }
3663
3744
 
3664
3745
  /** [conn] with [profile]'s settings over it: the one set of settings a
3665
- request is built from, the shape a Target profile had. A profile sets
3666
- no connection field. */
3667
- function mergeAnswer(conn , profile ) {
3746
+ request is built from, the shape a Target profile had. A profile -- or
3747
+ (Default)'s settings, which are one without its id and name -- sets no
3748
+ connection field. */
3749
+ function mergeAnswer(conn , profile ) {
3668
3750
  const out = { ...conn };
3669
3751
  const scoped = connectionScoped(typeOf(conn ));
3670
3752
  for (const [k, v] of Object.entries(profile ?? {})) {
@@ -3681,7 +3763,7 @@ function mergeAnswer(conn , profile
3681
3763
 
3682
3764
 
3683
3765
  /** The key a run's table holds [connection] under, with [profile] -- or with
3684
- none, the lab's defaults. A grader names its connection bare, its id alone,
3766
+ none, (Default)'s settings. A grader names its connection bare, its id alone,
3685
3767
  so it never meets a target's pair (see `answersOf`). */
3686
3768
  const answerKey = (connection , profile ) => `${connection}.${profile ?? ""}`;
3687
3769
 
@@ -3716,9 +3798,13 @@ function targetAnswerKey(doc
3716
3798
  * answer key resolved to the settings it sends -- the profile's connection
3717
3799
  * where it names one, else the key's, with the profile's settings over it.
3718
3800
  * A grader's bare id resolves as the Target profile it named did: its
3719
- * connection under the profile split from it. Null where any part is gone.
3801
+ * connection under the profile split from it. One that names no profile, or
3802
+ * a grader with none split beside it, takes [fallback] instead -- (Default)'s
3803
+ * settings, which sit under no named profile, so a profile is still what it
3804
+ * sets (Setup's Used by counts the same, `defaultUses`). Null where any part
3805
+ * is gone.
3720
3806
  */
3721
- function answersOf(connections , profiles ) {
3807
+ function answersOf(connections , profiles , fallback ) {
3722
3808
  const connection = (id ) => connections.find(c => c?.id === id) ?? null;
3723
3809
  const profile = (id ) => profiles.find(p => p?.id === id) ?? null;
3724
3810
  const split = (id ) => {
@@ -3731,7 +3817,7 @@ function answersOf(connections , profiles
3731
3817
  const p = pid ? profile(pid) : null;
3732
3818
  if (pid && !p) return null;
3733
3819
  const c = connection(isRef(p?.connection) ? p .connection .id : at.connection);
3734
- return c ? mergeAnswer(c, p) : null;
3820
+ return c ? mergeAnswer(c, p ?? fallback) : null;
3735
3821
  };
3736
3822
  return { connection, profile, split, resolve };
3737
3823
  }
@@ -3884,15 +3970,16 @@ SOURCE_TYPES.dynamic = {
3884
3970
  };
3885
3971
 
3886
3972
  // HTTP GET (docs/dynamic-sources.md § 1): the safest pull provider. It fetches
3887
- // a URL through the relay server-side -- Snapshot now freezes one Call into the
3888
- // Source, Live produces one fresh at submit -- so the page drives only the
3973
+ // a URL through the relay server-side -- Get response keeps a response, Live
3974
+ // data fetches it again at submit -- so the page drives only the
3889
3975
  // create dialog and the Call labels; the fetch, redaction and token-refusal are
3890
3976
  // the server's (its SOURCE_PROVIDERS mirror in server.py).
3891
3977
  DYNAMIC_PROVIDERS["http-get"] = { // provider: the HTTP GET adapter's own id
3892
3978
  id: "http-get", // provider: its id, the row's config.provider
3893
3979
  label: "HTTP GET",
3894
3980
  icon: "square-code",
3895
- noun: "call",
3981
+ noun: "response",
3982
+ produceIcon: "cloud-sync",
3896
3983
  tier: "pull",
3897
3984
  config: ["url"],
3898
3985
  configProblems(config){
@@ -3921,7 +4008,7 @@ DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3921
4008
  id: "api", // provider: its id, the row's config.provider
3922
4009
  label: "API call",
3923
4010
  icon: "arrow-left-right",
3924
- noun: "call",
4011
+ noun: "response",
3925
4012
  tier: "pull",
3926
4013
  config: ["method", "url", "reqHeaders", "body", "connection"],
3927
4014
  configProblems(config){
@@ -3950,7 +4037,7 @@ DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3950
4037
  // or an Upload… by hand, formalised as a provider. The lab fetches and executes
3951
4038
  // nothing: a Supplied item is named by the id the caller gives it (its file
3952
4039
  // name), which is what joins it to a case. It has no config and no server-side
3953
- // production, so no Snapshot now and no live; its Calls are the items handed in,
4040
+ // production, so no Get response and no live data; its Calls are the items handed in,
3954
4041
  // redacted on the way in like any other (server.py's take_dynamic). A hosted run
3955
4042
  // grades those Calls; a solely-Supplied Source with none is run only via the CLI.
3956
4043
  DYNAMIC_PROVIDERS["supplied"] = { // provider: the Supplied adapter's own id
@@ -3980,6 +4067,7 @@ DYNAMIC_PROVIDERS["file"] = { // provider: the File on disk adapter's own id
3980
4067
  label: "File on disk",
3981
4068
  icon: "file",
3982
4069
  noun: "file",
4070
+ produceIcon: "file-input",
3983
4071
  tier: "local",
3984
4072
  config: ["path"],
3985
4073
  configProblems(config){
@@ -4003,6 +4091,7 @@ DYNAMIC_PROVIDERS["command"] = { // provider: the Command adapter's own id
4003
4091
  label: "Command",
4004
4092
  icon: "terminal",
4005
4093
  noun: "call",
4094
+ produceIcon: "square-terminal",
4006
4095
  tier: "local",
4007
4096
  config: ["command", "args"],
4008
4097
  configProblems(config){
@@ -4085,11 +4174,14 @@ CONTENT_TYPES.source = {
4085
4174
  description: "Every file or record in a Source from the Library.",
4086
4175
  // `live` is a dynamic Source's per-run snapshot-vs-live override
4087
4176
  // (docs/dynamic-sources.md); meaningless for another kind, ignored there.
4088
- fields: ["type", "ref", "first", "loops", "live"],
4177
+ fields: ["type", "ref", "first", "loops", "live", "pin"],
4089
4178
  runFields: ["files", "revs"],
4090
4179
  validate(c, ctx, bad){
4091
4180
  if (!isRef(c.ref)) return void bad.push("a Source content has to name its Source as { id, name }");
4092
4181
  if (c.live != null && typeof c.live !== "boolean") bad.push("a Source content's live is true or false");
4182
+ if (c.pin != null && !(isObj(c.pin) && isStr(c.pin.name) && c.pin.name && Number.isInteger(c.pin.n) && c.pin.n >= 0)) {
4183
+ bad.push("a Source content's pin names a response as { name, n }");
4184
+ }
4093
4185
  if (c.first != null && !(Number.isInteger(c.first) && c.first > 0)) {
4094
4186
  bad.push("Run first wants a positive number, or empty for all");
4095
4187
  }
@@ -4269,7 +4361,7 @@ function erroredScore (s , error
4269
4361
  }
4270
4362
 
4271
4363
  /** The reply a metric grades and Results shows (#546): the last stage's
4272
- reply after its Pre-format filters, or the raw reply. */
4364
+ reply after its filters (#557), or the raw reply. */
4273
4365
  function gradedText(res ) {
4274
4366
  const last = res?.transcript?.at(-1);
4275
4367
  return last?.filtered?.got ?? last?.got ?? res?.raw ?? "";
@@ -5077,23 +5169,20 @@ STEP_TYPES.modifier = {
5077
5169
  };
5078
5170
 
5079
5171
  // A filters step: the filter sets a job cleans and gates its reply by, each a
5080
- // link or a private set (#339, #405), at the step's `stage`. A job may hold
5081
- // one of each stage -- a `text` one Pre-format, an `items` one Post-format -- so
5082
- // the type is `many`; two of a stage is the job's to refuse. A set belongs to
5083
- // no stage (#432): linked here, it runs the filters whose type fits the step's
5084
- // stage and skips the rest. A private set's body is the core's to judge
5172
+ // link or a private set (#339), in run order around the job's Response Format
5173
+ // (#615, `format`). A private set's body is the core's to judge
5085
5174
  // (filterSetBodyProblems); a link to a Library set is the server's to resolve,
5086
5175
  // so only its shape is checked here.
5087
5176
  STEP_TYPES.filters = {
5088
- label: "Filter sets", slot: "responses", rank: 2, many: true,
5177
+ label: "Filter sets", slot: "responses", rank: 2,
5089
5178
  description: "Cleans and gates the reply with linked or private filter sets.",
5090
5179
  in: "value", out: "value",
5091
5180
  apply: "runPipeline",
5092
- fields: ["type", "stage", "filterSets"],
5181
+ fields: ["type", "filterSets", "format"],
5093
5182
  validate(step, _ctx, bad, at){
5094
- if (step.stage !== "text" && step.stage !== "items") bad.push(`${at}: a filters step shapes the text or the items stage`);
5095
5183
  if (!Array.isArray(step.filterSets)) return void bad.push(`${at}: filterSets has to be a list`);
5096
5184
  for (const link of step.filterSets ) bad.push(...filterSetLinkProblems(link, at));
5185
+ bad.push(...formatProblems(step.format, step.filterSets.length, at));
5097
5186
  },
5098
5187
  };
5099
5188
 
@@ -5147,6 +5236,22 @@ function withContent (doc , content )
5147
5236
  return { ...doc, jobs: [withStep(first, "attachContent", content ? { type: "attachContent", content } : null), ...rest] };
5148
5237
  }
5149
5238
 
5239
+ /**
5240
+ * Whether target [i]'s words in job [k] have the item's text joined after
5241
+ * them (#597): job 1, a target that asks a prompt, text content -- a Source
5242
+ * of text items, or inline text, as its CONTENT_TYPES entry says -- and no
5243
+ * {text} in the words to place it (textPrompt). A later job's reply goes
5244
+ * only where a token places it, and an image or a document is sent as a part
5245
+ * of its own, so neither is joined. [ctx] reads a Source as validation does.
5246
+ */
5247
+ function contentJoined(doc , i , k ,
5248
+ ctx = {}) {
5249
+ if (k !== 0 || targetEntryOf(doc, i, k)?.asks !== "prompt") return false;
5250
+ if (String(targetStepOf(doc, i, k)?.prompt ?? "").includes("{text}")) return false;
5251
+ const content = contentOf(doc);
5252
+ return !!content && (CONTENT_TYPES[content.type]?.text?.(content, ctx) ?? null) != null;
5253
+ }
5254
+
5150
5255
  /** A document's targets, in column order. */
5151
5256
  function targetsOf(doc ) {
5152
5257
  return Array.isArray(doc?.targets) ? doc .targets : [];
@@ -5308,70 +5413,112 @@ function withOut(job , out ) {
5308
5413
  }
5309
5414
 
5310
5415
  /** [job] reading a reply as `legacyTagsOut` builds it, on the current model
5311
- (#405): its output a List, a Pre-format text set that reads the reply as
5312
- the old `preamble` did, and a Post-format items set that cleans and gates
5313
- it. The filters are `legacyTagsOut`'s, placed as private sets -- what the
5314
- version-15 upgrade builds for a legacy list job, for a test to build
5315
- directly. */
5416
+ (#405, #615): its output a List, and one filters list -- a private set
5417
+ above the format that reads the reply as the old `preamble` did, then the
5418
+ format, then a private set that cleans and gates its items. The filters
5419
+ are `legacyTagsOut`'s -- what the upgrade builds for a legacy list job, for
5420
+ a test to build directly. */
5316
5421
  function legacyTagsJob(job , rules , mode = "LOWER") {
5317
5422
  const legacy = legacyTagsOut(rules, mode);
5318
5423
  const items = ((legacy.modifiers[0]?.filters ) || []);
5319
- let j = withOut(job, { kind: "list", modifiers: [], parse: "csv" });
5320
- if (legacy.before.length) j = withFilterSets(j, [{ id: `filters-${j.id}-before`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: legacy.before } }], "text");
5321
- if (items.length) j = withFilterSets(j, [{ id: `filters-${j.id}`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: items } }], "items");
5322
- return j;
5323
- }
5324
-
5325
- /** A job's filters step for [stage], when it holds one (#405). */
5326
- const filterStepOf = (job , stage ) =>
5327
- (job?.steps || []).find((st) => isObj(st) && st.type === "filters" && st.stage === stage);
5328
-
5329
- /** The filter-set links a job's Responses stage carries for [stage], in
5330
- document order (#339, #405): read from that stage's filters step, or none.
5331
- Defaults to the Post-format (items) stage, the one that was there before. */
5332
- function filterSetsOf(job , stage = "items") {
5333
- return (filterStepOf(job, stage)?.filterSets ?? []).slice();
5334
- }
5335
-
5336
- /** [job] with its [stage] filter-set links set to [links]: that stage's
5337
- filters step added, replaced, or taken away when none are left. The other
5338
- stage's step is untouched. */
5339
- function withFilterSets(job , links , stage = "items") {
5340
- const steps = job.steps.filter(st => !(st.type === "filters" && st.stage === stage));
5341
- const next = links.length ? [...steps, { type: "filters" , stage, filterSets: [...links] }] : steps;
5342
- return { ...job, steps: ordered(next) };
5343
- }
5344
-
5345
- /** The filters a link resolves to, or none: a private (`own`) link its own
5346
- body, a Library link the body [resolve] gives (the worker's kept bodies),
5347
- and a Library link with no body nothing. */
5348
- const linkFilters = (link , resolve ) =>
5349
- (link.own ?? (link.set && resolve ? resolve(link.set) : null))?.filters || [];
5350
-
5351
- /** A job's Post-format (items) Responses as an ordered modifier list for a
5352
- run (#339): each modifier step as it is, and each items filter-set link as a
5353
- synthetic modifier that applies the set's filters where the link sits. The
5354
- Pre-format (text) filters are not modifiers -- `beforeFilters` gives
5355
- them, and a run applies them to the raw reply before it is read. [resolve]
5356
- gives a Library link's body. */
5424
+ const j = withOut(job, { kind: "list", modifiers: [], parse: "csv" });
5425
+ const above = legacy.before.length
5426
+ ? [{ id: `filters-${j.id}-before`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: legacy.before } }] : [];
5427
+ const below = items.length
5428
+ ? [{ id: `filters-${j.id}`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: items } }] : [];
5429
+ return withFilterSets(j, [...above, ...below], above.length);
5430
+ }
5431
+
5432
+ /** A job's filters step, when it holds one. */
5433
+ const filterStepOf = (job ) =>
5434
+ (job?.steps || []).find((st) => isObj(st) && st.type === "filters");
5435
+
5436
+ /** The filter-set links a job's Responses carries, in run order (#339,
5437
+ #615): read from its filters step, or none. */
5438
+ function filterSetsOf(job ) {
5439
+ return (filterStepOf(job)?.filterSets ?? []).slice();
5440
+ }
5441
+
5442
+ /** Where job's own Response Format sits among its links (#615): before
5443
+ `filterSetsOf(job)[n]`, or undefined where it holds no place of its own --
5444
+ a straddling set's marker, or the end, is where it reads. */
5445
+ function filterFormatOf(job ) {
5446
+ const at = filterStepOf(job)?.format;
5447
+ return Number.isInteger(at) ? at : undefined;
5448
+ }
5449
+
5450
+ /** [job] with its filter-set links set to [links], and its own Response
5451
+ Format at [format] among them (none: undefined). The filters step is
5452
+ added, replaced, or taken away when no link is left. */
5453
+ function withFilterSets(job , links , format ) {
5454
+ const steps = job.steps.filter(st => st.type !== "filters");
5455
+ const step = { type: "filters", filterSets: [...links], ...(format != null ? { format } : {}) };
5456
+ return { ...job, steps: ordered(links.length ? [...steps, step] : steps) };
5457
+ }
5458
+
5459
+ /** The body a link resolves to, or none: a private (`own`) link its own body,
5460
+ a Library link the body [resolve] gives (the worker's kept bodies). */
5461
+ const linkBody = (link , resolve ) =>
5462
+ link.own ?? (link.set && resolve ? resolve(link.set) ?? null : null);
5463
+
5464
+ /** Where job's Response Format reads among its links (#615), as the index
5465
+ of the link it reads in and the place among that link's filters: its own
5466
+ place (`format`) when it has one, else the first linked set whose body
5467
+ straddles the format (its `format`), else after every link. A format that
5468
+ reads text reads after every link. [resolve] gives a Library link's body. */
5469
+ function formatPlace(job , resolve ) {
5470
+ const links = filterSetsOf(job);
5471
+ if (!formatReadsItems(outOf(job)?.kind)) return { link: links.length, at: 0 };
5472
+ const own = filterFormatOf(job);
5473
+ if (own != null) return { link: Math.min(own, links.length), at: 0 };
5474
+ for (let i = 0; i < links.length; i++) {
5475
+ const at = linkBody(links[i] , resolve)?.format;
5476
+ if (Number.isInteger(at)) return { link: i, at: at };
5477
+ }
5478
+ return { link: links.length, at: 0 };
5479
+ }
5480
+
5481
+ /** A job's filters on either side of its Response Format (#615): `above`,
5482
+ run on the raw reply before it is read, and `below`, each link's share
5483
+ run on the items after it -- one list a link, so a filter that empties a
5484
+ list is named as a link's always was. */
5485
+ function filterSides(job , resolve ) {
5486
+ const links = filterSetsOf(job);
5487
+ const place = formatPlace(job, resolve);
5488
+ const above = [], below = [];
5489
+ links.forEach((link, i) => {
5490
+ const filters = linkBody(link, resolve)?.filters || [];
5491
+ if (i < place.link) above.push(...filters);
5492
+ else if (i > place.link) below.push(filters);
5493
+ else { above.push(...filters.slice(0, place.at)); below.push(filters.slice(place.at)); }
5494
+ });
5495
+ return { above, below };
5496
+ }
5497
+
5498
+ /** A job's Responses below its Response Format as an ordered modifier list
5499
+ for a run (#339, #615): each modifier step as it is, and each link's share
5500
+ below the format as a synthetic modifier that applies its filters to the
5501
+ items where the filters step sits. The filters above the format are not
5502
+ modifiers -- `beforeFilters` gives them, and a run applies them to the raw
5503
+ reply before it is read. [resolve] gives a Library link's body. */
5357
5504
  function stageModifiers(job , resolve ) {
5358
5505
  const out = [];
5359
5506
  for (const st of job?.steps || []) {
5360
5507
  if (!isObj(st)) continue;
5361
5508
  if (st.type === "modifier") out.push(st.modifier);
5362
- else if (st.type === "filters" && st.stage === "items") {
5363
- for (const link of (st.filterSets ) || []) out.push({ type: FILTER_MODIFIER, filters: linkFilters(link, resolve) });
5509
+ else if (st.type === "filters") {
5510
+ for (const filters of filterSides(job, resolve).below) out.push({ type: FILTER_MODIFIER, filters });
5364
5511
  }
5365
5512
  }
5366
5513
  return out;
5367
5514
  }
5368
5515
 
5369
- /** A job's Pre-format (text) filters for a run (#405), in document order:
5370
- the filters of every link on its `text` filters step, which a run applies to
5371
- the raw reply before the kind reads it into items. [resolve] gives a Library
5516
+ /** A job's filters above its Response Format, for a run (#405, #615), in
5517
+ run order: a run applies them to the raw reply before the kind reads it.
5518
+ Every filter of a job whose format reads text. [resolve] gives a Library
5372
5519
  link's body, as `stageModifiers` does. */
5373
5520
  function beforeFilters(job , resolve ) {
5374
- return (filterStepOf(job, "text")?.filterSets || []).flatMap(link => linkFilters(link, resolve));
5521
+ return filterSides(job, resolve).above;
5375
5522
  }
5376
5523
 
5377
5524
  /** A new id: random, so one minted in one browser never collides with one
@@ -5417,11 +5564,6 @@ STEP_TYPES.job = {
5417
5564
  }
5418
5565
  const twice = steps.find((st, x) => !STEP_TYPES[st.type] .many && steps.findIndex(o => o.type === st.type) !== x);
5419
5566
  if (twice) return void bad.push(`${at} has two ${STEP_TYPES[twice.type] .label ?? twice.type} steps`);
5420
- // A `filters` step is `many`, but only one per stage: a Pre-format and
5421
- // a Post-format set at most (#405).
5422
- const twoFilters = steps.find((st, x) => st.type === "filters"
5423
- && steps.findIndex(o => o.type === "filters" && o.stage === st.stage) !== x);
5424
- if (twoFilters) return void bad.push(`${at} has two ${twoFilters.stage === "text" ? "Pre-format" : "Post-format"} filter steps`);
5425
5567
  if (steps.some((st, x) => x > 0 && placeOf(st) < placeOf(steps[x - 1]))) {
5426
5568
  return void bad.push(`${at}: steps are its Content, then its Responses, each in order`);
5427
5569
  }
@@ -5533,6 +5675,12 @@ function versionProblem(doc ) {
5533
5675
 
5534
5676
 
5535
5677
 
5678
+
5679
+
5680
+
5681
+
5682
+
5683
+
5536
5684
 
5537
5685
 
5538
5686
  /**
@@ -5803,9 +5951,9 @@ function groupsOf(next ) {
5803
5951
  /** [doc] with each private group's body read as this version of a group
5804
5952
  body (`upgradeDatasetBody`): a version-8 one's `source` goes (#396); and
5805
5953
  each private filter set's as this version of a filter-set body
5806
- (`upgradeFilterSetBody`): a version-2 one's `stage` goes (#432). A body
5807
- carries its own version, so this runs on a current pipeline too; one whose
5808
- bodies are all current comes back as it was. */
5954
+ (`ownBodyV4`): its filters the verbs that read a reply as they did (#615).
5955
+ A body carries its own version, so this runs on a current pipeline too;
5956
+ one whose bodies are all current comes back as it was. */
5809
5957
  function ownBodiesNow(doc ) {
5810
5958
  const stale = (t ) => isObj(t) && isObj(t.own) && t.own.version !== DATASET_BODY_VERSION;
5811
5959
  const staleSet = (l ) => isObj(l) && isObj(l.own) && l.own.version !== FILTER_SET_VERSION;
@@ -5817,14 +5965,15 @@ function ownBodiesNow(doc ) {
5817
5965
  }
5818
5966
  if (Array.isArray(out.jobs) && out.jobs.some(staleJob)) {
5819
5967
  out = { ...out, jobs: out.jobs.map((j ) => (!staleJob(j) ? j : { ...j, steps: j.steps.map((st ) => (!staleStep(st) ? st
5820
- : { ...st, filterSets: st.filterSets.map((l ) => (staleSet(l) ? { ...l, own: upgradeFilterSetBody(l.own) } : l)) })) })) };
5968
+ : { ...st, filterSets: st.filterSets.map((l ) => (staleSet(l) ? { ...l, own: ownBodyV4(l.own) } : l)) })) })) };
5821
5969
  }
5822
5970
  return out;
5823
5971
  }
5824
5972
 
5825
5973
  function upgradePipeline (doc , ctx = {}) {
5826
5974
  // A current document is read as it is, but for a profile reference the Runs
5827
- // tab saved whole (see profileRefs), which is cut back, and a step on a
5975
+ // tab saved whole (see profileRefs), which is cut back, a Read Reply's
5976
+ // leftover `preamble: false` (withoutLeftoverPreamble), and a step on a
5828
5977
  // profile a target step stands in for (localSteps).
5829
5978
  if (isObj(doc) && doc.version === PIPELINE_VERSION) {
5830
5979
  let out = doc ;
@@ -5832,12 +5981,20 @@ function upgradePipeline (doc , ctx = {}) {
5832
5981
  out = clone(out) ;
5833
5982
  profileRefs(out);
5834
5983
  }
5835
- return localSteps(ownBodiesNow(out), ctx) ;
5984
+ return localSteps(ownBodiesNow(withoutLeftoverPreamble(out)), ctx) ;
5836
5985
  }
5837
- if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].includes(doc.version )) return doc;
5986
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].includes(doc.version )) return doc;
5987
+ // Every version reads as 18 first, then as 19: a job's filters are one list
5988
+ // around its Response Format (oneFilterList).
5989
+ const nineteen = (d ) => localSteps(ownBodiesNow(oneFilterList(d)), ctx) ;
5990
+ if (doc.version === 18) return nineteen(withoutLeftoverPreamble(cutRefs(clone(doc) )));
5991
+ // Every version reads as 17 first, then as 18: a saved-mode Source content
5992
+ // pins one response (savedPinned).
5993
+ const eighteen = (d ) => nineteen(savedPinned(d, ctx));
5994
+ if (doc.version === 17) return eighteen(cutRefs(clone(doc) ));
5838
5995
  // Every version reads as 16 first, then as 17: its requests read by their
5839
5996
  // job's HTTP API (requestsOnJobApi).
5840
- const seventeen = (d ) => localSteps(ownBodiesNow(requestsOnJobApi(d)), ctx) ;
5997
+ const seventeen = (d ) => eighteen(requestsOnJobApi(d));
5841
5998
  if (doc.version === 16) return seventeen(cutRefs(clone(doc) ));
5842
5999
  // Every version reads as 15 first, then as 16: its Target profiles split
5843
6000
  // into AI Connections and Connection Profiles (answersFromProfiles).
@@ -5883,11 +6040,13 @@ function filtersFromCleaning(next ) {
5883
6040
  const isFilters = (st ) => isObj(st) && st.type === "filters";
5884
6041
  next.jobs = next.jobs.map((job ) => {
5885
6042
  if (!isObj(job) || !Array.isArray(job.steps)) return job;
5886
- const steps = job.steps ;
5887
- const readReply = steps.find(st => isObj(st) && st.type === "readReply") ;
6043
+ const readReply = job.steps.find((st ) => isObj(st) && st.type === "readReply") ;
5888
6044
  const preamble = !!(readReply && isObj(readReply.out) && readReply.out.preamble === true);
6045
+ // Stripped from every Read Reply, `false` included, before the job is
6046
+ // asked whether it has anything to migrate (#604).
6047
+ const steps = (job.steps ).map(withoutPreamble);
5889
6048
  const stale = steps.some(st => isFilters(st) && (st ).stage == null);
5890
- if (!steps.some(isCleaning) && !preamble && !stale) return job;
6049
+ if (!steps.some(isCleaning) && !preamble && !stale) return { ...job, steps };
5891
6050
  const jid = isStr(job.id) ? job.id : "job";
5892
6051
 
5893
6052
  // Walk the Responses steps, turning each into a chunk of items filters
@@ -5902,25 +6061,22 @@ function filtersFromCleaning(next ) {
5902
6061
  if (isCleaning(st)) buffer = [...buffer, ...cleaningFilters((st ).modifier )];
5903
6062
  else if (isFilters(st)) {
5904
6063
  for (const link of ((st ).filterSets ) || []) {
5905
- if (link.own) buffer = [...buffer, ...(link.own.filters || [])];
6064
+ if (link.own) buffer = [...buffer, ...((ownBodyV4(link.own) ).filters || [])];
5906
6065
  else { flush(); links.push(link); }
5907
6066
  }
5908
6067
  }
5909
6068
  }
5910
6069
  flush();
5911
- const itemsStep = links.length ? { type: "filters", stage: "items", filterSets: links } : null;
5912
- const textStep = preamble
6070
+ // Version 18's two filters steps, a stage each, which oneFilterList reads
6071
+ // on the way to 19.
6072
+ const itemsStep = links.length ? { type: "filters", stage: "items", filterSets: links } : null;
6073
+ const textStep = preamble
5913
6074
  ? { type: "filters", stage: "text", filterSets: [{ id: `filters-${jid}-before`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: preambleFilters() } }] }
5914
6075
  : null;
5915
6076
 
5916
6077
  const out = [];
5917
6078
  let placed = false;
5918
6079
  for (const st of steps) {
5919
- if (isObj(st) && st.type === "readReply" && isObj(st.out) && "preamble" in st.out) {
5920
- const { preamble: _p, ...rest } = st.out ;
5921
- out.push({ ...st, out: rest });
5922
- continue;
5923
- }
5924
6080
  if (isCleaning(st) || isFilters(st)) {
5925
6081
  if (!placed) { if (textStep) out.push(textStep); if (itemsStep) out.push(itemsStep); placed = true; }
5926
6082
  continue;
@@ -5933,6 +6089,72 @@ function filtersFromCleaning(next ) {
5933
6089
  return next;
5934
6090
  }
5935
6091
 
6092
+ /** Version 18 to 19 (#615): each job's Pre-format and Post-format filters
6093
+ steps become one, in run order -- the Pre-format links, then the
6094
+ Post-format ones -- and where the job's format reads items, its Response
6095
+ Format sits between them (`format`), so every filter runs where it did. A
6096
+ Library set linked on both steps is linked once, between them, and its
6097
+ body (now straddling the format, `upgradeFilterSetBody`) says where the
6098
+ format reads: the job holds no place of its own. A job whose format reads
6099
+ text runs every filter on its text, so it holds no place either. Each
6100
+ private set's body is read as version 4 (`ownBodyV4`). Every version
6101
+ before 19 ends here. */
6102
+ function oneFilterList(next ) {
6103
+ next.version = PIPELINE_VERSION;
6104
+ if (!Array.isArray(next.jobs)) return next;
6105
+ const isFilters = (st ) => isObj(st) && st.type === "filters";
6106
+ next.jobs = next.jobs.map((job ) => {
6107
+ if (!isObj(job) || !Array.isArray(job.steps) || !job.steps.some(isFilters)) return job;
6108
+ const steps = job.steps ;
6109
+ const linksOf = (stage ) => steps.filter(st => isFilters(st) && (st.stage ?? "items") === stage)
6110
+ .flatMap(st => (Array.isArray(st.filterSets) ? st.filterSets : []) )
6111
+ .map(l => (isObj(l) && l.own ? { ...l, own: ownBodyV4(l.own) } : l));
6112
+ const pre = linksOf("text"), post = linksOf("items");
6113
+ const setId = (l ) => (isObj(l) && isObj(l.set) ? l.set.id : null);
6114
+ const both = pre.find(l => setId(l) && post.some(o => setId(o) === setId(l) && o.pin === l.pin));
6115
+ let links , format ;
6116
+ if (!formatReadsItems(outOf(job )?.kind)) { links = [...pre, ...post]; format = undefined; }
6117
+ else if (both) {
6118
+ links = [...pre.filter(l => l !== both), both, ...post.filter(o => !(setId(o) === setId(both) && o.pin === both.pin))];
6119
+ format = undefined;
6120
+ } else { links = [...pre, ...post]; format = post.length ? pre.length : undefined; }
6121
+ const out = [];
6122
+ let placed = false;
6123
+ for (const st of steps) {
6124
+ if (!isFilters(st)) { out.push(st); continue; }
6125
+ if (placed || !links.length) continue;
6126
+ out.push({ type: "filters", filterSets: links, ...(format != null ? { format } : {}) });
6127
+ placed = true;
6128
+ }
6129
+ return { ...job, steps: out };
6130
+ });
6131
+ return next;
6132
+ }
6133
+
6134
+ /** [st] without its List `preamble` setting, when it is a Read Reply holding
6135
+ one; [st] itself otherwise. */
6136
+ function withoutPreamble(st ) {
6137
+ if (!isObj(st) || st.type !== "readReply" || !isObj(st.out) || !("preamble" in st.out)) return st;
6138
+ const { preamble: _p, ...out } = st.out ;
6139
+ return { ...st, out };
6140
+ }
6141
+
6142
+ /** Whether a current document still carries a `preamble: false` that an
6143
+ upgrade before #604 left behind. It did nothing, so it is cut as the
6144
+ document is read; a `preamble: true` the lab never wrote at this version is
6145
+ left for validation to refuse. */
6146
+ function leftoverPreamble(doc ) {
6147
+ return Array.isArray(doc.jobs) && doc.jobs.some((job ) => isObj(job) && Array.isArray(job.steps)
6148
+ && job.steps.some((st ) => isObj(st) && st.type === "readReply" && isObj(st.out) && st.out.preamble === false));
6149
+ }
6150
+
6151
+ /** [doc] with each Read Reply's leftover `preamble: false` cut (leftoverPreamble). */
6152
+ function withoutLeftoverPreamble(doc ) {
6153
+ if (!leftoverPreamble(doc)) return doc;
6154
+ return { ...doc, jobs: doc.jobs.map((job ) => (!isObj(job) || !Array.isArray(job.steps) ? job
6155
+ : { ...job, steps: job.steps.map((st ) => (isObj(st) && isObj(st.out) && st.out.preamble === false ? withoutPreamble(st) : st)) })) };
6156
+ }
6157
+
5936
6158
  /** [doc] with no Metrics eval holding the `case` metric: a dataset's cases
5937
6159
  are metrics now (dataset body version 5), which an eval naming the
5938
6160
  dataset adds for each item, so the case's own metrics carry what that
@@ -5986,6 +6208,24 @@ function localSteps(doc , ctx ) {
5986
6208
  return next;
5987
6209
  }
5988
6210
 
6211
+ /**
6212
+ * Version 17 to 18 (#596): Saved response pins one response. A Source content
6213
+ * graded as saved -- `live` false, or unset over a Source whose default is
6214
+ * saved, which [ctx] says -- pins the Source's newest successful response
6215
+ * (owner), so a Source holding several grades one where it graded all. Live
6216
+ * data, a content that is not a Source's, or one [ctx] cannot say of, pins
6217
+ * none; a run document carries its files, and needs none.
6218
+ */
6219
+ function savedPinned(next , ctx ) {
6220
+ next.version = PIPELINE_VERSION;
6221
+ const content = contentOf(next ) ;
6222
+ if (!isObj(content) || content.type !== "source" || content.live === true || content.pin != null
6223
+ || isObj(next.profiles) || !isRef(content.ref) || !ctx.pinFor) return next;
6224
+ const pin = ctx.pinFor(content.ref.id, typeof content.live === "boolean" ? content.live : undefined);
6225
+ if (pin) content.pin = { name: pin.name, n: pin.n };
6226
+ return next;
6227
+ }
6228
+
5989
6229
  /**
5990
6230
  * Version 16 to 17 (#477): a request step has no HTTP API of its own; its
5991
6231
  * job's flow step says it. A step that named another -- Any endpoint over an
@@ -6439,7 +6679,11 @@ function resolvePipeline(doc ,
6439
6679
  run.profiles[key] = { ...conn, connection: cid };
6440
6680
  }
6441
6681
  const content = contentOf(run) ;
6442
- if (content?.type === "source") content.files = CONTENT_TYPES.source .expand (content, ctx.files);
6682
+ // Saved response grades its one pinned response (#596), whatever else the
6683
+ // Source holds; the server keeps that response's bytes with the run.
6684
+ if (content?.type === "source") {
6685
+ content.files = CONTENT_TYPES.source .expand (content, isObj(content.pin) && content.live !== true ? [content.pin.name] : ctx.files);
6686
+ }
6443
6687
  // Each Library group the evals read, as the version it grades with: the
6444
6688
  // body's fingerprint and, where the lab numbers them, `n` -- a pinned
6445
6689
  // link's pin.
@@ -6706,7 +6950,8 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6706
6950
  * [doc] as a bundle's text files: `pipeline.yaml`, `profiles.yaml` and
6707
6951
  * `datasets/<slug>.json`. [ctx.connections] and [ctx.connectionProfiles]
6708
6952
  * are Setup's -- keys and all, none of which is written -- or
6709
- * [ctx.profiles], Target profiles to split as the store was; [ctx.grader]
6953
+ * [ctx.profiles], Target profiles to split as the store was, and
6954
+ * [ctx.profileDefault] (Default)'s settings; [ctx.grader]
6710
6955
  * the lab's grader, written into each eval that would have asked it, and
6711
6956
  * [ctx.datasets] each graded dataset by id, as `{ name, body }`. Each
6712
6957
  * connection asked is written resolved, under its answer key with the
@@ -6715,12 +6960,13 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6715
6960
  * something else.
6716
6961
  */
6717
6962
  function exportBundle(doc , ctx
6718
-
6719
- = {})
6963
+
6964
+ = {})
6720
6965
  {
6721
6966
  const split = (ctx.profiles ?? []).map(splitProfile);
6722
6967
  const profiles = withSlugs([...(ctx.connections ?? []), ...split.map(x => x.connection)] );
6723
- const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))]);
6968
+ const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))],
6969
+ ctx.profileDefault);
6724
6970
  // What the lab would supply at submit, written down: no lab supplies it later.
6725
6971
  const out = clone(doc) ;
6726
6972
  out.evals = (Array.isArray(out.evals) ? out.evals : [])
@@ -6890,9 +7136,9 @@ function stagesFor(run , i , resolveFilters
6890
7136
  {
6891
7137
  const stages = run.jobs.map((ch, k) => {
6892
7138
  const { kind, modifiers: _mods, ...settings } = outOf(ch);
6893
- // The modifiers a run applies, with each Post-format filter set a
6894
- // synthetic modifier where its link sits (#339), and the Pre-format
6895
- // (text) filters applied to the raw reply before it is read (#405);
7139
+ // The modifiers a run applies, with the filters below the format a
7140
+ // synthetic modifier a link where the filters step sits (#339), and those
7141
+ // above it applied to the raw reply before it is read (#405, #615);
6896
7142
  // without a resolver, a Library link reads nothing, a private one its own.
6897
7143
  return { text: targetStepOf(run, i, k) .prompt, kind, withImage: sendsImage(ch),
6898
7144
  verbatim: !!targetEntryOf(run, i, k)?.verbatim, before: beforeFilters(ch, resolveFilters),
@@ -7178,8 +7424,10 @@ function validateEvals(ev , files
7178
7424
  continue;
7179
7425
  }
7180
7426
  if (scored.some(m => m.type === "refused" && !m.not)) {
7181
- // Nor does a call that was refused: there is no answer.
7182
- if (scored.length > 1) say("expects its call refused, and states something the answer should hold too");
7427
+ // Nor does a call that was refused: there is no answer. Scored in
7428
+ // points, the other metrics are alternatives rather than more to hold
7429
+ // (#560: refused, or the reply says it can't read the file).
7430
+ if (scored.length > 1 && ev.scoring?.mode !== "weighted") say("expects its call refused, and states something the answer should hold too");
7183
7431
  continue;
7184
7432
  }
7185
7433
  const wants = scored.filter(m => !m.not && (m.type === "contains-all" || m.type === "contains-any"));
@@ -7304,12 +7552,12 @@ export {
7304
7552
  SOURCE_TYPES, DEFAULT_SOURCE_TYPE, sourceTypeOf, WORKFLOW_PLATFORMS, platformOf, DYNAMIC_PROVIDERS, providerOf, WIZARDS, wizardProgress,
7305
7553
  registerKinds, defaultKind, jobDefaults, applyModifiers, keyVar, slugOf, uniqueSlug, isSlug, withSlugs, SLUG_MAX, connectionOf, addressOf, connectionSettings,
7306
7554
  splitProfile, mergeAnswer, answerKey, answerOfKey, answersOf, runAnswer, answerConnectionId, connectionScoped,
7307
- connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, connectionImage, connectionProblems,
7555
+ connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, replyFinish, contentJoined, connectionImage, connectionProblems,
7308
7556
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
7309
7557
  contentOf, withContent, replyOf,
7310
7558
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7311
7559
  tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7312
- filterSetsOf, withFilterSets, stageModifiers, beforeFilters, gradedText,
7560
+ filterSetsOf, filterFormatOf, withFilterSets, formatPlace, stageModifiers, beforeFilters, gradedText,
7313
7561
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7314
7562
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
7315
7563
  evalLabel, evalsOf, evalsDataset, casesRef, groupOf, groupOfMetrics, pipelineWarnings, itemsFound, previewOf, isWholeRun, EVAL_FIELDS,