From 2c4c0bbd9036c603a0cb84ae634fc3ce1ee5f940 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 13 Aug 2026 03:59:09 +0000 Subject: [PATCH] Define briefing fact requirement vocabulary --- internal/briefing/fact_requirements.go | 54 +++++++++++++++++ internal/briefing/modules.go | 84 +++++++++++++------------- internal/briefing/modules_test.go | 54 +++++++++++++++-- internal/module/module.go | 20 ------ internal/module/module_test.go | 7 --- 5 files changed, 146 insertions(+), 73 deletions(-) create mode 100644 internal/briefing/fact_requirements.go diff --git a/internal/briefing/fact_requirements.go b/internal/briefing/fact_requirements.go new file mode 100644 index 0000000..3626f9a --- /dev/null +++ b/internal/briefing/fact_requirements.go @@ -0,0 +1,54 @@ +package briefing + +type factRequirementCategory string + +const ( + collectedFactRequirement factRequirementCategory = "collected" + derivedFactRequirement factRequirementCategory = "derived" +) + +type factRequirement struct { + identity string + category factRequirementCategory +} + +func (r factRequirement) String() string { + return r.identity +} + +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} + + 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} +) + +var factRequirementVocabulary = []factRequirement{ + currentConditionsRequirement, + narrativeForecastRequirement, + hourlyForecastRequirement, + alertsRequirement, + discussionRequirement, + weatherStoryRequirement, + spcOutlooksRequirement, + sourceMetadataRequirement, + hourlyPeriodsRequirement, + narrativePeriodsRequirement, + alertOverlapsRequirement, + dailySummariesRequirement, + daypartSummariesRequirement, + precipTimingRequirement, + spcDerivedOutlooksRequirement, +} diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index bc1576b..26a4ebe 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -28,8 +28,8 @@ type ModuleDefinition struct { ID module.ID StanzaName string DefaultOptions any - RequiredCollected []module.FactRequirement - RequiredDerived []module.FactRequirement + RequiredCollected []factRequirement + RequiredDerived []factRequirement SupportedReports []report.ID MissingData module.MissingDataBehavior AllowDuplicate bool @@ -154,55 +154,55 @@ func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []strin var missing []string for _, requirement := range definition.RequiredCollected { if !collectedFactAvailable(requirement, ctx) { - missing = append(missing, string(requirement)) + missing = append(missing, requirement.String()) } } for _, requirement := range definition.RequiredDerived { if !derivedFactAvailable(requirement, ctx) { - missing = append(missing, string(requirement)) + missing = append(missing, requirement.String()) } } return missing } -func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool { +func collectedFactAvailable(requirement factRequirement, ctx ModuleContext) bool { switch requirement { - case module.CollectedCurrentConditions: + case currentConditionsRequirement: return ctx.Collected.Current != nil - case module.CollectedNarrativeForecast: + case narrativeForecastRequirement: return ctx.Collected.Narrative != nil - case module.CollectedHourlyForecast: + case hourlyForecastRequirement: return ctx.Collected.Hourly != nil - case module.CollectedAlerts: + case alertsRequirement: return ctx.Collected.Alerts != nil - case module.CollectedDiscussion: + case discussionRequirement: return ctx.Collected.Discussion != nil - case module.CollectedWeatherStory: + case weatherStoryRequirement: return ctx.Collected.WeatherStory != nil - case module.CollectedSPCConvectiveOutlooks: + case spcOutlooksRequirement: return ctx.Collected.SPCConvectiveOutlooks != nil - case module.CollectedSourceMetadata: + case sourceMetadataRequirement: return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0 default: return false } } -func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool { +func derivedFactAvailable(requirement factRequirement, ctx ModuleContext) bool { switch requirement { - case module.RequiresDerivedHourlyPeriods: + case hourlyPeriodsRequirement: return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0 - case module.RequiresDerivedNarrativePeriods: + case narrativePeriodsRequirement: return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0 - case module.RequiresDerivedAlertOverlaps: + case alertOverlapsRequirement: return true - case module.RequiresDerivedDailySummaries: + case dailySummariesRequirement: return len(ctx.Derived.DailySummaries) > 0 - case module.RequiresDerivedDaypartSummaries: + case daypartSummariesRequirement: return len(ctx.Derived.DaypartSummaries) > 0 - case module.RequiresDerivedPrecipTiming: + case precipTimingRequirement: return true - case module.RequiresDerivedSPCConvectiveOutlooks: + case spcDerivedOutlooksRequirement: return ctx.Derived.SPCConvectiveOutlooks != nil default: return false @@ -291,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata}, + RequiredCollected: []factRequirement{sourceMetadataRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildMetadataModule, @@ -300,7 +300,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.CurrentConditions, StanzaName: "current_conditions", DefaultOptions: module.CurrentConditionsOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions}, + RequiredCollected: []factRequirement{currentConditionsRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildCurrentConditionsModule, @@ -310,8 +310,8 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.NarrativeForecast, StanzaName: "narrative_forecast", DefaultOptions: module.NarrativeForecastOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods}, + RequiredCollected: []factRequirement{narrativeForecastRequirement}, + RequiredDerived: []factRequirement{narrativePeriodsRequirement}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, MissingData: module.MissingDataOmit, Builder: buildNarrativeForecastModule, @@ -320,8 +320,8 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.HourlyForecast, StanzaName: "hourly_forecast", DefaultOptions: module.HourlyForecastOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, + RequiredCollected: []factRequirement{hourlyForecastRequirement}, + RequiredDerived: []factRequirement{hourlyPeriodsRequirement}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}, MissingData: module.MissingDataOmit, Builder: buildHourlyForecastModule, @@ -331,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", DefaultOptions: module.DerivedDailySummaryOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, + RequiredDerived: []factRequirement{dailySummariesRequirement, precipTimingRequirement}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, MissingData: module.MissingDataError, Builder: buildDerivedDailySummaryModule, @@ -340,7 +340,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", DefaultOptions: module.DerivedDaypartSummariesOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, + RequiredDerived: []factRequirement{daypartSummariesRequirement}, SupportedReports: daypartReports, MissingData: module.MissingDataError, Builder: buildDerivedDaypartSummariesModule, @@ -350,7 +350,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.PrecipTiming, StanzaName: "precip_timing", DefaultOptions: module.PrecipTimingOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming}, + RequiredDerived: []factRequirement{precipTimingRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildPrecipTimingModule, @@ -359,8 +359,8 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.AlertDigest, StanzaName: "alert_digest", DefaultOptions: module.AlertDigestOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedAlerts}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps}, + RequiredCollected: []factRequirement{alertsRequirement}, + RequiredDerived: []factRequirement{alertOverlapsRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildAlertDigestModule, @@ -369,8 +369,8 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), DefaultOptions: module.SPCConvectiveOutlooksOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks}, + RequiredCollected: []factRequirement{spcOutlooksRequirement}, + RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildSPCConvectiveOutlooksModule, @@ -379,7 +379,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", DefaultOptions: module.AreaForecastDiscussionOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedDiscussion}, + RequiredCollected: []factRequirement{discussionRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildAreaForecastDiscussionModule, @@ -388,8 +388,8 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), DefaultOptions: module.SPCConvectiveDiscussionOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks}, + RequiredCollected: []factRequirement{spcOutlooksRequirement}, + RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildSPCConvectiveDiscussionModule, @@ -398,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.WeatherStory, StanzaName: "weather_story", DefaultOptions: module.WeatherStoryOptions{}, - RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory}, + RequiredCollected: []factRequirement{weatherStoryRequirement}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildWeatherStoryModule, @@ -407,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.OutdoorWindows, StanzaName: "outdoor_windows", DefaultOptions: module.OutdoorWindowsOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, + RequiredDerived: []factRequirement{daypartSummariesRequirement}, SupportedReports: daypartReports, MissingData: module.MissingDataEmpty, Builder: buildOutdoorWindowsModule, @@ -416,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.TodayPlanning, StanzaName: "today_planning", DefaultOptions: module.TodayPlanningOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + RequiredDerived: []factRequirement{dailySummariesRequirement}, SupportedReports: []report.ID{report.Today}, MissingData: module.MissingDataEmpty, Builder: buildTodayPlanningModule, @@ -425,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", DefaultOptions: module.TomorrowPlanningOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + RequiredDerived: []factRequirement{dailySummariesRequirement}, SupportedReports: []report.ID{report.Tomorrow}, MissingData: module.MissingDataEmpty, Builder: buildTomorrowPlanningModule, @@ -434,7 +434,7 @@ func defaultModuleDefinitions() []ModuleDefinition { ID: module.DailyPlanning, StanzaName: "daily_planning", DefaultOptions: module.DailyPlanningOptions{}, - RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + RequiredDerived: []factRequirement{dailySummariesRequirement}, SupportedReports: []report.ID{report.Daily}, MissingData: module.MissingDataEmpty, Builder: buildDailyPlanningModule, diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index b148ca1..90fedfd 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -30,6 +30,52 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) { } } +func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) { + vocabulary := make(map[string]factRequirement, len(factRequirementVocabulary)) + for _, requirement := range factRequirementVocabulary { + if requirement.identity == "" { + t.Fatal("fact requirement identity is empty") + } + if requirement.category != collectedFactRequirement && requirement.category != derivedFactRequirement { + t.Fatalf("fact requirement %q category = %q, want collected or derived", requirement.identity, requirement.category) + } + if _, exists := vocabulary[requirement.identity]; exists { + t.Fatalf("duplicate fact requirement %q", requirement.identity) + } + vocabulary[requirement.identity] = requirement + } + + used := map[factRequirement]struct{}{} + for _, definition := range defaultModuleDefinitions() { + for _, requirement := range definition.RequiredCollected { + assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement) + } + for _, requirement := range definition.RequiredDerived { + assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, derivedFactRequirement) + } + } + for _, requirement := range factRequirementVocabulary { + if _, ok := used[requirement]; !ok { + t.Fatalf("fact requirement %q is not used by a module definition", requirement.identity) + } + } +} + +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 { + t.Fatalf("module %q uses unknown fact requirement %q", moduleID, requirement.identity) + } + if descriptor != requirement { + t.Fatalf("module %q requirement %q does not use the vocabulary descriptor", moduleID, requirement.identity) + } + if requirement.category != want { + t.Fatalf("module %q requirement %q category = %q, want %q", moduleID, requirement.identity, requirement.category, want) + } + used[requirement] = struct{}{} +} + func TestDefaultReportModulesBuildSnapshots(t *testing.T) { registry := MustDefaultModuleRegistry() for _, definition := range report.DefaultRegistry().All() { @@ -467,24 +513,24 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) { ctx := ModuleContext{} - if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) { + if collectedFactAvailable(spcOutlooksRequirement, ctx) { t.Fatal("collectedFactAvailable() = true, want false without source") } ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}} - if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) { + if !collectedFactAvailable(spcOutlooksRequirement, ctx) { t.Fatal("collectedFactAvailable() = false, want true with checked source") } } func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) { ctx := ModuleContext{} - if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) { + if derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) { t.Fatal("derivedFactAvailable() = true, want false without derived outlooks") } ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}} - if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) { + if !derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) { t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks") } } diff --git a/internal/module/module.go b/internal/module/module.go index 1ac4f91..3337ee7 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -114,26 +114,6 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) { return zero, true, nil } -type FactRequirement string - -const ( - CollectedCurrentConditions FactRequirement = "collected.current_conditions" - CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast" - CollectedHourlyForecast FactRequirement = "collected.hourly_forecast" - CollectedAlerts FactRequirement = "collected.alerts" - CollectedDiscussion FactRequirement = "collected.discussion" - CollectedWeatherStory FactRequirement = "collected.weather_story" - CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks" - CollectedSourceMetadata FactRequirement = "collected.source_metadata" - RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" - RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" - RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" - RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" - RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" - RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" - RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks" -) - type MissingDataBehavior string const ( diff --git a/internal/module/module_test.go b/internal/module/module_test.go index f861903..6923a6b 100644 --- a/internal/module/module_test.go +++ b/internal/module/module_test.go @@ -120,13 +120,6 @@ func TestSPCConvectiveModuleContractsAreStable(t *testing.T) { if SPCConvectiveDiscussion != ID("spc_convective_discussion") { t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion) } - if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") { - t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks) - } - if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") { - t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks) - } - _ = SPCConvectiveOutlooksOptions{} _ = SPCConvectiveDiscussionOptions{} }