@iris-eval/mcp-server 0.8.2 → 0.10.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 +17 -0
- package/dist/custom-rule-store.d.ts +4 -0
- package/dist/custom-rule-store.js +8 -3
- package/dist/dashboard/assets/{index-CyzO6OC7.js → index-CeJbaq6m.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 +11 -30
- package/dist/dashboard/seed-demo-data.js +1 -1
- 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/citation-verify/verifier.d.ts +16 -1
- package/dist/eval/citation-verify/verifier.js +14 -4
- package/dist/eval/compose.d.ts +57 -0
- package/dist/eval/compose.js +179 -0
- package/dist/eval/criticality.d.ts +15 -1
- package/dist/eval/criticality.js +6 -0
- package/dist/eval/decision-moment.js +33 -4
- package/dist/eval/dormant.d.ts +4 -0
- package/dist/eval/dormant.js +22 -0
- package/dist/eval/engine.d.ts +6 -2
- package/dist/eval/engine.js +126 -12
- 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 +30 -0
- package/dist/eval/llm-judge/evaluator.js +26 -2
- 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/risk.d.ts +60 -0
- package/dist/eval/risk.js +187 -0
- package/dist/eval/rules/completeness.js +36 -1
- package/dist/eval/rules/cost.d.ts +2 -2
- package/dist/eval/rules/cost.js +50 -6
- package/dist/eval/rules/custom.d.ts +0 -12
- package/dist/eval/rules/custom.js +22 -0
- package/dist/eval/rules/relevance.js +23 -2
- package/dist/eval/rules/safety.d.ts +6 -2
- package/dist/eval/rules/safety.js +224 -51
- package/dist/eval/seeded-random.d.ts +4 -0
- package/dist/eval/seeded-random.js +36 -0
- package/dist/eval/stamp.d.ts +14 -0
- package/dist/eval/stamp.js +89 -0
- package/dist/eval/stats.d.ts +33 -0
- package/dist/eval/stats.js +109 -0
- package/dist/eval/text/checksums.d.ts +23 -0
- package/dist/eval/text/checksums.js +97 -0
- package/dist/eval/text/normalise.d.ts +30 -0
- package/dist/eval/text/normalise.js +265 -0
- package/dist/eval/text/sentences.d.ts +15 -0
- package/dist/eval/text/sentences.js +149 -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 +17 -3
- 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 +149 -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 +39 -60
- package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
- package/dist/tools/evaluate-with-llm-judge.js +124 -69
- package/dist/tools/get-traces.d.ts +9 -0
- package/dist/tools/get-traces.js +29 -28
- 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 +18 -2
- package/dist/tools/verify-citations.js +122 -96
- package/dist/types/config.d.ts +44 -0
- package/dist/types/eval.d.ts +309 -0
- package/dist/types/eval.js +2 -1
- package/dist/types/query.d.ts +2 -0
- package/package.json +1 -1
- package/server.json +2 -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { EvalResult, FailureClass } from '../types/eval.js';
|
|
2
|
+
export declare const RISK_DRAWS = 2000;
|
|
3
|
+
export declare const DEFAULT_PRIOR = 0.5;
|
|
4
|
+
/**
|
|
5
|
+
* What the prior means (arc 2 finding, for arc 3's deliberation):
|
|
6
|
+
* 'per-class' — plan §4.3 as written: π is the prior that EACH examined
|
|
7
|
+
* class is present. With K classes examined the prior that
|
|
8
|
+
* nothing is wrong is (1 − π)^K — at π = 0.5 and K = 10 it
|
|
9
|
+
* is 0.001, so the noisy-OR blocks nearly everything.
|
|
10
|
+
* 'per-output' — π is the prior that the OUTPUT is bad (any class
|
|
11
|
+
* present); it is spread over the K examined classes as
|
|
12
|
+
* π_c = 1 − (1 − π)^(1/K), so that Π(1 − π_c) = 1 − π.
|
|
13
|
+
* Both are measured on the composite corpus so the choice is made on a
|
|
14
|
+
* number, not a preference.
|
|
15
|
+
*/
|
|
16
|
+
export type PriorMode = 'per-class' | 'per-output';
|
|
17
|
+
export declare const DEFAULT_PRIOR_MODE: PriorMode;
|
|
18
|
+
/** τ = 1 / (1 + c) with c = 1: a false pass costs the same as a false block. */
|
|
19
|
+
export declare const DEFAULT_FALSE_PASS_COST = 1;
|
|
20
|
+
export declare const DEFAULT_TAU: number;
|
|
21
|
+
export interface RiskEstimate {
|
|
22
|
+
pBad: number;
|
|
23
|
+
lo: number;
|
|
24
|
+
hi: number;
|
|
25
|
+
perClass: Record<string, number | null>;
|
|
26
|
+
assumptions: string[];
|
|
27
|
+
}
|
|
28
|
+
export type RiskBasis = 'policy_gate' | 'detector_veto' | 'risk_over_loss' | 'clean' | 'no_rules';
|
|
29
|
+
export interface RiskVerdict {
|
|
30
|
+
state: 'pass' | 'fail' | 'unknown';
|
|
31
|
+
basis: RiskBasis;
|
|
32
|
+
by: string[];
|
|
33
|
+
risk: RiskEstimate | null;
|
|
34
|
+
confidence: 'decisive' | 'marginal' | null;
|
|
35
|
+
}
|
|
36
|
+
interface Detector {
|
|
37
|
+
name: string;
|
|
38
|
+
classes: FailureClass[];
|
|
39
|
+
fired: boolean;
|
|
40
|
+
counts: {
|
|
41
|
+
tp: number;
|
|
42
|
+
fp: number;
|
|
43
|
+
fn: number;
|
|
44
|
+
tn: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The evaluated detections and inferences with a published family, one entry per rule. */
|
|
48
|
+
export declare function detectorsOf(result: EvalResult): Detector[];
|
|
49
|
+
/** The per-class prior under a mode, given how many classes the detectors examine. */
|
|
50
|
+
export declare function classPrior(prior: number, mode: PriorMode, examinedClasses: number): number;
|
|
51
|
+
/** p_bad with a 95% credible interval from the Beta posteriors of every detector's sensitivity and specificity. */
|
|
52
|
+
export declare function riskEstimate(result: EvalResult, prior?: number, mode?: PriorMode): RiskEstimate | null;
|
|
53
|
+
/**
|
|
54
|
+
* Compose by kind, as arc 3 will: gates (a failing policy that is effectively
|
|
55
|
+
* critical here), then vetoes (a failing effectively-critical detection),
|
|
56
|
+
* then the risk against τ. `unknown` when a critical rule was asked and could
|
|
57
|
+
* not answer (defeated or config_invalid) — the fail-closed seam.
|
|
58
|
+
*/
|
|
59
|
+
export declare function riskVerdict(result: EvalResult, tau?: number, prior?: number, mode?: PriorMode): RiskVerdict;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { publishedAccuracyFor } from './accuracy.js';
|
|
2
|
+
import { PUBLISHED_ACCURACY_CORPUS_VERSION } from './published-accuracy.js';
|
|
3
|
+
import { FAILURE_CLASS_IDS } from './failure-classes.js';
|
|
4
|
+
import { fnv1a, mulberry32 } from './seeded-random.js';
|
|
5
|
+
/** Jeffreys prior: half a count on each cell, so a family that made no mistakes does not claim certainty. */
|
|
6
|
+
const HALF = 0.5;
|
|
7
|
+
const sensOf = (d) => (d.counts.tp + HALF) / (d.counts.tp + d.counts.fn + 2 * HALF);
|
|
8
|
+
const specOf = (d) => (d.counts.tn + HALF) / (d.counts.tn + d.counts.fp + 2 * HALF);
|
|
9
|
+
export const RISK_DRAWS = 2000;
|
|
10
|
+
export const DEFAULT_PRIOR = 0.5;
|
|
11
|
+
export const DEFAULT_PRIOR_MODE = 'per-output';
|
|
12
|
+
/** τ = 1 / (1 + c) with c = 1: a false pass costs the same as a false block. */
|
|
13
|
+
export const DEFAULT_FALSE_PASS_COST = 1;
|
|
14
|
+
export const DEFAULT_TAU = 1 / (1 + DEFAULT_FALSE_PASS_COST);
|
|
15
|
+
function beta(a, b, rng) {
|
|
16
|
+
// Marsaglia–Tsang gamma with the shape < 1 boost; beta = ga / (ga + gb).
|
|
17
|
+
const gamma = (shape) => {
|
|
18
|
+
if (shape < 1)
|
|
19
|
+
return gamma(shape + 1) * Math.pow(rng(), 1 / shape);
|
|
20
|
+
const d = shape - 1 / 3;
|
|
21
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
22
|
+
for (;;) {
|
|
23
|
+
let x;
|
|
24
|
+
let v;
|
|
25
|
+
do {
|
|
26
|
+
const u1 = rng();
|
|
27
|
+
const u2 = rng();
|
|
28
|
+
x = Math.sqrt(-2 * Math.log(u1 || 1e-12)) * Math.cos(2 * Math.PI * u2);
|
|
29
|
+
v = 1 + c * x;
|
|
30
|
+
} while (v <= 0);
|
|
31
|
+
v = v * v * v;
|
|
32
|
+
const u = rng();
|
|
33
|
+
if (u < 1 - 0.0331 * x * x * x * x)
|
|
34
|
+
return d * v;
|
|
35
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
|
|
36
|
+
return d * v;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const ga = gamma(a);
|
|
40
|
+
const gb = gamma(b);
|
|
41
|
+
return ga / (ga + gb);
|
|
42
|
+
}
|
|
43
|
+
/** The evaluated detections and inferences with a published family, one entry per rule. */
|
|
44
|
+
export function detectorsOf(result) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const r of result.rule_results) {
|
|
47
|
+
if (r.skipped)
|
|
48
|
+
continue;
|
|
49
|
+
if (r.kind !== 'detection' && r.kind !== 'inference')
|
|
50
|
+
continue;
|
|
51
|
+
const acc = publishedAccuracyFor(r.ruleName);
|
|
52
|
+
if (!acc)
|
|
53
|
+
continue;
|
|
54
|
+
out.push({
|
|
55
|
+
name: r.ruleName,
|
|
56
|
+
classes: (r.classes ?? []),
|
|
57
|
+
fired: r.passed === false,
|
|
58
|
+
counts: { tp: acc.tp, fp: acc.fp, fn: acc.fn, tn: acc.tn },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** The per-class prior under a mode, given how many classes the detectors examine. */
|
|
64
|
+
export function classPrior(prior, mode, examinedClasses) {
|
|
65
|
+
if (mode === 'per-class' || examinedClasses <= 1)
|
|
66
|
+
return prior;
|
|
67
|
+
return 1 - Math.pow(1 - prior, 1 / examinedClasses);
|
|
68
|
+
}
|
|
69
|
+
function pBadFrom(detectors, prior, mode, sensOf, specOf) {
|
|
70
|
+
const perClass = {};
|
|
71
|
+
let survive = 1;
|
|
72
|
+
const examinedClasses = FAILURE_CLASS_IDS.filter((cls) => detectors.some((d) => d.classes.includes(cls))).length;
|
|
73
|
+
const priorC = classPrior(prior, mode, examinedClasses);
|
|
74
|
+
for (const cls of FAILURE_CLASS_IDS) {
|
|
75
|
+
const examined = detectors.filter((d) => d.classes.includes(cls));
|
|
76
|
+
if (examined.length === 0) {
|
|
77
|
+
perClass[cls] = null;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const fired = examined.filter((d) => d.fired);
|
|
81
|
+
let q;
|
|
82
|
+
if (fired.length > 0) {
|
|
83
|
+
q = Math.max(...fired.map((d) => {
|
|
84
|
+
const s = sensOf(d);
|
|
85
|
+
const p = specOf(d);
|
|
86
|
+
const den = s * priorC + (1 - p) * (1 - priorC);
|
|
87
|
+
return den === 0 ? 0 : (s * priorC) / den;
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
let missAll = 1;
|
|
92
|
+
let specAll = 1;
|
|
93
|
+
for (const d of examined) {
|
|
94
|
+
missAll *= 1 - sensOf(d);
|
|
95
|
+
specAll *= specOf(d);
|
|
96
|
+
}
|
|
97
|
+
const den = priorC * missAll + (1 - priorC) * specAll;
|
|
98
|
+
q = den === 0 ? 0 : (priorC * missAll) / den;
|
|
99
|
+
}
|
|
100
|
+
perClass[cls] = q;
|
|
101
|
+
survive *= 1 - q;
|
|
102
|
+
}
|
|
103
|
+
return { pBad: 1 - survive, perClass };
|
|
104
|
+
}
|
|
105
|
+
const round4 = (x) => Math.round(x * 10_000) / 10_000;
|
|
106
|
+
/** p_bad with a 95% credible interval from the Beta posteriors of every detector's sensitivity and specificity. */
|
|
107
|
+
export function riskEstimate(result, prior = DEFAULT_PRIOR, mode = DEFAULT_PRIOR_MODE) {
|
|
108
|
+
const detectors = detectorsOf(result);
|
|
109
|
+
if (detectors.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
/*
|
|
112
|
+
* Jeffreys half-counts in the POINT estimate, not only in the draws.
|
|
113
|
+
*
|
|
114
|
+
* Twelve of the fifteen published families recorded zero false positives.
|
|
115
|
+
* With specificity exactly 1 the positive predictive value of a fire is
|
|
116
|
+
* exactly 1 at every prior, so p_bad read exactly 1.000 on 31 of the 111
|
|
117
|
+
* composite cases — the same overconfidence the credible interval was
|
|
118
|
+
* added to cure, reintroduced one layer down. A half-count on each cell
|
|
119
|
+
* says what thirty cases can actually support: no_silent_tool_failure's
|
|
120
|
+
* single-fire contribution moves from 1.000 to about 0.69, still well
|
|
121
|
+
* over the shipped threshold, and now with an interval that means
|
|
122
|
+
* something. Found by arc 2 while writing up the composer, not by
|
|
123
|
+
* reading it.
|
|
124
|
+
*/
|
|
125
|
+
const point = pBadFrom(detectors, prior, mode, sensOf, specOf);
|
|
126
|
+
const rng = mulberry32(fnv1a(`risk:${PUBLISHED_ACCURACY_CORPUS_VERSION}:${mode}:${prior.toFixed(3)}:${detectors.map((d) => `${d.name}${d.fired ? '!' : ''}`).join(',')}`));
|
|
127
|
+
const draws = [];
|
|
128
|
+
for (let i = 0; i < RISK_DRAWS; i++) {
|
|
129
|
+
const sens = new Map();
|
|
130
|
+
const spec = new Map();
|
|
131
|
+
for (const d of detectors) {
|
|
132
|
+
sens.set(d.name, beta(d.counts.tp + 0.5, d.counts.fn + 0.5, rng));
|
|
133
|
+
spec.set(d.name, beta(d.counts.tn + 0.5, d.counts.fp + 0.5, rng));
|
|
134
|
+
}
|
|
135
|
+
draws.push(pBadFrom(detectors, prior, mode, (d) => sens.get(d.name), (d) => spec.get(d.name)).pBad);
|
|
136
|
+
}
|
|
137
|
+
draws.sort((a, b) => a - b);
|
|
138
|
+
const at = (q) => draws[Math.min(draws.length - 1, Math.max(0, Math.ceil(q * draws.length) - 1))];
|
|
139
|
+
// The point uses the observed rates; a rate at exactly 1 (no false positives
|
|
140
|
+
// in the family) puts the point above every posterior draw, so the interval
|
|
141
|
+
// is widened to contain it — an interval that excludes its own point is a
|
|
142
|
+
// presentation error, not a finding.
|
|
143
|
+
return {
|
|
144
|
+
pBad: round4(point.pBad),
|
|
145
|
+
lo: round4(Math.min(at(0.025), point.pBad)),
|
|
146
|
+
hi: round4(Math.max(at(0.975), point.pBad)),
|
|
147
|
+
perClass: Object.fromEntries(Object.entries(point.perClass).map(([k, v]) => [k, v === null ? null : round4(v)])),
|
|
148
|
+
assumptions: [
|
|
149
|
+
'detectors independent across classes',
|
|
150
|
+
'published accuracy is in-sample, same-model labelled',
|
|
151
|
+
'sensitivity and specificity carry a half-count prior, so a family with no observed errors does not read as certain',
|
|
152
|
+
`prior ${prior}, spread ${mode}`,
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const isEffectivelyCritical = (r) => r.critical === true;
|
|
157
|
+
/**
|
|
158
|
+
* Compose by kind, as arc 3 will: gates (a failing policy that is effectively
|
|
159
|
+
* critical here), then vetoes (a failing effectively-critical detection),
|
|
160
|
+
* then the risk against τ. `unknown` when a critical rule was asked and could
|
|
161
|
+
* not answer (defeated or config_invalid) — the fail-closed seam.
|
|
162
|
+
*/
|
|
163
|
+
export function riskVerdict(result, tau = DEFAULT_TAU, prior = DEFAULT_PRIOR, mode = DEFAULT_PRIOR_MODE) {
|
|
164
|
+
const rows = result.rule_results;
|
|
165
|
+
const gates = rows.filter((r) => r.kind === 'policy' && !r.skipped && r.passed === false && isEffectivelyCritical(r));
|
|
166
|
+
if (gates.length > 0)
|
|
167
|
+
return { state: 'fail', basis: 'policy_gate', by: gates.map((r) => r.ruleName), risk: riskEstimate(result, prior, mode), confidence: null };
|
|
168
|
+
const vetoes = rows.filter((r) => (r.kind === 'detection' || r.kind === 'inference') && !r.skipped && r.passed === false && isEffectivelyCritical(r));
|
|
169
|
+
if (vetoes.length > 0)
|
|
170
|
+
return { state: 'fail', basis: 'detector_veto', by: vetoes.map((r) => r.ruleName), risk: riskEstimate(result, prior, mode), confidence: null };
|
|
171
|
+
const unknown = rows.filter((r) => isEffectivelyCritical(r) && r.skipped && r.skipClass && r.skipClass !== 'not_applicable');
|
|
172
|
+
if (unknown.length > 0)
|
|
173
|
+
return { state: 'unknown', basis: 'clean', by: unknown.map((r) => r.ruleName), risk: null, confidence: null };
|
|
174
|
+
const risk = riskEstimate(result, prior, mode);
|
|
175
|
+
if (!risk) {
|
|
176
|
+
const judged = rows.some((r) => !r.skipped);
|
|
177
|
+
return { state: judged ? 'pass' : 'unknown', basis: judged ? 'clean' : 'no_rules', by: [], risk: null, confidence: null };
|
|
178
|
+
}
|
|
179
|
+
const confidence = risk.lo <= tau && tau <= risk.hi ? 'marginal' : 'decisive';
|
|
180
|
+
if (risk.pBad > tau) {
|
|
181
|
+
const by = Object.entries(risk.perClass)
|
|
182
|
+
.filter(([, q]) => q !== null && q > 0.5)
|
|
183
|
+
.map(([cls]) => cls);
|
|
184
|
+
return { state: 'fail', basis: 'risk_over_loss', by, risk, confidence };
|
|
185
|
+
}
|
|
186
|
+
return { state: 'pass', basis: 'clean', by: [], risk, confidence };
|
|
187
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { countSentences } from '../text/sentences.js';
|
|
1
2
|
export const minOutputLength = {
|
|
2
3
|
name: 'min_output_length',
|
|
3
4
|
description: 'Output must meet a minimum character length',
|
|
4
5
|
evalType: 'completeness',
|
|
5
6
|
weight: 1,
|
|
7
|
+
kind: 'measurement',
|
|
8
|
+
mechanism: 'formula',
|
|
9
|
+
needs: ['output'],
|
|
10
|
+
question: 'complete',
|
|
11
|
+
classes: ['format'],
|
|
12
|
+
version: 1,
|
|
6
13
|
evaluate(context) {
|
|
7
14
|
const minLen = context.customConfig?.min_output_length
|
|
8
15
|
?? context.customConfig?.min_length
|
|
@@ -13,6 +20,8 @@ export const minOutputLength = {
|
|
|
13
20
|
ruleName: 'min_output_length',
|
|
14
21
|
passed,
|
|
15
22
|
score: passed ? 1 : Math.min(len / minLen, 0.99),
|
|
23
|
+
value: { stat: 'length', unit: 'chars', value: len },
|
|
24
|
+
evidence: [{ type: 'count', stat: 'length', unit: 'chars', value: len, threshold: minLen, thresholdSource: minLen === 50 ? 'default' : 'config' }],
|
|
16
25
|
message: passed ? `Output length (${len}) meets minimum (${minLen})` : `Output length (${len}) below minimum (${minLen})`,
|
|
17
26
|
};
|
|
18
27
|
},
|
|
@@ -22,12 +31,19 @@ export const nonEmptyOutput = {
|
|
|
22
31
|
description: 'Output must not be empty or whitespace-only',
|
|
23
32
|
evalType: 'completeness',
|
|
24
33
|
weight: 2,
|
|
34
|
+
kind: 'policy',
|
|
35
|
+
mechanism: 'formula',
|
|
36
|
+
needs: ['output'],
|
|
37
|
+
question: 'complete',
|
|
38
|
+
classes: ['format'],
|
|
39
|
+
version: 1,
|
|
25
40
|
evaluate(context) {
|
|
26
41
|
const passed = context.output.trim().length > 0;
|
|
27
42
|
return {
|
|
28
43
|
ruleName: 'non_empty_output',
|
|
29
44
|
passed,
|
|
30
45
|
score: passed ? 1 : 0,
|
|
46
|
+
value: { stat: 'length', unit: 'chars', value: context.output.trim().length },
|
|
31
47
|
message: passed ? 'Output is non-empty' : 'Output is empty or whitespace-only',
|
|
32
48
|
};
|
|
33
49
|
},
|
|
@@ -37,14 +53,25 @@ export const sentenceCount = {
|
|
|
37
53
|
description: 'Output must contain a minimum number of sentences',
|
|
38
54
|
evalType: 'completeness',
|
|
39
55
|
weight: 0.5,
|
|
56
|
+
kind: 'measurement',
|
|
57
|
+
mechanism: 'formula',
|
|
58
|
+
needs: ['output'],
|
|
59
|
+
question: 'complete',
|
|
60
|
+
classes: ['format'],
|
|
61
|
+
version: 1,
|
|
40
62
|
evaluate(context) {
|
|
41
63
|
const minSentences = context.customConfig?.min_sentences ?? 2;
|
|
42
|
-
|
|
64
|
+
// One splitter, shared with topic_consistency (src/eval/text/sentences.ts).
|
|
65
|
+
// Splitting on /[.!?]+/ counted "The latency is 3.5 seconds." as two
|
|
66
|
+
// sentences and "Dr. Chen approved it." as two more.
|
|
67
|
+
const sentences = countSentences(context.output);
|
|
43
68
|
const passed = sentences >= minSentences;
|
|
44
69
|
return {
|
|
45
70
|
ruleName: 'sentence_count',
|
|
46
71
|
passed,
|
|
47
72
|
score: passed ? 1 : Math.min(sentences / minSentences, 0.99),
|
|
73
|
+
value: { stat: 'sentences', unit: 'sentences', value: sentences },
|
|
74
|
+
evidence: [{ type: 'count', stat: 'sentences', unit: 'sentences', value: sentences, threshold: minSentences, thresholdSource: minSentences === 2 ? 'default' : 'config' }],
|
|
48
75
|
message: passed ? `Sentence count (${sentences}) meets minimum (${minSentences})` : `Sentence count (${sentences}) below minimum (${minSentences})`,
|
|
49
76
|
};
|
|
50
77
|
},
|
|
@@ -54,6 +81,12 @@ export const expectedCoverage = {
|
|
|
54
81
|
description: 'Output must cover key terms from expected output',
|
|
55
82
|
evalType: 'completeness',
|
|
56
83
|
weight: 1.5,
|
|
84
|
+
kind: 'measurement',
|
|
85
|
+
mechanism: 'formula',
|
|
86
|
+
needs: ['output', 'expected'],
|
|
87
|
+
question: 'complete',
|
|
88
|
+
classes: ['incomplete_ask'],
|
|
89
|
+
version: 1,
|
|
57
90
|
evaluate(context) {
|
|
58
91
|
if (!context.expected) {
|
|
59
92
|
return { ruleName: 'expected_coverage', passed: false, score: 0, message: 'No expected output provided', skipped: true, skipReason: 'context.expected not provided' };
|
|
@@ -74,6 +107,8 @@ export const expectedCoverage = {
|
|
|
74
107
|
ruleName: 'expected_coverage',
|
|
75
108
|
passed,
|
|
76
109
|
score: ratio,
|
|
110
|
+
value: { stat: 'expected_terms_covered', unit: 'ratio', value: ratio },
|
|
111
|
+
evidence: [{ type: 'count', stat: 'expected_terms_covered', unit: 'ratio', value: ratio, threshold: 0.5, thresholdSource: 'rule' }],
|
|
77
112
|
message: `Covered ${covered}/${expectedWords.size} expected terms (${(ratio * 100).toFixed(0)}%)`,
|
|
78
113
|
};
|
|
79
114
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EvalRule } from '../../types/eval.js';
|
|
2
2
|
export declare const costUnderThreshold: EvalRule;
|
|
3
|
-
export declare const
|
|
3
|
+
export declare const verbosityRatio: EvalRule;
|
|
4
4
|
/** Default for config key `max_tool_repeats`: how many identical calls are tolerated. */
|
|
5
5
|
export declare const DEFAULT_MAX_TOOL_REPEATS = 3;
|
|
6
6
|
/**
|
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,30 +22,40 @@ 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)})`,
|
|
21
30
|
};
|
|
22
31
|
},
|
|
23
32
|
};
|
|
24
|
-
export const
|
|
25
|
-
name: '
|
|
26
|
-
description: '
|
|
33
|
+
export const verbosityRatio = {
|
|
34
|
+
name: 'verbosity_ratio',
|
|
35
|
+
description: 'The completion-to-prompt token ratio against a ceiling: completion_tokens / prompt_tokens must not exceed max_token_ratio (default 5). This measures output VERBOSITY relative to prompt size, not efficiency — a long answer to a long prompt passes and a long answer to a short prompt fails, and neither says whether the tokens were well spent. Skipped when token usage is not supplied. Renamed from token_efficiency in 0.10.0, because the old name named something the rule does not measure',
|
|
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;
|
|
32
47
|
if (prompt === undefined || completion === undefined || prompt === 0) {
|
|
33
|
-
return { ruleName: '
|
|
48
|
+
return { ruleName: 'verbosity_ratio', passed: false, score: 0, message: 'Token usage not provided', skipped: true, skipReason: 'context.tokenUsage not provided' };
|
|
34
49
|
}
|
|
35
50
|
const ratio = completion / prompt;
|
|
36
51
|
const maxRatio = context.customConfig?.max_token_ratio ?? 5;
|
|
37
52
|
const passed = ratio <= maxRatio;
|
|
38
53
|
return {
|
|
39
|
-
ruleName: '
|
|
54
|
+
ruleName: 'verbosity_ratio',
|
|
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,7 +179,9 @@ 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
|
};
|
|
143
|
-
export const costRules = [costUnderThreshold,
|
|
187
|
+
export const costRules = [costUnderThreshold, verbosityRatio, noToolLoop];
|