package evidencecontext import ( "bytes" "encoding/json" "math" "os" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" ) func TestBuildExpandsAndMergesEvidenceByDocumentPosition(t *testing.T) { for _, test := range []struct { name string window int evidence []LaneEvidence wantUnits [][]int wantRefs [][]EvidenceRef }{ { name: "zero window", evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}, wantUnits: [][]int{{3}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}}, }, { name: "non monotonic ids use positions and clip boundaries", window: 1, evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}, wantUnits: [][]int{{10, 3, 30}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}}, }, { name: "separate gaps stay separate", evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(10, 10), ref(50, 50)}}}, wantUnits: [][]int{{10}, {50}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(10, 10)}}, {{LaneID: "npcs", SourceRef: ref(50, 50)}}}, }, { name: "overlapping windows merge", window: 1, evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3), ref(30, 30)}}}, wantUnits: [][]int{{10, 3, 30, 7}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}, {LaneID: "npcs", SourceRef: ref(30, 30)}}}, }, { name: "contiguous windows merge", window: 1, evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(10, 10), ref(7, 7)}}}, wantUnits: [][]int{{10, 3, 30, 7, 50}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(7, 7)}, {LaneID: "npcs", SourceRef: ref(10, 10)}}}, }, { name: "duplicate contributions retain unique lane attribution", evidence: []LaneEvidence{ {LaneID: "spells", SourceRefs: []source.SourceRef{ref(30, 30), ref(30, 30)}}, {LaneID: "npcs", SourceRefs: []source.SourceRef{ref(30, 30)}}, }, wantUnits: [][]int{{30}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(30, 30)}, {LaneID: "spells", SourceRef: ref(30, 30)}}}, }, { name: "empty contributions retain explicit empty contexts", evidence: []LaneEvidence{{LaneID: "npcs"}}, wantUnits: [][]int{}, wantRefs: [][]EvidenceRef{}, }, { name: "largest window clips without overflow", window: math.MaxInt, evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(30, 30)}}}, wantUnits: [][]int{{10, 3, 30, 7, 50}}, wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(30, 30)}}}, }, } { t.Run(test.name, func(t *testing.T) { document := testDocument(t) got, err := Build(BuildRequest{Source: document, WindowUnits: test.window, SelectedLanes: []string{"spells", "npcs"}, LaneEvidence: test.evidence}) if err != nil { t.Fatalf("Build() error = %v", err) } if want := []string{"npcs", "spells"}; !reflect.DeepEqual(got.SelectedLanes, want) { t.Fatalf("SelectedLanes = %#v, want %#v", got.SelectedLanes, want) } if got.WindowUnits != test.window || got.SourceID != document.ID || got.SourceDigest != document.Digest { t.Fatalf("Build() identity = %#v, want source and window identity", got) } if actual := contextUnitIDs(got.Contexts); !reflect.DeepEqual(actual, test.wantUnits) { t.Fatalf("context unit ids = %#v, want %#v", actual, test.wantUnits) } if actual := contextEvidenceRefs(got.Contexts); !reflect.DeepEqual(actual, test.wantRefs) { t.Fatalf("context evidence refs = %#v, want %#v", actual, test.wantRefs) } }) } } func TestBuildIsStableAndOwnsSourceAndInputs(t *testing.T) { document := testDocument(t) refs := []source.SourceRef{ref(30, 30), ref(3, 3)} request := BuildRequest{ Source: document, WindowUnits: 1, SelectedLanes: []string{"spells", "npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "spells", SourceRefs: refs}, {LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}, } first, err := Build(request) if err != nil { t.Fatal(err) } secondRequest := request secondRequest.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}, {LaneID: "spells", SourceRefs: []source.SourceRef{ref(3, 3), ref(30, 30)}}} second, err := Build(secondRequest) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(first, second) { t.Fatalf("Build() order differs:\nfirst: %#v\nsecond: %#v", first, second) } first.SelectedLanes[0] = "changed" first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed" if document.Units[0].Metadata["nested"].(map[string]any)["value"] != "original" { t.Fatal("Build() returned metadata aliases to source document") } document.Units[0].Metadata["nested"].(map[string]any)["value"] = "later" if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" { t.Fatal("Build() retained metadata aliases to source document") } refs[0].StartUnitID = 999 if !containsEvidenceRef(second.Contexts[0].EvidenceRefs, ref(30, 30)) { t.Fatal("Build() retained source-reference input aliases") } } func TestBuildRejectsInvalidInputs(t *testing.T) { for _, test := range []struct { name string mutate func(*BuildRequest) want string }{ {name: "negative window", mutate: func(request *BuildRequest) { request.WindowUnits = -1 }, want: "window_units"}, {name: "blank selected lane", mutate: func(request *BuildRequest) { request.SelectedLanes = []string{" "} }, want: "selected_lanes"}, {name: "duplicate selected lane", mutate: func(request *BuildRequest) { request.SelectedLanes = []string{"npcs", " npcs "} }, want: "duplicated"}, {name: "unselected contribution", mutate: func(request *BuildRequest) { request.LaneEvidence = []LaneEvidence{{LaneID: "other", SourceRefs: []source.SourceRef{ref(3, 3)}}} }, want: "not selected"}, {name: "source digest mismatch", mutate: func(request *BuildRequest) { request.Source.Digest = "sha256:" + strings.Repeat("0", 64) }, want: "does not match"}, {name: "invalid reference", mutate: func(request *BuildRequest) { request.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(99, 99)}}} }, want: "source reference[0]"}, } { t.Run(test.name, func(t *testing.T) { request := BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}} test.mutate(&request) if _, err := Build(request); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Build() error = %v, want %q", err, test.want) } }) } } func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) { fixture, err := os.ReadFile("testdata/source_evidence_context.v1.json") if err != nil { t.Fatal(err) } codec := New() value, err := codec.Decode(fixture) if err != nil { t.Fatalf("Decode(fixture) error = %v", err) } encoded, err := codec.Encode(value) if err != nil { t.Fatal(err) } if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) { t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded) } value.Contexts[0].Units[0].Text = "changed" decoded, err := codec.Decode(encoded) if err != nil { t.Fatal(err) } if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." { t.Fatal("Encode() retained mutable document storage") } built, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: 1, SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}}) if err != nil { t.Fatal(err) } content, err := codec.Encode(built) if err != nil { t.Fatal(err) } first, err := codec.Decode(content) if err != nil { t.Fatal(err) } second, err := codec.Decode(content) if err != nil { t.Fatal(err) } first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed" if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" { t.Fatal("Decode() returned metadata aliases") } } func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) { value, err := Build(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}}) if err != nil { t.Fatal(err) } for _, test := range []struct { name string mutate func(*Document) }{ {name: "unsorted lanes", mutate: func(value *Document) { value.SelectedLanes = []string{"z", "a"} }}, {name: "context range mismatch", mutate: func(value *Document) { value.Contexts[0].ContextRef.EndUnitID = 999 }}, {name: "mismatched evidence source", mutate: func(value *Document) { value.Contexts[0].EvidenceRefs[0].SourceRef.SourceID = "other" }}, {name: "invalid evidence range", mutate: func(value *Document) { value.Contexts[0].EvidenceRefs[0].SourceRef.StartUnitID = 10 }}, {name: "duplicate context unit", mutate: func(value *Document) { value.Contexts = append(value.Contexts, value.Contexts[0]) }}, } { t.Run(test.name, func(t *testing.T) { candidate, err := clone(value) if err != nil { t.Fatal(err) } test.mutate(&candidate) if _, err := New().Encode(candidate); err == nil { t.Fatal("Encode() error = nil, want durable model rejection") } }) } content, err := New().Encode(value) if err != nil { t.Fatal(err) } for _, test := range []struct { name string mutate func(map[string]any) }{ {name: "missing contexts", mutate: func(value map[string]any) { delete(value, "contexts") }}, {name: "null contexts", mutate: func(value map[string]any) { value["contexts"] = nil }}, {name: "unknown fixed field", mutate: func(value map[string]any) { value["unknown"] = true }}, {name: "missing units", mutate: func(value map[string]any) { delete(contextObject(value, 0), "units") }}, {name: "null evidence refs", mutate: func(value map[string]any) { contextObject(value, 0)["evidence_refs"] = nil }}, } { t.Run(test.name, func(t *testing.T) { raw := decodeJSON(t, content) test.mutate(raw) mutated, err := json.Marshal(raw) if err != nil { t.Fatal(err) } if _, err := New().Decode(mutated); err == nil { t.Fatal("Decode() error = nil, want strict payload rejection") } }) } if _, err := New().Decode(append(content, []byte(" {}")...)); err == nil { t.Fatal("Decode() error = nil, want trailing JSON rejection") } } func TestSerializeUsesFixedArtifactIdentity(t *testing.T) { artifact, err := Serialize(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}}) if err != nil { t.Fatal(err) } if artifact.Kind != ArtifactKind || artifact.MediaType != MediaType || artifact.Schema.ID != SchemaID || artifact.Schema.Name != SchemaName || artifact.Schema.Version != SchemaVersion { t.Fatalf("Serialize() = %#v, want fixed artifact identity", artifact) } decoded, err := New().Decode(artifact.Content) if err != nil || len(decoded.Contexts) != 0 || decoded.Contexts == nil { t.Fatalf("Decode(Serialize()) = %#v, %v; want explicit empty contexts", decoded, err) } } func testDocument(t *testing.T) *source.SourceDocument { t.Helper() document := &source.SourceDocument{ ID: "session", Kind: "transcript", Format: "application/json", Units: []source.SourceUnit{ {ID: 10, Kind: "segment", Text: "first", Ref: ref(10, 10), Metadata: map[string]any{"nested": map[string]any{"value": "original"}}}, {ID: 3, Kind: "segment", Text: "second", Ref: ref(3, 3)}, {ID: 30, Kind: "segment", Text: "third", Ref: ref(30, 30)}, {ID: 7, Kind: "segment", Text: "fourth", Ref: ref(7, 7)}, {ID: 50, Kind: "segment", Text: "fifth", Ref: ref(50, 50)}, }, } digest, err := source.DigestDocument(document) if err != nil { t.Fatal(err) } document.Digest = digest return document } func ref(start, end int) source.SourceRef { return source.SourceRef{SourceID: "session", StartUnitID: start, EndUnitID: end} } func contextUnitIDs(contexts []Context) [][]int { values := make([][]int, len(contexts)) for index, context := range contexts { values[index] = make([]int, len(context.Units)) for unitIndex, unit := range context.Units { values[index][unitIndex] = unit.ID } } return values } func contextEvidenceRefs(contexts []Context) [][]EvidenceRef { values := make([][]EvidenceRef, len(contexts)) for index, context := range contexts { values[index] = append([]EvidenceRef(nil), context.EvidenceRefs...) } return values } func containsEvidenceRef(values []EvidenceRef, want source.SourceRef) bool { for _, value := range values { if value.SourceRef == want { return true } } return false } func decodeJSON(t *testing.T, content []byte) map[string]any { t.Helper() decoder := json.NewDecoder(bytes.NewReader(content)) decoder.UseNumber() var value map[string]any if err := decoder.Decode(&value); err != nil { t.Fatal(err) } return value } func contextObject(value map[string]any, index int) map[string]any { return value["contexts"].([]any)[index].(map[string]any) }