Migrate comparison bundles to v2

This commit is contained in:
2026-08-25 19:52:41 +00:00
parent 0c9cd6d5fb
commit b3b23fb381
9 changed files with 120 additions and 10 deletions

View File

@@ -9,7 +9,7 @@ and retention belong to the [operations guide](../operations.md).
## Version And Layout ## Version And Layout
The current and only supported manifest schema version is The current and only supported manifest schema version is
`weatherreporter.comparison.v1`. A bundle directory contains exactly these `weatherreporter.comparison.v2`. A bundle directory contains exactly these
regular, non-symlinked files: regular, non-symlinked files:
```text ```text
@@ -51,7 +51,7 @@ this order:
```text ```text
position, profileId, backendId, modelName, status, validationStatus, position, profileId, backendId, modelName, status, validationStatus,
reportPath, error repairAttempts, reportPath, error
``` ```
`startedAt` and `finishedAt` are nonzero UTC timestamps, and the latter is not `startedAt` and `finishedAt` are nonzero UTC timestamps, and the latter is not
@@ -66,12 +66,15 @@ contiguous from one, profile IDs are distinct and nonblank, and
`succeeded + failed == total`. `succeeded + failed == total`.
A successful result has `status: "succeeded"`, `validationStatus: "passed"`, A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
and a non-negative `repairAttempts` count,
a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for
its position, total, and logical profile ID, and no `error`. A failed result has its position, total, and logical profile ID, and no `error`. A failed result has
`status: "failed"`, no `reportPath`, and an `error` object with nonblank `status: "failed"`, no `reportPath`, and an `error` object with nonblank
`category` and `message`. Its validation status is absent, `failed`, or `category` and `message`. Its validation status is absent, `failed`, or
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes. `skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
`backendId` and `validationStatus` are omitted when unavailable. `backendId` and `validationStatus` are omitted when unavailable. A failed
result with a completed validation (`failed` or `skipped`) must retain its
non-negative `repairAttempts`; early operational failures omit both fields.
Every successful Markdown file is declared by exactly one successful result. Every successful Markdown file is declared by exactly one successful result.
The directory contains no extra entries. Consumers can therefore verify the The directory contains no extra entries. Consumers can therefore verify the
@@ -88,7 +91,9 @@ case-variant, or duplicate fields; multiple JSON values; extra entries;
symlinks; and future or otherwise unsupported versions. Treat a bundle that symlinks; and future or otherwise unsupported versions. Treat a bundle that
fails recognition as an ordinary directory, not as a compatible bundle. fails recognition as an ordinary directory, not as a compatible bundle.
When replacing a recognized bundle, cancellation observed before the new Only v2 is recognized as a replaceable bundle; v1 is unsupported and must be
moved or removed before a replacement at the same destination. When replacing
a recognized bundle, cancellation observed before the new
bundle is installed preserves the prior bundle rather than committing the bundle is installed preserves the prior bundle rather than committing the
replacement. replacement.

View File

@@ -122,7 +122,9 @@ The destination is preflighted before prompt inspection and collection, then
rechecked immediately before an atomic publish. A missing or empty directory rechecked immediately before an atomic publish. A missing or empty directory
is usable. A nonempty directory can be replaced only when `--replace` is given is usable. A nonempty directory can be replaced only when `--replace` is given
and it is recognized as a current Weatherreporter comparison bundle; ordinary and it is recognized as a current Weatherreporter comparison bundle; ordinary
directories, symlinks, and unsafe destinations are rejected. Cancellation and directories, symlinks, and unsafe destinations are rejected. Existing v1
bundles are not recognized for replacement: move or remove them first.
Cancellation and
all failures before publication preserve an existing bundle, including a all failures before publication preserve an existing bundle, including a
cancellation observed while a replacement is being prepared. If guarded cancellation observed while a replacement is being prepared. If guarded
restoration cannot complete, the error names the retained sibling bundle for restoration cannot complete, the error names the retained sibling bundle for

View File

@@ -386,6 +386,8 @@ GOWORK=off go test -race -count=1 ./internal/adapters/promptkit ./internal/app
### Stage 7: Migrate Comparison Bundles To v2 ### Stage 7: Migrate Comparison Bundles To v2
Status: Complete.
Purpose: preserve repair activity in the profile-evaluation artifact and make Purpose: preserve repair activity in the profile-evaluation artifact and make
the strict durable schema change explicit. the strict durable schema change explicit.

View File

@@ -59,6 +59,7 @@ type ComparisonProfileResult struct {
ModelName string ModelName string
Status string Status string
ValidationStatus promptexec.ValidationStatus ValidationStatus promptexec.ValidationStatus
RepairAttempts *int
ReportPath string ReportPath string
LLMDebugPath string LLMDebugPath string
Error *comparison.SafeError Error *comparison.SafeError
@@ -197,6 +198,9 @@ func copyComparisonOutcomes(result *ComparisonResult, outcomes []comparisonProfi
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName, Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
Status: outcome.Status, ValidationStatus: outcome.ValidationStatus, LLMDebugPath: outcome.LLMDebugPath, Error: outcome.Error, Status: outcome.Status, ValidationStatus: outcome.ValidationStatus, LLMDebugPath: outcome.LLMDebugPath, Error: outcome.Error,
} }
if outcome.RepairAttempts != nil {
profile.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
}
if published && outcome.Status == comparison.StatusSucceeded { if published && outcome.Status == comparison.StatusSucceeded {
profile.ReportPath = filepath.Join(result.OutputDirectory, outcome.ReportPath) profile.ReportPath = filepath.Join(result.OutputDirectory, outcome.ReportPath)
} }
@@ -226,6 +230,9 @@ func comparisonBundle(result *ComparisonResult, dataPackage []byte, outcomes []c
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName, Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
Status: outcome.Status, ValidationStatus: string(outcome.ValidationStatus), Error: outcome.Error, Status: outcome.Status, ValidationStatus: string(outcome.ValidationStatus), Error: outcome.Error,
} }
if outcome.RepairAttempts != nil {
manifestResult.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
}
if outcome.Status == comparison.StatusSucceeded { if outcome.Status == comparison.StatusSucceeded {
manifestResult.ReportPath = outcome.ReportPath manifestResult.ReportPath = outcome.ReportPath
bundle.Reports = append(bundle.Reports, comparison.BundleReport{Position: outcome.Position, Path: outcome.ReportPath, Markdown: outcome.Markdown}) bundle.Reports = append(bundle.Reports, comparison.BundleReport{Position: outcome.Position, Path: outcome.ReportPath, Markdown: outcome.Markdown})

View File

@@ -107,6 +107,7 @@ type comparisonProfileSummary struct {
ModelName string `json:"modelName"` ModelName string `json:"modelName"`
Status string `json:"status"` Status string `json:"status"`
ValidationStatus string `json:"validationStatus,omitempty"` ValidationStatus string `json:"validationStatus,omitempty"`
RepairAttempts *int `json:"repairAttempts,omitempty"`
ReportPath string `json:"reportPath,omitempty"` ReportPath string `json:"reportPath,omitempty"`
LLMDebugPath string `json:"llmDebugPath,omitempty"` LLMDebugPath string `json:"llmDebugPath,omitempty"`
Error *comparison.SafeError `json:"error,omitempty"` Error *comparison.SafeError `json:"error,omitempty"`
@@ -167,6 +168,11 @@ func newGenerateNotificationSummary(result *app.NotificationResult) *generateNot
return summary return summary
} }
func repairAttemptsCopy(value int) *int {
copy := value
return &copy
}
func newBatchSummary(result *app.BatchResult, err error) batchSummary { func newBatchSummary(result *app.BatchResult, err error) batchSummary {
summary := batchSummary{Command: commandRun} summary := batchSummary{Command: commandRun}
if result == nil { if result == nil {
@@ -208,6 +214,9 @@ func newComparisonSummary(result *app.ComparisonResult, err error) comparisonSum
Status: profile.Status, ValidationStatus: string(profile.ValidationStatus), ReportPath: profile.ReportPath, Status: profile.Status, ValidationStatus: string(profile.ValidationStatus), ReportPath: profile.ReportPath,
LLMDebugPath: profile.LLMDebugPath, Error: profile.Error, LLMDebugPath: profile.LLMDebugPath, Error: profile.Error,
}) })
if profile.RepairAttempts != nil {
summary.Results[len(summary.Results)-1].RepairAttempts = repairAttemptsCopy(*profile.RepairAttempts)
}
} }
summary.Status = comparisonSummaryStatus(result, err) summary.Status = comparisonSummaryStatus(result, err)
if err != nil { if err != nil {

View File

@@ -15,7 +15,7 @@ import (
const ( const (
// SchemaVersion identifies the supported comparison manifest schema. // SchemaVersion identifies the supported comparison manifest schema.
SchemaVersion = "weatherreporter.comparison.v1" SchemaVersion = "weatherreporter.comparison.v2"
// ManifestFilename is the canonical name of a comparison manifest. // ManifestFilename is the canonical name of a comparison manifest.
ManifestFilename = "comparison.json" ManifestFilename = "comparison.json"
@@ -71,6 +71,7 @@ type Result struct {
ModelName string `json:"modelName"` ModelName string `json:"modelName"`
Status string `json:"status"` Status string `json:"status"`
ValidationStatus string `json:"validationStatus,omitempty"` ValidationStatus string `json:"validationStatus,omitempty"`
RepairAttempts *int `json:"repairAttempts,omitempty"`
ReportPath string `json:"reportPath,omitempty"` ReportPath string `json:"reportPath,omitempty"`
Error *SafeError `json:"error,omitempty"` Error *SafeError `json:"error,omitempty"`
} }
@@ -276,11 +277,14 @@ func (manifest Manifest) Validate() error {
if strings.TrimSpace(result.ModelName) == "" { if strings.TrimSpace(result.ModelName) == "" {
return fmt.Errorf("result %d has a blank model name", result.Position) return fmt.Errorf("result %d has a blank model name", result.Position)
} }
if result.RepairAttempts != nil && *result.RepairAttempts < 0 {
return fmt.Errorf("result %d has negative repair attempts", result.Position)
}
switch result.Status { switch result.Status {
case StatusSucceeded: case StatusSucceeded:
succeeded++ succeeded++
if result.ValidationStatus != "passed" { if result.ValidationStatus != "passed" || result.RepairAttempts == nil {
return fmt.Errorf("successful result %d did not pass validation", result.Position) return fmt.Errorf("successful result %d did not pass validation", result.Position)
} }
expectedPath, err := ReportFilename(result.Position, manifest.Total, result.ProfileID) expectedPath, err := ReportFilename(result.Position, manifest.Total, result.ProfileID)
@@ -302,6 +306,9 @@ func (manifest Manifest) Validate() error {
if err := result.Error.validate(); err != nil { if err := result.Error.validate(); err != nil {
return fmt.Errorf("failed result %d: %w", result.Position, err) return fmt.Errorf("failed result %d: %w", result.Position, err)
} }
if result.ValidationStatus != "" && result.RepairAttempts == nil {
return fmt.Errorf("failed result %d has validation without repair provenance", result.Position)
}
default: default:
return fmt.Errorf("result %d has unsupported status %q", result.Position, result.Status) return fmt.Errorf("result %d has unsupported status %q", result.Position, result.Status)
} }

View File

@@ -118,7 +118,7 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
t.Fatalf("EncodeManifest() error = %v", err) t.Fatalf("EncodeManifest() error = %v", err)
} }
want := "{\n" + want := "{\n" +
" \"schemaVersion\": \"weatherreporter.comparison.v1\",\n" + " \"schemaVersion\": \"weatherreporter.comparison.v2\",\n" +
" \"comparisonId\": \"comparison_daily-2026-08-24\",\n" + " \"comparisonId\": \"comparison_daily-2026-08-24\",\n" +
" \"startedAt\": \"2026-08-24T12:00:00Z\",\n" + " \"startedAt\": \"2026-08-24T12:00:00Z\",\n" +
" \"finishedAt\": \"2026-08-24T12:01:00Z\",\n" + " \"finishedAt\": \"2026-08-24T12:01:00Z\",\n" +
@@ -146,6 +146,7 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
" \"modelName\": \"gpt-5-mini\",\n" + " \"modelName\": \"gpt-5-mini\",\n" +
" \"status\": \"succeeded\",\n" + " \"status\": \"succeeded\",\n" +
" \"validationStatus\": \"passed\",\n" + " \"validationStatus\": \"passed\",\n" +
" \"repairAttempts\": 0,\n" +
" \"reportPath\": \"01-weather-light.md\"\n" + " \"reportPath\": \"01-weather-light.md\"\n" +
" },\n" + " },\n" +
" {\n" + " {\n" +
@@ -309,6 +310,7 @@ func validManifest() Manifest {
ModelName: "gpt-5-mini", ModelName: "gpt-5-mini",
Status: StatusSucceeded, Status: StatusSucceeded,
ValidationStatus: "passed", ValidationStatus: "passed",
RepairAttempts: intPtr(0),
ReportPath: "01-weather-light.md", ReportPath: "01-weather-light.md",
}, },
{ {
@@ -321,3 +323,8 @@ func validManifest() Manifest {
}, },
} }
} }
func intPtr(value int) *int {
copy := value
return &copy
}

View File

@@ -388,6 +388,7 @@ var resultFields = map[string]jsonValueValidator{
"modelName": nil, "modelName": nil,
"status": nil, "status": nil,
"validationStatus": nil, "validationStatus": nil,
"repairAttempts": validateRepairAttemptsJSON,
"reportPath": nil, "reportPath": nil,
"error": validateSafeErrorJSON, "error": validateSafeErrorJSON,
} }
@@ -430,7 +431,7 @@ func validateResultsJSON(decoder *json.Decoder) error {
return fmt.Errorf("results must be an array") return fmt.Errorf("results must be an array")
} }
for decoder.More() { for decoder.More() {
if err := validateJSONObject(decoder, resultFields); err != nil { if err := validateOrderedJSONObject(decoder, resultFields, []string{"position", "profileId", "backendId", "modelName", "status", "validationStatus", "repairAttempts", "reportPath", "error"}); err != nil {
return err return err
} }
} }
@@ -444,6 +445,65 @@ func validateResultsJSON(decoder *json.Decoder) error {
return nil return nil
} }
func validateOrderedJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator, order []string) error {
token, err := decoder.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return fmt.Errorf("manifest value must be an object")
}
seen := make(map[string]struct{}, len(fields))
last := -1
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return err
}
name, ok := token.(string)
if !ok {
return fmt.Errorf("manifest field name is invalid")
}
validator, known := fields[name]
if !known {
return fmt.Errorf("manifest field %q is not canonical", name)
}
if _, duplicate := seen[name]; duplicate {
return fmt.Errorf("manifest field %q is duplicated", name)
}
position := -1
for index, candidate := range order {
if candidate == name {
position = index
break
}
}
if position <= last {
return fmt.Errorf("manifest field %q is out of canonical order", name)
}
seen[name] = struct{}{}
last = position
if validator == nil {
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return err
}
continue
}
if err := validator(decoder); err != nil {
return err
}
}
token, err = decoder.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' {
return fmt.Errorf("manifest object has an invalid terminator")
}
return nil
}
func validateSafeErrorJSON(decoder *json.Decoder) error { func validateSafeErrorJSON(decoder *json.Decoder) error {
token, err := decoder.Token() token, err := decoder.Token()
if err != nil { if err != nil {
@@ -458,6 +518,17 @@ func validateSafeErrorJSON(decoder *json.Decoder) error {
return validateJSONObjectBody(decoder, safeErrorFields) return validateJSONObjectBody(decoder, safeErrorFields)
} }
func validateRepairAttemptsJSON(decoder *json.Decoder) error {
var value int
if err := decoder.Decode(&value); err != nil {
return err
}
if value < 0 {
return fmt.Errorf("repairAttempts must not be negative")
}
return nil
}
func validateJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator) error { func validateJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
token, err := decoder.Token() token, err := decoder.Token()
if err != nil { if err != nil {

View File

@@ -1077,7 +1077,7 @@ func testBundleWithReports(t *testing.T, count int) LogicalBundle {
} }
manifest.Results[i] = Result{ manifest.Results[i] = Result{
Position: position, ProfileID: profileID, ModelName: "gpt-5-mini", Position: position, ProfileID: profileID, ModelName: "gpt-5-mini",
Status: StatusSucceeded, ValidationStatus: "passed", ReportPath: path, Status: StatusSucceeded, ValidationStatus: "passed", RepairAttempts: intPtr(0), ReportPath: path,
} }
reports[i] = BundleReport{Position: position, Path: path, Markdown: []byte("# Daily\n")} reports[i] = BundleReport{Position: position, Path: path, Markdown: []byte("# Daily\n")}
} }