Establish prepared metadata identity
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,10 +74,9 @@ 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{
|
||||||
return Metadata{
|
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: variantForReport(metadata.ReportID),
|
||||||
@@ -72,11 +86,27 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
Timezone: ctx.Timezone,
|
Timezone: ctx.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
Location: copyLocation(ctx.Location),
|
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: 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,
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
internal/briefing/prepared_identity_test.go
Normal file
32
internal/briefing/prepared_identity_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user