@velajs/testing 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5097ee4: Add `@velajs/testing/eval`: a small, model-agnostic harness for scoring the output of any string-producing function. Ships heuristic scorers (`exactMatch`, `contains`, `keyword`, `regex`), an `llmScorer` LLM-as-judge whose `judge` callback is injected (no AI SDK dependency, fails soft on unparseable replies), and `evaluate(dataset, run, scorers)` which returns per-case reports plus a per-scorer and overall aggregate.
8
+
3
9
  ## 0.5.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -95,6 +95,37 @@ expect(await res.json()).toEqual([{ id: 1, name: 'Item 1' }]);
95
95
  | `TestingModule` | Result of `compile()`: `get(token)`, `createApplication()`, `close()`. |
96
96
  | `OverrideBy` | The fluent intermediate from `overrideX()` — exposed for type-narrowing. |
97
97
 
98
+ ## Output scoring / evals
99
+
100
+ `@velajs/testing/eval` is a small, model-agnostic harness for grading the output of any string-producing function — an LLM turn, an agent loop, a formatter — against heuristics or an injected LLM judge. It pulls in no AI SDK: the only model touchpoint is `llmScorer`, whose `judge` is a plain callback you supply.
101
+
102
+ ```ts
103
+ import { evaluate, keyword, llmScorer } from '@velajs/testing/eval';
104
+
105
+ const report = await evaluate(
106
+ [{ input: 'where is my order?', expected: 'shipped' }],
107
+ async (input) => askSupportAgent(input),
108
+ {
109
+ coverage: keyword(['shipped']),
110
+ helpful: llmScorer({ criteria: 'answers the question', judge: myModel }),
111
+ },
112
+ );
113
+
114
+ expect(report.aggregate.overall).toBeGreaterThan(0.5);
115
+ ```
116
+
117
+ Each scorer returns a `[0, 1]` score (auto-clamped) with an optional reason. `evaluate` runs every case through the producer, grades each output with every scorer, and returns per-case reports plus an aggregate (`perScorer` means and an `overall` mean).
118
+
119
+ | Export | Purpose |
120
+ |---|---|
121
+ | `evaluate(dataset, run, scorers)` | Grade a dataset; returns `{ cases, aggregate }`. |
122
+ | `exactMatch(options?)` | 1 when the output equals `expected` (trimmed by default). |
123
+ | `contains(needles, options?)` | Substring match, `all`-of (default) or `any`-of. |
124
+ | `keyword(keywords, options?)` | Fractional coverage — the share of keywords present. |
125
+ | `regex(pattern)` | 1 when the pattern matches (global/sticky flags stripped for reuse). |
126
+ | `llmScorer({ criteria, judge })` | LLM-as-judge with an injected `judge` callback; fails soft to 0. |
127
+ | `Scorer` | `(input) => number \| ScoreResult` (sync or async) — write your own. |
128
+
98
129
  ## How it's wired
99
130
 
100
131
  `@velajs/testing` consumes vela's framework primitives via `@velajs/vela/internal` (`MetadataRegistry`, `Container`, `RouteManager`, `ModuleLoader`, `ComponentManager`, `VelaApplication`, `bindAppProviders`). The same `bindAppProviders` that `VelaFactory.create` uses, so test-mode and run-mode app construction stay in lockstep automatically.
@@ -0,0 +1,154 @@
1
+ //#region src/eval/types.d.ts
2
+ /**
3
+ * Shared types for the output-evaluation harness.
4
+ *
5
+ * The harness is model-agnostic: a {@link Scorer} is any function that turns one
6
+ * sample into a `[0, 1]` score, and {@link evaluate} drives a dataset through a
7
+ * producer and grades every output. Nothing here reaches for an AI SDK — the
8
+ * only "AI" surface is `llmScorer`, whose judge callback is injected by the
9
+ * caller.
10
+ */
11
+ /** A value that may be produced synchronously or via a promise. */
12
+ type Awaitable<T> = T | Promise<T>;
13
+ /** The single sample handed to a {@link Scorer}. */
14
+ interface ScorerInput {
15
+ /** The prompt/input that produced {@link ScorerInput.output}, for judge context. */
16
+ input?: string;
17
+ /** The output under test — the only field every scorer is guaranteed to see. */
18
+ output: string;
19
+ /** The reference answer, when a scorer grades against a gold value. */
20
+ expected?: string;
21
+ /** Free-form per-sample metadata carried through untouched. */
22
+ metadata?: Record<string, unknown>;
23
+ }
24
+ /** A scorer's verdict: a `[0, 1]` score plus an optional human-readable reason. */
25
+ interface ScoreResult {
26
+ /** The grade, always clamped into `[0, 1]` by the harness. */
27
+ score: number;
28
+ /** Why the scorer landed on this grade. */
29
+ reason?: string;
30
+ }
31
+ /**
32
+ * A scorer maps one sample to a grade. Returning a bare number is shorthand for
33
+ * `{ score }`; return a {@link ScoreResult} to attach a reason. Sync or async.
34
+ */
35
+ type Scorer = (input: ScorerInput) => Awaitable<number | ScoreResult>;
36
+ /** One dataset row: an input plus an optional gold answer and metadata. */
37
+ interface EvalCase {
38
+ input: string;
39
+ expected?: string;
40
+ metadata?: Record<string, unknown>;
41
+ }
42
+ /** The result of grading one case: the produced output and each scorer's verdict. */
43
+ interface CaseReport {
44
+ input: string;
45
+ output: string;
46
+ /** Each scorer's verdict, keyed by the name it was registered under. */
47
+ scores: Record<string, ScoreResult>;
48
+ /** The mean of this case's scores across all scorers. */
49
+ average: number;
50
+ }
51
+ /** The roll-up across every case. */
52
+ interface EvalAggregate {
53
+ /** Mean score of each scorer across all cases, keyed by scorer name. */
54
+ perScorer: Record<string, number>;
55
+ /** Mean of every case's average — the single headline number. */
56
+ overall: number;
57
+ }
58
+ /** The full run: one report per case plus the aggregate roll-up. */
59
+ interface EvalReport {
60
+ cases: CaseReport[];
61
+ aggregate: EvalAggregate;
62
+ }
63
+ //#endregion
64
+ //#region src/eval/scorers.d.ts
65
+ /** Options for {@link exactMatch}. */
66
+ interface ExactMatchOptions {
67
+ /** Trim leading/trailing whitespace before comparing (default `true`). */
68
+ trim?: boolean;
69
+ /** Compare with case sensitivity (default `true`). */
70
+ caseSensitive?: boolean;
71
+ }
72
+ /**
73
+ * Score 1 when the output equals the case's `expected`, else 0. Whitespace is
74
+ * trimmed by default; a case with no `expected` scores 0 (there is nothing to
75
+ * match against, so it fails closed).
76
+ */
77
+ declare function exactMatch(options?: ExactMatchOptions): Scorer;
78
+ /** Options for {@link contains}. */
79
+ interface ContainsOptions {
80
+ /** `all` (default) requires every needle; `any` requires at least one. */
81
+ mode?: 'all' | 'any';
82
+ /** Match with case sensitivity (default: case-insensitive). */
83
+ caseSensitive?: boolean;
84
+ }
85
+ /**
86
+ * Binary substring scorer. Pass a single needle or a list; with a list, `mode`
87
+ * chooses whether every needle must be present (`all`, the default) or just one
88
+ * (`any`). Scores 1 when the condition holds, else 0.
89
+ */
90
+ declare function contains(needles: string | readonly string[], options?: ContainsOptions): Scorer;
91
+ /** Options for {@link keyword}. */
92
+ interface KeywordOptions {
93
+ /** Match with case sensitivity (default: case-insensitive). */
94
+ caseSensitive?: boolean;
95
+ }
96
+ /**
97
+ * Fractional coverage scorer: the share of `keywords` present in the output. A
98
+ * rubric with three keywords, two of which appear, scores `2/3`. An empty
99
+ * keyword list would silently score everything 1 — the opposite of a useful
100
+ * eval — so it is rejected at construction time.
101
+ */
102
+ declare function keyword(keywords: readonly string[], options?: KeywordOptions): Scorer;
103
+ /**
104
+ * Score 1 when `pattern` matches the output, else 0. The global/sticky flags are
105
+ * stripped from a copy of the pattern so a scorer reused across many samples
106
+ * never carries `lastIndex` state between calls.
107
+ */
108
+ declare function regex(pattern: RegExp): Scorer;
109
+ /** Options for {@link llmScorer}. */
110
+ interface LlmScorerOptions {
111
+ /** The rubric the judge grades against, e.g. "answers the question accurately". */
112
+ criteria: string;
113
+ /**
114
+ * The injected judge. Wire it to your own model call (`generateText`, a raw
115
+ * fetch, whatever). Keeping it a plain callback is what lets this package
116
+ * grade LLM output without depending on any AI SDK, and makes it trivial to
117
+ * fake in tests.
118
+ */
119
+ judge: (prompt: string) => Promise<string>;
120
+ }
121
+ /** Render the grading prompt for one sample under a rubric. */
122
+ declare function renderJudgePrompt(criteria: string, sample: ScorerInput): string;
123
+ /**
124
+ * Read a judge reply into a verdict. The score is the leading number, clamped,
125
+ * and the full reply is kept as the reason. Requiring the number at the very
126
+ * start is deliberate: a reply with no leading number fails closed to 0, and a
127
+ * digit buried later in the justification can never be misread as the grade.
128
+ */
129
+ declare function parseJudgeReply(reply: string): ScoreResult;
130
+ /**
131
+ * LLM-as-judge scorer. It builds a rubric prompt, hands it to the injected
132
+ * `judge`, and parses the reply into a `[0, 1]` grade. Unparseable replies fail
133
+ * soft (score 0 with the raw reply as the reason) rather than throwing.
134
+ */
135
+ declare function llmScorer(options: LlmScorerOptions): Scorer;
136
+ //#endregion
137
+ //#region src/eval/evaluate.d.ts
138
+ /**
139
+ * Run a whole dataset through `run` and grade each output with the named
140
+ * `scorers`. Every case is graded by every scorer; cases execute concurrently,
141
+ * so keep `run` stateless per input (or key any shared state on `input`).
142
+ *
143
+ * The returned report carries one {@link CaseReport} per case plus an aggregate:
144
+ * the mean of each scorer across all cases (`perScorer`) and the mean of the
145
+ * per-case averages (`overall`).
146
+ *
147
+ * @param dataset The cases to grade.
148
+ * @param run Produces the output string for a given input.
149
+ * @param scorers A name → scorer map; the names key the per-scorer aggregate.
150
+ */
151
+ declare function evaluate(dataset: readonly EvalCase[], run: (input: string) => Awaitable<string>, scorers: Record<string, Scorer>): Promise<EvalReport>;
152
+ //#endregion
153
+ export { type Awaitable, type CaseReport, type ContainsOptions, type EvalAggregate, type EvalCase, type EvalReport, type ExactMatchOptions, type KeywordOptions, type LlmScorerOptions, type ScoreResult, type Scorer, type ScorerInput, contains, evaluate, exactMatch, keyword, llmScorer, parseJudgeReply, regex, renderJudgePrompt };
154
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,191 @@
1
+ //#region src/eval/scorers.ts
2
+ /**
3
+ * Force a raw number into the `[0, 1]` unit range. Anything non-finite (NaN,
4
+ * Infinity) collapses to 0 so a broken scorer fails closed rather than skewing
5
+ * an aggregate.
6
+ */
7
+ function clampUnit(value) {
8
+ if (!Number.isFinite(value)) return 0;
9
+ if (value < 0) return 0;
10
+ if (value > 1) return 1;
11
+ return value;
12
+ }
13
+ /** Coerce a scorer's return (a bare number or a full result) into a clamped {@link ScoreResult}. */
14
+ function normalizeVerdict(value) {
15
+ if (typeof value === "number") return { score: clampUnit(value) };
16
+ if (value.reason === void 0) return { score: clampUnit(value.score) };
17
+ return {
18
+ score: clampUnit(value.score),
19
+ reason: value.reason
20
+ };
21
+ }
22
+ /** Arithmetic mean of a list; an empty list averages to 0. */
23
+ function arithmeticMean(values) {
24
+ if (values.length === 0) return 0;
25
+ let total = 0;
26
+ for (const value of values) total += value;
27
+ return total / values.length;
28
+ }
29
+ /** Case-fold a string unless the caller opted into case-sensitive matching. */
30
+ function fold(value, caseSensitive) {
31
+ return caseSensitive ? value : value.toLowerCase();
32
+ }
33
+ /**
34
+ * Score 1 when the output equals the case's `expected`, else 0. Whitespace is
35
+ * trimmed by default; a case with no `expected` scores 0 (there is nothing to
36
+ * match against, so it fails closed).
37
+ */
38
+ function exactMatch(options = {}) {
39
+ const trim = options.trim ?? true;
40
+ const caseSensitive = options.caseSensitive ?? true;
41
+ const shape = (value) => {
42
+ const trimmed = trim ? value.trim() : value;
43
+ return caseSensitive ? trimmed : trimmed.toLowerCase();
44
+ };
45
+ return ({ output, expected }) => {
46
+ if (expected === void 0) return {
47
+ score: 0,
48
+ reason: "no expected value to compare against"
49
+ };
50
+ return shape(output) === shape(expected) ? 1 : 0;
51
+ };
52
+ }
53
+ /**
54
+ * Binary substring scorer. Pass a single needle or a list; with a list, `mode`
55
+ * chooses whether every needle must be present (`all`, the default) or just one
56
+ * (`any`). Scores 1 when the condition holds, else 0.
57
+ */
58
+ function contains(needles, options = {}) {
59
+ const list = typeof needles === "string" ? [needles] : [...needles];
60
+ if (list.length === 0) throw new Error("@velajs/testing: contains() needs at least one needle");
61
+ const mode = options.mode ?? "all";
62
+ const targets = list.map((needle) => fold(needle, options.caseSensitive));
63
+ return ({ output }) => {
64
+ const haystack = fold(output, options.caseSensitive);
65
+ const found = targets.map((target) => haystack.includes(target));
66
+ return (mode === "any" ? found.some(Boolean) : found.every(Boolean)) ? 1 : 0;
67
+ };
68
+ }
69
+ /**
70
+ * Fractional coverage scorer: the share of `keywords` present in the output. A
71
+ * rubric with three keywords, two of which appear, scores `2/3`. An empty
72
+ * keyword list would silently score everything 1 — the opposite of a useful
73
+ * eval — so it is rejected at construction time.
74
+ */
75
+ function keyword(keywords, options = {}) {
76
+ if (keywords.length === 0) throw new Error("@velajs/testing: keyword() needs at least one keyword");
77
+ const targets = keywords.map((word) => fold(word, options.caseSensitive));
78
+ return ({ output }) => {
79
+ const haystack = fold(output, options.caseSensitive);
80
+ const hits = targets.filter((target) => haystack.includes(target)).length;
81
+ return {
82
+ score: hits / targets.length,
83
+ reason: `${hits}/${targets.length} keywords present`
84
+ };
85
+ };
86
+ }
87
+ /**
88
+ * Score 1 when `pattern` matches the output, else 0. The global/sticky flags are
89
+ * stripped from a copy of the pattern so a scorer reused across many samples
90
+ * never carries `lastIndex` state between calls.
91
+ */
92
+ function regex(pattern) {
93
+ const stateless = new RegExp(pattern.source, pattern.flags.replace(/[gy]/gu, ""));
94
+ return ({ output }) => stateless.test(output) ? 1 : 0;
95
+ }
96
+ /** The regex that reads the leading number the judge is instructed to emit. */
97
+ const LEADING_NUMBER = /^\s*([+-]?\d+(?:\.\d+)?)/u;
98
+ /** Render the grading prompt for one sample under a rubric. */
99
+ function renderJudgePrompt(criteria, sample) {
100
+ const lines = [
101
+ "You are grading an assistant output.",
102
+ `Criterion: ${criteria}`,
103
+ "Begin your reply with a single decimal from 0 (does not meet) to 1 (fully meets), then optionally a short justification."
104
+ ];
105
+ if (sample.input !== void 0) lines.push("", `Prompt: ${sample.input}`);
106
+ if (sample.expected !== void 0) lines.push("", `Expected answer: ${sample.expected}`);
107
+ lines.push("", `Output to grade: ${sample.output}`);
108
+ return lines.join("\n");
109
+ }
110
+ /**
111
+ * Read a judge reply into a verdict. The score is the leading number, clamped,
112
+ * and the full reply is kept as the reason. Requiring the number at the very
113
+ * start is deliberate: a reply with no leading number fails closed to 0, and a
114
+ * digit buried later in the justification can never be misread as the grade.
115
+ */
116
+ function parseJudgeReply(reply) {
117
+ const match = LEADING_NUMBER.exec(reply);
118
+ const reason = reply.trim();
119
+ if (match === null) return {
120
+ score: 0,
121
+ reason
122
+ };
123
+ return {
124
+ score: clampUnit(Number(match[1])),
125
+ reason
126
+ };
127
+ }
128
+ /**
129
+ * LLM-as-judge scorer. It builds a rubric prompt, hands it to the injected
130
+ * `judge`, and parses the reply into a `[0, 1]` grade. Unparseable replies fail
131
+ * soft (score 0 with the raw reply as the reason) rather than throwing.
132
+ */
133
+ function llmScorer(options) {
134
+ return async (sample) => parseJudgeReply(await options.judge(renderJudgePrompt(options.criteria, sample)));
135
+ }
136
+ //#endregion
137
+ //#region src/eval/evaluate.ts
138
+ /** Run every scorer over one sample and collect the verdicts keyed by scorer name. */
139
+ async function gradeSample(sample, scorers) {
140
+ const graded = await Promise.all(Object.entries(scorers).map(async ([name, scorer]) => {
141
+ return [name, normalizeVerdict(await scorer(sample))];
142
+ }));
143
+ const scores = {};
144
+ for (const [name, verdict] of graded) scores[name] = verdict;
145
+ return scores;
146
+ }
147
+ /**
148
+ * Run a whole dataset through `run` and grade each output with the named
149
+ * `scorers`. Every case is graded by every scorer; cases execute concurrently,
150
+ * so keep `run` stateless per input (or key any shared state on `input`).
151
+ *
152
+ * The returned report carries one {@link CaseReport} per case plus an aggregate:
153
+ * the mean of each scorer across all cases (`perScorer`) and the mean of the
154
+ * per-case averages (`overall`).
155
+ *
156
+ * @param dataset The cases to grade.
157
+ * @param run Produces the output string for a given input.
158
+ * @param scorers A name → scorer map; the names key the per-scorer aggregate.
159
+ */
160
+ async function evaluate(dataset, run, scorers) {
161
+ const cases = await Promise.all(dataset.map(async (testCase) => {
162
+ const output = await run(testCase.input);
163
+ const sample = {
164
+ input: testCase.input,
165
+ output
166
+ };
167
+ if (testCase.expected !== void 0) sample.expected = testCase.expected;
168
+ if (testCase.metadata !== void 0) sample.metadata = testCase.metadata;
169
+ const scores = await gradeSample(sample, scorers);
170
+ const average = arithmeticMean(Object.values(scores).map((verdict) => verdict.score));
171
+ return {
172
+ input: testCase.input,
173
+ output,
174
+ scores,
175
+ average
176
+ };
177
+ }));
178
+ const perScorer = {};
179
+ for (const name of Object.keys(scorers)) perScorer[name] = arithmeticMean(cases.map((report) => report.scores[name]?.score ?? 0));
180
+ return {
181
+ cases,
182
+ aggregate: {
183
+ perScorer,
184
+ overall: arithmeticMean(cases.map((report) => report.average))
185
+ }
186
+ };
187
+ }
188
+ //#endregion
189
+ export { contains, evaluate, exactMatch, keyword, llmScorer, parseJudgeReply, regex, renderJudgePrompt };
190
+
191
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/eval/scorers.ts","../../src/eval/evaluate.ts"],"sourcesContent":["import type { Scorer, ScorerInput, ScoreResult } from './types.js';\n\n/**\n * Force a raw number into the `[0, 1]` unit range. Anything non-finite (NaN,\n * Infinity) collapses to 0 so a broken scorer fails closed rather than skewing\n * an aggregate.\n */\nexport function clampUnit(value: number): number {\n if (!Number.isFinite(value)) {\n return 0;\n }\n if (value < 0) {\n return 0;\n }\n if (value > 1) {\n return 1;\n }\n\n return value;\n}\n\n/** Coerce a scorer's return (a bare number or a full result) into a clamped {@link ScoreResult}. */\nexport function normalizeVerdict(value: number | ScoreResult): ScoreResult {\n if (typeof value === 'number') {\n return { score: clampUnit(value) };\n }\n if (value.reason === undefined) {\n return { score: clampUnit(value.score) };\n }\n\n return { score: clampUnit(value.score), reason: value.reason };\n}\n\n/** Arithmetic mean of a list; an empty list averages to 0. */\nexport function arithmeticMean(values: readonly number[]): number {\n if (values.length === 0) {\n return 0;\n }\n\n let total = 0;\n\n for (const value of values) {\n total += value;\n }\n\n return total / values.length;\n}\n\n/** Case-fold a string unless the caller opted into case-sensitive matching. */\nfunction fold(value: string, caseSensitive: boolean | undefined): string {\n return caseSensitive ? value : value.toLowerCase();\n}\n\n/** Options for {@link exactMatch}. */\nexport interface ExactMatchOptions {\n /** Trim leading/trailing whitespace before comparing (default `true`). */\n trim?: boolean;\n /** Compare with case sensitivity (default `true`). */\n caseSensitive?: boolean;\n}\n\n/**\n * Score 1 when the output equals the case's `expected`, else 0. Whitespace is\n * trimmed by default; a case with no `expected` scores 0 (there is nothing to\n * match against, so it fails closed).\n */\nexport function exactMatch(options: ExactMatchOptions = {}): Scorer {\n const trim = options.trim ?? true;\n const caseSensitive = options.caseSensitive ?? true;\n const shape = (value: string): string => {\n const trimmed = trim ? value.trim() : value;\n\n return caseSensitive ? trimmed : trimmed.toLowerCase();\n };\n\n return ({ output, expected }): number | ScoreResult => {\n if (expected === undefined) {\n return { score: 0, reason: 'no expected value to compare against' };\n }\n\n return shape(output) === shape(expected) ? 1 : 0;\n };\n}\n\n/** Options for {@link contains}. */\nexport interface ContainsOptions {\n /** `all` (default) requires every needle; `any` requires at least one. */\n mode?: 'all' | 'any';\n /** Match with case sensitivity (default: case-insensitive). */\n caseSensitive?: boolean;\n}\n\n/**\n * Binary substring scorer. Pass a single needle or a list; with a list, `mode`\n * chooses whether every needle must be present (`all`, the default) or just one\n * (`any`). Scores 1 when the condition holds, else 0.\n */\nexport function contains(\n needles: string | readonly string[],\n options: ContainsOptions = {},\n): Scorer {\n const list = typeof needles === 'string' ? [needles] : [...needles];\n\n if (list.length === 0) {\n throw new Error('@velajs/testing: contains() needs at least one needle');\n }\n\n const mode = options.mode ?? 'all';\n const targets = list.map((needle) => fold(needle, options.caseSensitive));\n\n return ({ output }): number => {\n const haystack = fold(output, options.caseSensitive);\n const found = targets.map((target) => haystack.includes(target));\n const passed = mode === 'any' ? found.some(Boolean) : found.every(Boolean);\n\n return passed ? 1 : 0;\n };\n}\n\n/** Options for {@link keyword}. */\nexport interface KeywordOptions {\n /** Match with case sensitivity (default: case-insensitive). */\n caseSensitive?: boolean;\n}\n\n/**\n * Fractional coverage scorer: the share of `keywords` present in the output. A\n * rubric with three keywords, two of which appear, scores `2/3`. An empty\n * keyword list would silently score everything 1 — the opposite of a useful\n * eval — so it is rejected at construction time.\n */\nexport function keyword(keywords: readonly string[], options: KeywordOptions = {}): Scorer {\n if (keywords.length === 0) {\n throw new Error('@velajs/testing: keyword() needs at least one keyword');\n }\n\n const targets = keywords.map((word) => fold(word, options.caseSensitive));\n\n return ({ output }): ScoreResult => {\n const haystack = fold(output, options.caseSensitive);\n const hits = targets.filter((target) => haystack.includes(target)).length;\n\n return {\n score: hits / targets.length,\n reason: `${hits}/${targets.length} keywords present`,\n };\n };\n}\n\n/**\n * Score 1 when `pattern` matches the output, else 0. The global/sticky flags are\n * stripped from a copy of the pattern so a scorer reused across many samples\n * never carries `lastIndex` state between calls.\n */\nexport function regex(pattern: RegExp): Scorer {\n const stateless = new RegExp(pattern.source, pattern.flags.replace(/[gy]/gu, ''));\n\n return ({ output }): number => (stateless.test(output) ? 1 : 0);\n}\n\n/** Options for {@link llmScorer}. */\nexport interface LlmScorerOptions {\n /** The rubric the judge grades against, e.g. \"answers the question accurately\". */\n criteria: string;\n /**\n * The injected judge. Wire it to your own model call (`generateText`, a raw\n * fetch, whatever). Keeping it a plain callback is what lets this package\n * grade LLM output without depending on any AI SDK, and makes it trivial to\n * fake in tests.\n */\n judge: (prompt: string) => Promise<string>;\n}\n\n/** The regex that reads the leading number the judge is instructed to emit. */\nconst LEADING_NUMBER = /^\\s*([+-]?\\d+(?:\\.\\d+)?)/u;\n\n/** Render the grading prompt for one sample under a rubric. */\nexport function renderJudgePrompt(criteria: string, sample: ScorerInput): string {\n const lines = [\n 'You are grading an assistant output.',\n `Criterion: ${criteria}`,\n 'Begin your reply with a single decimal from 0 (does not meet) to 1 (fully meets), then optionally a short justification.',\n ];\n\n if (sample.input !== undefined) {\n lines.push('', `Prompt: ${sample.input}`);\n }\n if (sample.expected !== undefined) {\n lines.push('', `Expected answer: ${sample.expected}`);\n }\n\n lines.push('', `Output to grade: ${sample.output}`);\n\n return lines.join('\\n');\n}\n\n/**\n * Read a judge reply into a verdict. The score is the leading number, clamped,\n * and the full reply is kept as the reason. Requiring the number at the very\n * start is deliberate: a reply with no leading number fails closed to 0, and a\n * digit buried later in the justification can never be misread as the grade.\n */\nexport function parseJudgeReply(reply: string): ScoreResult {\n const match = LEADING_NUMBER.exec(reply);\n const reason = reply.trim();\n\n if (match === null) {\n return { score: 0, reason };\n }\n\n return { score: clampUnit(Number(match[1])), reason };\n}\n\n/**\n * LLM-as-judge scorer. It builds a rubric prompt, hands it to the injected\n * `judge`, and parses the reply into a `[0, 1]` grade. Unparseable replies fail\n * soft (score 0 with the raw reply as the reason) rather than throwing.\n */\nexport function llmScorer(options: LlmScorerOptions): Scorer {\n return async (sample): Promise<ScoreResult> =>\n parseJudgeReply(await options.judge(renderJudgePrompt(options.criteria, sample)));\n}\n","import { arithmeticMean, normalizeVerdict } from './scorers.js';\nimport type {\n Awaitable,\n CaseReport,\n EvalCase,\n EvalReport,\n Scorer,\n ScorerInput,\n ScoreResult,\n} from './types.js';\n\n/** Run every scorer over one sample and collect the verdicts keyed by scorer name. */\nasync function gradeSample(\n sample: ScorerInput,\n scorers: Record<string, Scorer>,\n): Promise<Record<string, ScoreResult>> {\n const graded = await Promise.all(\n Object.entries(scorers).map(async ([name, scorer]): Promise<[string, ScoreResult]> => {\n return [name, normalizeVerdict(await scorer(sample))];\n }),\n );\n\n const scores: Record<string, ScoreResult> = {};\n\n for (const [name, verdict] of graded) {\n scores[name] = verdict;\n }\n\n return scores;\n}\n\n/**\n * Run a whole dataset through `run` and grade each output with the named\n * `scorers`. Every case is graded by every scorer; cases execute concurrently,\n * so keep `run` stateless per input (or key any shared state on `input`).\n *\n * The returned report carries one {@link CaseReport} per case plus an aggregate:\n * the mean of each scorer across all cases (`perScorer`) and the mean of the\n * per-case averages (`overall`).\n *\n * @param dataset The cases to grade.\n * @param run Produces the output string for a given input.\n * @param scorers A name → scorer map; the names key the per-scorer aggregate.\n */\nexport async function evaluate(\n dataset: readonly EvalCase[],\n run: (input: string) => Awaitable<string>,\n scorers: Record<string, Scorer>,\n): Promise<EvalReport> {\n const cases = await Promise.all(\n dataset.map(async (testCase): Promise<CaseReport> => {\n const output = await run(testCase.input);\n const sample: ScorerInput = { input: testCase.input, output };\n\n if (testCase.expected !== undefined) {\n sample.expected = testCase.expected;\n }\n if (testCase.metadata !== undefined) {\n sample.metadata = testCase.metadata;\n }\n\n const scores = await gradeSample(sample, scorers);\n const average = arithmeticMean(Object.values(scores).map((verdict) => verdict.score));\n\n return { input: testCase.input, output, scores, average };\n }),\n );\n\n const perScorer: Record<string, number> = {};\n\n for (const name of Object.keys(scorers)) {\n perScorer[name] = arithmeticMean(cases.map((report) => report.scores[name]?.score ?? 0));\n }\n\n const overall = arithmeticMean(cases.map((report) => report.average));\n\n return { cases, aggregate: { perScorer, overall } };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/testing",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Testing utilities for Vela framework",
5
5
  "keywords": [
6
6
  "edge",
@@ -41,6 +41,10 @@
41
41
  "./websocket-node": {
42
42
  "types": "./dist/websocket-node/index.d.ts",
43
43
  "import": "./dist/websocket-node/index.js"
44
+ },
45
+ "./eval": {
46
+ "types": "./dist/eval/index.d.ts",
47
+ "import": "./dist/eval/index.js"
44
48
  }
45
49
  },
46
50
  "devDependencies": {
@@ -80,6 +84,7 @@
80
84
  "build": "tsdown",
81
85
  "test": "vitest run",
82
86
  "typecheck": "tsc --noEmit",
87
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
83
88
  "lint": "oxlint .",
84
89
  "format": "oxfmt .",
85
90
  "format:check": "oxfmt --check .",
@@ -88,6 +93,6 @@
88
93
  "changeset": "changeset",
89
94
  "version-packages": "changeset version",
90
95
  "release": "pnpm build && changeset publish",
91
- "verify": "pnpm lint && pnpm format:check && pnpm build && pnpm typecheck && pnpm test && pnpm publint && pnpm attw"
96
+ "verify": "pnpm lint && pnpm format:check && pnpm build && pnpm typecheck && pnpm typecheck:tests && pnpm test && pnpm publint && pnpm attw"
92
97
  }
93
98
  }