Skip to content

Commit 02e8d1b

Browse files
committed
fix(skill): clean up evaluator docs + capture per-task grader failures
Three loose ends from the perf review on 2b94014: 1. Module docstring "Flow" section read sequentially despite steps 3-4 now running concurrently via asyncio.gather. Rewrote to describe the concurrent dispatch + the EVAL_CONCURRENCY semaphore bound. 2. EVAL_CONCURRENCY comment claimed "~4x the sequential baseline" — but the semaphore is a sliding window across the combined trigger + coverage pool, so realistic speedup ranges from 30/8 (~3.75x) to 8x depending on per-call latency variance. Replaced the misleading claim with the actual semantics. 3. asyncio.gather had no return_exceptions=True, so a single grader raising RuntimeError (e.g. MaxTurnsExceeded wrap) aborted the whole eval and discarded the other ~29 successful gradings. Added per-task exception capture: failed prompts now go onto trigger_errors / coverage_errors on EvalResult, are surfaced as a [WARN] block in `openkb skill eval`, and are excluded from the rate denominators (we don't know what the verdict would have been). Added trigger_scored property and reworked passed / pass_rate to match. Plus one regression test covering: one trigger grader and one coverage grader both raise on different prompts; the other prompts grade normally and end up in their expected slots. 464 tests passing.
1 parent b5c2512 commit 02e8d1b

3 files changed

Lines changed: 125 additions & 22 deletions

File tree

openkb/cli.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,13 +1790,17 @@ def skill_eval(ctx, name, save_flag, eval_set_path, count):
17901790

17911791
click.echo(f"\nEval set: {result.total} prompts")
17921792
click.echo(
1793-
f"Trigger accuracy: {result.passed}/{result.total} "
1793+
f"Trigger accuracy: {result.passed}/{result.trigger_scored} "
17941794
f"({result.pass_rate * 100:.0f}%) "
17951795
f"— does the description fire on the right questions?"
17961796
)
1797-
scored = result.trigger_questions - len(result.coverage_ambiguous)
1797+
coverage_scored = (
1798+
result.trigger_questions
1799+
- len(result.coverage_ambiguous)
1800+
- len(result.coverage_errors)
1801+
)
17981802
click.echo(
1799-
f"Body coverage: {result.coverage_passed}/{scored} "
1803+
f"Body coverage: {result.coverage_passed}/{coverage_scored} "
18001804
f"({result.coverage_rate * 100:.0f}%) "
18011805
f"— does SKILL.md actually support what the description promises?"
18021806
)
@@ -1822,10 +1826,23 @@ def skill_eval(ctx, name, save_flag, eval_set_path, count):
18221826
tail = f" — {amb.reason}" if amb.reason else ""
18231827
click.echo(f" - {amb.prompt.question}{tail}")
18241828

1829+
if result.trigger_errors or result.coverage_errors:
1830+
click.echo(
1831+
f"\n[WARN] {len(result.trigger_errors)} trigger and "
1832+
f"{len(result.coverage_errors)} coverage grader call(s) "
1833+
f"failed and are excluded from the scores above:"
1834+
)
1835+
for err in result.trigger_errors:
1836+
click.echo(f" - trigger: {err.prompt.question}{err.reason}")
1837+
for err in result.coverage_errors:
1838+
click.echo(f" - coverage: {err.prompt.question}{err.reason}")
1839+
18251840
if (
18261841
not result.misses
18271842
and not result.coverage_misses
18281843
and not result.coverage_ambiguous
1844+
and not result.trigger_errors
1845+
and not result.coverage_errors
18291846
):
18301847
click.echo("\nAll prompts graded correctly with full body support.")
18311848

openkb/skill/evaluator.py

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@
1818
2. Generator LLM produces N should-trigger + N should-not prompts,
1919
using description AND body so prompts reflect what the skill
2020
actually claims to cover, not just description vibes.
21-
3. Trigger grader sees ONLY the description and answers
22-
trigger / no-trigger for each prompt — same as before.
23-
4. Alignment grader sees the SKILL.md body+references and each
24-
should-trigger prompt; answers "supported / unsupported" — does the
25-
skill have the substance to handle this question?
21+
3. Trigger grader (description-only, runs on every prompt) and
22+
alignment grader (body + references, runs on should-trigger
23+
prompts only) are dispatched concurrently via ``asyncio.gather``,
24+
bounded by ``EVAL_CONCURRENCY``. Each grader is independent so
25+
the two passes overlap completely.
26+
4. Failed gradings (e.g. ``MaxTurnsExceeded``) are captured per task
27+
via ``return_exceptions=True`` so a single failure doesn't discard
28+
the other ~29 successful gradings; errored prompts are surfaced
29+
separately and excluded from the rate denominators.
2630
5. Report both pass rates and the specific misses.
2731
2832
Uses the same LiteLLM model the rest of the KB uses (config.yaml). No
@@ -48,8 +52,11 @@
4852
REFERENCES_PREVIEW_BYTES = 4000 # cap reference content fed to the eval LLM
4953
# Bound on concurrent grader LLM calls in run_eval. Without this the
5054
# default count=10 would fire ~30 simultaneous requests, which most
51-
# providers rate-limit. 8 is a conservative starting point — runs ~4x
52-
# the sequential baseline while staying well under typical RPM caps.
55+
# providers rate-limit. 8 is a conservative starting point — the
56+
# semaphore acts as a sliding window across the combined trigger + coverage
57+
# pool, so realistic speedup ranges from 30/8 (~3.75x) to 8x depending
58+
# on per-call latency variance. Bumping this up trades rate-limit risk
59+
# for wall-clock latency.
5360
EVAL_CONCURRENCY = 8
5461

5562

@@ -86,37 +93,59 @@ class EvalResult:
8693
# grader-malfunction doesn't silently inflate ``coverage_misses`` and
8794
# deflate ``coverage_rate``.
8895
coverage_ambiguous: list[CoverageMiss] = field(default_factory=list)
96+
# Prompts whose grader raised (typically ``RuntimeError`` from a
97+
# ``MaxTurnsExceeded`` wrap or a malformed-response failure inside
98+
# the SDK). Captured per-task via ``return_exceptions=True`` in
99+
# ``run_eval`` so one failure doesn't discard the other ~29
100+
# gradings. Errors are excluded from rate denominators — we don't
101+
# know what the verdict would have been.
102+
trigger_errors: list[CoverageMiss] = field(default_factory=list)
103+
coverage_errors: list[CoverageMiss] = field(default_factory=list)
89104

90105
@property
91106
def total(self) -> int:
92107
return len(self.prompts)
93108

94109
@property
95110
def passed(self) -> int:
96-
return self.total - len(self.misses)
111+
return self.trigger_scored - len(self.misses)
112+
113+
@property
114+
def trigger_scored(self) -> int:
115+
"""Trigger prompts the grader returned a verdict on (no error)."""
116+
return self.total - len(self.trigger_errors)
97117

98118
@property
99119
def pass_rate(self) -> float:
100-
return self.passed / self.total if self.total else 0.0
120+
scored = self.trigger_scored
121+
return self.passed / scored if scored else 0.0
101122

102123
@property
103124
def trigger_questions(self) -> int:
104125
return sum(1 for p in self.prompts if p.expected == "trigger")
105126

106127
@property
107128
def coverage_passed(self) -> int:
108-
# Ambiguous outputs are excluded from both numerator and
109-
# denominator — see ``coverage_rate``.
110-
scored = self.trigger_questions - len(self.coverage_ambiguous)
129+
# Ambiguous and errored outputs are excluded from both numerator
130+
# and denominator — see ``coverage_rate``.
131+
scored = (
132+
self.trigger_questions
133+
- len(self.coverage_ambiguous)
134+
- len(self.coverage_errors)
135+
)
111136
return scored - len(self.coverage_misses)
112137

113138
@property
114139
def coverage_rate(self) -> float:
115140
# Score only the trigger prompts the grader gave a clear verdict
116-
# on. A garbled run that flips half the outputs to ambiguous
117-
# should narrow the denominator, not pretend half the body is
118-
# hollow.
119-
scored = self.trigger_questions - len(self.coverage_ambiguous)
141+
# on. A garbled run that flips half the outputs to ambiguous or
142+
# errors out should narrow the denominator, not pretend half the
143+
# body is hollow.
144+
scored = (
145+
self.trigger_questions
146+
- len(self.coverage_ambiguous)
147+
- len(self.coverage_errors)
148+
)
120149
return self.coverage_passed / scored if scored else 0.0
121150

122151

@@ -397,18 +426,32 @@ async def _coverage(p: EvalPrompt) -> tuple[
397426
coverage_prompts = [p for p in eval_set if p.expected == "trigger"]
398427
coverage_tasks = [_coverage(p) for p in coverage_prompts]
399428

429+
# return_exceptions=True so one failed grader doesn't discard the
430+
# other ~29 successful gradings. Errored prompts are surfaced
431+
# separately on the result and excluded from rate denominators.
400432
trigger_results, coverage_results = await asyncio.gather(
401-
asyncio.gather(*trigger_tasks),
402-
asyncio.gather(*coverage_tasks),
433+
asyncio.gather(*trigger_tasks, return_exceptions=True),
434+
asyncio.gather(*coverage_tasks, return_exceptions=True),
403435
)
404436

405437
# Walk inputs in original order so `result.*` lists are deterministic
406438
# even though the gather() above completed out of order.
407439
for prompt, graded in zip(eval_set, trigger_results):
440+
if isinstance(graded, BaseException):
441+
result.trigger_errors.append(
442+
CoverageMiss(prompt=prompt, reason=str(graded))
443+
)
444+
continue
408445
if graded != prompt.expected:
409446
result.misses.append(EvalMiss(prompt=prompt, graded=graded))
410447

411-
for prompt, (verdict, reason) in zip(coverage_prompts, coverage_results):
448+
for prompt, outcome in zip(coverage_prompts, coverage_results):
449+
if isinstance(outcome, BaseException):
450+
result.coverage_errors.append(
451+
CoverageMiss(prompt=prompt, reason=str(outcome))
452+
)
453+
continue
454+
verdict, reason = outcome
412455
if verdict == "ambiguous":
413456
result.coverage_ambiguous.append(
414457
CoverageMiss(prompt=prompt, reason=reason)

tests/test_skill_evaluator.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,49 @@ async def mixed_coverage(content, question, *, model):
315315
assert result.coverage_rate == pytest.approx(0.5)
316316

317317

318+
@pytest.mark.asyncio
319+
async def test_run_eval_captures_grader_failures_without_aborting(tmp_path):
320+
"""A single grader failure must NOT abort the whole eval. The failed
321+
prompt goes into trigger_errors/coverage_errors and is excluded from
322+
rate denominators; the other prompts grade normally."""
323+
skill_dir = _make_skill(tmp_path)
324+
eval_set = _build_eval_set(2, 2) # 2 trigger + 2 no-trigger
325+
326+
async def flaky_trigger(description, question, *, model):
327+
if question == "trig 0":
328+
raise RuntimeError("max-turn cap hit")
329+
# Everything else: perfect grader
330+
match = next(p for p in eval_set if p.question == question)
331+
return match.expected
332+
333+
async def flaky_coverage(content, question, *, model):
334+
if question == "trig 1":
335+
raise RuntimeError("malformed grader output")
336+
return "supported", ""
337+
338+
with patch("openkb.skill.evaluator.grade_one", side_effect=flaky_trigger), \
339+
patch("openkb.skill.evaluator.grade_coverage", side_effect=flaky_coverage):
340+
result = await run_eval(skill_dir, model="gpt-4o-mini", eval_set=eval_set)
341+
342+
# 4 prompts total; one trigger errored, one coverage errored.
343+
assert result.total == 4
344+
assert len(result.trigger_errors) == 1
345+
assert result.trigger_errors[0].prompt.question == "trig 0"
346+
assert "max-turn cap" in result.trigger_errors[0].reason
347+
assert len(result.coverage_errors) == 1
348+
assert result.coverage_errors[0].prompt.question == "trig 1"
349+
assert "malformed" in result.coverage_errors[0].reason
350+
351+
# Trigger: 3 prompts scored (1 errored), all correct -> 3/3 = 100%
352+
assert result.trigger_scored == 3
353+
assert result.passed == 3
354+
assert result.pass_rate == pytest.approx(1.0)
355+
356+
# Coverage: 2 trigger prompts, 1 errored, 1 supported -> 1/1 = 100%
357+
assert result.coverage_passed == 1
358+
assert result.coverage_rate == pytest.approx(1.0)
359+
360+
318361
# -------- save/load round-trip ------------------------------------------------
319362

320363

0 commit comments

Comments
 (0)