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.

253 lines
10KB

  1. package pipeline
  2. import (
  3. "strings"
  4. "testing"
  5. "time"
  6. )
  7. func TestDecisionQueueDropsByBudget(t *testing.T) {
  8. arbiter := NewArbiter()
  9. decisions := []SignalDecision{
  10. {Candidate: Candidate{ID: 1, SNRDb: 12}, ShouldRecord: true, ShouldAutoDecode: true},
  11. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: true},
  12. }
  13. budget := BudgetModel{
  14. Record: BudgetQueue{Max: 1},
  15. Decode: BudgetQueue{Max: 1},
  16. }
  17. stats := arbiter.ApplyDecisions(decisions, budget, time.Now(), Policy{DecisionHoldMs: 250})
  18. if stats.RecordDropped == 0 || stats.DecodeDropped == 0 {
  19. t.Fatalf("expected drops by budget, got %+v", stats)
  20. }
  21. allowed := 0
  22. for _, d := range decisions {
  23. if d.ShouldRecord || d.ShouldAutoDecode {
  24. allowed++
  25. continue
  26. }
  27. if !strings.HasPrefix(d.Reason, DecisionReasonQueueRecord) && !strings.HasPrefix(d.Reason, DecisionReasonQueueDecode) {
  28. t.Fatalf("unexpected decision reason: %s", d.Reason)
  29. }
  30. }
  31. if allowed != 1 {
  32. t.Fatalf("expected 1 decision allowed, got %d", allowed)
  33. }
  34. }
  35. func TestDecisionQueueEnforcesBudgets(t *testing.T) {
  36. decisions := []SignalDecision{
  37. {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true},
  38. {Candidate: Candidate{ID: 2, SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true},
  39. {Candidate: Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false},
  40. }
  41. arbiter := NewArbiter()
  42. policy := Policy{SignalPriorities: []string{"digital"}, MaxRecordingStreams: 1, MaxDecodeJobs: 1}
  43. budget := BudgetModelFromPolicy(policy)
  44. stats := arbiter.ApplyDecisions(decisions, budget, time.Now(), policy)
  45. if stats.RecordSelected != 1 || stats.DecodeSelected != 1 {
  46. t.Fatalf("unexpected counts: record=%d decode=%d", stats.RecordSelected, stats.DecodeSelected)
  47. }
  48. if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode {
  49. t.Fatalf("expected highest SNR decision to remain allowed")
  50. }
  51. if decisions[0].ShouldRecord || decisions[0].ShouldAutoDecode {
  52. t.Fatalf("expected lowest SNR decision to be budgeted off")
  53. }
  54. if decisions[2].ShouldRecord {
  55. t.Fatalf("expected mid SNR decision to be budgeted off by record budget")
  56. }
  57. if decisions[1].RecordAdmission == nil || decisions[1].RecordAdmission.Class != AdmissionClassAdmit {
  58. t.Fatalf("expected admitted record admission, got %+v", decisions[1].RecordAdmission)
  59. }
  60. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.Class != AdmissionClassDefer {
  61. t.Fatalf("expected deferred record admission, got %+v", decisions[0].RecordAdmission)
  62. }
  63. }
  64. func TestDecisionQueueHoldKeepsSelection(t *testing.T) {
  65. arbiter := NewArbiter()
  66. policy := Policy{DecisionHoldMs: 500}
  67. budget := BudgetModel{Record: BudgetQueue{Max: 1}, Decode: BudgetQueue{Max: 1}}
  68. now := time.Now()
  69. decisions := []SignalDecision{
  70. {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true},
  71. {Candidate: Candidate{ID: 2, SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true},
  72. }
  73. arbiter.ApplyDecisions(decisions, budget, now, policy)
  74. if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode {
  75. t.Fatalf("expected candidate 2 to be selected initially")
  76. }
  77. decisions = []SignalDecision{
  78. {Candidate: Candidate{ID: 1, SNRDb: 32}, ShouldRecord: true, ShouldAutoDecode: true},
  79. {Candidate: Candidate{ID: 2, SNRDb: 30}, ShouldRecord: true, ShouldAutoDecode: true},
  80. {Candidate: Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: true},
  81. }
  82. stats := arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  83. if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode {
  84. t.Fatalf("expected held candidate 2 to remain selected")
  85. }
  86. if decisions[0].ShouldRecord || decisions[0].ShouldAutoDecode {
  87. t.Fatalf("expected candidate 1 to remain queued behind hold")
  88. }
  89. if decisions[1].RecordAdmission == nil || decisions[1].RecordAdmission.Class != AdmissionClassHold {
  90. t.Fatalf("expected record admission hold class, got %+v", decisions[1].RecordAdmission)
  91. }
  92. if stats.DecisionHoldMs != policy.DecisionHoldMs {
  93. t.Fatalf("expected decision hold ms %d, got %d", policy.DecisionHoldMs, stats.DecisionHoldMs)
  94. }
  95. if stats.RecordDisplacedByHold != 1 || stats.RecordDisplaced != 1 {
  96. t.Fatalf("expected displaced-by-hold count 1, got %+v", stats)
  97. }
  98. }
  99. func TestDecisionQueueHighTierHoldProtected(t *testing.T) {
  100. arbiter := NewArbiter()
  101. policy := Policy{DecisionHoldMs: 500}
  102. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  103. now := time.Now()
  104. decisions := []SignalDecision{
  105. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  106. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  107. }
  108. arbiter.ApplyDecisions(decisions, budget, now, policy)
  109. if !decisions[0].ShouldRecord {
  110. t.Fatalf("expected candidate 1 to be selected initially")
  111. }
  112. decisions = []SignalDecision{
  113. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  114. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  115. {Candidate: Candidate{ID: 3, SNRDb: 32}, ShouldRecord: true},
  116. }
  117. arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  118. if !decisions[0].ShouldRecord {
  119. t.Fatalf("expected protected hold to keep candidate 1")
  120. }
  121. if decisions[2].ShouldRecord {
  122. t.Fatalf("expected candidate 3 to remain deferred behind protected hold")
  123. }
  124. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.Class != AdmissionClassHold {
  125. t.Fatalf("expected hold admission for candidate 1, got %+v", decisions[0].RecordAdmission)
  126. }
  127. if decisions[2].RecordAdmission == nil || decisions[2].RecordAdmission.Class != AdmissionClassDisplace {
  128. t.Fatalf("expected displacement admission for candidate 3, got %+v", decisions[2].RecordAdmission)
  129. }
  130. }
  131. func TestDecisionQueueFamilyPriorityProtectsHold(t *testing.T) {
  132. arbiter := NewArbiter()
  133. policy := Policy{DecisionHoldMs: 500, SignalPriorities: []string{"digital"}}
  134. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  135. now := time.Now()
  136. decisions := []SignalDecision{
  137. {Candidate: Candidate{ID: 1, SNRDb: 5, Hint: "digital"}, ShouldRecord: true},
  138. }
  139. arbiter.ApplyDecisions(decisions, budget, now, policy)
  140. if !decisions[0].ShouldRecord {
  141. t.Fatalf("expected candidate 1 to be selected initially")
  142. }
  143. decisions = []SignalDecision{
  144. {Candidate: Candidate{ID: 1, SNRDb: 5, Hint: "digital"}, ShouldRecord: true},
  145. {Candidate: Candidate{ID: 2, SNRDb: 35, Hint: "voice"}, ShouldRecord: true},
  146. }
  147. arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  148. if !decisions[0].ShouldRecord {
  149. t.Fatalf("expected family-priority hold to keep candidate 1")
  150. }
  151. if decisions[1].ShouldRecord {
  152. t.Fatalf("expected candidate 2 to remain deferred behind family hold")
  153. }
  154. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.FamilyRank != 1 {
  155. t.Fatalf("expected family rank on admission, got %+v", decisions[0].RecordAdmission)
  156. }
  157. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.TierFloor != PriorityTierHigh {
  158. t.Fatalf("expected tier floor on admission, got %+v", decisions[0].RecordAdmission)
  159. }
  160. }
  161. func TestDecisionQueueOpportunisticDisplacement(t *testing.T) {
  162. arbiter := NewArbiter()
  163. policy := Policy{DecisionHoldMs: 500}
  164. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  165. now := time.Now()
  166. decisions := []SignalDecision{
  167. {Candidate: Candidate{ID: 1, SNRDb: 15}, ShouldRecord: true},
  168. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  169. }
  170. arbiter.ApplyDecisions(decisions, budget, now, policy)
  171. if !decisions[0].ShouldRecord {
  172. t.Fatalf("expected candidate 1 to be selected initially")
  173. }
  174. decisions = []SignalDecision{
  175. {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true},
  176. {Candidate: Candidate{ID: 2, SNRDb: 4}, ShouldRecord: true},
  177. {Candidate: Candidate{ID: 3, SNRDb: 30}, ShouldRecord: true},
  178. }
  179. arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  180. if decisions[0].ShouldRecord {
  181. t.Fatalf("expected candidate 1 to be displaced")
  182. }
  183. if !decisions[2].ShouldRecord {
  184. t.Fatalf("expected candidate 3 to opportunistically displace hold")
  185. }
  186. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.Class != AdmissionClassDisplace {
  187. t.Fatalf("expected displacement admission for candidate 1, got %+v", decisions[0].RecordAdmission)
  188. }
  189. if decisions[2].RecordAdmission == nil || decisions[2].RecordAdmission.Class != AdmissionClassAdmit {
  190. t.Fatalf("expected admit admission for candidate 3, got %+v", decisions[2].RecordAdmission)
  191. }
  192. if decisions[2].RecordAdmission == nil || !strings.Contains(decisions[2].RecordAdmission.Reason, ReasonTagDisplaceOpportunist) {
  193. t.Fatalf("expected opportunistic displacement reason, got %+v", decisions[2].RecordAdmission)
  194. }
  195. }
  196. func TestDecisionQueueHoldExpiryChurn(t *testing.T) {
  197. arbiter := NewArbiter()
  198. policy := Policy{DecisionHoldMs: 100}
  199. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  200. now := time.Now()
  201. decisions := []SignalDecision{
  202. {Candidate: Candidate{ID: 1, SNRDb: 12}, ShouldRecord: true},
  203. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  204. }
  205. arbiter.ApplyDecisions(decisions, budget, now, policy)
  206. if !decisions[0].ShouldRecord {
  207. t.Fatalf("expected candidate 1 to be selected initially")
  208. }
  209. decisions = []SignalDecision{
  210. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  211. {Candidate: Candidate{ID: 2, SNRDb: 32}, ShouldRecord: true},
  212. {Candidate: Candidate{ID: 3, SNRDb: 5}, ShouldRecord: true},
  213. }
  214. arbiter.ApplyDecisions(decisions, budget, now.Add(50*time.Millisecond), policy)
  215. if !decisions[0].ShouldRecord {
  216. t.Fatalf("expected hold to keep candidate 1 before expiry")
  217. }
  218. decisions = []SignalDecision{
  219. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  220. {Candidate: Candidate{ID: 2, SNRDb: 32}, ShouldRecord: true},
  221. {Candidate: Candidate{ID: 3, SNRDb: 5}, ShouldRecord: true},
  222. }
  223. arbiter.ApplyDecisions(decisions, budget, now.Add(200*time.Millisecond), policy)
  224. if decisions[0].ShouldRecord {
  225. t.Fatalf("expected candidate 1 to be released after hold expiry")
  226. }
  227. if !decisions[1].ShouldRecord {
  228. t.Fatalf("expected candidate 2 to be selected after hold expiry")
  229. }
  230. if decisions[0].RecordAdmission == nil || !strings.Contains(decisions[0].RecordAdmission.Reason, ReasonTagHoldExpired) {
  231. t.Fatalf("expected hold expiry reason, got %+v", decisions[0].RecordAdmission)
  232. }
  233. }