Files
Viktor Hofer 3fff1f2573 Skill-validator: perf, cancellation, and reliability improvements (#482)
* Skill-validator: perf, cancellation, and reliability improvements

Performance:
- Cache skill-validator-dist.tar.gz in CI (evaluation.yml and skill-check.yml)
  to skip .NET SDK setup, build, and tar on cache hit. Evaluate and comment-on-pr
  jobs restore from cache with artifact download fallback.
- Replace SemaphoreSlim with Lock in SessionDatabase for lighter-weight
  synchronous locking.

Cancellation:
- Thread CancellationToken from System.CommandLine entry point through the
  entire evaluate call chain: Run -> EvaluateTarget -> EvaluateSkill/EvaluateAgent
  -> ExecuteScenario/ExecuteAgentScenario -> ExecuteRun/ExecuteAgentRun, plus
  EvaluateSkillNoise -> ExecuteNoiseTest.
- Add CancellationToken parameter to AgentRunner.RunAgent, Judge.JudgeRun,
  PairwiseJudge.Judge, and OverfittingJudge.Analyze, forwarding to
  RetryHelper and LlmSession.SendAsync respectively.
- Pass CancellationToken to all ConcurrencyLimiter.RunAsync call sites so
  semaphore waits are cancellable.
- Re-throw OperationCanceledException at the top-level skill evaluation loop
  so Ctrl+C / CI cancellation propagates instead of being swallowed as a
  skill failure.

Reliability:
- Smart timeline truncation in Judge and PairwiseJudge: error events
  (session.error, runner.error) are always preserved during truncation
  regardless of position, with a summary marker showing event type breakdown.
- Add jitter (±25%) to RetryHelper exponential backoff to prevent thundering
  herd on rate-limit recovery.
- Add CancellationToken parameter to ConcurrencyLimiter.RunAsync<T>.
- Add ConcurrencyLimiter tests (6 tests) and RetryHelper jitter tests (2 tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- RetryHelper: re-clamp delay after jitter so it never exceeds the
  remaining budget or MaxSingleDelayMs.
- Judge/PairwiseJudge: handle edge case where error count alone exceeds
  MaxTimelineEvents by trimming errors with a summary instead of
  unconditionally appending all of them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add high-variance detection and refactor FailureKind to enum

- Add Statistics.CoefficientOfVariation() for measuring score consistency
- Flag scenarios with CV > 0.5 as high-variance in ScenarioComparison
- Show console warning and markdown footnote for high-variance scenarios
- Include high-variance scenario names in Comparator verdict reason
- Refactor FailureKind from magic strings to strongly-typed enum with
  JsonStringEnumMemberName attributes for backwards-compatible JSON output
- Add 6 new CV unit tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Viktor Hofer <vihofer@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 13:58:28 +02:00

183 lines
5.0 KiB
C#

using SkillValidator.Evaluate;
namespace SkillValidator.Tests;
public class BootstrapConfidenceIntervalTests
{
[Fact]
public void ReturnsZeroIntervalForEmptyData()
{
var ci = Statistics.BootstrapConfidenceInterval([], 0.95);
Assert.Equal(0, ci.Low);
Assert.Equal(0, ci.High);
Assert.Equal(0.95, ci.Level);
}
[Fact]
public void ReturnsPointIntervalForSingleDataPoint()
{
var ci = Statistics.BootstrapConfidenceInterval([0.5], 0.95);
Assert.Equal(0.5, ci.Low);
Assert.Equal(0.5, ci.High);
}
[Fact]
public void ProducesReasonableIntervalForPositiveData()
{
double[] data = [0.1, 0.15, 0.2, 0.12, 0.18];
var ci = Statistics.BootstrapConfidenceInterval(data, 0.95);
Assert.True(ci.Low > 0);
Assert.True(ci.High > ci.Low);
Assert.True(ci.High <= 0.2);
Assert.Equal(0.95, ci.Level);
}
[Fact]
public void ProducesIntervalForMixedData()
{
double[] data = [-0.1, 0.2, -0.05, 0.15, -0.08, 0.1];
var ci = Statistics.BootstrapConfidenceInterval(data, 0.95);
Assert.True(ci.Low < ci.High);
}
[Fact]
public void NarrowerCiWithMoreDataPoints()
{
double[] small = [0.1, 0.2, 0.15];
double[] large = [0.1, 0.2, 0.15, 0.12, 0.18, 0.14, 0.16, 0.13, 0.17, 0.11];
var ciSmall = Statistics.BootstrapConfidenceInterval(small, 0.95);
var ciLarge = Statistics.BootstrapConfidenceInterval(large, 0.95);
var widthSmall = ciSmall.High - ciSmall.Low;
var widthLarge = ciLarge.High - ciLarge.Low;
Assert.True(widthLarge < widthSmall);
}
[Fact]
public void IsDeterministic()
{
double[] data = [0.1, 0.2, 0.3, 0.15, 0.25];
var ci1 = Statistics.BootstrapConfidenceInterval(data, 0.95);
var ci2 = Statistics.BootstrapConfidenceInterval(data, 0.95);
Assert.Equal(ci1.Low, ci2.Low);
Assert.Equal(ci1.High, ci2.High);
}
}
public class IsStatisticallySignificantTests
{
[Fact]
public void ReturnsTrueWhenCiIsEntirelyPositive()
{
Assert.True(Statistics.IsStatisticallySignificant(new ConfidenceInterval(0.05, 0.3, 0.95)));
}
[Fact]
public void ReturnsTrueWhenCiIsEntirelyNegative()
{
Assert.True(Statistics.IsStatisticallySignificant(new ConfidenceInterval(-0.4, -0.1, 0.95)));
}
[Fact]
public void ReturnsFalseWhenCiSpansZero()
{
Assert.False(Statistics.IsStatisticallySignificant(new ConfidenceInterval(-0.1, 0.2, 0.95)));
}
[Fact]
public void ReturnsFalseWhenCiIsExactlyAtZero()
{
Assert.False(Statistics.IsStatisticallySignificant(new ConfidenceInterval(0, 0.1, 0.95)));
}
}
public class WilsonScoreIntervalTests
{
[Fact]
public void ReturnsZeroIntervalForZeroTotal()
{
var ci = Statistics.WilsonScoreInterval(0, 0);
Assert.Equal(0, ci.Low);
Assert.Equal(0, ci.High);
}
[Fact]
public void ProducesReasonableIntervalForPerfectSuccess()
{
var ci = Statistics.WilsonScoreInterval(10, 10);
Assert.True(ci.Low > 0.5);
Assert.Equal(1, ci.High);
}
[Fact]
public void ProducesReasonableIntervalForNoSuccesses()
{
var ci = Statistics.WilsonScoreInterval(0, 10);
Assert.Equal(0, ci.Low);
Assert.True(ci.High < 0.5);
}
[Fact]
public void ProducesIntervalCenteredAroundProportion()
{
var ci = Statistics.WilsonScoreInterval(5, 10);
Assert.True(ci.Low < 0.5);
Assert.True(ci.High > 0.5);
}
[Fact]
public void NarrowsWithMoreSamples()
{
var ci10 = Statistics.WilsonScoreInterval(5, 10);
var ci100 = Statistics.WilsonScoreInterval(50, 100);
var width10 = ci10.High - ci10.Low;
var width100 = ci100.High - ci100.Low;
Assert.True(width100 < width10);
}
}
public class CoefficientOfVariationTests
{
[Fact]
public void ReturnsNullForEmpty()
{
Assert.Null(Statistics.CoefficientOfVariation([]));
}
[Fact]
public void ReturnsNullForSingleElement()
{
Assert.Null(Statistics.CoefficientOfVariation([0.5]));
}
[Fact]
public void ReturnsNullWhenMeanIsZero()
{
Assert.Null(Statistics.CoefficientOfVariation([-0.1, 0.1]));
}
[Fact]
public void ReturnsZeroForIdenticalValues()
{
var cv = Statistics.CoefficientOfVariation([0.3, 0.3, 0.3]);
Assert.NotNull(cv);
Assert.Equal(0.0, cv.Value, 0.001);
}
[Fact]
public void ReturnsHighValueForHighVariance()
{
// Scores: -0.5, 0.5, -0.3, 0.4 → high spread relative to mean
var cv = Statistics.CoefficientOfVariation([-0.5, 0.5, -0.3, 0.4]);
Assert.NotNull(cv);
Assert.True(cv > 1.0, $"Expected high CV, got {cv}");
}
[Fact]
public void ReturnsLowValueForConsistentScores()
{
var cv = Statistics.CoefficientOfVariation([0.10, 0.11, 0.09, 0.10, 0.12]);
Assert.NotNull(cv);
Assert.True(cv < 0.15, $"Expected low CV, got {cv}");
}
}