fb9b719f0b
Add ValidateCorpus and CorpusStatsFrom for structural integrity checks on the semantic route corpus. Validates total counts, route/source sums, fast-path+residual partition, empty SourceID/SplitGroup, invalid routes, duplicate identity, and conflicting labels on identical text. CorpusStats provides deterministic dataset hash (SHA-256 of sorted texts, first 16 bytes). ReproducibilityMeta in CorpusEnvelope records source fixture hashes, generator version, split algorithm, and dataset hash. TestCorpusValidation exercises the full validation pipeline.
181 lines
4.6 KiB
Go
181 lines
4.6 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 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")
|
|
}
|
|
}
|