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.
Effective units, timezone, and location context arrive in `ModuleContext` from
configuration and resolved report metadata. Field defaults are owned by
[configuration](../config.md), and prompt-package layout is owned by
[prompt input](prompt-input.md).
configuration and resolved report metadata. Report preparation also creates one
`PreparedIdentity` for the shared report identity, timing, configuration
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

View File

@@ -4,11 +4,17 @@
execution. This is the immutable boundary shared by ordinary report generation
and profile comparison; it is not a durable artifact.
Preparation builds report facts, the configured module snapshot, briefing
metadata, the curated prompt-input package, serialized YAML, and the
generated-text definition. It deep-copies mutable facts, snapshots, metadata,
and data-package bytes before returning them. Consumers receive independent
copies so one execution cannot change another's input or rendering context.
Preparation first establishes one `PreparedIdentity` for the report run, report
and prompt IDs, variant, generation time, units, timezone, valid period,
location, and source warnings. It then builds report facts, the configured
module snapshot, briefing metadata, the curated prompt-input package,
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
Markdown. Comparison prepares once, gives every selected profile the same YAML

View File

@@ -20,6 +20,7 @@ type preparedReport struct {
resolved report.Resolved
reportFacts ReportFacts
moduleSnapshot module.Snapshot
identity briefing.PreparedIdentity
briefingMetadata briefing.Metadata
sourceWarnings []weatherdata.SourceWarning
dataPackage []byte
@@ -54,11 +55,13 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil {
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)
if err != nil {
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})
if err != nil {
return preparedReport{}, &preparationError{operation: "build data package", err: err}
@@ -80,6 +83,10 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil {
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)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err}
@@ -88,8 +95,9 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
resolved: cloneResolved(req.Resolved),
reportFacts: clonedFacts,
moduleSnapshot: clonedSnapshot,
identity: clonedIdentity,
briefingMetadata: clonedMetadata,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...),
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
dataPackage: append([]byte(nil), serializedDataPackage...),
handler: handler,
}

View File

@@ -5,7 +5,9 @@ import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -29,11 +31,12 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
if err != nil {
t.Fatalf("second prepareReport() error = %v", err)
}
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) {
t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.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/identity/metadata are not deterministic: %q/%#v/%#v", prepared.dataPackage, prepared.identity, prepared.briefingMetadata)
}
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalIdentity := prepared.identity
originalMetadata := prepared.briefingMetadata
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
metadata, snapshot, reportFacts, err := prepared.renderInputs()
@@ -52,7 +55,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
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)
}
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")
}
}
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"`
}
// 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 {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
@@ -59,24 +74,39 @@ type BuildContext struct {
Location *LocationContext
}
func BuildMetadata(ctx BuildContext) Metadata {
func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
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{
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),
RunID: identity.RunID,
ReportID: identity.ReportID,
Variant: identity.Variant,
PromptID: identity.PromptID,
GeneratedAt: identity.GeneratedAt,
Units: identity.Units,
Timezone: identity.Timezone,
ValidPeriod: identity.ValidPeriod,
Location: copyLocation(identity.Location),
SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
Sources: sourceMetadata(bundle),
SourceWarnings: append([]weatherdata.SourceWarning(nil), identity.SourceWarnings...),
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)
}
}