Poindexter/kdtree_analytics_test.go

663 lines
18 KiB
Go
Raw Normal View History

package poindexter
import (
"math"
"testing"
"time"
)
// ============================================================================
// TreeAnalytics Tests
// ============================================================================
func TestNewTreeAnalytics(t *testing.T) {
a := NewTreeAnalytics()
if a == nil {
t.Fatal("NewTreeAnalytics returned nil")
}
if a.QueryCount.Load() != 0 {
t.Errorf("expected QueryCount=0, got %d", a.QueryCount.Load())
}
if a.InsertCount.Load() != 0 {
t.Errorf("expected InsertCount=0, got %d", a.InsertCount.Load())
}
if a.CreatedAt.IsZero() {
t.Error("CreatedAt should not be zero")
}
}
func TestTreeAnalyticsRecordQuery(t *testing.T) {
a := NewTreeAnalytics()
a.RecordQuery(1000) // 1μs
a.RecordQuery(2000) // 2μs
a.RecordQuery(500) // 0.5μs
if a.QueryCount.Load() != 3 {
t.Errorf("expected QueryCount=3, got %d", a.QueryCount.Load())
}
if a.TotalQueryTimeNs.Load() != 3500 {
t.Errorf("expected TotalQueryTimeNs=3500, got %d", a.TotalQueryTimeNs.Load())
}
if a.MinQueryTimeNs.Load() != 500 {
t.Errorf("expected MinQueryTimeNs=500, got %d", a.MinQueryTimeNs.Load())
}
if a.MaxQueryTimeNs.Load() != 2000 {
t.Errorf("expected MaxQueryTimeNs=2000, got %d", a.MaxQueryTimeNs.Load())
}
if a.LastQueryTimeNs.Load() != 500 {
t.Errorf("expected LastQueryTimeNs=500, got %d", a.LastQueryTimeNs.Load())
}
}
func TestTreeAnalyticsSnapshot(t *testing.T) {
a := NewTreeAnalytics()
a.RecordQuery(1000)
a.RecordQuery(3000)
a.RecordInsert()
a.RecordInsert()
a.RecordDelete()
a.RecordRebuild()
snap := a.Snapshot()
if snap.QueryCount != 2 {
t.Errorf("expected QueryCount=2, got %d", snap.QueryCount)
}
if snap.InsertCount != 2 {
t.Errorf("expected InsertCount=2, got %d", snap.InsertCount)
}
if snap.DeleteCount != 1 {
t.Errorf("expected DeleteCount=1, got %d", snap.DeleteCount)
}
if snap.AvgQueryTimeNs != 2000 {
t.Errorf("expected AvgQueryTimeNs=2000, got %d", snap.AvgQueryTimeNs)
}
if snap.MinQueryTimeNs != 1000 {
t.Errorf("expected MinQueryTimeNs=1000, got %d", snap.MinQueryTimeNs)
}
if snap.MaxQueryTimeNs != 3000 {
t.Errorf("expected MaxQueryTimeNs=3000, got %d", snap.MaxQueryTimeNs)
}
if snap.BackendRebuildCnt != 1 {
t.Errorf("expected BackendRebuildCnt=1, got %d", snap.BackendRebuildCnt)
}
}
func TestTreeAnalyticsReset(t *testing.T) {
a := NewTreeAnalytics()
a.RecordQuery(1000)
a.RecordInsert()
a.RecordDelete()
a.Reset()
if a.QueryCount.Load() != 0 {
t.Errorf("expected QueryCount=0 after reset, got %d", a.QueryCount.Load())
}
if a.InsertCount.Load() != 0 {
t.Errorf("expected InsertCount=0 after reset, got %d", a.InsertCount.Load())
}
if a.DeleteCount.Load() != 0 {
t.Errorf("expected DeleteCount=0 after reset, got %d", a.DeleteCount.Load())
}
}
// ============================================================================
// PeerAnalytics Tests
// ============================================================================
func TestNewPeerAnalytics(t *testing.T) {
p := NewPeerAnalytics()
if p == nil {
t.Fatal("NewPeerAnalytics returned nil")
}
}
func TestPeerAnalyticsRecordSelection(t *testing.T) {
p := NewPeerAnalytics()
p.RecordSelection("peer1", 0.5)
p.RecordSelection("peer1", 0.3)
p.RecordSelection("peer2", 1.0)
stats := p.GetPeerStats("peer1")
if stats.SelectionCount != 2 {
t.Errorf("expected peer1 SelectionCount=2, got %d", stats.SelectionCount)
}
if math.Abs(stats.AvgDistance-0.4) > 0.001 {
t.Errorf("expected peer1 AvgDistance~0.4, got %f", stats.AvgDistance)
}
stats2 := p.GetPeerStats("peer2")
if stats2.SelectionCount != 1 {
t.Errorf("expected peer2 SelectionCount=1, got %d", stats2.SelectionCount)
}
}
func TestPeerAnalyticsGetAllPeerStats(t *testing.T) {
p := NewPeerAnalytics()
p.RecordSelection("peer1", 0.5)
p.RecordSelection("peer1", 0.5)
p.RecordSelection("peer2", 1.0)
p.RecordSelection("peer3", 0.8)
p.RecordSelection("peer3", 0.8)
p.RecordSelection("peer3", 0.8)
all := p.GetAllPeerStats()
if len(all) != 3 {
t.Errorf("expected 3 peers, got %d", len(all))
}
// Should be sorted by selection count descending
if all[0].PeerID != "peer3" || all[0].SelectionCount != 3 {
t.Errorf("expected first peer to be peer3 with count=3, got %s with count=%d",
all[0].PeerID, all[0].SelectionCount)
}
}
func TestPeerAnalyticsGetTopPeers(t *testing.T) {
p := NewPeerAnalytics()
for i := 0; i < 5; i++ {
p.RecordSelection("peer1", 0.5)
}
for i := 0; i < 3; i++ {
p.RecordSelection("peer2", 0.3)
}
p.RecordSelection("peer3", 0.1)
top := p.GetTopPeers(2)
if len(top) != 2 {
t.Errorf("expected 2 top peers, got %d", len(top))
}
if top[0].PeerID != "peer1" {
t.Errorf("expected top peer to be peer1, got %s", top[0].PeerID)
}
if top[1].PeerID != "peer2" {
t.Errorf("expected second peer to be peer2, got %s", top[1].PeerID)
}
}
func TestPeerAnalyticsReset(t *testing.T) {
p := NewPeerAnalytics()
p.RecordSelection("peer1", 0.5)
p.Reset()
stats := p.GetAllPeerStats()
if len(stats) != 0 {
t.Errorf("expected 0 peers after reset, got %d", len(stats))
}
}
// ============================================================================
// DistributionStats Tests
// ============================================================================
func TestComputeDistributionStatsEmpty(t *testing.T) {
stats := ComputeDistributionStats(nil)
if stats.Count != 0 {
t.Errorf("expected Count=0 for empty input, got %d", stats.Count)
}
}
func TestComputeDistributionStatsSingle(t *testing.T) {
stats := ComputeDistributionStats([]float64{5.0})
if stats.Count != 1 {
t.Errorf("expected Count=1, got %d", stats.Count)
}
if stats.Min != 5.0 || stats.Max != 5.0 {
t.Errorf("expected Min=Max=5.0, got Min=%f, Max=%f", stats.Min, stats.Max)
}
if stats.Mean != 5.0 {
t.Errorf("expected Mean=5.0, got %f", stats.Mean)
}
if stats.Median != 5.0 {
t.Errorf("expected Median=5.0, got %f", stats.Median)
}
}
func TestComputeDistributionStatsMultiple(t *testing.T) {
// Values: 1, 2, 3, 4, 5 - mean=3, median=3
stats := ComputeDistributionStats([]float64{1, 2, 3, 4, 5})
if stats.Count != 5 {
t.Errorf("expected Count=5, got %d", stats.Count)
}
if stats.Min != 1.0 {
t.Errorf("expected Min=1.0, got %f", stats.Min)
}
if stats.Max != 5.0 {
t.Errorf("expected Max=5.0, got %f", stats.Max)
}
if stats.Mean != 3.0 {
t.Errorf("expected Mean=3.0, got %f", stats.Mean)
}
if stats.Median != 3.0 {
t.Errorf("expected Median=3.0, got %f", stats.Median)
}
// Variance = 2.0 for this dataset
if math.Abs(stats.Variance-2.0) > 0.001 {
t.Errorf("expected Variance~2.0, got %f", stats.Variance)
}
}
func TestComputeDistributionStatsPercentiles(t *testing.T) {
// 100 values from 0 to 99
values := make([]float64, 100)
for i := 0; i < 100; i++ {
values[i] = float64(i)
}
stats := ComputeDistributionStats(values)
// P25 should be around 24.75, P75 around 74.25
if math.Abs(stats.P25-24.75) > 0.1 {
t.Errorf("expected P25~24.75, got %f", stats.P25)
}
if math.Abs(stats.P75-74.25) > 0.1 {
t.Errorf("expected P75~74.25, got %f", stats.P75)
}
if math.Abs(stats.P90-89.1) > 0.1 {
t.Errorf("expected P90~89.1, got %f", stats.P90)
}
}
// ============================================================================
// AxisDistribution Tests
// ============================================================================
func TestComputeAxisDistributions(t *testing.T) {
points := []KDPoint[string]{
{ID: "a", Coords: []float64{1.0, 10.0}},
{ID: "b", Coords: []float64{2.0, 20.0}},
{ID: "c", Coords: []float64{3.0, 30.0}},
}
dists := ComputeAxisDistributions(points, []string{"x", "y"})
if len(dists) != 2 {
t.Errorf("expected 2 axis distributions, got %d", len(dists))
}
if dists[0].Axis != 0 || dists[0].Name != "x" {
t.Errorf("expected first axis=0, name=x, got axis=%d, name=%s", dists[0].Axis, dists[0].Name)
}
if dists[0].Stats.Mean != 2.0 {
t.Errorf("expected axis 0 mean=2.0, got %f", dists[0].Stats.Mean)
}
if dists[1].Axis != 1 || dists[1].Name != "y" {
t.Errorf("expected second axis=1, name=y, got axis=%d, name=%s", dists[1].Axis, dists[1].Name)
}
if dists[1].Stats.Mean != 20.0 {
t.Errorf("expected axis 1 mean=20.0, got %f", dists[1].Stats.Mean)
}
}
// ============================================================================
// NAT Routing Tests
// ============================================================================
func TestPeerQualityScoreDefaults(t *testing.T) {
// Perfect peer
perfect := NATRoutingMetrics{
ConnectivityScore: 1.0,
SymmetryScore: 1.0,
RelayProbability: 0.0,
DirectSuccessRate: 1.0,
AvgRTTMs: 10,
JitterMs: 5,
PacketLossRate: 0.0,
BandwidthMbps: 100,
NATType: string(NATTypeOpen),
}
score := PeerQualityScore(perfect, nil)
if score < 0.9 {
t.Errorf("expected perfect peer score > 0.9, got %f", score)
}
// Poor peer
poor := NATRoutingMetrics{
ConnectivityScore: 0.2,
SymmetryScore: 0.1,
RelayProbability: 0.9,
DirectSuccessRate: 0.1,
AvgRTTMs: 500,
JitterMs: 100,
PacketLossRate: 0.5,
BandwidthMbps: 1,
NATType: string(NATTypeSymmetric),
}
poorScore := PeerQualityScore(poor, nil)
if poorScore > 0.5 {
t.Errorf("expected poor peer score < 0.5, got %f", poorScore)
}
if poorScore >= score {
t.Error("poor peer should have lower score than perfect peer")
}
}
func TestPeerQualityScoreCustomWeights(t *testing.T) {
metrics := NATRoutingMetrics{
ConnectivityScore: 1.0,
SymmetryScore: 0.5,
RelayProbability: 0.0,
DirectSuccessRate: 1.0,
AvgRTTMs: 100,
JitterMs: 10,
PacketLossRate: 0.01,
BandwidthMbps: 50,
NATType: string(NATTypeFullCone),
}
// Weight latency heavily
latencyWeights := QualityWeights{
Latency: 10.0,
Jitter: 1.0,
PacketLoss: 1.0,
Bandwidth: 1.0,
Connectivity: 1.0,
Symmetry: 1.0,
DirectSuccess: 1.0,
RelayPenalty: 1.0,
NATType: 1.0,
}
scoreLatency := PeerQualityScore(metrics, &latencyWeights)
// Weight bandwidth heavily
bandwidthWeights := QualityWeights{
Latency: 1.0,
Jitter: 1.0,
PacketLoss: 1.0,
Bandwidth: 10.0,
Connectivity: 1.0,
Symmetry: 1.0,
DirectSuccess: 1.0,
RelayPenalty: 1.0,
NATType: 1.0,
}
scoreBandwidth := PeerQualityScore(metrics, &bandwidthWeights)
// Scores should differ based on weights
if scoreLatency == scoreBandwidth {
t.Error("different weights should produce different scores")
}
}
func TestDefaultQualityWeights(t *testing.T) {
w := DefaultQualityWeights()
if w.Latency <= 0 {
t.Error("Latency weight should be positive")
}
if w.Total() <= 0 {
t.Error("Total weights should be positive")
}
}
func TestNatTypeScore(t *testing.T) {
tests := []struct {
natType string
minScore float64
maxScore float64
}{
{string(NATTypeOpen), 0.9, 1.0},
{string(NATTypeFullCone), 0.8, 1.0},
{string(NATTypeSymmetric), 0.2, 0.4},
{string(NATTypeRelayRequired), 0.0, 0.1},
{"unknown", 0.3, 0.5},
}
for _, tc := range tests {
score := natTypeScore(tc.natType)
if score < tc.minScore || score > tc.maxScore {
t.Errorf("natType %s: expected score in [%f, %f], got %f",
tc.natType, tc.minScore, tc.maxScore, score)
}
}
}
// ============================================================================
// Trust Score Tests
// ============================================================================
func TestComputeTrustScoreNewPeer(t *testing.T) {
// New peer with no history
metrics := TrustMetrics{
SuccessfulTransactions: 0,
FailedTransactions: 0,
AgeSeconds: 86400, // 1 day old
}
score := ComputeTrustScore(metrics)
// New peer should get moderate trust
if score < 0.4 || score > 0.7 {
t.Errorf("expected new peer score in [0.4, 0.7], got %f", score)
}
}
func TestComputeTrustScoreGoodPeer(t *testing.T) {
metrics := TrustMetrics{
SuccessfulTransactions: 100,
FailedTransactions: 2,
AgeSeconds: 86400 * 30, // 30 days
VouchCount: 5,
FlagCount: 0,
LastSuccessAt: time.Now(),
}
score := ComputeTrustScore(metrics)
if score < 0.8 {
t.Errorf("expected good peer score > 0.8, got %f", score)
}
}
func TestComputeTrustScoreBadPeer(t *testing.T) {
metrics := TrustMetrics{
SuccessfulTransactions: 5,
FailedTransactions: 20,
AgeSeconds: 86400,
VouchCount: 0,
FlagCount: 10,
}
score := ComputeTrustScore(metrics)
if score > 0.3 {
t.Errorf("expected bad peer score < 0.3, got %f", score)
}
}
// ============================================================================
// Feature Normalization Tests
// ============================================================================
func TestStandardPeerFeaturesToSlice(t *testing.T) {
features := StandardPeerFeatures{
LatencyMs: 100,
HopCount: 5,
GeoDistanceKm: 1000,
TrustScore: 0.9,
BandwidthMbps: 50,
PacketLossRate: 0.01,
ConnectivityPct: 95,
NATScore: 0.8,
}
slice := features.ToFeatureSlice()
if len(slice) != 8 {
t.Errorf("expected 8 features, got %d", len(slice))
}
// TrustScore should be inverted (0.9 -> 0.1)
if math.Abs(slice[3]-0.1) > 0.001 {
t.Errorf("expected inverted trust score ~0.1, got %f", slice[3])
}
}
func TestNormalizePeerFeatures(t *testing.T) {
features := []float64{100, 5, 1000, 0.5, 50, 0.01, 50, 0.5}
ranges := DefaultPeerFeatureRanges()
normalized := NormalizePeerFeatures(features, ranges)
for i, v := range normalized {
if v < 0 || v > 1 {
t.Errorf("normalized feature %d out of range [0,1]: %f", i, v)
}
}
}
func TestWeightedPeerFeatures(t *testing.T) {
normalized := []float64{0.5, 0.5, 0.5, 0.5}
weights := []float64{1.0, 2.0, 0.5, 1.5}
weighted := WeightedPeerFeatures(normalized, weights)
expected := []float64{0.5, 1.0, 0.25, 0.75}
for i, v := range weighted {
if math.Abs(v-expected[i]) > 0.001 {
t.Errorf("weighted feature %d: expected %f, got %f", i, expected[i], v)
}
}
}
func TestStandardFeatureLabels(t *testing.T) {
labels := StandardFeatureLabels()
if len(labels) != 8 {
t.Errorf("expected 8 feature labels, got %d", len(labels))
}
}
// ============================================================================
// KDTree Analytics Integration Tests
// ============================================================================
func TestKDTreeAnalyticsIntegration(t *testing.T) {
points := []KDPoint[string]{
{ID: "a", Coords: []float64{0, 0}, Value: "A"},
{ID: "b", Coords: []float64{1, 1}, Value: "B"},
{ID: "c", Coords: []float64{2, 2}, Value: "C"},
}
tree, err := NewKDTree(points)
if err != nil {
t.Fatal(err)
}
// Check initial analytics
if tree.Analytics() == nil {
t.Fatal("Analytics should not be nil")
}
if tree.PeerAnalytics() == nil {
t.Fatal("PeerAnalytics should not be nil")
}
// Perform queries
tree.Nearest([]float64{0.1, 0.1})
tree.Nearest([]float64{0.9, 0.9})
tree.KNearest([]float64{0.5, 0.5}, 2)
snap := tree.GetAnalyticsSnapshot()
if snap.QueryCount != 3 {
t.Errorf("expected QueryCount=3, got %d", snap.QueryCount)
}
if snap.InsertCount != 0 {
t.Errorf("expected InsertCount=0, got %d", snap.InsertCount)
}
// Check peer stats
peerStats := tree.GetPeerStats()
if len(peerStats) == 0 {
t.Error("expected some peer stats after queries")
}
// Peer 'a' should have been selected for query [0.1, 0.1]
var foundA bool
for _, ps := range peerStats {
if ps.PeerID == "a" && ps.SelectionCount > 0 {
foundA = true
break
}
}
if !foundA {
t.Error("expected peer 'a' to be recorded in analytics")
}
// Test top peers
topPeers := tree.GetTopPeers(1)
if len(topPeers) != 1 {
t.Errorf("expected 1 top peer, got %d", len(topPeers))
}
// Test insert analytics
tree.Insert(KDPoint[string]{ID: "d", Coords: []float64{3, 3}, Value: "D"})
snap = tree.GetAnalyticsSnapshot()
if snap.InsertCount != 1 {
t.Errorf("expected InsertCount=1, got %d", snap.InsertCount)
}
// Test delete analytics
tree.DeleteByID("d")
snap = tree.GetAnalyticsSnapshot()
if snap.DeleteCount != 1 {
t.Errorf("expected DeleteCount=1, got %d", snap.DeleteCount)
}
// Test reset
tree.ResetAnalytics()
snap = tree.GetAnalyticsSnapshot()
if snap.QueryCount != 0 || snap.InsertCount != 0 || snap.DeleteCount != 0 {
t.Error("expected all counts to be 0 after reset")
}
}
func TestKDTreeDistanceDistribution(t *testing.T) {
points := []KDPoint[string]{
{ID: "a", Coords: []float64{0, 10}, Value: "A"},
{ID: "b", Coords: []float64{1, 20}, Value: "B"},
{ID: "c", Coords: []float64{2, 30}, Value: "C"},
}
tree, _ := NewKDTree(points)
dists := tree.ComputeDistanceDistribution([]string{"x", "y"})
if len(dists) != 2 {
t.Errorf("expected 2 axis distributions, got %d", len(dists))
}
if dists[0].Name != "x" || dists[0].Stats.Mean != 1.0 {
t.Errorf("unexpected axis 0 distribution: name=%s, mean=%f",
dists[0].Name, dists[0].Stats.Mean)
}
if dists[1].Name != "y" || dists[1].Stats.Mean != 20.0 {
t.Errorf("unexpected axis 1 distribution: name=%s, mean=%f",
dists[1].Name, dists[1].Stats.Mean)
}
}
func TestKDTreePointsExport(t *testing.T) {
points := []KDPoint[string]{
{ID: "a", Coords: []float64{0, 0}, Value: "A"},
{ID: "b", Coords: []float64{1, 1}, Value: "B"},
}
tree, _ := NewKDTree(points)
exported := tree.Points()
if len(exported) != 2 {
t.Errorf("expected 2 points, got %d", len(exported))
}
// Verify it's a copy, not a reference
exported[0].ID = "modified"
original := tree.Points()
if original[0].ID == "modified" {
t.Error("Points() should return a copy, not a reference")
}
}
func TestKDTreeBackend(t *testing.T) {
tree, _ := NewKDTreeFromDim[string](2)
backend := tree.Backend()
if backend != BackendLinear && backend != BackendGonum {
t.Errorf("unexpected backend: %s", backend)
}
}