evals-lab 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +4 -4
  3. package/bin/evals-lab.js +9 -1
  4. package/bin/lab-file.js +97 -0
  5. package/bin/run.js +1 -1
  6. package/lab/VERSION +1 -1
  7. package/lab/demo/CREDITS.md +37 -3
  8. package/lab/demo/datasets/demo-1.json +2 -2
  9. package/lab/demo/datasets/demo-2.json +2 -2
  10. package/lab/demo/datasets/sentiment.json +383 -0
  11. package/lab/demo/datasets/weather-alerts.json +61 -0
  12. package/lab/demo/datasets/wordings.json +39 -0
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/sentiment.json +65 -0
  17. package/lab/demo/pipelines/weather-alerts.json +66 -0
  18. package/lab/demo/pipelines/wordings.json +104 -0
  19. package/lab/demo/sources/Demo 1/source.json +3 -0
  20. package/lab/demo/sources/Demo 2/source.json +3 -0
  21. package/lab/demo/sources/sentiment/bakery-croissant.txt +1 -0
  22. package/lab/demo/sources/sentiment/bus-timetable.txt +1 -0
  23. package/lab/demo/sources/sentiment/cafe-coffee.txt +1 -0
  24. package/lab/demo/sources/sentiment/concert-night.txt +1 -0
  25. package/lab/demo/sources/sentiment/film-sequel.txt +1 -0
  26. package/lab/demo/sources/sentiment/garden-tomatoes.txt +1 -0
  27. package/lab/demo/sources/sentiment/gym-contract.txt +1 -0
  28. package/lab/demo/sources/sentiment/headphones-review.txt +1 -0
  29. package/lab/demo/sources/sentiment/hotel-room.txt +1 -0
  30. package/lab/demo/sources/sentiment/library-hours.txt +1 -0
  31. package/lab/demo/sources/sentiment/meeting-minutes.txt +1 -0
  32. package/lab/demo/sources/sentiment/parcel-lost.txt +1 -0
  33. package/lab/demo/sources/sentiment/phone-update.txt +1 -0
  34. package/lab/demo/sources/sentiment/puppy-home.txt +1 -0
  35. package/lab/demo/sources/sentiment/recipe-note.txt +1 -0
  36. package/lab/demo/sources/sentiment/road-closure.txt +1 -0
  37. package/lab/demo/sources/sentiment/source.json +3 -0
  38. package/lab/demo/sources/sentiment/teacher-thanks.txt +1 -0
  39. package/lab/demo/sources/sentiment/train-delay.txt +1 -0
  40. package/lab/demo/sources/sentiment/volunteer-day.txt +1 -0
  41. package/lab/demo/sources/sentiment/weather-report.txt +1 -0
  42. package/lab/demo/sources/weather-alerts/https-api.weather.gov-alerts-types.txt +116 -0
  43. package/lab/demo/sources/weather-alerts/source.json +8 -0
  44. package/lab/evals-core.mjs +246 -57
  45. package/lab/flows/wdl.mjs +19 -0
  46. package/lab/kinds/list.mjs +5 -2
  47. package/lab/metrics/builtin.mjs +34 -7
  48. package/lab/run-evals.js +30 -11
  49. package/lab/server.py +614 -75
  50. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +5 -0
  51. package/lab/web/dist/assets/main-BSw-jyYD.css +1 -0
  52. package/lab/web/dist/assets/main-CrB8wPZn.js +17 -0
  53. package/lab/web/dist/assets/tokens-CrjDMCbz.js +60 -0
  54. package/lab/web/dist/assets/tokens-DDOONQon.css +1 -0
  55. package/lab/web/dist/gallery.html +3 -3
  56. package/lab/web/dist/index.html +4 -4
  57. package/package.json +1 -1
  58. package/lab/web/dist/assets/gallery-DKy8QK3o.js +0 -5
  59. package/lab/web/dist/assets/main-BGmSu7aS.css +0 -1
  60. package/lab/web/dist/assets/main-DdGON-yl.js +0 -16
  61. package/lab/web/dist/assets/tokens-BmytrwxE.js +0 -60
  62. package/lab/web/dist/assets/tokens-DBN7I0ru.css +0 -1
@@ -293,6 +293,8 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
293
293
 
294
294
 
295
295
 
296
+
297
+
296
298
 
297
299
 
298
300
  /** A base filter type (#334's decision: a registry, not an enum). A reader
@@ -471,19 +473,20 @@ export function applyFilter(filter , reply ) {
471
473
  export function applyFilterSet(filters , reply , stage ) {
472
474
  const instruction = reply.instruction ?? "";
473
475
  let body = reply.body, items = reply.items;
474
- const dropped = [];
476
+ const dropped = [], rewrote = [];
475
477
  let emptied ;
476
478
  for (const filter of filters) {
477
479
  const scope = FILTER_TYPES[filter.type]?.scope;
478
480
  if (stage && scope && (scope === "text") !== (stage === "text")) continue;
479
481
  const out = applyFilter(filter, { body, items, instruction });
482
+ if (out.body !== body) rewrote.push(filter.id);
480
483
  body = out.body;
481
484
  if (items.length && !out.items.length) emptied = filter.id;
482
485
  items = out.items;
483
486
  dropped.push(...out.dropped);
484
487
  if (out.reject != null) return { body, items, dropped, reject: out.reject };
485
488
  }
486
- return { body, items, dropped, reject: null, ...(emptied ? { emptied } : {}) };
489
+ return { body, items, dropped, reject: null, ...(emptied ? { emptied } : {}), ...(rewrote.length ? { rewrote } : {}) };
487
490
  }
488
491
 
489
492
  // A newline always ends an item, as it does in the real csv/lines parse
@@ -139,9 +139,9 @@ function verdictOf(said )
139
139
  can hold one. A reply that still cannot be read is asked for once more;
140
140
  a second, or a grader that failed, is the grader's error -- said as one,
141
141
  so it is not read as the reply failing. [ok] says whether a verdict read
142
- is one this metric can use. */
142
+ is one this metric can use: true, or why not. */
143
143
  async function graded(ctx , prompt , schema ,
144
- ok = () => true) {
144
+ ok = () => true) {
145
145
  if (!ctx.ask) throw new Error("a model-graded metric needs a grader, and this run has none");
146
146
  let last = "";
147
147
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -149,14 +149,21 @@ async function graded(ctx , prompt , schema
149
149
  try { said = await ctx.ask(prompt, schema); } catch (e) { throw new Error(`grader error: ${e instanceof Error ? e.message : String(e)}`); }
150
150
  try {
151
151
  const v = verdictOf(said);
152
- if (ok(v)) return v;
153
- last = `the grader said ${short(JSON.stringify(v))}`;
152
+ const usable = ok(v);
153
+ if (usable === true) return v;
154
+ last = typeof usable === "string" ? usable : `the grader said ${short(JSON.stringify(v))}`;
154
155
  } catch (e) { last = e instanceof Error ? e.message : String(e); }
155
156
  }
156
157
  throw new Error(`grader error: ${last}`);
157
158
  }
159
+ /** Whether a pass-or-fail verdict's score is on the scale it was asked for.
160
+ One off it -- a 4 echoing a 1-to-5 rating -- is not clamped to a score the
161
+ grader never gave: it is read as unreadable, asked for again, then the
162
+ grader's error (#514). */
163
+ const onScale = (v ) =>
164
+ typeof v.score !== "number" || (v.score >= 0 && v.score <= 1) || `the grader scored ${v.score}, off the 0 to 1 scale`;
158
165
  const gradedPass = (v , threshold ) => {
159
- const score = typeof v.score === "number" ? Math.max(0, Math.min(1, v.score)) : v.pass === true ? 1 : 0;
166
+ const score = typeof v.score === "number" ? v.score : v.pass === true ? 1 : 0;
160
167
  const pass = typeof v.pass === "boolean" ? v.pass && score >= threshold : score >= threshold;
161
168
  return { pass, score, reason: str(v.reason) || (pass ? "the grader passed it" : "the grader failed it") };
162
169
  };
@@ -339,6 +346,7 @@ const metrics = {
339
346
  // rules discarded is the finding.
340
347
  discarded: {
341
348
  family: RESULT,
349
+ readsResult: true,
342
350
  label: "Discarded",
343
351
  description: "Passes only when the job's rules discarded the reply.",
344
352
  options: [],
@@ -348,8 +356,26 @@ const metrics = {
348
356
  return ok(thrown, thrown ? "discarded" : input.error ? input.error : `kept ${input.terms.length} items`);
349
357
  },
350
358
  },
359
+ // A case that expects no answer at all: the connection answering an error
360
+ // -- a 400 for a file it can't read -- is the finding. A discard is
361
+ // Discarded's, at whichever stage it was.
362
+ refused: {
363
+ family: RESULT,
364
+ readsResult: true,
365
+ readsRefusal: true,
366
+ label: "Refused",
367
+ description: "Passes only when the target's call was refused: an error from the connection, not a reply.",
368
+ options: [],
369
+ defaults: () => ({}),
370
+ score: (input) => {
371
+ if (!input.error) return ok(false, "replied");
372
+ const refused = !/^(stage \d+: )?discarded: /.test(input.error);
373
+ return ok(refused, refused ? `refused: ${input.error}` : input.error);
374
+ },
375
+ },
351
376
  "item-count": {
352
377
  family: RESULT,
378
+ readsResult: true,
353
379
  label: "Item count",
354
380
  description: "Passes when the number of items is within the bounds.",
355
381
  options: [{ key: "min", label: "At least", type: "number" }, { key: "max", label: "At most", type: "number" }],
@@ -385,6 +411,7 @@ const metrics = {
385
411
  },
386
412
  latency: {
387
413
  family: RESULT,
414
+ readsResult: true,
388
415
  label: "Latency",
389
416
  description: "Passes when the reply came back within the time set.",
390
417
  options: [{ key: "max", label: "At most (ms)", type: "number" }],
@@ -472,7 +499,7 @@ const metrics = {
472
499
  validate: (m, at, bad) => needs(m, ["rubric"], at, bad),
473
500
  score: async (input, m, ctx) => gradedPass(await graded(ctx,
474
501
  `You are grading an output against a rubric.\n\n${taskOf(input, "")}Rubric:\n${str(m.rubric)}\n\nOutput:\n${input.text}\n\n${REPLY}`,
475
- VERDICT_SCHEMA), num(m.threshold) ?? 0.5),
502
+ VERDICT_SCHEMA, onScale), num(m.threshold) ?? 0.5),
476
503
  },
477
504
  factuality: {
478
505
  family: GRADED,
@@ -488,7 +515,7 @@ const metrics = {
488
515
  score: async (input, m, ctx) => gradedPass(await graded(ctx,
489
516
  `You are checking an output for factual consistency with a reference. Differences in wording or detail are `
490
517
  + `fine; a contradiction, or a fact the reference does not support, is not.\n\n${taskOf(input, "")}Reference:\n`
491
- + `${str(m.reference).trim() || recorded(input)}\n\nOutput:\n${input.text}\n\n${REPLY}`, VERDICT_SCHEMA), num(m.threshold) ?? 0.5),
518
+ + `${str(m.reference).trim() || recorded(input)}\n\nOutput:\n${input.text}\n\n${REPLY}`, VERDICT_SCHEMA, onScale), num(m.threshold) ?? 0.5),
492
519
  },
493
520
  "judge-vs-production": {
494
521
  family: GRADED,
package/lab/run-evals.js CHANGED
@@ -479,20 +479,24 @@ function prepare(bin, file, c) {
479
479
 
480
480
  // ---- The type → handler registry ------------------------------------------
481
481
  // Design §4. What a file *is* to a run is decided here and only here, so the
482
- // queue and the page never learn about file types, and pdf and xlsx are
483
- // later entries that touch nothing but this map.
482
+ // queue and the page never learn about file types, and xlsx is a later
483
+ // entry that touches nothing but this map.
484
484
  //
485
485
  // A file with no mapper is passed through anyway -- decoded as UTF-8 with
486
486
  // replacement and joined at {text} like the text handler, and flagged in the
487
- // transcript. Stating the failure mode beats hiding it: a PDF's text layer
488
- // is not extracted, so its streams arrive as arbitrary bytes, and a run
489
- // that read them "well" would be a finding about nothing.
487
+ // transcript. Stating the failure mode beats hiding it: a binary format's
488
+ // bytes arrive as mojibake, and a run that read them "well" would be a
489
+ // finding about nothing.
490
490
  const HANDLERS = new Map();
491
491
  [
492
492
  // Images: prepared to the profile's pixel budget by ImageMagick and sent
493
493
  // as the image content part. The formats are server.py's FILE_TYPES
494
494
  // image half, so nothing accepted for upload has no executor to run it.
495
495
  ["image", "jpg", "jpeg", "png", "heic", "heif", "dng", "tif", "tiff"],
496
+ // Documents: the file's bytes, unprocessed, handed to an API that reads
497
+ // them whole (core.DOCUMENT_TYPES); a connection that cannot is refused
498
+ // before the run, by validatePipeline.
499
+ ["document", ...Object.keys(core.DOCUMENT_TYPES).map(e => e.slice(1))],
496
500
  // Text-ish: decoded UTF-8, joining the stage at {text} -- the text-content
497
501
  // semantics the canvas already has (core.textPrompt).
498
502
  ["text", "txt", "md", "csv"],
@@ -511,8 +515,8 @@ const UNMAPPED = "the file has no handler, so it was passed through as raw "
511
515
 
512
516
  /**
513
517
  * One file of the Source made into what the job consumes: an image as the
514
- * dataUrl at [conn]'s budget, or text at {text}. A file with no mapper
515
- * still runs, and says so.
518
+ * dataUrl at [conn]'s budget, a document as its bytes, or text at {text}. A
519
+ * file with no mapper still runs, and says so.
516
520
  */
517
521
  function itemContent(bin, file, conn) {
518
522
  const kind = HANDLERS.get(extOf(file));
@@ -520,6 +524,7 @@ function itemContent(bin, file, conn) {
520
524
  const { dataUrl, dims } = prepare(bin, file, conn);
521
525
  return { kind, dataUrl, text: null, flags: [] };
522
526
  }
527
+ if (kind === "document") return { kind, dataUrl: documentOf(file), text: null, flags: [] };
523
528
  // TextDecoder with fatal unset replaces malformed bytes with U+FFFD --
524
529
  // what "decoded as UTF-8 with replacement" means, for a mapped-text file
525
530
  // and an unmapped one alike.
@@ -527,6 +532,13 @@ function itemContent(bin, file, conn) {
527
532
  return { kind: kind ?? "unmapped", dataUrl: null, text, flags: kind ? [] : [UNMAPPED] };
528
533
  }
529
534
 
535
+ /** A document file as the attachment a stage sends: its bytes, its media
536
+ type and its name. */
537
+ function documentOf(file) {
538
+ const name = path.basename(file);
539
+ return core.documentUrl(name, core.documentTypeOf(name).media, fs.readFileSync(file).toString("base64"));
540
+ }
541
+
530
542
  // ---- Reaching a model ----------------------------------------------------
531
543
 
532
544
  /**
@@ -737,9 +749,10 @@ async function ask(to, c, prompt, dataUrl, schema = null) {
737
749
  // this invocation did, so resume and re-run item agree with a fresh run.
738
750
 
739
751
  // What a file *is* to a run, the same registry §4 gives the graded set: an
740
- // image is prepared to the profile's pixel budget; a text file is decoded
741
- // UTF-8 and joined at {text}; anything else is passed through as UTF-8 with
742
- // replacement and flagged, so the failure mode is stated rather than hidden.
752
+ // image is prepared to the profile's pixel budget; a document is sent whole;
753
+ // a text file is decoded UTF-8 and joined at {text}; anything else is passed
754
+ // through as UTF-8 with replacement and flagged, so the failure mode is
755
+ // stated rather than hidden.
743
756
  const RUN_IMAGE_TYPES = new Set([".jpg", ".jpeg", ".png", ".heic", ".heif", ".dng", ".tif", ".tiff"]);
744
757
  const RUN_TEXT_TYPES = new Set([".txt", ".md", ".csv"]);
745
758
 
@@ -916,6 +929,7 @@ async function runSnapshot(o) {
916
929
  : files.map(name => {
917
930
  const ext = path.extname(name).toLowerCase();
918
931
  if (RUN_IMAGE_TYPES.has(ext)) return { name, kind: "image" };
932
+ if (core.documentTypeOf(name)) return { name, kind: "document" };
919
933
  // A record (flows/record.ts): one call of a Power Automate step, what
920
934
  // an HTTP Request is rebuilt from.
921
935
  if (ext === ".json") return { name, kind: "record" };
@@ -981,6 +995,11 @@ async function runSnapshot(o) {
981
995
  break;
982
996
  }
983
997
  }
998
+ if (item.kind === "document") {
999
+ const file = path.join(o.source, item.name);
1000
+ if (!fs.existsSync(file)) { failed = `the file ${item.name} is not in the source`; break; }
1001
+ dataUrl = documentOf(file);
1002
+ }
984
1003
  let record = null;
985
1004
  if (item.kind === "record") {
986
1005
  const file = path.join(o.source, item.name);
@@ -1450,7 +1469,7 @@ async function main() {
1450
1469
  found: s.found, missed: s.missed, invented: s.invented, count: s.count,
1451
1470
  metrics: s.metrics, watch: s.watch,
1452
1471
  terms: res.terms || [], ms: res.ms,
1453
- // What a file type was to this run -- "image", "text", "unmapped".
1472
+ // What a file type was to this run -- "image", "document", "text", "unmapped".
1454
1473
  type: item?.kind ?? null,
1455
1474
  // What the model actually said. #248 wants a failure written out as a
1456
1475
  // task an agent can act on, and "missed water" without the reply that