evals-lab 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -2611,7 +2605,7 @@ class Sources:
2611
2605
  provider, json.dumps(request) if request else None, status, error, file))
2612
2606
 
2613
2607
  def snapshot(self, sid):
2614
- """Fetch now (#587): produce the Source's Call through the provider,
2608
+ """Get response (#587): produce the Source's Call through the provider,
2615
2609
  the `take` hook refusing a token and redacting, and keep it as a
2616
2610
  version of the response its name says (#596). A response already held
2617
2611
  gains the version and keeps its row, whatever was done to it -- an edit
@@ -2661,14 +2655,19 @@ class Sources:
2661
2655
  return {"name": stored, "outcome": "added", "provider": provider, "request": request,
2662
2656
  "status": status, "bytes": len(kept)}, None
2663
2657
  if path.read_bytes() == kept:
2658
+ # Status is the newest fetch's, though Updated stays when the
2659
+ # current response came.
2660
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2661
+ db.execute("UPDATE source_calls SET status = ?, error = NULL WHERE source = ? AND name = ?",
2662
+ (status, sid, name))
2664
2663
  return {"name": name, "outcome": "unchanged", "status": status}, None
2665
2664
  added, err = self._respond(sid, name, kept, "fetched", status)
2666
2665
  if err:
2667
2666
  return None, err
2668
2667
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2669
- db.execute("UPDATE source_calls SET request = ?, provider = ?, error = NULL, file = ? "
2668
+ db.execute("UPDATE source_calls SET request = ?, provider = ?, status = ?, error = NULL, file = ? "
2670
2669
  "WHERE source = ? AND name = ?",
2671
- (json.dumps(request) if request else None, provider, name, sid, name))
2670
+ (json.dumps(request) if request else None, provider, status, name, sid, name))
2672
2671
  return {"name": name, "outcome": "fetched", "provider": provider, "request": request,
2673
2672
  "status": status, "bytes": len(kept), **added}, None
2674
2673
 
@@ -3700,23 +3699,89 @@ def upgrade_body(body):
3700
3699
 
3701
3700
 
3702
3701
  # The filter-set body's version: evals-core.ts's FILTER_SET_VERSION. Version 3
3703
- # (#432) drops the `stage` version 2 (#405) gave it: a set belongs to no stage,
3704
- # and each filter's type decides where it runs, so a set linked before and after
3705
- # Read as runs its share in each. Version 1 named no stage either. A filter set
3706
- # is a Library row the lab stores and versions like an eval group (#336,
3707
- # FilterSets below); this and its twin are the body it keeps, held to
3708
- # evals-core.ts by proxy-check.py.
3709
- FILTER_SET_BODY_VERSION = 3
3702
+ # (#432) dropped the `stage` version 2 (#405) gave it. Version 4 (#615) has
3703
+ # seven verbs that run wherever they sit, around a Response Format a set may
3704
+ # straddle (its `format`). A filter set is a Library row the lab stores and
3705
+ # versions like an eval group (#336, FilterSets below); this and its twin are
3706
+ # the body it keeps, held to evals-core.ts by proxy-check.py.
3707
+ FILTER_SET_BODY_VERSION = 4
3708
+ # A version-3 filter's types that read the reply's text; the rest read items.
3709
+ V3_TEXT_TYPES = ("rewrite-text", "reject-text")
3710
+
3711
+
3712
+ def filter_v4(f):
3713
+ """A version-3 filter as the version-4 verb that reads a reply exactly as
3714
+ it did: kinds/list.ts's filterV4, in Python. A filter of no version-3
3715
+ type comes back as it was. Pure."""
3716
+ if not isinstance(f, dict):
3717
+ return f
3718
+ op = f.get("op") if isinstance(f.get("op"), dict) else {}
3719
+
3720
+ def pick(**keys):
3721
+ # A setting the old op lacked stays absent, as JavaScript's undefined
3722
+ # does in JSON; one it held as null stays null.
3723
+ return {k: op[v] for k, v in keys.items() if v in op}
3724
+
3725
+ def as_(kind, new_op=None):
3726
+ out = {k: v for k, v in f.items() if k != "op"}
3727
+ out["type"] = kind
3728
+ if new_op is not None:
3729
+ out["op"] = new_op
3730
+ if f.get("type") in V3_TEXT_TYPES and isinstance(f.get("tests"), list):
3731
+ out["tests"] = [{**t, "separator": "none"} if isinstance(t, dict) else t for t in f["tests"]]
3732
+ return out
3733
+
3734
+ replacement = op.get("replacement")
3735
+ replacement = "" if replacement is None else replacement
3736
+ t = f.get("type")
3737
+ if t == "rewrite-text":
3738
+ k = op.get("kind")
3739
+ if k in ("thinking", "preamble", "fences"):
3740
+ return as_("remove", {"kind": k})
3741
+ if k == "trim":
3742
+ return as_("trim", {"bullets": False})
3743
+ if k == "literal":
3744
+ return as_("replace", {"kind": "text", **pick(find="find"), "replacement": replacement})
3745
+ if k == "replace":
3746
+ return as_("replace", {"kind": "pattern", **pick(pattern="pattern"), "replacement": replacement})
3747
+ if k == "case":
3748
+ return as_("change-case", pick(mode="mode"))
3749
+ if k == "first":
3750
+ return as_("keep-first", pick(n="n", unit="unit"))
3751
+ if k == "extract":
3752
+ return as_("extract", pick(pattern="pattern"))
3753
+ return f
3754
+ if t in ("reject-text", "reject-reply"):
3755
+ return as_("reject")
3756
+ if t == "drop-item":
3757
+ return as_("remove", {"kind": "items"})
3758
+ if t == "rewrite-item":
3759
+ return as_("change-case", pick(mode="mode")) if op.get("kind") == "case" else as_("trim", {"bullets": True})
3760
+ if t == "keep-first":
3761
+ return as_("keep-first", {**pick(n="n"), "unit": "items"})
3762
+ return f
3710
3763
 
3711
3764
 
3712
3765
  def upgrade_filter_set_body(body):
3713
- """An earlier filter-set body as today's (version 3): evals-core.ts's
3714
- upgradeFilterSetBody, in Python. A version-1 or -2 body loses any `stage`
3715
- and takes this version; a version-3 body, and anything that is not a body,
3716
- come back as they are. Pure."""
3717
- if isinstance(body, dict) and body.get("version") in (1, 2):
3718
- return {**{k: v for k, v in body.items() if k != "stage"}, "version": FILTER_SET_BODY_VERSION}
3719
- return body
3766
+ """An earlier filter-set body as today's (version 4): evals-core.ts's
3767
+ upgradeFilterSetBody, in Python. A version-1 to -3 body loses any `stage`,
3768
+ each filter becomes its verb (filter_v4), and one that held both text and
3769
+ item filters puts the text ones first, then its `format` marker, then the
3770
+ item ones. A version-4 body, and anything that is not a body, come back as
3771
+ they are. Pure."""
3772
+ if not isinstance(body, dict) or body.get("version") not in (1, 2, 3):
3773
+ return body
3774
+ rest = {k: v for k, v in body.items() if k != "stage"}
3775
+ filters = rest.get("filters")
3776
+ if not isinstance(filters, list):
3777
+ return {**rest, "version": FILTER_SET_BODY_VERSION}
3778
+ is_text = [isinstance(f, dict) and f.get("type") in V3_TEXT_TYPES for f in filters]
3779
+ text = [f for f, x in zip(filters, is_text) if x]
3780
+ items = [f for f, x in zip(filters, is_text) if not x]
3781
+ out = {**rest, "version": FILTER_SET_BODY_VERSION, "filters": [filter_v4(f) for f in text + items]}
3782
+ if text and items:
3783
+ out["format"] = len(text)
3784
+ return out
3720
3785
 
3721
3786
 
3722
3787
  def body_prompt(body):
@@ -3808,11 +3873,11 @@ def response_pins(db, sid, name) -> set:
3808
3873
  except (ValueError, TypeError, AttributeError):
3809
3874
  continue
3810
3875
  for w in listed if isinstance(listed, list) else []:
3811
- c = content_of(w.get("work")) if isinstance(w, dict) else None
3812
- pin = c.get("pin") if isinstance(c, dict) else None
3813
- if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
3814
- and pin.get("name") == name and type(pin.get("n")) is int):
3815
- 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)
3816
3881
  return out
3817
3882
 
3818
3883
 
@@ -3862,6 +3927,8 @@ def free_name(base: str, taken: set, op: str) -> str:
3862
3927
  # are the body's; the per-filter shape is the core's to judge, as a case's
3863
3928
  # metrics are (dataset_problem). The suffixing is the generic one.
3864
3929
  FILTER_SET_FIELDS = ("version", "filters")
3930
+ # A body may also say where a job's Response Format reads among its filters.
3931
+ FILTER_SET_OPTIONAL = ("format",)
3865
3932
  FILTER_SET_NAME_MAX = 80
3866
3933
  unique_filter_set_name = unique_dataset_name
3867
3934
 
@@ -3872,7 +3939,7 @@ def filter_set_problem(body) -> str:
3872
3939
  if not isinstance(body, dict):
3873
3940
  return "a filter set's body is a JSON object"
3874
3941
  for k in body:
3875
- if k not in FILTER_SET_FIELDS:
3942
+ if k not in FILTER_SET_FIELDS + FILTER_SET_OPTIONAL:
3876
3943
  return f"a filter set's body has \"{k}\", which is not a filter-set field"
3877
3944
  for k in FILTER_SET_FIELDS:
3878
3945
  if k not in body:
@@ -3881,6 +3948,9 @@ def filter_set_problem(body) -> str:
3881
3948
  return f"a filter set's body is version {FILTER_SET_BODY_VERSION}"
3882
3949
  if not isinstance(body["filters"], list) or not all(isinstance(f, dict) for f in body["filters"]):
3883
3950
  return "filters has to be a list of filters"
3951
+ at = body.get("format")
3952
+ if at is not None and (type(at) is not int or not 0 <= at <= len(body["filters"])):
3953
+ return "Response Format has to sit among its filters"
3884
3954
  return ""
3885
3955
 
3886
3956
 
@@ -3902,6 +3972,17 @@ def filter_set_links(doc) -> list:
3902
3972
  Runs wires the step and migrates the inline rules in #339; this is the one
3903
3973
  reader of where the links sit, for the store (#336) to resolve and keep."""
3904
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
3905
3986
  jobs = doc.get("jobs") if isinstance(doc, dict) else None
3906
3987
  for job in jobs if isinstance(jobs, list) else []:
3907
3988
  steps = job.get("steps") if isinstance(job, dict) else None
@@ -4067,6 +4148,24 @@ class Prompts:
4067
4148
  "ORDER BY at DESC, run_id, job", (r["id"],))]
4068
4149
  return out
4069
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
+
4070
4169
  def list(self) -> list:
4071
4170
  with self.store.lock, self._connect() as db:
4072
4171
  return [self._summary(db, r) for r in db.execute(
@@ -4144,9 +4243,13 @@ class Prompts:
4144
4243
  prompt -- the version reading its words, or a new version if they
4145
4244
  were edited after the pick; any other cell links to the prompt
4146
4245
  already reading its words, or starts a new, unnamed one. A use is
4147
- recorded once: a second call for the same run changes nothing."""
4246
+ recorded once: a second call for the same run changes nothing.
4247
+ Returns each recorded cell's prompt, {target, job, prompt: {id,
4248
+ name}}: the page links a cell to it, so the next edit of typed words
4249
+ is that prompt's next version rather than another unnamed one."""
4148
4250
  # The caller's connection: a Row still reads by index, as it does.
4149
4251
  db.row_factory = sqlite3.Row
4252
+ recorded = []
4150
4253
  for i, sc, k, cell in cells_of(run):
4151
4254
  sid, sname = scenario_ref(sc, i)
4152
4255
  # Echo's words are no wording under test: the item is its reply,
@@ -4169,6 +4272,9 @@ class Prompts:
4169
4272
  db.execute("INSERT OR IGNORE INTO prompt_uses (prompt_id, version, run_id, scenario_id, "
4170
4273
  "scenario_name, job, at) VALUES (?, ?, ?, ?, ?, ?, ?)",
4171
4274
  (pid, version, rid, sid, sname, k, at))
4275
+ name = db.execute("SELECT name FROM prompts WHERE id = ?", (pid,)).fetchone()[0]
4276
+ recorded.append({"target": sid, "job": k, "prompt": {"id": pid, "name": name}})
4277
+ return recorded
4172
4278
 
4173
4279
  def backfill(self, runs):
4174
4280
  """The runs from before the library, read into it once. [runs] is
@@ -5533,23 +5639,38 @@ def pack_connection(ws, documents=False):
5533
5639
 
5534
5640
 
5535
5641
  def repoint_targets(doc: dict, ws, documents=False) -> None:
5536
- """A pack pipeline's targets that name a connection this lab does not have,
5537
- pointed at the one pack_connection picks, its profile dropped with it."""
5538
- targets = doc.get("targets")
5539
- 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:
5540
5647
  return
5541
5648
  have = {c.get("id") for c in ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
5542
5649
  if isinstance(c, dict)}
5543
- pick = None
5544
- for t in targets:
5545
- ref = t.get("connection") if isinstance(t, dict) else None
5546
- if not isinstance(ref, dict) or ref.get("id") in have:
5547
- continue
5548
- pick = pick or pack_connection(ws, documents)
5549
- 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"]:
5550
5655
  return
5551
- t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
5552
- 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"))
5553
5674
 
5554
5675
 
5555
5676
  def pack_requires_problem(manifest: dict, plugins: set) -> str:
@@ -5645,7 +5766,8 @@ def read_pack(data: bytes):
5645
5766
  if why:
5646
5767
  return None, why
5647
5768
  if (not isinstance(doc, dict) or doc.get("version") not in READABLE_VERSIONS
5648
- 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))):
5649
5771
  return None, f"{name} is not a pipeline document the lab reads"
5650
5772
  pack["pipelines"].append((parts[1][:-5], doc))
5651
5773
  elif len(parts) == 3 and parts[0] == "sources" and parts[2] == PACK_SOURCE_META:
@@ -5809,11 +5931,11 @@ class Packs:
5809
5931
  return out
5810
5932
 
5811
5933
  def presets(self) -> list:
5812
- """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."""
5813
5935
  with self.store.lock, self._connect() as db:
5814
- 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",
5815
5937
  (workspace_of(self.store),)).fetchall()
5816
- 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"])]
5817
5939
 
5818
5940
  # The page's documents are the server's to write here too, through the
5819
5941
  # docs table's own arbitration: a browser holding an older copy is told
@@ -6721,12 +6843,20 @@ def key_var(profile_id: str, slug=None) -> str:
6721
6843
 
6722
6844
 
6723
6845
  def run_items(run):
6724
- """A run's items: its content's file list, in order and with its repeats,
6725
- or the one inline text."""
6726
- content = content_of(run) or {}
6727
- if content.get("type") in ("text", "prompt"):
6728
- return [None]
6729
- 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 [])
6730
6860
 
6731
6861
 
6732
6862
  def worker_refusal(code, stderr, env):
@@ -6941,18 +7071,23 @@ class Queue:
6941
7071
  row = self._row(db.execute(sql, (rid, ws) if ws else (rid,)).fetchone())
6942
7072
  return row if self._readable(row) else None
6943
7073
 
6944
- 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):
6945
7075
  """Runs, newest first, and whether more follow. `before` and
6946
7076
  `before_id` are the `submittedAt` and id of the last run on the page
6947
7077
  before, so History can page through them the way it pages the runs
6948
7078
  store. `submittedAt` is to the second, so two runs can share one; the
6949
7079
  id breaks the tie, or a page ending between them would skip the
6950
- 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
6951
7084
  brief_row's unless `full` asks for the whole of it."""
6952
7085
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
6953
7086
  rows = self._all(db, workspace_of(self.store))
6954
7087
  key = lambda r: (r["submittedAt"], r["id"])
6955
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]
6956
7091
  rows.sort(key=key, reverse=True)
6957
7092
  page = rows[:limit]
6958
7093
  return (page if full else [brief_row(r) for r in page]), len(rows) > limit
@@ -6964,7 +7099,8 @@ class Queue:
6964
7099
 
6965
7100
  # ---- submit ---------------------------------------------------------
6966
7101
 
6967
- def submit(self, run: dict, dataset=None, rerun_of=None, groups=None, filter_sets=None, produced=None):
7102
+ def submit(self, run: dict, dataset=None, rerun_of=None, groups=None, filter_sets=None, produced=None,
7103
+ recorded=None):
6968
7104
  """
6969
7105
  A new queued run. `run` is the run document (docs/pipeline-model.md
6970
7106
  §5): the pipeline, the profiles it resolved to without their keys, its
@@ -6977,7 +7113,8 @@ class Queue:
6977
7113
  run a re-run was queued from. Returns the row. Its items are that list,
6978
7114
  or the one inline text, each through every scenario -- so the total is
6979
7115
  the list's length, repeats and all, the same count the runner and the
6980
- page make.
7116
+ page make. A `recorded` list is given each cell's prompt, as
7117
+ Prompts.record returns them.
6981
7118
  """
6982
7119
  rid = secrets.token_hex(6)
6983
7120
  now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -6987,7 +7124,7 @@ class Queue:
6987
7124
  "snapshot, results, progress, totals, dataset, rerun_of, groups, filter_sets, produced, workspace) "
6988
7125
  "VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
6989
7126
  (rid, "queued", now, json.dumps(run), "[]",
6990
- json.dumps({"current": None, "n": 0, "total": total}),
7127
+ json.dumps({"current": None, "at": None, "n": 0, "total": total}),
6991
7128
  json.dumps({"ran": 0, "passed": 0, "found": 0, "of": 0}),
6992
7129
  None if dataset is None else json.dumps(dataset), rerun_of,
6993
7130
  None if groups is None else json.dumps(groups),
@@ -6997,7 +7134,9 @@ class Queue:
6997
7134
  # Its prompts' uses, in the same transaction: a run is in the
6998
7135
  # library the moment it is queued, or not queued at all.
6999
7136
  if self.prompts is not None:
7000
- self.prompts.record(db, rid, run, now)
7137
+ links = self.prompts.record(db, rid, run, now)
7138
+ if recorded is not None:
7139
+ recorded.extend(links)
7001
7140
  # Read back under the lock the worker dequeues under, so the
7002
7141
  # answer is the run as it was queued: an idle worker can take it
7003
7142
  # the moment the lock is let go.
@@ -7237,9 +7376,10 @@ class Queue:
7237
7376
  if run["status"] in ("queued", "running"):
7238
7377
  return None, (409, "a run still in progress cannot be re-run")
7239
7378
  snap = run["snapshot"]
7240
- _, err = self._pinned_files(snap)
7241
- if err:
7242
- 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)
7243
7383
  # The group bodies the original kept: a re-run grades with exactly
7244
7384
  # them, whatever the groups or the pins read now; its filter-set
7245
7385
  # bodies travel the same way, so it cleans and gates as the original did.
@@ -7430,29 +7570,34 @@ class Queue:
7430
7570
  files_dir = rundir / "files"
7431
7571
  shutil.rmtree(rundir, ignore_errors=True)
7432
7572
  files_dir.mkdir(parents=True)
7433
- if live is not None:
7434
- for name in dict.fromkeys(content_of(snap).get("files") or []):
7435
- text = live.get(name)
7436
- if text is None:
7437
- return None, f"{name!r} was not produced for this run"
7438
- (files_dir / name).write_text(text, encoding="utf-8")
7439
- else:
7440
- 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)
7441
7587
  if err:
7442
7588
  return None, err
7443
7589
  for name, path in files:
7444
- shutil.copy2(path, files_dir / name)
7590
+ shutil.copy2(path, folder / name)
7445
7591
  (rundir / "run.json").write_text(json.dumps(snap))
7446
7592
  return rundir, None
7447
7593
 
7448
7594
  @staticmethod
7449
- def _pinned_files(snap):
7450
- """Each file a run document reads, by name, and the path holding the
7451
- bytes it pinned at submit -- or (None, why), naming the Source that
7452
- has gone, or the files gone from it or changed since. A document
7453
- with no Source reads no files."""
7454
- content = content_of(snap) or {}
7455
- 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":
7456
7601
  return [], None
7457
7602
  ref = content.get("ref") or {}
7458
7603
  label = ref.get("name") or ref.get("id")
@@ -7582,13 +7727,15 @@ class Queue:
7582
7727
  continue
7583
7728
  if obj.get("event") == "start":
7584
7729
  # The item the worker has just picked up, named before
7585
- # it is asked: what "current" means to a reader.
7730
+ # it is asked: what "current" means to a reader. Its
7731
+ # place too, since an item can repeat: the row in flight.
7586
7732
  with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
7587
7733
  cur = db.execute("SELECT progress FROM queue WHERE id = ?", (rid,)).fetchone()
7588
7734
  if cur is None:
7589
7735
  return
7590
7736
  progress = json.loads(cur[0])
7591
7737
  progress["current"] = obj.get("name")
7738
+ progress["at"] = obj["item"] if isinstance(obj["item"], int) else None
7592
7739
  db.execute("UPDATE queue SET progress = ? WHERE id = ?",
7593
7740
  (json.dumps(progress), rid))
7594
7741
  continue
@@ -7672,6 +7819,13 @@ class Queue:
7672
7819
  with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
7673
7820
  db.execute("UPDATE queue SET status = ?, finished_at = ?, error = ?, "
7674
7821
  "totals = ? WHERE id = ?", (status, now, error, json.dumps(totals), rid))
7822
+ # A terminal run has no row in flight.
7823
+ cur = db.execute("SELECT progress FROM queue WHERE id = ?", (rid,)).fetchone()
7824
+ if cur is not None:
7825
+ progress = json.loads(cur[0])
7826
+ if progress.get("at") is not None:
7827
+ progress["at"] = None
7828
+ db.execute("UPDATE queue SET progress = ? WHERE id = ?", (json.dumps(progress), rid))
7675
7829
 
7676
7830
  def _read_report(self, rid):
7677
7831
  """The worker's final report, which never shares the progress stream:
@@ -7894,24 +8048,59 @@ def worker_destinations(run: dict):
7894
8048
  # is the one its body is read by (#477).
7895
8049
  # 18: a dynamic Source's content graded as saved pins one response, `pin:
7896
8050
  # {name, n}`, which a fetch, an edit or a Restore never moves (#596).
7897
- PIPELINE_VERSION = 18
8051
+ # 19: a job's filters are one list around its Response Format, and filters are
8052
+ # seven verbs (#615).
8053
+ PIPELINE_VERSION = 20
7898
8054
  # What a stored run may be: the current version, and the ones evals-core.ts's
7899
8055
  # upgradePipeline reads. A new submission is upgraded to the current one.
7900
- READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)
8056
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)
7901
8057
  TARGET_CAP = 4
7902
8058
  # Target steps whose words the Prompt library does not record as a use: they
7903
8059
  # ask no model (evals-core.ts's STEP_TYPES.echo).
7904
8060
  UNRECORDED_STEPS = {"echo"}
7905
- 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
7906
8088
 
7907
8089
 
7908
8090
  def content_of(doc):
7909
- """A pipeline's content, where its version keeps it: job 1's Attach
7910
- Content step from version 8 (evals-core.ts contentOf), the document's own
7911
- `content` before. The dict itself, so a caller may stamp it in place.
7912
- 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."""
7913
8095
  if not isinstance(doc, dict):
7914
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
7915
8104
  jobs = doc.get("jobs") if isinstance(doc.get("jobs"), list) else doc.get("chains")
7916
8105
  first = jobs[0] if isinstance(jobs, list) and jobs and isinstance(jobs[0], dict) else None
7917
8106
  if first is not None and isinstance(first.get("steps"), list):
@@ -7922,6 +8111,25 @@ def content_of(doc):
7922
8111
  return None
7923
8112
  c = doc.get("content")
7924
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
+
7925
8133
  # The connection-type registry, mirrored from evals-core.ts (#46) because the
7926
8134
  # server is stdlib-only and cannot load it: which types ship, and the settings
7927
8135
  # each one's options may carry. The server only needs the field allow-list and
@@ -8200,12 +8408,23 @@ def upgrade_run(run):
8200
8408
 
8201
8409
 
8202
8410
  def cells_of(run):
8203
- """Every target's step in every job of a run, as (i, target, k, step):
8204
- version 10's targets and their steps, or an earlier version's scenarios
8205
- and their cells -- what the Prompt library records a use from, whichever
8206
- 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."""
8207
8416
  if not isinstance(run, dict):
8208
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
8209
8428
  targets = run.get("targets") if isinstance(run.get("targets"), list) else run.get("scenarios")
8210
8429
  for i, t in enumerate(targets if isinstance(targets, list) else []):
8211
8430
  if not isinstance(t, dict):
@@ -8229,22 +8448,46 @@ def run_problems(run):
8229
8448
  _fields(run, "the run", RUN_FIELDS, bad)
8230
8449
  if not isinstance(run.get("id"), str) or not run["id"].strip():
8231
8450
  bad.append("the run has no id")
8232
- jobs = run.get("jobs")
8233
- if not isinstance(jobs, list) or not jobs:
8234
- return bad + ["jobs has to be a list of at least one job"]
8235
- jids = [ch.get("id") for ch in jobs if isinstance(ch, dict)]
8236
- for k, ch in enumerate(jobs):
8237
- at = f"job {k + 1}"
8238
- if not isinstance(ch, dict):
8239
- 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")
8240
8463
  continue
8241
- # An id is a job's own, like a scenario's: it is how a stored run
8242
- # keeps pointing at the job that actually ran, whatever it is
8243
- # called now (#93).
8244
- if not isinstance(ch.get("id"), str) or not ch["id"].strip():
8245
- bad.append(f"{at} has no id")
8246
- elif jids.count(ch["id"]) > 1:
8247
- 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"]
8248
8491
  table = run.get("profiles")
8249
8492
  if not isinstance(table, dict):
8250
8493
  return bad + ["profiles has to be an object of id → connection"]
@@ -8269,76 +8512,81 @@ def run_problems(run):
8269
8512
  bad.append(f"profiles {spelt[var]} and {cid} both take their key from ${var}")
8270
8513
  spelt[var] = cid
8271
8514
  connection_problems(pid, conn, at, bad)
8272
- targets = run.get("targets")
8273
- if not isinstance(targets, list) or not 1 <= len(targets) <= TARGET_CAP:
8274
- return bad + [f"a run needs between one and {TARGET_CAP} targets"]
8275
- ids = [t.get("id") for t in targets if isinstance(t, dict)]
8276
- for i, t in enumerate(targets):
8277
- at = f"target {i + 1}"
8278
- 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):
8279
8522
  bad.append(f"{at} has to be an object")
8280
8523
  continue
8281
8524
  # The id the Prompt library records a use under (version 4).
8282
- 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():
8283
8526
  bad.append(f"{at} has no id")
8284
- elif ids.count(t["id"]) > 1:
8285
- bad.append(f"{at} has the id of another target")
8286
- steps = t.get("steps")
8287
- if not isinstance(steps, list) or len(steps) != len(jobs):
8288
- 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")
8289
8543
  continue
8290
- # A target with no connection is one whose steps ask none (Echo) or
8291
- # each name their own; which steps need one is the core's to judge.
8292
- # Each pair named -- the target's, a step's own -- is one the run
8293
- # carries (evals-core.ts targetAnswerOf).
8294
- def pair(o, base=None):
8295
- c = o.get("connection") if o.get("connection") is not None else (base or {}).get("connection")
8296
- p = o.get("profile")
8297
- return c, p
8298
- refs = [pair(t)] if t.get("connection") is not None or t.get("profile") is not None else []
8299
- refs += [pair(st, t) for st in steps
8300
- if isinstance(st, dict) and (st.get("connection") is not None or st.get("profile") is not None)]
8301
- # Whether the words may be blank -- Echo answering from the item --
8302
- # is the core's to judge, and the worker refuses the run in its words.
8303
- for k, step in enumerate(steps):
8304
- if not isinstance(step, dict) or not isinstance(step.get("type"), str):
8305
- bad.append(f"{at}, job {k + 1} has to name what it sends")
8306
- elif not isinstance(step.get("prompt"), str):
8307
- bad.append(f"{at}, job {k + 1} has no prompt")
8308
- elif step.get("from") is not None and not (
8309
- isinstance(step["from"], dict) and isinstance(step["from"].get("id"), str)):
8310
- bad.append(f"{at}, job {k + 1} names the prompt it was picked from without an id")
8311
- for c, p in refs:
8312
- if (not isinstance(c, dict) or not isinstance(c.get("id"), str)
8313
- or (p is not None and not (isinstance(p, dict) and isinstance(p.get("id"), str)))
8314
- or run_answer(table, answer_key(c["id"], p["id"] if p else None)) is None):
8315
- bad.append(f"{at} names a connection the run does not carry")
8316
- content = content_of(run)
8317
- kind = content.get("type") if isinstance(content, dict) else None
8318
- if kind == "source":
8319
- ref = content.get("ref")
8320
- files = content.get("files")
8321
- if not isinstance(ref, dict) or not isinstance(ref.get("id"), str) or not ref["id"]:
8322
- bad.append("a Source content has to name its Source")
8323
- # A live dynamic run (docs/dynamic-sources.md) produces its items at
8324
- # submit, so its file list is the server's to fill -- empty here is
8325
- # fine. Every other source run names the files it froze.
8326
- elif content.get("live") is True:
8327
- if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
8328
- bad.append("a run over a Source needs its file list")
8329
- elif not isinstance(files, list) or not files or not all(isinstance(f, str) for f in files):
8330
- bad.append("a run over a Source needs its file list")
8331
- pin = content.get("pin")
8332
- if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
8333
- and type(pin.get("n")) is int and pin["n"] >= 0):
8334
- bad.append("a Source content's pin names a response as { name, n }")
8335
- elif kind == "text":
8336
- if not isinstance(content.get("text"), str) or not content["text"].strip():
8337
- bad.append("a run over text needs some text")
8338
- elif kind == "prompt":
8339
- pass
8340
- else:
8341
- 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)
8342
8590
  evals = run.get("evals")
8343
8591
  if not isinstance(evals, list) or not all(isinstance(t, dict) and isinstance(t.get("type"), str) for t in evals):
8344
8592
  bad.append("evals has to be a list of evals, each naming its type")
@@ -8514,7 +8762,8 @@ class Handler(BaseHTTPRequestHandler):
8514
8762
 
8515
8763
  # The eval groups' routes (docs/pipeline-model.md §17) are the datasets'
8516
8764
  # under their new name: /api/datasets goes on answering the same rows, so
8517
- # 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
8518
8767
  # asked for by the new name is `groups`.
8519
8768
  GROUPS_ROUTE = "/api/eval-groups"
8520
8769
  grouped = False
@@ -8608,8 +8857,14 @@ class Handler(BaseHTTPRequestHandler):
8608
8857
  return self._json(400, {"error": "limit has to be a number"})
8609
8858
  before = (query.get("before") or [None])[0]
8610
8859
  before_id = (query.get("beforeId") or [None])[0]
8611
- runs, more = QUEUE.list(limit, before, before_id, full=(query.get("full") or [""])[0] == "1")
8612
- 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})
8613
8868
  if path.startswith("/api/queue/") and path.endswith("/groups") and path.count("/") == 4:
8614
8869
  # The bodies of the eval groups a run grades with, by `<id>@<n>`
8615
8870
  # (§17); null for a run that kept none, as /dataset answers.
@@ -8650,10 +8905,8 @@ class Handler(BaseHTTPRequestHandler):
8650
8905
  run["behind"] = QUEUE._behind(run["id"]) or 0
8651
8906
  return self._json(200, run)
8652
8907
  if path == "/api/presets":
8653
- # The presets file beside server.py, if the image has one, and
8654
- # every installed pack's.
8655
- own = json.loads(PRESETS.read_text("utf-8")) if PRESETS is not None else []
8656
- 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 [])
8657
8910
  if path == "/api/plugins":
8658
8911
  if PLUGINS is None:
8659
8912
  return self._send(404, b"not found", "text/plain")
@@ -9348,28 +9601,18 @@ class Handler(BaseHTTPRequestHandler):
9348
9601
 
9349
9602
  # ---- The run queue (#530) ----------------------------------------------
9350
9603
 
9351
- def _queue_submit(self):
9352
- """A run document, checked and queued. The page resolves it at submit
9353
- (evals-core.ts resolvePipeline); anything else posting here is held to
9354
- the same fields."""
9355
- if QUEUE is None:
9356
- return self._send(404, b"not found", "text/plain")
9357
- run = self._payload()
9358
- if run is None:
9359
- return self._json(400, {"error": "bad body"})
9360
- run, why = upgrade_run(run)
9361
- if why:
9362
- return self._json(400, {"error": "the run cannot be queued: " + why})
9363
- bad = run_problems(run)
9364
- if bad:
9365
- return self._json(400, {"error": "the run cannot be queued: " + "; ".join(bad)})
9366
- 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))."""
9367
9610
  produced = None
9368
9611
  if content["type"] == "source":
9369
9612
  sid = content["ref"]["id"]
9370
9613
  src = SOURCES.get(sid)
9371
9614
  if src is None:
9372
- return self._json(404, {"error": "no such Source"})
9615
+ return None, (404, "no such Source")
9373
9616
  # A dynamic Source (docs/dynamic-sources.md) grades either its stored
9374
9617
  # Calls (snapshot) or content produced fresh at submit (live). The
9375
9618
  # choice is the run's (content.live) over the Source's default
@@ -9379,20 +9622,19 @@ class Handler(BaseHTTPRequestHandler):
9379
9622
  if src.get("type") == "dynamic":
9380
9623
  entry = source_entry("dynamic", src.get("config") or {})
9381
9624
  if entry is None:
9382
- 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")
9383
9626
  cfg = src.get("config") or {}
9384
9627
  if entry.get("produce") is None:
9385
9628
  # A Supplied provider hands items in (the caller's --items,
9386
9629
  # or an Upload…); the server produces nothing, so there is no
9387
9630
  # live for it -- a hosted run grades the items it holds. A
9388
- # Source with none is refused above by run_problems, naming
9631
+ # Source with none is refused by run_problems, naming
9389
9632
  # the Source with no files; a run that asks it to go live is
9390
9633
  # refused here, pointing at the CLI (docs/ci.md).
9391
9634
  if content.get("live"):
9392
9635
  noun = entry.get("noun", "item")
9393
- return self._json(400, {"error":
9394
- f"this Source produces nothing: it grades the {noun}s handed in "
9395
- 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")
9396
9638
  content["live"] = False
9397
9639
  else:
9398
9640
  live = content.get("live")
@@ -9405,14 +9647,14 @@ class Handler(BaseHTTPRequestHandler):
9405
9647
  # written by hand is graded as saved.
9406
9648
  produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
9407
9649
  if err:
9408
- return self._json(err[0], {"error": err[1]})
9650
+ return None, err
9409
9651
  content["live"] = True
9410
9652
  content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
9411
9653
  for name, text in produced.items()}
9412
9654
  elif live:
9413
9655
  produced, name, err = self._produce_live(sid, entry, cfg)
9414
9656
  if err:
9415
- return self._json(err[0], {"error": err[1]})
9657
+ return None, err
9416
9658
  content["live"] = True
9417
9659
  # The one capture, looped as a snapshot's files are
9418
9660
  # (evals-core.ts expand): Run first cannot cut below
@@ -9430,8 +9672,8 @@ class Handler(BaseHTTPRequestHandler):
9430
9672
  # whatever a fetch, an edit or a Restore made current since.
9431
9673
  body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
9432
9674
  if body is None:
9433
- return self._json(400, {"error": f"the response this run grades, {pin['name']}, "
9434
- "was removed: pick another"})
9675
+ return None, (400, f"the response this run grades, {pin['name']}, "
9676
+ "was removed: pick another")
9435
9677
  text = body.decode("utf-8", errors="replace")
9436
9678
  produced = {pin["name"]: text}
9437
9679
  loops = content.get("loops")
@@ -9445,8 +9687,34 @@ class Handler(BaseHTTPRequestHandler):
9445
9687
  for name in dict.fromkeys(content["files"]):
9446
9688
  revs[name] = SOURCES.rev(sid, name)
9447
9689
  if revs[name] is None:
9448
- 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")
9449
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()}}
9450
9718
  # A connection not shared with this workspace blocks the run with the
9451
9719
  # sentence the Run bar shows and the connections it names, so its Share
9452
9720
  # link can grant them at once (docs/workspaces.md).
@@ -9503,8 +9771,12 @@ class Handler(BaseHTTPRequestHandler):
9503
9771
  # The worker reads a body per group now (#233), so a run may link
9504
9772
  # several; each body is kept with the row under `<id>@<n>`. A live
9505
9773
  # dynamic run carries its produced item bytes too.
9506
- return self._json(201, {"run": QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None,
9507
- produced=produced)})
9774
+ # Each target step's prompt, as recorded: a step typed with no `from`
9775
+ # takes it, so its next edit versions that prompt (docs/pipeline-model.md §14).
9776
+ recorded = []
9777
+ row = QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None, produced=produced,
9778
+ recorded=recorded)
9779
+ return self._json(201, {"run": row, "prompts": recorded})
9508
9780
 
9509
9781
  @staticmethod
9510
9782
  def _produce_endpoints(sid, entry, cfg, files):