evals-lab 0.10.0 → 0.11.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.
@@ -146,8 +146,7 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
146
146
  held inline, run at the step's `stage` (#432: a set has no stage of its
147
147
  own; the step runs its filters of that stage). A job may hold one `filters` step of
148
148
  each stage -- a `text` one run on the raw reply before it is read into
149
- items (before Read as), an `items` one run on the items after (after Read
150
- as). */
149
+ items (Pre-format), an `items` one run on the items after (Post-format). */
151
150
 
152
151
 
153
152
 
@@ -221,9 +220,11 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
221
220
 
222
221
 
223
222
  /** What a target's step says beside its type: what to ask in that job,
224
- and whom, where it names a profile of its own. */
223
+ and whom, where it names an AI Connection of its own -- and, optionally,
224
+ a Connection Profile over it (version 16). */
225
225
 
226
226
 
227
+
227
228
 
228
229
 
229
230
 
@@ -249,8 +250,11 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
249
250
 
250
251
 
251
252
 
252
-
253
-
253
+
254
+
255
+
256
+
257
+
254
258
 
255
259
 
256
260
 
@@ -345,7 +349,7 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
345
349
  * at. `filters` are tried in order -- each drop-item narrows the items the
346
350
  * next reads, and the first reject-reply that fires gates the answer.
347
351
  */
348
- /** Which side of Read as a filter runs on (#405): `text` the raw reply before
352
+ /** Which side of Response Format a filter runs on (#405): `text` the raw reply before
349
353
  it is read into items (rewrite-text / reject-text), `items` the items after
350
354
  (drop-item, rewrite-item, keep-first, reject-reply). A filter's type decides
351
355
  its stage; a set belongs to none (#432), and runs its share of filters in
@@ -356,6 +360,9 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
356
360
  reply; item, list and answer filters read the items it is read into. */
357
361
  export const stageOfScope = (scope ) => (scope === "text" ? "text" : "items");
358
362
 
363
+ /** A filter stage as the page names it: before the reply is formatted, or after. */
364
+ export const FILTER_STAGE_LABEL = { text: "Pre-format", items: "Post-format" };
365
+
359
366
 
360
367
 
361
368
 
@@ -401,11 +408,16 @@ export const stageOfScope = (scope ) => (scope === "text" ?
401
408
 
402
409
 
403
410
 
404
- /** A Setup profile as a run carries it: request settings, never the key. */
411
+ /** What a target asked, as a run carries it: the AI Connection and its
412
+ Connection Profile's settings resolved into one -- request settings,
413
+ never the key. */
405
414
 
406
415
 
407
-
416
+
408
417
 
418
+
419
+
420
+
409
421
 
410
422
 
411
423
 
@@ -420,6 +432,8 @@ export const stageOfScope = (scope ) => (scope === "text" ?
420
432
  /** A pipeline resolved at submit (§5): what the queue runs and History
421
433
  reads. The additions, and nothing else. */
422
434
 
435
+
436
+
423
437
 
424
438
 
425
439
 
@@ -455,6 +469,38 @@ export const stageOfScope = (scope ) => (scope === "text" ?
455
469
 
456
470
 
457
471
 
472
+ /**
473
+ * An AI Connection as Setup stores it (version 16): where the lab sends a
474
+ * prompt -- its type, address, key and model, and any of its type's settings
475
+ * that say how it is reached (`scope: "connection"`). Its key is one typed
476
+ * in this browser, or KEY_HELD and its id, or empty.
477
+ */
478
+
479
+
480
+
481
+
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+ /**
490
+ * A Connection Profile as Setup stores it: settings a target may carry over
491
+ * its connection -- model and image settings, each optional, an absent one
492
+ * the lab's default -- and, optionally, the connection it answers on in
493
+ * place of the target's. One split from a Target profile is stamped with
494
+ * that profile's id (`fromProfile`), which is what an upgrade finds it by.
495
+ */
496
+
497
+
498
+
499
+
500
+
501
+
502
+
503
+
458
504
  /** A connection's settings in one flat set, the shape a type reads. */
459
505
 
460
506
 
@@ -468,18 +514,22 @@ export const stageOfScope = (scope ) => (scope === "text" ?
468
514
  /** A pipeline read from YAML, references remapped to this lab. */
469
515
 
470
516
 
471
-
517
+
472
518
 
473
519
 
474
520
 
475
521
 
476
- /** A Target profile as an import or export matches it: its slug, where it
522
+ /** An AI Connection as an import or export matches it: its slug, where it
477
523
  has one -- withSlugs gives the rest theirs. */
478
524
 
479
525
 
480
526
  /** What an import matches references against: { id, name } each. */
481
527
 
528
+
529
+
482
530
 
531
+
532
+
483
533
 
484
534
 
485
535
 
@@ -487,6 +537,8 @@ export const stageOfScope = (scope ) => (scope === "text" ?
487
537
 
488
538
 
489
539
 
540
+
541
+
490
542
 
491
543
 
492
544
  /** An import's outcome: the remapped pipeline, or one sentence refusing it. */
@@ -501,7 +553,7 @@ export const stageOfScope = (scope ) => (scope === "text" ?
501
553
 
502
554
 
503
555
 
504
-
556
+
505
557
 
506
558
 
507
559
 
@@ -576,10 +628,24 @@ export function kept(text , max = KEPT_CHARS) {
576
628
 
577
629
 
578
630
 
631
+
632
+
633
+
634
+
579
635
 
580
636
 
581
637
 
582
638
 
639
+ /** Which filter discarded a job's reply, or emptied it, and where (#435). */
640
+
641
+
642
+
643
+
644
+
645
+
646
+
647
+
648
+
583
649
  /** What runPipeline is told besides the stages. */
584
650
 
585
651
 
@@ -943,6 +1009,9 @@ export function kept(text , max = KEPT_CHARS) {
943
1009
 
944
1010
 
945
1011
 
1012
+
1013
+
1014
+
946
1015
 
947
1016
 
948
1017
  /**
@@ -1000,7 +1069,12 @@ export function kept(text , max = KEPT_CHARS) {
1000
1069
  the lab holds. Each is optional, and a
1001
1070
  reference nothing is given to look up is not checked. */
1002
1071
 
1003
-
1072
+
1073
+
1074
+
1075
+
1076
+
1077
+
1004
1078
 
1005
1079
 
1006
1080
 
@@ -1299,7 +1373,7 @@ const SLOTS = ["content", "target", "responses"];
1299
1373
 
1300
1374
 
1301
1375
 
1302
-
1376
+
1303
1377
 
1304
1378
 
1305
1379
 
@@ -1578,6 +1652,10 @@ function datasetRules(body ) {
1578
1652
 
1579
1653
 
1580
1654
 
1655
+
1656
+
1657
+
1658
+
1581
1659
 
1582
1660
 
1583
1661
 
@@ -2417,12 +2495,12 @@ CONNECTION_TYPES.http = {
2417
2495
  id: "http", label: "HTTP endpoint",
2418
2496
  description: "Any API a Power Automate step calls: an address, a key and how the key is sent.",
2419
2497
  settings: [
2420
- { key: "auth", label: "Key header", control: "select", default: "bearer", hint: "",
2498
+ { key: "auth", label: "Key header", control: "select", default: "bearer", hint: "", scope: "connection",
2421
2499
  choices: [{ value: "bearer", label: "Authorization: Bearer" }, { value: "x-api-key", label: "x-api-key" },
2422
2500
  { value: "api-key", label: "api-key" }, { value: "none", label: "none" }] },
2423
- { key: "headers", label: "Headers", control: "text", default: "", hint: "" },
2501
+ { key: "headers", label: "Headers", control: "text", default: "", hint: "", scope: "connection" },
2424
2502
  // What Test connection asks of it: a GET, keyed as a run is.
2425
- { key: "testPath", label: "Test path", control: "text", default: "", hint: "" },
2503
+ { key: "testPath", label: "Test path", control: "text", default: "", hint: "", scope: "connection" },
2426
2504
  ],
2427
2505
  answers: ["request"],
2428
2506
  lists: false,
@@ -3100,22 +3178,30 @@ async function runPipeline(stages , dataUrl , cal
3100
3178
  // and that answer is graded as though the job had worked.
3101
3179
  const handsOn = i < stages.length - 1;
3102
3180
  const against = res.readAgainst ?? echo;
3103
- // The before-Read-as (text) filters shape the raw reply before the kind
3181
+ // The Pre-format (text) filters shape the raw reply before the kind
3104
3182
  // reads it (#405): a Rewrite text reshapes it, a Reject text ends the job.
3105
3183
  const pre = applyFilterSet(st.before || [], { body: res.raw ?? "", items: [], instruction: against }, "text");
3106
- if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`) });
3184
+ const by = (filter , stage , rejected ) => ({ filter, stage, job: i + 1, rejected });
3185
+ if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, "text", true) });
3107
3186
  const read = reader.read(pre.body, { echo: against, parsing: opts.parsing, settings: st.settings });
3108
3187
  if (read.error) return result({ error: named(read.error) });
3109
3188
  const mod = applyModifiers(mods[i], kind, read.value, { instruction: against, body: read.body ?? res.raw ?? "" });
3110
- if (mod.reject) return result({ error: named(`discarded: ${mod.reject}`) });
3189
+ if (mod.reject) {
3190
+ return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, "items", true) } : {}) });
3191
+ }
3192
+ // A filter that dropped the last item: what a zero result is explained by.
3193
+ const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, "items", false) } : {};
3111
3194
  const nothing = handsOn ? reader.handsOn?.(mod.value) : null;
3112
- if (nothing) return result({ error: named(nothing) });
3195
+ if (nothing) return result({ error: named(nothing), ...emptied });
3113
3196
  const value = mod.value;
3114
3197
  held = { kind, value };
3115
3198
  const gone = [...(read.dropped || []), ...mod.dropped];
3116
3199
  echoed = (read.echoed || 0) + mod.dropped.filter(d => d.by === ECHO).length;
3117
3200
  done.push({ reply: res.raw ?? "", rendered: reader.render?.(value) || {} });
3118
- if (!handsOn) dropped = gone;
3201
+ if (!handsOn) {
3202
+ dropped = gone;
3203
+ return result({ echoed, dropped, ...emptied });
3204
+ }
3119
3205
  }
3120
3206
  return result({ echoed, dropped });
3121
3207
  }
@@ -3132,7 +3218,7 @@ function readList(reply , out , instruction =
3132
3218
  const reader = OUTPUT_KINDS[out.kind];
3133
3219
  if (!reader) return { items: [], dropped: [], echoed: 0, error: `"${out.kind}" is not an output kind this lab has` };
3134
3220
  const { kind, modifiers, before, ...settings } = out;
3135
- // The before-Read-as (text) filters shape the raw reply before the kind
3221
+ // The Pre-format (text) filters shape the raw reply before the kind
3136
3222
  // reads it (#405); a Reject text ends the job.
3137
3223
  const pre = applyFilterSet(before || [], { body: reply, items: [], instruction }, "text");
3138
3224
  if (pre.reject != null) return { items: [], dropped: [], echoed: 0, error: `discarded: ${pre.reject}` };
@@ -3153,7 +3239,7 @@ function readList(reply , out , instruction =
3153
3239
  was thrown away. */
3154
3240
  function applyModifiers (list , kind , value ,
3155
3241
  ctx = { instruction: "", body: "" }) {
3156
- let v = value;
3242
+ let v = value, by ;
3157
3243
  const dropped = [];
3158
3244
  for (const m of list || []) {
3159
3245
  const entry = MODIFIERS[m.type];
@@ -3164,9 +3250,10 @@ function applyModifiers (list , kind ,
3164
3250
  if (!outcome) { v = out ; continue; }
3165
3251
  v = outcome.value;
3166
3252
  dropped.push(...(outcome.dropped || []));
3167
- if (outcome.reject) return { value: v, dropped, reject: outcome.reject };
3253
+ if (outcome.reject) return { value: v, dropped, reject: outcome.reject, ...(outcome.by ? { by: outcome.by } : {}) };
3254
+ by = outcome.by ?? by;
3168
3255
  }
3169
- return { value: v, dropped };
3256
+ return { value: v, dropped, ...(by ? { by } : {}) };
3170
3257
  }
3171
3258
 
3172
3259
  // ---- The pipeline model ----------------------------------------------------
@@ -3205,7 +3292,12 @@ function applyModifiers (list , kind ,
3205
3292
  // 12: a Contains metric's Ignore case holds item by item too.
3206
3293
  // 13: an eval is a link to an eval group, or a group of the pipeline's own,
3207
3294
  // and the document has an overall pass rule (pipeline-model §17).
3208
- const PIPELINE_VERSION = 15 ;
3295
+ // 14: a job's Responses stage links filter sets on a `filters` step (§18).
3296
+ // 15: every cleaning step is a filter, Pre- or Post-format (#405).
3297
+ // 16: a target names an AI Connection and, optionally, a Connection Profile,
3298
+ // where it named one Target profile; a run's table is keyed by the pair
3299
+ // (answerKey) -- the Target profile split in the store as it opens (#445).
3300
+ const PIPELINE_VERSION = 16 ;
3209
3301
 
3210
3302
  // Plain objects, so an entry is added by assignment and a reader never needs
3211
3303
  // to know which registered it.
@@ -3338,7 +3430,7 @@ OUTPUT_KINDS.text = {
3338
3430
  // name is only a label and two profiles can share one. #46: the profile
3339
3431
  // carries `type`, and the run's resolved copy carries it too
3340
3432
  // (docs/pipeline-model.md §7).
3341
- const CONNECTION_FIELDS = ["name", "slug", "url", "model", "type", "temperature",
3433
+ const CONNECTION_FIELDS = ["name", "slug", "connection", "url", "model", "type", "temperature",
3342
3434
  "px", "format", "quality", "options"];
3343
3435
  const SETTING_KEYS = [...new Set(Object.values(CONNECTION_TYPES).flatMap(t => t.settings.map(s => s.key)))];
3344
3436
  const OPTION_FIELDS = ["seed", "nPredict", ...DECODING_KEYS];
@@ -3346,6 +3438,9 @@ const IMAGE_FORMATS = ["image/jpeg", "image/webp", "image/png
3346
3438
  // Ids are what a key variable is spelled from, so they stay spellable: a
3347
3439
  // letter or digit run, with at most one hyphen.
3348
3440
  const PROFILE_ID = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?$/;
3441
+ // A run's table key (answerKey): a connection's id, then a dot and its
3442
+ // profile's id -- or nothing, for none -- or a grader's bare id.
3443
+ const ANSWER_KEY = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?(?:\.(?:[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?)?)?$/;
3349
3444
  const LOOKS_LIKE_A_KEY = /^(?:api[-_]?)?key$|^(?:authorization|bearer|secret|password|token)$/i;
3350
3445
 
3351
3446
  /** The variable a profile's key is read from: its slug's, when it has one --
@@ -3441,6 +3536,138 @@ function connectionSettings(c ) {
3441
3536
  return { ...rest, ...(options || {}) };
3442
3537
  }
3443
3538
 
3539
+ // ---- AI Connections and Connection Profiles (version 16) ---------------------
3540
+ // A Target profile was one stored thing: where a prompt goes and the settings
3541
+ // it goes with. It is two now -- an AI Connection (type, address, key, model)
3542
+ // and an optional Connection Profile (settings, and optionally a connection of
3543
+ // its own in place of the target's) -- and a target names both. A store from
3544
+ // before is split once as it opens (server.py `split_profiles`): each profile
3545
+ // becomes a connection keeping its id and, where it set settings, a profile
3546
+ // stamped with that id (`fromProfile`). What a run carries of a pair is the
3547
+ // two resolved into one Connection, as a profile resolved before.
3548
+
3549
+ /** What an AI Connection keeps of a Target profile, beside its type's
3550
+ connection-scoped settings. A legacy `profile` (#46) stays with the
3551
+ connection: it says the type until the page converts it. */
3552
+ const AI_CONNECTION_FIELDS = ["id", "name", "slug", "type", "url", "key", "model", "profile"];
3553
+ /** What a Connection Profile holds that is not a setting. */
3554
+ const CONNECTION_PROFILE_FIELDS = ["id", "name", "connection", "fromProfile"];
3555
+
3556
+ /** The settings of [type] that say how a connection is reached. */
3557
+ const connectionScoped = (type ) =>
3558
+ (CONNECTION_TYPES[type]?.settings ?? []).filter(s => s.scope === "connection").map(s => s.key);
3559
+
3560
+ /** Whether a stored setting is set: blank is the lab's default. */
3561
+ const setValue = (v ) => v != null && v !== "";
3562
+
3563
+ /**
3564
+ * A Target profile as an AI Connection and, where it set any setting, the
3565
+ * Connection Profile holding them -- the same id, stamped with its origin.
3566
+ * Resolved together (`mergeAnswer`) they send what the profile sent.
3567
+ * server.py's `split_profile` is its twin, held to it by proxy-check.py.
3568
+ */
3569
+ function splitProfile(p ) {
3570
+ const scoped = connectionScoped(typeOf(p ));
3571
+ const connection = {}, settings = {};
3572
+ for (const [k, v] of Object.entries(p)) {
3573
+ if (AI_CONNECTION_FIELDS.includes(k) || scoped.includes(k)) connection[k] = v;
3574
+ else if (setValue(v)) settings[k] = v;
3575
+ }
3576
+ const profile = Object.keys(settings).length
3577
+ ? { id: String(p.id ?? ""), name: String(p.name ?? ""), fromProfile: String(p.id ?? ""), ...settings } : null;
3578
+ return { connection, profile };
3579
+ }
3580
+
3581
+ /** [conn] with [profile]'s settings over it: the one set of settings a
3582
+ request is built from, the shape a Target profile had. A profile sets
3583
+ no connection field. */
3584
+ function mergeAnswer(conn , profile ) {
3585
+ const out = { ...conn };
3586
+ const scoped = connectionScoped(typeOf(conn ));
3587
+ for (const [k, v] of Object.entries(profile ?? {})) {
3588
+ if (!CONNECTION_PROFILE_FIELDS.includes(k) && !AI_CONNECTION_FIELDS.includes(k) && !scoped.includes(k) && setValue(v)) out[k] = v;
3589
+ }
3590
+ return out;
3591
+ }
3592
+
3593
+ /** Whom a target or one of its steps asks: an AI Connection, and the
3594
+ Connection Profile over it, or none. */
3595
+
3596
+
3597
+
3598
+
3599
+
3600
+ /** The key a run's table holds [connection] under, with [profile] -- or with
3601
+ none, the lab's defaults. A grader names its connection bare, its id alone,
3602
+ so it never meets a target's pair (see `answersOf`). */
3603
+ const answerKey = (connection , profile ) => `${connection}.${profile ?? ""}`;
3604
+
3605
+ /** [key] read back: a pair's connection and profile, or a bare id. */
3606
+ function answerOfKey(key ) {
3607
+ const at = key.indexOf(".");
3608
+ if (at < 0) return { connection: key, profile: null, bare: true };
3609
+ return { connection: key.slice(0, at), profile: key.slice(at + 1) || null, bare: false };
3610
+ }
3611
+
3612
+ /** Whom target [i] asks in job [k]: its step's own connection or profile,
3613
+ where it names either, else the target's. */
3614
+ function targetAnswerOf(doc , i , k ) {
3615
+ const t = targetsOf(doc)[i];
3616
+ const st = targetStepOf(doc, i, k);
3617
+ const own = st?.connection != null || st?.profile != null;
3618
+ const connection = (own ? st?.connection ?? t?.connection : t?.connection) ?? null;
3619
+ if (!isRef(connection)) return undefined;
3620
+ const profile = (own ? st?.profile : t?.profile) ?? null;
3621
+ return { connection, profile: isRef(profile) ? profile : null };
3622
+ }
3623
+
3624
+ /** The answer key of target [i] in job [k], or undefined where it asks none. */
3625
+ function targetAnswerKey(doc , i , k ) {
3626
+ const a = targetAnswerOf(doc, i, k);
3627
+ return a ? answerKey(a.connection.id, a.profile?.id) : undefined;
3628
+ }
3629
+
3630
+ /**
3631
+ * What the lab's two stores answer: each by id, the profile split from a
3632
+ * Target profile's id (what an upgrade and a grader read it by), and an
3633
+ * answer key resolved to the settings it sends -- the profile's connection
3634
+ * where it names one, else the key's, with the profile's settings over it.
3635
+ * A grader's bare id resolves as the Target profile it named did: its
3636
+ * connection under the profile split from it. Null where any part is gone.
3637
+ */
3638
+ function answersOf(connections , profiles ) {
3639
+ const connection = (id ) => connections.find(c => c?.id === id) ?? null;
3640
+ const profile = (id ) => profiles.find(p => p?.id === id) ?? null;
3641
+ const split = (id ) => {
3642
+ const p = profiles.find(x => x?.fromProfile === id);
3643
+ return p ? { id: p.id, name: p.name } : null;
3644
+ };
3645
+ const resolve = (key ) => {
3646
+ const at = answerOfKey(key);
3647
+ const pid = at.bare ? split(at.connection)?.id ?? null : at.profile;
3648
+ const p = pid ? profile(pid) : null;
3649
+ if (pid && !p) return null;
3650
+ const c = connection(isRef(p?.connection) ? p .connection .id : at.connection);
3651
+ return c ? mergeAnswer(c, p) : null;
3652
+ };
3653
+ return { connection, profile, split, resolve };
3654
+ }
3655
+
3656
+ /** The connection a run carries for [key]: its own entry, or -- a run from
3657
+ before version 16, whose table is keyed by Target profile id, and names
3658
+ the connection alone once upgraded -- the one under the connection's id. */
3659
+ function runAnswer(run , key ) {
3660
+ const table = run?.profiles ;
3661
+ if (!isObj(table)) return undefined;
3662
+ if (Object.hasOwn(table, key)) return table[key];
3663
+ const at = answerOfKey(key);
3664
+ return !at.bare && at.profile == null && Object.hasOwn(table, at.connection) ? table[at.connection] : undefined;
3665
+ }
3666
+
3667
+ /** The id of the AI Connection whose key a run's entry [key] is sent with. */
3668
+ const answerConnectionId = (key , conn ) =>
3669
+ (isStr(conn?.connection) && conn .connection) || answerOfKey(key).connection;
3670
+
3444
3671
  // The rest of what a connection's fields mean lives in the connection-type
3445
3672
  // registry above (#46): the runner asks a type how to build a request and how
3446
3673
  // to read a reply, and validation asks connectionProblems (below, with the run
@@ -3721,7 +3948,7 @@ DYNAMIC_PROVIDERS["command"] = { // provider: the Command adapter's own id
3721
3948
  holds, so a tick costs a predicate and not a request. A reader names a field
3722
3949
  it needs; a wizard that reads more declares it here. */
3723
3950
 
3724
-
3951
+
3725
3952
 
3726
3953
 
3727
3954
 
@@ -4091,14 +4318,14 @@ LEGACY_TESTS.metrics = {
4091
4318
  if (t.grader != null && !isRef(t.grader)) bad.push("the Metrics name their grader as { id, name }");
4092
4319
  // The eval's own grader, or the lab's.
4093
4320
  const grader = isRef(t.grader) ? t.grader : isRef(ctx.grader) ? ctx.grader : null;
4094
- if (gradedIn(t.metrics) && !grader) bad.push("a model-graded metric needs a grader: pick one on the eval, or make a Target profile the lab's grader");
4321
+ if (gradedIn(t.metrics) && !grader) bad.push("a model-graded metric needs a grader: pick one on the eval, or make a connection the lab's grader");
4095
4322
  // A grader is asked words, and needs a model to ask.
4096
4323
  const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4097
- if (grader && ctx.profiles && !p) bad.push(`Target profile ${grader.name || grader.id} not found`);
4324
+ if (grader && ctx.profiles && !p) bad.push(`Connection ${grader.name || grader.id} not found`);
4098
4325
  else if (p) {
4099
4326
  const type = CONNECTION_TYPES[typeOf(p )];
4100
4327
  if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4101
- else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's Target profile");
4328
+ else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4102
4329
  }
4103
4330
  },
4104
4331
  rules: t => (Array.isArray(t.metrics) ? t.metrics : []).map((m , x ) => ({
@@ -4226,14 +4453,14 @@ EVAL_TYPES.group = {
4226
4453
  if (own.run.length && !wholeRunGroup(t)) bad.push("the group's own body reads each item or the whole run, not both");
4227
4454
  // The group's own grader, or the lab's.
4228
4455
  const grader = isRef(own.grader) ? own.grader : isRef(ctx.grader) ? ctx.grader : null;
4229
- if (gradedIn(own.every) && !grader) bad.push("a model-graded metric needs a grader: pick one on the eval, or make a Target profile the lab's grader");
4456
+ 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");
4230
4457
  // A grader is asked words, and needs a model to ask.
4231
4458
  const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4232
- if (grader && ctx.profiles && !p) bad.push(`Target profile ${grader.name || grader.id} not found`);
4459
+ if (grader && ctx.profiles && !p) bad.push(`Connection ${grader.name || grader.id} not found`);
4233
4460
  else if (p) {
4234
4461
  const type = CONNECTION_TYPES[typeOf(p )];
4235
4462
  if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4236
- else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's Target profile");
4463
+ else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4237
4464
  }
4238
4465
  },
4239
4466
  rules: t => ownList(t).map((m , x ) => ({
@@ -4565,7 +4792,7 @@ STEP_TYPES.prompt = {
4565
4792
  description: "Asks each target's model its prompt about the item.",
4566
4793
  in: "item", out: "text",
4567
4794
  apply: "runPipeline",
4568
- fields: ["type", "prompt", "profile", "from"],
4795
+ fields: ["type", "prompt", "connection", "profile", "from"],
4569
4796
  validate(step, _ctx, bad, at){
4570
4797
  if (!isStr(step.prompt)) bad.push(`${at}: prompt has to be text`);
4571
4798
  },
@@ -4597,7 +4824,7 @@ STEP_TYPES.httpRequest = {
4597
4824
  ...(HTTP_APIS[step.api] ?? HTTP_APIS.raw ).cellOf(step.body) }),
4598
4825
  in: "item", out: "text",
4599
4826
  apply: "runPipeline",
4600
- fields: ["type", "api", "method", "path", "query", "body", "prompt", "profile", "from", "system", "model", "settings", "prefill"],
4827
+ fields: ["type", "api", "method", "path", "query", "body", "prompt", "connection", "profile", "from", "system", "model", "settings", "prefill"],
4601
4828
  validate(step, _ctx, bad, at){
4602
4829
  const api = HTTP_APIS[step.api];
4603
4830
  if (!api) bad.push(`${at}: "${step.api}" is not an HTTP API this lab has`);
@@ -4608,7 +4835,7 @@ STEP_TYPES.httpRequest = {
4608
4835
  // A key is the Setup profile's, sent by its Key header; one in the
4609
4836
  // template would be kept in the pipeline and every run of it.
4610
4837
  const hit = keyField(step.body) ?? keyField(step.query, "query");
4611
- if (hit) bad.push(`${at} carries a key at ${hit}, and a key never goes in a pipeline -- the Target profile sends it`);
4838
+ if (hit) bad.push(`${at} carries a key at ${hit}, and a key never goes in a pipeline -- the connection sends it`);
4612
4839
  if (!api) return;
4613
4840
  // Its fields, where its HTTP API has a place for them.
4614
4841
  for (const f of ["system", "model", "prefill"] ) {
@@ -4723,7 +4950,7 @@ STEP_TYPES.modifier = {
4723
4950
 
4724
4951
  // A filters step: the filter sets a job cleans and gates its reply by, each a
4725
4952
  // link or a private set (#339, #405), at the step's `stage`. A job may hold
4726
- // one of each stage -- a `text` one before Read as, an `items` one after -- so
4953
+ // one of each stage -- a `text` one Pre-format, an `items` one Post-format -- so
4727
4954
  // the type is `many`; two of a stage is the job's to refuse. A set belongs to
4728
4955
  // no stage (#432): linked here, it runs the filters whose type fits the step's
4729
4956
  // stage and skips the rest. A private set's body is the core's to judge
@@ -4824,11 +5051,6 @@ function jobTargetType(doc
4824
5051
  return targetStepOf(doc, 0, k)?.type ?? "prompt";
4825
5052
  }
4826
5053
 
4827
- /** The Setup profile target [i] asks in job [k]: its step's own, or the target's. */
4828
- function targetProfileOf(doc , i , k ) {
4829
- return targetStepOf(doc, i, k)?.profile ?? targetsOf(doc)[i]?.profile ?? undefined;
4830
- }
4831
-
4832
5054
  /** Whether target [i] is a Recorded target: it replays each call's recorded
4833
5055
  reply rather than sending anything (its job-1 step answers locally with a
4834
5056
  connection that `replays`). A recorded-reply metric compares the recorded
@@ -4958,8 +5180,8 @@ function withOut(job , out ) {
4958
5180
  }
4959
5181
 
4960
5182
  /** [job] reading a reply as `legacyTagsOut` builds it, on the current model
4961
- (#405): its output a List, a before-Read-as text set that reads the reply as
4962
- the old `preamble` did, and an after-Read-as items set that cleans and gates
5183
+ (#405): its output a List, a Pre-format text set that reads the reply as
5184
+ the old `preamble` did, and a Post-format items set that cleans and gates
4963
5185
  it. The filters are `legacyTagsOut`'s, placed as private sets -- what the
4964
5186
  version-15 upgrade builds for a legacy list job, for a test to build
4965
5187
  directly. */
@@ -4978,7 +5200,7 @@ const filterStepOf = (job , stage )
4978
5200
 
4979
5201
  /** The filter-set links a job's Responses stage carries for [stage], in
4980
5202
  document order (#339, #405): read from that stage's filters step, or none.
4981
- Defaults to the after-Read-as (items) stage, the one that was there before. */
5203
+ Defaults to the Post-format (items) stage, the one that was there before. */
4982
5204
  function filterSetsOf(job , stage = "items") {
4983
5205
  return (filterStepOf(job, stage)?.filterSets ?? []).slice();
4984
5206
  }
@@ -4998,10 +5220,10 @@ function withFilterSets(job , links , stage
4998
5220
  const linkFilters = (link , resolve ) =>
4999
5221
  (link.own ?? (link.set && resolve ? resolve(link.set) : null))?.filters || [];
5000
5222
 
5001
- /** A job's after-Read-as (items) Responses as an ordered modifier list for a
5223
+ /** A job's Post-format (items) Responses as an ordered modifier list for a
5002
5224
  run (#339): each modifier step as it is, and each items filter-set link as a
5003
5225
  synthetic modifier that applies the set's filters where the link sits. The
5004
- before-Read-as (text) filters are not modifiers -- `beforeFilters` gives
5226
+ Pre-format (text) filters are not modifiers -- `beforeFilters` gives
5005
5227
  them, and a run applies them to the raw reply before it is read. [resolve]
5006
5228
  gives a Library link's body. */
5007
5229
  function stageModifiers(job , resolve ) {
@@ -5016,7 +5238,7 @@ function stageModifiers(job , resolve
5016
5238
  return out;
5017
5239
  }
5018
5240
 
5019
- /** A job's before-Read-as (text) filters for a run (#405), in document order:
5241
+ /** A job's Pre-format (text) filters for a run (#405), in document order:
5020
5242
  the filters of every link on its `text` filters step, which a run applies to
5021
5243
  the raw reply before the kind reads it into items. [resolve] gives a Library
5022
5244
  link's body, as `stageModifiers` does. */
@@ -5067,11 +5289,11 @@ STEP_TYPES.job = {
5067
5289
  }
5068
5290
  const twice = steps.find((st, x) => !STEP_TYPES[st.type] .many && steps.findIndex(o => o.type === st.type) !== x);
5069
5291
  if (twice) return void bad.push(`${at} has two ${STEP_TYPES[twice.type] .label ?? twice.type} steps`);
5070
- // A `filters` step is `many`, but only one per stage: a before-Read-as and
5071
- // an after-Read-as set at most (#405).
5292
+ // A `filters` step is `many`, but only one per stage: a Pre-format and
5293
+ // a Post-format set at most (#405).
5072
5294
  const twoFilters = steps.find((st, x) => st.type === "filters"
5073
5295
  && steps.findIndex(o => o.type === "filters" && o.stage === st.stage) !== x);
5074
- if (twoFilters) return void bad.push(`${at} has two ${twoFilters.stage === "text" ? "before Read as" : "after Read as"} filter steps`);
5296
+ if (twoFilters) return void bad.push(`${at} has two ${twoFilters.stage === "text" ? "Pre-format" : "Post-format"} filter steps`);
5075
5297
  if (steps.some((st, x) => x > 0 && placeOf(st) < placeOf(steps[x - 1]))) {
5076
5298
  return void bad.push(`${at}: steps are its Content, then its Responses, each in order`);
5077
5299
  }
@@ -5173,10 +5395,16 @@ function versionProblem(doc ) {
5173
5395
  rules, as a run with no graded eval parsed. */
5174
5396
 
5175
5397
 
5176
-
5398
+
5177
5399
 
5178
-
5400
+
5401
+
5179
5402
 
5403
+
5404
+
5405
+
5406
+
5407
+
5180
5408
 
5181
5409
 
5182
5410
  /**
@@ -5227,8 +5455,10 @@ function columnsOf(doc )
5227
5455
  function profileRefs(doc ) {
5228
5456
  const cut = (r ) => (isObj(r) && isStr(r.id) ? { id: r.id, name: isStr(r.name) ? r.name : "" } : r);
5229
5457
  for (const { column, steps } of columnsOf(doc)) {
5230
- if ("profile" in column) column.profile = cut(column.profile);
5231
- for (const st of steps) if (st.profile != null) st.profile = cut(st.profile);
5458
+ for (const f of ["connection", "profile"]) {
5459
+ if (f in column) column[f] = cut(column[f]);
5460
+ for (const st of steps) if (st[f] != null) st[f] = cut(st[f]);
5461
+ }
5232
5462
  }
5233
5463
  }
5234
5464
 
@@ -5238,7 +5468,40 @@ const cutRefs = (doc ) => { profileRefs(doc); return doc; }
5238
5468
  /** Whether any profile reference in a pipeline holds more than { id, name }. */
5239
5469
  function fatProfileRef(doc ) {
5240
5470
  const fat = (r ) => isObj(r) && Object.keys(r).some(k => k !== "id" && k !== "name");
5241
- return isObj(doc) && columnsOf(doc).some(({ column, steps }) => fat(column.profile) || steps.some(st => fat(st.profile)));
5471
+ return isObj(doc) && columnsOf(doc).some(({ column, steps }) => fat(column.profile) || fat(column.connection)
5472
+ || steps.some(st => fat(st.profile) || fat(st.connection)));
5473
+ }
5474
+
5475
+ /**
5476
+ * Version 15 to 16: a Target profile is an AI Connection now, keeping the
5477
+ * profile's id, and -- where the profile set settings -- the Connection
5478
+ * Profile split from it ([ctx]'s `connectionSplit`), so each target and step
5479
+ * asks what it asked. A run document carries each profile resolved, under
5480
+ * its id (`runAnswer` reads it so), so it names the connection alone, as
5481
+ * does a reader that cannot say what was split. Idempotent: a target that
5482
+ * names a connection already is left as it is.
5483
+ */
5484
+ function answersFromProfiles(doc , ctx ) {
5485
+ doc.version = PIPELINE_VERSION;
5486
+ const split = (ref ) => (isObj(doc.profiles) || !isObj(ref) || !isStr(ref.id) ? null
5487
+ : ctx.connectionSplit?.(ref.id) ?? null);
5488
+ if (!Array.isArray(doc.targets)) return doc;
5489
+ doc.targets = doc.targets.map((t ) => {
5490
+ if (!isObj(t) || "connection" in t) return t;
5491
+ const { id, name, profile = null, steps, ...rest } = t;
5492
+ const p = split(profile);
5493
+ const out = { id, name, connection: profile, profile: p ? { id: p.id, name: p.name ?? "" } : null, steps, ...rest };
5494
+ if (Array.isArray(steps)) {
5495
+ out.steps = steps.map((st ) => {
5496
+ if (!isObj(st) || st.profile == null || "connection" in st) return st;
5497
+ const { profile: own, ...more } = st;
5498
+ const sp = split(own);
5499
+ return { ...more, connection: own, ...(sp ? { profile: { id: sp.id, name: sp.name ?? "" } } : {}) };
5500
+ });
5501
+ }
5502
+ return out;
5503
+ });
5504
+ return doc;
5242
5505
  }
5243
5506
 
5244
5507
  /** Version 5 to 6: the one test, or none, as a list. Its id is `t1`, so
@@ -5443,32 +5706,34 @@ function upgradePipeline (doc , ctx = {}) {
5443
5706
  }
5444
5707
  return localSteps(ownBodiesNow(out), ctx) ;
5445
5708
  }
5446
- if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].includes(doc.version )) return doc;
5709
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].includes(doc.version )) return doc;
5710
+ // Every version reads as 15 first, then as 16: its Target profiles split
5711
+ // into AI Connections and Connection Profiles (answersFromProfiles).
5712
+ const sixteen = (fifteen ) => localSteps(ownBodiesNow(answersFromProfiles(fifteen, ctx)), ctx) ;
5713
+ if (doc.version === 15) return sixteen(cutRefs(clone(doc) ));
5447
5714
  // Versions 13 and 14 read as 15 by migrating a job's cleaning modifier steps
5448
- // and its List `preamble` setting into before/after-Read-as filter sets
5715
+ // and its List `preamble` setting into Pre-/Post-format filter sets
5449
5716
  // (#339, #405). A version-14 document already moved its Drop items / Reject
5450
5717
  // into a (stageless) filters step; filtersFromCleaning stages it and folds in
5451
5718
  // the remaining Tidy / Remove duplicates / Keep first / Case.
5452
- if (doc.version === 13 || doc.version === 14) {
5453
- return localSteps(ownBodiesNow(filtersFromCleaning(clone(doc) )), ctx) ;
5454
- }
5719
+ if (doc.version === 13 || doc.version === 14) return sixteen(filtersFromCleaning(clone(doc) ));
5455
5720
  if (doc.version === 11 || doc.version === 12) {
5456
- return localSteps(ownBodiesNow(filtersFromCleaning(groupsOf(withoutCaseMetric(caseAsWritten(cutRefs(clone(doc) )))))), ctx) ;
5721
+ return sixteen(filtersFromCleaning(groupsOf(withoutCaseMetric(caseAsWritten(cutRefs(clone(doc) ))))));
5457
5722
  }
5458
5723
  // Every version before 10 reads as version 9 first, then as 10, then up
5459
5724
  // through 15. A version-10 document is cut as a current one was
5460
5725
  // (profileRefs); the earlier ones are cut on their way through nineOf.
5461
5726
  const ten = doc.version === 10 ? cutRefs(clone(doc) ) : targetsFromScenarios(nineOf(clone(doc) , ctx));
5462
- return localSteps(ownBodiesNow(filtersFromCleaning(groupsOf(withoutCaseMetric(caseAsWritten(evalsKey(ten)))))), ctx) ;
5727
+ return sixteen(filtersFromCleaning(groupsOf(withoutCaseMetric(caseAsWritten(evalsKey(ten))))));
5463
5728
  }
5464
5729
 
5465
5730
  /** [doc] with every job's cleaning modifier steps and its List `preamble`
5466
5731
  setting migrated into filter sets on `filters` steps, up to version 15
5467
- (#339, #405). The after-Read-as (items) filters are built by walking the
5732
+ (#339, #405). The Post-format (items) filters are built by walking the
5468
5733
  Responses steps in document order -- each cleaning modifier its filters
5469
5734
  (`cleaningFilters`), each existing filters step's links in place, a private
5470
5735
  one's filters coalescing with adjacent modifier filters into one own set --
5471
- and the `preamble: true` setting becomes a before-Read-as (text) set of
5736
+ and the `preamble: true` setting becomes a Pre-format (text) set of
5472
5737
  Remove thinking then Remove preamble. The steps take the place of the first
5473
5738
  of them, and the preamble setting is stripped.
5474
5739
 
@@ -5556,12 +5821,20 @@ function withoutCaseMetric(doc ) {
5556
5821
  step then asks none, or names its own, keeps no profile either. The same
5557
5822
  document, untouched, when there is nothing to read so. */
5558
5823
  function localSteps(doc , ctx ) {
5559
- const typeOfProfile = ctx.profileType
5560
- ?? (isObj(doc.profiles) ? (id ) => (isObj(doc.profiles[id]) ? doc.profiles[id].type : undefined) : null);
5561
- if (!typeOfProfile) return doc;
5824
+ const run = isObj(doc.profiles);
5825
+ if (!ctx.profileType && !run) return doc;
5826
+ // A connection's type: [ctx]'s, by its id, or what a run carries for it.
5827
+ const typeOfAnswer = (connection , profile ) => {
5828
+ if (!isObj(connection) || !isStr(connection.id)) return undefined;
5829
+ if (ctx.profileType) return ctx.profileType(connection.id);
5830
+ const conn = runAnswer(doc, answerKey(connection.id, isObj(profile) && isStr(profile.id) ? profile.id : null));
5831
+ return isObj(conn) ? conn.type : undefined;
5832
+ };
5562
5833
  const stands = new Map(Object.entries(STEP_TYPES).filter(([, e]) => e.slot === "target" && e.replaces).map(([type, e]) => [e.replaces , type]));
5563
- const as = (ref ) => (isObj(ref) && isStr(ref.id) ? stands.get(typeOfProfile(ref.id) ?? "") : undefined);
5564
- const turns = (t , st ) => isObj(st) && STEP_TYPES[st.type]?.asks === "prompt" && as(st.profile ?? t.profile);
5834
+ const as = (connection , profile ) => stands.get(typeOfAnswer(connection, profile) ?? "");
5835
+ const ownsAnswer = (st ) => st.connection != null || st.profile != null;
5836
+ const turns = (t , st ) => isObj(st) && STEP_TYPES[st.type]?.asks === "prompt"
5837
+ && (ownsAnswer(st) ? as(st.connection ?? t.connection, st.profile) : as(t.connection, t.profile));
5565
5838
  const targets = Array.isArray(doc.targets) ? doc.targets : [];
5566
5839
  if (!targets.some(t => isObj(t) && Array.isArray(t.steps) && t.steps.some((st ) => turns(t, st)))) return doc;
5567
5840
  const next = clone(doc) ;
@@ -5571,9 +5844,12 @@ function localSteps(doc , ctx ) {
5571
5844
  const type = turns(t, st);
5572
5845
  return type ? { type, prompt: st.prompt, ...(st.from ? { from: st.from } : {}) } : st;
5573
5846
  });
5574
- // A run keeps the profile it ran as, so its report names it as it did.
5575
- if (!isObj(doc.profiles) && as(t.profile)
5576
- && t.steps.every((st ) => isObj(st) && (STEP_TYPES[st.type]?.local || st.profile != null))) t.profile = null;
5847
+ // A run keeps the connection it ran as, so its report names it as it did.
5848
+ if (!run && as(t.connection, t.profile)
5849
+ && t.steps.every((st ) => isObj(st) && (STEP_TYPES[st.type]?.local || ownsAnswer(st)))) {
5850
+ t.connection = null;
5851
+ t.profile = null;
5852
+ }
5577
5853
  }
5578
5854
  return next;
5579
5855
  }
@@ -5633,6 +5909,16 @@ function blankPipeline(opts
5633
5909
  jobs: [job], targets: [], evals: [], pass: { mode: "all" } };
5634
5910
  }
5635
5911
 
5912
+ /** What is wrong with how a target or a step [at] names whom it asks: each
5913
+ reference as { id, name }, and a profile only over a connection. */
5914
+ function answerRefsProblems(o , at , bad ) {
5915
+ for (const [f, what] of [["connection", "AI Connection"], ["profile", "Connection Profile"]] ) {
5916
+ if (o[f] == null) continue;
5917
+ if (!isRef(o[f])) bad.push(`${at} has to name its ${what} as { id, name }`);
5918
+ else onlyFields(o[f], `${at}'s ${f}`, ["id", "name"], bad);
5919
+ }
5920
+ }
5921
+
5636
5922
  /**
5637
5923
  * Every reason [doc] cannot run, as sentences -- none when it can. The
5638
5924
  * reference lookups are [ctx]'s: `profiles(id)` and `sources(id)` answer with
@@ -5645,9 +5931,7 @@ function validatePipeline(input , ctx = {}) {
5645
5931
  const bad = [];
5646
5932
  const run = doc.profiles !== undefined;
5647
5933
  const c = { ...ctx, run };
5648
- if (run && !ctx.profiles) {
5649
- c.profiles = (id ) => isObj(doc.profiles) && Object.hasOwn(doc.profiles, id) ? doc.profiles[id] : null;
5650
- }
5934
+ if (run && !ctx.profiles) c.profiles = (key ) => (runAnswer(doc, key) ) ?? null;
5651
5935
  onlyFields(doc, "the pipeline", run ? RUN_FIELDS : PIPELINE_FIELDS, bad);
5652
5936
  if (!isStr(doc.id) || !doc.id.trim()) bad.push("the pipeline has no id");
5653
5937
  if (doc.name != null && !isStr(doc.name)) bad.push("name has to be text");
@@ -5668,32 +5952,31 @@ function validatePipeline(input , ctx = {}) {
5668
5952
  targets.forEach((t, i) => {
5669
5953
  const at = targetLabel(doc, i);
5670
5954
  if (!isObj(t)) return void bad.push(`${at} has to be an object`);
5671
- onlyFields(t, at, ["id", "name", "profile", "steps"], bad);
5955
+ onlyFields(t, at, ["id", "name", "connection", "profile", "steps"], bad);
5672
5956
  if (!isStr(t.id) || !t.id.trim()) bad.push(`${at} has no id`);
5673
5957
  else if (targets.findIndex((o) => isObj(o) && o.id === t.id) !== i) bad.push(`${at} has the id of another target`);
5674
5958
  if (t.name != null && !isStr(t.name)) bad.push(`${at}: name has to be text`);
5675
- if (t.profile != null && !isRef(t.profile)) bad.push(`${at} has to name its Target profile as { id, name }`);
5676
- else if (t.profile != null) onlyFields(t.profile, `${at}'s profile`, ["id", "name"], bad);
5959
+ answerRefsProblems(t, at, bad);
5677
5960
  if (!Array.isArray(t.steps) || t.steps.length !== n) {
5678
5961
  bad.push(`${at} has ${Array.isArray(t.steps) ? t.steps.length : "no"} prompts for ${n} job${n === 1 ? "" : "s"}`);
5679
5962
  return;
5680
5963
  }
5681
- // A target with no profile of its own is one whose steps need none, or
5682
- // each name theirs.
5683
- if (t.profile == null && t.steps.some((st ) => !(isObj(st) && STEP_TYPES[st.type]?.local) && !(isObj(st) && st.profile != null))) {
5684
- bad.push(`${at} has to name its Target profile as { id, name }`);
5964
+ // A target with no connection of its own is one whose steps need none,
5965
+ // or each name theirs; a profile needs a connection to be over.
5966
+ const ownsOne = (st ) => isObj(st) && st.connection != null;
5967
+ if (t.connection == null && t.steps.some((st ) => !(isObj(st) && STEP_TYPES[st.type]?.local) && !ownsOne(st))) {
5968
+ bad.push(`${at} has to name its AI Connection as { id, name }`);
5685
5969
  }
5686
5970
  t.steps.forEach((st , k ) => {
5687
5971
  const where = `${at}, ${jobLabel(doc, k)}`;
5688
5972
  if (!isObj(st)) return void bad.push(`${where} has to be an object`);
5689
5973
  const entry = STEP_TYPES[st.type];
5690
5974
  if (entry?.slot !== "target") return void bad.push(`${where}: "${st.type}" is not something a target can send`);
5691
- if (entry.local && st.profile != null) return void bad.push(`${where}: ${entry.label} asks no profile`);
5975
+ if (entry.local && (st.profile != null || st.connection != null)) return void bad.push(`${where}: ${entry.label} asks no connection`);
5692
5976
  if (k > 0 && entry.firstJobOnly) return void bad.push(`${where}: ${entry.firstJobOnly}`);
5693
5977
  onlyFields(st, where, entry.fields ?? [], bad);
5694
5978
  entry.validate(st, c, bad, where);
5695
- if (st.profile != null && !isRef(st.profile)) bad.push(`${where} has to name its Target profile as { id, name }`);
5696
- else if (st.profile != null) onlyFields(st.profile, `${where}'s profile`, ["id", "name"], bad);
5979
+ answerRefsProblems(st, where, bad);
5697
5980
  if (st.from != null && !isRef(st.from)) bad.push(`${where} has to name the prompt it was picked from as { id, name }`);
5698
5981
  // A request is a flow step's, rebuilt from its records, and its
5699
5982
  // template has no place for an image.
@@ -5703,13 +5986,16 @@ function validatePipeline(input , ctx = {}) {
5703
5986
  });
5704
5987
  if (bad.length) return bad;
5705
5988
 
5706
- // A reference that resolves to nothing, once per reference.
5989
+ // A reference that resolves to nothing, once per reference: the part
5990
+ // that is gone, where the lookups can say which.
5707
5991
  const missing = new Set ();
5708
- const lookup = (ref ) => {
5709
- const p = c.profiles ? c.profiles(ref.id) : {};
5710
- if (!p && !missing.has(ref.id)) {
5711
- missing.add(ref.id);
5712
- bad.push(`Target profile ${ref.name || ref.id} not found`);
5992
+ const lookup = (a ) => {
5993
+ const key = answerKey(a.connection.id, a.profile?.id);
5994
+ const p = c.profiles ? c.profiles(key) : {};
5995
+ if (!p && !missing.has(key)) {
5996
+ missing.add(key);
5997
+ const gone = a.profile && c.connections?.(a.connection.id) ? a.profile : a.connection;
5998
+ bad.push(`${gone === a.profile ? "Connection Profile" : "Connection"} ${gone.name || gone.id} not found`);
5713
5999
  }
5714
6000
  return p;
5715
6001
  };
@@ -5725,7 +6011,7 @@ function validatePipeline(input , ctx = {}) {
5725
6011
  // What target [i] asks in job [k]: Echo's own connection for a step
5726
6012
  // answered here, else its profile as the lookups hold it.
5727
6013
  const askedOf = (i , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k))
5728
- : c.profiles ? c.profiles(targetProfileOf(doc, i, k) .id) : null);
6014
+ : c.profiles ? c.profiles(targetAnswerKey(doc, i, k) ) : null);
5729
6015
  const itemsHaveText = !!content && !CONTENT_TYPES[content.type]?.bare;
5730
6016
  for (let k = 0; k < n; k++) {
5731
6017
  const i = targets.findIndex((_, i) => {
@@ -5746,7 +6032,7 @@ function validatePipeline(input , ctx = {}) {
5746
6032
  const nonText = (contentFiles || []).map(f => String((f )?.name ?? f))
5747
6033
  .find(n => !/\.(?:txt|md|csv)$/i.test(n));
5748
6034
  targets.forEach((_, i) => {
5749
- const conns = doc.jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetProfileOf(doc, i, k) )));
6035
+ const conns = doc.jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetAnswerOf(doc, i, k) )));
5750
6036
  conns.forEach((p , k ) => {
5751
6037
  if (!local(p)) return;
5752
6038
  const entry = CONNECTION_TYPES[typeOf(p )] ;
@@ -5771,8 +6057,8 @@ function validatePipeline(input , ctx = {}) {
5771
6057
  && targetEntryOf(doc, i, k)?.modelFrom !== "step" && !String(p.model || "").trim());
5772
6058
  if (bare >= 0) {
5773
6059
  bad.push(n > 1
5774
- ? `no model on the profile ${jobLabel(doc, bare)} of ${targetLabel(doc, i)} uses — manage profiles on the Setup tab`
5775
- : `no model on ${targetLabel(doc, i)}'s Target profile — manage profiles on the Setup tab`);
6060
+ ? `no model on the connection ${jobLabel(doc, bare)} of ${targetLabel(doc, i)} uses — manage connections on the Setup tab`
6061
+ : `no model on ${targetLabel(doc, i)}'s connection — manage connections on the Setup tab`);
5776
6062
  }
5777
6063
  });
5778
6064
  STEP_TYPES.evals .validate(doc.evals, c, bad, outOf(doc.jobs.at(-1)).kind, doc);
@@ -5841,15 +6127,22 @@ function pipelineWarnings(doc , ctx = {}) {
5841
6127
  /** A run document's profiles table: id → connection, keyless, spellable. */
5842
6128
  function profilesProblems(profiles , bad ) {
5843
6129
  if (!isObj(profiles)) return void bad.push("profiles has to be an object of id → connection");
6130
+ // Two entries may share one connection, and so one key; two connections
6131
+ // spelling one variable would hand one the other's key.
5844
6132
  const spelt = new Map ();
5845
6133
  for (const [id, conn] of Object.entries(profiles)) {
5846
- if (!PROFILE_ID.test(id)) {
6134
+ if (!ANSWER_KEY.test(id)) {
5847
6135
  bad.push(`profile id "${id}" has to be letters and digits, with at most one hyphen: its key comes from EVALSLAB_API_KEY_<ID>`);
5848
6136
  continue;
5849
6137
  }
5850
- const from = keyVar(id, isObj(conn) && isStr(conn.slug) ? conn.slug : null);
5851
- if (spelt.has(from)) bad.push(`profiles ${spelt.get(from)} and ${id} both take their key from $${from}`);
5852
- spelt.set(from, id);
6138
+ if (isObj(conn) && conn.connection != null && !(isStr(conn.connection) && PROFILE_ID.test(conn.connection))) {
6139
+ bad.push(`profile ${id}: connection has to be an AI Connection's id`);
6140
+ continue;
6141
+ }
6142
+ const cid = answerConnectionId(id, isObj(conn) ? conn : null);
6143
+ const from = keyVar(cid, isObj(conn) && isStr(conn.slug) ? conn.slug : null);
6144
+ if (spelt.has(from) && spelt.get(from) !== cid) bad.push(`profiles ${spelt.get(from)} and ${cid} both take their key from $${from}`);
6145
+ spelt.set(from, cid);
5853
6146
  const at = `profile ${id}`;
5854
6147
  if (!isObj(conn)) { bad.push(`${at} has to be an object`); continue; }
5855
6148
  connectionProblems(conn, at, from, bad);
@@ -5915,24 +6208,34 @@ function connectionProblems(conn , at , from
5915
6208
  for (const why of CONNECTION_TYPES[type]?.settingsProblems?.(connectionSettings(conn)) ?? []) bad.push(`${at} ${why}`);
5916
6209
  }
5917
6210
 
5918
- /** The profile ids a pipeline references, scenarios first, in order. */
6211
+ /** The AI Connection ids a pipeline asks -- each target's and step's, then
6212
+ each grader's -- in order: the Target profile ids, before version 16. */
5919
6213
  function profileIds(doc ) {
5920
- const ids = [];
5921
- for (const t of targetsOf(doc)) {
5922
- for (const ref of [t.profile, ...(Array.isArray(t.steps) ? t.steps : []).map(st => st?.profile)] ) {
5923
- if (ref?.id && !ids.includes(ref.id)) ids.push(ref.id);
5924
- }
5925
- }
6214
+ return [...new Set(answerKeys(doc).map(k => answerOfKey(k).connection))];
6215
+ }
6216
+
6217
+ /** Every answer key a pipeline asks, targets first, in order: each target's
6218
+ and step's connection and profile, then each grader's bare id. What a run
6219
+ carries one connection for. */
6220
+ function answerKeys(doc ) {
6221
+ const keys = [];
6222
+ const add = (key ) => { if (key && !keys.includes(key)) keys.push(key); };
6223
+ targetsOf(doc).forEach((t, i) => {
6224
+ // The target's own, which History reads it by, then each step's.
6225
+ if (isRef(t.connection)) add(answerKey(t.connection.id, isRef(t.profile) ? t.profile.id : null));
6226
+ (Array.isArray(t.steps) ? t.steps : []).forEach((_, k) => add(targetAnswerKey(doc, i, k)));
6227
+ });
5926
6228
  // Then the ones an eval asks (a grader), so a run carries them too.
5927
6229
  for (const t of evalsOf(doc)) {
5928
- for (const ref of EVAL_TYPES[t.type]?.profiles?.(t) ?? []) if (ref?.id && !ids.includes(ref.id)) ids.push(ref.id);
6230
+ for (const ref of EVAL_TYPES[t.type]?.profiles?.(t) ?? []) if (ref?.id && !keys.includes(ref.id)) keys.push(ref.id);
5929
6231
  }
5930
- return ids;
6232
+ return keys;
5931
6233
  }
5932
6234
 
5933
6235
  /**
5934
6236
  * The run document: [doc] plus what its references resolved to, once, at
5935
- * submit. `ctx.profiles(id)` answers with a stored profile, `ctx.files` is
6237
+ * submit. `ctx.profiles(key)` answers an answer key with what it resolves to
6238
+ * (`answersOf`'s `resolve`), `ctx.files` is
5936
6239
  * the Source's file list as it reads now, `ctx.comment` is the run's own.
5937
6240
  */
5938
6241
  function resolvePipeline(doc ,
@@ -5942,10 +6245,16 @@ function resolvePipeline(doc ,
5942
6245
  // What the lab supplies an eval -- its grader -- before the profiles it
5943
6246
  // asks are carried.
5944
6247
  run.evals = (Array.isArray(run.evals) ? run.evals : []).map((t ) => EVAL_TYPES[t?.type]?.resolve?.(t, ctx) ?? t);
6248
+ // Each pair asked, resolved and frozen on its own: two targets sharing a
6249
+ // connection under different profiles send different settings. The entry
6250
+ // names the connection whose key it is sent with.
5945
6251
  run.profiles = {};
5946
- for (const id of profileIds(run)) {
5947
- const p = ctx.profiles?.(id);
5948
- if (p) run.profiles[id] = connectionOf(p );
6252
+ for (const key of answerKeys(run)) {
6253
+ const p = ctx.profiles?.(key);
6254
+ if (!p) continue;
6255
+ const conn = connectionOf(p );
6256
+ const cid = isStr(p.id) && p.id ? p.id : answerOfKey(key).connection;
6257
+ run.profiles[key] = { ...conn, connection: cid };
5949
6258
  }
5950
6259
  const content = contentOf(run) ;
5951
6260
  if (content?.type === "source") content.files = CONTENT_TYPES.source .expand (content, ctx.files);
@@ -6008,13 +6317,14 @@ function pipelineToYaml(doc , ctx
6008
6317
  return yaml.dump(out, { lineWidth: -1, noRefs: true });
6009
6318
  }
6010
6319
 
6011
- /** Every Target profile reference in [doc] -- each target's, each of its
6012
- steps', each eval's grader -- replaced, in place, by [f] of it. */
6320
+ /** Every AI Connection reference in [doc] -- each target's, each of its
6321
+ steps', each eval's grader -- replaced, in place, by [f] of it. A
6322
+ Connection Profile is named by id and name, and left as it is. */
6013
6323
  function eachProfileRef(doc , f ) {
6014
6324
  for (const t of targetsOf(doc)) {
6015
- if (t.profile) t.profile = f(t.profile);
6325
+ if (t.connection) t.connection = f(t.connection);
6016
6326
  for (const st of Array.isArray(t.steps) ? t.steps : []) {
6017
- if (st?.profile) st.profile = f(st.profile);
6327
+ if (st?.connection) st.connection = f(st.connection);
6018
6328
  }
6019
6329
  }
6020
6330
  for (const t of Array.isArray(doc.evals) ? doc.evals : []) {
@@ -6035,6 +6345,9 @@ function eachProfileRef(doc , f )
6035
6345
  * stands.
6036
6346
  */
6037
6347
  function importPipeline(input , ctx = {}) {
6348
+ // A version-15 file names its profiles by slug, which the upgrade cannot
6349
+ // split (it needs this lab's id), so the split is asked again once remapped.
6350
+ const preSplit = isObj(input) && typeof input.version === "number" && input.version < 16 && !isObj(input.profiles);
6038
6351
  // A file exported before version 2 still imports: it is upgraded, not refused.
6039
6352
  const doc = upgradePipeline(input, ctx);
6040
6353
  const v = versionProblem(doc);
@@ -6042,9 +6355,10 @@ function importPipeline(input , ctx = {}) {
6042
6355
  const profiles = ctx.profiles ?? [], sources = ctx.sources ?? [], groups = ctx.groups ?? [];
6043
6356
  const next = clone(doc) ;
6044
6357
  const missing = [], seen = new Set ();
6045
- const lists = { profile: profiles, source: sources, group: groups };
6358
+ const lists = { profile: profiles, connectionProfile: ctx.connectionProfiles ?? [], source: sources, group: groups };
6046
6359
  const words = {
6047
- profile: (ref ) => `Target profile ${ref.slug || ref.name || ref.id} not found`,
6360
+ profile: (ref ) => `Connection ${ref.slug || ref.name || ref.id} not found`,
6361
+ connectionProfile: (ref ) => `Connection Profile ${ref.name || ref.id} not found`,
6048
6362
  source: (ref ) => `Source ${ref.name || ref.id} not found`,
6049
6363
  group: (ref ) => `Eval group ${ref.name || ref.id} not found`,
6050
6364
  };
@@ -6070,10 +6384,22 @@ function importPipeline(input , ctx = {}) {
6070
6384
  };
6071
6385
  const content = contentOf(next) ;
6072
6386
  if (content?.type === "source") content.ref = remap(content.ref, "source");
6387
+ const split = (connection ) => {
6388
+ const p = preSplit && isRef(connection) && profiles.some(x => x.id === connection.id) ? ctx.connectionSplit?.(connection.id) : null;
6389
+ return p ? { id: p.id, name: p.name ?? "" } : null;
6390
+ };
6073
6391
  for (const t of targetsOf(next)) {
6074
- if (t.profile) t.profile = remapProfile(t.profile);
6392
+ // A profile is matched where the lab says which it holds.
6393
+ if (t.connection) t.connection = remapProfile(t.connection);
6394
+ if (isRef(t.profile) && ctx.connectionProfiles) t.profile = remap(t.profile, "connectionProfile");
6395
+ else if (t.profile == null) t.profile = split(t.connection);
6075
6396
  for (const st of Array.isArray(t.steps) ? t.steps : []) {
6076
- if (st?.profile) st.profile = remapProfile(st.profile);
6397
+ if (st?.connection) st.connection = remapProfile(st.connection);
6398
+ if (isRef(st?.profile) && ctx.connectionProfiles) st.profile = remap(st.profile, "connectionProfile");
6399
+ else if (isObj(st) && st.connection && st.profile == null) {
6400
+ const sp = split(st.connection);
6401
+ if (sp) st.profile = sp;
6402
+ }
6077
6403
  }
6078
6404
  }
6079
6405
  for (const t of Array.isArray(next.evals) ? next.evals : []) {
@@ -6173,7 +6499,7 @@ function junitXml(suites , runner ) {
6173
6499
  //
6174
6500
  // Export for CI writes a pipeline as files a repository keeps and
6175
6501
  // `evals-lab run` runs with no lab around it (docs/pipeline-yaml.md, "Export
6176
- // for CI"): the pipeline's YAML, its Target profiles by slug, and each
6502
+ // for CI"): the pipeline's YAML, its connections resolved and keyed by slug, and each
6177
6503
  // dataset it grades against as the Library's export of it. The server adds
6178
6504
  // the plugins and, when asked, the Source's items, and zips the lot. Read
6179
6505
  // back, the files are the run document the page would have submitted, with
@@ -6196,28 +6522,38 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6196
6522
 
6197
6523
  /**
6198
6524
  * [doc] as a bundle's text files: `pipeline.yaml`, `profiles.yaml` and
6199
- * `datasets/<slug>.json`. [ctx.profiles] is Setup's list -- keys and all,
6200
- * none of which is written -- [ctx.grader] the lab's grader, written into
6201
- * each eval that would have asked it, and [ctx.datasets] each graded
6202
- * dataset by id, as `{ name, body }`. `{ error }` when a dataset or a
6203
- * profile the pipeline names is not given, since a bundle without it runs
6525
+ * `datasets/<slug>.json`. [ctx.connections] and [ctx.connectionProfiles]
6526
+ * are Setup's -- keys and all, none of which is written -- or
6527
+ * [ctx.profiles], Target profiles to split as the store was; [ctx.grader]
6528
+ * the lab's grader, written into each eval that would have asked it, and
6529
+ * [ctx.datasets] each graded dataset by id, as `{ name, body }`. Each
6530
+ * connection asked is written resolved, under its answer key with the
6531
+ * connection's slug for its id. `{ error }` when a dataset or a connection
6532
+ * the pipeline names is not given, since a bundle without it runs
6204
6533
  * something else.
6205
6534
  */
6206
- function exportBundle(doc , ctx
6535
+ function exportBundle(doc , ctx
6536
+
6207
6537
  = {})
6208
6538
  {
6209
- const profiles = withSlugs((ctx.profiles ?? []) );
6539
+ const split = (ctx.profiles ?? []).map(splitProfile);
6540
+ const profiles = withSlugs([...(ctx.connections ?? []), ...split.map(x => x.connection)] );
6541
+ const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))]);
6210
6542
  // What the lab would supply at submit, written down: no lab supplies it later.
6211
6543
  const out = clone(doc) ;
6212
6544
  out.evals = (Array.isArray(out.evals) ? out.evals : [])
6213
6545
  .map((t ) => EVAL_TYPES[t?.type]?.resolve?.(t, { grader: ctx.grader ?? null,
6214
6546
  groups: Object.entries(ctx.datasets ?? {}).map(([id, d]) => ({ id, name: d.name, grader: d.body.grader ?? null })) }) ?? t);
6215
6547
  const table = {};
6216
- for (const id of profileIds(out)) {
6217
- const p = profiles.find(x => x.id === id);
6218
- if (!p) return { error: `Target profile ${id} not found` };
6548
+ for (const key of answerKeys(out)) {
6549
+ const at = answerOfKey(key);
6550
+ const named = answers.connection(at.connection);
6551
+ const p = answers.resolve(key);
6552
+ if (!named) return { error: `Connection ${at.connection} not found` };
6553
+ if (!p) return { error: `Connection Profile ${at.profile} not found` };
6219
6554
  const { slug, ...flat } = connectionSettings(connectionOf(p) );
6220
- table[slug ] = flat;
6555
+ // A profile answering on a connection of its own takes that one's key.
6556
+ table[at.bare ? named.slug : answerKey(named.slug , at.profile)] = slug === named.slug ? flat : { ...flat, slug };
6221
6557
  }
6222
6558
  const files = {};
6223
6559
  const taken = new Set (), written = new Set ();
@@ -6264,7 +6600,7 @@ function bundleProblems(files , keys = [])
6264
6600
  for (const [path, text] of Object.entries(files)) {
6265
6601
  for (const key of keys) {
6266
6602
  if (isStr(key) && key.trim().length >= 4 && text.includes(key.trim())) {
6267
- bad.push(`${path} holds a Target profile's key, and a key never leaves the lab`);
6603
+ bad.push(`${path} holds a connection's key, and a key never leaves the lab`);
6268
6604
  break;
6269
6605
  }
6270
6606
  }
@@ -6294,14 +6630,23 @@ function readBundle(files , ctx = {})
6294
6630
  if (!isObj(table)) return { error: `${BUNDLE_PROFILES} maps each profile's slug to its settings` };
6295
6631
  const keyed = bundleProblems(files);
6296
6632
  if (keyed.length) return { error: keyed[0] };
6297
- const profiles = [];
6298
- for (const [slug, p] of Object.entries(table)) {
6299
- if (!isSlug(slug)) return { error: `${BUNDLE_PROFILES}: ${JSON.stringify(slug)} is not a slug` };
6300
- if (!isObj(p)) return { error: `${BUNDLE_PROFILES}: ${slug} has to hold its settings` };
6633
+ // Each entry under its answer key, the connection's slug for its id: a
6634
+ // bundle from before version 16 keys each by its Target profile's slug.
6635
+ const profiles = [];
6636
+ const entries = {};
6637
+ for (const [key, p] of Object.entries(table)) {
6638
+ const at = answerOfKey(key);
6639
+ if (!isSlug(at.connection) || (at.profile != null && !PROFILE_ID.test(at.profile))) {
6640
+ return { error: `${BUNDLE_PROFILES}: ${JSON.stringify(key)} is not a slug` };
6641
+ }
6642
+ if (!isObj(p)) return { error: `${BUNDLE_PROFILES}: ${key} has to hold its settings` };
6301
6643
  if (!CONNECTION_TYPES[typeOf(p )]) {
6302
- return { error: `${BUNDLE_PROFILES}: ${slug} is of type ${p.type}, which this lab does not have` };
6644
+ return { error: `${BUNDLE_PROFILES}: ${key} is of type ${p.type}, which this lab does not have` };
6303
6645
  }
6304
- profiles.push({ ...p, id: slug, slug, name: isStr(p.name) && p.name ? p.name : slug });
6646
+ const slug = isSlug(p.slug) ? p.slug : at.connection;
6647
+ const name = isStr(p.name) && p.name ? p.name : slug;
6648
+ entries[key] = { ...p, id: slug, slug, name };
6649
+ if (!profiles.some(x => x.id === at.connection)) profiles.push({ id: at.connection, slug: at.connection, name: slug === at.connection ? name : at.connection });
6305
6650
  }
6306
6651
  // Each dataset by its name, which is what joins it to the eval naming it.
6307
6652
  const datasets = [];
@@ -6334,6 +6679,8 @@ function readBundle(files , ctx = {})
6334
6679
  if (!ref) continue;
6335
6680
  if (datasets.some(d => d.name === ref.name)) refs.push({ id: ref.id, name: ref.name });
6336
6681
  }
6682
+ // A profile is named by the id the key holds it under; one the table
6683
+ // lacks is a reference the run does not carry, and validation says so.
6337
6684
  const imported = importPipeline(raw, { profiles, groups: refs,
6338
6685
  sources: content?.type === "source" && isRef(content.ref) ? [content.ref] : [] });
6339
6686
  if ("error" in imported) return imported;
@@ -6342,7 +6689,7 @@ function readBundle(files , ctx = {})
6342
6689
  for (const r of refs) byId[r.id] = datasets.find(d => d.name === r.name) .body;
6343
6690
  const caseItems = [...new Set(Object.values(byId).flatMap(b => gradedSetFrom(b ).filter(c => !c.todo).map(caseItem)).filter(Boolean))];
6344
6691
  const run = resolvePipeline(imported.doc, {
6345
- profiles: id => profiles.find(p => p.id === id) ?? null,
6692
+ profiles: key => runAnswer({ profiles: entries }, key) ?? null,
6346
6693
  files: ctx.items ? [...ctx.items].sort() : caseItems,
6347
6694
  });
6348
6695
  const bad = validatePipeline(run, { groups: refs });
@@ -6361,24 +6708,28 @@ function stagesFor(run , i , resolveFilters
6361
6708
  {
6362
6709
  const stages = run.jobs.map((ch, k) => {
6363
6710
  const { kind, modifiers: _mods, ...settings } = outOf(ch);
6364
- // The modifiers a run applies, with each after-Read-as filter set a
6365
- // synthetic modifier where its link sits (#339), and the before-Read-as
6711
+ // The modifiers a run applies, with each Post-format filter set a
6712
+ // synthetic modifier where its link sits (#339), and the Pre-format
6366
6713
  // (text) filters applied to the raw reply before it is read (#405);
6367
6714
  // without a resolver, a Library link reads nothing, a private one its own.
6368
6715
  return { text: targetStepOf(run, i, k) .prompt, kind, withImage: sendsImage(ch),
6369
6716
  verbatim: !!targetEntryOf(run, i, k)?.verbatim, before: beforeFilters(ch, resolveFilters),
6370
6717
  modifiers: stageModifiers(ch, resolveFilters), settings };
6371
6718
  });
6719
+ // Each named by the connection whose key it is sent with.
6720
+ const asked = (key , held ) => {
6721
+ const { connection: _c, ...conn } = held || {} ;
6722
+ return { id: answerConnectionId(key, held), ...conn };
6723
+ };
6372
6724
  const connections = run.jobs.map((_, k) => {
6373
6725
  // A step answered here asks nothing: the profile a run made before it
6374
6726
  // kept is what it ran as, else Echo itself.
6727
+ const key = targetAnswerKey(run, i, k);
6375
6728
  if (targetEntryOf(run, i, k)?.local) {
6376
- const id = targetProfileOf(run, i, k)?.id;
6377
- const held = id != null ? run.profiles?.[id] : undefined;
6378
- return held && CONNECTION_TYPES[held.type]?.local ? { id: id , ...held } : { id: "", ...localConnOf(targetEntryOf(run, i, k)) };
6729
+ const held = key != null ? runAnswer(run, key) : undefined;
6730
+ return held && CONNECTION_TYPES[held.type]?.local ? asked(key , held) : { id: "", ...localConnOf(targetEntryOf(run, i, k)) };
6379
6731
  }
6380
- const id = targetProfileOf(run, i, k) .id;
6381
- return { id, ...(run.profiles?.[id] || {}) };
6732
+ return key != null ? asked(key, runAnswer(run, key)) : { id: "" };
6382
6733
  });
6383
6734
  // What a transport that builds its own request (an HTTP Request) builds
6384
6735
  // it from in each job, and this target's step there.
@@ -6388,11 +6739,16 @@ function stagesFor(run , i , resolveFilters
6388
6739
 
6389
6740
  /** The Setup profile scenario [i] of a run ran under, as History shows it: keyless. */
6390
6741
  function scenarioProfile(run , i ) {
6391
- const id = targetsOf(run)[i]?.profile?.id;
6742
+ // Read by the target: its own connection and profile, frozen at submit
6743
+ // apart from any other target's that shares the connection.
6744
+ const t = targetsOf(run)[i];
6745
+ const key = isRef(t?.connection) ? answerKey(t.connection.id, isRef(t.profile) ? t.profile.id : null) : undefined;
6392
6746
  // A target that asks nothing ran as Echo.
6393
- if (id == null && targetEntryOf(run, i, 0)?.local) { const lc = localConnOf(targetEntryOf(run, i, 0)); return { id: "", name: lc.name, settings: { ...lc } }; }
6394
- const conn = id != null ? run.profiles?.[id] : null;
6395
- return conn ? { id: id , name: conn.name, settings: connectionSettings(conn ) } : null;
6747
+ if (key == null && targetEntryOf(run, i, 0)?.local) { const lc = localConnOf(targetEntryOf(run, i, 0)); return { id: "", name: lc.name, settings: { ...lc } }; }
6748
+ const held = key != null ? runAnswer(run, key) : undefined;
6749
+ if (!held) return null;
6750
+ const { connection: _c, ...conn } = held;
6751
+ return { id: answerConnectionId(key , held), name: conn.name, settings: connectionSettings(conn ) };
6396
6752
  }
6397
6753
 
6398
6754
  /** The kind a run's result is read as: its last job's. */
@@ -6758,13 +7114,14 @@ export {
6758
7114
  PIPELINE_VERSION, DATASET_BODY_VERSION, STEP_TYPES, CONTENT_TYPES, OUTPUT_KINDS, MODIFIERS, EVAL_TYPES, LEGACY_TESTS, METRICS, readMetrics, metricSummary, metricCheck, itemScoresAsync, recordedReplyOf,
6759
7115
  SOURCE_TYPES, DEFAULT_SOURCE_TYPE, sourceTypeOf, WORKFLOW_PLATFORMS, platformOf, DYNAMIC_PROVIDERS, providerOf, WIZARDS, wizardProgress,
6760
7116
  registerKinds, defaultKind, jobDefaults, applyModifiers, keyVar, slugOf, uniqueSlug, isSlug, withSlugs, SLUG_MAX, connectionOf, addressOf, connectionSettings,
7117
+ splitProfile, mergeAnswer, answerKey, answerOfKey, answersOf, runAnswer, answerConnectionId, connectionScoped,
6761
7118
  connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, connectionImage, connectionProblems,
6762
7119
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
6763
7120
  contentOf, withContent, replyOf,
6764
- targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetProfileOf, recordedTargetOf, withTarget,
7121
+ targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
6765
7122
  tokensOf, withTokens, sendsImage, withImage, flowStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
6766
7123
  filterSetsOf, withFilterSets, stageModifiers, beforeFilters,
6767
- blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds,
7124
+ blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
6768
7125
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
6769
7126
  evalLabel, evalsOf, evalsDataset, casesRef, groupOf, groupOfMetrics, pipelineWarnings, itemsFound, previewOf, isWholeRun, EVAL_FIELDS,
6770
7127
  pipelineToYaml, importPipeline, yamlToPipeline, exportBundle, readBundle, bundleProblems, junitXml,