From 9798fd7cbad3b5708d10b8715431d2ec1d067700 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 22 Mar 2026 10:44:19 +0100 Subject: [PATCH] Clarify derived detection policy and evidence summaries --- cmd/sdrd/level_summary.go | 48 ++++++++++++++++-- cmd/sdrd/pipeline_runtime.go | 12 +++++ internal/pipeline/evidence.go | 14 ++++-- internal/pipeline/evidence_test.go | 49 +++++++++++++++++++ internal/pipeline/surveillance_policy.go | 24 +++++++-- internal/pipeline/surveillance_policy_test.go | 32 ++++++++++++ 6 files changed, 168 insertions(+), 11 deletions(-) diff --git a/cmd/sdrd/level_summary.go b/cmd/sdrd/level_summary.go index aea71e1..62005d7 100644 --- a/cmd/sdrd/level_summary.go +++ b/cmd/sdrd/level_summary.go @@ -2,6 +2,7 @@ package main import ( "sort" + "strings" "sdr-wideband-suite/internal/pipeline" ) @@ -10,6 +11,7 @@ type SurveillanceLevelSummary struct { Name string `json:"name"` Role string `json:"role,omitempty"` Truth string `json:"truth,omitempty"` + Kind string `json:"kind,omitempty"` SampleRate int `json:"sample_rate,omitempty"` FFTSize int `json:"fft_size,omitempty"` BinHz float64 `json:"bin_hz,omitempty"` @@ -22,6 +24,8 @@ type SurveillanceLevelSummary struct { type CandidateEvidenceSummary struct { Level string `json:"level"` + Role string `json:"role,omitempty"` + Kind string `json:"kind,omitempty"` Provenance string `json:"provenance,omitempty"` Count int `json:"count"` } @@ -32,6 +36,10 @@ type CandidateEvidenceStateSummary struct { Fused int `json:"fused"` MultiLevelConfirmed int `json:"multi_level_confirmed"` DerivedOnly int `json:"derived_only"` + SupportOnly int `json:"support_only"` + PrimaryPresent int `json:"primary_present"` + DerivedPresent int `json:"derived_present"` + SupportPresent int `json:"support_present"` PrimaryOnly int `json:"primary_only"` } @@ -71,10 +79,12 @@ func buildSurveillanceLevelSummaries(set pipeline.SurveillanceLevelSet, spectra if binHz == 0 && level.SampleRate > 0 && level.FFTSize > 0 { binHz = float64(level.SampleRate) / float64(level.FFTSize) } + kind := evidenceKind(level) out[name] = SurveillanceLevelSummary{ Name: name, Role: level.Role, Truth: level.Truth, + Kind: kind, SampleRate: level.SampleRate, FFTSize: level.FFTSize, BinHz: binHz, @@ -114,6 +124,8 @@ func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateE } type key struct { level string + role string + kind string provenance string } counts := map[key]int{} @@ -123,7 +135,9 @@ func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateE if name == "" { name = "unknown" } - k := key{level: name, provenance: ev.Provenance} + role := strings.TrimSpace(ev.Level.Role) + kind := evidenceKind(ev.Level) + k := key{level: name, role: role, kind: kind, provenance: ev.Provenance} counts[k]++ } } @@ -132,12 +146,15 @@ func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateE } out := make([]CandidateEvidenceSummary, 0, len(counts)) for k, v := range counts { - out = append(out, CandidateEvidenceSummary{Level: k.level, Provenance: k.provenance, Count: v}) + out = append(out, CandidateEvidenceSummary{Level: k.level, Role: k.role, Kind: k.kind, Provenance: k.provenance, Count: v}) } sort.Slice(out, func(i, j int) bool { if out[i].Count == out[j].Count { if out[i].Level == out[j].Level { - return out[i].Provenance < out[j].Provenance + if out[i].Kind == out[j].Kind { + return out[i].Provenance < out[j].Provenance + } + return out[i].Kind < out[j].Kind } return out[i].Level < out[j].Level } @@ -166,6 +183,18 @@ func buildCandidateEvidenceStateSummary(candidates []pipeline.Candidate) *Candid if state.DerivedOnly { summary.DerivedOnly++ } + if state.SupportOnly { + summary.SupportOnly++ + } + if state.PrimaryLevelCount > 0 { + summary.PrimaryPresent++ + } + if state.DerivedLevelCount > 0 { + summary.DerivedPresent++ + } + if state.SupportLevelCount > 0 { + summary.SupportPresent++ + } if state.PrimaryLevelCount > 0 && state.DerivedLevelCount == 0 { summary.PrimaryOnly++ } @@ -175,3 +204,16 @@ func buildCandidateEvidenceStateSummary(candidates []pipeline.Candidate) *Candid } return &summary } + +func evidenceKind(level pipeline.AnalysisLevel) string { + if pipeline.IsPresentationLevel(level) { + return "presentation" + } + if pipeline.IsSupportLevel(level) { + return "support" + } + if pipeline.IsDetectionLevel(level) { + return "detection" + } + return "unknown" +} diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 4b366e4..1c0d371 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -898,6 +898,18 @@ func (rt *dspRuntime) buildSurveillancePlan(policy pipeline.Policy) surveillance presentation := analysisLevel("presentation", pipeline.RolePresentation, "presentation", baseRate, rt.cfg.Surveillance.DisplayBins, rt.cfg.CenterHz, span, "display", 1, baseRate) context.Presentation = presentation + if len(derivedLevels) == 0 && detectionPolicy.DerivedDetectionEnabled { + detectionPolicy.DerivedDetectionEnabled = false + detectionPolicy.DerivedDetectionReason = "levels" + } + switch { + case len(derivedLevels) > 0: + detectionPolicy.DerivedDetectionMode = "detection" + case len(supportLevels) > 0: + detectionPolicy.DerivedDetectionMode = "support" + default: + detectionPolicy.DerivedDetectionMode = "disabled" + } levelSet := pipeline.SurveillanceLevelSet{ Primary: primary, Derived: append([]pipeline.AnalysisLevel(nil), derivedLevels...), diff --git a/internal/pipeline/evidence.go b/internal/pipeline/evidence.go index 69c761b..9fe57a9 100644 --- a/internal/pipeline/evidence.go +++ b/internal/pipeline/evidence.go @@ -26,6 +26,7 @@ type CandidateEvidenceState struct { Provenance []string `json:"provenance,omitempty"` Fused bool `json:"fused,omitempty"` DerivedOnly bool `json:"derived_only,omitempty"` + SupportOnly bool `json:"support_only,omitempty"` MultiLevelConfirmed bool `json:"multi_level_confirmed,omitempty"` MultiLevelConfirmedHint string `json:"multi_level_confirmed_hint,omitempty"` } @@ -65,10 +66,14 @@ func IsPresentationLevel(level AnalysisLevel) bool { // IsSupportLevel reports whether a level is a non-detection support level. func IsSupportLevel(level AnalysisLevel) bool { role := strings.ToLower(strings.TrimSpace(level.Role)) + name := strings.ToLower(strings.TrimSpace(level.Name)) if role == RoleSurveillanceSupport { return true } - return strings.Contains(role, "surveillance-support") || strings.Contains(role, "support") + if strings.Contains(role, "surveillance-support") || strings.Contains(role, "support") { + return true + } + return strings.Contains(name, "support") } // IsDetectionLevel reports whether a level is intended for detection/analysis. @@ -107,11 +112,11 @@ func isPrimarySurveillanceLevel(level AnalysisLevel) bool { } func isDerivedSurveillanceLevel(level AnalysisLevel) bool { - role := strings.ToLower(strings.TrimSpace(level.Role)) - name := strings.ToLower(strings.TrimSpace(level.Name)) - if role == RoleSurveillanceSupport { + if IsSupportLevel(level) { return false } + role := strings.ToLower(strings.TrimSpace(level.Role)) + name := strings.ToLower(strings.TrimSpace(level.Name)) if role == RoleSurveillanceDerived { return true } @@ -181,6 +186,7 @@ func CandidateEvidenceStateFor(candidate Candidate) CandidateEvidenceState { state.Provenance = sortedKeys(provenanceSet) state.Fused = state.LevelCount > 1 || len(state.Provenance) > 1 state.DerivedOnly = state.DerivedLevelCount > 0 && state.PrimaryLevelCount == 0 && state.DetectionLevelCount == state.DerivedLevelCount + state.SupportOnly = state.SupportLevelCount > 0 && state.DetectionLevelCount == 0 && state.PresentationLevelCount == 0 state.MultiLevelConfirmed = state.DetectionLevelCount >= 2 if state.MultiLevelConfirmed { if state.PrimaryLevelCount > 0 && state.DerivedLevelCount > 0 { diff --git a/internal/pipeline/evidence_test.go b/internal/pipeline/evidence_test.go index 62873a2..176e178 100644 --- a/internal/pipeline/evidence_test.go +++ b/internal/pipeline/evidence_test.go @@ -43,3 +43,52 @@ func TestCandidateEvidenceStateTracksSupportLevels(t *testing.T) { t.Fatalf("unexpected confirmation flags: %+v", state) } } + +func TestCandidateEvidenceStateSupportOnly(t *testing.T) { + candidate := Candidate{ + ID: 2, + Evidence: []LevelEvidence{ + {Level: AnalysisLevel{Name: "surveillance-support", Role: RoleSurveillanceSupport, Truth: "surveillance"}, Provenance: "support"}, + }, + } + state := CandidateEvidenceStateFor(candidate) + if state.DetectionLevelCount != 0 || state.SupportLevelCount != 1 { + t.Fatalf("unexpected support-only counts: %+v", state) + } + if !state.SupportOnly || state.DerivedOnly || state.MultiLevelConfirmed { + t.Fatalf("unexpected support-only flags: %+v", state) + } +} + +func TestCandidateEvidenceStatePrimaryWithSupport(t *testing.T) { + candidate := Candidate{ + ID: 3, + Evidence: []LevelEvidence{ + {Level: AnalysisLevel{Name: "surveillance", Role: RoleSurveillancePrimary, Truth: "surveillance"}, Provenance: "primary"}, + {Level: AnalysisLevel{Name: "surveillance-support", Role: RoleSurveillanceSupport, Truth: "surveillance"}, Provenance: "support"}, + }, + } + state := CandidateEvidenceStateFor(candidate) + if state.DetectionLevelCount != 1 || state.SupportLevelCount != 1 { + t.Fatalf("unexpected primary+support counts: %+v", state) + } + if state.SupportOnly || state.DerivedOnly || state.MultiLevelConfirmed { + t.Fatalf("unexpected primary+support flags: %+v", state) + } +} + +func TestCandidateEvidenceStateDerivedOnly(t *testing.T) { + candidate := Candidate{ + ID: 4, + Evidence: []LevelEvidence{ + {Level: AnalysisLevel{Name: "surveillance-lowres", Role: RoleSurveillanceDerived, Truth: "surveillance"}, Provenance: "derived"}, + }, + } + state := CandidateEvidenceStateFor(candidate) + if state.DetectionLevelCount != 1 || state.DerivedLevelCount != 1 { + t.Fatalf("unexpected derived-only counts: %+v", state) + } + if !state.DerivedOnly || state.SupportOnly || state.MultiLevelConfirmed { + t.Fatalf("unexpected derived-only flags: %+v", state) + } +} diff --git a/internal/pipeline/surveillance_policy.go b/internal/pipeline/surveillance_policy.go index 81628a3..62c3d3e 100644 --- a/internal/pipeline/surveillance_policy.go +++ b/internal/pipeline/surveillance_policy.go @@ -7,6 +7,7 @@ type SurveillanceDetectionPolicy struct { DerivedDetection string `json:"derived_detection"` DerivedDetectionEnabled bool `json:"derived_detection_enabled"` DerivedDetectionReason string `json:"derived_detection_reason,omitempty"` + DerivedDetectionMode string `json:"derived_detection_mode,omitempty"` PrimaryRole string `json:"primary_role"` DerivedRole string `json:"derived_role"` SupportRole string `json:"support_role"` @@ -38,17 +39,23 @@ func strategyIsMulti(strategy string) bool { // SurveillanceDetectionPolicyFromPolicy derives detection governance from policy intent/profile. func SurveillanceDetectionPolicyFromPolicy(policy Policy) SurveillanceDetectionPolicy { mode := normalizeDerivedDetection(policy.SurveillanceDerivedDetection) + strategyMulti := strategyIsMulti(policy.SurveillanceStrategy) enabled := false reason := "" switch mode { case "on": - enabled = true - reason = "config" + if strategyMulti { + enabled = true + reason = "config" + } else { + enabled = false + reason = "strategy" + } case "off": enabled = false reason = "config" default: - if !strategyIsMulti(policy.SurveillanceStrategy) { + if !strategyMulti { enabled = false reason = "strategy" } else { @@ -64,14 +71,23 @@ func SurveillanceDetectionPolicyFromPolicy(policy Policy) SurveillanceDetectionP reason = "legacy" default: enabled = true - reason = "strategy" + reason = "auto" } } } + modeState := "disabled" + if strategyMulti { + if enabled { + modeState = "detection" + } else { + modeState = "support" + } + } return SurveillanceDetectionPolicy{ DerivedDetection: mode, DerivedDetectionEnabled: enabled, DerivedDetectionReason: reason, + DerivedDetectionMode: modeState, PrimaryRole: RoleSurveillancePrimary, DerivedRole: RoleSurveillanceDerived, SupportRole: RoleSurveillanceSupport, diff --git a/internal/pipeline/surveillance_policy_test.go b/internal/pipeline/surveillance_policy_test.go index 552735e..b4bd984 100644 --- a/internal/pipeline/surveillance_policy_test.go +++ b/internal/pipeline/surveillance_policy_test.go @@ -14,6 +14,9 @@ func TestSurveillanceDetectionPolicyAuto(t *testing.T) { if !got.DerivedDetectionEnabled { t.Fatalf("expected auto policy to enable derived detection, got %+v", got) } + if got.DerivedDetectionMode != "detection" { + t.Fatalf("expected detection mode, got %+v", got) + } policy.Profile = "archive" policy.Intent = "archive-and-triage" @@ -21,10 +24,39 @@ func TestSurveillanceDetectionPolicyAuto(t *testing.T) { if got.DerivedDetectionEnabled { t.Fatalf("expected archive policy to disable derived detection, got %+v", got) } + if got.DerivedDetectionMode != "support" { + t.Fatalf("expected support mode for archive policy, got %+v", got) + } policy = Policy{SurveillanceStrategy: "single-resolution", SurveillanceDerivedDetection: "auto"} got = SurveillanceDetectionPolicyFromPolicy(policy) if got.DerivedDetectionEnabled { t.Fatalf("expected single-resolution to disable derived detection, got %+v", got) } + if got.DerivedDetectionMode != "disabled" { + t.Fatalf("expected disabled mode for single-resolution, got %+v", got) + } +} + +func TestSurveillanceDetectionPolicyOverrides(t *testing.T) { + policy := Policy{SurveillanceStrategy: "single-resolution", SurveillanceDerivedDetection: "on"} + got := SurveillanceDetectionPolicyFromPolicy(policy) + if got.DerivedDetectionEnabled { + t.Fatalf("expected single-resolution to force derived detection off, got %+v", got) + } + if got.DerivedDetectionReason != "strategy" || got.DerivedDetectionMode != "disabled" { + t.Fatalf("expected strategy-based disable, got %+v", got) + } + + policy = Policy{SurveillanceStrategy: "multi-resolution", SurveillanceDerivedDetection: "off"} + got = SurveillanceDetectionPolicyFromPolicy(policy) + if got.DerivedDetectionEnabled || got.DerivedDetectionMode != "support" { + t.Fatalf("expected off to yield support mode, got %+v", got) + } + + policy = Policy{SurveillanceStrategy: "multi-resolution", SurveillanceDerivedDetection: "on", Profile: "archive"} + got = SurveillanceDetectionPolicyFromPolicy(policy) + if !got.DerivedDetectionEnabled || got.DerivedDetectionReason != "config" || got.DerivedDetectionMode != "detection" { + t.Fatalf("expected config on to override archive, got %+v", got) + } }