| @@ -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" | |||
| } | |||
| @@ -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...), | |||
| @@ -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 { | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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, | |||
| @@ -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) | |||
| } | |||
| } | |||