390 lines
13 KiB
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
|
|
}
|