feat(03-01): extend TrafficClass to 14 classes with hash-bucketed unknowns
- Replace ClassUnknown with ClassUnknown1-4 (unknown-1 through unknown-4) - AllClasses() now returns 14 elements - Add hashBucket() function routing unrecognized traffic deterministically - Update synth/config.go: NumLayers=14, 4 dissonant unknown tone configs at 862-1047 Hz - Add TestAllClassesCount and TestHashBucketDistribution tests - Add TestClassFreqConfigsComplete and TestNumLayersMatchesAllClasses to config_test.go - Update TestNewBankHas14Layers, TestMixerNoClip for 14 classes - All classify and synth tests pass
This commit is contained in:
+32
-3
@@ -17,10 +17,9 @@ func NewClassifier(rules []Rule) *Classifier {
|
|||||||
|
|
||||||
// Classify inspects a gopacket.Packet and returns a ClassifiedPacket.
|
// Classify inspects a gopacket.Packet and returns a ClassifiedPacket.
|
||||||
// It checks ICMP first, then extracts TCP/UDP port info and matches against rules.
|
// 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 {
|
func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket {
|
||||||
result := ClassifiedPacket{
|
result := ClassifiedPacket{
|
||||||
Class: ClassUnknown,
|
|
||||||
Length: len(pkt.Data()),
|
Length: len(pkt.Data()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +65,37 @@ func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||||
return result
|
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package classify_test
|
package classify_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gopacket/gopacket"
|
"github.com/gopacket/gopacket"
|
||||||
@@ -220,8 +221,8 @@ func TestClassify(t *testing.T) {
|
|||||||
t.Run("TestClassifyUnknown", func(t *testing.T) {
|
t.Run("TestClassifyUnknown", func(t *testing.T) {
|
||||||
pkt := buildUnknownPacket(t)
|
pkt := buildUnknownPacket(t)
|
||||||
got := c.Classify(pkt)
|
got := c.Classify(pkt)
|
||||||
if got.Class != classify.ClassUnknown {
|
if !strings.HasPrefix(string(got.Class), "unknown-") {
|
||||||
t.Errorf("Unknown packet: got class %q, want %q", got.Class, classify.ClassUnknown)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-2
@@ -14,14 +14,19 @@ const (
|
|||||||
ClassDHCP TrafficClass = "DHCP"
|
ClassDHCP TrafficClass = "DHCP"
|
||||||
ClassOtherTCP TrafficClass = "other-TCP"
|
ClassOtherTCP TrafficClass = "other-TCP"
|
||||||
ClassOtherUDP TrafficClass = "other-UDP"
|
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.
|
// AllClasses returns all known traffic classes in display order.
|
||||||
func AllClasses() []TrafficClass {
|
func AllClasses() []TrafficClass {
|
||||||
return []TrafficClass{
|
return []TrafficClass{
|
||||||
ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH,
|
ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH,
|
||||||
ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, ClassUnknown,
|
ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP,
|
||||||
|
ClassUnknown1, ClassUnknown2, ClassUnknown3, ClassUnknown4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -7,10 +7,10 @@ import (
|
|||||||
"github.com/netsynth/netsynth/classify"
|
"github.com/netsynth/netsynth/classify"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBankHas11Layers(t *testing.T) {
|
func TestNewBankHas14Layers(t *testing.T) {
|
||||||
b := NewBank(1.0)
|
b := NewBank(1.0)
|
||||||
if len(b.layers) != 11 {
|
if len(b.layers) != 14 {
|
||||||
t.Errorf("NewBank() has %d layers, want 11", len(b.layers))
|
t.Errorf("NewBank() has %d layers, want 14", len(b.layers))
|
||||||
}
|
}
|
||||||
// Verify each class has exactly one layer
|
// Verify each class has exactly one layer
|
||||||
for _, class := range classify.AllClasses() {
|
for _, class := range classify.AllClasses() {
|
||||||
@@ -76,13 +76,13 @@ func TestRenderWindowNonZeroWithTraffic(t *testing.T) {
|
|||||||
func TestMixerNoClip(t *testing.T) {
|
func TestMixerNoClip(t *testing.T) {
|
||||||
b := NewBank(0.01) // fast EMA to quickly ramp up to near-max amplitude
|
b := NewBank(0.01) // fast EMA to quickly ramp up to near-max amplitude
|
||||||
counts := make(map[classify.TrafficClass]int64)
|
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() {
|
for _, class := range classify.AllClasses() {
|
||||||
counts[class] = 1000
|
counts[class] = 1000
|
||||||
}
|
}
|
||||||
snap := classify.WindowSnapshot{
|
snap := classify.WindowSnapshot{
|
||||||
Counts: counts,
|
Counts: counts,
|
||||||
TotalPackets: 11000,
|
TotalPackets: 14000,
|
||||||
WindowIndex: 0,
|
WindowIndex: 0,
|
||||||
}
|
}
|
||||||
// Render multiple windows to let EMA converge
|
// Render multiple windows to let EMA converge
|
||||||
|
|||||||
+8
-5
@@ -6,8 +6,8 @@ const (
|
|||||||
SampleRate = 44100 // D-13: CD quality
|
SampleRate = 44100 // D-13: CD quality
|
||||||
WindowMs = 500 // matches aggregate.DefaultWindowMs
|
WindowMs = 500 // matches aggregate.DefaultWindowMs
|
||||||
SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050
|
SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050
|
||||||
NumLayers = 11
|
NumLayers = 14
|
||||||
GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0909
|
GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0714
|
||||||
WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude
|
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.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.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},
|
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).
|
// D-05: Unknown buckets in 850-1100 Hz dissonant range, detuned intervals
|
||||||
// Creates 3 Hz beating when SMTP is present = dissonant "doesn't belong" signal.
|
// D-06: Same dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} for all 4
|
||||||
classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0},
|
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},
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -17,8 +17,9 @@ func TestAllClassesHaveConfig(t *testing.T) {
|
|||||||
|
|
||||||
func TestFrequenciesInRange(t *testing.T) {
|
func TestFrequenciesInRange(t *testing.T) {
|
||||||
for class, cfg := range synth.ClassFreqConfigs {
|
for class, cfg := range synth.ClassFreqConfigs {
|
||||||
if cfg.BaseHz < 60 || cfg.BaseHz > 800 {
|
// D-05: unknown buckets use 850-1100 Hz dissonant range; known classes use 60-800 Hz
|
||||||
t.Errorf("class %q BaseHz=%.1f is out of range [60, 800]", class, cfg.BaseHz)
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user