Compare commits

..

8 commits

Author SHA1 Message Date
544a3bf5ad Merge pull request 'feat: Add math modules — stats, scale, epsilon, score, signal' (#1) from host-uk/Poindexter:feat/math-modules into main
Some checks failed
CI / build-test-wasm (push) Has been cancelled
CI / build-test-gonum (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
Reviewed-on: #1
2026-02-16 16:19:30 +00:00
Claude
fa998619dc
feat: Add math modules — stats, scale, epsilon, score, signal
Some checks failed
CI / build-test-wasm (pull_request) Has been cancelled
CI / build-test-gonum (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
Centralizes common math operations used across core/go-ai, core/go,
and core/mining into Poindexter as the math pillar (alongside Borg=data,
Enchantrix=encryption).

New modules:
- stats: Sum, Mean, Variance, StdDev, MinMax, IsUnderrepresented
- scale: Lerp, InverseLerp, Remap, RoundToN, Clamp, MinMaxScale
- epsilon: ApproxEqual, ApproxZero
- score: WeightedScore, Ratio, Delta, DeltaPercent
- signal: RampUp, SineWave, Oscillate, Noise (seeded RNG)

235 LOC implementation, 509 LOC tests, zero external deps, WASM-safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:16:49 +00:00
Snider
91146b212a feat: Add CLAUDE.md for project guidance and documentation 2026-01-04 20:04:22 +00:00
Snider
e182d4f497
Merge pull request #4 from Snider/claude/expose-networking-analytics-LNUmx
feat: Expose networking analytics for KD-Tree NAT routing
2025-12-29 18:32:16 +00:00
Claude
298791ef95
feat: Add extended DNS record types (ClouDNS compatible)
- Add support for 13 additional record types: ALIAS, RP, SSHFP, TLSA,
  DS, DNSKEY, NAPTR, LOC, HINFO, CERT, SMIMEA, WR (Web Redirect), SPF
- Add GetDNSRecordTypeInfo() for metadata with RFC references
- Add GetCommonDNSRecordTypes() for commonly used types
- Add structured types for CAA, SSHFP, TLSA, DS, DNSKEY, NAPTR, RP,
  LOC, ALIAS, and WebRedirect records
- Export new functions in WASM bindings
- Update TypeScript definitions and loader.js
- Add comprehensive tests for new record types
2025-12-25 12:38:32 +00:00
Claude
d96c9f266c
feat: Add DNS tools with lookup, RDAP, and external tool links
Add comprehensive DNS tools module for network analysis:

DNS Lookup functionality:
- Support for A, AAAA, MX, TXT, NS, CNAME, SOA, PTR, SRV, CAA records
- DNSLookup() and DNSLookupAll() for single/complete lookups
- Configurable timeouts
- Structured result types for all record types

RDAP (new-style WHOIS) support:
- RDAPLookupDomain() for domain registration data
- RDAPLookupIP() for IP address information
- RDAPLookupASN() for autonomous system info
- Built-in server registry for common TLDs and RIRs
- ParseRDAPResponse() for extracting key domain info

External tool link generators:
- GetExternalToolLinks() - 20+ links for domain analysis
- GetExternalToolLinksIP() - IP-specific analysis tools
- GetExternalToolLinksEmail() - Email/domain verification

Tools include: MXToolbox (DNS, MX, SPF, DMARC, DKIM, blacklist),
DNSChecker, ViewDNS, IntoDNS, DNSViz, SecurityTrails, SSL Labs,
Shodan, Censys, IPInfo, AbuseIPDB, VirusTotal, and more.

WASM bindings expose link generators and RDAP URL builders
for use in TypeScript/browser environments.
2025-12-25 12:26:06 +00:00
Claude
4609b7b2bf
feat: Expose networking analytics for KD-Tree NAT routing
Add comprehensive networking analytics to support peer selection based
on performance and trust metrics for KD-Tree based NAT routing:

- Add kdtree_analytics.go with TreeAnalytics, PeerAnalytics,
  DistributionStats, NATRoutingMetrics, TrustMetrics, and
  QualityWeights structures
- Track query/insert/delete operations with timing statistics
- Track per-peer selection frequency and average distances
- Add PeerQualityScore() for composite peer ranking
- Add ComputeTrustScore() for reputation-based selection
- Add distribution statistics (min, max, mean, median, percentiles)
- Add feature normalization helpers for multi-dimensional peer data

WASM/TypeScript integration:
- Expose all analytics via WASM bindings
- Update TypeScript definitions with full type coverage
- Update loader.js with new API methods
- Update TypeScript demo to showcase analytics features

Includes comprehensive test coverage for all analytics functionality.
2025-12-25 12:18:18 +00:00
299b01ec73
Remove sample results and notes from perf.md
Removed sample benchmark results and notes from performance documentation.
2025-11-04 13:25:45 +00:00
25 changed files with 5408 additions and 266 deletions

166
CLAUDE.md Normal file
View file

@ -0,0 +1,166 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Poindexter is a Go library providing:
- **Sorting utilities** with custom comparators (ints, strings, floats, generic types)
- **KDTree** for nearest-neighbor search with multiple distance metrics (Euclidean, Manhattan, Chebyshev, Cosine)
- **Helper functions** for building normalized/weighted KD points (2D/3D/4D/ND)
- **DNS/RDAP tools** for domain, IP, and ASN lookups
- **Analytics** for tree operations and peer selection tracking
## Build Commands
```bash
# Run all tests
make test
# Run tests with race detector
make race
# Run tests with coverage (writes coverage.out)
make cover
# Run a single test
go test -run TestFunctionName ./...
# Build all packages
make build
# Build with gonum optimized backend
go build -tags=gonum ./...
# Run tests with gonum backend
go test -tags=gonum ./...
# Build WebAssembly module
make wasm-build
# Run benchmarks (writes bench.txt)
make bench
# Run benchmarks for specific backend
make bench-linear # Linear backend
make bench-gonum # Gonum backend (requires -tags=gonum)
# Fuzz testing (default 10s)
make fuzz
make fuzz FUZZTIME=30s
# Lint and static analysis
make lint # requires golangci-lint
make vuln # requires govulncheck
# CI-parity local run
make ci
# Format code
make fmt
# Tidy modules
make tidy
```
## Architecture
### Core Components
**kdtree.go** - Main KDTree implementation:
- `KDTree[T]` generic struct with payload type T
- `KDPoint[T]` represents points with ID, Coords, and Value
- Query methods: `Nearest()`, `KNearest()`, `Radius()`
- Mutation methods: `Insert()`, `DeleteByID()`
- Distance metrics implement `DistanceMetric` interface
**Dual Backend System:**
- `kdtree_gonum.go` (build tag `gonum`) - Optimized median-split KD-tree with branch-and-bound pruning
- `kdtree_gonum_stub.go` (default) - Stubs when gonum tag not set
- Linear backend is always available; gonum backend is default when tag is enabled
- Backend selected via `WithBackend()` option or defaults based on build tags
**kdtree_helpers.go** - Point construction utilities:
- `BuildND()`, `Build2D()`, `Build3D()`, `Build4D()` - Build normalized/weighted KD points
- `ComputeNormStatsND()` - Compute per-axis min/max for normalization
- Supports axis inversion and per-axis weights
**kdtree_analytics.go** - Operational metrics:
- `TreeAnalytics` - Query/insert/delete counts, timing stats
- `PeerAnalytics` - Per-peer selection tracking for DHT/NAT routing
- Distribution statistics and NAT routing metrics
**sort.go** - Sorting utilities:
- Type-specific sorts: `SortInts()`, `SortStrings()`, `SortFloat64s()`
- Generic sorts: `SortBy()`, `SortByKey()`
- Binary search: `BinarySearch()`, `BinarySearchStrings()`
**dns_tools.go** - DNS and RDAP lookup utilities:
- DNS record types and lookup functions
- RDAP (modern WHOIS) for domains, IPs, ASNs
- External tool link generators
### Examples Directory
Runnable examples demonstrating KDTree usage patterns:
- `examples/dht_ping_1d/` - 1D DHT with ping latency
- `examples/kdtree_2d_ping_hop/` - 2D with ping + hop count
- `examples/kdtree_3d_ping_hop_geo/` - 3D with geographic distance
- `examples/kdtree_4d_ping_hop_geo_score/` - 4D with trust scores
- `examples/dht_helpers/` - Convenience wrappers for common DHT schemas
- `examples/wasm-browser-ts/` - TypeScript + Vite browser demo
### WebAssembly
WASM module in `wasm/main.go` exposes KDTree functionality to JavaScript. Build outputs to `dist/`.
## Key Patterns
### KDTree Construction
```go
pts := []poindexter.KDPoint[string]{
{ID: "A", Coords: []float64{0, 0}, Value: "alpha"},
}
tree, err := poindexter.NewKDTree(pts,
poindexter.WithMetric(poindexter.EuclideanDistance{}),
poindexter.WithBackend(poindexter.BackendGonum))
```
### Building Normalized Points
```go
pts, err := poindexter.BuildND(items,
func(p Peer) string { return p.ID }, // ID extractor
[]func(Peer) float64{getPing, getHops}, // Feature extractors
[]float64{1.0, 0.5}, // Per-axis weights
[]bool{false, false}) // Inversion flags
```
### Distance Metrics
- `EuclideanDistance{}` - L2 norm (default)
- `ManhattanDistance{}` - L1 norm
- `ChebyshevDistance{}` - L-infinity (max) norm
- `CosineDistance{}` - 1 - cosine similarity
- `WeightedCosineDistance{Weights: []float64{...}}` - Weighted cosine
Note: Cosine metrics use linear backend only; L2/L1/L-infinity work with gonum backend.
## Testing
Tests are organized by component:
- `kdtree_test.go`, `kdtree_nd_test.go` - Core KDTree tests
- `kdtree_gonum_test.go` - Gonum backend specific tests
- `kdtree_backend_parity_test.go` - Backend equivalence tests
- `fuzz_kdtree_test.go` - Fuzz targets
- `bench_*.go` - Benchmark suites
Coverage targets:
```bash
make cover # Summary
make coverfunc # Per-function breakdown
make cover-kdtree # kdtree.go only
make coverhtml # HTML report
```
## License
EUPL-1.2 (European Union Public Licence v1.2)

1006
dns_tools.go Normal file

File diff suppressed because it is too large Load diff

732
dns_tools_test.go Normal file
View file

@ -0,0 +1,732 @@
package poindexter
import (
"strings"
"testing"
)
// ============================================================================
// External Tool Links Tests
// ============================================================================
func TestGetExternalToolLinks(t *testing.T) {
links := GetExternalToolLinks("example.com")
if links.Target != "example.com" {
t.Errorf("expected target=example.com, got %s", links.Target)
}
if links.Type != "domain" {
t.Errorf("expected type=domain, got %s", links.Type)
}
// Check MXToolbox links
if !strings.Contains(links.MXToolboxDNS, "mxtoolbox.com") {
t.Error("MXToolboxDNS should contain mxtoolbox.com")
}
if !strings.Contains(links.MXToolboxDNS, "example.com") {
t.Error("MXToolboxDNS should contain the domain")
}
if !strings.Contains(links.MXToolboxMX, "mxtoolbox.com") {
t.Error("MXToolboxMX should contain mxtoolbox.com")
}
if !strings.Contains(links.MXToolboxSPF, "spf") {
t.Error("MXToolboxSPF should contain 'spf'")
}
if !strings.Contains(links.MXToolboxDMARC, "dmarc") {
t.Error("MXToolboxDMARC should contain 'dmarc'")
}
// Check DNSChecker links
if !strings.Contains(links.DNSCheckerDNS, "dnschecker.org") {
t.Error("DNSCheckerDNS should contain dnschecker.org")
}
// Check other tools
if !strings.Contains(links.WhoIs, "who.is") {
t.Error("WhoIs should contain who.is")
}
if !strings.Contains(links.SSLLabs, "ssllabs.com") {
t.Error("SSLLabs should contain ssllabs.com")
}
if !strings.Contains(links.VirusTotal, "virustotal.com") {
t.Error("VirusTotal should contain virustotal.com")
}
}
func TestGetExternalToolLinksIP(t *testing.T) {
links := GetExternalToolLinksIP("8.8.8.8")
if links.Target != "8.8.8.8" {
t.Errorf("expected target=8.8.8.8, got %s", links.Target)
}
if links.Type != "ip" {
t.Errorf("expected type=ip, got %s", links.Type)
}
// Check IP-specific links
if !strings.Contains(links.IPInfo, "ipinfo.io") {
t.Error("IPInfo should contain ipinfo.io")
}
if !strings.Contains(links.IPInfo, "8.8.8.8") {
t.Error("IPInfo should contain the IP address")
}
if !strings.Contains(links.AbuseIPDB, "abuseipdb.com") {
t.Error("AbuseIPDB should contain abuseipdb.com")
}
if !strings.Contains(links.Shodan, "shodan.io") {
t.Error("Shodan should contain shodan.io")
}
if !strings.Contains(links.MXToolboxBlacklist, "blacklist") {
t.Error("MXToolboxBlacklist should contain 'blacklist'")
}
}
func TestGetExternalToolLinksEmail(t *testing.T) {
// Test with email address
links := GetExternalToolLinksEmail("test@example.com")
if links.Target != "test@example.com" {
t.Errorf("expected target=test@example.com, got %s", links.Target)
}
if links.Type != "email" {
t.Errorf("expected type=email, got %s", links.Type)
}
// Email tools should use the domain
if !strings.Contains(links.MXToolboxMX, "example.com") {
t.Error("MXToolboxMX should contain the domain from email")
}
if !strings.Contains(links.MXToolboxSPF, "spf") {
t.Error("MXToolboxSPF should contain 'spf'")
}
if !strings.Contains(links.MXToolboxDMARC, "dmarc") {
t.Error("MXToolboxDMARC should contain 'dmarc'")
}
// Test with just domain
links2 := GetExternalToolLinksEmail("example.org")
if links2.Target != "example.org" {
t.Errorf("expected target=example.org, got %s", links2.Target)
}
}
func TestGetExternalToolLinksSpecialChars(t *testing.T) {
// Test URL encoding
links := GetExternalToolLinks("test-domain.example.com")
if !strings.Contains(links.MXToolboxDNS, "test-domain.example.com") {
t.Error("Should handle hyphens in domain")
}
}
// ============================================================================
// DNS Lookup Tests (Unit tests for structure, not network)
// ============================================================================
func TestDNSRecordTypes(t *testing.T) {
types := []DNSRecordType{
DNSRecordA,
DNSRecordAAAA,
DNSRecordMX,
DNSRecordTXT,
DNSRecordNS,
DNSRecordCNAME,
DNSRecordSOA,
DNSRecordPTR,
DNSRecordSRV,
DNSRecordCAA,
}
expected := []string{"A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA", "PTR", "SRV", "CAA"}
for i, typ := range types {
if string(typ) != expected[i] {
t.Errorf("expected type %s, got %s", expected[i], typ)
}
}
}
func TestDNSRecordTypesExtended(t *testing.T) {
// Test all ClouDNS record types are defined
types := []DNSRecordType{
DNSRecordALIAS,
DNSRecordRP,
DNSRecordSSHFP,
DNSRecordTLSA,
DNSRecordDS,
DNSRecordDNSKEY,
DNSRecordNAPTR,
DNSRecordLOC,
DNSRecordHINFO,
DNSRecordCERT,
DNSRecordSMIMEA,
DNSRecordWR,
DNSRecordSPF,
}
expected := []string{"ALIAS", "RP", "SSHFP", "TLSA", "DS", "DNSKEY", "NAPTR", "LOC", "HINFO", "CERT", "SMIMEA", "WR", "SPF"}
for i, typ := range types {
if string(typ) != expected[i] {
t.Errorf("expected type %s, got %s", expected[i], typ)
}
}
}
func TestGetDNSRecordTypeInfo(t *testing.T) {
info := GetDNSRecordTypeInfo()
if len(info) == 0 {
t.Error("GetDNSRecordTypeInfo should return non-empty list")
}
// Check that common types exist
commonFound := 0
for _, r := range info {
if r.Common {
commonFound++
}
// Each entry should have type, name, and description
if r.Type == "" {
t.Error("Record type should not be empty")
}
if r.Name == "" {
t.Error("Record name should not be empty")
}
if r.Description == "" {
t.Error("Record description should not be empty")
}
}
if commonFound < 10 {
t.Errorf("Expected at least 10 common record types, got %d", commonFound)
}
// Check for specific types
typeMap := make(map[DNSRecordType]DNSRecordTypeInfo)
for _, r := range info {
typeMap[r.Type] = r
}
if _, ok := typeMap[DNSRecordA]; !ok {
t.Error("A record type should be in info")
}
if _, ok := typeMap[DNSRecordALIAS]; !ok {
t.Error("ALIAS record type should be in info")
}
if _, ok := typeMap[DNSRecordTLSA]; !ok {
t.Error("TLSA record type should be in info")
}
if _, ok := typeMap[DNSRecordWR]; !ok {
t.Error("WR (Web Redirect) record type should be in info")
}
}
func TestGetCommonDNSRecordTypes(t *testing.T) {
types := GetCommonDNSRecordTypes()
if len(types) == 0 {
t.Error("GetCommonDNSRecordTypes should return non-empty list")
}
// Check that standard types are present
typeSet := make(map[DNSRecordType]bool)
for _, typ := range types {
typeSet[typ] = true
}
if !typeSet[DNSRecordA] {
t.Error("A record should be in common types")
}
if !typeSet[DNSRecordAAAA] {
t.Error("AAAA record should be in common types")
}
if !typeSet[DNSRecordMX] {
t.Error("MX record should be in common types")
}
if !typeSet[DNSRecordTXT] {
t.Error("TXT record should be in common types")
}
if !typeSet[DNSRecordALIAS] {
t.Error("ALIAS record should be in common types")
}
}
func TestGetAllDNSRecordTypes(t *testing.T) {
types := GetAllDNSRecordTypes()
if len(types) < 20 {
t.Errorf("GetAllDNSRecordTypes should return at least 20 types, got %d", len(types))
}
// Check for ClouDNS-specific types
typeSet := make(map[DNSRecordType]bool)
for _, typ := range types {
typeSet[typ] = true
}
if !typeSet[DNSRecordWR] {
t.Error("WR (Web Redirect) should be in all types")
}
if !typeSet[DNSRecordNAPTR] {
t.Error("NAPTR should be in all types")
}
if !typeSet[DNSRecordDS] {
t.Error("DS should be in all types")
}
}
func TestDNSLookupResultStructure(t *testing.T) {
result := DNSLookupResult{
Domain: "example.com",
QueryType: "A",
Records: []DNSRecord{
{Type: DNSRecordA, Name: "example.com", Value: "93.184.216.34"},
},
LookupTimeMs: 50,
}
if result.Domain != "example.com" {
t.Error("Domain should be set")
}
if len(result.Records) != 1 {
t.Error("Should have 1 record")
}
if result.Records[0].Type != DNSRecordA {
t.Error("Record type should be A")
}
}
func TestCompleteDNSLookupStructure(t *testing.T) {
result := CompleteDNSLookup{
Domain: "example.com",
A: []string{"93.184.216.34"},
AAAA: []string{"2606:2800:220:1:248:1893:25c8:1946"},
MX: []MXRecord{
{Host: "mail.example.com", Priority: 10},
},
NS: []string{"ns1.example.com", "ns2.example.com"},
TXT: []string{"v=spf1 include:_spf.example.com ~all"},
}
if result.Domain != "example.com" {
t.Error("Domain should be set")
}
if len(result.A) != 1 {
t.Error("Should have 1 A record")
}
if len(result.AAAA) != 1 {
t.Error("Should have 1 AAAA record")
}
if len(result.MX) != 1 {
t.Error("Should have 1 MX record")
}
if result.MX[0].Priority != 10 {
t.Error("MX priority should be 10")
}
if len(result.NS) != 2 {
t.Error("Should have 2 NS records")
}
}
// ============================================================================
// RDAP Tests (Unit tests for structure, not network)
// ============================================================================
func TestRDAPResponseStructure(t *testing.T) {
resp := RDAPResponse{
LDHName: "example.com",
Status: []string{"active", "client transfer prohibited"},
Events: []RDAPEvent{
{EventAction: "registration", EventDate: "2020-01-01T00:00:00Z"},
{EventAction: "expiration", EventDate: "2025-01-01T00:00:00Z"},
},
Entities: []RDAPEntity{
{Handle: "REGISTRAR-1", Roles: []string{"registrar"}},
},
Nameservers: []RDAPNs{
{LDHName: "ns1.example.com"},
{LDHName: "ns2.example.com"},
},
}
if resp.LDHName != "example.com" {
t.Error("LDHName should be set")
}
if len(resp.Status) != 2 {
t.Error("Should have 2 status values")
}
if len(resp.Events) != 2 {
t.Error("Should have 2 events")
}
if resp.Events[0].EventAction != "registration" {
t.Error("First event should be registration")
}
if len(resp.Nameservers) != 2 {
t.Error("Should have 2 nameservers")
}
}
func TestParseRDAPResponse(t *testing.T) {
resp := RDAPResponse{
LDHName: "example.com",
Status: []string{"active", "dnssecSigned"},
Events: []RDAPEvent{
{EventAction: "registration", EventDate: "2020-01-01T00:00:00Z"},
{EventAction: "expiration", EventDate: "2025-01-01T00:00:00Z"},
{EventAction: "last changed", EventDate: "2024-06-15T00:00:00Z"},
},
Entities: []RDAPEntity{
{Handle: "REGISTRAR-123", Roles: []string{"registrar"}},
},
Nameservers: []RDAPNs{
{LDHName: "ns1.example.com"},
{LDHName: "ns2.example.com"},
},
}
info := ParseRDAPResponse(resp)
if info.Domain != "example.com" {
t.Errorf("expected domain=example.com, got %s", info.Domain)
}
if info.RegistrationDate != "2020-01-01T00:00:00Z" {
t.Errorf("expected registration date, got %s", info.RegistrationDate)
}
if info.ExpirationDate != "2025-01-01T00:00:00Z" {
t.Errorf("expected expiration date, got %s", info.ExpirationDate)
}
if info.UpdatedDate != "2024-06-15T00:00:00Z" {
t.Errorf("expected updated date, got %s", info.UpdatedDate)
}
if info.Registrar != "REGISTRAR-123" {
t.Errorf("expected registrar, got %s", info.Registrar)
}
if len(info.Nameservers) != 2 {
t.Error("Should have 2 nameservers")
}
if !info.DNSSEC {
t.Error("DNSSEC should be true (detected from status)")
}
}
func TestParseRDAPResponseEmpty(t *testing.T) {
resp := RDAPResponse{
LDHName: "test.com",
}
info := ParseRDAPResponse(resp)
if info.Domain != "test.com" {
t.Error("Domain should be set even with minimal response")
}
if info.DNSSEC {
t.Error("DNSSEC should be false with no status")
}
if len(info.Nameservers) != 0 {
t.Error("Nameservers should be empty")
}
}
// ============================================================================
// RDAP Server Tests
// ============================================================================
func TestRDAPServers(t *testing.T) {
// Check that we have servers for common TLDs
commonTLDs := []string{"com", "net", "org", "io"}
for _, tld := range commonTLDs {
if _, ok := rdapServers[tld]; !ok {
t.Errorf("missing RDAP server for TLD: %s", tld)
}
}
// Check RIRs
rirs := []string{"arin", "ripe", "apnic", "afrinic", "lacnic"}
for _, rir := range rirs {
if _, ok := rdapServers[rir]; !ok {
t.Errorf("missing RDAP server for RIR: %s", rir)
}
}
}
// ============================================================================
// MX Record Tests
// ============================================================================
func TestMXRecordStructure(t *testing.T) {
mx := MXRecord{
Host: "mail.example.com",
Priority: 10,
}
if mx.Host != "mail.example.com" {
t.Error("Host should be set")
}
if mx.Priority != 10 {
t.Error("Priority should be 10")
}
}
// ============================================================================
// SRV Record Tests
// ============================================================================
func TestSRVRecordStructure(t *testing.T) {
srv := SRVRecord{
Target: "sipserver.example.com",
Port: 5060,
Priority: 10,
Weight: 100,
}
if srv.Target != "sipserver.example.com" {
t.Error("Target should be set")
}
if srv.Port != 5060 {
t.Error("Port should be 5060")
}
if srv.Priority != 10 {
t.Error("Priority should be 10")
}
if srv.Weight != 100 {
t.Error("Weight should be 100")
}
}
// ============================================================================
// SOA Record Tests
// ============================================================================
func TestSOARecordStructure(t *testing.T) {
soa := SOARecord{
PrimaryNS: "ns1.example.com",
AdminEmail: "admin.example.com",
Serial: 2024010101,
Refresh: 7200,
Retry: 3600,
Expire: 1209600,
MinTTL: 86400,
}
if soa.PrimaryNS != "ns1.example.com" {
t.Error("PrimaryNS should be set")
}
if soa.Serial != 2024010101 {
t.Error("Serial should match")
}
if soa.Refresh != 7200 {
t.Error("Refresh should be 7200")
}
}
// ============================================================================
// Extended Record Type Structure Tests
// ============================================================================
func TestCAARecordStructure(t *testing.T) {
caa := CAARecord{
Flag: 0,
Tag: "issue",
Value: "letsencrypt.org",
}
if caa.Tag != "issue" {
t.Error("Tag should be 'issue'")
}
if caa.Value != "letsencrypt.org" {
t.Error("Value should be set")
}
}
func TestSSHFPRecordStructure(t *testing.T) {
sshfp := SSHFPRecord{
Algorithm: 4, // Ed25519
FPType: 2, // SHA-256
Fingerprint: "abc123def456",
}
if sshfp.Algorithm != 4 {
t.Error("Algorithm should be 4 (Ed25519)")
}
if sshfp.FPType != 2 {
t.Error("FPType should be 2 (SHA-256)")
}
}
func TestTLSARecordStructure(t *testing.T) {
tlsa := TLSARecord{
Usage: 3, // Domain-issued certificate
Selector: 1, // SubjectPublicKeyInfo
MatchingType: 1, // SHA-256
CertData: "abcd1234",
}
if tlsa.Usage != 3 {
t.Error("Usage should be 3")
}
if tlsa.Selector != 1 {
t.Error("Selector should be 1")
}
}
func TestDSRecordStructure(t *testing.T) {
ds := DSRecord{
KeyTag: 12345,
Algorithm: 13, // ECDSAP256SHA256
DigestType: 2, // SHA-256
Digest: "deadbeef",
}
if ds.KeyTag != 12345 {
t.Error("KeyTag should be 12345")
}
if ds.Algorithm != 13 {
t.Error("Algorithm should be 13")
}
}
func TestNAPTRRecordStructure(t *testing.T) {
naptr := NAPTRRecord{
Order: 100,
Preference: 10,
Flags: "U",
Service: "E2U+sip",
Regexp: "!^.*$!sip:info@example.com!",
Replacement: ".",
}
if naptr.Order != 100 {
t.Error("Order should be 100")
}
if naptr.Service != "E2U+sip" {
t.Error("Service should be E2U+sip")
}
}
func TestRPRecordStructure(t *testing.T) {
rp := RPRecord{
Mailbox: "admin.example.com",
TxtDom: "info.example.com",
}
if rp.Mailbox != "admin.example.com" {
t.Error("Mailbox should be set")
}
}
func TestLOCRecordStructure(t *testing.T) {
loc := LOCRecord{
Latitude: 51.5074,
Longitude: -0.1278,
Altitude: 11,
Size: 10,
HPrecis: 10,
VPrecis: 10,
}
if loc.Latitude < 51.5 || loc.Latitude > 51.6 {
t.Error("Latitude should be near 51.5074")
}
}
func TestALIASRecordStructure(t *testing.T) {
alias := ALIASRecord{
Target: "target.example.com",
}
if alias.Target != "target.example.com" {
t.Error("Target should be set")
}
}
func TestWebRedirectRecordStructure(t *testing.T) {
wr := WebRedirectRecord{
URL: "https://www.example.com",
RedirectType: 301,
Frame: false,
}
if wr.URL != "https://www.example.com" {
t.Error("URL should be set")
}
if wr.RedirectType != 301 {
t.Error("RedirectType should be 301")
}
}
// ============================================================================
// Helper Function Tests
// ============================================================================
func TestIsNoSuchHostError(t *testing.T) {
tests := []struct {
errStr string
expected bool
}{
{"no such host", true},
{"NXDOMAIN", true},
{"not found", true},
{"connection refused", false},
{"timeout", false},
{"", false},
}
for _, tc := range tests {
var err error
if tc.errStr != "" {
err = &testError{msg: tc.errStr}
}
result := isNoSuchHostError(err)
if result != tc.expected {
t.Errorf("isNoSuchHostError(%q) = %v, want %v", tc.errStr, result, tc.expected)
}
}
}
type testError struct {
msg string
}
func (e *testError) Error() string {
return e.msg
}
// ============================================================================
// URL Building Tests
// ============================================================================
func TestBuildRDAPURLs(t *testing.T) {
// These test the URL structure, not actual lookups
// Domain URL
domain := "example.com"
expectedDomainPrefix := "https://rdap.org/domain/"
if !strings.HasPrefix("https://rdap.org/domain/"+domain, expectedDomainPrefix) {
t.Error("Domain URL format is incorrect")
}
// IP URL
ip := "8.8.8.8"
expectedIPPrefix := "https://rdap.org/ip/"
if !strings.HasPrefix("https://rdap.org/ip/"+ip, expectedIPPrefix) {
t.Error("IP URL format is incorrect")
}
// ASN URL
asn := "15169"
expectedASNPrefix := "https://rdap.org/autnum/"
if !strings.HasPrefix("https://rdap.org/autnum/"+asn, expectedASNPrefix) {
t.Error("ASN URL format is incorrect")
}
}

View file

@ -72,63 +72,3 @@ BenchmarkNearest_10k_4D_Gonum_Uniform-8 50000 12,300 ns/op 0 B/op 0 alloc
- Local (Gonum): `go test -tags=gonum -bench . -benchmem -run=^$ ./...`
- CI artifacts: download `bench-linear.txt` and `bench-gonum.txt` from the latest workflow run.
- Optional: add historical trend graphs via Benchstat or Codecov integration.
## Sample results (from a recent local run)
Results vary by machine, Go version, and dataset seed. The following run was captured locally and is provided as a reference point.
- Machine: darwin/arm64, Apple M3 Ultra
- Package: `github.com/Snider/Poindexter`
- Command: `go test -bench . -benchmem -run=^$ ./... | tee bench.txt`
Full output:
```
goos: darwin
goarch: arm64
pkg: github.com/Snider/Poindexter
BenchmarkNearest_Linear_Uniform_1k_2D-32 409321 3001 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Gonum_Uniform_1k_2D-32 413823 2888 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Linear_Uniform_10k_2D-32 43053 27809 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Gonum_Uniform_10k_2D-32 42996 27936 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Linear_Uniform_1k_4D-32 326492 3746 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Gonum_Uniform_1k_4D-32 338983 3857 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Linear_Uniform_10k_4D-32 35661 32985 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Gonum_Uniform_10k_4D-32 35678 33388 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Linear_Clustered_1k_2D-32 425220 2874 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Gonum_Clustered_1k_2D-32 420080 2849 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Linear_Clustered_10k_2D-32 43242 27776 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_Gonum_Clustered_10k_2D-32 42392 27889 ns/op 0 B/op 0 allocs/op
BenchmarkKNN10_Linear_Uniform_10k_2D-32 1206 977599 ns/op 164492 B/op 6 allocs/op
BenchmarkKNN10_Gonum_Uniform_10k_2D-32 1239 972501 ns/op 164488 B/op 6 allocs/op
BenchmarkKNN10_Linear_Clustered_10k_2D-32 1219 973242 ns/op 164492 B/op 6 allocs/op
BenchmarkKNN10_Gonum_Clustered_10k_2D-32 1214 971017 ns/op 164488 B/op 6 allocs/op
BenchmarkRadiusMid_Linear_Uniform_10k_2D-32 1279 917692 ns/op 947529 B/op 23 allocs/op
BenchmarkRadiusMid_Gonum_Uniform_10k_2D-32 1299 918176 ns/op 947529 B/op 23 allocs/op
BenchmarkRadiusMid_Linear_Clustered_10k_2D-32 1059 1123281 ns/op 1217866 B/op 24 allocs/op
BenchmarkRadiusMid_Gonum_Clustered_10k_2D-32 1063 1149507 ns/op 1217871 B/op 24 allocs/op
BenchmarkNearest_1k_2D-32 401595 2964 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_10k_2D-32 42129 28229 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_1k_4D-32 365626 3642 ns/op 0 B/op 0 allocs/op
BenchmarkNearest_10k_4D-32 36298 33176 ns/op 0 B/op 0 allocs/op
BenchmarkKNearest10_1k_2D-32 20348 59568 ns/op 17032 B/op 6 allocs/op
BenchmarkKNearest10_10k_2D-32 1224 969093 ns/op 164488 B/op 6 allocs/op
BenchmarkRadiusMid_1k_2D-32 21867 53273 ns/op 77512 B/op 16 allocs/op
BenchmarkRadiusMid_10k_2D-32 1302 933791 ns/op 955720 B/op 23 allocs/op
PASS
ok github.com/Snider/Poindexter 40.102s
PASS
ok github.com/Snider/Poindexter/examples/dht_ping_1d 0.348s
PASS
ok github.com/Snider/Poindexter/examples/kdtree_2d_ping_hop 0.266s
PASS
ok github.com/Snider/Poindexter/examples/kdtree_3d_ping_hop_geo 0.272s
PASS
ok github.com/Snider/Poindexter/examples/kdtree_4d_ping_hop_geo_score 0.269s
```
Notes:
- The first block shows dual-backend benchmarks (Linear vs Gonum) for uniform and clustered datasets at 2D/4D with N=1k/10k.
- The final block includes the legacy single-backend benchmarks for additional sizes; both are useful for comparison.
To compare against the optimized KD backend explicitly, build with `-tags=gonum` and/or download `bench-gonum.txt` from CI artifacts.

View file

@ -0,0 +1,46 @@
# Poindexter Math Expansion
**Date:** 2026-02-16
**Status:** Approved
## Context
Poindexter serves as the math pillar (alongside Borg=data, Enchantrix=encryption) in the Lethean ecosystem. It currently provides KD-Tree spatial queries, 5 distance metrics, sorting utilities, and normalization helpers.
Analysis of math operations scattered across core/go, core/go-ai, and core/mining revealed common patterns that Poindexter should centralize: descriptive statistics, scaling/interpolation, approximate equality, weighted scoring, and signal generation.
## New Modules
### stats.go — Descriptive statistics
Sum, Mean, Variance, StdDev, MinMax, IsUnderrepresented.
Consumers: ml/coverage.go, lab/handler/chart.go
### scale.go — Normalization and interpolation
Lerp, InverseLerp, Remap, RoundToN, Clamp, MinMaxScale.
Consumers: lab/handler/chart.go, i18n/numbers.go
### epsilon.go — Approximate equality
ApproxEqual, ApproxZero.
Consumers: ml/exact.go
### score.go — Weighted composite scoring
Factor type, WeightedScore, Ratio, Delta, DeltaPercent.
Consumers: ml/heuristic.go, ml/compare.go
### signal.go — Time-series primitives
RampUp, SineWave, Oscillate, Noise (seeded RNG).
Consumers: mining/simulated_miner.go
## Constraints
- Zero external dependencies (WASM-compilable)
- Pure Go, stdlib only (math, math/rand)
- Same package (`poindexter`), flat structure
- Table-driven tests for every function
- No changes to existing files
## Not In Scope
- MLX tensor ops (hardware-accelerated, stays in go-ai)
- DNS tools migration to go-netops (separate PR)
- gonum backend integration (future work)

14
epsilon.go Normal file
View file

@ -0,0 +1,14 @@
package poindexter
import "math"
// ApproxEqual returns true if the absolute difference between a and b
// is less than epsilon.
func ApproxEqual(a, b, epsilon float64) bool {
return math.Abs(a-b) < epsilon
}
// ApproxZero returns true if the absolute value of v is less than epsilon.
func ApproxZero(v, epsilon float64) bool {
return math.Abs(v) < epsilon
}

50
epsilon_test.go Normal file
View file

@ -0,0 +1,50 @@
package poindexter
import "testing"
func TestApproxEqual(t *testing.T) {
tests := []struct {
name string
a, b float64
epsilon float64
want bool
}{
{"equal", 1.0, 1.0, 0.01, true},
{"close", 1.0, 1.005, 0.01, true},
{"not_close", 1.0, 1.02, 0.01, false},
{"negative", -1.0, -1.005, 0.01, true},
{"zero", 0, 0.0001, 0.001, true},
{"at_boundary", 1.0, 1.01, 0.01, false},
{"large_epsilon", 100, 200, 150, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ApproxEqual(tt.a, tt.b, tt.epsilon)
if got != tt.want {
t.Errorf("ApproxEqual(%v, %v, %v) = %v, want %v", tt.a, tt.b, tt.epsilon, got, tt.want)
}
})
}
}
func TestApproxZero(t *testing.T) {
tests := []struct {
name string
v float64
epsilon float64
want bool
}{
{"zero", 0, 0.01, true},
{"small_pos", 0.005, 0.01, true},
{"small_neg", -0.005, 0.01, true},
{"not_zero", 0.02, 0.01, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ApproxZero(tt.v, tt.epsilon)
if got != tt.want {
t.Errorf("ApproxZero(%v, %v) = %v, want %v", tt.v, tt.epsilon, got, tt.want)
}
})
}
}

View file

@ -13,19 +13,159 @@ async function run() {
console.log('Poindexter (WASM) version:', await px.version());
// =========================================================================
// Basic KD-Tree operations
// =========================================================================
const tree = await px.newTree(2);
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
await tree.insert({ id: 'b', coords: [1, 0], value: 'B' });
await tree.insert({ id: 'c', coords: [0, 1], value: 'C' });
await tree.insert({ id: 'peer-a', coords: [0, 0], value: 'Peer A' });
await tree.insert({ id: 'peer-b', coords: [1, 0], value: 'Peer B' });
await tree.insert({ id: 'peer-c', coords: [0, 1], value: 'Peer C' });
await tree.insert({ id: 'peer-d', coords: [0.5, 0.5], value: 'Peer D' });
console.log('\n=== Basic Queries ===');
const nn = await tree.nearest([0.9, 0.1]);
console.log('Nearest [0.9,0.1]:', nn);
const kn = await tree.kNearest([0.9, 0.9], 2);
console.log('kNN k=2 [0.9,0.9]:', kn);
const kn = await tree.kNearest([0.5, 0.5], 3);
console.log('kNN k=3 [0.5,0.5]:', kn);
const rad = await tree.radius([0, 0], 1.1);
console.log('Radius r=1.1 [0,0]:', rad);
// =========================================================================
// Analytics Demo
// =========================================================================
console.log('\n=== Tree Analytics ===');
// Perform more queries to generate analytics
for (let i = 0; i < 10; i++) {
await tree.nearest([Math.random(), Math.random()]);
}
await tree.kNearest([0.2, 0.8], 2);
await tree.kNearest([0.7, 0.3], 2);
// Get tree-level analytics
const analytics = await tree.getAnalytics();
console.log('Tree Analytics:', {
queryCount: analytics.queryCount,
insertCount: analytics.insertCount,
avgQueryTimeNs: analytics.avgQueryTimeNs,
minQueryTimeNs: analytics.minQueryTimeNs,
maxQueryTimeNs: analytics.maxQueryTimeNs,
});
// =========================================================================
// Peer Selection Analytics
// =========================================================================
console.log('\n=== Peer Selection Analytics ===');
// Get all peer stats
const peerStats = await tree.getPeerStats();
console.log('All Peer Stats:', peerStats);
// Get top 3 most frequently selected peers
const topPeers = await tree.getTopPeers(3);
console.log('Top 3 Peers:', topPeers);
// =========================================================================
// Axis Distribution Analysis
// =========================================================================
console.log('\n=== Axis Distributions ===');
const axisDists = await tree.getAxisDistributions(['latency', 'hops']);
console.log('Axis Distributions:', axisDists);
// =========================================================================
// NAT Routing / Peer Quality Scoring
// =========================================================================
console.log('\n=== NAT Routing & Peer Quality ===');
// Simulate peer network metrics
const peerMetrics = {
connectivityScore: 0.9,
symmetryScore: 0.8,
relayProbability: 0.1,
directSuccessRate: 0.95,
avgRttMs: 50,
jitterMs: 10,
packetLossRate: 0.01,
bandwidthMbps: 100,
natType: 'full_cone' as const,
};
const qualityScore = await px.computePeerQualityScore(peerMetrics);
console.log('Peer Quality Score (0-1):', qualityScore.toFixed(3));
// Get default quality weights
const defaultWeights = await px.getDefaultQualityWeights();
console.log('Default Quality Weights:', defaultWeights);
// =========================================================================
// Trust Score Calculation
// =========================================================================
console.log('\n=== Trust Score ===');
const trustMetrics = {
reputationScore: 0.8,
successfulTransactions: 150,
failedTransactions: 3,
ageSeconds: 86400 * 30, // 30 days
vouchCount: 5,
flagCount: 0,
proofOfWork: 0.5,
};
const trustScore = await px.computeTrustScore(trustMetrics);
console.log('Trust Score (0-1):', trustScore.toFixed(3));
// =========================================================================
// Distribution Statistics
// =========================================================================
console.log('\n=== Distribution Statistics ===');
// Simulate some distance measurements
const distances = [0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.5, 0.8, 1.2];
const distStats = await px.computeDistributionStats(distances);
console.log('Distance Distribution Stats:', {
count: distStats.count,
min: distStats.min.toFixed(3),
max: distStats.max.toFixed(3),
mean: distStats.mean.toFixed(3),
median: distStats.median.toFixed(3),
stdDev: distStats.stdDev.toFixed(3),
p90: distStats.p90.toFixed(3),
});
// =========================================================================
// Feature Normalization for KD-Tree
// =========================================================================
console.log('\n=== Feature Normalization ===');
// Raw peer features: [latency_ms, hops, geo_km, trust_inv, bw_inv, loss, conn_inv, nat_inv]
const rawFeatures = [100, 5, 500, 0.1, 50, 0.02, 5, 0.1];
// Get default feature ranges
const featureRanges = await px.getDefaultPeerFeatureRanges();
console.log('Feature Labels:', featureRanges.labels);
// Normalize features
const normalizedFeatures = await px.normalizePeerFeatures(rawFeatures);
console.log('Normalized Features:', normalizedFeatures.map((f: number) => f.toFixed(3)));
// Apply custom weights
const customWeights = [1.5, 1.0, 0.5, 1.2, 0.8, 2.0, 1.0, 0.7];
const weightedFeatures = await px.weightedPeerFeatures(normalizedFeatures, customWeights);
console.log('Weighted Features:', weightedFeatures.map((f: number) => f.toFixed(3)));
// =========================================================================
// Analytics Reset
// =========================================================================
console.log('\n=== Analytics Reset ===');
await tree.resetAnalytics();
const resetAnalytics = await tree.getAnalytics();
console.log('After Reset - Query Count:', resetAnalytics.queryCount);
console.log('\n=== Demo Complete ===');
}
run().catch((err) => {

158
kdtree.go
View file

@ -4,6 +4,7 @@ import (
"errors"
"math"
"sort"
"time"
)
var (
@ -218,6 +219,10 @@ type KDTree[T any] struct {
idIndex map[string]int
backend KDBackend
backendData any // opaque handle for backend-specific structures (e.g., gonum tree)
// Analytics tracking (optional, enabled by default)
analytics *TreeAnalytics
peerAnalytics *PeerAnalytics
}
// NewKDTree builds a KDTree from the given points.
@ -259,12 +264,14 @@ func NewKDTree[T any](pts []KDPoint[T], opts ...KDOption) (*KDTree[T], error) {
backend = BackendLinear // tag not enabled → fallback
}
t := &KDTree[T]{
points: append([]KDPoint[T](nil), pts...),
dim: dim,
metric: cfg.metric,
idIndex: idIndex,
backend: backend,
backendData: backendData,
points: append([]KDPoint[T](nil), pts...),
dim: dim,
metric: cfg.metric,
idIndex: idIndex,
backend: backend,
backendData: backendData,
analytics: NewTreeAnalytics(),
peerAnalytics: NewPeerAnalytics(),
}
return t, nil
}
@ -284,12 +291,14 @@ func NewKDTreeFromDim[T any](dim int, opts ...KDOption) (*KDTree[T], error) {
backend = BackendLinear
}
return &KDTree[T]{
points: nil,
dim: dim,
metric: cfg.metric,
idIndex: make(map[string]int),
backend: backend,
backendData: nil,
points: nil,
dim: dim,
metric: cfg.metric,
idIndex: make(map[string]int),
backend: backend,
backendData: nil,
analytics: NewTreeAnalytics(),
peerAnalytics: NewPeerAnalytics(),
}, nil
}
@ -305,12 +314,23 @@ func (t *KDTree[T]) Nearest(query []float64) (KDPoint[T], float64, bool) {
if len(query) != t.dim || t.Len() == 0 {
return KDPoint[T]{}, 0, false
}
start := time.Now()
defer func() {
if t.analytics != nil {
t.analytics.RecordQuery(time.Since(start).Nanoseconds())
}
}()
// Gonum backend (if available and built)
if t.backend == BackendGonum && t.backendData != nil {
if idx, dist, ok := gonumNearest[T](t.backendData, query); ok && idx >= 0 && idx < len(t.points) {
return t.points[idx], dist, true
p := t.points[idx]
if t.peerAnalytics != nil {
t.peerAnalytics.RecordSelection(p.ID, dist)
}
return p, dist, true
}
// fall through to linear scan if backend didnt return a result
// fall through to linear scan if backend didn't return a result
}
bestIdx := -1
bestDist := math.MaxFloat64
@ -324,7 +344,11 @@ func (t *KDTree[T]) Nearest(query []float64) (KDPoint[T], float64, bool) {
if bestIdx < 0 {
return KDPoint[T]{}, 0, false
}
return t.points[bestIdx], bestDist, true
p := t.points[bestIdx]
if t.peerAnalytics != nil {
t.peerAnalytics.RecordSelection(p.ID, bestDist)
}
return p, bestDist, true
}
// KNearest returns up to k nearest neighbors to the query in ascending distance order.
@ -333,6 +357,13 @@ func (t *KDTree[T]) KNearest(query []float64, k int) ([]KDPoint[T], []float64) {
if k <= 0 || len(query) != t.dim || t.Len() == 0 {
return nil, nil
}
start := time.Now()
defer func() {
if t.analytics != nil {
t.analytics.RecordQuery(time.Since(start).Nanoseconds())
}
}()
// Gonum backend path
if t.backend == BackendGonum && t.backendData != nil {
idxs, dists := gonumKNearest[T](t.backendData, query, k)
@ -340,6 +371,9 @@ func (t *KDTree[T]) KNearest(query []float64, k int) ([]KDPoint[T], []float64) {
neighbors := make([]KDPoint[T], len(idxs))
for i := range idxs {
neighbors[i] = t.points[idxs[i]]
if t.peerAnalytics != nil {
t.peerAnalytics.RecordSelection(neighbors[i].ID, dists[i])
}
}
return neighbors, dists
}
@ -362,6 +396,9 @@ func (t *KDTree[T]) KNearest(query []float64, k int) ([]KDPoint[T], []float64) {
for i := 0; i < k; i++ {
neighbors[i] = t.points[tmp[i].idx]
dists[i] = tmp[i].dist
if t.peerAnalytics != nil {
t.peerAnalytics.RecordSelection(neighbors[i].ID, dists[i])
}
}
return neighbors, dists
}
@ -371,6 +408,13 @@ func (t *KDTree[T]) Radius(query []float64, r float64) ([]KDPoint[T], []float64)
if r < 0 || len(query) != t.dim || t.Len() == 0 {
return nil, nil
}
start := time.Now()
defer func() {
if t.analytics != nil {
t.analytics.RecordQuery(time.Since(start).Nanoseconds())
}
}()
// Gonum backend path
if t.backend == BackendGonum && t.backendData != nil {
idxs, dists := gonumRadius[T](t.backendData, query, r)
@ -378,6 +422,9 @@ func (t *KDTree[T]) Radius(query []float64, r float64) ([]KDPoint[T], []float64)
neighbors := make([]KDPoint[T], len(idxs))
for i := range idxs {
neighbors[i] = t.points[idxs[i]]
if t.peerAnalytics != nil {
t.peerAnalytics.RecordSelection(neighbors[i].ID, dists[i])
}
}
return neighbors, dists
}
@ -402,6 +449,9 @@ func (t *KDTree[T]) Radius(query []float64, r float64) ([]KDPoint[T], []float64)
for i := range sel {
neighbors[i] = t.points[sel[i].idx]
dists[i] = sel[i].dist
if t.peerAnalytics != nil {
t.peerAnalytics.RecordSelection(neighbors[i].ID, dists[i])
}
}
return neighbors, dists
}
@ -421,10 +471,17 @@ func (t *KDTree[T]) Insert(p KDPoint[T]) bool {
if p.ID != "" {
t.idIndex[p.ID] = len(t.points) - 1
}
// Record insert in analytics
if t.analytics != nil {
t.analytics.RecordInsert()
}
// Rebuild backend if using Gonum
if t.backend == BackendGonum && hasGonum() {
if bd, err := buildGonumBackend(t.points, t.metric); err == nil {
t.backendData = bd
if t.analytics != nil {
t.analytics.RecordRebuild()
}
} else {
// fallback to linear if rebuild fails
t.backend = BackendLinear
@ -451,10 +508,17 @@ func (t *KDTree[T]) DeleteByID(id string) bool {
}
t.points = t.points[:last]
delete(t.idIndex, id)
// Record delete in analytics
if t.analytics != nil {
t.analytics.RecordDelete()
}
// Rebuild backend if using Gonum
if t.backend == BackendGonum && hasGonum() {
if bd, err := buildGonumBackend(t.points, t.metric); err == nil {
t.backendData = bd
if t.analytics != nil {
t.analytics.RecordRebuild()
}
} else {
// fallback to linear if rebuild fails
t.backend = BackendLinear
@ -463,3 +527,67 @@ func (t *KDTree[T]) DeleteByID(id string) bool {
}
return true
}
// Analytics returns the tree analytics tracker.
// Returns nil if analytics tracking is disabled.
func (t *KDTree[T]) Analytics() *TreeAnalytics {
return t.analytics
}
// PeerAnalytics returns the peer analytics tracker.
// Returns nil if peer analytics tracking is disabled.
func (t *KDTree[T]) PeerAnalytics() *PeerAnalytics {
return t.peerAnalytics
}
// GetAnalyticsSnapshot returns a point-in-time snapshot of tree analytics.
func (t *KDTree[T]) GetAnalyticsSnapshot() TreeAnalyticsSnapshot {
if t.analytics == nil {
return TreeAnalyticsSnapshot{}
}
return t.analytics.Snapshot()
}
// GetPeerStats returns per-peer selection statistics.
func (t *KDTree[T]) GetPeerStats() []PeerStats {
if t.peerAnalytics == nil {
return nil
}
return t.peerAnalytics.GetAllPeerStats()
}
// GetTopPeers returns the top N most frequently selected peers.
func (t *KDTree[T]) GetTopPeers(n int) []PeerStats {
if t.peerAnalytics == nil {
return nil
}
return t.peerAnalytics.GetTopPeers(n)
}
// ComputeDistanceDistribution analyzes the distribution of current point coordinates.
func (t *KDTree[T]) ComputeDistanceDistribution(axisNames []string) []AxisDistribution {
return ComputeAxisDistributions(t.points, axisNames)
}
// ResetAnalytics clears all analytics data.
func (t *KDTree[T]) ResetAnalytics() {
if t.analytics != nil {
t.analytics.Reset()
}
if t.peerAnalytics != nil {
t.peerAnalytics.Reset()
}
}
// Points returns a copy of all points in the tree.
// This is useful for analytics and export operations.
func (t *KDTree[T]) Points() []KDPoint[T] {
result := make([]KDPoint[T], len(t.points))
copy(result, t.points)
return result
}
// Backend returns the active backend type.
func (t *KDTree[T]) Backend() KDBackend {
return t.backend
}

700
kdtree_analytics.go Normal file
View file

@ -0,0 +1,700 @@
package poindexter
import (
"math"
"sort"
"sync"
"sync/atomic"
"time"
)
// TreeAnalytics tracks operational statistics for a KDTree.
// All counters are safe for concurrent reads; use the Reset() method for atomic reset.
type TreeAnalytics struct {
QueryCount atomic.Int64 // Total nearest/kNearest/radius queries
InsertCount atomic.Int64 // Total successful inserts
DeleteCount atomic.Int64 // Total successful deletes
// Timing statistics (nanoseconds)
TotalQueryTimeNs atomic.Int64
LastQueryTimeNs atomic.Int64
MinQueryTimeNs atomic.Int64
MaxQueryTimeNs atomic.Int64
LastQueryAt atomic.Int64 // Unix nanoseconds
CreatedAt time.Time
LastRebuiltAt atomic.Int64 // Unix nanoseconds (for gonum backend rebuilds)
BackendRebuildCnt atomic.Int64 // Number of backend rebuilds
}
// NewTreeAnalytics creates a new analytics tracker.
func NewTreeAnalytics() *TreeAnalytics {
a := &TreeAnalytics{
CreatedAt: time.Now(),
}
a.MinQueryTimeNs.Store(math.MaxInt64)
return a
}
// RecordQuery records a query operation with timing.
func (a *TreeAnalytics) RecordQuery(durationNs int64) {
a.QueryCount.Add(1)
a.TotalQueryTimeNs.Add(durationNs)
a.LastQueryTimeNs.Store(durationNs)
a.LastQueryAt.Store(time.Now().UnixNano())
// Update min/max (best-effort, not strictly atomic)
for {
cur := a.MinQueryTimeNs.Load()
if durationNs >= cur || a.MinQueryTimeNs.CompareAndSwap(cur, durationNs) {
break
}
}
for {
cur := a.MaxQueryTimeNs.Load()
if durationNs <= cur || a.MaxQueryTimeNs.CompareAndSwap(cur, durationNs) {
break
}
}
}
// RecordInsert records a successful insert.
func (a *TreeAnalytics) RecordInsert() {
a.InsertCount.Add(1)
}
// RecordDelete records a successful delete.
func (a *TreeAnalytics) RecordDelete() {
a.DeleteCount.Add(1)
}
// RecordRebuild records a backend rebuild.
func (a *TreeAnalytics) RecordRebuild() {
a.BackendRebuildCnt.Add(1)
a.LastRebuiltAt.Store(time.Now().UnixNano())
}
// Snapshot returns a point-in-time view of the analytics.
func (a *TreeAnalytics) Snapshot() TreeAnalyticsSnapshot {
avgNs := int64(0)
qc := a.QueryCount.Load()
if qc > 0 {
avgNs = a.TotalQueryTimeNs.Load() / qc
}
minNs := a.MinQueryTimeNs.Load()
if minNs == math.MaxInt64 {
minNs = 0
}
return TreeAnalyticsSnapshot{
QueryCount: qc,
InsertCount: a.InsertCount.Load(),
DeleteCount: a.DeleteCount.Load(),
AvgQueryTimeNs: avgNs,
MinQueryTimeNs: minNs,
MaxQueryTimeNs: a.MaxQueryTimeNs.Load(),
LastQueryTimeNs: a.LastQueryTimeNs.Load(),
LastQueryAt: time.Unix(0, a.LastQueryAt.Load()),
CreatedAt: a.CreatedAt,
BackendRebuildCnt: a.BackendRebuildCnt.Load(),
LastRebuiltAt: time.Unix(0, a.LastRebuiltAt.Load()),
}
}
// Reset atomically resets all counters.
func (a *TreeAnalytics) Reset() {
a.QueryCount.Store(0)
a.InsertCount.Store(0)
a.DeleteCount.Store(0)
a.TotalQueryTimeNs.Store(0)
a.LastQueryTimeNs.Store(0)
a.MinQueryTimeNs.Store(math.MaxInt64)
a.MaxQueryTimeNs.Store(0)
a.LastQueryAt.Store(0)
a.BackendRebuildCnt.Store(0)
a.LastRebuiltAt.Store(0)
}
// TreeAnalyticsSnapshot is an immutable snapshot for JSON serialization.
type TreeAnalyticsSnapshot struct {
QueryCount int64 `json:"queryCount"`
InsertCount int64 `json:"insertCount"`
DeleteCount int64 `json:"deleteCount"`
AvgQueryTimeNs int64 `json:"avgQueryTimeNs"`
MinQueryTimeNs int64 `json:"minQueryTimeNs"`
MaxQueryTimeNs int64 `json:"maxQueryTimeNs"`
LastQueryTimeNs int64 `json:"lastQueryTimeNs"`
LastQueryAt time.Time `json:"lastQueryAt"`
CreatedAt time.Time `json:"createdAt"`
BackendRebuildCnt int64 `json:"backendRebuildCount"`
LastRebuiltAt time.Time `json:"lastRebuiltAt"`
}
// PeerAnalytics tracks per-peer selection statistics for NAT routing optimization.
type PeerAnalytics struct {
mu sync.RWMutex
// Per-peer hit counters (peer ID -> selection count)
hitCounts map[string]*atomic.Int64
// Per-peer cumulative distance sums for average calculation
distanceSums map[string]*atomic.Uint64 // stored as bits of float64
// Last selection time per peer
lastSelected map[string]*atomic.Int64 // Unix nano
}
// NewPeerAnalytics creates a new peer analytics tracker.
func NewPeerAnalytics() *PeerAnalytics {
return &PeerAnalytics{
hitCounts: make(map[string]*atomic.Int64),
distanceSums: make(map[string]*atomic.Uint64),
lastSelected: make(map[string]*atomic.Int64),
}
}
// RecordSelection records that a peer was selected/returned in a query result.
func (p *PeerAnalytics) RecordSelection(peerID string, distance float64) {
if peerID == "" {
return
}
p.mu.RLock()
hc, hok := p.hitCounts[peerID]
ds, dok := p.distanceSums[peerID]
ls, lok := p.lastSelected[peerID]
p.mu.RUnlock()
if !hok || !dok || !lok {
p.mu.Lock()
if _, ok := p.hitCounts[peerID]; !ok {
p.hitCounts[peerID] = &atomic.Int64{}
}
if _, ok := p.distanceSums[peerID]; !ok {
p.distanceSums[peerID] = &atomic.Uint64{}
}
if _, ok := p.lastSelected[peerID]; !ok {
p.lastSelected[peerID] = &atomic.Int64{}
}
hc = p.hitCounts[peerID]
ds = p.distanceSums[peerID]
ls = p.lastSelected[peerID]
p.mu.Unlock()
}
hc.Add(1)
// Atomic float add via CAS
for {
old := ds.Load()
oldF := math.Float64frombits(old)
newF := oldF + distance
if ds.CompareAndSwap(old, math.Float64bits(newF)) {
break
}
}
ls.Store(time.Now().UnixNano())
}
// GetPeerStats returns statistics for a specific peer.
func (p *PeerAnalytics) GetPeerStats(peerID string) PeerStats {
p.mu.RLock()
defer p.mu.RUnlock()
hc, hok := p.hitCounts[peerID]
ds, dok := p.distanceSums[peerID]
ls, lok := p.lastSelected[peerID]
stats := PeerStats{PeerID: peerID}
if hok {
stats.SelectionCount = hc.Load()
}
if dok && stats.SelectionCount > 0 {
stats.AvgDistance = math.Float64frombits(ds.Load()) / float64(stats.SelectionCount)
}
if lok {
stats.LastSelectedAt = time.Unix(0, ls.Load())
}
return stats
}
// GetAllPeerStats returns statistics for all tracked peers.
func (p *PeerAnalytics) GetAllPeerStats() []PeerStats {
p.mu.RLock()
defer p.mu.RUnlock()
result := make([]PeerStats, 0, len(p.hitCounts))
for id := range p.hitCounts {
stats := PeerStats{PeerID: id}
if hc := p.hitCounts[id]; hc != nil {
stats.SelectionCount = hc.Load()
}
if ds := p.distanceSums[id]; ds != nil && stats.SelectionCount > 0 {
stats.AvgDistance = math.Float64frombits(ds.Load()) / float64(stats.SelectionCount)
}
if ls := p.lastSelected[id]; ls != nil {
stats.LastSelectedAt = time.Unix(0, ls.Load())
}
result = append(result, stats)
}
// Sort by selection count descending
sort.Slice(result, func(i, j int) bool {
return result[i].SelectionCount > result[j].SelectionCount
})
return result
}
// GetTopPeers returns the top N most frequently selected peers.
func (p *PeerAnalytics) GetTopPeers(n int) []PeerStats {
all := p.GetAllPeerStats()
if n > len(all) {
n = len(all)
}
return all[:n]
}
// Reset clears all peer analytics data.
func (p *PeerAnalytics) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.hitCounts = make(map[string]*atomic.Int64)
p.distanceSums = make(map[string]*atomic.Uint64)
p.lastSelected = make(map[string]*atomic.Int64)
}
// PeerStats holds statistics for a single peer.
type PeerStats struct {
PeerID string `json:"peerId"`
SelectionCount int64 `json:"selectionCount"`
AvgDistance float64 `json:"avgDistance"`
LastSelectedAt time.Time `json:"lastSelectedAt"`
}
// DistributionStats provides statistical analysis of distances in query results.
type DistributionStats struct {
Count int `json:"count"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Mean float64 `json:"mean"`
Median float64 `json:"median"`
StdDev float64 `json:"stdDev"`
P25 float64 `json:"p25"` // 25th percentile
P75 float64 `json:"p75"` // 75th percentile
P90 float64 `json:"p90"` // 90th percentile
P99 float64 `json:"p99"` // 99th percentile
Variance float64 `json:"variance"`
Skewness float64 `json:"skewness"`
SampleSize int `json:"sampleSize"`
ComputedAt time.Time `json:"computedAt"`
}
// ComputeDistributionStats calculates distribution statistics from a slice of distances.
func ComputeDistributionStats(distances []float64) DistributionStats {
n := len(distances)
if n == 0 {
return DistributionStats{ComputedAt: time.Now()}
}
// Sort for percentile calculations
sorted := make([]float64, n)
copy(sorted, distances)
sort.Float64s(sorted)
// Basic stats
min, max := sorted[0], sorted[n-1]
sum := 0.0
for _, d := range sorted {
sum += d
}
mean := sum / float64(n)
// Variance and standard deviation
sumSqDiff := 0.0
for _, d := range sorted {
diff := d - mean
sumSqDiff += diff * diff
}
variance := sumSqDiff / float64(n)
stdDev := math.Sqrt(variance)
// Skewness
skewness := 0.0
if stdDev > 0 {
sumCubeDiff := 0.0
for _, d := range sorted {
diff := (d - mean) / stdDev
sumCubeDiff += diff * diff * diff
}
skewness = sumCubeDiff / float64(n)
}
return DistributionStats{
Count: n,
Min: min,
Max: max,
Mean: mean,
Median: percentile(sorted, 0.5),
StdDev: stdDev,
P25: percentile(sorted, 0.25),
P75: percentile(sorted, 0.75),
P90: percentile(sorted, 0.90),
P99: percentile(sorted, 0.99),
Variance: variance,
Skewness: skewness,
SampleSize: n,
ComputedAt: time.Now(),
}
}
// percentile returns the p-th percentile from a sorted slice.
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return 0
}
if len(sorted) == 1 {
return sorted[0]
}
idx := p * float64(len(sorted)-1)
lower := int(idx)
upper := lower + 1
if upper >= len(sorted) {
return sorted[len(sorted)-1]
}
frac := idx - float64(lower)
return sorted[lower]*(1-frac) + sorted[upper]*frac
}
// AxisDistribution provides per-axis (feature) distribution analysis.
type AxisDistribution struct {
Axis int `json:"axis"`
Name string `json:"name,omitempty"`
Stats DistributionStats `json:"stats"`
}
// ComputeAxisDistributions analyzes the distribution of values along each axis.
func ComputeAxisDistributions[T any](points []KDPoint[T], axisNames []string) []AxisDistribution {
if len(points) == 0 {
return nil
}
dim := len(points[0].Coords)
result := make([]AxisDistribution, dim)
for axis := 0; axis < dim; axis++ {
values := make([]float64, len(points))
for i, p := range points {
if axis < len(p.Coords) {
values[i] = p.Coords[axis]
}
}
name := ""
if axis < len(axisNames) {
name = axisNames[axis]
}
result[axis] = AxisDistribution{
Axis: axis,
Name: name,
Stats: ComputeDistributionStats(values),
}
}
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"`
ActivePeers int `json:"activePeers"` // Peers queried recently
HealthyPeers int `json:"healthyPeers"` // Peers with good metrics
DegradedPeers int `json:"degradedPeers"` // Peers with some issues
UnhealthyPeers int `json:"unhealthyPeers"` // Peers with poor metrics
AvgLatencyMs float64 `json:"avgLatencyMs"`
MedianLatencyMs float64 `json:"medianLatencyMs"`
AvgTrustScore float64 `json:"avgTrustScore"`
AvgQualityScore float64 `json:"avgQualityScore"`
DirectConnectRate float64 `json:"directConnectRate"` // % of peers directly reachable
RelayDependency float64 `json:"relayDependency"` // % of peers needing relay
ComputedAt time.Time `json:"computedAt"`
}
// FeatureVector represents a normalized feature vector for a peer.
// This is the core structure for KD-Tree based peer selection.
type FeatureVector struct {
PeerID string `json:"peerId"`
Features []float64 `json:"features"`
Labels []string `json:"labels,omitempty"` // Optional feature names
}
// StandardPeerFeatures defines the standard feature set for peer selection.
// These map to dimensions in the KD-Tree.
type StandardPeerFeatures struct {
LatencyMs float64 `json:"latencyMs"` // Lower is better
HopCount int `json:"hopCount"` // Lower is better
GeoDistanceKm float64 `json:"geoDistanceKm"` // Lower is better
TrustScore float64 `json:"trustScore"` // Higher is better (invert)
BandwidthMbps float64 `json:"bandwidthMbps"` // Higher is better (invert)
PacketLossRate float64 `json:"packetLossRate"` // Lower is better
ConnectivityPct float64 `json:"connectivityPct"` // Higher is better (invert)
NATScore float64 `json:"natScore"` // Higher is better (invert)
}
// ToFeatureSlice converts structured features to a slice for KD-Tree operations.
// Inversion is handled so that lower distance = better peer.
func (f StandardPeerFeatures) ToFeatureSlice() []float64 {
return []float64{
f.LatencyMs,
float64(f.HopCount),
f.GeoDistanceKm,
1 - f.TrustScore, // Invert: higher trust = lower value
100 - f.BandwidthMbps, // Invert: higher bandwidth = lower value (capped at 100)
f.PacketLossRate,
100 - f.ConnectivityPct, // Invert: higher connectivity = lower value
1 - f.NATScore, // Invert: higher NAT score = lower value
}
}
// StandardFeatureLabels returns the labels for standard peer features.
func StandardFeatureLabels() []string {
return []string{
"latency_ms",
"hop_count",
"geo_distance_km",
"trust_score_inv",
"bandwidth_inv",
"packet_loss_rate",
"connectivity_inv",
"nat_score_inv",
}
}
// FeatureRanges defines min/max ranges for feature normalization.
type FeatureRanges struct {
Ranges []AxisStats `json:"ranges"`
}
// DefaultPeerFeatureRanges returns sensible default ranges for peer features.
func DefaultPeerFeatureRanges() FeatureRanges {
return FeatureRanges{
Ranges: []AxisStats{
{Min: 0, Max: 1000}, // Latency: 0-1000ms
{Min: 0, Max: 20}, // Hops: 0-20
{Min: 0, Max: 20000}, // Geo distance: 0-20000km (half Earth circumference)
{Min: 0, Max: 1}, // Trust score (inverted): 0-1
{Min: 0, Max: 100}, // Bandwidth (inverted): 0-100Mbps
{Min: 0, Max: 1}, // Packet loss: 0-100%
{Min: 0, Max: 100}, // Connectivity (inverted): 0-100%
{Min: 0, Max: 1}, // NAT score (inverted): 0-1
},
}
}
// NormalizePeerFeatures normalizes peer features to [0,1] using provided ranges.
func NormalizePeerFeatures(features []float64, ranges FeatureRanges) []float64 {
result := make([]float64, len(features))
for i, v := range features {
if i < len(ranges.Ranges) {
result[i] = scale01(v, ranges.Ranges[i].Min, ranges.Ranges[i].Max)
} else {
result[i] = v
}
}
return result
}
// WeightedPeerFeatures applies per-feature weights after normalization.
func WeightedPeerFeatures(normalized []float64, weights []float64) []float64 {
result := make([]float64, len(normalized))
for i, v := range normalized {
w := 1.0
if i < len(weights) {
w = weights[i]
}
result[i] = v * w
}
return result
}

662
kdtree_analytics_test.go Normal file
View file

@ -0,0 +1,662 @@
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)
}
}

View file

@ -1,90 +0,0 @@
package poindexter
import (
"testing"
)
func TestNewKDTree_Empty(t *testing.T) {
_, err := NewKDTree[any](nil)
if err != ErrEmptyPoints {
t.Errorf("expected ErrEmptyPoints, got %v", err)
}
}
func TestWasmSmokeEquivalent(t *testing.T) {
tree, err := NewKDTreeFromDim[string](2)
if err != nil {
t.Fatalf("NewKDTreeFromDim failed: %v", err)
}
tree.Insert(KDPoint[string]{ID: "a", Coords: []float64{0, 0}, Value: "A"})
tree.Insert(KDPoint[string]{ID: "b", Coords: []float64{1, 0}, Value: "B"})
_, _, found := tree.Nearest([]float64{0.9, 0.1})
if !found {
t.Error("expected to find a nearest point")
}
}
func TestNewKDTree_ZeroDim(t *testing.T) {
pts := []KDPoint[any]{{Coords: []float64{}}}
_, err := NewKDTree[any](pts)
if err != ErrZeroDim {
t.Errorf("expected ErrZeroDim, got %v", err)
}
}
func TestNewKDTree_DimMismatch(t *testing.T) {
pts := []KDPoint[any]{
{Coords: []float64{1, 2}},
{Coords: []float64{3}},
}
_, err := NewKDTree[any](pts)
if err != ErrDimMismatch {
t.Errorf("expected ErrDimMismatch, got %v", err)
}
}
func TestNewKDTree_DuplicateID(t *testing.T) {
pts := []KDPoint[any]{
{ID: "a", Coords: []float64{1, 2}},
{ID: "a", Coords: []float64{3, 4}},
}
_, err := NewKDTree[any](pts)
if err != ErrDuplicateID {
t.Errorf("expected ErrDuplicateID, got %v", err)
}
}
func TestBuildND_InvalidFeatures(t *testing.T) {
_, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, nil, nil, nil)
if err != ErrInvalidFeatures {
t.Errorf("expected ErrInvalidFeatures, got %v", err)
}
}
func TestBuildND_InvalidWeights(t *testing.T) {
_, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, nil, nil)
if err != ErrInvalidWeights {
t.Errorf("expected ErrInvalidWeights, got %v", err)
}
}
func TestBuildND_InvalidInvert(t *testing.T) {
_, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, []float64{0}, nil)
if err != ErrInvalidInvert {
t.Errorf("expected ErrInvalidInvert, got %v", err)
}
}
func TestBuildNDWithStats_DimMismatch(t *testing.T) {
_, err := BuildNDWithStats[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, []float64{0}, []bool{false}, NormStats{})
if err != ErrStatsDimMismatch {
t.Errorf("expected ErrStatsDimMismatch, got %v", err)
}
}
func TestGonumStub_BuildBackend(t *testing.T) {
_, err := buildGonumBackend[any](nil, nil)
if err != ErrEmptyPoints {
t.Errorf("expected ErrEmptyPoints, got %v", err)
}
}

View file

@ -71,9 +71,7 @@ Explore runnable examples in the repository:
- examples/kdtree_2d_ping_hop
- examples/kdtree_3d_ping_hop_geo
- examples/kdtree_4d_ping_hop_geo_score
- examples/dht_helpers (convenience wrappers for common DHT schemas)
- examples/wasm-browser (browser demo using the ESM loader)
- examples/wasm-browser-ts (TypeScript + Vite local demo)
### KDTree performance and notes
- Dual backend support: Linear (always available) and an optimized KD backend enabled when building with `-tags=gonum`. Linear is the default; with the `gonum` tag, the optimized backend becomes the default.
@ -218,21 +216,4 @@ This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2)
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Coverage
- CI produces coverage summaries as artifacts on every push/PR:
- Default job: `coverage-summary.md` (from `coverage.out`)
- Gonum-tag job: `coverage-summary-gonum.md` (from `coverage-gonum.out`)
- Locally, you can generate and inspect coverage with the Makefile:
```bash
make cover # runs tests with race + coverage and prints the total
make coverfunc # prints per-function coverage
make cover-kdtree # filters coverage to kdtree.go
make coverhtml # writes coverage.html for visual inspection
```
Note: CI also uploads raw coverage profiles as artifacts (`coverage.out`, `coverage-gonum.out`).
Contributions are welcome! Please feel free to submit a Pull Request.

View file

@ -15,7 +15,153 @@ export interface KNearestResult {
dists: number[];
}
// ============================================================================
// Analytics Types
// ============================================================================
/** Tree operation analytics snapshot */
export interface TreeAnalytics {
queryCount: number;
insertCount: number;
deleteCount: number;
avgQueryTimeNs: number;
minQueryTimeNs: number;
maxQueryTimeNs: number;
lastQueryTimeNs: number;
lastQueryAt: number; // Unix milliseconds
createdAt: number; // Unix milliseconds
backendRebuildCount: number;
lastRebuiltAt: number; // Unix milliseconds
}
/** Per-peer selection statistics */
export interface PeerStats {
peerId: string;
selectionCount: number;
avgDistance: number;
lastSelectedAt: number; // Unix milliseconds
}
/** Statistical distribution analysis */
export interface DistributionStats {
count: number;
min: number;
max: number;
mean: number;
median: number;
stdDev: number;
p25: number;
p75: number;
p90: number;
p99: number;
variance: number;
skewness: number;
sampleSize?: number;
computedAt?: number; // Unix milliseconds
}
/** Per-axis distribution in the KD-Tree */
export interface AxisDistribution {
axis: number;
name: string;
stats: DistributionStats;
}
// ============================================================================
// NAT Routing Types
// ============================================================================
/** NAT type classification for routing decisions */
export type NATTypeClassification =
| 'open'
| 'full_cone'
| 'restricted_cone'
| 'port_restricted'
| 'symmetric'
| 'symmetric_udp'
| 'cgnat'
| 'firewalled'
| 'relay_required'
| 'unknown';
/** Network metrics for NAT routing decisions */
export interface NATRoutingMetrics {
connectivityScore: number; // 0-1: higher = better reachability
symmetryScore: number; // 0-1: higher = more symmetric NAT
relayProbability: number; // 0-1: likelihood peer needs relay
directSuccessRate: number; // 0-1: historical direct connection success
avgRttMs: number; // Average RTT in milliseconds
jitterMs: number; // RTT variance in milliseconds
packetLossRate: number; // 0-1: packet loss rate
bandwidthMbps: number; // Bandwidth estimate in Mbps
natType: NATTypeClassification;
lastProbeAt?: number; // Unix milliseconds
}
/** Weights for peer quality scoring */
export interface QualityWeights {
latency: number;
jitter: number;
packetLoss: number;
bandwidth: number;
connectivity: number;
symmetry: number;
directSuccess: number;
relayPenalty: number;
natType: number;
}
/** Trust metrics for peer reputation */
export interface TrustMetrics {
reputationScore: number; // 0-1: aggregated trust score
successfulTransactions: number;
failedTransactions: number;
ageSeconds: number; // How long this peer has been known
lastSuccessAt?: number; // Unix milliseconds
lastFailureAt?: number; // Unix milliseconds
vouchCount: number; // Peers vouching for this peer
flagCount: number; // Reports against this peer
proofOfWork: number; // Computational proof of stake/work
}
/** Axis min/max range for normalization */
export interface AxisRange {
min: number;
max: number;
}
/** Feature ranges for peer feature normalization */
export interface FeatureRanges {
ranges: AxisRange[];
labels?: string[];
}
/** Standard peer features for KD-Tree based selection */
export interface StandardPeerFeatures {
latencyMs: number;
hopCount: number;
geoDistanceKm: number;
trustScore: number;
bandwidthMbps: number;
packetLossRate: number;
connectivityPct: number;
natScore: number;
}
/** Export data with all points */
export interface TreeExport {
dim: number;
len: number;
backend: string;
points: PxPoint[];
}
// ============================================================================
// Tree Interface
// ============================================================================
export interface PxTree {
// Core operations
len(): Promise<number>;
dim(): Promise<number>;
insert(point: PxPoint): Promise<boolean>;
@ -24,18 +170,377 @@ export interface PxTree {
kNearest(query: number[], k: number): Promise<KNearestResult>;
radius(query: number[], r: number): Promise<KNearestResult>;
exportJSON(): Promise<string>;
// Analytics operations
getAnalytics(): Promise<TreeAnalytics>;
getPeerStats(): Promise<PeerStats[]>;
getTopPeers(n: number): Promise<PeerStats[]>;
getAxisDistributions(axisNames?: string[]): Promise<AxisDistribution[]>;
resetAnalytics(): Promise<boolean>;
}
// ============================================================================
// Init Options
// ============================================================================
export interface InitOptions {
wasmURL?: string;
wasmExecURL?: string;
instantiateWasm?: (source: ArrayBuffer, importObject: WebAssembly.Imports) => Promise<WebAssembly.Instance> | WebAssembly.Instance;
}
// ============================================================================
// DNS Tools Types
// ============================================================================
/** DNS record types - standard and extended (ClouDNS compatible) */
export type DNSRecordType =
// Standard record types
| 'A'
| 'AAAA'
| 'MX'
| 'TXT'
| 'NS'
| 'CNAME'
| 'SOA'
| 'PTR'
| 'SRV'
| 'CAA'
// Additional record types (ClouDNS and others)
| 'ALIAS' // Virtual A record - CNAME-like for apex domain
| 'RP' // Responsible Person
| 'SSHFP' // SSH Fingerprint
| 'TLSA' // DANE TLS Authentication
| 'DS' // DNSSEC Delegation Signer
| 'DNSKEY' // DNSSEC Key
| 'NAPTR' // Naming Authority Pointer
| 'LOC' // Geographic Location
| 'HINFO' // Host Information
| 'CERT' // Certificate record
| 'SMIMEA' // S/MIME Certificate Association
| 'WR' // Web Redirect (ClouDNS specific)
| 'SPF'; // Sender Policy Framework (legacy)
/** DNS record type metadata */
export interface DNSRecordTypeInfo {
type: DNSRecordType;
name: string;
description: string;
rfc?: string;
common: boolean;
}
/** CAA record */
export interface CAARecord {
flag: number;
tag: string; // "issue", "issuewild", "iodef"
value: string;
}
/** SSHFP record */
export interface SSHFPRecord {
algorithm: number; // 1=RSA, 2=DSA, 3=ECDSA, 4=Ed25519
fpType: number; // 1=SHA-1, 2=SHA-256
fingerprint: string;
}
/** TLSA (DANE) record */
export interface TLSARecord {
usage: number; // 0-3: CA constraint, Service cert, Trust anchor, Domain-issued
selector: number; // 0=Full cert, 1=SubjectPublicKeyInfo
matchingType: number; // 0=Exact, 1=SHA-256, 2=SHA-512
certData: string;
}
/** DS (DNSSEC Delegation Signer) record */
export interface DSRecord {
keyTag: number;
algorithm: number;
digestType: number;
digest: string;
}
/** DNSKEY record */
export interface DNSKEYRecord {
flags: number;
protocol: number;
algorithm: number;
publicKey: string;
}
/** NAPTR record */
export interface NAPTRRecord {
order: number;
preference: number;
flags: string;
service: string;
regexp: string;
replacement: string;
}
/** RP (Responsible Person) record */
export interface RPRecord {
mailbox: string; // Email as DNS name (user.domain.com)
txtDom: string; // Domain with TXT record containing more info
}
/** LOC (Location) record */
export interface LOCRecord {
latitude: number;
longitude: number;
altitude: number;
size: number;
hPrecision: number;
vPrecision: number;
}
/** ALIAS record (provider-specific) */
export interface ALIASRecord {
target: string;
}
/** Web Redirect record (ClouDNS specific) */
export interface WebRedirectRecord {
url: string;
redirectType: number; // 301, 302, etc.
frame: boolean; // Frame redirect vs HTTP redirect
}
/** External tool links for domain/IP/email analysis */
export interface ExternalToolLinks {
target: string;
type: 'domain' | 'ip' | 'email';
// MXToolbox links
mxtoolboxDns?: string;
mxtoolboxMx?: string;
mxtoolboxBlacklist?: string;
mxtoolboxSmtp?: string;
mxtoolboxSpf?: string;
mxtoolboxDmarc?: string;
mxtoolboxDkim?: string;
mxtoolboxHttp?: string;
mxtoolboxHttps?: string;
mxtoolboxPing?: string;
mxtoolboxTrace?: string;
mxtoolboxWhois?: string;
mxtoolboxAsn?: string;
// DNSChecker links
dnscheckerDns?: string;
dnscheckerPropagation?: string;
// Other tools
whois?: string;
viewdns?: string;
intodns?: string;
dnsviz?: string;
securitytrails?: string;
shodan?: string;
censys?: string;
builtwith?: string;
ssllabs?: string;
hstsPreload?: string;
hardenize?: string;
// IP-specific tools
ipinfo?: string;
abuseipdb?: string;
virustotal?: string;
threatcrowd?: string;
// Email-specific tools
mailtester?: string;
learndmarc?: string;
}
/** RDAP server registry */
export interface RDAPServers {
tlds: Record<string, string>;
rirs: Record<string, string>;
universal: string;
}
/** RDAP response event */
export interface RDAPEvent {
eventAction: string;
eventDate: string;
eventActor?: string;
}
/** RDAP entity (registrar, registrant, etc.) */
export interface RDAPEntity {
handle?: string;
roles?: string[];
vcardArray?: any[];
entities?: RDAPEntity[];
events?: RDAPEvent[];
}
/** RDAP nameserver */
export interface RDAPNameserver {
ldhName: string;
ipAddresses?: {
v4?: string[];
v6?: string[];
};
}
/** RDAP link */
export interface RDAPLink {
value?: string;
rel?: string;
href?: string;
type?: string;
}
/** RDAP remark/notice */
export interface RDAPRemark {
title?: string;
description?: string[];
links?: RDAPLink[];
}
/** RDAP response (for domain, IP, or ASN lookups) */
export interface RDAPResponse {
// Common fields
handle?: string;
ldhName?: string;
unicodeName?: string;
status?: string[];
events?: RDAPEvent[];
entities?: RDAPEntity[];
nameservers?: RDAPNameserver[];
links?: RDAPLink[];
remarks?: RDAPRemark[];
notices?: RDAPRemark[];
// Network-specific (for IP lookups)
startAddress?: string;
endAddress?: string;
ipVersion?: string;
name?: string;
type?: string;
country?: string;
parentHandle?: string;
// Error fields
errorCode?: number;
title?: string;
description?: string[];
// Metadata
rawJson?: string;
lookupTimeMs: number;
timestamp: string;
error?: string;
}
/** Parsed domain info from RDAP */
export interface ParsedDomainInfo {
domain: string;
registrar?: string;
registrationDate?: string;
expirationDate?: string;
updatedDate?: string;
status?: string[];
nameservers?: string[];
dnssec: boolean;
}
/** DNS lookup result */
export interface DNSLookupResult {
domain: string;
queryType: string;
records: DNSRecord[];
mxRecords?: MXRecord[];
srvRecords?: SRVRecord[];
soaRecord?: SOARecord;
lookupTimeMs: number;
error?: string;
timestamp: string;
}
/** DNS record */
export interface DNSRecord {
type: DNSRecordType;
name: string;
value: string;
ttl?: number;
}
/** MX record */
export interface MXRecord {
host: string;
priority: number;
}
/** SRV record */
export interface SRVRecord {
target: string;
port: number;
priority: number;
weight: number;
}
/** SOA record */
export interface SOARecord {
primaryNs: string;
adminEmail: string;
serial: number;
refresh: number;
retry: number;
expire: number;
minTtl: number;
}
/** Complete DNS lookup result */
export interface CompleteDNSLookup {
domain: string;
a?: string[];
aaaa?: string[];
mx?: MXRecord[];
ns?: string[];
txt?: string[];
cname?: string;
soa?: SOARecord;
lookupTimeMs: number;
errors?: string[];
timestamp: string;
}
// ============================================================================
// Main API
// ============================================================================
export interface PxAPI {
// Core functions
version(): Promise<string>;
hello(name?: string): Promise<string>;
newTree(dim: number): Promise<PxTree>;
// Statistics utilities
computeDistributionStats(distances: number[]): Promise<DistributionStats>;
// NAT routing / peer quality functions
computePeerQualityScore(metrics: NATRoutingMetrics, weights?: QualityWeights): Promise<number>;
computeTrustScore(metrics: TrustMetrics): Promise<number>;
getDefaultQualityWeights(): Promise<QualityWeights>;
getDefaultPeerFeatureRanges(): Promise<FeatureRanges>;
normalizePeerFeatures(features: number[], ranges?: FeatureRanges): Promise<number[]>;
weightedPeerFeatures(normalized: number[], weights: number[]): Promise<number[]>;
// DNS tools
getExternalToolLinks(domain: string): Promise<ExternalToolLinks>;
getExternalToolLinksIP(ip: string): Promise<ExternalToolLinks>;
getExternalToolLinksEmail(emailOrDomain: string): Promise<ExternalToolLinks>;
getRDAPServers(): Promise<RDAPServers>;
buildRDAPDomainURL(domain: string): Promise<string>;
buildRDAPIPURL(ip: string): Promise<string>;
buildRDAPASNURL(asn: string): Promise<string>;
getDNSRecordTypes(): Promise<DNSRecordType[]>;
getDNSRecordTypeInfo(): Promise<DNSRecordTypeInfo[]>;
getCommonDNSRecordTypes(): Promise<DNSRecordType[]>;
}
export function init(options?: InitOptions): Promise<PxAPI>;

View file

@ -6,12 +6,9 @@
// await tree.insert({ id: 'a', coords: [0,0], value: 'A' });
// const res = await tree.nearest([0.1, 0.2]);
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
async function loadScriptOnce(src) {
// Browser-only
return new Promise((resolve, reject) => {
if (typeof document === 'undefined') return reject(new Error('loadScriptOnce requires a browser environment'));
// If already present, resolve immediately
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const s = document.createElement('script');
s.src = src;
@ -22,23 +19,10 @@ async function loadScriptOnce(src) {
}
async function ensureWasmExec(url) {
if (globalThis.Go) return;
if (isNode) {
const { fileURLToPath } = await import('url');
const fs = await import('fs/promises');
const vm = await import('vm');
const wasmExecPath = fileURLToPath(url);
const wasmExecCode = await fs.readFile(wasmExecPath, 'utf8');
vm.runInThisContext(wasmExecCode, { filename: wasmExecPath });
} else if (typeof window !== 'undefined') {
await loadScriptOnce(url);
} else {
throw new Error('Unsupported environment: not Node.js or a browser');
}
if (typeof globalThis.Go !== 'function') {
throw new Error('wasm_exec.js did not define globalThis.Go');
if (typeof window !== 'undefined' && typeof window.Go === 'function') return;
await loadScriptOnce(url);
if (typeof window === 'undefined' || typeof window.Go !== 'function') {
throw new Error('wasm_exec.js did not define window.Go');
}
}
@ -56,53 +40,42 @@ function call(name, ...args) {
class PxTree {
constructor(treeId) { this.treeId = treeId; }
// Core operations
async len() { return call('pxTreeLen', this.treeId); }
async dim() { return call('pxTreeDim', this.treeId); }
async insert(point) { return call('pxInsert', this.treeId, point); }
async deleteByID(id) { return call('pxDeleteByID', this.treeId, id); }
async nearest(query) {
const res = await call('pxNearest', this.treeId, query);
if (!res.found) return { point: null, dist: 0, found: false };
return {
point: { id: res.id, coords: res.coords, value: res.value },
dist: res.dist,
found: true,
};
}
async nearest(query) { return call('pxNearest', this.treeId, query); }
async kNearest(query, k) { return call('pxKNearest', this.treeId, query, k); }
async radius(query, r) { return call('pxRadius', this.treeId, query, r); }
async exportJSON() { return call('pxExportJSON', this.treeId); }
// Analytics operations
async getAnalytics() { return call('pxGetAnalytics', this.treeId); }
async getPeerStats() { return call('pxGetPeerStats', this.treeId); }
async getTopPeers(n) { return call('pxGetTopPeers', this.treeId, n); }
async getAxisDistributions(axisNames) { return call('pxGetAxisDistributions', this.treeId, axisNames); }
async resetAnalytics() { return call('pxResetAnalytics', this.treeId); }
}
export async function init(options = {}) {
const {
wasmURL = new URL('./dist/poindexter.wasm', import.meta.url).toString(),
wasmExecURL = new URL('./dist/wasm_exec.js', import.meta.url).toString(),
instantiateWasm, // optional custom instantiator
fetch: customFetch,
instantiateWasm // optional custom instantiator: (source, importObject) => WebAssembly.Instance
} = options;
await ensureWasmExec(wasmExecURL);
const go = new globalThis.Go();
const fetchFn = customFetch || (isNode ? (async (url) => {
const { fileURLToPath } = await import('url');
const fs = await import('fs/promises');
const path = fileURLToPath(url);
const bytes = await fs.readFile(path);
return new Response(bytes, { 'Content-Type': 'application/wasm' });
}) : fetch);
const go = new window.Go();
let result;
if (instantiateWasm) {
const source = await fetchFn(wasmURL).then(r => r.arrayBuffer());
const source = await fetch(wasmURL).then(r => r.arrayBuffer());
const inst = await instantiateWasm(source, go.importObject);
result = { instance: inst };
} else if (WebAssembly.instantiateStreaming && !isNode) {
result = await WebAssembly.instantiateStreaming(fetchFn(wasmURL), go.importObject);
} else if (WebAssembly.instantiateStreaming) {
result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject);
} else {
const resp = await fetchFn(wasmURL);
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
result = await WebAssembly.instantiate(bytes, go.importObject);
}
@ -112,12 +85,33 @@ export async function init(options = {}) {
go.run(result.instance);
const api = {
// Core functions
version: async () => call('pxVersion'),
hello: async (name) => call('pxHello', name ?? ''),
newTree: async (dim) => {
const info = call('pxNewTree', dim);
return new PxTree(info.treeId);
},
// Statistics utilities
computeDistributionStats: async (distances) => call('pxComputeDistributionStats', distances),
// NAT routing / peer quality functions
computePeerQualityScore: async (metrics, weights) => call('pxComputePeerQualityScore', metrics, weights),
computeTrustScore: async (metrics) => call('pxComputeTrustScore', metrics),
getDefaultQualityWeights: async () => call('pxGetDefaultQualityWeights'),
getDefaultPeerFeatureRanges: async () => call('pxGetDefaultPeerFeatureRanges'),
normalizePeerFeatures: async (features, ranges) => call('pxNormalizePeerFeatures', features, ranges),
weightedPeerFeatures: async (normalized, weights) => call('pxWeightedPeerFeatures', normalized, weights),
// DNS tools
getExternalToolLinks: async (domain) => call('pxGetExternalToolLinks', domain),
getExternalToolLinksIP: async (ip) => call('pxGetExternalToolLinksIP', ip),
getExternalToolLinksEmail: async (emailOrDomain) => call('pxGetExternalToolLinksEmail', emailOrDomain),
getRDAPServers: async () => call('pxGetRDAPServers'),
buildRDAPDomainURL: async (domain) => call('pxBuildRDAPDomainURL', domain),
buildRDAPIPURL: async (ip) => call('pxBuildRDAPIPURL', ip),
buildRDAPASNURL: async (asn) => call('pxBuildRDAPASNURL', asn),
getDNSRecordTypes: async () => call('pxGetDNSRecordTypes'),
getDNSRecordTypeInfo: async () => call('pxGetDNSRecordTypeInfo'),
getCommonDNSRecordTypes: async () => call('pxGetCommonDNSRecordTypes')
};
return api;

View file

@ -6,8 +6,9 @@ import { init } from './loader.js';
(async function () {
try {
const px = await init({
wasmURL: new URL('dist/poindexter.wasm', import.meta.url),
wasmExecURL: new URL('dist/wasm_exec.js', import.meta.url),
// In CI, dist/ is placed at repo root via make wasm-build && make npm-pack
wasmURL: new URL('./dist/poindexter.wasm', import.meta.url).pathname,
wasmExecURL: new URL('./dist/wasm_exec.js', import.meta.url).pathname,
});
const ver = await px.version();
if (!ver || typeof ver !== 'string') throw new Error('version not string');
@ -16,8 +17,8 @@ import { init } from './loader.js';
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
await tree.insert({ id: 'b', coords: [1, 0], value: 'B' });
const nn = await tree.nearest([0.9, 0.1]);
if (!nn || !nn.point) throw new Error('nearest failed');
console.log('WASM smoke ok:', ver, 'nearest.id=', nn.point.id);
if (!nn || !nn.id) throw new Error('nearest failed');
console.log('WASM smoke ok:', ver, 'nearest.id=', nn.id);
} catch (err) {
console.error('WASM smoke failed:', err);
process.exit(1);

61
scale.go Normal file
View file

@ -0,0 +1,61 @@
package poindexter
import "math"
// Lerp performs linear interpolation between a and b.
// t=0 returns a, t=1 returns b, t=0.5 returns the midpoint.
func Lerp(t, a, b float64) float64 {
return a + t*(b-a)
}
// InverseLerp returns where v falls between a and b as a fraction [0,1].
// Returns 0 if a == b.
func InverseLerp(v, a, b float64) float64 {
if a == b {
return 0
}
return (v - a) / (b - a)
}
// Remap maps v from the range [inMin, inMax] to [outMin, outMax].
// Equivalent to Lerp(InverseLerp(v, inMin, inMax), outMin, outMax).
func Remap(v, inMin, inMax, outMin, outMax float64) float64 {
return Lerp(InverseLerp(v, inMin, inMax), outMin, outMax)
}
// RoundToN rounds f to n decimal places.
func RoundToN(f float64, decimals int) float64 {
mul := math.Pow(10, float64(decimals))
return math.Round(f*mul) / mul
}
// Clamp restricts v to the range [min, max].
func Clamp(v, min, max float64) float64 {
if v < min {
return min
}
if v > max {
return max
}
return v
}
// ClampInt restricts v to the range [min, max].
func ClampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
// MinMaxScale normalizes v into [0,1] given its range [min, max].
// Returns 0 if min == max.
func MinMaxScale(v, min, max float64) float64 {
if min == max {
return 0
}
return (v - min) / (max - min)
}

148
scale_test.go Normal file
View file

@ -0,0 +1,148 @@
package poindexter
import (
"math"
"testing"
)
func TestLerp(t *testing.T) {
tests := []struct {
name string
t_, a, b float64
want float64
}{
{"start", 0, 10, 20, 10},
{"end", 1, 10, 20, 20},
{"mid", 0.5, 10, 20, 15},
{"quarter", 0.25, 0, 100, 25},
{"extrapolate", 2, 0, 10, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Lerp(tt.t_, tt.a, tt.b)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Lerp(%v, %v, %v) = %v, want %v", tt.t_, tt.a, tt.b, got, tt.want)
}
})
}
}
func TestInverseLerp(t *testing.T) {
tests := []struct {
name string
v, a, b float64
want float64
}{
{"start", 10, 10, 20, 0},
{"end", 20, 10, 20, 1},
{"mid", 15, 10, 20, 0.5},
{"equal_range", 5, 5, 5, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := InverseLerp(tt.v, tt.a, tt.b)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("InverseLerp(%v, %v, %v) = %v, want %v", tt.v, tt.a, tt.b, got, tt.want)
}
})
}
}
func TestRemap(t *testing.T) {
tests := []struct {
name string
v, inMin, inMax, outMin, outMax float64
want float64
}{
{"identity", 5, 0, 10, 0, 10, 5},
{"scale_up", 5, 0, 10, 0, 100, 50},
{"reverse", 3, 0, 10, 10, 0, 7},
{"offset", 0, 0, 1, 100, 200, 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Remap(tt.v, tt.inMin, tt.inMax, tt.outMin, tt.outMax)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Remap = %v, want %v", got, tt.want)
}
})
}
}
func TestRoundToN(t *testing.T) {
tests := []struct {
name string
f float64
decimals int
want float64
}{
{"zero_dec", 3.456, 0, 3},
{"one_dec", 3.456, 1, 3.5},
{"two_dec", 3.456, 2, 3.46},
{"three_dec", 3.4564, 3, 3.456},
{"negative", -2.555, 2, -2.56},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RoundToN(tt.f, tt.decimals)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("RoundToN(%v, %v) = %v, want %v", tt.f, tt.decimals, got, tt.want)
}
})
}
}
func TestClamp(t *testing.T) {
tests := []struct {
name string
v, min, max float64
want float64
}{
{"within", 5, 0, 10, 5},
{"below", -5, 0, 10, 0},
{"above", 15, 0, 10, 10},
{"at_min", 0, 0, 10, 0},
{"at_max", 10, 0, 10, 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Clamp(tt.v, tt.min, tt.max)
if got != tt.want {
t.Errorf("Clamp(%v, %v, %v) = %v, want %v", tt.v, tt.min, tt.max, got, tt.want)
}
})
}
}
func TestClampInt(t *testing.T) {
if got := ClampInt(5, 0, 10); got != 5 {
t.Errorf("ClampInt(5, 0, 10) = %v, want 5", got)
}
if got := ClampInt(-1, 0, 10); got != 0 {
t.Errorf("ClampInt(-1, 0, 10) = %v, want 0", got)
}
if got := ClampInt(15, 0, 10); got != 10 {
t.Errorf("ClampInt(15, 0, 10) = %v, want 10", got)
}
}
func TestMinMaxScale(t *testing.T) {
tests := []struct {
name string
v, min, max float64
want float64
}{
{"mid", 5, 0, 10, 0.5},
{"at_min", 0, 0, 10, 0},
{"at_max", 10, 0, 10, 1},
{"equal_range", 5, 5, 5, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MinMaxScale(tt.v, tt.min, tt.max)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("MinMaxScale(%v, %v, %v) = %v, want %v", tt.v, tt.min, tt.max, got, tt.want)
}
})
}
}

40
score.go Normal file
View file

@ -0,0 +1,40 @@
package poindexter
// Factor is a valueweight pair for composite scoring.
type Factor struct {
Value float64
Weight float64
}
// WeightedScore computes the weighted sum of factors.
// Each factor contributes Value * Weight to the total.
// Returns 0 for empty slices.
func WeightedScore(factors []Factor) float64 {
var total float64
for _, f := range factors {
total += f.Value * f.Weight
}
return total
}
// Ratio returns part/whole safely. Returns 0 if whole is 0.
func Ratio(part, whole float64) float64 {
if whole == 0 {
return 0
}
return part / whole
}
// Delta returns the difference new_ - old.
func Delta(old, new_ float64) float64 {
return new_ - old
}
// DeltaPercent returns the percentage change from old to new_.
// Returns 0 if old is 0.
func DeltaPercent(old, new_ float64) float64 {
if old == 0 {
return 0
}
return (new_ - old) / old * 100
}

86
score_test.go Normal file
View file

@ -0,0 +1,86 @@
package poindexter
import (
"math"
"testing"
)
func TestWeightedScore(t *testing.T) {
tests := []struct {
name string
factors []Factor
want float64
}{
{"empty", nil, 0},
{"single", []Factor{{Value: 5, Weight: 2}}, 10},
{"multiple", []Factor{
{Value: 3, Weight: 2}, // 6
{Value: 1, Weight: -5}, // -5
}, 1},
{"lek_heuristic", []Factor{
{Value: 2, Weight: 2}, // engagement × 2
{Value: 1, Weight: 3}, // creative × 3
{Value: 1, Weight: 1.5}, // first person × 1.5
{Value: 3, Weight: -5}, // compliance × -5
}, -6.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WeightedScore(tt.factors)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("WeightedScore = %v, want %v", got, tt.want)
}
})
}
}
func TestRatio(t *testing.T) {
tests := []struct {
name string
part, whole float64
want float64
}{
{"half", 5, 10, 0.5},
{"full", 10, 10, 1},
{"zero_whole", 5, 0, 0},
{"zero_part", 0, 10, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Ratio(tt.part, tt.whole)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Ratio(%v, %v) = %v, want %v", tt.part, tt.whole, got, tt.want)
}
})
}
}
func TestDelta(t *testing.T) {
if got := Delta(10, 15); got != 5 {
t.Errorf("Delta(10, 15) = %v, want 5", got)
}
if got := Delta(15, 10); got != -5 {
t.Errorf("Delta(15, 10) = %v, want -5", got)
}
}
func TestDeltaPercent(t *testing.T) {
tests := []struct {
name string
old, new_ float64
want float64
}{
{"increase", 100, 150, 50},
{"decrease", 100, 75, -25},
{"zero_old", 0, 10, 0},
{"no_change", 50, 50, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DeltaPercent(tt.old, tt.new_)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("DeltaPercent(%v, %v) = %v, want %v", tt.old, tt.new_, got, tt.want)
}
})
}
}

57
signal.go Normal file
View file

@ -0,0 +1,57 @@
package poindexter
import (
"math"
"math/rand"
)
// RampUp returns a linear ramp from 0 to 1 over the given duration.
// The result is clamped to [0, 1].
func RampUp(elapsed, duration float64) float64 {
if duration <= 0 {
return 1
}
return Clamp(elapsed/duration, 0, 1)
}
// SineWave returns a sine value with the given period and amplitude.
// Output range is [-amplitude, amplitude].
func SineWave(t, period, amplitude float64) float64 {
if period == 0 {
return 0
}
return math.Sin(t/period*2*math.Pi) * amplitude
}
// Oscillate modulates a base value with a sine wave.
// Returns base * (1 + sin(t/period*2π) * amplitude).
func Oscillate(base, t, period, amplitude float64) float64 {
if period == 0 {
return base
}
return base * (1 + math.Sin(t/period*2*math.Pi)*amplitude)
}
// Noise generates seeded pseudo-random values.
type Noise struct {
rng *rand.Rand
}
// NewNoise creates a seeded noise generator.
func NewNoise(seed int64) *Noise {
return &Noise{rng: rand.New(rand.NewSource(seed))}
}
// Float64 returns a random value in [-variance, variance].
func (n *Noise) Float64(variance float64) float64 {
return (n.rng.Float64() - 0.5) * 2 * variance
}
// Int returns a random integer in [0, max).
// Returns 0 if max <= 0.
func (n *Noise) Int(max int) int {
if max <= 0 {
return 0
}
return n.rng.Intn(max)
}

103
signal_test.go Normal file
View file

@ -0,0 +1,103 @@
package poindexter
import (
"math"
"testing"
)
func TestRampUp(t *testing.T) {
tests := []struct {
name string
elapsed, duration float64
want float64
}{
{"start", 0, 30, 0},
{"mid", 15, 30, 0.5},
{"end", 30, 30, 1},
{"over", 60, 30, 1},
{"negative", -5, 30, 0},
{"zero_duration", 10, 0, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RampUp(tt.elapsed, tt.duration)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("RampUp(%v, %v) = %v, want %v", tt.elapsed, tt.duration, got, tt.want)
}
})
}
}
func TestSineWave(t *testing.T) {
// At t=0, sin(0) = 0
if got := SineWave(0, 10, 5); math.Abs(got) > 1e-9 {
t.Errorf("SineWave(0, 10, 5) = %v, want 0", got)
}
// At t=period/4, sin(π/2) = 1, so result = amplitude
got := SineWave(2.5, 10, 5)
if math.Abs(got-5) > 1e-9 {
t.Errorf("SineWave(2.5, 10, 5) = %v, want 5", got)
}
// Zero period returns 0
if got := SineWave(5, 0, 5); got != 0 {
t.Errorf("SineWave(5, 0, 5) = %v, want 0", got)
}
}
func TestOscillate(t *testing.T) {
// At t=0, sin(0)=0, so result = base * (1 + 0) = base
got := Oscillate(100, 0, 10, 0.05)
if math.Abs(got-100) > 1e-9 {
t.Errorf("Oscillate(100, 0, 10, 0.05) = %v, want 100", got)
}
// At t=period/4, sin=1, so result = base * (1 + amplitude)
got = Oscillate(100, 2.5, 10, 0.05)
if math.Abs(got-105) > 1e-9 {
t.Errorf("Oscillate(100, 2.5, 10, 0.05) = %v, want 105", got)
}
// Zero period returns base
if got := Oscillate(100, 5, 0, 0.05); got != 100 {
t.Errorf("Oscillate(100, 5, 0, 0.05) = %v, want 100", got)
}
}
func TestNoise(t *testing.T) {
n := NewNoise(42)
// Float64 should be within [-variance, variance]
for i := 0; i < 1000; i++ {
v := n.Float64(0.1)
if v < -0.1 || v > 0.1 {
t.Fatalf("Float64(0.1) = %v, outside [-0.1, 0.1]", v)
}
}
// Int should be within [0, max)
n2 := NewNoise(42)
for i := 0; i < 1000; i++ {
v := n2.Int(10)
if v < 0 || v >= 10 {
t.Fatalf("Int(10) = %v, outside [0, 10)", v)
}
}
// Int with zero max returns 0
if got := n.Int(0); got != 0 {
t.Errorf("Int(0) = %v, want 0", got)
}
if got := n.Int(-1); got != 0 {
t.Errorf("Int(-1) = %v, want 0", got)
}
}
func TestNoiseDeterministic(t *testing.T) {
n1 := NewNoise(123)
n2 := NewNoise(123)
for i := 0; i < 100; i++ {
a := n1.Float64(1.0)
b := n2.Float64(1.0)
if a != b {
t.Fatalf("iteration %d: different values for same seed: %v != %v", i, a, b)
}
}
}

63
stats.go Normal file
View file

@ -0,0 +1,63 @@
package poindexter
import "math"
// Sum returns the sum of all values. Returns 0 for empty slices.
func Sum(data []float64) float64 {
var s float64
for _, v := range data {
s += v
}
return s
}
// Mean returns the arithmetic mean. Returns 0 for empty slices.
func Mean(data []float64) float64 {
if len(data) == 0 {
return 0
}
return Sum(data) / float64(len(data))
}
// Variance returns the population variance. Returns 0 for empty slices.
func Variance(data []float64) float64 {
if len(data) == 0 {
return 0
}
m := Mean(data)
var ss float64
for _, v := range data {
d := v - m
ss += d * d
}
return ss / float64(len(data))
}
// StdDev returns the population standard deviation.
func StdDev(data []float64) float64 {
return math.Sqrt(Variance(data))
}
// MinMax returns the minimum and maximum values.
// Returns (0, 0) for empty slices.
func MinMax(data []float64) (min, max float64) {
if len(data) == 0 {
return 0, 0
}
min, max = data[0], data[0]
for _, v := range data[1:] {
if v < min {
min = v
}
if v > max {
max = v
}
}
return min, max
}
// IsUnderrepresented returns true if val is below threshold fraction of avg.
// For example, IsUnderrepresented(3, 10, 0.5) returns true because 3 < 10*0.5.
func IsUnderrepresented(val, avg, threshold float64) bool {
return val < avg*threshold
}

122
stats_test.go Normal file
View file

@ -0,0 +1,122 @@
package poindexter
import (
"math"
"testing"
)
func TestSum(t *testing.T) {
tests := []struct {
name string
data []float64
want float64
}{
{"empty", nil, 0},
{"single", []float64{5}, 5},
{"multiple", []float64{1, 2, 3, 4, 5}, 15},
{"negative", []float64{-1, -2, 3}, 0},
{"floats", []float64{0.1, 0.2, 0.3}, 0.6},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Sum(tt.data)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Sum(%v) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
func TestMean(t *testing.T) {
tests := []struct {
name string
data []float64
want float64
}{
{"empty", nil, 0},
{"single", []float64{5}, 5},
{"multiple", []float64{2, 4, 6}, 4},
{"floats", []float64{1.5, 2.5}, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Mean(tt.data)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Mean(%v) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
func TestVariance(t *testing.T) {
tests := []struct {
name string
data []float64
want float64
}{
{"empty", nil, 0},
{"constant", []float64{5, 5, 5}, 0},
{"simple", []float64{2, 4, 4, 4, 5, 5, 7, 9}, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Variance(tt.data)
if math.Abs(got-tt.want) > 1e-9 {
t.Errorf("Variance(%v) = %v, want %v", tt.data, got, tt.want)
}
})
}
}
func TestStdDev(t *testing.T) {
got := StdDev([]float64{2, 4, 4, 4, 5, 5, 7, 9})
if math.Abs(got-2) > 1e-9 {
t.Errorf("StdDev = %v, want 2", got)
}
}
func TestMinMax(t *testing.T) {
tests := []struct {
name string
data []float64
wantMin float64
wantMax float64
}{
{"empty", nil, 0, 0},
{"single", []float64{3}, 3, 3},
{"ordered", []float64{1, 2, 3}, 1, 3},
{"reversed", []float64{3, 2, 1}, 1, 3},
{"negative", []float64{-5, 0, 5}, -5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotMin, gotMax := MinMax(tt.data)
if gotMin != tt.wantMin || gotMax != tt.wantMax {
t.Errorf("MinMax(%v) = (%v, %v), want (%v, %v)", tt.data, gotMin, gotMax, tt.wantMin, tt.wantMax)
}
})
}
}
func TestIsUnderrepresented(t *testing.T) {
tests := []struct {
name string
val float64
avg float64
threshold float64
want bool
}{
{"below", 3, 10, 0.5, true},
{"at", 5, 10, 0.5, false},
{"above", 7, 10, 0.5, false},
{"zero_avg", 0, 0, 0.5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsUnderrepresented(tt.val, tt.avg, tt.threshold)
if got != tt.want {
t.Errorf("IsUnderrepresented(%v, %v, %v) = %v, want %v", tt.val, tt.avg, tt.threshold, got, tt.want)
}
})
}
}

View file

@ -151,12 +151,11 @@ func nearest(_ js.Value, args []js.Value) (any, error) {
return nil, fmt.Errorf("unknown treeId %d", id)
}
p, d, found := t.Nearest(query)
if !found {
return map[string]any{"found": false}, nil
out := map[string]any{
"point": map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value},
"dist": d,
"found": found,
}
out := kdPointToJS(p)
out["dist"] = d
out["found"] = true
return out, nil
}
@ -178,13 +177,9 @@ func kNearest(_ js.Value, args []js.Value) (any, error) {
pts, dists := t.KNearest(query, k)
jsPts := make([]any, len(pts))
for i, p := range pts {
jsPts[i] = kdPointToJS(p)
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
}
jsDists := make([]any, len(dists))
for i, d := range dists {
jsDists[i] = d
}
return map[string]any{"points": jsPts, "dists": jsDists}, nil
return map[string]any{"points": jsPts, "dists": dists}, nil
}
func radius(_ js.Value, args []js.Value) (any, error) {
@ -205,13 +200,9 @@ func radius(_ js.Value, args []js.Value) (any, error) {
pts, dists := t.Radius(query, r)
jsPts := make([]any, len(pts))
for i, p := range pts {
jsPts[i] = kdPointToJS(p)
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
}
jsDists := make([]any, len(dists))
for i, d := range dists {
jsDists[i] = d
}
return map[string]any{"points": jsPts, "dists": jsDists}, nil
return map[string]any{"points": jsPts, "dists": dists}, nil
}
func exportJSON(_ js.Value, args []js.Value) (any, error) {
@ -224,20 +215,488 @@ func exportJSON(_ js.Value, args []js.Value) (any, error) {
if !ok {
return nil, fmt.Errorf("unknown treeId %d", id)
}
// naive export: ask for all points by radius from origin with large r; or keep
// internal slice? KDTree doesn't expose iteration, so skip heavy export here.
// Return metrics only for now.
m := map[string]any{"dim": t.Dim(), "len": t.Len()}
// Export all points
points := t.Points()
jsPts := make([]any, len(points))
for i, p := range points {
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
}
m := map[string]any{
"dim": t.Dim(),
"len": t.Len(),
"backend": string(t.Backend()),
"points": jsPts,
}
b, _ := json.Marshal(m)
return string(b), nil
}
func kdPointToJS(p pd.KDPoint[string]) map[string]any {
coords := make([]any, len(p.Coords))
for i, v := range p.Coords {
coords[i] = v
func getAnalytics(_ js.Value, args []js.Value) (any, error) {
// getAnalytics(treeId) -> analytics snapshot
if len(args) < 1 {
return nil, errors.New("getAnalytics(treeId)")
}
return map[string]any{"id": p.ID, "coords": coords, "value": p.Value}
id := args[0].Int()
t, ok := treeRegistry[id]
if !ok {
return nil, fmt.Errorf("unknown treeId %d", id)
}
snap := t.GetAnalyticsSnapshot()
return map[string]any{
"queryCount": snap.QueryCount,
"insertCount": snap.InsertCount,
"deleteCount": snap.DeleteCount,
"avgQueryTimeNs": snap.AvgQueryTimeNs,
"minQueryTimeNs": snap.MinQueryTimeNs,
"maxQueryTimeNs": snap.MaxQueryTimeNs,
"lastQueryTimeNs": snap.LastQueryTimeNs,
"lastQueryAt": snap.LastQueryAt.UnixMilli(),
"createdAt": snap.CreatedAt.UnixMilli(),
"backendRebuildCount": snap.BackendRebuildCnt,
"lastRebuiltAt": snap.LastRebuiltAt.UnixMilli(),
}, nil
}
func getPeerStats(_ js.Value, args []js.Value) (any, error) {
// getPeerStats(treeId) -> array of peer stats
if len(args) < 1 {
return nil, errors.New("getPeerStats(treeId)")
}
id := args[0].Int()
t, ok := treeRegistry[id]
if !ok {
return nil, fmt.Errorf("unknown treeId %d", id)
}
stats := t.GetPeerStats()
jsStats := make([]any, len(stats))
for i, s := range stats {
jsStats[i] = map[string]any{
"peerId": s.PeerID,
"selectionCount": s.SelectionCount,
"avgDistance": s.AvgDistance,
"lastSelectedAt": s.LastSelectedAt.UnixMilli(),
}
}
return jsStats, nil
}
func getTopPeers(_ js.Value, args []js.Value) (any, error) {
// getTopPeers(treeId, n) -> array of top n peer stats
if len(args) < 2 {
return nil, errors.New("getTopPeers(treeId, n)")
}
id := args[0].Int()
n := args[1].Int()
t, ok := treeRegistry[id]
if !ok {
return nil, fmt.Errorf("unknown treeId %d", id)
}
stats := t.GetTopPeers(n)
jsStats := make([]any, len(stats))
for i, s := range stats {
jsStats[i] = map[string]any{
"peerId": s.PeerID,
"selectionCount": s.SelectionCount,
"avgDistance": s.AvgDistance,
"lastSelectedAt": s.LastSelectedAt.UnixMilli(),
}
}
return jsStats, nil
}
func getAxisDistributions(_ js.Value, args []js.Value) (any, error) {
// getAxisDistributions(treeId, axisNames?: string[]) -> array of axis distribution stats
if len(args) < 1 {
return nil, errors.New("getAxisDistributions(treeId)")
}
id := args[0].Int()
t, ok := treeRegistry[id]
if !ok {
return nil, fmt.Errorf("unknown treeId %d", id)
}
var axisNames []string
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
ln := args[1].Length()
axisNames = make([]string, ln)
for i := 0; i < ln; i++ {
axisNames[i] = args[1].Index(i).String()
}
}
dists := t.ComputeDistanceDistribution(axisNames)
jsDists := make([]any, len(dists))
for i, d := range dists {
jsDists[i] = map[string]any{
"axis": d.Axis,
"name": d.Name,
"stats": map[string]any{
"count": d.Stats.Count,
"min": d.Stats.Min,
"max": d.Stats.Max,
"mean": d.Stats.Mean,
"median": d.Stats.Median,
"stdDev": d.Stats.StdDev,
"p25": d.Stats.P25,
"p75": d.Stats.P75,
"p90": d.Stats.P90,
"p99": d.Stats.P99,
"variance": d.Stats.Variance,
"skewness": d.Stats.Skewness,
},
}
}
return jsDists, nil
}
func resetAnalytics(_ js.Value, args []js.Value) (any, error) {
// resetAnalytics(treeId) -> resets all analytics
if len(args) < 1 {
return nil, errors.New("resetAnalytics(treeId)")
}
id := args[0].Int()
t, ok := treeRegistry[id]
if !ok {
return nil, fmt.Errorf("unknown treeId %d", id)
}
t.ResetAnalytics()
return true, nil
}
func computeDistributionStats(_ js.Value, args []js.Value) (any, error) {
// computeDistributionStats(distances: number[]) -> distribution stats
if len(args) < 1 {
return nil, errors.New("computeDistributionStats(distances)")
}
distances, err := getFloatSlice(args[0])
if err != nil {
return nil, err
}
stats := pd.ComputeDistributionStats(distances)
return map[string]any{
"count": stats.Count,
"min": stats.Min,
"max": stats.Max,
"mean": stats.Mean,
"median": stats.Median,
"stdDev": stats.StdDev,
"p25": stats.P25,
"p75": stats.P75,
"p90": stats.P90,
"p99": stats.P99,
"variance": stats.Variance,
"skewness": stats.Skewness,
"sampleSize": stats.SampleSize,
"computedAt": stats.ComputedAt.UnixMilli(),
}, nil
}
func computePeerQualityScore(_ js.Value, args []js.Value) (any, error) {
// computePeerQualityScore(metrics: NATRoutingMetrics, weights?: QualityWeights) -> score
if len(args) < 1 {
return nil, errors.New("computePeerQualityScore(metrics)")
}
m := args[0]
metrics := pd.NATRoutingMetrics{
ConnectivityScore: m.Get("connectivityScore").Float(),
SymmetryScore: m.Get("symmetryScore").Float(),
RelayProbability: m.Get("relayProbability").Float(),
DirectSuccessRate: m.Get("directSuccessRate").Float(),
AvgRTTMs: m.Get("avgRttMs").Float(),
JitterMs: m.Get("jitterMs").Float(),
PacketLossRate: m.Get("packetLossRate").Float(),
BandwidthMbps: m.Get("bandwidthMbps").Float(),
NATType: m.Get("natType").String(),
}
var weights *pd.QualityWeights
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
w := args[1]
weights = &pd.QualityWeights{
Latency: w.Get("latency").Float(),
Jitter: w.Get("jitter").Float(),
PacketLoss: w.Get("packetLoss").Float(),
Bandwidth: w.Get("bandwidth").Float(),
Connectivity: w.Get("connectivity").Float(),
Symmetry: w.Get("symmetry").Float(),
DirectSuccess: w.Get("directSuccess").Float(),
RelayPenalty: w.Get("relayPenalty").Float(),
NATType: w.Get("natType").Float(),
}
}
score := pd.PeerQualityScore(metrics, weights)
return score, nil
}
func computeTrustScore(_ js.Value, args []js.Value) (any, error) {
// computeTrustScore(metrics: TrustMetrics) -> score
if len(args) < 1 {
return nil, errors.New("computeTrustScore(metrics)")
}
m := args[0]
metrics := pd.TrustMetrics{
ReputationScore: m.Get("reputationScore").Float(),
SuccessfulTransactions: int64(m.Get("successfulTransactions").Int()),
FailedTransactions: int64(m.Get("failedTransactions").Int()),
AgeSeconds: int64(m.Get("ageSeconds").Int()),
VouchCount: m.Get("vouchCount").Int(),
FlagCount: m.Get("flagCount").Int(),
ProofOfWork: m.Get("proofOfWork").Float(),
}
score := pd.ComputeTrustScore(metrics)
return score, nil
}
func getDefaultQualityWeights(_ js.Value, _ []js.Value) (any, error) {
w := pd.DefaultQualityWeights()
return map[string]any{
"latency": w.Latency,
"jitter": w.Jitter,
"packetLoss": w.PacketLoss,
"bandwidth": w.Bandwidth,
"connectivity": w.Connectivity,
"symmetry": w.Symmetry,
"directSuccess": w.DirectSuccess,
"relayPenalty": w.RelayPenalty,
"natType": w.NATType,
}, nil
}
func getDefaultPeerFeatureRanges(_ js.Value, _ []js.Value) (any, error) {
ranges := pd.DefaultPeerFeatureRanges()
jsRanges := make([]any, len(ranges.Ranges))
for i, r := range ranges.Ranges {
jsRanges[i] = map[string]any{
"min": r.Min,
"max": r.Max,
}
}
return map[string]any{
"ranges": jsRanges,
"labels": pd.StandardFeatureLabels(),
}, nil
}
func normalizePeerFeatures(_ js.Value, args []js.Value) (any, error) {
// normalizePeerFeatures(features: number[], ranges?: FeatureRanges) -> number[]
if len(args) < 1 {
return nil, errors.New("normalizePeerFeatures(features)")
}
features, err := getFloatSlice(args[0])
if err != nil {
return nil, err
}
ranges := pd.DefaultPeerFeatureRanges()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
rangesArg := args[1].Get("ranges")
if !rangesArg.IsUndefined() && !rangesArg.IsNull() {
ln := rangesArg.Length()
ranges.Ranges = make([]pd.AxisStats, ln)
for i := 0; i < ln; i++ {
r := rangesArg.Index(i)
ranges.Ranges[i] = pd.AxisStats{
Min: r.Get("min").Float(),
Max: r.Get("max").Float(),
}
}
}
}
normalized := pd.NormalizePeerFeatures(features, ranges)
return normalized, nil
}
func weightedPeerFeatures(_ js.Value, args []js.Value) (any, error) {
// weightedPeerFeatures(normalized: number[], weights: number[]) -> number[]
if len(args) < 2 {
return nil, errors.New("weightedPeerFeatures(normalized, weights)")
}
normalized, err := getFloatSlice(args[0])
if err != nil {
return nil, err
}
weights, err := getFloatSlice(args[1])
if err != nil {
return nil, err
}
weighted := pd.WeightedPeerFeatures(normalized, weights)
return weighted, nil
}
// ============================================================================
// DNS Tools Functions
// ============================================================================
func getExternalToolLinks(_ js.Value, args []js.Value) (any, error) {
// getExternalToolLinks(domain: string) -> ExternalToolLinks
if len(args) < 1 {
return nil, errors.New("getExternalToolLinks(domain)")
}
domain := args[0].String()
links := pd.GetExternalToolLinks(domain)
return externalToolLinksToJS(links), nil
}
func getExternalToolLinksIP(_ js.Value, args []js.Value) (any, error) {
// getExternalToolLinksIP(ip: string) -> ExternalToolLinks
if len(args) < 1 {
return nil, errors.New("getExternalToolLinksIP(ip)")
}
ip := args[0].String()
links := pd.GetExternalToolLinksIP(ip)
return externalToolLinksToJS(links), nil
}
func getExternalToolLinksEmail(_ js.Value, args []js.Value) (any, error) {
// getExternalToolLinksEmail(emailOrDomain: string) -> ExternalToolLinks
if len(args) < 1 {
return nil, errors.New("getExternalToolLinksEmail(emailOrDomain)")
}
emailOrDomain := args[0].String()
links := pd.GetExternalToolLinksEmail(emailOrDomain)
return externalToolLinksToJS(links), nil
}
func externalToolLinksToJS(links pd.ExternalToolLinks) map[string]any {
return map[string]any{
"target": links.Target,
"type": links.Type,
// MXToolbox
"mxtoolboxDns": links.MXToolboxDNS,
"mxtoolboxMx": links.MXToolboxMX,
"mxtoolboxBlacklist": links.MXToolboxBlacklist,
"mxtoolboxSmtp": links.MXToolboxSMTP,
"mxtoolboxSpf": links.MXToolboxSPF,
"mxtoolboxDmarc": links.MXToolboxDMARC,
"mxtoolboxDkim": links.MXToolboxDKIM,
"mxtoolboxHttp": links.MXToolboxHTTP,
"mxtoolboxHttps": links.MXToolboxHTTPS,
"mxtoolboxPing": links.MXToolboxPing,
"mxtoolboxTrace": links.MXToolboxTrace,
"mxtoolboxWhois": links.MXToolboxWhois,
"mxtoolboxAsn": links.MXToolboxASN,
// DNSChecker
"dnscheckerDns": links.DNSCheckerDNS,
"dnscheckerPropagation": links.DNSCheckerPropagation,
// Other tools
"whois": links.WhoIs,
"viewdns": links.ViewDNS,
"intodns": links.IntoDNS,
"dnsviz": links.DNSViz,
"securitytrails": links.SecurityTrails,
"shodan": links.Shodan,
"censys": links.Censys,
"builtwith": links.BuiltWith,
"ssllabs": links.SSLLabs,
"hstsPreload": links.HSTSPreload,
"hardenize": links.Hardenize,
// IP-specific
"ipinfo": links.IPInfo,
"abuseipdb": links.AbuseIPDB,
"virustotal": links.VirusTotal,
"threatcrowd": links.ThreatCrowd,
// Email-specific
"mailtester": links.MailTester,
"learndmarc": links.LearnDMARC,
}
}
func getRDAPServers(_ js.Value, _ []js.Value) (any, error) {
// Returns a list of known RDAP servers for reference
servers := map[string]any{
"tlds": map[string]string{
"com": "https://rdap.verisign.com/com/v1/",
"net": "https://rdap.verisign.com/net/v1/",
"org": "https://rdap.publicinterestregistry.org/rdap/",
"info": "https://rdap.afilias.net/rdap/info/",
"io": "https://rdap.nic.io/",
"co": "https://rdap.nic.co/",
"dev": "https://rdap.nic.google/",
"app": "https://rdap.nic.google/",
},
"rirs": map[string]string{
"arin": "https://rdap.arin.net/registry/",
"ripe": "https://rdap.db.ripe.net/",
"apnic": "https://rdap.apnic.net/",
"afrinic": "https://rdap.afrinic.net/rdap/",
"lacnic": "https://rdap.lacnic.net/rdap/",
},
"universal": "https://rdap.org/",
}
return servers, nil
}
func buildRDAPDomainURL(_ js.Value, args []js.Value) (any, error) {
// buildRDAPDomainURL(domain: string) -> string
if len(args) < 1 {
return nil, errors.New("buildRDAPDomainURL(domain)")
}
domain := args[0].String()
// Use universal RDAP redirector
return fmt.Sprintf("https://rdap.org/domain/%s", domain), nil
}
func buildRDAPIPURL(_ js.Value, args []js.Value) (any, error) {
// buildRDAPIPURL(ip: string) -> string
if len(args) < 1 {
return nil, errors.New("buildRDAPIPURL(ip)")
}
ip := args[0].String()
return fmt.Sprintf("https://rdap.org/ip/%s", ip), nil
}
func buildRDAPASNURL(_ js.Value, args []js.Value) (any, error) {
// buildRDAPASNURL(asn: string) -> string
if len(args) < 1 {
return nil, errors.New("buildRDAPASNURL(asn)")
}
asn := args[0].String()
// Normalize ASN
asnNum := asn
if len(asn) > 2 && (asn[:2] == "AS" || asn[:2] == "as") {
asnNum = asn[2:]
}
return fmt.Sprintf("https://rdap.org/autnum/%s", asnNum), nil
}
func getDNSRecordTypes(_ js.Value, _ []js.Value) (any, error) {
// Returns all available DNS record types
types := pd.GetAllDNSRecordTypes()
result := make([]string, len(types))
for i, t := range types {
result[i] = string(t)
}
return result, nil
}
func getDNSRecordTypeInfo(_ js.Value, _ []js.Value) (any, error) {
// Returns detailed info about all DNS record types
info := pd.GetDNSRecordTypeInfo()
result := make([]any, len(info))
for i, r := range info {
result[i] = map[string]any{
"type": string(r.Type),
"name": r.Name,
"description": r.Description,
"rfc": r.RFC,
"common": r.Common,
}
}
return result, nil
}
func getCommonDNSRecordTypes(_ js.Value, _ []js.Value) (any, error) {
// Returns only commonly used DNS record types
types := pd.GetCommonDNSRecordTypes()
result := make([]string, len(types))
for i, t := range types {
result[i] = string(t)
}
return result, nil
}
func main() {
@ -254,6 +713,34 @@ func main() {
export("pxRadius", radius)
export("pxExportJSON", exportJSON)
// Export analytics API
export("pxGetAnalytics", getAnalytics)
export("pxGetPeerStats", getPeerStats)
export("pxGetTopPeers", getTopPeers)
export("pxGetAxisDistributions", getAxisDistributions)
export("pxResetAnalytics", resetAnalytics)
export("pxComputeDistributionStats", computeDistributionStats)
// Export NAT routing / peer quality API
export("pxComputePeerQualityScore", computePeerQualityScore)
export("pxComputeTrustScore", computeTrustScore)
export("pxGetDefaultQualityWeights", getDefaultQualityWeights)
export("pxGetDefaultPeerFeatureRanges", getDefaultPeerFeatureRanges)
export("pxNormalizePeerFeatures", normalizePeerFeatures)
export("pxWeightedPeerFeatures", weightedPeerFeatures)
// Export DNS tools API
export("pxGetExternalToolLinks", getExternalToolLinks)
export("pxGetExternalToolLinksIP", getExternalToolLinksIP)
export("pxGetExternalToolLinksEmail", getExternalToolLinksEmail)
export("pxGetRDAPServers", getRDAPServers)
export("pxBuildRDAPDomainURL", buildRDAPDomainURL)
export("pxBuildRDAPIPURL", buildRDAPIPURL)
export("pxBuildRDAPASNURL", buildRDAPASNURL)
export("pxGetDNSRecordTypes", getDNSRecordTypes)
export("pxGetDNSRecordTypeInfo", getDNSRecordTypeInfo)
export("pxGetCommonDNSRecordTypes", getCommonDNSRecordTypes)
// Keep running
select {}
}