Establish prepared metadata identity

This commit is contained in:
2026-08-13 01:52:11 +00:00
parent e520ffb13b
commit e2dd8d0e29
6 changed files with 153 additions and 27 deletions

View File

@@ -78,9 +78,12 @@ modules. SPC discussion is omitted unless a retained categorical outlook meets
the package's severity criterion and matching discussion text exists. the package's severity criterion and matching discussion text exists.
Effective units, timezone, and location context arrive in `ModuleContext` from Effective units, timezone, and location context arrive in `ModuleContext` from
configuration and resolved report metadata. Field defaults are owned by configuration and resolved report metadata. Report preparation also creates one
[configuration](../config.md), and prompt-package layout is owned by `PreparedIdentity` for the shared report identity, timing, configuration
[prompt input](prompt-input.md). context, and source warnings. Briefing metadata derives its matching fields
from that value; module projections retain their prompt-safe shape. Field
defaults are owned by [configuration](../config.md), and prompt-package layout
is owned by [prompt input](prompt-input.md).
## Verification and invariants ## Verification and invariants

View File

@@ -4,11 +4,17 @@
execution. This is the immutable boundary shared by ordinary report generation execution. This is the immutable boundary shared by ordinary report generation
and profile comparison; it is not a durable artifact. and profile comparison; it is not a durable artifact.
Preparation builds report facts, the configured module snapshot, briefing Preparation first establishes one `PreparedIdentity` for the report run, report
metadata, the curated prompt-input package, serialized YAML, and the and prompt IDs, variant, generation time, units, timezone, valid period,
generated-text definition. It deep-copies mutable facts, snapshots, metadata, location, and source warnings. It then builds report facts, the configured
and data-package bytes before returning them. Consumers receive independent module snapshot, briefing metadata, the curated prompt-input package,
copies so one execution cannot change another's input or rendering context. serialized YAML, and the generated-text definition. The current metadata
projections remain equivalent to that identity while each retains its own
boundary-specific shape.
Preparation deep-copies mutable facts, snapshots, identity, metadata, and
data-package bytes before returning them. Consumers receive independent copies
so one execution cannot change another's input or rendering context.
Single-report generation executes one prepared profile and publishes its Single-report generation executes one prepared profile and publishes its
Markdown. Comparison prepares once, gives every selected profile the same YAML Markdown. Comparison prepares once, gives every selected profile the same YAML

View File

@@ -20,6 +20,7 @@ type preparedReport struct {
resolved report.Resolved resolved report.Resolved
reportFacts ReportFacts reportFacts ReportFacts
moduleSnapshot module.Snapshot moduleSnapshot module.Snapshot
identity briefing.PreparedIdentity
briefingMetadata briefing.Metadata briefingMetadata briefing.Metadata
sourceWarnings []weatherdata.SourceWarning sourceWarnings []weatherdata.SourceWarning
dataPackage []byte dataPackage []byte
@@ -54,11 +55,13 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "build report facts", err: err} return preparedReport{}, &preparationError{operation: "build report facts", err: err}
} }
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
identity := briefing.BuildPreparedIdentity(buildContext)
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts) moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts)
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err} return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
} }
metadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)) metadata := briefing.BuildMetadata(identity, buildContext.Bundle)
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot}) dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot})
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "build data package", err: err} return preparedReport{}, &preparationError{operation: "build data package", err: err}
@@ -80,6 +83,10 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err} return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
} }
clonedIdentity, err := clonePreparedValue(identity)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
}
clonedMetadata, err := clonePreparedValue(metadata) clonedMetadata, err := clonePreparedValue(metadata)
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err} return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err}
@@ -88,8 +95,9 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
resolved: cloneResolved(req.Resolved), resolved: cloneResolved(req.Resolved),
reportFacts: clonedFacts, reportFacts: clonedFacts,
moduleSnapshot: clonedSnapshot, moduleSnapshot: clonedSnapshot,
identity: clonedIdentity,
briefingMetadata: clonedMetadata, briefingMetadata: clonedMetadata,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...), sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
dataPackage: append([]byte(nil), serializedDataPackage...), dataPackage: append([]byte(nil), serializedDataPackage...),
handler: handler, handler: handler,
} }

View File

@@ -5,7 +5,9 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -29,11 +31,12 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("second prepareReport() error = %v", err) t.Fatalf("second prepareReport() error = %v", err)
} }
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) { if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) {
t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.briefingMetadata) t.Fatalf("prepared package/identity/metadata are not deterministic: %q/%#v/%#v", prepared.dataPackage, prepared.identity, prepared.briefingMetadata)
} }
originalDataPackage := append([]byte(nil), prepared.dataPackage...) originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalIdentity := prepared.identity
originalMetadata := prepared.briefingMetadata originalMetadata := prepared.briefingMetadata
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...) originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
metadata, snapshot, reportFacts, err := prepared.renderInputs() metadata, snapshot, reportFacts, err := prepared.renderInputs()
@@ -52,7 +55,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
bundle.Sources[0].Query["mutated"] = "true" bundle.Sources[0].Query["mutated"] = "true"
} }
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
t.Fatalf("prepared values changed after caller mutation: %#v", prepared) t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
} }
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" { if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" {
@@ -62,3 +65,47 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
t.Fatal("prepared report values retain consumer mutation") t.Fatal("prepared report values retain consumer mutation")
} }
} }
func TestPrepareReportSharesOneMetadataIdentity(t *testing.T) {
cfg := generationConfig()
bundle := generationBundle(t)
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
}, generationTime("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}})
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
}
identity := prepared.identity
metadata := prepared.briefingMetadata
if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(metadata.Location, identity.Location) || !reflect.DeepEqual(metadata.SourceWarnings, identity.SourceWarnings) {
t.Fatalf("briefing metadata does not match prepared identity: %#v/%#v", metadata, identity)
}
prompt := promptMetadata(metadata)
if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) {
t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity)
}
moduleMetadata, found, err := module.StanzaValue[briefing.MetadataModule](prepared.moduleSnapshot, "metadata")
if err != nil || !found {
t.Fatalf("metadata stanza = %#v/%t/%v", moduleMetadata, found, err)
}
if moduleMetadata.RunID != identity.RunID || moduleMetadata.ReportID != identity.ReportID || moduleMetadata.Variant != identity.Variant || moduleMetadata.PromptID != identity.PromptID || !moduleMetadata.GeneratedAt.Equal(identity.GeneratedAt) || moduleMetadata.Units != identity.Units || moduleMetadata.Timezone != identity.Timezone || moduleMetadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(moduleMetadata.Location, identity.Location) {
t.Fatalf("module metadata does not match prepared identity: %#v/%#v", moduleMetadata, identity)
}
if len(moduleMetadata.SourceWarnings) != len(identity.SourceWarnings) {
t.Fatalf("module source warnings = %#v, want %#v", moduleMetadata.SourceWarnings, identity.SourceWarnings)
}
for index, warning := range identity.SourceWarnings {
summary := moduleMetadata.SourceWarnings[index]
if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact {
t.Fatalf("module source warning %d = %#v, want %#v", index, summary, warning)
}
}
}

View File

@@ -26,6 +26,21 @@ type Metadata struct {
Alerts *AlertStatus `json:"alerts,omitempty"` Alerts *AlertStatus `json:"alerts,omitempty"`
} }
// PreparedIdentity is the single prepared authority for report identity,
// timing, configuration context, and source warnings.
type PreparedIdentity struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
}
type LocationContext struct { type LocationContext struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -59,24 +74,39 @@ type BuildContext struct {
Location *LocationContext Location *LocationContext
} }
func BuildMetadata(ctx BuildContext) Metadata { func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
metadata := ctx.Resolved.Metadata() metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) return PreparedIdentity{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Units: ctx.Units,
Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...),
}
}
func BuildMetadata(identity PreparedIdentity, bundle *weatherdata.Bundle) Metadata {
sourceLocationID, sourceLocation := sourceLocation(bundle)
return Metadata{ return Metadata{
RunID: metadata.RunID, RunID: identity.RunID,
ReportID: metadata.ReportID, ReportID: identity.ReportID,
Variant: variantForReport(metadata.ReportID), Variant: identity.Variant,
PromptID: metadata.PromptID, PromptID: identity.PromptID,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: identity.GeneratedAt,
Units: ctx.Units, Units: identity.Units,
Timezone: ctx.Timezone, Timezone: identity.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: identity.ValidPeriod,
Location: copyLocation(ctx.Location), Location: copyLocation(identity.Location),
SourceLocationID: sourceLocationID, SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation, SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle), Sources: sourceMetadata(bundle),
SourceWarnings: sourceWarnings(ctx.Bundle), SourceWarnings: append([]weatherdata.SourceWarning(nil), identity.SourceWarnings...),
Alerts: alertStatus(ctx.Bundle), Alerts: alertStatus(bundle),
} }
} }

View File

@@ -0,0 +1,32 @@
package briefing
import (
"reflect"
"testing"
)
func TestPreparedIdentityBuildsEquivalentMetadata(t *testing.T) {
moduleContext := testModuleContext()
context := BuildContext{
Resolved: moduleContext.Resolved,
Bundle: moduleContext.Collected.Bundle(),
Units: moduleContext.Units,
Timezone: moduleContext.Timezone,
Location: moduleContext.Location,
}
identity := BuildPreparedIdentity(context)
metadata := BuildMetadata(identity, context.Bundle)
if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod {
t.Fatalf("metadata identity = %#v, want %#v", metadata, identity)
}
if !reflect.DeepEqual(metadata.Location, identity.Location) || !reflect.DeepEqual(metadata.SourceWarnings, identity.SourceWarnings) {
t.Fatalf("metadata location/warnings = %#v/%#v, want %#v/%#v", metadata.Location, metadata.SourceWarnings, identity.Location, identity.SourceWarnings)
}
metadata.Location.Name = "consumer mutation"
metadata.SourceWarnings = append(metadata.SourceWarnings, moduleContext.Collected.SourceWarnings[0])
if identity.Location.Name == "consumer mutation" || len(identity.SourceWarnings) != len(moduleContext.Collected.SourceWarnings) {
t.Fatalf("identity changed through metadata mutation: %#v", identity)
}
}