From 9d1263743cafa87b4c84e6102d096aa26ce8dda0 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 22 Mar 2026 10:31:16 +0100 Subject: [PATCH] Add surveillance detection governance --- cmd/sdrd/http_handlers.go | 34 ++++--- cmd/sdrd/level_summary.go | 5 +- cmd/sdrd/pipeline_runtime.go | 79 ++++++++++------ internal/config/config.go | 86 ++++++++++------- internal/pipeline/evidence.go | 45 +++++++++ internal/pipeline/phases.go | 27 +++--- internal/pipeline/policy.go | 114 ++++++++++++----------- internal/pipeline/scheduler.go | 1 + internal/pipeline/surveillance_policy.go | 80 ++++++++++++++++ internal/runtime/runtime.go | 20 +++- 10 files changed, 338 insertions(+), 153 deletions(-) create mode 100644 internal/pipeline/surveillance_policy.go diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 1964a20..872a99b 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -143,6 +143,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime "mode": policy.Mode, "intent": policy.Intent, "surveillance_strategy": policy.SurveillanceStrategy, + "surveillance_detection": policy.SurveillanceDetection, "refinement_strategy": policy.RefinementStrategy, "monitor_center_hz": policy.MonitorCenterHz, "monitor_start_hz": policy.MonitorStartHz, @@ -178,21 +179,24 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime candidateEvidence := buildCandidateEvidenceSummary(snap.surveillance.Candidates) candidateEvidenceStates := buildCandidateEvidenceStateSummary(snap.surveillance.Candidates) out := map[string]any{ - "plan": snap.refinement.Input.Plan, - "windows": snap.refinement.Input.Windows, - "window_stats": windowStats, - "request": snap.refinement.Input.Request, - "context": snap.refinement.Input.Context, - "detail_level": snap.refinement.Input.Detail, - "arbitration": arbitration, - "work_items": snap.refinement.Input.WorkItems, - "candidates": len(snap.refinement.Input.Candidates), - "scheduled": len(snap.refinement.Input.Scheduled), - "signals": len(snap.refinement.Result.Signals), - "decisions": len(snap.refinement.Result.Decisions), - "surveillance_level": snap.surveillance.Level, - "surveillance_levels": snap.surveillance.Levels, - "surveillance_level_set": levelSet, + "plan": snap.refinement.Input.Plan, + "windows": snap.refinement.Input.Windows, + "window_stats": windowStats, + "request": snap.refinement.Input.Request, + "context": snap.refinement.Input.Context, + "detail_level": snap.refinement.Input.Detail, + "arbitration": arbitration, + "work_items": snap.refinement.Input.WorkItems, + "candidates": len(snap.refinement.Input.Candidates), + "scheduled": len(snap.refinement.Input.Scheduled), + "signals": len(snap.refinement.Result.Signals), + "decisions": len(snap.refinement.Result.Decisions), + "surveillance_level": snap.surveillance.Level, + "surveillance_levels": snap.surveillance.Levels, + "surveillance_level_set": levelSet, + "surveillance_detection_policy": snap.surveillance.DetectionPolicy, + "surveillance_detection_levels": levelSet.Detection, + "surveillance_support_levels": levelSet.Support, "surveillance_active_levels": func() []pipeline.AnalysisLevel { if len(levelSet.All) > 0 { return levelSet.All diff --git a/cmd/sdrd/level_summary.go b/cmd/sdrd/level_summary.go index 17e54f6..aea71e1 100644 --- a/cmd/sdrd/level_summary.go +++ b/cmd/sdrd/level_summary.go @@ -36,7 +36,7 @@ type CandidateEvidenceStateSummary struct { } func buildSurveillanceLevelSummaries(set pipeline.SurveillanceLevelSet, spectra []pipeline.SurveillanceLevelSpectrum) map[string]SurveillanceLevelSummary { - if set.Primary.Name == "" && len(set.Derived) == 0 && set.Presentation.Name == "" && len(set.All) == 0 { + if set.Primary.Name == "" && len(set.Derived) == 0 && len(set.Support) == 0 && set.Presentation.Name == "" && len(set.All) == 0 { return nil } bins := map[string]int{} @@ -54,6 +54,9 @@ func buildSurveillanceLevelSummaries(set pipeline.SurveillanceLevelSet, spectra if len(set.Derived) > 0 { levels = append(levels, set.Derived...) } + if len(set.Support) > 0 { + levels = append(levels, set.Support...) + } if set.Presentation.Name != "" { levels = append(levels, set.Presentation) } diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 4b20658..4b366e4 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -82,12 +82,13 @@ type surveillanceLevelSpec struct { } type surveillancePlan struct { - Primary pipeline.AnalysisLevel - Levels []pipeline.AnalysisLevel - LevelSet pipeline.SurveillanceLevelSet - Presentation pipeline.AnalysisLevel - Context pipeline.AnalysisContext - Specs []surveillanceLevelSpec + Primary pipeline.AnalysisLevel + Levels []pipeline.AnalysisLevel + LevelSet pipeline.SurveillanceLevelSet + Presentation pipeline.AnalysisLevel + Context pipeline.AnalysisContext + DetectionPolicy pipeline.SurveillanceDetectionPolicy + Specs []surveillanceLevelSpec } const derivedIDBlock = int64(1_000_000_000) @@ -442,18 +443,19 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S candidates := pipeline.FuseCandidates(primaryCandidates, derivedCandidates) scheduled := pipeline.ScheduleCandidates(candidates, policy) return pipeline.SurveillanceResult{ - Level: plan.Primary, - Levels: plan.Levels, - LevelSet: plan.LevelSet, - DisplayLevel: plan.Presentation, - Context: plan.Context, - Spectra: art.surveillanceSpectra, - Candidates: candidates, - Scheduled: scheduled, - Finished: art.finished, - Signals: art.detected, - NoiseFloor: art.noiseFloor, - Thresholds: art.thresholds, + Level: plan.Primary, + Levels: plan.Levels, + LevelSet: plan.LevelSet, + DetectionPolicy: plan.DetectionPolicy, + DisplayLevel: plan.Presentation, + Context: plan.Context, + Spectra: art.surveillanceSpectra, + Candidates: candidates, + Scheduled: scheduled, + Finished: art.finished, + Signals: art.detected, + NoiseFloor: art.noiseFloor, + Thresholds: art.thresholds, } } @@ -862,11 +864,13 @@ func (rt *dspRuntime) buildSurveillancePlan(policy pipeline.Policy) surveillance baseFFT = rt.cfg.FFTSize } span := spanForPolicy(policy, float64(baseRate)) - primary := analysisLevel("surveillance", "surveillance", "surveillance", baseRate, baseFFT, rt.cfg.CenterHz, span, "baseband", 1, baseRate) + detectionPolicy := pipeline.SurveillanceDetectionPolicyFromPolicy(policy) + primary := analysisLevel("surveillance", pipeline.RoleSurveillancePrimary, "surveillance", baseRate, baseFFT, rt.cfg.CenterHz, span, "baseband", 1, baseRate) levels := []pipeline.AnalysisLevel{primary} specs := []surveillanceLevelSpec{{Level: primary, Decim: 1, AllowGPU: true}} context := pipeline.AnalysisContext{Surveillance: primary} derivedLevels := make([]pipeline.AnalysisLevel, 0, 2) + supportLevels := make([]pipeline.AnalysisLevel, 0, 2) strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy)) switch strategy { @@ -876,36 +880,51 @@ func (rt *dspRuntime) buildSurveillancePlan(policy pipeline.Policy) surveillance derivedFFT := baseFFT / decim if derivedRate >= 200000 && derivedFFT >= 256 { derivedSpan := spanForPolicy(policy, float64(derivedRate)) - derived := analysisLevel("surveillance-lowres", "surveillance-lowres", "surveillance", derivedRate, derivedFFT, rt.cfg.CenterHz, derivedSpan, "decimated", decim, baseRate) - levels = append(levels, derived) + role := pipeline.RoleSurveillanceSupport + if detectionPolicy.DerivedDetectionEnabled { + role = pipeline.RoleSurveillanceDerived + } + derived := analysisLevel("surveillance-lowres", role, "surveillance", derivedRate, derivedFFT, rt.cfg.CenterHz, derivedSpan, "decimated", decim, baseRate) + if detectionPolicy.DerivedDetectionEnabled { + levels = append(levels, derived) + derivedLevels = append(derivedLevels, derived) + } else { + supportLevels = append(supportLevels, derived) + } specs = append(specs, surveillanceLevelSpec{Level: derived, Decim: decim, AllowGPU: false}) context.Derived = append(context.Derived, derived) - derivedLevels = append(derivedLevels, derived) } } - presentation := analysisLevel("presentation", "presentation", "presentation", baseRate, rt.cfg.Surveillance.DisplayBins, rt.cfg.CenterHz, span, "display", 1, baseRate) + presentation := analysisLevel("presentation", pipeline.RolePresentation, "presentation", baseRate, rt.cfg.Surveillance.DisplayBins, rt.cfg.CenterHz, span, "display", 1, baseRate) context.Presentation = presentation levelSet := pipeline.SurveillanceLevelSet{ Primary: primary, Derived: append([]pipeline.AnalysisLevel(nil), derivedLevels...), + Support: append([]pipeline.AnalysisLevel(nil), supportLevels...), Presentation: presentation, } - allLevels := make([]pipeline.AnalysisLevel, 0, 1+len(derivedLevels)+1) + detectionLevels := make([]pipeline.AnalysisLevel, 0, 1+len(derivedLevels)) + detectionLevels = append(detectionLevels, primary) + detectionLevels = append(detectionLevels, derivedLevels...) + levelSet.Detection = detectionLevels + allLevels := make([]pipeline.AnalysisLevel, 0, 1+len(derivedLevels)+len(supportLevels)+1) allLevels = append(allLevels, primary) allLevels = append(allLevels, derivedLevels...) + allLevels = append(allLevels, supportLevels...) if presentation.Name != "" { allLevels = append(allLevels, presentation) } levelSet.All = allLevels return surveillancePlan{ - Primary: primary, - Levels: levels, - LevelSet: levelSet, - Presentation: presentation, - Context: context, - Specs: specs, + Primary: primary, + Levels: levels, + LevelSet: levelSet, + Presentation: presentation, + Context: context, + DetectionPolicy: detectionPolicy, + Specs: specs, } } diff --git a/internal/config/config.go b/internal/config/config.go index 7b720bb..c93f3a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "math" "os" + "strings" "time" "gopkg.in/yaml.v3" @@ -87,11 +88,12 @@ type PipelineConfig struct { } type SurveillanceConfig struct { - AnalysisFFTSize int `yaml:"analysis_fft_size" json:"analysis_fft_size"` - FrameRate int `yaml:"frame_rate" json:"frame_rate"` - Strategy string `yaml:"strategy" json:"strategy"` - DisplayBins int `yaml:"display_bins" json:"display_bins"` - DisplayFPS int `yaml:"display_fps" json:"display_fps"` + AnalysisFFTSize int `yaml:"analysis_fft_size" json:"analysis_fft_size"` + FrameRate int `yaml:"frame_rate" json:"frame_rate"` + Strategy string `yaml:"strategy" json:"strategy"` + DisplayBins int `yaml:"display_bins" json:"display_bins"` + DisplayFPS int `yaml:"display_fps" json:"display_fps"` + DerivedDetection string `yaml:"derived_detection" json:"derived_detection"` } type RefinementConfig struct { @@ -170,11 +172,12 @@ func Default() Config { }, }, Surveillance: SurveillanceConfig{ - AnalysisFFTSize: 2048, - FrameRate: 15, - Strategy: "single-resolution", - DisplayBins: 2048, - DisplayFPS: 15, + AnalysisFFTSize: 2048, + FrameRate: 15, + Strategy: "single-resolution", + DisplayBins: 2048, + DisplayFPS: 15, + DerivedDetection: "auto", }, Refinement: RefinementConfig{ Enabled: true, @@ -198,11 +201,12 @@ func Default() Config { Description: "Current single-band pipeline behavior", Pipeline: &PipelineConfig{Mode: "legacy", Profile: "legacy", Goals: PipelineGoalConfig{Intent: "general-monitoring"}}, Surveillance: &SurveillanceConfig{ - AnalysisFFTSize: 2048, - FrameRate: 15, - Strategy: "single-resolution", - DisplayBins: 2048, - DisplayFPS: 15, + AnalysisFFTSize: 2048, + FrameRate: 15, + Strategy: "single-resolution", + DisplayBins: 2048, + DisplayFPS: 15, + DerivedDetection: "auto", }, Refinement: &RefinementConfig{ Enabled: true, @@ -229,11 +233,12 @@ func Default() Config { SignalPriorities: []string{"digital", "wfm"}, }}, Surveillance: &SurveillanceConfig{ - AnalysisFFTSize: 4096, - FrameRate: 12, - Strategy: "multi-resolution", - DisplayBins: 2048, - DisplayFPS: 12, + AnalysisFFTSize: 4096, + FrameRate: 12, + Strategy: "multi-resolution", + DisplayBins: 2048, + DisplayFPS: 12, + DerivedDetection: "auto", }, Refinement: &RefinementConfig{ Enabled: true, @@ -260,11 +265,12 @@ func Default() Config { SignalPriorities: []string{"digital", "wfm", "trunk"}, }}, Surveillance: &SurveillanceConfig{ - AnalysisFFTSize: 8192, - FrameRate: 10, - Strategy: "multi-resolution", - DisplayBins: 4096, - DisplayFPS: 10, + AnalysisFFTSize: 8192, + FrameRate: 10, + Strategy: "multi-resolution", + DisplayBins: 4096, + DisplayFPS: 10, + DerivedDetection: "auto", }, Refinement: &RefinementConfig{ Enabled: true, @@ -291,11 +297,12 @@ func Default() Config { SignalPriorities: []string{"wfm", "nfm", "digital"}, }}, Surveillance: &SurveillanceConfig{ - AnalysisFFTSize: 4096, - FrameRate: 12, - Strategy: "single-resolution", - DisplayBins: 2048, - DisplayFPS: 12, + AnalysisFFTSize: 4096, + FrameRate: 12, + Strategy: "single-resolution", + DisplayBins: 2048, + DisplayFPS: 12, + DerivedDetection: "auto", }, Refinement: &RefinementConfig{ Enabled: true, @@ -322,11 +329,12 @@ func Default() Config { SignalPriorities: []string{"ft8", "wspr", "fsk", "psk", "dmr"}, }}, Surveillance: &SurveillanceConfig{ - AnalysisFFTSize: 4096, - FrameRate: 12, - Strategy: "multi-resolution", - DisplayBins: 2048, - DisplayFPS: 12, + AnalysisFFTSize: 4096, + FrameRate: 12, + Strategy: "multi-resolution", + DisplayBins: 2048, + DisplayFPS: 12, + DerivedDetection: "auto", }, Refinement: &RefinementConfig{ Enabled: true, @@ -495,6 +503,14 @@ func applyDefaults(cfg Config) Config { if cfg.Surveillance.Strategy == "" { cfg.Surveillance.Strategy = "single-resolution" } + if cfg.Surveillance.DerivedDetection == "" { + cfg.Surveillance.DerivedDetection = "auto" + } + switch strings.ToLower(strings.TrimSpace(cfg.Surveillance.DerivedDetection)) { + case "auto", "on", "off", "true", "false", "enabled", "disabled", "enable", "disable": + default: + cfg.Surveillance.DerivedDetection = "auto" + } if cfg.Surveillance.DisplayBins <= 0 { cfg.Surveillance.DisplayBins = cfg.FFTSize } diff --git a/internal/pipeline/evidence.go b/internal/pipeline/evidence.go index f502002..69c761b 100644 --- a/internal/pipeline/evidence.go +++ b/internal/pipeline/evidence.go @@ -6,6 +6,13 @@ import ( "strings" ) +const ( + RoleSurveillancePrimary = "surveillance-primary" + RoleSurveillanceDerived = "surveillance-derived" + RoleSurveillanceSupport = "surveillance-support" + RolePresentation = "presentation" +) + // CandidateEvidenceState summarizes fused evidence semantics for a candidate. type CandidateEvidenceState struct { TotalLevelEntries int `json:"total_level_entries"` @@ -13,6 +20,7 @@ type CandidateEvidenceState struct { DetectionLevelCount int `json:"detection_level_count"` PrimaryLevelCount int `json:"primary_level_count,omitempty"` DerivedLevelCount int `json:"derived_level_count,omitempty"` + SupportLevelCount int `json:"support_level_count,omitempty"` PresentationLevelCount int `json:"presentation_level_count,omitempty"` Levels []string `json:"levels,omitempty"` Provenance []string `json:"provenance,omitempty"` @@ -30,6 +38,7 @@ type EvidenceScoreDetails struct { DetectionLevels int `json:"detection_levels"` PrimaryLevels int `json:"primary_levels,omitempty"` DerivedLevels int `json:"derived_levels,omitempty"` + SupportLevels int `json:"support_levels,omitempty"` ProvenanceCount int `json:"provenance_count,omitempty"` DerivedOnly bool `json:"derived_only,omitempty"` MultiLevelConfirmed bool `json:"multi_level_confirmed,omitempty"` @@ -44,20 +53,41 @@ func IsPresentationLevel(level AnalysisLevel) bool { role := strings.ToLower(strings.TrimSpace(level.Role)) truth := strings.ToLower(strings.TrimSpace(level.Truth)) name := strings.ToLower(strings.TrimSpace(level.Name)) + if role == RolePresentation { + return true + } if strings.Contains(role, "presentation") || strings.Contains(truth, "presentation") { return true } return strings.Contains(name, "presentation") || strings.Contains(name, "display") } +// IsSupportLevel reports whether a level is a non-detection support level. +func IsSupportLevel(level AnalysisLevel) bool { + role := strings.ToLower(strings.TrimSpace(level.Role)) + if role == RoleSurveillanceSupport { + return true + } + return strings.Contains(role, "surveillance-support") || strings.Contains(role, "support") +} + // IsDetectionLevel reports whether a level is intended for detection/analysis. func IsDetectionLevel(level AnalysisLevel) bool { if IsPresentationLevel(level) { return false } + if IsSupportLevel(level) { + return false + } role := strings.ToLower(strings.TrimSpace(level.Role)) truth := strings.ToLower(strings.TrimSpace(level.Truth)) name := strings.ToLower(strings.TrimSpace(level.Name)) + switch role { + case RoleSurveillancePrimary, RoleSurveillanceDerived: + return true + case RoleSurveillanceSupport: + return false + } if strings.Contains(truth, "surveillance") { return true } @@ -70,12 +100,21 @@ func IsDetectionLevel(level AnalysisLevel) bool { func isPrimarySurveillanceLevel(level AnalysisLevel) bool { role := strings.ToLower(strings.TrimSpace(level.Role)) name := strings.ToLower(strings.TrimSpace(level.Name)) + if role == RoleSurveillancePrimary { + return true + } return role == "surveillance" || name == "surveillance" } func isDerivedSurveillanceLevel(level AnalysisLevel) bool { role := strings.ToLower(strings.TrimSpace(level.Role)) name := strings.ToLower(strings.TrimSpace(level.Name)) + if role == RoleSurveillanceSupport { + return false + } + if role == RoleSurveillanceDerived { + return true + } if strings.HasPrefix(role, "surveillance-") && role != "surveillance" { return true } @@ -107,6 +146,7 @@ func CandidateEvidenceStateFor(candidate Candidate) CandidateEvidenceState { primaryLevels := map[string]struct{}{} derivedLevels := map[string]struct{}{} presentationLevels := map[string]struct{}{} + supportLevels := map[string]struct{}{} for _, ev := range candidate.Evidence { levelKey := evidenceLevelKey(ev.Level) levelSet[levelKey] = struct{}{} @@ -117,6 +157,10 @@ func CandidateEvidenceStateFor(candidate Candidate) CandidateEvidenceState { presentationLevels[levelKey] = struct{}{} continue } + if IsSupportLevel(ev.Level) { + supportLevels[levelKey] = struct{}{} + continue + } if IsDetectionLevel(ev.Level) { detectionLevels[levelKey] = struct{}{} if isPrimarySurveillanceLevel(ev.Level) { @@ -131,6 +175,7 @@ func CandidateEvidenceStateFor(candidate Candidate) CandidateEvidenceState { state.DetectionLevelCount = len(detectionLevels) state.PrimaryLevelCount = len(primaryLevels) state.DerivedLevelCount = len(derivedLevels) + state.SupportLevelCount = len(supportLevels) state.PresentationLevelCount = len(presentationLevels) state.Levels = sortedKeys(levelSet) state.Provenance = sortedKeys(provenanceSet) diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go index b9ea486..8c41163 100644 --- a/internal/pipeline/phases.go +++ b/internal/pipeline/phases.go @@ -31,23 +31,26 @@ type AnalysisContext struct { type SurveillanceLevelSet struct { Primary AnalysisLevel `json:"primary"` Derived []AnalysisLevel `json:"derived,omitempty"` + Support []AnalysisLevel `json:"support,omitempty"` Presentation AnalysisLevel `json:"presentation,omitempty"` + Detection []AnalysisLevel `json:"detection,omitempty"` All []AnalysisLevel `json:"all,omitempty"` } type SurveillanceResult struct { - Level AnalysisLevel `json:"level"` - Levels []AnalysisLevel `json:"levels,omitempty"` - LevelSet SurveillanceLevelSet `json:"level_set,omitempty"` - Candidates []Candidate `json:"candidates"` - Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` - Finished []detector.Event `json:"finished"` - Signals []detector.Signal `json:"signals"` - NoiseFloor float64 `json:"noise_floor"` - Thresholds []float64 `json:"thresholds,omitempty"` - DisplayLevel AnalysisLevel `json:"display_level"` - Context AnalysisContext `json:"context,omitempty"` - Spectra []SurveillanceLevelSpectrum `json:"spectra,omitempty"` + Level AnalysisLevel `json:"level"` + Levels []AnalysisLevel `json:"levels,omitempty"` + LevelSet SurveillanceLevelSet `json:"level_set,omitempty"` + DetectionPolicy SurveillanceDetectionPolicy `json:"detection_policy,omitempty"` + Candidates []Candidate `json:"candidates"` + Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` + Finished []detector.Event `json:"finished"` + Signals []detector.Signal `json:"signals"` + NoiseFloor float64 `json:"noise_floor"` + Thresholds []float64 `json:"thresholds,omitempty"` + DisplayLevel AnalysisLevel `json:"display_level"` + Context AnalysisContext `json:"context,omitempty"` + Spectra []SurveillanceLevelSpectrum `json:"spectra,omitempty"` } type RefinementPlan struct { diff --git a/internal/pipeline/policy.go b/internal/pipeline/policy.go index 01565ed..272ec5b 100644 --- a/internal/pipeline/policy.go +++ b/internal/pipeline/policy.go @@ -3,34 +3,36 @@ package pipeline import "sdr-wideband-suite/internal/config" type Policy struct { - Mode string `json:"mode"` - Profile string `json:"profile,omitempty"` - Intent string `json:"intent"` - MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"` - MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` - MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` - MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` - SignalPriorities []string `json:"signal_priorities,omitempty"` - AutoRecordClasses []string `json:"auto_record_classes,omitempty"` - AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` - SurveillanceFFTSize int `json:"surveillance_fft_size"` - SurveillanceFPS int `json:"surveillance_fps"` - DisplayBins int `json:"display_bins"` - DisplayFPS int `json:"display_fps"` - SurveillanceStrategy string `json:"surveillance_strategy"` - RefinementStrategy string `json:"refinement_strategy,omitempty"` - RefinementEnabled bool `json:"refinement_enabled"` - MaxRefinementJobs int `json:"max_refinement_jobs"` - RefinementMaxConcurrent int `json:"refinement_max_concurrent"` - RefinementDetailFFTSize int `json:"refinement_detail_fft_size"` - MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` - RefinementMinSpanHz float64 `json:"refinement_min_span_hz"` - RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` - RefinementAutoSpan bool `json:"refinement_auto_span"` - PreferGPU bool `json:"prefer_gpu"` - MaxRecordingStreams int `json:"max_recording_streams"` - MaxDecodeJobs int `json:"max_decode_jobs"` - DecisionHoldMs int `json:"decision_hold_ms"` + Mode string `json:"mode"` + Profile string `json:"profile,omitempty"` + Intent string `json:"intent"` + MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"` + MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` + MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` + MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` + SignalPriorities []string `json:"signal_priorities,omitempty"` + AutoRecordClasses []string `json:"auto_record_classes,omitempty"` + AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` + SurveillanceFFTSize int `json:"surveillance_fft_size"` + SurveillanceFPS int `json:"surveillance_fps"` + DisplayBins int `json:"display_bins"` + DisplayFPS int `json:"display_fps"` + SurveillanceStrategy string `json:"surveillance_strategy"` + SurveillanceDerivedDetection string `json:"surveillance_derived_detection"` + RefinementStrategy string `json:"refinement_strategy,omitempty"` + RefinementEnabled bool `json:"refinement_enabled"` + MaxRefinementJobs int `json:"max_refinement_jobs"` + RefinementMaxConcurrent int `json:"refinement_max_concurrent"` + RefinementDetailFFTSize int `json:"refinement_detail_fft_size"` + MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` + RefinementMinSpanHz float64 `json:"refinement_min_span_hz"` + RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` + RefinementAutoSpan bool `json:"refinement_auto_span"` + PreferGPU bool `json:"prefer_gpu"` + MaxRecordingStreams int `json:"max_recording_streams"` + MaxDecodeJobs int `json:"max_decode_jobs"` + DecisionHoldMs int `json:"decision_hold_ms"` + SurveillanceDetection SurveillanceDetectionPolicy `json:"surveillance_detection,omitempty"` } func PolicyFromConfig(cfg config.Config) Policy { @@ -39,35 +41,37 @@ func PolicyFromConfig(cfg config.Config) Policy { detailFFT = cfg.Surveillance.AnalysisFFTSize } p := Policy{ - Mode: cfg.Pipeline.Mode, - Profile: cfg.Pipeline.Profile, - Intent: cfg.Pipeline.Goals.Intent, - MonitorCenterHz: cfg.CenterHz, - MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, - MonitorEndHz: cfg.Pipeline.Goals.MonitorEndHz, - MonitorSpanHz: cfg.Pipeline.Goals.MonitorSpanHz, - SignalPriorities: append([]string(nil), cfg.Pipeline.Goals.SignalPriorities...), - AutoRecordClasses: append([]string(nil), cfg.Pipeline.Goals.AutoRecordClasses...), - AutoDecodeClasses: append([]string(nil), cfg.Pipeline.Goals.AutoDecodeClasses...), - SurveillanceFFTSize: cfg.Surveillance.AnalysisFFTSize, - SurveillanceFPS: cfg.Surveillance.FrameRate, - DisplayBins: cfg.Surveillance.DisplayBins, - DisplayFPS: cfg.Surveillance.DisplayFPS, - SurveillanceStrategy: cfg.Surveillance.Strategy, - RefinementEnabled: cfg.Refinement.Enabled, - MaxRefinementJobs: cfg.Resources.MaxRefinementJobs, - RefinementMaxConcurrent: cfg.Refinement.MaxConcurrent, - RefinementDetailFFTSize: detailFFT, - MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, - RefinementMinSpanHz: cfg.Refinement.MinSpanHz, - RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, - RefinementAutoSpan: config.BoolValue(cfg.Refinement.AutoSpan, true), - PreferGPU: cfg.Resources.PreferGPU, - MaxRecordingStreams: cfg.Resources.MaxRecordingStreams, - MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, - DecisionHoldMs: cfg.Resources.DecisionHoldMs, + Mode: cfg.Pipeline.Mode, + Profile: cfg.Pipeline.Profile, + Intent: cfg.Pipeline.Goals.Intent, + MonitorCenterHz: cfg.CenterHz, + MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, + MonitorEndHz: cfg.Pipeline.Goals.MonitorEndHz, + MonitorSpanHz: cfg.Pipeline.Goals.MonitorSpanHz, + SignalPriorities: append([]string(nil), cfg.Pipeline.Goals.SignalPriorities...), + AutoRecordClasses: append([]string(nil), cfg.Pipeline.Goals.AutoRecordClasses...), + AutoDecodeClasses: append([]string(nil), cfg.Pipeline.Goals.AutoDecodeClasses...), + SurveillanceFFTSize: cfg.Surveillance.AnalysisFFTSize, + SurveillanceFPS: cfg.Surveillance.FrameRate, + DisplayBins: cfg.Surveillance.DisplayBins, + DisplayFPS: cfg.Surveillance.DisplayFPS, + SurveillanceStrategy: cfg.Surveillance.Strategy, + SurveillanceDerivedDetection: cfg.Surveillance.DerivedDetection, + RefinementEnabled: cfg.Refinement.Enabled, + MaxRefinementJobs: cfg.Resources.MaxRefinementJobs, + RefinementMaxConcurrent: cfg.Refinement.MaxConcurrent, + RefinementDetailFFTSize: detailFFT, + MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, + RefinementMinSpanHz: cfg.Refinement.MinSpanHz, + RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, + RefinementAutoSpan: config.BoolValue(cfg.Refinement.AutoSpan, true), + PreferGPU: cfg.Resources.PreferGPU, + MaxRecordingStreams: cfg.Resources.MaxRecordingStreams, + MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, + DecisionHoldMs: cfg.Resources.DecisionHoldMs, } p.RefinementStrategy, _ = refinementStrategy(p) + p.SurveillanceDetection = SurveillanceDetectionPolicyFromPolicy(p) if p.MonitorSpanHz <= 0 && p.MonitorStartHz != 0 && p.MonitorEndHz != 0 && p.MonitorEndHz > p.MonitorStartHz { p.MonitorSpanHz = p.MonitorEndHz - p.MonitorStartHz } diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 2824c6c..6459bb7 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -264,6 +264,7 @@ func candidateEvidenceScore(candidate Candidate, strategy string) (float64, Evid DetectionLevels: state.DetectionLevelCount, PrimaryLevels: state.PrimaryLevelCount, DerivedLevels: state.DerivedLevelCount, + SupportLevels: state.SupportLevelCount, ProvenanceCount: len(state.Provenance), DerivedOnly: state.DerivedOnly, MultiLevelConfirmed: state.MultiLevelConfirmed, diff --git a/internal/pipeline/surveillance_policy.go b/internal/pipeline/surveillance_policy.go new file mode 100644 index 0000000..81628a3 --- /dev/null +++ b/internal/pipeline/surveillance_policy.go @@ -0,0 +1,80 @@ +package pipeline + +import "strings" + +// SurveillanceDetectionPolicy describes how surveillance levels are governed for detection. +type SurveillanceDetectionPolicy struct { + DerivedDetection string `json:"derived_detection"` + DerivedDetectionEnabled bool `json:"derived_detection_enabled"` + DerivedDetectionReason string `json:"derived_detection_reason,omitempty"` + PrimaryRole string `json:"primary_role"` + DerivedRole string `json:"derived_role"` + SupportRole string `json:"support_role"` + PresentationRole string `json:"presentation_role"` +} + +func normalizeDerivedDetection(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "on", "true", "enabled", "enable": + return "on" + case "off", "false", "disabled", "disable": + return "off" + case "auto", "": + return "auto" + default: + return "auto" + } +} + +func strategyIsMulti(strategy string) bool { + switch strings.ToLower(strings.TrimSpace(strategy)) { + case "multi-resolution", "multi", "multi-res", "multi_res": + return true + default: + return strings.Contains(strings.ToLower(strategy), "multi") + } +} + +// SurveillanceDetectionPolicyFromPolicy derives detection governance from policy intent/profile. +func SurveillanceDetectionPolicyFromPolicy(policy Policy) SurveillanceDetectionPolicy { + mode := normalizeDerivedDetection(policy.SurveillanceDerivedDetection) + enabled := false + reason := "" + switch mode { + case "on": + enabled = true + reason = "config" + case "off": + enabled = false + reason = "config" + default: + if !strategyIsMulti(policy.SurveillanceStrategy) { + enabled = false + reason = "strategy" + } else { + intent := strings.ToLower(strings.TrimSpace(policy.Intent)) + profile := strings.ToLower(strings.TrimSpace(policy.Profile)) + modeName := strings.ToLower(strings.TrimSpace(policy.Mode)) + switch { + case strings.Contains(profile, "archive") || strings.Contains(intent, "archive") || strings.Contains(intent, "triage") || strings.Contains(modeName, "archive"): + enabled = false + reason = "archive" + case strings.Contains(profile, "legacy") || strings.Contains(modeName, "legacy"): + enabled = false + reason = "legacy" + default: + enabled = true + reason = "strategy" + } + } + } + return SurveillanceDetectionPolicy{ + DerivedDetection: mode, + DerivedDetectionEnabled: enabled, + DerivedDetectionReason: reason, + PrimaryRole: RoleSurveillancePrimary, + DerivedRole: RoleSurveillanceDerived, + SupportRole: RoleSurveillanceSupport, + PresentationRole: RolePresentation, + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 50001ce..5d34d8d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -22,11 +22,12 @@ type PipelineUpdate struct { } type SurveillanceUpdate struct { - AnalysisFFTSize *int `json:"analysis_fft_size"` - FrameRate *int `json:"frame_rate"` - Strategy *string `json:"strategy"` - DisplayBins *int `json:"display_bins"` - DisplayFPS *int `json:"display_fps"` + AnalysisFFTSize *int `json:"analysis_fft_size"` + FrameRate *int `json:"frame_rate"` + Strategy *string `json:"strategy"` + DisplayBins *int `json:"display_bins"` + DisplayFPS *int `json:"display_fps"` + DerivedDetection *string `json:"derived_detection"` } type RefinementUpdate struct { @@ -251,6 +252,15 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { } next.Surveillance.DisplayFPS = v } + if update.Surveillance.DerivedDetection != nil { + mode := strings.ToLower(strings.TrimSpace(*update.Surveillance.DerivedDetection)) + switch mode { + case "auto", "on", "off", "true", "false", "enabled", "disabled", "enable", "disable": + next.Surveillance.DerivedDetection = mode + default: + return m.cfg, errors.New("surveillance.derived_detection must be auto, on, or off") + } + } } if update.Refinement != nil { if update.Refinement.Enabled != nil {