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 := canonicalizeOwned(document) if err != nil { return Document{}, fmt.Errorf("validate evidence context: %w", err) } return canonical, nil } 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 }