Files
Abhitej John 78054c1161 Migrate LLM evals to the Vally harness (#877)
* Make Vally the sole LLM eval engine, retiring skill-validator evaluate

Collapses the parallel skill-validator + Vally eval pipeline into a single
Vally-only path while preserving every capability skill-validator provided:
PR gate, PR comment, dotnet/skills-data historical push, and the dashboard.
The skill-validator `check` linter is retained (skill-check.yml) pending
microsoft/vally #463.

- evaluation.yml: delete build-validator and the skill-validator `evaluate`
  job; single `evaluate:` job now uses vally-evaluation.yml. Forward the
  COPILOT_PAT_0..9 pool onto it (from #911) so the reusable workflow's token
  gate is satisfied. Rewire comment-on-pr / report-status / publish-* /
  deploy-dashboard onto `evaluate` and vally-results-* artifacts.
- vally-evaluation.yml: pin @microsoft/vally-cli@0.9.0; keep the #911 prepare
  guard; write the generated per-plugin experiment to $GITHUB_WORKSPACE so
  relative eval globs resolve.
- adapt.mjs: reconcile #887's runCompareWithRetry + conclusive/unmatched
  hardening with the consolidation helpers (nonActivation, compareByStim,
  roleToDashboard, etc.). Add consolidate.mjs and gen-experiment.mjs.
- build-replay-sessions.ps1: read Vally executor-session-logs/events.jsonl
  instead of skill-validator session output.
- Rename all tests/*/*/eval.vally.yaml to eval.yaml (Vally format is now the
  only eval format); author Vally evals for the four skills that lacked one.
- CONTRIBUTING.md + InvestigatingResults: point contributors at the Vally
  harness; drop non-public links.

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

* Fix setup-local-sdk eval for Vally schema

The hand-authored setup-local-sdk eval used skill-validator grader/environment
schema that Vally rejects at validation (no stimuli executed):
- file-contains/file-not-contains graders require 'value:' not 'substring:'
  (output-contains correctly keeps 'substring:').
- environment.files entries require 'src:'/'dest:' (copy a fixture) rather than
  inline 'path:'/'content:'. Moved the global.json body into fixtures/global.json.

Verified with 'vally lint --eval-spec' (0 errors) across all 97 eval.yaml.

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

* Fix markdownlint violations in migration docs

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

* Address PR review feedback on eval migration

- Consolidate run-vally-evals.sh into a single neutral entrypoint
  eng/run-skill-evals.sh (deletes the thin wrapper; adapt.mjs stays in
  eng/vally-adapter/ since CI invokes it directly)
- Rename reusable workflow vally-evaluation.yml -> evaluation-run.yml and
  update all references/self-trigger regexes
- Rename local output dir vally-results -> eval-results
- Bake prerequisite preflight (Node 20+, GITHUB_TOKEN/gh) into the runner
- Simplify CONTRIBUTING "Running tests locally" prerequisites
- Scrub harness name from consumer-facing surfaces

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

* Reinstate overfitting detection under the Vally harness

Overfitting detection previously ran inside skill-validator's `evaluate`
command, which the Vally migration replaced, so it silently stopped running.
Reinstate it as a standalone step wired into the Vally pipeline:

- Add `skill-validator overfitting` command that discovers skills, parses
  each eval.yaml, runs the existing OverfittingJudge, and emits
  [{plugin, skill, overfittingResult}] JSON (per-skill failures non-fatal,
  bounded parallelism).
- Add a Vally-format parser bridge (ParseEvalConfigFlexible) so the judge
  handles the current stimuli/graders eval.yaml format; the legacy
  scenarios-only parser rejected all 98 evals, so the judge never ran.
- adapt.mjs: merge overfitting results onto each verdict via
  --overfitting <file> (keyed by plugin/skill); output is byte-identical
  when the flag is absent.
- evaluation-run.yml: build skill-validator (shared cache with check),
  run the judge per leg reading GITHUB_TOKEN, and pass --overfitting to
  adapt.mjs so the dashboard/skills-data consume it unchanged.

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

* Reconcile #919 Vally eval configs into the sole eval.yaml convention

PR #919 landed on main adding fresh Vally specs for three coverage-gap
skills (setup-local-sdk, convert-blazor-server-to-webapp, dotnet-webapi)
under the interim eval.vally.yaml filename. This branch collapses to a
single Vally spec named eval.yaml, so the merge left each skill with both
my migrated eval.yaml and #919's newer eval.vally.yaml (a git-clean but
semantic duplicate the pipeline would not discover).

Adopt #919's reviewed Vally content as the canonical eval.yaml for all
three skills and drop the redundant eval.vally.yaml files, preserving the
'sole eval.yaml, zero eval.vally.yaml' invariant. Content is byte-identical
to #919's blobs (b14121c6 / 47d43823 / 18a9362f), which already passed
vally-evaluate on main.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 15:22:31 -07:00

161 lines
6.3 KiB
C#

using System.Text.Json;
using SkillValidator;
using SkillValidator.Evaluate;
namespace SkillValidator.Tests;
public class OverfittingCommandTests
{
[Theory]
[InlineData("tests/dotnet-msbuild/build-perf-baseline/eval.yaml", "dotnet-msbuild", "build-perf-baseline")]
[InlineData("tests/dotnet/csharp-scripts/eval.yaml", "dotnet", "csharp-scripts")]
public void DeriveIdentity_ExtractsPluginAndSkillFromNestedEvalPath(string evalPath, string expectedPlugin, string expectedSkill)
{
// Normalize to the platform separator so the test runs on Windows and Linux.
var native = evalPath.Replace('/', Path.DirectorySeparatorChar);
var (plugin, skill) = OverfittingCommand.DeriveIdentity(native);
Assert.Equal(expectedPlugin, plugin);
Assert.Equal(expectedSkill, skill);
}
[Fact]
public void OverfittingEntry_SerializesToCamelCaseWithStringSeverity()
{
var result = new OverfittingResult(
Score: 0.42,
Severity: OverfittingSeverity.Moderate,
RubricAssessments: new List<RubricOverfitAssessment>
{
new("sc1", "criterion1", "vocabulary", 0.8, "Tests exact wording"),
},
AssertionAssessments: new List<AssertionOverfitAssessment>
{
new("sc1", "output_matches: foo", "narrow", 0.9, "Narrow match"),
},
PromptAssessments: new List<PromptOverfitAssessment>(),
CrossScenarioIssues: new List<string>(),
OverallReasoning: "example reasoning");
var entry = new OverfittingCommand.OverfittingEntry("dotnet-msbuild", "build-perf-baseline", result);
var json = JsonSerializer.Serialize(entry, SkillValidatorJsonContext.Default.OverfittingEntry);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Top-level keys are camelCase.
Assert.Equal("dotnet-msbuild", root.GetProperty("plugin").GetString());
Assert.Equal("build-perf-baseline", root.GetProperty("skill").GetString());
var overfit = root.GetProperty("overfittingResult");
Assert.Equal(0.42, overfit.GetProperty("score").GetDouble(), 3);
// Severity must serialize as a string, not a number (dashboard reads it as a string).
var severity = overfit.GetProperty("severity");
Assert.Equal(JsonValueKind.String, severity.ValueKind);
Assert.Equal("Moderate", severity.GetString());
// Nested collections use camelCase and preserve the rubric scenario field.
var rubric = overfit.GetProperty("rubricAssessments");
Assert.Equal(JsonValueKind.Array, rubric.ValueKind);
Assert.Equal("sc1", rubric[0].GetProperty("scenario").GetString());
}
[Fact]
public void OverfittingEntryList_SerializesAsArray()
{
var result = new OverfittingResult(
0.1,
OverfittingSeverity.Low,
new List<RubricOverfitAssessment>(),
new List<AssertionOverfitAssessment>(),
new List<PromptOverfitAssessment>(),
new List<string>(),
"ok");
var list = new List<OverfittingCommand.OverfittingEntry>
{
new("plugin-a", "skill-a", result),
};
var json = JsonSerializer.Serialize(list, SkillValidatorJsonContext.Default.ListOverfittingEntry);
using var doc = JsonDocument.Parse(json);
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal("skill-a", doc.RootElement[0].GetProperty("skill").GetString());
Assert.Equal("Low", doc.RootElement[0].GetProperty("overfittingResult").GetProperty("severity").GetString());
}
[Fact]
public void ParseEvalConfigFlexible_ReadsVallyStimuliFormat()
{
// The current on-disk eval.yaml schema (Vally-native): stimuli with a
// per-stimulus prompt, graders, and rubric. The legacy ParseEvalConfig
// rejects this ("must have at least one scenario"); the flexible parser
// must map it so the overfitting judge can run.
const string yaml = """
name: sample
description: A sample eval
type: capability
config:
timeout: 10m
stimuli:
- name: First stimulus
prompt: Do the thing without naming the skill.
graders:
- type: output-contains
config:
substring: global.json
- type: output-matches
config:
pattern: (paths|committed)
- type: prompt
rubric:
- The agent achieved the outcome
- The agent explained cleanup
- name: Second stimulus
prompt: Another request.
graders:
- type: output-contains
config:
substring: dotnet-install
""";
var cfg = EvalSchema.ParseEvalConfigFlexible(yaml);
Assert.NotNull(cfg);
Assert.Equal(2, cfg!.Scenarios.Count);
var first = cfg.Scenarios[0];
Assert.Equal("First stimulus", first.Name);
Assert.Equal("Do the thing without naming the skill.", first.Prompt);
// Rubric maps straight through (the judge classifies these for overfitting).
Assert.NotNull(first.Rubric);
Assert.Equal(2, first.Rubric!.Count);
Assert.Contains("The agent achieved the outcome", first.Rubric);
// Output graders map to assertions; the LLM-rubric "prompt" grader is skipped.
Assert.NotNull(first.Assertions);
Assert.Equal(2, first.Assertions!.Count);
Assert.Equal(AssertionType.OutputContains, first.Assertions[0].Type);
Assert.Equal("global.json", first.Assertions[0].Value);
Assert.Equal(AssertionType.OutputMatches, first.Assertions[1].Type);
Assert.Equal("(paths|committed)", first.Assertions[1].Pattern);
}
[Fact]
public void ParseEvalConfigFlexible_ReturnsNullWhenNoStimuliOrScenarios()
{
const string yaml = """
name: sample
description: An eval with neither stimuli nor scenarios
type: capability
""";
Assert.Null(EvalSchema.ParseEvalConfigFlexible(yaml));
}
}