From a4c7eca87bb2ceca3e60c6596ac32958ddb93da1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 27 Jul 2026 18:11:49 +0000 Subject: [PATCH] Add evidence context artifact builder --- .../schemas/source_evidence_context.v1.json | 66 ++++ internal/framework/evidencecontext/build.go | 196 ++++++++++ internal/framework/evidencecontext/codec.go | 329 ++++++++++++++++ .../evidencecontext/evidencecontext_test.go | 350 ++++++++++++++++++ internal/framework/evidencecontext/model.go | 50 +++ .../testdata/source_evidence_context.v1.json | 1 + 6 files changed, 992 insertions(+) create mode 100644 internal/framework/evidencecontext/assets/schemas/source_evidence_context.v1.json create mode 100644 internal/framework/evidencecontext/build.go create mode 100644 internal/framework/evidencecontext/codec.go create mode 100644 internal/framework/evidencecontext/evidencecontext_test.go create mode 100644 internal/framework/evidencecontext/model.go create mode 100644 internal/framework/evidencecontext/testdata/source_evidence_context.v1.json diff --git a/internal/framework/evidencecontext/assets/schemas/source_evidence_context.v1.json b/internal/framework/evidencecontext/assets/schemas/source_evidence_context.v1.json new file mode 100644 index 00000000..169c9c0e --- /dev/null +++ b/internal/framework/evidencecontext/assets/schemas/source_evidence_context.v1.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.source.evidence_context", + "title": "notarius_source_evidence_context_v1", + "type": "object", + "additionalProperties": false, + "required": ["source_id", "source_digest", "window_units", "selected_lanes", "contexts"], + "properties": { + "source_id": {"type": "string", "minLength": 1}, + "source_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "window_units": {"type": "integer", "minimum": 0}, + "selected_lanes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "string", "minLength": 1} + }, + "contexts": { + "type": "array", + "items": {"$ref": "#/$defs/context"} + } + }, + "$defs": { + "source_ref": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "start_unit_id", "end_unit_id"], + "properties": { + "source_id": {"type": "string", "minLength": 1}, + "start_unit_id": {"type": "integer", "minimum": 1}, + "end_unit_id": {"type": "integer", "minimum": 1} + } + }, + "unit": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "text", "ref"], + "properties": { + "id": {"type": "integer", "minimum": 1}, + "kind": {"type": "string", "minLength": 1}, + "text": {"type": "string", "minLength": 1}, + "ref": {"$ref": "#/$defs/source_ref"}, + "metadata": {"type": "object", "additionalProperties": true} + } + }, + "evidence_ref": { + "type": "object", + "additionalProperties": false, + "required": ["lane_id", "source_ref"], + "properties": { + "lane_id": {"type": "string", "minLength": 1}, + "source_ref": {"$ref": "#/$defs/source_ref"} + } + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": ["context_ref", "evidence_refs", "units"], + "properties": { + "context_ref": {"$ref": "#/$defs/source_ref"}, + "evidence_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/evidence_ref"}}, + "units": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/unit"}} + } + } + } +} diff --git a/internal/framework/evidencecontext/build.go b/internal/framework/evidencecontext/build.go new file mode 100644 index 00000000..c029a0ad --- /dev/null +++ b/internal/framework/evidencecontext/build.go @@ -0,0 +1,196 @@ +package evidencecontext + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +type contribution struct { + laneID string + ref source.SourceRef + startPos int + endPos int +} + +type expandedRange struct { + startPos int + endPos int + contributions []contribution +} + +// Build validates accepted direct references, expands them by source-document +// position, and returns their deterministic context union. +func Build(request BuildRequest) (Document, error) { + if request.WindowUnits < 0 { + return Document{}, fmt.Errorf("window_units must not be negative") + } + lanes, err := normalizeSelectedLanes(request.SelectedLanes) + if err != nil { + return Document{}, err + } + if err := source.ValidateDocument(request.Source); err != nil { + return Document{}, fmt.Errorf("validate source document: %w", err) + } + digest, err := source.DigestDocument(request.Source) + if err != nil { + return Document{}, fmt.Errorf("digest source document: %w", err) + } + if digest != request.Source.Digest { + return Document{}, fmt.Errorf("source digest does not match source document digest") + } + + selected := make(map[string]struct{}, len(lanes)) + for _, laneID := range lanes { + selected[laneID] = struct{}{} + } + index := source.NewDocumentIndex(request.Source) + seen := make(map[evidenceKey]struct{}) + contributions := make([]contribution, 0) + for laneIndex, laneEvidence := range request.LaneEvidence { + laneID := strings.TrimSpace(laneEvidence.LaneID) + if _, ok := selected[laneID]; !ok { + return Document{}, fmt.Errorf("lane evidence[%d] lane %q is not selected", laneIndex, laneID) + } + for refIndex, ref := range laneEvidence.SourceRefs { + if err := index.ValidateRef(ref); err != nil { + return Document{}, fmt.Errorf("lane %q source reference[%d]: %w", laneID, refIndex, err) + } + key := evidenceKey{laneID: laneID, ref: ref} + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + startPos, _ := index.Position(ref.StartUnitID) + endPos, _ := index.Position(ref.EndUnitID) + contributions = append(contributions, contribution{laneID: laneID, ref: ref, startPos: expandStart(startPos, request.WindowUnits), endPos: expandEnd(endPos, len(request.Source.Units), request.WindowUnits)}) + } + } + + sort.Slice(contributions, func(i, j int) bool { return lessContribution(contributions[i], contributions[j]) }) + document := Document{ + SourceID: request.Source.ID, + SourceDigest: digest, + WindowUnits: request.WindowUnits, + SelectedLanes: lanes, + Contexts: make([]Context, 0), + } + for _, rangeValue := range mergeRanges(contributions) { + context, err := buildContext(request.Source, rangeValue) + if err != nil { + return Document{}, err + } + document.Contexts = append(document.Contexts, context) + } + canonical, err := canonicalize(document) + if err != nil { + return Document{}, fmt.Errorf("validate evidence context: %w", err) + } + return clone(canonical) +} + +type evidenceKey struct { + laneID string + ref source.SourceRef +} + +func normalizeSelectedLanes(values []string) ([]string, error) { + if len(values) == 0 { + return nil, fmt.Errorf("selected_lanes must not be empty") + } + seen := make(map[string]struct{}, len(values)) + lanes := make([]string, 0, len(values)) + for index, raw := range values { + laneID := strings.TrimSpace(raw) + if laneID == "" { + return nil, fmt.Errorf("selected_lanes[%d] must not be empty", index) + } + if _, exists := seen[laneID]; exists { + return nil, fmt.Errorf("selected_lanes lane %q is duplicated", laneID) + } + seen[laneID] = struct{}{} + lanes = append(lanes, laneID) + } + sort.Strings(lanes) + return lanes, nil +} + +func expandStart(position, window int) int { + if window > position { + return 0 + } + return position - window +} + +func expandEnd(position, length, window int) int { + last := length - 1 + if window > last-position { + return last + } + return position + window +} + +func lessContribution(left, right contribution) bool { + if left.startPos != right.startPos { + return left.startPos < right.startPos + } + if left.endPos != right.endPos { + return left.endPos < right.endPos + } + return lessEvidenceRef(EvidenceRef{LaneID: left.laneID, SourceRef: left.ref}, EvidenceRef{LaneID: right.laneID, SourceRef: right.ref}) +} + +func mergeRanges(values []contribution) []expandedRange { + if len(values) == 0 { + return nil + } + ranges := make([]expandedRange, 0, len(values)) + for _, value := range values { + if len(ranges) == 0 || value.startPos > ranges[len(ranges)-1].endPos+1 { + ranges = append(ranges, expandedRange{startPos: value.startPos, endPos: value.endPos, contributions: []contribution{value}}) + continue + } + current := &ranges[len(ranges)-1] + if value.endPos > current.endPos { + current.endPos = value.endPos + } + current.contributions = append(current.contributions, value) + } + return ranges +} + +func buildContext(document *source.SourceDocument, value expandedRange) (Context, error) { + evidenceRefs := make([]EvidenceRef, 0, len(value.contributions)) + for _, contribution := range value.contributions { + evidenceRefs = append(evidenceRefs, EvidenceRef{LaneID: contribution.laneID, SourceRef: contribution.ref}) + } + sort.Slice(evidenceRefs, func(i, j int) bool { return lessEvidenceRef(evidenceRefs[i], evidenceRefs[j]) }) + units := make([]source.SourceUnit, 0, value.endPos-value.startPos+1) + for position := value.startPos; position <= value.endPos; position++ { + unit, err := cloneSourceUnit(document.Units[position]) + if err != nil { + return Context{}, fmt.Errorf("clone source unit at position %d: %w", position, err) + } + units = append(units, unit) + } + return Context{ + ContextRef: source.SourceRef{SourceID: document.ID, StartUnitID: units[0].ID, EndUnitID: units[len(units)-1].ID}, + EvidenceRefs: evidenceRefs, + Units: units, + }, nil +} + +func lessEvidenceRef(left, right EvidenceRef) bool { + if left.LaneID != right.LaneID { + return left.LaneID < right.LaneID + } + if left.SourceRef.SourceID != right.SourceRef.SourceID { + return left.SourceRef.SourceID < right.SourceRef.SourceID + } + if left.SourceRef.StartUnitID != right.SourceRef.StartUnitID { + return left.SourceRef.StartUnitID < right.SourceRef.StartUnitID + } + return left.SourceRef.EndUnitID < right.SourceRef.EndUnitID +} diff --git a/internal/framework/evidencecontext/codec.go b/internal/framework/evidencecontext/codec.go new file mode 100644 index 00000000..6782d2c6 --- /dev/null +++ b/internal/framework/evidencecontext/codec.go @@ -0,0 +1,329 @@ +package evidencecontext + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + "sync" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +//go:embed assets/schemas/source_evidence_context.v1.json +var schemaAssets embed.FS + +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +var ( + loadSchemaOnce sync.Once + loadedSchema []byte + compiledSchema *jsonschema.Schema + loadSchemaErr error +) + +// Codec owns strict serialization for the durable evidence-context contract. +type Codec struct{} + +func New() *Codec { return &Codec{} } + +func (c *Codec) Kind() contracts.ArtifactKind { return ArtifactKind } + +func (c *Codec) Schema() contracts.ArtifactSchema { + raw, err := c.schemaBytes() + if err != nil { + return contracts.ArtifactSchema{} + } + return contracts.ArtifactSchema{ID: SchemaID, Name: SchemaName, Version: SchemaVersion, JSONSchema: raw} +} + +func (c *Codec) MediaType() string { return MediaType } + +// Serialize builds and encodes the framework-owned serialized artifact. +func Serialize(request BuildRequest) (contracts.SerializedArtifact, error) { + value, err := Build(request) + if err != nil { + return contracts.SerializedArtifact{}, err + } + codec := New() + content, err := codec.Encode(value) + if err != nil { + return contracts.SerializedArtifact{}, err + } + return contracts.SerializedArtifact{Kind: ArtifactKind, Schema: codec.Schema(), MediaType: MediaType, Content: content}, nil +} + +func (c *Codec) Encode(value Document) ([]byte, error) { + if _, err := c.schemaBytes(); err != nil { + return nil, err + } + canonical, err := canonicalize(value) + if err != nil { + return nil, fmt.Errorf("encode evidence context: %w", err) + } + content, err := json.Marshal(canonical) + if err != nil { + return nil, fmt.Errorf("encode evidence context: %w", err) + } + if err := validateSchemaInstance(content); err != nil { + return nil, fmt.Errorf("encode evidence context: %w", err) + } + return content, nil +} + +func (c *Codec) Decode(content []byte) (Document, error) { + if _, err := c.schemaBytes(); err != nil { + return Document{}, err + } + if err := validateSchemaInstance(content); err != nil { + return Document{}, fmt.Errorf("decode evidence context: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var value Document + if err := decoder.Decode(&value); err != nil { + return Document{}, fmt.Errorf("decode evidence context: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Document{}, fmt.Errorf("decode evidence context: multiple JSON values") + } + canonical, err := canonicalize(value) + if err != nil { + return Document{}, fmt.Errorf("decode evidence context: %w", err) + } + return clone(canonical) +} + +func (c *Codec) schemaBytes() ([]byte, error) { + loadSchemaOnce.Do(loadAndCompileSchema) + if loadSchemaErr != nil { + return nil, loadSchemaErr + } + return append([]byte(nil), loadedSchema...), nil +} + +func loadAndCompileSchema() { + raw, err := schemaAssets.ReadFile("assets/schemas/source_evidence_context.v1.json") + if err != nil { + loadSchemaErr = fmt.Errorf("read source evidence context schema: %w", err) + return + } + var identity struct { + ID string `json:"$id"` + Title string `json:"title"` + Type string `json:"type"` + Required []string `json:"required"` + } + if err := json.Unmarshal(raw, &identity); err != nil { + loadSchemaErr = fmt.Errorf("decode source evidence context schema: %w", err) + return + } + if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "object" || !hasRequiredFields(identity.Required) { + loadSchemaErr = fmt.Errorf("source evidence context schema identity or required fields are invalid") + return + } + schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + loadSchemaErr = fmt.Errorf("parse source evidence context schema: %w", err) + return + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("source-evidence-context-schema.json", schemaDocument); err != nil { + loadSchemaErr = fmt.Errorf("load source evidence context schema: %w", err) + return + } + compiled, err := compiler.Compile("source-evidence-context-schema.json") + if err != nil { + loadSchemaErr = fmt.Errorf("compile source evidence context schema: %w", err) + return + } + loadedSchema = append([]byte(nil), raw...) + compiledSchema = compiled +} + +func validateSchemaInstance(content []byte) error { + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(content)) + if err != nil { + return fmt.Errorf("payload is not valid JSON: %w", err) + } + if err := compiledSchema.Validate(instance); err != nil { + return fmt.Errorf("payload does not conform to source evidence context schema: %w", err) + } + return nil +} + +func hasRequiredFields(required []string) bool { + want := map[string]bool{"source_id": true, "source_digest": true, "window_units": true, "selected_lanes": true, "contexts": true} + for _, field := range required { + delete(want, field) + } + return len(want) == 0 +} + +func canonicalize(value Document) (Document, error) { + owned, err := clone(value) + if err != nil { + return Document{}, err + } + value = owned + if err := requireIdentity("source_id", value.SourceID); err != nil { + return Document{}, err + } + if !digestPattern.MatchString(value.SourceDigest) { + return Document{}, fmt.Errorf("source_digest must be a sha256 digest") + } + if value.WindowUnits < 0 { + return Document{}, fmt.Errorf("window_units must not be negative") + } + if err := validateSelectedLanes(value.SelectedLanes); err != nil { + return Document{}, err + } + if value.Contexts == nil { + value.Contexts = make([]Context, 0) + } + selected := make(map[string]struct{}, len(value.SelectedLanes)) + for _, laneID := range value.SelectedLanes { + selected[laneID] = struct{}{} + } + seenUnits := make(map[int]struct{}) + for contextIndex := range value.Contexts { + context, err := canonicalizeContext(value.SourceID, selected, seenUnits, value.Contexts[contextIndex], contextIndex) + if err != nil { + return Document{}, err + } + value.Contexts[contextIndex] = context + } + return value, nil +} + +func validateSelectedLanes(lanes []string) error { + if len(lanes) == 0 { + return fmt.Errorf("selected_lanes must not be empty") + } + for index, laneID := range lanes { + if err := requireIdentity(fmt.Sprintf("selected_lanes[%d]", index), laneID); err != nil { + return err + } + if index > 0 && lanes[index-1] >= laneID { + return fmt.Errorf("selected_lanes must be unique and in lexical order") + } + } + return nil +} + +func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnits map[int]struct{}, value Context, contextIndex int) (Context, error) { + prefix := fmt.Sprintf("contexts[%d]", contextIndex) + if len(value.EvidenceRefs) == 0 { + return Context{}, fmt.Errorf("%s.evidence_refs must not be empty", prefix) + } + if len(value.Units) == 0 { + return Context{}, fmt.Errorf("%s.units must not be empty", prefix) + } + if err := validateRefIdentity(sourceID, value.ContextRef, prefix+".context_ref"); err != nil { + return Context{}, err + } + positions := make(map[int]int, len(value.Units)) + for unitIndex := range value.Units { + unit, err := cloneSourceUnit(value.Units[unitIndex]) + if err != nil { + return Context{}, fmt.Errorf("%s.units[%d]: %w", prefix, unitIndex, err) + } + if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" { + return Context{}, fmt.Errorf("%s.units[%d] has invalid required fields", prefix, unitIndex) + } + if err := validateRefIdentity(sourceID, unit.Ref, fmt.Sprintf("%s.units[%d].ref", prefix, unitIndex)); err != nil { + return Context{}, err + } + if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID { + return Context{}, fmt.Errorf("%s.units[%d].ref must identify unit id %d", prefix, unitIndex, unit.ID) + } + if _, exists := positions[unit.ID]; exists { + return Context{}, fmt.Errorf("%s.units contains duplicate unit id %d", prefix, unit.ID) + } + if _, exists := seenUnits[unit.ID]; exists { + return Context{}, fmt.Errorf("contexts contain duplicate unit id %d", unit.ID) + } + positions[unit.ID] = unitIndex + seenUnits[unit.ID] = struct{}{} + value.Units[unitIndex] = unit + } + if value.ContextRef.StartUnitID != value.Units[0].ID || value.ContextRef.EndUnitID != value.Units[len(value.Units)-1].ID { + return Context{}, fmt.Errorf("%s.context_ref must identify the first and last units", prefix) + } + for evidenceIndex := range value.EvidenceRefs { + evidence := value.EvidenceRefs[evidenceIndex] + if _, ok := selected[evidence.LaneID]; !ok { + return Context{}, fmt.Errorf("%s.evidence_refs[%d].lane_id is not selected", prefix, evidenceIndex) + } + if err := requireIdentity(fmt.Sprintf("%s.evidence_refs[%d].lane_id", prefix, evidenceIndex), evidence.LaneID); err != nil { + return Context{}, err + } + if err := validateRefIdentity(sourceID, evidence.SourceRef, fmt.Sprintf("%s.evidence_refs[%d].source_ref", prefix, evidenceIndex)); err != nil { + return Context{}, err + } + start, startOK := positions[evidence.SourceRef.StartUnitID] + end, endOK := positions[evidence.SourceRef.EndUnitID] + if !startOK || !endOK || start > end { + return Context{}, fmt.Errorf("%s.evidence_refs[%d].source_ref is outside context units", prefix, evidenceIndex) + } + if evidenceIndex > 0 && !lessEvidenceRef(value.EvidenceRefs[evidenceIndex-1], evidence) { + return Context{}, fmt.Errorf("%s.evidence_refs must be unique and in deterministic order", prefix) + } + } + return value, nil +} + +func validateRefIdentity(sourceID string, ref source.SourceRef, field string) error { + if ref.SourceID != sourceID { + return fmt.Errorf("%s.source_id does not match source_id", field) + } + if ref.StartUnitID <= 0 || ref.EndUnitID <= 0 { + return fmt.Errorf("%s endpoints must be positive", field) + } + return nil +} + +func requireIdentity(field, value string) error { + if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value { + return fmt.Errorf("%s must be a non-empty trimmed string", field) + } + return nil +} + +func clone(value Document) (Document, error) { + value.SelectedLanes = append([]string(nil), value.SelectedLanes...) + if value.Contexts == nil { + value.Contexts = make([]Context, 0) + } else { + contexts := make([]Context, len(value.Contexts)) + for contextIndex, context := range value.Contexts { + contexts[contextIndex].ContextRef = context.ContextRef + contexts[contextIndex].EvidenceRefs = append([]EvidenceRef(nil), context.EvidenceRefs...) + contexts[contextIndex].Units = make([]source.SourceUnit, len(context.Units)) + for unitIndex, unit := range context.Units { + cloned, err := cloneSourceUnit(unit) + if err != nil { + return Document{}, fmt.Errorf("clone contexts[%d].units[%d]: %w", contextIndex, unitIndex, err) + } + contexts[contextIndex].Units[unitIndex] = cloned + } + } + value.Contexts = contexts + } + return value, nil +} + +func cloneSourceUnit(unit source.SourceUnit) (source.SourceUnit, error) { + metadata, err := source.CloneMetadata(unit.Metadata) + if err != nil { + return source.SourceUnit{}, fmt.Errorf("clone metadata: %w", err) + } + unit.Metadata = metadata + return unit, nil +} diff --git a/internal/framework/evidencecontext/evidencecontext_test.go b/internal/framework/evidencecontext/evidencecontext_test.go new file mode 100644 index 00000000..4f9da767 --- /dev/null +++ b/internal/framework/evidencecontext/evidencecontext_test.go @@ -0,0 +1,350 @@ +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(fixture) + if err != nil { + t.Fatal(err) + } + if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." { + t.Fatal("Decode() reused 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) +} diff --git a/internal/framework/evidencecontext/model.go b/internal/framework/evidencecontext/model.go new file mode 100644 index 00000000..c053e580 --- /dev/null +++ b/internal/framework/evidencecontext/model.go @@ -0,0 +1,50 @@ +// Package evidencecontext owns the durable source evidence-context contract. +package evidencecontext + +import ( + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +const ( + ArtifactKind contracts.ArtifactKind = "source/evidence-context" + SchemaID = "notarius.source.evidence_context" + SchemaName = "notarius_source_evidence_context_v1" + SchemaVersion = "v1" + MediaType = "application/json" +) + +// Document is the durable union of direct evidence and surrounding source +// context selected for one accepted source document. +type Document struct { + SourceID string `json:"source_id"` + SourceDigest string `json:"source_digest"` + WindowUnits int `json:"window_units"` + SelectedLanes []string `json:"selected_lanes"` + Contexts []Context `json:"contexts"` +} + +type Context struct { + ContextRef source.SourceRef `json:"context_ref"` + EvidenceRefs []EvidenceRef `json:"evidence_refs"` + Units []source.SourceUnit `json:"units"` +} + +type EvidenceRef struct { + LaneID string `json:"lane_id"` + SourceRef source.SourceRef `json:"source_ref"` +} + +// LaneEvidence attributes direct source references to one selected lane. +type LaneEvidence struct { + LaneID string `json:"lane_id"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +// BuildRequest supplies accepted source material and direct lane evidence. +type BuildRequest struct { + Source *source.SourceDocument + WindowUnits int + SelectedLanes []string + LaneEvidence []LaneEvidence +} diff --git a/internal/framework/evidencecontext/testdata/source_evidence_context.v1.json b/internal/framework/evidencecontext/testdata/source_evidence_context.v1.json new file mode 100644 index 00000000..38175b05 --- /dev/null +++ b/internal/framework/evidencecontext/testdata/source_evidence_context.v1.json @@ -0,0 +1 @@ +{"source_id":"session-alpha","source_digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","window_units":0,"selected_lanes":["npcs"],"contexts":[{"context_ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13},"evidence_refs":[{"lane_id":"npcs","source_ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13}}],"units":[{"id":13,"kind":"transcript_segment","text":"The party meets Rowan.","ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13}}]}]}