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.

330 lines
11KB

  1. package pipeline
  2. import (
  3. "sort"
  4. "strings"
  5. )
  6. type ScheduledCandidate struct {
  7. Candidate Candidate `json:"candidate"`
  8. Priority float64 `json:"priority"`
  9. Score *RefinementScore `json:"score,omitempty"`
  10. Breakdown *RefinementScoreDetails `json:"breakdown,omitempty"`
  11. }
  12. type RefinementScoreModel struct {
  13. SNRWeight float64 `json:"snr_weight"`
  14. BandwidthWeight float64 `json:"bandwidth_weight"`
  15. PeakWeight float64 `json:"peak_weight"`
  16. EvidenceWeight float64 `json:"evidence_weight"`
  17. }
  18. type RefinementScoreDetails struct {
  19. SNRScore float64 `json:"snr_score"`
  20. BandwidthScore float64 `json:"bandwidth_score"`
  21. PeakScore float64 `json:"peak_score"`
  22. PolicyBoost float64 `json:"policy_boost"`
  23. EvidenceScore float64 `json:"evidence_score"`
  24. EvidenceDetail *EvidenceScoreDetails `json:"evidence_detail,omitempty"`
  25. }
  26. type RefinementScore struct {
  27. Total float64 `json:"total"`
  28. Breakdown RefinementScoreDetails `json:"breakdown"`
  29. Weights *RefinementScoreModel `json:"weights,omitempty"`
  30. }
  31. type RefinementWorkItem struct {
  32. Candidate Candidate `json:"candidate"`
  33. Window RefinementWindow `json:"window,omitempty"`
  34. Execution *RefinementExecution `json:"execution,omitempty"`
  35. Priority float64 `json:"priority,omitempty"`
  36. Score *RefinementScore `json:"score,omitempty"`
  37. Breakdown *RefinementScoreDetails `json:"breakdown,omitempty"`
  38. Status string `json:"status,omitempty"`
  39. Reason string `json:"reason,omitempty"`
  40. }
  41. type RefinementExecution struct {
  42. Stage string `json:"stage,omitempty"`
  43. SampleRate int `json:"sample_rate,omitempty"`
  44. FFTSize int `json:"fft_size,omitempty"`
  45. CenterHz float64 `json:"center_hz,omitempty"`
  46. SpanHz float64 `json:"span_hz,omitempty"`
  47. Source string `json:"source,omitempty"`
  48. }
  49. const (
  50. RefinementStatusPlanned = "planned"
  51. RefinementStatusAdmitted = "admitted"
  52. RefinementStatusRunning = "running"
  53. RefinementStatusCompleted = "completed"
  54. RefinementStatusDropped = "dropped"
  55. RefinementStatusSkipped = "skipped"
  56. RefinementStatusDisplaced = "displaced"
  57. )
  58. const (
  59. RefinementReasonPlanned = "refinement:planned"
  60. RefinementReasonAdmitted = "refinement:admitted"
  61. RefinementReasonRunning = "refinement:running"
  62. RefinementReasonCompleted = "refinement:completed"
  63. RefinementReasonMonitorGate = "refinement:drop:monitor"
  64. RefinementReasonBelowSNR = "refinement:drop:snr"
  65. RefinementReasonBudget = "refinement:skip:budget"
  66. RefinementReasonDisabled = "refinement:drop:disabled"
  67. RefinementReasonUnclassified = "refinement:drop:unclassified"
  68. RefinementReasonDisplaced = "refinement:skip:displaced"
  69. )
  70. // BuildRefinementPlan scores and ranks candidates for costly local refinement.
  71. // Admission/budget enforcement is handled by arbitration to keep refinement/record/decode consistent.
  72. // Current heuristic is intentionally simple and deterministic; later phases can add
  73. // richer scoring (novelty, persistence, profile-aware band priorities, decoder value).
  74. func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan {
  75. strategy, strategyReason := refinementStrategy(policy)
  76. budgetModel := BudgetModelFromPolicy(policy)
  77. budget := budgetModel.Refinement.Max
  78. plan := RefinementPlan{
  79. TotalCandidates: len(candidates),
  80. MinCandidateSNRDb: policy.MinCandidateSNRDb,
  81. Budget: budget,
  82. BudgetSource: budgetModel.Refinement.Source,
  83. Strategy: strategy,
  84. StrategyReason: strategyReason,
  85. }
  86. if start, end, ok := monitorBounds(policy); ok {
  87. plan.MonitorStartHz = start
  88. plan.MonitorEndHz = end
  89. if end > start {
  90. plan.MonitorSpanHz = end - start
  91. }
  92. }
  93. if len(candidates) == 0 {
  94. return plan
  95. }
  96. snrWeight, bwWeight, peakWeight := refinementIntentWeights(policy.Intent)
  97. scoreModel := RefinementScoreModel{
  98. SNRWeight: snrWeight,
  99. BandwidthWeight: bwWeight,
  100. PeakWeight: peakWeight,
  101. EvidenceWeight: 0.6,
  102. }
  103. scoreModel = applyStrategyWeights(strategy, scoreModel)
  104. plan.ScoreModel = scoreModel
  105. scored := make([]ScheduledCandidate, 0, len(candidates))
  106. workItems := make([]RefinementWorkItem, 0, len(candidates))
  107. for _, c := range candidates {
  108. candidate := c
  109. RefreshCandidateEvidenceState(&candidate)
  110. if !candidateInMonitor(policy, candidate) {
  111. plan.DroppedByMonitor++
  112. workItems = append(workItems, RefinementWorkItem{
  113. Candidate: candidate,
  114. Status: RefinementStatusDropped,
  115. Reason: RefinementReasonMonitorGate,
  116. })
  117. continue
  118. }
  119. if candidate.SNRDb < policy.MinCandidateSNRDb {
  120. plan.DroppedBySNR++
  121. workItems = append(workItems, RefinementWorkItem{
  122. Candidate: candidate,
  123. Status: RefinementStatusDropped,
  124. Reason: RefinementReasonBelowSNR,
  125. })
  126. continue
  127. }
  128. snrScore := candidate.SNRDb * scoreModel.SNRWeight
  129. bwScore := 0.0
  130. peakScore := 0.0
  131. policyBoost := CandidatePriorityBoost(policy, candidate.Hint)
  132. if candidate.BandwidthHz > 0 {
  133. bwScore = minFloat64(candidate.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight
  134. }
  135. if candidate.PeakDb > 0 {
  136. peakScore = (candidate.PeakDb / 20.0) * scoreModel.PeakWeight
  137. }
  138. rawEvidenceScore, evidenceDetail := candidateEvidenceScore(candidate, strategy)
  139. evidenceDetail.Weight = scoreModel.EvidenceWeight
  140. evidenceDetail.RawScore = rawEvidenceScore
  141. evidenceDetail.WeightedScore = rawEvidenceScore * scoreModel.EvidenceWeight
  142. evidenceScore := evidenceDetail.WeightedScore
  143. priority := snrScore + bwScore + peakScore + policyBoost
  144. priority += evidenceScore
  145. score := &RefinementScore{
  146. Total: priority,
  147. Breakdown: RefinementScoreDetails{
  148. SNRScore: snrScore,
  149. BandwidthScore: bwScore,
  150. PeakScore: peakScore,
  151. PolicyBoost: policyBoost,
  152. EvidenceScore: evidenceScore,
  153. EvidenceDetail: &evidenceDetail,
  154. },
  155. Weights: &scoreModel,
  156. }
  157. scored = append(scored, ScheduledCandidate{
  158. Candidate: candidate,
  159. Priority: priority,
  160. Score: score,
  161. Breakdown: &score.Breakdown,
  162. })
  163. workItems = append(workItems, RefinementWorkItem{
  164. Candidate: candidate,
  165. Priority: priority,
  166. Score: score,
  167. Breakdown: &score.Breakdown,
  168. Status: RefinementStatusPlanned,
  169. Reason: RefinementReasonPlanned,
  170. })
  171. }
  172. sort.Slice(scored, func(i, j int) bool {
  173. if scored[i].Priority == scored[j].Priority {
  174. return scored[i].Candidate.CenterHz < scored[j].Candidate.CenterHz
  175. }
  176. return scored[i].Priority > scored[j].Priority
  177. })
  178. if len(scored) > 0 {
  179. minPriority := scored[0].Priority
  180. maxPriority := scored[0].Priority
  181. sumPriority := 0.0
  182. for _, s := range scored {
  183. if s.Priority < minPriority {
  184. minPriority = s.Priority
  185. }
  186. if s.Priority > maxPriority {
  187. maxPriority = s.Priority
  188. }
  189. sumPriority += s.Priority
  190. }
  191. plan.PriorityMin = minPriority
  192. plan.PriorityMax = maxPriority
  193. plan.PriorityAvg = sumPriority / float64(len(scored))
  194. }
  195. plan.Ranked = append(plan.Ranked, scored...)
  196. plan.WorkItems = workItems
  197. return plan
  198. }
  199. func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandidate {
  200. plan := BuildRefinementPlan(candidates, policy)
  201. if len(plan.Ranked) > 0 {
  202. return plan.Ranked
  203. }
  204. return plan.Selected
  205. }
  206. func refinementStrategy(policy Policy) (string, string) {
  207. intent := strings.ToLower(strings.TrimSpace(policy.Intent))
  208. profile := strings.ToLower(strings.TrimSpace(policy.Profile))
  209. switch {
  210. case strings.Contains(profile, "digital"):
  211. return "digital-hunting", "profile"
  212. case strings.Contains(profile, "archive"):
  213. return "archive-oriented", "profile"
  214. case strings.Contains(profile, "aggressive"):
  215. return "multi-resolution", "profile"
  216. case strings.Contains(intent, "digital") || strings.Contains(intent, "hunt") || strings.Contains(intent, "decode"):
  217. return "digital-hunting", "intent"
  218. case strings.Contains(intent, "archive") || strings.Contains(intent, "triage") || strings.Contains(policy.Mode, "archive"):
  219. return "archive-oriented", "intent"
  220. case strings.Contains(strings.ToLower(policy.SurveillanceStrategy), "multi"):
  221. return "multi-resolution", "surveillance-strategy"
  222. default:
  223. return "single-resolution", "default"
  224. }
  225. }
  226. func applyStrategyWeights(strategy string, model RefinementScoreModel) RefinementScoreModel {
  227. switch strings.ToLower(strings.TrimSpace(strategy)) {
  228. case "digital-hunting":
  229. model.SNRWeight *= 1.4
  230. model.BandwidthWeight *= 0.75
  231. model.PeakWeight *= 1.2
  232. case "archive-oriented":
  233. model.SNRWeight *= 1.1
  234. model.BandwidthWeight *= 1.6
  235. model.PeakWeight *= 1.05
  236. case "multi-resolution", "multi", "multi-res", "multi_res":
  237. model.SNRWeight *= 1.15
  238. model.BandwidthWeight *= 1.1
  239. model.PeakWeight *= 1.15
  240. case "single-resolution":
  241. model.SNRWeight *= 1.1
  242. model.BandwidthWeight *= 1.0
  243. model.PeakWeight *= 1.0
  244. }
  245. return model
  246. }
  247. func candidateEvidenceScore(candidate Candidate, strategy string) (float64, EvidenceScoreDetails) {
  248. state := CandidateEvidenceStateFor(candidate)
  249. details := EvidenceScoreDetails{
  250. DetectionLevels: state.DetectionLevelCount,
  251. PrimaryLevels: state.PrimaryLevelCount,
  252. DerivedLevels: state.DerivedLevelCount,
  253. ProvenanceCount: len(state.Provenance),
  254. DerivedOnly: state.DerivedOnly,
  255. MultiLevelConfirmed: state.MultiLevelConfirmed,
  256. }
  257. score := 0.0
  258. if state.MultiLevelConfirmed && state.DetectionLevelCount > 1 {
  259. bonus := 0.85 * float64(state.DetectionLevelCount-1)
  260. score += bonus
  261. details.MultiLevelBonus = bonus
  262. }
  263. if len(state.Provenance) > 1 {
  264. bonus := 0.15 * float64(len(state.Provenance)-1)
  265. score += bonus
  266. details.ProvenanceBonus = bonus
  267. }
  268. if state.DerivedOnly {
  269. penalty := 0.35
  270. score -= penalty
  271. details.DerivedPenalty = -penalty
  272. }
  273. switch strings.ToLower(strings.TrimSpace(strategy)) {
  274. case "multi-resolution", "multi", "multi-res", "multi_res":
  275. if state.DerivedOnly {
  276. bias := 0.2
  277. score += bias
  278. details.StrategyBias = bias
  279. } else if state.MultiLevelConfirmed {
  280. bias := 0.1
  281. score += bias
  282. details.StrategyBias = bias
  283. }
  284. case "digital-hunting":
  285. if state.DerivedOnly {
  286. bias := -0.15
  287. score += bias
  288. details.StrategyBias = bias
  289. } else if state.MultiLevelConfirmed {
  290. bias := 0.05
  291. score += bias
  292. details.StrategyBias = bias
  293. }
  294. case "archive-oriented":
  295. if state.DerivedOnly {
  296. bias := -0.1
  297. score += bias
  298. details.StrategyBias = bias
  299. }
  300. case "single-resolution":
  301. if state.MultiLevelConfirmed {
  302. bias := 0.05
  303. score += bias
  304. details.StrategyBias = bias
  305. }
  306. }
  307. return score, details
  308. }
  309. func minFloat64(a, b float64) float64 {
  310. if a < b {
  311. return a
  312. }
  313. return b
  314. }