Files
Maven/internal/router/semantic/corpus.go
T
claude fb9b719f0b router/semantic: corpus validation infrastructure (slice 13)
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.
2026-09-07 02:06:58 +04:00

207 lines
6.9 KiB
Go

package semantic
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
)
//go:embed corpus_v1.json
var corpusV1JSON []byte
// RouteExample — one labeled utterance in the coarse-route experiment corpus.
// Every row carries provenance so no row can masquerade as independent when
// related paraphrases exist.
type RouteExample struct {
Text string `json:"text"`
Route SemanticRoute `json:"route"`
Source string `json:"source"` // where this row came from
SourceID string `json:"source_id"` // case ID in the source corpus
SplitGroup string `json:"split_group"` // family/seed ID for split discipline
Tags []string `json:"tags,omitempty"`
// FastPathResolved is true when a stage-0 grammar already handles this
// utterance. The learned router should not be measured on these unless
// explicitly desired; they are tagged, not removed.
FastPathResolved bool `json:"fast_path_resolved"`
// RouterResidual marks rows that would actually reach the general routing
// cascade after pre-route resolvers (command-prohibition, etc.) have had
// first refusal. Static corpus evaluation cannot determine this for all
// cases (some need dialogue state), so this is an explicit annotation
// rather than a derived field.
RouterResidual *bool `json:"router_residual,omitempty"`
}
// CorpusEnvelope — the versioned JSON envelope with reproducibility metadata.
type CorpusEnvelope struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
// Reproducibility metadata — informational, not validated against at
// load time. The hash fields are precomputed from the source fixtures
// at corpus-build time and recorded here so a later reader can verify
// the corpus was built from the expected inputs.
Reproducibility *ReproducibilityMeta `json:"reproducibility,omitempty"`
Examples []RouteExample `json:"examples"`
}
// ReproducibilityMeta — dataset identity for later experiment reproduction.
type ReproducibilityMeta struct {
// SourceFixtureHash is the SHA-256 of the concatenated source fixture
// files used to build this corpus, hex-encoded, first 16 bytes.
SourceFixtureHash string `json:"source_fixture_hash"`
// ContrastGeneratorVersion identifies the transform code version.
ContrastGeneratorVersion string `json:"contrast_generator_version"`
// SplitAlgorithm identifies the split algorithm and version.
SplitAlgorithm string `json:"split_algorithm"`
// DatasetHash is the SHA-256 of the sorted example texts, hex-encoded,
// first 16 bytes. Computed at validation time.
DatasetHash string `json:"dataset_hash"`
// FrozenHoldoutHash is the SHA-256 of the frozen holdout group IDs,
// computed when the split is created.
FrozenHoldoutHash string `json:"frozen_holdout_hash,omitempty"`
}
// SchemaVersionV1 is the version this package understands.
const SchemaVersionV1 = 1
// CorpusStats — computed from a validated corpus. Returned by ValidateCorpus
// so callers get the numbers without recomputing.
type CorpusStats struct {
Total int
RouteCounts map[SemanticRoute]int
SourceCounts map[string]int
FastPath int
Residual int
DatasetHash string
}
// LoadCorpus returns the embedded corpus, rejecting unknown schema versions
// and failing on structural validation errors.
func LoadCorpus() ([]RouteExample, error) {
var env CorpusEnvelope
if err := json.Unmarshal(corpusV1JSON, &env); err != nil {
return nil, fmt.Errorf("semantic corpus: parse: %w", err)
}
if env.SchemaVersion != SchemaVersionV1 {
return nil, fmt.Errorf("semantic corpus: schema_version %d, want %d",
env.SchemaVersion, SchemaVersionV1)
}
if err := ValidateCorpus(env.Examples); err != nil {
return nil, fmt.Errorf("semantic corpus: %w", err)
}
return env.Examples, nil
}
// ValidateCorpus checks structural invariants: total matches, route/source
// sums, fast-path+residual, no duplicate identities, no conflicting labels.
func ValidateCorpus(exs []RouteExample) error {
if len(exs) == 0 {
return fmt.Errorf("corpus is empty")
}
routeCounts := map[SemanticRoute]int{}
sourceCounts := map[string]int{}
type idKey struct{ Source, SourceID, Text string }
seen := map[idKey]bool{}
textRoute := map[string]SemanticRoute{}
for i, e := range exs {
if e.Source == "" {
return fmt.Errorf("row %d: empty source (source_id=%q)", i, e.SourceID)
}
if e.SourceID == "" {
return fmt.Errorf("row %d: empty source_id (source=%q)", i, e.Source)
}
if e.SplitGroup == "" {
return fmt.Errorf("row %d: empty split_group (source_id=%q)", i, e.SourceID)
}
if !ValidRoute(e.Route) {
return fmt.Errorf("row %d: invalid route %q (source_id=%q)", i, e.Route, e.SourceID)
}
// Check for conflicting labels on identical text.
norm := strings.TrimSpace(e.Text)
key := idKey{Source: e.Source, SourceID: e.SourceID, Text: norm}
if seen[key] {
return fmt.Errorf("row %d: duplicate source+source_id+text %q:%q:%q", i, e.Source, e.SourceID, norm)
}
seen[key] = true
routeCounts[e.Route]++
sourceCounts[e.Source]++
if prev, ok := textRoute[norm]; ok && prev != e.Route {
return fmt.Errorf("row %d: text %q has route %q, but earlier row had %q",
i, norm, e.Route, prev)
}
textRoute[norm] = e.Route
}
// Verify fast-path + residual == total.
fastPath, residual := SplitCounts(exs)
if fastPath+residual != len(exs) {
return fmt.Errorf("fast_path(%d) + residual(%d) = %d != total(%d)",
fastPath, residual, fastPath+residual, len(exs))
}
return nil
}
// CorpusStatsFrom computes the stats for a validated corpus.
func CorpusStatsFrom(exs []RouteExample) CorpusStats {
routeCounts := map[SemanticRoute]int{}
sourceCounts := map[string]int{}
for _, e := range exs {
routeCounts[e.Route]++
sourceCounts[e.Source]++
}
fastPath, residual := SplitCounts(exs)
// Deterministic dataset hash: sort texts, hash the concatenation.
texts := make([]string, len(exs))
for i, e := range exs {
texts[i] = e.Text
}
sort.Strings(texts)
h := sha256.Sum256([]byte(strings.Join(texts, "\n")))
return CorpusStats{
Total: len(exs),
RouteCounts: routeCounts,
SourceCounts: sourceCounts,
FastPath: fastPath,
Residual: residual,
DatasetHash: hex.EncodeToString(h[:16]),
}
}
// ByRoute groups examples by their semantic route, for per-route inspection.
func ByRoute(exs []RouteExample) map[SemanticRoute][]RouteExample {
m := make(map[SemanticRoute][]RouteExample)
for _, e := range exs {
m[e.Route] = append(m[e.Route], e)
}
for k := range m {
sort.Slice(m[k], func(i, j int) bool { return m[k][i].Text < m[k][j].Text })
}
return m
}
// SplitCounts returns the number of fast-path-resolved vs
// general-route-required examples.
func SplitCounts(exs []RouteExample) (fastPath, residual int) {
for _, e := range exs {
if e.FastPathResolved {
fastPath++
} else {
residual++
}
}
return
}