diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 3779a04..c72918c 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -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, diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go index 2fcc9f9..2fc1d3e 100644 --- a/internal/pipeline/phases.go +++ b/internal/pipeline/phases.go @@ -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"` diff --git a/internal/pipeline/phases_test.go b/internal/pipeline/phases_test.go index 44cf9f8..2c45c45 100644 --- a/internal/pipeline/phases_test.go +++ b/internal/pipeline/phases_test.go @@ -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) + } } diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index dc7f14e..9a57ca5 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -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 { diff --git a/internal/pipeline/scheduler_test.go b/internal/pipeline/scheduler_test.go index 452293f..5fdef5a 100644 --- a/internal/pipeline/scheduler_test.go +++ b/internal/pipeline/scheduler_test.go @@ -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{