From 838c94156db5a4c8b8064554ccc55f11cbd05778 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 22 Mar 2026 14:36:08 +0100 Subject: [PATCH] Add window-based record/decode actions --- cmd/sdrd/decision_compact.go | 4 ++ cmd/sdrd/level_summary.go | 4 ++ internal/config/config.go | 14 +++-- internal/pipeline/arbitration_reasons.go | 18 +++--- internal/pipeline/decision_queue.go | 2 +- internal/pipeline/decisions.go | 72 ++++++++++++++++++++++++ internal/pipeline/decisions_test.go | 46 +++++++++++++++ internal/pipeline/monitor_rules.go | 34 ++++++----- internal/pipeline/scheduler.go | 2 + internal/pipeline/types.go | 6 ++ 10 files changed, 173 insertions(+), 29 deletions(-) diff --git a/cmd/sdrd/decision_compact.go b/cmd/sdrd/decision_compact.go index 0b78cd6..5265668 100644 --- a/cmd/sdrd/decision_compact.go +++ b/cmd/sdrd/decision_compact.go @@ -10,6 +10,8 @@ type compactDecision struct { Reason string `json:"reason,omitempty"` MonitorBias float64 `json:"monitor_bias,omitempty"` MonitorDetail *pipeline.MonitorWindowMatch `json:"monitor_detail,omitempty"` + RecordWindow *pipeline.MonitorWindowMatch `json:"record_window,omitempty"` + DecodeWindow *pipeline.MonitorWindowMatch `json:"decode_window,omitempty"` RecordAdmission *pipeline.PriorityAdmission `json:"record_admission,omitempty"` DecodeAdmission *pipeline.PriorityAdmission `json:"decode_admission,omitempty"` Candidate pipeline.Candidate `json:"candidate"` @@ -26,6 +28,8 @@ func compactDecisions(decisions []pipeline.SignalDecision) []compactDecision { Reason: d.Reason, MonitorBias: d.MonitorBias, MonitorDetail: d.MonitorDetail, + RecordWindow: d.RecordWindow, + DecodeWindow: d.DecodeWindow, RecordAdmission: d.RecordAdmission, DecodeAdmission: d.DecodeAdmission, Candidate: d.Candidate, diff --git a/cmd/sdrd/level_summary.go b/cmd/sdrd/level_summary.go index c361502..80f4ed1 100644 --- a/cmd/sdrd/level_summary.go +++ b/cmd/sdrd/level_summary.go @@ -53,6 +53,8 @@ type CandidateWindowSummary struct { SpanHz float64 `json:"span_hz,omitempty"` Priority float64 `json:"priority,omitempty"` PriorityBias float64 `json:"priority_bias,omitempty"` + AutoRecord bool `json:"auto_record,omitempty"` + AutoDecode bool `json:"auto_decode,omitempty"` Candidates int `json:"candidates"` } @@ -235,6 +237,8 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe SpanHz: win.SpanHz, Priority: win.Priority, PriorityBias: win.PriorityBias, + AutoRecord: win.AutoRecord, + AutoDecode: win.AutoDecode, } index[win.Index] = len(out) out = append(out, entry) diff --git a/internal/config/config.go b/internal/config/config.go index 0683bda..cd591b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,12 +16,14 @@ type Band struct { } type MonitorWindow struct { - Label string `yaml:"label" json:"label"` - StartHz float64 `yaml:"start_hz" json:"start_hz"` - EndHz float64 `yaml:"end_hz" json:"end_hz"` - CenterHz float64 `yaml:"center_hz" json:"center_hz"` - SpanHz float64 `yaml:"span_hz" json:"span_hz"` - Priority float64 `yaml:"priority" json:"priority"` + Label string `yaml:"label" json:"label"` + StartHz float64 `yaml:"start_hz" json:"start_hz"` + EndHz float64 `yaml:"end_hz" json:"end_hz"` + CenterHz float64 `yaml:"center_hz" json:"center_hz"` + SpanHz float64 `yaml:"span_hz" json:"span_hz"` + Priority float64 `yaml:"priority" json:"priority"` + AutoRecord bool `yaml:"auto_record" json:"auto_record"` + AutoDecode bool `yaml:"auto_decode" json:"auto_decode"` } type DetectorConfig struct { diff --git a/internal/pipeline/arbitration_reasons.go b/internal/pipeline/arbitration_reasons.go index ca8ae6a..ad7aaff 100644 --- a/internal/pipeline/arbitration_reasons.go +++ b/internal/pipeline/arbitration_reasons.go @@ -6,14 +6,16 @@ const ( ) const ( - DecisionReasonRecordClass = "decision:record:class" - DecisionReasonRecordHint = "decision:record:hint" - DecisionReasonDecodeClass = "decision:decode:class" - DecisionReasonDecodeHint = "decision:decode:hint" - DecisionReasonHintOnly = "decision:hint" - DecisionReasonQueueRecord = "queue:record:budget" - DecisionReasonQueueDecode = "queue:decode:budget" - DecisionReasonUnspecified = "decision:unspecified" + DecisionReasonRecordClass = "decision:record:class" + DecisionReasonRecordHint = "decision:record:hint" + DecisionReasonRecordWindow = "decision:record:window" + DecisionReasonDecodeClass = "decision:decode:class" + DecisionReasonDecodeHint = "decision:decode:hint" + DecisionReasonDecodeWindow = "decision:decode:window" + DecisionReasonHintOnly = "decision:hint" + DecisionReasonQueueRecord = "queue:record:budget" + DecisionReasonQueueDecode = "queue:decode:budget" + DecisionReasonUnspecified = "decision:unspecified" ) const ( diff --git a/internal/pipeline/decision_queue.go b/internal/pipeline/decision_queue.go index 70b07df..7292ae7 100644 --- a/internal/pipeline/decision_queue.go +++ b/internal/pipeline/decision_queue.go @@ -476,7 +476,7 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p } func windowTagForDecision(decision SignalDecision) string { - if decision.MonitorBias == 0 || decision.MonitorDetail == nil { + if decision.MonitorDetail == nil { return "" } label := strings.TrimSpace(decision.MonitorDetail.Label) diff --git a/internal/pipeline/decisions.go b/internal/pipeline/decisions.go index 6001006..954eff0 100644 --- a/internal/pipeline/decisions.go +++ b/internal/pipeline/decisions.go @@ -14,6 +14,8 @@ type SignalDecision struct { Reason string `json:"reason,omitempty"` MonitorBias float64 `json:"monitor_bias,omitempty"` MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` + RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"` + DecodeWindow *MonitorWindowMatch `json:"decode_window,omitempty"` RecordAdmission *PriorityAdmission `json:"record_admission,omitempty"` DecodeAdmission *PriorityAdmission `json:"decode_admission,omitempty"` } @@ -47,13 +49,83 @@ func DecideSignalAction(policy Policy, candidate Candidate, cls *classifier.Clas decision.Reason = DecisionReasonDecodeHint } } + recordMatch := bestMonitorActionMatch(candidate.MonitorMatches, true, false) + if !decision.ShouldRecord && recordMatch != nil { + decision.ShouldRecord = true + decision.RecordWindow = recordMatch + if decision.Reason == "" { + decision.Reason = DecisionReasonRecordWindow + } + } + decodeMatch := bestMonitorActionMatch(candidate.MonitorMatches, false, true) + if !decision.ShouldAutoDecode && decodeMatch != nil { + decision.ShouldAutoDecode = true + decision.DecodeWindow = decodeMatch + if decision.Reason == "" { + decision.Reason = DecisionReasonDecodeWindow + } + } if decision.Reason == "" && candidate.Hint != "" { decision.Reason = DecisionReasonHintOnly } monitorBias, monitorDetail := MonitorWindowBias(policy, candidate) + if monitorDetail == nil { + monitorDetail = selectMonitorDetail(recordMatch, decodeMatch) + } if monitorBias != 0 { decision.MonitorBias = monitorBias + } + if monitorDetail != nil { decision.MonitorDetail = monitorDetail } return decision } + +func bestMonitorActionMatch(matches []MonitorWindowMatch, wantRecord bool, wantDecode bool) *MonitorWindowMatch { + if len(matches) == 0 || (!wantRecord && !wantDecode) { + return nil + } + best := -1 + for i := range matches { + match := matches[i] + if wantRecord && !match.AutoRecord { + continue + } + if wantDecode && !match.AutoDecode { + continue + } + if best == -1 || betterMonitorActionMatch(match, matches[best]) { + best = i + } + } + if best == -1 { + return nil + } + return &matches[best] +} + +func betterMonitorActionMatch(candidate MonitorWindowMatch, best MonitorWindowMatch) bool { + if candidate.Coverage != best.Coverage { + return candidate.Coverage > best.Coverage + } + if candidate.DistanceHz != best.DistanceHz { + return candidate.DistanceHz < best.DistanceHz + } + if candidate.Bias != best.Bias { + return candidate.Bias > best.Bias + } + return candidate.Index < best.Index +} + +func selectMonitorDetail(recordMatch *MonitorWindowMatch, decodeMatch *MonitorWindowMatch) *MonitorWindowMatch { + if recordMatch == nil { + return decodeMatch + } + if decodeMatch == nil { + return recordMatch + } + if betterMonitorActionMatch(*recordMatch, *decodeMatch) { + return recordMatch + } + return decodeMatch +} diff --git a/internal/pipeline/decisions_test.go b/internal/pipeline/decisions_test.go index 78fece5..168c9ad 100644 --- a/internal/pipeline/decisions_test.go +++ b/internal/pipeline/decisions_test.go @@ -31,3 +31,49 @@ func TestDecideSignalActionUsesHintWithoutClass(t *testing.T) { t.Fatalf("expected reason for hint-based decision") } } + +func TestDecideSignalActionWindowAutoRecord(t *testing.T) { + policy := Policy{ + MonitorWindows: finalizeMonitorWindows([]MonitorWindow{{ + Label: "record-zone", + StartHz: 100, + EndHz: 200, + CenterHz: 150, + SpanHz: 100, + AutoRecord: true, + }}), + } + decision := DecideSignalAction(policy, Candidate{ID: 3, CenterHz: 150}, nil) + if !decision.ShouldRecord { + t.Fatalf("expected window auto record decision") + } + if decision.Reason != DecisionReasonRecordWindow { + t.Fatalf("expected window record reason, got %q", decision.Reason) + } + if decision.RecordWindow == nil || decision.RecordWindow.Label != "record-zone" { + t.Fatalf("expected record window match to be set") + } +} + +func TestDecideSignalActionWindowAutoDecode(t *testing.T) { + policy := Policy{ + MonitorWindows: finalizeMonitorWindows([]MonitorWindow{{ + Label: "decode-zone", + StartHz: 300, + EndHz: 350, + CenterHz: 325, + SpanHz: 50, + AutoDecode: true, + }}), + } + decision := DecideSignalAction(policy, Candidate{ID: 4, CenterHz: 325}, nil) + if !decision.ShouldAutoDecode { + t.Fatalf("expected window auto decode decision") + } + if decision.Reason != DecisionReasonDecodeWindow { + t.Fatalf("expected window decode reason, got %q", decision.Reason) + } + if decision.DecodeWindow == nil || decision.DecodeWindow.Label != "decode-zone" { + t.Fatalf("expected decode window match to be set") + } +} diff --git a/internal/pipeline/monitor_rules.go b/internal/pipeline/monitor_rules.go index f47c338..006d2f4 100644 --- a/internal/pipeline/monitor_rules.go +++ b/internal/pipeline/monitor_rules.go @@ -105,13 +105,15 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni if raw.StartHz > 0 && raw.EndHz > raw.StartHz { span := raw.EndHz - raw.StartHz return MonitorWindow{ - Label: raw.Label, - StartHz: raw.StartHz, - EndHz: raw.EndHz, - CenterHz: (raw.StartHz + raw.EndHz) / 2, - SpanHz: span, - Source: "goals:window:start_end", - Priority: raw.Priority, + Label: raw.Label, + StartHz: raw.StartHz, + EndHz: raw.EndHz, + CenterHz: (raw.StartHz + raw.EndHz) / 2, + SpanHz: span, + Source: "goals:window:start_end", + Priority: raw.Priority, + AutoRecord: raw.AutoRecord, + AutoDecode: raw.AutoDecode, }, true } center := raw.CenterHz @@ -125,13 +127,15 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni source = "goals:window:span_default" } return MonitorWindow{ - Label: raw.Label, - StartHz: center - half, - EndHz: center + half, - CenterHz: center, - SpanHz: raw.SpanHz, - Source: source, - Priority: raw.Priority, + Label: raw.Label, + StartHz: center - half, + EndHz: center + half, + CenterHz: center, + SpanHz: raw.SpanHz, + Source: source, + Priority: raw.Priority, + AutoRecord: raw.AutoRecord, + AutoDecode: raw.AutoDecode, }, true } return MonitorWindow{}, false @@ -271,6 +275,8 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida Coverage: coverage, DistanceHz: distance, Bias: bias, + AutoRecord: win.AutoRecord, + AutoDecode: win.AutoDecode, }) } if len(matches) == 0 { diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 709f8ac..d7638bb 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -422,6 +422,8 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { SpanHz: win.SpanHz, Priority: win.Priority, PriorityBias: win.PriorityBias, + AutoRecord: win.AutoRecord, + AutoDecode: win.AutoDecode, }) } return stats diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go index 9e27632..ff54ea7 100644 --- a/internal/pipeline/types.go +++ b/internal/pipeline/types.go @@ -41,6 +41,8 @@ type MonitorWindow struct { Source string `json:"source,omitempty"` Priority float64 `json:"priority,omitempty"` PriorityBias float64 `json:"priority_bias,omitempty"` + AutoRecord bool `json:"auto_record,omitempty"` + AutoDecode bool `json:"auto_decode,omitempty"` } // MonitorWindowMatch captures how a candidate overlaps a monitor window. @@ -56,6 +58,8 @@ type MonitorWindowMatch struct { Coverage float64 `json:"coverage,omitempty"` DistanceHz float64 `json:"distance_hz,omitempty"` Bias float64 `json:"bias,omitempty"` + AutoRecord bool `json:"auto_record,omitempty"` + AutoDecode bool `json:"auto_decode,omitempty"` } // MonitorWindowStats summarizes candidate attribution per monitor window. @@ -69,6 +73,8 @@ type MonitorWindowStats struct { SpanHz float64 `json:"span_hz,omitempty"` Priority float64 `json:"priority,omitempty"` PriorityBias float64 `json:"priority_bias,omitempty"` + AutoRecord bool `json:"auto_record,omitempty"` + AutoDecode bool `json:"auto_decode,omitempty"` Candidates int `json:"candidates,omitempty"` Planned int `json:"planned,omitempty"` Dropped int `json:"dropped,omitempty"`