diff --git a/cmd/sdrd/arbitration_snapshot.go b/cmd/sdrd/arbitration_snapshot.go index fe048e0..3fb9bbb 100644 --- a/cmd/sdrd/arbitration_snapshot.go +++ b/cmd/sdrd/arbitration_snapshot.go @@ -6,7 +6,6 @@ func buildArbitrationSnapshot(step pipeline.RefinementStep, arb pipeline.Arbitra return &ArbitrationSnapshot{ Budgets: &arb.Budgets, HoldPolicy: &arb.HoldPolicy, - RefinementPlan: &step.Input.Plan, RefinementAdmission: &arb.Refinement, Queue: arb.Queue, DecisionSummary: summarizeDecisions(step.Result.Decisions), diff --git a/cmd/sdrd/types.go b/cmd/sdrd/types.go index 50454a5..493aa89 100644 --- a/cmd/sdrd/types.go +++ b/cmd/sdrd/types.go @@ -47,7 +47,6 @@ type DecisionDebug struct { type ArbitrationSnapshot struct { Budgets *pipeline.BudgetModel `json:"budgets,omitempty"` HoldPolicy *pipeline.HoldPolicy `json:"hold_policy,omitempty"` - RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` RefinementAdmission *pipeline.RefinementAdmission `json:"refinement_admission,omitempty"` Queue pipeline.DecisionQueueStats `json:"queue,omitempty"` DecisionSummary decisionSummary `json:"decision_summary,omitempty"` diff --git a/internal/pipeline/arbitration_test.go b/internal/pipeline/arbitration_test.go index de58f41..adaf1cf 100644 --- a/internal/pipeline/arbitration_test.go +++ b/internal/pipeline/arbitration_test.go @@ -37,6 +37,22 @@ func TestAdmitRefinementPlanNoCandidatesReason(t *testing.T) { } } +func TestAdmitRefinementPlanUnlimitedBudget(t *testing.T) { + policy := Policy{MaxRefinementJobs: 0, MinCandidateSNRDb: 0} + cands := []Candidate{ + {ID: 1, CenterHz: 100, SNRDb: 5}, + {ID: 2, CenterHz: 200, SNRDb: 6}, + } + plan := BuildRefinementPlan(cands, policy) + res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}}) + if len(res.Plan.Selected) != len(cands) { + t.Fatalf("expected all candidates admitted, got %d", len(res.Plan.Selected)) + } + if res.Admission.Skipped != 0 || res.Plan.DroppedByBudget != 0 { + t.Fatalf("expected no budget drops, got admission=%+v plan=%+v", res.Admission, res.Plan) + } +} + func containsReason(reasons []string, target string) bool { for _, r := range reasons { if r == target { diff --git a/internal/pipeline/budget_test.go b/internal/pipeline/budget_test.go new file mode 100644 index 0000000..38679e9 --- /dev/null +++ b/internal/pipeline/budget_test.go @@ -0,0 +1,26 @@ +package pipeline + +import "testing" + +func TestBudgetModelRefinementSource(t *testing.T) { + t.Run("uses refinement max concurrent when tighter", func(t *testing.T) { + policy := Policy{MaxRefinementJobs: 12, RefinementMaxConcurrent: 4} + budget := BudgetModelFromPolicy(policy) + if budget.Refinement.Max != 4 { + t.Fatalf("expected refinement max 4, got %d", budget.Refinement.Max) + } + if budget.Refinement.Source != "refinement.max_concurrent" { + t.Fatalf("expected refinement source refinement.max_concurrent, got %s", budget.Refinement.Source) + } + }) + t.Run("keeps resources budget when smaller", func(t *testing.T) { + policy := Policy{MaxRefinementJobs: 3, RefinementMaxConcurrent: 8} + budget := BudgetModelFromPolicy(policy) + if budget.Refinement.Max != 3 { + t.Fatalf("expected refinement max 3, got %d", budget.Refinement.Max) + } + if budget.Refinement.Source != "resources.max_refinement_jobs" { + t.Fatalf("expected refinement source resources.max_refinement_jobs, got %s", budget.Refinement.Source) + } + }) +} diff --git a/internal/pipeline/decision_queue_test.go b/internal/pipeline/decision_queue_test.go index 2edb80f..becc731 100644 --- a/internal/pipeline/decision_queue_test.go +++ b/internal/pipeline/decision_queue_test.go @@ -57,3 +57,31 @@ func TestDecisionQueueEnforcesBudgets(t *testing.T) { t.Fatalf("expected mid SNR decision to be budgeted off by record budget") } } + +func TestDecisionQueueHoldKeepsSelection(t *testing.T) { + arbiter := NewArbiter() + policy := Policy{DecisionHoldMs: 500} + budget := BudgetModel{Record: BudgetQueue{Max: 1}, Decode: BudgetQueue{Max: 1}} + now := time.Now() + + decisions := []SignalDecision{ + {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true}, + {Candidate: Candidate{ID: 2, SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true}, + } + arbiter.ApplyDecisions(decisions, budget, now, policy) + if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode { + t.Fatalf("expected candidate 2 to be selected initially") + } + + decisions = []SignalDecision{ + {Candidate: Candidate{ID: 1, SNRDb: 25}, ShouldRecord: true, ShouldAutoDecode: true}, + {Candidate: Candidate{ID: 2, SNRDb: 2}, ShouldRecord: true, ShouldAutoDecode: true}, + } + arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy) + if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode { + t.Fatalf("expected held candidate 2 to remain selected") + } + if decisions[0].ShouldRecord || decisions[0].ShouldAutoDecode { + t.Fatalf("expected candidate 1 to remain queued behind hold") + } +} diff --git a/internal/pipeline/scheduler_test.go b/internal/pipeline/scheduler_test.go index b1012b4..ffa2080 100644 --- a/internal/pipeline/scheduler_test.go +++ b/internal/pipeline/scheduler_test.go @@ -237,6 +237,21 @@ func TestRefinementStrategyUsesProfile(t *testing.T) { } } +func TestRefinementStrategyUsesIntentAndSurveillance(t *testing.T) { + strategy, reason := refinementStrategy(Policy{Intent: "decode-digital"}) + if strategy != "digital-hunting" || reason != "intent" { + t.Fatalf("expected intent to set digital strategy, got %s (%s)", strategy, reason) + } + strategy, reason = refinementStrategy(Policy{Intent: "archive-and-triage"}) + if strategy != "archive-oriented" || reason != "intent" { + t.Fatalf("expected intent to set archive strategy, got %s (%s)", strategy, reason) + } + strategy, reason = refinementStrategy(Policy{SurveillanceStrategy: "multi-resolution"}) + if strategy != "multi-resolution" || reason != "surveillance-strategy" { + t.Fatalf("expected surveillance strategy to set multi-resolution, got %s (%s)", strategy, reason) + } +} + func findWorkItem(items []RefinementWorkItem, id int64) *RefinementWorkItem { for i := range items { if items[i].Candidate.ID == id {