Add lint configuration, benchmarks, and documentation for KDTree
This commit is contained in:
parent
ec61d6afde
commit
dad508de31
16 changed files with 639 additions and 13 deletions
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -4,9 +4,11 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [ main, master ]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test:
|
build:
|
||||||
|
name: Build & Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -15,12 +17,15 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go ${{ matrix.go-version }}
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go-version }}
|
go-version: ${{ matrix.go-version }}
|
||||||
|
|
||||||
- name: Verify go.mod is tidy
|
- name: Go env
|
||||||
|
run: go env
|
||||||
|
|
||||||
|
- name: Tidy check
|
||||||
run: |
|
run: |
|
||||||
go mod tidy
|
go mod tidy
|
||||||
git diff --exit-code -- go.mod go.sum
|
git diff --exit-code -- go.mod go.sum
|
||||||
|
|
@ -28,15 +33,25 @@ jobs:
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build ./...
|
run: go build ./...
|
||||||
|
|
||||||
- name: Test (race)
|
|
||||||
run: go test -race ./...
|
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Govulncheck
|
- name: Test (race)
|
||||||
|
run: go test -race ./...
|
||||||
|
|
||||||
|
- name: Build examples
|
||||||
|
run: |
|
||||||
|
if [ -d examples ]; then
|
||||||
|
go build ./examples/...
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vulncheck
|
||||||
uses: golang/govulncheck-action@v1
|
uses: golang/govulncheck-action@v1
|
||||||
with:
|
with:
|
||||||
go-version-input: ${{ matrix.go-version }}
|
go-version-input: ${{ matrix.go-version }}
|
||||||
# Run against packages in the module
|
|
||||||
args: ./...
|
- name: Lint
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: --timeout=5m
|
||||||
|
|
|
||||||
20
.golangci.yml
Normal file
20
.golangci.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- govet
|
||||||
|
- gosimple
|
||||||
|
- staticcheck
|
||||||
|
- ineffassign
|
||||||
|
- gofmt
|
||||||
|
- revive
|
||||||
|
issues:
|
||||||
|
exclude-use-default: false
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
linters-settings:
|
||||||
|
revive:
|
||||||
|
severity: warning
|
||||||
|
rules:
|
||||||
|
- name: exported
|
||||||
|
arguments: ["disable"] # keep comments pragmatic; we have pkg docs and key API docs
|
||||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- README badges (pkg.go.dev, CI, Go Report Card, govulncheck) and KDTree performance/concurrency notes.
|
||||||
|
- Examples directory with runnable programs: 1D ping, 2D ping+hop, 3D ping+hop+geo, 4D ping+hop+geo+score.
|
||||||
|
- CI workflow (Go 1.22/1.23): tidy check, build, vet, test -race, build examples, govulncheck, golangci-lint.
|
||||||
|
- Lint configuration (.golangci.yml) with a pragmatic ruleset.
|
||||||
|
- Contributor docs: CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md.
|
||||||
|
- pkg.go.dev example functions for KDTree usage and helpers.
|
||||||
|
- Fuzz tests and benchmarks for KDTree (Nearest/KNearest/Radius and metrics).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Documented KDTree complexity and tie-ordering in code comments.
|
||||||
|
- Docs: API examples synced to Version 0.2.0; added references to helpers and examples.
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-10-??
|
||||||
|
### Added
|
||||||
|
- KDTree public API with generic payloads and helper builders (Build2D/3D/4D).
|
||||||
|
- Docs pages for DHT examples and multi-dimensional KDTree usage.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/Snider/Poindexter/compare/v0.2.0...HEAD
|
||||||
|
[0.2.0]: https://github.com/Snider/Poindexter/releases/tag/v0.2.0
|
||||||
9
CODE_OF_CONDUCT.md
Normal file
9
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
This project has adopted the Contributor Covenant Code of Conduct.
|
||||||
|
|
||||||
|
- Version: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
|
||||||
|
- FAQ: https://www.contributor-covenant.org/faq
|
||||||
|
- Translations: https://www.contributor-covenant.org/translations
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the maintainers via GitHub issues or by email listed on the repository profile. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.
|
||||||
59
CONTRIBUTING.md
Normal file
59
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Contributing to Poindexter
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This document describes how to build, test, lint, and propose changes.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
- Go 1.22+ (1.23 preferred)
|
||||||
|
- `git clone https://github.com/Snider/Poindexter`
|
||||||
|
- `cd Poindexter`
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
- Tidy deps: `go mod tidy`
|
||||||
|
- Build: `go build ./...`
|
||||||
|
- Run tests: `go test ./...`
|
||||||
|
- Run race tests: `go test -race ./...`
|
||||||
|
- Run examples: `go run ./examples/...`
|
||||||
|
|
||||||
|
## Lint and vet
|
||||||
|
|
||||||
|
We use golangci-lint in CI. To run locally:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Install once
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
|
||||||
|
|
||||||
|
# Run
|
||||||
|
golangci-lint run
|
||||||
|
```
|
||||||
|
|
||||||
|
Also run `go vet ./...` periodically.
|
||||||
|
|
||||||
|
## Fuzzing and benchmarks
|
||||||
|
|
||||||
|
- Fuzz (manually): `go test -run=NONE -fuzz=Fuzz -fuzztime=10s`
|
||||||
|
- Benchmarks: `go test -bench=. -benchmem`
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
- Create a branch from `main`.
|
||||||
|
- Ensure `go mod tidy` produces no changes.
|
||||||
|
- Ensure `go test -race ./...` passes.
|
||||||
|
- Ensure `golangci-lint run` has no issues.
|
||||||
|
- Update CHANGELOG.md (Unreleased section) with a brief summary.
|
||||||
|
|
||||||
|
## Coding style
|
||||||
|
|
||||||
|
- Follow standard Go formatting and idioms.
|
||||||
|
- Public APIs must have doc comments starting with the identifier name and should be concise.
|
||||||
|
- Avoid breaking changes in minor versions; use SemVer.
|
||||||
|
|
||||||
|
## Release process
|
||||||
|
|
||||||
|
Maintainers:
|
||||||
|
- Update CHANGELOG.md.
|
||||||
|
- Tag releases `vX.Y.Z`.
|
||||||
|
- Consider updating docs and README badges if needed.
|
||||||
|
|
||||||
|
Thanks for helping improve Poindexter!
|
||||||
17
README.md
17
README.md
|
|
@ -1,5 +1,10 @@
|
||||||
# Poindexter
|
# Poindexter
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/github.com/Snider/Poindexter)
|
||||||
|
[](https://github.com/Snider/Poindexter/actions)
|
||||||
|
[](https://goreportcard.com/report/github.com/Snider/Poindexter)
|
||||||
|
[](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck)
|
||||||
|
|
||||||
A Go library package providing utility functions including sorting algorithms with custom comparators.
|
A Go library package providing utility functions including sorting algorithms with custom comparators.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
@ -59,6 +64,18 @@ func main() {
|
||||||
|
|
||||||
Full documentation is available at [https://snider.github.io/Poindexter/](https://snider.github.io/Poindexter/)
|
Full documentation is available at [https://snider.github.io/Poindexter/](https://snider.github.io/Poindexter/)
|
||||||
|
|
||||||
|
Explore runnable examples in the repository:
|
||||||
|
- examples/dht_ping_1d
|
||||||
|
- examples/kdtree_2d_ping_hop
|
||||||
|
- examples/kdtree_3d_ping_hop_geo
|
||||||
|
- examples/kdtree_4d_ping_hop_geo_score
|
||||||
|
|
||||||
|
### KDTree performance and notes
|
||||||
|
- Current KDTree queries are O(n) linear scans, which are great for small-to-medium datasets or low-latency prototyping. For 1e5+ points and low/medium dimensions, consider swapping the internal engine to `gonum.org/v1/gonum/spatial/kdtree` (the API here is compatible by design).
|
||||||
|
- Insert is O(1) amortized; delete by ID is O(1) via swap-delete; order is not preserved.
|
||||||
|
- Concurrency: the KDTree type is not safe for concurrent mutation. Protect with a mutex or share immutable snapshots for read-mostly workloads.
|
||||||
|
- See multi-dimensional examples (ping/hops/geo/score) in docs and `examples/`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2). See [LICENSE](LICENSE) for details.
|
This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2). See [LICENSE](LICENSE) for details.
|
||||||
|
|
|
||||||
20
SECURITY.md
Normal file
20
SECURITY.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We support the latest minor release series. Please use the most recent tagged version.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you believe you have found a security vulnerability in Poindexter:
|
||||||
|
|
||||||
|
- Please DO NOT open a public GitHub issue.
|
||||||
|
- Email the maintainer listed on the repository profile with:
|
||||||
|
- A description of the issue and its impact
|
||||||
|
- Steps to reproduce (a minimal proof-of-concept if possible)
|
||||||
|
- Affected versions/commit hashes
|
||||||
|
- We will acknowledge receipt within 5 business days and work with you on a fix and coordinated disclosure.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
We run `govulncheck` in CI. If you see alerts or advisories that affect Poindexter, please include links or CVE identifiers in your report.
|
||||||
69
bench_kdtree_test.go
Normal file
69
bench_kdtree_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package poindexter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makePoints(n, dim int) []KDPoint[int] {
|
||||||
|
pts := make([]KDPoint[int], n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
coords := make([]float64, dim)
|
||||||
|
for d := 0; d < dim; d++ {
|
||||||
|
coords[d] = rand.Float64()
|
||||||
|
}
|
||||||
|
pts[i] = KDPoint[int]{ID: fmt.Sprint(i), Coords: coords, Value: i}
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchNearest(b *testing.B, n, dim int) {
|
||||||
|
pts := makePoints(n, dim)
|
||||||
|
tr, _ := NewKDTree(pts)
|
||||||
|
q := make([]float64, dim)
|
||||||
|
for i := range q {
|
||||||
|
q[i] = 0.5
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _, _ = tr.Nearest(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchKNearest(b *testing.B, n, dim, k int) {
|
||||||
|
pts := makePoints(n, dim)
|
||||||
|
tr, _ := NewKDTree(pts)
|
||||||
|
q := make([]float64, dim)
|
||||||
|
for i := range q {
|
||||||
|
q[i] = 0.5
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = tr.KNearest(q, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchRadius(b *testing.B, n, dim int, r float64) {
|
||||||
|
pts := makePoints(n, dim)
|
||||||
|
tr, _ := NewKDTree(pts)
|
||||||
|
q := make([]float64, dim)
|
||||||
|
for i := range q {
|
||||||
|
q[i] = 0.5
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = tr.Radius(q, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkNearest_1k_2D(b *testing.B) { benchNearest(b, 1_000, 2) }
|
||||||
|
func BenchmarkNearest_10k_2D(b *testing.B) { benchNearest(b, 10_000, 2) }
|
||||||
|
func BenchmarkNearest_1k_4D(b *testing.B) { benchNearest(b, 1_000, 4) }
|
||||||
|
func BenchmarkNearest_10k_4D(b *testing.B) { benchNearest(b, 10_000, 4) }
|
||||||
|
|
||||||
|
func BenchmarkKNearest10_1k_2D(b *testing.B) { benchKNearest(b, 1_000, 2, 10) }
|
||||||
|
func BenchmarkKNearest10_10k_2D(b *testing.B) { benchKNearest(b, 10_000, 2, 10) }
|
||||||
|
|
||||||
|
func BenchmarkRadiusMid_1k_2D(b *testing.B) { benchRadius(b, 1_000, 2, 0.5) }
|
||||||
|
func BenchmarkRadiusMid_10k_2D(b *testing.B) { benchRadius(b, 10_000, 2, 0.5) }
|
||||||
37
docs/api.md
37
docs/api.md
|
|
@ -373,3 +373,40 @@ best, dist, _ := kdt.Nearest([]float64{0, 0, 0, 0})
|
||||||
Notes:
|
Notes:
|
||||||
- Keep and reuse your normalization parameters (min/max) if you need consistency across updates; otherwise rebuild points when the candidate set changes.
|
- Keep and reuse your normalization parameters (min/max) if you need consistency across updates; otherwise rebuild points when the candidate set changes.
|
||||||
- Use `invert` to turn “higher is better” features (like scores) into lower costs for distance calculations.
|
- Use `invert` to turn “higher is better” features (like scores) into lower costs for distance calculations.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KDTree Constructors and Errors
|
||||||
|
|
||||||
|
### NewKDTree
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewKDTree[T any](pts []KDPoint[T], opts ...KDOption) (*KDTree[T], error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a KDTree from the provided points. All points must have the same dimensionality (> 0) and IDs (if provided) must be unique.
|
||||||
|
|
||||||
|
Possible errors:
|
||||||
|
- `ErrEmptyPoints`: no points provided
|
||||||
|
- `ErrZeroDim`: dimension must be at least 1
|
||||||
|
- `ErrDimMismatch`: inconsistent dimensionality among points
|
||||||
|
- `ErrDuplicateID`: duplicate point ID encountered
|
||||||
|
|
||||||
|
### NewKDTreeFromDim
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewKDTreeFromDim[T any](dim int, opts ...KDOption) (*KDTree[T], error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Construct an empty KDTree with the given dimension, then populate later via `Insert`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KDTree Notes: Complexity, Ties, Concurrency
|
||||||
|
|
||||||
|
- Complexity: current implementation uses O(n) linear scans for queries (`Nearest`, `KNearest`, `Radius`). Inserts are O(1) amortized. Deletes by ID are O(1) using swap-delete (order not preserved).
|
||||||
|
- Tie ordering: when multiple neighbors have the same distance, ordering of ties is arbitrary and not stable between calls.
|
||||||
|
- Concurrency: KDTree is not safe for concurrent mutation. Wrap with a mutex or share immutable snapshots for read-mostly workloads.
|
||||||
|
|
||||||
|
See runnable examples in the repository `examples/` and the docs pages for 1D DHT and multi-dimensional KDTree usage.
|
||||||
|
|
|
||||||
41
examples/dht_ping_1d/main.go
Normal file
41
examples/dht_ping_1d/main.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
poindexter "github.com/Snider/Poindexter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Peer struct {
|
||||||
|
Addr string
|
||||||
|
Ping int
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Toy DHT routing table
|
||||||
|
table := []Peer{
|
||||||
|
{Addr: "peer1.example:4001", Ping: 74},
|
||||||
|
{Addr: "peer2.example:4001", Ping: 52},
|
||||||
|
{Addr: "peer3.example:4001", Ping: 110},
|
||||||
|
{Addr: "peer4.example:4001", Ping: 35},
|
||||||
|
{Addr: "peer5.example:4001", Ping: 60},
|
||||||
|
{Addr: "peer6.example:4001", Ping: 44},
|
||||||
|
}
|
||||||
|
pts := make([]poindexter.KDPoint[Peer], 0, len(table))
|
||||||
|
for i, p := range table {
|
||||||
|
pts = append(pts, poindexter.KDPoint[Peer]{
|
||||||
|
ID: fmt.Sprintf("peer-%d", i+1),
|
||||||
|
Coords: []float64{float64(p.Ping)},
|
||||||
|
Value: p,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
kdt, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
best, d, ok := kdt.Nearest([]float64{0})
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("no peers found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Best peer: %s (ping=%d ms), distance=%.0f\n", best.Value.Addr, best.Value.Ping, d)
|
||||||
|
}
|
||||||
34
examples/kdtree_2d_ping_hop/main.go
Normal file
34
examples/kdtree_2d_ping_hop/main.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
poindexter "github.com/Snider/Poindexter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Peer2 struct {
|
||||||
|
ID string
|
||||||
|
PingMS float64
|
||||||
|
Hops float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
peers := []Peer2{
|
||||||
|
{ID: "A", PingMS: 22, Hops: 3},
|
||||||
|
{ID: "B", PingMS: 34, Hops: 2},
|
||||||
|
{ID: "C", PingMS: 15, Hops: 4},
|
||||||
|
{ID: "D", PingMS: 55, Hops: 1},
|
||||||
|
{ID: "E", PingMS: 18, Hops: 2},
|
||||||
|
}
|
||||||
|
weights := [2]float64{1.0, 1.0}
|
||||||
|
invert := [2]bool{false, false}
|
||||||
|
pts, _ := poindexter.Build2D(
|
||||||
|
peers,
|
||||||
|
func(p Peer2) string { return p.ID },
|
||||||
|
func(p Peer2) float64 { return p.PingMS },
|
||||||
|
func(p Peer2) float64 { return p.Hops },
|
||||||
|
weights, invert,
|
||||||
|
)
|
||||||
|
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{}))
|
||||||
|
best, _, _ := tr.Nearest([]float64{0, 0.3})
|
||||||
|
fmt.Println("2D best:", best.ID)
|
||||||
|
}
|
||||||
36
examples/kdtree_3d_ping_hop_geo/main.go
Normal file
36
examples/kdtree_3d_ping_hop_geo/main.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
poindexter "github.com/Snider/Poindexter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Peer3 struct {
|
||||||
|
ID string
|
||||||
|
PingMS float64
|
||||||
|
Hops float64
|
||||||
|
GeoKM float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
peers := []Peer3{
|
||||||
|
{ID: "A", PingMS: 22, Hops: 3, GeoKM: 1200},
|
||||||
|
{ID: "B", PingMS: 34, Hops: 2, GeoKM: 800},
|
||||||
|
{ID: "C", PingMS: 15, Hops: 4, GeoKM: 4500},
|
||||||
|
{ID: "D", PingMS: 55, Hops: 1, GeoKM: 300},
|
||||||
|
{ID: "E", PingMS: 18, Hops: 2, GeoKM: 2200},
|
||||||
|
}
|
||||||
|
weights := [3]float64{1.0, 0.7, 0.3}
|
||||||
|
invert := [3]bool{false, false, false}
|
||||||
|
pts, _ := poindexter.Build3D(
|
||||||
|
peers,
|
||||||
|
func(p Peer3) string { return p.ID },
|
||||||
|
func(p Peer3) float64 { return p.PingMS },
|
||||||
|
func(p Peer3) float64 { return p.Hops },
|
||||||
|
func(p Peer3) float64 { return p.GeoKM },
|
||||||
|
weights, invert,
|
||||||
|
)
|
||||||
|
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||||
|
best, _, _ := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.4})
|
||||||
|
fmt.Println("3D best:", best.ID)
|
||||||
|
}
|
||||||
38
examples/kdtree_4d_ping_hop_geo_score/main.go
Normal file
38
examples/kdtree_4d_ping_hop_geo_score/main.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
poindexter "github.com/Snider/Poindexter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Peer4 struct {
|
||||||
|
ID string
|
||||||
|
PingMS float64
|
||||||
|
Hops float64
|
||||||
|
GeoKM float64
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
peers := []Peer4{
|
||||||
|
{ID: "A", PingMS: 22, Hops: 3, GeoKM: 1200, Score: 0.86},
|
||||||
|
{ID: "B", PingMS: 34, Hops: 2, GeoKM: 800, Score: 0.91},
|
||||||
|
{ID: "C", PingMS: 15, Hops: 4, GeoKM: 4500, Score: 0.70},
|
||||||
|
{ID: "D", PingMS: 55, Hops: 1, GeoKM: 300, Score: 0.95},
|
||||||
|
{ID: "E", PingMS: 18, Hops: 2, GeoKM: 2200, Score: 0.80},
|
||||||
|
}
|
||||||
|
weights := [4]float64{1.0, 0.7, 0.2, 1.2}
|
||||||
|
invert := [4]bool{false, false, false, true}
|
||||||
|
pts, _ := poindexter.Build4D(
|
||||||
|
peers,
|
||||||
|
func(p Peer4) string { return p.ID },
|
||||||
|
func(p Peer4) float64 { return p.PingMS },
|
||||||
|
func(p Peer4) float64 { return p.Hops },
|
||||||
|
func(p Peer4) float64 { return p.GeoKM },
|
||||||
|
func(p Peer4) float64 { return p.Score },
|
||||||
|
weights, invert,
|
||||||
|
)
|
||||||
|
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||||
|
best, _, _ := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.3, 0})
|
||||||
|
fmt.Println("4D best:", best.ID)
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,92 @@ func ExampleBuild2D() {
|
||||||
)
|
)
|
||||||
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{}))
|
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{}))
|
||||||
_, _, _ = tr.Nearest([]float64{0, 0})
|
_, _, _ = tr.Nearest([]float64{0, 0})
|
||||||
// Querying the origin (0,0) in normalized space tends to favor minima on each axis.
|
|
||||||
fmt.Printf("dim=%d len=%d", tr.Dim(), tr.Len())
|
fmt.Printf("dim=%d len=%d", tr.Dim(), tr.Len())
|
||||||
// Output: dim=2 len=3
|
// Output: dim=2 len=3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExampleKDTree_Nearest() {
|
||||||
|
pts := []poindexter.KDPoint[int]{
|
||||||
|
{ID: "x", Coords: []float64{0, 0}, Value: 1},
|
||||||
|
{ID: "y", Coords: []float64{2, 0}, Value: 2},
|
||||||
|
}
|
||||||
|
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||||
|
p, d, ok := tr.Nearest([]float64{1, 0})
|
||||||
|
fmt.Printf("ok=%v id=%s d=%.1f", ok, p.ID, d)
|
||||||
|
// Output: ok=true id=y d=1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleKDTree_KNearest() {
|
||||||
|
pts := []poindexter.KDPoint[int]{
|
||||||
|
{ID: "a", Coords: []float64{0}, Value: 0},
|
||||||
|
{ID: "b", Coords: []float64{1}, Value: 0},
|
||||||
|
{ID: "c", Coords: []float64{2}, Value: 0},
|
||||||
|
}
|
||||||
|
tr, _ := poindexter.NewKDTree(pts)
|
||||||
|
ns, ds := tr.KNearest([]float64{0.6}, 2)
|
||||||
|
fmt.Printf("%s %.1f | %s %.1f", ns[0].ID, ds[0], ns[1].ID, ds[1])
|
||||||
|
// Output: b 0.4 | a 0.6
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleKDTree_Radius() {
|
||||||
|
pts := []poindexter.KDPoint[int]{
|
||||||
|
{ID: "a", Coords: []float64{0}, Value: 0},
|
||||||
|
{ID: "b", Coords: []float64{1}, Value: 0},
|
||||||
|
{ID: "c", Coords: []float64{2}, Value: 0},
|
||||||
|
}
|
||||||
|
tr, _ := poindexter.NewKDTree(pts)
|
||||||
|
within, _ := tr.Radius([]float64{0}, 1.0)
|
||||||
|
fmt.Printf("%d %s %s", len(within), within[0].ID, within[1].ID)
|
||||||
|
// Output: 2 a b
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleKDTree_InsertDeleteByID() {
|
||||||
|
pts := []poindexter.KDPoint[string]{
|
||||||
|
{ID: "A", Coords: []float64{0}, Value: "a"},
|
||||||
|
}
|
||||||
|
tr, _ := poindexter.NewKDTree(pts)
|
||||||
|
tr.Insert(poindexter.KDPoint[string]{ID: "Z", Coords: []float64{0.1}, Value: "z"})
|
||||||
|
p, _, _ := tr.Nearest([]float64{0.09})
|
||||||
|
fmt.Println(p.ID)
|
||||||
|
tr.DeleteByID("Z")
|
||||||
|
p2, _, _ := tr.Nearest([]float64{0.09})
|
||||||
|
fmt.Println(p2.ID)
|
||||||
|
// Output:
|
||||||
|
// Z
|
||||||
|
// A
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleBuild3D() {
|
||||||
|
type rec struct{ x, y, z float64 }
|
||||||
|
items := []rec{{0, 0, 0}, {1, 1, 1}}
|
||||||
|
weights := [3]float64{1, 1, 1}
|
||||||
|
invert := [3]bool{false, false, false}
|
||||||
|
pts, _ := poindexter.Build3D(items,
|
||||||
|
func(r rec) string { return "" },
|
||||||
|
func(r rec) float64 { return r.x },
|
||||||
|
func(r rec) float64 { return r.y },
|
||||||
|
func(r rec) float64 { return r.z },
|
||||||
|
weights, invert,
|
||||||
|
)
|
||||||
|
tr, _ := poindexter.NewKDTree(pts)
|
||||||
|
fmt.Println(tr.Dim())
|
||||||
|
// Output: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleBuild4D() {
|
||||||
|
type rec struct{ a, b, c, d float64 }
|
||||||
|
items := []rec{{0, 0, 0, 0}, {1, 1, 1, 1}}
|
||||||
|
weights := [4]float64{1, 1, 1, 1}
|
||||||
|
invert := [4]bool{false, false, false, false}
|
||||||
|
pts, _ := poindexter.Build4D(items,
|
||||||
|
func(r rec) string { return "" },
|
||||||
|
func(r rec) float64 { return r.a },
|
||||||
|
func(r rec) float64 { return r.b },
|
||||||
|
func(r rec) float64 { return r.c },
|
||||||
|
func(r rec) float64 { return r.d },
|
||||||
|
weights, invert,
|
||||||
|
)
|
||||||
|
tr, _ := poindexter.NewKDTree(pts)
|
||||||
|
fmt.Println(tr.Dim())
|
||||||
|
// Output: 4
|
||||||
|
}
|
||||||
|
|
|
||||||
113
fuzz_kdtree_test.go
Normal file
113
fuzz_kdtree_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package poindexter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzKDTreeNearest_NoPanic ensures Nearest never panics and distances are non-negative.
|
||||||
|
func FuzzKDTreeNearest_NoPanic(f *testing.F) {
|
||||||
|
// Seed with small cases
|
||||||
|
f.Add(3, 2)
|
||||||
|
f.Add(5, 4)
|
||||||
|
f.Fuzz(func(t *testing.T, n int, dim int) {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > 64 {
|
||||||
|
n = 64
|
||||||
|
}
|
||||||
|
if dim <= 0 {
|
||||||
|
dim = 1
|
||||||
|
}
|
||||||
|
if dim > 8 {
|
||||||
|
dim = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
pts := make([]KDPoint[int], n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
coords := make([]float64, dim)
|
||||||
|
for d := 0; d < dim; d++ {
|
||||||
|
coords[d] = rand.Float64()*100 - 50
|
||||||
|
}
|
||||||
|
pts[i] = KDPoint[int]{ID: "", Coords: coords, Value: i}
|
||||||
|
}
|
||||||
|
tr, err := NewKDTree(pts)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
q := make([]float64, dim)
|
||||||
|
for d := range q {
|
||||||
|
q[d] = rand.Float64()*100 - 50
|
||||||
|
}
|
||||||
|
_, dist, _ := tr.Nearest(q)
|
||||||
|
if dist < 0 {
|
||||||
|
t.Fatalf("negative distance: %v", dist)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzMetrics_NoNegative checks Manhattan, Euclidean, Chebyshev don't return negatives for random inputs.
|
||||||
|
func FuzzMetrics_NoNegative(f *testing.F) {
|
||||||
|
f.Add(2)
|
||||||
|
f.Add(4)
|
||||||
|
f.Fuzz(func(t *testing.T, dim int) {
|
||||||
|
if dim <= 0 {
|
||||||
|
dim = 1
|
||||||
|
}
|
||||||
|
if dim > 8 {
|
||||||
|
dim = 8
|
||||||
|
}
|
||||||
|
a := make([]float64, dim)
|
||||||
|
b := make([]float64, dim)
|
||||||
|
for i := 0; i < dim; i++ {
|
||||||
|
a[i] = rand.Float64()*10 - 5
|
||||||
|
b[i] = rand.Float64()*10 - 5
|
||||||
|
}
|
||||||
|
m1 := EuclideanDistance{}.Distance(a, b)
|
||||||
|
m2 := ManhattanDistance{}.Distance(a, b)
|
||||||
|
m3 := ChebyshevDistance{}.Distance(a, b)
|
||||||
|
if m1 < 0 || m2 < 0 || m3 < 0 {
|
||||||
|
t.Fatalf("negative metric: %v %v %v", m1, m2, m3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzDimensionMismatch_NoPanic ensures queries with wrong dims return ok=false and not panic.
|
||||||
|
func FuzzDimensionMismatch_NoPanic(f *testing.F) {
|
||||||
|
f.Add(3, 2, 1)
|
||||||
|
f.Fuzz(func(t *testing.T, n, dim, qdim int) {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > 32 {
|
||||||
|
n = 32
|
||||||
|
}
|
||||||
|
if dim <= 0 {
|
||||||
|
dim = 1
|
||||||
|
}
|
||||||
|
if dim > 6 {
|
||||||
|
dim = 6
|
||||||
|
}
|
||||||
|
if qdim < 0 {
|
||||||
|
qdim = 0
|
||||||
|
}
|
||||||
|
if qdim > 6 {
|
||||||
|
qdim = 6
|
||||||
|
}
|
||||||
|
pts := make([]KDPoint[int], n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
coords := make([]float64, dim)
|
||||||
|
pts[i] = KDPoint[int]{Coords: coords}
|
||||||
|
}
|
||||||
|
tr, err := NewKDTree(pts)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
q := make([]float64, qdim)
|
||||||
|
_, _, ok := tr.Nearest(q)
|
||||||
|
if qdim != dim && ok {
|
||||||
|
t.Fatalf("expected ok=false for dim mismatch; dim=%d qdim=%d", dim, qdim)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
12
kdtree.go
12
kdtree.go
|
|
@ -86,8 +86,13 @@ type kdOptions struct {
|
||||||
func WithMetric(m DistanceMetric) KDOption { return func(o *kdOptions) { o.metric = m } }
|
func WithMetric(m DistanceMetric) KDOption { return func(o *kdOptions) { o.metric = m } }
|
||||||
|
|
||||||
// KDTree is a lightweight wrapper providing nearest-neighbor operations.
|
// KDTree is a lightweight wrapper providing nearest-neighbor operations.
|
||||||
// Note: This implementation currently uses linear scans for queries
|
//
|
||||||
// and is designed to be easily swappable with gonum.org/v1/gonum/spatial/kdtree
|
// Complexity: queries are O(n) linear scans in the current implementation.
|
||||||
|
// Inserts are O(1) amortized; deletes by ID are O(1) using swap-delete (order not preserved).
|
||||||
|
// Concurrency: KDTree is not safe for concurrent mutation. Guard with a mutex or
|
||||||
|
// share immutable snapshots for read-mostly workloads.
|
||||||
|
//
|
||||||
|
// This type is designed to be easily swappable with gonum.org/v1/gonum/spatial/kdtree
|
||||||
// in the future without breaking the public API.
|
// in the future without breaking the public API.
|
||||||
type KDTree[T any] struct {
|
type KDTree[T any] struct {
|
||||||
points []KDPoint[T]
|
points []KDPoint[T]
|
||||||
|
|
@ -156,7 +161,7 @@ func (t *KDTree[T]) Dim() int { return t.dim }
|
||||||
func (t *KDTree[T]) Len() int { return len(t.points) }
|
func (t *KDTree[T]) Len() int { return len(t.points) }
|
||||||
|
|
||||||
// Nearest returns the closest point to the query, along with its distance.
|
// Nearest returns the closest point to the query, along with its distance.
|
||||||
// ok is false if the tree is empty.
|
// ok is false if the tree is empty or the query dimensionality does not match Dim().
|
||||||
func (t *KDTree[T]) Nearest(query []float64) (KDPoint[T], float64, bool) {
|
func (t *KDTree[T]) Nearest(query []float64) (KDPoint[T], float64, bool) {
|
||||||
if len(query) != t.dim || t.Len() == 0 {
|
if len(query) != t.dim || t.Len() == 0 {
|
||||||
return KDPoint[T]{}, 0, false
|
return KDPoint[T]{}, 0, false
|
||||||
|
|
@ -177,6 +182,7 @@ func (t *KDTree[T]) Nearest(query []float64) (KDPoint[T], float64, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// KNearest returns up to k nearest neighbors to the query in ascending distance order.
|
// KNearest returns up to k nearest neighbors to the query in ascending distance order.
|
||||||
|
// If multiple points are at the same distance, tie ordering is arbitrary and not stable between calls.
|
||||||
func (t *KDTree[T]) KNearest(query []float64, k int) ([]KDPoint[T], []float64) {
|
func (t *KDTree[T]) KNearest(query []float64, k int) ([]KDPoint[T], []float64) {
|
||||||
if k <= 0 || len(query) != t.dim || t.Len() == 0 {
|
if k <= 0 || len(query) != t.dim || t.Len() == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue