// Package comparison owns logical profile-comparison bundle contracts and // guarded filesystem destination planning and publication. package comparison import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "path" "strings" "time" "unicode/utf8" ) const ( // SchemaVersion identifies the supported comparison manifest schema. SchemaVersion = "weatherreporter.comparison.v1" // ManifestFilename is the canonical name of a comparison manifest. ManifestFilename = "comparison.json" // DataPackageFilename is the canonical name of the shared prompt input. DataPackageFilename = "data-package.yml" // StatusSucceeded identifies a profile that produced a validated report. StatusSucceeded = "succeeded" // StatusFailed identifies a profile that did not produce a report. StatusFailed = "failed" maxProfileSlugBytes = 64 maxErrorMessageBytes = 1024 ) // Manifest is the versioned, authoritative index of a comparison bundle. // Field declaration order is the JSON field order. type Manifest struct { SchemaVersion string `json:"schemaVersion"` ComparisonID string `json:"comparisonId"` StartedAt time.Time `json:"startedAt"` FinishedAt time.Time `json:"finishedAt"` ReportID string `json:"reportId"` ValidPeriod ValidPeriod `json:"validPeriod"` Timezone string `json:"timezone"` PromptID string `json:"promptId"` PromptVersion string `json:"promptVersion"` PromptHash string `json:"promptHash"` DataPackage DataPackageReference `json:"dataPackage"` Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Results []Result `json:"results"` } // ValidPeriod records the report's resolved half-open period. type ValidPeriod struct { Start time.Time `json:"start"` End time.Time `json:"end"` } // DataPackageReference identifies and verifies the shared prompt input. type DataPackageReference struct { Path string `json:"path"` SHA256 string `json:"sha256"` } // Result records one explicitly selected profile in selection order. type Result struct { Position int `json:"position"` ProfileID string `json:"profileId"` BackendID string `json:"backendId,omitempty"` ModelName string `json:"modelName"` Status string `json:"status"` ValidationStatus string `json:"validationStatus,omitempty"` ReportPath string `json:"reportPath,omitempty"` Error *SafeError `json:"error,omitempty"` } // SafeError contains bounded, operator-safe failure information only. type SafeError struct { Category string `json:"category"` Message string `json:"message"` } // LogicalBundle contains every byte required to publish a comparison without // coupling the comparison contract to a filesystem implementation. type LogicalBundle struct { Manifest Manifest DataPackage []byte Reports []BundleReport } // BundleReport is one rendered Markdown document in a logical bundle. type BundleReport struct { Position int Path string Markdown []byte } // ValidateProfileIDs requires at least two distinct, nonblank profile IDs. It // intentionally preserves accepted IDs unchanged and treats case distinctly. func ValidateProfileIDs(profileIDs []string) error { if len(profileIDs) < 2 { return fmt.Errorf("comparison requires at least two profile IDs") } seen := make(map[string]struct{}, len(profileIDs)) for _, profileID := range profileIDs { if strings.TrimSpace(profileID) == "" { return fmt.Errorf("profile ID must not be blank") } if _, duplicate := seen[profileID]; duplicate { return fmt.Errorf("duplicate profile ID %q", profileID) } seen[profileID] = struct{}{} } return nil } // ProfileSlug returns a deterministic, filesystem-safe representation of a // logical profile ID. The logical ID remains authoritative in the manifest. func ProfileSlug(profileID string) string { var builder strings.Builder lastReplacement := false for _, r := range profileID { if isSlugRune(r) { builder.WriteRune(r) lastReplacement = false continue } if !lastReplacement { builder.WriteByte('-') lastReplacement = true } } slug := strings.Trim(builder.String(), "-_") if len(slug) > maxProfileSlugBytes { slug = strings.Trim(slug[:maxProfileSlugBytes], "-_") } if slug == "" { return "profile" } return slug } func isSlugRune(r rune) bool { return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' } // OrdinalWidth returns the zero-padding width for a comparison of count // profiles. func OrdinalWidth(count int) int { width := 2 for value := count; value >= 100; value /= 10 { width++ } return width } // ReportFilename derives a report's deterministic bundle filename. func ReportFilename(position, profileCount int, profileID string) (string, error) { if profileCount < 1 { return "", fmt.Errorf("profile count must be positive") } if position < 1 || position > profileCount { return "", fmt.Errorf("profile position %d is outside 1..%d", position, profileCount) } return fmt.Sprintf("%0*d-%s.md", OrdinalWidth(profileCount), position, ProfileSlug(profileID)), nil } // BuildComparisonID derives the stable comparison identity for a resolved // report run. func BuildComparisonID(reportRunID string) (string, error) { if strings.TrimSpace(reportRunID) == "" { return "", fmt.Errorf("report run ID must not be blank") } return "comparison_" + reportRunID, nil } // DefaultDirectoryName derives the bundle directory name for a resolved report // output filename. func DefaultDirectoryName(reportOutputName string) (string, error) { if !strings.HasSuffix(reportOutputName, ".md") { return "", fmt.Errorf("report output name %q must end in .md", reportOutputName) } if !isArtifactBasename(reportOutputName) { return "", fmt.Errorf("report output name %q must be a basename", reportOutputName) } stem := strings.TrimSuffix(reportOutputName, ".md") if stem == "" { return "", fmt.Errorf("report output name %q has an empty stem", reportOutputName) } return "comparison-" + stem, nil } // SHA256 returns a lowercase hexadecimal SHA-256 digest. func SHA256(content []byte) string { digest := sha256.Sum256(content) return hex.EncodeToString(digest[:]) } // NewSafeError returns bounded, valid UTF-8 error information suitable for a // comparison manifest. func NewSafeError(category, message string) SafeError { return SafeError{Category: category, Message: TruncateErrorMessage(message)} } // TruncateErrorMessage returns a valid UTF-8 message of at most 1,024 bytes. func TruncateErrorMessage(message string) string { message = strings.ToValidUTF8(message, "\uFFFD") if len(message) <= maxErrorMessageBytes { return message } end := maxErrorMessageBytes for end > 0 && !utf8.RuneStart(message[end]) { end-- } return message[:end] } // Validate checks every invariant required for a current comparison manifest. func (manifest Manifest) Validate() error { if manifest.SchemaVersion != SchemaVersion { return fmt.Errorf("unsupported comparison schema version %q", manifest.SchemaVersion) } if strings.TrimSpace(manifest.ComparisonID) == "" { return fmt.Errorf("comparison ID must not be blank") } if manifest.StartedAt.IsZero() || manifest.FinishedAt.IsZero() { return fmt.Errorf("comparison timestamps must be nonzero") } if manifest.StartedAt.Location() != time.UTC || manifest.FinishedAt.Location() != time.UTC { return fmt.Errorf("comparison timestamps must use UTC") } if manifest.FinishedAt.Before(manifest.StartedAt) { return fmt.Errorf("comparison finish time precedes start time") } if manifest.ValidPeriod.Start.IsZero() || manifest.ValidPeriod.End.IsZero() || !manifest.ValidPeriod.End.After(manifest.ValidPeriod.Start) { return fmt.Errorf("valid period must have a nonempty increasing range") } if strings.TrimSpace(manifest.ReportID) == "" || strings.TrimSpace(manifest.Timezone) == "" { return fmt.Errorf("report ID and timezone must not be blank") } if strings.TrimSpace(manifest.PromptID) == "" || strings.TrimSpace(manifest.PromptVersion) == "" || !isSHA256(manifest.PromptHash) { return fmt.Errorf("prompt identity is invalid") } if manifest.DataPackage.Path != DataPackageFilename || !isSHA256(manifest.DataPackage.SHA256) { return fmt.Errorf("data package reference is invalid") } if manifest.Total < 2 || manifest.Total != len(manifest.Results) { return fmt.Errorf("comparison result count is invalid") } if manifest.Succeeded < 0 || manifest.Failed < 0 || manifest.Succeeded+manifest.Failed != manifest.Total { return fmt.Errorf("comparison result totals are inconsistent") } profiles := make(map[string]struct{}, len(manifest.Results)) reportPaths := make(map[string]struct{}, len(manifest.Results)) succeeded, failed := 0, 0 for index, result := range manifest.Results { if result.Position != index+1 { return fmt.Errorf("result position %d is not ordered", result.Position) } if strings.TrimSpace(result.ProfileID) == "" { return fmt.Errorf("result %d has a blank profile ID", result.Position) } if _, duplicate := profiles[result.ProfileID]; duplicate { return fmt.Errorf("result %d duplicates profile ID %q", result.Position, result.ProfileID) } profiles[result.ProfileID] = struct{}{} if strings.TrimSpace(result.ModelName) == "" { return fmt.Errorf("result %d has a blank model name", result.Position) } switch result.Status { case StatusSucceeded: succeeded++ if result.ValidationStatus != "passed" { return fmt.Errorf("successful result %d did not pass validation", result.Position) } if result.Error != nil || !isReportPath(result.ReportPath) { return fmt.Errorf("successful result %d has invalid report details", result.Position) } if _, duplicate := reportPaths[result.ReportPath]; duplicate { return fmt.Errorf("result %d duplicates report path %q", result.Position, result.ReportPath) } reportPaths[result.ReportPath] = struct{}{} case StatusFailed: failed++ if result.ReportPath != "" || result.Error == nil || !isValidationStatus(result.ValidationStatus) { return fmt.Errorf("failed result %d has invalid failure details", result.Position) } if err := result.Error.validate(); err != nil { return fmt.Errorf("failed result %d: %w", result.Position, err) } default: return fmt.Errorf("result %d has unsupported status %q", result.Position, result.Status) } } if succeeded != manifest.Succeeded || failed != manifest.Failed { return fmt.Errorf("comparison status counts do not match results") } return nil } func (safeError SafeError) validate() error { if strings.TrimSpace(safeError.Category) == "" || strings.TrimSpace(safeError.Message) == "" { return fmt.Errorf("safe error category and message must not be blank") } if !utf8.ValidString(safeError.Message) || len(safeError.Message) > maxErrorMessageBytes { return fmt.Errorf("safe error message is not bounded UTF-8") } return nil } func isValidationStatus(status string) bool { return status == "" || status == "failed" || status == "skipped" } func isSHA256(value string) bool { if len(value) != sha256.Size*2 { return false } for _, r := range value { if (r < '0' || r > '9') && (r < 'a' || r > 'f') { return false } } return true } func isReportPath(value string) bool { return strings.HasSuffix(value, ".md") && isArtifactBasename(value) && value != ManifestFilename && value != DataPackageFilename } func isArtifactBasename(value string) bool { return value != "" && value != "." && value != ".." && !strings.ContainsAny(value, "/\\") && path.Base(value) == value } // Validate checks that the manifest and in-memory bundle payloads agree. func (bundle LogicalBundle) Validate() error { if err := bundle.Manifest.Validate(); err != nil { return err } if SHA256(bundle.DataPackage) != bundle.Manifest.DataPackage.SHA256 { return fmt.Errorf("data package digest does not match manifest") } if len(bundle.Reports) != bundle.Manifest.Succeeded { return fmt.Errorf("bundle report count does not match manifest") } reportIndex := 0 for _, result := range bundle.Manifest.Results { if result.Status != StatusSucceeded { continue } report := bundle.Reports[reportIndex] if report.Position != result.Position || report.Path != result.ReportPath || !isReportPath(report.Path) { return fmt.Errorf("bundle report for result %d does not match manifest", result.Position) } reportIndex++ } return nil } // EncodeManifest validates and deterministically encodes a manifest as the // canonical two-space-indented JSON document with one trailing newline. func EncodeManifest(manifest Manifest) ([]byte, error) { if err := manifest.Validate(); err != nil { return nil, err } encoded, err := json.MarshalIndent(manifest, "", " ") if err != nil { return nil, fmt.Errorf("encode comparison manifest: %w", err) } return append(encoded, '\n'), nil }