| @@ -6,7 +6,6 @@ func buildArbitrationSnapshot(step pipeline.RefinementStep, arb pipeline.Arbitra | |||||
| return &ArbitrationSnapshot{ | return &ArbitrationSnapshot{ | ||||
| Budgets: &arb.Budgets, | Budgets: &arb.Budgets, | ||||
| HoldPolicy: &arb.HoldPolicy, | HoldPolicy: &arb.HoldPolicy, | ||||
| RefinementPlan: &step.Input.Plan, | |||||
| RefinementAdmission: &arb.Refinement, | RefinementAdmission: &arb.Refinement, | ||||
| Queue: arb.Queue, | Queue: arb.Queue, | ||||
| DecisionSummary: summarizeDecisions(step.Result.Decisions), | DecisionSummary: summarizeDecisions(step.Result.Decisions), | ||||
| @@ -47,7 +47,6 @@ type DecisionDebug struct { | |||||
| type ArbitrationSnapshot struct { | type ArbitrationSnapshot struct { | ||||
| Budgets *pipeline.BudgetModel `json:"budgets,omitempty"` | Budgets *pipeline.BudgetModel `json:"budgets,omitempty"` | ||||
| HoldPolicy *pipeline.HoldPolicy `json:"hold_policy,omitempty"` | HoldPolicy *pipeline.HoldPolicy `json:"hold_policy,omitempty"` | ||||
| RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` | |||||
| RefinementAdmission *pipeline.RefinementAdmission `json:"refinement_admission,omitempty"` | RefinementAdmission *pipeline.RefinementAdmission `json:"refinement_admission,omitempty"` | ||||
| Queue pipeline.DecisionQueueStats `json:"queue,omitempty"` | Queue pipeline.DecisionQueueStats `json:"queue,omitempty"` | ||||
| DecisionSummary decisionSummary `json:"decision_summary,omitempty"` | DecisionSummary decisionSummary `json:"decision_summary,omitempty"` | ||||
| @@ -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 { | func containsReason(reasons []string, target string) bool { | ||||
| for _, r := range reasons { | for _, r := range reasons { | ||||
| if r == target { | if r == target { | ||||
| @@ -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) | |||||
| } | |||||
| }) | |||||
| } | |||||
| @@ -57,3 +57,31 @@ func TestDecisionQueueEnforcesBudgets(t *testing.T) { | |||||
| t.Fatalf("expected mid SNR decision to be budgeted off by record budget") | 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") | |||||
| } | |||||
| } | |||||
| @@ -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 { | func findWorkItem(items []RefinementWorkItem, id int64) *RefinementWorkItem { | ||||
| for i := range items { | for i := range items { | ||||
| if items[i].Candidate.ID == id { | if items[i].Candidate.ID == id { | ||||