Files
dotnet__skills/eng/skill-validator/tests/Evaluate/OverfittingJudgeTests.cs
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

1112 lines
43 KiB
C#

using System.Text.Json;
using SkillValidator.Evaluate;
using SkillValidator.Shared;
namespace SkillValidator.Tests;
public class OverfittingJudgeTests
{
// --- Score computation tests ---
[Fact]
public void ComputeScore_AllOutcomeBroad_ReturnsZero()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "criterion1", "outcome", 0.9, "Good outcome test"),
new("sc1", "criterion2", "outcome", 0.8, "Another outcome test"),
};
var assertions = new List<AssertionOverfitAssessment>
{
new("sc1", "file_exists: *.binlog", "broad", 0.9, "Checks file existence"),
};
var score = OverfittingJudge.ComputeOverfittingScore(rubric, assertions);
Assert.Equal(0.0, score);
}
[Fact]
public void ComputeScore_AllVocabularyNarrow_ReturnsHigh()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "Uses --clreventlevel flag", "vocabulary", 0.9, "Tests exact flag name"),
new("sc1", "Checked GC Heap Size > 500MB", "vocabulary", 0.8, "Tests exact counter"),
};
var assertions = new List<AssertionOverfitAssessment>
{
new("sc1", "output_matches: (-bl:\\{\\{\\}\\})", "narrow", 0.95, "Tests specific escaping"),
};
var score = OverfittingJudge.ComputeOverfittingScore(rubric, assertions);
// rubricAvg = (1.0*0.9 + 1.0*0.8) / 2 = 0.85
// assertionAvg = (1.0*0.95) / 1 = 0.95
// combined = 0.7*0.85 + 0.3*0.95 = 0.595 + 0.285 = 0.88
Assert.True(score > 0.5, $"Expected high score, got {score}");
}
[Fact]
public void ComputeScore_MixedClassifications_ReturnsMedium()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "Identified root cause", "outcome", 0.9, "Good"),
new("sc1", "Built twice to check", "technique", 0.7, "Diagnostic step"),
new("sc2", "Used specific label", "vocabulary", 0.8, "Exact wording"),
};
var assertions = new List<AssertionOverfitAssessment>
{
new("sc1", "file_exists: *.binlog", "broad", 0.9, "Outcome check"),
new("sc2", "output_matches: specific-pattern", "narrow", 0.85, "Narrow match"),
};
var score = OverfittingJudge.ComputeOverfittingScore(rubric, assertions);
// rubricAvg = (0 + 0.5*0.7 + 1.0*0.8) / 3 = (0 + 0.35 + 0.8) / 3 = 0.3833
// assertionAvg = (0 + 1.0*0.85) / 2 = 0.425
// combined = 0.7*0.3833 + 0.3*0.425 = 0.26833 + 0.1275 = 0.3958
Assert.True(score > 0.2 && score < 0.5, $"Expected moderate score, got {score}");
}
[Fact]
public void ComputeScore_EmptyInputs_ReturnsZero()
{
var score = OverfittingJudge.ComputeOverfittingScore(
new List<RubricOverfitAssessment>(),
new List<AssertionOverfitAssessment>());
Assert.Equal(0.0, score);
}
[Fact]
public void ComputeScore_TechniqueOnly_ReturnsMedium()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "Ran dotnet-counters monitor", "technique", 1.0, "Specific tool"),
};
var assertions = new List<AssertionOverfitAssessment>();
var score = OverfittingJudge.ComputeOverfittingScore(rubric, assertions);
// rubricAvg = 0.5 * 1.0 / 1 = 0.5
// assertionAvg = 0 (no assertions)
// combined = 0.7 * 0.5 + 0.3 * 0 = 0.35
Assert.Equal(0.35, score, 2);
}
// --- JSON response parsing tests ---
private static readonly string ValidOverfittingJson = JsonSerializer.Serialize(new
{
rubric_assessments = new[]
{
new
{
scenario = "generate-unique-binlog",
criterion = "Used -bl:{} for unique binlog names",
classification = "outcome",
confidence = 0.85,
reasoning = "This is genuinely the only way to do it"
},
new
{
scenario = "generate-unique-binlog",
criterion = "PowerShell escaping {{}}",
classification = "vocabulary",
confidence = 0.9,
reasoning = "Tests shell escaping LLMs already know"
}
},
assertion_assessments = new[]
{
new
{
scenario = "generate-unique-binlog",
assertion_summary = "file_exists: *.binlog",
classification = "broad",
confidence = 0.95,
reasoning = "Tests the outcome"
},
new
{
scenario = "generate-unique-binlog",
assertion_summary = "output_matches: (-bl:\\{\\{\\}\\})",
classification = "narrow",
confidence = 0.9,
reasoning = "Tests specific syntax"
}
},
cross_scenario_issues = new[] { "Repetitive testing of shell escaping across scenarios" },
overall_overfitting_score = 0.45,
overall_reasoning = "The eval has moderate overfitting due to vocabulary testing."
});
[Fact]
public void ParseResponse_ValidJson_ParsesCorrectly()
{
var result = OverfittingJudge.ParseOverfittingResponse(ValidOverfittingJson);
Assert.Equal(2, result.RubricAssessments.Count);
Assert.Equal(2, result.AssertionAssessments.Count);
Assert.Single(result.CrossScenarioIssues);
Assert.NotEmpty(result.OverallReasoning);
}
[Fact]
public void ParseResponse_ValidJson_ComputesBlendedScore()
{
var result = OverfittingJudge.ParseOverfittingResponse(ValidOverfittingJson);
// Computed: rubricAvg = (0*0.85 + 1.0*0.9) / 2 = 0.45
// assertionAvg = (0*0.95 + 1.0*0.9) / 2 = 0.45
// computed = 0.7*0.45 + 0.3*0.45 = 0.45
// LLM overall = 0.45
// Final = 0.6*0.45 + 0.4*0.45 = 0.45
Assert.True(result.Score >= 0.0 && result.Score <= 1.0);
}
[Fact]
public void ParseResponse_InCodeBlock_ParsesCorrectly()
{
var content = "```json\n" + ValidOverfittingJson + "\n```";
var result = OverfittingJudge.ParseOverfittingResponse(content);
Assert.Equal(2, result.RubricAssessments.Count);
Assert.Equal(2, result.AssertionAssessments.Count);
}
[Fact]
public void ParseResponse_WithSurroundingText_ParsesCorrectly()
{
var content = "Here is my analysis:\n\n" + ValidOverfittingJson + "\n\nThat concludes the assessment.";
var result = OverfittingJudge.ParseOverfittingResponse(content);
Assert.Equal(2, result.RubricAssessments.Count);
}
[Fact]
public void ParseResponse_NoJson_Throws()
{
Assert.Throws<InvalidOperationException>(() =>
OverfittingJudge.ParseOverfittingResponse("No JSON here at all"));
}
// --- Severity mapping tests ---
[Theory]
[InlineData(0.0, OverfittingSeverity.Low)]
[InlineData(0.10, OverfittingSeverity.Low)]
[InlineData(0.19, OverfittingSeverity.Low)]
[InlineData(0.20, OverfittingSeverity.Moderate)]
[InlineData(0.35, OverfittingSeverity.Moderate)]
[InlineData(0.49, OverfittingSeverity.Moderate)]
[InlineData(0.50, OverfittingSeverity.High)]
[InlineData(0.75, OverfittingSeverity.High)]
[InlineData(1.0, OverfittingSeverity.High)]
public void SeverityMapping_CorrectThresholds(double score, OverfittingSeverity expected)
{
// Build a response where both computed and LLM overall equal the target score
// With no rubric/assertions, computed = 0, final = 0.4 * clamp(llmScore, 0, 1)
// To get exact score: we need computed portion + LLM portion
// Use rubric items to produce the exact computed score we want
var rubric = new List<object>();
if (score > 0)
{
// One vocabulary item with confidence = score (produces computedScore = 0.7*score)
// And set LLM overall to score (produces 0.4*score)
// final = 0.6*(0.7*score) + 0.4*score = 0.42*score + 0.4*score = 0.82*score
// That's not right either. Just test the severity at the boundary.
}
var json = JsonSerializer.Serialize(new
{
rubric_assessments = Array.Empty<object>(),
assertion_assessments = Array.Empty<object>(),
cross_scenario_issues = Array.Empty<string>(),
overall_overfitting_score = Math.Min(score / 0.4, 1.0),
overall_reasoning = "test"
});
var result = OverfittingJudge.ParseOverfittingResponse(json);
// final = 0.6 * 0 + 0.4 * min(score/0.4, 1.0)
// For score <= 0.4: final = score
// For score > 0.4: final = 0.4 (clamped)
// This test verifies the score is valid and severity mapping holds for achievable scores
Assert.True(result.Score >= 0.0 && result.Score <= 1.0);
if (score <= 0.4)
{
Assert.Equal(expected, result.Severity);
}
}
// --- OverfittingResult serialization ---
[Fact]
public void OverfittingResult_SerializesToJson_WithStringSeverity()
{
var result = new OverfittingResult(
0.55,
OverfittingSeverity.High,
new List<RubricOverfitAssessment>
{
new("sc1", "test criterion", "vocabulary", 0.9, "reason")
},
new List<AssertionOverfitAssessment>
{
new("sc1", "output_matches: pattern", "narrow", 0.85, "reason")
},
new List<PromptOverfitAssessment>(),
new List<string> { "cross-scenario issue" },
"Overall reasoning"
);
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});
Assert.Contains("\"severity\":\"High\"", json);
Assert.Contains("\"score\":0.55", json);
}
// --- Prompt building tests ---
[Fact]
public void BuildSystemPrompt_ContainsKeyElements()
{
var prompt = OverfittingJudge.BuildSystemPrompt();
Assert.Contains("DOMAIN EXPERT TEST", prompt);
Assert.Contains("LLM KNOWLEDGE TEST", prompt);
Assert.Contains("outcome", prompt);
Assert.Contains("technique", prompt);
Assert.Contains("vocabulary", prompt);
Assert.Contains("broad", prompt);
Assert.Contains("narrow", prompt);
Assert.Contains("Few-shot examples", prompt);
}
[Fact]
public async Task BuildUserPrompt_IncludesSkillAndEvalContent()
{
var skill = new SkillInfo(
Name: "test-skill",
Description: "A test skill",
Path: "/skills/test-skill",
SkillMdPath: "/skills/test-skill/SKILL.md",
SkillMdContent: "# Test Skill\nThis teaches something.");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>
{
new("scenario1", "Do something",
Rubric: new List<string> { "Did the thing correctly" })
}));
var prompt = await OverfittingJudge.BuildUserPromptAsync(evalSkill);
Assert.Contains("SKILL_CONTENT_START", prompt);
Assert.Contains("SKILL_CONTENT_END", prompt);
Assert.Contains("EVAL_CONTENT_START", prompt);
Assert.Contains("EVAL_CONTENT_END", prompt);
Assert.Contains("test-skill", prompt);
Assert.Contains("Test Skill", prompt);
}
[Fact]
public async Task BuildUserPrompt_TruncatesLargeSkillContent()
{
var largeContent = new string('x', 50_000);
var skill = new SkillInfo(
Name: "large-skill",
Description: "A large skill",
Path: "/skills/large-skill",
SkillMdPath: "/skills/large-skill/SKILL.md",
SkillMdContent: largeContent);
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>()));
var prompt = await OverfittingJudge.BuildUserPromptAsync(evalSkill);
Assert.Contains("TRUNCATED", prompt);
Assert.Contains("large-skill/SKILL.md", prompt);
}
// --- Markdown table integration ---
[Fact]
public void MarkdownTable_IncludesOverfitColumn()
{
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "test-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "sc1",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = 0.25,
Breakdown = new MetricBreakdown(0, 0, 0, 0, 0, 0, 0),
}
},
OverallImprovementScore = 0.25,
Reason = "Pass",
OverfittingResult = new OverfittingResult(
0.38,
OverfittingSeverity.Moderate,
new List<RubricOverfitAssessment>(),
new List<AssertionOverfitAssessment>(),
new List<PromptOverfitAssessment>(),
new List<string>(),
"Moderate overfitting detected"
)
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.Contains("Overfit", md);
Assert.DoesNotContain("Notes", md);
Assert.Contains("Quality", md);
Assert.DoesNotContain("Baseline", md);
Assert.Contains("\U0001f7e2", md); // 🟢 green circle for improvement
Assert.Contains("🟡 0.38", md);
}
[Fact]
public void MarkdownTable_ShowsDashWhenNoOverfitting()
{
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "test-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "sc1",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = 0.25,
Breakdown = new MetricBreakdown(0, 0, 0, 0, 0, 0, 0),
}
},
OverallImprovementScore = 0.25,
Reason = "Pass",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.Contains("Overfit", md);
Assert.DoesNotContain("Notes", md);
Assert.Contains("Quality", md);
Assert.Contains("\u2192", md); // → arrow in quality cell
Assert.Contains("| \u2014 |", md); // — dash in Overfit column when no result
}
[Fact]
public void MarkdownTable_ShowsFootnoteWhenVerdictDisagreesWithQuality()
{
// Quality improved (+1.0) but composite is negative due to token/time overhead.
// ImprovementScore derived from breakdown * DefaultWeights:
// -5.0*0.05 + -3.0*0.025 + 0*0.15 + -3.0*0.025 + 0.4*0.40 + 0.2*0.30 + 0*0.05 = -0.18
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "test-skill",
SkillPath = "/test",
Passed = false,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "overhead-scenario",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline", TokenEstimate = 1000, ToolCallCount = 5, WallTimeMs = 2100 },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled", TokenEstimate = 8000, ToolCallCount = 15, WallTimeMs = 8500 },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = -0.18,
Breakdown = new MetricBreakdown(
TokenReduction: -5.0,
ToolCallReduction: -3.0,
TaskCompletionImprovement: 0,
TimeReduction: -3.0,
QualityImprovement: 0.4,
OverallJudgmentImprovement: 0.2,
ErrorReduction: 0),
}
},
OverallImprovementScore = -0.18,
Reason = "Below threshold",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.Contains("[1]", md);
Assert.Contains("(Isolated) Quality improved but weighted score is", md);
Assert.Contains("due to:", md);
// Raw metrics should appear in footnote
Assert.Contains("tokens (1000", md);
Assert.Contains("8000)", md);
Assert.Contains("tool calls (5", md);
Assert.Contains("time (2.1s", md);
}
[Fact]
public void MarkdownTable_NoFootnoteWhenVerdictMatchesQuality()
{
// Quality improved and composite is positive — no footnote needed.
// ImprovementScore derived from breakdown * DefaultWeights:
// 0*0.05 + 0*0.025 + 0*0.15 + 0*0.025 + 0.5*0.40 + 0.5*0.30 + 0*0.05 = 0.35
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "test-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "clean-pass",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 3.0, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = 0.35,
Breakdown = new MetricBreakdown(
TokenReduction: 0,
ToolCallReduction: 0,
TaskCompletionImprovement: 0,
TimeReduction: 0,
QualityImprovement: 0.5,
OverallJudgmentImprovement: 0.5,
ErrorReduction: 0),
}
},
OverallImprovementScore = 0.35,
Reason = "Pass",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.DoesNotContain("[1]", md);
Assert.DoesNotContain("weighted score", md);
}
[Fact]
public void MarkdownTable_ShowsFootnoteWhenQualityDroppedButCompositePositive()
{
// Quality dropped (-1.0) but composite is positive due to efficiency gains.
// ImprovementScore derived from breakdown * DefaultWeights:
// 2.0*0.05 + 0*0.025 + 1.0*0.15 + 0*0.025 + -0.2*0.40 + -0.1*0.30 + 0*0.05 = 0.14
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "test-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "efficiency-offset",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline", TokenEstimate = 5000, TaskCompleted = false },
new JudgeResult(new List<RubricScore>(), 4.0, "Good")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled", TokenEstimate = 2000, TaskCompleted = true },
new JudgeResult(new List<RubricScore>(), 3.0, "OK")),
ImprovementScore = 0.14,
Breakdown = new MetricBreakdown(
TokenReduction: 2.0,
ToolCallReduction: 0,
TaskCompletionImprovement: 1.0,
TimeReduction: 0,
QualityImprovement: -0.2,
OverallJudgmentImprovement: -0.1,
ErrorReduction: 0),
}
},
OverallImprovementScore = 0.14,
Reason = "Pass",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.Contains("[1]", md);
Assert.Contains("(Isolated) Quality dropped but weighted score is", md);
Assert.Contains("due to:", md);
// Raw metrics should appear in footnote
Assert.Contains("completion", md);
Assert.Contains("tokens (5000", md);
Assert.Contains("2000)", md);
}
[Fact]
public void MarkdownTable_ShowsFootnoteWhenQualityUnchangedButVerdictNegative()
{
// Quality scores are identical between baseline and skill runs, but verdict is negative.
// A footnote should explain what efficiency metrics caused the negative score.
// ImprovementScore derived from breakdown * DefaultWeights:
// -2.0*0.05 + 0 + 0 + 0 + 0 + 0 + 0 = -0.10
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "test-skill",
SkillPath = "/test",
Passed = false,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "quality-unchanged",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline", TokenEstimate = 1000, ToolCallCount = 5 },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled", TokenEstimate = 3000, ToolCallCount = 5 },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
ImprovementScore = -0.10,
Breakdown = new MetricBreakdown(
TokenReduction: -2.0,
ToolCallReduction: 0,
TaskCompletionImprovement: 0,
TimeReduction: 0,
QualityImprovement: 0,
OverallJudgmentImprovement: 0,
ErrorReduction: 0),
}
},
OverallImprovementScore = -0.10,
Reason = "Below threshold",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.Contains("[1]", md);
Assert.Contains("(Isolated) Quality unchanged but weighted score is", md);
Assert.Contains("tokens (1000", md);
}
[Fact]
public void MarkdownSummary_ShowsErrorsSectionForPreEvalFailures()
{
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "broken-skill",
SkillPath = "/test",
Passed = false,
Scenarios = [],
OverallImprovementScore = 0,
Reason = "Skill description is 1,370 characters — maximum is 1,024.",
FailureKind = FailureKind.SpecConformanceFailure,
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.Contains("### ❌ Skill validation errors", md);
Assert.Contains("- `broken-skill: Skill description is 1,370 characters", md);
}
[Fact]
public void MarkdownSummary_OmitsErrorsSectionWhenAllVerdictsPassed()
{
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "good-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "test-scenario",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = 0.25,
Breakdown = new MetricBreakdown(0, 0, 0, 0, 0, 0, 0),
}
},
OverallImprovementScore = 0.25,
Reason = "Pass",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.DoesNotContain("### ❌ Skill validation errors", md);
}
[Fact]
public void MarkdownSummary_OmitsErrorsSectionForFailuresWithScenarios()
{
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "threshold-fail",
SkillPath = "/test",
Passed = false,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "test-scenario",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 4.0, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 3.0, "Worse")),
ImprovementScore = -0.25,
Breakdown = new MetricBreakdown(0, 0, 0, 0, 0, 0, 0),
}
},
OverallImprovementScore = -0.25,
Reason = "Regression detected",
FailureKind = FailureKind.CompletionRegression,
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
Assert.DoesNotContain("### ❌ Skill validation errors", md);
}
[Fact]
public void MarkdownTable_SkillsLoaded_NoDuplicateWhenIsolatedAndPluginIdentical()
{
var activation = new SkillActivationInfo(
Activated: true,
DetectedSkills: new List<string> { "my-skill" },
ExtraTools: new List<string> { "report_intent", "skill" },
SkillEventCount: 1);
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "my-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "sc1",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
SkilledPlugin = new RunResult(
new RunMetrics { AgentOutput = "skilled-plugin" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = 0.25,
Breakdown = new MetricBreakdown(0, 0, 0, 0, 0, 0, 0),
SkillActivationIsolated = activation,
SkillActivationPlugin = activation,
}
},
OverallImprovementScore = 0.25,
Reason = "Pass",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
// Skills loaded cell should contain the activation info exactly once (no " / " separator)
Assert.DoesNotContain("my-skill; tools: report_intent, skill / ✅ my-skill", md);
Assert.Contains("✅ my-skill; tools: report_intent, skill", md);
}
[Fact]
public void MarkdownTable_SkillsLoaded_ShowsBothWhenIsolatedAndPluginDiffer()
{
var isolatedActivation = new SkillActivationInfo(
Activated: true,
DetectedSkills: new List<string> { "my-skill" },
ExtraTools: new List<string> { "report_intent" },
SkillEventCount: 1);
var pluginActivation = new SkillActivationInfo(
Activated: true,
DetectedSkills: new List<string> { "my-skill" },
ExtraTools: new List<string> { "report_intent", "skill" },
SkillEventCount: 1);
var verdicts = new List<SkillVerdict>
{
new()
{
SkillName = "my-skill",
SkillPath = "/test",
Passed = true,
Scenarios = new List<ScenarioComparison>
{
new()
{
ScenarioName = "sc1",
Baseline = new RunResult(
new RunMetrics { AgentOutput = "baseline" },
new JudgeResult(new List<RubricScore>(), 3.5, "OK")),
SkilledIsolated = new RunResult(
new RunMetrics { AgentOutput = "skilled" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
SkilledPlugin = new RunResult(
new RunMetrics { AgentOutput = "skilled-plugin" },
new JudgeResult(new List<RubricScore>(), 4.5, "Good")),
ImprovementScore = 0.25,
Breakdown = new MetricBreakdown(0, 0, 0, 0, 0, 0, 0),
SkillActivationIsolated = isolatedActivation,
SkillActivationPlugin = pluginActivation,
}
},
OverallImprovementScore = 0.25,
Reason = "Pass",
}
};
var md = Reporter.GenerateMarkdownSummary(verdicts);
// Both activation entries should appear separated by " / "
Assert.Contains("✅ my-skill; tools: report_intent / ✅ my-skill; tools: report_intent, skill", md);
}
// --- Prompt overfitting detection tests ---
[Fact]
public void DetectPromptOverfitting_ExplicitSkillName_Detected()
{
var skill = new SkillInfo(
Name: "migrate-dotnet10-to-dotnet11",
Description: "Migration skill",
Path: "/skills/migrate-dotnet10-to-dotnet11",
SkillMdPath: "/skills/migrate-dotnet10-to-dotnet11/SKILL.md",
SkillMdContent: "# Migration Skill");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>
{
new("scenario1",
"Use the migrate-dotnet10-to-dotnet11 skill to help me migrate my .NET 10 console app to .NET 11."),
}));
var assessments = OverfittingJudge.DetectPromptOverfitting(evalSkill);
Assert.Single(assessments);
Assert.Equal("explicit_skill_reference", assessments[0].Issue);
Assert.Equal(1.0, assessments[0].Confidence);
Assert.Contains("migrate-dotnet10-to-dotnet11", assessments[0].Reasoning);
}
[Fact]
public void DetectPromptOverfitting_MultipleScenarios_AllDetected()
{
var skill = new SkillInfo(
Name: "migrate-dotnet10-to-dotnet11",
Description: "Migration skill",
Path: "/skills/migrate-dotnet10-to-dotnet11",
SkillMdPath: "/skills/migrate-dotnet10-to-dotnet11/SKILL.md",
SkillMdContent: "# Migration Skill");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>
{
new("scenario1",
"Use the migrate-dotnet10-to-dotnet11 skill to help me with compression changes."),
new("scenario2",
"Use the migrate-dotnet10-to-dotnet11 skill to help me with C# 15 compiler changes."),
new("scenario3",
"Use the migrate-dotnet10-to-dotnet11 skill to help me with EF Core Cosmos DB."),
}));
var assessments = OverfittingJudge.DetectPromptOverfitting(evalSkill);
Assert.Equal(3, assessments.Count);
Assert.All(assessments, a => Assert.Equal("explicit_skill_reference", a.Issue));
}
[Fact]
public void DetectPromptOverfitting_UseSkillPhrase_Detected()
{
var skill = new SkillInfo(
Name: "dotnet-pinvoke",
Description: "P/Invoke skill",
Path: "/skills/dotnet-pinvoke",
SkillMdPath: "/skills/dotnet-pinvoke/SKILL.md",
SkillMdContent: "# P/Invoke Skill");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>
{
new("scenario1",
"Use the pinvoke skill to help me create a P/Invoke binding."),
}));
var assessments = OverfittingJudge.DetectPromptOverfitting(evalSkill);
Assert.Single(assessments);
Assert.Equal("skill_instruction", assessments[0].Issue);
Assert.Equal(0.9, assessments[0].Confidence);
}
[Fact]
public void DetectPromptOverfitting_NeutralPrompt_NothingDetected()
{
var skill = new SkillInfo(
Name: "migrate-dotnet10-to-dotnet11",
Description: "Migration skill",
Path: "/skills/migrate-dotnet10-to-dotnet11",
SkillMdPath: "/skills/migrate-dotnet10-to-dotnet11/SKILL.md",
SkillMdContent: "# Migration Skill");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>
{
new("scenario1",
"I need to migrate my .NET 10 console app to .NET 11. What breaks?"),
}));
var assessments = OverfittingJudge.DetectPromptOverfitting(evalSkill);
Assert.Empty(assessments);
}
[Fact]
public void DetectPromptOverfitting_CaseInsensitive_Detected()
{
var skill = new SkillInfo(
Name: "Migrate-Dotnet10-To-Dotnet11",
Description: "Migration skill",
Path: "/skills/migrate-dotnet10-to-dotnet11",
SkillMdPath: "/skills/migrate-dotnet10-to-dotnet11/SKILL.md",
SkillMdContent: "# Migration Skill");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: new EvalConfig(new List<EvalScenario>
{
new("scenario1",
"Use the migrate-dotnet10-to-dotnet11 skill to help me."),
}));
var assessments = OverfittingJudge.DetectPromptOverfitting(evalSkill);
Assert.Single(assessments);
Assert.Equal("explicit_skill_reference", assessments[0].Issue);
}
[Fact]
public void DetectPromptOverfitting_NoEvalConfig_ReturnsEmpty()
{
var skill = new SkillInfo(
Name: "test-skill",
Description: "Test",
Path: "/skills/test-skill",
SkillMdPath: "/skills/test-skill/SKILL.md",
SkillMdContent: "# Test");
var evalSkill = new EvalSkillInfo(
Skill: skill,
EvalPath: null,
EvalConfig: null);
var assessments = OverfittingJudge.DetectPromptOverfitting(evalSkill);
Assert.Empty(assessments);
}
// --- Score computation with prompt assessments ---
[Fact]
public void ComputeScore_WithPromptIssues_BoostsScore()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "Explains GZipStream changes", "outcome", 0.9, "Good outcome test"),
};
var assertions = new List<AssertionOverfitAssessment>
{
new("sc1", "output_matches: GZipStream", "broad", 0.9, "Broad test"),
};
var prompts = new List<PromptOverfitAssessment>
{
new("sc1", "explicit_skill_reference", 1.0, "Prompt names the skill"),
};
var scoreWithPrompts = OverfittingJudge.ComputeOverfittingScore(rubric, assertions, prompts);
var scoreWithout = OverfittingJudge.ComputeOverfittingScore(rubric, assertions);
// With prompt issues: 0.4*1.0 + 0.4*0.0 + 0.2*0.0 = 0.4
// Without: 0.7*0.0 + 0.3*0.0 = 0.0
Assert.True(scoreWithPrompts > scoreWithout,
$"Score with prompts ({scoreWithPrompts}) should exceed score without ({scoreWithout})");
Assert.True(scoreWithPrompts >= 0.4,
$"Score with prompt issues should be at least 0.4, got {scoreWithPrompts}");
}
[Fact]
public void ComputeScore_AllScenariosExplicitRef_HighScore()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "criterion1", "outcome", 0.9, "Good"),
new("sc2", "criterion2", "outcome", 0.8, "Good"),
};
var assertions = new List<AssertionOverfitAssessment>
{
new("sc1", "output_matches: pattern", "broad", 0.9, "Broad"),
};
var prompts = new List<PromptOverfitAssessment>
{
new("sc1", "explicit_skill_reference", 1.0, "Names skill"),
new("sc2", "explicit_skill_reference", 1.0, "Names skill"),
};
var score = OverfittingJudge.ComputeOverfittingScore(rubric, assertions, prompts);
// 0.4*1.0 + 0.4*0.0 + 0.2*0.0 = 0.4
Assert.Equal(0.4, score, 2);
}
[Fact]
public void ComputeScore_PromptIssuesWithVocabulary_CompoundsHigh()
{
var rubric = new List<RubricOverfitAssessment>
{
new("sc1", "Uses specific term", "vocabulary", 0.9, "Vocabulary test"),
};
var assertions = new List<AssertionOverfitAssessment>
{
new("sc1", "output_matches: specific-pattern", "narrow", 0.85, "Narrow match"),
};
var prompts = new List<PromptOverfitAssessment>
{
new("sc1", "explicit_skill_reference", 1.0, "Names skill"),
};
var score = OverfittingJudge.ComputeOverfittingScore(rubric, assertions, prompts);
// 0.4*1.0 + 0.4*(1.0*0.9) + 0.2*(1.0*0.85) = 0.4 + 0.36 + 0.17 = 0.93
Assert.True(score > 0.8, $"Combined prompt+vocabulary should produce high score, got {score}");
}
// --- ParseResponse with prompt assessments ---
[Fact]
public void ParseResponse_WithPromptAssessments_ParsesCorrectly()
{
var json = JsonSerializer.Serialize(new
{
rubric_assessments = new[]
{
new { scenario = "sc1", criterion = "test", classification = "outcome", confidence = 0.9, reasoning = "ok" }
},
assertion_assessments = Array.Empty<object>(),
prompt_assessments = new[]
{
new { scenario = "sc1", issue = "explicit_skill_reference", confidence = 1.0, reasoning = "Prompt names the skill" }
},
cross_scenario_issues = Array.Empty<string>(),
overall_overfitting_score = 0.5,
overall_reasoning = "Prompt overfitting detected"
});
var result = OverfittingJudge.ParseOverfittingResponse(json);
Assert.Single(result.PromptAssessments);
Assert.Equal("explicit_skill_reference", result.PromptAssessments[0].Issue);
Assert.Equal(1.0, result.PromptAssessments[0].Confidence);
}
[Fact]
public void ParseResponse_DeterministicPromptsMergedWithLlm()
{
var json = JsonSerializer.Serialize(new
{
rubric_assessments = Array.Empty<object>(),
assertion_assessments = Array.Empty<object>(),
prompt_assessments = new[]
{
new { scenario = "sc1", issue = "explicit_skill_reference", confidence = 0.8, reasoning = "LLM detected" },
new { scenario = "sc2", issue = "explicit_skill_reference", confidence = 0.9, reasoning = "LLM detected" }
},
cross_scenario_issues = Array.Empty<string>(),
overall_overfitting_score = 0.5,
overall_reasoning = "test"
});
var deterministicAssessments = new List<PromptOverfitAssessment>
{
new("sc1", "explicit_skill_reference", 1.0, "Deterministic: prompt contains skill name")
};
var result = OverfittingJudge.ParseOverfittingResponse(json, deterministicAssessments);
// sc1: deterministic wins (same scenario+issue already covered), sc2: LLM addition
Assert.Equal(2, result.PromptAssessments.Count);
var sc1 = result.PromptAssessments.First(p => p.Scenario == "sc1");
Assert.Equal(1.0, sc1.Confidence); // deterministic confidence
Assert.Contains("Deterministic", sc1.Reasoning);
}
// --- BuildSystemPrompt includes prompt classification ---
[Fact]
public void BuildSystemPrompt_ContainsPromptClassification()
{
var prompt = OverfittingJudge.BuildSystemPrompt();
Assert.Contains("prompt_assessments", prompt);
Assert.Contains("explicit_skill_reference", prompt);
Assert.Contains("skill_instruction", prompt);
Assert.Contains("neutral", prompt);
Assert.Contains("Scenario prompt classifications", prompt);
}
}