diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39f369f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.22.x', '1.23.x' ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Verify go.mod is tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Build + run: go build ./... + + - name: Test (race) + run: go test -race ./... + + - name: Vet + run: go vet ./... + + - name: Govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ matrix.go-version }} + # Run against packages in the module + args: ./... diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..8e24b7b --- /dev/null +++ b/doc.go @@ -0,0 +1,4 @@ +// Package poindexter provides sorting utilities and a KDTree with simple +// nearest-neighbour queries. It also includes helper functions to build +// normalised, weighted KD points for 2D/3D/4D use-cases. +package poindexter diff --git a/docs/api.md b/docs/api.md index 5ba8f84..658d60f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,13 +13,13 @@ func Version() string Returns the current version of the library. **Returns:** -- `string`: The version string (e.g., "0.1.0") +- `string`: The version string (e.g., "0.2.0") **Example:** ```go version := poindexter.Version() -fmt.Println(version) // Output: 0.1.0 +fmt.Println(version) // Output: 0.2.0 ``` --- diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..9468001 --- /dev/null +++ b/examples_test.go @@ -0,0 +1,36 @@ +package poindexter_test + +import ( + "fmt" + + poindexter "github.com/Snider/Poindexter" +) + +func ExampleNewKDTree() { + pts := []poindexter.KDPoint[string]{ + {ID: "A", Coords: []float64{0, 0}, Value: "alpha"}, + {ID: "B", Coords: []float64{1, 0}, Value: "bravo"}, + } + tr, _ := poindexter.NewKDTree(pts) + p, _, _ := tr.Nearest([]float64{0.2, 0}) + fmt.Println(p.ID) + // Output: A +} + +func ExampleBuild2D() { + type rec struct{ ping, hops float64 } + items := []rec{{ping: 20, hops: 3}, {ping: 30, hops: 2}, {ping: 15, hops: 4}} + weights := [2]float64{1.0, 1.0} + invert := [2]bool{false, false} + pts, _ := poindexter.Build2D(items, + func(r rec) string { return "" }, + func(r rec) float64 { return r.ping }, + func(r rec) float64 { return r.hops }, + weights, invert, + ) + tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{})) + _, _, _ = 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()) + // Output: dim=2 len=3 +} diff --git a/go.mod b/go.mod index 61dc59f..82fec55 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Snider/Poindexter -go 1.24.9 +go 1.23 diff --git a/kdtree.go b/kdtree.go index 61c0399..587047c 100644 --- a/kdtree.go +++ b/kdtree.go @@ -6,6 +6,17 @@ import ( "sort" ) +var ( + // ErrEmptyPoints indicates that no points were provided to build a KDTree. + ErrEmptyPoints = errors.New("kdtree: no points provided") + // ErrZeroDim indicates that points or tree dimension must be at least 1. + ErrZeroDim = errors.New("kdtree: points must have at least one dimension") + // ErrDimMismatch indicates inconsistent dimensionality among points. + ErrDimMismatch = errors.New("kdtree: inconsistent dimensionality in points") + // ErrDuplicateID indicates a duplicate point ID was encountered. + ErrDuplicateID = errors.New("kdtree: duplicate point ID") +) + // KDPoint represents a point with coordinates and an attached payload/value. // ID should be unique within a tree to enable O(1) deletes by ID. // Coords must all have the same dimensionality within a given KDTree. @@ -89,20 +100,20 @@ type KDTree[T any] struct { // All points must have the same dimensionality (>0). func NewKDTree[T any](pts []KDPoint[T], opts ...KDOption) (*KDTree[T], error) { if len(pts) == 0 { - return nil, errors.New("no points provided") + return nil, ErrEmptyPoints } dim := len(pts[0].Coords) if dim == 0 { - return nil, errors.New("points must have at least one dimension") + return nil, ErrZeroDim } idIndex := make(map[string]int, len(pts)) for i, p := range pts { if len(p.Coords) != dim { - return nil, errors.New("inconsistent dimensionality in points") + return nil, ErrDimMismatch } if p.ID != "" { if _, exists := idIndex[p.ID]; exists { - return nil, errors.New("duplicate point ID: " + p.ID) + return nil, ErrDuplicateID } idIndex[p.ID] = i } @@ -120,6 +131,24 @@ func NewKDTree[T any](pts []KDPoint[T], opts ...KDOption) (*KDTree[T], error) { return t, nil } +// NewKDTreeFromDim constructs an empty KDTree with the specified dimension. +// Call Insert to add points after construction. +func NewKDTreeFromDim[T any](dim int, opts ...KDOption) (*KDTree[T], error) { + if dim <= 0 { + return nil, ErrZeroDim + } + cfg := kdOptions{metric: EuclideanDistance{}} + for _, o := range opts { + o(&cfg) + } + return &KDTree[T]{ + points: nil, + dim: dim, + metric: cfg.metric, + idIndex: make(map[string]int), + }, nil +} + // Dim returns the number of dimensions. func (t *KDTree[T]) Dim() int { return t.dim } diff --git a/sort.go b/sort.go index 1ccfcff..cf650ce 100644 --- a/sort.go +++ b/sort.go @@ -39,7 +39,7 @@ func SortBy[T any](data []T, less func(i, j int) bool) { } // SortByKey sorts a slice by extracting a comparable key from each element. -// The key function should return a value that implements constraints.Ordered. +// K is restricted to int, float64, or string. func SortByKey[T any, K int | float64 | string](data []T, key func(T) K) { sort.Slice(data, func(i, j int) bool { return key(data[i]) < key(data[j])