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

442 lines
17 KiB
C#

using System.Text.Json;
using Microsoft.Data.Sqlite;
using SkillValidator.Evaluate;
using SkillValidator.Shared;
namespace SkillValidator.Tests;
public class SessionDatabaseTests : IDisposable
{
private readonly string _dbPath;
private readonly SessionDatabase _db;
public SessionDatabaseTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test-sessions-{Guid.NewGuid()}.db");
_db = new SessionDatabase(_dbPath);
}
public void Dispose()
{
_db.Dispose();
// Clear SQLite connection pool so file handles are fully released
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
TryDelete(_dbPath);
TryDelete(_dbPath + "-wal");
TryDelete(_dbPath + "-shm");
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort cleanup */ }
}
[Fact]
public void RegisterAndComplete_RoundTrips()
{
var rubricJson = JsonSerializer.Serialize(new[] { "Quality", "Completeness" });
_db.RegisterSession("s1", "my-skill", "/path/to/skill", "scenario-a", 0, "baseline", "gpt-4.1", "sessions/s1", "/work", "Fix the bug", "abcdef012345", rubricJson);
_db.CompleteSession("s1", "completed", """{"TokenEstimate":100}""");
var sessions = _db.GetCompletedSessions();
var s = Assert.Single(sessions);
Assert.Equal("s1", s.Id);
Assert.Equal("my-skill", s.SkillName);
Assert.Equal("/path/to/skill", s.SkillPath);
Assert.Equal("scenario-a", s.ScenarioName);
Assert.Equal(0, s.RunIndex);
Assert.Equal("baseline", s.Role);
Assert.Equal("gpt-4.1", s.Model);
Assert.Equal("sessions/s1", s.ConfigDir);
Assert.Equal("completed", s.Status);
Assert.Equal("Fix the bug", s.Prompt);
Assert.Equal("abcdef012345", s.SkillSha);
Assert.Equal(rubricJson, s.RubricJson);
Assert.Equal("""{"TokenEstimate":100}""", s.MetricsJson);
Assert.Null(s.JudgeJson);
Assert.Null(s.PairwiseJson);
}
[Fact]
public void SaveJudgeResult_UpdatesExistingRow()
{
_db.RegisterSession("s1", "skill", "/p", "scn", 0, "baseline", "model", null, null);
_db.CompleteSession("s1", "completed", "{}");
_db.SaveJudgeResult("s1", """{"OverallScore":4}""");
var s = Assert.Single(_db.GetCompletedSessions());
Assert.Equal("""{"OverallScore":4}""", s.JudgeJson);
}
[Fact]
public void SavePairwiseResult_UpdatesExistingRow()
{
_db.RegisterSession("s1", "skill", "/p", "scn", 0, "baseline", "model", null, null);
_db.CompleteSession("s1", "completed", "{}");
_db.SavePairwiseResult("s1", """{"Winner":"with-skill"}""");
var s = Assert.Single(_db.GetCompletedSessions());
Assert.Equal("""{"Winner":"with-skill"}""", s.PairwiseJson);
}
[Fact]
public void RegisterWithoutPromptOrSkillSha_StoresNulls()
{
_db.RegisterSession("s1", "skill", "/p", "scn", 0, "baseline", "model", null, null);
_db.CompleteSession("s1", "completed", "{}");
var s = Assert.Single(_db.GetCompletedSessions());
Assert.Null(s.Prompt);
Assert.Null(s.SkillSha);
Assert.Null(s.RubricJson);
}
[Fact]
public void GetCompletedSessions_ExcludesRunning()
{
_db.RegisterSession("s1", "skill", "/p", "scn", 0, "baseline", "model", null, null);
// Never completed — should not appear
var sessions = _db.GetCompletedSessions();
Assert.Empty(sessions);
}
[Fact]
public void GetCompletedSessions_IncludesTimedOut()
{
_db.RegisterSession("s1", "skill", "/p", "scn", 0, "baseline", "model", null, null);
_db.CompleteSession("s1", "timed_out", "{}");
var sessions = _db.GetCompletedSessions();
Assert.Single(sessions);
Assert.Equal("timed_out", sessions[0].Status);
}
[Fact]
public void MultipleSessions_OrderedCorrectly()
{
// Register pairs for two scenarios
_db.RegisterSession("b0", "skill", "/p", "alpha", 0, "baseline", "m", null, null);
_db.RegisterSession("w0", "skill", "/p", "alpha", 0, "with-skill", "m", null, null);
_db.RegisterSession("b1", "skill", "/p", "beta", 0, "baseline", "m", null, null);
_db.RegisterSession("w1", "skill", "/p", "beta", 0, "with-skill", "m", null, null);
_db.CompleteSession("b0", "completed", "{}");
_db.CompleteSession("w0", "completed", "{}");
_db.CompleteSession("b1", "completed", "{}");
_db.CompleteSession("w1", "completed", "{}");
var sessions = _db.GetCompletedSessions();
Assert.Equal(4, sessions.Count);
// Ordered by skill_name, scenario_name, run_index, role
Assert.Equal("alpha", sessions[0].ScenarioName);
Assert.Equal("baseline", sessions[0].Role);
Assert.Equal("alpha", sessions[1].ScenarioName);
Assert.Equal("with-skill", sessions[1].Role);
Assert.Equal("beta", sessions[2].ScenarioName);
}
[Fact]
public async Task ConcurrentWrites_DoNotCorrupt()
{
const int count = 20;
var tasks = Enumerable.Range(0, count).Select(i => Task.Run(() =>
{
var id = $"s{i}";
_db.RegisterSession(id, "skill", "/p", "scn", i, i % 2 == 0 ? "baseline" : "with-skill", "m", null, null);
_db.CompleteSession(id, "completed", $"{{\"Index\":{i}}}");
_db.SaveJudgeResult(id, $"{{\"Score\":{i}}}");
}));
await Task.WhenAll(tasks);
var sessions = _db.GetCompletedSessions();
Assert.Equal(count, sessions.Count);
Assert.All(sessions, s =>
{
Assert.Equal("completed", s.Status);
Assert.NotNull(s.MetricsJson);
Assert.NotNull(s.JudgeJson);
});
}
[Fact]
public void ComputeDirectorySha_IsDeterministic()
{
var dir = Path.Combine(Path.GetTempPath(), $"sha-test-{Guid.NewGuid()}");
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "SKILL.md"), "# Test Skill");
File.WriteAllText(Path.Combine(dir, "plugin.json"), "{}");
var sha1 = SessionDatabase.ComputeDirectorySha(dir);
var sha2 = SessionDatabase.ComputeDirectorySha(dir);
Assert.Equal(sha1, sha2);
Assert.Equal(12, sha1.Length);
// Changing content produces a different SHA
File.WriteAllText(Path.Combine(dir, "SKILL.md"), "# Modified");
var sha3 = SessionDatabase.ComputeDirectorySha(dir);
Assert.NotEqual(sha1, sha3);
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void ComputeDirectorySha_DistinguishesPathAndContentBoundaries()
{
var dir1 = Path.Combine(Path.GetTempPath(), $"sha-boundary-a-{Guid.NewGuid()}");
var dir2 = Path.Combine(Path.GetTempPath(), $"sha-boundary-b-{Guid.NewGuid()}");
Directory.CreateDirectory(dir1);
Directory.CreateDirectory(dir2);
try
{
File.WriteAllText(Path.Combine(dir1, "a"), "12");
File.WriteAllText(Path.Combine(dir1, "b"), "34");
File.WriteAllText(Path.Combine(dir2, "a1"), "2");
File.WriteAllText(Path.Combine(dir2, "b"), "34");
Assert.NotEqual(
SessionDatabase.ComputeDirectorySha(dir1),
SessionDatabase.ComputeDirectorySha(dir2));
}
finally
{
Directory.Delete(dir1, true);
Directory.Delete(dir2, true);
}
}
[Fact]
public void SeparateDbFiles_AreIndependent()
{
// Simulates two concurrent eval processes using different result dirs
var dbPath2 = Path.Combine(Path.GetTempPath(), $"test-sessions-{Guid.NewGuid()}.db");
try
{
using var db2 = new SessionDatabase(dbPath2);
_db.RegisterSession("s1", "skill-a", "/a", "scn", 0, "baseline", "m", null, null);
_db.CompleteSession("s1", "completed", "{}");
db2.RegisterSession("s1", "skill-b", "/b", "scn", 0, "baseline", "m", null, null);
db2.CompleteSession("s1", "completed", "{}");
// Each DB has exactly one session with different skill names
var sessions1 = _db.GetCompletedSessions();
var sessions2 = db2.GetCompletedSessions();
Assert.Single(sessions1);
Assert.Single(sessions2);
Assert.Equal("skill-a", sessions1[0].SkillName);
Assert.Equal("skill-b", sessions2[0].SkillName);
}
finally
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
TryDelete(dbPath2);
TryDelete(dbPath2 + "-wal");
TryDelete(dbPath2 + "-shm");
}
}
[Fact]
public void SchemaInfo_ContainsTypeAndVersion()
{
var info = _db.GetSchemaInfo();
Assert.Equal("skill-validator", info["type"]);
Assert.Equal("3", info["version"]);
}
[Fact]
public void SchemaInfo_CanPersistJudgeModel()
{
_db.SetSchemaInfo("judge_model", "claude-opus-4.6");
var info = _db.GetSchemaInfo();
Assert.Equal("claude-opus-4.6", info["judge_model"]);
}
[Fact]
public void RegisterSession_RoundTripsBaselineKey()
{
_db.RegisterSession("s1", "my-skill", "/path", "scenario-a", 0, "baseline", "gpt-4.1",
"sessions/s1", "/work", "Fix the bug", "abc123", null, "promptsha:targetsha");
_db.CompleteSession("s1", "completed", "{}");
var s = Assert.Single(_db.GetCompletedSessions());
Assert.Equal("promptsha:targetsha", s.BaselineKey);
}
[Fact]
public void RegisterSession_NullBaselineKey_RoundTrips()
{
_db.RegisterSession("s1", "my-skill", "/path", "scenario-a", 0, "baseline", "gpt-4.1", "sessions/s1", null);
_db.CompleteSession("s1", "completed", "{}");
var s = Assert.Single(_db.GetCompletedSessions());
Assert.Null(s.BaselineKey);
}
[Fact]
public void LegacyDatabase_UpgradesBaselineKeyColumn()
{
var legacyDbPath = Path.Combine(Path.GetTempPath(), $"legacy-bk-sessions-{Guid.NewGuid()}.db");
try
{
using (var connection = new SqliteConnection($"Data Source={legacyDbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE schema_info (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO schema_info (key, value) VALUES ('type', 'skill-validator');
INSERT INTO schema_info (key, value) VALUES ('version', '1');
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
skill_name TEXT NOT NULL,
skill_path TEXT NOT NULL,
scenario_name TEXT NOT NULL,
run_index INTEGER NOT NULL,
role TEXT NOT NULL,
model TEXT NOT NULL,
config_dir TEXT,
work_dir TEXT,
prompt TEXT,
skill_sha TEXT,
rubric TEXT,
status TEXT NOT NULL DEFAULT 'running',
started_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE run_results (
session_id TEXT PRIMARY KEY REFERENCES sessions(id),
metrics_json TEXT NOT NULL,
judge_json TEXT,
pairwise_json TEXT
);
INSERT INTO sessions (id, skill_name, skill_path, scenario_name, run_index, role, model, status, started_at, completed_at)
VALUES ('s1', 'skill', '/p', 'scn', 0, 'baseline', 'model', 'completed', '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z');
INSERT INTO run_results (session_id, metrics_json) VALUES ('s1', '{}');
""";
cmd.ExecuteNonQuery();
}
using var upgradedDb = new SessionDatabase(legacyDbPath);
var legacySession = Assert.Single(upgradedDb.GetCompletedSessions());
Assert.Null(legacySession.BaselineKey);
upgradedDb.RegisterSession("s2", "skill", "/p", "scn", 1, "with-skill", "model",
null, null, "Prompt", null, null, "key-2");
upgradedDb.CompleteSession("s2", "completed", "{}");
var upgradedSession = Assert.Single(upgradedDb.GetCompletedSessions(), s => s.Id == "s2");
Assert.Equal("key-2", upgradedSession.BaselineKey);
}
finally
{
SqliteConnection.ClearAllPools();
TryDelete(legacyDbPath);
TryDelete(legacyDbPath + "-wal");
TryDelete(legacyDbPath + "-shm");
}
}
[Fact]
public void LegacyDatabase_UpgradesRubricColumn()
{
var legacyDbPath = Path.Combine(Path.GetTempPath(), $"legacy-sessions-{Guid.NewGuid()}.db");
try
{
using (var connection = new SqliteConnection($"Data Source={legacyDbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE schema_info (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO schema_info (key, value) VALUES ('type', 'skill-validator');
INSERT INTO schema_info (key, value) VALUES ('version', '1');
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
skill_name TEXT NOT NULL,
skill_path TEXT NOT NULL,
scenario_name TEXT NOT NULL,
run_index INTEGER NOT NULL,
role TEXT NOT NULL,
model TEXT NOT NULL,
config_dir TEXT,
work_dir TEXT,
prompt TEXT,
skill_sha TEXT,
status TEXT NOT NULL DEFAULT 'running',
started_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE run_results (
session_id TEXT PRIMARY KEY REFERENCES sessions(id),
metrics_json TEXT NOT NULL,
judge_json TEXT,
pairwise_json TEXT
);
INSERT INTO sessions (id, skill_name, skill_path, scenario_name, run_index, role, model, status, started_at, completed_at)
VALUES ('s1', 'skill', '/p', 'scn', 0, 'baseline', 'model', 'completed', '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z');
INSERT INTO run_results (session_id, metrics_json) VALUES ('s1', '{}');
""";
cmd.ExecuteNonQuery();
}
using var upgradedDb = new SessionDatabase(legacyDbPath);
var legacySession = Assert.Single(upgradedDb.GetCompletedSessions());
Assert.Null(legacySession.RubricJson);
Assert.Equal("3", upgradedDb.GetSchemaInfo()["version"]);
var rubricJson = JsonSerializer.Serialize(new[] { "Quality" });
upgradedDb.RegisterSession("s2", "skill", "/p", "scn", 1, "with-skill", "model", null, null, "Prompt", null, rubricJson);
upgradedDb.CompleteSession("s2", "completed", "{}");
var upgradedSession = Assert.Single(upgradedDb.GetCompletedSessions(), s => s.Id == "s2");
Assert.Equal(rubricJson, upgradedSession.RubricJson);
}
finally
{
SqliteConnection.ClearAllPools();
TryDelete(legacyDbPath);
TryDelete(legacyDbPath + "-wal");
TryDelete(legacyDbPath + "-shm");
}
}
[Fact]
public void CompleteSession_RequiresExistingSession()
{
Assert.Throws<Microsoft.Data.Sqlite.SqliteException>(() =>
_db.CompleteSession("missing", "completed", "{}"));
}
[Fact]
public void ConfigDir_StoredAsRelativePath()
{
_db.RegisterSession("s1", "skill", "/p", "scn", 0, "baseline", "m", "sessions/s1", null);
_db.CompleteSession("s1", "completed", "{}");
var s = Assert.Single(_db.GetCompletedSessions());
Assert.Equal("sessions/s1", s.ConfigDir);
}
}