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.
@@ -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). */
@@ -253,7 +255,9 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
253
255
 
254
256
  /** A target (pipeline-model §16): one column through every job, compared
255
257
  side by side in Results -- what version 9 called a scenario. Its step in
256
- each job says what it sends there. */
258
+ each job says what it sends there. From version 20 a Target is the
259
+ derived view of a Variant over the pipeline's Request steps (jobsOf,
260
+ targetsOf); the stored shape is the flat step list and its Variants (§20). */
257
261
 
258
262
 
259
263
 
@@ -268,6 +272,95 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
268
272
 
269
273
 
270
274
 
275
+ // ---- the flat step list and Variants (version 20, pipeline-model §20) --------
276
+ //
277
+ // Version 20 reshapes a pipeline from jobs (each a stage holding every
278
+ // Target's step) into one ordered list of steps with Variants beside it. The
279
+ // steps are Get items, Request N, Read Response N and Filter; a Request holds
280
+ // the settings every Variant shares, and each Variant overrides any of them
281
+ // per Request. jobsOf / targetsOf derive the version-19 jobs and targets from
282
+ // this shape, so every reader (the page, the runner, the server through the
283
+ // core) keeps reading one shape; withJobs / withTargets serialise an edit
284
+ // back. The encoding is a verbatim re-packaging of jobs and targets, so a
285
+ // round-trip is byte-exact and stagesFor gives the same stages (#669).
286
+
287
+ /** Get items: the items a run goes over, before Request 1. There can be
288
+ several, each adding more items (§20); the upgrade from 19 makes one from
289
+ job 1's Attach Content. */
290
+
291
+
292
+
293
+
294
+
295
+
296
+ /** A Request: the settings every Variant shares in this job -- its base cell
297
+ (Variant A's: what it asks and of whom, its words, and an HTTP Request's
298
+ template), whether the item's image goes with it, the token mappings its
299
+ words resolve under, and the flow step its records are calls of, for a
300
+ workflow Request. Each Variant overrides its cell (Variant.overrides); the
301
+ image and the tokens are the Request's alone. */
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+
316
+
317
+
318
+ /** Read Response N: how Request N's response is read -- the flow's reading of
319
+ it (`readAs`, a workflow Request's) and the output kind it is validated as
320
+ (`out`, absent for plain text). Comes after its Request. */
321
+
322
+
323
+
324
+
325
+
326
+
327
+
328
+
329
+
330
+
331
+
332
+
333
+
334
+ /** Filter: the filter sets a Request's response is cleaned and gated by, in
335
+ run order around its Response Format (the version-19 `filters` step, now
336
+ its own step in the list). Comes after its Request. */
337
+
338
+
339
+
340
+
341
+
342
+
343
+
344
+
345
+
346
+ /** One step of a version-20 pipeline. */
347
+
348
+
349
+ /** A Variant (version 20): a column through every Request, at most four. It
350
+ overrides a Request's shared settings, keyed by the Request's id -- AI
351
+ Connection, Model, Profile, Prompt (or Body) and a workflow Request's
352
+ Answered by (the cell's `type`); Send image and Tokens are the Request's.
353
+ An empty override inherits the Request's base; Variant A (the first)
354
+ inherits every one. The Variant's own `connection` / `profile` are the fallback a Request with no
355
+ override of its own uses, as a version-19 target's were. */
356
+
357
+
358
+
359
+
360
+
361
+
362
+
363
+
271
364
  /** What every eval carries whatever its type: an id minted once and never
272
365
  shown, an optional name ("Eval 2" when blank), and whether the evals after
273
366
  it still read what it failed on (docs/pipeline-model.md §3). */
@@ -358,32 +451,20 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
358
451
  * at. `filters` are tried in order -- each drop-item narrows the items the
359
452
  * next reads, and the first reject-reply that fires gates the answer.
360
453
  */
361
- /** Which side of Response Format a filter step sits on (#405): `text`
362
- before it, on the raw reply (Pre-format), `items` after it (Post-format).
363
- It also names what a filter reads: the reply's text (rewrite-text /
364
- reject-text) or the items (drop-item, rewrite-item, keep-first,
365
- reject-reply). A text filter runs at either stage, an item filter only
366
- Post-format after a kind that reads items (#557, `stageReads`); a set
367
- belongs to no stage (#432), and runs its share in each it is linked in. */
368
-
369
-
370
- /** The stage a filter type's scope belongs to: text filters read the raw
371
- reply; item, list and answer filters read the items it is read into. */
372
- export const stageOfScope = (scope ) => (scope === "text" ? "text" : "items");
373
-
374
- /** What job's [stage] filters read when it answers as [kind] (#557):
375
- Pre-format the text; Post-format the items after a kind that reads
376
- items, and the text after any other. A filter runs there when its
377
- `stageOfScope` is this. */
378
- export const stageReads = (stage , kind ) =>
379
- stage === "items" && OUTPUT_KINDS[kind ?? ""]?.offers?.includes("items") ? "items" : "text";
380
-
381
- /** A filter stage as the page names it: before the reply is formatted, or after. */
382
- export const FILTER_STAGE_LABEL = { text: "Pre-format", items: "Post-format" };
454
+ /** Whether a job answering as [kind] reads its reply into items, so that
455
+ where its Response Format sits among its filters matters (#615): the
456
+ filters above it run on the raw text, those below on each item. A format
457
+ that reads text reads after every filter. */
458
+ export const formatReadsItems = (kind ) =>
459
+ !!OUTPUT_KINDS[kind ?? ""]?.offers?.includes("items");
383
460
 
384
461
 
385
462
 
386
463
 
464
+
465
+
466
+
467
+
387
468
 
388
469
 
389
470
  /** A Library filter set by reference; a run's copy says the version it used:
@@ -420,8 +501,13 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
420
501
 
421
502
 
422
503
 
423
-
424
-
504
+
505
+  
506
+
507
+
508
+
509
+
510
+
425
511
 
426
512
 
427
513
 
@@ -571,14 +657,10 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
571
657
 
572
658
 
573
659
 
660
+
574
661
 
575
-
576
-
662
+
577
663
 
578
-
579
-
580
-
581
-
582
664
 
583
665
 
584
666
 
@@ -668,17 +750,16 @@ export function kept(text , max = KEPT_CHARS) {
668
750
 
669
751
 
670
752
 
671
-
672
-
753
+
673
754
 
674
755
 
675
756
 
676
757
 
677
758
 
678
- /** Which filter discarded a job's reply, or emptied it, and where (#435). */
759
+ /** Which filter discarded a job's reply, or emptied it, and where (#435). A
760
+ run kept before #615 also says the stage it ran in, which nothing reads. */
679
761
 
680
762
 
681
-
682
763
 
683
764
 
684
765
 
@@ -713,7 +794,6 @@ export function kept(text , max = KEPT_CHARS) {
713
794
 
714
795
 
715
796
 
716
-
717
797
 
718
798
 
719
799
 
@@ -980,12 +1060,18 @@ export function kept(text , max = KEPT_CHARS) {
980
1060
 
981
1061
 
982
1062
 
1063
+
1064
+
1065
+
983
1066
 
984
1067
 
985
1068
  /** One item of a queue row's results: a file or the pasted text. */
986
1069
 
987
1070
 
988
1071
 
1072
+
1073
+  
1074
+
989
1075
 
990
1076
 
991
1077
 
@@ -1247,10 +1333,10 @@ const platformOf = (src
1247
1333
 
1248
1334
 
1249
1335
 
1250
-
1251
-
1252
-
1253
-
1336
+
1337
+
1338
+
1339
+
1254
1340
 
1255
1341
 
1256
1342
 
@@ -1549,24 +1635,42 @@ function upgradeDatasetBody (body ) {
1549
1635
  `{ version, filters }` body #334 introduces. A later format adds a step to
1550
1636
  `upgradeFilterSetBody`, as `upgradeDatasetBody` grew, with server.py's
1551
1637
  `upgrade_filter_set_body` its twin. */
1552
- const FILTER_SET_VERSION = 3 ;
1553
-
1554
- /** [body] as this version of a filter set (3), from any earlier one. Version 2
1555
- (#405) named the stage a set shaped; version 3 (#432) names none -- each
1556
- filter's type decides where it runs -- so a version-1 or -2 body loses its
1557
- `stage` and reads as this one. Anything else comes back untouched for
1558
- `filterSetBodyProblems` to refuse. Pure: the same body gives the same
1559
- answer, and server.py's upgrade_filter_set_body is its twin. */
1638
+ const FILTER_SET_VERSION = 4 ;
1639
+
1640
+ /** [body] as this version of a filter set (4), from any earlier one. Version 2
1641
+ (#405) named the stage a set shaped, which version 3 (#432) dropped: each
1642
+ filter's type decided where it ran. Version 4 (#615) has seven verbs that
1643
+ run wherever they sit, around a Response Format a set may straddle: each
1644
+ filter becomes the verb that reads a reply as it did (`filterV4`), and a
1645
+ set that held both text and item filters puts its text ones first, in
1646
+ their order, then its `format` marker, then its item ones -- what it ran
1647
+ linked both before and after the format. Anything else comes back
1648
+ untouched for `filterSetBodyProblems` to refuse. Pure: the same body gives
1649
+ the same answer, and server.py's upgrade_filter_set_body is its twin. */
1560
1650
  function upgradeFilterSetBody (body ) {
1561
1651
  if (!isObj(body)) return body;
1562
1652
  if (body.version === FILTER_SET_VERSION) return body;
1563
- if (body.version === 1 || body.version === 2) {
1653
+ if (body.version === 1 || body.version === 2 || body.version === 3) {
1564
1654
  const { stage: _stage, ...rest } = body;
1565
- return { ...rest, version: FILTER_SET_VERSION } ;
1655
+ if (!Array.isArray(rest.filters)) return { ...rest, version: FILTER_SET_VERSION } ;
1656
+ const isText = (f ) => isObj(f) && V3_TEXT_TYPES.includes(f.type );
1657
+ const text = rest.filters.filter(isText), items = rest.filters.filter((f ) => !isText(f));
1658
+ return { ...rest, version: FILTER_SET_VERSION, filters: [...text, ...items].map(filterV4),
1659
+ ...(text.length && items.length ? { format: text.length } : {}) } ;
1566
1660
  }
1567
1661
  return body;
1568
1662
  }
1569
1663
 
1664
+ /** A version-1 to -3 private filter set (#615) as a version-4 one that runs
1665
+ on the side of the format its link sits: each filter its verb, in its
1666
+ order, and no marker -- a private set held one stage's filters. A current
1667
+ body comes back as it was. */
1668
+ function ownBodyV4(own ) {
1669
+ if (!isObj(own) || ![1, 2, 3].includes(own.version )) return own;
1670
+ const { stage: _stage, ...rest } = own;
1671
+ return { ...rest, version: FILTER_SET_VERSION, ...(Array.isArray(rest.filters) ? { filters: rest.filters.map(filterV4) } : {}) };
1672
+ }
1673
+
1570
1674
  /** A filter set with no filters, at this version: what New filter set starts
1571
1675
  from, and what a lab with none holds. */
1572
1676
  function blankFilterSetBody() {
@@ -1581,7 +1685,16 @@ function filterSetBodyProblems(body , at = "the filter set") {
1581
1685
  if (body.version !== FILTER_SET_VERSION) {
1582
1686
  return [`${at} is version ${JSON.stringify(body.version)}, and the lab reads version ${FILTER_SET_VERSION}`];
1583
1687
  }
1584
- return filterProblems(body.filters, at);
1688
+ return [...filterProblems(body.filters, at),
1689
+ ...formatProblems(body.format, Array.isArray(body.filters) ? body.filters.length : 0, at)];
1690
+ }
1691
+
1692
+ /** Why [format] is not a place among [n] entries the Response Format can
1693
+ read at (#615): a whole number from 0 to [n], or none. */
1694
+ function formatProblems(format , n , at ) {
1695
+ if (format == null) return [];
1696
+ return Number.isInteger(format) && (format ) >= 0 && (format ) <= n
1697
+ ? [] : [`${at}: Response Format has to sit among its filters`];
1585
1698
  }
1586
1699
 
1587
1700
  /** Why [link] is not a filter-set link a run's Responses step may carry, one
@@ -2123,7 +2236,7 @@ function ollamaType(id , label , cloud ) {
2123
2236
  description: cloud ? "Ollama's cloud at ollama.com, with a key." : "Ollama on a machine of your own; it takes no key.",
2124
2237
  settings: [
2125
2238
  { key: "seed", label: "Seed", control: "text", default: "", hint: "" },
2126
- { key: "nPredict", label: "Reply tokens", control: "text", default: "", hint: "" },
2239
+ { key: "nPredict", label: "Response tokens", control: "text", default: "", hint: "" },
2127
2240
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "" },
2128
2241
  { key: "repeatPenalty", label: "Repeat penalty", control: "text", default: "", hint: "" },
2129
2242
  ],
@@ -2176,7 +2289,7 @@ const sendsNothing = () => { throw new Error("Echo answers from the item
2176
2289
  // stays so a stored run, or a profile made before, still reads.
2177
2290
  CONNECTION_TYPES.echo = {
2178
2291
  id: "echo", label: "Echo", settings: [], keyless: true, picker: false,
2179
- description: "No model: answers with each text item itself, to grade replies recorded earlier.",
2292
+ description: "No model: answers with each text item itself, to grade responses recorded earlier.",
2180
2293
  local: (item, sent) => item.text ?? sent,
2181
2294
  request: sendsNothing, parseReply: sendsNothing, listModels: sendsNothing, parseModels: () => [],
2182
2295
  };
@@ -2196,7 +2309,7 @@ function recordedRaw(record ) {
2196
2309
  // the flow reads a reply; `local` is the no-Read-as fallback.
2197
2310
  CONNECTION_TYPES.recorded = {
2198
2311
  id: "recorded", label: "Recorded", settings: [], keyless: true, picker: false, replays: true,
2199
- description: "Answers each call with the reply it got when it was recorded. Sends nothing.",
2312
+ description: "Answers each item with the response it got when it was recorded. Sends nothing.",
2200
2313
  local: (_item, _sent, record) => recordedRaw(record),
2201
2314
  request: sendsNothing, parseReply: sendsNothing, listModels: sendsNothing, parseModels: () => [],
2202
2315
  };
@@ -2240,7 +2353,7 @@ CONNECTION_TYPES["openai-compatible"] = {
2240
2353
  // A reasoning model refuses a temperature.
2241
2354
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "",
2242
2355
  appliesTo: (conn) => !/^(gpt-5|o\d)/i.test(String(conn.model ?? "")) },
2243
- { key: "nPredict", label: "Reply tokens", control: "text", default: "", hint: "" },
2356
+ { key: "nPredict", label: "Response tokens", control: "text", default: "", hint: "" },
2244
2357
  ],
2245
2358
  // /v1/chat/completions, covering OpenAI, OpenRouter, vLLM, LM Studio.
2246
2359
  // max_completion_tokens stays with this type, as the hosted providers
@@ -2284,7 +2397,7 @@ CONNECTION_TYPES.anthropic = {
2284
2397
  settings: [
2285
2398
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "",
2286
2399
  appliesTo: (conn) => !CLAUDE_FIXED_SAMPLING.test(String(conn.model ?? "")) },
2287
- { key: "maxTokens", label: "Reply tokens", control: "text", default: "", hint: "" },
2400
+ { key: "maxTokens", label: "Response tokens", control: "text", default: "", hint: "" },
2288
2401
  ],
2289
2402
  // Asked words (a Prompt step, a grader) or sent a flow's whole request (an
2290
2403
  // HTTP Request step): one profile and one key do both.
@@ -2349,7 +2462,7 @@ CONNECTION_TYPES["llama.cpp"] = {
2349
2462
  settings: [
2350
2463
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "" },
2351
2464
  { key: "seed", label: "Seed", control: "text", default: "", hint: "" },
2352
- { key: "nPredict", label: "Reply tokens", control: "text", default: "", hint: "" },
2465
+ { key: "nPredict", label: "Response tokens", control: "text", default: "", hint: "" },
2353
2466
  { key: "repeatPenalty", label: "Repeat penalty", control: "text", default: "", hint: "" },
2354
2467
  { key: "frequencyPenalty", label: "Frequency penalty", control: "text", default: "", hint: "" },
2355
2468
  { key: "presencePenalty", label: "Presence penalty", control: "text", default: "", hint: "" },
@@ -2363,7 +2476,7 @@ CONNECTION_TYPES["llama.cpp"] = {
2363
2476
  choices: [{ value: "on", label: "on" }, { value: "off", label: "off" }], hint: "" },
2364
2477
  { key: "thinking", label: "Thinking", control: "select", default: "off",
2365
2478
  choices: [{ value: "on", label: "on" }, { value: "off", label: "off" }], hint: "" },
2366
- { key: "rejectLoops", label: "Reject looping replies", control: "select", default: "off",
2479
+ { key: "rejectLoops", label: "Reject looping responses", control: "select", default: "off",
2367
2480
  choices: [{ value: "on", label: "on" }, { value: "off", label: "off" }], hint: "" },
2368
2481
  ],
2369
2482
  request(conn, prompt, dataUrl, base, key) {
@@ -3093,7 +3206,7 @@ function singleRules(test ) {
3093
3206
  }
3094
3207
 
3095
3208
  /** "3 of 12 replies", or "the reply" where there was only one. */
3096
- const ofReplies = (k , n ) => (n === 1 ? "the reply" : `${k} of ${n} replies`);
3209
+ const ofReplies = (k , n ) => (n === 1 ? "the response" : `${k} of ${n} responses`);
3097
3210
  const quoted = (s ) => `"${s.trim().slice(0, 60)}"`;
3098
3211
 
3099
3212
  /**
@@ -3214,7 +3327,7 @@ function jobProblem(stages , call
3214
3327
  const kind = OUTPUT_KINDS[kinds[from] ] ;
3215
3328
  if (sub && !(kind.offers || []).includes(sub)) {
3216
3329
  return `and stage ${from + 1} answers with ${kind.noun || kinds[from]}, which has no ${sub} — `
3217
- + `{${m ? `stage${from + 1}` : "reply"}} is its reply`;
3330
+ + `{${m ? `stage${from + 1}` : "reply"}} is its response`;
3218
3331
  }
3219
3332
  return null;
3220
3333
  };
@@ -3324,30 +3437,20 @@ async function runPipeline(stages , dataUrl , cal
3324
3437
  // and that answer is graded as though the job had worked.
3325
3438
  const handsOn = i < stages.length - 1;
3326
3439
  const against = res.readAgainst ?? echo;
3327
- // The Pre-format (text) filters shape the raw reply before the kind
3328
- // reads it (#405): a Rewrite text reshapes it, a Reject text ends the job.
3440
+ // The filters above the format shape the raw reply before the kind
3441
+ // reads it (#405, #615): they reshape it, or a Reject ends the job.
3329
3442
  const pre = applyFilterSet(st.before || [], { body: res.raw ?? "", items: [], instruction: against }, "text");
3330
- const by = (filter , stage , rejected ) => ({ filter, stage, job: i + 1, rejected });
3331
- if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, "text", true) });
3443
+ const by = (filter , rejected ) => ({ filter, job: i + 1, rejected });
3444
+ if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, true) });
3332
3445
  if (pre.rewrote) transcript[transcript.length - 1] .filtered = { got: pre.body, by: pre.rewrote };
3333
3446
  const read = reader.read(pre.body, { echo: against, parsing: opts.parsing, settings: st.settings });
3334
3447
  if (read.error) return result({ error: named(read.error) });
3335
3448
  const mod = applyModifiers(mods[i], kind, read.value, { instruction: against, body: read.body ?? res.raw ?? "" });
3336
3449
  if (mod.reject) {
3337
- return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, "items", true) } : {}) });
3338
- }
3339
- // A kind that reads no items runs its Post-format filters on its text,
3340
- // once read (#557); what they leave is what is graded.
3341
- if (st.after?.length && typeof mod.value === "string") {
3342
- const post = applyFilterSet(st.after, { body: mod.value, items: [], instruction: against }, "text");
3343
- if (post.reject != null) return result({ error: named(`discarded: ${post.reject}`), filteredBy: by(post.reject, "items", true) });
3344
- if (post.rewrote) {
3345
- transcript[transcript.length - 1] .filtered = { got: post.body, by: [...(pre.rewrote || []), ...post.rewrote] };
3346
- mod.value = post.body;
3347
- }
3450
+ return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, true) } : {}) });
3348
3451
  }
3349
3452
  // A filter that dropped the last item: what a zero result is explained by.
3350
- const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, "items", false) } : {};
3453
+ const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, false) } : {};
3351
3454
  const nothing = handsOn ? reader.handsOn?.(mod.value) : null;
3352
3455
  if (nothing) return result({ error: named(nothing), ...emptied });
3353
3456
  const value = mod.value;
@@ -3377,8 +3480,8 @@ function readList(reply , out , instruction =
3377
3480
  const reader = OUTPUT_KINDS[out.kind];
3378
3481
  if (!reader) return { items: [], dropped: [], echoed: 0, error: `"${out.kind}" is not an output kind this lab has` };
3379
3482
  const { kind, modifiers, before, ...settings } = out;
3380
- // The Pre-format (text) filters shape the raw reply before the kind
3381
- // reads it (#405); a Reject text ends the job.
3483
+ // The filters above the format shape the raw reply before the kind reads
3484
+ // it (#405, #615); a Reject ends the job.
3382
3485
  const pre = applyFilterSet(before || [], { body: reply, items: [], instruction }, "text");
3383
3486
  if (pre.reject != null) return { items: [], dropped: [], echoed: 0, error: `discarded: ${pre.reject}` };
3384
3487
  const read = reader.read(pre.body, { echo: instruction, settings });
@@ -3456,7 +3559,11 @@ function applyModifiers (list , kind ,
3456
3559
  // 16: a target names an AI Connection and, optionally, a Connection Profile,
3457
3560
  // where it named one Target profile; a run's table is keyed by the pair
3458
3561
  // (answerKey) -- the Target profile split in the store as it opens (#445).
3459
- const PIPELINE_VERSION = 18 ;
3562
+ // 17: a request reads by its job's HTTP API; 18: a saved-mode Source content
3563
+ // pins one response (#596).
3564
+ // 19: a job's filters are one list, run in order around its Response Format,
3565
+ // and filters are seven verbs (#615).
3566
+ const PIPELINE_VERSION = 20 ;
3460
3567
 
3461
3568
  // Plain objects, so an entry is added by assignment and a reader never needs
3462
3569
  // to know which registered it.
@@ -3576,9 +3683,9 @@ const clone = (v ) => JSON.parse(JSON.stringify(v));
3576
3683
  // A reply read as itself: anything at all, handed on untouched.
3577
3684
  OUTPUT_KINDS.text = {
3578
3685
  label: "Plain text", noun: "text", offers: [],
3579
- description: "Keeps the reply as it came back, whole.",
3686
+ description: "Keeps the response as it came back, whole.",
3580
3687
  read: reply => ({ value: String(reply ?? "") }),
3581
- handsOn: (value ) => (value.trim() ? null : "empty reply"),
3688
+ handsOn: (value ) => (value.trim() ? null : "empty response"),
3582
3689
  } ;
3583
3690
 
3584
3691
  // ---- the connection a profile reference resolves to ------------------------
@@ -3689,6 +3796,28 @@ function connectionOf(p ) {
3689
3796
  return out;
3690
3797
  }
3691
3798
 
3799
+ /** The settings a Prompt Request may hold of its own (#672): any connection
3800
+ type's, but those that say how it is reached and the model, its own field. */
3801
+ const requestSettingKeys = () => [...new Set(Object.values(CONNECTION_TYPES)
3802
+ .flatMap(t => t.settings.filter(s => s.scope !== "connection" && s.key !== "model").map(s => s.key)))];
3803
+
3804
+ /** [conn] with a Request's own settings over it, as connectionOf carries
3805
+ them: a preset kept on a Variant (#672). One left blank is not sent, so a
3806
+ preset clears what it does not set, as Apply preset does a profile's. */
3807
+ function withOwnSettings (conn , own ) {
3808
+ if (!own || !Object.keys(own).length) return conn;
3809
+ const out = { ...conn };
3810
+ const options = { ...(conn.options || {}) };
3811
+ for (const [k, v] of Object.entries(own)) {
3812
+ const set = String(v ?? "").trim() !== "";
3813
+ if (k === "temperature") { if (set) out.temperature = v; else delete out.temperature; }
3814
+ else if (set) options[k] = v;
3815
+ else delete options[k];
3816
+ }
3817
+ if (Object.keys(options).length) out.options = options; else delete out.options;
3818
+ return out;
3819
+ }
3820
+
3692
3821
  /** A run's connection as one flat set of settings, the shape a profile has. */
3693
3822
  function connectionSettings(c ) {
3694
3823
  const { options, ...rest } = c || {};
@@ -3771,7 +3900,7 @@ function answerOfKey(key )
3771
3900
 
3772
3901
  /** Whom target [i] asks in job [k]: its step's own connection or profile,
3773
3902
  where it names either, else the target's. */
3774
- function targetAnswerOf(doc , i , k ) {
3903
+ function targetAnswerOf(doc , i , k ) {
3775
3904
  const t = targetsOf(doc)[i];
3776
3905
  const st = targetStepOf(doc, i, k);
3777
3906
  const own = st?.connection != null || st?.profile != null;
@@ -3782,7 +3911,7 @@ function targetAnswerOf(doc
3782
3911
  }
3783
3912
 
3784
3913
  /** The answer key of target [i] in job [k], or undefined where it asks none. */
3785
- function targetAnswerKey(doc , i , k ) {
3914
+ function targetAnswerKey(doc , i , k ) {
3786
3915
  const a = targetAnswerOf(doc, i, k);
3787
3916
  return a ? answerKey(a.connection.id, a.profile?.id) : undefined;
3788
3917
  }
@@ -3863,9 +3992,9 @@ function encoderQuality(quality ) {
3863
3992
  // ---- labels -----------------------------------------------------------------
3864
3993
  // A name is optional everywhere; a blank one is the number the canvas has
3865
3994
  // always shown, so a sentence names what the reader sees.
3866
- const jobLabel = (doc , k ) =>
3867
- (doc.jobs?.[k]?.name || "").trim() || `Job ${k + 1}`;
3868
- const scenarioLabel = (doc , i ) => targetLabel(doc, i);
3995
+ const jobLabel = (doc , k ) =>
3996
+ (jobsOf(doc)[k]?.name || "").trim() || `Job ${k + 1}`;
3997
+ const scenarioLabel = (doc , i ) => targetLabel(doc, i);
3869
3998
  // The runner calls a job's link a stage; the model calls it a job.
3870
3999
  const jobWords = (s ) => s && String(s).replace(/\bstage[ ](\d+)/g, "job $1");
3871
4000
 
@@ -3876,8 +4005,8 @@ function onlyFields(obj , at , allowed , bad
3876
4005
  for (const k of Object.keys(obj)) {
3877
4006
  if (allowed.includes(k)) continue;
3878
4007
  bad.push(LOOKS_LIKE_A_KEY.test(k)
3879
- ? `${at} carries "${k}", and a key never goes in a pipeline — $${keyFrom || "EVALSLAB_API_KEY_<ID>"} supplies it`
3880
- : `${at} has "${k}", which is not a pipeline field`);
4008
+ ? `${at} carries "${k}", and a key never goes in an experiment — $${keyFrom || "EVALSLAB_API_KEY_<ID>"} supplies it`
4009
+ : `${at} has "${k}", which is not an experiment field`);
3881
4010
  }
3882
4011
  }
3883
4012
 
@@ -3910,9 +4039,9 @@ SOURCE_TYPES.workflow = {
3910
4039
  icon: "workflow",
3911
4040
  description: "A cloud flow, with records of its steps' calls from its run history.",
3912
4041
  group: "Workflows",
3913
- // Each item reads as a call, as the Source's Calls group names them, though
4042
+ // Each item reads as an item, as the Source's Items group names them, though
3914
4043
  // the file holding one is a record.
3915
- noun: "call",
4044
+ noun: "item",
3916
4045
  uploads: [".json"],
3917
4046
  sendsImage: false,
3918
4047
  hasActions: true,
@@ -3954,9 +4083,9 @@ WORKFLOW_PLATFORMS["power-automate"] = { // platform: the Power Automate adapt
3954
4083
  SOURCE_TYPES.dynamic = {
3955
4084
  label: "Dynamic",
3956
4085
  icon: "globe",
3957
- description: "Data produced on demand -- an HTTP GET, an API, a file or a command -- captured as Calls.",
4086
+ description: "Data produced on demand -- an HTTP GET, an API, a file or a command -- captured as Items.",
3958
4087
  group: "Dynamic",
3959
- noun: "call", // the generic default; the provider's own overrides it
4088
+ noun: "item", // the generic default; the provider's own overrides it
3960
4089
  uploads: [".txt", ".json", ".csv", ".md"],
3961
4090
  sendsImage: false,
3962
4091
  hasActions: false,
@@ -3965,7 +4094,7 @@ SOURCE_TYPES.dynamic = {
3965
4094
  };
3966
4095
 
3967
4096
  // HTTP GET (docs/dynamic-sources.md § 1): the safest pull provider. It fetches
3968
- // a URL through the relay server-side -- Fetch now keeps a response, Live
4097
+ // a URL through the relay server-side -- Get response keeps a response, Live
3969
4098
  // data fetches it again at submit -- so the page drives only the
3970
4099
  // create dialog and the Call labels; the fetch, redaction and token-refusal are
3971
4100
  // the server's (its SOURCE_PROVIDERS mirror in server.py).
@@ -3974,6 +4103,7 @@ DYNAMIC_PROVIDERS["http-get"] = { // provider: the HTTP GET adapter's own id
3974
4103
  label: "HTTP GET",
3975
4104
  icon: "square-code",
3976
4105
  noun: "response",
4106
+ produceIcon: "cloud-sync",
3977
4107
  tier: "pull",
3978
4108
  config: ["url"],
3979
4109
  configProblems(config){
@@ -4031,7 +4161,7 @@ DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
4031
4161
  // or an Upload… by hand, formalised as a provider. The lab fetches and executes
4032
4162
  // nothing: a Supplied item is named by the id the caller gives it (its file
4033
4163
  // name), which is what joins it to a case. It has no config and no server-side
4034
- // production, so no Fetch now and no live data; its Calls are the items handed in,
4164
+ // production, so no Get response and no live data; its Calls are the items handed in,
4035
4165
  // redacted on the way in like any other (server.py's take_dynamic). A hosted run
4036
4166
  // grades those Calls; a solely-Supplied Source with none is run only via the CLI.
4037
4167
  DYNAMIC_PROVIDERS["supplied"] = { // provider: the Supplied adapter's own id
@@ -4061,7 +4191,7 @@ DYNAMIC_PROVIDERS["file"] = { // provider: the File on disk adapter's own id
4061
4191
  label: "File on disk",
4062
4192
  icon: "file",
4063
4193
  noun: "file",
4064
- produceLabel: "Read now",
4194
+ produceIcon: "file-input",
4065
4195
  tier: "local",
4066
4196
  config: ["path"],
4067
4197
  configProblems(config){
@@ -4084,8 +4214,8 @@ DYNAMIC_PROVIDERS["command"] = { // provider: the Command adapter's own id
4084
4214
  id: "command", // provider: its id, the row's config.provider
4085
4215
  label: "Command",
4086
4216
  icon: "terminal",
4087
- noun: "call",
4088
- produceLabel: "Run now",
4217
+ noun: "item",
4218
+ produceIcon: "square-terminal",
4089
4219
  tier: "local",
4090
4220
  config: ["command", "args"],
4091
4221
  configProblems(config){
@@ -4232,8 +4362,8 @@ CONTENT_TYPES.text = {
4232
4362
  // model asked the prompt itself, or Echo answering with it, the prompt then
4233
4363
  // being the recorded reply.
4234
4364
  CONTENT_TYPES.prompt = {
4235
- label: "Prompt only",
4236
- description: "No item: each target's prompt is sent on its own.",
4365
+ label: "(No content)",
4366
+ description: "No item: each variant's prompt is sent on its own.",
4237
4367
  inline: true,
4238
4368
  bare: true,
4239
4369
  fields: ["type"],
@@ -4297,9 +4427,9 @@ LEGACY_TESTS.graded = {
4297
4427
  validate(t, ctx, bad){
4298
4428
  const d = t.dataset;
4299
4429
  if (!isRef(d) || (d.version != null && !isStr(d.version))) {
4300
- return void bad.push("a graded eval has to name its dataset as { id, name }");
4430
+ return void bad.push("a graded eval has to name its Library eval as { id, name }");
4301
4431
  }
4302
- if (ctx.groups && !ctx.groups.some(x => x.id === d.id)) bad.push(`Eval group ${d.name || d.id} not found`);
4432
+ if (ctx.groups && !ctx.groups.some(x => x.id === d.id)) bad.push(`Eval ${d.name || d.id} not found`);
4303
4433
  },
4304
4434
  };
4305
4435
 
@@ -4501,9 +4631,9 @@ function readRun(list , run ) {
4501
4631
  const off = each.filter(r => !r.pass);
4502
4632
  const first = each[0];
4503
4633
  if (!first) return [{ type: m.type, label: METRICS[m.type]?.label ?? m.type, weight: typeof m.weight === "number" ? m.weight : 1,
4504
- pass: true, score: 1, reason: "no replies" }];
4634
+ pass: true, score: 1, reason: "no responses" }];
4505
4635
  return [{ ...first, pass: !off.length, score: each.length ? (each.length - off.length) / each.length : 1,
4506
- reason: off.length ? `${off.length} of ${each.length} replies: ${off[0] .reason}` : `all ${each.length} replies` }];
4636
+ reason: off.length ? `${off.length} of ${each.length} responses: ${off[0] .reason}` : `all ${each.length} responses` }];
4507
4637
  });
4508
4638
  }
4509
4639
 
@@ -4512,7 +4642,7 @@ function readRun(list , run ) {
4512
4642
  // pipeline-check.js hold the eval group to.
4513
4643
  LEGACY_TESTS.metrics = {
4514
4644
  label: "Metrics",
4515
- description: "Checks of each reply, or of the whole run, scored all-must-pass or in weighted points.",
4645
+ description: "Checks of each response, or of the whole run, scored all-metrics-pass or in points.",
4516
4646
  fields: ["type", "metrics", "mode", "threshold", "grader", "dataset", "over"],
4517
4647
  // A metric that reads terms (a case) reads only a kind that yields them.
4518
4648
  accepts: t => (Array.isArray(t.metrics) && t.metrics.some((m ) => METRICS[m?.type]?.needsTerms)
@@ -4527,8 +4657,8 @@ LEGACY_TESTS.metrics = {
4527
4657
  }
4528
4658
  // The dataset whose cases a case metric reads, and whose own metrics join.
4529
4659
  if (t.dataset != null) {
4530
- if (!isRef(t.dataset) || (t.dataset.version != null && !isStr(t.dataset.version))) bad.push("the Metrics name their dataset as { id, name }");
4531
- else if (ctx.groups && !ctx.groups.some(x => x.id === t.dataset.id)) bad.push(`Eval group ${t.dataset.name || t.dataset.id} not found`);
4660
+ if (!isRef(t.dataset) || (t.dataset.version != null && !isStr(t.dataset.version))) bad.push("the Metrics name their eval as { id, name }");
4661
+ else if (ctx.groups && !ctx.groups.some(x => x.id === t.dataset.id)) bad.push(`Eval ${t.dataset.name || t.dataset.id} not found`);
4532
4662
  }
4533
4663
  if (!SCORING_MODES.includes(t.mode)) bad.push(`the Metrics are scored ${SCORING_MODES.join(" or ")}`);
4534
4664
  if (t.mode === "weighted" && typeof t.threshold !== "number") bad.push("weighted Metrics need a threshold: the points an item has to reach");
@@ -4620,8 +4750,8 @@ const wholeRunGroup = (t ) => isObj(t.own) && Array.isArray(t
4620
4750
  // group does (readGroup), or -- a private group of Whole run metrics alone --
4621
4751
  // the whole run (readGroupRun).
4622
4752
  EVAL_TYPES.group = {
4623
- label: "Eval group",
4624
- description: "Checks of each reply, or of the whole run, from an eval group: linked from the Library, or this pipeline's own.",
4753
+ label: "Eval",
4754
+ description: "Checks of each response, or of the whole run, from an eval: linked from the Library, or this experiment's own.",
4625
4755
  fields: ["type", "group", "pin", "own", "grader"],
4626
4756
  // A metric that reads terms (a case) reads only a kind that yields them.
4627
4757
  accepts: t => ([...ownList(t)].some((m ) => METRICS[m?.type]?.needsTerms)
@@ -4634,17 +4764,17 @@ EVAL_TYPES.group = {
4634
4764
  const named = (ref , what ) => {
4635
4765
  const n = isObj(ref) ? ref.n : undefined;
4636
4766
  if (!isRef(ref) || (ref.version != null && !isStr(ref.version)) || (n != null && !Number.isInteger(n))) {
4637
- return void bad.push(`${what} names its eval group as { id, name }`);
4767
+ return void bad.push(`${what} names its eval as { id, name }`);
4638
4768
  }
4639
4769
  onlyFields(ref, what, ["id", "name", "version", "n"], bad);
4640
4770
  const held = ctx.groups?.find(x => x.id === ref.id);
4641
- if (ctx.groups && !held) bad.push(`Eval group ${ref.name || ref.id} not found`);
4771
+ if (ctx.groups && !held) bad.push(`Eval ${ref.name || ref.id} not found`);
4642
4772
  return held;
4643
4773
  };
4644
- if (t.pin != null && !(Number.isInteger(t.pin) && t.pin > 0)) bad.push("a pin is a group's version: a whole number from 1");
4774
+ if (t.pin != null && !(Number.isInteger(t.pin) && t.pin > 0)) bad.push("a pin is an eval's version: a whole number from 1");
4645
4775
  if (t.grader != null && !isRef(t.grader)) bad.push("the link names its grader as { id, name }");
4646
4776
  if (g != null) {
4647
- if (own != null) return void bad.push("an eval links a Library group or holds its own, not both");
4777
+ if (own != null) return void bad.push("an eval links a Library eval or holds its own, not both");
4648
4778
  const held = named(g, "the link");
4649
4779
  if (held && t.pin != null && typeof held.versions === "number" && t.pin > held.versions) {
4650
4780
  bad.push(`${g.name || g.id} has no version ${t.pin}`);
@@ -4656,17 +4786,17 @@ EVAL_TYPES.group = {
4656
4786
  graderProblems(grader, ctx, bad);
4657
4787
  return;
4658
4788
  }
4659
- if (!isObj(own)) return void bad.push("an eval links a Library group, or holds its own");
4660
- if (t.pin != null) bad.push("a group of this pipeline's own versions with it, so it is never pinned");
4661
- if (own.version !== DATASET_BODY_VERSION) bad.push(`the group's own body is version ${DATASET_BODY_VERSION}`);
4662
- for (const key of ["scoring", "every", "run", "cases"]) if (own[key] == null) bad.push(`the group's own body has no ${key}`);
4663
- onlyFields(own, "the group's own body", ["version", "scoring", "grader", "every", "run", "cases", "casesFrom"], bad);
4789
+ if (!isObj(own)) return void bad.push("an eval links a Library eval, or holds its own");
4790
+ if (t.pin != null) bad.push("an eval of this experiment's own versions with it, so it is never pinned");
4791
+ if (own.version !== DATASET_BODY_VERSION) bad.push(`the eval's own body is version ${DATASET_BODY_VERSION}`);
4792
+ for (const key of ["scoring", "every", "run", "cases"]) if (own[key] == null) bad.push(`the eval's own body has no ${key}`);
4793
+ onlyFields(own, "the eval's own body", ["version", "scoring", "grader", "every", "run", "cases", "casesFrom"], bad);
4664
4794
  if (bad.length) return;
4665
4795
  bad.push(...validateEvals(own));
4666
- if (own.casesFrom != null) named(own.casesFrom, "Cases from");
4796
+ if (own.casesFrom != null) named(own.casesFrom, "Eval cases from");
4667
4797
  // Read item by item or over the run: one section at a time, until a
4668
4798
  // group's Whole run is reported beside its items (#233).
4669
- if (own.run.length && !wholeRunGroup(t)) bad.push("the group's own body reads each item or the whole run, not both");
4799
+ if (own.run.length && !wholeRunGroup(t)) bad.push("the eval's own body reads each item or the whole run, not both");
4670
4800
  // The group's own grader, or the lab's.
4671
4801
  const grader = isRef(own.grader) ? own.grader : isRef(ctx.grader) ? ctx.grader : null;
4672
4802
  if (gradedIn(own.every) && !grader) bad.push("a model-graded metric needs a grader: pick one on the eval, or make a connection the lab's grader");
@@ -4731,8 +4861,8 @@ function caseIn(body , item ) {
4731
4861
  // ignoring case, as the Single Test did.
4732
4862
  METRICS["has-items"] = {
4733
4863
  label: "Has items",
4734
- family: "The reply's text",
4735
- description: "Passes when the reply holds all, or any, of the items listed.",
4864
+ family: "The response's text",
4865
+ description: "Passes when the response holds all, or any, of the items listed.",
4736
4866
  options: [{ key: "values", label: "Items", type: "textarea" },
4737
4867
  { key: "need", label: "Needs", type: "select", choices: [{ value: "all", label: "all" }, { value: "any", label: "any" }] }],
4738
4868
  defaults: () => ({ values: "", need: "all" }),
@@ -4760,8 +4890,8 @@ const parseCountOf = (m ) =>
4760
4890
  // A reply's length in characters, once trimmed.
4761
4891
  METRICS.length = {
4762
4892
  label: "Length",
4763
- family: "The reply's text",
4764
- description: "Compares the reply's length in characters.",
4893
+ family: "The response's text",
4894
+ description: "Compares the response's length in characters.",
4765
4895
  options: [{ key: "op", label: "Is", type: "select", choices: LENGTH_OPS.map(([value, label]) => ({ value, label })) },
4766
4896
  { key: "n", label: "Characters", type: "number" }],
4767
4897
  defaults: () => ({ op: "exactly", n: 0 }),
@@ -4781,7 +4911,7 @@ METRICS.length = {
4781
4911
  METRICS["parse-count"] = {
4782
4912
  label: "Parses",
4783
4913
  family: "The result",
4784
- description: "Passes when the reply reads as the format chosen, holding the number of results set.",
4914
+ description: "Passes when the response reads as the format chosen, holding the number of results set.",
4785
4915
  // Unformatted is one result a reply, as the Single Test counted it.
4786
4916
  options: [{ key: "parse", label: "As", type: "select", choices: [{ value: "", label: "Unformatted" }, { value: "csv", label: "csv" },
4787
4917
  { value: "json", label: "json" }, { value: "xml", label: "xml" }] },
@@ -4934,11 +5064,11 @@ function metricCheck(m ) {
4934
5064
  // Attach Content: the items a run goes over, job 1's first step.
4935
5065
  STEP_TYPES.attachContent = {
4936
5066
  label: "Attach Content", slot: "content", rank: 0,
4937
- description: "The items the run goes through: each is sent to every target.",
5067
+ description: "The items the run goes through: each is sent to every variant.",
4938
5068
  in: null, out: "items",
4939
5069
  apply: "itemContent", // run-evals.js, through its file-type registry
4940
5070
  fields: ["type", "content"],
4941
- firstJobOnly: "only job 1 attaches content -- a later job is handed the reply before it",
5071
+ firstJobOnly: "only job 1 attaches content -- a later job is handed the response before it",
4942
5072
  validate(step, ctx, bad){
4943
5073
  const c = step?.content;
4944
5074
  if (!c) return void bad.push("set the content first");
@@ -4998,12 +5128,21 @@ STEP_TYPES.tokenMappings = {
4998
5128
  // Prompt: a target's words, asked of its model.
4999
5129
  STEP_TYPES.prompt = {
5000
5130
  label: "Prompt", slot: "target", asks: "prompt", modelFrom: "profile",
5001
- description: "Asks each target's model its prompt about the item.",
5131
+ description: "Asks each variant's model its prompt about the item.",
5002
5132
  in: "item", out: "text",
5003
5133
  apply: "runPipeline",
5004
- fields: ["type", "prompt", "connection", "profile", "from"],
5134
+ // A model and settings of its own go over its connection's (stagesFor):
5135
+ // a preset kept on a Variant (#672).
5136
+ fields: ["type", "prompt", "connection", "profile", "from", "model", "settings"],
5005
5137
  validate(step, _ctx, bad, at){
5006
5138
  if (!isStr(step.prompt)) bad.push(`${at}: prompt has to be text`);
5139
+ if (step.model != null && !isStr(step.model)) bad.push(`${at}: model has to be text`);
5140
+ if (step.settings != null) {
5141
+ if (!isObj(step.settings) || !Object.values(step.settings).every(isStr)) bad.push(`${at}: settings are names to text`);
5142
+ else for (const k of Object.keys(step.settings)) {
5143
+ if (!requestSettingKeys().includes(k)) bad.push(`${at}: no connection type has a setting ${k}`);
5144
+ }
5145
+ }
5007
5146
  },
5008
5147
  };
5009
5148
 
@@ -5025,7 +5164,7 @@ function keyField(v , at = "body") {
5025
5164
  }
5026
5165
  STEP_TYPES.httpRequest = {
5027
5166
  label: "HTTP Request", slot: "target", asks: "request", verbatim: true, modelFrom: "step",
5028
- description: "Sends a Power Automate step's own request, rebuilt from each record, with each target's words in it.",
5167
+ description: "Sends a Power Automate step's own request, rebuilt from each record, with each variant's words in it.",
5029
5168
  firstJobOnly: "an HTTP Request is sent from its item's record, so it is job 1's",
5030
5169
  // A new target starts from the flow's own words and fields, read by the
5031
5170
  // job's HTTP API ([step] carries it: requestStepOf).
@@ -5046,7 +5185,7 @@ STEP_TYPES.httpRequest = {
5046
5185
  // A key is the Setup profile's, sent by its Key header; one in the
5047
5186
  // template would be kept in the pipeline and every run of it.
5048
5187
  const hit = keyField(step.body) ?? keyField(step.query, "query");
5049
- if (hit) bad.push(`${at} carries a key at ${hit}, and a key never goes in a pipeline -- the connection sends it`);
5188
+ if (hit) bad.push(`${at} carries a key at ${hit}, and a key never goes in an experiment -- the connection sends it`);
5050
5189
  if (!api) return;
5051
5190
  // Its fields, where its HTTP API has a place for them.
5052
5191
  for (const f of ["system", "model", "prefill"] ) {
@@ -5077,7 +5216,7 @@ STEP_TYPES.httpRequest = {
5077
5216
  // against; over Prompt only they are the reply.
5078
5217
  STEP_TYPES.echo = {
5079
5218
  label: "Echo", slot: "target", asks: "nothing", local: true, replaces: "echo",
5080
- description: "Sends nothing: each text item is its own reply, to grade replies recorded earlier.",
5219
+ description: "Sends nothing: each text item is its own response, to grade responses recorded earlier.",
5081
5220
  firstJobOnly: "Echo answers only job 1, with the item's own text",
5082
5221
  in: "item", out: "text",
5083
5222
  apply: "runPipeline",
@@ -5091,7 +5230,7 @@ STEP_TYPES.echo = {
5091
5230
  // the flow reads one. No profile, no prompt -- the record is the reply.
5092
5231
  STEP_TYPES.recorded = {
5093
5232
  label: "Recorded", slot: "target", asks: "nothing", local: true, replaces: "recorded",
5094
- firstJobOnly: "a call's recorded reply is job 1's",
5233
+ firstJobOnly: "an item's recorded response is job 1's",
5095
5234
  in: "item", out: "text",
5096
5235
  apply: "runPipeline",
5097
5236
  // `prompt` only so the step satisfies the target-step shape; it is never
@@ -5116,7 +5255,7 @@ const localConnOf = (entry )
5116
5255
  // Read as: what the flow does to the reply before it reads it.
5117
5256
  STEP_TYPES.readAs = {
5118
5257
  label: "Read as", slot: "responses", rank: 0,
5119
- description: "What the flow makes of the reply before it reads it.",
5258
+ description: "What the flow makes of the response before it reads it.",
5120
5259
  in: "text", out: "text",
5121
5260
  apply: "runPipeline",
5122
5261
  fields: ["type", "readAs"],
@@ -5127,8 +5266,8 @@ STEP_TYPES.readAs = {
5127
5266
 
5128
5267
  // Response Format Validation: the kind a reply is read as.
5129
5268
  STEP_TYPES.readReply = {
5130
- label: "Read Reply", slot: "responses", rank: 1,
5131
- description: "How the reply is read before evals and later jobs see it.",
5269
+ label: "Read Response", slot: "responses", rank: 1,
5270
+ description: "How the response is read before evals and later jobs see it.",
5132
5271
  in: "text", out: step => step.out?.kind,
5133
5272
  // Read inside the stage: runPipeline parses each reply as it comes back.
5134
5273
  apply: "runPipeline",
@@ -5147,7 +5286,7 @@ STEP_TYPES.readReply = {
5147
5286
  // A modifier: one change to the parsed reply, after the kind that takes it.
5148
5287
  STEP_TYPES.modifier = {
5149
5288
  label: "Modifier", slot: "responses", rank: 2, many: true,
5150
- description: "Changes the reply after it is read.",
5289
+ description: "Changes the response after it is read.",
5151
5290
  in: "value", out: "value",
5152
5291
  apply: "runPipeline",
5153
5292
  fields: ["type", "modifier"],
@@ -5163,23 +5302,20 @@ STEP_TYPES.modifier = {
5163
5302
  };
5164
5303
 
5165
5304
  // A filters step: the filter sets a job cleans and gates its reply by, each a
5166
- // link or a private set (#339, #405), at the step's `stage`. A job may hold
5167
- // one of each stage -- a `text` one Pre-format, an `items` one Post-format -- so
5168
- // the type is `many`; two of a stage is the job's to refuse. A set belongs to
5169
- // no stage (#432): linked here, it runs the filters whose type fits the step's
5170
- // stage and skips the rest. A private set's body is the core's to judge
5305
+ // link or a private set (#339), in run order around the job's Response Format
5306
+ // (#615, `format`). A private set's body is the core's to judge
5171
5307
  // (filterSetBodyProblems); a link to a Library set is the server's to resolve,
5172
5308
  // so only its shape is checked here.
5173
5309
  STEP_TYPES.filters = {
5174
- label: "Filter sets", slot: "responses", rank: 2, many: true,
5175
- description: "Cleans and gates the reply with linked or private filter sets.",
5310
+ label: "Filter sets", slot: "responses", rank: 2,
5311
+ description: "Cleans and gates the response with linked or private filter sets.",
5176
5312
  in: "value", out: "value",
5177
5313
  apply: "runPipeline",
5178
- fields: ["type", "stage", "filterSets"],
5314
+ fields: ["type", "filterSets", "format"],
5179
5315
  validate(step, _ctx, bad, at){
5180
- if (step.stage !== "text" && step.stage !== "items") bad.push(`${at}: a filters step shapes the text or the items stage`);
5181
5316
  if (!Array.isArray(step.filterSets)) return void bad.push(`${at}: filterSets has to be a list`);
5182
5317
  for (const link of step.filterSets ) bad.push(...filterSetLinkProblems(link, at));
5318
+ bad.push(...formatProblems(step.format, step.filterSets.length, at));
5183
5319
  },
5184
5320
  };
5185
5321
 
@@ -5220,17 +5356,263 @@ function withStep(job , type , step ) {
5220
5356
  return { ...job, steps: ordered(step ? [...steps, step] : steps) };
5221
5357
  }
5222
5358
 
5223
- /** A pipeline's content: job 1's content step, or none. */
5224
- function contentOf(doc ) {
5225
- return stepOf (doc?.jobs?.[0], "attachContent")?.content ?? null;
5359
+ // ---- the flat step list and Variants: derive jobs/targets, serialise back ----
5360
+ // Version 20 stores a pipeline as its step list and Variants; every reader
5361
+ // reads the version-19 jobs and targets jobsOf / targetsOf derive, and every
5362
+ // edit is serialised back by withJobs / withTargets. The encoding is a
5363
+ // verbatim re-packaging, so the round-trip is byte-exact and stagesFor gives
5364
+ // the same stages (pipeline-model §20, #669). A doc still carrying `jobs` or
5365
+ // `targets` -- the shape upgradePipeline's chain builds before it serialises,
5366
+ // and any legacy caller -- is read through as it is.
5367
+
5368
+ /** What a pipeline/run reader takes: the version-20 step list and Variants,
5369
+ or the jobs-and-targets intermediate the upgrade builds before it
5370
+ serialises -- the accessors read whichever is present. */
5371
+
5372
+
5373
+
5374
+ const isGetItems = (st ) => isObj(st) && st.type === "getItems";
5375
+ const isRequest = (st ) => isObj(st) && st.type === "request";
5376
+ const isReadResponse = (st ) => isObj(st) && st.type === "readResponse";
5377
+ const isFilterV20 = (st ) => isObj(st) && st.type === "filter";
5378
+
5379
+ /** Whether two JSON values say the same thing: object key order is ignored
5380
+ and a key holding `undefined` counts as absent, as JSON writes them;
5381
+ array order counts. */
5382
+ function sameJson(a , b ) {
5383
+ if (a === b) return true;
5384
+ if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((v, i) => sameJson(v, b[i]));
5385
+ if (!isObj(a) || !isObj(b)) return false;
5386
+ const keys = (o ) => Object.keys(o).filter(k => o[k] !== undefined);
5387
+ const ka = keys(a);
5388
+ return ka.length === keys(b).length && ka.every(k => b[k] !== undefined && sameJson(a[k], b[k]));
5389
+ }
5390
+
5391
+ /** The override to carry Variant [cur] over the Request's base [base], or
5392
+ none where they are the same. A different step type, or a different set of
5393
+ fields, is carried whole (it holds `type`, so mergeCell replaces); a cell
5394
+ of the same shape carries only the fields whose value differs. */
5395
+ function cellDiff(base , cur ) {
5396
+ if (!cur) return undefined;
5397
+ if (!base) return clone(cur);
5398
+ const bk = Object.keys(base).sort().join("\u0000"), ck = Object.keys(cur).sort().join("\u0000");
5399
+ if (cur.type !== base.type || bk !== ck) return clone(cur);
5400
+ const ov = {};
5401
+ for (const key of Object.keys(cur)) if (key !== "type" && !sameJson((cur )[key], (base )[key])) ov[key] = clone((cur )[key]);
5402
+ return Object.keys(ov).length ? ov : undefined;
5403
+ }
5404
+
5405
+ /** A Variant's cell over a Request's base [base]: the base where there is no
5406
+ override, the override whole where it carries its own `type` (a different
5407
+ Answered by, or a different shape), else the base with the override's
5408
+ fields. */
5409
+ function mergeCell(base , ov ) {
5410
+ if (!ov) return clone(base ?? { type: "prompt", prompt: "" }) ;
5411
+ if ("type" in ov) return clone(ov) ;
5412
+ return { ...clone(base ?? { type: "prompt", prompt: "" }), ...clone(ov) } ;
5413
+ }
5414
+
5415
+ /** [jobs] and [targets] as a version-20 step list and Variants. Variant A's
5416
+ step in each job is the Request's base; a later Variant carries only its
5417
+ overrides. A verbatim re-packaging: jobsOf and targetsOf read it back whole.
5418
+ Given the list it replaces ([old]), the steps keep their order there and
5419
+ the steps the jobs do not hold (keepOrder). */
5420
+ function serialize(jobs , targets , old ) {
5421
+ const { steps, variants } = serializeFresh(jobs, targets);
5422
+ return { steps: keepOrder(old, steps), variants };
5423
+ }
5424
+
5425
+ /** [next], a freshly serialised step list, in the order [old] held its steps
5426
+ (§20): the person moves steps in the Steps list, and an edit made through
5427
+ the jobs view must not move them back. Each step of [old] takes its new
5428
+ version where [next] has one (its id kept): a Request by its id, a Read
5429
+ Response or Filter by the Request it reads, and the first Get items by
5430
+ being the first. A later Get items, which the jobs view does not hold, is
5431
+ kept as it is; and a Request already here with no Read Response gets
5432
+ none, though the jobs view reads it as plain text. A step new
5433
+ in [next] goes after the step before it there. With no [old], [next]. */
5434
+ function keepOrder(old , next ) {
5435
+ if (!Array.isArray(old)) return next;
5436
+ const key = (st ) => st.type === "request" ? `request:${st.id}`
5437
+ : st.type === "getItems" ? null : `${st.type}:${st.request}`;
5438
+ const byKey = new Map(next.flatMap(st => { const k = key(st); return k ? [[k, st] ] : []; }));
5439
+ const items = next.find(isGetItems);
5440
+ const empty = (st ) => st.type === "readResponse" && st.readAs == null && !st.out && !st.modifiers?.length;
5441
+ // Each step of [next] placed, by the step it became in the result.
5442
+ const placed = new Map ();
5443
+ const out = [];
5444
+ const firstItems = old.find(isGetItems);
5445
+ for (const st of old.filter((o) => isObj(o))) {
5446
+ if (isGetItems(st)) {
5447
+ if (st !== firstItems) out.push(st);
5448
+ else if (items) { const put = { ...items, id: st.id }; out.push(put); placed.set(items, put); }
5449
+ continue;
5450
+ }
5451
+ const k = key(st);
5452
+ const n = k ? byKey.get(k) : undefined;
5453
+ if (n && !placed.has(n)) { const put = { ...n, id: st.id }; out.push(put); placed.set(n, put); }
5454
+ }
5455
+ // A Read Response the jobs view reads as plain text, for a Request already
5456
+ // here that had none, stays away: the person removed it, or never added one.
5457
+ const hadRead = new Set(old.flatMap(st => isReadResponse(st) ? [st.request] : []));
5458
+ next.forEach((n, x) => {
5459
+ if (placed.has(n)) return;
5460
+ if (empty(n) && !hadRead.has((n ).request)
5461
+ && old.some(st => isRequest(st) && st.id === (n ).request)) return;
5462
+ let at = 0;
5463
+ for (let y = x - 1; y >= 0; y--) {
5464
+ const before = placed.get(next[y] );
5465
+ if (before) { at = out.indexOf(before) + 1; break; }
5466
+ }
5467
+ out.splice(at, 0, n);
5468
+ placed.set(n, n);
5469
+ });
5470
+ return out;
5471
+ }
5472
+
5473
+ function serializeFresh(jobs , targets ) {
5474
+ const steps = [];
5475
+ const content = stepOf (jobs[0], "attachContent")?.content ?? null;
5476
+ if (content) steps.push({ type: "getItems", id: `items-${jobs[0] .id}`, content });
5477
+ jobs.forEach((job, k) => {
5478
+ const base = targets[0]?.steps?.[k];
5479
+ const req = { type: "request", id: job.id, name: job.name,
5480
+ cell: base ? clone(base) : { type: "prompt", prompt: "" } };
5481
+ if (sendsImage(job)) req.image = true;
5482
+ const toks = tokensOf(job);
5483
+ if (toks.length) req.tokens = toks;
5484
+ const flow = flowStepOf(job);
5485
+ if (flow) req.flow = flow;
5486
+ steps.push(req);
5487
+ const read = { type: "readResponse", id: `read-${job.id}`, request: job.id };
5488
+ const readAs = readAsOf(job);
5489
+ if (readAs != null) read.readAs = readAs;
5490
+ const reply = replyOf(job);
5491
+ if (reply) read.out = reply.out;
5492
+ const mods = (job.steps || []).filter((st) => isObj(st) && st.type === "modifier").map(st => st.modifier);
5493
+ if (mods.length) read.modifiers = mods;
5494
+ steps.push(read);
5495
+ const filt = filterStepOf(job);
5496
+ if (filt) steps.push({ type: "filter", id: `filter-${job.id}`, request: job.id,
5497
+ filterSets: filt.filterSets, ...(filt.format != null ? { format: filt.format } : {}) });
5498
+ });
5499
+ const variants = targets.map((t, i) => {
5500
+ const overrides = {};
5501
+ if (i > 0) jobs.forEach((job, k) => {
5502
+ const ov = cellDiff(targets[0]?.steps?.[k], t.steps?.[k]);
5503
+ if (ov) overrides[job.id] = ov;
5504
+ });
5505
+ return { id: t.id, name: t.name, connection: t.connection ?? null, profile: t.profile ?? null, overrides };
5506
+ });
5507
+ return { steps, variants };
5508
+ }
5509
+
5510
+ /** The version-19 jobs a version-20 document holds: one per Request, its
5511
+ Content (job 1's Get items, the Request's image, tokens and flow) and its
5512
+ Responses (its Read Response and Filter) rebuilt from the step list. */
5513
+ function jobsOf(doc ) {
5514
+ if (Array.isArray(doc?.jobs)) return doc .jobs ;
5515
+ const all = Array.isArray(doc?.steps) ? doc .steps : [];
5516
+ const items = all.find(isGetItems);
5517
+ const requests = all.filter(isRequest);
5518
+ return requests.map((req, k) => {
5519
+ const read = all.find((st) => isReadResponse(st) && st.request === req.id);
5520
+ const filt = all.find((st) => isFilterV20(st) && st.request === req.id);
5521
+ const inner = [];
5522
+ if (k === 0 && items) inner.push({ type: "attachContent", content: items.content });
5523
+ if (req.flow) inner.push({ type: "flowStep", step: req.flow.step, loop: req.flow.loop ?? null, api: req.flow.api });
5524
+ if (req.image) inner.push({ type: "attachImage" });
5525
+ if (req.tokens && req.tokens.length) inner.push({ type: "tokenMappings", tokenMappings: req.tokens });
5526
+ if (read?.readAs != null) inner.push({ type: "readAs", readAs: read.readAs });
5527
+ if (read?.out) inner.push({ type: "readReply", out: read.out });
5528
+ for (const m of read?.modifiers ?? []) inner.push({ type: "modifier", modifier: m });
5529
+ if (filt) inner.push({ type: "filters", filterSets: filt.filterSets, ...(filt.format != null ? { format: filt.format } : {}) });
5530
+ return { type: "job", id: req.id, name: req.name, steps: ordered(inner) };
5531
+ });
5532
+ }
5533
+
5534
+ /** [doc] with its jobs set, serialised back into the step list beside its
5535
+ Variants (the targets left as they read now). */
5536
+ function withJobs (doc , jobs ) {
5537
+ return { ...doc, ...serialize(jobs, targetsOf(doc ), (doc ).steps) };
5538
+ }
5539
+
5540
+ /** [doc] with its targets set, serialised back into the Variants beside its
5541
+ step list (the jobs left as they read now). */
5542
+ function withTargets (doc , targets ) {
5543
+ return { ...doc, ...serialize(jobsOf(doc ), targets, (doc ).steps) };
5544
+ }
5545
+
5546
+ /** A pipeline's content: job 1's Get items step, or none. */
5547
+ function contentOf(doc ) {
5548
+ if (Array.isArray(doc?.jobs)) return stepOf (doc .jobs [0], "attachContent")?.content ?? null;
5549
+ return (Array.isArray(doc?.steps) ? doc .steps .find(isGetItems)?.content : null) ?? null;
5550
+ }
5551
+
5552
+ /** Every Get items step's content, in list order, with the step's id and the
5553
+ folder a run keeps its files under: the first at the top of the run's
5554
+ files, as a run over one Source always was, and each later one under
5555
+ `@N`, N its place among them -- a name no item can have, since an item's
5556
+ name starts with a letter, a digit or `_` (§20). A version-19 document's
5557
+ one content is the first. */
5558
+ function itemContents(doc ) {
5559
+ if (Array.isArray(doc?.jobs)) {
5560
+ const content = contentOf(doc);
5561
+ return content ? [{ step: "", content, dir: "" }] : [];
5562
+ }
5563
+ const steps = Array.isArray(doc?.steps) ? doc .steps : [];
5564
+ return steps.filter(isGetItems).map((st, k) => ({ step: st.id, content: st.content, dir: k ? `@${k + 1}` : "" }));
5226
5565
  }
5227
5566
 
5228
- /** [doc] with its content set -- job 1's content step added, replaced, or
5229
- taken away for null. */
5230
- function withContent (doc , content ) {
5231
- const [first, ...rest] = doc.jobs;
5567
+ /** One Get items step's content, as itemContents reads it. */
5568
+
5569
+
5570
+
5571
+
5572
+
5573
+
5574
+ /** One item a run goes over: a file of a Source (its name, under its step's
5575
+ folder), a text of the document's own, or the bare item a run with no
5576
+ content of its own goes over, its prompt alone. */
5577
+
5578
+
5579
+
5580
+
5581
+
5582
+
5583
+
5584
+
5585
+
5586
+
5587
+ /** The items [run] goes over, in order: each Get items step's in turn -- a
5588
+ Source's files (the run's `files`, stamped at submit) or a text -- so a
5589
+ second Get items adds more items, run through the same steps. A step
5590
+ with no item of its own (No content) adds none; a run where no step adds
5591
+ one goes over the bare item. */
5592
+ function runItemsOf(run ) {
5593
+ const out = [];
5594
+ const all = itemContents(run);
5595
+ for (const { step, content, dir } of all) {
5596
+ const type = isObj(content) ? CONTENT_TYPES[content.type] : undefined;
5597
+ if (content?.type === "source") {
5598
+ for (const name of content.files || []) out.push({ name, dir, step });
5599
+ } else if (type?.inline && !type.bare) {
5600
+ const text = type.text?.(content, {}) ?? "";
5601
+ if (text.trim()) out.push({ name: null, text, dir, step });
5602
+ }
5603
+ }
5604
+ const bare = all.find(x => isObj(x.content) && CONTENT_TYPES[x.content.type]?.bare);
5605
+ if (!out.length && bare) out.push({ name: null, bare: true, dir: "", step: bare.step });
5606
+ return out;
5607
+ }
5608
+
5609
+ /** [doc] with its content set -- job 1's Get items added, replaced, or taken
5610
+ away for null -- serialised back into the step list. */
5611
+ function withContent (doc , content ) {
5612
+ const jobs = jobsOf(doc );
5613
+ const [first, ...rest] = jobs;
5232
5614
  if (!first) return doc;
5233
- return { ...doc, jobs: [withStep(first, "attachContent", content ? { type: "attachContent", content } : null), ...rest] };
5615
+ return withJobs(doc, [withStep(first, "attachContent", content ? { type: "attachContent", content } : null), ...rest]);
5234
5616
  }
5235
5617
 
5236
5618
  /**
@@ -5241,7 +5623,7 @@ function withContent (doc , content )
5241
5623
  * only where a token places it, and an image or a document is sent as a part
5242
5624
  * of its own, so neither is joined. [ctx] reads a Source as validation does.
5243
5625
  */
5244
- function contentJoined(doc , i , k ,
5626
+ function contentJoined(doc , i , k ,
5245
5627
  ctx = {}) {
5246
5628
  if (k !== 0 || targetEntryOf(doc, i, k)?.asks !== "prompt") return false;
5247
5629
  if (String(targetStepOf(doc, i, k)?.prompt ?? "").includes("{text}")) return false;
@@ -5249,9 +5631,18 @@ function contentJoined(doc
5249
5631
  return !!content && (CONTENT_TYPES[content.type]?.text?.(content, ctx) ?? null) != null;
5250
5632
  }
5251
5633
 
5252
- /** A document's targets, in column order. */
5253
- function targetsOf(doc ) {
5254
- return Array.isArray(doc?.targets) ? doc .targets : [];
5634
+ /** A document's targets, in column order: each Variant as a column through
5635
+ the Request steps, its cell in each the Request's base with the Variant's
5636
+ override over it (version 20). A doc still carrying `targets` -- the shape
5637
+ the upgrade builds before it serialises -- is read as it is. */
5638
+ function targetsOf(doc ) {
5639
+ if (Array.isArray(doc?.targets)) return doc .targets ;
5640
+ const requests = (Array.isArray(doc?.steps) ? doc .steps : []).filter(isRequest);
5641
+ const variants = Array.isArray(doc?.variants) ? doc .variants : [];
5642
+ return variants.map(v => ({
5643
+ id: v.id, name: v.name, connection: v.connection ?? null, profile: v.profile ?? null,
5644
+ steps: requests.map(req => mergeCell(req.cell, v.overrides?.[req.id])),
5645
+ }));
5255
5646
  }
5256
5647
 
5257
5648
  /** Target [i]'s letter, A for the first: the same in every job, so a
@@ -5260,24 +5651,24 @@ function targetLetter(i ) {
5260
5651
  return i < 26 ? String.fromCharCode(65 + i) : String(i + 1);
5261
5652
  }
5262
5653
 
5263
- /** Target [i]'s name, or its letter's: "Target A". */
5264
- function targetLabel(doc , i ) {
5265
- return (targetsOf(doc)[i]?.name || "").trim() || `Target ${targetLetter(i)}`;
5654
+ /** Target [i]'s name, or its letter's: "Variant A". */
5655
+ function targetLabel(doc , i ) {
5656
+ return (targetsOf(doc)[i]?.name || "").trim() || `Variant ${targetLetter(i)}`;
5266
5657
  }
5267
5658
 
5268
5659
  /** What target [i] sends in job [k]: its step there. */
5269
- function targetStepOf(doc , i , k ) {
5660
+ function targetStepOf(doc , i , k ) {
5270
5661
  const st = targetsOf(doc)[i]?.steps?.[k];
5271
5662
  return isObj(st) ? st : undefined;
5272
5663
  }
5273
5664
 
5274
5665
  /** The registry entry of target [i]'s step in job [k]: what it asks, and of whom. */
5275
- const targetEntryOf = (doc , i , k ) =>
5666
+ const targetEntryOf = (doc , i , k ) =>
5276
5667
  STEP_TYPES[targetStepOf(doc, i, k)?.type ?? ""];
5277
5668
 
5278
5669
  /** What job [k]'s targets send, as the job's editor shows it: the first
5279
5670
  target's step type there, or Prompt's while there is none. */
5280
- function jobTargetType(doc , k ) {
5671
+ function jobTargetType(doc , k ) {
5281
5672
  return targetStepOf(doc, 0, k)?.type ?? "prompt";
5282
5673
  }
5283
5674
 
@@ -5286,18 +5677,19 @@ function jobTargetType(doc
5286
5677
  connection that `replays`). A recorded-reply metric compares the recorded
5287
5678
  reply with itself there, so it says nothing (n/a). Asked through the
5288
5679
  registry, never a step id. */
5289
- function recordedTargetOf(doc , i ) {
5680
+ function recordedTargetOf(doc , i ) {
5290
5681
  const entry = targetEntryOf(doc, i, 0);
5291
5682
  return !!(entry?.local && CONNECTION_TYPES[localConnOf(entry).type]?.replays);
5292
5683
  }
5293
5684
 
5294
5685
  /** [doc] with target [i]'s step in job [k] changed: [change] merged into
5295
- it, or the step [change] makes of it. */
5296
- function withTarget (doc , i , k ,
5686
+ it, or the step [change] makes of it. Serialised back into the Variants. */
5687
+ function withTarget (doc , i , k ,
5297
5688
  change ) {
5298
- return { ...doc, targets: doc.targets.map((t, x) => (x !== i ? t : {
5689
+ const targets = targetsOf(doc ).map((t, x) => (x !== i ? t : {
5299
5690
  ...t, steps: t.steps.map((st, j) => (j !== k ? st : typeof change === "function" ? change(st) : { ...st, ...change } )),
5300
- })) };
5691
+ }));
5692
+ return withTargets(doc, targets);
5301
5693
  }
5302
5694
 
5303
5695
  /** The token mappings [job]'s words are resolved under. */
@@ -5327,8 +5719,8 @@ const readAsOf = (job ) => stepOf (job, "readA
5327
5719
 
5328
5720
  /** What a transport builds target [i]'s request in job [k] from: the
5329
5721
  target's step, with the flow step and reading the job holds. */
5330
- function requestStepOf(doc , i , k ) {
5331
- const job = doc.jobs[k];
5722
+ function requestStepOf(doc , i , k ) {
5723
+ const job = jobsOf(doc)[k];
5332
5724
  return { ...flowStepOf(job), ...targetStepOf(doc, i, k), readAs: readAsOf(job) } ;
5333
5725
  }
5334
5726
 
@@ -5410,79 +5802,112 @@ function withOut(job , out ) {
5410
5802
  }
5411
5803
 
5412
5804
  /** [job] reading a reply as `legacyTagsOut` builds it, on the current model
5413
- (#405): its output a List, a Pre-format text set that reads the reply as
5414
- the old `preamble` did, and a Post-format items set that cleans and gates
5415
- it. The filters are `legacyTagsOut`'s, placed as private sets -- what the
5416
- version-15 upgrade builds for a legacy list job, for a test to build
5417
- directly. */
5805
+ (#405, #615): its output a List, and one filters list -- a private set
5806
+ above the format that reads the reply as the old `preamble` did, then the
5807
+ format, then a private set that cleans and gates its items. The filters
5808
+ are `legacyTagsOut`'s -- what the upgrade builds for a legacy list job, for
5809
+ a test to build directly. */
5418
5810
  function legacyTagsJob(job , rules , mode = "LOWER") {
5419
5811
  const legacy = legacyTagsOut(rules, mode);
5420
5812
  const items = ((legacy.modifiers[0]?.filters ) || []);
5421
- let j = withOut(job, { kind: "list", modifiers: [], parse: "csv" });
5422
- 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");
5423
- if (items.length) j = withFilterSets(j, [{ id: `filters-${j.id}`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: items } }], "items");
5424
- return j;
5425
- }
5426
-
5427
- /** A job's filters step for [stage], when it holds one (#405). */
5428
- const filterStepOf = (job , stage ) =>
5429
- (job?.steps || []).find((st) => isObj(st) && st.type === "filters" && st.stage === stage);
5430
-
5431
- /** The filter-set links a job's Responses stage carries for [stage], in
5432
- document order (#339, #405): read from that stage's filters step, or none.
5433
- Defaults to the Post-format (items) stage, the one that was there before. */
5434
- function filterSetsOf(job , stage = "items") {
5435
- return (filterStepOf(job, stage)?.filterSets ?? []).slice();
5436
- }
5437
-
5438
- /** [job] with its [stage] filter-set links set to [links]: that stage's
5439
- filters step added, replaced, or taken away when none are left. The other
5440
- stage's step is untouched. */
5441
- function withFilterSets(job , links , stage = "items") {
5442
- const steps = job.steps.filter(st => !(st.type === "filters" && st.stage === stage));
5443
- const next = links.length ? [...steps, { type: "filters" , stage, filterSets: [...links] }] : steps;
5444
- return { ...job, steps: ordered(next) };
5445
- }
5446
-
5447
- /** The filters a link resolves to, or none: a private (`own`) link its own
5448
- body, a Library link the body [resolve] gives (the worker's kept bodies),
5449
- and a Library link with no body nothing. */
5450
- const linkFilters = (link , resolve ) =>
5451
- (link.own ?? (link.set && resolve ? resolve(link.set) : null))?.filters || [];
5452
-
5453
- /** A job's Post-format (items) Responses as an ordered modifier list for a
5454
- run (#339): each modifier step as it is, and each items filter-set link as a
5455
- synthetic modifier that applies the set's filters where the link sits. The
5456
- Pre-format (text) filters are not modifiers -- `beforeFilters` gives
5457
- them, and a run applies them to the raw reply before it is read. [resolve]
5458
- gives a Library link's body. */
5813
+ const j = withOut(job, { kind: "list", modifiers: [], parse: "csv" });
5814
+ const above = legacy.before.length
5815
+ ? [{ id: `filters-${j.id}-before`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: legacy.before } }] : [];
5816
+ const below = items.length
5817
+ ? [{ id: `filters-${j.id}`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: items } }] : [];
5818
+ return withFilterSets(j, [...above, ...below], above.length);
5819
+ }
5820
+
5821
+ /** A job's filters step, when it holds one. */
5822
+ const filterStepOf = (job ) =>
5823
+ (job?.steps || []).find((st) => isObj(st) && st.type === "filters");
5824
+
5825
+ /** The filter-set links a job's Responses carries, in run order (#339,
5826
+ #615): read from its filters step, or none. */
5827
+ function filterSetsOf(job ) {
5828
+ return (filterStepOf(job)?.filterSets ?? []).slice();
5829
+ }
5830
+
5831
+ /** Where job's own Response Format sits among its links (#615): before
5832
+ `filterSetsOf(job)[n]`, or undefined where it holds no place of its own --
5833
+ a straddling set's marker, or the end, is where it reads. */
5834
+ function filterFormatOf(job ) {
5835
+ const at = filterStepOf(job)?.format;
5836
+ return Number.isInteger(at) ? at : undefined;
5837
+ }
5838
+
5839
+ /** [job] with its filter-set links set to [links], and its own Response
5840
+ Format at [format] among them (none: undefined). The filters step is
5841
+ added, replaced, or taken away when no link is left. */
5842
+ function withFilterSets(job , links , format ) {
5843
+ const steps = job.steps.filter(st => st.type !== "filters");
5844
+ const step = { type: "filters", filterSets: [...links], ...(format != null ? { format } : {}) };
5845
+ return { ...job, steps: ordered(links.length ? [...steps, step] : steps) };
5846
+ }
5847
+
5848
+ /** The body a link resolves to, or none: a private (`own`) link its own body,
5849
+ a Library link the body [resolve] gives (the worker's kept bodies). */
5850
+ const linkBody = (link , resolve ) =>
5851
+ link.own ?? (link.set && resolve ? resolve(link.set) ?? null : null);
5852
+
5853
+ /** Where job's Response Format reads among its links (#615), as the index
5854
+ of the link it reads in and the place among that link's filters: its own
5855
+ place (`format`) when it has one, else the first linked set whose body
5856
+ straddles the format (its `format`), else after every link. A format that
5857
+ reads text reads after every link. [resolve] gives a Library link's body. */
5858
+ function formatPlace(job , resolve ) {
5859
+ const links = filterSetsOf(job);
5860
+ if (!formatReadsItems(outOf(job)?.kind)) return { link: links.length, at: 0 };
5861
+ const own = filterFormatOf(job);
5862
+ if (own != null) return { link: Math.min(own, links.length), at: 0 };
5863
+ for (let i = 0; i < links.length; i++) {
5864
+ const at = linkBody(links[i] , resolve)?.format;
5865
+ if (Number.isInteger(at)) return { link: i, at: at };
5866
+ }
5867
+ return { link: links.length, at: 0 };
5868
+ }
5869
+
5870
+ /** A job's filters on either side of its Response Format (#615): `above`,
5871
+ run on the raw reply before it is read, and `below`, each link's share
5872
+ run on the items after it -- one list a link, so a filter that empties a
5873
+ list is named as a link's always was. */
5874
+ function filterSides(job , resolve ) {
5875
+ const links = filterSetsOf(job);
5876
+ const place = formatPlace(job, resolve);
5877
+ const above = [], below = [];
5878
+ links.forEach((link, i) => {
5879
+ const filters = linkBody(link, resolve)?.filters || [];
5880
+ if (i < place.link) above.push(...filters);
5881
+ else if (i > place.link) below.push(filters);
5882
+ else { above.push(...filters.slice(0, place.at)); below.push(filters.slice(place.at)); }
5883
+ });
5884
+ return { above, below };
5885
+ }
5886
+
5887
+ /** A job's Responses below its Response Format as an ordered modifier list
5888
+ for a run (#339, #615): each modifier step as it is, and each link's share
5889
+ below the format as a synthetic modifier that applies its filters to the
5890
+ items where the filters step sits. The filters above the format are not
5891
+ modifiers -- `beforeFilters` gives them, and a run applies them to the raw
5892
+ reply before it is read. [resolve] gives a Library link's body. */
5459
5893
  function stageModifiers(job , resolve ) {
5460
5894
  const out = [];
5461
5895
  for (const st of job?.steps || []) {
5462
5896
  if (!isObj(st)) continue;
5463
5897
  if (st.type === "modifier") out.push(st.modifier);
5464
- else if (st.type === "filters" && st.stage === "items") {
5465
- for (const link of (st.filterSets ) || []) out.push({ type: FILTER_MODIFIER, filters: linkFilters(link, resolve) });
5898
+ else if (st.type === "filters") {
5899
+ for (const filters of filterSides(job, resolve).below) out.push({ type: FILTER_MODIFIER, filters });
5466
5900
  }
5467
5901
  }
5468
5902
  return out;
5469
5903
  }
5470
5904
 
5471
- /** A job's Post-format filters for a run when its kind reads no items
5472
- (#557), in document order: every link's on its `items` filters step, which
5473
- a run applies to the text after the kind reads it. A kind that reads items
5474
- has none here -- `stageModifiers` runs its Post-format links. */
5475
- function afterFilters(job , resolve ) {
5476
- if (stageReads("items", outOf(job)?.kind) !== "text") return [];
5477
- return (filterStepOf(job, "items")?.filterSets || []).flatMap(link => linkFilters(link, resolve));
5478
- }
5479
-
5480
- /** A job's Pre-format (text) filters for a run (#405), in document order:
5481
- the filters of every link on its `text` filters step, which a run applies to
5482
- the raw reply before the kind reads it into items. [resolve] gives a Library
5905
+ /** A job's filters above its Response Format, for a run (#405, #615), in
5906
+ run order: a run applies them to the raw reply before the kind reads it.
5907
+ Every filter of a job whose format reads text. [resolve] gives a Library
5483
5908
  link's body, as `stageModifiers` does. */
5484
5909
  function beforeFilters(job , resolve ) {
5485
- return (filterStepOf(job, "text")?.filterSets || []).flatMap(link => linkFilters(link, resolve));
5910
+ return filterSides(job, resolve).above;
5486
5911
  }
5487
5912
 
5488
5913
  /** A new id: random, so one minted in one browser never collides with one
@@ -5524,15 +5949,10 @@ STEP_TYPES.job = {
5524
5949
  const unknown = steps.find(st => !slotOf(st));
5525
5950
  if (unknown) return void bad.push(`${at}: "${isObj(unknown) ? unknown.type : unknown}" is not a step this lab has`);
5526
5951
  if (steps.some(st => slotOf(st) === "target")) {
5527
- return void bad.push(`${at}: what a target sends is the target's own step, not the job's`);
5952
+ return void bad.push(`${at}: what a variant sends is the variant's own step, not the job's`);
5528
5953
  }
5529
5954
  const twice = steps.find((st, x) => !STEP_TYPES[st.type] .many && steps.findIndex(o => o.type === st.type) !== x);
5530
5955
  if (twice) return void bad.push(`${at} has two ${STEP_TYPES[twice.type] .label ?? twice.type} steps`);
5531
- // A `filters` step is `many`, but only one per stage: a Pre-format and
5532
- // a Post-format set at most (#405).
5533
- const twoFilters = steps.find((st, x) => st.type === "filters"
5534
- && steps.findIndex(o => o.type === "filters" && o.stage === st.stage) !== x);
5535
- if (twoFilters) return void bad.push(`${at} has two ${twoFilters.stage === "text" ? "Pre-format" : "Post-format"} filter steps`);
5536
5956
  if (steps.some((st, x) => x > 0 && placeOf(st) < placeOf(steps[x - 1]))) {
5537
5957
  return void bad.push(`${at}: steps are its Content, then its Responses, each in order`);
5538
5958
  }
@@ -5614,18 +6034,18 @@ STEP_TYPES.evals = {
5614
6034
 
5615
6035
  // ---- the document -------------------------------------------------------------
5616
6036
 
5617
- const PIPELINE_FIELDS = ["version", "id", "name", "jobs", "targets", "evals", "pass"];
6037
+ const PIPELINE_FIELDS = ["version", "id", "name", "steps", "variants", "evals", "pass"];
5618
6038
  // What resolving adds, and nothing else: the profiles it resolved to and the
5619
6039
  // run's own comment, which belongs to the run and never to the pipeline.
5620
6040
  const RUN_FIELDS = [...PIPELINE_FIELDS, "profiles", "comment", "plugins"];
5621
6041
 
5622
6042
  /** The one rule for a document that is not this version: refuse it, by name. */
5623
6043
  function versionProblem(doc ) {
5624
- if (!isObj(doc)) return "a pipeline is a JSON object";
6044
+ if (!isObj(doc)) return "an experiment is a JSON object";
5625
6045
  if (doc.version === PIPELINE_VERSION) return null;
5626
6046
  return doc.version == null
5627
- ? `this pipeline carries no version, and the lab reads version ${PIPELINE_VERSION}`
5628
- : `this pipeline is version ${JSON.stringify(doc.version)}, and the lab reads version ${PIPELINE_VERSION}`;
6047
+ ? `this experiment carries no version, and the lab reads version ${PIPELINE_VERSION}`
6048
+ : `this experiment is version ${JSON.stringify(doc.version)}, and the lab reads version ${PIPELINE_VERSION}`;
5629
6049
  }
5630
6050
 
5631
6051
  /** What an upgrade from version 2 needs from outside the document: the rules
@@ -5783,7 +6203,7 @@ function newFilterLink(set ) {
5783
6203
  return { id: newId(), set: { id: set.id, name: set.name }, pin: null };
5784
6204
  }
5785
6205
 
5786
- function jobsOf(next ) {
6206
+ function chainsToJobs(next ) {
5787
6207
  next.version = PIPELINE_VERSION;
5788
6208
  if (Array.isArray(next.chains)) {
5789
6209
  const entries = Object.entries(next).map(([k, v]) => (k === "chains" ? ["jobs", v] : [k, v]));
@@ -5920,9 +6340,9 @@ function groupsOf(next ) {
5920
6340
  /** [doc] with each private group's body read as this version of a group
5921
6341
  body (`upgradeDatasetBody`): a version-8 one's `source` goes (#396); and
5922
6342
  each private filter set's as this version of a filter-set body
5923
- (`upgradeFilterSetBody`): a version-2 one's `stage` goes (#432). A body
5924
- carries its own version, so this runs on a current pipeline too; one whose
5925
- bodies are all current comes back as it was. */
6343
+ (`ownBodyV4`): its filters the verbs that read a reply as they did (#615).
6344
+ A body carries its own version, so this runs on a current pipeline too;
6345
+ one whose bodies are all current comes back as it was. */
5926
6346
  function ownBodiesNow(doc ) {
5927
6347
  const stale = (t ) => isObj(t) && isObj(t.own) && t.own.version !== DATASET_BODY_VERSION;
5928
6348
  const staleSet = (l ) => isObj(l) && isObj(l.own) && l.own.version !== FILTER_SET_VERSION;
@@ -5934,28 +6354,66 @@ function ownBodiesNow(doc ) {
5934
6354
  }
5935
6355
  if (Array.isArray(out.jobs) && out.jobs.some(staleJob)) {
5936
6356
  out = { ...out, jobs: out.jobs.map((j ) => (!staleJob(j) ? j : { ...j, steps: j.steps.map((st ) => (!staleStep(st) ? st
5937
- : { ...st, filterSets: st.filterSets.map((l ) => (staleSet(l) ? { ...l, own: upgradeFilterSetBody(l.own) } : l)) })) })) };
6357
+ : { ...st, filterSets: st.filterSets.map((l ) => (staleSet(l) ? { ...l, own: ownBodyV4(l.own) } : l)) })) })) };
5938
6358
  }
5939
6359
  return out;
5940
6360
  }
5941
6361
 
6362
+ /** A jobs-and-targets intermediate (the version-19 shape) as a version-20
6363
+ document: its step list and Variants, serialised, with a run's additions
6364
+ kept in place (version 20, §20). The fields are in RUN_FIELDS order, so a
6365
+ document re-read is byte-for-byte itself. */
6366
+ function serializeDoc(jt ) {
6367
+ const { steps, variants } = serialize(jobsOf(jt), targetsOf(jt), jt.steps);
6368
+ const out = { version: PIPELINE_VERSION, id: jt.id, name: jt.name, steps, variants,
6369
+ evals: jt.evals, pass: jt.pass };
6370
+ for (const f of ["profiles", "comment", "plugins"]) if (jt[f] !== undefined) out[f] = jt[f];
6371
+ return out;
6372
+ }
6373
+
6374
+ /** A version-20 document as the jobs-and-targets intermediate, so the
6375
+ normalisations that read jobs and targets (localSteps, ownBodiesNow,
6376
+ profileRefs) can run before it is serialised back. */
6377
+ function jtOf(doc ) {
6378
+ const out = { version: doc.version, id: doc.id, name: doc.name,
6379
+ jobs: jobsOf(doc), targets: targetsOf(doc), evals: doc.evals, pass: doc.pass };
6380
+ // The step list it came from, which serializeDoc keeps the order of.
6381
+ if (Array.isArray(doc.steps)) out.steps = doc.steps;
6382
+ for (const f of ["profiles", "comment", "plugins"]) if (doc[f] !== undefined) out[f] = doc[f];
6383
+ return out;
6384
+ }
6385
+
5942
6386
  function upgradePipeline (doc , ctx = {}) {
5943
6387
  // A current document is read as it is, but for a profile reference the Runs
5944
6388
  // tab saved whole (see profileRefs), which is cut back, a Read Reply's
5945
6389
  // leftover `preamble: false` (withoutLeftoverPreamble), and a step on a
5946
- // profile a target step stands in for (localSteps).
6390
+ // profile a target step stands in for (localSteps). It is read as the jobs
6391
+ // intermediate, normalised, and serialised back (serializeDoc), so a clean
6392
+ // document comes back as the same object.
5947
6393
  if (isObj(doc) && doc.version === PIPELINE_VERSION) {
5948
- let out = doc ;
5949
- if (fatProfileRef(out)) {
5950
- out = clone(out) ;
5951
- profileRefs(out);
5952
- }
5953
- return localSteps(ownBodiesNow(withoutLeftoverPreamble(out)), ctx) ;
6394
+ const jt = jtOf(doc);
6395
+ if (fatProfileRef(jt)) profileRefs(jt);
6396
+ const out = serializeDoc(localSteps(ownBodiesNow(withoutLeftoverPreamble(jt)), ctx));
6397
+ // The same document back when nothing moved: the page saves a stored
6398
+ // pipeline whose upgrade is a new object, so a new one would rewrite every
6399
+ // experiment on every load. Compared by content, not as text: a document
6400
+ // stored in another key order (Import, the demo pack) has not changed.
6401
+ return (sameJson(out, doc) ? doc : out) ;
5954
6402
  }
5955
- if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(doc.version )) return doc;
6403
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19].includes(doc.version )) return doc;
6404
+ // Every version reads up through the version-19 jobs-and-targets shape, then
6405
+ // reshapes to the version-20 step list and Variants (serializeDoc).
6406
+ const twenty = (jt ) => serializeDoc(localSteps(ownBodiesNow(jt), ctx)) ;
6407
+ // A version-19 document is already that shape; a Read Reply's leftover
6408
+ // preamble is cut and its profile references trimmed, then it reshapes.
6409
+ if (doc.version === 19) return twenty(withoutLeftoverPreamble(cutRefs(clone(doc) )));
6410
+ // Every version before 19 reads as 19 first: a job's filters are one list
6411
+ // around its Response Format (oneFilterList), then it reshapes.
6412
+ const nineteen = (d ) => twenty(oneFilterList(d));
6413
+ if (doc.version === 18) return nineteen(withoutLeftoverPreamble(cutRefs(clone(doc) )));
5956
6414
  // Every version reads as 17 first, then as 18: a saved-mode Source content
5957
6415
  // pins one response (savedPinned).
5958
- const eighteen = (d ) => localSteps(ownBodiesNow(savedPinned(d, ctx)), ctx) ;
6416
+ const eighteen = (d ) => nineteen(savedPinned(d, ctx));
5959
6417
  if (doc.version === 17) return eighteen(cutRefs(clone(doc) ));
5960
6418
  // Every version reads as 16 first, then as 17: its requests read by their
5961
6419
  // job's HTTP API (requestsOnJobApi).
@@ -6026,14 +6484,16 @@ function filtersFromCleaning(next ) {
6026
6484
  if (isCleaning(st)) buffer = [...buffer, ...cleaningFilters((st ).modifier )];
6027
6485
  else if (isFilters(st)) {
6028
6486
  for (const link of ((st ).filterSets ) || []) {
6029
- if (link.own) buffer = [...buffer, ...(link.own.filters || [])];
6487
+ if (link.own) buffer = [...buffer, ...((ownBodyV4(link.own) ).filters || [])];
6030
6488
  else { flush(); links.push(link); }
6031
6489
  }
6032
6490
  }
6033
6491
  }
6034
6492
  flush();
6035
- const itemsStep = links.length ? { type: "filters", stage: "items", filterSets: links } : null;
6036
- const textStep = preamble
6493
+ // Version 18's two filters steps, a stage each, which oneFilterList reads
6494
+ // on the way to 19.
6495
+ const itemsStep = links.length ? { type: "filters", stage: "items", filterSets: links } : null;
6496
+ const textStep = preamble
6037
6497
  ? { type: "filters", stage: "text", filterSets: [{ id: `filters-${jid}-before`, set: null, pin: null, own: { version: FILTER_SET_VERSION, filters: preambleFilters() } }] }
6038
6498
  : null;
6039
6499
 
@@ -6052,6 +6512,48 @@ function filtersFromCleaning(next ) {
6052
6512
  return next;
6053
6513
  }
6054
6514
 
6515
+ /** Version 18 to 19 (#615): each job's Pre-format and Post-format filters
6516
+ steps become one, in run order -- the Pre-format links, then the
6517
+ Post-format ones -- and where the job's format reads items, its Response
6518
+ Format sits between them (`format`), so every filter runs where it did. A
6519
+ Library set linked on both steps is linked once, between them, and its
6520
+ body (now straddling the format, `upgradeFilterSetBody`) says where the
6521
+ format reads: the job holds no place of its own. A job whose format reads
6522
+ text runs every filter on its text, so it holds no place either. Each
6523
+ private set's body is read as version 4 (`ownBodyV4`). Every version
6524
+ before 19 ends here. */
6525
+ function oneFilterList(next ) {
6526
+ next.version = PIPELINE_VERSION;
6527
+ if (!Array.isArray(next.jobs)) return next;
6528
+ const isFilters = (st ) => isObj(st) && st.type === "filters";
6529
+ next.jobs = next.jobs.map((job ) => {
6530
+ if (!isObj(job) || !Array.isArray(job.steps) || !job.steps.some(isFilters)) return job;
6531
+ const steps = job.steps ;
6532
+ const linksOf = (stage ) => steps.filter(st => isFilters(st) && (st.stage ?? "items") === stage)
6533
+ .flatMap(st => (Array.isArray(st.filterSets) ? st.filterSets : []) )
6534
+ .map(l => (isObj(l) && l.own ? { ...l, own: ownBodyV4(l.own) } : l));
6535
+ const pre = linksOf("text"), post = linksOf("items");
6536
+ const setId = (l ) => (isObj(l) && isObj(l.set) ? l.set.id : null);
6537
+ const both = pre.find(l => setId(l) && post.some(o => setId(o) === setId(l) && o.pin === l.pin));
6538
+ let links , format ;
6539
+ if (!formatReadsItems(outOf(job )?.kind)) { links = [...pre, ...post]; format = undefined; }
6540
+ else if (both) {
6541
+ links = [...pre.filter(l => l !== both), both, ...post.filter(o => !(setId(o) === setId(both) && o.pin === both.pin))];
6542
+ format = undefined;
6543
+ } else { links = [...pre, ...post]; format = post.length ? pre.length : undefined; }
6544
+ const out = [];
6545
+ let placed = false;
6546
+ for (const st of steps) {
6547
+ if (!isFilters(st)) { out.push(st); continue; }
6548
+ if (placed || !links.length) continue;
6549
+ out.push({ type: "filters", filterSets: links, ...(format != null ? { format } : {}) });
6550
+ placed = true;
6551
+ }
6552
+ return { ...job, steps: out };
6553
+ });
6554
+ return next;
6555
+ }
6556
+
6055
6557
  /** [st] without its List `preamble` setting, when it is a Read Reply holding
6056
6558
  one; [st] itself otherwise. */
6057
6559
  function withoutPreamble(st ) {
@@ -6183,9 +6685,9 @@ function nineOf(next , ctx ) {
6183
6685
  if (next.version === 9) { profileRefs(next); return next; }
6184
6686
  if (next.version === 8) return metricsOf(next);
6185
6687
  if (next.version === 7) return metricsOf(stepsOf(testsList(next)));
6186
- if (next.version === 6) return metricsOf(stepsOf(jobsOf(next)));
6187
- if (next.version === 5) return metricsOf(stepsOf(jobsOf(testsList(next))));
6188
- if (next.version === 3 || next.version === 4) return metricsOf(stepsOf(jobsOf(testsList(upgradeIds(next)))));
6688
+ if (next.version === 6) return metricsOf(stepsOf(chainsToJobs(next)));
6689
+ if (next.version === 5) return metricsOf(stepsOf(chainsToJobs(testsList(next))));
6690
+ if (next.version === 3 || next.version === 4) return metricsOf(stepsOf(chainsToJobs(testsList(upgradeIds(next)))));
6189
6691
  if (next.version === 1) {
6190
6692
  next.version = 2;
6191
6693
  if (Array.isArray(next.chains)) {
@@ -6211,7 +6713,7 @@ function nineOf(next , ctx ) {
6211
6713
  if (isObj(cell)) cell.prompt = renamed(cell.prompt);
6212
6714
  }
6213
6715
  }
6214
- return metricsOf(stepsOf(jobsOf(testsList(upgradeIds(next)))));
6716
+ return metricsOf(stepsOf(chainsToJobs(testsList(upgradeIds(next)))));
6215
6717
  }
6216
6718
 
6217
6719
  /** Version 1's two maps as a list of mappings. */
@@ -6224,13 +6726,14 @@ function tokenMappingsFromV1(set ) {
6224
6726
  ];
6225
6727
  }
6226
6728
 
6227
- /** A current-version document with one job and no scenario. Its job sends
6228
- the item's image, as a job over a Source would; one over text content
6729
+ /** A current-version document with one Request and no Variant. Its Request
6730
+ sends the item's image, as a job over a Source would; one over text content
6229
6731
  has none to send, and the page turns it off with the content. */
6230
6732
  function blankPipeline(opts = {}) {
6231
6733
  const job = withImage(jobDefaults(opts.kind, opts.tokens), true);
6734
+ const { steps, variants } = serialize([job], []);
6232
6735
  return { version: PIPELINE_VERSION, id: newId(), name: opts.name || "",
6233
- jobs: [job], targets: [], evals: [], pass: { mode: "all" } };
6736
+ steps, variants, evals: [], pass: { mode: "all" } };
6234
6737
  }
6235
6738
 
6236
6739
  /** What is wrong with how a target or a step [at] names whom it asks: each
@@ -6243,6 +6746,102 @@ function answerRefsProblems(o , at , bad ) {
6243
6746
  }
6244
6747
  }
6245
6748
 
6749
+ /** Each step of [doc]'s list as the Steps list names it, by its id: Get
6750
+ items, Request N (numbered by the Requests before it), Read Response N
6751
+ (the number of the Request it reads), Filter. */
6752
+ function stepNames(doc ) {
6753
+ const steps = Array.isArray(doc?.steps) ? doc .steps : [];
6754
+ const number = new Map(steps.filter(isRequest).map((r, k) => [r.id, k + 1]));
6755
+ const out = {};
6756
+ for (const st of steps) {
6757
+ if (!isObj(st)) continue;
6758
+ out[st.id] = st.type === "getItems" ? "Get items"
6759
+ : st.type === "request" ? `Request ${number.get(st.id)}`
6760
+ : st.type === "readResponse" ? `Read Response ${number.get(st.request) ?? ""}`.trim()
6761
+ : "Filter";
6762
+ }
6763
+ return out;
6764
+ }
6765
+
6766
+ /** Where each step of [doc]'s list that is out of order must go, by its id
6767
+ (the order rules stepsProblems holds the document to): a Get items after
6768
+ Request 1 comes before it; a Read Response or Filter above the Request it
6769
+ reads comes after it. A step in order is not listed. */
6770
+ function stepFaults(doc ) {
6771
+ const steps = Array.isArray(doc?.steps) ? doc .steps : [];
6772
+ const names = stepNames(doc);
6773
+ const first = steps.findIndex(isRequest);
6774
+ const out = {};
6775
+ steps.forEach((st, at) => {
6776
+ if (isGetItems(st) && first >= 0 && at > first) out[st.id] = `Comes before ${names[steps[first] .id]}`;
6777
+ if (isReadResponse(st) || isFilterV20(st)) {
6778
+ const req = steps.findIndex(o => isRequest(o) && o.id === st.request);
6779
+ if (req > at) out[st.id] = `Comes after ${names[steps[req] .id]}`;
6780
+ }
6781
+ });
6782
+ return out;
6783
+ }
6784
+
6785
+ /** The step types a version-20 pipeline's step list holds. */
6786
+ const PIPELINE_STEP_TYPES = ["getItems", "request", "readResponse", "filter"];
6787
+
6788
+ /**
6789
+ * Every reason [doc]'s step list and Variants are not a well-formed version-20
6790
+ * pipeline (§20): a step of an unknown type, a Get items after Request 1, a
6791
+ * Read Response or Filter before the Request it reads, and a Variant that is
6792
+ * not an object with its overrides keyed by Request ids. The order rules name
6793
+ * the step the out-of-order one must come before, so the page can point at it.
6794
+ */
6795
+ function stepsProblems(doc , bad ) {
6796
+ if (!Array.isArray(doc.steps)) return void bad.push("steps has to be a list");
6797
+ const steps = doc.steps;
6798
+ const requestIds = [];
6799
+ const seen = new Set ();
6800
+ // The number of each Request, by its id, so a later step names it.
6801
+ for (const st of steps) if (isRequest(st)) requestIds.push(isStr(st.id) ? st.id : "");
6802
+ const numberOf = (id ) => requestIds.indexOf(id) + 1;
6803
+ let sawRequest = false;
6804
+ steps.forEach((st) => {
6805
+ if (!isObj(st) || !isStr(st.type) || !PIPELINE_STEP_TYPES.includes(st.type)) {
6806
+ return void bad.push(`"${isObj(st) ? st.type : st}" is not a step this lab has`);
6807
+ }
6808
+ if (!isStr(st.id) || !st.id.trim()) bad.push(`a ${st.type} step has no id`);
6809
+ else if (seen.has(st.id)) bad.push(`two steps have the id ${st.id}`);
6810
+ else seen.add(st.id);
6811
+ if (st.type === "getItems") {
6812
+ if (sawRequest) bad.push("Get items comes before Request 1");
6813
+ } else if (st.type === "request") {
6814
+ sawRequest = true;
6815
+ } else {
6816
+ // Read Response N and Filter come after Request N.
6817
+ const label = st.type === "readResponse" ? "Read Response" : "Filter";
6818
+ if (!isStr(st.request) || !requestIds.includes(st.request)) {
6819
+ bad.push(`a ${label} reads a Request the experiment does not have`);
6820
+ } else {
6821
+ const n = numberOf(st.request);
6822
+ const at = steps.indexOf(st), req = steps.findIndex(o => isRequest(o) && o.id === st.request);
6823
+ if (at < req) bad.push(`${label} ${n} comes after Request ${n}`);
6824
+ }
6825
+ }
6826
+ });
6827
+ if (!requestIds.length) bad.push("add a Request first");
6828
+ if (!Array.isArray(doc.variants)) return void bad.push("variants has to be a list");
6829
+ const vseen = new Set ();
6830
+ doc.variants.forEach((v , i ) => {
6831
+ const at = targetLabel(doc, i);
6832
+ if (!isObj(v)) return void bad.push(`${at} has to be an object`);
6833
+ onlyFields(v, at, ["id", "name", "connection", "profile", "overrides"], bad);
6834
+ if (!isStr(v.id) || !v.id.trim()) bad.push(`${at} has no id`);
6835
+ else if (vseen.has(v.id)) bad.push(`${at} has the id of another variant`);
6836
+ else vseen.add(v.id);
6837
+ if (v.name != null && !isStr(v.name)) bad.push(`${at}: name has to be text`);
6838
+ answerRefsProblems(v, at, bad);
6839
+ if (v.overrides != null && (!isObj(v.overrides) || Object.keys(v.overrides).some(k => !requestIds.includes(k)))) {
6840
+ bad.push(`${at}: overrides are keyed by Request ids`);
6841
+ }
6842
+ });
6843
+ }
6844
+
6246
6845
  /**
6247
6846
  * Every reason [doc] cannot run, as sentences -- none when it can. The
6248
6847
  * reference lookups are [ctx]'s: `profiles(id)` and `sources(id)` answer with
@@ -6256,29 +6855,41 @@ function validatePipeline(input , ctx = {}) {
6256
6855
  const run = doc.profiles !== undefined;
6257
6856
  const c = { ...ctx, run };
6258
6857
  if (run && !ctx.profiles) c.profiles = (key ) => (runAnswer(doc, key) ) ?? null;
6259
- onlyFields(doc, "the pipeline", run ? RUN_FIELDS : PIPELINE_FIELDS, bad);
6260
- if (!isStr(doc.id) || !doc.id.trim()) bad.push("the pipeline has no id");
6858
+ onlyFields(doc, "the experiment", run ? RUN_FIELDS : PIPELINE_FIELDS, bad);
6859
+ if (!isStr(doc.id) || !doc.id.trim()) bad.push("the experiment has no id");
6261
6860
  if (doc.name != null && !isStr(doc.name)) bad.push("name has to be text");
6262
6861
  if (run && doc.comment != null && !isStr(doc.comment)) bad.push("comment has to be text");
6263
- if (!Array.isArray(doc.jobs) || !doc.jobs.length) {
6264
- return [...bad, "jobs has to be a list of at least one job"];
6265
- }
6266
- if (!targetsOf(doc).length) bad.push("add a target first");
6267
- // Content is job 1's Attach Content step; a job 1 without one has none yet.
6862
+ // The version-20 step list and Variants are well-formed, so the jobs and
6863
+ // targets the rest reads (jobsOf, targetsOf) can be derived from them.
6864
+ stepsProblems(doc, bad);
6865
+ if (bad.length) return bad;
6866
+ // The derived jobs-and-targets view every semantic check below reads.
6867
+ const jobs = jobsOf(doc);
6868
+ const jdoc = { ...doc, jobs };
6869
+ if (!jobs.length) return [...bad, "add a Request first"];
6870
+ if (!targetsOf(doc).length) bad.push("add a variant first");
6871
+ // Content is job 1's Get items step; a job 1 without one has none yet.
6268
6872
  if (!contentOf(doc)) bad.push("set the content first");
6269
6873
  if (run) profilesProblems(doc.profiles, bad);
6270
- doc.jobs.forEach((ch , k ) => STEP_TYPES.job .validate(ch, c, bad, k, doc));
6874
+ jobs.forEach((ch , k ) => STEP_TYPES.job .validate(ch, c, bad, k, jdoc));
6875
+ // A later Get items, which job 1 does not attach, is held to the same
6876
+ // rules, named by its place.
6877
+ itemContents(doc).slice(1).forEach(({ content }, k) => {
6878
+ const said = [];
6879
+ STEP_TYPES.attachContent .validate ({ type: "attachContent", content }, c, said);
6880
+ for (const s of said) bad.push(`Get items ${k + 2}: ${s}`);
6881
+ });
6271
6882
  if (bad.length) return bad;
6272
6883
  const content = contentOf(doc) ;
6273
6884
 
6274
6885
  const targets = targetsOf(doc);
6275
- const n = doc.jobs.length;
6886
+ const n = jobs.length;
6276
6887
  targets.forEach((t, i) => {
6277
6888
  const at = targetLabel(doc, i);
6278
6889
  if (!isObj(t)) return void bad.push(`${at} has to be an object`);
6279
6890
  onlyFields(t, at, ["id", "name", "connection", "profile", "steps"], bad);
6280
6891
  if (!isStr(t.id) || !t.id.trim()) bad.push(`${at} has no id`);
6281
- else if (targets.findIndex((o) => isObj(o) && o.id === t.id) !== i) bad.push(`${at} has the id of another target`);
6892
+ else if (targets.findIndex((o) => isObj(o) && o.id === t.id) !== i) bad.push(`${at} has the id of another variant`);
6282
6893
  if (t.name != null && !isStr(t.name)) bad.push(`${at}: name has to be text`);
6283
6894
  answerRefsProblems(t, at, bad);
6284
6895
  if (!Array.isArray(t.steps) || t.steps.length !== n) {
@@ -6295,19 +6906,19 @@ function validatePipeline(input , ctx = {}) {
6295
6906
  const where = `${at}, ${jobLabel(doc, k)}`;
6296
6907
  if (!isObj(st)) return void bad.push(`${where} has to be an object`);
6297
6908
  const entry = STEP_TYPES[st.type];
6298
- if (entry?.slot !== "target") return void bad.push(`${where}: "${st.type}" is not something a target can send`);
6909
+ if (entry?.slot !== "target") return void bad.push(`${where}: "${st.type}" is not something a variant can send`);
6299
6910
  if (entry.local && (st.profile != null || st.connection != null)) return void bad.push(`${where}: ${entry.label} asks no connection`);
6300
6911
  if (k > 0 && entry.firstJobOnly) return void bad.push(`${where}: ${entry.firstJobOnly}`);
6301
6912
  onlyFields(st, where, entry.fields ?? [], bad);
6302
6913
  // A request is read and placed by its job's HTTP API.
6303
- const flow = entry.asks === "request" ? flowStepOf(doc.jobs[k]) : null;
6914
+ const flow = entry.asks === "request" ? flowStepOf(jobs[k]) : null;
6304
6915
  entry.validate(flow ? { ...st, api: flow.api } : st, c, bad, where);
6305
6916
  answerRefsProblems(st, where, bad);
6306
6917
  if (st.from != null && !isRef(st.from)) bad.push(`${where} has to name the prompt it was picked from as { id, name }`);
6307
6918
  // A request is a flow step's, rebuilt from its records, and its
6308
6919
  // template has no place for an image.
6309
- if (entry.asks === "request" && !flowStepOf(doc.jobs[k])) bad.push(`${where}: ${entry.label} needs the job's flow step`);
6310
- if (entry.asks === "request" && sendsImage(doc.jobs[k])) bad.push(`${where}: ${entry.label} has no place for an image`);
6920
+ if (entry.asks === "request" && !flowStepOf(jobs[k])) bad.push(`${where}: ${entry.label} needs the job's flow step`);
6921
+ if (entry.asks === "request" && sendsImage(jobs[k])) bad.push(`${where}: ${entry.label} has no place for an image`);
6311
6922
  });
6312
6923
  });
6313
6924
  if (bad.length) return bad;
@@ -6362,7 +6973,7 @@ function validatePipeline(input , ctx = {}) {
6362
6973
  const docType = (contentFiles || []).map(f => documentTypeOf(String((f )?.name ?? f)))
6363
6974
  .find(Boolean) ?? null;
6364
6975
  targets.forEach((_, i) => {
6365
- const conns = doc.jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetAnswerOf(doc, i, k) )));
6976
+ const conns = jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetAnswerOf(doc, i, k) )));
6366
6977
  conns.forEach((p , k ) => {
6367
6978
  if (!local(p)) return;
6368
6979
  const entry = CONNECTION_TYPES[typeOf(p )] ;
@@ -6384,27 +6995,28 @@ function validatePipeline(input , ctx = {}) {
6384
6995
  }
6385
6996
  // A request goes as the flow wrote it, so its connection has to speak
6386
6997
  // the job's HTTP API; one the lab cannot tell (Any endpoint) passes.
6387
- const api = step.asks === "request" ? flowStepOf(doc.jobs[k])?.api : undefined;
6998
+ const api = step.asks === "request" ? flowStepOf(jobs[k])?.api : undefined;
6388
6999
  const speaks = api && api !== "raw" && type?.speaks ? type.speaks(p ) : null;
6389
7000
  if (api && speaks && speaks !== api) {
6390
7001
  const name = targetAnswerOf(doc, i, k)?.connection.name || type .label;
6391
7002
  bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't send an ${HTTP_APIS[api]?.label ?? api} request`);
6392
7003
  }
6393
- if (docType && type && !type.documents && sendsImage(doc.jobs[k])) {
7004
+ if (docType && type && !type.documents && sendsImage(jobs[k])) {
6394
7005
  const name = targetAnswerOf(doc, i, k)?.connection.name || type.label;
6395
7006
  bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't read ${docType.label}s`);
6396
7007
  }
6397
7008
  });
6398
7009
  // The model is the profile's unless the target's step carries it.
6399
7010
  const bare = conns.findIndex((p , k ) => p && c.profiles && !local(p)
6400
- && targetEntryOf(doc, i, k)?.modelFrom !== "step" && !String(p.model || "").trim());
7011
+ && targetEntryOf(doc, i, k)?.modelFrom !== "step" && !String(p.model || "").trim()
7012
+ && !String(targetStepOf(doc, i, k)?.model || "").trim());
6401
7013
  if (bare >= 0) {
6402
7014
  bad.push(n > 1
6403
7015
  ? `no model on the connection ${jobLabel(doc, bare)} of ${targetLabel(doc, i)} uses — manage connections on the Setup tab`
6404
7016
  : `no model on ${targetLabel(doc, i)}'s connection — manage connections on the Setup tab`);
6405
7017
  }
6406
7018
  });
6407
- STEP_TYPES.evals .validate(doc.evals, c, bad, outOf(doc.jobs.at(-1)).kind, doc);
7019
+ STEP_TYPES.evals .validate(doc.evals, c, bad, outOf(jobs.at(-1)).kind, doc);
6408
7020
  passProblems(doc.pass, Array.isArray(doc.evals) ? doc.evals.length : 0, bad);
6409
7021
  if (bad.length) return bad;
6410
7022
 
@@ -6412,9 +7024,9 @@ function validatePipeline(input , ctx = {}) {
6412
7024
  // says which job, instead of failing every item the same way.
6413
7025
  const text = CONTENT_TYPES[content?.type]?.text?.(content, c) ?? null;
6414
7026
  targets.forEach((_, i) => {
6415
- const stages = doc.jobs.map((ch , k ) => ({ text: targetStepOf(doc, i, k) .prompt, kind: outOf(ch).kind,
7027
+ const stages = jobs.map((ch , k ) => ({ text: targetStepOf(doc, i, k) .prompt, kind: outOf(ch).kind,
6416
7028
  verbatim: !!targetEntryOf(doc, i, k)?.verbatim }));
6417
- const problem = jobProblem(stages, stages.map(() => () => {}), doc.jobs.map((ch ) => tokensOf(ch)), text);
7029
+ const problem = jobProblem(stages, stages.map(() => () => {}), jobs.map((ch ) => tokensOf(ch)), text);
6418
7030
  if (problem) bad.push(`${targetLabel(doc, i)}: ${jobWords(problem)}`);
6419
7031
  });
6420
7032
  return bad;
@@ -6427,8 +7039,8 @@ function passProblems(pass , n , bad ) {
6427
7039
  return void bad.push("pass has to be { mode: all } or { mode: atLeast, count }");
6428
7040
  }
6429
7041
  onlyFields(pass, "pass", pass.mode === "all" ? ["mode"] : ["mode", "count"], bad);
6430
- if (pass.mode === "atLeast" && !(Number.isInteger(pass.count) && pass.count > 0)) bad.push("pass at least has to count a whole number of groups from 1");
6431
- else if (pass.mode === "atLeast" && pass.count > n) bad.push(`pass asks for ${pass.count} groups, and the pipeline has ${n}`);
7042
+ if (pass.mode === "atLeast" && !(Number.isInteger(pass.count) && pass.count > 0)) bad.push("pass at least has to count a whole number of evals from 1");
7043
+ else if (pass.mode === "atLeast" && pass.count > n) bad.push(`pass asks for ${pass.count} evals, and the experiment has ${n}`);
6432
7044
  }
6433
7045
 
6434
7046
  /** How many of [items] are in [files] (names or `{ name }` entries): a
@@ -6461,7 +7073,7 @@ function pipelineWarnings(doc , ctx = {}) {
6461
7073
  if (!items?.length) return;
6462
7074
  const found = itemsFound(items, files);
6463
7075
  if (found < items.length) {
6464
- warn.push(`${evalLabel(doc , j)}: ${found} of ${ref .name || ref .id}'s ${items.length} cases have their item in ${where}`);
7076
+ warn.push(`${evalLabel(doc , j)}: ${found} of ${ref .name || ref .id}'s ${items.length} eval cases have their item in ${where}`);
6465
7077
  }
6466
7078
  });
6467
7079
  return warn;
@@ -6512,7 +7124,7 @@ function connectionProblems(conn , at , from
6512
7124
  // A key in an address is written into every log line and error message
6513
7125
  // that prints it.
6514
7126
  if (u && (u.username || u.password)) {
6515
- bad.push(`${at} has a key in its address, and a key never goes in a pipeline — $${from} supplies it`);
7127
+ bad.push(`${at} has a key in its address, and a key never goes in an experiment — $${from} supplies it`);
6516
7128
  }
6517
7129
  }
6518
7130
  if (!isStr(conn.model)) bad.push(`${at}: model has to be text`);
@@ -6579,10 +7191,12 @@ function answerKeys(doc )
6579
7191
  * The run document: [doc] plus what its references resolved to, once, at
6580
7192
  * submit. `ctx.profiles(key)` answers an answer key with what it resolves to
6581
7193
  * (`answersOf`'s `resolve`), `ctx.files` is
6582
- * the Source's file list as it reads now, `ctx.comment` is the run's own.
7194
+ * the first Get items' Source's file list as it reads now and
7195
+ * `ctx.filesOf(id)` any Source's (a later Get items reads it), `ctx.comment`
7196
+ * is the run's own.
6583
7197
  */
6584
7198
  function resolvePipeline(doc ,
6585
- ctx
7199
+ ctx
6586
7200
  = {}) {
6587
7201
  const run = clone(doc) ;
6588
7202
  // What the lab supplies an eval -- its grader -- before the profiles it
@@ -6599,12 +7213,14 @@ function resolvePipeline(doc ,
6599
7213
  const cid = isStr(p.id) && p.id ? p.id : answerOfKey(key).connection;
6600
7214
  run.profiles[key] = { ...conn, connection: cid };
6601
7215
  }
6602
- const content = contentOf(run) ;
6603
7216
  // Saved response grades its one pinned response (#596), whatever else the
6604
7217
  // Source holds; the server keeps that response's bytes with the run.
6605
- if (content?.type === "source") {
6606
- content.files = CONTENT_TYPES.source .expand (content, isObj(content.pin) && content.live !== true ? [content.pin.name] : ctx.files);
6607
- }
7218
+ itemContents(run).forEach(({ content: c }, k) => {
7219
+ const content = c ;
7220
+ if (content?.type !== "source") return;
7221
+ const files = k === 0 && ctx.files !== undefined ? ctx.files : ctx.filesOf?.(content.ref?.id);
7222
+ content.files = CONTENT_TYPES.source .expand (content, isObj(content.pin) && content.live !== true ? [content.pin.name] : files);
7223
+ });
6608
7224
  // Each Library group the evals read, as the version it grades with: the
6609
7225
  // body's fingerprint and, where the lab numbers them, `n` -- a pinned
6610
7226
  // link's pin.
@@ -6628,8 +7244,9 @@ function pipelineOfRun(run , ctx = {}) {
6628
7244
  delete doc.profiles;
6629
7245
  delete doc.comment;
6630
7246
  delete doc.plugins;
6631
- const content = contentOf(doc) ;
6632
- if (content) { delete content.files; delete content.revs; }
7247
+ for (const { content } of itemContents(doc)) {
7248
+ if (isObj(content)) { delete (content ).files; delete (content ).revs; }
7249
+ }
6633
7250
  // What the run recorded of each group it read is the run's. The grader
6634
7251
  // it carried stays, as a Metrics eval's did: a bundle writes it down.
6635
7252
  for (const t of doc.evals || []) {
@@ -6664,16 +7281,22 @@ function pipelineToYaml(doc , ctx
6664
7281
  return yaml.dump(out, { lineWidth: -1, noRefs: true });
6665
7282
  }
6666
7283
 
6667
- /** Every AI Connection reference in [doc] -- each target's, each of its
6668
- steps', each eval's grader -- replaced, in place, by [f] of it. A
6669
- Connection Profile is named by id and name, and left as it is. */
7284
+ /** Every AI Connection reference in [doc] -- each Variant's, each of its
7285
+ steps', each eval's grader -- replaced, in place, by [f] of it. The
7286
+ Variants and their Request steps are reached through the jobs-and-targets
7287
+ intermediate and serialised back; a Connection Profile is named by id and
7288
+ name, and left as it is. */
6670
7289
  function eachProfileRef(doc , f ) {
6671
- for (const t of targetsOf(doc)) {
7290
+ const jt = jtOf(doc);
7291
+ for (const t of jt.targets) {
6672
7292
  if (t.connection) t.connection = f(t.connection);
6673
7293
  for (const st of Array.isArray(t.steps) ? t.steps : []) {
6674
7294
  if (st?.connection) st.connection = f(st.connection);
6675
7295
  }
6676
7296
  }
7297
+ const re = serialize(jt.jobs, jt.targets, doc.steps);
7298
+ doc.steps = re.steps;
7299
+ doc.variants = re.variants;
6677
7300
  for (const t of Array.isArray(doc.evals) ? doc.evals : []) {
6678
7301
  if (isObj(t) && t.grader) t.grader = f(t.grader);
6679
7302
  if (isObj(t?.own) && t.own.grader) t.own.grader = f(t.own.grader);
@@ -6700,14 +7323,17 @@ function importPipeline(input , ctx = {}) {
6700
7323
  const v = versionProblem(doc);
6701
7324
  if (v) return { error: v };
6702
7325
  const profiles = ctx.profiles ?? [], sources = ctx.sources ?? [], groups = ctx.groups ?? [];
6703
- const next = clone(doc) ;
7326
+ // The references are remapped on the jobs-and-targets intermediate (where
7327
+ // content, every target's connection and each step's live), then the result
7328
+ // is serialised back into the version-20 step list and Variants.
7329
+ const next = jtOf(doc);
6704
7330
  const missing = [], seen = new Set ();
6705
7331
  const lists = { profile: profiles, connectionProfile: ctx.connectionProfiles ?? [], source: sources, group: groups };
6706
7332
  const words = {
6707
7333
  profile: (ref ) => `Connection ${ref.slug || ref.name || ref.id} not found`,
6708
7334
  connectionProfile: (ref ) => `Connection Profile ${ref.name || ref.id} not found`,
6709
7335
  source: (ref ) => `Source ${ref.name || ref.id} not found`,
6710
- group: (ref ) => `Eval group ${ref.name || ref.id} not found`,
7336
+ group: (ref ) => `Eval ${ref.name || ref.id} not found`,
6711
7337
  };
6712
7338
  const remap = (ref , kind ) => {
6713
7339
  const hit = lists[kind].find(x => x.id === ref.id) || lists[kind].find(x => x.name === ref.name);
@@ -6729,8 +7355,10 @@ function importPipeline(input , ctx = {}) {
6729
7355
  if (!seen.has(sentence)) { seen.add(sentence); missing.push(sentence); }
6730
7356
  return { id: ref.slug, name: isStr(ref.name) && ref.name ? ref.name : ref.slug };
6731
7357
  };
6732
- const content = contentOf(next) ;
6733
- if (content?.type === "source") content.ref = remap(content.ref, "source");
7358
+ for (const { content: c } of itemContents(next)) {
7359
+ const content = c ;
7360
+ if (content?.type === "source") content.ref = remap(content.ref, "source");
7361
+ }
6734
7362
  const split = (connection ) => {
6735
7363
  const p = preSplit && isRef(connection) && profiles.some(x => x.id === connection.id) ? ctx.connectionSplit?.(connection.id) : null;
6736
7364
  return p ? { id: p.id, name: p.name ?? "" } : null;
@@ -6766,14 +7394,16 @@ function importPipeline(input , ctx = {}) {
6766
7394
  // keep the ones it carries, so export → import → export is the same
6767
7395
  // document and a re-import can recognise the same pipeline (#93).
6768
7396
  mintIds(next);
7397
+ // Serialised back into the version-20 shape the lab reads.
7398
+ const out = serializeDoc(next);
6769
7399
  // What a document may still lack on import is its references -- matched
6770
7400
  // above and listed as missing. Anything else the model says is wrong is a
6771
7401
  // document the lab cannot edit and run as it stands, so it is refused by
6772
7402
  // the first such sentence. Validated with no lookups, so a reference a
6773
7403
  // later step will satisfy is never the refusal.
6774
- const bad = validatePipeline(next).filter(s => !/ not found$/.test(s));
7404
+ const bad = validatePipeline(out).filter(s => !/ not found$/.test(s));
6775
7405
  if (bad.length) return { error: bad[0] };
6776
- return { doc: next, missing };
7406
+ return { doc: out, missing };
6777
7407
  }
6778
7408
 
6779
7409
  /** Mint, in place, the ids a current-version document must have that it
@@ -6789,8 +7419,10 @@ function mintIds(doc ) {
6789
7419
  for (const t of Array.isArray(doc.evals) ? doc.evals : []) {
6790
7420
  if (isObj(t) && (!isStr(t.id) || !t.id)) t.id = newId();
6791
7421
  }
6792
- // Nor need it say how its linked groups pass together: all of them.
6793
- if (!("pass" in doc)) doc.pass = { mode: "all" };
7422
+ // Nor need it say how its linked groups pass together: all of them. (A
7423
+ // document serialised to version 20 carries a `pass` key even when it was
7424
+ // absent, so a null one is filled too.)
7425
+ if (doc.pass == null) doc.pass = { mode: "all" };
6794
7426
  return doc;
6795
7427
  }
6796
7428
 
@@ -7055,22 +7687,23 @@ function readBundle(files , ctx = {})
7055
7687
  function stagesFor(run , i , resolveFilters )
7056
7688
 
7057
7689
  {
7058
- const stages = run.jobs.map((ch, k) => {
7690
+ const jobs = jobsOf(run);
7691
+ const stages = jobs.map((ch, k) => {
7059
7692
  const { kind, modifiers: _mods, ...settings } = outOf(ch);
7060
- // The modifiers a run applies, with each Post-format filter set a
7061
- // synthetic modifier where its link sits (#339), and the Pre-format
7062
- // (text) filters applied to the raw reply before it is read (#405);
7693
+ // The modifiers a run applies, with the filters below the format a
7694
+ // synthetic modifier a link where the filters step sits (#339), and those
7695
+ // above it applied to the raw reply before it is read (#405, #615);
7063
7696
  // without a resolver, a Library link reads nothing, a private one its own.
7064
7697
  return { text: targetStepOf(run, i, k) .prompt, kind, withImage: sendsImage(ch),
7065
7698
  verbatim: !!targetEntryOf(run, i, k)?.verbatim, before: beforeFilters(ch, resolveFilters),
7066
- after: afterFilters(ch, resolveFilters), modifiers: stageModifiers(ch, resolveFilters), settings };
7699
+ modifiers: stageModifiers(ch, resolveFilters), settings };
7067
7700
  });
7068
7701
  // Each named by the connection whose key it is sent with.
7069
7702
  const asked = (key , held ) => {
7070
7703
  const { connection: _c, ...conn } = held || {} ;
7071
7704
  return { id: answerConnectionId(key, held), ...conn };
7072
7705
  };
7073
- const connections = run.jobs.map((_, k) => {
7706
+ const connections = jobs.map((_, k) => {
7074
7707
  // A step answered here asks nothing: the profile a run made before it
7075
7708
  // kept is what it ran as, else Echo itself.
7076
7709
  const key = targetAnswerKey(run, i, k);
@@ -7078,12 +7711,19 @@ function stagesFor(run , i , resolveFilters
7078
7711
  const held = key != null ? runAnswer(run, key) : undefined;
7079
7712
  return held && CONNECTION_TYPES[held.type]?.local ? asked(key , held) : { id: "", ...localConnOf(targetEntryOf(run, i, k)) };
7080
7713
  }
7081
- return key != null ? asked(key, runAnswer(run, key)) : { id: "" };
7714
+ if (key == null) return { id: "" };
7715
+ // A Request's own Model goes over its connection's, where the step's
7716
+ // model is the connection's to send (a Prompt; an HTTP Request places
7717
+ // its own in the body).
7718
+ const { model, settings: own } = targetStepOf(run, i, k) ?? {};
7719
+ const conn = asked(key, runAnswer(run, key));
7720
+ if (targetEntryOf(run, i, k)?.modelFrom !== "profile") return conn;
7721
+ return withOwnSettings(model?.trim() ? { ...conn, model: model.trim() } : conn, own);
7082
7722
  });
7083
7723
  // What a transport that builds its own request (an HTTP Request) builds
7084
7724
  // it from in each job, and this target's step there.
7085
- return { stages, tokens: run.jobs.map(tokensOf), connections,
7086
- calls: run.jobs.map((_, k) => requestStepOf(run, i, k)), cells: run.jobs.map((_, k) => targetStepOf(run, i, k) ) };
7725
+ return { stages, tokens: jobs.map(tokensOf), connections,
7726
+ calls: jobs.map((_, k) => requestStepOf(run, i, k)), cells: jobs.map((_, k) => targetStepOf(run, i, k) ) };
7087
7727
  }
7088
7728
 
7089
7729
  /** The Setup profile scenario [i] of a run ran under, as History shows it: keyless. */
@@ -7101,11 +7741,11 @@ function scenarioProfile(run , i )
7101
7741
  }
7102
7742
 
7103
7743
  /** The kind a run's result is read as: its last job's. */
7104
- const lastKind = (doc ) => outOf(doc.jobs?.at(-1))?.kind ?? null;
7744
+ const lastKind = (doc ) => outOf(jobsOf(doc).at(-1))?.kind ?? null;
7105
7745
 
7106
7746
  /** A value of a modifier on a run's last job -- the case mode History prints. */
7107
- const lastModifier = (doc , type ) =>
7108
- (outOf(doc.jobs?.at(-1))?.modifiers || []).find(m => m.type === type) || null;
7747
+ const lastModifier = (doc , type ) =>
7748
+ (outOf(jobsOf(doc).at(-1))?.modifiers || []).find(m => m.type === type) || null;
7109
7749
 
7110
7750
  /** What a modifier is set to, in a few words, from its entry's own options:
7111
7751
  a choice's label, a number, "3 rules" -- or "on" for one with none. What
@@ -7158,8 +7798,8 @@ function itemScores(run , kase , res
7158
7798
 
7159
7799
  /** What production recorded as the reply to an item, for a run's metrics: its
7160
7800
  last job's call says, from the item's record, or nobody does. */
7161
- function recordedReplyOf(run , record ) {
7162
- const last = run.jobs.at(-1), flow = flowStepOf(last);
7801
+ function recordedReplyOf(run , record ) {
7802
+ const last = jobsOf(run).at(-1), flow = flowStepOf(last);
7163
7803
  return record && flow ? STEP_TYPES.flowStep .production ({ ...flow, readAs: readAsOf(last) }, record) : null;
7164
7804
  }
7165
7805
 
@@ -7167,18 +7807,21 @@ function recordedReplyOf(run , record )
7167
7807
  * itemScores for the runner, which can wait: an eval that reads every item
7168
7808
  * (`read`: the Metrics, which may ask a grader) scores one with no case too.
7169
7809
  */
7170
- async function itemScoresAsync(run , kase , res ,
7810
+ async function itemScoresAsync(run , kase , res ,
7171
7811
  more = {}) {
7172
7812
  const out = {};
7173
7813
  let stopped = false;
7814
+ // The runner hands over a reply it just made, the re-score and the page a
7815
+ // stored one; a metric reads the same fields of either.
7816
+ const reply = res ;
7174
7817
  for (const t of evalsOf(run)) {
7175
7818
  const type = EVAL_TYPES[t.type];
7176
7819
  if (isWholeRun(type, t) || (!type?.read && !(type?.score && kase))) continue;
7177
7820
  if (stopped) { out[t.id] = SKIPPED; continue; }
7178
7821
  // An eval with nothing to read on this item leaves no entry, as a graded
7179
7822
  // eval does on an item with no case.
7180
- const s = type.read ? await type.read(t, kase ?? null, res, { plain: !OUTPUT_KINDS[lastKind(run) ?? ""]?.terms, ...more })
7181
- : type.score (t, kase , res);
7823
+ const s = type.read ? await type.read(t, kase ?? null, reply, { plain: !OUTPUT_KINDS[lastKind(run) ?? ""]?.terms, ...more })
7824
+ : type.score (t, kase , reply);
7182
7825
  if (!s) continue;
7183
7826
  out[t.id] = s;
7184
7827
  if (!s.pass && !t.continueOnFailure) stopped = true;
@@ -7186,6 +7829,35 @@ async function itemScoresAsync(run , kase ,
7186
7829
  return Object.keys(out).length ? out : null;
7187
7830
  }
7188
7831
 
7832
+ /**
7833
+ * A finished run's stored results re-scored, the kept replies untouched and
7834
+ * each per-item eval's verdict recomputed against the eval bodies [more]
7835
+ * hands back. This is how the Results view scores a run against the Eval as
7836
+ * it reads now (#657): with [more.group] resolving each linked group to its
7837
+ * current version, and [more.item] each result's own name, an edited eval
7838
+ * case is read at its latest version, and the run itself is not touched --
7839
+ * nothing here is written back.
7840
+ *
7841
+ * No model is asked: pass no [more.grader] and a model-graded metric reads as
7842
+ * unmet, exactly as the eval case editor's live preview does, so the view
7843
+ * makes no call. The one scorer is itemScoresAsync -- the same the runner's
7844
+ * --rescore reaches -- so the page and the runner cannot disagree. A
7845
+ * whole-run eval settles in scenarioEvals from the replies, so it is not
7846
+ * re-read here; only per-item verdicts move.
7847
+ */
7848
+ async function rescoreResults(run , results ,
7849
+ more = {}) {
7850
+ return Promise.all((results || []).map(async it => {
7851
+ if (!isObj(it) || !Array.isArray(it.scenarios)) return it ;
7852
+ const per = { ...more, item: isStr(it.name) ? it.name : null };
7853
+ const scenarios = await Promise.all((it.scenarios ).map(async side =>
7854
+ isObj(side) && side.res
7855
+ ? { ...side, scores: await itemScoresAsync(run, null, side.res, per) }
7856
+ : side));
7857
+ return { ...(it ), scenarios } ;
7858
+ }));
7859
+ }
7860
+
7189
7861
  /**
7190
7862
  * Every eval's reading of scenario [i] of a run, in the run's order, from
7191
7863
  * the items so far. [settled] says every item is in: only then has a
@@ -7231,13 +7903,41 @@ function scenarioEvals(run , i , items
7231
7903
  });
7232
7904
  }
7233
7905
 
7906
+ /**
7907
+ * A link's Whole run verdict, per Target, keyed by the eval's id: a Library
7908
+ * group's `run` metrics read over the replies so far (readGroupRun). A private
7909
+ * group's Whole run is scenarioEvals' own (it holds the body); a link's body
7910
+ * is held apart, so it is read here, beside the link's items (§17). [resolve]
7911
+ * gives a link's body by its reference: the runner's --groups, or the page's
7912
+ * kept or current versions. One that gives none reads nothing.
7913
+ */
7914
+ function wholeRunsOf(run , items ,
7915
+ resolve ) {
7916
+ const kind = lastKind(run);
7917
+ return targetsOf(run).map((_, i) => {
7918
+ const out = {};
7919
+ const ress = (items || []).map(it => (it && !it.unrun ? it.scenarios?.[i]?.res : undefined));
7920
+ for (const t of evalsOf(run)) {
7921
+ if (t.type !== "group" || isWholeRun(EVAL_TYPES.group, t)) continue;
7922
+ const body = groupOf(t, { group: resolve });
7923
+ const v = Array.isArray(body.run) && body.run.length ? readGroupRun(body , ress, kind) : null;
7924
+ if (v) out[t.id] = v;
7925
+ }
7926
+ return out;
7927
+ });
7928
+ }
7929
+
7234
7930
  /** One eval group's verdict over a scenario (§17): a whole-run group's own
7235
- verdict, or a per-item group's "every item it read passed"; null where it
7236
- was skipped, or had nothing to read yet. */
7931
+ verdict, or a per-item group's "every item it read passed" -- and, for a
7932
+ link reading a Whole run beside its items, that too: either failing fails
7933
+ it, as the runner's verdict does. Null where it was skipped, or had
7934
+ nothing to read yet. */
7237
7935
  function groupPass(o ) {
7238
7936
  if (o.skipped) return null;
7239
7937
  if (o.whole) return o.verdict?.ran ? o.verdict.pass : null;
7240
- return o.ran ? o.passed === o.ran : null;
7938
+ const item = o.ran ? o.passed === o.ran : null;
7939
+ const whole = o.wholeRun?.ran ? o.wholeRun.pass : null;
7940
+ return item === false || whole === false ? false : item ?? whole;
7241
7941
  }
7242
7942
 
7243
7943
  /** A scenario's pass or fail over every eval that read it: null where none
@@ -7287,25 +7987,25 @@ function passVerdict(pass , passes
7287
7987
  function validateEvals(ev , files ) {
7288
7988
  const bad = [];
7289
7989
  if (!ev || typeof ev !== "object" || Array.isArray(ev)) {
7290
- return ["the graded set has to be a JSON object with a `cases` list"];
7990
+ return ["an eval has to be a JSON object with a `cases` list"];
7291
7991
  }
7292
7992
  if (ev.cases != null && !Array.isArray(ev.cases)) bad.push("cases has to be a list");
7293
7993
  // An eval group's own sections (version 7): what a Metrics eval held.
7294
7994
  if (ev.scoring != null) {
7295
7995
  const sc = ev.scoring;
7296
- if (!isObj(sc) || !SCORING_MODES.includes(sc.mode)) bad.push(`a group is scored ${SCORING_MODES.join(" or ")}`);
7297
- else if (sc.mode === "weighted" && typeof sc.threshold !== "number") bad.push("a group scored in points needs Pass at: the points an item has to reach");
7298
- else if (sc.threshold != null && typeof sc.threshold !== "number") bad.push("a group's Pass at has to be a number");
7996
+ if (!isObj(sc) || !SCORING_MODES.includes(sc.mode)) bad.push(`an eval is scored ${SCORING_MODES.join(" or ")}`);
7997
+ else if (sc.mode === "weighted" && typeof sc.threshold !== "number") bad.push("an eval scored in points needs Pass at: the points an item has to reach");
7998
+ else if (sc.threshold != null && typeof sc.threshold !== "number") bad.push("an eval's Pass at has to be a number");
7299
7999
  }
7300
- if (ev.grader != null && !isRef(ev.grader)) bad.push("a group names its grader as { id, name }, or null");
7301
- if (ev.every != null) metricsProblems(ev.every, "Every item", bad);
8000
+ if (ev.grader != null && !isRef(ev.grader)) bad.push("an eval names its grader as { id, name }, or null");
8001
+ if (ev.every != null) metricsProblems(ev.every, "Every item metrics", bad);
7302
8002
  if (ev.run != null) {
7303
- metricsProblems(ev.run, "Whole run", bad);
8003
+ metricsProblems(ev.run, "Whole run metrics", bad);
7304
8004
  // Read once over every reply: nothing that reads one item, or asks a grader.
7305
8005
  for (const m of Array.isArray(ev.run) ? ev.run : []) {
7306
8006
  const entry = isObj(m) ? METRICS[m.type] : undefined;
7307
- if (entry?.graded) bad.push(`Whole run: ${entry.label} is model-graded, so it cannot read a whole run`);
7308
- else if (entry?.perItem) bad.push(`Whole run: ${entry.label} reads one item at a time, so it cannot read a whole run`);
8007
+ if (entry?.graded) bad.push(`Whole run metrics: ${entry.label} is model-graded, so it cannot read a whole run`);
8008
+ else if (entry?.perItem) bad.push(`Whole run metrics: ${entry.label} reads one item at a time, so it cannot read a whole run`);
7309
8009
  }
7310
8010
  }
7311
8011
  if (bad.length) return bad;
@@ -7316,13 +8016,13 @@ function validateEvals(ev , files
7316
8016
  const seen = new Set ();
7317
8017
  for (const c of graded) {
7318
8018
  if (!isObj(c)) {
7319
- bad.push("cases holds something that is not a case");
8019
+ bad.push("cases holds something that is not an eval case");
7320
8020
  continue;
7321
8021
  }
7322
- if (!isStr(c.id) || !c.id.trim()) bad.push("a case has no id");
8022
+ if (!isStr(c.id) || !c.id.trim()) bad.push("an eval case has no id");
7323
8023
  else if (seen.has(c.id)) bad.push(`${c.id} is graded twice`);
7324
8024
  else seen.add(c.id);
7325
- if (!caseItem(c).trim()) bad.push(`${c.id || "a case"} names no item`);
8025
+ if (!caseItem(c).trim()) bad.push(`${c.id || "an eval case"} names no item`);
7326
8026
  if (c.todo != null && typeof c.todo !== "boolean") bad.push(`${c.id}: todo is true or false`);
7327
8027
  if (c.note != null && !isStr(c.note)) bad.push(`${c.id}: note has to be text`);
7328
8028
  if (c.metrics != null) metricsProblems(c.metrics, String(c.id), bad);
@@ -7404,8 +8104,8 @@ function validateEvals(ev , files
7404
8104
  // set and graded by nobody is one nobody discovers is ungraded.
7405
8105
  for (const f of shown) {
7406
8106
  if (!gradedAt(f)) {
7407
- bad.push(`${f} is in the Source and this dataset does not grade it, `
7408
- + `so the Cases view does not list it`);
8107
+ bad.push(`${f} is in the Source and this eval does not grade it, `
8108
+ + `so the Eval Cases view does not list it`);
7409
8109
  }
7410
8110
  }
7411
8111
  return bad;
@@ -7469,18 +8169,18 @@ export {
7469
8169
  tallyPercent, runPipeline, validateEvals, evalsWarnings, evalsJson, readList,
7470
8170
  scoreSingle, singleRules, parseCount, lengthHolds, countHolds, COUNT_OPS, LENGTH_OPS,
7471
8171
  COUNT_OP_LABEL, LENGTH_OP_LABEL,
7472
- PIPELINE_VERSION, DATASET_BODY_VERSION, STEP_TYPES, CONTENT_TYPES, OUTPUT_KINDS, MODIFIERS, EVAL_TYPES, LEGACY_TESTS, METRICS, readMetrics, metricSummary, metricCheck, itemScoresAsync, recordedReplyOf,
8172
+ PIPELINE_VERSION, DATASET_BODY_VERSION, STEP_TYPES, CONTENT_TYPES, OUTPUT_KINDS, MODIFIERS, EVAL_TYPES, LEGACY_TESTS, METRICS, readMetrics, metricSummary, metricCheck, itemScoresAsync, rescoreResults, recordedReplyOf,
7473
8173
  SOURCE_TYPES, DEFAULT_SOURCE_TYPE, sourceTypeOf, WORKFLOW_PLATFORMS, platformOf, DYNAMIC_PROVIDERS, providerOf, WIZARDS, wizardProgress,
7474
8174
  registerKinds, defaultKind, jobDefaults, applyModifiers, keyVar, slugOf, uniqueSlug, isSlug, withSlugs, SLUG_MAX, connectionOf, addressOf, connectionSettings,
7475
8175
  splitProfile, mergeAnswer, answerKey, answerOfKey, answersOf, runAnswer, answerConnectionId, connectionScoped,
7476
8176
  connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, replyFinish, contentJoined, connectionImage, connectionProblems,
7477
8177
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
7478
- contentOf, withContent, replyOf,
8178
+ contentOf, itemContents, runItemsOf, withContent, replyOf, jobsOf, withJobs, withTargets, serialize, stepNames, stepFaults,
7479
8179
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7480
8180
  tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7481
- filterSetsOf, withFilterSets, stageModifiers, beforeFilters, afterFilters, gradedText,
8181
+ filterSetsOf, filterFormatOf, withFilterSets, formatPlace, stageModifiers, beforeFilters, gradedText,
7482
8182
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7483
- scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
8183
+ scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, wholeRunsOf, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
7484
8184
  evalLabel, evalsOf, evalsDataset, casesRef, groupOf, groupOfMetrics, pipelineWarnings, itemsFound, previewOf, isWholeRun, EVAL_FIELDS,
7485
8185
  pipelineToYaml, importPipeline, yamlToPipeline, exportBundle, readBundle, bundleProblems, junitXml,
7486
8186
  pluginHost, legacyTagsOut, legacyTagsModifiers, legacyTagsJob, splitItems, tidy, tryExamples, itemRulesProblems, dropBy, rejectBy, matcher,