@velajs/testing 0.5.0 → 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 +13 -2
- package/README.md +31 -0
- package/dist/eval/index.d.ts +154 -0
- package/dist/eval/index.js +191 -0
- package/dist/eval/index.js.map +1 -0
- package/dist/index.d.ts +484 -15
- package/dist/index.js +866 -14
- package/dist/index.js.map +1 -0
- package/dist/test-ws-connection-BHlEKwQz.js +166 -0
- package/dist/test-ws-connection-BHlEKwQz.js.map +1 -0
- package/dist/websocket-node/index.d.ts +1 -1
- package/dist/websocket-node/index.js +67 -102
- package/dist/websocket-node/index.js.map +1 -0
- package/package.json +64 -45
- package/dist/db/test-database.d.ts +0 -27
- package/dist/db/test-database.js +0 -19
- package/dist/http/path-utils.d.ts +0 -11
- package/dist/http/path-utils.js +0 -37
- package/dist/http/test-http-client.d.ts +0 -37
- package/dist/http/test-http-client.js +0 -61
- package/dist/http/test-http-request.d.ts +0 -46
- package/dist/http/test-http-request.js +0 -87
- package/dist/http/test-response.d.ts +0 -76
- package/dist/http/test-response.js +0 -166
- package/dist/sse/test-sse-connection.d.ts +0 -46
- package/dist/sse/test-sse-connection.js +0 -196
- package/dist/sse/test-sse-request.d.ts +0 -30
- package/dist/sse/test-sse-request.js +0 -62
- package/dist/test.d.ts +0 -5
- package/dist/test.js +0 -6
- package/dist/testing-module.builder.d.ts +0 -25
- package/dist/testing-module.builder.js +0 -160
- package/dist/testing-module.d.ts +0 -87
- package/dist/testing-module.js +0 -141
- package/dist/ws/test-ws-connection.d.ts +0 -41
- package/dist/ws/test-ws-connection.js +0 -105
- package/dist/ws/test-ws-request.d.ts +0 -43
- package/dist/ws/test-ws-request.js +0 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.5.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 647fd5f: Modernize the package build, validation, and release toolchain.
|
|
14
|
+
|
|
3
15
|
## 0.3.0 (2026-07-04)
|
|
4
16
|
|
|
5
17
|
- `ComponentManager.init` call removed; provider overrides use the supported `Container.replaceProvider`; registers `DiscoveryService` mirroring bootstrap. Requires `@velajs/vela >=1.11.0`.
|
|
6
18
|
|
|
7
|
-
|
|
8
19
|
## 0.2.1 (2026-05-14)
|
|
9
20
|
|
|
10
21
|
### Fixes
|
|
@@ -25,7 +36,7 @@ Bugs fixed, rebuilt on `@velajs/vela/internal`, no more `reflect-metadata` dep.
|
|
|
25
36
|
- **Provider override shape was wrong.** `overrideProvider(token).useValue(v)` constructed `{ token, useValue }` but vela's container reads `{ provide, useValue }`. The misleading `as ProviderOptions` cast hid this; nothing actually overrode. Fixed.
|
|
26
37
|
- **`useClass` now uses the container's native `useClass` path** instead of the bogus `useFactory: () => new cls()` workaround. The previous comment claimed `Container.registerOptions` doesn't read `useClass` — it does (`vela/src/container/container.ts`).
|
|
27
38
|
- **`loader.resolveAllInstances()` is now awaited.** Was called synchronously even though vela's signature is async; lifecycle hooks fired before instance resolution finished.
|
|
28
|
-
- **
|
|
39
|
+
- **APP\_\* token wiring uses vela's canonical `bindAppProviders` helper.** Replaces the five duplicated `useGlobalGuards/Pipes/Interceptors/Filters/Middleware(container.resolve(APP_*))` blocks that handled only the single-value case (vela switched to array form months ago). Now stays in lockstep with `VelaFactory.create` automatically.
|
|
29
40
|
|
|
30
41
|
### Breaking changes
|
|
31
42
|
|
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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"}
|