@iris-eval/mcp-server 0.8.1 → 0.9.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/README.md +9 -2
- package/dist/capabilities.d.ts +64 -0
- package/dist/capabilities.js +65 -0
- package/dist/config/defaults.js +2 -0
- package/dist/custom-rule-store.d.ts +4 -0
- package/dist/custom-rule-store.js +8 -3
- package/dist/dashboard/assets/{index-BfMShR3p.js → index-Cz8_oOqG.js} +1 -1
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/capabilities.d.ts +3 -0
- package/dist/dashboard/routes/capabilities.js +11 -0
- package/dist/dashboard/routes/health.d.ts +5 -1
- package/dist/dashboard/routes/health.js +15 -3
- package/dist/dashboard/routes/rules.js +4 -1
- package/dist/dashboard/routes/traces.d.ts +3 -0
- package/dist/dashboard/routes/traces.js +9 -28
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +6 -2
- package/dist/eval/accuracy.d.ts +41 -0
- package/dist/eval/accuracy.js +97 -0
- package/dist/eval/criticality.d.ts +8 -1
- package/dist/eval/criticality.js +6 -0
- package/dist/eval/dormant.d.ts +4 -0
- package/dist/eval/dormant.js +22 -0
- package/dist/eval/engine.d.ts +1 -0
- package/dist/eval/engine.js +49 -3
- package/dist/eval/failure-classes.d.ts +8 -0
- package/dist/eval/failure-classes.js +18 -0
- package/dist/eval/llm-judge/evaluator.d.ts +10 -0
- package/dist/eval/llm-judge/evaluator.js +16 -1
- package/dist/eval/published-accuracy.d.ts +230 -0
- package/dist/eval/published-accuracy.js +86 -0
- package/dist/eval/questions.d.ts +12 -0
- package/dist/eval/questions.js +14 -0
- package/dist/eval/response-schema.d.ts +652 -0
- package/dist/eval/response-schema.js +130 -0
- package/dist/eval/response.d.ts +12 -0
- package/dist/eval/response.js +30 -0
- package/dist/eval/rules/completeness.js +31 -0
- package/dist/eval/rules/cost.d.ts +1 -1
- package/dist/eval/rules/cost.js +44 -0
- package/dist/eval/rules/custom.d.ts +0 -12
- package/dist/eval/rules/custom.js +21 -0
- package/dist/eval/rules/relevance.js +16 -0
- package/dist/eval/rules/safety.js +178 -1
- package/dist/eval/stamp.d.ts +14 -0
- package/dist/eval/stamp.js +88 -0
- package/dist/eval/stats.d.ts +33 -0
- package/dist/eval/stats.js +109 -0
- package/dist/eval/verdict.d.ts +34 -0
- package/dist/eval/verdict.js +131 -0
- package/dist/index.js +5 -28
- package/dist/instructions.d.ts +17 -0
- package/dist/instructions.js +53 -0
- package/dist/judge-enablement.d.ts +34 -0
- package/dist/judge-enablement.js +78 -0
- package/dist/judge-enablement.json +10 -0
- package/dist/preferences.d.ts +1 -1
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.js +29 -0
- package/dist/resources/index.d.ts +5 -2
- package/dist/resources/index.js +65 -5
- package/dist/resources/uris.d.ts +12 -0
- package/dist/resources/uris.js +24 -0
- package/dist/retention.d.ts +20 -0
- package/dist/retention.js +44 -0
- package/dist/self-test.d.ts +1 -0
- package/dist/self-test.js +14 -0
- package/dist/server.d.ts +10 -1
- package/dist/server.js +34 -7
- package/dist/storage/index.js +1 -1
- package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
- package/dist/storage/migrations/007-eval-provenance.js +30 -0
- package/dist/storage/migrations/index.js +24 -4
- package/dist/storage/sqlite-adapter.d.ts +26 -1
- package/dist/storage/sqlite-adapter.js +135 -15
- package/dist/tools/delete-rule.d.ts +8 -0
- package/dist/tools/delete-rule.js +30 -38
- package/dist/tools/delete-trace.d.ts +5 -0
- package/dist/tools/delete-trace.js +24 -27
- package/dist/tools/deploy-rule.d.ts +13 -1
- package/dist/tools/deploy-rule.js +37 -34
- package/dist/tools/describe.d.ts +20 -0
- package/dist/tools/describe.js +36 -0
- package/dist/tools/errors.d.ts +36 -0
- package/dist/tools/errors.js +134 -0
- package/dist/tools/evaluate-output.d.ts +8 -1
- package/dist/tools/evaluate-output.js +37 -58
- package/dist/tools/evaluate-with-llm-judge.d.ts +31 -0
- package/dist/tools/evaluate-with-llm-judge.js +96 -69
- package/dist/tools/get-traces.d.ts +9 -0
- package/dist/tools/get-traces.js +30 -29
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +22 -1
- package/dist/tools/list-rules.d.ts +13 -0
- package/dist/tools/list-rules.js +43 -46
- package/dist/tools/log-trace.d.ts +4 -0
- package/dist/tools/log-trace.js +31 -29
- package/dist/tools/respond.d.ts +42 -0
- package/dist/tools/respond.js +90 -0
- package/dist/tools/strict-input.js +1 -1
- package/dist/tools/trace-link.d.ts +2 -0
- package/dist/tools/trace-link.js +13 -2
- package/dist/tools/verify-citations.d.ts +17 -2
- package/dist/tools/verify-citations.js +98 -93
- package/dist/types/config.d.ts +9 -0
- package/dist/types/eval.d.ts +258 -0
- package/dist/types/eval.js +2 -1
- package/dist/types/query.d.ts +2 -0
- package/package.json +1 -1
- package/server.json +72 -2
- package/dist/resources/dashboard-summary.d.ts +0 -3
- package/dist/resources/dashboard-summary.js +0 -16
- package/dist/resources/trace-detail.d.ts +0 -3
- package/dist/resources/trace-detail.js +0 -30
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The one zod object that IS the evaluation response.
|
|
3
|
+
*
|
|
4
|
+
* Three things read it: the response-shape drift-lock test (which runs the
|
|
5
|
+
* real tool handlers and validates what they return), the tools'
|
|
6
|
+
* `outputSchema` once the structured-content release lands, and — at 1.0 —
|
|
7
|
+
* the published response-schema-v1.json. Keeping them one object is what
|
|
8
|
+
* makes "the response shape is settled" checkable: a field added here is
|
|
9
|
+
* additive by construction (every object is loose — unknown keys pass), and a field
|
|
10
|
+
* removed or re-meant fails the drift-lock before it can ship.
|
|
11
|
+
*
|
|
12
|
+
* Optional everywhere a field is optional on the TypeScript type; the test
|
|
13
|
+
* asserts presence where the release promises it (every built-in result
|
|
14
|
+
* carries kind, role, saw, uncertainty), so this schema states the shape
|
|
15
|
+
* and the test states the promise.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
export const intervalSchema = z.looseObject({ point: z.number(), lo: z.number(), hi: z.number() });
|
|
19
|
+
const priorSchema = z.looseObject({ pi: z.number(), source: z.enum(['default', 'config', 'estimated']) });
|
|
20
|
+
const corpusSchema = z.looseObject({
|
|
21
|
+
n: z.number().int(),
|
|
22
|
+
tp: z.number().int(),
|
|
23
|
+
fp: z.number().int(),
|
|
24
|
+
fn: z.number().int(),
|
|
25
|
+
tn: z.number().int(),
|
|
26
|
+
version: z.string(),
|
|
27
|
+
release: z.string(),
|
|
28
|
+
labelling: z.enum(['same-model', 'human-verified']),
|
|
29
|
+
});
|
|
30
|
+
export const uncertaintySchema = z.discriminatedUnion('basis', [
|
|
31
|
+
// One member per basis (zod's discriminator must be unique); the fired /
|
|
32
|
+
// not-fired split is enforced by the refinement: a fire carries ppv, a
|
|
33
|
+
// quiet result carries missRate.
|
|
34
|
+
z
|
|
35
|
+
.looseObject({ basis: z.literal('published_accuracy'), fired: z.boolean(), ppv: intervalSchema.optional(), missRate: intervalSchema.optional(), prior: priorSchema, corpus: corpusSchema })
|
|
36
|
+
.refine((v) => (v.fired ? v.ppv !== undefined : v.missRate !== undefined), { message: 'a fired result carries ppv; a quiet one carries missRate' }),
|
|
37
|
+
z.looseObject({ basis: z.literal('definition'), conformance: z.looseObject({ n: z.number().int(), matched: z.number().int() }) }),
|
|
38
|
+
z.looseObject({ basis: z.literal('self_consistency'), samples: z.number().int(), voteFraction: z.number(), scoreSd: z.number() }),
|
|
39
|
+
z.looseObject({ basis: z.literal('local_labels'), precision: intervalSchema, n: z.number().int() }),
|
|
40
|
+
z.looseObject({ basis: z.literal('policy') }),
|
|
41
|
+
z.looseObject({ basis: z.literal('unmeasured'), why: z.string() }),
|
|
42
|
+
]);
|
|
43
|
+
export const evidenceSchema = z.discriminatedUnion('type', [
|
|
44
|
+
z.looseObject({ type: z.literal('span'), source: z.string(), start: z.number().int().nonnegative(), end: z.number().int().nonnegative(), label: z.string() }),
|
|
45
|
+
z.looseObject({ type: z.literal('pattern'), name: z.string(), count: z.number().int().nonnegative() }),
|
|
46
|
+
z.looseObject({ type: z.literal('toolCall'), index: z.number().int().nonnegative(), toolName: z.string(), label: z.string() }),
|
|
47
|
+
z.looseObject({ type: z.literal('citation'), url: z.string(), status: z.enum(['resolved', 'dead', 'unverifiable', 'supported', 'unsupported']) }),
|
|
48
|
+
z.looseObject({ type: z.literal('count'), stat: z.string(), unit: z.string(), value: z.number(), threshold: z.number().optional(), thresholdSource: z.enum(['default', 'config', 'call', 'rule']).optional() }),
|
|
49
|
+
]);
|
|
50
|
+
export const measuredValueSchema = z.looseObject({ stat: z.string(), unit: z.string(), value: z.number() });
|
|
51
|
+
export const claimKindSchema = z.enum(['measurement', 'detection', 'inference', 'judgment', 'policy', 'verification']);
|
|
52
|
+
export const roleSchema = z.enum(['gate', 'veto', 'risk', 'advisory', 'term']);
|
|
53
|
+
export const skipClassSchema = z.enum(['not_applicable', 'defeated', 'config_invalid']);
|
|
54
|
+
export const needSchema = z.enum(['output', 'input', 'expected', 'tool_calls', 'tool_outputs', 'tools_catalogue', 'cost', 'tokens', 'citations']);
|
|
55
|
+
export const questionIdSchema = z.enum(['safe_output', 'grounded', 'complete', 'relevant', 'task_completed', 'tool_use_correct', 'within_budget']);
|
|
56
|
+
export const evalRuleResultSchema = z.looseObject({
|
|
57
|
+
ruleName: z.string(),
|
|
58
|
+
ruleId: z.string().optional(),
|
|
59
|
+
category: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).optional(),
|
|
60
|
+
critical: z.boolean().optional(),
|
|
61
|
+
criticalSource: z.enum(['default', 'config']).optional(),
|
|
62
|
+
passed: z.boolean(),
|
|
63
|
+
score: z.number(),
|
|
64
|
+
message: z.string(),
|
|
65
|
+
skipped: z.boolean().optional(),
|
|
66
|
+
skipReason: z.string().optional(),
|
|
67
|
+
configInvalid: z.boolean().optional(),
|
|
68
|
+
budgetExceeded: z.boolean().optional(),
|
|
69
|
+
kind: claimKindSchema.optional(),
|
|
70
|
+
role: roleSchema.optional(),
|
|
71
|
+
question: questionIdSchema.optional(),
|
|
72
|
+
classes: z.array(z.string()).optional(),
|
|
73
|
+
ruleVersion: z.number().int().optional(),
|
|
74
|
+
saw: z.array(needSchema).optional(),
|
|
75
|
+
skipClass: skipClassSchema.optional(),
|
|
76
|
+
uncertainty: uncertaintySchema.optional(),
|
|
77
|
+
evidence: z.array(evidenceSchema).optional(),
|
|
78
|
+
value: measuredValueSchema.optional(),
|
|
79
|
+
});
|
|
80
|
+
export const evalCategoryResultSchema = z.looseObject({
|
|
81
|
+
score: z.number().nullable(),
|
|
82
|
+
passed: z.boolean().nullable(),
|
|
83
|
+
rules_evaluated: z.number().int(),
|
|
84
|
+
rules_skipped: z.number().int(),
|
|
85
|
+
insufficient_data: z.boolean(),
|
|
86
|
+
critical_failures: z.array(z.string()).optional(),
|
|
87
|
+
critical_skipped: z.array(z.string()).optional(),
|
|
88
|
+
});
|
|
89
|
+
export const coverageSchema = z.looseObject({
|
|
90
|
+
inputs: z.record(z.string(), z.boolean()),
|
|
91
|
+
questions: z.array(z.looseObject({ id: questionIdSchema, status: z.enum(['judged', 'unjudged', 'not_applicable']), why: z.string().optional() })),
|
|
92
|
+
dormant: z.array(z.looseObject({ ruleId: z.string(), name: z.string(), reason: z.string() })).optional(),
|
|
93
|
+
});
|
|
94
|
+
export const verdictSchema = z.looseObject({
|
|
95
|
+
state: z.enum(['pass', 'fail', 'unknown']),
|
|
96
|
+
passed: z.boolean(),
|
|
97
|
+
basis: z.enum(['policy_gate', 'detector_veto', 'critical_unknown', 'required_evidence_missing', 'risk_over_loss', 'score_below_threshold', 'clean', 'no_rules']),
|
|
98
|
+
by: z.array(z.string()),
|
|
99
|
+
risk: z.looseObject({ pBad: z.number(), lo: z.number(), hi: z.number() }).nullable(),
|
|
100
|
+
confidence: z.enum(['decisive', 'marginal']).optional(),
|
|
101
|
+
});
|
|
102
|
+
export const provenanceSchema = z.looseObject({
|
|
103
|
+
irisVersion: z.string(),
|
|
104
|
+
rulesetHash: z.string(),
|
|
105
|
+
configHash: z.string(),
|
|
106
|
+
thresholds: z.looseObject({ default: z.number(), perRule: z.record(z.string(), z.unknown()).optional() }),
|
|
107
|
+
corpusVersion: z.string(),
|
|
108
|
+
judgedAt: z.string(),
|
|
109
|
+
});
|
|
110
|
+
/** The `evaluate_output` response — the same object the engine returns plus the tool's own fields. */
|
|
111
|
+
export const evaluateOutputResponseSchema = z.looseObject({
|
|
112
|
+
id: z.string().describe('the evaluation id, readable at iris://evaluations/{id}'),
|
|
113
|
+
trace_id: z.string().optional().describe('the linked trace, when named'),
|
|
114
|
+
verdict: verdictSchema.optional().describe('state, passed, basis (which layer decided), by (the rules), risk'),
|
|
115
|
+
coverage: coverageSchema.optional().describe('per question: judged, unjudged and why, or not_applicable; plus the inputs carried'),
|
|
116
|
+
provenance: provenanceSchema.optional().describe('Iris version, ruleset and config hashes, thresholds, corpus version, time'),
|
|
117
|
+
erased_at: z.string().optional().describe('set once the linked trace was deleted'),
|
|
118
|
+
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).describe('the bundle that ran'),
|
|
119
|
+
score: z.number().describe('0..1 weighted quality over the rules that ran'),
|
|
120
|
+
passed: z.boolean().describe('the ship verdict; false when nothing was judged'),
|
|
121
|
+
rule_results: z.array(evalRuleResultSchema).describe('per rule: verdict, message, kind, role, question, saw, evidence, uncertainty'),
|
|
122
|
+
suggestions: z.array(z.string()).describe('what to change'),
|
|
123
|
+
rules_evaluated: z.number().int().describe('rules that judged'),
|
|
124
|
+
rules_skipped: z.number().int().describe('rules that skipped'),
|
|
125
|
+
insufficient_data: z.boolean().describe('true when no rule could judge'),
|
|
126
|
+
critical_failures: z.array(z.string()).optional().describe('critical rules that failed and vetoed passed'),
|
|
127
|
+
critical_skipped: z.array(z.string()).optional().describe('critical rules that could not judge; treat as unknown'),
|
|
128
|
+
categories: z.record(z.string(), evalCategoryResultSchema).optional().describe('per-bundle verdicts for eval_type all'),
|
|
129
|
+
note: z.string().optional().describe('present when eval_type was omitted'),
|
|
130
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EvalResult } from '../types/eval.js';
|
|
2
|
+
import type { DormantRule } from './dormant.js';
|
|
3
|
+
export interface EvaluationResponseOptions {
|
|
4
|
+
/** The trace the evaluation was linked to, echoed so a caller can join the two without a second read. */
|
|
5
|
+
traceId?: string;
|
|
6
|
+
/** Present only when `eval_type` was omitted and the default ran. */
|
|
7
|
+
note?: string;
|
|
8
|
+
/** Quarantined gating rules on this server, carried as coverage.dormant (a gate reads the verdict, never list_rules). */
|
|
9
|
+
dormant?: DormantRule[];
|
|
10
|
+
}
|
|
11
|
+
/** The response body for an evaluation — what the tool returns as text and, later, as structured content. */
|
|
12
|
+
export declare function toEvaluationResponse(result: EvalResult, options?: EvaluationResponseOptions): Record<string, unknown>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The response body for an evaluation — what the tool returns as text and, later, as structured content. */
|
|
2
|
+
export function toEvaluationResponse(result, options = {}) {
|
|
3
|
+
const traceId = options.traceId ?? result.trace_id;
|
|
4
|
+
return {
|
|
5
|
+
id: result.id,
|
|
6
|
+
...(traceId ? { trace_id: traceId } : {}),
|
|
7
|
+
// Echo which bundle actually ran. Without this, a caller who omitted
|
|
8
|
+
// eval_type could not tell a "safety pass" from a completeness eval
|
|
9
|
+
// that never ran a single safety rule.
|
|
10
|
+
eval_type: result.eval_type,
|
|
11
|
+
score: result.score,
|
|
12
|
+
passed: result.passed,
|
|
13
|
+
...(result.verdict ? { verdict: result.verdict } : {}),
|
|
14
|
+
...(result.critical_failures?.length ? { critical_failures: result.critical_failures } : {}),
|
|
15
|
+
// The other half of the veto contract: every critical rule that SKIPPED
|
|
16
|
+
// is named so a fail-closed gate can treat the evaluation as unknown.
|
|
17
|
+
...(result.critical_skipped?.length ? { critical_skipped: result.critical_skipped } : {}),
|
|
18
|
+
rule_results: result.rule_results,
|
|
19
|
+
suggestions: result.suggestions,
|
|
20
|
+
rules_evaluated: result.rules_evaluated,
|
|
21
|
+
rules_skipped: result.rules_skipped,
|
|
22
|
+
insufficient_data: result.insufficient_data,
|
|
23
|
+
...(result.coverage ? { coverage: options.dormant?.length ? { ...result.coverage, dormant: options.dormant } : result.coverage } : {}),
|
|
24
|
+
...(result.erased_at ? { erased_at: result.erased_at } : {}),
|
|
25
|
+
...(result.provenance ? { provenance: result.provenance } : {}),
|
|
26
|
+
// Per-bundle breakdown — eval_type="all" only.
|
|
27
|
+
...(result.categories ? { categories: result.categories } : {}),
|
|
28
|
+
...(options.note ? { note: options.note } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -3,6 +3,12 @@ export const minOutputLength = {
|
|
|
3
3
|
description: 'Output must meet a minimum character length',
|
|
4
4
|
evalType: 'completeness',
|
|
5
5
|
weight: 1,
|
|
6
|
+
kind: 'measurement',
|
|
7
|
+
mechanism: 'formula',
|
|
8
|
+
needs: ['output'],
|
|
9
|
+
question: 'complete',
|
|
10
|
+
classes: ['format'],
|
|
11
|
+
version: 1,
|
|
6
12
|
evaluate(context) {
|
|
7
13
|
const minLen = context.customConfig?.min_output_length
|
|
8
14
|
?? context.customConfig?.min_length
|
|
@@ -13,6 +19,8 @@ export const minOutputLength = {
|
|
|
13
19
|
ruleName: 'min_output_length',
|
|
14
20
|
passed,
|
|
15
21
|
score: passed ? 1 : Math.min(len / minLen, 0.99),
|
|
22
|
+
value: { stat: 'length', unit: 'chars', value: len },
|
|
23
|
+
evidence: [{ type: 'count', stat: 'length', unit: 'chars', value: len, threshold: minLen, thresholdSource: minLen === 50 ? 'default' : 'config' }],
|
|
16
24
|
message: passed ? `Output length (${len}) meets minimum (${minLen})` : `Output length (${len}) below minimum (${minLen})`,
|
|
17
25
|
};
|
|
18
26
|
},
|
|
@@ -22,12 +30,19 @@ export const nonEmptyOutput = {
|
|
|
22
30
|
description: 'Output must not be empty or whitespace-only',
|
|
23
31
|
evalType: 'completeness',
|
|
24
32
|
weight: 2,
|
|
33
|
+
kind: 'policy',
|
|
34
|
+
mechanism: 'formula',
|
|
35
|
+
needs: ['output'],
|
|
36
|
+
question: 'complete',
|
|
37
|
+
classes: ['format'],
|
|
38
|
+
version: 1,
|
|
25
39
|
evaluate(context) {
|
|
26
40
|
const passed = context.output.trim().length > 0;
|
|
27
41
|
return {
|
|
28
42
|
ruleName: 'non_empty_output',
|
|
29
43
|
passed,
|
|
30
44
|
score: passed ? 1 : 0,
|
|
45
|
+
value: { stat: 'length', unit: 'chars', value: context.output.trim().length },
|
|
31
46
|
message: passed ? 'Output is non-empty' : 'Output is empty or whitespace-only',
|
|
32
47
|
};
|
|
33
48
|
},
|
|
@@ -37,6 +52,12 @@ export const sentenceCount = {
|
|
|
37
52
|
description: 'Output must contain a minimum number of sentences',
|
|
38
53
|
evalType: 'completeness',
|
|
39
54
|
weight: 0.5,
|
|
55
|
+
kind: 'measurement',
|
|
56
|
+
mechanism: 'formula',
|
|
57
|
+
needs: ['output'],
|
|
58
|
+
question: 'complete',
|
|
59
|
+
classes: ['format'],
|
|
60
|
+
version: 1,
|
|
40
61
|
evaluate(context) {
|
|
41
62
|
const minSentences = context.customConfig?.min_sentences ?? 2;
|
|
42
63
|
const sentences = context.output.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
|
|
@@ -45,6 +66,8 @@ export const sentenceCount = {
|
|
|
45
66
|
ruleName: 'sentence_count',
|
|
46
67
|
passed,
|
|
47
68
|
score: passed ? 1 : Math.min(sentences / minSentences, 0.99),
|
|
69
|
+
value: { stat: 'sentences', unit: 'sentences', value: sentences },
|
|
70
|
+
evidence: [{ type: 'count', stat: 'sentences', unit: 'sentences', value: sentences, threshold: minSentences, thresholdSource: minSentences === 2 ? 'default' : 'config' }],
|
|
48
71
|
message: passed ? `Sentence count (${sentences}) meets minimum (${minSentences})` : `Sentence count (${sentences}) below minimum (${minSentences})`,
|
|
49
72
|
};
|
|
50
73
|
},
|
|
@@ -54,6 +77,12 @@ export const expectedCoverage = {
|
|
|
54
77
|
description: 'Output must cover key terms from expected output',
|
|
55
78
|
evalType: 'completeness',
|
|
56
79
|
weight: 1.5,
|
|
80
|
+
kind: 'measurement',
|
|
81
|
+
mechanism: 'formula',
|
|
82
|
+
needs: ['output', 'expected'],
|
|
83
|
+
question: 'complete',
|
|
84
|
+
classes: ['incomplete_ask'],
|
|
85
|
+
version: 1,
|
|
57
86
|
evaluate(context) {
|
|
58
87
|
if (!context.expected) {
|
|
59
88
|
return { ruleName: 'expected_coverage', passed: false, score: 0, message: 'No expected output provided', skipped: true, skipReason: 'context.expected not provided' };
|
|
@@ -74,6 +103,8 @@ export const expectedCoverage = {
|
|
|
74
103
|
ruleName: 'expected_coverage',
|
|
75
104
|
passed,
|
|
76
105
|
score: ratio,
|
|
106
|
+
value: { stat: 'expected_terms_covered', unit: 'ratio', value: ratio },
|
|
107
|
+
evidence: [{ type: 'count', stat: 'expected_terms_covered', unit: 'ratio', value: ratio, threshold: 0.5, thresholdSource: 'rule' }],
|
|
77
108
|
message: `Covered ${covered}/${expectedWords.size} expected terms (${(ratio * 100).toFixed(0)}%)`,
|
|
78
109
|
};
|
|
79
110
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EvalRule } from '../../types/eval.js';
|
|
2
2
|
export declare const costUnderThreshold: EvalRule;
|
|
3
3
|
export declare const tokenEfficiency: EvalRule;
|
|
4
4
|
/** Default for config key `max_tool_repeats`: how many identical calls are tolerated. */
|
package/dist/eval/rules/cost.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import { MAX_EVIDENCE_ITEMS } from '../../types/eval.js';
|
|
1
2
|
import { callKey, describeInput, skipWithoutTrajectory } from './trajectory.js';
|
|
2
3
|
export const costUnderThreshold = {
|
|
3
4
|
name: 'cost_under_threshold',
|
|
4
5
|
description: 'Total cost must be under a configurable USD threshold',
|
|
5
6
|
evalType: 'cost',
|
|
6
7
|
weight: 1,
|
|
8
|
+
kind: 'policy',
|
|
9
|
+
mechanism: 'formula',
|
|
10
|
+
needs: ['cost'],
|
|
11
|
+
question: 'within_budget',
|
|
12
|
+
classes: ['over_budget'],
|
|
13
|
+
version: 1,
|
|
7
14
|
evaluate(context) {
|
|
8
15
|
if (context.costUsd === undefined || context.costUsd === null) {
|
|
9
16
|
return { ruleName: 'cost_under_threshold', passed: false, score: 0, message: 'Cost data not provided', skipped: true, skipReason: 'context.costUsd not provided' };
|
|
@@ -15,6 +22,8 @@ export const costUnderThreshold = {
|
|
|
15
22
|
ruleName: 'cost_under_threshold',
|
|
16
23
|
passed,
|
|
17
24
|
score: passed ? 1 : Math.max(0, 1 - (cost - threshold) / threshold),
|
|
25
|
+
value: { stat: 'cost', unit: 'usd', value: cost },
|
|
26
|
+
evidence: [{ type: 'count', stat: 'cost', unit: 'usd', value: cost, threshold, thresholdSource: threshold === 0.10 ? 'default' : 'config' }],
|
|
18
27
|
message: passed
|
|
19
28
|
? `Cost ($${cost.toFixed(4)}) is under threshold ($${threshold.toFixed(4)})`
|
|
20
29
|
: `Cost ($${cost.toFixed(4)}) exceeds threshold ($${threshold.toFixed(4)})`,
|
|
@@ -26,6 +35,12 @@ export const tokenEfficiency = {
|
|
|
26
35
|
description: 'Checks output-to-input token ratio for efficiency',
|
|
27
36
|
evalType: 'cost',
|
|
28
37
|
weight: 0.5,
|
|
38
|
+
kind: 'measurement',
|
|
39
|
+
mechanism: 'formula',
|
|
40
|
+
needs: ['tokens'],
|
|
41
|
+
question: 'within_budget',
|
|
42
|
+
classes: ['over_budget'],
|
|
43
|
+
version: 1,
|
|
29
44
|
evaluate(context) {
|
|
30
45
|
const prompt = context.tokenUsage?.prompt_tokens;
|
|
31
46
|
const completion = context.tokenUsage?.completion_tokens;
|
|
@@ -39,6 +54,8 @@ export const tokenEfficiency = {
|
|
|
39
54
|
ruleName: 'token_efficiency',
|
|
40
55
|
passed,
|
|
41
56
|
score: passed ? 1 : Math.max(0, 1 - (ratio - maxRatio) / maxRatio),
|
|
57
|
+
value: { stat: 'completion_to_prompt_ratio', unit: 'ratio', value: ratio },
|
|
58
|
+
evidence: [{ type: 'count', stat: 'completion_to_prompt_ratio', unit: 'ratio', value: ratio, threshold: maxRatio, thresholdSource: maxRatio === 5 ? 'default' : 'config' }],
|
|
42
59
|
message: passed
|
|
43
60
|
? `Token ratio (${ratio.toFixed(2)}) is within limits (max ${maxRatio})`
|
|
44
61
|
: `Token ratio (${ratio.toFixed(2)}) exceeds max (${maxRatio})`,
|
|
@@ -91,6 +108,12 @@ export const noToolLoop = {
|
|
|
91
108
|
description: 'The agent must not repeat itself. Fails when one tool is called with an identical input (object keys sorted, whitespace collapsed) more than max_tool_repeats times — default 3, config key `max_tool_repeats` — or when two calls alternate for more than two complete A,B,A,B cycles. Skips when no tool calls are provided, so an evaluation with no trajectory reports "not judged" rather than clean. Catches the wasted spend a cost threshold cannot see: five identical calls can still bill under a per-evaluation cost limit',
|
|
92
109
|
evalType: 'cost',
|
|
93
110
|
weight: 1,
|
|
111
|
+
kind: 'detection',
|
|
112
|
+
mechanism: 'formula',
|
|
113
|
+
needs: ['tool_calls'],
|
|
114
|
+
question: 'tool_use_correct',
|
|
115
|
+
classes: ['tool_loop'],
|
|
116
|
+
version: 1,
|
|
94
117
|
evaluate(context) {
|
|
95
118
|
const skip = skipWithoutTrajectory('no_tool_loop', context);
|
|
96
119
|
if (skip)
|
|
@@ -112,12 +135,22 @@ export const noToolLoop = {
|
|
|
112
135
|
worstCount = count;
|
|
113
136
|
}
|
|
114
137
|
}
|
|
138
|
+
const repeatsEvidence = [
|
|
139
|
+
{ type: 'count', stat: 'max_repeats_of_one_call', unit: 'calls', value: worstCount, threshold: maxRepeats, thresholdSource: maxRepeats === DEFAULT_MAX_TOOL_REPEATS ? 'default' : 'config' },
|
|
140
|
+
];
|
|
115
141
|
if (worstCount > maxRepeats) {
|
|
116
142
|
const call = calls[keys.indexOf(worstKey)];
|
|
143
|
+
const evidence = [...repeatsEvidence];
|
|
144
|
+
keys.forEach((key, index) => {
|
|
145
|
+
if (key === worstKey && evidence.length < MAX_EVIDENCE_ITEMS)
|
|
146
|
+
evidence.push({ type: 'toolCall', index, toolName: calls[index].tool_name, label: 'repeated call' });
|
|
147
|
+
});
|
|
117
148
|
return {
|
|
118
149
|
ruleName: 'no_tool_loop',
|
|
119
150
|
passed: false,
|
|
120
151
|
score: Math.max(0, 1 - (worstCount - maxRepeats) * 0.25),
|
|
152
|
+
value: { stat: 'max_repeats_of_one_call', unit: 'calls', value: worstCount },
|
|
153
|
+
evidence,
|
|
121
154
|
message: `Tool loop: ${call.tool_name} called ${worstCount} times with the same input — ${describeInput(call.input)} — over ${calls.length} call${calls.length === 1 ? '' : 's'} (max ${maxRepeats})`,
|
|
122
155
|
};
|
|
123
156
|
}
|
|
@@ -125,10 +158,19 @@ export const noToolLoop = {
|
|
|
125
158
|
if (cycle !== null && cycle.cycles > MAX_TWO_CALL_CYCLES) {
|
|
126
159
|
const a = calls[keys.indexOf(cycle.a)];
|
|
127
160
|
const b = calls[keys.indexOf(cycle.b)];
|
|
161
|
+
const evidence = [
|
|
162
|
+
{ type: 'count', stat: 'two_call_cycles', unit: 'cycles', value: cycle.cycles, threshold: MAX_TWO_CALL_CYCLES, thresholdSource: 'rule' },
|
|
163
|
+
];
|
|
164
|
+
keys.forEach((key, index) => {
|
|
165
|
+
if ((key === cycle.a || key === cycle.b) && evidence.length < MAX_EVIDENCE_ITEMS)
|
|
166
|
+
evidence.push({ type: 'toolCall', index, toolName: calls[index].tool_name, label: 'alternating call' });
|
|
167
|
+
});
|
|
128
168
|
return {
|
|
129
169
|
ruleName: 'no_tool_loop',
|
|
130
170
|
passed: false,
|
|
131
171
|
score: Math.max(0, 1 - (cycle.cycles - MAX_TWO_CALL_CYCLES) * 0.25),
|
|
172
|
+
value: { stat: 'two_call_cycles', unit: 'cycles', value: cycle.cycles },
|
|
173
|
+
evidence,
|
|
132
174
|
message: `Tool loop: ${a.tool_name} (${describeInput(a.input)}) and ${b.tool_name} (${describeInput(b.input)}) alternate for ${cycle.cycles} cycles (max ${MAX_TWO_CALL_CYCLES})`,
|
|
133
175
|
};
|
|
134
176
|
}
|
|
@@ -137,6 +179,8 @@ export const noToolLoop = {
|
|
|
137
179
|
passed: true,
|
|
138
180
|
score: 1,
|
|
139
181
|
message: `No repeated tool call (${calls.length} call${calls.length === 1 ? '' : 's'}; most repeated ran ${worstCount}×, max ${maxRepeats})`,
|
|
182
|
+
value: { stat: 'max_repeats_of_one_call', unit: 'calls', value: worstCount },
|
|
183
|
+
evidence: repeatsEvidence,
|
|
140
184
|
};
|
|
141
185
|
},
|
|
142
186
|
};
|
|
@@ -15,16 +15,4 @@ export declare function normalizeRegexSource(patternStr: string, flags: string):
|
|
|
15
15
|
pattern: string;
|
|
16
16
|
flags: string;
|
|
17
17
|
};
|
|
18
|
-
/**
|
|
19
|
-
* Builds a runnable EvalRule from a persisted/inline definition.
|
|
20
|
-
*
|
|
21
|
-
* `severity` comes from the DEPLOYED rule's metadata (deploy_rule / the
|
|
22
|
-
* dashboard composer). high/critical severities make the rule CRITICAL:
|
|
23
|
-
* a failing evaluation forces the overall eval to passed=false regardless
|
|
24
|
-
* of the weighted score. Before this, a rule-author could deploy a
|
|
25
|
-
* severity="critical" policy rule, watch it FAIL on a violating output,
|
|
26
|
-
* and still get passed:true (score 0.895) — severity affected nothing but
|
|
27
|
-
* dashboard sorting. Inline custom_rules (evaluate_output's per-call
|
|
28
|
-
* definitions) carry no severity and stay weight-only.
|
|
29
|
-
*/
|
|
30
18
|
export declare function createCustomRule(definition: CustomRuleDefinition, severity?: RuleSeverity): EvalRule;
|
|
@@ -220,13 +220,34 @@ function runSandboxed(definition, pattern, flags, context) {
|
|
|
220
220
|
* dashboard sorting. Inline custom_rules (evaluate_output's per-call
|
|
221
221
|
* definitions) carry no severity and stay weight-only.
|
|
222
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* A custom rule is the author's own constraint, so its kind is `policy`
|
|
225
|
+
* whatever its mechanism; the mechanism and the inputs it reads follow the
|
|
226
|
+
* type. The question it answers is the author's and is not guessed here.
|
|
227
|
+
*/
|
|
228
|
+
const CUSTOM_TYPE_META = {
|
|
229
|
+
regex_match: { mechanism: 'pattern', needs: ['output'] },
|
|
230
|
+
regex_no_match: { mechanism: 'pattern', needs: ['output'] },
|
|
231
|
+
min_length: { mechanism: 'formula', needs: ['output'] },
|
|
232
|
+
max_length: { mechanism: 'formula', needs: ['output'] },
|
|
233
|
+
contains_keywords: { mechanism: 'pattern', needs: ['output'] },
|
|
234
|
+
excludes_keywords: { mechanism: 'pattern', needs: ['output'] },
|
|
235
|
+
json_schema: { mechanism: 'formula', needs: ['output'] },
|
|
236
|
+
cost_threshold: { mechanism: 'formula', needs: ['cost'] },
|
|
237
|
+
};
|
|
223
238
|
export function createCustomRule(definition, severity) {
|
|
239
|
+
const meta = CUSTOM_TYPE_META[definition.type];
|
|
224
240
|
return {
|
|
225
241
|
name: definition.name,
|
|
226
242
|
description: `Custom rule: ${definition.name}`,
|
|
227
243
|
evalType: 'custom',
|
|
228
244
|
weight: definition.weight ?? 1,
|
|
229
245
|
critical: severity === 'high' || severity === 'critical',
|
|
246
|
+
kind: 'policy',
|
|
247
|
+
mechanism: meta?.mechanism ?? 'formula',
|
|
248
|
+
needs: meta?.needs ?? ['output'],
|
|
249
|
+
classes: [],
|
|
250
|
+
version: 1,
|
|
230
251
|
evaluate(context) {
|
|
231
252
|
switch (definition.type) {
|
|
232
253
|
case 'regex_match': {
|
|
@@ -127,6 +127,12 @@ export const keywordOverlap = {
|
|
|
127
127
|
description: 'Recall of the input\'s content terms in the output: stopwords and request verbs are not terms, code identifiers and paths are split into their words, inflections are folded (purge/purged/purging). Passes when at least 35% of the input\'s terms appear in the output (configurable: keyword_overlap)',
|
|
128
128
|
evalType: 'relevance',
|
|
129
129
|
weight: 1,
|
|
130
|
+
kind: 'measurement',
|
|
131
|
+
mechanism: 'formula',
|
|
132
|
+
needs: ['output', 'input'],
|
|
133
|
+
question: 'relevant',
|
|
134
|
+
classes: ['off_task'],
|
|
135
|
+
version: 1,
|
|
130
136
|
evaluate(context) {
|
|
131
137
|
if (!context.input) {
|
|
132
138
|
return { ruleName: 'keyword_overlap', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
|
|
@@ -145,6 +151,8 @@ export const keywordOverlap = {
|
|
|
145
151
|
const threshold = context.customConfig?.keyword_overlap ?? 0.35;
|
|
146
152
|
const passed = ratio >= threshold;
|
|
147
153
|
return {
|
|
154
|
+
value: { stat: 'input_terms_in_output', unit: 'ratio', value: ratio },
|
|
155
|
+
evidence: [{ type: 'count', stat: 'input_terms_in_output', unit: 'ratio', value: ratio, threshold, thresholdSource: threshold === 0.35 ? 'default' : 'config' }],
|
|
148
156
|
ruleName: 'keyword_overlap',
|
|
149
157
|
passed,
|
|
150
158
|
score: Math.min(ratio * 2, 1),
|
|
@@ -177,6 +185,12 @@ export const topicConsistency = {
|
|
|
177
185
|
description: 'Continuity with the input: the share of the output\'s content-bearing sentences that connect to the input\'s topic — a sentence connects when it shares a content term with the input or with an earlier connected sentence (list items are read under the sentence that introduces them). Passes when at least a third connect (configurable: topic_consistency); a third, not half, so a short honest answer that elaborates in fresh words is not read as drift. Replaces the output-word-ratio measure that failed every grounded technical answer. Skipped when the output is too brief for meaningful comparison',
|
|
178
186
|
evalType: 'relevance',
|
|
179
187
|
weight: 1,
|
|
188
|
+
kind: 'measurement',
|
|
189
|
+
mechanism: 'formula',
|
|
190
|
+
needs: ['output', 'input'],
|
|
191
|
+
question: 'relevant',
|
|
192
|
+
classes: ['off_task'],
|
|
193
|
+
version: 1,
|
|
180
194
|
evaluate(context) {
|
|
181
195
|
if (!context.input) {
|
|
182
196
|
return { ruleName: 'topic_consistency', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
|
|
@@ -240,6 +254,8 @@ export const topicConsistency = {
|
|
|
240
254
|
passed,
|
|
241
255
|
// Full marks at two thirds connected; proportional below.
|
|
242
256
|
score: Math.min(ratio * 1.5, 1),
|
|
257
|
+
value: { stat: 'connected_sentences', unit: 'ratio', value: ratio },
|
|
258
|
+
evidence: [{ type: 'count', stat: 'connected_sentences', unit: 'ratio', value: ratio, threshold, thresholdSource: threshold === DEFAULT_TOPIC_THRESHOLD ? 'default' : 'config' }],
|
|
243
259
|
message: `Topic consistency: ${connected}/${sentences} content sentences connect to the input's topic (${(ratio * 100).toFixed(0)}%)`,
|
|
244
260
|
};
|
|
245
261
|
},
|