diff --git a/classify/classifier.go b/classify/classifier.go index 2d7e096..257e4aa 100644 --- a/classify/classifier.go +++ b/classify/classifier.go @@ -17,10 +17,9 @@ func NewClassifier(rules []Rule) *Classifier { // Classify inspects a gopacket.Packet and returns a ClassifiedPacket. // It checks ICMP first, then extracts TCP/UDP port info and matches against rules. -// Per D-03: returns ClassUnknown if no rule matches. +// Per D-03: returns ClassUnknown1-4 via hash-bucketing if no rule matches. func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket { result := ClassifiedPacket{ - Class: ClassUnknown, Length: len(pkt.Data()), } @@ -34,6 +33,7 @@ func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket { return result } } + result.Class = hashBucket(result.DstPort, result.Protocol) return result } @@ -49,6 +49,7 @@ func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket { return result } } + result.Class = hashBucket(result.DstPort, result.Protocol) return result } @@ -64,9 +65,37 @@ func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket { return result } } + result.Class = hashBucket(result.DstPort, result.Protocol) return result } - // No recognized transport layer -> ClassUnknown per D-03 + // No recognized transport layer -> hash-bucket per D-03 + result.Class = hashBucket(result.DstPort, result.Protocol) return result } + +// hashBucket maps an unrecognized packet to one of 4 unknown traffic classes. +// Deterministic: same (dstPort, protocol) always maps to the same bucket. +// ARP and other non-transport packets have dstPort=0, protocol="" -> bucket 0 (ClassUnknown1). +func hashBucket(dstPort uint16, protocol string) TrafficClass { + var protoNum uint16 + switch protocol { + case "tcp": + protoNum = 6 + case "udp": + protoNum = 17 + case "icmp": + protoNum = 1 + } + h := uint32(dstPort)*31 + uint32(protoNum)*7 + switch h % 4 { + case 0: + return ClassUnknown1 + case 1: + return ClassUnknown2 + case 2: + return ClassUnknown3 + default: + return ClassUnknown4 + } +} diff --git a/classify/classifier_test.go b/classify/classifier_test.go index 53ab1e7..2b7e4a6 100644 --- a/classify/classifier_test.go +++ b/classify/classifier_test.go @@ -1,6 +1,7 @@ package classify_test import ( + "strings" "testing" "github.com/gopacket/gopacket" @@ -220,8 +221,8 @@ func TestClassify(t *testing.T) { t.Run("TestClassifyUnknown", func(t *testing.T) { pkt := buildUnknownPacket(t) got := c.Classify(pkt) - if got.Class != classify.ClassUnknown { - t.Errorf("Unknown packet: got class %q, want %q", got.Class, classify.ClassUnknown) + if !strings.HasPrefix(string(got.Class), "unknown-") { + t.Errorf("Unknown packet: got class %q, want unknown-N bucket", got.Class) } }) @@ -241,3 +242,58 @@ func TestClassify(t *testing.T) { } }) } + +func TestAllClassesCount(t *testing.T) { + classes := classify.AllClasses() + if len(classes) != 14 { + t.Errorf("AllClasses() returned %d classes, want 14", len(classes)) + } + // No plain "unknown" should exist + for _, c := range classes { + if c == "unknown" { + t.Error("AllClasses() still contains plain \"unknown\" — should be removed per D-04") + } + } + // All 4 buckets must be present + buckets := map[classify.TrafficClass]bool{ + classify.ClassUnknown1: false, classify.ClassUnknown2: false, + classify.ClassUnknown3: false, classify.ClassUnknown4: false, + } + for _, c := range classes { + if _, ok := buckets[c]; ok { + buckets[c] = true + } + } + for bucket, found := range buckets { + if !found { + t.Errorf("AllClasses() missing bucket %q", bucket) + } + } +} + +func TestHashBucketDistribution(t *testing.T) { + // hashBucket is unexported, test through Classify with a minimal rule set + // that has no catch-all TCP/UDP rules, so unmatched ports reach hashBucket. + minimalRules := []classify.Rule{ + {Protocol: "icmp", DstPort: 0, Class: classify.ClassICMP}, + {Protocol: "tcp", DstPort: 443, Class: classify.ClassHTTPS}, + } + c := classify.NewClassifier(minimalRules) + seen := make(map[classify.TrafficClass]bool) + // Try a range of unmatched TCP ports to hit all 4 buckets + for port := uint16(10000); port < 11000; port++ { + pkt := buildTCPPacket(t, port) + got := c.Classify(pkt) + if strings.HasPrefix(string(got.Class), "unknown-") { + seen[got.Class] = true + } + } + for _, bucket := range []classify.TrafficClass{ + classify.ClassUnknown1, classify.ClassUnknown2, + classify.ClassUnknown3, classify.ClassUnknown4, + } { + if !seen[bucket] { + t.Errorf("hashBucket never produced %q across ports 10000-10999", bucket) + } + } +} diff --git a/classify/types.go b/classify/types.go index f58dd3a..e06719e 100644 --- a/classify/types.go +++ b/classify/types.go @@ -14,14 +14,19 @@ const ( ClassDHCP TrafficClass = "DHCP" ClassOtherTCP TrafficClass = "other-TCP" ClassOtherUDP TrafficClass = "other-UDP" - ClassUnknown TrafficClass = "unknown" + // D-04: ClassUnknown replaced by 4 hash-bucketed classes for distinct tones + ClassUnknown1 TrafficClass = "unknown-1" + ClassUnknown2 TrafficClass = "unknown-2" + ClassUnknown3 TrafficClass = "unknown-3" + ClassUnknown4 TrafficClass = "unknown-4" ) // AllClasses returns all known traffic classes in display order. func AllClasses() []TrafficClass { return []TrafficClass{ ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH, - ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, ClassUnknown, + ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, + ClassUnknown1, ClassUnknown2, ClassUnknown3, ClassUnknown4, } } diff --git a/synth/bank_test.go b/synth/bank_test.go index 753938e..4056bda 100644 --- a/synth/bank_test.go +++ b/synth/bank_test.go @@ -7,10 +7,10 @@ import ( "github.com/netsynth/netsynth/classify" ) -func TestNewBankHas11Layers(t *testing.T) { +func TestNewBankHas14Layers(t *testing.T) { b := NewBank(1.0) - if len(b.layers) != 11 { - t.Errorf("NewBank() has %d layers, want 11", len(b.layers)) + if len(b.layers) != 14 { + t.Errorf("NewBank() has %d layers, want 14", len(b.layers)) } // Verify each class has exactly one layer for _, class := range classify.AllClasses() { @@ -76,13 +76,13 @@ func TestRenderWindowNonZeroWithTraffic(t *testing.T) { func TestMixerNoClip(t *testing.T) { b := NewBank(0.01) // fast EMA to quickly ramp up to near-max amplitude counts := make(map[classify.TrafficClass]int64) - // All 11 classes at max count — worst-case mixing scenario + // All 14 classes at max count — worst-case mixing scenario for _, class := range classify.AllClasses() { counts[class] = 1000 } snap := classify.WindowSnapshot{ Counts: counts, - TotalPackets: 11000, + TotalPackets: 14000, WindowIndex: 0, } // Render multiple windows to let EMA converge diff --git a/synth/config.go b/synth/config.go index a18c920..67fcb2b 100644 --- a/synth/config.go +++ b/synth/config.go @@ -6,8 +6,8 @@ const ( SampleRate = 44100 // D-13: CD quality WindowMs = 500 // matches aggregate.DefaultWindowMs SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050 - NumLayers = 11 - GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0909 + NumLayers = 14 + GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0714 WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude ) @@ -38,7 +38,10 @@ var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ classify.ClassDHCP: {600.0, []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, -0.75}, classify.ClassOtherTCP: {700.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, 0.75}, classify.ClassOtherUDP: {780.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, -0.75}, - // D-04: 437 Hz is ~12 cents flat from A4 (440 Hz/SMTP). - // Creates 3 Hz beating when SMTP is present = dissonant "doesn't belong" signal. - classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0}, + // D-05: Unknown buckets in 850-1100 Hz dissonant range, detuned intervals + // D-06: Same dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} for all 4 + classify.ClassUnknown1: {862.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.6}, + classify.ClassUnknown2: {920.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.6}, + classify.ClassUnknown3: {981.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.9}, + classify.ClassUnknown4: {1047.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.9}, } diff --git a/synth/config_test.go b/synth/config_test.go index 9c2cffe..f1e822c 100644 --- a/synth/config_test.go +++ b/synth/config_test.go @@ -17,8 +17,9 @@ func TestAllClassesHaveConfig(t *testing.T) { func TestFrequenciesInRange(t *testing.T) { for class, cfg := range synth.ClassFreqConfigs { - if cfg.BaseHz < 60 || cfg.BaseHz > 800 { - t.Errorf("class %q BaseHz=%.1f is out of range [60, 800]", class, cfg.BaseHz) + // D-05: unknown buckets use 850-1100 Hz dissonant range; known classes use 60-800 Hz + if cfg.BaseHz < 60 || cfg.BaseHz > 1100 { + t.Errorf("class %q BaseHz=%.1f is out of range [60, 1100]", class, cfg.BaseHz) } } } @@ -48,3 +49,17 @@ func TestPanPositionsInRange(t *testing.T) { } } } + +func TestClassFreqConfigsComplete(t *testing.T) { + for _, class := range classify.AllClasses() { + if _, ok := synth.ClassFreqConfigs[class]; !ok { + t.Errorf("ClassFreqConfigs missing entry for class %q", class) + } + } +} + +func TestNumLayersMatchesAllClasses(t *testing.T) { + if synth.NumLayers != len(classify.AllClasses()) { + t.Errorf("NumLayers=%d but AllClasses() has %d entries", synth.NumLayers, len(classify.AllClasses())) + } +}