forked from Snider/Poindexter
663 lines
18 KiB
Go
663 lines
18 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|