Validate fact requirements through briefing vocabulary

This commit is contained in:
2026-08-13 04:02:21 +00:00
parent 2c4c0bbd90
commit 71b7a74d3d
4 changed files with 167 additions and 93 deletions

View File

@@ -10,7 +10,10 @@ snapshot, construct YAML, invoke Promptkit, or render a report.
Every `ModuleDefinition` declares an ID, stanza name, default option value,
required collected and derived facts, supported report IDs, missing-data
behavior, duplicate policy, builder, and optional prompt exporter.
behavior, duplicate policy, builder, and optional prompt exporter. The
briefing-owned fact-requirement vocabulary supplies each prerequisite's stable
identity, category, and availability predicate; registry construction rejects
unknown requirements and requirements listed under the wrong category.
`BuildModule` first verifies the requested module, report compatibility, and
option shape. It then applies the declared missing-data behavior:

View File

@@ -8,8 +8,9 @@ const (
)
type factRequirement struct {
identity string
category factRequirementCategory
identity string
category factRequirementCategory
available func(ModuleContext) bool
}
func (r factRequirement) String() string {
@@ -17,25 +18,55 @@ func (r factRequirement) String() string {
}
var (
currentConditionsRequirement = factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement}
narrativeForecastRequirement = factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement}
hourlyForecastRequirement = factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement}
alertsRequirement = factRequirement{identity: "collected.alerts", category: collectedFactRequirement}
discussionRequirement = factRequirement{identity: "collected.discussion", category: collectedFactRequirement}
weatherStoryRequirement = factRequirement{identity: "collected.weather_story", category: collectedFactRequirement}
spcOutlooksRequirement = factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement}
sourceMetadataRequirement = factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement}
currentConditionsRequirement = &factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Current != nil
}}
narrativeForecastRequirement = &factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Narrative != nil
}}
hourlyForecastRequirement = &factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Hourly != nil
}}
alertsRequirement = &factRequirement{identity: "collected.alerts", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Alerts != nil
}}
discussionRequirement = &factRequirement{identity: "collected.discussion", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Discussion != nil
}}
weatherStoryRequirement = &factRequirement{identity: "collected.weather_story", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.WeatherStory != nil
}}
spcOutlooksRequirement = &factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.SPCConvectiveOutlooks != nil
}}
sourceMetadataRequirement = &factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
}}
hourlyPeriodsRequirement = factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement}
narrativePeriodsRequirement = factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement}
alertOverlapsRequirement = factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement}
dailySummariesRequirement = factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement}
daypartSummariesRequirement = factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement}
precipTimingRequirement = factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement}
spcDerivedOutlooksRequirement = factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement}
hourlyPeriodsRequirement = &factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
}}
narrativePeriodsRequirement = &factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
}}
alertOverlapsRequirement = &factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement, available: func(ModuleContext) bool {
return true
}}
dailySummariesRequirement = &factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.DailySummaries) > 0
}}
daypartSummariesRequirement = &factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.DaypartSummaries) > 0
}}
precipTimingRequirement = &factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement, available: func(ModuleContext) bool {
return true
}}
spcDerivedOutlooksRequirement = &factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Derived.SPCConvectiveOutlooks != nil
}}
)
var factRequirementVocabulary = []factRequirement{
var factRequirementVocabulary = []*factRequirement{
currentConditionsRequirement,
narrativeForecastRequirement,
hourlyForecastRequirement,

View File

@@ -28,8 +28,8 @@ type ModuleDefinition struct {
ID module.ID
StanzaName string
DefaultOptions any
RequiredCollected []factRequirement
RequiredDerived []factRequirement
RequiredCollected []*factRequirement
RequiredDerived []*factRequirement
SupportedReports []report.ID
MissingData module.MissingDataBehavior
AllowDuplicate bool
@@ -76,6 +76,9 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
if definition.MissingData == module.MissingDataWarn {
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
}
if err := validateFactRequirements(definition); err != nil {
return ModuleRegistry{}, err
}
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
}
@@ -153,60 +156,48 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
var missing []string
for _, requirement := range definition.RequiredCollected {
if !collectedFactAvailable(requirement, ctx) {
if !requirement.available(ctx) {
missing = append(missing, requirement.String())
}
}
for _, requirement := range definition.RequiredDerived {
if !derivedFactAvailable(requirement, ctx) {
if !requirement.available(ctx) {
missing = append(missing, requirement.String())
}
}
return missing
}
func collectedFactAvailable(requirement factRequirement, ctx ModuleContext) bool {
switch requirement {
case currentConditionsRequirement:
return ctx.Collected.Current != nil
case narrativeForecastRequirement:
return ctx.Collected.Narrative != nil
case hourlyForecastRequirement:
return ctx.Collected.Hourly != nil
case alertsRequirement:
return ctx.Collected.Alerts != nil
case discussionRequirement:
return ctx.Collected.Discussion != nil
case weatherStoryRequirement:
return ctx.Collected.WeatherStory != nil
case spcOutlooksRequirement:
return ctx.Collected.SPCConvectiveOutlooks != nil
case sourceMetadataRequirement:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
return false
func validateFactRequirements(definition ModuleDefinition) error {
if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil {
return err
}
return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement)
}
func derivedFactAvailable(requirement factRequirement, ctx ModuleContext) bool {
switch requirement {
case hourlyPeriodsRequirement:
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
case narrativePeriodsRequirement:
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
case alertOverlapsRequirement:
return true
case dailySummariesRequirement:
return len(ctx.Derived.DailySummaries) > 0
case daypartSummariesRequirement:
return len(ctx.Derived.DaypartSummaries) > 0
case precipTimingRequirement:
return true
case spcDerivedOutlooksRequirement:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error {
for _, requirement := range requirements {
if requirement == nil {
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "")
}
descriptor, ok := lookupFactRequirement(requirement.identity)
if !ok || descriptor != requirement {
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, requirement.identity)
}
if descriptor.category != want {
return fmt.Errorf("module %q lists %s fact requirement %q as %s", moduleID, descriptor.category, descriptor.identity, want)
}
}
return nil
}
func lookupFactRequirement(identity string) (*factRequirement, bool) {
for _, requirement := range factRequirementVocabulary {
if requirement.identity == identity {
return requirement, true
}
}
return nil, false
}
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
@@ -291,7 +282,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
RequiredCollected: []factRequirement{sourceMetadataRequirement},
RequiredCollected: []*factRequirement{sourceMetadataRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildMetadataModule,
@@ -300,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.CurrentConditions,
StanzaName: "current_conditions",
DefaultOptions: module.CurrentConditionsOptions{},
RequiredCollected: []factRequirement{currentConditionsRequirement},
RequiredCollected: []*factRequirement{currentConditionsRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
@@ -310,8 +301,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.NarrativeForecast,
StanzaName: "narrative_forecast",
DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []factRequirement{narrativeForecastRequirement},
RequiredDerived: []factRequirement{narrativePeriodsRequirement},
RequiredCollected: []*factRequirement{narrativeForecastRequirement},
RequiredDerived: []*factRequirement{narrativePeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule,
@@ -320,8 +311,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.HourlyForecast,
StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []factRequirement{hourlyForecastRequirement},
RequiredDerived: []factRequirement{hourlyPeriodsRequirement},
RequiredCollected: []*factRequirement{hourlyForecastRequirement},
RequiredDerived: []*factRequirement{hourlyPeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
@@ -331,7 +322,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement, precipTimingRequirement},
RequiredDerived: []*factRequirement{dailySummariesRequirement, precipTimingRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule,
@@ -340,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDaypartSummaries,
StanzaName: "derived_daypart_summaries",
DefaultOptions: module.DerivedDaypartSummariesOptions{},
RequiredDerived: []factRequirement{daypartSummariesRequirement},
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports,
MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule,
@@ -350,7 +341,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []factRequirement{precipTimingRequirement},
RequiredDerived: []*factRequirement{precipTimingRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,
@@ -359,8 +350,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AlertDigest,
StanzaName: "alert_digest",
DefaultOptions: module.AlertDigestOptions{},
RequiredCollected: []factRequirement{alertsRequirement},
RequiredDerived: []factRequirement{alertOverlapsRequirement},
RequiredCollected: []*factRequirement{alertsRequirement},
RequiredDerived: []*factRequirement{alertOverlapsRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule,
@@ -369,8 +360,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []factRequirement{spcOutlooksRequirement},
RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement},
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
@@ -379,7 +370,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []factRequirement{discussionRequirement},
RequiredCollected: []*factRequirement{discussionRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
@@ -388,8 +379,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []factRequirement{spcOutlooksRequirement},
RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement},
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
@@ -398,7 +389,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.WeatherStory,
StanzaName: "weather_story",
DefaultOptions: module.WeatherStoryOptions{},
RequiredCollected: []factRequirement{weatherStoryRequirement},
RequiredCollected: []*factRequirement{weatherStoryRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildWeatherStoryModule,
@@ -407,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.OutdoorWindows,
StanzaName: "outdoor_windows",
DefaultOptions: module.OutdoorWindowsOptions{},
RequiredDerived: []factRequirement{daypartSummariesRequirement},
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports,
MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule,
@@ -416,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TodayPlanning,
StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement},
RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule,
@@ -425,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement},
RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule,
@@ -434,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement},
RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,

View File

@@ -31,7 +31,7 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
}
func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
vocabulary := make(map[string]factRequirement, len(factRequirementVocabulary))
vocabulary := make(map[string]*factRequirement, len(factRequirementVocabulary))
for _, requirement := range factRequirementVocabulary {
if requirement.identity == "" {
t.Fatal("fact requirement identity is empty")
@@ -42,10 +42,13 @@ func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
if _, exists := vocabulary[requirement.identity]; exists {
t.Fatalf("duplicate fact requirement %q", requirement.identity)
}
if requirement.available == nil {
t.Fatalf("fact requirement %q has no availability predicate", requirement.identity)
}
vocabulary[requirement.identity] = requirement
}
used := map[factRequirement]struct{}{}
used := map[*factRequirement]struct{}{}
for _, definition := range defaultModuleDefinitions() {
for _, requirement := range definition.RequiredCollected {
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement)
@@ -61,7 +64,7 @@ func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
}
}
func assertFactRequirementCategory(t *testing.T, vocabulary map[string]factRequirement, used map[factRequirement]struct{}, moduleID module.ID, requirement factRequirement, want factRequirementCategory) {
func assertFactRequirementCategory(t *testing.T, vocabulary map[string]*factRequirement, used map[*factRequirement]struct{}, moduleID module.ID, requirement *factRequirement, want factRequirementCategory) {
t.Helper()
descriptor, ok := vocabulary[requirement.identity]
if !ok {
@@ -480,6 +483,52 @@ func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
}
}
func TestModuleRegistryRejectsInvalidFactRequirements(t *testing.T) {
tests := []struct {
name string
configure func(*ModuleDefinition)
wantErr string
}{
{
name: "Unknown",
configure: func(definition *ModuleDefinition) {
definition.RequiredCollected = []*factRequirement{{identity: "collected.unknown", category: collectedFactRequirement}}
},
wantErr: `module "metadata" uses unknown collected fact requirement "collected.unknown"`,
},
{
name: "DerivedListedAsCollected",
configure: func(definition *ModuleDefinition) {
definition.RequiredCollected = []*factRequirement{dailySummariesRequirement}
},
wantErr: `module "metadata" lists derived fact requirement "derived.daily_summaries" as collected`,
},
{
name: "CollectedListedAsDerived",
configure: func(definition *ModuleDefinition) {
definition.RequiredDerived = []*factRequirement{currentConditionsRequirement}
},
wantErr: `module "metadata" lists collected fact requirement "collected.current_conditions" as derived`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
definition := ModuleDefinition{
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
Builder: noopModuleBuilder,
}
tt.configure(&definition)
_, err := NewModuleRegistry([]ModuleDefinition{definition})
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
@@ -513,25 +562,25 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(spcOutlooksRequirement, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
if spcOutlooksRequirement.available(ctx) {
t.Fatal("SPC outlook requirement is available, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(spcOutlooksRequirement, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
if !spcOutlooksRequirement.available(ctx) {
t.Fatal("SPC outlook requirement is unavailable, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
if spcDerivedOutlooksRequirement.available(ctx) {
t.Fatal("derived SPC outlook requirement is available, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
if !spcDerivedOutlooksRequirement.available(ctx) {
t.Fatal("derived SPC outlook requirement is unavailable, want true for checked empty derived outlooks")
}
}