Files
weatherreporter/internal/comparison/comparison.go

390 lines
13 KiB
Go

// 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)
}
expectedPath, err := ReportFilename(result.Position, manifest.Total, result.ProfileID)
if err != nil {
return fmt.Errorf("successful result %d has invalid report identity: %w", result.Position, err)
}
if result.Error != nil || result.ReportPath != expectedPath {
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
}