@lunora/testing 1.0.0-alpha.111 → 1.0.0-alpha.113

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/dist/index.d.mts CHANGED
@@ -140,7 +140,26 @@ declare const agentHarness: (agent: AgentDefinition, options: AgentHarnessOption
140
140
  declare const finalTurn: (text: string, extra?: Partial<AgentGenerateResult>) => AgentGenerateResult;
141
141
  /** A single-tool-call turn: the loop runs the named tool with `input`. */
142
142
  declare const toolCallTurn: (id: string, name: string, input: unknown, text?: string) => AgentGenerateResult;
143
- /** The primitive an eval attaches to a span: a numeric score or a string label. */
143
+ /**
144
+ * Shared, bundler-inlined builder for the OpenTelemetry `gen_ai.evaluation.*`
145
+ * attributes an AI **evaluation** verdict (a scorer's `{name, score, label?}`)
146
+ * contributes to a **generation span**.
147
+ *
148
+ * This is the emit-time counterpart of the cloud OTLP decoder, which reads
149
+ * `gen_ai.evaluation.<name>.score` (number) and optional
150
+ * `gen_ai.evaluation.<name>.label` (string) attribute pairs back off a generation
151
+ * span (`EVALUATION_PREFIX = "gen_ai.evaluation."`). The framework emits exactly
152
+ * that pair here so a score rides the same trace as the generation it grades.
153
+ *
154
+ * It lives in `shared/` because more than one layer needs the identical wire
155
+ * format with no runtime dependency edge between them: `@lunora/do` builds it into
156
+ * a live `ctx.trace` span's post-hoc attributes (`SpanHandle.recordEvaluation`),
157
+ * and `@lunora/server` mirrors the handle shape structurally. `@lunora/testing`
158
+ * ships a parallel test-time helper (`recordEvaluation` / `evaluationAttributes`)
159
+ * that targets the same `gen_ai.evaluation.*` contract for span-less eval
160
+ * events/metrics. Keep this file genuinely zero-dependency so inlining stays sound.
161
+ */
162
+ /** The primitive an eval contributes to a span: a numeric score or a string label. */
144
163
  type EvaluationAttributeValue = number | string;
145
164
  /**
146
165
  * Structural slice of the post-hoc span handle `ctx.trace` hands its body (see the
@@ -194,6 +213,11 @@ interface RecordEvaluationInput {
194
213
  * Build the `gen_ai.evaluation.NAME.*` attribute bag for one eval verdict — the
195
214
  * `.score` (number) always, the `.label` (string) when a label is given. Exported
196
215
  * so a caller can emit the score as a standalone event/metric without a span.
216
+ *
217
+ * Delegates to the shared `shared/evaluation-attributes.ts` builder so
218
+ * `@lunora/testing`'s scorers and the runtime's own `recordEvaluation` emit the
219
+ * identical wire format — only the thrown error type differs, wrapped here as a
220
+ * `LunoraError` to match this package's public error contract.
197
221
  */
198
222
  declare const evaluationAttributes: (input: Pick<RecordEvaluationInput, "label" | "name" | "score">) => Record<string, EvaluationAttributeValue>;
199
223
  /**
@@ -210,11 +234,14 @@ interface FakeScheduledJob extends ScheduledJob {
210
234
  args: Record<string, unknown>;
211
235
  }
212
236
  /**
213
- * A single scheduled-job failure captured during an `advance()` / `runPending()`
214
- * sweep. Production's scheduler isolates per-job failures (one bad job does not
215
- * abort the rest), so the fake scheduler does the same — but, being a test
216
- * harness, it never swallows the error: every failure is recorded here so tests
217
- * can still assert on it.
237
+ * A single scheduled-job failure that exhausted its retry budget, captured
238
+ * during an `advance()` / `runPending()` sweep. Mirrors `SchedulerDO`'s
239
+ * dead-letter park (`recordRetry()`, `packages/scheduler/src/scheduler-do.ts:875-909`):
240
+ * a job that fails while it still has retries left is silently re-enqueued
241
+ * with backoff and is NOT recorded here. Only once a job's `attempts` exceeds
242
+ * `@lunora/scheduler`'s `MAX_RETRY_ATTEMPTS` does it land here — being a test
243
+ * harness, the fake scheduler never swallows a terminal failure, so tests can
244
+ * still assert on it.
218
245
  */
219
246
  interface ScheduledJobFailure {
220
247
  /** The args the job was dispatched with. */
@@ -235,40 +262,55 @@ interface FakeSchedulerControls {
235
262
  * Advance the virtual clock by `ms` milliseconds, executing all jobs whose
236
263
  * `scheduledFor` timestamp is now at or before the new virtual "now". Jobs
237
264
  * are dispatched in `scheduledFor` order (oldest first). Newly queued jobs
238
- * (scheduled by an executed job during the advance) are NOT re-evaluated in
239
- * the same advance call — callers should advance again if needed.
265
+ * (scheduled by an executed job, or re-enqueued as a retry, during the
266
+ * advance) are NOT re-evaluated in the same advance call — callers should
267
+ * advance again if needed.
240
268
  *
241
269
  * Per-job failures are isolated (matching production): a job that throws does
242
- * NOT prevent the remaining due jobs from running. After every due job has
243
- * run, the failures are surfaced they are recorded on
244
- * {@link FakeSchedulerControls.failures} and, by default, re-thrown so a test
245
- * still sees the error. A single failure is re-thrown verbatim; multiple
246
- * failures are aggregated into an `AggregateError`. Pass
270
+ * NOT prevent the remaining due jobs from running. A failure with retries
271
+ * left is silently re-enqueued with exponential backoff on the virtual clock
272
+ * mirroring `SchedulerDO`'s default retry policy (`MAX_RETRY_ATTEMPTS`
273
+ * retries, `RETRY_BASE_DELAY_MS` base delay, doubling; both imported from
274
+ * `@lunora/scheduler`) rather than surfaced. Advancing far enough to
275
+ * observe a terminal failure therefore costs the WHOLE backoff schedule
276
+ * (30s + 60s + 120s + 240s + 480s = 930s of virtual clock at today's
277
+ * defaults), not a single tick.
278
+ * Only once a job's retry budget is exhausted is the failure surfaced: it is
279
+ * recorded on {@link FakeSchedulerControls.failures} and, by default,
280
+ * re-thrown so a test still sees the error. A single such failure is
281
+ * re-thrown verbatim; multiple are aggregated into an `AggregateError`. Pass
247
282
  * `{ throwOnError: false }` to suppress the re-throw and inspect
248
283
  * {@link FakeSchedulerControls.failures} (and the returned count) instead.
249
284
  *
250
- * Returns the number of jobs that were executed (including failed ones).
285
+ * Returns the number of jobs dispatched this sweep, including ones that
286
+ * failed and were silently retried, and ones that failed terminally.
251
287
  */
252
288
  advance: (ms: number, options?: SweepOptions) => Promise<number>;
253
289
  /**
254
- * All scheduled-job failures captured so far, in execution order, across
255
- * every `advance()` / `runPending()` call on this harness. Always available,
256
- * even when `throwOnError: false` suppressed the re-throw. The list is a
257
- * snapshot mutating it does not affect the scheduler.
290
+ * All scheduled-job failures that exhausted their retry budget, in
291
+ * execution order, across every `advance()` / `runPending()` call on this
292
+ * harness. A failure with retries remaining is NOT recorded here — see
293
+ * {@link ScheduledJobFailure}. Always available, even when
294
+ * `throwOnError: false` suppressed the re-throw. The list is a snapshot —
295
+ * mutating it does not affect the scheduler.
258
296
  */
259
297
  failures: () => ScheduledJobFailure[];
260
298
  /**
261
299
  * List all pending jobs (those not yet executed or cancelled) in the order
262
- * they were enqueued.
300
+ * they were enqueued. A job currently waiting out its retry backoff is
301
+ * still pending (visible here with its `attempts` count incremented and
302
+ * `scheduledFor` pushed out) until its budget is exhausted.
263
303
  */
264
304
  list: () => FakeScheduledJob[];
265
305
  /**
266
306
  * Execute all currently pending jobs regardless of their `scheduledFor`
267
307
  * time. Equivalent to advancing to `Infinity`. Returns the number of jobs
268
- * executed (including failed ones).
308
+ * dispatched this sweep (including failed ones, retried or terminal).
269
309
  *
270
- * Failure isolation and surfacing match {@link FakeSchedulerControls.advance}:
271
- * one failing job does not abort the rest, and failures are recorded on
310
+ * Failure isolation, retry, and surfacing match
311
+ * {@link FakeSchedulerControls.advance}: one failing job does not abort the
312
+ * rest, a failure under the retry budget is silently re-enqueued rather
313
+ * than surfaced, and only a terminal failure is recorded on
272
314
  * {@link FakeSchedulerControls.failures} and re-thrown unless
273
315
  * `{ throwOnError: false }` is passed.
274
316
  */
@@ -578,6 +620,20 @@ interface Scorer {
578
620
  name: string;
579
621
  score: (sample: ScorerSample) => Promise<ScoreResult | number> | ScoreResult | number;
580
622
  }
623
+ /**
624
+ * What a `produce` runner may return: the output text alone, or the text plus
625
+ * metadata describing what the run actually did.
626
+ *
627
+ * The metadata form exists for scorers that judge more than the final string —
628
+ * a retrieval scorer needs the ranked ids the run retrieved, which only the run
629
+ * can know. It is merged OVER the case's own metadata before scoring.
630
+ */
631
+ interface ProducedOutput {
632
+ /** Merged over the case's `metadata`, then handed to every scorer. */
633
+ metadata?: Record<string, unknown>;
634
+ /** The output text under test. */
635
+ output: string;
636
+ }
581
637
  /** One dataset case: an input and its optional gold answer/metadata. */
582
638
  interface EvalCase {
583
639
  expected?: string;
@@ -628,5 +684,70 @@ declare const scoreSample: (sample: ScorerSample, scorers: ReadonlyArray<Scorer>
628
684
  * give each its own thread/key inside `produce` if the producer is stateful.
629
685
  * Returns per-case results plus the mean of their averages.
630
686
  */
631
- declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<string> | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
632
- export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type EvaluationAttributeValue, type EvaluationMetrics, type EvaluationSpanHandle, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type RecordEvaluationInput, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, evaluationAttributes, exactMatchScorer, finalTurn, keywordScorer, llmScorer, lunoraTest, recordEvaluation, regexScorer, scoreSample, toolCallTurn };
687
+ declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<ProducedOutput | string> | ProducedOutput | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
688
+ /** Where a retrieval scorer reads its two id lists from. */
689
+ interface RetrievalScorerOptions {
690
+ /** Metadata key holding the gold relevant ids. Default `"relevant"`. */
691
+ relevantKey?: string;
692
+ /** Metadata key holding the run's ranked retrieved ids. Default `"retrieved"`. */
693
+ retrievedKey?: string;
694
+ }
695
+ /**
696
+ * Recall@k scorer — what fraction of the gold passages made it into the top
697
+ * `k`.
698
+ *
699
+ * This is the ceiling on everything downstream: a passage retrieval never
700
+ * returned is one no reranker can promote and no prompt can cite. Omit `k` to
701
+ * score the whole retrieved list.
702
+ */
703
+ declare const recallAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
704
+ /**
705
+ * Precision@k scorer — what fraction of the top `k` retrieved passages are
706
+ * gold.
707
+ *
708
+ * This is the counterweight to recall: padding `topK` raises recall for free
709
+ * while burying the answer in noise the model has to read past, and pay for.
710
+ * Omit `k` to score the whole retrieved list.
711
+ */
712
+ declare const precisionAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
713
+ /**
714
+ * Mean Reciprocal Rank scorer — `1 / rank` of the first gold passage: 1 if it
715
+ * is first, 0.5 if second, 0 if absent.
716
+ *
717
+ * This is the metric that notices ordering, which recall cannot. A gold passage
718
+ * at rank 20 counts the same as rank 1 for recall@20, but only one of those
719
+ * survives a context-window trim or a model that skims the top of its prompt.
720
+ */
721
+ declare const mrrScorer: (options?: RetrievalScorerOptions) => Scorer;
722
+ /**
723
+ * Normalized Discounted Cumulative Gain scorer — relevance discounted
724
+ * logarithmically by rank, divided by the best achievable arrangement.
725
+ *
726
+ * This is the one to gate on when comparing retrieval strategies. Unlike recall
727
+ * it is sensitive to order, and unlike MRR it credits every gold passage rather
728
+ * than only the first — so it is the metric that can actually say whether a
729
+ * reranker or a hybrid leg helped.
730
+ *
731
+ * Relevance is binary: an id is gold or it is not, which is what a gold-id set
732
+ * expresses. Graded relevance would need per-id weights.
733
+ */
734
+ declare const ndcgAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
735
+ /**
736
+ * Groundedness scorer — does the answer assert only what the retrieved context
737
+ * supports?
738
+ *
739
+ * This is the generation-side counterpart to the metrics above: perfect
740
+ * retrieval still fails if the model answers from its own weights. It scores
741
+ * the output against the context via an injected `judge`, the same shape
742
+ * `llmScorer` takes, so this stays model-agnostic and mockable.
743
+ *
744
+ * Reads the context from `metadata.context` by default — the `context` string
745
+ * `retrieve()` already returns. Fails closed: no context means nothing could
746
+ * have grounded the answer.
747
+ */
748
+ declare const groundednessScorer: (options: {
749
+ contextKey?: string;
750
+ judge: (prompt: string) => Promise<string>;
751
+ name?: string;
752
+ }) => Scorer;
753
+ export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type EvaluationAttributeValue, type EvaluationMetrics, type EvaluationSpanHandle, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type ProducedOutput, type RecordEvaluationInput, type RetrievalScorerOptions, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, evaluationAttributes, exactMatchScorer, finalTurn, groundednessScorer, keywordScorer, llmScorer, lunoraTest, mrrScorer, ndcgAtK, precisionAtK, recallAtK, recordEvaluation, regexScorer, scoreSample, toolCallTurn };
package/dist/index.d.ts CHANGED
@@ -140,7 +140,26 @@ declare const agentHarness: (agent: AgentDefinition, options: AgentHarnessOption
140
140
  declare const finalTurn: (text: string, extra?: Partial<AgentGenerateResult>) => AgentGenerateResult;
141
141
  /** A single-tool-call turn: the loop runs the named tool with `input`. */
142
142
  declare const toolCallTurn: (id: string, name: string, input: unknown, text?: string) => AgentGenerateResult;
143
- /** The primitive an eval attaches to a span: a numeric score or a string label. */
143
+ /**
144
+ * Shared, bundler-inlined builder for the OpenTelemetry `gen_ai.evaluation.*`
145
+ * attributes an AI **evaluation** verdict (a scorer's `{name, score, label?}`)
146
+ * contributes to a **generation span**.
147
+ *
148
+ * This is the emit-time counterpart of the cloud OTLP decoder, which reads
149
+ * `gen_ai.evaluation.<name>.score` (number) and optional
150
+ * `gen_ai.evaluation.<name>.label` (string) attribute pairs back off a generation
151
+ * span (`EVALUATION_PREFIX = "gen_ai.evaluation."`). The framework emits exactly
152
+ * that pair here so a score rides the same trace as the generation it grades.
153
+ *
154
+ * It lives in `shared/` because more than one layer needs the identical wire
155
+ * format with no runtime dependency edge between them: `@lunora/do` builds it into
156
+ * a live `ctx.trace` span's post-hoc attributes (`SpanHandle.recordEvaluation`),
157
+ * and `@lunora/server` mirrors the handle shape structurally. `@lunora/testing`
158
+ * ships a parallel test-time helper (`recordEvaluation` / `evaluationAttributes`)
159
+ * that targets the same `gen_ai.evaluation.*` contract for span-less eval
160
+ * events/metrics. Keep this file genuinely zero-dependency so inlining stays sound.
161
+ */
162
+ /** The primitive an eval contributes to a span: a numeric score or a string label. */
144
163
  type EvaluationAttributeValue = number | string;
145
164
  /**
146
165
  * Structural slice of the post-hoc span handle `ctx.trace` hands its body (see the
@@ -194,6 +213,11 @@ interface RecordEvaluationInput {
194
213
  * Build the `gen_ai.evaluation.NAME.*` attribute bag for one eval verdict — the
195
214
  * `.score` (number) always, the `.label` (string) when a label is given. Exported
196
215
  * so a caller can emit the score as a standalone event/metric without a span.
216
+ *
217
+ * Delegates to the shared `shared/evaluation-attributes.ts` builder so
218
+ * `@lunora/testing`'s scorers and the runtime's own `recordEvaluation` emit the
219
+ * identical wire format — only the thrown error type differs, wrapped here as a
220
+ * `LunoraError` to match this package's public error contract.
197
221
  */
198
222
  declare const evaluationAttributes: (input: Pick<RecordEvaluationInput, "label" | "name" | "score">) => Record<string, EvaluationAttributeValue>;
199
223
  /**
@@ -210,11 +234,14 @@ interface FakeScheduledJob extends ScheduledJob {
210
234
  args: Record<string, unknown>;
211
235
  }
212
236
  /**
213
- * A single scheduled-job failure captured during an `advance()` / `runPending()`
214
- * sweep. Production's scheduler isolates per-job failures (one bad job does not
215
- * abort the rest), so the fake scheduler does the same — but, being a test
216
- * harness, it never swallows the error: every failure is recorded here so tests
217
- * can still assert on it.
237
+ * A single scheduled-job failure that exhausted its retry budget, captured
238
+ * during an `advance()` / `runPending()` sweep. Mirrors `SchedulerDO`'s
239
+ * dead-letter park (`recordRetry()`, `packages/scheduler/src/scheduler-do.ts:875-909`):
240
+ * a job that fails while it still has retries left is silently re-enqueued
241
+ * with backoff and is NOT recorded here. Only once a job's `attempts` exceeds
242
+ * `@lunora/scheduler`'s `MAX_RETRY_ATTEMPTS` does it land here — being a test
243
+ * harness, the fake scheduler never swallows a terminal failure, so tests can
244
+ * still assert on it.
218
245
  */
219
246
  interface ScheduledJobFailure {
220
247
  /** The args the job was dispatched with. */
@@ -235,40 +262,55 @@ interface FakeSchedulerControls {
235
262
  * Advance the virtual clock by `ms` milliseconds, executing all jobs whose
236
263
  * `scheduledFor` timestamp is now at or before the new virtual "now". Jobs
237
264
  * are dispatched in `scheduledFor` order (oldest first). Newly queued jobs
238
- * (scheduled by an executed job during the advance) are NOT re-evaluated in
239
- * the same advance call — callers should advance again if needed.
265
+ * (scheduled by an executed job, or re-enqueued as a retry, during the
266
+ * advance) are NOT re-evaluated in the same advance call — callers should
267
+ * advance again if needed.
240
268
  *
241
269
  * Per-job failures are isolated (matching production): a job that throws does
242
- * NOT prevent the remaining due jobs from running. After every due job has
243
- * run, the failures are surfaced they are recorded on
244
- * {@link FakeSchedulerControls.failures} and, by default, re-thrown so a test
245
- * still sees the error. A single failure is re-thrown verbatim; multiple
246
- * failures are aggregated into an `AggregateError`. Pass
270
+ * NOT prevent the remaining due jobs from running. A failure with retries
271
+ * left is silently re-enqueued with exponential backoff on the virtual clock
272
+ * mirroring `SchedulerDO`'s default retry policy (`MAX_RETRY_ATTEMPTS`
273
+ * retries, `RETRY_BASE_DELAY_MS` base delay, doubling; both imported from
274
+ * `@lunora/scheduler`) rather than surfaced. Advancing far enough to
275
+ * observe a terminal failure therefore costs the WHOLE backoff schedule
276
+ * (30s + 60s + 120s + 240s + 480s = 930s of virtual clock at today's
277
+ * defaults), not a single tick.
278
+ * Only once a job's retry budget is exhausted is the failure surfaced: it is
279
+ * recorded on {@link FakeSchedulerControls.failures} and, by default,
280
+ * re-thrown so a test still sees the error. A single such failure is
281
+ * re-thrown verbatim; multiple are aggregated into an `AggregateError`. Pass
247
282
  * `{ throwOnError: false }` to suppress the re-throw and inspect
248
283
  * {@link FakeSchedulerControls.failures} (and the returned count) instead.
249
284
  *
250
- * Returns the number of jobs that were executed (including failed ones).
285
+ * Returns the number of jobs dispatched this sweep, including ones that
286
+ * failed and were silently retried, and ones that failed terminally.
251
287
  */
252
288
  advance: (ms: number, options?: SweepOptions) => Promise<number>;
253
289
  /**
254
- * All scheduled-job failures captured so far, in execution order, across
255
- * every `advance()` / `runPending()` call on this harness. Always available,
256
- * even when `throwOnError: false` suppressed the re-throw. The list is a
257
- * snapshot mutating it does not affect the scheduler.
290
+ * All scheduled-job failures that exhausted their retry budget, in
291
+ * execution order, across every `advance()` / `runPending()` call on this
292
+ * harness. A failure with retries remaining is NOT recorded here — see
293
+ * {@link ScheduledJobFailure}. Always available, even when
294
+ * `throwOnError: false` suppressed the re-throw. The list is a snapshot —
295
+ * mutating it does not affect the scheduler.
258
296
  */
259
297
  failures: () => ScheduledJobFailure[];
260
298
  /**
261
299
  * List all pending jobs (those not yet executed or cancelled) in the order
262
- * they were enqueued.
300
+ * they were enqueued. A job currently waiting out its retry backoff is
301
+ * still pending (visible here with its `attempts` count incremented and
302
+ * `scheduledFor` pushed out) until its budget is exhausted.
263
303
  */
264
304
  list: () => FakeScheduledJob[];
265
305
  /**
266
306
  * Execute all currently pending jobs regardless of their `scheduledFor`
267
307
  * time. Equivalent to advancing to `Infinity`. Returns the number of jobs
268
- * executed (including failed ones).
308
+ * dispatched this sweep (including failed ones, retried or terminal).
269
309
  *
270
- * Failure isolation and surfacing match {@link FakeSchedulerControls.advance}:
271
- * one failing job does not abort the rest, and failures are recorded on
310
+ * Failure isolation, retry, and surfacing match
311
+ * {@link FakeSchedulerControls.advance}: one failing job does not abort the
312
+ * rest, a failure under the retry budget is silently re-enqueued rather
313
+ * than surfaced, and only a terminal failure is recorded on
272
314
  * {@link FakeSchedulerControls.failures} and re-thrown unless
273
315
  * `{ throwOnError: false }` is passed.
274
316
  */
@@ -578,6 +620,20 @@ interface Scorer {
578
620
  name: string;
579
621
  score: (sample: ScorerSample) => Promise<ScoreResult | number> | ScoreResult | number;
580
622
  }
623
+ /**
624
+ * What a `produce` runner may return: the output text alone, or the text plus
625
+ * metadata describing what the run actually did.
626
+ *
627
+ * The metadata form exists for scorers that judge more than the final string —
628
+ * a retrieval scorer needs the ranked ids the run retrieved, which only the run
629
+ * can know. It is merged OVER the case's own metadata before scoring.
630
+ */
631
+ interface ProducedOutput {
632
+ /** Merged over the case's `metadata`, then handed to every scorer. */
633
+ metadata?: Record<string, unknown>;
634
+ /** The output text under test. */
635
+ output: string;
636
+ }
581
637
  /** One dataset case: an input and its optional gold answer/metadata. */
582
638
  interface EvalCase {
583
639
  expected?: string;
@@ -628,5 +684,70 @@ declare const scoreSample: (sample: ScorerSample, scorers: ReadonlyArray<Scorer>
628
684
  * give each its own thread/key inside `produce` if the producer is stateful.
629
685
  * Returns per-case results plus the mean of their averages.
630
686
  */
631
- declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<string> | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
632
- export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type EvaluationAttributeValue, type EvaluationMetrics, type EvaluationSpanHandle, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type RecordEvaluationInput, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, evaluationAttributes, exactMatchScorer, finalTurn, keywordScorer, llmScorer, lunoraTest, recordEvaluation, regexScorer, scoreSample, toolCallTurn };
687
+ declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<ProducedOutput | string> | ProducedOutput | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
688
+ /** Where a retrieval scorer reads its two id lists from. */
689
+ interface RetrievalScorerOptions {
690
+ /** Metadata key holding the gold relevant ids. Default `"relevant"`. */
691
+ relevantKey?: string;
692
+ /** Metadata key holding the run's ranked retrieved ids. Default `"retrieved"`. */
693
+ retrievedKey?: string;
694
+ }
695
+ /**
696
+ * Recall@k scorer — what fraction of the gold passages made it into the top
697
+ * `k`.
698
+ *
699
+ * This is the ceiling on everything downstream: a passage retrieval never
700
+ * returned is one no reranker can promote and no prompt can cite. Omit `k` to
701
+ * score the whole retrieved list.
702
+ */
703
+ declare const recallAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
704
+ /**
705
+ * Precision@k scorer — what fraction of the top `k` retrieved passages are
706
+ * gold.
707
+ *
708
+ * This is the counterweight to recall: padding `topK` raises recall for free
709
+ * while burying the answer in noise the model has to read past, and pay for.
710
+ * Omit `k` to score the whole retrieved list.
711
+ */
712
+ declare const precisionAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
713
+ /**
714
+ * Mean Reciprocal Rank scorer — `1 / rank` of the first gold passage: 1 if it
715
+ * is first, 0.5 if second, 0 if absent.
716
+ *
717
+ * This is the metric that notices ordering, which recall cannot. A gold passage
718
+ * at rank 20 counts the same as rank 1 for recall@20, but only one of those
719
+ * survives a context-window trim or a model that skims the top of its prompt.
720
+ */
721
+ declare const mrrScorer: (options?: RetrievalScorerOptions) => Scorer;
722
+ /**
723
+ * Normalized Discounted Cumulative Gain scorer — relevance discounted
724
+ * logarithmically by rank, divided by the best achievable arrangement.
725
+ *
726
+ * This is the one to gate on when comparing retrieval strategies. Unlike recall
727
+ * it is sensitive to order, and unlike MRR it credits every gold passage rather
728
+ * than only the first — so it is the metric that can actually say whether a
729
+ * reranker or a hybrid leg helped.
730
+ *
731
+ * Relevance is binary: an id is gold or it is not, which is what a gold-id set
732
+ * expresses. Graded relevance would need per-id weights.
733
+ */
734
+ declare const ndcgAtK: (k?: number, options?: RetrievalScorerOptions) => Scorer;
735
+ /**
736
+ * Groundedness scorer — does the answer assert only what the retrieved context
737
+ * supports?
738
+ *
739
+ * This is the generation-side counterpart to the metrics above: perfect
740
+ * retrieval still fails if the model answers from its own weights. It scores
741
+ * the output against the context via an injected `judge`, the same shape
742
+ * `llmScorer` takes, so this stays model-agnostic and mockable.
743
+ *
744
+ * Reads the context from `metadata.context` by default — the `context` string
745
+ * `retrieve()` already returns. Fails closed: no context means nothing could
746
+ * have grounded the answer.
747
+ */
748
+ declare const groundednessScorer: (options: {
749
+ contextKey?: string;
750
+ judge: (prompt: string) => Promise<string>;
751
+ name?: string;
752
+ }) => Scorer;
753
+ export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type EvaluationAttributeValue, type EvaluationMetrics, type EvaluationSpanHandle, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type ProducedOutput, type RecordEvaluationInput, type RetrievalScorerOptions, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, evaluationAttributes, exactMatchScorer, finalTurn, groundednessScorer, keywordScorer, llmScorer, lunoraTest, mrrScorer, ndcgAtK, precisionAtK, recallAtK, recordEvaluation, regexScorer, scoreSample, toolCallTurn };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{agentHarness as o,finalTurn as t,toolCallTurn as a}from"./packem_shared/agentHarness-DA9onGVO.mjs";import{evaluationAttributes as c,recordEvaluation as n}from"./packem_shared/evaluationAttributes-BA1uUP-z.mjs";import{lunoraTest as u}from"./packem_shared/lunoraTest-CC-mCvor.mjs";import{containsScorer as m,evaluate as p,exactMatchScorer as s,keywordScorer as f,llmScorer as S,regexScorer as d,scoreSample as v}from"./packem_shared/containsScorer-DDK7WEuL.mjs";import{extractLink as T,listCapturedMail as g,waitForMail as k}from"@lunora/mail/testing";export{o as agentHarness,m as containsScorer,p as evaluate,c as evaluationAttributes,s as exactMatchScorer,T as extractLink,t as finalTurn,f as keywordScorer,g as listCapturedMail,S as llmScorer,u as lunoraTest,n as recordEvaluation,d as regexScorer,v as scoreSample,a as toolCallTurn,k as waitForMail};
1
+ import{agentHarness as o,finalTurn as t,toolCallTurn as a}from"./packem_shared/agentHarness-DA9onGVO.mjs";import{evaluationAttributes as l,recordEvaluation as n}from"./packem_shared/evaluationAttributes-CYLgTP1P.mjs";import{lunoraTest as s}from"./packem_shared/lunoraTest-UV1TxREP.mjs";import{groundednessScorer as p,mrrScorer as u,ndcgAtK as x,precisionAtK as S,recallAtK as f}from"./packem_shared/groundednessScorer-CznJBIjn.mjs";import{containsScorer as g,evaluate as A,exactMatchScorer as v,keywordScorer as K,llmScorer as M,regexScorer as T,scoreSample as k}from"./packem_shared/containsScorer-gv0Sdd86.mjs";import{extractLink as C,listCapturedMail as b,waitForMail as h}from"@lunora/mail/testing";export{o as agentHarness,g as containsScorer,A as evaluate,l as evaluationAttributes,v as exactMatchScorer,C as extractLink,t as finalTurn,p as groundednessScorer,K as keywordScorer,b as listCapturedMail,M as llmScorer,s as lunoraTest,u as mrrScorer,x as ndcgAtK,S as precisionAtK,f as recallAtK,n as recordEvaluation,T as regexScorer,k as scoreSample,a as toolCallTurn,h as waitForMail};
@@ -0,0 +1,2 @@
1
+ import{LunoraError as g}from"@lunora/errors";const l=/^\s*(-?\d+(?:\.\d+)?)/u,i=e=>Number.isFinite(e)?Math.min(1,Math.max(0,e)):0,h=e=>typeof e=="number"?{score:i(e)}:{score:i(e.score),...e.reason===void 0?{}:{reason:e.reason}},u=e=>e.length===0?0:e.reduce((t,o)=>t+o,0)/e.length,y=(e,t={})=>({name:`contains:${e}`,score:({output:o})=>{const a=t.caseSensitive?o:o.toLowerCase(),r=t.caseSensitive?e:e.toLowerCase();return a.includes(r)?1:0}}),v=(e,t="regex")=>({name:t,score:({output:o})=>e.test(o)?1:0}),$=()=>({name:"exact-match",score:({expected:e,output:t})=>t.trim()===e?.trim()?1:0}),L=e=>{if(e.length===0)throw new g("BAD_REQUEST","@lunora/testing: keywordScorer requires at least one keyword");return{name:"keyword-coverage",score:({output:t})=>{const o=t.toLowerCase(),a=e.filter(r=>o.includes(r.toLowerCase())).length;return{reason:`${String(a)}/${String(e.length)} keywords present`,score:a/e.length}}}},S=(e,t)=>[`Rate the ASSISTANT OUTPUT against this criterion: ${e}`,"Respond with a single number from 0 (fails) to 1 (fully meets), then a dash and a one-line reason.",...t.input===void 0?[]:["",`Input: ${t.input}`],...t.expected===void 0?[]:["",`Reference answer: ${t.expected}`],"",`Assistant output: ${t.output}`].join(`
2
+ `),w=e=>{const t=l.exec(e);return{reason:e.trim(),score:t?i(Number(t[1])):0}},b=e=>({name:e.name??"llm-judge",score:async t=>w(await e.judge(S(e.criteria,t)))}),f=async(e,t)=>{const o=await Promise.all(t.map(async n=>({name:n.name,result:h(await n.score(e))}))),a={},r=new Map;for(const{name:n,result:s}of o){const c=r.get(n)??0;r.set(n,c+1),a[c===0?n:`${n}#${String(c+1)}`]=s}return{average:u(o.map(({result:n})=>n.score)),scores:a}},A=async(e,t,o)=>{const a=await Promise.all(e.map(async r=>{const n=await t(r.input),s=typeof n=="string"?n:n.output,c=typeof n=="string"||n.metadata===void 0?r.metadata:{...r.metadata,...n.metadata},m={input:r.input,output:s,...r.expected===void 0?{}:{expected:r.expected},...c===void 0?{}:{metadata:c}},{average:d,scores:p}=await f(m,o);return{average:d,input:r.input,output:s,scores:p}}));return{average:u(a.map(r=>r.average)),items:a}};export{y as containsScorer,A as evaluate,$ as exactMatchScorer,L as keywordScorer,b as llmScorer,w as parseJudgeScore,v as regexScorer,f as scoreSample};
@@ -0,0 +1 @@
1
+ import{LunoraError as a}from"@lunora/errors";const n=/[\w.-]/u,t=e=>{let r="";for(const o of e)r+=n.test(o)?o:"_";return r},s=e=>{if(typeof e.name!="string"||e.name.length===0)throw new TypeError("recordEvaluation requires a non-empty `name`");if(typeof e.score!="number"||!Number.isFinite(e.score))throw new TypeError("recordEvaluation `score` must be a finite number");const r=t(e.name),o={[`gen_ai.evaluation.${r}.score`]:e.score};return e.label!==void 0&&(o[`gen_ai.evaluation.${r}.label`]=e.label),o},c=e=>{try{return s(e)}catch(r){throw r instanceof TypeError?new a("BAD_REQUEST",`@lunora/testing: ${r.message}`):r}},l=e=>{const r=c(e);return e.span?.setAttributes(r),e.metrics?.gauge(`gen_ai.evaluation.${t(e.name)}.score`,e.score,e.label===void 0?void 0:{label:e.label}),r};export{c as evaluationAttributes,l as recordEvaluation};
@@ -0,0 +1,3 @@
1
+ import{LunoraError as g}from"@lunora/errors";import{parseJudgeScore as h}from"./containsScorer-gv0Sdd86.mjs";const w="retrieved",v="relevant",l=(e,t)=>{const n=e.metadata?.[t];return Array.isArray(n)?n.filter(r=>typeof r=="string"&&r.length>0):[]},s=(e,t)=>{const n=l(e,t?.relevantKey??v);if(n.length!==0)return{relevant:new Set(n),retrieved:l(e,t?.retrievedKey??w)}},a=e=>({reason:`no gold ids under metadata.${e?.relevantKey??v} — cannot score retrieval`,score:0}),c=(e,t)=>{if(e!==void 0&&(!Number.isInteger(e)||e<1))throw new g("BAD_REQUEST",`@lunora/testing: ${t} \`k\` must be a positive integer`)},u=(e,t)=>t===void 0?e.retrieved:e.retrieved.slice(0,t),f=(e,t)=>{const n=new Set;for(const r of e)t.has(r)&&n.add(r);return n.size},S=(e,t)=>{const n=new Set;let r=0;for(const[o,i]of e.entries())t.has(i)&&!n.has(i)&&(n.add(i),r+=1/Math.log2(o+2));return r},m=e=>{let t=0;for(let n=0;n<e;n+=1)t+=1/Math.log2(n+2);return t},p=(e,t)=>(c(e,"recallAtK"),{name:e===void 0?"recall":`recall@${String(e)}`,score:n=>{const r=s(n,t);if(r===void 0)return a(t);const o=f(u(r,e),r.relevant);return{reason:`${String(o)}/${String(r.relevant.size)} gold ids retrieved`,score:o/r.relevant.size}}}),x=(e,t)=>(c(e,"precisionAtK"),{name:e===void 0?"precision":`precision@${String(e)}`,score:n=>{const r=s(n,t);if(r===void 0)return a(t);const o=u(r,e);if(o.length===0)return{reason:"nothing retrieved",score:0};const i=f(o,r.relevant);return{reason:`${String(i)}/${String(o.length)} retrieved ids are gold`,score:i/o.length}}}),y=e=>({name:"mrr",score:t=>{const n=s(t,e);if(n===void 0)return a(e);const r=n.retrieved.findIndex(o=>n.relevant.has(o));return r===-1?{reason:"no gold id retrieved",score:0}:{reason:`first gold id at rank ${String(r+1)}`,score:1/(r+1)}}}),$=(e,t)=>(c(e,"ndcgAtK"),{name:e===void 0?"ndcg":`ndcg@${String(e)}`,score:n=>{const r=s(n,t);if(r===void 0)return a(t);const o=u(r,e);if(o.length===0)return{reason:"nothing retrieved",score:0};const i=S(o,r.relevant),d=m(e===void 0?r.relevant.size:Math.min(r.relevant.size,e));return{reason:`dcg ${i.toFixed(3)} / ideal ${d.toFixed(3)}`,score:d===0?0:i/d}}}),K=e=>{if(typeof e.judge!="function")throw new g("BAD_REQUEST","@lunora/testing: groundednessScorer requires an injected `judge` function");const t=e.contextKey??"context";return{name:e.name??"groundedness",score:async n=>{const r=n.metadata?.[t];if(typeof r!="string"||r.trim().length===0)return{reason:`no retrieved context under metadata.${t}`,score:0};const o=await e.judge(["Rate how well the ASSISTANT ANSWER is supported by the RETRIEVED CONTEXT below.","Score 1 if every claim in the answer is supported by the context, 0 if the answer asserts","anything the context does not support. Judge support only — do NOT reward correctness","the context does not contain.","Respond with a single number from 0 to 1, then a dash and a one-line reason.","",`Retrieved context:
2
+ ${r}`,"",`Assistant answer: ${n.output}`].join(`
3
+ `));return h(o)}}};export{K as groundednessScorer,y as mrrScorer,$ as ndcgAtK,x as precisionAtK,p as recallAtK};
@@ -0,0 +1 @@
1
+ import{LunoraError as _}from"@lunora/errors";import{runShardMigrations as O,createShardCtxDb as z,RLS_UNWRAP_SYMBOL as Z}from"@lunora/shard-engine";import{evaluationAttributes as tt}from"./evaluationAttributes-CYLgTP1P.mjs";import{MAX_RETRY_ATTEMPTS as et,RETRY_BASE_DELAY_MS as nt}from"@lunora/scheduler";import{DatabaseSync as ot}from"node:sqlite";const rt=(r,c,s,u,g)=>{let p=g,y=1;const d=new Map,f=[],h=(e,l,a={})=>{const t=`fake-job-${String(y)}`;return y+=1,d.set(t,{args:a,enqueuedAt:p,functionPath:l,id:t,scheduledFor:e}),t},b=e=>typeof e=="string"?e:e.name??e.binding??"",E={cancel:e=>{const l=d.has(e);return d.delete(e),Promise.resolve({cancelled:l})},get:e=>Promise.resolve(d.get(e)??null),list:()=>Promise.resolve([...d.values()]),runAfter:(e,l,a)=>{const t=h(p+e,b(l),a);return Promise.resolve(t)},runAt:(e,l,a)=>{const t=h(e,b(l),a);return Promise.resolve(t)}},k=async e=>{d.delete(e.id);const a=u().get(e.functionPath);if(a===void 0){console.warn(`[fake-scheduler] unknown functionPath "${e.functionPath}" — job ${e.id} dropped`);return}if(a.kind==="mutation"||a.kind==="action"){const t=r(),n=a.kind==="action"?s():c();await t(a.kind,a,n,e.args)}else console.warn(`[fake-scheduler] functionPath "${e.functionPath}" is a ${a.kind} — only mutations and actions can be scheduled; job ${e.id} dropped`)},M=async e=>{const l=[...d.values()].filter(n=>n.scheduledFor<=e).toSorted((n,v)=>n.scheduledFor-v.scheduledFor),a=[];let t=0;for(const n of l)if(d.has(n.id)){t+=1;try{await k(n)}catch(v){const T=(n.attempts??0)+1;if(T>et){const N={args:n.args,error:v,functionPath:n.functionPath,id:n.id};a.push(N),f.push(N)}else{const N=nt*2**(T-1);d.set(n.id,{...n,attempts:T,scheduledFor:p+N})}}}return{executed:t,failed:a}},C=async(e,l)=>{const{executed:a,failed:t}=await M(e);if(t.length>0&&(l?.throwOnError??!0)){const[n]=t;throw t.length===1&&n!==void 0?n.error:new AggregateError(t.map(v=>v.error),`${String(t.length)} scheduled jobs failed: ${t.map(v=>v.functionPath).join(", ")}`)}return a};return{controls:{advance:(e,l)=>(p+=e,C(p,l)),failures:()=>[...f],list:()=>[...d.values()],runPending:e=>C(Number.POSITIVE_INFINITY,e)},scheduler:E}},st=()=>{const r=new ot(":memory:"),c=u=>({one(){if(u.length!==1)throw new _("INTERNAL",`expected exactly one row, received ${String(u.length)}`);const[g]=u;return g},[Symbol.iterator](){return u[Symbol.iterator]()},toArray(){return u}});return{close:()=>{r.close()},sql:{exec:(u,...g)=>{const y=r.prepare(u).all(...g);return c(y)}}}},q=r=>{if(typeof r!="object"||r===null)return;const{kind:c}=r;if(c==="query"||c==="mutation"||c==="action")return c},it=r=>typeof r=="object"&&r!==null&&r.visibility==="internal"?"internal":"public",Q=r=>{throw new _("INTERNAL",`ctx.${r} is not available in the in-memory @lunora/testing harness (v1)`)},w=r=>new Proxy((...c)=>Q(r),{apply:()=>Q(r),get:()=>Q(r)}),K={spanId:"0000000000000001",traceId:"00000000000000000000000000000001"},ct={addEvent:()=>{},addLink:()=>{},recordEvaluation:()=>{},recordException:()=>{},setAttribute:()=>{},setAttributes:()=>{},spanContext:()=>K},at=()=>{const r={attributes:{},events:[],links:[]},c={addEvent:(s,u)=>{r.events.push({...u===void 0?{}:{attributes:{...u}},name:s})},addLink:s=>{r.links.push({spanId:s.spanId,traceId:s.traceId})},recordEvaluation:s=>{Object.assign(r.attributes,tt(s))},recordException:s=>{const u={"exception.message":s instanceof Error?s.message:String(s),"exception.type":s instanceof Error?s.constructor.name:"Error"};s instanceof Error&&s.stack!==void 0&&(u["exception.stacktrace"]=s.stack),c.addEvent("exception",u)},setAttribute:(s,u)=>{r.attributes[s]=u},setAttributes:s=>{Object.assign(r.attributes,s)},spanContext:()=>K};return{handle:c,recorded:r}},D=async(r,c)=>await c(D,ct),Y={count:()=>{},gauge:()=>{},record:()=>{}},j={debug:()=>{},error:()=>{},event:()=>{},fatal:()=>{},info:()=>{},log:()=>{},trace:()=>{},warn:()=>{},with:()=>j},ut=(r,c,s)=>(g,p)=>{let y=!1;const d=[];let f,h,b=0,E=0;const k=()=>q(g)?r("query",g,c,p,!1):Promise.resolve(g(c)),M=(t,n)=>{if(t<E)return;E=t;const v={done:!1,value:n};if(d.length===0)f=v,h=void 0;else{f=void 0,h=void 0;for(const T of d.splice(0))T.resolve(v)}},C=(t,n)=>{if(!(t<E))if(E=t,d.length===0)h={error:n},f=void 0;else{f=void 0,h=void 0;for(const v of d.splice(0))v.reject(n)}},R=t=>n=>{M(t,n)},e=t=>n=>{C(t,n)},l=()=>{if(y)return;b+=1;const t=b;k().then(R(t)).catch(e(t))};s.add(l);const a={[Symbol.asyncIterator](){return a},next:()=>{if(y)return Promise.resolve({done:!0,value:void 0});if(E===b){if(h!==void 0){const{error:t}=h;return h=void 0,Promise.reject(t)}if(f!==void 0){const t=f;return f=void 0,Promise.resolve(t)}}return E<b?new Promise((t,n)=>{d.push({reject:n,resolve:t})}):k().then(t=>{if(h!==void 0){const{error:n}=h;throw h=void 0,n}if(f!==void 0){const n=f;return f=void 0,n}return{done:!1,value:t}})},return:()=>{y=!0,s.delete(l);for(const t of d.splice(0))t.resolve({done:!0,value:void 0});return Promise.resolve({done:!0,value:void 0})}};return k().then(R(0)).catch(e(0)),a},pt=(r,c)=>{const{close:s,sql:u}=st(),g=r;O(u,g);const p=z({enforceRls:c?.enforceRls??!0,schema:g,sql:u}),y=p[Z]??p,d=m=>{u.exec.call(u,m)};let f=Promise.resolve();const h=m=>{const P=async()=>{d("BEGIN");try{const S=await m();return d("COMMIT"),S}catch(S){try{d("ROLLBACK")}catch{}throw S}},x=f.then(P);return f=x.then(()=>{},()=>{}),x};let b=!1;const E=()=>{b||(b=!0,s())},k=new Map(Object.entries(c?.functions??{}).map(([m,P])=>[m,P])),M=new Set,C=()=>{for(const m of M)m()},R=m=>(C(),m);let e,l,a;const t=c?.now??Date.now(),n=at(),v=(m,P)=>{if(m===void 0)throw new _("INTERNAL",`[fake-scheduler] ${P} not yet available — scheduler.advance called before harness construction completed`);return m},{controls:T,scheduler:N}=rt(()=>v(e,"dispatch"),()=>v(l,"mutationContext"),()=>v(a,"actionContext"),()=>k,t),B=m=>{const P={getIdentity:()=>Promise.resolve(m??null),userId:m?.userId??null},x={auth:P,db:p,env:c?.env,log:j,metrics:Y,now:t,span:n.handle,trace:D,runQuery:((o,i)=>I("query",o,x,i)),secrets:w("secrets"),storage:w("storage"),vectors:w("vectors")},S={auth:P,db:p,env:c?.env,log:j,metrics:Y,now:t,span:n.handle,trace:D,runMutation:((o,i)=>I("mutation",o,S,i)),runQuery:((o,i)=>I("query",o,x,i)),scheduler:N,secrets:w("secrets"),storage:w("storage"),vectors:w("vectors"),workflows:w("workflows")};l??=S;const U={...S,db:y},F={auth:P,db:p,env:c?.env,fetch:c?.fetch??w("fetch"),log:j,metrics:Y,now:t,span:n.handle,trace:D,runAction:((o,i)=>I("action",o,F,i)),runMutation:((o,i)=>I("mutation",o,S,i)),runQuery:((o,i)=>I("query",o,x,i)),scheduler:N,secrets:w("secrets"),storage:w("storage"),vectors:w("vectors"),workflows:w("workflows")};a??=F;const $=(o,i,A,L,J)=>{const H=q(i);if(H!==o)throw new _("INTERNAL",`expected a registered ${o}, received a ${H??"non-function"} reference`);if(!J&&it(i)==="internal")throw new _("INTERNAL",`This ${o} is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${o.charAt(0).toUpperCase()}${o.slice(1)} from another function instead.`);return Promise.resolve(i.handler(A,L??{}))},I=(o,i,A,L)=>$(o,i,A,L,!0);e??=(o,i,A,L)=>o==="mutation"?h(()=>$("mutation",i,A,L,!0)).then(R):I("action",i,A,L);const V=((o,i)=>q(o)?$("query",o,x,i,!1):Promise.resolve(o(x))),W=((o,i)=>{const A=q(o)?()=>$("mutation",o,S,i,!1):()=>o(S);return h(A).then(R)}),X=((o,i)=>q(o)?$("action",o,F,i,!1):Promise.resolve(o(F))),G=ut($,x,M);return{action:X,close:E,mutation:W,query:V,run:o=>h(()=>o(U)).then(R),scheduler:T,subscribe:G,wideEvent:()=>n.recorded,withIdentity:o=>B(o)}};return B(null)};export{pt as lunoraTest};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/testing",
3
- "version": "1.0.0-alpha.111",
3
+ "version": "1.0.0-alpha.113",
4
4
  "description": "Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,11 +50,12 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/agent": "1.0.0-alpha.57",
53
+ "@lunora/agent": "1.0.0-alpha.59",
54
54
  "@lunora/errors": "1.0.0-alpha.22",
55
- "@lunora/mail": "1.0.0-alpha.50",
56
- "@lunora/server": "1.0.0-alpha.76",
57
- "@lunora/shard-engine": "1.0.0-alpha.29"
55
+ "@lunora/mail": "1.0.0-alpha.51",
56
+ "@lunora/scheduler": "1.0.0-alpha.34",
57
+ "@lunora/server": "1.0.0-alpha.78",
58
+ "@lunora/shard-engine": "1.0.0-alpha.31"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "@playwright/test": "^1.61.1"
@@ -1,2 +0,0 @@
1
- import{LunoraError as d}from"@lunora/errors";const g=/^\s*(-?\d+(?:\.\d+)?)/u,i=e=>Number.isFinite(e)?Math.min(1,Math.max(0,e)):0,p=e=>typeof e=="number"?{score:i(e)}:{score:i(e.score),...e.reason===void 0?{}:{reason:e.reason}},u=e=>e.length===0?0:e.reduce((t,n)=>t+n,0)/e.length,x=(e,t={})=>({name:`contains:${e}`,score:({output:n})=>{const a=t.caseSensitive?n:n.toLowerCase(),r=t.caseSensitive?e:e.toLowerCase();return a.includes(r)?1:0}}),f=(e,t="regex")=>({name:t,score:({output:n})=>e.test(n)?1:0}),v=()=>({name:"exact-match",score:({expected:e,output:t})=>t.trim()===e?.trim()?1:0}),y=e=>{if(e.length===0)throw new d("BAD_REQUEST","@lunora/testing: keywordScorer requires at least one keyword");return{name:"keyword-coverage",score:({output:t})=>{const n=t.toLowerCase(),a=e.filter(r=>n.includes(r.toLowerCase())).length;return{reason:`${String(a)}/${String(e.length)} keywords present`,score:a/e.length}}}},l=(e,t)=>[`Rate the ASSISTANT OUTPUT against this criterion: ${e}`,"Respond with a single number from 0 (fails) to 1 (fully meets), then a dash and a one-line reason.",...t.input===void 0?[]:["",`Input: ${t.input}`],...t.expected===void 0?[]:["",`Reference answer: ${t.expected}`],"",`Assistant output: ${t.output}`].join(`
2
- `),h=e=>{const t=g.exec(e);return{reason:e.trim(),score:t?i(Number(t[1])):0}},$=e=>({name:e.name??"llm-judge",score:async t=>h(await e.judge(l(e.criteria,t)))}),S=async(e,t)=>{const n=await Promise.all(t.map(async o=>({name:o.name,result:p(await o.score(e))}))),a={},r=new Map;for(const{name:o,result:s}of n){const c=r.get(o)??0;r.set(o,c+1),a[c===0?o:`${o}#${String(c+1)}`]=s}return{average:u(n.map(({result:o})=>o.score)),scores:a}},L=async(e,t,n)=>{const a=await Promise.all(e.map(async r=>{const o=await t(r.input),s={input:r.input,output:o,...r.expected===void 0?{}:{expected:r.expected},...r.metadata===void 0?{}:{metadata:r.metadata}},{average:c,scores:m}=await S(s,n);return{average:c,input:r.input,output:o,scores:m}}));return{average:u(a.map(r=>r.average)),items:a}};export{x as containsScorer,L as evaluate,v as exactMatchScorer,y as keywordScorer,$ as llmScorer,f as regexScorer,S as scoreSample};
@@ -1 +0,0 @@
1
- import{LunoraError as a}from"@lunora/errors";const n=/[\w.-]/u,t=e=>{let o="";for(const r of e)o+=n.test(r)?r:"_";return o},s=e=>{if(typeof e.name!="string"||e.name.length===0)throw new a("BAD_REQUEST","@lunora/testing: recordEvaluation requires a non-empty `name`");if(typeof e.score!="number"||!Number.isFinite(e.score))throw new a("BAD_REQUEST","@lunora/testing: recordEvaluation `score` must be a finite number");const o=t(e.name),r={[`gen_ai.evaluation.${o}.score`]:e.score};return e.label!==void 0&&(r[`gen_ai.evaluation.${o}.label`]=e.label),r},c=e=>{const o=s(e);return e.span?.setAttributes(o),e.metrics?.gauge(`gen_ai.evaluation.${t(e.name)}.score`,e.score,e.label===void 0?void 0:{label:e.label}),o};export{s as evaluationAttributes,c as recordEvaluation};
@@ -1 +0,0 @@
1
- import{LunoraError as M}from"@lunora/errors";import{runShardMigrations as X,createShardCtxDb as z,RLS_UNWRAP_SYMBOL as Z}from"@lunora/shard-engine";import{evaluationAttributes as tt}from"./evaluationAttributes-BA1uUP-z.mjs";import{DatabaseSync as et}from"node:sqlite";const nt=(o,c,s,u,g)=>{let p=g,y=1;const d=new Map,f=[],h=(e,l,a={})=>{const t=`fake-job-${String(y)}`;return y+=1,d.set(t,{args:a,enqueuedAt:p,functionPath:l,id:t,scheduledFor:e}),t},b=e=>typeof e=="string"?e:e.name??e.binding??"",P={cancel:e=>{const l=d.has(e);return d.delete(e),Promise.resolve({cancelled:l})},get:e=>Promise.resolve(d.get(e)??null),list:()=>Promise.resolve([...d.values()]),runAfter:(e,l,a)=>{const t=h(p+e,b(l),a);return Promise.resolve(t)},runAt:(e,l,a)=>{const t=h(e,b(l),a);return Promise.resolve(t)}},A=async e=>{d.delete(e.id);const a=u().get(e.functionPath);if(a===void 0){console.warn(`[fake-scheduler] unknown functionPath "${e.functionPath}" — job ${e.id} dropped`);return}if(a.kind==="mutation"||a.kind==="action"){const t=o(),r=a.kind==="action"?s():c();await t(a.kind,a,r,e.args)}else console.warn(`[fake-scheduler] functionPath "${e.functionPath}" is a ${a.kind} — only mutations and actions can be scheduled; job ${e.id} dropped`)},I=async e=>{const l=[...d.values()].filter(r=>r.scheduledFor<=e).toSorted((r,v)=>r.scheduledFor-v.scheduledFor),a=[];let t=0;for(const r of l)if(d.has(r.id)){t+=1;try{await A(r)}catch(v){const $={args:r.args,error:v,functionPath:r.functionPath,id:r.id};a.push($),f.push($)}}return{executed:t,failed:a}},C=async(e,l)=>{const{executed:a,failed:t}=await I(e);if(t.length>0&&(l?.throwOnError??!0)){const[r]=t;throw t.length===1&&r!==void 0?r.error:new AggregateError(t.map(v=>v.error),`${String(t.length)} scheduled jobs failed: ${t.map(v=>v.functionPath).join(", ")}`)}return a};return{controls:{advance:(e,l)=>(p+=e,C(p,l)),failures:()=>[...f],list:()=>[...d.values()],runPending:e=>C(Number.POSITIVE_INFINITY,e)},scheduler:P}},rt=()=>{const o=new et(":memory:"),c=u=>({one(){if(u.length!==1)throw new M("INTERNAL",`expected exactly one row, received ${String(u.length)}`);const[g]=u;return g},[Symbol.iterator](){return u[Symbol.iterator]()},toArray(){return u}});return{close:()=>{o.close()},sql:{exec:(u,...g)=>{const y=o.prepare(u).all(...g);return c(y)}}}},L=o=>{if(typeof o!="object"||o===null)return;const{kind:c}=o;if(c==="query"||c==="mutation"||c==="action")return c},ot=o=>typeof o=="object"&&o!==null&&o.visibility==="internal"?"internal":"public",_=o=>{throw new M("INTERNAL",`ctx.${o} is not available in the in-memory @lunora/testing harness (v1)`)},w=o=>new Proxy((...c)=>_(o),{apply:()=>_(o),get:()=>_(o)}),U={spanId:"0000000000000001",traceId:"00000000000000000000000000000001"},st={addEvent:()=>{},addLink:()=>{},recordEvaluation:()=>{},recordException:()=>{},setAttribute:()=>{},setAttributes:()=>{},spanContext:()=>U},it=()=>{const o={attributes:{},events:[],links:[]},c={addEvent:(s,u)=>{o.events.push({...u===void 0?{}:{attributes:{...u}},name:s})},addLink:s=>{o.links.push({spanId:s.spanId,traceId:s.traceId})},recordEvaluation:s=>{Object.assign(o.attributes,tt(s))},recordException:s=>{const u={"exception.message":s instanceof Error?s.message:String(s),"exception.type":s instanceof Error?s.constructor.name:"Error"};s instanceof Error&&s.stack!==void 0&&(u["exception.stacktrace"]=s.stack),c.addEvent("exception",u)},setAttribute:(s,u)=>{o.attributes[s]=u},setAttributes:s=>{Object.assign(o.attributes,s)},spanContext:()=>U};return{handle:c,recorded:o}},j=async(o,c)=>await c(j,st),Q={count:()=>{},gauge:()=>{},record:()=>{}},D={debug:()=>{},error:()=>{},event:()=>{},fatal:()=>{},info:()=>{},log:()=>{},trace:()=>{},warn:()=>{},with:()=>D},ct=(o,c,s)=>(g,p)=>{let y=!1;const d=[];let f,h,b=0,P=0;const A=()=>L(g)?o("query",g,c,p,!1):Promise.resolve(g(c)),I=(t,r)=>{if(t<P)return;P=t;const v={done:!1,value:r};if(d.length===0)f=v,h=void 0;else{f=void 0,h=void 0;for(const $ of d.splice(0))$.resolve(v)}},C=(t,r)=>{if(!(t<P))if(P=t,d.length===0)h={error:r},f=void 0;else{f=void 0,h=void 0;for(const v of d.splice(0))v.reject(r)}},R=t=>r=>{I(t,r)},e=t=>r=>{C(t,r)},l=()=>{if(y)return;b+=1;const t=b;A().then(R(t)).catch(e(t))};s.add(l);const a={[Symbol.asyncIterator](){return a},next:()=>{if(y)return Promise.resolve({done:!0,value:void 0});if(P===b){if(h!==void 0){const{error:t}=h;return h=void 0,Promise.reject(t)}if(f!==void 0){const t=f;return f=void 0,Promise.resolve(t)}}return P<b?new Promise((t,r)=>{d.push({reject:r,resolve:t})}):A().then(t=>{if(h!==void 0){const{error:r}=h;throw h=void 0,r}if(f!==void 0){const r=f;return f=void 0,r}return{done:!1,value:t}})},return:()=>{y=!0,s.delete(l);for(const t of d.splice(0))t.resolve({done:!0,value:void 0});return Promise.resolve({done:!0,value:void 0})}};return A().then(R(0)).catch(e(0)),a},ft=(o,c)=>{const{close:s,sql:u}=rt(),g=o;X(u,g);const p=z({enforceRls:c?.enforceRls??!0,schema:g,sql:u}),y=p[Z]??p,d=m=>{u.exec.call(u,m)};let f=Promise.resolve();const h=m=>{const x=async()=>{d("BEGIN");try{const S=await m();return d("COMMIT"),S}catch(S){try{d("ROLLBACK")}catch{}throw S}},E=f.then(x);return f=E.then(()=>{},()=>{}),E};let b=!1;const P=()=>{b||(b=!0,s())},A=new Map(Object.entries(c?.functions??{}).map(([m,x])=>[m,x])),I=new Set,C=()=>{for(const m of I)m()},R=m=>(C(),m);let e,l,a;const t=c?.now??Date.now(),r=it(),v=(m,x)=>{if(m===void 0)throw new M("INTERNAL",`[fake-scheduler] ${x} not yet available — scheduler.advance called before harness construction completed`);return m},{controls:$,scheduler:B}=nt(()=>v(e,"dispatch"),()=>v(l,"mutationContext"),()=>v(a,"actionContext"),()=>A,t),H=m=>{const x={getIdentity:()=>Promise.resolve(m??null),userId:m?.userId??null},E={auth:x,db:p,env:c?.env,log:D,metrics:Q,now:t,span:r.handle,trace:j,runQuery:((n,i)=>N("query",n,E,i)),secrets:w("secrets"),storage:w("storage"),vectors:w("vectors")},S={auth:x,db:p,env:c?.env,log:D,metrics:Q,now:t,span:r.handle,trace:j,runMutation:((n,i)=>N("mutation",n,S,i)),runQuery:((n,i)=>N("query",n,E,i)),scheduler:B,secrets:w("secrets"),storage:w("storage"),vectors:w("vectors"),workflows:w("workflows")};l??=S;const V={...S,db:y},F={auth:x,db:p,env:c?.env,fetch:c?.fetch??w("fetch"),log:D,metrics:Q,now:t,span:r.handle,trace:j,runAction:((n,i)=>N("action",n,F,i)),runMutation:((n,i)=>N("mutation",n,S,i)),runQuery:((n,i)=>N("query",n,E,i)),scheduler:B,secrets:w("secrets"),storage:w("storage"),vectors:w("vectors"),workflows:w("workflows")};a??=F;const T=(n,i,k,q,O)=>{const K=L(i);if(K!==n)throw new M("INTERNAL",`expected a registered ${n}, received a ${K??"non-function"} reference`);if(!O&&ot(i)==="internal")throw new M("INTERNAL",`This ${n} is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${n.charAt(0).toUpperCase()}${n.slice(1)} from another function instead.`);return Promise.resolve(i.handler(k,q??{}))},N=(n,i,k,q)=>T(n,i,k,q,!0);e??=(n,i,k,q)=>n==="mutation"?h(()=>T("mutation",i,k,q,!0)).then(R):N("action",i,k,q);const W=((n,i)=>L(n)?T("query",n,E,i,!1):Promise.resolve(n(E))),Y=((n,i)=>{const k=L(n)?()=>T("mutation",n,S,i,!1):()=>n(S);return h(k).then(R)}),G=((n,i)=>L(n)?T("action",n,F,i,!1):Promise.resolve(n(F))),J=ct(T,E,I);return{action:G,close:P,mutation:Y,query:W,run:n=>h(()=>n(V)).then(R),scheduler:$,subscribe:J,wideEvent:()=>r.recorded,withIdentity:n=>H(n)}};return H(null)};export{ft as lunoraTest};