Wideband autonomous SDR analysis engine forked from sdr-visual-suite
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

348 lines
13KB

  1. package pipeline
  2. import (
  3. "testing"
  4. "time"
  5. )
  6. func TestScheduleCandidates(t *testing.T) {
  7. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 5}
  8. cands := []Candidate{
  9. {ID: 1, CenterHz: 100, SNRDb: 4, BandwidthHz: 10000, PeakDb: 1},
  10. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  11. {ID: 3, CenterHz: 300, SNRDb: 10, BandwidthHz: 25000, PeakDb: 2},
  12. {ID: 4, CenterHz: 400, SNRDb: 20, BandwidthHz: 100000, PeakDb: 5},
  13. }
  14. got := ScheduleCandidates(cands, policy)
  15. if len(got) != 3 {
  16. t.Fatalf("expected 3 scheduled candidates, got %d", len(got))
  17. }
  18. if got[0].Candidate.ID != 4 {
  19. t.Fatalf("expected strongest candidate first, got id=%d", got[0].Candidate.ID)
  20. }
  21. if got[1].Candidate.ID != 2 {
  22. t.Fatalf("expected next strongest candidate second, got id=%d", got[1].Candidate.ID)
  23. }
  24. }
  25. func TestBuildRefinementPlanTracksDrops(t *testing.T) {
  26. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 10}
  27. cands := []Candidate{
  28. {ID: 1, CenterHz: 100, SNRDb: 4, BandwidthHz: 10000, PeakDb: 1},
  29. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  30. {ID: 3, CenterHz: 300, SNRDb: 11, BandwidthHz: 25000, PeakDb: 2},
  31. }
  32. plan := BuildRefinementPlan(cands, policy)
  33. if plan.TotalCandidates != 3 {
  34. t.Fatalf("expected total candidates 3, got %d", plan.TotalCandidates)
  35. }
  36. if plan.DroppedBySNR != 1 {
  37. t.Fatalf("expected 1 dropped by SNR, got %d", plan.DroppedBySNR)
  38. }
  39. if plan.DroppedByBudget != 0 {
  40. t.Fatalf("expected 0 dropped by budget in plan stage, got %d", plan.DroppedByBudget)
  41. }
  42. if len(plan.Selected) != 0 {
  43. t.Fatalf("expected no admitted selection in plan stage, got %+v", plan.Selected)
  44. }
  45. if len(plan.Ranked) != 2 {
  46. t.Fatalf("expected ranked candidates after gating, got %d", len(plan.Ranked))
  47. }
  48. if len(plan.WorkItems) != len(cands) {
  49. t.Fatalf("expected work items for all candidates, got %d", len(plan.WorkItems))
  50. }
  51. item2 := findWorkItem(plan.WorkItems, 2)
  52. if item2 == nil || item2.Status != RefinementStatusPlanned || item2.Reason != RefinementReasonPlanned {
  53. t.Fatalf("expected candidate 2 planned with reason, got %+v", item2)
  54. }
  55. item1 := findWorkItem(plan.WorkItems, 1)
  56. if item1 == nil || item1.Reason != RefinementReasonBelowSNR {
  57. t.Fatalf("expected candidate 1 dropped by snr, got %+v", item1)
  58. }
  59. item3 := findWorkItem(plan.WorkItems, 3)
  60. if item3 == nil || item3.Status != RefinementStatusPlanned {
  61. t.Fatalf("expected candidate 3 planned pre-admission, got %+v", item3)
  62. }
  63. }
  64. func TestBuildRefinementPlanRespectsMaxConcurrent(t *testing.T) {
  65. policy := Policy{MaxRefinementJobs: 5, RefinementMaxConcurrent: 2, MinCandidateSNRDb: 0}
  66. cands := []Candidate{
  67. {ID: 1, CenterHz: 100, SNRDb: 9},
  68. {ID: 2, CenterHz: 200, SNRDb: 8},
  69. {ID: 3, CenterHz: 300, SNRDb: 7},
  70. }
  71. plan := BuildRefinementPlan(cands, policy)
  72. if plan.Budget != 2 {
  73. t.Fatalf("expected budget 2, got %d", plan.Budget)
  74. }
  75. if plan.BudgetSource != "refinement.max_concurrent" {
  76. t.Fatalf("expected budget source refinement.max_concurrent, got %s", plan.BudgetSource)
  77. }
  78. if len(plan.Selected) != 0 {
  79. t.Fatalf("expected no selected until admission, got %d", len(plan.Selected))
  80. }
  81. }
  82. func TestBuildRefinementPlanAppliesMonitorSpan(t *testing.T) {
  83. policy := Policy{MaxRefinementJobs: 5, MinCandidateSNRDb: 0, MonitorStartHz: 150, MonitorEndHz: 350}
  84. cands := []Candidate{
  85. {ID: 1, CenterHz: 100, BandwidthHz: 20},
  86. {ID: 2, CenterHz: 200, BandwidthHz: 50},
  87. {ID: 3, CenterHz: 300, BandwidthHz: 100},
  88. {ID: 4, CenterHz: 500, BandwidthHz: 50},
  89. }
  90. plan := BuildRefinementPlan(cands, policy)
  91. if plan.DroppedByMonitor != 2 {
  92. t.Fatalf("expected 2 dropped by monitor, got %d", plan.DroppedByMonitor)
  93. }
  94. if len(plan.Ranked) != 2 {
  95. t.Fatalf("expected 2 ranked within monitor, got %d", len(plan.Ranked))
  96. }
  97. }
  98. func TestBuildRefinementPlanAppliesMonitorSpanCentered(t *testing.T) {
  99. policy := Policy{MaxRefinementJobs: 5, MinCandidateSNRDb: 0, MonitorCenterHz: 300, MonitorSpanHz: 200}
  100. cands := []Candidate{
  101. {ID: 1, CenterHz: 100, BandwidthHz: 20},
  102. {ID: 2, CenterHz: 250, BandwidthHz: 50},
  103. {ID: 3, CenterHz: 300, BandwidthHz: 100},
  104. {ID: 4, CenterHz: 420, BandwidthHz: 50},
  105. }
  106. plan := BuildRefinementPlan(cands, policy)
  107. if plan.DroppedByMonitor != 1 {
  108. t.Fatalf("expected 1 dropped by monitor, got %d", plan.DroppedByMonitor)
  109. }
  110. if len(plan.Ranked) != 3 {
  111. t.Fatalf("expected 3 ranked within monitor, got %d", len(plan.Ranked))
  112. }
  113. }
  114. func TestAutoSpanForHint(t *testing.T) {
  115. span, source := AutoSpanForHint("WFM_STEREO")
  116. if span < 150000 || source == "" {
  117. t.Fatalf("expected WFM span, got %.0f (%s)", span, source)
  118. }
  119. span, source = AutoSpanForHint("CW")
  120. if span != 500 || source == "" {
  121. t.Fatalf("expected CW span, got %.0f (%s)", span, source)
  122. }
  123. span, source = AutoSpanForHint("")
  124. if span != 0 || source != "" {
  125. t.Fatalf("expected empty span for unknown hint, got %.0f (%s)", span, source)
  126. }
  127. }
  128. func TestScheduleCandidatesPriorityBoost(t *testing.T) {
  129. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}}
  130. got := ScheduleCandidates([]Candidate{
  131. {ID: 1, SNRDb: 15, Hint: "voice"},
  132. {ID: 2, SNRDb: 14, Hint: "digital-burst"},
  133. }, policy)
  134. if len(got) != 2 || got[0].Candidate.ID != 2 {
  135. t.Fatalf("expected priority boost to favor digital candidate, got %+v", got)
  136. }
  137. }
  138. func TestScheduleCandidatesEvidenceBoost(t *testing.T) {
  139. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 0}
  140. single := Candidate{
  141. ID: 1,
  142. SNRDb: 8,
  143. BandwidthHz: 12000,
  144. Evidence: []LevelEvidence{
  145. {Level: AnalysisLevel{Name: "surveillance"}},
  146. },
  147. }
  148. multi := Candidate{
  149. ID: 2,
  150. SNRDb: 8,
  151. BandwidthHz: 12000,
  152. Evidence: []LevelEvidence{
  153. {Level: AnalysisLevel{Name: "surveillance"}},
  154. {Level: AnalysisLevel{Name: "surveillance-lowres"}},
  155. },
  156. }
  157. plan := BuildRefinementPlan([]Candidate{single, multi}, policy)
  158. if len(plan.Ranked) < 2 {
  159. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  160. }
  161. if plan.Ranked[0].Candidate.ID != multi.ID {
  162. t.Fatalf("expected multi-level candidate to rank first, got %+v", plan.Ranked[0])
  163. }
  164. if plan.Ranked[0].Breakdown == nil || plan.Ranked[0].Breakdown.EvidenceScore <= 0 {
  165. t.Fatalf("expected evidence score to be populated, got %+v", plan.Ranked[0].Breakdown)
  166. }
  167. if plan.Ranked[0].Breakdown.EvidenceDetail == nil || !plan.Ranked[0].Breakdown.EvidenceDetail.MultiLevelConfirmed {
  168. t.Fatalf("expected evidence detail for multi-level candidate, got %+v", plan.Ranked[0].Breakdown)
  169. }
  170. }
  171. func TestScheduleCandidatesDerivedOnlyPenalty(t *testing.T) {
  172. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 0}
  173. primary := Candidate{
  174. ID: 1,
  175. SNRDb: 10,
  176. BandwidthHz: 12000,
  177. Evidence: []LevelEvidence{
  178. {Level: AnalysisLevel{Name: "surveillance", Role: "surveillance", Truth: "surveillance"}},
  179. },
  180. }
  181. derived := Candidate{
  182. ID: 2,
  183. SNRDb: 10,
  184. BandwidthHz: 12000,
  185. Evidence: []LevelEvidence{
  186. {Level: AnalysisLevel{Name: "surveillance-lowres", Role: "surveillance-lowres", Truth: "surveillance"}},
  187. },
  188. }
  189. plan := BuildRefinementPlan([]Candidate{derived, primary}, policy)
  190. if len(plan.Ranked) != 2 {
  191. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  192. }
  193. if plan.Ranked[0].Candidate.ID != primary.ID {
  194. t.Fatalf("expected primary evidence to outrank derived-only, got %+v", plan.Ranked[0])
  195. }
  196. }
  197. func TestScheduleCandidatesDerivedOnlyStrategyBias(t *testing.T) {
  198. cand := Candidate{
  199. ID: 1,
  200. SNRDb: 9,
  201. BandwidthHz: 12000,
  202. Evidence: []LevelEvidence{
  203. {Level: AnalysisLevel{Name: "surveillance-lowres", Role: "surveillance-lowres", Truth: "surveillance"}},
  204. },
  205. }
  206. singlePlan := BuildRefinementPlan([]Candidate{cand}, Policy{MinCandidateSNRDb: 0})
  207. multiPlan := BuildRefinementPlan([]Candidate{cand}, Policy{MinCandidateSNRDb: 0, SurveillanceStrategy: "multi-resolution"})
  208. if len(singlePlan.Ranked) == 0 || len(multiPlan.Ranked) == 0 {
  209. t.Fatalf("expected ranked candidates in both plans")
  210. }
  211. singleScore := singlePlan.Ranked[0].Breakdown.EvidenceScore
  212. multiScore := multiPlan.Ranked[0].Breakdown.EvidenceScore
  213. if multiScore <= singleScore {
  214. t.Fatalf("expected multi-resolution strategy to improve derived-only evidence score, got %.3f vs %.3f", multiScore, singleScore)
  215. }
  216. if multiPlan.Ranked[0].Breakdown.EvidenceDetail == nil || multiPlan.Ranked[0].Breakdown.EvidenceDetail.StrategyBias <= 0 {
  217. t.Fatalf("expected strategy bias detail for multi-resolution, got %+v", multiPlan.Ranked[0].Breakdown.EvidenceDetail)
  218. }
  219. }
  220. func TestBuildRefinementPlanPriorityStats(t *testing.T) {
  221. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0}
  222. cands := []Candidate{
  223. {ID: 1, CenterHz: 100, SNRDb: 8, BandwidthHz: 10000, PeakDb: 2},
  224. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 20000, PeakDb: 4},
  225. }
  226. plan := BuildRefinementPlan(cands, policy)
  227. if plan.PriorityMax < plan.PriorityMin {
  228. t.Fatalf("priority bounds invalid: %+v", plan)
  229. }
  230. res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}})
  231. if len(res.Plan.Selected) != 1 {
  232. t.Fatalf("expected 1 admitted, got %d", len(res.Plan.Selected))
  233. }
  234. if res.Plan.PriorityCutoff != res.Plan.Selected[0].Priority {
  235. t.Fatalf("expected cutoff to match selection, got %.2f vs %.2f", res.Plan.PriorityCutoff, res.Plan.Selected[0].Priority)
  236. }
  237. if res.Plan.Selected[0].Breakdown == nil {
  238. t.Fatalf("expected breakdown on selected candidate")
  239. }
  240. if res.Plan.Selected[0].Score == nil || res.Plan.Selected[0].Score.Total == 0 {
  241. t.Fatalf("expected score on selected candidate")
  242. }
  243. }
  244. func TestBuildRefinementPlanStrategyBias(t *testing.T) {
  245. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, Intent: "archive-and-triage"}
  246. cands := []Candidate{
  247. {ID: 1, CenterHz: 100, SNRDb: 12, BandwidthHz: 5000, PeakDb: 1},
  248. {ID: 2, CenterHz: 200, SNRDb: 11, BandwidthHz: 100000, PeakDb: 1},
  249. }
  250. plan := BuildRefinementPlan(cands, policy)
  251. if len(plan.Ranked) != 2 {
  252. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  253. }
  254. if plan.Ranked[0].Candidate.ID != 2 {
  255. t.Fatalf("expected archive-oriented strategy to favor wider candidate, got %+v", plan.Ranked[0])
  256. }
  257. }
  258. func TestAdmitRefinementPlanAppliesBudget(t *testing.T) {
  259. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 10}
  260. cands := []Candidate{
  261. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  262. {ID: 3, CenterHz: 300, SNRDb: 11, BandwidthHz: 25000, PeakDb: 2},
  263. }
  264. plan := BuildRefinementPlan(cands, policy)
  265. res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}})
  266. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 2 {
  267. t.Fatalf("unexpected admission selection: %+v", res.Plan.Selected)
  268. }
  269. if res.Plan.DroppedByBudget != 1 {
  270. t.Fatalf("expected 1 dropped by budget, got %d", res.Plan.DroppedByBudget)
  271. }
  272. item2 := findWorkItem(res.WorkItems, 2)
  273. if item2 == nil || item2.Status != RefinementStatusAdmitted {
  274. t.Fatalf("expected candidate 2 admitted, got %+v", item2)
  275. }
  276. item3 := findWorkItem(res.WorkItems, 3)
  277. if item3 == nil || item3.Status != RefinementStatusSkipped {
  278. t.Fatalf("expected candidate 3 skipped, got %+v", item3)
  279. }
  280. }
  281. func TestAdmitRefinementPlanDisplacedByHold(t *testing.T) {
  282. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0}
  283. cands := []Candidate{
  284. {ID: 1, CenterHz: 100, SNRDb: 5},
  285. {ID: 2, CenterHz: 200, SNRDb: 12},
  286. }
  287. plan := BuildRefinementPlan(cands, policy)
  288. hold := &RefinementHold{Active: map[int64]time.Time{1: time.Now().Add(2 * time.Second)}}
  289. res := AdmitRefinementPlan(plan, policy, time.Now(), hold)
  290. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 1 {
  291. t.Fatalf("expected held candidate to remain admitted, got %+v", res.Plan.Selected)
  292. }
  293. item2 := findWorkItem(res.WorkItems, 2)
  294. if item2 == nil || item2.Status != RefinementStatusDisplaced {
  295. t.Fatalf("expected higher priority candidate displaced, got %+v", item2)
  296. }
  297. }
  298. func TestRefinementStrategyUsesProfile(t *testing.T) {
  299. strategy, reason := refinementStrategy(Policy{Profile: "digital-hunting"})
  300. if strategy != "digital-hunting" || reason != "profile" {
  301. t.Fatalf("expected digital profile to set strategy, got %s (%s)", strategy, reason)
  302. }
  303. strategy, reason = refinementStrategy(Policy{Profile: "archive"})
  304. if strategy != "archive-oriented" || reason != "profile" {
  305. t.Fatalf("expected archive profile to set strategy, got %s (%s)", strategy, reason)
  306. }
  307. }
  308. func TestRefinementStrategyUsesIntentAndSurveillance(t *testing.T) {
  309. strategy, reason := refinementStrategy(Policy{Intent: "decode-digital"})
  310. if strategy != "digital-hunting" || reason != "intent" {
  311. t.Fatalf("expected intent to set digital strategy, got %s (%s)", strategy, reason)
  312. }
  313. strategy, reason = refinementStrategy(Policy{Intent: "archive-and-triage"})
  314. if strategy != "archive-oriented" || reason != "intent" {
  315. t.Fatalf("expected intent to set archive strategy, got %s (%s)", strategy, reason)
  316. }
  317. strategy, reason = refinementStrategy(Policy{SurveillanceStrategy: "multi-resolution"})
  318. if strategy != "multi-resolution" || reason != "surveillance-strategy" {
  319. t.Fatalf("expected surveillance strategy to set multi-resolution, got %s (%s)", strategy, reason)
  320. }
  321. }
  322. func findWorkItem(items []RefinementWorkItem, id int64) *RefinementWorkItem {
  323. for i := range items {
  324. if items[i].Candidate.ID == id {
  325. return &items[i]
  326. }
  327. }
  328. return nil
  329. }