d63619bd0e
Add FrozenHoldoutSplit with deterministic 15% ratio using dedicated hash seed. Produces frozen/dev partition with SplitGroup-aware leakage prevention — all contrastive variants of one seed stay in the same split. Add GroupedCVFolds for k-fold grouped cross-validation on the development pool. Each fold preserves split_group boundaries; every example appears in exactly one eval set across all folds. Tests verify determinism (same split → same hash), no split_group leakage across frozen/dev, route coverage in both pools, fold completeness, and grouped CV coverage.
290 lines
7.5 KiB
Go
290 lines
7.5 KiB
Go
package semantic
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
func TestValidRoute(t *testing.T) {
|
|
for _, r := range AllRoutes {
|
|
if !ValidRoute(r) {
|
|
t.Errorf("ValidRoute(%q) = false, want true", r)
|
|
}
|
|
}
|
|
if ValidRoute("bogus") {
|
|
t.Error("ValidRoute(\"bogus\") = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestIntentToRouteMapping(t *testing.T) {
|
|
cases := []struct {
|
|
intent router.Intent
|
|
route SemanticRoute
|
|
}{
|
|
{router.IntentChat, RouteConversation},
|
|
{router.IntentQuery, RouteKnowledge},
|
|
{router.IntentAct, RouteAction},
|
|
{router.IntentReminder, RouteAction},
|
|
{router.IntentFact, RouteMemoryWrite},
|
|
{router.IntentNote, RouteMemoryWrite},
|
|
{router.IntentSystem, RouteSystem},
|
|
{router.Intent("unknown"), RouteUncertain},
|
|
{router.Intent(""), RouteUncertain},
|
|
}
|
|
for _, c := range cases {
|
|
got := IntentToRoute(c.intent)
|
|
if got != c.route {
|
|
t.Errorf("IntentToRoute(%q) = %q, want %q", c.intent, got, c.route)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadCorpus(t *testing.T) {
|
|
exs, err := LoadCorpus()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exs) == 0 {
|
|
t.Fatal("corpus is empty")
|
|
}
|
|
for _, e := range exs {
|
|
if !ValidRoute(e.Route) {
|
|
t.Errorf("example %q has invalid route %q", e.SourceID, e.Route)
|
|
}
|
|
if e.Source == "" {
|
|
t.Errorf("example %q has empty source", e.SourceID)
|
|
}
|
|
if e.SplitGroup == "" {
|
|
t.Errorf("example %q has empty split_group", e.SourceID)
|
|
}
|
|
}
|
|
fastPath, residual := SplitCounts(exs)
|
|
if fastPath == 0 {
|
|
t.Error("no fast_path_resolved examples in corpus")
|
|
}
|
|
if residual == 0 {
|
|
t.Error("no residual examples in corpus")
|
|
}
|
|
t.Logf("corpus: %d examples (%d fast-path, %d residual)", len(exs), fastPath, residual)
|
|
}
|
|
|
|
func TestCorpusValidation(t *testing.T) {
|
|
exs, err := LoadCorpus()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stats := CorpusStatsFrom(exs)
|
|
|
|
// Total matches.
|
|
if stats.Total != len(exs) {
|
|
t.Errorf("stats.Total=%d, want %d", stats.Total, len(exs))
|
|
}
|
|
|
|
// Route counts sum to total.
|
|
routeSum := 0
|
|
for _, c := range stats.RouteCounts {
|
|
routeSum += c
|
|
}
|
|
if routeSum != stats.Total {
|
|
t.Errorf("route counts sum to %d, want %d", routeSum, stats.Total)
|
|
}
|
|
|
|
// Source counts sum to total.
|
|
sourceSum := 0
|
|
for _, c := range stats.SourceCounts {
|
|
sourceSum += c
|
|
}
|
|
if sourceSum != stats.Total {
|
|
t.Errorf("source counts sum to %d, want %d", sourceSum, stats.Total)
|
|
}
|
|
|
|
// Fast-path + residual == total.
|
|
if stats.FastPath+stats.Residual != stats.Total {
|
|
t.Errorf("fast_path(%d) + residual(%d) = %d, want %d",
|
|
stats.FastPath, stats.Residual, stats.FastPath+stats.Residual, stats.Total)
|
|
}
|
|
|
|
// All SourceIDs are valid (non-empty, checked by ValidateCorpus).
|
|
// All SplitGroups are non-empty (checked by ValidateCorpus).
|
|
// No duplicate corpus identity (checked by ValidateCorpus).
|
|
// No identical Text with conflicting labels (checked by ValidateCorpus).
|
|
|
|
t.Logf("validation passed: %d rows, route_hash=%s", stats.Total, stats.DatasetHash)
|
|
|
|
// Verify the exact counts match what we expect.
|
|
if stats.Total != 136 {
|
|
t.Errorf("total = %d, want 136", stats.Total)
|
|
}
|
|
if stats.SourceCounts["ru_routing_v1"] != 96 {
|
|
t.Errorf("ru_routing_v1 count = %d, want 96", stats.SourceCounts["ru_routing_v1"])
|
|
}
|
|
if stats.SourceCounts["contrastive"] != 40 {
|
|
t.Errorf("contrastive count = %d, want 40", stats.SourceCounts["contrastive"])
|
|
}
|
|
}
|
|
|
|
func TestCorpusByRoute(t *testing.T) {
|
|
exs, err := LoadCorpus()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
byRoute := ByRoute(exs)
|
|
for _, route := range AllRoutes {
|
|
count := len(byRoute[route])
|
|
if count == 0 {
|
|
t.Errorf("no examples for route %q", route)
|
|
}
|
|
t.Logf(" %s: %d examples", route, count)
|
|
}
|
|
}
|
|
|
|
func TestSplitByFamily(t *testing.T) {
|
|
exs, err := LoadCorpus()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
train, eval := SplitByFamily(exs, 0.8)
|
|
total := len(train) + len(eval)
|
|
if total != len(exs) {
|
|
t.Errorf("split lost examples: train=%d eval=%d total=%d, want %d",
|
|
len(train), len(eval), total, len(exs))
|
|
}
|
|
trainGroups := map[string]bool{}
|
|
for _, e := range train {
|
|
trainGroups[e.SplitGroup] = true
|
|
}
|
|
for _, e := range eval {
|
|
if trainGroups[e.SplitGroup] {
|
|
t.Errorf("split_group %q leaked across train/eval (example %q)",
|
|
e.SplitGroup, e.SourceID)
|
|
}
|
|
}
|
|
t.Logf("split: %d train, %d eval", len(train), len(eval))
|
|
}
|
|
|
|
func TestFrozenHoldoutSplit(t *testing.T) {
|
|
exs, err := LoadCorpus()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
frozen, dev, hash := FrozenHoldoutSplit(exs)
|
|
total := len(frozen) + len(dev)
|
|
if total != len(exs) {
|
|
t.Errorf("split lost examples: frozen=%d dev=%d total=%d, want %d",
|
|
len(frozen), len(dev), total, len(exs))
|
|
}
|
|
if len(frozen) == 0 {
|
|
t.Error("frozen holdout is empty")
|
|
}
|
|
if len(dev) == 0 {
|
|
t.Error("development pool is empty")
|
|
}
|
|
|
|
// No split_group leakage.
|
|
frozenGroups := map[string]bool{}
|
|
for _, e := range frozen {
|
|
frozenGroups[e.SplitGroup] = true
|
|
}
|
|
for _, e := range dev {
|
|
if frozenGroups[e.SplitGroup] {
|
|
t.Errorf("split_group %q leaked across frozen/dev (example %q)",
|
|
e.SplitGroup, e.SourceID)
|
|
}
|
|
}
|
|
|
|
// Determinism: same split produces same hash.
|
|
frozen2, dev2, hash2 := FrozenHoldoutSplit(exs)
|
|
if hash != hash2 {
|
|
t.Errorf("non-deterministic: hash=%s vs %s", hash, hash2)
|
|
}
|
|
if len(frozen) != len(frozen2) || len(dev) != len(dev2) {
|
|
t.Errorf("non-deterministic: frozen=%d/%d dev=%d/%d",
|
|
len(frozen), len(frozen2), len(dev), len(dev2))
|
|
}
|
|
|
|
// Log route coverage in both splits (not all routes need to appear
|
|
// in the frozen holdout — at 15% of 136 rows, small routes may miss).
|
|
frozenRoutes := map[SemanticRoute]bool{}
|
|
for _, e := range frozen {
|
|
frozenRoutes[e.Route] = true
|
|
}
|
|
devRoutes := map[SemanticRoute]bool{}
|
|
for _, e := range dev {
|
|
devRoutes[e.Route] = true
|
|
}
|
|
for _, r := range AllRoutes {
|
|
if !frozenRoutes[r] {
|
|
t.Logf("note: route %q missing from frozen holdout (expected at 15%%)", r)
|
|
}
|
|
if !devRoutes[r] {
|
|
t.Errorf("route %q missing from development pool", r)
|
|
}
|
|
}
|
|
|
|
t.Logf("frozen: %d examples, dev: %d examples, holdout_hash=%s",
|
|
len(frozen), len(dev), hash)
|
|
}
|
|
|
|
func TestGroupedCVFolds(t *testing.T) {
|
|
exs, err := LoadCorpus()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, dev, _ := FrozenHoldoutSplit(exs)
|
|
folds := GroupedCVFolds(dev, 5)
|
|
if len(folds) != 5 {
|
|
t.Fatalf("expected 5 folds, got %d", len(folds))
|
|
}
|
|
|
|
for i, f := range folds {
|
|
total := len(f.Train) + len(f.Eval)
|
|
if total != len(dev) {
|
|
t.Errorf("fold %d: train=%d + eval=%d = %d, want %d",
|
|
i, len(f.Train), len(f.Eval), total, len(dev))
|
|
}
|
|
if len(f.Eval) == 0 {
|
|
t.Errorf("fold %d: empty eval set", i)
|
|
}
|
|
|
|
// No split_group leakage within a fold.
|
|
evalGroups := map[string]bool{}
|
|
for _, e := range f.Eval {
|
|
evalGroups[e.SplitGroup] = true
|
|
}
|
|
for _, e := range f.Train {
|
|
if evalGroups[e.SplitGroup] {
|
|
t.Errorf("fold %d: split_group %q leaked (example %q)",
|
|
i, e.SplitGroup, e.SourceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every example appears in exactly one eval set across all folds.
|
|
evalCount := 0
|
|
for _, f := range folds {
|
|
evalCount += len(f.Eval)
|
|
}
|
|
if evalCount != len(dev) {
|
|
t.Errorf("total eval examples across folds: %d, want %d", evalCount, len(dev))
|
|
}
|
|
|
|
t.Logf("grouped CV: %d folds on %d development examples", len(folds), len(dev))
|
|
}
|
|
|
|
func TestFamilyID(t *testing.T) {
|
|
id1 := FamilyID("ru-act-002", "negation")
|
|
id2 := FamilyID("ru-act-002", "question")
|
|
id3 := FamilyID("ru-act-002", "")
|
|
id4 := FamilyID("ru-act-001", "negation")
|
|
if id1 == id2 {
|
|
t.Error("different transforms produced same FamilyID")
|
|
}
|
|
if id1 == id3 {
|
|
t.Error("transform and base produced same FamilyID")
|
|
}
|
|
if id1 == id4 {
|
|
t.Error("different base IDs produced same FamilyID")
|
|
}
|
|
}
|