| @@ -16,10 +16,12 @@ func AddCandidateEvidence(candidate *Candidate, evidence LevelEvidence) { | |||||
| evLevel = "unknown" | evLevel = "unknown" | ||||
| } | } | ||||
| if evLevel == levelName && ev.Provenance == evidence.Provenance { | if evLevel == levelName && ev.Provenance == evidence.Provenance { | ||||
| RefreshCandidateEvidenceState(candidate) | |||||
| return | return | ||||
| } | } | ||||
| } | } | ||||
| candidate.Evidence = append(candidate.Evidence, evidence) | candidate.Evidence = append(candidate.Evidence, evidence) | ||||
| RefreshCandidateEvidenceState(candidate) | |||||
| } | } | ||||
| func MergeCandidateEvidence(dst *Candidate, src Candidate) { | func MergeCandidateEvidence(dst *Candidate, src Candidate) { | ||||
| @@ -32,18 +34,8 @@ func MergeCandidateEvidence(dst *Candidate, src Candidate) { | |||||
| } | } | ||||
| func CandidateEvidenceLevelCount(candidate Candidate) int { | func CandidateEvidenceLevelCount(candidate Candidate) int { | ||||
| if len(candidate.Evidence) == 0 { | |||||
| return 0 | |||||
| } | |||||
| levels := map[string]struct{}{} | |||||
| for _, ev := range candidate.Evidence { | |||||
| name := ev.Level.Name | |||||
| if name == "" { | |||||
| name = "unknown" | |||||
| } | |||||
| levels[name] = struct{}{} | |||||
| } | |||||
| return len(levels) | |||||
| state := CandidateEvidenceStateFor(candidate) | |||||
| return state.DetectionLevelCount | |||||
| } | } | ||||
| func FuseCandidates(primary []Candidate, derived []Candidate) []Candidate { | func FuseCandidates(primary []Candidate, derived []Candidate) []Candidate { | ||||
| @@ -74,6 +66,9 @@ func FuseCandidates(primary []Candidate, derived []Candidate) []Candidate { | |||||
| } | } | ||||
| out = append(out, cand) | out = append(out, cand) | ||||
| } | } | ||||
| for i := range out { | |||||
| RefreshCandidateEvidenceState(&out[i]) | |||||
| } | |||||
| return out | return out | ||||
| } | } | ||||
| @@ -113,6 +108,9 @@ func candidateSpanHz(candidate Candidate) float64 { | |||||
| func candidateBinHz(candidate Candidate) float64 { | func candidateBinHz(candidate Candidate) float64 { | ||||
| for _, ev := range candidate.Evidence { | for _, ev := range candidate.Evidence { | ||||
| if IsPresentationLevel(ev.Level) || !IsDetectionLevel(ev.Level) { | |||||
| continue | |||||
| } | |||||
| if ev.Level.BinHz > 0 { | if ev.Level.BinHz > 0 { | ||||
| return ev.Level.BinHz | return ev.Level.BinHz | ||||
| } | } | ||||
| @@ -30,6 +30,9 @@ func TestFuseCandidatesDedup(t *testing.T) { | |||||
| if got := CandidateEvidenceLevelCount(fused[0]); got != 2 { | if got := CandidateEvidenceLevelCount(fused[0]); got != 2 { | ||||
| t.Fatalf("expected 2 evidence levels after fuse, got %d", got) | t.Fatalf("expected 2 evidence levels after fuse, got %d", got) | ||||
| } | } | ||||
| if fused[0].EvidenceState == nil || !fused[0].EvidenceState.Fused || !fused[0].EvidenceState.MultiLevelConfirmed { | |||||
| t.Fatalf("expected fused multi-level evidence state, got %+v", fused[0].EvidenceState) | |||||
| } | |||||
| } | } | ||||
| func TestFuseCandidatesSingleVsMultiResolution(t *testing.T) { | func TestFuseCandidatesSingleVsMultiResolution(t *testing.T) { | ||||
| @@ -0,0 +1,173 @@ | |||||
| package pipeline | |||||
| import ( | |||||
| "fmt" | |||||
| "sort" | |||||
| "strings" | |||||
| ) | |||||
| // CandidateEvidenceState summarizes fused evidence semantics for a candidate. | |||||
| type CandidateEvidenceState struct { | |||||
| TotalLevelEntries int `json:"total_level_entries"` | |||||
| LevelCount int `json:"level_count"` | |||||
| DetectionLevelCount int `json:"detection_level_count"` | |||||
| PrimaryLevelCount int `json:"primary_level_count,omitempty"` | |||||
| DerivedLevelCount int `json:"derived_level_count,omitempty"` | |||||
| PresentationLevelCount int `json:"presentation_level_count,omitempty"` | |||||
| Levels []string `json:"levels,omitempty"` | |||||
| Provenance []string `json:"provenance,omitempty"` | |||||
| Fused bool `json:"fused,omitempty"` | |||||
| DerivedOnly bool `json:"derived_only,omitempty"` | |||||
| MultiLevelConfirmed bool `json:"multi_level_confirmed,omitempty"` | |||||
| MultiLevelConfirmedHint string `json:"multi_level_confirmed_hint,omitempty"` | |||||
| } | |||||
| // EvidenceScoreDetails explains how evidence influenced refinement scoring. | |||||
| type EvidenceScoreDetails struct { | |||||
| RawScore float64 `json:"raw_score"` | |||||
| Weight float64 `json:"weight"` | |||||
| WeightedScore float64 `json:"weighted_score"` | |||||
| DetectionLevels int `json:"detection_levels"` | |||||
| PrimaryLevels int `json:"primary_levels,omitempty"` | |||||
| DerivedLevels int `json:"derived_levels,omitempty"` | |||||
| ProvenanceCount int `json:"provenance_count,omitempty"` | |||||
| DerivedOnly bool `json:"derived_only,omitempty"` | |||||
| MultiLevelConfirmed bool `json:"multi_level_confirmed,omitempty"` | |||||
| MultiLevelBonus float64 `json:"multi_level_bonus,omitempty"` | |||||
| ProvenanceBonus float64 `json:"provenance_bonus,omitempty"` | |||||
| DerivedPenalty float64 `json:"derived_penalty,omitempty"` | |||||
| StrategyBias float64 `json:"strategy_bias,omitempty"` | |||||
| } | |||||
| // IsPresentationLevel reports whether a level is intended only for presentation. | |||||
| 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 strings.Contains(role, "presentation") || strings.Contains(truth, "presentation") { | |||||
| return true | |||||
| } | |||||
| return strings.Contains(name, "presentation") || strings.Contains(name, "display") | |||||
| } | |||||
| // IsDetectionLevel reports whether a level is intended for detection/analysis. | |||||
| func IsDetectionLevel(level AnalysisLevel) bool { | |||||
| if IsPresentationLevel(level) { | |||||
| return false | |||||
| } | |||||
| role := strings.ToLower(strings.TrimSpace(level.Role)) | |||||
| truth := strings.ToLower(strings.TrimSpace(level.Truth)) | |||||
| name := strings.ToLower(strings.TrimSpace(level.Name)) | |||||
| if strings.Contains(truth, "surveillance") { | |||||
| return true | |||||
| } | |||||
| if role == "surveillance" || strings.HasPrefix(role, "surveillance-") { | |||||
| return true | |||||
| } | |||||
| return strings.Contains(name, "surveillance") | |||||
| } | |||||
| func isPrimarySurveillanceLevel(level AnalysisLevel) bool { | |||||
| role := strings.ToLower(strings.TrimSpace(level.Role)) | |||||
| name := strings.ToLower(strings.TrimSpace(level.Name)) | |||||
| 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 strings.HasPrefix(role, "surveillance-") && role != "surveillance" { | |||||
| return true | |||||
| } | |||||
| if strings.HasPrefix(name, "surveillance-") && name != "surveillance" { | |||||
| return true | |||||
| } | |||||
| return strings.Contains(role, "lowres") || strings.Contains(name, "lowres") || strings.Contains(name, "derived") | |||||
| } | |||||
| func evidenceLevelKey(level AnalysisLevel) string { | |||||
| if level.Name != "" { | |||||
| return level.Name | |||||
| } | |||||
| if level.SampleRate > 0 && level.FFTSize > 0 { | |||||
| return fmt.Sprintf("sr%d-fft%d", level.SampleRate, level.FFTSize) | |||||
| } | |||||
| return "unknown" | |||||
| } | |||||
| // CandidateEvidenceStateFor builds a fused evidence state from a candidate. | |||||
| func CandidateEvidenceStateFor(candidate Candidate) CandidateEvidenceState { | |||||
| state := CandidateEvidenceState{} | |||||
| if len(candidate.Evidence) == 0 { | |||||
| return state | |||||
| } | |||||
| levelSet := map[string]struct{}{} | |||||
| provenanceSet := map[string]struct{}{} | |||||
| detectionLevels := map[string]struct{}{} | |||||
| primaryLevels := map[string]struct{}{} | |||||
| derivedLevels := map[string]struct{}{} | |||||
| presentationLevels := map[string]struct{}{} | |||||
| for _, ev := range candidate.Evidence { | |||||
| levelKey := evidenceLevelKey(ev.Level) | |||||
| levelSet[levelKey] = struct{}{} | |||||
| if ev.Provenance != "" { | |||||
| provenanceSet[ev.Provenance] = struct{}{} | |||||
| } | |||||
| if IsPresentationLevel(ev.Level) { | |||||
| presentationLevels[levelKey] = struct{}{} | |||||
| continue | |||||
| } | |||||
| if IsDetectionLevel(ev.Level) { | |||||
| detectionLevels[levelKey] = struct{}{} | |||||
| if isPrimarySurveillanceLevel(ev.Level) { | |||||
| primaryLevels[levelKey] = struct{}{} | |||||
| } else if isDerivedSurveillanceLevel(ev.Level) { | |||||
| derivedLevels[levelKey] = struct{}{} | |||||
| } | |||||
| } | |||||
| } | |||||
| state.TotalLevelEntries = len(candidate.Evidence) | |||||
| state.LevelCount = len(levelSet) | |||||
| state.DetectionLevelCount = len(detectionLevels) | |||||
| state.PrimaryLevelCount = len(primaryLevels) | |||||
| state.DerivedLevelCount = len(derivedLevels) | |||||
| state.PresentationLevelCount = len(presentationLevels) | |||||
| state.Levels = sortedKeys(levelSet) | |||||
| 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.MultiLevelConfirmed = state.DetectionLevelCount >= 2 | |||||
| if state.MultiLevelConfirmed { | |||||
| if state.PrimaryLevelCount > 0 && state.DerivedLevelCount > 0 { | |||||
| state.MultiLevelConfirmedHint = "primary+derived" | |||||
| } else { | |||||
| state.MultiLevelConfirmedHint = "multi-detection" | |||||
| } | |||||
| } | |||||
| return state | |||||
| } | |||||
| // RefreshCandidateEvidenceState updates the candidate's cached evidence summary. | |||||
| func RefreshCandidateEvidenceState(candidate *Candidate) { | |||||
| if candidate == nil { | |||||
| return | |||||
| } | |||||
| state := CandidateEvidenceStateFor(*candidate) | |||||
| if state.TotalLevelEntries == 0 { | |||||
| candidate.EvidenceState = nil | |||||
| return | |||||
| } | |||||
| candidate.EvidenceState = &state | |||||
| } | |||||
| func sortedKeys(src map[string]struct{}) []string { | |||||
| if len(src) == 0 { | |||||
| return nil | |||||
| } | |||||
| out := make([]string, 0, len(src)) | |||||
| for k := range src { | |||||
| out = append(out, k) | |||||
| } | |||||
| sort.Strings(out) | |||||
| return out | |||||
| } | |||||
| @@ -20,11 +20,12 @@ type RefinementScoreModel struct { | |||||
| } | } | ||||
| type RefinementScoreDetails struct { | type RefinementScoreDetails struct { | ||||
| SNRScore float64 `json:"snr_score"` | |||||
| BandwidthScore float64 `json:"bandwidth_score"` | |||||
| PeakScore float64 `json:"peak_score"` | |||||
| PolicyBoost float64 `json:"policy_boost"` | |||||
| EvidenceScore float64 `json:"evidence_score"` | |||||
| SNRScore float64 `json:"snr_score"` | |||||
| BandwidthScore float64 `json:"bandwidth_score"` | |||||
| PeakScore float64 `json:"peak_score"` | |||||
| PolicyBoost float64 `json:"policy_boost"` | |||||
| EvidenceScore float64 `json:"evidence_score"` | |||||
| EvidenceDetail *EvidenceScoreDetails `json:"evidence_detail,omitempty"` | |||||
| } | } | ||||
| type RefinementScore struct { | type RefinementScore struct { | ||||
| @@ -114,35 +115,41 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| scored := make([]ScheduledCandidate, 0, len(candidates)) | scored := make([]ScheduledCandidate, 0, len(candidates)) | ||||
| workItems := make([]RefinementWorkItem, 0, len(candidates)) | workItems := make([]RefinementWorkItem, 0, len(candidates)) | ||||
| for _, c := range candidates { | for _, c := range candidates { | ||||
| if !candidateInMonitor(policy, c) { | |||||
| candidate := c | |||||
| RefreshCandidateEvidenceState(&candidate) | |||||
| if !candidateInMonitor(policy, candidate) { | |||||
| plan.DroppedByMonitor++ | plan.DroppedByMonitor++ | ||||
| workItems = append(workItems, RefinementWorkItem{ | workItems = append(workItems, RefinementWorkItem{ | ||||
| Candidate: c, | |||||
| Candidate: candidate, | |||||
| Status: RefinementStatusDropped, | Status: RefinementStatusDropped, | ||||
| Reason: RefinementReasonMonitorGate, | Reason: RefinementReasonMonitorGate, | ||||
| }) | }) | ||||
| continue | continue | ||||
| } | } | ||||
| if c.SNRDb < policy.MinCandidateSNRDb { | |||||
| if candidate.SNRDb < policy.MinCandidateSNRDb { | |||||
| plan.DroppedBySNR++ | plan.DroppedBySNR++ | ||||
| workItems = append(workItems, RefinementWorkItem{ | workItems = append(workItems, RefinementWorkItem{ | ||||
| Candidate: c, | |||||
| Candidate: candidate, | |||||
| Status: RefinementStatusDropped, | Status: RefinementStatusDropped, | ||||
| Reason: RefinementReasonBelowSNR, | Reason: RefinementReasonBelowSNR, | ||||
| }) | }) | ||||
| continue | continue | ||||
| } | } | ||||
| snrScore := c.SNRDb * scoreModel.SNRWeight | |||||
| snrScore := candidate.SNRDb * scoreModel.SNRWeight | |||||
| bwScore := 0.0 | bwScore := 0.0 | ||||
| peakScore := 0.0 | peakScore := 0.0 | ||||
| policyBoost := CandidatePriorityBoost(policy, c.Hint) | |||||
| if c.BandwidthHz > 0 { | |||||
| bwScore = minFloat64(c.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight | |||||
| policyBoost := CandidatePriorityBoost(policy, candidate.Hint) | |||||
| if candidate.BandwidthHz > 0 { | |||||
| bwScore = minFloat64(candidate.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight | |||||
| } | } | ||||
| if c.PeakDb > 0 { | |||||
| peakScore = (c.PeakDb / 20.0) * scoreModel.PeakWeight | |||||
| if candidate.PeakDb > 0 { | |||||
| peakScore = (candidate.PeakDb / 20.0) * scoreModel.PeakWeight | |||||
| } | } | ||||
| evidenceScore := candidateEvidenceScore(c) * scoreModel.EvidenceWeight | |||||
| rawEvidenceScore, evidenceDetail := candidateEvidenceScore(candidate, strategy) | |||||
| evidenceDetail.Weight = scoreModel.EvidenceWeight | |||||
| evidenceDetail.RawScore = rawEvidenceScore | |||||
| evidenceDetail.WeightedScore = rawEvidenceScore * scoreModel.EvidenceWeight | |||||
| evidenceScore := evidenceDetail.WeightedScore | |||||
| priority := snrScore + bwScore + peakScore + policyBoost | priority := snrScore + bwScore + peakScore + policyBoost | ||||
| priority += evidenceScore | priority += evidenceScore | ||||
| score := &RefinementScore{ | score := &RefinementScore{ | ||||
| @@ -153,17 +160,18 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| PeakScore: peakScore, | PeakScore: peakScore, | ||||
| PolicyBoost: policyBoost, | PolicyBoost: policyBoost, | ||||
| EvidenceScore: evidenceScore, | EvidenceScore: evidenceScore, | ||||
| EvidenceDetail: &evidenceDetail, | |||||
| }, | }, | ||||
| Weights: &scoreModel, | Weights: &scoreModel, | ||||
| } | } | ||||
| scored = append(scored, ScheduledCandidate{ | scored = append(scored, ScheduledCandidate{ | ||||
| Candidate: c, | |||||
| Candidate: candidate, | |||||
| Priority: priority, | Priority: priority, | ||||
| Score: score, | Score: score, | ||||
| Breakdown: &score.Breakdown, | Breakdown: &score.Breakdown, | ||||
| }) | }) | ||||
| workItems = append(workItems, RefinementWorkItem{ | workItems = append(workItems, RefinementWorkItem{ | ||||
| Candidate: c, | |||||
| Candidate: candidate, | |||||
| Priority: priority, | Priority: priority, | ||||
| Score: score, | Score: score, | ||||
| Breakdown: &score.Breakdown, | Breakdown: &score.Breakdown, | ||||
| @@ -250,12 +258,67 @@ func applyStrategyWeights(strategy string, model RefinementScoreModel) Refinemen | |||||
| return model | return model | ||||
| } | } | ||||
| func candidateEvidenceScore(candidate Candidate) float64 { | |||||
| levels := CandidateEvidenceLevelCount(candidate) | |||||
| if levels <= 1 { | |||||
| return 0 | |||||
| func candidateEvidenceScore(candidate Candidate, strategy string) (float64, EvidenceScoreDetails) { | |||||
| state := CandidateEvidenceStateFor(candidate) | |||||
| details := EvidenceScoreDetails{ | |||||
| DetectionLevels: state.DetectionLevelCount, | |||||
| PrimaryLevels: state.PrimaryLevelCount, | |||||
| DerivedLevels: state.DerivedLevelCount, | |||||
| ProvenanceCount: len(state.Provenance), | |||||
| DerivedOnly: state.DerivedOnly, | |||||
| MultiLevelConfirmed: state.MultiLevelConfirmed, | |||||
| } | } | ||||
| return float64(levels - 1) | |||||
| score := 0.0 | |||||
| if state.MultiLevelConfirmed && state.DetectionLevelCount > 1 { | |||||
| bonus := 0.85 * float64(state.DetectionLevelCount-1) | |||||
| score += bonus | |||||
| details.MultiLevelBonus = bonus | |||||
| } | |||||
| if len(state.Provenance) > 1 { | |||||
| bonus := 0.15 * float64(len(state.Provenance)-1) | |||||
| score += bonus | |||||
| details.ProvenanceBonus = bonus | |||||
| } | |||||
| if state.DerivedOnly { | |||||
| penalty := 0.35 | |||||
| score -= penalty | |||||
| details.DerivedPenalty = -penalty | |||||
| } | |||||
| switch strings.ToLower(strings.TrimSpace(strategy)) { | |||||
| case "multi-resolution", "multi", "multi-res", "multi_res": | |||||
| if state.DerivedOnly { | |||||
| bias := 0.2 | |||||
| score += bias | |||||
| details.StrategyBias = bias | |||||
| } else if state.MultiLevelConfirmed { | |||||
| bias := 0.1 | |||||
| score += bias | |||||
| details.StrategyBias = bias | |||||
| } | |||||
| case "digital-hunting": | |||||
| if state.DerivedOnly { | |||||
| bias := -0.15 | |||||
| score += bias | |||||
| details.StrategyBias = bias | |||||
| } else if state.MultiLevelConfirmed { | |||||
| bias := 0.05 | |||||
| score += bias | |||||
| details.StrategyBias = bias | |||||
| } | |||||
| case "archive-oriented": | |||||
| if state.DerivedOnly { | |||||
| bias := -0.1 | |||||
| score += bias | |||||
| details.StrategyBias = bias | |||||
| } | |||||
| case "single-resolution": | |||||
| if state.MultiLevelConfirmed { | |||||
| bias := 0.05 | |||||
| score += bias | |||||
| details.StrategyBias = bias | |||||
| } | |||||
| } | |||||
| return score, details | |||||
| } | } | ||||
| func minFloat64(a, b float64) float64 { | func minFloat64(a, b float64) float64 { | ||||
| @@ -173,6 +173,60 @@ func TestScheduleCandidatesEvidenceBoost(t *testing.T) { | |||||
| if plan.Ranked[0].Breakdown == nil || plan.Ranked[0].Breakdown.EvidenceScore <= 0 { | if plan.Ranked[0].Breakdown == nil || plan.Ranked[0].Breakdown.EvidenceScore <= 0 { | ||||
| t.Fatalf("expected evidence score to be populated, got %+v", plan.Ranked[0].Breakdown) | t.Fatalf("expected evidence score to be populated, got %+v", plan.Ranked[0].Breakdown) | ||||
| } | } | ||||
| if plan.Ranked[0].Breakdown.EvidenceDetail == nil || !plan.Ranked[0].Breakdown.EvidenceDetail.MultiLevelConfirmed { | |||||
| t.Fatalf("expected evidence detail for multi-level candidate, got %+v", plan.Ranked[0].Breakdown) | |||||
| } | |||||
| } | |||||
| func TestScheduleCandidatesDerivedOnlyPenalty(t *testing.T) { | |||||
| policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 0} | |||||
| primary := Candidate{ | |||||
| ID: 1, | |||||
| SNRDb: 10, | |||||
| BandwidthHz: 12000, | |||||
| Evidence: []LevelEvidence{ | |||||
| {Level: AnalysisLevel{Name: "surveillance", Role: "surveillance", Truth: "surveillance"}}, | |||||
| }, | |||||
| } | |||||
| derived := Candidate{ | |||||
| ID: 2, | |||||
| SNRDb: 10, | |||||
| BandwidthHz: 12000, | |||||
| Evidence: []LevelEvidence{ | |||||
| {Level: AnalysisLevel{Name: "surveillance-lowres", Role: "surveillance-lowres", Truth: "surveillance"}}, | |||||
| }, | |||||
| } | |||||
| plan := BuildRefinementPlan([]Candidate{derived, primary}, policy) | |||||
| if len(plan.Ranked) != 2 { | |||||
| t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked)) | |||||
| } | |||||
| if plan.Ranked[0].Candidate.ID != primary.ID { | |||||
| t.Fatalf("expected primary evidence to outrank derived-only, got %+v", plan.Ranked[0]) | |||||
| } | |||||
| } | |||||
| func TestScheduleCandidatesDerivedOnlyStrategyBias(t *testing.T) { | |||||
| cand := Candidate{ | |||||
| ID: 1, | |||||
| SNRDb: 9, | |||||
| BandwidthHz: 12000, | |||||
| Evidence: []LevelEvidence{ | |||||
| {Level: AnalysisLevel{Name: "surveillance-lowres", Role: "surveillance-lowres", Truth: "surveillance"}}, | |||||
| }, | |||||
| } | |||||
| singlePlan := BuildRefinementPlan([]Candidate{cand}, Policy{MinCandidateSNRDb: 0}) | |||||
| multiPlan := BuildRefinementPlan([]Candidate{cand}, Policy{MinCandidateSNRDb: 0, SurveillanceStrategy: "multi-resolution"}) | |||||
| if len(singlePlan.Ranked) == 0 || len(multiPlan.Ranked) == 0 { | |||||
| t.Fatalf("expected ranked candidates in both plans") | |||||
| } | |||||
| singleScore := singlePlan.Ranked[0].Breakdown.EvidenceScore | |||||
| multiScore := multiPlan.Ranked[0].Breakdown.EvidenceScore | |||||
| if multiScore <= singleScore { | |||||
| t.Fatalf("expected multi-resolution strategy to improve derived-only evidence score, got %.3f vs %.3f", multiScore, singleScore) | |||||
| } | |||||
| if multiPlan.Ranked[0].Breakdown.EvidenceDetail == nil || multiPlan.Ranked[0].Breakdown.EvidenceDetail.StrategyBias <= 0 { | |||||
| t.Fatalf("expected strategy bias detail for multi-resolution, got %+v", multiPlan.Ranked[0].Breakdown.EvidenceDetail) | |||||
| } | |||||
| } | } | ||||
| func TestBuildRefinementPlanPriorityStats(t *testing.T) { | func TestBuildRefinementPlanPriorityStats(t *testing.T) { | ||||
| @@ -8,17 +8,18 @@ import ( | |||||
| // Candidate is the coarse output of the surveillance detector. | // Candidate is the coarse output of the surveillance detector. | ||||
| // It intentionally stays lightweight and cheap to produce. | // It intentionally stays lightweight and cheap to produce. | ||||
| type Candidate struct { | type Candidate struct { | ||||
| ID int64 `json:"id"` | |||||
| CenterHz float64 `json:"center_hz"` | |||||
| BandwidthHz float64 `json:"bandwidth_hz"` | |||||
| PeakDb float64 `json:"peak_db"` | |||||
| SNRDb float64 `json:"snr_db"` | |||||
| FirstBin int `json:"first_bin"` | |||||
| LastBin int `json:"last_bin"` | |||||
| NoiseDb float64 `json:"noise_db,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| Hint string `json:"hint,omitempty"` | |||||
| Evidence []LevelEvidence `json:"evidence,omitempty"` | |||||
| ID int64 `json:"id"` | |||||
| CenterHz float64 `json:"center_hz"` | |||||
| BandwidthHz float64 `json:"bandwidth_hz"` | |||||
| PeakDb float64 `json:"peak_db"` | |||||
| SNRDb float64 `json:"snr_db"` | |||||
| FirstBin int `json:"first_bin"` | |||||
| LastBin int `json:"last_bin"` | |||||
| NoiseDb float64 `json:"noise_db,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| Hint string `json:"hint,omitempty"` | |||||
| Evidence []LevelEvidence `json:"evidence,omitempty"` | |||||
| EvidenceState *CandidateEvidenceState `json:"evidence_state,omitempty"` | |||||
| } | } | ||||
| // LevelEvidence captures which analysis level produced a candidate. | // LevelEvidence captures which analysis level produced a candidate. | ||||