localarena 0.1.0 → 0.2.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.
@@ -0,0 +1,828 @@
1
+ import { ChatMessage } from "./providers.js";
2
+
3
+ function assertRecord(value, field) {
4
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
5
+ throw new TypeError(`${field} must be an object`);
6
+ }
7
+ }
8
+
9
+ function assertNonEmptyString(value, field) {
10
+ if (typeof value !== "string") {
11
+ throw new TypeError(`${field} must be a string`);
12
+ }
13
+ if (value.trim().length === 0) {
14
+ throw new RangeError(`${field} must not be empty`);
15
+ }
16
+ }
17
+
18
+ function cloneJSON(value, field, seen = new Set(), depth = 0) {
19
+ if (depth > 100) {
20
+ throw new RangeError(`${field} exceeds the maximum JSON depth`);
21
+ }
22
+ if (
23
+ value === null ||
24
+ typeof value === "string" ||
25
+ typeof value === "boolean"
26
+ ) {
27
+ return value;
28
+ }
29
+ if (typeof value === "number") {
30
+ if (!Number.isFinite(value)) {
31
+ throw new TypeError(`${field} must contain only finite numbers`);
32
+ }
33
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
34
+ throw new TypeError(`${field} integers must be within the safe range`);
35
+ }
36
+ return value;
37
+ }
38
+ if (typeof value !== "object") {
39
+ throw new TypeError(`${field} must contain only JSON values`);
40
+ }
41
+ if (seen.has(value)) {
42
+ throw new TypeError(`${field} must not contain cycles`);
43
+ }
44
+
45
+ seen.add(value);
46
+ let result;
47
+ if (Array.isArray(value)) {
48
+ result = value.map((item, index) =>
49
+ cloneJSON(item, `${field}[${index}]`, seen, depth + 1),
50
+ );
51
+ } else {
52
+ const prototype = Object.getPrototypeOf(value);
53
+ if (prototype !== Object.prototype && prototype !== null) {
54
+ throw new TypeError(`${field} must contain only plain objects`);
55
+ }
56
+ result = {};
57
+ for (const [key, item] of Object.entries(value)) {
58
+ Object.defineProperty(result, key, {
59
+ configurable: true,
60
+ enumerable: true,
61
+ value: cloneJSON(item, `${field}.${key}`, seen, depth + 1),
62
+ writable: true,
63
+ });
64
+ }
65
+ }
66
+ seen.delete(value);
67
+ return result;
68
+ }
69
+
70
+ function freezeJSON(value) {
71
+ if (Array.isArray(value)) {
72
+ return Object.freeze(value.map(freezeJSON));
73
+ }
74
+ if (value !== null && typeof value === "object") {
75
+ return Object.freeze(
76
+ Object.fromEntries(
77
+ Object.entries(value).map(([key, item]) => [key, freezeJSON(item)]),
78
+ ),
79
+ );
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function frozenJSONObject(value, field) {
85
+ assertRecord(value, field);
86
+ return freezeJSON(cloneJSON(value, field));
87
+ }
88
+
89
+ // Unicode 15 full case-fold mappings that differ from default lowercase.
90
+ // Cherokee ranges are handled arithmetically below to keep this table compact.
91
+ const CASE_FOLD_SPECIALS = Object.freeze({
92
+ µ: "μ",
93
+ ß: "ss",
94
+ ʼn: "ʼn",
95
+ ſ: "s",
96
+ ǰ: "ǰ",
97
+ "ͅ": "ι",
98
+ ΐ: "ΐ",
99
+ ΰ: "ΰ",
100
+ ς: "σ",
101
+ ϐ: "β",
102
+ ϑ: "θ",
103
+ ϕ: "φ",
104
+ ϖ: "π",
105
+ ϰ: "κ",
106
+ ϱ: "ρ",
107
+ ϵ: "ε",
108
+ և: "եւ",
109
+ ᲀ: "в",
110
+ ᲁ: "д",
111
+ ᲂ: "о",
112
+ ᲃ: "с",
113
+ ᲄ: "т",
114
+ ᲅ: "т",
115
+ ᲆ: "ъ",
116
+ ᲇ: "ѣ",
117
+ ᲈ: "ꙋ",
118
+ ẖ: "ẖ",
119
+ ẗ: "ẗ",
120
+ ẘ: "ẘ",
121
+ ẙ: "ẙ",
122
+ ẚ: "aʾ",
123
+ ẛ: "ṡ",
124
+ ὐ: "ὐ",
125
+ ὒ: "ὒ",
126
+ ὔ: "ὔ",
127
+ ὖ: "ὖ",
128
+ ᾀ: "ἀι",
129
+ ᾁ: "ἁι",
130
+ ᾂ: "ἂι",
131
+ ᾃ: "ἃι",
132
+ ᾄ: "ἄι",
133
+ ᾅ: "ἅι",
134
+ ᾆ: "ἆι",
135
+ ᾇ: "ἇι",
136
+ ᾐ: "ἠι",
137
+ ᾑ: "ἡι",
138
+ ᾒ: "ἢι",
139
+ ᾓ: "ἣι",
140
+ ᾔ: "ἤι",
141
+ ᾕ: "ἥι",
142
+ ᾖ: "ἦι",
143
+ ᾗ: "ἧι",
144
+ ᾠ: "ὠι",
145
+ ᾡ: "ὡι",
146
+ ᾢ: "ὢι",
147
+ ᾣ: "ὣι",
148
+ ᾤ: "ὤι",
149
+ ᾥ: "ὥι",
150
+ ᾦ: "ὦι",
151
+ ᾧ: "ὧι",
152
+ ᾲ: "ὰι",
153
+ ᾳ: "αι",
154
+ ᾴ: "άι",
155
+ ᾶ: "ᾶ",
156
+ ᾷ: "ᾶι",
157
+ ι: "ι",
158
+ ῂ: "ὴι",
159
+ ῃ: "ηι",
160
+ ῄ: "ήι",
161
+ ῆ: "ῆ",
162
+ ῇ: "ῆι",
163
+ ῒ: "ῒ",
164
+ ΐ: "ΐ",
165
+ ῖ: "ῖ",
166
+ ῗ: "ῗ",
167
+ ῢ: "ῢ",
168
+ ΰ: "ΰ",
169
+ ῤ: "ῤ",
170
+ ῦ: "ῦ",
171
+ ῧ: "ῧ",
172
+ ῲ: "ὼι",
173
+ ῳ: "ωι",
174
+ ῴ: "ώι",
175
+ ῶ: "ῶ",
176
+ ῷ: "ῶι",
177
+ ff: "ff",
178
+ fi: "fi",
179
+ fl: "fl",
180
+ ffi: "ffi",
181
+ ffl: "ffl",
182
+ ſt: "st",
183
+ st: "st",
184
+ ﬓ: "մն",
185
+ ﬔ: "մե",
186
+ ﬕ: "մի",
187
+ ﬖ: "վն",
188
+ ﬗ: "մխ",
189
+ });
190
+
191
+ function unicodeCaseFold(value) {
192
+ return Array.from(value.toLowerCase(), (character) => {
193
+ const codePoint = character.codePointAt(0);
194
+ if (codePoint >= 0xab70 && codePoint <= 0xabbf) {
195
+ return String.fromCodePoint(codePoint - 0x97d0);
196
+ }
197
+ if (codePoint >= 0x13f8 && codePoint <= 0x13fd) {
198
+ return String.fromCodePoint(codePoint - 8);
199
+ }
200
+ return CASE_FOLD_SPECIALS[character] ?? character;
201
+ }).join("");
202
+ }
203
+
204
+ function normalizedText(value, { strip, ignoreCase }) {
205
+ let result = strip ? value.trim() : value;
206
+ if (ignoreCase) {
207
+ result = unicodeCaseFold(result);
208
+ }
209
+ return result;
210
+ }
211
+
212
+ function assertEvaluator(value) {
213
+ if (
214
+ value === null ||
215
+ typeof value !== "object" ||
216
+ typeof value.evaluate !== "function" ||
217
+ typeof value.toConfig !== "function"
218
+ ) {
219
+ throw new TypeError("evaluator must implement evaluate() and toConfig()");
220
+ }
221
+ }
222
+
223
+ function assertGeneration(value) {
224
+ if (
225
+ value === null ||
226
+ typeof value !== "object" ||
227
+ typeof value.text !== "string"
228
+ ) {
229
+ throw new TypeError("generation must contain text");
230
+ }
231
+ }
232
+
233
+ function deepJSONEqual(left, right) {
234
+ if (Object.is(left, right)) {
235
+ return true;
236
+ }
237
+ if (Array.isArray(left) || Array.isArray(right)) {
238
+ if (!Array.isArray(left) || !Array.isArray(right)) {
239
+ return false;
240
+ }
241
+ return (
242
+ left.length === right.length &&
243
+ left.every((value, index) => deepJSONEqual(value, right[index]))
244
+ );
245
+ }
246
+ if (
247
+ left !== null &&
248
+ right !== null &&
249
+ typeof left === "object" &&
250
+ typeof right === "object"
251
+ ) {
252
+ const leftKeys = Object.keys(left).sort();
253
+ const rightKeys = Object.keys(right).sort();
254
+ return (
255
+ leftKeys.length === rightKeys.length &&
256
+ leftKeys.every(
257
+ (key, index) =>
258
+ key === rightKeys[index] && deepJSONEqual(left[key], right[key]),
259
+ )
260
+ );
261
+ }
262
+ return false;
263
+ }
264
+
265
+ function jsonErrorLocation(error, source) {
266
+ const match = /position\s+(\d+)/iu.exec(error.message);
267
+ if (match === null) {
268
+ return "invalid JSON";
269
+ }
270
+ const position = Math.min(source.length, Number(match[1]));
271
+ const prefix = source.slice(0, position);
272
+ const lines = prefix.split("\n");
273
+ return `invalid JSON at line ${lines.length}, column ${lines.at(-1).length + 1}`;
274
+ }
275
+
276
+ function assertUniqueJSONKeys(source) {
277
+ let cursor = 0;
278
+
279
+ const skipWhitespace = () => {
280
+ while (/[\t\n\r ]/u.test(source[cursor] ?? "")) {
281
+ cursor += 1;
282
+ }
283
+ };
284
+
285
+ const parseString = () => {
286
+ const start = cursor;
287
+ cursor += 1;
288
+ while (cursor < source.length) {
289
+ if (source[cursor] === "\\") {
290
+ cursor += 2;
291
+ continue;
292
+ }
293
+ if (source[cursor] === '"') {
294
+ cursor += 1;
295
+ return JSON.parse(source.slice(start, cursor));
296
+ }
297
+ cursor += 1;
298
+ }
299
+ throw new SyntaxError("invalid JSON string");
300
+ };
301
+
302
+ const parseValue = () => {
303
+ skipWhitespace();
304
+ const token = source[cursor];
305
+ if (token === "{") {
306
+ cursor += 1;
307
+ skipWhitespace();
308
+ const keys = new Set();
309
+ if (source[cursor] === "}") {
310
+ cursor += 1;
311
+ return;
312
+ }
313
+ while (cursor < source.length) {
314
+ const key = parseString();
315
+ if (keys.has(key)) {
316
+ throw new SyntaxError("duplicate JSON key");
317
+ }
318
+ keys.add(key);
319
+ skipWhitespace();
320
+ cursor += 1;
321
+ parseValue();
322
+ skipWhitespace();
323
+ if (source[cursor] === "}") {
324
+ cursor += 1;
325
+ return;
326
+ }
327
+ cursor += 1;
328
+ skipWhitespace();
329
+ }
330
+ return;
331
+ }
332
+ if (token === "[") {
333
+ cursor += 1;
334
+ skipWhitespace();
335
+ if (source[cursor] === "]") {
336
+ cursor += 1;
337
+ return;
338
+ }
339
+ while (cursor < source.length) {
340
+ parseValue();
341
+ skipWhitespace();
342
+ if (source[cursor] === "]") {
343
+ cursor += 1;
344
+ return;
345
+ }
346
+ cursor += 1;
347
+ }
348
+ return;
349
+ }
350
+ if (token === '"') {
351
+ parseString();
352
+ return;
353
+ }
354
+ while (
355
+ cursor < source.length &&
356
+ !/[\t\n\r ,\]}]/u.test(source[cursor])
357
+ ) {
358
+ cursor += 1;
359
+ }
360
+ };
361
+
362
+ parseValue();
363
+ skipWhitespace();
364
+ if (cursor !== source.length) {
365
+ throw new SyntaxError("invalid JSON");
366
+ }
367
+ }
368
+
369
+ export function parseStrictJSON(source) {
370
+ const parsed = JSON.parse(source);
371
+ assertUniqueJSONKeys(source);
372
+ return parsed;
373
+ }
374
+
375
+ function evaluatorOptions(first, second, requiredKey) {
376
+ if (
377
+ first !== null &&
378
+ typeof first === "object" &&
379
+ !Array.isArray(first) &&
380
+ Object.hasOwn(first, requiredKey) &&
381
+ second === undefined
382
+ ) {
383
+ return first;
384
+ }
385
+ return { [requiredKey]: first, ...(second ?? {}) };
386
+ }
387
+
388
+ export class Score {
389
+ constructor(valueOrOptions, options = {}) {
390
+ const values =
391
+ valueOrOptions !== null &&
392
+ typeof valueOrOptions === "object" &&
393
+ !Array.isArray(valueOrOptions)
394
+ ? valueOrOptions
395
+ : { value: valueOrOptions, ...options };
396
+ assertRecord(values, "score");
397
+ if (typeof values.value !== "number" || !Number.isFinite(values.value)) {
398
+ throw new TypeError("score.value must be a finite number");
399
+ }
400
+ if (values.value < 0 || values.value > 1) {
401
+ throw new RangeError("score.value must be between zero and one");
402
+ }
403
+ const passed = values.passed ?? null;
404
+ if (passed !== null && typeof passed !== "boolean") {
405
+ throw new TypeError("score.passed must be a boolean or null");
406
+ }
407
+ const reason = values.reason ?? "";
408
+ if (typeof reason !== "string") {
409
+ throw new TypeError("score.reason must be a string");
410
+ }
411
+
412
+ this.value = values.value;
413
+ this.passed = passed;
414
+ this.reason = reason;
415
+ this.metadata = frozenJSONObject(values.metadata ?? {}, "score.metadata");
416
+ Object.freeze(this);
417
+ }
418
+
419
+ snapshot() {
420
+ return {
421
+ value: this.value,
422
+ passed: this.passed,
423
+ reason: this.reason,
424
+ metadata: cloneJSON(this.metadata, "score.metadata"),
425
+ };
426
+ }
427
+
428
+ toJSON() {
429
+ return this.snapshot();
430
+ }
431
+
432
+ static fromSnapshot(value) {
433
+ return new Score(value);
434
+ }
435
+ }
436
+
437
+ export class PromptTask {
438
+ constructor(idOrOptions, messages, options = {}) {
439
+ const values =
440
+ idOrOptions !== null &&
441
+ typeof idOrOptions === "object" &&
442
+ !Array.isArray(idOrOptions)
443
+ ? idOrOptions
444
+ : { id: idOrOptions, messages, ...options };
445
+ assertRecord(values, "task");
446
+ assertNonEmptyString(values.id, "task.id");
447
+ if (!Array.isArray(values.messages) || values.messages.length === 0) {
448
+ throw new TypeError("task.messages must be a non-empty array");
449
+ }
450
+ const normalizedMessages = values.messages.map((message, index) => {
451
+ if (!(message instanceof ChatMessage)) {
452
+ throw new TypeError(
453
+ `task.messages[${index}] must be a ChatMessage`,
454
+ );
455
+ }
456
+ if (!["system", "user", "assistant"].includes(message.role)) {
457
+ throw new RangeError(
458
+ `task.messages[${index}].role must be system, user, or assistant`,
459
+ );
460
+ }
461
+ return message;
462
+ });
463
+ if (values.evaluator !== undefined && values.evaluator !== null) {
464
+ assertEvaluator(values.evaluator);
465
+ }
466
+
467
+ this.id = values.id;
468
+ this.messages = Object.freeze(normalizedMessages);
469
+ this.evaluator = values.evaluator ?? null;
470
+ this.metadata = frozenJSONObject(values.metadata ?? {}, "task.metadata");
471
+ Object.freeze(this);
472
+ }
473
+
474
+ static fromText(id, prompt, options = {}) {
475
+ assertRecord(options, "options");
476
+ if (typeof prompt !== "string") {
477
+ throw new TypeError("prompt must be a string");
478
+ }
479
+ const messages = [];
480
+ if (options.system !== undefined && options.system !== null) {
481
+ if (typeof options.system !== "string") {
482
+ throw new TypeError("system must be a string or null");
483
+ }
484
+ messages.push(new ChatMessage("system", options.system));
485
+ }
486
+ messages.push(new ChatMessage("user", prompt));
487
+ return new PromptTask({
488
+ id,
489
+ messages,
490
+ evaluator: options.evaluator,
491
+ metadata: options.metadata,
492
+ });
493
+ }
494
+
495
+ snapshot() {
496
+ return {
497
+ id: this.id,
498
+ messages: this.messages.map(({ role, content }) => ({ role, content })),
499
+ evaluator: this.evaluator?.toConfig() ?? null,
500
+ metadata: cloneJSON(this.metadata, "task.metadata"),
501
+ };
502
+ }
503
+
504
+ toJSON() {
505
+ return this.snapshot();
506
+ }
507
+ }
508
+
509
+ export class ExactMatch {
510
+ constructor(expectedOrOptions, options) {
511
+ const values = evaluatorOptions(expectedOrOptions, options, "expected");
512
+ if (typeof values.expected !== "string") {
513
+ throw new TypeError("expected must be a string");
514
+ }
515
+ const strip = values.strip ?? true;
516
+ const ignoreCase = values.ignoreCase ?? values.ignore_case ?? false;
517
+ if (typeof strip !== "boolean" || typeof ignoreCase !== "boolean") {
518
+ throw new TypeError("strip and ignoreCase must be booleans");
519
+ }
520
+
521
+ this.expected = values.expected;
522
+ this.strip = strip;
523
+ this.ignoreCase = ignoreCase;
524
+ Object.freeze(this);
525
+ }
526
+
527
+ get name() {
528
+ return "exact";
529
+ }
530
+
531
+ evaluate(_task, generation) {
532
+ assertGeneration(generation);
533
+ const options = { strip: this.strip, ignoreCase: this.ignoreCase };
534
+ const passed =
535
+ normalizedText(generation.text, options) ===
536
+ normalizedText(this.expected, options);
537
+ return new Score(passed ? 1 : 0, {
538
+ passed,
539
+ reason: passed
540
+ ? "exact match"
541
+ : "answer did not exactly match",
542
+ });
543
+ }
544
+
545
+ toConfig() {
546
+ return {
547
+ type: this.name,
548
+ expected: this.expected,
549
+ strip: this.strip,
550
+ ignore_case: this.ignoreCase,
551
+ };
552
+ }
553
+ }
554
+
555
+ export class Contains {
556
+ constructor(expectedOrOptions, options) {
557
+ const values = evaluatorOptions(expectedOrOptions, options, "expected");
558
+ const expected =
559
+ typeof values.expected === "string"
560
+ ? [values.expected]
561
+ : values.expected;
562
+ if (
563
+ !Array.isArray(expected) ||
564
+ expected.length === 0 ||
565
+ expected.some(
566
+ (value) => typeof value !== "string" || value.length === 0,
567
+ )
568
+ ) {
569
+ throw new RangeError(
570
+ "expected must contain one or more non-empty strings",
571
+ );
572
+ }
573
+ const mode = values.mode ?? "all";
574
+ if (mode !== "all" && mode !== "any") {
575
+ throw new RangeError('mode must be "all" or "any"');
576
+ }
577
+ const ignoreCase = values.ignoreCase ?? values.ignore_case ?? false;
578
+ if (typeof ignoreCase !== "boolean") {
579
+ throw new TypeError("ignoreCase must be a boolean");
580
+ }
581
+
582
+ this.expected = Object.freeze([...expected]);
583
+ this.mode = mode;
584
+ this.ignoreCase = ignoreCase;
585
+ Object.freeze(this);
586
+ }
587
+
588
+ get name() {
589
+ return "contains";
590
+ }
591
+
592
+ evaluate(_task, generation) {
593
+ assertGeneration(generation);
594
+ const normalize = (value) =>
595
+ normalizedText(value, { strip: false, ignoreCase: this.ignoreCase });
596
+ const haystack = normalize(generation.text);
597
+ const matches = this.expected.map((value) =>
598
+ haystack.includes(normalize(value)),
599
+ );
600
+ const passed =
601
+ this.mode === "all" ? matches.every(Boolean) : matches.some(Boolean);
602
+ const matched = matches.filter(Boolean).length;
603
+ return new Score(passed ? 1 : 0, {
604
+ passed,
605
+ reason: `matched ${matched} of ${matches.length} required strings`,
606
+ metadata: { matched, total: matches.length },
607
+ });
608
+ }
609
+
610
+ toConfig() {
611
+ return {
612
+ type: this.name,
613
+ expected: [...this.expected],
614
+ mode: this.mode,
615
+ ignore_case: this.ignoreCase,
616
+ };
617
+ }
618
+ }
619
+
620
+ export class RegexMatch {
621
+ constructor(patternOrOptions, options) {
622
+ const values = evaluatorOptions(patternOrOptions, options, "pattern");
623
+ assertNonEmptyString(values.pattern, "pattern");
624
+ const ignoreCase = values.ignoreCase ?? values.ignore_case ?? false;
625
+ const fullMatch = values.fullMatch ?? values.full_match ?? false;
626
+ if (typeof ignoreCase !== "boolean" || typeof fullMatch !== "boolean") {
627
+ throw new TypeError("ignoreCase and fullMatch must be booleans");
628
+ }
629
+
630
+ new RegExp(values.pattern, ignoreCase ? "iu" : "u");
631
+ this.pattern = values.pattern;
632
+ this.ignoreCase = ignoreCase;
633
+ this.fullMatch = fullMatch;
634
+ Object.freeze(this);
635
+ }
636
+
637
+ get name() {
638
+ return "regex";
639
+ }
640
+
641
+ evaluate(_task, generation) {
642
+ assertGeneration(generation);
643
+ const expression = new RegExp(
644
+ this.pattern,
645
+ this.ignoreCase ? "iu" : "u",
646
+ );
647
+ const match = expression.exec(generation.text);
648
+ const passed =
649
+ match !== null &&
650
+ (!this.fullMatch ||
651
+ (match.index === 0 && match[0].length === generation.text.length));
652
+ return new Score(passed ? 1 : 0, {
653
+ passed,
654
+ reason: passed
655
+ ? "regular expression matched"
656
+ : "regular expression did not match",
657
+ });
658
+ }
659
+
660
+ toConfig() {
661
+ return {
662
+ type: this.name,
663
+ pattern: this.pattern,
664
+ ignore_case: this.ignoreCase,
665
+ full_match: this.fullMatch,
666
+ };
667
+ }
668
+ }
669
+
670
+ export class JSONMatch {
671
+ constructor(expected = null, options) {
672
+ let values;
673
+ if (
674
+ options === undefined &&
675
+ expected !== null &&
676
+ typeof expected === "object" &&
677
+ !Array.isArray(expected) &&
678
+ (Object.hasOwn(expected, "compare") ||
679
+ (Object.hasOwn(expected, "expected") &&
680
+ Object.keys(expected).every((key) =>
681
+ ["expected", "compare"].includes(key),
682
+ )))
683
+ ) {
684
+ values = expected;
685
+ } else {
686
+ values = { expected, ...(options ?? {}) };
687
+ }
688
+ const compare = values.compare ?? false;
689
+ if (typeof compare !== "boolean") {
690
+ throw new TypeError("compare must be a boolean");
691
+ }
692
+
693
+ this.compare = compare;
694
+ this.expected = compare
695
+ ? freezeJSON(cloneJSON(values.expected, "expected"))
696
+ : null;
697
+ Object.freeze(this);
698
+ }
699
+
700
+ get name() {
701
+ return "json";
702
+ }
703
+
704
+ evaluate(_task, generation) {
705
+ assertGeneration(generation);
706
+ let actual;
707
+ try {
708
+ actual = parseStrictJSON(generation.text);
709
+ } catch (error) {
710
+ return new Score(0, {
711
+ passed: false,
712
+ reason: jsonErrorLocation(error, generation.text),
713
+ });
714
+ }
715
+ const passed = !this.compare || deepJSONEqual(actual, this.expected);
716
+ return new Score(passed ? 1 : 0, {
717
+ passed,
718
+ reason: !this.compare
719
+ ? "valid JSON"
720
+ : passed
721
+ ? "JSON matched"
722
+ : "JSON did not match",
723
+ });
724
+ }
725
+
726
+ toConfig() {
727
+ const config = {
728
+ type: this.name,
729
+ compare: this.compare,
730
+ };
731
+ if (this.compare) {
732
+ config.expected = cloneJSON(this.expected, "expected");
733
+ }
734
+ return config;
735
+ }
736
+ }
737
+
738
+ export const JsonMatch = JSONMatch;
739
+
740
+ export class NumericMatch {
741
+ constructor(expectedOrOptions, options) {
742
+ const values = evaluatorOptions(expectedOrOptions, options, "expected");
743
+ const tolerance = values.tolerance ?? 0;
744
+ if (
745
+ typeof values.expected !== "number" ||
746
+ !Number.isFinite(values.expected)
747
+ ) {
748
+ throw new TypeError("expected must be a finite number");
749
+ }
750
+ if (typeof tolerance !== "number" || !Number.isFinite(tolerance)) {
751
+ throw new TypeError("tolerance must be a finite number");
752
+ }
753
+ if (tolerance < 0) {
754
+ throw new RangeError("tolerance must not be negative");
755
+ }
756
+
757
+ this.expected = values.expected;
758
+ this.tolerance = tolerance;
759
+ Object.freeze(this);
760
+ }
761
+
762
+ get name() {
763
+ return "numeric";
764
+ }
765
+
766
+ evaluate(_task, generation) {
767
+ assertGeneration(generation);
768
+ const source = generation.text.trim();
769
+ const numeric =
770
+ /^[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?|nan)$/iu;
771
+ if (source.length === 0 || !numeric.test(source)) {
772
+ return new Score(0, {
773
+ passed: false,
774
+ reason: "answer was not a number",
775
+ });
776
+ }
777
+ const actual = Number(source);
778
+ if (!Number.isFinite(actual)) {
779
+ return new Score(0, {
780
+ passed: false,
781
+ reason: "answer was not finite",
782
+ });
783
+ }
784
+ const difference = Math.abs(actual - this.expected);
785
+ const passed = difference <= this.tolerance;
786
+ return new Score(passed ? 1 : 0, {
787
+ passed,
788
+ reason: passed
789
+ ? "numeric answer matched"
790
+ : `absolute error ${difference} exceeded tolerance`,
791
+ metadata: {
792
+ actual,
793
+ absolute_error: difference,
794
+ },
795
+ });
796
+ }
797
+
798
+ toConfig() {
799
+ return {
800
+ type: this.name,
801
+ expected: this.expected,
802
+ tolerance: this.tolerance,
803
+ };
804
+ }
805
+ }
806
+
807
+ export function evaluatorFromConfig(config) {
808
+ if (config === null || config === undefined || config.type === "none") {
809
+ return null;
810
+ }
811
+ assertRecord(config, "evaluator");
812
+ switch (config.type) {
813
+ case "exact":
814
+ return new ExactMatch(config);
815
+ case "contains":
816
+ return new Contains(config);
817
+ case "regex":
818
+ return new RegexMatch(config);
819
+ case "json":
820
+ return new JSONMatch(config);
821
+ case "numeric":
822
+ return new NumericMatch(config);
823
+ default:
824
+ throw new RangeError(
825
+ `unsupported evaluator type: ${String(config.type)}`,
826
+ );
827
+ }
828
+ }