@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.
Files changed (114) hide show
  1. package/README.md +9 -2
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +2 -0
  5. package/dist/custom-rule-store.d.ts +4 -0
  6. package/dist/custom-rule-store.js +8 -3
  7. package/dist/dashboard/assets/{index-BfMShR3p.js → index-Cz8_oOqG.js} +1 -1
  8. package/dist/dashboard/index.html +1 -1
  9. package/dist/dashboard/routes/capabilities.d.ts +3 -0
  10. package/dist/dashboard/routes/capabilities.js +11 -0
  11. package/dist/dashboard/routes/health.d.ts +5 -1
  12. package/dist/dashboard/routes/health.js +15 -3
  13. package/dist/dashboard/routes/rules.js +4 -1
  14. package/dist/dashboard/routes/traces.d.ts +3 -0
  15. package/dist/dashboard/routes/traces.js +9 -28
  16. package/dist/dashboard/server.d.ts +2 -0
  17. package/dist/dashboard/server.js +6 -2
  18. package/dist/eval/accuracy.d.ts +41 -0
  19. package/dist/eval/accuracy.js +97 -0
  20. package/dist/eval/criticality.d.ts +8 -1
  21. package/dist/eval/criticality.js +6 -0
  22. package/dist/eval/dormant.d.ts +4 -0
  23. package/dist/eval/dormant.js +22 -0
  24. package/dist/eval/engine.d.ts +1 -0
  25. package/dist/eval/engine.js +49 -3
  26. package/dist/eval/failure-classes.d.ts +8 -0
  27. package/dist/eval/failure-classes.js +18 -0
  28. package/dist/eval/llm-judge/evaluator.d.ts +10 -0
  29. package/dist/eval/llm-judge/evaluator.js +16 -1
  30. package/dist/eval/published-accuracy.d.ts +230 -0
  31. package/dist/eval/published-accuracy.js +86 -0
  32. package/dist/eval/questions.d.ts +12 -0
  33. package/dist/eval/questions.js +14 -0
  34. package/dist/eval/response-schema.d.ts +652 -0
  35. package/dist/eval/response-schema.js +130 -0
  36. package/dist/eval/response.d.ts +12 -0
  37. package/dist/eval/response.js +30 -0
  38. package/dist/eval/rules/completeness.js +31 -0
  39. package/dist/eval/rules/cost.d.ts +1 -1
  40. package/dist/eval/rules/cost.js +44 -0
  41. package/dist/eval/rules/custom.d.ts +0 -12
  42. package/dist/eval/rules/custom.js +21 -0
  43. package/dist/eval/rules/relevance.js +16 -0
  44. package/dist/eval/rules/safety.js +178 -1
  45. package/dist/eval/stamp.d.ts +14 -0
  46. package/dist/eval/stamp.js +88 -0
  47. package/dist/eval/stats.d.ts +33 -0
  48. package/dist/eval/stats.js +109 -0
  49. package/dist/eval/verdict.d.ts +34 -0
  50. package/dist/eval/verdict.js +131 -0
  51. package/dist/index.js +5 -28
  52. package/dist/instructions.d.ts +17 -0
  53. package/dist/instructions.js +53 -0
  54. package/dist/judge-enablement.d.ts +34 -0
  55. package/dist/judge-enablement.js +78 -0
  56. package/dist/judge-enablement.json +10 -0
  57. package/dist/preferences.d.ts +1 -1
  58. package/dist/prompts.d.ts +3 -0
  59. package/dist/prompts.js +29 -0
  60. package/dist/resources/index.d.ts +5 -2
  61. package/dist/resources/index.js +65 -5
  62. package/dist/resources/uris.d.ts +12 -0
  63. package/dist/resources/uris.js +24 -0
  64. package/dist/retention.d.ts +20 -0
  65. package/dist/retention.js +44 -0
  66. package/dist/self-test.d.ts +1 -0
  67. package/dist/self-test.js +14 -0
  68. package/dist/server.d.ts +10 -1
  69. package/dist/server.js +34 -7
  70. package/dist/storage/index.js +1 -1
  71. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  72. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  73. package/dist/storage/migrations/index.js +24 -4
  74. package/dist/storage/sqlite-adapter.d.ts +26 -1
  75. package/dist/storage/sqlite-adapter.js +135 -15
  76. package/dist/tools/delete-rule.d.ts +8 -0
  77. package/dist/tools/delete-rule.js +30 -38
  78. package/dist/tools/delete-trace.d.ts +5 -0
  79. package/dist/tools/delete-trace.js +24 -27
  80. package/dist/tools/deploy-rule.d.ts +13 -1
  81. package/dist/tools/deploy-rule.js +37 -34
  82. package/dist/tools/describe.d.ts +20 -0
  83. package/dist/tools/describe.js +36 -0
  84. package/dist/tools/errors.d.ts +36 -0
  85. package/dist/tools/errors.js +134 -0
  86. package/dist/tools/evaluate-output.d.ts +8 -1
  87. package/dist/tools/evaluate-output.js +37 -58
  88. package/dist/tools/evaluate-with-llm-judge.d.ts +31 -0
  89. package/dist/tools/evaluate-with-llm-judge.js +96 -69
  90. package/dist/tools/get-traces.d.ts +9 -0
  91. package/dist/tools/get-traces.js +30 -29
  92. package/dist/tools/index.d.ts +8 -0
  93. package/dist/tools/index.js +22 -1
  94. package/dist/tools/list-rules.d.ts +13 -0
  95. package/dist/tools/list-rules.js +43 -46
  96. package/dist/tools/log-trace.d.ts +4 -0
  97. package/dist/tools/log-trace.js +31 -29
  98. package/dist/tools/respond.d.ts +42 -0
  99. package/dist/tools/respond.js +90 -0
  100. package/dist/tools/strict-input.js +1 -1
  101. package/dist/tools/trace-link.d.ts +2 -0
  102. package/dist/tools/trace-link.js +13 -2
  103. package/dist/tools/verify-citations.d.ts +17 -2
  104. package/dist/tools/verify-citations.js +98 -93
  105. package/dist/types/config.d.ts +9 -0
  106. package/dist/types/eval.d.ts +258 -0
  107. package/dist/types/eval.js +2 -1
  108. package/dist/types/query.d.ts +2 -0
  109. package/package.json +1 -1
  110. package/server.json +72 -2
  111. package/dist/resources/dashboard-summary.d.ts +0 -3
  112. package/dist/resources/dashboard-summary.js +0 -16
  113. package/dist/resources/trace-detail.d.ts +0 -3
  114. 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 { EvalRule } from '../../types/eval.js';
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. */
@@ -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
  },