| @@ -2,6 +2,7 @@ package main | |||||
| import ( | import ( | ||||
| "sort" | "sort" | ||||
| "strings" | |||||
| "sdr-wideband-suite/internal/pipeline" | "sdr-wideband-suite/internal/pipeline" | ||||
| ) | ) | ||||
| @@ -10,6 +11,7 @@ type SurveillanceLevelSummary struct { | |||||
| Name string `json:"name"` | Name string `json:"name"` | ||||
| Role string `json:"role,omitempty"` | Role string `json:"role,omitempty"` | ||||
| Truth string `json:"truth,omitempty"` | Truth string `json:"truth,omitempty"` | ||||
| Kind string `json:"kind,omitempty"` | |||||
| SampleRate int `json:"sample_rate,omitempty"` | SampleRate int `json:"sample_rate,omitempty"` | ||||
| FFTSize int `json:"fft_size,omitempty"` | FFTSize int `json:"fft_size,omitempty"` | ||||
| BinHz float64 `json:"bin_hz,omitempty"` | BinHz float64 `json:"bin_hz,omitempty"` | ||||
| @@ -22,6 +24,8 @@ type SurveillanceLevelSummary struct { | |||||
| type CandidateEvidenceSummary struct { | type CandidateEvidenceSummary struct { | ||||
| Level string `json:"level"` | Level string `json:"level"` | ||||
| Role string `json:"role,omitempty"` | |||||
| Kind string `json:"kind,omitempty"` | |||||
| Provenance string `json:"provenance,omitempty"` | Provenance string `json:"provenance,omitempty"` | ||||
| Count int `json:"count"` | Count int `json:"count"` | ||||
| } | } | ||||
| @@ -32,6 +36,10 @@ type CandidateEvidenceStateSummary struct { | |||||
| Fused int `json:"fused"` | Fused int `json:"fused"` | ||||
| MultiLevelConfirmed int `json:"multi_level_confirmed"` | MultiLevelConfirmed int `json:"multi_level_confirmed"` | ||||
| DerivedOnly int `json:"derived_only"` | 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"` | PrimaryOnly int `json:"primary_only"` | ||||
| } | } | ||||
| @@ -71,10 +79,12 @@ func buildSurveillanceLevelSummaries(set pipeline.SurveillanceLevelSet, spectra | |||||
| if binHz == 0 && level.SampleRate > 0 && level.FFTSize > 0 { | if binHz == 0 && level.SampleRate > 0 && level.FFTSize > 0 { | ||||
| binHz = float64(level.SampleRate) / float64(level.FFTSize) | binHz = float64(level.SampleRate) / float64(level.FFTSize) | ||||
| } | } | ||||
| kind := evidenceKind(level) | |||||
| out[name] = SurveillanceLevelSummary{ | out[name] = SurveillanceLevelSummary{ | ||||
| Name: name, | Name: name, | ||||
| Role: level.Role, | Role: level.Role, | ||||
| Truth: level.Truth, | Truth: level.Truth, | ||||
| Kind: kind, | |||||
| SampleRate: level.SampleRate, | SampleRate: level.SampleRate, | ||||
| FFTSize: level.FFTSize, | FFTSize: level.FFTSize, | ||||
| BinHz: binHz, | BinHz: binHz, | ||||
| @@ -114,6 +124,8 @@ func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateE | |||||
| } | } | ||||
| type key struct { | type key struct { | ||||
| level string | level string | ||||
| role string | |||||
| kind string | |||||
| provenance string | provenance string | ||||
| } | } | ||||
| counts := map[key]int{} | counts := map[key]int{} | ||||
| @@ -123,7 +135,9 @@ func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateE | |||||
| if name == "" { | if name == "" { | ||||
| name = "unknown" | 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]++ | counts[k]++ | ||||
| } | } | ||||
| } | } | ||||
| @@ -132,12 +146,15 @@ func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateE | |||||
| } | } | ||||
| out := make([]CandidateEvidenceSummary, 0, len(counts)) | out := make([]CandidateEvidenceSummary, 0, len(counts)) | ||||
| for k, v := range 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 { | sort.Slice(out, func(i, j int) bool { | ||||
| if out[i].Count == out[j].Count { | if out[i].Count == out[j].Count { | ||||
| if out[i].Level == out[j].Level { | 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 | return out[i].Level < out[j].Level | ||||
| } | } | ||||
| @@ -166,6 +183,18 @@ func buildCandidateEvidenceStateSummary(candidates []pipeline.Candidate) *Candid | |||||
| if state.DerivedOnly { | if state.DerivedOnly { | ||||
| summary.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 { | if state.PrimaryLevelCount > 0 && state.DerivedLevelCount == 0 { | ||||
| summary.PrimaryOnly++ | summary.PrimaryOnly++ | ||||
| } | } | ||||
| @@ -175,3 +204,16 @@ func buildCandidateEvidenceStateSummary(candidates []pipeline.Candidate) *Candid | |||||
| } | } | ||||
| return &summary | 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" | |||||
| } | |||||
| @@ -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) | presentation := analysisLevel("presentation", pipeline.RolePresentation, "presentation", baseRate, rt.cfg.Surveillance.DisplayBins, rt.cfg.CenterHz, span, "display", 1, baseRate) | ||||
| context.Presentation = presentation | 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{ | levelSet := pipeline.SurveillanceLevelSet{ | ||||
| Primary: primary, | Primary: primary, | ||||
| Derived: append([]pipeline.AnalysisLevel(nil), derivedLevels...), | Derived: append([]pipeline.AnalysisLevel(nil), derivedLevels...), | ||||
| @@ -26,6 +26,7 @@ type CandidateEvidenceState struct { | |||||
| Provenance []string `json:"provenance,omitempty"` | Provenance []string `json:"provenance,omitempty"` | ||||
| Fused bool `json:"fused,omitempty"` | Fused bool `json:"fused,omitempty"` | ||||
| DerivedOnly bool `json:"derived_only,omitempty"` | DerivedOnly bool `json:"derived_only,omitempty"` | ||||
| SupportOnly bool `json:"support_only,omitempty"` | |||||
| MultiLevelConfirmed bool `json:"multi_level_confirmed,omitempty"` | MultiLevelConfirmed bool `json:"multi_level_confirmed,omitempty"` | ||||
| MultiLevelConfirmedHint string `json:"multi_level_confirmed_hint,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. | // IsSupportLevel reports whether a level is a non-detection support level. | ||||
| func IsSupportLevel(level AnalysisLevel) bool { | func IsSupportLevel(level AnalysisLevel) bool { | ||||
| role := strings.ToLower(strings.TrimSpace(level.Role)) | role := strings.ToLower(strings.TrimSpace(level.Role)) | ||||
| name := strings.ToLower(strings.TrimSpace(level.Name)) | |||||
| if role == RoleSurveillanceSupport { | if role == RoleSurveillanceSupport { | ||||
| return true | 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. | // IsDetectionLevel reports whether a level is intended for detection/analysis. | ||||
| @@ -107,11 +112,11 @@ func isPrimarySurveillanceLevel(level AnalysisLevel) bool { | |||||
| } | } | ||||
| func isDerivedSurveillanceLevel(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 | return false | ||||
| } | } | ||||
| role := strings.ToLower(strings.TrimSpace(level.Role)) | |||||
| name := strings.ToLower(strings.TrimSpace(level.Name)) | |||||
| if role == RoleSurveillanceDerived { | if role == RoleSurveillanceDerived { | ||||
| return true | return true | ||||
| } | } | ||||
| @@ -181,6 +186,7 @@ func CandidateEvidenceStateFor(candidate Candidate) CandidateEvidenceState { | |||||
| state.Provenance = sortedKeys(provenanceSet) | state.Provenance = sortedKeys(provenanceSet) | ||||
| state.Fused = state.LevelCount > 1 || len(state.Provenance) > 1 | state.Fused = state.LevelCount > 1 || len(state.Provenance) > 1 | ||||
| state.DerivedOnly = state.DerivedLevelCount > 0 && state.PrimaryLevelCount == 0 && state.DetectionLevelCount == state.DerivedLevelCount | 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 | state.MultiLevelConfirmed = state.DetectionLevelCount >= 2 | ||||
| if state.MultiLevelConfirmed { | if state.MultiLevelConfirmed { | ||||
| if state.PrimaryLevelCount > 0 && state.DerivedLevelCount > 0 { | if state.PrimaryLevelCount > 0 && state.DerivedLevelCount > 0 { | ||||
| @@ -43,3 +43,52 @@ func TestCandidateEvidenceStateTracksSupportLevels(t *testing.T) { | |||||
| t.Fatalf("unexpected confirmation flags: %+v", state) | 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) | |||||
| } | |||||
| } | |||||
| @@ -7,6 +7,7 @@ type SurveillanceDetectionPolicy struct { | |||||
| DerivedDetection string `json:"derived_detection"` | DerivedDetection string `json:"derived_detection"` | ||||
| DerivedDetectionEnabled bool `json:"derived_detection_enabled"` | DerivedDetectionEnabled bool `json:"derived_detection_enabled"` | ||||
| DerivedDetectionReason string `json:"derived_detection_reason,omitempty"` | DerivedDetectionReason string `json:"derived_detection_reason,omitempty"` | ||||
| DerivedDetectionMode string `json:"derived_detection_mode,omitempty"` | |||||
| PrimaryRole string `json:"primary_role"` | PrimaryRole string `json:"primary_role"` | ||||
| DerivedRole string `json:"derived_role"` | DerivedRole string `json:"derived_role"` | ||||
| SupportRole string `json:"support_role"` | SupportRole string `json:"support_role"` | ||||
| @@ -38,17 +39,23 @@ func strategyIsMulti(strategy string) bool { | |||||
| // SurveillanceDetectionPolicyFromPolicy derives detection governance from policy intent/profile. | // SurveillanceDetectionPolicyFromPolicy derives detection governance from policy intent/profile. | ||||
| func SurveillanceDetectionPolicyFromPolicy(policy Policy) SurveillanceDetectionPolicy { | func SurveillanceDetectionPolicyFromPolicy(policy Policy) SurveillanceDetectionPolicy { | ||||
| mode := normalizeDerivedDetection(policy.SurveillanceDerivedDetection) | mode := normalizeDerivedDetection(policy.SurveillanceDerivedDetection) | ||||
| strategyMulti := strategyIsMulti(policy.SurveillanceStrategy) | |||||
| enabled := false | enabled := false | ||||
| reason := "" | reason := "" | ||||
| switch mode { | switch mode { | ||||
| case "on": | case "on": | ||||
| enabled = true | |||||
| reason = "config" | |||||
| if strategyMulti { | |||||
| enabled = true | |||||
| reason = "config" | |||||
| } else { | |||||
| enabled = false | |||||
| reason = "strategy" | |||||
| } | |||||
| case "off": | case "off": | ||||
| enabled = false | enabled = false | ||||
| reason = "config" | reason = "config" | ||||
| default: | default: | ||||
| if !strategyIsMulti(policy.SurveillanceStrategy) { | |||||
| if !strategyMulti { | |||||
| enabled = false | enabled = false | ||||
| reason = "strategy" | reason = "strategy" | ||||
| } else { | } else { | ||||
| @@ -64,14 +71,23 @@ func SurveillanceDetectionPolicyFromPolicy(policy Policy) SurveillanceDetectionP | |||||
| reason = "legacy" | reason = "legacy" | ||||
| default: | default: | ||||
| enabled = true | enabled = true | ||||
| reason = "strategy" | |||||
| reason = "auto" | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| modeState := "disabled" | |||||
| if strategyMulti { | |||||
| if enabled { | |||||
| modeState = "detection" | |||||
| } else { | |||||
| modeState = "support" | |||||
| } | |||||
| } | |||||
| return SurveillanceDetectionPolicy{ | return SurveillanceDetectionPolicy{ | ||||
| DerivedDetection: mode, | DerivedDetection: mode, | ||||
| DerivedDetectionEnabled: enabled, | DerivedDetectionEnabled: enabled, | ||||
| DerivedDetectionReason: reason, | DerivedDetectionReason: reason, | ||||
| DerivedDetectionMode: modeState, | |||||
| PrimaryRole: RoleSurveillancePrimary, | PrimaryRole: RoleSurveillancePrimary, | ||||
| DerivedRole: RoleSurveillanceDerived, | DerivedRole: RoleSurveillanceDerived, | ||||
| SupportRole: RoleSurveillanceSupport, | SupportRole: RoleSurveillanceSupport, | ||||
| @@ -14,6 +14,9 @@ func TestSurveillanceDetectionPolicyAuto(t *testing.T) { | |||||
| if !got.DerivedDetectionEnabled { | if !got.DerivedDetectionEnabled { | ||||
| t.Fatalf("expected auto policy to enable derived detection, got %+v", got) | 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.Profile = "archive" | ||||
| policy.Intent = "archive-and-triage" | policy.Intent = "archive-and-triage" | ||||
| @@ -21,10 +24,39 @@ func TestSurveillanceDetectionPolicyAuto(t *testing.T) { | |||||
| if got.DerivedDetectionEnabled { | if got.DerivedDetectionEnabled { | ||||
| t.Fatalf("expected archive policy to disable derived detection, got %+v", got) | 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"} | policy = Policy{SurveillanceStrategy: "single-resolution", SurveillanceDerivedDetection: "auto"} | ||||
| got = SurveillanceDetectionPolicyFromPolicy(policy) | got = SurveillanceDetectionPolicyFromPolicy(policy) | ||||
| if got.DerivedDetectionEnabled { | if got.DerivedDetectionEnabled { | ||||
| t.Fatalf("expected single-resolution to disable derived detection, got %+v", got) | 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) | |||||
| } | |||||
| } | } | ||||