evals-lab 0.15.0 → 0.16.1

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.
package/lab/server.py CHANGED
@@ -89,12 +89,6 @@ DISPLAYABLE = (".jpg", ".jpeg", ".png")
89
89
  # is what issue #270 turned out to be.
90
90
  UNDISPLAYABLE = (".heic", ".heif", ".dng", ".tif", ".tiff")
91
91
 
92
- # The llama.cpp presets the Setup tab offers as Apply preset…: measured
93
- # decoding settings, Setup's data and no dataset's. Read by the page (served
94
- # at /api/presets) and by evals-check.js's Decoding.kt comparison; absent, the
95
- # tab just has no presets to offer, so it is not fatal to start.
96
- PRESETS = HERE / "presets.json" if (HERE / "presets.json").is_file() else None
97
-
98
92
  # The page, built from web/ (React + Vite, #24 and #27's page PR): the image's
99
93
  # web stage writes it here, as `npm run build` does in a checkout. Required,
100
94
  # like the dataset: without it there is no page. WEB_DIST names another
@@ -3879,11 +3873,11 @@ def response_pins(db, sid, name) -> set:
3879
3873
  except (ValueError, TypeError, AttributeError):
3880
3874
  continue
3881
3875
  for w in listed if isinstance(listed, list) else []:
3882
- c = content_of(w.get("work")) if isinstance(w, dict) else None
3883
- pin = c.get("pin") if isinstance(c, dict) else None
3884
- if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
3885
- and pin.get("name") == name and type(pin.get("n")) is int):
3886
- out.add(pin["n"] or 1)
3876
+ for _step, c, _at in item_contents(w.get("work")) if isinstance(w, dict) else []:
3877
+ pin = c.get("pin")
3878
+ if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
3879
+ and pin.get("name") == name and type(pin.get("n")) is int):
3880
+ out.add(pin["n"] or 1)
3887
3881
  return out
3888
3882
 
3889
3883
 
@@ -3978,6 +3972,17 @@ def filter_set_links(doc) -> list:
3978
3972
  Runs wires the step and migrates the inline rules in #339; this is the one
3979
3973
  reader of where the links sit, for the store (#336) to resolve and keep."""
3980
3974
  out = []
3975
+ # Version 20: filter sets live on the Filter steps of the flat step list
3976
+ # (evals-core.ts); version 19 and older held them on a job's Responses
3977
+ # steps.
3978
+ steps = doc.get("steps") if isinstance(doc, dict) else None
3979
+ if isinstance(steps, list) and any(isinstance(s, dict) and s.get("type") in ("getItems", "request") for s in steps):
3980
+ for st in steps:
3981
+ links = st.get("filterSets") if isinstance(st, dict) else None
3982
+ for link in links if isinstance(links, list) else []:
3983
+ if isinstance(link, dict):
3984
+ out.append(link)
3985
+ return out
3981
3986
  jobs = doc.get("jobs") if isinstance(doc, dict) else None
3982
3987
  for job in jobs if isinstance(jobs, list) else []:
3983
3988
  steps = job.get("steps") if isinstance(job, dict) else None
@@ -4143,6 +4148,24 @@ class Prompts:
4143
4148
  "ORDER BY at DESC, run_id, job", (r["id"],))]
4144
4149
  return out
4145
4150
 
4151
+ def used_by(self, run_ids) -> dict:
4152
+ """Each run's prompt uses, by run id: the target, the job, and the
4153
+ prompt and version its words were recorded as. A trashed prompt's
4154
+ uses are left out with it."""
4155
+ out = {}
4156
+ ids = list(run_ids)
4157
+ with self.store.lock, self._connect() as db:
4158
+ for k in range(0, len(ids), 500):
4159
+ chunk = ids[k:k + 500]
4160
+ for u in db.execute(
4161
+ "SELECT u.run_id, u.scenario_id, u.job, u.version, p.id, p.name FROM prompt_uses u "
4162
+ "JOIN prompts p ON p.id = u.prompt_id WHERE p.trash IS NULL AND p.workspace = ? "
4163
+ f"AND u.run_id IN ({', '.join('?' * len(chunk))}) ORDER BY u.job",
4164
+ (workspace_of(self.store), *chunk)):
4165
+ out.setdefault(u["run_id"], []).append(
4166
+ {"target": u["scenario_id"], "job": u["job"], "id": u["id"], "name": u["name"], "version": u["version"]})
4167
+ return out
4168
+
4146
4169
  def list(self) -> list:
4147
4170
  with self.store.lock, self._connect() as db:
4148
4171
  return [self._summary(db, r) for r in db.execute(
@@ -5616,23 +5639,38 @@ def pack_connection(ws, documents=False):
5616
5639
 
5617
5640
 
5618
5641
  def repoint_targets(doc: dict, ws, documents=False) -> None:
5619
- """A pack pipeline's targets that name a connection this lab does not have,
5620
- pointed at the one pack_connection picks, its profile dropped with it."""
5621
- targets = doc.get("targets")
5622
- if not isinstance(targets, list) or STORE is None:
5642
+ """A pack pipeline's Variants or Request cells that name a connection this
5643
+ lab does not have, pointed at the one pack_connection picks, its profile
5644
+ dropped with it. Version 20 keeps the connection on each Variant and on the
5645
+ Request steps' cells (and a Variant's overrides), so each is repointed."""
5646
+ if not isinstance(doc, dict) or STORE is None:
5623
5647
  return
5624
5648
  have = {c.get("id") for c in ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
5625
5649
  if isinstance(c, dict)}
5626
- pick = None
5627
- for t in targets:
5628
- ref = t.get("connection") if isinstance(t, dict) else None
5629
- if not isinstance(ref, dict) or ref.get("id") in have:
5630
- continue
5631
- pick = pick or pack_connection(ws, documents)
5632
- if pick is None:
5650
+ state = {"pick": None, "none": False}
5651
+
5652
+ def repoint(o):
5653
+ ref = o.get("connection") if isinstance(o, dict) else None
5654
+ if not isinstance(ref, dict) or ref.get("id") in have or state["none"]:
5633
5655
  return
5634
- t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
5635
- t["profile"] = None
5656
+ if state["pick"] is None:
5657
+ state["pick"] = pack_connection(ws, documents)
5658
+ if state["pick"] is None:
5659
+ state["none"] = True
5660
+ return
5661
+ pick = state["pick"]
5662
+ o["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
5663
+ if "profile" in o:
5664
+ o["profile"] = None
5665
+
5666
+ for v in doc.get("variants") or []:
5667
+ if isinstance(v, dict):
5668
+ repoint(v)
5669
+ for ov in (v.get("overrides") or {}).values():
5670
+ repoint(ov)
5671
+ for s in doc.get("steps") or []:
5672
+ if isinstance(s, dict) and s.get("type") == "request":
5673
+ repoint(s.get("cell"))
5636
5674
 
5637
5675
 
5638
5676
  def pack_requires_problem(manifest: dict, plugins: set) -> str:
@@ -5728,7 +5766,8 @@ def read_pack(data: bytes):
5728
5766
  if why:
5729
5767
  return None, why
5730
5768
  if (not isinstance(doc, dict) or doc.get("version") not in READABLE_VERSIONS
5731
- or not (isinstance(doc.get("jobs"), list) or isinstance(doc.get("chains"), list))):
5769
+ or not (isinstance(doc.get("steps"), list) or isinstance(doc.get("jobs"), list)
5770
+ or isinstance(doc.get("chains"), list))):
5732
5771
  return None, f"{name} is not a pipeline document the lab reads"
5733
5772
  pack["pipelines"].append((parts[1][:-5], doc))
5734
5773
  elif len(parts) == 3 and parts[0] == "sources" and parts[2] == PACK_SOURCE_META:
@@ -5892,11 +5931,11 @@ class Packs:
5892
5931
  return out
5893
5932
 
5894
5933
  def presets(self) -> list:
5895
- """Every installed pack's Setup presets, each marked with its pack."""
5934
+ """Every installed pack's Setup presets, each marked with its pack's id and name."""
5896
5935
  with self.store.lock, self._connect() as db:
5897
- rows = db.execute("SELECT id, presets FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
5936
+ rows = db.execute("SELECT id, name, presets FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
5898
5937
  (workspace_of(self.store),)).fetchall()
5899
- return [{**p, "pack": r["id"]} for r in rows for p in json.loads(r["presets"])]
5938
+ return [{**p, "pack": r["id"], "packName": r["name"]} for r in rows for p in json.loads(r["presets"])]
5900
5939
 
5901
5940
  # The page's documents are the server's to write here too, through the
5902
5941
  # docs table's own arbitration: a browser holding an older copy is told
@@ -6804,12 +6843,20 @@ def key_var(profile_id: str, slug=None) -> str:
6804
6843
 
6805
6844
 
6806
6845
  def run_items(run):
6807
- """A run's items: its content's file list, in order and with its repeats,
6808
- or the one inline text."""
6809
- content = content_of(run) or {}
6810
- if content.get("type") in ("text", "prompt"):
6811
- return [None]
6812
- return list(content.get("files") or [])
6846
+ """A run's items (evals-core.ts runItemsOf): each Get items step's in turn,
6847
+ a Source's file list in order and with its repeats or one inline text; a
6848
+ run where none adds one goes over the bare item, its prompt alone."""
6849
+ out, bare = [], False
6850
+ for _step, content, _at in item_contents(run):
6851
+ kind = content.get("type")
6852
+ if kind == "source":
6853
+ out.extend(content.get("files") or [])
6854
+ elif kind == "text":
6855
+ if isinstance(content.get("text"), str) and content["text"].strip():
6856
+ out.append(None)
6857
+ elif kind == "prompt":
6858
+ bare = True
6859
+ return out or ([None] if bare else [])
6813
6860
 
6814
6861
 
6815
6862
  def worker_refusal(code, stderr, env):
@@ -7024,18 +7071,23 @@ class Queue:
7024
7071
  row = self._row(db.execute(sql, (rid, ws) if ws else (rid,)).fetchone())
7025
7072
  return row if self._readable(row) else None
7026
7073
 
7027
- def list(self, limit=RUNS_PAGE, before=None, before_id=None, full=False):
7074
+ def list(self, limit=RUNS_PAGE, before=None, before_id=None, full=False, pipeline=None):
7028
7075
  """Runs, newest first, and whether more follow. `before` and
7029
7076
  `before_id` are the `submittedAt` and id of the last run on the page
7030
7077
  before, so History can page through them the way it pages the runs
7031
7078
  store. `submittedAt` is to the second, so two runs can share one; the
7032
7079
  id breaks the tie, or a page ending between them would skip the
7033
- second (#238). `before` alone stops at the second. Each row is
7080
+ second (#238). `before` alone stops at the second. `pipeline` keeps
7081
+ the runs of one pipeline, by its document's id: History's Overview
7082
+ (#642) reads a window of one pipeline's runs, which paging every run
7083
+ would find slowly where that pipeline runs rarely. Each row is
7034
7084
  brief_row's unless `full` asks for the whole of it."""
7035
7085
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
7036
7086
  rows = self._all(db, workspace_of(self.store))
7037
7087
  key = lambda r: (r["submittedAt"], r["id"])
7038
7088
  rows = [r for r in rows if before is None or key(r) < (before, before_id or "")]
7089
+ if pipeline is not None:
7090
+ rows = [r for r in rows if (r["snapshot"] or {}).get("id") == pipeline]
7039
7091
  rows.sort(key=key, reverse=True)
7040
7092
  page = rows[:limit]
7041
7093
  return (page if full else [brief_row(r) for r in page]), len(rows) > limit
@@ -7324,9 +7376,10 @@ class Queue:
7324
7376
  if run["status"] in ("queued", "running"):
7325
7377
  return None, (409, "a run still in progress cannot be re-run")
7326
7378
  snap = run["snapshot"]
7327
- _, err = self._pinned_files(snap)
7328
- if err:
7329
- return None, (409, err)
7379
+ for _step, content, _at in item_contents(snap):
7380
+ _, err = self._pinned_files(content)
7381
+ if err:
7382
+ return None, (409, err)
7330
7383
  # The group bodies the original kept: a re-run grades with exactly
7331
7384
  # them, whatever the groups or the pins read now; its filter-set
7332
7385
  # bodies travel the same way, so it cleans and gates as the original did.
@@ -7517,29 +7570,34 @@ class Queue:
7517
7570
  files_dir = rundir / "files"
7518
7571
  shutil.rmtree(rundir, ignore_errors=True)
7519
7572
  files_dir.mkdir(parents=True)
7520
- if live is not None:
7521
- for name in dict.fromkeys(content_of(snap).get("files") or []):
7522
- text = live.get(name)
7523
- if text is None:
7524
- return None, f"{name!r} was not produced for this run"
7525
- (files_dir / name).write_text(text, encoding="utf-8")
7526
- else:
7527
- files, err = self._pinned_files(snap)
7573
+ # Each Get items step's files under its own folder (item_contents): a
7574
+ # content produced at submit from the bytes kept with the row, any
7575
+ # other from its Source.
7576
+ for _step, content, at in item_contents(snap):
7577
+ folder = files_dir / at if at else files_dir
7578
+ folder.mkdir(exist_ok=True)
7579
+ if live is not None and (content.get("live") is True or isinstance(content.get("pin"), dict)):
7580
+ for name in dict.fromkeys(content.get("files") or []):
7581
+ text = live.get(at + "/" + name if at else name)
7582
+ if text is None:
7583
+ return None, f"{name!r} was not produced for this run"
7584
+ (folder / name).write_text(text, encoding="utf-8")
7585
+ continue
7586
+ files, err = self._pinned_files(content)
7528
7587
  if err:
7529
7588
  return None, err
7530
7589
  for name, path in files:
7531
- shutil.copy2(path, files_dir / name)
7590
+ shutil.copy2(path, folder / name)
7532
7591
  (rundir / "run.json").write_text(json.dumps(snap))
7533
7592
  return rundir, None
7534
7593
 
7535
7594
  @staticmethod
7536
- def _pinned_files(snap):
7537
- """Each file a run document reads, by name, and the path holding the
7538
- bytes it pinned at submit -- or (None, why), naming the Source that
7539
- has gone, or the files gone from it or changed since. A document
7540
- with no Source reads no files."""
7541
- content = content_of(snap) or {}
7542
- if content.get("type") != "source":
7595
+ def _pinned_files(content):
7596
+ """Each file one Get items content reads, by name, and the path holding
7597
+ the bytes it pinned at submit -- or (None, why), naming the Source
7598
+ that has gone, or the files gone from it or changed since. A content
7599
+ that is not a Source's reads no files."""
7600
+ if not isinstance(content, dict) or content.get("type") != "source":
7543
7601
  return [], None
7544
7602
  ref = content.get("ref") or {}
7545
7603
  label = ref.get("name") or ref.get("id")
@@ -7992,24 +8050,57 @@ def worker_destinations(run: dict):
7992
8050
  # {name, n}`, which a fetch, an edit or a Restore never moves (#596).
7993
8051
  # 19: a job's filters are one list around its Response Format, and filters are
7994
8052
  # seven verbs (#615).
7995
- PIPELINE_VERSION = 19
8053
+ PIPELINE_VERSION = 20
7996
8054
  # What a stored run may be: the current version, and the ones evals-core.ts's
7997
8055
  # upgradePipeline reads. A new submission is upgraded to the current one.
7998
- READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19)
8056
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)
7999
8057
  TARGET_CAP = 4
8000
8058
  # Target steps whose words the Prompt library does not record as a use: they
8001
8059
  # ask no model (evals-core.ts's STEP_TYPES.echo).
8002
8060
  UNRECORDED_STEPS = {"echo"}
8003
- RUN_FIELDS = ("version", "id", "name", "jobs", "targets", "evals", "pass", "profiles", "comment", "plugins")
8061
+ # The version-20 document is a flat step list and Variants (pipeline-model
8062
+ # §20): `steps` and `variants` in the place version 19 held `jobs` and
8063
+ # `targets`.
8064
+ RUN_FIELDS = ("version", "id", "name", "steps", "variants", "evals", "pass", "profiles", "comment", "plugins")
8065
+
8066
+
8067
+ def request_steps(doc):
8068
+ """A version-20 document's Request steps, in order (evals-core.ts): one per
8069
+ job, holding the settings every Variant shares."""
8070
+ steps = doc.get("steps") if isinstance(doc, dict) else None
8071
+ return [s for s in (steps if isinstance(steps, list) else [])
8072
+ if isinstance(s, dict) and s.get("type") == "request"]
8073
+
8074
+
8075
+ def merge_cell(base, ov):
8076
+ """A Variant's cell over a Request's base (evals-core.ts mergeCell): the
8077
+ base where there is no override, the override whole where it carries its
8078
+ own `type` (a different Answered by, or a different shape), else the base
8079
+ with the override's fields."""
8080
+ base = base if isinstance(base, dict) else {}
8081
+ if not isinstance(ov, dict) or not ov:
8082
+ return dict(base)
8083
+ if "type" in ov:
8084
+ return dict(ov)
8085
+ merged = dict(base)
8086
+ merged.update(ov)
8087
+ return merged
8004
8088
 
8005
8089
 
8006
8090
  def content_of(doc):
8007
- """A pipeline's content, where its version keeps it: job 1's Attach
8008
- Content step from version 8 (evals-core.ts contentOf), the document's own
8009
- `content` before. The dict itself, so a caller may stamp it in place.
8010
- None for none."""
8091
+ """A pipeline's content, where its version keeps it: version 20's first Get
8092
+ items step, version 8's job-1 Attach Content step (evals-core.ts
8093
+ contentOf), the document's own `content` before. The dict itself, so a
8094
+ caller may stamp it in place. None for none."""
8011
8095
  if not isinstance(doc, dict):
8012
8096
  return None
8097
+ steps = doc.get("steps")
8098
+ if isinstance(steps, list) and any(isinstance(s, dict) and s.get("type") in ("getItems", "request") for s in steps):
8099
+ for step in steps:
8100
+ if isinstance(step, dict) and step.get("type") == "getItems":
8101
+ c = step.get("content")
8102
+ return c if isinstance(c, dict) else None
8103
+ return None
8013
8104
  jobs = doc.get("jobs") if isinstance(doc.get("jobs"), list) else doc.get("chains")
8014
8105
  first = jobs[0] if isinstance(jobs, list) and jobs and isinstance(jobs[0], dict) else None
8015
8106
  if first is not None and isinstance(first.get("steps"), list):
@@ -8020,6 +8111,25 @@ def content_of(doc):
8020
8111
  return None
8021
8112
  c = doc.get("content")
8022
8113
  return c if isinstance(c, dict) else None
8114
+ def item_contents(doc):
8115
+ """Every Get items step's content, in list order, as (step id, content,
8116
+ folder) -- evals-core.ts itemContents: the first's files at the top of a
8117
+ run's files, each later one's under `@N`, N its place among them, a name
8118
+ no item can have (NAME_RESTRICT). A document before version 20 has its
8119
+ one content."""
8120
+ if not isinstance(doc, dict):
8121
+ return []
8122
+ steps = doc.get("steps")
8123
+ if isinstance(steps, list) and any(isinstance(s, dict) and s.get("type") in ("getItems", "request") for s in steps):
8124
+ out = []
8125
+ for k, step in enumerate(s for s in steps if isinstance(s, dict) and s.get("type") == "getItems"):
8126
+ if isinstance(step.get("content"), dict):
8127
+ out.append((step.get("id") or "", step["content"], f"@{k + 1}" if k else ""))
8128
+ return out
8129
+ c = content_of(doc)
8130
+ return [("", c, "")] if c is not None else []
8131
+
8132
+
8023
8133
  # The connection-type registry, mirrored from evals-core.ts (#46) because the
8024
8134
  # server is stdlib-only and cannot load it: which types ship, and the settings
8025
8135
  # each one's options may carry. The server only needs the field allow-list and
@@ -8298,12 +8408,23 @@ def upgrade_run(run):
8298
8408
 
8299
8409
 
8300
8410
  def cells_of(run):
8301
- """Every target's step in every job of a run, as (i, target, k, step):
8302
- version 10's targets and their steps, or an earlier version's scenarios
8303
- and their cells -- what the Prompt library records a use from, whichever
8304
- version a stored run is."""
8411
+ """Every Variant's cell in every Request of a run, as (i, variant, k, cell):
8412
+ version 20's Variants over the Request steps (evals-core.ts targetsOf), an
8413
+ earlier version's targets/scenarios and their steps/cells -- what the
8414
+ Prompt library records a use from, whichever version a stored run is. The
8415
+ cell is the Request's base with the Variant's override over it."""
8305
8416
  if not isinstance(run, dict):
8306
8417
  return
8418
+ reqs = request_steps(run)
8419
+ if reqs:
8420
+ variants = run.get("variants")
8421
+ for i, v in enumerate(variants if isinstance(variants, list) else []):
8422
+ if not isinstance(v, dict):
8423
+ continue
8424
+ overrides = v.get("overrides") if isinstance(v.get("overrides"), dict) else {}
8425
+ for k, req in enumerate(reqs):
8426
+ yield i, v, k, merge_cell(req.get("cell"), overrides.get(req.get("id")))
8427
+ return
8307
8428
  targets = run.get("targets") if isinstance(run.get("targets"), list) else run.get("scenarios")
8308
8429
  for i, t in enumerate(targets if isinstance(targets, list) else []):
8309
8430
  if not isinstance(t, dict):
@@ -8327,22 +8448,46 @@ def run_problems(run):
8327
8448
  _fields(run, "the run", RUN_FIELDS, bad)
8328
8449
  if not isinstance(run.get("id"), str) or not run["id"].strip():
8329
8450
  bad.append("the run has no id")
8330
- jobs = run.get("jobs")
8331
- if not isinstance(jobs, list) or not jobs:
8332
- return bad + ["jobs has to be a list of at least one job"]
8333
- jids = [ch.get("id") for ch in jobs if isinstance(ch, dict)]
8334
- for k, ch in enumerate(jobs):
8335
- at = f"job {k + 1}"
8336
- if not isinstance(ch, dict):
8337
- bad.append(f"{at} has to be an object")
8451
+ # The version-20 step list: Get items before Request 1, each Read Response
8452
+ # and Filter after the Request it reads (evals-core.ts stepsProblems). The
8453
+ # server checks the shape and order; tokens and kinds are the runner's.
8454
+ steps = run.get("steps")
8455
+ if not isinstance(steps, list):
8456
+ return bad + ["steps has to be a list"]
8457
+ req_ids = [s.get("id") for s in steps if isinstance(s, dict) and s.get("type") == "request"]
8458
+ seen_ids = set()
8459
+ saw_request = False
8460
+ for pos, s in enumerate(steps):
8461
+ if not isinstance(s, dict) or s.get("type") not in ("getItems", "request", "readResponse", "filter"):
8462
+ bad.append(f"\"{s.get('type') if isinstance(s, dict) else s}\" is not a step this lab has")
8338
8463
  continue
8339
- # An id is a job's own, like a scenario's: it is how a stored run
8340
- # keeps pointing at the job that actually ran, whatever it is
8341
- # called now (#93).
8342
- if not isinstance(ch.get("id"), str) or not ch["id"].strip():
8343
- bad.append(f"{at} has no id")
8344
- elif jids.count(ch["id"]) > 1:
8345
- bad.append(f"{at} has the id of another job")
8464
+ sid = s.get("id")
8465
+ if not isinstance(sid, str) or not sid.strip():
8466
+ bad.append(f"a {s['type']} step has no id")
8467
+ elif sid in seen_ids:
8468
+ bad.append(f"two steps have the id {sid}")
8469
+ else:
8470
+ seen_ids.add(sid)
8471
+ if s["type"] == "getItems":
8472
+ if saw_request:
8473
+ bad.append("Get items comes before Request 1")
8474
+ elif s["type"] == "request":
8475
+ saw_request = True
8476
+ else:
8477
+ # A Read Response or Filter reads the response of a Request, and
8478
+ # comes after it: the step it must come before names the Request.
8479
+ label = "Read Response" if s["type"] == "readResponse" else "Filter"
8480
+ rid = s.get("request")
8481
+ if not isinstance(rid, str) or rid not in req_ids:
8482
+ bad.append(f"a {label} reads a Request the experiment does not have")
8483
+ else:
8484
+ n = req_ids.index(rid) + 1
8485
+ req_pos = next((j for j, o in enumerate(steps)
8486
+ if isinstance(o, dict) and o.get("type") == "request" and o.get("id") == rid), -1)
8487
+ if pos < req_pos:
8488
+ bad.append(f"{label} {n} comes after Request {n}")
8489
+ if not req_ids:
8490
+ return bad + ["add a Request first"]
8346
8491
  table = run.get("profiles")
8347
8492
  if not isinstance(table, dict):
8348
8493
  return bad + ["profiles has to be an object of id → connection"]
@@ -8367,76 +8512,81 @@ def run_problems(run):
8367
8512
  bad.append(f"profiles {spelt[var]} and {cid} both take their key from ${var}")
8368
8513
  spelt[var] = cid
8369
8514
  connection_problems(pid, conn, at, bad)
8370
- targets = run.get("targets")
8371
- if not isinstance(targets, list) or not 1 <= len(targets) <= TARGET_CAP:
8372
- return bad + [f"a run needs between one and {TARGET_CAP} targets"]
8373
- ids = [t.get("id") for t in targets if isinstance(t, dict)]
8374
- for i, t in enumerate(targets):
8375
- at = f"target {i + 1}"
8376
- if not isinstance(t, dict):
8515
+ variants = run.get("variants")
8516
+ if not isinstance(variants, list) or not 1 <= len(variants) <= TARGET_CAP:
8517
+ return bad + [f"a run needs between one and {TARGET_CAP} variants"]
8518
+ vids = [v.get("id") for v in variants if isinstance(v, dict)]
8519
+ for i, v in enumerate(variants):
8520
+ at = f"variant {i + 1}"
8521
+ if not isinstance(v, dict):
8377
8522
  bad.append(f"{at} has to be an object")
8378
8523
  continue
8379
8524
  # The id the Prompt library records a use under (version 4).
8380
- if not isinstance(t.get("id"), str) or not t["id"].strip():
8525
+ if not isinstance(v.get("id"), str) or not v["id"].strip():
8381
8526
  bad.append(f"{at} has no id")
8382
- elif ids.count(t["id"]) > 1:
8383
- bad.append(f"{at} has the id of another target")
8384
- steps = t.get("steps")
8385
- if not isinstance(steps, list) or len(steps) != len(jobs):
8386
- bad.append(f"{at} has to have one step per job")
8527
+ elif vids.count(v["id"]) > 1:
8528
+ bad.append(f"{at} has the id of another variant")
8529
+ overrides = v.get("overrides")
8530
+ if overrides is not None and (not isinstance(overrides, dict) or any(k not in req_ids for k in overrides)):
8531
+ bad.append(f"{at}: overrides are keyed by Request ids")
8532
+ # Each Variant's cell in each Request -- the Request's base with the
8533
+ # Variant's override over it (cells_of) -- what it sends and the
8534
+ # connection the run carries for it. A Variant with no connection is one
8535
+ # whose cells ask none (Echo) or each name their own; which needs one is
8536
+ # the core's to judge. Whether the words may be blank (Echo answering from
8537
+ # the item) is the core's too, and the worker refuses the run in its words.
8538
+ seen_ref = set()
8539
+ for i, v, k, cell in cells_of(run):
8540
+ at = f"variant {i + 1}, job {k + 1}"
8541
+ if not isinstance(cell, dict) or not isinstance(cell.get("type"), str):
8542
+ bad.append(f"{at} has to name what it sends")
8387
8543
  continue
8388
- # A target with no connection is one whose steps ask none (Echo) or
8389
- # each name their own; which steps need one is the core's to judge.
8390
- # Each pair named -- the target's, a step's own -- is one the run
8391
- # carries (evals-core.ts targetAnswerOf).
8392
- def pair(o, base=None):
8393
- c = o.get("connection") if o.get("connection") is not None else (base or {}).get("connection")
8394
- p = o.get("profile")
8395
- return c, p
8396
- refs = [pair(t)] if t.get("connection") is not None or t.get("profile") is not None else []
8397
- refs += [pair(st, t) for st in steps
8398
- if isinstance(st, dict) and (st.get("connection") is not None or st.get("profile") is not None)]
8399
- # Whether the words may be blank -- Echo answering from the item --
8400
- # is the core's to judge, and the worker refuses the run in its words.
8401
- for k, step in enumerate(steps):
8402
- if not isinstance(step, dict) or not isinstance(step.get("type"), str):
8403
- bad.append(f"{at}, job {k + 1} has to name what it sends")
8404
- elif not isinstance(step.get("prompt"), str):
8405
- bad.append(f"{at}, job {k + 1} has no prompt")
8406
- elif step.get("from") is not None and not (
8407
- isinstance(step["from"], dict) and isinstance(step["from"].get("id"), str)):
8408
- bad.append(f"{at}, job {k + 1} names the prompt it was picked from without an id")
8409
- for c, p in refs:
8410
- if (not isinstance(c, dict) or not isinstance(c.get("id"), str)
8411
- or (p is not None and not (isinstance(p, dict) and isinstance(p.get("id"), str)))
8412
- or run_answer(table, answer_key(c["id"], p["id"] if p else None)) is None):
8413
- bad.append(f"{at} names a connection the run does not carry")
8414
- content = content_of(run)
8415
- kind = content.get("type") if isinstance(content, dict) else None
8416
- if kind == "source":
8417
- ref = content.get("ref")
8418
- files = content.get("files")
8419
- if not isinstance(ref, dict) or not isinstance(ref.get("id"), str) or not ref["id"]:
8420
- bad.append("a Source content has to name its Source")
8421
- # A live dynamic run (docs/dynamic-sources.md) produces its items at
8422
- # submit, so its file list is the server's to fill -- empty here is
8423
- # fine. Every other source run names the files it froze.
8424
- elif content.get("live") is True:
8425
- if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
8426
- bad.append("a run over a Source needs its file list")
8427
- elif not isinstance(files, list) or not files or not all(isinstance(f, str) for f in files):
8428
- bad.append("a run over a Source needs its file list")
8429
- pin = content.get("pin")
8430
- if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
8431
- and type(pin.get("n")) is int and pin["n"] >= 0):
8432
- bad.append("a Source content's pin names a response as { name, n }")
8433
- elif kind == "text":
8434
- if not isinstance(content.get("text"), str) or not content["text"].strip():
8435
- bad.append("a run over text needs some text")
8436
- elif kind == "prompt":
8437
- pass
8438
- else:
8439
- bad.append("a run needs a Source's files, some inline text, or Prompt only")
8544
+ if not isinstance(cell.get("prompt"), str):
8545
+ bad.append(f"{at} has no prompt")
8546
+ elif cell.get("from") is not None and not (
8547
+ isinstance(cell["from"], dict) and isinstance(cell["from"].get("id"), str)):
8548
+ bad.append(f"{at} names the prompt it was picked from without an id")
8549
+ c = cell.get("connection") if cell.get("connection") is not None else v.get("connection")
8550
+ p = cell.get("profile") if cell.get("profile") is not None else v.get("profile")
8551
+ if c is None and p is None:
8552
+ continue
8553
+ if (not isinstance(c, dict) or not isinstance(c.get("id"), str)
8554
+ or (p is not None and not (isinstance(p, dict) and isinstance(p.get("id"), str)))
8555
+ or run_answer(table, answer_key(c["id"], p["id"] if p else None)) is None):
8556
+ if i not in seen_ref:
8557
+ seen_ref.add(i)
8558
+ bad.append(f"variant {i + 1} names a connection the run does not carry")
8559
+ # Each Get items step's content; a later one is named by its place, and
8560
+ # may add no files (a run of one file of the first, say).
8561
+ contents = item_contents(run) or [("", None, "")]
8562
+ for k, (_step, content, _at) in enumerate(contents):
8563
+ said = []
8564
+ kind = content.get("type") if isinstance(content, dict) else None
8565
+ if kind == "source":
8566
+ ref = content.get("ref")
8567
+ files = content.get("files")
8568
+ if not isinstance(ref, dict) or not isinstance(ref.get("id"), str) or not ref["id"]:
8569
+ said.append("a Source content has to name its Source")
8570
+ # A live dynamic run (docs/dynamic-sources.md) produces its items at
8571
+ # submit, so its file list is the server's to fill -- empty here is
8572
+ # fine. Every other source run names the files it froze.
8573
+ elif content.get("live") is True:
8574
+ if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
8575
+ said.append("a run over a Source needs its file list")
8576
+ elif not isinstance(files, list) or not (files or k) or not all(isinstance(f, str) for f in files):
8577
+ said.append("a run over a Source needs its file list")
8578
+ pin = content.get("pin")
8579
+ if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
8580
+ and type(pin.get("n")) is int and pin["n"] >= 0):
8581
+ said.append("a Source content's pin names a response as { name, n }")
8582
+ elif kind == "text":
8583
+ if not isinstance(content.get("text"), str) or not content["text"].strip():
8584
+ said.append("a run over text needs some text")
8585
+ elif kind == "prompt":
8586
+ pass
8587
+ else:
8588
+ said.append("a run needs a Source's files, some inline text, or (No content)")
8589
+ bad.extend(f"Get items {k + 1}: {x}" if k else x for x in said)
8440
8590
  evals = run.get("evals")
8441
8591
  if not isinstance(evals, list) or not all(isinstance(t, dict) and isinstance(t.get("type"), str) for t in evals):
8442
8592
  bad.append("evals has to be a list of evals, each naming its type")
@@ -8612,7 +8762,8 @@ class Handler(BaseHTTPRequestHandler):
8612
8762
 
8613
8763
  # The eval groups' routes (docs/pipeline-model.md §17) are the datasets'
8614
8764
  # under their new name: /api/datasets goes on answering the same rows, so
8615
- # dataset-diff.js and a script written against it keep working. A list
8765
+ # the app's comparison (docs/app-data.md) and a script written against it
8766
+ # keep working: another repository's contract, so keep its shape. A list
8616
8767
  # asked for by the new name is `groups`.
8617
8768
  GROUPS_ROUTE = "/api/eval-groups"
8618
8769
  grouped = False
@@ -8706,8 +8857,14 @@ class Handler(BaseHTTPRequestHandler):
8706
8857
  return self._json(400, {"error": "limit has to be a number"})
8707
8858
  before = (query.get("before") or [None])[0]
8708
8859
  before_id = (query.get("beforeId") or [None])[0]
8709
- runs, more = QUEUE.list(limit, before, before_id, full=(query.get("full") or [""])[0] == "1")
8710
- return self._json(200, {"runs": runs, "more": more})
8860
+ pipeline = (query.get("pipeline") or [None])[0]
8861
+ runs, more = QUEUE.list(limit, before, before_id, full=(query.get("full") or [""])[0] == "1",
8862
+ pipeline=pipeline)
8863
+ # Which version of a Library prompt each target step ran: the
8864
+ # prompt library records it, not the run's document (§14), and
8865
+ # Overview breaks a target's line where it changes.
8866
+ used = PROMPTS.used_by([r["id"] for r in runs]) if PROMPTS else {}
8867
+ return self._json(200, {"runs": [{**r, "prompts": used.get(r["id"], [])} for r in runs], "more": more})
8711
8868
  if path.startswith("/api/queue/") and path.endswith("/groups") and path.count("/") == 4:
8712
8869
  # The bodies of the eval groups a run grades with, by `<id>@<n>`
8713
8870
  # (§17); null for a run that kept none, as /dataset answers.
@@ -8748,10 +8905,8 @@ class Handler(BaseHTTPRequestHandler):
8748
8905
  run["behind"] = QUEUE._behind(run["id"]) or 0
8749
8906
  return self._json(200, run)
8750
8907
  if path == "/api/presets":
8751
- # The presets file beside server.py, if the image has one, and
8752
- # every installed pack's.
8753
- own = json.loads(PRESETS.read_text("utf-8")) if PRESETS is not None else []
8754
- return self._json(200, own + (PACKS.presets() if PACKS is not None else []))
8908
+ # Every installed pack's: the lab ships none of its own (#652).
8909
+ return self._json(200, PACKS.presets() if PACKS is not None else [])
8755
8910
  if path == "/api/plugins":
8756
8911
  if PLUGINS is None:
8757
8912
  return self._send(404, b"not found", "text/plain")
@@ -9446,28 +9601,18 @@ class Handler(BaseHTTPRequestHandler):
9446
9601
 
9447
9602
  # ---- The run queue (#530) ----------------------------------------------
9448
9603
 
9449
- def _queue_submit(self):
9450
- """A run document, checked and queued. The page resolves it at submit
9451
- (evals-core.ts resolvePipeline); anything else posting here is held to
9452
- the same fields."""
9453
- if QUEUE is None:
9454
- return self._send(404, b"not found", "text/plain")
9455
- run = self._payload()
9456
- if run is None:
9457
- return self._json(400, {"error": "bad body"})
9458
- run, why = upgrade_run(run)
9459
- if why:
9460
- return self._json(400, {"error": "the run cannot be queued: " + why})
9461
- bad = run_problems(run)
9462
- if bad:
9463
- return self._json(400, {"error": "the run cannot be queued: " + "; ".join(bad)})
9464
- content = content_of(run)
9604
+ def _stamp_content(self, content):
9605
+ """One Get items step's content made ready to queue, in place: a
9606
+ Source's files pinned at their revision, or a dynamic Source's items
9607
+ produced or its pinned response read. Returns (produced, None) -- the
9608
+ bytes kept with the run by item name, or None for none -- or (None,
9609
+ (status, error))."""
9465
9610
  produced = None
9466
9611
  if content["type"] == "source":
9467
9612
  sid = content["ref"]["id"]
9468
9613
  src = SOURCES.get(sid)
9469
9614
  if src is None:
9470
- return self._json(404, {"error": "no such Source"})
9615
+ return None, (404, "no such Source")
9471
9616
  # A dynamic Source (docs/dynamic-sources.md) grades either its stored
9472
9617
  # Calls (snapshot) or content produced fresh at submit (live). The
9473
9618
  # choice is the run's (content.live) over the Source's default
@@ -9477,20 +9622,19 @@ class Handler(BaseHTTPRequestHandler):
9477
9622
  if src.get("type") == "dynamic":
9478
9623
  entry = source_entry("dynamic", src.get("config") or {})
9479
9624
  if entry is None:
9480
- return self._json(403, {"error": "the lab does not offer that Source's provider"})
9625
+ return None, (403, "the lab does not offer that Source's provider")
9481
9626
  cfg = src.get("config") or {}
9482
9627
  if entry.get("produce") is None:
9483
9628
  # A Supplied provider hands items in (the caller's --items,
9484
9629
  # or an Upload…); the server produces nothing, so there is no
9485
9630
  # live for it -- a hosted run grades the items it holds. A
9486
- # Source with none is refused above by run_problems, naming
9631
+ # Source with none is refused by run_problems, naming
9487
9632
  # the Source with no files; a run that asks it to go live is
9488
9633
  # refused here, pointing at the CLI (docs/ci.md).
9489
9634
  if content.get("live"):
9490
9635
  noun = entry.get("noun", "item")
9491
- return self._json(400, {"error":
9492
- f"this Source produces nothing: it grades the {noun}s handed in "
9493
- f"-- add one, or run it from the CLI with evals-lab run --items"})
9636
+ return None, (400, f"this Source produces nothing: it grades the {noun}s handed in "
9637
+ f"-- add one, or run it from the CLI with evals-lab run --items")
9494
9638
  content["live"] = False
9495
9639
  else:
9496
9640
  live = content.get("live")
@@ -9503,14 +9647,14 @@ class Handler(BaseHTTPRequestHandler):
9503
9647
  # written by hand is graded as saved.
9504
9648
  produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
9505
9649
  if err:
9506
- return self._json(err[0], {"error": err[1]})
9650
+ return None, err
9507
9651
  content["live"] = True
9508
9652
  content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
9509
9653
  for name, text in produced.items()}
9510
9654
  elif live:
9511
9655
  produced, name, err = self._produce_live(sid, entry, cfg)
9512
9656
  if err:
9513
- return self._json(err[0], {"error": err[1]})
9657
+ return None, err
9514
9658
  content["live"] = True
9515
9659
  # The one capture, looped as a snapshot's files are
9516
9660
  # (evals-core.ts expand): Run first cannot cut below
@@ -9528,8 +9672,8 @@ class Handler(BaseHTTPRequestHandler):
9528
9672
  # whatever a fetch, an edit or a Restore made current since.
9529
9673
  body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
9530
9674
  if body is None:
9531
- return self._json(400, {"error": f"the response this run grades, {pin['name']}, "
9532
- "was removed: pick another"})
9675
+ return None, (400, f"the response this run grades, {pin['name']}, "
9676
+ "was removed: pick another")
9533
9677
  text = body.decode("utf-8", errors="replace")
9534
9678
  produced = {pin["name"]: text}
9535
9679
  loops = content.get("loops")
@@ -9543,8 +9687,34 @@ class Handler(BaseHTTPRequestHandler):
9543
9687
  for name in dict.fromkeys(content["files"]):
9544
9688
  revs[name] = SOURCES.rev(sid, name)
9545
9689
  if revs[name] is None:
9546
- return self._json(400, {"error": f"{name!r} is not in that Source"})
9690
+ return None, (400, f"{name!r} is not in that Source")
9547
9691
  content["revs"] = revs
9692
+ return produced, None
9693
+
9694
+ def _queue_submit(self):
9695
+ """A run document, checked and queued. The page resolves it at submit
9696
+ (evals-core.ts resolvePipeline); anything else posting here is held to
9697
+ the same fields."""
9698
+ if QUEUE is None:
9699
+ return self._send(404, b"not found", "text/plain")
9700
+ run = self._payload()
9701
+ if run is None:
9702
+ return self._json(400, {"error": "bad body"})
9703
+ run, why = upgrade_run(run)
9704
+ if why:
9705
+ return self._json(400, {"error": "the run cannot be queued: " + why})
9706
+ bad = run_problems(run)
9707
+ if bad:
9708
+ return self._json(400, {"error": "the run cannot be queued: " + "; ".join(bad)})
9709
+ produced = None
9710
+ for _step, content, at in item_contents(run):
9711
+ got, err = self._stamp_content(content)
9712
+ if err:
9713
+ return self._json(err[0], {"error": err[1]})
9714
+ if got is not None:
9715
+ # A later Get items' items are kept under its own folder
9716
+ # (evals-core.ts itemContents), so two Sources' names never meet.
9717
+ produced = {**(produced or {}), **{(at + "/" + n if at else n): t for n, t in got.items()}}
9548
9718
  # A connection not shared with this workspace blocks the run with the
9549
9719
  # sentence the Run bar shows and the connections it names, so its Share
9550
9720
  # link can grant them at once (docs/workspaces.md).