Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

420 lignes
16KB

  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 TestScheduleCandidatesFamilyTierFloor(t *testing.T) {
  140. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital", "wfm"}}
  141. cands := []Candidate{
  142. {ID: 1, SNRDb: 1, Hint: "digital-burst"},
  143. {ID: 2, SNRDb: 20, Hint: "voice"},
  144. }
  145. plan := BuildRefinementPlan(cands, policy)
  146. item := findScheduled(plan.Ranked, 1)
  147. if item == nil {
  148. t.Fatalf("expected ranked candidate 1")
  149. }
  150. if item.Family != "digital" || item.FamilyRank != 1 {
  151. t.Fatalf("expected digital family rank 1, got family=%s rank=%d", item.Family, item.FamilyRank)
  152. }
  153. if item.TierFloor != PriorityTierHigh {
  154. t.Fatalf("expected tier floor high, got %s", item.TierFloor)
  155. }
  156. if priorityTierRank(item.Tier) < priorityTierRank(PriorityTierHigh) {
  157. t.Fatalf("expected tier to be raised by family floor, got %s", item.Tier)
  158. }
  159. }
  160. func TestScheduleCandidatesEvidenceBoost(t *testing.T) {
  161. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 0}
  162. single := Candidate{
  163. ID: 1,
  164. SNRDb: 8,
  165. BandwidthHz: 12000,
  166. Evidence: []LevelEvidence{
  167. {Level: AnalysisLevel{Name: "surveillance"}},
  168. },
  169. }
  170. multi := Candidate{
  171. ID: 2,
  172. SNRDb: 8,
  173. BandwidthHz: 12000,
  174. Evidence: []LevelEvidence{
  175. {Level: AnalysisLevel{Name: "surveillance"}},
  176. {Level: AnalysisLevel{Name: "surveillance-lowres"}},
  177. },
  178. }
  179. plan := BuildRefinementPlan([]Candidate{single, multi}, policy)
  180. if len(plan.Ranked) < 2 {
  181. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  182. }
  183. if plan.Ranked[0].Candidate.ID != multi.ID {
  184. t.Fatalf("expected multi-level candidate to rank first, got %+v", plan.Ranked[0])
  185. }
  186. if plan.Ranked[0].Breakdown == nil || plan.Ranked[0].Breakdown.EvidenceScore <= 0 {
  187. t.Fatalf("expected evidence score to be populated, got %+v", plan.Ranked[0].Breakdown)
  188. }
  189. if plan.Ranked[0].Breakdown.EvidenceDetail == nil || !plan.Ranked[0].Breakdown.EvidenceDetail.MultiLevelConfirmed {
  190. t.Fatalf("expected evidence detail for multi-level candidate, got %+v", plan.Ranked[0].Breakdown)
  191. }
  192. }
  193. func TestScheduleCandidatesDerivedOnlyPenalty(t *testing.T) {
  194. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 0}
  195. primary := Candidate{
  196. ID: 1,
  197. SNRDb: 10,
  198. BandwidthHz: 12000,
  199. Evidence: []LevelEvidence{
  200. {Level: AnalysisLevel{Name: "surveillance", Role: RoleSurveillancePrimary, Truth: "surveillance"}},
  201. },
  202. }
  203. derived := Candidate{
  204. ID: 2,
  205. SNRDb: 10,
  206. BandwidthHz: 12000,
  207. Evidence: []LevelEvidence{
  208. {Level: AnalysisLevel{Name: "surveillance-lowres", Role: RoleSurveillanceDerived, Truth: "surveillance"}},
  209. },
  210. }
  211. plan := BuildRefinementPlan([]Candidate{derived, primary}, policy)
  212. if len(plan.Ranked) != 2 {
  213. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  214. }
  215. if plan.Ranked[0].Candidate.ID != primary.ID {
  216. t.Fatalf("expected primary evidence to outrank derived-only, got %+v", plan.Ranked[0])
  217. }
  218. }
  219. func TestScheduleCandidatesDerivedOnlyStrategyBias(t *testing.T) {
  220. cand := Candidate{
  221. ID: 1,
  222. SNRDb: 9,
  223. BandwidthHz: 12000,
  224. Evidence: []LevelEvidence{
  225. {Level: AnalysisLevel{Name: "surveillance-lowres", Role: RoleSurveillanceDerived, Truth: "surveillance"}},
  226. },
  227. }
  228. singlePlan := BuildRefinementPlan([]Candidate{cand}, Policy{MinCandidateSNRDb: 0})
  229. multiPlan := BuildRefinementPlan([]Candidate{cand}, Policy{MinCandidateSNRDb: 0, SurveillanceStrategy: "multi-resolution"})
  230. if len(singlePlan.Ranked) == 0 || len(multiPlan.Ranked) == 0 {
  231. t.Fatalf("expected ranked candidates in both plans")
  232. }
  233. singleScore := singlePlan.Ranked[0].Breakdown.EvidenceScore
  234. multiScore := multiPlan.Ranked[0].Breakdown.EvidenceScore
  235. if multiScore <= singleScore {
  236. t.Fatalf("expected multi-resolution strategy to improve derived-only evidence score, got %.3f vs %.3f", multiScore, singleScore)
  237. }
  238. if multiPlan.Ranked[0].Breakdown.EvidenceDetail == nil || multiPlan.Ranked[0].Breakdown.EvidenceDetail.StrategyBias <= 0 {
  239. t.Fatalf("expected strategy bias detail for multi-resolution, got %+v", multiPlan.Ranked[0].Breakdown.EvidenceDetail)
  240. }
  241. }
  242. func TestBuildRefinementPlanPriorityStats(t *testing.T) {
  243. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0}
  244. cands := []Candidate{
  245. {ID: 1, CenterHz: 100, SNRDb: 8, BandwidthHz: 10000, PeakDb: 2},
  246. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 20000, PeakDb: 4},
  247. }
  248. plan := BuildRefinementPlan(cands, policy)
  249. if plan.PriorityMax < plan.PriorityMin {
  250. t.Fatalf("priority bounds invalid: %+v", plan)
  251. }
  252. res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}})
  253. if len(res.Plan.Selected) != 1 {
  254. t.Fatalf("expected 1 admitted, got %d", len(res.Plan.Selected))
  255. }
  256. if res.Plan.PriorityCutoff != res.Plan.Selected[0].Priority {
  257. t.Fatalf("expected cutoff to match selection, got %.2f vs %.2f", res.Plan.PriorityCutoff, res.Plan.Selected[0].Priority)
  258. }
  259. if res.Plan.Selected[0].Breakdown == nil {
  260. t.Fatalf("expected breakdown on selected candidate")
  261. }
  262. if res.Plan.Selected[0].Score == nil || res.Plan.Selected[0].Score.Total == 0 {
  263. t.Fatalf("expected score on selected candidate")
  264. }
  265. }
  266. func TestBuildRefinementPlanStrategyBias(t *testing.T) {
  267. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, Intent: "archive-and-triage"}
  268. cands := []Candidate{
  269. {ID: 1, CenterHz: 100, SNRDb: 12, BandwidthHz: 5000, PeakDb: 1},
  270. {ID: 2, CenterHz: 200, SNRDb: 11, BandwidthHz: 100000, PeakDb: 1},
  271. }
  272. plan := BuildRefinementPlan(cands, policy)
  273. if len(plan.Ranked) != 2 {
  274. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  275. }
  276. if plan.Ranked[0].Candidate.ID != 2 {
  277. t.Fatalf("expected archive-oriented strategy to favor wider candidate, got %+v", plan.Ranked[0])
  278. }
  279. }
  280. func TestAdmitRefinementPlanAppliesBudget(t *testing.T) {
  281. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 10}
  282. cands := []Candidate{
  283. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  284. {ID: 3, CenterHz: 300, SNRDb: 11, BandwidthHz: 25000, PeakDb: 2},
  285. }
  286. plan := BuildRefinementPlan(cands, policy)
  287. res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}})
  288. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 2 {
  289. t.Fatalf("unexpected admission selection: %+v", res.Plan.Selected)
  290. }
  291. if res.Plan.DroppedByBudget != 1 {
  292. t.Fatalf("expected 1 dropped by budget, got %d", res.Plan.DroppedByBudget)
  293. }
  294. item2 := findWorkItem(res.WorkItems, 2)
  295. if item2 == nil || item2.Status != RefinementStatusAdmitted {
  296. t.Fatalf("expected candidate 2 admitted, got %+v", item2)
  297. }
  298. if item2.Admission == nil || item2.Admission.Class != AdmissionClassAdmit || item2.Admission.Tier == "" {
  299. t.Fatalf("expected admission class/tier on admitted item, got %+v", item2.Admission)
  300. }
  301. item3 := findWorkItem(res.WorkItems, 3)
  302. if item3 == nil || item3.Status != RefinementStatusSkipped {
  303. t.Fatalf("expected candidate 3 skipped, got %+v", item3)
  304. }
  305. if item3.Admission == nil || item3.Admission.Class != AdmissionClassDefer {
  306. t.Fatalf("expected deferred admission class on skipped item, got %+v", item3.Admission)
  307. }
  308. }
  309. func TestAdmitRefinementPlanDisplacedByHold(t *testing.T) {
  310. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, DecisionHoldMs: 500}
  311. cands := []Candidate{
  312. {ID: 1, CenterHz: 100, SNRDb: 9},
  313. {ID: 2, CenterHz: 200, SNRDb: 12},
  314. {ID: 3, CenterHz: 300, SNRDb: 2},
  315. }
  316. plan := BuildRefinementPlan(cands, policy)
  317. hold := &RefinementHold{Active: map[int64]time.Time{1: time.Now().Add(2 * time.Second)}}
  318. res := AdmitRefinementPlan(plan, policy, time.Now(), hold)
  319. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 1 {
  320. t.Fatalf("expected held candidate to remain admitted, got %+v", res.Plan.Selected)
  321. }
  322. item2 := findWorkItem(res.WorkItems, 2)
  323. if item2 == nil || item2.Status != RefinementStatusDisplaced {
  324. t.Fatalf("expected higher priority candidate displaced, got %+v", item2)
  325. }
  326. if item2.Admission == nil || item2.Admission.Class != AdmissionClassDisplace {
  327. t.Fatalf("expected displaced admission class, got %+v", item2.Admission)
  328. }
  329. if res.Admission.DisplacedByHold != 1 || res.Admission.Displaced != 1 {
  330. t.Fatalf("expected displaced-by-hold count 1, got %+v", res.Admission)
  331. }
  332. if res.Admission.DecisionHoldMs != policy.DecisionHoldMs {
  333. t.Fatalf("expected decision hold ms %d, got %d", policy.DecisionHoldMs, res.Admission.DecisionHoldMs)
  334. }
  335. }
  336. func TestAdmitRefinementPlanOpportunisticDisplacement(t *testing.T) {
  337. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, DecisionHoldMs: 500}
  338. cands := []Candidate{
  339. {ID: 1, CenterHz: 100, SNRDb: 5},
  340. {ID: 2, CenterHz: 200, SNRDb: 25},
  341. }
  342. plan := BuildRefinementPlan(cands, policy)
  343. hold := &RefinementHold{Active: map[int64]time.Time{1: time.Now().Add(2 * time.Second)}}
  344. res := AdmitRefinementPlan(plan, policy, time.Now(), hold)
  345. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 2 {
  346. t.Fatalf("expected opportunistic displacement to admit candidate 2, got %+v", res.Plan.Selected)
  347. }
  348. item1 := findWorkItem(res.WorkItems, 1)
  349. if item1 == nil || item1.Status != RefinementStatusDisplaced {
  350. t.Fatalf("expected candidate 1 displaced, got %+v", item1)
  351. }
  352. if item1.Admission == nil || item1.Admission.Class != AdmissionClassDisplace {
  353. t.Fatalf("expected displaced admission class, got %+v", item1.Admission)
  354. }
  355. if item1.Admission == nil || !strings.Contains(item1.Admission.Reason, ReasonTagDisplaceOpportunist) {
  356. t.Fatalf("expected opportunistic displacement reason, got %+v", item1.Admission)
  357. }
  358. }
  359. func TestRefinementStrategyUsesProfile(t *testing.T) {
  360. strategy, reason := refinementStrategy(Policy{Profile: "digital-hunting"})
  361. if strategy != "digital-hunting" || reason != "profile" {
  362. t.Fatalf("expected digital profile to set strategy, got %s (%s)", strategy, reason)
  363. }
  364. strategy, reason = refinementStrategy(Policy{Profile: "archive"})
  365. if strategy != "archive-oriented" || reason != "profile" {
  366. t.Fatalf("expected archive profile to set strategy, got %s (%s)", strategy, reason)
  367. }
  368. }
  369. func TestRefinementStrategyUsesIntentAndSurveillance(t *testing.T) {
  370. strategy, reason := refinementStrategy(Policy{Intent: "decode-digital"})
  371. if strategy != "digital-hunting" || reason != "intent" {
  372. t.Fatalf("expected intent to set digital strategy, got %s (%s)", strategy, reason)
  373. }
  374. strategy, reason = refinementStrategy(Policy{Intent: "archive-and-triage"})
  375. if strategy != "archive-oriented" || reason != "intent" {
  376. t.Fatalf("expected intent to set archive strategy, got %s (%s)", strategy, reason)
  377. }
  378. strategy, reason = refinementStrategy(Policy{SurveillanceStrategy: "multi-resolution"})
  379. if strategy != "multi-resolution" || reason != "surveillance-strategy" {
  380. t.Fatalf("expected surveillance strategy to set multi-resolution, got %s (%s)", strategy, reason)
  381. }
  382. }
  383. func findWorkItem(items []RefinementWorkItem, id int64) *RefinementWorkItem {
  384. for i := range items {
  385. if items[i].Candidate.ID == id {
  386. return &items[i]
  387. }
  388. }
  389. return nil
  390. }
  391. func findScheduled(items []ScheduledCandidate, id int64) *ScheduledCandidate {
  392. for i := range items {
  393. if items[i].Candidate.ID == id {
  394. return &items[i]
  395. }
  396. }
  397. return nil
  398. }