| @@ -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, | |||
| @@ -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) | |||
| @@ -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 { | |||
| @@ -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 ( | |||
| @@ -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) | |||
| @@ -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 | |||
| } | |||
| @@ -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") | |||
| } | |||
| } | |||
| @@ -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 { | |||
| @@ -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 | |||
| @@ -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"` | |||