| @@ -231,9 +231,15 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S | |||||
| func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pipeline.RefinementInput { | func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pipeline.RefinementInput { | ||||
| policy := pipeline.PolicyFromConfig(rt.cfg) | policy := pipeline.PolicyFromConfig(rt.cfg) | ||||
| plan := pipeline.BuildRefinementPlan(surv.Candidates, policy) | |||||
| scheduled := append([]pipeline.ScheduledCandidate(nil), surv.Scheduled...) | |||||
| if len(scheduled) == 0 && len(plan.Selected) > 0 { | |||||
| scheduled = append([]pipeline.ScheduledCandidate(nil), plan.Selected...) | |||||
| } | |||||
| input := pipeline.RefinementInput{ | input := pipeline.RefinementInput{ | ||||
| Candidates: append([]pipeline.Candidate(nil), surv.Candidates...), | Candidates: append([]pipeline.Candidate(nil), surv.Candidates...), | ||||
| Scheduled: append([]pipeline.ScheduledCandidate(nil), surv.Scheduled...), | |||||
| Scheduled: scheduled, | |||||
| Plan: plan, | |||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| FFTSize: rt.cfg.FFTSize, | FFTSize: rt.cfg.FFTSize, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| @@ -11,9 +11,19 @@ type SurveillanceResult struct { | |||||
| Thresholds []float64 `json:"thresholds,omitempty"` | Thresholds []float64 `json:"thresholds,omitempty"` | ||||
| } | } | ||||
| type RefinementPlan struct { | |||||
| TotalCandidates int `json:"total_candidates"` | |||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | |||||
| Budget int `json:"budget"` | |||||
| DroppedBySNR int `json:"dropped_by_snr"` | |||||
| DroppedByBudget int `json:"dropped_by_budget"` | |||||
| Selected []ScheduledCandidate `json:"selected,omitempty"` | |||||
| } | |||||
| type RefinementInput struct { | type RefinementInput struct { | ||||
| Candidates []Candidate `json:"candidates,omitempty"` | Candidates []Candidate `json:"candidates,omitempty"` | ||||
| Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` | Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` | ||||
| Plan RefinementPlan `json:"plan,omitempty"` | |||||
| SampleRate int `json:"sample_rate"` | SampleRate int `json:"sample_rate"` | ||||
| FFTSize int `json:"fft_size"` | FFTSize int `json:"fft_size"` | ||||
| CenterHz float64 `json:"center_hz"` | CenterHz float64 `json:"center_hz"` | ||||
| @@ -31,6 +31,10 @@ func TestRefinementInputCarriesScheduledCandidates(t *testing.T) { | |||||
| res := RefinementInput{ | res := RefinementInput{ | ||||
| Candidates: []Candidate{{ID: 2}}, | Candidates: []Candidate{{ID: 2}}, | ||||
| Scheduled: []ScheduledCandidate{{Candidate: Candidate{ID: 2}, Priority: 4}}, | Scheduled: []ScheduledCandidate{{Candidate: Candidate{ID: 2}, Priority: 4}}, | ||||
| Plan: RefinementPlan{ | |||||
| TotalCandidates: 1, | |||||
| Budget: 4, | |||||
| }, | |||||
| SampleRate: 2048000, | SampleRate: 2048000, | ||||
| FFTSize: 2048, | FFTSize: 2048, | ||||
| CenterHz: 7.1e6, | CenterHz: 7.1e6, | ||||
| @@ -42,4 +46,7 @@ func TestRefinementInputCarriesScheduledCandidates(t *testing.T) { | |||||
| if res.SampleRate != 2048000 || res.FFTSize != 2048 || res.CenterHz != 7.1e6 { | if res.SampleRate != 2048000 || res.FFTSize != 2048 || res.CenterHz != 7.1e6 { | ||||
| t.Fatalf("unexpected refinement input fields: %+v", res) | t.Fatalf("unexpected refinement input fields: %+v", res) | ||||
| } | } | ||||
| if res.Plan.TotalCandidates != 1 || res.Plan.Budget != 4 { | |||||
| t.Fatalf("unexpected refinement plan fields: %+v", res.Plan) | |||||
| } | |||||
| } | } | ||||
| @@ -7,16 +7,22 @@ type ScheduledCandidate struct { | |||||
| Priority float64 `json:"priority"` | Priority float64 `json:"priority"` | ||||
| } | } | ||||
| // ScheduleCandidates picks the most valuable candidates for costly local refinement. | |||||
| // BuildRefinementPlan scores and budgets candidates for costly local refinement. | |||||
| // Current heuristic is intentionally simple and deterministic; later phases can add | // Current heuristic is intentionally simple and deterministic; later phases can add | ||||
| // richer scoring (novelty, persistence, profile-aware band priorities, decoder value). | // richer scoring (novelty, persistence, profile-aware band priorities, decoder value). | ||||
| func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandidate { | |||||
| func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| plan := RefinementPlan{ | |||||
| TotalCandidates: len(candidates), | |||||
| MinCandidateSNRDb: policy.MinCandidateSNRDb, | |||||
| Budget: policy.MaxRefinementJobs, | |||||
| } | |||||
| if len(candidates) == 0 { | if len(candidates) == 0 { | ||||
| return nil | |||||
| return plan | |||||
| } | } | ||||
| out := make([]ScheduledCandidate, 0, len(candidates)) | |||||
| scored := make([]ScheduledCandidate, 0, len(candidates)) | |||||
| for _, c := range candidates { | for _, c := range candidates { | ||||
| if c.SNRDb < policy.MinCandidateSNRDb { | if c.SNRDb < policy.MinCandidateSNRDb { | ||||
| plan.DroppedBySNR++ | |||||
| continue | continue | ||||
| } | } | ||||
| priority := c.SNRDb + CandidatePriorityBoost(policy, c.Hint) | priority := c.SNRDb + CandidatePriorityBoost(policy, c.Hint) | ||||
| @@ -26,19 +32,25 @@ func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandid | |||||
| if c.PeakDb > 0 { | if c.PeakDb > 0 { | ||||
| priority += c.PeakDb / 20.0 | priority += c.PeakDb / 20.0 | ||||
| } | } | ||||
| out = append(out, ScheduledCandidate{Candidate: c, Priority: priority}) | |||||
| scored = append(scored, ScheduledCandidate{Candidate: c, Priority: priority}) | |||||
| } | } | ||||
| sort.Slice(out, func(i, j int) bool { | |||||
| if out[i].Priority == out[j].Priority { | |||||
| return out[i].Candidate.CenterHz < out[j].Candidate.CenterHz | |||||
| sort.Slice(scored, func(i, j int) bool { | |||||
| if scored[i].Priority == scored[j].Priority { | |||||
| return scored[i].Candidate.CenterHz < scored[j].Candidate.CenterHz | |||||
| } | } | ||||
| return out[i].Priority > out[j].Priority | |||||
| return scored[i].Priority > scored[j].Priority | |||||
| }) | }) | ||||
| limit := policy.MaxRefinementJobs | limit := policy.MaxRefinementJobs | ||||
| if limit <= 0 || limit > len(out) { | |||||
| limit = len(out) | |||||
| if limit <= 0 || limit > len(scored) { | |||||
| limit = len(scored) | |||||
| } | } | ||||
| return out[:limit] | |||||
| plan.Selected = scored[:limit] | |||||
| plan.DroppedByBudget = len(scored) - len(plan.Selected) | |||||
| return plan | |||||
| } | |||||
| func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandidate { | |||||
| return BuildRefinementPlan(candidates, policy).Selected | |||||
| } | } | ||||
| func minFloat64(a, b float64) float64 { | func minFloat64(a, b float64) float64 { | ||||
| @@ -22,6 +22,28 @@ func TestScheduleCandidates(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| func TestBuildRefinementPlanTracksDrops(t *testing.T) { | |||||
| policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 10} | |||||
| cands := []Candidate{ | |||||
| {ID: 1, CenterHz: 100, SNRDb: 4, BandwidthHz: 10000, PeakDb: 1}, | |||||
| {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3}, | |||||
| {ID: 3, CenterHz: 300, SNRDb: 11, BandwidthHz: 25000, PeakDb: 2}, | |||||
| } | |||||
| plan := BuildRefinementPlan(cands, policy) | |||||
| if plan.TotalCandidates != 3 { | |||||
| t.Fatalf("expected total candidates 3, got %d", plan.TotalCandidates) | |||||
| } | |||||
| if plan.DroppedBySNR != 1 { | |||||
| t.Fatalf("expected 1 dropped by SNR, got %d", plan.DroppedBySNR) | |||||
| } | |||||
| if plan.DroppedByBudget != 1 { | |||||
| t.Fatalf("expected 1 dropped by budget, got %d", plan.DroppedByBudget) | |||||
| } | |||||
| if len(plan.Selected) != 1 || plan.Selected[0].Candidate.ID != 2 { | |||||
| t.Fatalf("unexpected plan selection: %+v", plan.Selected) | |||||
| } | |||||
| } | |||||
| func TestScheduleCandidatesPriorityBoost(t *testing.T) { | func TestScheduleCandidatesPriorityBoost(t *testing.T) { | ||||
| policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} | policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} | ||||
| got := ScheduleCandidates([]Candidate{ | got := ScheduleCandidates([]Candidate{ | ||||