Files
YuliiaKovalova 76a5ec8cb3 Decouple agent runs from judging in skill-validator (--no-judge + cross-dir rejudge) (#769)
* Decouple agent runs from judging in skill-validator

Allow baseline and treatment agent runs to execute in one parallel pool, with
judging deferred to a final step, removing the serial precompute-baseline ->
evaluate barrier.

evaluate --no-judge: runs the requested agent arms and persists sessions.db +
metrics, but skips all judging and requires no baseline file (exits 0 on
successful runs). Forces session persistence; mutually exclusive with
--baseline-out/--baseline-from; requires --results-dir. Only the agent model is
validated (judge model recorded as metadata); warns when zero runs persist.

rejudge --baseline-dir: cross-directory judging that pairs each treatment run
with its baseline run by the BaselineStore key (prompt SHA + target SHA,
persisted in a new sessions.db baseline_key column), runs the same
pairwise/independent judges and pass/fail gates evaluate uses, and writes
verdicts/reports. Baseline and treatment must share --model (rejected if either
side mixes models); effective judge model resolves to --judge-model, else the
treatment DB's, else the baseline DB's, rejecting a persisted mismatch. The
inline per-run judging is extracted into a shared JudgeRunGroup helper reused by
both the same-dir and cross-dir paths (no duplicated judging logic).

Adds tests for baseline_key round-trip + legacy migration, ComputeScenarioKey,
cross-dir pairing, and model/judge-model compatibility. Updates README and
InvestigatingResults docs.

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

* Hoist ComputeScenarioKey to scenario level to avoid per-run fixture hashing

Compute the baseline key once per scenario instead of once per run, so
multi-run evaluations (--runs N) don't re-hash fixture files N times.
The key is threaded into ExecuteRun/ExecuteAgentRun as a parameter.

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

* Memoize scenario-key fixture hashing for the duration of an evaluation

Route the persisted baseline_key through an evaluation-scoped BaselineStore
cache (ComputeScenarioKeyCached) instead of the static ComputeScenarioKey, so
a scenario's setup fixtures are hashed at most once across every run, arm, and
scenario of an evaluation. When a baseline store already exists (--baseline-from
/--baseline-out) its warm input-hash cache is reused; otherwise a dedicated,
side-effect-free cache (BaselineStore.ForKeyCache) is threaded through.

Addresses the PR review comment about ComputeScenarioKey re-hashing large
fixture directories. Adds a test asserting the cached variant equals the
canonical static computation.

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

* Bump session schema version to 3 for the baseline_key column

The baseline_key column was added without bumping schema_info.version, so
external tools keying off the version couldn't detect the new column. Introduce
a single SchemaVersion constant (= 3), stamp it after migrations run, and drop
the drift-prone version literal from the raw CREATE SQL so SetSchemaInfo is the
sole source of truth. Update the schema-info and legacy-upgrade tests to expect
version 3.

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

* Fail fast on judging-dependent options under --no-judge; fix README prefix

Under --no-judge the noise test and overfitting fix cannot run (they require
judging), so reject --noise-skills-dir and --overfitting-fix up front instead
of silently ignoring them, mirroring the existing --baseline-out/--baseline-from
guard. Add EvaluateCommandTests covering these early rejections (they short-
circuit before any model/network call).

Also prefix the decoupled-runs README example with 'skill-validator' for
consistency with the other CLI invocations in the doc.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 13:35:33 +02:00

411 lines
16 KiB
C#

using System.Text.Json;
using SkillValidator;
using SkillValidator.Evaluate;
namespace SkillValidator.Tests;
public class BaselineStoreTests
{
private const string Model = "model-x";
private const string Judge = "judge-x";
private static RunResult MakeBaseline(double overallScore = 3, string output = "baseline output") =>
new(
new RunMetrics
{
TokenEstimate = 1000,
ToolCallCount = 4,
ToolCallBreakdown = new Dictionary<string, int> { ["bash"] = 4 },
AgentOutput = output,
TaskCompleted = true,
Events = [],
},
new JudgeResult([new RubricScore("Quality", overallScore, "ok")], overallScore, "fine"));
private static EvalScenario Scenario(string name, string prompt) => new(name, prompt);
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), $"sv-baseline-test-{Guid.NewGuid():N}.json");
[Fact]
public void ComputePromptSha_IsDeterministicAndPromptSensitive()
{
var a = BaselineStore.ComputePromptSha("do the thing");
var b = BaselineStore.ComputePromptSha("do the thing");
var c = BaselineStore.ComputePromptSha("do something else");
Assert.Equal(a, b);
Assert.NotEqual(a, c);
Assert.Equal(64, a.Length); // SHA-256 hex
}
[Fact]
public void SaveThenLoad_RoundTripsBaselinePerScenario()
{
var path = TempPath();
try
{
var store = BaselineStore.ForWrite(Model, Judge);
var s1 = Scenario("alpha", "prompt one");
var s2 = Scenario("beta", "prompt two");
store.Record(s1, runs: 5, MakeBaseline(overallScore: 4, output: "out-1"));
store.Record(s2, runs: 5, MakeBaseline(overallScore: 2, output: "out-2"));
store.Save(path);
Assert.True(File.Exists(path));
var loaded = BaselineStore.Load(path, Model, Judge);
Assert.True(loaded.IsReuse);
Assert.Equal(2, loaded.Count);
var b1 = loaded.TryGetBaseline(s1);
var b2 = loaded.TryGetBaseline(s2);
Assert.NotNull(b1);
Assert.NotNull(b2);
Assert.Equal("out-1", b1!.Metrics.AgentOutput);
Assert.Equal(4, b1.JudgeResult.OverallScore);
Assert.Equal("out-2", b2!.Metrics.AgentOutput);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ComputeScenarioKey_IsDeterministicAndSensitive()
{
var a = BaselineStore.ComputeScenarioKey(Scenario("s", "prompt one"), null);
var a2 = BaselineStore.ComputeScenarioKey(Scenario("s", "prompt one"), null);
var diffPrompt = BaselineStore.ComputeScenarioKey(Scenario("s", "prompt two"), null);
var diffTarget = BaselineStore.ComputeScenarioKey(Scenario("s", "prompt one") with { Rubric = ["x"] }, null);
Assert.Equal(a, a2); // stable for identical inputs
Assert.NotEqual(a, diffPrompt); // sensitive to prompt (prompt SHA)
Assert.NotEqual(a, diffTarget); // sensitive to evaluation criteria (target SHA)
// The key embeds both the prompt SHA and the target SHA.
Assert.Contains(BaselineStore.ComputePromptSha("prompt one"), a);
Assert.Contains(BaselineStore.ComputeTargetSha(Scenario("s", "prompt one"), null), a);
}
[Fact]
public void ComputeScenarioKeyCached_MatchesStaticAndIsStableAcrossCalls()
{
var cache = BaselineStore.ForKeyCache();
var scenario = Scenario("s", "prompt one");
var expected = BaselineStore.ComputeScenarioKey(scenario, null);
var first = cache.ComputeScenarioKeyCached(scenario, null);
var second = cache.ComputeScenarioKeyCached(scenario, null);
Assert.Equal(expected, first); // cached variant equals the canonical static computation
Assert.Equal(first, second); // repeated lookups (memoized) stay identical
}
[Fact]
public void Load_ThrowsOnModelMismatch()
{
var path = TempPath();
try
{
var store = BaselineStore.ForWrite(Model, Judge);
store.Record(Scenario("alpha", "prompt one"), runs: 3, MakeBaseline());
store.Save(path);
var ex = Assert.Throws<InvalidOperationException>(() => BaselineStore.Load(path, "model-y", Judge));
Assert.Contains(Model, ex.Message);
Assert.Contains("model-y", ex.Message);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Load_ThrowsOnJudgeModelMismatch()
{
var path = TempPath();
try
{
var store = BaselineStore.ForWrite(Model, Judge);
store.Record(Scenario("alpha", "prompt one"), runs: 3, MakeBaseline());
store.Save(path);
var ex = Assert.Throws<InvalidOperationException>(() => BaselineStore.Load(path, Model, "judge-y"));
Assert.Contains(Judge, ex.Message);
Assert.Contains("judge-y", ex.Message);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Load_ThrowsOnUnsupportedVersion()
{
var path = TempPath();
try
{
var file = new BaselineFile(
Version: BaselineStore.CurrentVersion + 1,
Model: Model,
JudgeModel: Judge,
ValidatorVersion: "9.9.9",
CreatedAt: DateTime.UtcNow.ToString("o"),
Scenarios: []);
File.WriteAllText(path, JsonSerializer.Serialize(file, SkillValidatorJsonContext.Default.BaselineFile));
var ex = Assert.Throws<InvalidOperationException>(() => BaselineStore.Load(path, Model, Judge));
Assert.Contains("unsupported version", ex.Message);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Load_ThrowsWhenFileMissing()
{
Assert.Throws<FileNotFoundException>(() => BaselineStore.Load(TempPath(), Model, Judge));
}
[Fact]
public void FindMissingScenarios_ReturnsScenariosWithoutCachedBaseline()
{
var path = TempPath();
try
{
var store = BaselineStore.ForWrite(Model, Judge);
var present = Scenario("alpha", "prompt one");
store.Record(present, runs: 5, MakeBaseline());
store.Save(path);
var loaded = BaselineStore.Load(path, Model, Judge);
var missing = loaded.FindMissingScenarios([(present, null), (Scenario("beta", "prompt two"), null)]);
Assert.Single(missing);
Assert.StartsWith("beta", missing[0]);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void WriteStore_IsNotReuse()
{
var store = BaselineStore.ForWrite(Model, Judge);
Assert.False(store.IsReuse);
Assert.Null(store.TryGetBaseline(Scenario("alpha", "prompt one")));
}
private static string MakeEvalDirWithFixture(string fixtureName, string fixtureContent)
{
var dir = Path.Combine(Path.GetTempPath(), $"sv-baseline-fixture-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "eval.yaml"), "scenarios: []");
File.WriteAllText(Path.Combine(dir, fixtureName), fixtureContent);
return Path.Combine(dir, "eval.yaml");
}
private static EvalScenario FixtureScenario(string name, string prompt) =>
new(name, prompt, new SetupConfig(CopyTestFiles: true));
[Fact]
public void ComputeTargetSha_DiffersByFixtureContentAndIsStable()
{
var evalA = MakeEvalDirWithFixture("build.binlog", "AAAA");
var evalB = MakeEvalDirWithFixture("build.binlog", "BBBB");
try
{
var scenario = FixtureScenario("s", "investigate build.binlog");
var shaA1 = BaselineStore.ComputeTargetSha(scenario, evalA);
var shaA2 = BaselineStore.ComputeTargetSha(scenario, evalA);
var shaB = BaselineStore.ComputeTargetSha(scenario, evalB);
Assert.Equal(shaA1, shaA2); // stable for identical inputs
Assert.NotEqual(shaA1, shaB); // sensitive to fixture content
Assert.Equal(64, shaA1.Length);
// No setup → a stable, distinct constant.
var noSetup = BaselineStore.ComputeTargetSha(Scenario("s", "investigate build.binlog"), evalA);
Assert.NotEqual(shaA1, noSetup);
}
finally
{
Directory.Delete(Path.GetDirectoryName(evalA)!, recursive: true);
Directory.Delete(Path.GetDirectoryName(evalB)!, recursive: true);
}
}
[Fact]
public void ComputeTargetSha_DiffersByEvaluationCriteria()
{
const string prompt = "investigate the failure";
var baseScenario = Scenario("s", prompt);
var withRubric = baseScenario with { Rubric = ["Did it find the root cause?"] };
var withAssertion = baseScenario with { Assertions = [new Assertion(AssertionType.OutputContains, Value: "error")] };
var withTurns = baseScenario with { MaxTurns = 5 };
var withExpectTools = baseScenario with { ExpectTools = ["bash"] };
var shaBase = BaselineStore.ComputeTargetSha(baseScenario, null);
// Each criterion that shapes the cached result must change the identity.
Assert.NotEqual(shaBase, BaselineStore.ComputeTargetSha(withRubric, null));
Assert.NotEqual(shaBase, BaselineStore.ComputeTargetSha(withAssertion, null));
Assert.NotEqual(shaBase, BaselineStore.ComputeTargetSha(withTurns, null));
Assert.NotEqual(shaBase, BaselineStore.ComputeTargetSha(withExpectTools, null));
// Same criteria → stable identity.
Assert.Equal(
BaselineStore.ComputeTargetSha(withRubric, null),
BaselineStore.ComputeTargetSha(baseScenario with { Rubric = ["Did it find the root cause?"] }, null));
}
[Fact]
public void Record_IsFirstWriterWins_ForSameScenarioIdentity()
{
var path = TempPath();
try
{
var store = BaselineStore.ForWrite(Model, Judge);
var scenario = Scenario("alpha", "prompt one");
// Same identity recorded twice (e.g. two parallel targets sharing a scenario)
// with differing run-to-run results: the first record must win so --baseline-out
// is deterministic regardless of completion order.
store.Record(scenario, runs: 5, MakeBaseline(output: "first"));
store.Record(scenario, runs: 5, MakeBaseline(output: "second"));
Assert.Equal(1, store.Count);
store.Save(path);
var loaded = BaselineStore.Load(path, Model, Judge);
Assert.Equal("first", loaded.TryGetBaseline(scenario)!.Metrics.AgentOutput);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ComputeTargetSha_IncludesNestedFixtureFiles()
{
// copy_test_files copies subdirectories recursively, so nested fixture content
// must participate in the target identity (mirrors AgentRunner.CopyDirectory).
var evalPath = MakeEvalDirWithFixture("top.txt", "top");
var evalDir = Path.GetDirectoryName(evalPath)!;
var nestedDir = Path.Combine(evalDir, "sub");
Directory.CreateDirectory(nestedDir);
var nestedFile = Path.Combine(nestedDir, "data.bin");
File.WriteAllText(nestedFile, "v1");
try
{
var scenario = FixtureScenario("s", "investigate");
var before = BaselineStore.ComputeTargetSha(scenario, evalPath);
File.WriteAllText(nestedFile, "v2");
var after = BaselineStore.ComputeTargetSha(scenario, evalPath);
Assert.NotEqual(before, after); // nested file change invalidates reuse
}
finally
{
Directory.Delete(evalDir, recursive: true);
}
}
[Fact]
public void ComputeTargetSha_HashesFixtures_WhenEvalPathIsBareFilename()
{
// A bare filename (no directory component) must still hash sibling fixtures:
// Path.GetDirectoryName returns "" for "eval.yaml", so without normalization
// fixture hashing is silently skipped and distinct fixtures collide.
var evalPath = MakeEvalDirWithFixture("build.binlog", "AAAA");
var evalDir = Path.GetDirectoryName(evalPath)!;
var originalCwd = Directory.GetCurrentDirectory();
try
{
Directory.SetCurrentDirectory(evalDir);
var scenario = FixtureScenario("s", "investigate build.binlog");
var shaA = BaselineStore.ComputeTargetSha(scenario, "eval.yaml");
File.WriteAllText(Path.Combine(evalDir, "build.binlog"), "BBBB");
var shaB = BaselineStore.ComputeTargetSha(scenario, "eval.yaml");
Assert.NotEqual(shaA, shaB); // fixture content participates in identity
}
finally
{
Directory.SetCurrentDirectory(originalCwd);
Directory.Delete(evalDir, recursive: true);
}
}
[Fact]
public void Clone_ProducesIndependentCopy()
{
var source = MakeBaseline(output: "src").Metrics;
source.JudgeInputTokens = 10;
source.ToolCallBreakdown["bash"] = 4;
var clone = source.Clone();
clone.JudgeInputTokens = 99;
clone.ToolCallBreakdown["bash"] = 1;
clone.AssertionResults.Add(new AssertionResult(new Assertion(AssertionType.OutputContains, Value: "x"), true, ""));
// Mutating the clone must not leak back into the source — the cached baseline
// can be reused concurrently across parallel target evaluations.
Assert.Equal(10, source.JudgeInputTokens);
Assert.Equal(4, source.ToolCallBreakdown["bash"]);
Assert.Empty(source.AssertionResults);
Assert.NotSame(source.ToolCallBreakdown, clone.ToolCallBreakdown);
Assert.NotSame(source.AssertionResults, clone.AssertionResults);
}
[Fact]
public void SamePromptDifferentFixture_DoesNotReuseBaseline()
{
var path = TempPath();
var evalA = MakeEvalDirWithFixture("build.binlog", "case-A-binlog");
var evalB = MakeEvalDirWithFixture("build.binlog", "case-B-binlog");
try
{
// Two cases share an identical prompt but feed different fixtures.
const string sharedPrompt = "The binlog is at build.binlog. What went wrong?";
var scenarioA = FixtureScenario("case-A", sharedPrompt);
var scenarioB = FixtureScenario("case-B", sharedPrompt);
// Persist a baseline only for case A.
var store = BaselineStore.ForWrite(Model, Judge);
store.Record(scenarioA, runs: 5, MakeBaseline(output: "A-baseline"), evalA);
store.Save(path);
var loaded = BaselineStore.Load(path, Model, Judge);
// Case A reuses its baseline; case B must NOT (different targetSha).
Assert.NotNull(loaded.TryGetBaseline(scenarioA, evalA));
Assert.Equal("A-baseline", loaded.TryGetBaseline(scenarioA, evalA)!.Metrics.AgentOutput);
Assert.Null(loaded.TryGetBaseline(scenarioB, evalB));
// FindMissingScenarios surfaces case B (with its eval path) despite the shared prompt.
var missing = loaded.FindMissingScenarios([(scenarioA, evalA), (scenarioB, evalB)]);
Assert.Single(missing);
Assert.StartsWith("case-B", missing[0]);
Assert.Contains(evalB, missing[0]);
}
finally
{
File.Delete(path);
Directory.Delete(Path.GetDirectoryName(evalA)!, recursive: true);
Directory.Delete(Path.GetDirectoryName(evalB)!, recursive: true);
}
}
}