219 lines
12 KiB
Go
219 lines
12 KiB
Go
package locationregistry
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
|
)
|
|
|
|
func TestModuleContractAndMetadata(t *testing.T) {
|
|
want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.LocationRegistryKind}
|
|
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
|
}
|
|
registry := pipeline.NewNormalizerRegistry()
|
|
if err := Register(registry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := New(nil, Options{}); err == nil {
|
|
t.Fatal("New() accepted nil client")
|
|
}
|
|
normalizer := newNormalizer(t, &recordingLocationNormalizerClient{})
|
|
metadata := normalizer.ManifestMetadata()
|
|
limits, ok := metadata["semantic_reconciliation_limits"].(map[string]any)
|
|
if !ok || metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["prompt_version"] != PromptVersion || metadata["response_schema_key"] != string(semanticreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != semanticreconcile.ResponseSchemaID || metadata["response_schema_name"] != semanticreconcile.ResponseSchemaName || metadata["semantic_reconciliation_policy"] != semanticreconcile.Policy || len(limits) != 3 {
|
|
t.Fatalf("metadata = %#v", metadata)
|
|
}
|
|
for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
|
|
if !hasFingerprint(normalizer.CheckpointFingerprints(), name) {
|
|
t.Fatalf("fingerprints missing %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t *testing.T) {
|
|
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
|
{Name: " The Tavern ", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
|
|
{Name: "the tavern", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
|
|
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
|
|
{Name: "The Tavern Cellar", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
|
|
}}
|
|
before := dnd.LocationRegistry{Locations: append([]dnd.Location(nil), input.Locations...)}
|
|
client := &recordingLocationNormalizerClient{}
|
|
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequest(input))
|
|
if err != nil || len(result.Value.Locations) != 3 {
|
|
t.Fatalf("Normalize() = %#v, %v; want one exact duplicate removed", result, err)
|
|
}
|
|
if !reflect.DeepEqual(input, before) {
|
|
t.Fatalf("Normalize() mutated input: %#v", input)
|
|
}
|
|
if got := []string{result.Value.Locations[0].Name, result.Value.Locations[1].Name, result.Value.Locations[2].Name}; !reflect.DeepEqual(got, []string{"The Tavern", "The Tavern", "The Tavern Cellar"}) {
|
|
t.Fatalf("locations = %#v, want same names and nested place retained", got)
|
|
}
|
|
if result.Value.Locations[0].ID == result.Value.Locations[1].ID || !hasWarning(result.Warnings, ReasonCodeDuplicateLocationCollapsed) || len(client.requests) != 0 {
|
|
t.Fatalf("result = %#v, want evidence-anchored IDs and exact duplicate warning", result)
|
|
}
|
|
}
|
|
|
|
func BenchmarkExactDuplicateGroupsManyDistinct(b *testing.B) {
|
|
records := make([]normalizedRecord, 1_000)
|
|
for index := range records {
|
|
records[index] = normalizedRecord{location: dnd.Location{
|
|
Name: "Location " + strconv.Itoa(index),
|
|
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: index + 1, EndUnitID: index + 1}},
|
|
}}
|
|
}
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for iteration := 0; iteration < b.N; iteration++ {
|
|
if groups := exactDuplicateGroups(records); len(groups) != len(records) {
|
|
b.Fatalf("group count = %d, want %d", len(groups), len(records))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) {
|
|
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
|
|
doc := semanticDocument()
|
|
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
|
{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
|
{Name: "the Greencloak's refuge", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
|
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
|
}}
|
|
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
|
if err != nil || result.Retry != nil || len(result.Value.Locations) != 2 {
|
|
t.Fatalf("Normalize() = %#v, %v", result, err)
|
|
}
|
|
merged := result.Value.Locations[0]
|
|
if merged.Name != "the Greencloak's refuge" || merged.ID != identity.DeriveID(merged.Name, merged.SourceRefs) || len(merged.SourceRefs) != 2 || !hasWarning(result.Warnings, ReasonCodeDuplicateLocationCollapsed) {
|
|
t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings)
|
|
}
|
|
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
|
|
if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
|
|
t.Fatalf("private inputs = %s", encoded)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates(t *testing.T) {
|
|
doc := semanticDocument()
|
|
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
|
{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
|
{Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
|
{Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
|
}}
|
|
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3}]}`}
|
|
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
|
if err != nil || result.Retry == nil || len(result.Value.Locations) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") {
|
|
t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err)
|
|
}
|
|
if len(result.Retry.FallbackWarnings) != 1 || !strings.Contains(result.Retry.FallbackWarnings[0].Message, "2 proposal group") {
|
|
t.Fatalf("fallback warnings = %#v", result.Retry.FallbackWarnings)
|
|
}
|
|
}
|
|
|
|
func TestReconciliationCandidatesKeepSameNameEvidenceDistinct(t *testing.T) {
|
|
doc := semanticDocument()
|
|
records := []normalizedRecord{
|
|
{location: dnd.Location{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, inputIndexes: []int{0}, earliest: 0},
|
|
{location: dnd.Location{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, inputIndexes: []int{1}, earliest: 1},
|
|
}
|
|
candidates, _, err := reconciliationInputs(records)
|
|
if err != nil {
|
|
t.Fatalf("reconciliationInputs() error = %v", err)
|
|
}
|
|
preparation, err := semanticreconcile.Prepare(doc, candidates, semanticreconcile.DefaultLimits())
|
|
if err != nil || preparation.Disposition() != semanticreconcile.Ready {
|
|
t.Fatalf("Prepare() = %#v, %v; want ready candidates", preparation, err)
|
|
}
|
|
var candidateInput struct {
|
|
Candidates []struct {
|
|
CandidateID int `json:"candidate_id"`
|
|
Label string `json:"label"`
|
|
} `json:"candidates"`
|
|
}
|
|
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateID != 1 || candidateInput.Candidates[1].CandidateID != 2 || candidateInput.Candidates[0].Label != "The Tavern" || candidateInput.Candidates[1].Label != "The Tavern" {
|
|
t.Fatalf("candidate input = %#v, want distinct integer handles for equal names", candidateInput)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testing.T) {
|
|
client := &recordingLocationNormalizerClient{}
|
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Kind: "narration", Text: "A sprawling city"}}}
|
|
limit := semanticreconcile.DefaultLimits().MaximumCandidates
|
|
input := dnd.LocationRegistry{Locations: make([]dnd.Location, limit+1)}
|
|
for index := range input.Locations {
|
|
input.Locations[index] = dnd.Location{Name: fmt.Sprintf("Place %d", index), SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
|
|
}
|
|
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
|
if err != nil || result.Retry != nil {
|
|
t.Fatalf("Normalize() = %#v, %v; want deterministic limit fallback", result, err)
|
|
}
|
|
if len(client.requests) != 0 || len(result.Value.Locations) != limit+1 {
|
|
t.Fatalf("completion calls = %d, locations = %d; want no call and all records", len(client.requests), len(result.Value.Locations))
|
|
}
|
|
if !hasWarning(result.Warnings, ReasonCodeLocationSemanticReconciliationExhausted) || len(result.Warnings) > diagnostics.MaxWarnings {
|
|
t.Fatalf("warnings = %#v, want bounded reconciliation fallback", result.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeHandlesRetryFallbackAndErrors(t *testing.T) {
|
|
doc := semanticDocument()
|
|
input := dnd.LocationRegistry{Locations: []dnd.Location{{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
|
|
invalid, err := newNormalizer(t, &recordingLocationNormalizerClient{err: contracts.ErrInvalidStructuredOutput}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
|
if err != nil || invalid.Retry == nil || invalid.Retry.ReasonCode != ReasonCodeLocationSemanticProposalInvalid {
|
|
t.Fatalf("invalid result = %#v, %v", invalid, err)
|
|
}
|
|
_, err = newNormalizer(t, &recordingLocationNormalizerClient{err: errors.New("provider unavailable")}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
|
if err == nil || !strings.Contains(err.Error(), "provider unavailable") {
|
|
t.Fatalf("provider error = %v", err)
|
|
}
|
|
warnings := make([]contracts.Warning, 25)
|
|
bounded := limitWarningsForRetry(warnings)
|
|
if len(bounded) != 19 || bounded[len(bounded)-1].ReasonCode != ReasonCodeLocationNormalizationWarningsOmitted {
|
|
t.Fatalf("retry warning limit = %#v", bounded)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeOrdersEvidenceAndIsIdempotent(t *testing.T) {
|
|
doc := &source.SourceDocument{ID: "ordered", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
|
input := dnd.LocationRegistry{Locations: []dnd.Location{{Name: "Old Mill", SourceRefs: []source.SourceRef{
|
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
|
}}}}
|
|
normalizer := newNormalizer(t, &recordingLocationNormalizerClient{})
|
|
first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
|
if err != nil || !reflect.DeepEqual([]int{first.Value.Locations[0].SourceRefs[0].StartUnitID, first.Value.Locations[0].SourceRefs[1].StartUnitID}, []int{30, 10}) {
|
|
t.Fatalf("first Normalize() = %#v, %v; want document-ordered evidence", first, err)
|
|
}
|
|
second, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(first.Value, doc))
|
|
if err != nil || !reflect.DeepEqual(second.Value, first.Value) {
|
|
t.Fatalf("second Normalize() = %#v, %v; want idempotent value %#v", second, err, first.Value)
|
|
}
|
|
}
|
|
|
|
func hasFingerprint(fingerprints []pipeline.CheckpointFingerprint, name string) bool {
|
|
for _, fingerprint := range fingerprints {
|
|
if fingerprint.Name == name && fingerprint.Value != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|