| @@ -231,9 +231,15 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S | |||
| func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pipeline.RefinementInput { | |||
| 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{ | |||
| Candidates: append([]pipeline.Candidate(nil), surv.Candidates...), | |||
| Scheduled: append([]pipeline.ScheduledCandidate(nil), surv.Scheduled...), | |||
| Scheduled: scheduled, | |||
| Plan: plan, | |||
| SampleRate: rt.cfg.SampleRate, | |||
| FFTSize: rt.cfg.FFTSize, | |||
| CenterHz: rt.cfg.CenterHz, | |||
| @@ -11,9 +11,19 @@ type SurveillanceResult struct { | |||
| 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 { | |||
| Candidates []Candidate `json:"candidates,omitempty"` | |||
| Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` | |||
| Plan RefinementPlan `json:"plan,omitempty"` | |||
| SampleRate int `json:"sample_rate"` | |||
| FFTSize int `json:"fft_size"` | |||
| CenterHz float64 `json:"center_hz"` | |||
| @@ -31,6 +31,10 @@ func TestRefinementInputCarriesScheduledCandidates(t *testing.T) { | |||
| res := RefinementInput{ | |||
| Candidates: []Candidate{{ID: 2}}, | |||
| Scheduled: []ScheduledCandidate{{Candidate: Candidate{ID: 2}, Priority: 4}}, | |||
| Plan: RefinementPlan{ | |||
| TotalCandidates: 1, | |||
| Budget: 4, | |||
| }, | |||
| SampleRate: 2048000, | |||
| FFTSize: 2048, | |||
| CenterHz: 7.1e6, | |||
| @@ -42,4 +46,7 @@ func TestRefinementInputCarriesScheduledCandidates(t *testing.T) { | |||
| if res.SampleRate != 2048000 || res.FFTSize != 2048 || res.CenterHz != 7.1e6 { | |||
| 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"` | |||
| } | |||
| // 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 | |||
| // 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 { | |||
| return nil | |||
| return plan | |||
| } | |||
| out := make([]ScheduledCandidate, 0, len(candidates)) | |||
| scored := make([]ScheduledCandidate, 0, len(candidates)) | |||
| for _, c := range candidates { | |||
| if c.SNRDb < policy.MinCandidateSNRDb { | |||
| plan.DroppedBySNR++ | |||
| continue | |||
| } | |||
| priority := c.SNRDb + CandidatePriorityBoost(policy, c.Hint) | |||
| @@ -26,19 +32,25 @@ func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandid | |||
| if c.PeakDb > 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 | |||
| 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 { | |||
| @@ -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) { | |||
| policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} | |||
| got := ScheduleCandidates([]Candidate{ | |||