| @@ -10,6 +10,8 @@ type compactDecision struct { | |||||
| Reason string `json:"reason,omitempty"` | Reason string `json:"reason,omitempty"` | ||||
| MonitorBias float64 `json:"monitor_bias,omitempty"` | MonitorBias float64 `json:"monitor_bias,omitempty"` | ||||
| MonitorDetail *pipeline.MonitorWindowMatch `json:"monitor_detail,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"` | RecordAdmission *pipeline.PriorityAdmission `json:"record_admission,omitempty"` | ||||
| DecodeAdmission *pipeline.PriorityAdmission `json:"decode_admission,omitempty"` | DecodeAdmission *pipeline.PriorityAdmission `json:"decode_admission,omitempty"` | ||||
| Candidate pipeline.Candidate `json:"candidate"` | Candidate pipeline.Candidate `json:"candidate"` | ||||
| @@ -26,6 +28,8 @@ func compactDecisions(decisions []pipeline.SignalDecision) []compactDecision { | |||||
| Reason: d.Reason, | Reason: d.Reason, | ||||
| MonitorBias: d.MonitorBias, | MonitorBias: d.MonitorBias, | ||||
| MonitorDetail: d.MonitorDetail, | MonitorDetail: d.MonitorDetail, | ||||
| RecordWindow: d.RecordWindow, | |||||
| DecodeWindow: d.DecodeWindow, | |||||
| RecordAdmission: d.RecordAdmission, | RecordAdmission: d.RecordAdmission, | ||||
| DecodeAdmission: d.DecodeAdmission, | DecodeAdmission: d.DecodeAdmission, | ||||
| Candidate: d.Candidate, | Candidate: d.Candidate, | ||||
| @@ -53,6 +53,8 @@ type CandidateWindowSummary struct { | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | SpanHz float64 `json:"span_hz,omitempty"` | ||||
| Priority float64 `json:"priority,omitempty"` | Priority float64 `json:"priority,omitempty"` | ||||
| PriorityBias float64 `json:"priority_bias,omitempty"` | PriorityBias float64 `json:"priority_bias,omitempty"` | ||||
| AutoRecord bool `json:"auto_record,omitempty"` | |||||
| AutoDecode bool `json:"auto_decode,omitempty"` | |||||
| Candidates int `json:"candidates"` | Candidates int `json:"candidates"` | ||||
| } | } | ||||
| @@ -235,6 +237,8 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe | |||||
| SpanHz: win.SpanHz, | SpanHz: win.SpanHz, | ||||
| Priority: win.Priority, | Priority: win.Priority, | ||||
| PriorityBias: win.PriorityBias, | PriorityBias: win.PriorityBias, | ||||
| AutoRecord: win.AutoRecord, | |||||
| AutoDecode: win.AutoDecode, | |||||
| } | } | ||||
| index[win.Index] = len(out) | index[win.Index] = len(out) | ||||
| out = append(out, entry) | out = append(out, entry) | ||||
| @@ -16,12 +16,14 @@ type Band struct { | |||||
| } | } | ||||
| type MonitorWindow 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 { | type DetectorConfig struct { | ||||
| @@ -6,14 +6,16 @@ const ( | |||||
| ) | ) | ||||
| 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 ( | const ( | ||||
| @@ -476,7 +476,7 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| } | } | ||||
| func windowTagForDecision(decision SignalDecision) string { | func windowTagForDecision(decision SignalDecision) string { | ||||
| if decision.MonitorBias == 0 || decision.MonitorDetail == nil { | |||||
| if decision.MonitorDetail == nil { | |||||
| return "" | return "" | ||||
| } | } | ||||
| label := strings.TrimSpace(decision.MonitorDetail.Label) | label := strings.TrimSpace(decision.MonitorDetail.Label) | ||||
| @@ -14,6 +14,8 @@ type SignalDecision struct { | |||||
| Reason string `json:"reason,omitempty"` | Reason string `json:"reason,omitempty"` | ||||
| MonitorBias float64 `json:"monitor_bias,omitempty"` | MonitorBias float64 `json:"monitor_bias,omitempty"` | ||||
| MonitorDetail *MonitorWindowMatch `json:"monitor_detail,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"` | RecordAdmission *PriorityAdmission `json:"record_admission,omitempty"` | ||||
| DecodeAdmission *PriorityAdmission `json:"decode_admission,omitempty"` | DecodeAdmission *PriorityAdmission `json:"decode_admission,omitempty"` | ||||
| } | } | ||||
| @@ -47,13 +49,83 @@ func DecideSignalAction(policy Policy, candidate Candidate, cls *classifier.Clas | |||||
| decision.Reason = DecisionReasonDecodeHint | 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 != "" { | if decision.Reason == "" && candidate.Hint != "" { | ||||
| decision.Reason = DecisionReasonHintOnly | decision.Reason = DecisionReasonHintOnly | ||||
| } | } | ||||
| monitorBias, monitorDetail := MonitorWindowBias(policy, candidate) | monitorBias, monitorDetail := MonitorWindowBias(policy, candidate) | ||||
| if monitorDetail == nil { | |||||
| monitorDetail = selectMonitorDetail(recordMatch, decodeMatch) | |||||
| } | |||||
| if monitorBias != 0 { | if monitorBias != 0 { | ||||
| decision.MonitorBias = monitorBias | decision.MonitorBias = monitorBias | ||||
| } | |||||
| if monitorDetail != nil { | |||||
| decision.MonitorDetail = monitorDetail | decision.MonitorDetail = monitorDetail | ||||
| } | } | ||||
| return decision | 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 | |||||
| } | |||||
| @@ -31,3 +31,49 @@ func TestDecideSignalActionUsesHintWithoutClass(t *testing.T) { | |||||
| t.Fatalf("expected reason for hint-based decision") | 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") | |||||
| } | |||||
| } | |||||
| @@ -105,13 +105,15 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni | |||||
| if raw.StartHz > 0 && raw.EndHz > raw.StartHz { | if raw.StartHz > 0 && raw.EndHz > raw.StartHz { | ||||
| span := raw.EndHz - raw.StartHz | span := raw.EndHz - raw.StartHz | ||||
| return MonitorWindow{ | 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 | }, true | ||||
| } | } | ||||
| center := raw.CenterHz | center := raw.CenterHz | ||||
| @@ -125,13 +127,15 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni | |||||
| source = "goals:window:span_default" | source = "goals:window:span_default" | ||||
| } | } | ||||
| return MonitorWindow{ | 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 | }, true | ||||
| } | } | ||||
| return MonitorWindow{}, false | return MonitorWindow{}, false | ||||
| @@ -271,6 +275,8 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida | |||||
| Coverage: coverage, | Coverage: coverage, | ||||
| DistanceHz: distance, | DistanceHz: distance, | ||||
| Bias: bias, | Bias: bias, | ||||
| AutoRecord: win.AutoRecord, | |||||
| AutoDecode: win.AutoDecode, | |||||
| }) | }) | ||||
| } | } | ||||
| if len(matches) == 0 { | if len(matches) == 0 { | ||||
| @@ -422,6 +422,8 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { | |||||
| SpanHz: win.SpanHz, | SpanHz: win.SpanHz, | ||||
| Priority: win.Priority, | Priority: win.Priority, | ||||
| PriorityBias: win.PriorityBias, | PriorityBias: win.PriorityBias, | ||||
| AutoRecord: win.AutoRecord, | |||||
| AutoDecode: win.AutoDecode, | |||||
| }) | }) | ||||
| } | } | ||||
| return stats | return stats | ||||
| @@ -41,6 +41,8 @@ type MonitorWindow struct { | |||||
| Source string `json:"source,omitempty"` | Source string `json:"source,omitempty"` | ||||
| Priority float64 `json:"priority,omitempty"` | Priority float64 `json:"priority,omitempty"` | ||||
| PriorityBias float64 `json:"priority_bias,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. | // MonitorWindowMatch captures how a candidate overlaps a monitor window. | ||||
| @@ -56,6 +58,8 @@ type MonitorWindowMatch struct { | |||||
| Coverage float64 `json:"coverage,omitempty"` | Coverage float64 `json:"coverage,omitempty"` | ||||
| DistanceHz float64 `json:"distance_hz,omitempty"` | DistanceHz float64 `json:"distance_hz,omitempty"` | ||||
| Bias float64 `json:"bias,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. | // MonitorWindowStats summarizes candidate attribution per monitor window. | ||||
| @@ -69,6 +73,8 @@ type MonitorWindowStats struct { | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | SpanHz float64 `json:"span_hz,omitempty"` | ||||
| Priority float64 `json:"priority,omitempty"` | Priority float64 `json:"priority,omitempty"` | ||||
| PriorityBias float64 `json:"priority_bias,omitempty"` | PriorityBias float64 `json:"priority_bias,omitempty"` | ||||
| AutoRecord bool `json:"auto_record,omitempty"` | |||||
| AutoDecode bool `json:"auto_decode,omitempty"` | |||||
| Candidates int `json:"candidates,omitempty"` | Candidates int `json:"candidates,omitempty"` | ||||
| Planned int `json:"planned,omitempty"` | Planned int `json:"planned,omitempty"` | ||||
| Dropped int `json:"dropped,omitempty"` | Dropped int `json:"dropped,omitempty"` | ||||