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.

389 lines
15KB

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