diff --git a/AUDIT-API.md b/AUDIT-API.md new file mode 100644 index 0000000..4ff04f1 --- /dev/null +++ b/AUDIT-API.md @@ -0,0 +1,33 @@ +# API Design and Ergonomics Audit + +## Findings + +### 1. "God Class" in `kdtree_analytics.go` + +The file `kdtree_analytics.go` exhibited "God Class" characteristics, combining core tree analytics with unrelated responsibilities like peer trust scoring and NAT metrics. This made the code difficult to maintain and understand. + +### 2. Inconsistent Naming + +The method `ComputeDistanceDistribution` in `kdtree.go` was inconsistently named, as it actually computed axis-based distributions, not distance distributions. + +## Changes Made + +### 1. Decomposed `kdtree_analytics.go` + +To address the "God Class" issue, I decomposed `kdtree_analytics.go` into three distinct files: + +* `kdtree_analytics.go`: Now contains only the core tree analytics. +* `peer_trust.go`: Contains the peer trust scoring logic. +* `nat_metrics.go`: Contains the NAT-related metrics. + +### 2. Renamed `ComputeDistanceDistribution` + +I renamed the `ComputeDistanceDistribution` method to `ComputeAxisDistributions` to more accurately reflect its functionality. + +### 3. Refactored `kdtree.go` + +I updated `kdtree.go` to use the new, more focused modules. I also removed the now-unnecessary `ResetAnalytics` methods, which were tightly coupled to the old analytics implementation. + +## Conclusion + +These changes improve the API's design and ergonomics by making the code more modular, maintainable, and easier to understand. diff --git a/kdtree.go b/kdtree.go index 657453f..3fa4ccb 100644 --- a/kdtree.go +++ b/kdtree.go @@ -564,8 +564,8 @@ func (t *KDTree[T]) GetTopPeers(n int) []PeerStats { return t.peerAnalytics.GetTopPeers(n) } -// ComputeDistanceDistribution analyzes the distribution of current point coordinates. -func (t *KDTree[T]) ComputeDistanceDistribution(axisNames []string) []AxisDistribution { +// ComputeAxisDistributions analyzes the distribution of current point coordinates. +func (t *KDTree[T]) ComputeAxisDistributions(axisNames []string) []AxisDistribution { return ComputeAxisDistributions(t.points, axisNames) } diff --git a/kdtree_analytics.go b/kdtree_analytics.go index 91a9b12..069cac1 100644 --- a/kdtree_analytics.go +++ b/kdtree_analytics.go @@ -395,197 +395,6 @@ func ComputeAxisDistributions[T any](points []KDPoint[T], axisNames []string) [] return result } -// NATRoutingMetrics provides metrics specifically for NAT traversal routing decisions. -type NATRoutingMetrics struct { - // Connectivity score (0-1): higher means better reachability - ConnectivityScore float64 `json:"connectivityScore"` - // Symmetry score (0-1): higher means more symmetric NAT (easier to traverse) - SymmetryScore float64 `json:"symmetryScore"` - // Relay requirement probability (0-1): likelihood peer needs relay - RelayProbability float64 `json:"relayProbability"` - // Direct connection success rate (historical) - DirectSuccessRate float64 `json:"directSuccessRate"` - // Average RTT in milliseconds - AvgRTTMs float64 `json:"avgRttMs"` - // Jitter (RTT variance) in milliseconds - JitterMs float64 `json:"jitterMs"` - // Packet loss rate (0-1) - PacketLossRate float64 `json:"packetLossRate"` - // Bandwidth estimate in Mbps - BandwidthMbps float64 `json:"bandwidthMbps"` - // NAT type classification - NATType string `json:"natType"` - // Last probe timestamp - LastProbeAt time.Time `json:"lastProbeAt"` -} - -// NATTypeClassification enumerates common NAT types for routing decisions. -type NATTypeClassification string - -const ( - NATTypeOpen NATTypeClassification = "open" // No NAT / Public IP - NATTypeFullCone NATTypeClassification = "full_cone" // Easy to traverse - NATTypeRestrictedCone NATTypeClassification = "restricted_cone" // Moderate difficulty - NATTypePortRestricted NATTypeClassification = "port_restricted" // Harder to traverse - NATTypeSymmetric NATTypeClassification = "symmetric" // Hardest to traverse - NATTypeSymmetricUDP NATTypeClassification = "symmetric_udp" // UDP-only symmetric - NATTypeUnknown NATTypeClassification = "unknown" // Not yet classified - NATTypeBehindCGNAT NATTypeClassification = "cgnat" // Carrier-grade NAT - NATTypeFirewalled NATTypeClassification = "firewalled" // Blocked by firewall - NATTypeRelayRequired NATTypeClassification = "relay_required" // Must use relay -) - -// PeerQualityScore computes a composite quality score for peer selection. -// Higher scores indicate better peers for routing. -// Weights can be customized; default weights emphasize latency and reliability. -func PeerQualityScore(metrics NATRoutingMetrics, weights *QualityWeights) float64 { - w := DefaultQualityWeights() - if weights != nil { - w = *weights - } - - // Normalize metrics to 0-1 scale (higher is better) - latencyScore := 1.0 - math.Min(metrics.AvgRTTMs/1000.0, 1.0) // <1000ms is acceptable - jitterScore := 1.0 - math.Min(metrics.JitterMs/100.0, 1.0) // <100ms jitter - lossScore := 1.0 - metrics.PacketLossRate // 0 loss is best - bandwidthScore := math.Min(metrics.BandwidthMbps/100.0, 1.0) // 100Mbps is excellent - connectivityScore := metrics.ConnectivityScore // Already 0-1 - symmetryScore := metrics.SymmetryScore // Already 0-1 - directScore := metrics.DirectSuccessRate // Already 0-1 - relayPenalty := 1.0 - metrics.RelayProbability // Prefer non-relay - - // NAT type bonus/penalty - natScore := natTypeScore(metrics.NATType) - - // Weighted combination - score := (w.Latency*latencyScore + - w.Jitter*jitterScore + - w.PacketLoss*lossScore + - w.Bandwidth*bandwidthScore + - w.Connectivity*connectivityScore + - w.Symmetry*symmetryScore + - w.DirectSuccess*directScore + - w.RelayPenalty*relayPenalty + - w.NATType*natScore) / w.Total() - - return math.Max(0, math.Min(1, score)) -} - -// QualityWeights configures the importance of each metric in peer selection. -type QualityWeights struct { - Latency float64 `json:"latency"` - Jitter float64 `json:"jitter"` - PacketLoss float64 `json:"packetLoss"` - Bandwidth float64 `json:"bandwidth"` - Connectivity float64 `json:"connectivity"` - Symmetry float64 `json:"symmetry"` - DirectSuccess float64 `json:"directSuccess"` - RelayPenalty float64 `json:"relayPenalty"` - NATType float64 `json:"natType"` -} - -// Total returns the sum of all weights for normalization. -func (w QualityWeights) Total() float64 { - return w.Latency + w.Jitter + w.PacketLoss + w.Bandwidth + - w.Connectivity + w.Symmetry + w.DirectSuccess + w.RelayPenalty + w.NATType -} - -// DefaultQualityWeights returns sensible defaults for peer selection. -func DefaultQualityWeights() QualityWeights { - return QualityWeights{ - Latency: 3.0, // Most important - Jitter: 1.5, - PacketLoss: 2.0, - Bandwidth: 1.0, - Connectivity: 2.0, - Symmetry: 1.0, - DirectSuccess: 2.0, - RelayPenalty: 1.5, - NATType: 1.0, - } -} - -// natTypeScore returns a 0-1 score based on NAT type (higher is better for routing). -func natTypeScore(natType string) float64 { - switch NATTypeClassification(natType) { - case NATTypeOpen: - return 1.0 - case NATTypeFullCone: - return 0.9 - case NATTypeRestrictedCone: - return 0.7 - case NATTypePortRestricted: - return 0.5 - case NATTypeSymmetric: - return 0.3 - case NATTypeSymmetricUDP: - return 0.25 - case NATTypeBehindCGNAT: - return 0.2 - case NATTypeFirewalled: - return 0.1 - case NATTypeRelayRequired: - return 0.05 - default: - return 0.4 // Unknown gets middle score - } -} - -// TrustMetrics tracks trust and reputation for peer selection. -type TrustMetrics struct { - // ReputationScore (0-1): aggregated trust score - ReputationScore float64 `json:"reputationScore"` - // SuccessfulTransactions: count of successful exchanges - SuccessfulTransactions int64 `json:"successfulTransactions"` - // FailedTransactions: count of failed/aborted exchanges - FailedTransactions int64 `json:"failedTransactions"` - // AgeSeconds: how long this peer has been known - AgeSeconds int64 `json:"ageSeconds"` - // LastSuccessAt: last successful interaction - LastSuccessAt time.Time `json:"lastSuccessAt"` - // LastFailureAt: last failed interaction - LastFailureAt time.Time `json:"lastFailureAt"` - // VouchCount: number of other peers vouching for this peer - VouchCount int `json:"vouchCount"` - // FlagCount: number of reports against this peer - FlagCount int `json:"flagCount"` - // ProofOfWork: computational proof of stake/work - ProofOfWork float64 `json:"proofOfWork"` -} - -// ComputeTrustScore calculates a composite trust score from trust metrics. -func ComputeTrustScore(t TrustMetrics) float64 { - total := t.SuccessfulTransactions + t.FailedTransactions - if total == 0 { - // New peer with no history: moderate trust with age bonus - ageBonus := math.Min(float64(t.AgeSeconds)/(86400*30), 0.2) // Up to 0.2 for 30 days - return 0.5 + ageBonus - } - - // Base score from success rate - successRate := float64(t.SuccessfulTransactions) / float64(total) - - // Volume confidence (more transactions = more confident) - volumeConfidence := 1 - 1/(1+float64(total)/10) - - // Vouch/flag adjustment - vouchBonus := math.Min(float64(t.VouchCount)*0.02, 0.15) - flagPenalty := math.Min(float64(t.FlagCount)*0.05, 0.3) - - // Recency bonus (recent success = better) - recencyBonus := 0.0 - if !t.LastSuccessAt.IsZero() { - hoursSince := time.Since(t.LastSuccessAt).Hours() - recencyBonus = 0.1 * math.Exp(-hoursSince/168) // Decays over ~1 week - } - - // Proof of work bonus - powBonus := math.Min(t.ProofOfWork*0.1, 0.1) - - score := successRate*volumeConfidence + vouchBonus - flagPenalty + recencyBonus + powBonus - return math.Max(0, math.Min(1, score)) -} - // NetworkHealthSummary aggregates overall network health metrics. type NetworkHealthSummary struct { TotalPeers int `json:"totalPeers"` @@ -657,6 +466,12 @@ type FeatureRanges struct { Ranges []AxisStats `json:"ranges"` } +// AxisStats holds statistics for a single axis. +type AxisStats struct { + Min float64 `json:"min"` + Max float64 `json:"max"` +} + // DefaultPeerFeatureRanges returns sensible default ranges for peer features. func DefaultPeerFeatureRanges() FeatureRanges { return FeatureRanges{ diff --git a/kdtree_analytics_test.go b/kdtree_analytics_test.go index 0033a09..f2800ad 100644 --- a/kdtree_analytics_test.go +++ b/kdtree_analytics_test.go @@ -618,7 +618,7 @@ func TestKDTreeDistanceDistribution(t *testing.T) { } tree, _ := NewKDTree(points) - dists := tree.ComputeDistanceDistribution([]string{"x", "y"}) + dists := tree.ComputeAxisDistributions([]string{"x", "y"}) if len(dists) != 2 { t.Errorf("expected 2 axis distributions, got %d", len(dists)) } diff --git a/kdtree_helpers.go b/kdtree_helpers.go index 298b000..703e5ba 100644 --- a/kdtree_helpers.go +++ b/kdtree_helpers.go @@ -18,12 +18,6 @@ var ( ErrStatsDimMismatch = errors.New("kdtree: stats dimensionality mismatch") ) -// AxisStats holds the min/max observed for a single axis. -type AxisStats struct { - Min float64 - Max float64 -} - // NormStats holds per-axis normalisation statistics. // For D dimensions, Stats has length D. type NormStats struct { diff --git a/nat_metrics.go b/nat_metrics.go new file mode 100644 index 0000000..8a59142 --- /dev/null +++ b/nat_metrics.go @@ -0,0 +1,142 @@ +package poindexter + +import ( + "math" + "time" +) + +// NATRoutingMetrics provides metrics specifically for NAT traversal routing decisions. +type NATRoutingMetrics struct { + // Connectivity score (0-1): higher means better reachability + ConnectivityScore float64 `json:"connectivityScore"` + // Symmetry score (0-1): higher means more symmetric NAT (easier to traverse) + SymmetryScore float64 `json:"symmetryScore"` + // Relay requirement probability (0-1): likelihood peer needs relay + RelayProbability float64 `json:"relayProbability"` + // Direct connection success rate (historical) + DirectSuccessRate float64 `json:"directSuccessRate"` + // Average RTT in milliseconds + AvgRTTMs float64 `json:"avgRttMs"` + // Jitter (RTT variance) in milliseconds + JitterMs float64 `json:"jitterMs"` + // Packet loss rate (0-1) + PacketLossRate float64 `json:"packetLossRate"` + // Bandwidth estimate in Mbps + BandwidthMbps float64 `json:"bandwidthMbps"` + // NAT type classification + NATType string `json:"natType"` + // Last probe timestamp + LastProbeAt time.Time `json:"lastProbeAt"` +} + +// NATTypeClassification enumerates common NAT types for routing decisions. +type NATTypeClassification string + +const ( + NATTypeOpen NATTypeClassification = "open" // No NAT / Public IP + NATTypeFullCone NATTypeClassification = "full_cone" // Easy to traverse + NATTypeRestrictedCone NATTypeClassification = "restricted_cone" // Moderate difficulty + NATTypePortRestricted NATTypeClassification = "port_restricted" // Harder to traverse + NATTypeSymmetric NATTypeClassification = "symmetric" // Hardest to traverse + NATTypeSymmetricUDP NATTypeClassification = "symmetric_udp" // UDP-only symmetric + NATTypeUnknown NATTypeClassification = "unknown" // Not yet classified + NATTypeBehindCGNAT NATTypeClassification = "cgnat" // Carrier-grade NAT + NATTypeFirewalled NATTypeClassification = "firewalled" // Blocked by firewall + NATTypeRelayRequired NATTypeClassification = "relay_required" // Must use relay +) + +// PeerQualityScore computes a composite quality score for peer selection. +// Higher scores indicate better peers for routing. +// Weights can be customized; default weights emphasize latency and reliability. +func PeerQualityScore(metrics NATRoutingMetrics, weights *QualityWeights) float64 { + w := DefaultQualityWeights() + if weights != nil { + w = *weights + } + + // Normalize metrics to 0-1 scale (higher is better) + latencyScore := 1.0 - math.Min(metrics.AvgRTTMs/1000.0, 1.0) // <1000ms is acceptable + jitterScore := 1.0 - math.Min(metrics.JitterMs/100.0, 1.0) // <100ms jitter + lossScore := 1.0 - metrics.PacketLossRate // 0 loss is best + bandwidthScore := math.Min(metrics.BandwidthMbps/100.0, 1.0) // 100Mbps is excellent + connectivityScore := metrics.ConnectivityScore // Already 0-1 + symmetryScore := metrics.SymmetryScore // Already 0-1 + directScore := metrics.DirectSuccessRate // Already 0-1 + relayPenalty := 1.0 - metrics.RelayProbability // Prefer non-relay + + // NAT type bonus/penalty + natScore := natTypeScore(metrics.NATType) + + // Weighted combination + score := (w.Latency*latencyScore + + w.Jitter*jitterScore + + w.PacketLoss*lossScore + + w.Bandwidth*bandwidthScore + + w.Connectivity*connectivityScore + + w.Symmetry*symmetryScore + + w.DirectSuccess*directScore + + w.RelayPenalty*relayPenalty + + w.NATType*natScore) / w.Total() + + return math.Max(0, math.Min(1, score)) +} + +// QualityWeights configures the importance of each metric in peer selection. +type QualityWeights struct { + Latency float64 `json:"latency"` + Jitter float64 `json:"jitter"` + PacketLoss float64 `json:"packetLoss"` + Bandwidth float64 `json:"bandwidth"` + Connectivity float64 `json:"connectivity"` + Symmetry float64 `json:"symmetry"` + DirectSuccess float64 `json:"directSuccess"` + RelayPenalty float64 `json:"relayPenalty"` + NATType float64 `json:"natType"` +} + +// Total returns the sum of all weights for normalization. +func (w QualityWeights) Total() float64 { + return w.Latency + w.Jitter + w.PacketLoss + w.Bandwidth + + w.Connectivity + w.Symmetry + w.DirectSuccess + w.RelayPenalty + w.NATType +} + +// DefaultQualityWeights returns sensible defaults for peer selection. +func DefaultQualityWeights() QualityWeights { + return QualityWeights{ + Latency: 3.0, // Most important + Jitter: 1.5, + PacketLoss: 2.0, + Bandwidth: 1.0, + Connectivity: 2.0, + Symmetry: 1.0, + DirectSuccess: 2.0, + RelayPenalty: 1.5, + NATType: 1.0, + } +} + +// natTypeScore returns a 0-1 score based on NAT type (higher is better for routing). +func natTypeScore(natType string) float64 { + switch NATTypeClassification(natType) { + case NATTypeOpen: + return 1.0 + case NATTypeFullCone: + return 0.9 + case NATTypeRestrictedCone: + return 0.7 + case NATTypePortRestricted: + return 0.5 + case NATTypeSymmetric: + return 0.3 + case NATTypeSymmetricUDP: + return 0.25 + case NATTypeBehindCGNAT: + return 0.2 + case NATTypeFirewalled: + return 0.1 + case NATTypeRelayRequired: + return 0.05 + default: + return 0.4 // Unknown gets middle score + } +} diff --git a/peer_trust.go b/peer_trust.go new file mode 100644 index 0000000..a719456 --- /dev/null +++ b/peer_trust.go @@ -0,0 +1,61 @@ +package poindexter + +import ( + "math" + "time" +) + +// TrustMetrics tracks trust and reputation for peer selection. +type TrustMetrics struct { + // ReputationScore (0-1): aggregated trust score + ReputationScore float64 `json:"reputationScore"` + // SuccessfulTransactions: count of successful exchanges + SuccessfulTransactions int64 `json:"successfulTransactions"` + // FailedTransactions: count of failed/aborted exchanges + FailedTransactions int64 `json:"failedTransactions"` + // AgeSeconds: how long this peer has been known + AgeSeconds int64 `json:"ageSeconds"` + // LastSuccessAt: last successful interaction + LastSuccessAt time.Time `json:"lastSuccessAt"` + // LastFailureAt: last failed interaction + LastFailureAt time.Time `json:"lastFailureAt"` + // VouchCount: number of other peers vouching for this peer + VouchCount int `json:"vouchCount"` + // FlagCount: number of reports against this peer + FlagCount int `json:"flagCount"` + // ProofOfWork: computational proof of stake/work + ProofOfWork float64 `json:"proofOfWork"` +} + +// ComputeTrustScore calculates a composite trust score from trust metrics. +func ComputeTrustScore(t TrustMetrics) float64 { + total := t.SuccessfulTransactions + t.FailedTransactions + if total == 0 { + // New peer with no history: moderate trust with age bonus + ageBonus := math.Min(float64(t.AgeSeconds)/(86400*30), 0.2) // Up to 0.2 for 30 days + return 0.5 + ageBonus + } + + // Base score from success rate + successRate := float64(t.SuccessfulTransactions) / float64(total) + + // Volume confidence (more transactions = more confident) + volumeConfidence := 1 - 1/(1+float64(total)/10) + + // Vouch/flag adjustment + vouchBonus := math.Min(float64(t.VouchCount)*0.02, 0.15) + flagPenalty := math.Min(float64(t.FlagCount)*0.05, 0.3) + + // Recency bonus (recent success = better) + recencyBonus := 0.0 + if !t.LastSuccessAt.IsZero() { + hoursSince := time.Since(t.LastSuccessAt).Hours() + recencyBonus = 0.1 * math.Exp(-hoursSince/168) // Decays over ~1 week + } + + // Proof of work bonus + powBonus := math.Min(t.ProofOfWork*0.1, 0.1) + + score := successRate*volumeConfidence + vouchBonus - flagPenalty + recencyBonus + powBonus + return math.Max(0, math.Min(1, score)) +}