Merge pull request #2 from Snider/kd-tree-peer-finding
Kd tree peer finding
This commit is contained in:
commit
1a6ab5bf49
74 changed files with 6976 additions and 80 deletions
178
.github/workflows/ci.yml
vendored
Normal file
178
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
build-test-wasm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.x'
|
||||
|
||||
- name: Install extra tools
|
||||
run: |
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Go env
|
||||
run: go env
|
||||
|
||||
- name: CI checks (lint, tests, coverage, etc.)
|
||||
run: make ci
|
||||
|
||||
- name: Benchmarks (linear)
|
||||
run: go test -bench . -benchmem -run=^$ ./... | tee bench-linear.txt
|
||||
|
||||
- name: Coverage summary (linear)
|
||||
run: |
|
||||
if [ -f coverage.out ]; then
|
||||
go tool cover -func=coverage.out > coverage-summary.md;
|
||||
else
|
||||
echo "coverage.out not found" > coverage-summary.md;
|
||||
fi
|
||||
|
||||
- name: Upload coverage summary (linear)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-summary
|
||||
path: coverage-summary.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload benchmarks (linear)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-linear
|
||||
path: bench-linear.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build WebAssembly module
|
||||
run: make wasm-build
|
||||
|
||||
- name: Prepare npm package folder
|
||||
run: make npm-pack
|
||||
|
||||
- name: WASM smoke test (Node)
|
||||
run: node npm/poindexter-wasm/smoke.mjs
|
||||
|
||||
- name: Create npm tarball
|
||||
id: npm_pack
|
||||
run: |
|
||||
echo "tarball=$(npm pack ./npm/poindexter-wasm)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload dist (WASM artifacts)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: poindexter-wasm-dist
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
dist/poindexter.wasm
|
||||
dist/wasm_exec.js
|
||||
|
||||
- name: Upload npm package folder
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-poindexter-wasm
|
||||
if-no-files-found: error
|
||||
path: npm/poindexter-wasm/**
|
||||
|
||||
- name: Upload npm tarball
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-poindexter-wasm-tarball
|
||||
if-no-files-found: error
|
||||
path: ${{ steps.npm_pack.outputs.tarball }}
|
||||
|
||||
build-test-gonum:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.x'
|
||||
|
||||
- name: Install extra tools
|
||||
run: |
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Go env
|
||||
run: go env
|
||||
|
||||
- name: Lint
|
||||
run: golangci-lint run
|
||||
|
||||
- name: Build (gonum tag)
|
||||
run: go build -tags=gonum ./...
|
||||
|
||||
- name: Unit tests + race + coverage (gonum tag)
|
||||
run: go test -tags=gonum -race -coverpkg=./... -coverprofile=coverage-gonum.out -covermode=atomic ./...
|
||||
|
||||
- name: Fuzz (10s per fuzz test, gonum tag)
|
||||
run: |
|
||||
set -e
|
||||
for pkg in $(go list ./...); do
|
||||
FUZZES=$(go test -tags=gonum -list '^Fuzz' "$pkg" | grep '^Fuzz' || true)
|
||||
if [ -z "$FUZZES" ]; then
|
||||
echo "==> Skipping $pkg (no fuzz targets)"
|
||||
continue
|
||||
fi
|
||||
for fz in $FUZZES; do
|
||||
echo "==> Fuzzing $pkg :: $fz for 10s"
|
||||
go test -tags=gonum -run=NONE -fuzz=^${fz}$ -fuzztime=10s "$pkg"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Benchmarks (gonum tag)
|
||||
run: go test -tags=gonum -bench . -benchmem -run=^$ ./... | tee bench-gonum.txt
|
||||
|
||||
- name: Upload coverage (gonum)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-gonum
|
||||
path: coverage-gonum.out
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Coverage summary (gonum)
|
||||
run: |
|
||||
if [ -f coverage-gonum.out ]; then
|
||||
go tool cover -func=coverage-gonum.out > coverage-summary-gonum.md;
|
||||
else
|
||||
echo "coverage-gonum.out not found" > coverage-summary-gonum.md;
|
||||
fi
|
||||
|
||||
- name: Upload coverage summary (gonum)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-summary-gonum
|
||||
path: coverage-summary-gonum.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload benchmarks (gonum)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-gonum
|
||||
path: bench-gonum.txt
|
||||
if-no-files-found: error
|
||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
|
|
@ -9,24 +9,36 @@ permissions:
|
|||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
release:
|
||||
name: Goreleaser publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0 # needed for changelog and tags
|
||||
|
||||
- name: Set up Go
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
go-version: '1.23.x'
|
||||
cache: true
|
||||
|
||||
- name: Tidy check
|
||||
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: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
args: release --clean --config .goreleaser.yaml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,7 +10,8 @@
|
|||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
bench.txt
|
||||
coverage.html
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
vendor/
|
||||
|
||||
|
|
|
|||
20
.golangci.yml
Normal file
20
.golangci.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
version: "2"
|
||||
run:
|
||||
timeout: 5m
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- staticcheck
|
||||
- ineffassign
|
||||
- misspell
|
||||
- errcheck
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
linters-settings:
|
||||
errcheck:
|
||||
# Using default settings; test file exclusions are handled by exclude-rules above.
|
||||
45
.goreleaser.yaml
Normal file
45
.goreleaser.yaml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# GoReleaser config for Poindexter (library)
|
||||
# This configuration focuses on generating GitHub Releases with changelog notes.
|
||||
# Since Poindexter is a library (no CLI binaries), we skip building archives.
|
||||
|
||||
project_name: poindexter
|
||||
|
||||
dist: dist
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds: [] # no binaries to build for this library
|
||||
|
||||
dockers: []
|
||||
|
||||
archives: [] # do not produce tarballs/zip since there are no binaries
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^chore:'
|
||||
- '^test:'
|
||||
- '^ci:'
|
||||
- 'README'
|
||||
|
||||
release:
|
||||
prerelease: false
|
||||
draft: false
|
||||
mode: replace
|
||||
footer: |
|
||||
--
|
||||
Generated by GoReleaser. See CHANGELOG.md for curated notes.
|
||||
|
||||
report_sizes: true
|
||||
|
||||
# Snapshot configuration for non-tag builds (optional local use)
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next+{{ .ShortCommit }}"
|
||||
|
|
@ -1,71 +1,49 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
# NOTE: This file is intentionally a mirror of .goreleaser.yaml.
|
||||
# Canonical configuration lives in .goreleaser.yaml; keep both in sync.
|
||||
# CI release workflow explicitly uses .goreleaser.yaml to avoid ambiguity.
|
||||
|
||||
# The lines below are called `modelines`. See `:help modeline`
|
||||
# Feel free to remove those if you don't want/need to use them.
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
# GoReleaser config for Poindexter (library)
|
||||
# This configuration focuses on generating GitHub Releases with changelog notes.
|
||||
# Since Poindexter is a library (no CLI binaries), we skip building archives.
|
||||
|
||||
version: 2
|
||||
project_name: poindexter
|
||||
|
||||
dist: dist
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# Optional: Exclude specific combinations
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
builds: [] # no binaries to build for this library
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
dockers: []
|
||||
|
||||
archives: [] # do not produce tarballs/zip since there are no binaries
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^ci:"
|
||||
- "^chore:"
|
||||
- "^build:"
|
||||
- Merge pull request
|
||||
- Merge branch
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ incpatch .Version }}-next"
|
||||
- '^docs:'
|
||||
- '^chore:'
|
||||
- '^test:'
|
||||
- '^ci:'
|
||||
- 'README'
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: Snider
|
||||
name: Poindexter
|
||||
prerelease: false
|
||||
draft: false
|
||||
prerelease: auto
|
||||
mode: replace
|
||||
footer: |
|
||||
--
|
||||
Generated by GoReleaser. See CHANGELOG.md for curated notes.
|
||||
|
||||
report_sizes: true
|
||||
|
||||
# Snapshot configuration for non-tag builds (optional local use)
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next+{{ .ShortCommit }}"
|
||||
|
|
|
|||
66
CHANGELOG.md
Normal file
66
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# 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
|
||||
- Dual-backend benchmarks (Linear vs Gonum) with deterministic datasets (uniform/clustered) in 2D/4D for N=1k/10k; artifacts uploaded in CI as `bench-linear.txt` and `bench-gonum.txt`.
|
||||
- Documentation: Performance guide updated to cover backend selection, how to run both backends, CI artifact links, and guidance on when each backend is preferred.
|
||||
- Documentation: Performance guide now includes a Sample results table sourced from a recent local run.
|
||||
- Documentation: README gained a “Backend selection” section with default behavior, build tag usage, overrides, and supported metrics notes.
|
||||
- Documentation: API reference (`docs/api.md`) now documents `KDBackend`, `WithBackend`, default selection, and supported metrics for the optimized backend.
|
||||
- Examples: Added `examples/wasm-browser/` minimal browser demo (ESM + HTML) for the WASM build.
|
||||
- pkg.go.dev Examples: `ExampleNewKDTreeFromDim_Insert`, `ExampleKDTree_TiesBehavior`, `ExampleKDTree_Radius_none`.
|
||||
- Lint: enable `errcheck` in `.golangci.yml` with test-file exclusion to reduce noise.
|
||||
- CI: enable module cache in `actions/setup-go` to speed up workflows.
|
||||
|
||||
### Fixed
|
||||
- go vet failures in examples due to misnamed `Example*` functions; renamed to avoid referencing non-existent methods and identifiers.
|
||||
- Stabilized `ExampleKDTree_Nearest` to avoid a tie case; adjusted query and expected output.
|
||||
- Relaxed floating-point equality in `TestWeightedCosineDistance_Basics` to use an epsilon, avoiding spurious failures on some toolchains.
|
||||
|
||||
## [0.3.0] - 2025-11-03
|
||||
### Added
|
||||
- New distance metrics: `CosineDistance` and `WeightedCosineDistance` (1 - cosine similarity), with robust zero-vector handling and bounds.
|
||||
- N-D normalization helpers: `ComputeNormStatsND`, `BuildND`, `BuildNDWithStats` for arbitrary dimensions, with validation errors (`ErrInvalidFeatures`, `ErrInvalidWeights`, `ErrInvalidInvert`, `ErrStatsDimMismatch`).
|
||||
- Tests: unit tests for cosine/weighted-cosine metrics; parity tests between `Build4D` and `BuildND`; error-path tests; extended fuzz to include cosine metrics.
|
||||
- pkg.go.dev examples: `ExampleBuildND`, `ExampleBuildNDWithStats`, `ExampleCosineDistance`.
|
||||
|
||||
### Changed
|
||||
- Version bumped to `0.3.0`.
|
||||
- README: list Cosine among supported metrics.
|
||||
|
||||
## [0.2.1] - 2025-11-03
|
||||
### Added
|
||||
- Normalization stats helpers: `AxisStats`, `NormStats`, `ComputeNormStats2D/3D/4D`.
|
||||
- Builders that reuse stats: `Build2DWithStats`, `Build3DWithStats`, `Build4DWithStats`.
|
||||
- CI: coverage integration (`-coverprofile`), Codecov upload and badge.
|
||||
- CI: benchmark runs publish artifacts per Go version.
|
||||
- Docs: Performance page (`docs/perf.md`) and MkDocs nav entry.
|
||||
- pkg.go.dev examples: `ExampleBuild2DWithStats`, `ExampleBuild4DWithStats`.
|
||||
- Tests for stats parity, min==max safety, and dynamic update with reused stats.
|
||||
- Docs: API reference section “KDTree Normalization Stats (reuse across updates)”; updated multi-dimensional docs with WithStats snippet.
|
||||
|
||||
### Changed
|
||||
- Bumped version to `0.2.1`.
|
||||
|
||||
### Previously added in Unreleased
|
||||
- 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).
|
||||
|
||||
## [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.3.0...HEAD
|
||||
[0.3.0]: https://github.com/Snider/Poindexter/releases/tag/v0.3.0
|
||||
[0.2.1]: https://github.com/Snider/Poindexter/releases/tag/v0.2.1
|
||||
[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: [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)
|
||||
- FAQ: [Contributor Covenant FAQ](https://www.contributor-covenant.org/faq)
|
||||
- Translations: [Available 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.
|
||||
79
CONTRIBUTING.md
Normal file
79
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Contributing to Poindexter
|
||||
|
||||
Thanks for your interest in contributing! This document describes how to build, test, lint, and propose changes.
|
||||
|
||||
## Getting started
|
||||
|
||||
- Go 1.23+
|
||||
- `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
|
||||
|
||||
We use GoReleaser to publish GitHub Releases when a semver tag is pushed.
|
||||
|
||||
Steps for maintainers:
|
||||
- Ensure `CHANGELOG.md` has an entry for the version and links are updated at the bottom (Unreleased compares from latest tag).
|
||||
- Ensure `poindexter.Version()` returns the new version and tests pass.
|
||||
- Merge all changes to `main` and wait for CI to be green.
|
||||
- Create an annotated tag and push:
|
||||
|
||||
```bash
|
||||
VERSION=vX.Y.Z
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
git push origin "$VERSION"
|
||||
```
|
||||
- GitHub Actions workflow `.github/workflows/release.yml` will run tests and GoReleaser to publish the Release.
|
||||
- Verify the release notes and badges (README release badge updates automatically).
|
||||
|
||||
Optional:
|
||||
- Dry-run locally without publishing:
|
||||
|
||||
```bash
|
||||
goreleaser release --skip=publish --clean
|
||||
```
|
||||
|
||||
See `RELEASE.md` for more details.
|
||||
|
||||
Thanks for helping improve Poindexter!
|
||||
171
Makefile
Normal file
171
Makefile
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Maintainer Makefile for Poindexter
|
||||
# Usage: `make <target>`
|
||||
# Many targets are CI-parity helpers for local use.
|
||||
|
||||
# Tools (override with env if needed)
|
||||
GO ?= go
|
||||
GOLANGCI_LINT?= golangci-lint
|
||||
GORELEASER ?= goreleaser
|
||||
MKDOCS ?= mkdocs
|
||||
|
||||
# Params
|
||||
FUZZTIME ?= 10s
|
||||
BENCHOUT ?= bench.txt
|
||||
COVEROUT ?= coverage.out
|
||||
COVERHTML?= coverage.html
|
||||
|
||||
.PHONY: help all
|
||||
all: help
|
||||
help: ## List available targets
|
||||
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9_.-]+:.*##/ {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort
|
||||
|
||||
.PHONY: tidy
|
||||
tidy: ## Run `go mod tidy`
|
||||
$(GO) mod tidy
|
||||
|
||||
.PHONY: tidy-check
|
||||
tidy-check: ## Run tidy and ensure go.mod/go.sum unchanged
|
||||
$(GO) mod tidy
|
||||
@git diff --exit-code -- go.mod go.sum
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Format code with go fmt
|
||||
$(GO) fmt ./...
|
||||
|
||||
.PHONY: vet
|
||||
vet: ## Run go vet
|
||||
$(GO) vet ./...
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build all packages
|
||||
$(GO) build ./...
|
||||
|
||||
# WebAssembly build outputs
|
||||
DIST_DIR ?= dist
|
||||
WASM_OUT ?= $(DIST_DIR)/poindexter.wasm
|
||||
WASM_EXEC ?= $(shell $(GO) env GOROOT)/lib/wasm/wasm_exec.js
|
||||
|
||||
.PHONY: wasm-build
|
||||
wasm-build: ## Build WebAssembly module to $(WASM_OUT)
|
||||
@mkdir -p $(DIST_DIR)
|
||||
GOOS=js GOARCH=wasm $(GO) build -o $(WASM_OUT) ./wasm
|
||||
@set -e; \
|
||||
if [ -n "$$WASM_EXEC" ] && [ -f "$$WASM_EXEC" ]; then \
|
||||
cp "$$WASM_EXEC" $(DIST_DIR)/wasm_exec.js; \
|
||||
else \
|
||||
CAND1="$$($(GO) env GOROOT)/lib/wasm/wasm_exec.js"; \
|
||||
CAND2="$$($(GO) env GOROOT)/libexec/lib/wasm/wasm_exec.js"; \
|
||||
if [ -f "$$CAND1" ]; then cp "$$CAND1" $(DIST_DIR)/wasm_exec.js; \
|
||||
elif [ -f "$$CAND2" ]; then cp "$$CAND2" $(DIST_DIR)/wasm_exec.js; \
|
||||
else echo "Warning: could not locate wasm_exec.js under GOROOT or WASM_EXEC; please copy it manually"; fi; \
|
||||
fi
|
||||
@echo "WASM built: $(WASM_OUT)"
|
||||
|
||||
.PHONY: npm-pack
|
||||
npm-pack: wasm-build ## Prepare npm package folder with dist artifacts
|
||||
@mkdir -p npm/poindexter-wasm
|
||||
@rm -rf npm/poindexter-wasm/dist
|
||||
@cp -R $(DIST_DIR) npm/poindexter-wasm/dist
|
||||
@cp LICENSE npm/poindexter-wasm/LICENSE
|
||||
@cp README.md npm/poindexter-wasm/PROJECT_README.md
|
||||
@echo "npm package prepared in npm/poindexter-wasm"
|
||||
|
||||
.PHONY: examples
|
||||
examples: ## Build all example programs under examples/
|
||||
@if [ -d examples ]; then $(GO) build ./examples/...; else echo "No examples/ directory"; fi
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run unit tests
|
||||
$(GO) test ./...
|
||||
|
||||
.PHONY: race
|
||||
race: ## Run tests with race detector
|
||||
$(GO) test -race ./...
|
||||
|
||||
.PHONY: cover
|
||||
cover: ## Run tests with race + coverage and summarize
|
||||
$(GO) test -race -coverprofile=$(COVEROUT) -covermode=atomic ./...
|
||||
@$(GO) tool cover -func=$(COVEROUT) | tail -n 1
|
||||
|
||||
.PHONY: coverfunc
|
||||
coverfunc: ## Print per-function coverage from $(COVEROUT)
|
||||
@$(GO) tool cover -func=$(COVEROUT)
|
||||
|
||||
.PHONY: cover-kdtree
|
||||
cover-kdtree: ## Print coverage details for kdtree.go only
|
||||
@$(GO) tool cover -func=$(COVEROUT) | grep 'kdtree.go' || true
|
||||
|
||||
.PHONY: coverhtml
|
||||
coverhtml: cover ## Generate HTML coverage report at $(COVERHTML)
|
||||
@$(GO) tool cover -html=$(COVEROUT) -o $(COVERHTML)
|
||||
@echo "Wrote $(COVERHTML)"
|
||||
|
||||
.PHONY: fuzz
|
||||
fuzz: ## Run Go fuzz tests for $(FUZZTIME)
|
||||
@set -e; \
|
||||
PKGS="$$($(GO) list ./...)"; \
|
||||
for pkg in $$PKGS; do \
|
||||
FUZZES="$$($(GO) test -list '^Fuzz' $$pkg | grep '^Fuzz' || true)"; \
|
||||
if [ -z "$$FUZZES" ]; then \
|
||||
echo "==> Skipping $$pkg (no fuzz targets)"; \
|
||||
continue; \
|
||||
fi; \
|
||||
for fz in $$FUZZES; do \
|
||||
echo "==> Fuzzing $$pkg :: $$fz for $(FUZZTIME)"; \
|
||||
$(GO) test -run=NONE -fuzz=^$${fz}$$ -fuzztime=$(FUZZTIME) $$pkg; \
|
||||
done; \
|
||||
done
|
||||
|
||||
.PHONY: bench
|
||||
# Benchmark configuration variables
|
||||
BENCHPKG ?= ./...
|
||||
BENCHFILTER ?= .
|
||||
BENCHTAGS ?=
|
||||
BENCHMEMFLAG ?= -benchmem
|
||||
|
||||
bench: ## Run benchmarks (configurable: BENCHPKG, BENCHFILTER, BENCHTAGS, BENCHOUT)
|
||||
$(GO) test $(if $(BENCHTAGS),-tags=$(BENCHTAGS),) -bench $(BENCHFILTER) $(BENCHMEMFLAG) -run=^$$ $(BENCHPKG) | tee $(BENCHOUT)
|
||||
|
||||
.PHONY: bench-linear
|
||||
bench-linear: ## Run linear-backend benchmarks and write bench-linear.txt
|
||||
$(MAKE) bench BENCHTAGS= BENCHOUT=bench-linear.txt
|
||||
|
||||
.PHONY: bench-gonum
|
||||
bench-gonum: ## Run gonum-backend benchmarks (includes 100k benches) and write bench-gonum.txt
|
||||
$(MAKE) bench BENCHTAGS=gonum BENCHOUT=bench-gonum.txt
|
||||
|
||||
.PHONY: bench-list
|
||||
bench-list: ## List available benchmark names for BENCHPKG (use with BENCHPKG=./pkg)
|
||||
$(GO) test $(if $(BENCHTAGS),-tags=$(BENCHTAGS),) -run=^$$ -bench ^$$ -list '^Benchmark' $(BENCHPKG)
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run golangci-lint (requires it installed)
|
||||
$(GOLANGCI_LINT) run
|
||||
|
||||
.PHONY: vuln
|
||||
vuln: ## Run govulncheck (requires it installed)
|
||||
govulncheck ./...
|
||||
|
||||
.PHONY: ci
|
||||
ci: tidy-check build vet cover examples bench lint vuln wasm-build ## CI-parity local run (includes wasm-build)
|
||||
@echo "CI-like checks completed"
|
||||
|
||||
.PHONY: release
|
||||
release: ## Run GoReleaser to publish a tagged release (requires tag and permissions)
|
||||
$(GORELEASER) release --clean --config .goreleaser.yaml
|
||||
|
||||
.PHONY: snapshot
|
||||
snapshot: ## Run GoReleaser in snapshot mode (no publish)
|
||||
$(GORELEASER) release --skip=publish --clean --config .goreleaser.yaml
|
||||
|
||||
.PHONY: docs-serve
|
||||
docs-serve: ## Serve MkDocs locally (requires mkdocs-material)
|
||||
$(MKDOCS) serve -a 127.0.0.1:8000
|
||||
|
||||
.PHONY: docs-build
|
||||
docs-build: ## Build MkDocs site into site/
|
||||
$(MKDOCS) build
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Remove generated files and directories
|
||||
rm -rf $(DIST_DIR) $(COVEROUT) $(COVERHTML) $(BENCHOUT)
|
||||
196
README.md
196
README.md
|
|
@ -1,5 +1,12 @@
|
|||
# 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)
|
||||
[](https://codecov.io/gh/Snider/Poindexter)
|
||||
[](https://github.com/Snider/Poindexter/releases)
|
||||
|
||||
A Go library package providing utility functions including sorting algorithms with custom comparators.
|
||||
|
||||
## Features
|
||||
|
|
@ -7,6 +14,7 @@ A Go library package providing utility functions including sorting algorithms wi
|
|||
- 🔢 **Sorting Utilities**: Sort integers, strings, and floats in ascending or descending order
|
||||
- 🎯 **Custom Sorting**: Sort any type with custom comparison functions or key extractors
|
||||
- 🔍 **Binary Search**: Fast search on sorted data
|
||||
- 🧭 **KDTree (NN Search)**: Build a KDTree over points with generic payloads; nearest, k-NN, and radius queries with Euclidean, Manhattan, Chebyshev, and Cosine metrics
|
||||
- 📦 **Generic Functions**: Type-safe operations using Go generics
|
||||
- ✅ **Well-Tested**: Comprehensive test coverage
|
||||
- 📖 **Documentation**: Full documentation available at GitHub Pages
|
||||
|
|
@ -24,7 +32,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Snider/Poindexter"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -38,16 +46,19 @@ func main() {
|
|||
Name string
|
||||
Price float64
|
||||
}
|
||||
|
||||
products := []Product{
|
||||
{"Apple", 1.50},
|
||||
{"Banana", 0.75},
|
||||
{"Cherry", 3.00},
|
||||
|
||||
products := []Product{{"Apple", 1.50}, {"Banana", 0.75}, {"Cherry", 3.00}}
|
||||
poindexter.SortByKey(products, func(p Product) float64 { return p.Price })
|
||||
|
||||
// KDTree quick demo
|
||||
pts := []poindexter.KDPoint[string]{
|
||||
{ID: "A", Coords: []float64{0, 0}, Value: "alpha"},
|
||||
{ID: "B", Coords: []float64{1, 0}, Value: "bravo"},
|
||||
{ID: "C", Coords: []float64{0, 1}, Value: "charlie"},
|
||||
}
|
||||
|
||||
poindexter.SortByKey(products, func(p Product) float64 {
|
||||
return p.Price
|
||||
})
|
||||
tree, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
nearest, dist, _ := tree.Nearest([]float64{0.9, 0.1})
|
||||
fmt.Println(nearest.ID, nearest.Value, dist) // B bravo ~0.141...
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -55,10 +66,173 @@ func main() {
|
|||
|
||||
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
|
||||
- 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.
|
||||
- Complexity: Linear backend is O(n) per query. Optimized KD backend is typically sub-linear on prunable datasets and dims ≤ ~8, especially as N grows (≥10k–100k).
|
||||
- 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/`.
|
||||
- Performance guide: see docs/Performance for benchmark guidance and tips: [docs/perf.md](docs/perf.md) • Hosted: https://snider.github.io/Poindexter/perf/
|
||||
|
||||
### Backend selection
|
||||
- Default backend is Linear. If you build with `-tags=gonum`, the default becomes the optimized KD backend.
|
||||
- You can override per tree at construction:
|
||||
|
||||
```go
|
||||
// Force Linear (always available)
|
||||
kdt1, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendLinear))
|
||||
|
||||
// Force Gonum (requires build tag)
|
||||
kdt2, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendGonum))
|
||||
```
|
||||
|
||||
- Supported metrics in the optimized backend: Euclidean (L2), Manhattan (L1), Chebyshev (L∞).
|
||||
- Cosine and Weighted-Cosine currently run on the Linear backend.
|
||||
- See the Performance guide for measured comparisons and when to choose which backend.
|
||||
|
||||
#### Choosing a metric (quick tips)
|
||||
- Euclidean (L2): smooth trade-offs across axes; solid default for blended preferences.
|
||||
- Manhattan (L1): emphasizes per-axis absolute differences; good when each unit of ping/hop matters equally.
|
||||
- Chebyshev (L∞): dominated by the worst axis; useful for strict thresholds (e.g., reject high hop count regardless of ping).
|
||||
- Cosine: angle-based for vector similarity; pair it with normalized/weighted features when direction matters more than magnitude.
|
||||
|
||||
See the multi-dimensional KDTree docs for end-to-end examples and weighting/normalization helpers: [Multi-Dimensional KDTree (DHT)](docs/kdtree-multidimensional.md).
|
||||
|
||||
## Maintainer Makefile
|
||||
|
||||
The repository includes a maintainer-friendly `Makefile` that mirrors CI tasks and speeds up local workflows.
|
||||
|
||||
- help — list available targets
|
||||
- tidy / tidy-check — run `go mod tidy`, optionally verify no diffs
|
||||
- fmt — format code (`go fmt ./...`)
|
||||
- vet — `go vet ./...`
|
||||
- build — `go build ./...`
|
||||
- examples — build all programs under `examples/` (if present)
|
||||
- test — run unit tests
|
||||
- race — run tests with the race detector
|
||||
- cover — run tests with race + coverage (writes `coverage.out` and prints summary)
|
||||
- coverhtml — render HTML coverage report to `coverage.html`
|
||||
- coverfunc — print per-function coverage (from `coverage.out`)
|
||||
- cover-kdtree — print coverage details filtered to `kdtree.go`
|
||||
- fuzz — run Go fuzzing for a configurable time (default 10s) matching CI
|
||||
- bench — run benchmarks with `-benchmem` (writes `bench.txt`)
|
||||
- lint — run `golangci-lint` (if installed)
|
||||
- vuln — run `govulncheck` (if installed)
|
||||
- ci — CI-parity aggregate: tidy-check, build, vet, cover, examples, bench, lint, vuln
|
||||
- release — run GoReleaser with the canonical `.goreleaser.yaml` (for tagged releases)
|
||||
- snapshot — GoReleaser snapshot (no publish)
|
||||
- docs-serve — serve MkDocs locally on 127.0.0.1:8000
|
||||
- docs-build — build MkDocs site into `site/`
|
||||
|
||||
Quick usage:
|
||||
|
||||
- See all targets:
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
- Fast local cycle:
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make vet
|
||||
make test
|
||||
```
|
||||
|
||||
- CI-parity run (what GitHub Actions does, locally):
|
||||
|
||||
```bash
|
||||
make ci
|
||||
```
|
||||
|
||||
- Coverage summary:
|
||||
|
||||
```bash
|
||||
make cover
|
||||
```
|
||||
|
||||
- Generate HTML coverage report (writes coverage.html):
|
||||
|
||||
```bash
|
||||
make coverhtml
|
||||
```
|
||||
|
||||
- Fuzz for 10 seconds (default):
|
||||
|
||||
```bash
|
||||
make fuzz
|
||||
```
|
||||
|
||||
- Fuzz with a custom time (e.g., 30s):
|
||||
|
||||
```bash
|
||||
make fuzz FUZZTIME=30s
|
||||
```
|
||||
|
||||
- Run benchmarks (writes bench.txt):
|
||||
|
||||
```bash
|
||||
make bench
|
||||
```
|
||||
|
||||
- Build examples (if any under ./examples):
|
||||
|
||||
```bash
|
||||
make examples
|
||||
```
|
||||
|
||||
- Serve docs locally (requires mkdocs-material):
|
||||
|
||||
```bash
|
||||
make docs-serve
|
||||
```
|
||||
|
||||
Configurable variables:
|
||||
|
||||
- `FUZZTIME` (default `10s`) — e.g. `make fuzz FUZZTIME=30s`
|
||||
- `BENCHOUT` (default `bench.txt`), `COVEROUT` (default `coverage.out`), `COVERHTML` (default `coverage.html`)
|
||||
- Tool commands are overridable via env: `GO`, `GOLANGCI_LINT`, `GORELEASER`, `MKDOCS`
|
||||
|
||||
Requirements for optional targets:
|
||||
|
||||
- `golangci-lint` for `make lint`
|
||||
- `golang.org/x/vuln/cmd/govulncheck` for `make vuln`
|
||||
- `goreleaser` for `make release` / `make snapshot`
|
||||
- `mkdocs` + `mkdocs-material` for `make docs-serve` / `make docs-build`
|
||||
|
||||
See the full Makefile at the repo root for authoritative target definitions.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2). See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
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`).
|
||||
|
|
|
|||
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.
|
||||
35
bench_kdtree_dual_100k_test.go
Normal file
35
bench_kdtree_dual_100k_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//go:build gonum
|
||||
|
||||
package poindexter
|
||||
|
||||
import "testing"
|
||||
|
||||
// 100k-size benchmarks run only in the gonum-tag job to keep CI time reasonable.
|
||||
|
||||
func BenchmarkNearest_Linear_Uniform_100k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 2, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Uniform_100k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 2, BackendGonum, true, 0)
|
||||
}
|
||||
|
||||
func BenchmarkNearest_Linear_Uniform_100k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 4, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Uniform_100k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 4, BackendGonum, true, 0)
|
||||
}
|
||||
|
||||
func BenchmarkNearest_Linear_Clustered_100k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 2, BackendLinear, false, 3)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Clustered_100k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 2, BackendGonum, false, 3)
|
||||
}
|
||||
|
||||
func BenchmarkNearest_Linear_Clustered_100k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 4, BackendLinear, false, 3)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Clustered_100k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 100_000, 4, BackendGonum, false, 3)
|
||||
}
|
||||
180
bench_kdtree_dual_test.go
Normal file
180
bench_kdtree_dual_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// deterministicRand returns a rand.Rand with a fixed seed for reproducible datasets.
|
||||
func deterministicRand() *rand.Rand { return rand.New(rand.NewSource(42)) }
|
||||
|
||||
func makeUniformPoints(n, dim int) []KDPoint[int] {
|
||||
r := deterministicRand()
|
||||
pts := make([]KDPoint[int], n)
|
||||
for i := 0; i < n; i++ {
|
||||
coords := make([]float64, dim)
|
||||
for d := 0; d < dim; d++ {
|
||||
coords[d] = r.Float64()
|
||||
}
|
||||
pts[i] = KDPoint[int]{ID: fmt.Sprint(i), Coords: coords, Value: i}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// makeClusteredPoints creates n points around c clusters with small variance.
|
||||
func makeClusteredPoints(n, dim, c int) []KDPoint[int] {
|
||||
if c <= 0 {
|
||||
c = 1
|
||||
}
|
||||
r := deterministicRand()
|
||||
centers := make([][]float64, c)
|
||||
for i := 0; i < c; i++ {
|
||||
centers[i] = make([]float64, dim)
|
||||
for d := 0; d < dim; d++ {
|
||||
centers[i][d] = r.Float64()
|
||||
}
|
||||
}
|
||||
pts := make([]KDPoint[int], n)
|
||||
for i := 0; i < n; i++ {
|
||||
coords := make([]float64, dim)
|
||||
cent := centers[r.Intn(c)]
|
||||
for d := 0; d < dim; d++ {
|
||||
// small gaussian noise around center (Box-Muller)
|
||||
u1 := r.Float64()
|
||||
u2 := r.Float64()
|
||||
z := (rand.NormFloat64()) // uses global; fine for test speed
|
||||
_ = u1
|
||||
_ = u2
|
||||
coords[d] = cent[d] + 0.03*z
|
||||
if coords[d] < 0 {
|
||||
coords[d] = 0
|
||||
} else if coords[d] > 1 {
|
||||
coords[d] = 1
|
||||
}
|
||||
}
|
||||
pts[i] = KDPoint[int]{ID: fmt.Sprint(i), Coords: coords, Value: i}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
func benchNearestBackend(b *testing.B, n, dim int, backend KDBackend, uniform bool, clusters int) {
|
||||
var pts []KDPoint[int]
|
||||
if uniform {
|
||||
pts = makeUniformPoints(n, dim)
|
||||
} else {
|
||||
pts = makeClusteredPoints(n, dim, clusters)
|
||||
}
|
||||
tr, _ := NewKDTree(pts, WithBackend(backend))
|
||||
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 benchKNNBackend(b *testing.B, n, dim, k int, backend KDBackend, uniform bool, clusters int) {
|
||||
var pts []KDPoint[int]
|
||||
if uniform {
|
||||
pts = makeUniformPoints(n, dim)
|
||||
} else {
|
||||
pts = makeClusteredPoints(n, dim, clusters)
|
||||
}
|
||||
tr, _ := NewKDTree(pts, WithBackend(backend))
|
||||
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 benchRadiusBackend(b *testing.B, n, dim int, r float64, backend KDBackend, uniform bool, clusters int) {
|
||||
var pts []KDPoint[int]
|
||||
if uniform {
|
||||
pts = makeUniformPoints(n, dim)
|
||||
} else {
|
||||
pts = makeClusteredPoints(n, dim, clusters)
|
||||
}
|
||||
tr, _ := NewKDTree(pts, WithBackend(backend))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Uniform 2D/4D, Linear vs Gonum (opt-in via build tag; falls back to linear if not available)
|
||||
func BenchmarkNearest_Linear_Uniform_1k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 1_000, 2, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Uniform_1k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 1_000, 2, BackendGonum, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Linear_Uniform_10k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 10_000, 2, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Uniform_10k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 10_000, 2, BackendGonum, true, 0)
|
||||
}
|
||||
|
||||
func BenchmarkNearest_Linear_Uniform_1k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 1_000, 4, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Uniform_1k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 1_000, 4, BackendGonum, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Linear_Uniform_10k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 10_000, 4, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Uniform_10k_4D(b *testing.B) {
|
||||
benchNearestBackend(b, 10_000, 4, BackendGonum, true, 0)
|
||||
}
|
||||
|
||||
// Clustered 2D/4D (3 clusters)
|
||||
func BenchmarkNearest_Linear_Clustered_1k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 1_000, 2, BackendLinear, false, 3)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Clustered_1k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 1_000, 2, BackendGonum, false, 3)
|
||||
}
|
||||
func BenchmarkNearest_Linear_Clustered_10k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 10_000, 2, BackendLinear, false, 3)
|
||||
}
|
||||
func BenchmarkNearest_Gonum_Clustered_10k_2D(b *testing.B) {
|
||||
benchNearestBackend(b, 10_000, 2, BackendGonum, false, 3)
|
||||
}
|
||||
|
||||
func BenchmarkKNN10_Linear_Uniform_10k_2D(b *testing.B) {
|
||||
benchKNNBackend(b, 10_000, 2, 10, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkKNN10_Gonum_Uniform_10k_2D(b *testing.B) {
|
||||
benchKNNBackend(b, 10_000, 2, 10, BackendGonum, true, 0)
|
||||
}
|
||||
func BenchmarkKNN10_Linear_Clustered_10k_2D(b *testing.B) {
|
||||
benchKNNBackend(b, 10_000, 2, 10, BackendLinear, false, 3)
|
||||
}
|
||||
func BenchmarkKNN10_Gonum_Clustered_10k_2D(b *testing.B) {
|
||||
benchKNNBackend(b, 10_000, 2, 10, BackendGonum, false, 3)
|
||||
}
|
||||
|
||||
func BenchmarkRadiusMid_Linear_Uniform_10k_2D(b *testing.B) {
|
||||
benchRadiusBackend(b, 10_000, 2, 0.5, BackendLinear, true, 0)
|
||||
}
|
||||
func BenchmarkRadiusMid_Gonum_Uniform_10k_2D(b *testing.B) {
|
||||
benchRadiusBackend(b, 10_000, 2, 0.5, BackendGonum, true, 0)
|
||||
}
|
||||
func BenchmarkRadiusMid_Linear_Clustered_10k_2D(b *testing.B) {
|
||||
benchRadiusBackend(b, 10_000, 2, 0.5, BackendLinear, false, 3)
|
||||
}
|
||||
func BenchmarkRadiusMid_Gonum_Clustered_10k_2D(b *testing.B) {
|
||||
benchRadiusBackend(b, 10_000, 2, 0.5, BackendGonum, false, 3)
|
||||
}
|
||||
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) }
|
||||
7
doc.go
Normal file
7
doc.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// 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 and arbitrary N‑D use-cases.
|
||||
//
|
||||
// Distance metrics include Euclidean (L2), Manhattan (L1), Chebyshev (L∞), and
|
||||
// Cosine/Weighted-Cosine for vector similarity.
|
||||
package poindexter
|
||||
286
docs/api.md
286
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.3.0")
|
||||
|
||||
**Example:**
|
||||
|
||||
```go
|
||||
version := poindexter.Version()
|
||||
fmt.Println(version) // Output: 0.1.0
|
||||
fmt.Println(version) // Output: 0.3.0
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -316,3 +316,285 @@ Performs a binary search on a sorted slice of strings.
|
|||
|
||||
**Returns:**
|
||||
- `int`: The index where target is found, or -1 if not found
|
||||
|
||||
|
||||
## KDTree Helpers
|
||||
|
||||
Poindexter provides helpers to build normalized, weighted KD points from your own records. These functions min–max normalize each axis over your dataset, optionally invert axes where higher is better (to turn them into “lower cost”), and apply per‑axis weights.
|
||||
|
||||
```go
|
||||
func Build2D[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
f1, f2 func(T) float64,
|
||||
weights [2]float64,
|
||||
invert [2]bool,
|
||||
) ([]KDPoint[T], error)
|
||||
|
||||
func Build3D[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
f1, f2, f3 func(T) float64,
|
||||
weights [3]float64,
|
||||
invert [3]bool,
|
||||
) ([]KDPoint[T], error)
|
||||
|
||||
func Build4D[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
f1, f2, f3, f4 func(T) float64,
|
||||
weights [4]float64,
|
||||
invert [4]bool,
|
||||
) ([]KDPoint[T], error)
|
||||
```
|
||||
|
||||
Example (4D over ping, hops, geo, score):
|
||||
|
||||
```go
|
||||
// weights and inversion: flip score so higher is better → lower cost
|
||||
weights := [4]float64{1.0, 0.7, 0.2, 1.2}
|
||||
invert := [4]bool{false, false, false, true}
|
||||
|
||||
pts, err := poindexter.Build4D(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.GeoKM },
|
||||
func(p Peer) float64 { return p.Score },
|
||||
weights, invert,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
kdt, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
best, dist, _ := kdt.Nearest([]float64{0, 0, 0, 0})
|
||||
```
|
||||
|
||||
Notes:
|
||||
- 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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## KDTree Normalization Stats (reuse across updates)
|
||||
|
||||
To keep normalization consistent across dynamic updates, compute per‑axis min/max once and reuse it to build points later. This avoids drift when the candidate set changes.
|
||||
|
||||
### Types
|
||||
|
||||
```go
|
||||
// AxisStats holds the min/max observed for a single axis.
|
||||
type AxisStats struct {
|
||||
Min float64
|
||||
Max float64
|
||||
}
|
||||
|
||||
// NormStats holds per‑axis normalisation stats; for D dims, Stats has length D.
|
||||
type NormStats struct {
|
||||
Stats []AxisStats
|
||||
}
|
||||
```
|
||||
|
||||
### Compute normalization stats
|
||||
|
||||
```go
|
||||
func ComputeNormStats2D[T any](items []T, f1, f2 func(T) float64) NormStats
|
||||
func ComputeNormStats3D[T any](items []T, f1, f2, f3 func(T) float64) NormStats
|
||||
func ComputeNormStats4D[T any](items []T, f1, f2, f3, f4 func(T) float64) NormStats
|
||||
```
|
||||
|
||||
### Build with precomputed stats
|
||||
|
||||
```go
|
||||
func Build2DWithStats[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
f1, f2 func(T) float64,
|
||||
weights [2]float64,
|
||||
invert [2]bool,
|
||||
stats NormStats,
|
||||
) ([]KDPoint[T], error)
|
||||
|
||||
func Build3DWithStats[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
f1, f2, f3 func(T) float64,
|
||||
weights [3]float64,
|
||||
invert [3]bool,
|
||||
stats NormStats,
|
||||
) ([]KDPoint[T], error)
|
||||
|
||||
func Build4DWithStats[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
f1, f2, f3, f4 func(T) float64,
|
||||
weights [4]float64,
|
||||
invert [4]bool,
|
||||
stats NormStats,
|
||||
) ([]KDPoint[T], error)
|
||||
```
|
||||
|
||||
|
||||
#### Example (2D)
|
||||
|
||||
```go
|
||||
// Compute stats once over your baseline set
|
||||
stats := poindexter.ComputeNormStats2D(peers,
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
)
|
||||
|
||||
// Build points using those stats (now or later)
|
||||
pts, _ := poindexter.Build2DWithStats(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
[2]float64{1,1}, [2]bool{false,false}, stats,
|
||||
)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If `min==max` for an axis, normalized value is `0` for that axis.
|
||||
- `invert[i]` flips the normalized axis as `1 - n` before applying `weights[i]`.
|
||||
- These helpers mirror `Build2D/3D/4D`, but use your provided `NormStats` instead of recomputing from the items slice.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## KDTree Normalization Helpers (N‑D)
|
||||
|
||||
Poindexter includes helpers to build KD points from arbitrary dimensions.
|
||||
|
||||
```go
|
||||
func BuildND[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
features []func(T) float64,
|
||||
weights []float64,
|
||||
invert []bool,
|
||||
) ([]KDPoint[T], error)
|
||||
|
||||
// Like BuildND but never returns an error. It performs no validation beyond
|
||||
// basic length checks and propagates NaN/Inf values from feature extractors.
|
||||
func BuildNDNoErr[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
features []func(T) float64,
|
||||
weights []float64,
|
||||
invert []bool,
|
||||
) []KDPoint[T]
|
||||
```
|
||||
|
||||
- `features`: extract raw values per axis.
|
||||
- `weights`: per-axis weights, same length as `features`.
|
||||
- `invert`: if true for an axis, uses `1 - normalized` before weighting (turns “higher is better” into lower cost).
|
||||
- Use `ComputeNormStatsND` + `BuildNDWithStats` to reuse normalization between updates.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
pts := poindexter.BuildNDNoErr(records,
|
||||
func(r Rec) string { return r.ID },
|
||||
[]func(Rec) float64{
|
||||
func(r Rec) float64 { return r.PingMS },
|
||||
func(r Rec) float64 { return r.Hops },
|
||||
func(r Rec) float64 { return r.GeoKM },
|
||||
func(r Rec) float64 { return r.Score },
|
||||
},
|
||||
[]float64{1.0, 0.7, 0.2, 1.2},
|
||||
[]bool{false, false, false, true},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## KDTree Backend selection
|
||||
|
||||
Poindexter provides two internal backends for KDTree queries:
|
||||
|
||||
- `linear`: always available; performs O(n) scans for `Nearest`, `KNearest`, and `Radius`.
|
||||
- `gonum`: optimized KD backend compiled when you build with the `gonum` build tag; typically sub-linear on prunable datasets and modest dimensions.
|
||||
|
||||
### Types and options
|
||||
|
||||
```go
|
||||
// KDBackend selects the internal engine used by KDTree.
|
||||
type KDBackend string
|
||||
|
||||
const (
|
||||
BackendLinear KDBackend = "linear"
|
||||
BackendGonum KDBackend = "gonum"
|
||||
)
|
||||
|
||||
// WithBackend selects the internal KDTree backend ("linear" or "gonum").
|
||||
// If the requested backend is unavailable (e.g., missing build tag), the constructor
|
||||
// falls back to the linear backend.
|
||||
func WithBackend(b KDBackend) KDOption
|
||||
```
|
||||
|
||||
### Default selection
|
||||
|
||||
- Default is `linear`.
|
||||
- If you build your project with `-tags=gonum`, the default becomes `gonum`.
|
||||
|
||||
### Usage examples
|
||||
|
||||
```go
|
||||
// Default metric is Euclidean; you can override with WithMetric.
|
||||
pts := []poindexter.KDPoint[string]{
|
||||
{ID: "A", Coords: []float64{0, 0}},
|
||||
{ID: "B", Coords: []float64{1, 0}},
|
||||
}
|
||||
|
||||
// Force Linear (always available)
|
||||
lin, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendLinear))
|
||||
_, _, _ = lin.Nearest([]float64{0.9, 0.1})
|
||||
|
||||
// Force Gonum (requires building with: go build -tags=gonum)
|
||||
gon, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendGonum))
|
||||
_, _, _ = gon.Nearest([]float64{0.9, 0.1})
|
||||
```
|
||||
|
||||
### Supported metrics in the optimized backend
|
||||
|
||||
- Euclidean (L2), Manhattan (L1), Chebyshev (L∞).
|
||||
- Cosine and Weighted-Cosine currently use the Linear backend.
|
||||
|
||||
See also the Performance guide for measured comparisons and guidance: `docs/perf.md`.
|
||||
116
docs/dht-best-ping.md
Normal file
116
docs/dht-best-ping.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Example: Find the best (lowest‑ping) peer in a DHT table
|
||||
|
||||
This example shows how to model a "made up" DHT routing table and use Poindexter's `KDTree` to quickly find:
|
||||
|
||||
- the single best peer by ping (nearest neighbor)
|
||||
- the top N best peers by ping (k‑nearest neighbors)
|
||||
- all peers under a ping threshold (radius search)
|
||||
|
||||
We keep it simple by mapping each peer to a 1‑dimensional coordinate: its ping in milliseconds. Using 1D means the KDTree's distance is just the absolute difference between pings.
|
||||
|
||||
> Tip: In a real system, you might expand to multiple dimensions (e.g., `[ping_ms, hop_count, geo_distance, score]`) and choose a metric (`L1`, `L2`, or `L∞`) that best matches your routing heuristic. See how to build normalized, weighted multi‑dimensional points with the public helpers `poindexter.Build2D/3D/4D` here: [Multi-Dimensional KDTree (DHT)](kdtree-multidimensional.md).
|
||||
|
||||
---
|
||||
|
||||
## Full example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
// Peer is our DHT peer entry (made up for this example).
|
||||
type Peer struct {
|
||||
Addr string // multiaddr or host:port
|
||||
Ping int // measured ping in milliseconds
|
||||
}
|
||||
|
||||
func main() {
|
||||
// A toy DHT routing table with made-up ping values
|
||||
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},
|
||||
}
|
||||
|
||||
// Map peers to KD points in 1D where coordinate = ping (ms).
|
||||
// Use stable string IDs so we can delete/update later.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Build a KDTree. Euclidean metric is fine for 1D ping comparisons.
|
||||
kdt, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 1) Find the best (lowest-ping) peer.
|
||||
// Query is a 1D point representing desired ping target. Using 0 finds the min.
|
||||
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)
|
||||
// Example output: Best peer: peer4.example:4001 (ping=35 ms), distance=35
|
||||
|
||||
// 2) Top-N best peers by ping.
|
||||
top, dists := kdt.KNearest([]float64{0}, 3)
|
||||
fmt.Println("Top 3 peers by ping:")
|
||||
for i := range top {
|
||||
fmt.Printf(" #%d %s (ping=%d ms), distance=%.0f\n", i+1, top[i].Value.Addr, top[i].Value.Ping, dists[i])
|
||||
}
|
||||
|
||||
// 3) All peers under a threshold (e.g., <= 50 ms): radius search.
|
||||
within, wd := kdt.Radius([]float64{0}, 50)
|
||||
fmt.Println("Peers with ping <= 50 ms:")
|
||||
for i := range within {
|
||||
fmt.Printf(" %s (ping=%d ms), distance=%.0f\n", within[i].Value.Addr, within[i].Value.Ping, wd[i])
|
||||
}
|
||||
|
||||
// 4) Dynamic updates: if a peer improves ping, we can delete & re-insert with a new ID
|
||||
// (or keep the same ID and just update the point if your application tracks indices).
|
||||
// Here we simulate peer5 dropping from 60 ms to 30 ms.
|
||||
if kdt.DeleteByID("peer-5") {
|
||||
improved := poindexter.KDPoint[Peer]{
|
||||
ID: "peer-5", // keep the same ID for simplicity
|
||||
Coords: []float64{30},
|
||||
Value: Peer{Addr: "peer5.example:4001", Ping: 30},
|
||||
}
|
||||
_ = kdt.Insert(improved)
|
||||
}
|
||||
|
||||
// Recompute the best after update
|
||||
best2, d2, _ := kdt.Nearest([]float64{0})
|
||||
fmt.Printf("After update, best peer: %s (ping=%d ms), distance=%.0f\n", best2.Value.Addr, best2.Value.Ping, d2)
|
||||
}
|
||||
```
|
||||
|
||||
### Why does querying with `[0]` work?
|
||||
We use Euclidean distance in 1D, so `distance = |ping - target|`. With target `0`, minimizing the distance is equivalent to minimizing the ping itself.
|
||||
|
||||
|
||||
### Extending the metric/space
|
||||
- Multi-objective: encode more routing features (lower is better) as extra dimensions, e.g. `[ping_ms, hops, queue_delay_ms]`.
|
||||
- Metric choice:
|
||||
- `EuclideanDistance` (L2): balances outliers smoothly.
|
||||
- `ManhattanDistance` (L1): linear penalty; robust for sparsity.
|
||||
- `ChebyshevDistance` (L∞): cares about the worst dimension.
|
||||
- Normalization: when mixing units (ms, hops, km), normalize or weight dimensions so the metric reflects your priority.
|
||||
|
||||
|
||||
### Notes
|
||||
- This KDTree currently uses an internal linear scan for queries. The API is stable and designed so it can be swapped to use `gonum.org/v1/gonum/spatial/kdtree` under the hood later for sub-linear queries on large datasets.
|
||||
- IDs are optional but recommended for O(1)-style deletes; keep them unique per tree.
|
||||
|
|
@ -122,4 +122,6 @@ func main() {
|
|||
## Next Steps
|
||||
|
||||
- Check out the [API Reference](api.md) for detailed documentation
|
||||
- Try the example: [Find the best (lowest‑ping) DHT peer](dht-best-ping.md)
|
||||
- Explore multidimensional KDTree over ping/hops/geo/score: [Multidimensional KDTree (DHT)](kdtree-multidimensional.md)
|
||||
- Read about the [License](license.md)
|
||||
|
|
|
|||
|
|
@ -47,3 +47,13 @@ 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.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
- Find the best (lowest‑ping) DHT peer using KDTree: [Best Ping Peer (DHT)](dht-best-ping.md)
|
||||
- Multi-dimensional neighbor search over ping, hops, geo, and score: [Multi-Dimensional KDTree (DHT)](kdtree-multidimensional.md)
|
||||
|
||||
## Performance
|
||||
|
||||
- Benchmark methodology and guidance: [Performance](perf.md)
|
||||
|
|
|
|||
288
docs/kdtree-multidimensional.md
Normal file
288
docs/kdtree-multidimensional.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# KDTree: Multi‑Dimensional Search (DHT peers)
|
||||
|
||||
This example extends the single‑dimension "best ping" demo to a realistic multi‑dimensional selection:
|
||||
|
||||
- ping_ms (lower is better)
|
||||
- hop_count (lower is better)
|
||||
- geo_distance_km (lower is better)
|
||||
- score (higher is better — e.g., capacity/reputation)
|
||||
|
||||
We will:
|
||||
- Build 4‑D points over these features
|
||||
- Run `Nearest`, `KNearest`, and `Radius` queries
|
||||
- Show subsets: ping+hop (2‑D) and ping+hop+geo (3‑D)
|
||||
- Demonstrate weighting/normalization to balance disparate units
|
||||
|
||||
> Tip: KDTree distances are geometric. Mixing units (ms, hops, km, arbitrary score) requires scaling so that each axis contributes proportionally to your decision policy.
|
||||
|
||||
## Dataset
|
||||
|
||||
We’ll use a small, made‑up set of DHT peers in each runnable example below. Each example declares its own `Peer` type and dataset so you can copy‑paste and run independently.
|
||||
|
||||
## Normalization and weights
|
||||
|
||||
To make heterogeneous units comparable (ms, hops, km, score), use the library helpers which:
|
||||
- Min‑max normalize each axis to [0,1] over your provided dataset
|
||||
- Optionally invert axes where “higher is better” so they become “lower cost”
|
||||
- Apply per‑axis weights so you can emphasize what matters
|
||||
|
||||
Build 4‑D points and query them with helpers (full program):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
type Peer struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
GeoKM float64
|
||||
Score float64
|
||||
}
|
||||
|
||||
var peers = []Peer{
|
||||
{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},
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Build 4‑D KDTree using Euclidean (L2)
|
||||
weights4 := [4]float64{1.0, 0.7, 0.2, 1.2}
|
||||
invert4 := [4]bool{false, false, false, true} // invert score (higher is better)
|
||||
pts, err := poindexter.Build4D(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.GeoKM },
|
||||
func(p Peer) float64 { return p.Score },
|
||||
weights4, invert4,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
tree, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
|
||||
// Query target preferences (construct a query in normalized/weighted space)
|
||||
// Example: seek very low ping, low hops, moderate geo, high score (low score_cost)
|
||||
query := []float64{weights4[0]*0.0, weights4[1]*0.2, weights4[2]*0.3, weights4[3]*0.0}
|
||||
|
||||
// 1‑NN
|
||||
best, dist, ok := tree.Nearest(query)
|
||||
if ok {
|
||||
fmt.Printf("Best peer: %s (dist=%.4f)\n", best.ID, dist)
|
||||
}
|
||||
|
||||
// k‑NN (top 3)
|
||||
neigh, dists := tree.KNearest(query, 3)
|
||||
for i := range neigh {
|
||||
fmt.Printf("%d) %s dist=%.4f\n", i+1, neigh[i].ID, dists[i])
|
||||
}
|
||||
|
||||
// Radius query
|
||||
within, wd := tree.Radius(query, 0.35)
|
||||
fmt.Printf("Within radius 0.35: ")
|
||||
for i := range within {
|
||||
fmt.Printf("%s(%.3f) ", within[i].ID, wd[i])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
```
|
||||
|
||||
## 2‑D: Ping + Hop
|
||||
|
||||
Sometimes you want a strict trade‑off between just latency and path length. Build 2‑D points using helpers:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
type Peer struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
}
|
||||
|
||||
var peers = []Peer{
|
||||
{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},
|
||||
}
|
||||
|
||||
func main() {
|
||||
weights2 := [2]float64{1.0, 1.0}
|
||||
invert2 := [2]bool{false, false}
|
||||
|
||||
pts2, err := poindexter.Build2D(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID }, // id
|
||||
func(p Peer) float64 { return p.PingMS },// f1: ping
|
||||
func(p Peer) float64 { return p.Hops }, // f2: hops
|
||||
weights2, invert2,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
tree2, _ := poindexter.NewKDTree(pts2, poindexter.WithMetric(poindexter.ManhattanDistance{})) // L1 favors axis‑aligned tradeoffs
|
||||
// Prefer very low ping, modest hops
|
||||
query2 := []float64{weights2[0]*0.0, weights2[1]*0.3}
|
||||
best2, _, _ := tree2.Nearest(query2)
|
||||
fmt.Println("2D best (ping+hop):", best2.ID)
|
||||
}
|
||||
```
|
||||
|
||||
## 3‑D: Ping + Hop + Geo
|
||||
|
||||
Add geography to discourage far peers when latency is similar. Use the 3‑D helper:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
type Peer struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
GeoKM float64
|
||||
}
|
||||
|
||||
var peers = []Peer{
|
||||
{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},
|
||||
}
|
||||
|
||||
func main() {
|
||||
weights3 := [3]float64{1.0, 0.7, 0.3}
|
||||
invert3 := [3]bool{false, false, false}
|
||||
|
||||
pts3, err := poindexter.Build3D(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.GeoKM },
|
||||
weights3, invert3,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
tree3, _ := poindexter.NewKDTree(pts3, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
// Prefer low ping/hop, modest geo
|
||||
query3 := []float64{weights3[0]*0.0, weights3[1]*0.2, weights3[2]*0.4}
|
||||
top3, _, _ := tree3.Nearest(query3)
|
||||
fmt.Println("3D best (ping+hop+geo):", top3.ID)
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic updates
|
||||
|
||||
Your routing table changes constantly. Insert/remove peers. For consistent normalization, compute and reuse your min/max stats (preferred) or rebuild points when the candidate set changes.
|
||||
|
||||
Tip: Use the WithStats helpers to reuse normalization across updates:
|
||||
|
||||
```go
|
||||
// Compute once over your baseline
|
||||
stats := poindexter.ComputeNormStats2D(peers,
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
)
|
||||
|
||||
// Build now or later using the same stats
|
||||
ts, _ := poindexter.Build2DWithStats(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
[2]float64{1,1}, [2]bool{false,false}, stats,
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
type Peer struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
}
|
||||
|
||||
var peers = []Peer{
|
||||
{ID: "A", PingMS: 22, Hops: 3},
|
||||
{ID: "B", PingMS: 34, Hops: 2},
|
||||
{ID: "C", PingMS: 15, Hops: 4},
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initial 2‑D build (ping + hops)
|
||||
weights2 := [2]float64{1.0, 1.0}
|
||||
invert2 := [2]bool{false, false}
|
||||
|
||||
// Compute normalization stats once over your baseline set
|
||||
stats := poindexter.ComputeNormStats2D(
|
||||
peers,
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
)
|
||||
|
||||
// Build using the precomputed stats so future inserts share the same scale
|
||||
pts, _ := poindexter.Build2DWithStats(
|
||||
peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
weights2, invert2, stats,
|
||||
)
|
||||
tree, _ := poindexter.NewKDTree(pts)
|
||||
|
||||
// Insert a new peer: reuse the same normalization stats to keep scale consistent
|
||||
newPeer := Peer{ID: "Z", PingMS: 12, Hops: 2}
|
||||
addPts, _ := poindexter.Build2DWithStats(
|
||||
[]Peer{newPeer},
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
weights2, invert2, stats,
|
||||
)
|
||||
_ = tree.Insert(addPts[0])
|
||||
|
||||
// Verify nearest now prefers Z for low ping target
|
||||
best, _, _ := tree.Nearest([]float64{0, 0})
|
||||
fmt.Println("Best after insert:", best.ID)
|
||||
|
||||
// Delete by ID when peer goes offline
|
||||
_ = tree.DeleteByID("Z")
|
||||
}
|
||||
```
|
||||
|
||||
## Choosing a metric
|
||||
|
||||
- Euclidean (L2): smooth trade‑offs across axes; good default for blended preferences
|
||||
- Manhattan (L1): emphasizes per‑axis absolute differences; useful when each unit of ping/hop matters equally
|
||||
- Chebyshev (L∞): min‑max style; dominated by the worst axis (e.g., reject any peer with too many hops regardless of ping)
|
||||
|
||||
## Notes on production use
|
||||
|
||||
- Keep and reuse normalization parameters (min/max or mean/std) rather than recomputing per query to avoid drift.
|
||||
- Consider capping outliers (e.g., clamp geo distances > 5000 km).
|
||||
- For large N (≥ 1e5) and low dims (≤ 8), consider swapping the internal engine to `gonum.org/v1/gonum/spatial/kdtree` behind the same API for faster queries.
|
||||
134
docs/perf.md
Normal file
134
docs/perf.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Performance: KDTree benchmarks and guidance
|
||||
|
||||
This page summarizes how to measure KDTree performance in this repository and how to compare the two internal backends (Linear vs Gonum) that you can select at build/runtime.
|
||||
|
||||
## How benchmarks are organized
|
||||
|
||||
- Micro-benchmarks live in `bench_kdtree_test.go`, `bench_kdtree_dual_test.go`, and `bench_kdtree_dual_100k_test.go` and cover:
|
||||
- `Nearest` in 2D and 4D with N = 1k, 10k (both backends)
|
||||
- `Nearest` in 2D and 4D with N = 100k (gonum-tag job; linear also measured there)
|
||||
- `KNearest(k=10)` in 2D/4D with N = 1k, 10k
|
||||
- `Radius` (mid radius r≈0.5 after normalization) in 2D/4D with N = 1k, 10k
|
||||
- Datasets: Uniform and 3-cluster synthetic generators in normalized [0,1] spaces.
|
||||
- Backends: Linear (always available) and Gonum (enabled when built with `-tags=gonum`).
|
||||
|
||||
Run them locally:
|
||||
|
||||
```bash
|
||||
# Linear backend (default)
|
||||
go test -bench . -benchmem -run=^$ ./...
|
||||
|
||||
# Gonum backend (optimized KD; requires build tag)
|
||||
go test -tags=gonum -bench . -benchmem -run=^$ ./...
|
||||
```
|
||||
|
||||
GitHub Actions publishes benchmark artifacts on every push/PR:
|
||||
- Linear job: artifact `bench-linear.txt`
|
||||
- Gonum job: artifact `bench-gonum.txt`
|
||||
|
||||
## Backend selection and defaults
|
||||
|
||||
- Default backend is Linear.
|
||||
- If you build with `-tags=gonum`, the default switches to the optimized KD backend.
|
||||
- You can override at runtime:
|
||||
|
||||
```
|
||||
// Force Linear
|
||||
kdt, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendLinear))
|
||||
// Force Gonum (requires build tag)
|
||||
kdt, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendGonum))
|
||||
```
|
||||
|
||||
Supported metrics in the optimized backend: L2 (Euclidean), L1 (Manhattan), L∞ (Chebyshev). Cosine/Weighted-Cosine currently use the Linear backend.
|
||||
|
||||
## What to expect (rule of thumb)
|
||||
|
||||
- Linear backend: O(n) per query; fast for small-to-medium datasets (≤10k), especially in low dims (≤4).
|
||||
- Gonum backend: typically sub-linear for prunable datasets and dims ≤ ~8, with noticeable gains as N grows (≥10k–100k), especially on uniform or moderately clustered data and moderate radii.
|
||||
- For large radii (many points within r) or highly correlated/pathological data, pruning may be less effective and behavior approaches O(n) even with KD-trees.
|
||||
|
||||
## Interpreting results
|
||||
|
||||
Benchmarks output something like:
|
||||
|
||||
```
|
||||
BenchmarkNearest_10k_4D_Gonum_Uniform-8 50000 12,300 ns/op 0 B/op 0 allocs/op
|
||||
```
|
||||
|
||||
- `ns/op`: lower is better (nanoseconds per operation)
|
||||
- `B/op` and `allocs/op`: memory behavior; fewer is better
|
||||
- `KNearest` incurs extra work due to sorting; `Radius` cost scales with the number of hits.
|
||||
|
||||
## Improving performance
|
||||
|
||||
- Normalize and weight features once; reuse across queries (see `Build*WithStats` helpers).
|
||||
- Choose a metric aligned with your policy: L2 usually a solid default; L1 for per-axis penalties; L∞ for hard-threshold dominated objectives.
|
||||
- Batch queries to benefit from CPU caches.
|
||||
- Prefer the Gonum backend for larger N and dims ≤ ~8; stick to Linear for tiny datasets or when using Cosine metrics.
|
||||
|
||||
## Reproducing and tracking performance
|
||||
|
||||
- Local (Linear): `go test -bench . -benchmem -run=^$ ./...`
|
||||
- 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.
|
||||
171
docs/wasm.md
Normal file
171
docs/wasm.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Browser/WebAssembly (WASM)
|
||||
|
||||
Poindexter ships a browser build compiled to WebAssembly along with a small JS loader and TypeScript types. This allows you to use the KD‑Tree functionality directly from web apps (Angular, React, Vue, plain ESM, etc.).
|
||||
|
||||
## What’s included
|
||||
|
||||
- `dist/poindexter.wasm` — the compiled Go WASM module
|
||||
- `dist/wasm_exec.js` — Go’s runtime shim required to run WASM in the browser
|
||||
- `npm/poindexter-wasm/loader.js` — ESM loader that instantiates the WASM and exposes a friendly API
|
||||
- `npm/poindexter-wasm/index.d.ts` — TypeScript typings for the loader and KD‑Tree API
|
||||
|
||||
## Quick start
|
||||
|
||||
- Build artifacts and copy `wasm_exec.js`:
|
||||
|
||||
```bash
|
||||
make wasm-build
|
||||
```
|
||||
|
||||
- Prepare the npm package folder with `dist/` and docs:
|
||||
|
||||
```bash
|
||||
make npm-pack
|
||||
```
|
||||
|
||||
- Minimal browser ESM usage (serve `dist/` statically):
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { init } from '/npm/poindexter-wasm/loader.js';
|
||||
const px = await init({
|
||||
wasmURL: '/dist/poindexter.wasm',
|
||||
wasmExecURL: '/dist/wasm_exec.js',
|
||||
});
|
||||
const tree = await px.newTree(2);
|
||||
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
|
||||
const nn = await tree.nearest([0.1, 0.2]);
|
||||
console.log(nn);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Building locally
|
||||
|
||||
```bash
|
||||
make wasm-build
|
||||
```
|
||||
|
||||
This produces `dist/poindexter.wasm` and copies `wasm_exec.js` into `dist/` from your Go installation. If your environment is non‑standard, you can override the path:
|
||||
|
||||
```bash
|
||||
WASM_EXEC=/custom/path/wasm_exec.js make wasm-build
|
||||
```
|
||||
|
||||
To assemble the npm package folder with the built artifacts:
|
||||
|
||||
```bash
|
||||
make npm-pack
|
||||
```
|
||||
|
||||
This populates `npm/poindexter-wasm/` with `dist/`, licence and readme files. You can then create a tarball for local testing:
|
||||
|
||||
```bash
|
||||
npm pack ./npm/poindexter-wasm
|
||||
```
|
||||
|
||||
## Using in Angular (example)
|
||||
|
||||
1) Install the package (use the tarball generated above or a published version):
|
||||
|
||||
```bash
|
||||
npm install <path-to>/snider-poindexter-wasm-0.0.0-development.tgz
|
||||
# or once published
|
||||
npm install @snider/poindexter-wasm
|
||||
```
|
||||
|
||||
2) Make the WASM runtime files available as app assets. In `angular.json` under `build.options.assets`:
|
||||
|
||||
```json
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/@snider/poindexter-wasm/dist",
|
||||
"output": "/assets/poindexter/"
|
||||
}
|
||||
```
|
||||
|
||||
3) Import and initialize in your code:
|
||||
|
||||
```ts
|
||||
import { init } from '@snider/poindexter-wasm';
|
||||
|
||||
const px = await init({
|
||||
// If you used the assets mapping above, these defaults should work:
|
||||
wasmURL: '/assets/poindexter/poindexter.wasm',
|
||||
wasmExecURL: '/assets/poindexter/wasm_exec.js',
|
||||
});
|
||||
|
||||
const tree = await px.newTree(2);
|
||||
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
|
||||
const nearest = await tree.nearest([0.1, 0.2]);
|
||||
console.log(nearest);
|
||||
```
|
||||
|
||||
## JavaScript API
|
||||
|
||||
Top‑level functions returned by `init()`:
|
||||
|
||||
- `version(): string`
|
||||
- `hello(name?: string): string`
|
||||
- `newTree(dim: number): Promise<Tree>`
|
||||
|
||||
Tree methods:
|
||||
|
||||
- `dim(): Promise<number>`
|
||||
- `len(): Promise<number>`
|
||||
- `insert(p: { id: string; coords: number[]; value?: string }): Promise<void>`
|
||||
- `deleteByID(id: string): Promise<boolean>`
|
||||
- `nearest(query: number[]): Promise<{ id: string; coords: number[]; value: string; dist: number } | null>`
|
||||
- `kNearest(query: number[], k: number): Promise<Array<{ id: string; coords: number[]; value: string; dist: number }>>`
|
||||
- `radius(query: number[], r: number): Promise<Array<{ id: string; coords: number[]; value: string; dist: number }>>`
|
||||
- `exportJSON(): Promise<string>`
|
||||
|
||||
Notes:
|
||||
- The WASM bridge currently uses `KDTree[string]` for values to keep the boundary simple. You can encode richer payloads as JSON strings if needed.
|
||||
- `wasm_exec.js` must be available next to the `.wasm` file (the loader accepts explicit URLs if you place them elsewhere).
|
||||
|
||||
## CI artifacts
|
||||
|
||||
Our CI builds and uploads the following artifacts on each push/PR:
|
||||
|
||||
- `poindexter-wasm-dist` — the `dist/` folder containing `poindexter.wasm` and `wasm_exec.js`
|
||||
- `npm-poindexter-wasm` — the prepared npm package folder with `dist/` and documentation
|
||||
- `npm-poindexter-wasm-tarball` — a `.tgz` created via `npm pack` for quick local install/testing
|
||||
|
||||
You can download these artifacts from the workflow run summary in GitHub Actions.
|
||||
|
||||
## Browser demo (checked into repo)
|
||||
|
||||
There is a tiny browser demo you can load locally from this repo:
|
||||
|
||||
- Path: `examples/wasm-browser/index.html`
|
||||
- Prerequisites: run `make wasm-build` so `dist/poindexter.wasm` and `dist/wasm_exec.js` exist.
|
||||
- Serve the repo root (so relative paths resolve), for example:
|
||||
|
||||
```bash
|
||||
python3 -m http.server -b 127.0.0.1 8000
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- http://127.0.0.1:8000/examples/wasm-browser/
|
||||
|
||||
Open the browser console to see outputs from `nearest`, `kNearest`, and `radius` queries.
|
||||
|
||||
### TypeScript + Vite demo (local-only)
|
||||
|
||||
A minimal TypeScript demo using Vite is also included:
|
||||
|
||||
- Path: `examples/wasm-browser-ts/`
|
||||
- Prerequisites: run `make wasm-build` at the repo root first.
|
||||
- From the example folder:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open the URL printed by Vite (usually http://127.0.0.1:5173/) and check the browser console.
|
||||
|
||||
Notes:
|
||||
- The dev script copies `dist/poindexter.wasm`, `dist/wasm_exec.js`, and the ESM loader into the example's `public/` folder before serving.
|
||||
- This example is intentionally excluded from CI to keep the pipeline lean.
|
||||
69
examples/dht_helpers/main.go
Normal file
69
examples/dht_helpers/main.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
po "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
// BuildPingHop2D wraps poindexter.Build2D to construct 2D points from (ping_ms, hop_count).
|
||||
func BuildPingHop2D[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
ping func(T) float64,
|
||||
hops func(T) float64,
|
||||
weights [2]float64,
|
||||
invert [2]bool,
|
||||
) ([]po.KDPoint[T], error) {
|
||||
return po.Build2D(items, id, ping, hops, weights, invert)
|
||||
}
|
||||
|
||||
// BuildPingHopGeo3D wraps poindexter.Build3D for (ping_ms, hop_count, geo_km).
|
||||
func BuildPingHopGeo3D[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
ping func(T) float64,
|
||||
hops func(T) float64,
|
||||
geoKM func(T) float64,
|
||||
weights [3]float64,
|
||||
invert [3]bool,
|
||||
) ([]po.KDPoint[T], error) {
|
||||
return po.Build3D(items, id, ping, hops, geoKM, weights, invert)
|
||||
}
|
||||
|
||||
// BuildPingHopGeoScore4D wraps poindexter.Build4D for (ping_ms, hop_count, geo_km, score).
|
||||
// Typical usage sets invert for score=true so higher score => lower cost.
|
||||
func BuildPingHopGeoScore4D[T any](
|
||||
items []T,
|
||||
id func(T) string,
|
||||
ping func(T) float64,
|
||||
hops func(T) float64,
|
||||
geoKM func(T) float64,
|
||||
score func(T) float64,
|
||||
weights [4]float64,
|
||||
invert [4]bool,
|
||||
) ([]po.KDPoint[T], error) {
|
||||
return po.Build4D(items, id, ping, hops, geoKM, score, weights, invert)
|
||||
}
|
||||
|
||||
// Demo program that builds a small tree using the 2D helper and performs a query.
|
||||
func main() {
|
||||
type Peer struct {
|
||||
ID string
|
||||
PingMS, Hops float64
|
||||
}
|
||||
peers := []Peer{{"A", 20, 1}, {"B", 50, 2}, {"C", 10, 3}}
|
||||
|
||||
pts, err := BuildPingHop2D(peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
[2]float64{1.0, 0.7},
|
||||
[2]bool{false, false},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
kdt, _ := po.NewKDTree(pts, po.WithMetric(po.EuclideanDistance{}))
|
||||
best, dist, _ := kdt.Nearest([]float64{0, 0})
|
||||
fmt.Println(best.ID, dist)
|
||||
}
|
||||
8
examples/dht_helpers/main_test.go
Normal file
8
examples/dht_helpers/main_test.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMain_Run(t *testing.T) {
|
||||
// Just ensure the example main runs without panic to contribute to coverage
|
||||
main()
|
||||
}
|
||||
49
examples/dht_ping_1d/example_test.go
Normal file
49
examples/dht_ping_1d/example_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type peer struct {
|
||||
Addr string
|
||||
Ping int
|
||||
}
|
||||
|
||||
// TestExample1D ensures the 1D example logic runs and exercises KDTree paths.
|
||||
func TestExample1D(t *testing.T) {
|
||||
// Same toy table as the example
|
||||
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 {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
best, d, ok := kdt.Nearest([]float64{0})
|
||||
if !ok {
|
||||
t.Fatalf("no nearest")
|
||||
}
|
||||
// Expect the minimum ping (35ms)
|
||||
if best.Value.Ping != 35 {
|
||||
t.Fatalf("expected best ping 35ms, got %d", best.Value.Ping)
|
||||
}
|
||||
// Distance from [0] to [35] should be 35
|
||||
if d != 35 {
|
||||
t.Fatalf("expected distance 35, got %v", d)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
9
examples/dht_ping_1d/main_test.go
Normal file
9
examples/dht_ping_1d/main_test.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestExampleMain runs the example's main function to ensure it executes without panic.
|
||||
// This also allows the example code paths to be included in coverage reports.
|
||||
func TestExampleMain(t *testing.T) {
|
||||
main()
|
||||
}
|
||||
49
examples/kdtree_2d_ping_hop/example_test.go
Normal file
49
examples/kdtree_2d_ping_hop/example_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
type peer2 struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
}
|
||||
|
||||
func TestExample2D(t *testing.T) {
|
||||
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, err := 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,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Build2D err: %v", err)
|
||||
}
|
||||
tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
best, d, ok := tr.Nearest([]float64{0, 0.3})
|
||||
if !ok {
|
||||
t.Fatalf("no nearest")
|
||||
}
|
||||
if best.ID == "" {
|
||||
t.Fatalf("unexpected empty ID")
|
||||
}
|
||||
if d < 0 {
|
||||
t.Fatalf("negative distance: %v", d)
|
||||
}
|
||||
}
|
||||
43
examples/kdtree_2d_ping_hop/main.go
Normal file
43
examples/kdtree_2d_ping_hop/main.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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, err := 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,
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Build2D failed: %v", err))
|
||||
}
|
||||
tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.ManhattanDistance{}))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("NewKDTree failed: %v", err))
|
||||
}
|
||||
best, _, ok := tr.Nearest([]float64{0, 0.3})
|
||||
if !ok {
|
||||
panic("no nearest neighbour found")
|
||||
}
|
||||
fmt.Println("2D best:", best.ID)
|
||||
}
|
||||
7
examples/kdtree_2d_ping_hop/main_test.go
Normal file
7
examples/kdtree_2d_ping_hop/main_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExample2D_Main(t *testing.T) {
|
||||
main()
|
||||
}
|
||||
50
examples/kdtree_3d_ping_hop_geo/example_test.go
Normal file
50
examples/kdtree_3d_ping_hop_geo/example_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type peer3test struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
GeoKM float64
|
||||
}
|
||||
|
||||
func TestExample3D(t *testing.T) {
|
||||
peers := []peer3test{
|
||||
{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, err := poindexter.Build3D(
|
||||
peers,
|
||||
func(p peer3test) string { return p.ID },
|
||||
func(p peer3test) float64 { return p.PingMS },
|
||||
func(p peer3test) float64 { return p.Hops },
|
||||
func(p peer3test) float64 { return p.GeoKM },
|
||||
weights, invert,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Build3D err: %v", err)
|
||||
}
|
||||
tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
best, d, ok := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.4})
|
||||
if !ok {
|
||||
t.Fatalf("no nearest")
|
||||
}
|
||||
if best.ID == "" {
|
||||
t.Fatalf("unexpected empty ID")
|
||||
}
|
||||
if d < 0 {
|
||||
t.Fatalf("negative distance: %v", d)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
7
examples/kdtree_3d_ping_hop_geo/main_test.go
Normal file
7
examples/kdtree_3d_ping_hop_geo/main_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExample3D_Main(t *testing.T) {
|
||||
main()
|
||||
}
|
||||
52
examples/kdtree_4d_ping_hop_geo_score/example_test.go
Normal file
52
examples/kdtree_4d_ping_hop_geo_score/example_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type peer4test struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
GeoKM float64
|
||||
Score float64
|
||||
}
|
||||
|
||||
func TestExample4D(t *testing.T) {
|
||||
peers := []peer4test{
|
||||
{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, err := poindexter.Build4D(
|
||||
peers,
|
||||
func(p peer4test) string { return p.ID },
|
||||
func(p peer4test) float64 { return p.PingMS },
|
||||
func(p peer4test) float64 { return p.Hops },
|
||||
func(p peer4test) float64 { return p.GeoKM },
|
||||
func(p peer4test) float64 { return p.Score },
|
||||
weights, invert,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Build4D err: %v", err)
|
||||
}
|
||||
tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
best, d, ok := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.3, 0})
|
||||
if !ok {
|
||||
t.Fatalf("no nearest")
|
||||
}
|
||||
if best.ID == "" {
|
||||
t.Fatalf("unexpected empty ID")
|
||||
}
|
||||
if d < 0 {
|
||||
t.Fatalf("negative distance: %v", d)
|
||||
}
|
||||
}
|
||||
47
examples/kdtree_4d_ping_hop_geo_score/main.go
Normal file
47
examples/kdtree_4d_ping_hop_geo_score/main.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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, err := 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,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tr, err := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
best, _, ok := tr.Nearest([]float64{0, weights[1] * 0.2, weights[2] * 0.3, 0})
|
||||
if !ok {
|
||||
panic("no nearest neighbour found")
|
||||
}
|
||||
fmt.Println("4D best:", best.ID)
|
||||
}
|
||||
7
examples/kdtree_4d_ping_hop_geo_score/main_test.go
Normal file
7
examples/kdtree_4d_ping_hop_geo_score/main_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExample4D_Main(t *testing.T) {
|
||||
main()
|
||||
}
|
||||
46
examples/wasm-browser-ts/README.md
Normal file
46
examples/wasm-browser-ts/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# WASM Browser Example (TypeScript + Vite)
|
||||
|
||||
This is a minimal TypeScript example that runs Poindexter’s WebAssembly build in the browser.
|
||||
It bundles a tiny page with Vite and demonstrates creating a KDTree and running `Nearest`,
|
||||
`KNearest`, and `Radius` queries.
|
||||
|
||||
## Prerequisites
|
||||
- Go toolchain installed
|
||||
- Node.js 18+ (tested with Node 20)
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Build the WASM artifacts at the repo root:
|
||||
|
||||
```bash
|
||||
make wasm-build
|
||||
```
|
||||
|
||||
This creates `dist/poindexter.wasm` and `dist/wasm_exec.js`.
|
||||
|
||||
2) From this example directory, install deps and start the dev server (the script copies the required files into `public/` before starting Vite):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3) Open the URL printed by Vite (usually http://127.0.0.1:5173/). Open the browser console to see outputs.
|
||||
|
||||
## What the dev script does
|
||||
- Copies `../../dist/poindexter.wasm` and `../../dist/wasm_exec.js` into `public/`
|
||||
- Copies `../../npm/poindexter-wasm/loader.js` into `public/`
|
||||
- Starts Vite with `public/` as the static root for those assets
|
||||
|
||||
The TypeScript code imports the loader from `/loader.js` and initializes with:
|
||||
|
||||
```ts
|
||||
const px = await init({
|
||||
wasmURL: '/poindexter.wasm',
|
||||
wasmExecURL: '/wasm_exec.js',
|
||||
});
|
||||
```
|
||||
|
||||
## Notes
|
||||
- This example is local-only and not built in CI to keep jobs light.
|
||||
- You can adapt the same structure inside your own web projects; alternatively, install the published npm package when available and serve `dist/` as static assets.
|
||||
25
examples/wasm-browser-ts/index.html
Normal file
25
examples/wasm-browser-ts/index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Poindexter WASM TS Demo</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }
|
||||
pre { background: #f6f8fa; padding: 1rem; overflow-x: auto; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Poindexter WASM (TypeScript + Vite)</h1>
|
||||
<p>
|
||||
This demo initializes the WebAssembly build and performs KDTree queries. Open your browser console to see results.
|
||||
</p>
|
||||
<p>
|
||||
Before running, build the WASM artifacts at the repo root:
|
||||
</p>
|
||||
<pre><code>make wasm-build</code></pre>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
examples/wasm-browser-ts/package.json
Normal file
16
examples/wasm-browser-ts/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "poindexter-wasm-browser-ts",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "node scripts/copy-assets.mjs",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
40
examples/wasm-browser-ts/scripts/copy-assets.mjs
Normal file
40
examples/wasm-browser-ts/scripts/copy-assets.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Copies WASM artifacts and loader into the public/ folder before Vite dev/build.
|
||||
// Run as an npm script (predev) from this example directory.
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
async function main() {
|
||||
const root = resolve(__dirname, '../../..');
|
||||
const exampleDir = resolve(__dirname, '..');
|
||||
const publicDir = resolve(exampleDir, 'public');
|
||||
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
|
||||
const sources = [
|
||||
// WASM artifacts built by `make wasm-build`
|
||||
resolve(root, 'dist/poindexter.wasm'),
|
||||
resolve(root, 'dist/wasm_exec.js'),
|
||||
// ESM loader shipped with the repo's npm folder
|
||||
resolve(root, 'npm/poindexter-wasm/loader.js'),
|
||||
];
|
||||
|
||||
const targets = [
|
||||
resolve(publicDir, 'poindexter.wasm'),
|
||||
resolve(publicDir, 'wasm_exec.js'),
|
||||
resolve(publicDir, 'loader.js'),
|
||||
];
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
await cp(sources[i], targets[i]);
|
||||
console.log(`Copied ${sources[i]} -> ${targets[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('copy-assets failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
33
examples/wasm-browser-ts/src/main.ts
Normal file
33
examples/wasm-browser-ts/src/main.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Minimal TypeScript demo that uses the Poindexter WASM ESM loader.
|
||||
// Precondition: run `make wasm-build` at repo root, then `npm run dev` in this folder.
|
||||
|
||||
// We copy the loader and wasm artifacts to /public via scripts/copy-assets.mjs before dev starts.
|
||||
// @ts-ignore
|
||||
import { init } from '/loader.js';
|
||||
|
||||
async function run() {
|
||||
const px = await init({
|
||||
wasmURL: '/poindexter.wasm',
|
||||
wasmExecURL: '/wasm_exec.js',
|
||||
});
|
||||
|
||||
console.log('Poindexter (WASM) version:', await px.version());
|
||||
|
||||
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' });
|
||||
|
||||
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 rad = await tree.radius([0, 0], 1.1);
|
||||
console.log('Radius r=1.1 [0,0]:', rad);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('WASM demo error:', err);
|
||||
});
|
||||
15
examples/wasm-browser-ts/tsconfig.json
Normal file
15
examples/wasm-browser-ts/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
17
examples/wasm-browser-ts/vite.config.ts
Normal file
17
examples/wasm-browser-ts/vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
// Minimal Vite config for the WASM TS example.
|
||||
// Serves files from project root; our dev script copies required artifacts to public/.
|
||||
export default defineConfig({
|
||||
root: '.',
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
open: false,
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
open: false,
|
||||
},
|
||||
});
|
||||
60
examples/wasm-browser/index.html
Normal file
60
examples/wasm-browser/index.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Poindexter WASM Browser Demo</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }
|
||||
pre { background: #f6f8fa; padding: 1rem; overflow-x: auto; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Poindexter WASM Browser Demo</h1>
|
||||
<p>This demo uses the ESM loader to initialize the WebAssembly build and run a simple KDTree query entirely in your browser.</p>
|
||||
|
||||
<p>
|
||||
Serve this file from the repository root so the asset paths resolve. For example:
|
||||
</p>
|
||||
<pre><code>python3 -m http.server -b 127.0.0.1 8000</code></pre>
|
||||
<p>Then open <code>http://127.0.0.1:8000/examples/wasm-browser/</code> in your browser.</p>
|
||||
|
||||
<h2>Console output</h2>
|
||||
<p>Open DevTools console to inspect results.</p>
|
||||
|
||||
<script type="module">
|
||||
// Import the ESM loader from the npm package directory within this repo.
|
||||
// When serving from repo root, this path resolves to the local loader.
|
||||
import { init } from '../../npm/poindexter-wasm/loader.js';
|
||||
|
||||
async function main() {
|
||||
const px = await init({
|
||||
// Point to the built WASM artifacts in dist/. Ensure you ran `make wasm-build` first.
|
||||
wasmURL: '../../dist/poindexter.wasm',
|
||||
wasmExecURL: '../../dist/wasm_exec.js',
|
||||
});
|
||||
|
||||
console.log('Poindexter version (WASM):', await px.version());
|
||||
|
||||
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' });
|
||||
|
||||
const nearest = await tree.nearest([0.9, 0.1]);
|
||||
console.log('Nearest to [0.9,0.1]:', nearest);
|
||||
|
||||
const knn = await tree.kNearest([0.9, 0.9], 2);
|
||||
console.log('kNN (k=2) for [0.9,0.9]:', knn);
|
||||
|
||||
const within = await tree.radius([0, 0], 1.1);
|
||||
console.log('Within r=1.1 of [0,0]:', within);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Demo error:', err);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
240
examples_test.go
Normal file
240
examples_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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})
|
||||
fmt.Printf("dim=%d len=%d", tr.Dim(), tr.Len())
|
||||
// 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.4, 0})
|
||||
fmt.Printf("ok=%v id=%s d=%.1f", ok, p.ID, d)
|
||||
// Output: ok=true id=y d=0.6
|
||||
}
|
||||
|
||||
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_DeleteByID() {
|
||||
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
|
||||
}
|
||||
|
||||
func ExampleBuild2DWithStats() {
|
||||
type rec struct{ ping, hops float64 }
|
||||
items := []rec{{20, 3}, {30, 2}, {15, 4}}
|
||||
weights := [2]float64{1.0, 1.0}
|
||||
invert := [2]bool{false, false}
|
||||
stats := poindexter.ComputeNormStats2D(items,
|
||||
func(r rec) float64 { return r.ping },
|
||||
func(r rec) float64 { return r.hops },
|
||||
)
|
||||
pts, _ := poindexter.Build2DWithStats(items,
|
||||
func(r rec) string { return "" },
|
||||
func(r rec) float64 { return r.ping },
|
||||
func(r rec) float64 { return r.hops },
|
||||
weights, invert, stats,
|
||||
)
|
||||
tr, _ := poindexter.NewKDTree(pts)
|
||||
fmt.Printf("dim=%d len=%d", tr.Dim(), tr.Len())
|
||||
// Output: dim=2 len=3
|
||||
}
|
||||
|
||||
func ExampleBuildND() {
|
||||
type rec struct{ a, b, c float64 }
|
||||
items := []rec{{0, 0, 0}, {1, 2, 3}, {0.5, 1, 1.5}}
|
||||
features := []func(rec) float64{
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
func(r rec) float64 { return r.c },
|
||||
}
|
||||
weights := []float64{1, 0.5, 2}
|
||||
invert := []bool{false, false, false}
|
||||
pts, _ := poindexter.BuildND(items, func(r rec) string { return "" }, features, weights, invert)
|
||||
tr, _ := poindexter.NewKDTree(pts)
|
||||
fmt.Printf("dim=%d len=%d", tr.Dim(), tr.Len())
|
||||
// Output: dim=3 len=3
|
||||
}
|
||||
|
||||
func ExampleBuildNDWithStats() {
|
||||
type rec struct{ a, b float64 }
|
||||
items := []rec{{0, 0}, {1, 2}, {0.5, 1}}
|
||||
features := []func(rec) float64{
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
}
|
||||
stats, _ := poindexter.ComputeNormStatsND(items, features)
|
||||
weights := []float64{1, 0.5}
|
||||
invert := []bool{false, false}
|
||||
pts, _ := poindexter.BuildNDWithStats(items, func(r rec) string { return "" }, features, weights, invert, stats)
|
||||
tr, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.CosineDistance{}))
|
||||
fmt.Printf("dim=%d len=%d", tr.Dim(), tr.Len())
|
||||
// Output: dim=2 len=3
|
||||
}
|
||||
|
||||
func ExampleCosineDistance() {
|
||||
a := []float64{1, 0}
|
||||
b := []float64{0, 1}
|
||||
d := poindexter.CosineDistance{}.Distance(a, b)
|
||||
fmt.Printf("%.0f", d)
|
||||
// Output: 1
|
||||
}
|
||||
|
||||
func ExampleBuild4DWithStats() {
|
||||
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}
|
||||
stats := poindexter.ComputeNormStats4D(items,
|
||||
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 },
|
||||
)
|
||||
pts, _ := poindexter.Build4DWithStats(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, stats,
|
||||
)
|
||||
tr, _ := poindexter.NewKDTree(pts)
|
||||
fmt.Println(tr.Dim())
|
||||
// Output: 4
|
||||
}
|
||||
|
||||
func ExampleNewKDTreeFromDim() {
|
||||
// Construct an empty 2D tree, insert a point, then query.
|
||||
tr, _ := poindexter.NewKDTreeFromDim[string](2)
|
||||
tr.Insert(poindexter.KDPoint[string]{ID: "A", Coords: []float64{0.1, 0.2}, Value: "alpha"})
|
||||
p, _, ok := tr.Nearest([]float64{0, 0})
|
||||
fmt.Printf("ok=%v id=%s dim=%d len=%d", ok, p.ID, tr.Dim(), tr.Len())
|
||||
// Output: ok=true id=A dim=2 len=1
|
||||
}
|
||||
|
||||
func Example_tiesBehavior() {
|
||||
// Two points equidistant from the query; tie ordering is arbitrary,
|
||||
// but distances are equal.
|
||||
pts := []poindexter.KDPoint[int]{
|
||||
{ID: "L", Coords: []float64{-1}},
|
||||
{ID: "R", Coords: []float64{+1}},
|
||||
}
|
||||
tr, _ := poindexter.NewKDTree(pts)
|
||||
ns, ds := tr.KNearest([]float64{0}, 2)
|
||||
_ = ns // neighbor order is unspecified
|
||||
fmt.Printf("equal=%.1f==%.1f? %v", ds[0], ds[1], ds[0] == ds[1])
|
||||
// Output: equal=1.0==1.0? true
|
||||
}
|
||||
|
||||
func ExampleKDTree_Radius_none() {
|
||||
// Radius query that yields no matches.
|
||||
pts := []poindexter.KDPoint[int]{
|
||||
{ID: "a", Coords: []float64{10}},
|
||||
{ID: "b", Coords: []float64{20}},
|
||||
}
|
||||
tr, _ := poindexter.NewKDTree(pts)
|
||||
within, _ := tr.Radius([]float64{0}, 5)
|
||||
fmt.Println(len(within))
|
||||
// Output: 0
|
||||
}
|
||||
122
fuzz_kdtree_test.go
Normal file
122
fuzz_kdtree_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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)
|
||||
m4 := CosineDistance{}.Distance(a, b)
|
||||
w := make([]float64, dim)
|
||||
for i := range w {
|
||||
w[i] = 1
|
||||
}
|
||||
m5 := WeightedCosineDistance{Weights: w}.Distance(a, b)
|
||||
if m1 < 0 || m2 < 0 || m3 < 0 || m4 < 0 || m5 < 0 {
|
||||
t.Fatalf("negative metric: %v %v %v %v %v", m1, m2, m3, m4, m5)
|
||||
}
|
||||
if m4 > 2 || m5 > 2 {
|
||||
t.Fatalf("cosine distance out of bounds: %v %v", m4, m5)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -1,3 +1,3 @@
|
|||
module github.com/Snider/Poindexter
|
||||
|
||||
go 1.24.9
|
||||
go 1.23
|
||||
|
|
|
|||
465
kdtree.go
Normal file
465
kdtree.go
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"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")
|
||||
// ErrBackendUnavailable indicates that a requested backend cannot be used (e.g., not built/tagged).
|
||||
ErrBackendUnavailable = errors.New("kdtree: requested backend unavailable")
|
||||
)
|
||||
|
||||
// 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.
|
||||
type KDPoint[T any] struct {
|
||||
ID string
|
||||
Coords []float64
|
||||
Value T
|
||||
}
|
||||
|
||||
// DistanceMetric defines a metric over R^n.
|
||||
type DistanceMetric interface {
|
||||
Distance(a, b []float64) float64
|
||||
}
|
||||
|
||||
// EuclideanDistance implements the L2 metric.
|
||||
type EuclideanDistance struct{}
|
||||
|
||||
func (EuclideanDistance) Distance(a, b []float64) float64 {
|
||||
var sum float64
|
||||
for i := range a {
|
||||
d := a[i] - b[i]
|
||||
sum += d * d
|
||||
}
|
||||
return math.Sqrt(sum)
|
||||
}
|
||||
|
||||
// ManhattanDistance implements the L1 metric.
|
||||
type ManhattanDistance struct{}
|
||||
|
||||
func (ManhattanDistance) Distance(a, b []float64) float64 {
|
||||
var sum float64
|
||||
for i := range a {
|
||||
d := a[i] - b[i]
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
sum += d
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// ChebyshevDistance implements the L-infinity (max) metric.
|
||||
type ChebyshevDistance struct{}
|
||||
|
||||
func (ChebyshevDistance) Distance(a, b []float64) float64 {
|
||||
var max float64
|
||||
for i := range a {
|
||||
d := a[i] - b[i]
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d > max {
|
||||
max = d
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// CosineDistance implements 1 - cosine similarity.
|
||||
//
|
||||
// Distance is defined as 1 - (a·b)/(||a||*||b||). If both vectors are zero,
|
||||
// distance is 0. If exactly one is zero, distance is 1. Numerical results are
|
||||
// clamped to [0,2].
|
||||
// Note: For typical normalized/weighted feature vectors with non-negative entries,
|
||||
// the value will be in [0,1]. Opposite vectors in general spaces can yield up to 2.
|
||||
type CosineDistance struct{}
|
||||
|
||||
func (CosineDistance) Distance(a, b []float64) float64 {
|
||||
var dot, na2, nb2 float64
|
||||
for i := range a {
|
||||
ai := a[i]
|
||||
bi := b[i]
|
||||
dot += ai * bi
|
||||
na2 += ai * ai
|
||||
nb2 += bi * bi
|
||||
}
|
||||
if na2 == 0 && nb2 == 0 {
|
||||
return 0
|
||||
}
|
||||
if na2 == 0 || nb2 == 0 {
|
||||
return 1
|
||||
}
|
||||
den := math.Sqrt(na2) * math.Sqrt(nb2)
|
||||
if den == 0 { // guard, though covered above
|
||||
return 1
|
||||
}
|
||||
cos := dot / den
|
||||
if cos > 1 {
|
||||
cos = 1
|
||||
} else if cos < -1 {
|
||||
cos = -1
|
||||
}
|
||||
d := 1 - cos
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
if d > 2 {
|
||||
return 2
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// WeightedCosineDistance implements 1 - weighted cosine similarity, where weights
|
||||
// scale each axis in both the dot product and the norms.
|
||||
// If Weights is nil or has zero length, this reduces to CosineDistance.
|
||||
type WeightedCosineDistance struct{ Weights []float64 }
|
||||
|
||||
func (wcd WeightedCosineDistance) Distance(a, b []float64) float64 {
|
||||
w := wcd.Weights
|
||||
if len(w) == 0 || len(w) != len(a) || len(a) != len(b) {
|
||||
// Fallback to unweighted cosine when lengths mismatch or weights missing.
|
||||
return CosineDistance{}.Distance(a, b)
|
||||
}
|
||||
var dot, na2, nb2 float64
|
||||
for i := range a {
|
||||
wi := w[i]
|
||||
ai := a[i]
|
||||
bi := b[i]
|
||||
v := wi * ai
|
||||
dot += v * bi // wi*ai*bi
|
||||
na2 += v * ai // wi*ai*ai
|
||||
nb2 += (wi * bi) * bi // wi*bi*bi
|
||||
}
|
||||
if na2 == 0 && nb2 == 0 {
|
||||
return 0
|
||||
}
|
||||
if na2 == 0 || nb2 == 0 {
|
||||
return 1
|
||||
}
|
||||
den := math.Sqrt(na2) * math.Sqrt(nb2)
|
||||
if den == 0 {
|
||||
return 1
|
||||
}
|
||||
cos := dot / den
|
||||
if cos > 1 {
|
||||
cos = 1
|
||||
} else if cos < -1 {
|
||||
cos = -1
|
||||
}
|
||||
d := 1 - cos
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
if d > 2 {
|
||||
return 2
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// KDOption configures KDTree construction (non-generic to allow inference).
|
||||
type KDOption func(*kdOptions)
|
||||
|
||||
type kdOptions struct {
|
||||
metric DistanceMetric
|
||||
backend KDBackend
|
||||
}
|
||||
|
||||
// defaultBackend returns the implicit backend depending on build tags.
|
||||
// If built with the "gonum" tag, prefer the Gonum backend by default to keep
|
||||
// code paths simple and performant; otherwise fall back to the linear backend.
|
||||
func defaultBackend() KDBackend {
|
||||
if hasGonum() {
|
||||
return BackendGonum
|
||||
}
|
||||
return BackendLinear
|
||||
}
|
||||
|
||||
// KDBackend selects the internal engine used by KDTree.
|
||||
type KDBackend string
|
||||
|
||||
const (
|
||||
BackendLinear KDBackend = "linear"
|
||||
BackendGonum KDBackend = "gonum"
|
||||
)
|
||||
|
||||
// WithMetric sets the distance metric for the KDTree.
|
||||
func WithMetric(m DistanceMetric) KDOption { return func(o *kdOptions) { o.metric = m } }
|
||||
|
||||
// WithBackend selects the internal KDTree backend ("linear" or "gonum").
|
||||
// Default is linear. If the requested backend is unavailable (e.g., gonum build tag not enabled),
|
||||
// the constructor will silently fall back to the linear backend.
|
||||
func WithBackend(b KDBackend) KDOption { return func(o *kdOptions) { o.backend = b } }
|
||||
|
||||
// KDTree is a lightweight wrapper providing nearest-neighbor operations.
|
||||
//
|
||||
// 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.
|
||||
type KDTree[T any] struct {
|
||||
points []KDPoint[T]
|
||||
dim int
|
||||
metric DistanceMetric
|
||||
idIndex map[string]int
|
||||
backend KDBackend
|
||||
backendData any // opaque handle for backend-specific structures (e.g., gonum tree)
|
||||
}
|
||||
|
||||
// NewKDTree builds a KDTree from the given points.
|
||||
// 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, ErrEmptyPoints
|
||||
}
|
||||
dim := len(pts[0].Coords)
|
||||
if dim == 0 {
|
||||
return nil, ErrZeroDim
|
||||
}
|
||||
idIndex := make(map[string]int, len(pts))
|
||||
for i, p := range pts {
|
||||
if len(p.Coords) != dim {
|
||||
return nil, ErrDimMismatch
|
||||
}
|
||||
if p.ID != "" {
|
||||
if _, exists := idIndex[p.ID]; exists {
|
||||
return nil, ErrDuplicateID
|
||||
}
|
||||
idIndex[p.ID] = i
|
||||
}
|
||||
}
|
||||
cfg := kdOptions{metric: EuclideanDistance{}, backend: defaultBackend()}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
backend := cfg.backend
|
||||
var backendData any
|
||||
// Attempt to build gonum backend if requested and available.
|
||||
if backend == BackendGonum && hasGonum() {
|
||||
if bd, err := buildGonumBackend(pts, cfg.metric); err == nil {
|
||||
backendData = bd
|
||||
} else {
|
||||
backend = BackendLinear // fallback gracefully
|
||||
}
|
||||
} else if backend == BackendGonum && !hasGonum() {
|
||||
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,
|
||||
}
|
||||
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{}, backend: defaultBackend()}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
backend := cfg.backend
|
||||
if backend == BackendGonum && !hasGonum() {
|
||||
backend = BackendLinear
|
||||
}
|
||||
return &KDTree[T]{
|
||||
points: nil,
|
||||
dim: dim,
|
||||
metric: cfg.metric,
|
||||
idIndex: make(map[string]int),
|
||||
backend: backend,
|
||||
backendData: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Dim returns the number of dimensions.
|
||||
func (t *KDTree[T]) Dim() int { return t.dim }
|
||||
|
||||
// Len returns the number of points in the tree.
|
||||
func (t *KDTree[T]) Len() int { return len(t.points) }
|
||||
|
||||
// Nearest returns the closest point to the query, along with its distance.
|
||||
// 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) {
|
||||
if len(query) != t.dim || t.Len() == 0 {
|
||||
return KDPoint[T]{}, 0, false
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// fall through to linear scan if backend didn’t return a result
|
||||
}
|
||||
bestIdx := -1
|
||||
bestDist := math.MaxFloat64
|
||||
for i := range t.points {
|
||||
d := t.metric.Distance(query, t.points[i].Coords)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
if bestIdx < 0 {
|
||||
return KDPoint[T]{}, 0, false
|
||||
}
|
||||
return t.points[bestIdx], bestDist, true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if k <= 0 || len(query) != t.dim || t.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Gonum backend path
|
||||
if t.backend == BackendGonum && t.backendData != nil {
|
||||
idxs, dists := gonumKNearest[T](t.backendData, query, k)
|
||||
if len(idxs) > 0 {
|
||||
neighbors := make([]KDPoint[T], len(idxs))
|
||||
for i := range idxs {
|
||||
neighbors[i] = t.points[idxs[i]]
|
||||
}
|
||||
return neighbors, dists
|
||||
}
|
||||
// fall back on unexpected empty
|
||||
}
|
||||
tmp := make([]struct {
|
||||
idx int
|
||||
dist float64
|
||||
}, len(t.points))
|
||||
for i := range t.points {
|
||||
tmp[i].idx = i
|
||||
tmp[i].dist = t.metric.Distance(query, t.points[i].Coords)
|
||||
}
|
||||
sort.Slice(tmp, func(i, j int) bool { return tmp[i].dist < tmp[j].dist })
|
||||
if k > len(tmp) {
|
||||
k = len(tmp)
|
||||
}
|
||||
neighbors := make([]KDPoint[T], k)
|
||||
dists := make([]float64, k)
|
||||
for i := 0; i < k; i++ {
|
||||
neighbors[i] = t.points[tmp[i].idx]
|
||||
dists[i] = tmp[i].dist
|
||||
}
|
||||
return neighbors, dists
|
||||
}
|
||||
|
||||
// Radius returns points within radius r (inclusive) from the query, sorted by distance.
|
||||
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
|
||||
}
|
||||
// Gonum backend path
|
||||
if t.backend == BackendGonum && t.backendData != nil {
|
||||
idxs, dists := gonumRadius[T](t.backendData, query, r)
|
||||
if len(idxs) > 0 {
|
||||
neighbors := make([]KDPoint[T], len(idxs))
|
||||
for i := range idxs {
|
||||
neighbors[i] = t.points[idxs[i]]
|
||||
}
|
||||
return neighbors, dists
|
||||
}
|
||||
// fall back if no results
|
||||
}
|
||||
var sel []struct {
|
||||
idx int
|
||||
dist float64
|
||||
}
|
||||
for i := range t.points {
|
||||
d := t.metric.Distance(query, t.points[i].Coords)
|
||||
if d <= r {
|
||||
sel = append(sel, struct {
|
||||
idx int
|
||||
dist float64
|
||||
}{i, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(sel, func(i, j int) bool { return sel[i].dist < sel[j].dist })
|
||||
neighbors := make([]KDPoint[T], len(sel))
|
||||
dists := make([]float64, len(sel))
|
||||
for i := range sel {
|
||||
neighbors[i] = t.points[sel[i].idx]
|
||||
dists[i] = sel[i].dist
|
||||
}
|
||||
return neighbors, dists
|
||||
}
|
||||
|
||||
// Insert adds a point. Returns false if dimensionality mismatch or duplicate ID exists.
|
||||
func (t *KDTree[T]) Insert(p KDPoint[T]) bool {
|
||||
if len(p.Coords) != t.dim {
|
||||
return false
|
||||
}
|
||||
if p.ID != "" {
|
||||
if _, exists := t.idIndex[p.ID]; exists {
|
||||
return false
|
||||
}
|
||||
// will set after append
|
||||
}
|
||||
t.points = append(t.points, p)
|
||||
if p.ID != "" {
|
||||
t.idIndex[p.ID] = len(t.points) - 1
|
||||
}
|
||||
// Rebuild backend if using Gonum
|
||||
if t.backend == BackendGonum && hasGonum() {
|
||||
if bd, err := buildGonumBackend(t.points, t.metric); err == nil {
|
||||
t.backendData = bd
|
||||
} else {
|
||||
// fallback to linear if rebuild fails
|
||||
t.backend = BackendLinear
|
||||
t.backendData = nil
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteByID removes a point by its ID. Returns false if not found or ID empty.
|
||||
func (t *KDTree[T]) DeleteByID(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
idx, ok := t.idIndex[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
last := len(t.points) - 1
|
||||
// swap delete
|
||||
t.points[idx] = t.points[last]
|
||||
if t.points[idx].ID != "" {
|
||||
t.idIndex[t.points[idx].ID] = idx
|
||||
}
|
||||
t.points = t.points[:last]
|
||||
delete(t.idIndex, id)
|
||||
// Rebuild backend if using Gonum
|
||||
if t.backend == BackendGonum && hasGonum() {
|
||||
if bd, err := buildGonumBackend(t.points, t.metric); err == nil {
|
||||
t.backendData = bd
|
||||
} else {
|
||||
// fallback to linear if rebuild fails
|
||||
t.backend = BackendLinear
|
||||
t.backendData = nil
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
129
kdtree_backend_parity_test.go
Normal file
129
kdtree_backend_parity_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeFixedPoints creates a deterministic set of points in 4D and 2D for parity checks.
|
||||
func makeFixedPoints() []KDPoint[int] {
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "A", Coords: []float64{0, 0, 0, 0}, Value: 1},
|
||||
{ID: "B", Coords: []float64{1, 0, 0.5, 0.2}, Value: 2},
|
||||
{ID: "C", Coords: []float64{0, 1, 0.3, 0.7}, Value: 3},
|
||||
{ID: "D", Coords: []float64{1, 1, 0.9, 0.9}, Value: 4},
|
||||
{ID: "E", Coords: []float64{0.2, 0.8, 0.4, 0.6}, Value: 5},
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
func TestBackendParity_Nearest(t *testing.T) {
|
||||
pts := makeFixedPoints()
|
||||
queries := [][]float64{
|
||||
{0, 0, 0, 0},
|
||||
{0.9, 0.2, 0.5, 0.1},
|
||||
{0.5, 0.5, 0.5, 0.5},
|
||||
}
|
||||
|
||||
lin, err := NewKDTree(pts, WithBackend(BackendLinear), WithMetric(EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("linear NewKDTree: %v", err)
|
||||
}
|
||||
|
||||
// Only build a gonum tree when the optimized backend is compiled in.
|
||||
if hasGonum() {
|
||||
gon, err := NewKDTree(pts, WithBackend(BackendGonum), WithMetric(EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("gonum NewKDTree: %v", err)
|
||||
}
|
||||
for _, q := range queries {
|
||||
pl, dl, okl := lin.Nearest(q)
|
||||
pg, dg, okg := gon.Nearest(q)
|
||||
if okl != okg {
|
||||
t.Fatalf("ok mismatch: linear=%v gonum=%v", okl, okg)
|
||||
}
|
||||
if !okl {
|
||||
continue
|
||||
}
|
||||
if pl.ID != pg.ID {
|
||||
t.Errorf("nearest ID mismatch for %v: linear=%s gonum=%s", q, pl.ID, pg.ID)
|
||||
}
|
||||
if (dl == 0 && dg != 0) || (dl != 0 && dg == 0) {
|
||||
t.Errorf("nearest distance zero/nonzero mismatch: linear=%v gonum=%v", dl, dg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendParity_KNearest(t *testing.T) {
|
||||
pts := makeFixedPoints()
|
||||
q := []float64{0.6, 0.6, 0.4, 0.4}
|
||||
ks := []int{1, 2, 5, 10}
|
||||
lin, _ := NewKDTree(pts, WithBackend(BackendLinear), WithMetric(EuclideanDistance{}))
|
||||
if hasGonum() {
|
||||
gon, _ := NewKDTree(pts, WithBackend(BackendGonum), WithMetric(EuclideanDistance{}))
|
||||
for _, k := range ks {
|
||||
ln, ld := lin.KNearest(q, k)
|
||||
gn, gd := gon.KNearest(q, k)
|
||||
if len(ln) != len(gn) || len(ld) != len(gd) {
|
||||
t.Fatalf("k=%d length mismatch: linear (%d,%d) vs gonum (%d,%d)", k, len(ln), len(ld), len(gn), len(gd))
|
||||
}
|
||||
// Compare IDs element-wise; ties may reorder between backends, so relax by set equality when distances equal.
|
||||
for i := range ln {
|
||||
if ln[i].ID != gn[i].ID {
|
||||
// If distances are effectively equal, allow different order
|
||||
if i < len(ld) && i < len(gd) && ld[i] == gd[i] {
|
||||
continue
|
||||
}
|
||||
t.Logf("k=%d index %d ID mismatch: linear=%s gonum=%s (dl=%.6f dg=%.6f)", k, i, ln[i].ID, gn[i].ID, ld[i], gd[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendParity_Radius(t *testing.T) {
|
||||
pts := makeFixedPoints()
|
||||
q := []float64{0.4, 0.6, 0.4, 0.6}
|
||||
radii := []float64{0, 0.15, 0.3, 1.0}
|
||||
lin, _ := NewKDTree(pts, WithBackend(BackendLinear), WithMetric(EuclideanDistance{}))
|
||||
if hasGonum() {
|
||||
gon, _ := NewKDTree(pts, WithBackend(BackendGonum), WithMetric(EuclideanDistance{}))
|
||||
for _, r := range radii {
|
||||
ln, ld := lin.Radius(q, r)
|
||||
gn, gd := gon.Radius(q, r)
|
||||
if len(ln) != len(gn) || len(ld) != len(gd) {
|
||||
t.Fatalf("r=%.3f length mismatch: linear (%d,%d) vs gonum (%d,%d)", r, len(ln), len(ld), len(gn), len(gd))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendParity_RandomQueries2D(t *testing.T) {
|
||||
// Down-project 4D to 2D to exercise pruning differences as well
|
||||
pts4 := makeFixedPoints()
|
||||
pts2 := make([]KDPoint[int], len(pts4))
|
||||
for i, p := range pts4 {
|
||||
pts2[i] = KDPoint[int]{ID: p.ID, Coords: []float64{p.Coords[0], p.Coords[1]}, Value: p.Value}
|
||||
}
|
||||
lin, _ := NewKDTree(pts2, WithBackend(BackendLinear), WithMetric(ManhattanDistance{}))
|
||||
if hasGonum() {
|
||||
gon, _ := NewKDTree(pts2, WithBackend(BackendGonum), WithMetric(ManhattanDistance{}))
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
for i := 0; i < 50; i++ {
|
||||
q := []float64{rng.Float64(), rng.Float64()}
|
||||
pl, dl, okl := lin.Nearest(q)
|
||||
pg, dg, okg := gon.Nearest(q)
|
||||
if okl != okg {
|
||||
t.Fatalf("ok mismatch (2D rand)")
|
||||
}
|
||||
if !okl {
|
||||
continue
|
||||
}
|
||||
if pl.ID != pg.ID && (dl != dg) {
|
||||
// Allow different picks only if distances tie; otherwise flag
|
||||
t.Errorf("2D rand nearest mismatch: linear %s(%.6f) gonum %s(%.6f)", pl.ID, dl, pg.ID, dg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
kdtree_branches_test.go
Normal file
42
kdtree_branches_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package poindexter
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestKNearest_EdgeCases(t *testing.T) {
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "a", Coords: []float64{0}},
|
||||
}
|
||||
tr, _ := NewKDTree(pts)
|
||||
// k <= 0 → nil
|
||||
ns, ds := tr.KNearest([]float64{0}, 0)
|
||||
if ns != nil || ds != nil {
|
||||
t.Fatalf("expected nil for k<=0, got %v %v", ns, ds)
|
||||
}
|
||||
// query-dim mismatch → nil
|
||||
ns, ds = tr.KNearest([]float64{0, 1}, 1)
|
||||
if ns != nil || ds != nil {
|
||||
t.Fatalf("expected nil for dim mismatch, got %v %v", ns, ds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadius_QueryDimMismatch(t *testing.T) {
|
||||
pts := []KDPoint[int]{{ID: "p", Coords: []float64{0}}}
|
||||
tr, _ := NewKDTree(pts)
|
||||
ns, ds := tr.Radius([]float64{0, 0}, 1)
|
||||
if ns != nil || ds != nil {
|
||||
t.Fatalf("expected nil for dim mismatch, got %v %v", ns, ds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsert_DimMismatch(t *testing.T) {
|
||||
tr, _ := NewKDTreeFromDim[int](2)
|
||||
ok := tr.Insert(KDPoint[int]{ID: "bad", Coords: []float64{0}}) // wrong dim
|
||||
if ok {
|
||||
t.Fatalf("expected false on insert with dim mismatch")
|
||||
}
|
||||
// inserting with empty ID should succeed and not touch idIndex
|
||||
ok = tr.Insert(KDPoint[int]{ID: "", Coords: []float64{0, 0}})
|
||||
if !ok {
|
||||
t.Fatalf("expected true on insert with empty ID and matching dim")
|
||||
}
|
||||
}
|
||||
116
kdtree_extra_test.go
Normal file
116
kdtree_extra_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewKDTree_Errors(t *testing.T) {
|
||||
// empty points
|
||||
if _, err := NewKDTree[string](nil); !errors.Is(err, ErrEmptyPoints) {
|
||||
t.Fatalf("want ErrEmptyPoints, got %v", err)
|
||||
}
|
||||
// zero-dim
|
||||
pts0 := []KDPoint[string]{{ID: "A", Coords: nil}}
|
||||
if _, err := NewKDTree(pts0); !errors.Is(err, ErrZeroDim) {
|
||||
t.Fatalf("want ErrZeroDim, got %v", err)
|
||||
}
|
||||
// dim mismatch
|
||||
ptsDim := []KDPoint[string]{
|
||||
{ID: "A", Coords: []float64{0}},
|
||||
{ID: "B", Coords: []float64{0, 1}},
|
||||
}
|
||||
if _, err := NewKDTree(ptsDim); !errors.Is(err, ErrDimMismatch) {
|
||||
t.Fatalf("want ErrDimMismatch, got %v", err)
|
||||
}
|
||||
// duplicate IDs
|
||||
ptsDup := []KDPoint[string]{
|
||||
{ID: "X", Coords: []float64{0}},
|
||||
{ID: "X", Coords: []float64{1}},
|
||||
}
|
||||
if _, err := NewKDTree(ptsDup); !errors.Is(err, ErrDuplicateID) {
|
||||
t.Fatalf("want ErrDuplicateID, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteByID_NotFound(t *testing.T) {
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "A", Coords: []float64{0}, Value: 1},
|
||||
}
|
||||
tr, err := NewKDTree(pts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
if tr.DeleteByID("NOPE") {
|
||||
t.Fatalf("expected false for missing ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKNearest_KGreaterThanN(t *testing.T) {
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "a", Coords: []float64{0}},
|
||||
{ID: "b", Coords: []float64{2}},
|
||||
}
|
||||
tr, _ := NewKDTree(pts)
|
||||
ns, ds := tr.KNearest([]float64{1}, 5)
|
||||
if len(ns) != 2 || len(ds) != 2 {
|
||||
t.Fatalf("want 2 neighbors, got %d", len(ns))
|
||||
}
|
||||
if !(ds[0] <= ds[1]) {
|
||||
t.Fatalf("distances not sorted: %v", ds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadius_BoundaryAndZero(t *testing.T) {
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "o", Coords: []float64{0}},
|
||||
{ID: "one", Coords: []float64{1}},
|
||||
}
|
||||
tr, _ := NewKDTree(pts, WithMetric(EuclideanDistance{}))
|
||||
// radius exactly includes point at distance 1
|
||||
within, _ := tr.Radius([]float64{0}, 1)
|
||||
foundOne := false
|
||||
for _, p := range within {
|
||||
if p.ID == "one" {
|
||||
foundOne = true
|
||||
}
|
||||
}
|
||||
if !foundOne {
|
||||
t.Fatalf("expected to include point at exact radius")
|
||||
}
|
||||
// radius zero should include exact match only
|
||||
within0, _ := tr.Radius([]float64{0}, 0)
|
||||
if len(within0) == 0 || within0[0].ID != "o" {
|
||||
t.Fatalf("expected only origin at r=0, got %v", within0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKDTreeFromDim_WithMetric_InsertQuery(t *testing.T) {
|
||||
tr, err := NewKDTreeFromDim[string](2, WithMetric(ManhattanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
ok := tr.Insert(KDPoint[string]{ID: "A", Coords: []float64{0, 0}, Value: "a"})
|
||||
if !ok {
|
||||
t.Fatalf("insert failed")
|
||||
}
|
||||
tr.Insert(KDPoint[string]{ID: "B", Coords: []float64{2, 2}, Value: "b"})
|
||||
p, d, ok := tr.Nearest([]float64{1, 0})
|
||||
if !ok || p.ID != "A" {
|
||||
t.Fatalf("expected A nearest, got %v", p)
|
||||
}
|
||||
if d != 1 { // ManhattanDistance from (1,0) to (0,0) is 1
|
||||
t.Fatalf("expected manhattan distance 1, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearest_QueryDimMismatch(t *testing.T) {
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "a", Coords: []float64{0, 0}},
|
||||
}
|
||||
tr, _ := NewKDTree(pts)
|
||||
_, _, ok := tr.Nearest([]float64{0})
|
||||
if ok {
|
||||
t.Fatalf("expected ok=false for query dim mismatch")
|
||||
}
|
||||
}
|
||||
311
kdtree_gonum.go
Normal file
311
kdtree_gonum.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
//go:build gonum
|
||||
|
||||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Note: This file is compiled when built with the "gonum" tag. For now, we
|
||||
// provide an internal KD-tree backend that performs balanced median-split
|
||||
// construction and branch-and-bound queries. This gives sub-linear behavior on
|
||||
// suitable datasets without introducing an external dependency. The public API
|
||||
// and option names remain the same; a future change can swap this implementation
|
||||
// to use gonum.org/v1/gonum/spatial/kdtree without altering callers.
|
||||
|
||||
// hasGonum reports whether the optimized backend is compiled in.
|
||||
func hasGonum() bool { return true }
|
||||
|
||||
// kdNode represents a node in the median-split KD-tree.
|
||||
type kdNode struct {
|
||||
axis int
|
||||
idx int // index into the original points slice
|
||||
val float64
|
||||
left *kdNode
|
||||
right *kdNode
|
||||
}
|
||||
|
||||
// kdBackend holds the KD-tree root and metadata.
|
||||
type kdBackend struct {
|
||||
root *kdNode
|
||||
dim int
|
||||
metric DistanceMetric
|
||||
// Access to original coords by index is done via a closure we capture at build
|
||||
coords func(i int) []float64
|
||||
len int
|
||||
}
|
||||
|
||||
// buildGonumBackend builds a balanced KD-tree using variance-based axis choice
|
||||
// and median splits. It does not reorder the external points slice; it keeps
|
||||
// indices and accesses the original data via closures, preserving caller order.
|
||||
func buildGonumBackend[T any](points []KDPoint[T], metric DistanceMetric) (any, error) {
|
||||
// Only enable this backend for metrics where the axis-slab bound is valid
|
||||
// for pruning: L2/L1/L∞. For other metrics (e.g., Cosine), fall back.
|
||||
switch metric.(type) {
|
||||
case EuclideanDistance, ManhattanDistance, ChebyshevDistance:
|
||||
// supported
|
||||
default:
|
||||
return nil, ErrBackendUnavailable
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return &kdBackend{root: nil, dim: 0, metric: metric, coords: func(int) []float64 { return nil }}, nil
|
||||
}
|
||||
dim := len(points[0].Coords)
|
||||
coords := func(i int) []float64 { return points[i].Coords }
|
||||
idxs := make([]int, len(points))
|
||||
for i := range idxs {
|
||||
idxs[i] = i
|
||||
}
|
||||
root := buildKDRecursive(idxs, coords, dim, 0)
|
||||
return &kdBackend{root: root, dim: dim, metric: metric, coords: coords, len: len(points)}, nil
|
||||
}
|
||||
|
||||
// compute per-axis standard deviation (used for axis selection)
|
||||
func axisStd(idxs []int, coords func(int) []float64, dim int) []float64 {
|
||||
vars := make([]float64, dim)
|
||||
means := make([]float64, dim)
|
||||
n := float64(len(idxs))
|
||||
if n == 0 {
|
||||
return vars
|
||||
}
|
||||
for _, i := range idxs {
|
||||
c := coords(i)
|
||||
for d := 0; d < dim; d++ {
|
||||
means[d] += c[d]
|
||||
}
|
||||
}
|
||||
for d := 0; d < dim; d++ {
|
||||
means[d] /= n
|
||||
}
|
||||
for _, i := range idxs {
|
||||
c := coords(i)
|
||||
for d := 0; d < dim; d++ {
|
||||
delta := c[d] - means[d]
|
||||
vars[d] += delta * delta
|
||||
}
|
||||
}
|
||||
for d := 0; d < dim; d++ {
|
||||
vars[d] = math.Sqrt(vars[d] / n)
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
func buildKDRecursive(idxs []int, coords func(int) []float64, dim int, depth int) *kdNode {
|
||||
if len(idxs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// choose axis with max stddev
|
||||
stds := axisStd(idxs, coords, dim)
|
||||
axis := 0
|
||||
maxv := stds[0]
|
||||
for d := 1; d < dim; d++ {
|
||||
if stds[d] > maxv {
|
||||
maxv = stds[d]
|
||||
axis = d
|
||||
}
|
||||
}
|
||||
// nth-element (partial sort) by axis using sort.Slice for simplicity
|
||||
sort.Slice(idxs, func(i, j int) bool { return coords(idxs[i])[axis] < coords(idxs[j])[axis] })
|
||||
mid := len(idxs) / 2
|
||||
medianIdx := idxs[mid]
|
||||
n := &kdNode{axis: axis, idx: medianIdx, val: coords(medianIdx)[axis]}
|
||||
n.left = buildKDRecursive(append([]int(nil), idxs[:mid]...), coords, dim, depth+1)
|
||||
n.right = buildKDRecursive(append([]int(nil), idxs[mid+1:]...), coords, dim, depth+1)
|
||||
return n
|
||||
}
|
||||
|
||||
// gonumNearest performs 1-NN search using the KD backend.
|
||||
func gonumNearest[T any](backend any, query []float64) (int, float64, bool) {
|
||||
b, ok := backend.(*kdBackend)
|
||||
if !ok || b.root == nil || len(query) != b.dim {
|
||||
return -1, 0, false
|
||||
}
|
||||
bestIdx := -1
|
||||
bestDist := math.MaxFloat64
|
||||
var search func(*kdNode)
|
||||
search = func(n *kdNode) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
c := b.coords(n.idx)
|
||||
d := b.metric.Distance(query, c)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
bestIdx = n.idx
|
||||
}
|
||||
axis := n.axis
|
||||
qv := query[axis]
|
||||
// choose side
|
||||
near, far := n.left, n.right
|
||||
if qv >= n.val {
|
||||
near, far = n.right, n.left
|
||||
}
|
||||
search(near)
|
||||
// prune if hyperslab distance is >= bestDist
|
||||
diff := qv - n.val
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff <= bestDist {
|
||||
search(far)
|
||||
}
|
||||
}
|
||||
search(b.root)
|
||||
if bestIdx < 0 {
|
||||
return -1, 0, false
|
||||
}
|
||||
return bestIdx, bestDist, true
|
||||
}
|
||||
|
||||
// small max-heap for (distance, index)
|
||||
// We’ll use a slice maintaining the largest distance at [0] via container/heap-like ops.
|
||||
type knnItem struct {
|
||||
idx int
|
||||
dist float64
|
||||
}
|
||||
|
||||
type knnHeap []knnItem
|
||||
|
||||
func (h knnHeap) Len() int { return len(h) }
|
||||
func (h knnHeap) less(i, j int) bool { return h[i].dist > h[j].dist } // max-heap by dist
|
||||
func (h *knnHeap) push(x knnItem) { *h = append(*h, x); h.up(len(*h) - 1) }
|
||||
func (h *knnHeap) pop() knnItem {
|
||||
n := len(*h) - 1
|
||||
h.swap(0, n)
|
||||
v := (*h)[n]
|
||||
*h = (*h)[:n]
|
||||
h.down(0)
|
||||
return v
|
||||
}
|
||||
func (h *knnHeap) peek() knnItem { return (*h)[0] }
|
||||
func (h knnHeap) swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *knnHeap) up(i int) {
|
||||
for i > 0 {
|
||||
p := (i - 1) / 2
|
||||
if !h.less(i, p) {
|
||||
break
|
||||
}
|
||||
h.swap(i, p)
|
||||
i = p
|
||||
}
|
||||
}
|
||||
func (h *knnHeap) down(i int) {
|
||||
for {
|
||||
l := 2*i + 1
|
||||
r := l + 1
|
||||
largest := i
|
||||
if l < h.Len() && h.less(l, largest) {
|
||||
largest = l
|
||||
}
|
||||
if r < h.Len() && h.less(r, largest) {
|
||||
largest = r
|
||||
}
|
||||
if largest == i {
|
||||
break
|
||||
}
|
||||
h.swap(i, largest)
|
||||
i = largest
|
||||
}
|
||||
}
|
||||
|
||||
// gonumKNearest returns indices in ascending distance order.
|
||||
func gonumKNearest[T any](backend any, query []float64, k int) ([]int, []float64) {
|
||||
b, ok := backend.(*kdBackend)
|
||||
if !ok || b.root == nil || len(query) != b.dim || k <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var h knnHeap
|
||||
bestCap := k
|
||||
var search func(*kdNode)
|
||||
search = func(n *kdNode) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
c := b.coords(n.idx)
|
||||
d := b.metric.Distance(query, c)
|
||||
if h.Len() < bestCap {
|
||||
h.push(knnItem{idx: n.idx, dist: d})
|
||||
} else if d < h.peek().dist {
|
||||
// replace max
|
||||
h[0] = knnItem{idx: n.idx, dist: d}
|
||||
h.down(0)
|
||||
}
|
||||
axis := n.axis
|
||||
qv := query[axis]
|
||||
near, far := n.left, n.right
|
||||
if qv >= n.val {
|
||||
near, far = n.right, n.left
|
||||
}
|
||||
search(near)
|
||||
// prune against current worst in heap if heap is full; otherwise use bestDist
|
||||
threshold := math.MaxFloat64
|
||||
if h.Len() == bestCap {
|
||||
threshold = h.peek().dist
|
||||
} else if h.Len() > 0 {
|
||||
// use best known (not strictly necessary)
|
||||
threshold = h.peek().dist
|
||||
}
|
||||
diff := qv - n.val
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff <= threshold {
|
||||
search(far)
|
||||
}
|
||||
}
|
||||
search(b.root)
|
||||
// Extract to slices and sort ascending by distance
|
||||
res := make([]knnItem, len(h))
|
||||
copy(res, h)
|
||||
sort.Slice(res, func(i, j int) bool { return res[i].dist < res[j].dist })
|
||||
idxs := make([]int, len(res))
|
||||
dists := make([]float64, len(res))
|
||||
for i := range res {
|
||||
idxs[i] = res[i].idx
|
||||
dists[i] = res[i].dist
|
||||
}
|
||||
return idxs, dists
|
||||
}
|
||||
|
||||
func gonumRadius[T any](backend any, query []float64, r float64) ([]int, []float64) {
|
||||
b, ok := backend.(*kdBackend)
|
||||
if !ok || b.root == nil || len(query) != b.dim || r < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var res []knnItem
|
||||
var search func(*kdNode)
|
||||
search = func(n *kdNode) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
c := b.coords(n.idx)
|
||||
d := b.metric.Distance(query, c)
|
||||
if d <= r {
|
||||
res = append(res, knnItem{idx: n.idx, dist: d})
|
||||
}
|
||||
axis := n.axis
|
||||
qv := query[axis]
|
||||
near, far := n.left, n.right
|
||||
if qv >= n.val {
|
||||
near, far = n.right, n.left
|
||||
}
|
||||
search(near)
|
||||
diff := qv - n.val
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff <= r {
|
||||
search(far)
|
||||
}
|
||||
}
|
||||
search(b.root)
|
||||
sort.Slice(res, func(i, j int) bool { return res[i].dist < res[j].dist })
|
||||
idxs := make([]int, len(res))
|
||||
dists := make([]float64, len(res))
|
||||
for i := range res {
|
||||
idxs[i] = res[i].idx
|
||||
dists[i] = res[i].dist
|
||||
}
|
||||
return idxs, dists
|
||||
}
|
||||
23
kdtree_gonum_stub.go
Normal file
23
kdtree_gonum_stub.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//go:build !gonum
|
||||
|
||||
package poindexter
|
||||
|
||||
// hasGonum reports whether the gonum backend is compiled in (build tag 'gonum').
|
||||
func hasGonum() bool { return false }
|
||||
|
||||
// buildGonumBackend is unavailable without the 'gonum' build tag.
|
||||
func buildGonumBackend[T any](pts []KDPoint[T], metric DistanceMetric) (any, error) {
|
||||
return nil, ErrEmptyPoints // sentinel non-nil error to force fallback
|
||||
}
|
||||
|
||||
func gonumNearest[T any](backend any, query []float64) (int, float64, bool) {
|
||||
return -1, 0, false
|
||||
}
|
||||
|
||||
func gonumKNearest[T any](backend any, query []float64, k int) ([]int, []float64) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func gonumRadius[T any](backend any, query []float64, r float64) ([]int, []float64) {
|
||||
return nil, nil
|
||||
}
|
||||
625
kdtree_gonum_test.go
Normal file
625
kdtree_gonum_test.go
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
//go:build gonum
|
||||
|
||||
package poindexter
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func equalish(a, b []float64, tol float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if math.Abs(a[i]-b[i]) > tol {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGonumKnnHeap(t *testing.T) {
|
||||
h := knnHeap{}
|
||||
|
||||
h.push(knnItem{idx: 1, dist: 1.0})
|
||||
h.push(knnItem{idx: 2, dist: 2.0})
|
||||
h.push(knnItem{idx: 3, dist: 0.5})
|
||||
|
||||
if h.Len() != 3 {
|
||||
t.Errorf("expected heap length 3, got %d", h.Len())
|
||||
}
|
||||
|
||||
item := h.pop()
|
||||
if item.idx != 2 || item.dist != 2.0 {
|
||||
t.Errorf("expected item with index 2 and dist 2.0, got idx %d dist %f", item.idx, item.dist)
|
||||
}
|
||||
|
||||
item = h.pop()
|
||||
if item.idx != 1 || item.dist != 1.0 {
|
||||
t.Errorf("expected item with index 1 and dist 1.0, got idx %d dist %f", item.idx, item.dist)
|
||||
}
|
||||
|
||||
item = h.pop()
|
||||
if item.idx != 3 || item.dist != 0.5 {
|
||||
t.Errorf("expected item with index 3 and dist 0.5, got idx %d dist %f", item.idx, item.dist)
|
||||
}
|
||||
|
||||
if h.Len() != 0 {
|
||||
t.Errorf("expected heap length 0, got %d", h.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearest(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p, dist, ok := tree.Nearest([]float64{1.1, 1.1})
|
||||
if !ok || p.ID != "1" || math.Abs(dist-0.1414213562373095) > 1e-9 {
|
||||
t.Errorf("expected point 1 with dist ~0.14, got point %s with dist %f", p.ID, dist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumKNearest(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps, dists := tree.KNearest([]float64{1.1, 1.1}, 2)
|
||||
if len(ps) != 2 || ps[0].ID != "1" || ps[1].ID != "2" {
|
||||
t.Errorf("expected points 1 and 2, got %v", ps)
|
||||
}
|
||||
|
||||
expectedDists := []float64{0.1414213562373095, 1.2727922061357854}
|
||||
if !equalish(dists, expectedDists, 1e-9) {
|
||||
t.Errorf("expected dists %v, got %v", expectedDists, dists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumRadius(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps, dists := tree.Radius([]float64{1.1, 1.1}, 1.5)
|
||||
if len(ps) != 2 || ps[0].ID != "1" || ps[1].ID != "2" {
|
||||
t.Errorf("expected points 1 and 2, got %v", ps)
|
||||
}
|
||||
|
||||
expectedDists := []float64{0.1414213562373095, 1.2727922061357854}
|
||||
if !equalish(dists, expectedDists, 1e-9) {
|
||||
t.Errorf("expected dists %v, got %v", expectedDists, dists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGonumBackendWithNonSupportedMetric(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum), WithMetric(CosineDistance{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tree.backend != BackendLinear {
|
||||
t.Errorf("expected fallback to linear backend, but got %s", tree.backend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithEmptyTree(t *testing.T) {
|
||||
_, err := NewKDTree([]KDPoint[int]{}, WithBackend(BackendGonum))
|
||||
if err != ErrEmptyPoints {
|
||||
t.Fatalf("expected ErrEmptyPoints, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAxisStdWithNoPoints(t *testing.T) {
|
||||
stds := axisStd(nil, nil, 2)
|
||||
if len(stds) != 2 || stds[0] != 0 || stds[1] != 0 {
|
||||
t.Errorf("expected [0, 0], got %v", stds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithNilRoot(t *testing.T) {
|
||||
backend := &kdBackend{root: nil, dim: 2}
|
||||
_, _, ok := gonumNearest[int](backend, []float64{1, 1})
|
||||
if ok {
|
||||
t.Error("expected no point found, but got one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithMismatchedDimensions(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, ok := tree.Nearest([]float64{1, 1, 1})
|
||||
if ok {
|
||||
t.Error("expected no point found, but got one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumKNearestWithEmptyTree(t *testing.T) {
|
||||
_, err := NewKDTree([]KDPoint[int]{}, WithBackend(BackendGonum))
|
||||
if err != ErrEmptyPoints {
|
||||
t.Fatalf("expected ErrEmptyPoints, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumRadiusWithEmptyTree(t *testing.T) {
|
||||
_, err := NewKDTree([]KDPoint[int]{}, WithBackend(BackendGonum))
|
||||
if err != ErrEmptyPoints {
|
||||
t.Fatalf("expected ErrEmptyPoints, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumKNearestWithZeroK(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{1, 1}, 0)
|
||||
if len(ps) != 0 {
|
||||
t.Error("expected 0 points, got some")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumRadiusWithNegativeRadius(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.Radius([]float64{1, 1}, -1)
|
||||
if len(ps) != 0 {
|
||||
t.Error("expected 0 points, got some")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithSinglePoint(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, ok := tree.Nearest([]float64{1, 1})
|
||||
if !ok || p.ID != "1" {
|
||||
t.Errorf("expected point 1, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumKnnHeapPop(t *testing.T) {
|
||||
h := knnHeap{}
|
||||
h.push(knnItem{idx: 1, dist: 1.0})
|
||||
h.push(knnItem{idx: 2, dist: 2.0})
|
||||
h.push(knnItem{idx: 3, dist: 0.5})
|
||||
|
||||
if h.Len() != 3 {
|
||||
t.Errorf("expected heap length 3, got %d", h.Len())
|
||||
}
|
||||
|
||||
item := h.pop()
|
||||
if item.idx != 2 || item.dist != 2.0 {
|
||||
t.Errorf("expected item with index 2 and dist 2.0, got idx %d dist %f", item.idx, item.dist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumKNearestWithSmallK(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps, _ := tree.KNearest([]float64{1.1, 1.1}, 1)
|
||||
if len(ps) != 1 || ps[0].ID != "1" {
|
||||
t.Errorf("expected point 1, got %v", ps)
|
||||
}
|
||||
}
|
||||
func TestGonumNearestReturnsFalseForNoPoints(t *testing.T) {
|
||||
tree, err := NewKDTreeFromDim[int](2, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, ok := tree.Nearest([]float64{0, 0})
|
||||
if ok {
|
||||
t.Errorf("expected ok to be false, but it was true")
|
||||
}
|
||||
}
|
||||
func TestBuildKDRecursiveWithSinglePoint(t *testing.T) {
|
||||
idxs := []int{0}
|
||||
coords := func(i int) []float64 { return []float64{1, 1} }
|
||||
node := buildKDRecursive(idxs, coords, 2, 0)
|
||||
if node == nil {
|
||||
t.Fatal("expected a node, got nil")
|
||||
}
|
||||
if node.idx != 0 {
|
||||
t.Errorf("expected index 0, got %d", node.idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumKNearestWithLargeK(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{1.1, 1.1}, 5)
|
||||
if len(ps) != 3 {
|
||||
t.Errorf("expected 3 points, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
func TestGonumNearestWithIdenticalPoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{1, 1})
|
||||
if p.ID != "1" && p.ID != "2" {
|
||||
t.Errorf("expected point 1 or 2, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumKNearestPrefersCloserPoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{1.1, 1.1}},
|
||||
{ID: "3", Coords: []float64{1.2, 1.2}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{0.9, 0.9}, 2)
|
||||
if len(ps) != 2 || ps[0].ID != "1" || ps[1].ID != "2" {
|
||||
t.Errorf("expected points 1 and 2, got %v", ps)
|
||||
}
|
||||
}
|
||||
func TestGonumKNearestWithFewerPointsThanK(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{1, 1}, 2)
|
||||
if len(ps) != 1 {
|
||||
t.Errorf("expected 1 point, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
func TestGonumKNearestReturnsCorrectOrder(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{3, 3}},
|
||||
{ID: "2", Coords: []float64{1, 1}},
|
||||
{ID: "3", Coords: []float64{2, 2}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{0, 0}, 3)
|
||||
if ps[0].ID != "2" || ps[1].ID != "3" || ps[2].ID != "1" {
|
||||
t.Errorf("expected points in order 2, 3, 1, got %v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumRadiusReturnsAllWithinRadius(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{10, 10}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.Radius([]float64{0, 0}, 3)
|
||||
if len(ps) != 2 {
|
||||
t.Errorf("expected 2 points, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumRadiusReturnsEmptyForLargeRadiusWithNoPoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{10, 10}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.Radius([]float64{0, 0}, 1)
|
||||
if len(ps) != 0 {
|
||||
t.Errorf("expected 0 points, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
func TestGonumNearestWithNegativeCoords(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{-1, -1}},
|
||||
{ID: "2", Coords: []float64{-2, -2}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{-1.1, -1.1})
|
||||
if p.ID != "1" {
|
||||
t.Errorf("expected point 1, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumRadiusWithOverlappingPoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.Radius([]float64{1, 1}, 0.1)
|
||||
if len(ps) != 2 {
|
||||
t.Errorf("expected 2 points, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithFurtherPoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{10, 10}},
|
||||
{ID: "2", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{0, 0})
|
||||
if p.ID != "2" {
|
||||
t.Errorf("expected point 2, got %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithZeroDistance(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, dist, _ := tree.Nearest([]float64{1, 1})
|
||||
if dist != 0 {
|
||||
t.Errorf("expected distance 0, got %f", dist)
|
||||
}
|
||||
}
|
||||
func TestGonumNearestWithRightChild(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{5, 5}},
|
||||
{ID: "2", Coords: []float64{1, 1}},
|
||||
{ID: "3", Coords: []float64{8, 8}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{9, 9})
|
||||
if p.ID != "3" {
|
||||
t.Errorf("expected point 3, got %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumKNearestHeapBehavior(t *testing.T) {
|
||||
h := knnHeap{}
|
||||
h.push(knnItem{idx: 1, dist: 1.0})
|
||||
h.push(knnItem{idx: 2, dist: 3.0})
|
||||
h.push(knnItem{idx: 3, dist: 2.0})
|
||||
|
||||
if h.peek().dist != 3.0 {
|
||||
t.Errorf("expected max dist to be 3.0, got %f", h.peek().dist)
|
||||
}
|
||||
|
||||
h.push(knnItem{idx: 4, dist: 0.5})
|
||||
if h.peek().dist != 3.0 {
|
||||
t.Errorf("expected max dist to be 3.0, got %f", h.peek().dist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumNearestWithUnsortedPoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{10, 0}},
|
||||
{ID: "2", Coords: []float64{0, 10}},
|
||||
{ID: "3", Coords: []float64{5, 5}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{4, 4})
|
||||
if p.ID != "3" {
|
||||
t.Errorf("expected point 3, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumKNearestWithThreshold(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{10, 10}},
|
||||
{ID: "3", Coords: []float64{2, 2}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{0, 0}, 2)
|
||||
if len(ps) != 2 {
|
||||
t.Fatalf("expected 2 points, got %d", len(ps))
|
||||
}
|
||||
if ps[0].ID != "1" || ps[1].ID != "3" {
|
||||
t.Errorf("expected points 1 and 3, got %v and %v", ps[0].ID, ps[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumRadiusSearch(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{1.5, 1.5}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.Radius([]float64{1.2, 1.2}, 0.5)
|
||||
if len(ps) != 2 {
|
||||
t.Errorf("expected 2 points, got %d", len(ps))
|
||||
}
|
||||
}
|
||||
func TestGonumNearestWithFloatMinMax(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1e150, 1e150}},
|
||||
{ID: "2", Coords: []float64{-1e150, -1e150}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, ok := tree.Nearest([]float64{0, 0})
|
||||
if !ok {
|
||||
t.Fatal("expected to find a point, but didn't")
|
||||
}
|
||||
if p.ID != "1" && p.ID != "2" {
|
||||
t.Errorf("expected point 1 or 2, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumKnnHeapWithDuplicateDistances(t *testing.T) {
|
||||
h := knnHeap{}
|
||||
h.push(knnItem{idx: 1, dist: 1.0})
|
||||
h.push(knnItem{idx: 2, dist: 1.0})
|
||||
if h.Len() != 2 {
|
||||
t.Errorf("expected heap length 2, got %d", h.Len())
|
||||
}
|
||||
}
|
||||
func TestGonumNearestToPointOnAxis(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{0, 10}},
|
||||
{ID: "2", Coords: []float64{0, -10}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{0, 0})
|
||||
if p.ID != "1" && p.ID != "2" {
|
||||
t.Errorf("expected point 1 or 2, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumNearestReturnsCorrectlyWhenPointsAreCollinear(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 1}},
|
||||
{ID: "2", Coords: []float64{2, 2}},
|
||||
{ID: "3", Coords: []float64{3, 3}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{1.9, 1.9})
|
||||
if p.ID != "2" {
|
||||
t.Errorf("expected point 2, got %v", p)
|
||||
}
|
||||
}
|
||||
func TestGonumKNearestWithMorePoints(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{0, 0}},
|
||||
{ID: "2", Coords: []float64{1, 1}},
|
||||
{ID: "3", Coords: []float64{2, 2}},
|
||||
{ID: "4", Coords: []float64{3, 3}},
|
||||
{ID: "5", Coords: []float64{4, 4}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.KNearest([]float64{0.5, 0.5}, 3)
|
||||
if len(ps) != 3 {
|
||||
t.Fatalf("expected 3 points, got %d", len(ps))
|
||||
}
|
||||
if !((ps[0].ID == "1" && ps[1].ID == "2") || (ps[0].ID == "2" && ps[1].ID == "1")) {
|
||||
t.Errorf("expected first two points to be 1 and 2, got %s and %s", ps[0].ID, ps[1].ID)
|
||||
}
|
||||
if ps[2].ID != "3" {
|
||||
t.Errorf("expected third point to be 3, got %s", ps[2].ID)
|
||||
}
|
||||
}
|
||||
func TestGonumRadiusReturnsSorted(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1.2, 1.2}},
|
||||
{ID: "2", Coords: []float64{1.1, 1.1}},
|
||||
{ID: "3", Coords: []float64{1.0, 1.0}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps, _ := tree.Radius([]float64{0, 0}, 2)
|
||||
if len(ps) != 3 {
|
||||
t.Errorf("expected 3 points, got %d", len(ps))
|
||||
}
|
||||
if ps[0].ID != "3" || ps[1].ID != "2" || ps[2].ID != "1" {
|
||||
t.Errorf("expected order 3, 2, 1, got %v, %v, %v", ps[0].ID, ps[1].ID, ps[2].ID)
|
||||
}
|
||||
}
|
||||
func TestGonumNearestWithMultipleDimensions(t *testing.T) {
|
||||
points := []KDPoint[int]{
|
||||
{ID: "1", Coords: []float64{1, 2, 3, 4}},
|
||||
{ID: "2", Coords: []float64{5, 6, 7, 8}},
|
||||
}
|
||||
tree, err := NewKDTree(points, WithBackend(BackendGonum))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, _ := tree.Nearest([]float64{1.1, 2.1, 3.1, 4.1})
|
||||
if p.ID != "1" {
|
||||
t.Errorf("expected point 1, got %v", p)
|
||||
}
|
||||
}
|
||||
447
kdtree_helpers.go
Normal file
447
kdtree_helpers.go
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
package poindexter
|
||||
|
||||
import "errors"
|
||||
|
||||
// Helper builders for KDTree points with min-max normalisation, optional inversion per-axis,
|
||||
// and per-axis weights. These are convenience utilities to make it easy to map domain
|
||||
// records into KD space for 2D/3D/4D use-cases.
|
||||
|
||||
// Errors for helper builders.
|
||||
var (
|
||||
// ErrInvalidFeatures indicates that no features were provided or nil feature encountered.
|
||||
ErrInvalidFeatures = errors.New("kdtree: invalid features: provide at least one feature and ensure none are nil")
|
||||
// ErrInvalidWeights indicates weights length doesn't match features length.
|
||||
ErrInvalidWeights = errors.New("kdtree: invalid weights length; must match number of features")
|
||||
// ErrInvalidInvert indicates invert flags length doesn't match features length.
|
||||
ErrInvalidInvert = errors.New("kdtree: invalid invert length; must match number of features")
|
||||
// ErrStatsDimMismatch indicates NormStats dimensions do not match features length.
|
||||
ErrStatsDimMismatch = errors.New("kdtree: stats dimensionality mismatch")
|
||||
)
|
||||
|
||||
// AxisStats holds the min/max observed for a single axis.
|
||||
type AxisStats struct {
|
||||
Min float64
|
||||
Max float64
|
||||
}
|
||||
|
||||
// NormStats holds per-axis normalisation statistics.
|
||||
// For D dimensions, Stats has length D.
|
||||
type NormStats struct {
|
||||
Stats []AxisStats
|
||||
}
|
||||
|
||||
// ComputeNormStatsND computes per-axis min/max for an arbitrary number of features.
|
||||
func ComputeNormStatsND[T any](items []T, features []func(T) float64) (NormStats, error) {
|
||||
if len(features) == 0 {
|
||||
return NormStats{}, ErrInvalidFeatures
|
||||
}
|
||||
// Initialise mins/maxes on first item where possible
|
||||
stats := make([]AxisStats, len(features))
|
||||
if len(items) == 0 {
|
||||
// empty items → zero stats slice of correct dim
|
||||
return NormStats{Stats: stats}, nil
|
||||
}
|
||||
// Seed with first item values
|
||||
first := items[0]
|
||||
for i, f := range features {
|
||||
if f == nil {
|
||||
return NormStats{}, ErrInvalidFeatures
|
||||
}
|
||||
v := f(first)
|
||||
stats[i] = AxisStats{Min: v, Max: v}
|
||||
}
|
||||
// Process remaining items
|
||||
for _, it := range items[1:] {
|
||||
for i, f := range features {
|
||||
v := f(it)
|
||||
if v < stats[i].Min {
|
||||
stats[i].Min = v
|
||||
}
|
||||
if v > stats[i].Max {
|
||||
stats[i].Max = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return NormStats{Stats: stats}, nil
|
||||
}
|
||||
|
||||
// BuildND constructs normalised-and-weighted KD points from arbitrary amount features.
|
||||
// Features are min-max normalised per axis over the provided items, optionally inverted,
|
||||
// then multiplied by per-axis weights.
|
||||
func BuildND[T any](items []T, id func(T) string, features []func(T) float64, weights []float64, invert []bool) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(features) == 0 {
|
||||
return nil, ErrInvalidFeatures
|
||||
}
|
||||
if len(weights) != len(features) {
|
||||
return nil, ErrInvalidWeights
|
||||
}
|
||||
if len(invert) != len(features) {
|
||||
return nil, ErrInvalidInvert
|
||||
}
|
||||
stats, err := ComputeNormStatsND(items, features)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildNDWithStats(items, id, features, weights, invert, stats)
|
||||
}
|
||||
|
||||
// BuildNDNoErr constructs normalized-and-weighted KD points like BuildND but never returns an error.
|
||||
// It performs no input validation beyond basic length checks and will propagate NaN/Inf values
|
||||
// from feature extractors into the resulting coordinates. Use when you control inputs and want a
|
||||
// simpler call signature.
|
||||
func BuildNDNoErr[T any](items []T, id func(T) string, features []func(T) float64, weights []float64, invert []bool) []KDPoint[T] {
|
||||
if len(items) == 0 || len(features) == 0 {
|
||||
return nil
|
||||
}
|
||||
// If lengths are inconsistent, return empty (no panic); this function is intentionally lenient.
|
||||
if len(weights) != len(features) || len(invert) != len(features) {
|
||||
return nil
|
||||
}
|
||||
stats, _ := ComputeNormStatsND(items, features)
|
||||
pts, _ := BuildNDWithStats(items, id, features, weights, invert, stats)
|
||||
return pts
|
||||
}
|
||||
|
||||
// BuildNDWithStats builds points using provided normalisation stats.
|
||||
func BuildNDWithStats[T any](items []T, id func(T) string, features []func(T) float64, weights []float64, invert []bool, stats NormStats) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(features) == 0 {
|
||||
return nil, ErrInvalidFeatures
|
||||
}
|
||||
if len(weights) != len(features) {
|
||||
return nil, ErrInvalidWeights
|
||||
}
|
||||
if len(invert) != len(features) {
|
||||
return nil, ErrInvalidInvert
|
||||
}
|
||||
if len(stats.Stats) != len(features) {
|
||||
return nil, ErrStatsDimMismatch
|
||||
}
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
coords := make([]float64, len(features))
|
||||
for d, f := range features {
|
||||
if f == nil {
|
||||
return nil, ErrInvalidFeatures
|
||||
}
|
||||
n := scale01(f(it), stats.Stats[d].Min, stats.Stats[d].Max)
|
||||
if invert[d] {
|
||||
n = 1 - n
|
||||
}
|
||||
coords[d] = weights[d] * n
|
||||
}
|
||||
var pid string
|
||||
if id != nil {
|
||||
pid = id(it)
|
||||
}
|
||||
pts[i] = KDPoint[T]{ID: pid, Value: it, Coords: coords}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// minMax returns (min,max) of a slice.
|
||||
func minMax(xs []float64) (float64, float64) {
|
||||
if len(xs) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
mn, mx := xs[0], xs[0]
|
||||
for _, v := range xs[1:] {
|
||||
if v < mn {
|
||||
mn = v
|
||||
}
|
||||
if v > mx {
|
||||
mx = v
|
||||
}
|
||||
}
|
||||
return mn, mx
|
||||
}
|
||||
|
||||
// scale01 maps v from [min,max] to [0,1]. If min==max, returns 0.
|
||||
func scale01(v, min, max float64) float64 {
|
||||
if max == min {
|
||||
return 0
|
||||
}
|
||||
return (v - min) / (max - min)
|
||||
}
|
||||
|
||||
// ComputeNormStats2D computes per-axis min/max for two features.
|
||||
func ComputeNormStats2D[T any](items []T, f1, f2 func(T) float64) NormStats {
|
||||
vals1 := make([]float64, len(items))
|
||||
vals2 := make([]float64, len(items))
|
||||
for i, it := range items {
|
||||
vals1[i] = f1(it)
|
||||
vals2[i] = f2(it)
|
||||
}
|
||||
mn1, mx1 := minMax(vals1)
|
||||
mn2, mx2 := minMax(vals2)
|
||||
return NormStats{Stats: []AxisStats{{mn1, mx1}, {mn2, mx2}}}
|
||||
}
|
||||
|
||||
// ComputeNormStats3D computes per-axis min/max for three features.
|
||||
func ComputeNormStats3D[T any](items []T, f1, f2, f3 func(T) float64) NormStats {
|
||||
vals1 := make([]float64, len(items))
|
||||
vals2 := make([]float64, len(items))
|
||||
vals3 := make([]float64, len(items))
|
||||
for i, it := range items {
|
||||
vals1[i] = f1(it)
|
||||
vals2[i] = f2(it)
|
||||
vals3[i] = f3(it)
|
||||
}
|
||||
mn1, mx1 := minMax(vals1)
|
||||
mn2, mx2 := minMax(vals2)
|
||||
mn3, mx3 := minMax(vals3)
|
||||
return NormStats{Stats: []AxisStats{{mn1, mx1}, {mn2, mx2}, {mn3, mx3}}}
|
||||
}
|
||||
|
||||
// ComputeNormStats4D computes per-axis min/max for four features.
|
||||
func ComputeNormStats4D[T any](items []T, f1, f2, f3, f4 func(T) float64) NormStats {
|
||||
vals1 := make([]float64, len(items))
|
||||
vals2 := make([]float64, len(items))
|
||||
vals3 := make([]float64, len(items))
|
||||
vals4 := make([]float64, len(items))
|
||||
for i, it := range items {
|
||||
vals1[i] = f1(it)
|
||||
vals2[i] = f2(it)
|
||||
vals3[i] = f3(it)
|
||||
vals4[i] = f4(it)
|
||||
}
|
||||
mn1, mx1 := minMax(vals1)
|
||||
mn2, mx2 := minMax(vals2)
|
||||
mn3, mx3 := minMax(vals3)
|
||||
mn4, mx4 := minMax(vals4)
|
||||
return NormStats{Stats: []AxisStats{{mn1, mx1}, {mn2, mx2}, {mn3, mx3}, {mn4, mx4}}}
|
||||
}
|
||||
|
||||
// Build2D constructs normalised-and-weighted KD points from items using two feature extractors.
|
||||
// - id: function to provide a stable string ID (can return "" if you don't need DeleteByID)
|
||||
// - f1,f2: feature extractors (raw values)
|
||||
// - weights: per-axis weights applied after normalization
|
||||
// - invert: per-axis flags; if true, the axis is inverted (1-norm) so that higher raw values become lower cost
|
||||
func Build2D[T any](items []T, id func(T) string, f1, f2 func(T) float64, weights [2]float64, invert [2]bool) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
vals1 := make([]float64, len(items))
|
||||
vals2 := make([]float64, len(items))
|
||||
for i, it := range items {
|
||||
vals1[i] = f1(it)
|
||||
vals2[i] = f2(it)
|
||||
}
|
||||
mn1, mx1 := minMax(vals1)
|
||||
mn2, mx2 := minMax(vals2)
|
||||
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
n1 := scale01(vals1[i], mn1, mx1)
|
||||
n2 := scale01(vals2[i], mn2, mx2)
|
||||
if invert[0] {
|
||||
n1 = 1 - n1
|
||||
}
|
||||
if invert[1] {
|
||||
n2 = 1 - n2
|
||||
}
|
||||
pts[i] = KDPoint[T]{
|
||||
ID: id(it),
|
||||
Value: it,
|
||||
Coords: []float64{
|
||||
weights[0] * n1,
|
||||
weights[1] * n2,
|
||||
},
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// Build2DWithStats builds points using provided normalisation stats.
|
||||
func Build2DWithStats[T any](items []T, id func(T) string, f1, f2 func(T) float64, weights [2]float64, invert [2]bool, stats NormStats) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(stats.Stats) != 2 {
|
||||
return nil, ErrStatsDimMismatch
|
||||
}
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
n1 := scale01(f1(it), stats.Stats[0].Min, stats.Stats[0].Max)
|
||||
n2 := scale01(f2(it), stats.Stats[1].Min, stats.Stats[1].Max)
|
||||
if invert[0] {
|
||||
n1 = 1 - n1
|
||||
}
|
||||
if invert[1] {
|
||||
n2 = 1 - n2
|
||||
}
|
||||
pts[i] = KDPoint[T]{
|
||||
ID: id(it),
|
||||
Value: it,
|
||||
Coords: []float64{weights[0] * n1, weights[1] * n2},
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// Build3D constructs normalised-and-weighted KD points using three feature extractors.
|
||||
func Build3D[T any](items []T, id func(T) string, f1, f2, f3 func(T) float64, weights [3]float64, invert [3]bool) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
vals1 := make([]float64, len(items))
|
||||
vals2 := make([]float64, len(items))
|
||||
vals3 := make([]float64, len(items))
|
||||
for i, it := range items {
|
||||
vals1[i] = f1(it)
|
||||
vals2[i] = f2(it)
|
||||
vals3[i] = f3(it)
|
||||
}
|
||||
mn1, mx1 := minMax(vals1)
|
||||
mn2, mx2 := minMax(vals2)
|
||||
mn3, mx3 := minMax(vals3)
|
||||
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
n1 := scale01(vals1[i], mn1, mx1)
|
||||
n2 := scale01(vals2[i], mn2, mx2)
|
||||
n3 := scale01(vals3[i], mn3, mx3)
|
||||
if invert[0] {
|
||||
n1 = 1 - n1
|
||||
}
|
||||
if invert[1] {
|
||||
n2 = 1 - n2
|
||||
}
|
||||
if invert[2] {
|
||||
n3 = 1 - n3
|
||||
}
|
||||
pts[i] = KDPoint[T]{
|
||||
ID: id(it),
|
||||
Value: it,
|
||||
Coords: []float64{
|
||||
weights[0] * n1,
|
||||
weights[1] * n2,
|
||||
weights[2] * n3,
|
||||
},
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// Build3DWithStats builds points using provided normalisation stats.
|
||||
func Build3DWithStats[T any](items []T, id func(T) string, f1, f2, f3 func(T) float64, weights [3]float64, invert [3]bool, stats NormStats) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(stats.Stats) != 3 {
|
||||
return nil, ErrStatsDimMismatch
|
||||
}
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
n1 := scale01(f1(it), stats.Stats[0].Min, stats.Stats[0].Max)
|
||||
n2 := scale01(f2(it), stats.Stats[1].Min, stats.Stats[1].Max)
|
||||
n3 := scale01(f3(it), stats.Stats[2].Min, stats.Stats[2].Max)
|
||||
if invert[0] {
|
||||
n1 = 1 - n1
|
||||
}
|
||||
if invert[1] {
|
||||
n2 = 1 - n2
|
||||
}
|
||||
if invert[2] {
|
||||
n3 = 1 - n3
|
||||
}
|
||||
pts[i] = KDPoint[T]{
|
||||
ID: id(it),
|
||||
Value: it,
|
||||
Coords: []float64{weights[0] * n1, weights[1] * n2, weights[2] * n3},
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// Build4D constructs normalised-and-weighted KD points using four feature extractors.
|
||||
func Build4D[T any](items []T, id func(T) string, f1, f2, f3, f4 func(T) float64, weights [4]float64, invert [4]bool) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
vals1 := make([]float64, len(items))
|
||||
vals2 := make([]float64, len(items))
|
||||
vals3 := make([]float64, len(items))
|
||||
vals4 := make([]float64, len(items))
|
||||
for i, it := range items {
|
||||
vals1[i] = f1(it)
|
||||
vals2[i] = f2(it)
|
||||
vals3[i] = f3(it)
|
||||
vals4[i] = f4(it)
|
||||
}
|
||||
mn1, mx1 := minMax(vals1)
|
||||
mn2, mx2 := minMax(vals2)
|
||||
mn3, mx3 := minMax(vals3)
|
||||
mn4, mx4 := minMax(vals4)
|
||||
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
n1 := scale01(vals1[i], mn1, mx1)
|
||||
n2 := scale01(vals2[i], mn2, mx2)
|
||||
n3 := scale01(vals3[i], mn3, mx3)
|
||||
n4 := scale01(vals4[i], mn4, mx4)
|
||||
if invert[0] {
|
||||
n1 = 1 - n1
|
||||
}
|
||||
if invert[1] {
|
||||
n2 = 1 - n2
|
||||
}
|
||||
if invert[2] {
|
||||
n3 = 1 - n3
|
||||
}
|
||||
if invert[3] {
|
||||
n4 = 1 - n4
|
||||
}
|
||||
pts[i] = KDPoint[T]{
|
||||
ID: id(it),
|
||||
Value: it,
|
||||
Coords: []float64{
|
||||
weights[0] * n1,
|
||||
weights[1] * n2,
|
||||
weights[2] * n3,
|
||||
weights[3] * n4,
|
||||
},
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// Build4DWithStats builds points using provided normalisation stats.
|
||||
func Build4DWithStats[T any](items []T, id func(T) string, f1, f2, f3, f4 func(T) float64, weights [4]float64, invert [4]bool, stats NormStats) ([]KDPoint[T], error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(stats.Stats) != 4 {
|
||||
return nil, ErrStatsDimMismatch
|
||||
}
|
||||
pts := make([]KDPoint[T], len(items))
|
||||
for i, it := range items {
|
||||
n1 := scale01(f1(it), stats.Stats[0].Min, stats.Stats[0].Max)
|
||||
n2 := scale01(f2(it), stats.Stats[1].Min, stats.Stats[1].Max)
|
||||
n3 := scale01(f3(it), stats.Stats[2].Min, stats.Stats[2].Max)
|
||||
n4 := scale01(f4(it), stats.Stats[3].Min, stats.Stats[3].Max)
|
||||
if invert[0] {
|
||||
n1 = 1 - n1
|
||||
}
|
||||
if invert[1] {
|
||||
n2 = 1 - n2
|
||||
}
|
||||
if invert[2] {
|
||||
n3 = 1 - n3
|
||||
}
|
||||
if invert[3] {
|
||||
n4 = 1 - n4
|
||||
}
|
||||
pts[i] = KDPoint[T]{
|
||||
ID: id(it),
|
||||
Value: it,
|
||||
Coords: []float64{weights[0] * n1, weights[1] * n2, weights[2] * n3, weights[3] * n4},
|
||||
}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
283
kdtree_helpers_test.go
Normal file
283
kdtree_helpers_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuild2D_NormalizationAndInversion(t *testing.T) {
|
||||
type rec struct{ a, b float64 }
|
||||
items := []rec{{a: 0, b: 100}, {a: 10, b: 300}}
|
||||
// f1 over [0,10], f2 over [100,300]
|
||||
pts, err := Build2D(items,
|
||||
func(r rec) string { return "" },
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
[2]float64{2.0, 0.5},
|
||||
[2]bool{true, false}, // invert first axis, not second
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Build2D err: %v", err)
|
||||
}
|
||||
if len(pts) != 2 {
|
||||
t.Fatalf("expected 2 points, got %d", len(pts))
|
||||
}
|
||||
// item0: a=0 -> n1=0 -> invert -> 1 -> *2 = 2; b=100 -> n2=0 -> *0.5 = 0
|
||||
if got := fmt.Sprintf("%.1f,%.1f", pts[0].Coords[0], pts[0].Coords[1]); got != "2.0,0.0" {
|
||||
t.Fatalf("coords[0] = %s, want 2.0,0.0", got)
|
||||
}
|
||||
// item1: a=10 -> n1=1 -> invert -> 0 -> *2 = 0; b=300 -> n2=1 -> *0.5=0.5
|
||||
if got := fmt.Sprintf("%.1f,%.1f", pts[1].Coords[0], pts[1].Coords[1]); got != "0.0,0.5" {
|
||||
t.Fatalf("coords[1] = %s, want 0.0,0.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild3D_AllEqualSafe(t *testing.T) {
|
||||
type rec struct{ x, y, z float64 }
|
||||
items := []rec{{1, 1, 1}, {1, 1, 1}}
|
||||
pts, err := Build3D(items,
|
||||
func(r rec) string { return "id" },
|
||||
func(r rec) float64 { return r.x },
|
||||
func(r rec) float64 { return r.y },
|
||||
func(r rec) float64 { return r.z },
|
||||
[3]float64{1, 1, 1},
|
||||
[3]bool{false, false, false},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Build3D err: %v", err)
|
||||
}
|
||||
if len(pts) != 2 {
|
||||
t.Fatalf("len = %d", len(pts))
|
||||
}
|
||||
for i := range pts {
|
||||
if len(pts[i].Coords) != 3 {
|
||||
t.Fatalf("dim = %d", len(pts[i].Coords))
|
||||
}
|
||||
for _, c := range pts[i].Coords {
|
||||
if c != 0 {
|
||||
t.Fatalf("expected 0 when min==max, got %v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example-style end-to-end sanity on 4D using the documented Peer data
|
||||
func TestBuild4D_EndToEnd_Example(t *testing.T) {
|
||||
type Peer struct {
|
||||
ID string
|
||||
PingMS float64
|
||||
Hops float64
|
||||
GeoKM float64
|
||||
Score float64
|
||||
}
|
||||
peers := []Peer{
|
||||
{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} // flip score so higher score -> lower cost
|
||||
pts, err := Build4D(peers,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.PingMS },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.GeoKM },
|
||||
func(p Peer) float64 { return p.Score },
|
||||
weights, invert,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Build4D err: %v", err)
|
||||
}
|
||||
if len(pts) != len(peers) {
|
||||
t.Fatalf("len pts=%d", len(pts))
|
||||
}
|
||||
// Build KDTree and query near origin in normalized/weighted space (prefer minima on all axes)
|
||||
tree, err := NewKDTree(pts, WithMetric(EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
if tree.Dim() != 4 {
|
||||
t.Fatalf("dim=%d", tree.Dim())
|
||||
}
|
||||
best, _, ok := tree.Nearest([]float64{0, 0, 0, 0})
|
||||
if !ok {
|
||||
t.Fatalf("no nearest")
|
||||
}
|
||||
// With these weights and inversions, peer B emerges as closest in this setup.
|
||||
if best.ID != "B" {
|
||||
t.Fatalf("expected best B, got %s", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNormStatsAndWithStats_Parity2D(t *testing.T) {
|
||||
type rec struct{ a, b float64 }
|
||||
items := []rec{{0, 10}, {5, 20}, {10, 30}}
|
||||
weights := [2]float64{1, 2}
|
||||
invert := [2]bool{false, true}
|
||||
// Build using automatic stats
|
||||
autoPts, err := Build2D(items,
|
||||
func(r rec) string { return "" },
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
weights, invert,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("auto build err: %v", err)
|
||||
}
|
||||
// Compute stats and build with stats
|
||||
stats := ComputeNormStats2D(items,
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
)
|
||||
withPts, err := Build2DWithStats(items,
|
||||
func(r rec) string { return "" },
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
weights, invert, stats,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("with-stats build err: %v", err)
|
||||
}
|
||||
if len(withPts) != len(autoPts) {
|
||||
t.Fatalf("len mismatch")
|
||||
}
|
||||
for i := range withPts {
|
||||
if len(withPts[i].Coords) != 2 {
|
||||
t.Fatalf("dim mismatch")
|
||||
}
|
||||
if withPts[i].Coords[0] != autoPts[i].Coords[0] || withPts[i].Coords[1] != autoPts[i].Coords[1] {
|
||||
t.Fatalf("coords mismatch at %d: %v vs %v", i, withPts[i].Coords, autoPts[i].Coords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild3DWithStats_MinEqualsMax_Safe(t *testing.T) {
|
||||
type rec struct{ x, y, z float64 }
|
||||
items := []rec{{1, 2, 3}, {1, 5, 3}, {1, 9, 3}}
|
||||
weights := [3]float64{1, 1, 1}
|
||||
invert := [3]bool{false, false, false}
|
||||
// x and z min==max across items for x=1, z=3
|
||||
stats := NormStats{Stats: []AxisStats{{Min: 1, Max: 1}, {Min: 2, Max: 9}, {Min: 3, Max: 3}}}
|
||||
pts, err := Build3DWithStats(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, stats,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
for _, p := range pts {
|
||||
if p.Coords[0] != 0 || p.Coords[2] != 0 {
|
||||
t.Fatalf("expected zero for min==max axes, got %v", p.Coords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild4DWithStats_DynamicUpdateExample(t *testing.T) {
|
||||
type Peer struct {
|
||||
ID string
|
||||
Ping, Hops, Geo, Score float64
|
||||
}
|
||||
base := []Peer{{"A", 20, 3, 1000, 0.8}, {"B", 30, 2, 800, 0.9}}
|
||||
weights := [4]float64{1, 1, 0.2, 1.2}
|
||||
invert := [4]bool{false, false, false, true}
|
||||
stats := ComputeNormStats4D(base,
|
||||
func(p Peer) float64 { return p.Ping },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.Geo },
|
||||
func(p Peer) float64 { return p.Score },
|
||||
)
|
||||
pts, err := Build4DWithStats(base,
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.Ping },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.Geo },
|
||||
func(p Peer) float64 { return p.Score },
|
||||
weights, invert, stats,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
tr, err := NewKDTree(pts)
|
||||
if err != nil {
|
||||
t.Fatalf("kdt err: %v", err)
|
||||
}
|
||||
// add a new peer using same stats
|
||||
newPeer := Peer{"Z", 15, 2, 1200, 0.85}
|
||||
newPts, _ := Build4DWithStats([]Peer{newPeer},
|
||||
func(p Peer) string { return p.ID },
|
||||
func(p Peer) float64 { return p.Ping },
|
||||
func(p Peer) float64 { return p.Hops },
|
||||
func(p Peer) float64 { return p.Geo },
|
||||
func(p Peer) float64 { return p.Score },
|
||||
weights, invert, stats,
|
||||
)
|
||||
if !tr.Insert(newPts[0]) {
|
||||
t.Fatalf("insert failed")
|
||||
}
|
||||
if tr.Dim() != 4 {
|
||||
t.Fatalf("dim != 4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNormStats3D(t *testing.T) {
|
||||
type rec struct{ x, y, z float64 }
|
||||
items := []rec{{1, 10, 100}, {2, 20, 200}, {3, 30, 300}}
|
||||
stats := ComputeNormStats3D(items,
|
||||
func(r rec) float64 { return r.x },
|
||||
func(r rec) float64 { return r.y },
|
||||
func(r rec) float64 { return r.z },
|
||||
)
|
||||
expected := NormStats{
|
||||
Stats: []AxisStats{
|
||||
{Min: 1, Max: 3},
|
||||
{Min: 10, Max: 30},
|
||||
{Min: 100, Max: 300},
|
||||
},
|
||||
}
|
||||
if stats.Stats[0] != expected.Stats[0] || stats.Stats[1] != expected.Stats[1] || stats.Stats[2] != expected.Stats[2] {
|
||||
t.Fatalf("expected %v, got %v", expected, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildND(t *testing.T) {
|
||||
type rec struct{ a, b, c float64 }
|
||||
items := []rec{{1, 2, 3}, {4, 5, 6}}
|
||||
extractors := []func(rec) float64{
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
func(r rec) float64 { return r.c },
|
||||
}
|
||||
weights := []float64{1, 1, 1}
|
||||
invert := []bool{false, false, false}
|
||||
pts, err := BuildND(items, func(r rec) string { return "" }, extractors, weights, invert)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pts) != 2 {
|
||||
t.Fatalf("expected 2 points, got %d", len(pts))
|
||||
}
|
||||
if len(pts[0].Coords) != 3 {
|
||||
t.Fatalf("expected 3 dimensions, got %d", len(pts[0].Coords))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNDError(t *testing.T) {
|
||||
type rec struct{ a, b, c float64 }
|
||||
items := []rec{{1, 2, 3}, {4, 5, 6}}
|
||||
extractors := []func(rec) float64{
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
func(r rec) float64 { return r.c },
|
||||
}
|
||||
weights := []float64{1, 1} // Mismatched length
|
||||
invert := []bool{false, false, false}
|
||||
_, err := BuildND(items, func(r rec) string { return "" }, extractors, weights, invert)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, but got nil")
|
||||
}
|
||||
}
|
||||
100
kdtree_morecov_test.go
Normal file
100
kdtree_morecov_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInsert_DuplicateID(t *testing.T) {
|
||||
tr, err := NewKDTreeFromDim[string](1)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
ok := tr.Insert(KDPoint[string]{ID: "X", Coords: []float64{0}})
|
||||
if !ok {
|
||||
t.Fatalf("first insert should succeed")
|
||||
}
|
||||
// duplicate ID should fail
|
||||
if tr.Insert(KDPoint[string]{ID: "X", Coords: []float64{1}}) {
|
||||
t.Fatalf("expected insert duplicate ID to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteByID_SwapDelete(t *testing.T) {
|
||||
// Arrange 3 points so that deleting the middle triggers swap-delete path
|
||||
pts := []KDPoint[int]{
|
||||
{ID: "A", Coords: []float64{0}},
|
||||
{ID: "B", Coords: []float64{1}},
|
||||
{ID: "C", Coords: []float64{2}},
|
||||
}
|
||||
tr, err := NewKDTree(pts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree err: %v", err)
|
||||
}
|
||||
if !tr.DeleteByID("B") {
|
||||
t.Fatalf("delete B failed")
|
||||
}
|
||||
if tr.Len() != 2 {
|
||||
t.Fatalf("expected len 2, got %d", tr.Len())
|
||||
}
|
||||
// Ensure B is gone and A/C remain reachable
|
||||
ids := make(map[string]bool)
|
||||
for _, q := range [][]float64{{0}, {2}} {
|
||||
p, _, ok := tr.Nearest(q)
|
||||
if ok {
|
||||
ids[p.ID] = true
|
||||
}
|
||||
}
|
||||
if ids["B"] {
|
||||
t.Fatalf("B should not be present after delete")
|
||||
}
|
||||
if !ids["A"] || !ids["C"] {
|
||||
t.Fatalf("expected both A and C to be found after deleting B, got: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadius_NegativeReturnsNil(t *testing.T) {
|
||||
pts := []KDPoint[int]{{ID: "z", Coords: []float64{0}}}
|
||||
tr, _ := NewKDTree(pts)
|
||||
ns, ds := tr.Radius([]float64{0}, -1)
|
||||
if ns != nil || ds != nil {
|
||||
// Both should be nil on invalid radius
|
||||
t.Fatalf("expected nil slices on negative radius, got %v %v", ns, ds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearest_EmptyTree(t *testing.T) {
|
||||
tr, _ := NewKDTreeFromDim[int](2)
|
||||
_, _, ok := tr.Nearest([]float64{0, 0})
|
||||
if ok {
|
||||
t.Fatalf("expected ok=false for empty tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedCosineMetric_ViaKDTree(t *testing.T) {
|
||||
// Two points oriented differently around the query; ensure call path exercised
|
||||
type rec struct{ a, b float64 }
|
||||
items := []rec{{1, 0}, {0, 1}}
|
||||
weights := []float64{1, 2}
|
||||
invert := []bool{false, false}
|
||||
features := []func(rec) float64{
|
||||
func(r rec) float64 { return r.a },
|
||||
func(r rec) float64 { return r.b },
|
||||
}
|
||||
pts, err := BuildND(items, func(r rec) string { return fmt.Sprintf("%v", r) }, features, weights, invert)
|
||||
if err != nil {
|
||||
t.Fatalf("buildND err: %v", err)
|
||||
}
|
||||
tr, err := NewKDTree(pts, WithMetric(WeightedCosineDistance{Weights: weights}))
|
||||
if err != nil {
|
||||
t.Fatalf("kdt err: %v", err)
|
||||
}
|
||||
q := []float64{0.5 * weights[0], 0.5 * weights[1]} // mid direction
|
||||
_, d, ok := tr.Nearest(q)
|
||||
if !ok {
|
||||
t.Fatalf("no nearest")
|
||||
}
|
||||
if d < 0 || d > 2 {
|
||||
t.Fatalf("cosine distance out of bounds: %v", d)
|
||||
}
|
||||
}
|
||||
63
kdtree_nd_noerr_test.go
Normal file
63
kdtree_nd_noerr_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildNDNoErr_Parity checks that BuildNDNoErr matches BuildND on valid inputs.
|
||||
func TestBuildNDNoErr_Parity(t *testing.T) {
|
||||
type rec struct {
|
||||
A, B, C float64
|
||||
ID string
|
||||
}
|
||||
items := []rec{
|
||||
{A: 10, B: 100, C: 1, ID: "x"},
|
||||
{A: 20, B: 200, C: 2, ID: "y"},
|
||||
{A: 30, B: 300, C: 3, ID: "z"},
|
||||
}
|
||||
features := []func(rec) float64{
|
||||
func(r rec) float64 { return r.A },
|
||||
func(r rec) float64 { return r.B },
|
||||
func(r rec) float64 { return r.C },
|
||||
}
|
||||
weights := []float64{1, 0.5, 2}
|
||||
invert := []bool{false, true, false}
|
||||
idfn := func(r rec) string { return r.ID }
|
||||
|
||||
ptsStrict, err := BuildND(items, idfn, features, weights, invert)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildND returned error: %v", err)
|
||||
}
|
||||
ptsLoose := BuildNDNoErr(items, idfn, features, weights, invert)
|
||||
if len(ptsStrict) != len(ptsLoose) {
|
||||
t.Fatalf("length mismatch: strict %d loose %d", len(ptsStrict), len(ptsLoose))
|
||||
}
|
||||
for i := range ptsStrict {
|
||||
if ptsStrict[i].ID != ptsLoose[i].ID {
|
||||
t.Fatalf("ID mismatch at %d: %s vs %s", i, ptsStrict[i].ID, ptsLoose[i].ID)
|
||||
}
|
||||
if len(ptsStrict[i].Coords) != len(ptsLoose[i].Coords) {
|
||||
t.Fatalf("dim mismatch at %d: %d vs %d", i, len(ptsStrict[i].Coords), len(ptsLoose[i].Coords))
|
||||
}
|
||||
for d := range ptsStrict[i].Coords {
|
||||
if math.Abs(ptsStrict[i].Coords[d]-ptsLoose[i].Coords[d]) > 1e-12 {
|
||||
t.Fatalf("coord mismatch at %d dim %d: %v vs %v", i, d, ptsStrict[i].Coords[d], ptsLoose[i].Coords[d])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildNDNoErr_Lenient ensures the no-error builder is lenient and returns nil on bad lengths.
|
||||
func TestBuildNDNoErr_Lenient(t *testing.T) {
|
||||
type rec struct{ A float64 }
|
||||
items := []rec{{A: 1}, {A: 2}}
|
||||
features := []func(rec) float64{func(r rec) float64 { return r.A }}
|
||||
weightsBad := []float64{} // wrong length
|
||||
invert := []bool{false}
|
||||
pts := BuildNDNoErr(items, func(r rec) string { return fmt.Sprint(r.A) }, features, weightsBad, invert)
|
||||
if pts != nil {
|
||||
t.Fatalf("expected nil result on bad weights length, got %v", pts)
|
||||
}
|
||||
}
|
||||
118
kdtree_nd_test.go
Normal file
118
kdtree_nd_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCosineDistance_Basics(t *testing.T) {
|
||||
// identical vectors → distance 0
|
||||
a := []float64{1, 0, 0}
|
||||
b := []float64{1, 0, 0}
|
||||
d := CosineDistance{}.Distance(a, b)
|
||||
if d != 0 {
|
||||
t.Fatalf("expected 0, got %v", d)
|
||||
}
|
||||
// orthogonal → distance 1
|
||||
b = []float64{0, 1, 0}
|
||||
d = CosineDistance{}.Distance(a, b)
|
||||
if d < 0.999 || d > 1.001 {
|
||||
t.Fatalf("expected ~1, got %v", d)
|
||||
}
|
||||
// opposite → distance 2
|
||||
a = []float64{1, 0}
|
||||
b = []float64{-1, 0}
|
||||
d = CosineDistance{}.Distance(a, b)
|
||||
if d < 1.999 || d > 2.001 {
|
||||
t.Fatalf("expected ~2, got %v", d)
|
||||
}
|
||||
// zero vectors
|
||||
a = []float64{0, 0}
|
||||
b = []float64{0, 0}
|
||||
d = CosineDistance{}.Distance(a, b)
|
||||
if d != 0 {
|
||||
t.Fatalf("both zero → 0, got %v", d)
|
||||
}
|
||||
// one zero
|
||||
a = []float64{0, 0}
|
||||
b = []float64{1, 2}
|
||||
d = CosineDistance{}.Distance(a, b)
|
||||
if d != 1 {
|
||||
t.Fatalf("one zero → 1, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedCosineDistance_Basics(t *testing.T) {
|
||||
w := WeightedCosineDistance{Weights: []float64{2, 0.5}}
|
||||
a := []float64{1, 0}
|
||||
b := []float64{1, 0}
|
||||
d := w.Distance(a, b)
|
||||
// allow tiny floating-point noise
|
||||
if d > 1e-12 {
|
||||
t.Fatalf("expected ~0, got %v", d)
|
||||
}
|
||||
// orthogonal remains ~1 regardless of weights for these axes
|
||||
b = []float64{0, 3}
|
||||
d = w.Distance(a, b)
|
||||
if d < 0.999 || d > 1.001 {
|
||||
t.Fatalf("expected ~1, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildND_ParityWithBuild4D(t *testing.T) {
|
||||
type rec struct{ a, b, c, d float64 }
|
||||
items := []rec{{0, 10, 100, 1}, {10, 20, 200, 2}, {5, 15, 150, 1.5}}
|
||||
weights4 := [4]float64{1.0, 0.5, 2.0, 1.0}
|
||||
invert4 := [4]bool{false, true, false, true}
|
||||
pts4, err := 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 },
|
||||
weights4, invert4,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build4d err: %v", err)
|
||||
}
|
||||
|
||||
features := []func(rec) float64{
|
||||
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 },
|
||||
}
|
||||
wts := []float64{weights4[0], weights4[1], weights4[2], weights4[3]}
|
||||
inv := []bool{invert4[0], invert4[1], invert4[2], invert4[3]}
|
||||
ptsN, err := BuildND(items, func(r rec) string { return "" }, features, wts, inv)
|
||||
if err != nil {
|
||||
t.Fatalf("buildND err: %v", err)
|
||||
}
|
||||
if len(ptsN) != len(pts4) {
|
||||
t.Fatalf("len mismatch")
|
||||
}
|
||||
for i := range ptsN {
|
||||
if len(ptsN[i].Coords) != 4 {
|
||||
t.Fatalf("dim != 4")
|
||||
}
|
||||
for d := 0; d < 4; d++ {
|
||||
if fmt.Sprintf("%.6f", ptsN[i].Coords[d]) != fmt.Sprintf("%.6f", pts4[i].Coords[d]) {
|
||||
t.Fatalf("coords mismatch at i=%d d=%d: %v vs %v", i, d, ptsN[i].Coords, pts4[i].Coords)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNDWithStats_Errors(t *testing.T) {
|
||||
type rec struct{ x float64 }
|
||||
items := []rec{{1}, {2}}
|
||||
features := []func(rec) float64{func(r rec) float64 { return r.x }}
|
||||
wts := []float64{1}
|
||||
inv := []bool{false}
|
||||
// stats dim mismatch
|
||||
stats := NormStats{Stats: []AxisStats{{Min: 0, Max: 1}, {Min: 0, Max: 1}}}
|
||||
_, err := BuildNDWithStats(items, func(r rec) string { return "" }, features, wts, inv, stats)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for stats dim mismatch")
|
||||
}
|
||||
}
|
||||
109
kdtree_test.go
Normal file
109
kdtree_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func samplePoints() []KDPoint[string] {
|
||||
return []KDPoint[string]{
|
||||
{ID: "A", Coords: []float64{0, 0}, Value: "alpha"},
|
||||
{ID: "B", Coords: []float64{1, 0}, Value: "bravo"},
|
||||
{ID: "C", Coords: []float64{0, 1}, Value: "charlie"},
|
||||
{ID: "D", Coords: []float64{1, 1}, Value: "delta"},
|
||||
{ID: "E", Coords: []float64{2, 2}, Value: "echo"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDTree_Nearest(t *testing.T) {
|
||||
pts := samplePoints()
|
||||
tree, err := NewKDTree(pts, WithMetric(EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree error: %v", err)
|
||||
}
|
||||
|
||||
p, dist, ok := tree.Nearest([]float64{0.9, 0.9})
|
||||
if !ok {
|
||||
t.Fatalf("expected a nearest neighbor")
|
||||
}
|
||||
if p.ID != "D" {
|
||||
t.Fatalf("expected D, got %s", p.ID)
|
||||
}
|
||||
if dist <= 0 {
|
||||
t.Fatalf("expected positive distance, got %v", dist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDTree_KNearest(t *testing.T) {
|
||||
pts := samplePoints()
|
||||
tree, err := NewKDTree(pts, WithMetric(ManhattanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree error: %v", err)
|
||||
}
|
||||
|
||||
neighbors, dists := tree.KNearest([]float64{0.9, 0.9}, 3)
|
||||
if len(neighbors) != 3 || len(dists) != 3 {
|
||||
t.Fatalf("expected 3 neighbors, got %d", len(neighbors))
|
||||
}
|
||||
if neighbors[0].ID != "D" {
|
||||
t.Fatalf("expected first neighbor D, got %s", neighbors[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDTree_Radius(t *testing.T) {
|
||||
pts := samplePoints()
|
||||
tree, err := NewKDTree(pts, WithMetric(EuclideanDistance{}))
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree error: %v", err)
|
||||
}
|
||||
|
||||
neighbors, dists := tree.Radius([]float64{0, 0}, 1.01)
|
||||
if len(neighbors) < 2 {
|
||||
t.Fatalf("expected at least 2 neighbors within radius, got %d", len(neighbors))
|
||||
}
|
||||
// distances should be non-decreasing
|
||||
for i := 1; i < len(dists); i++ {
|
||||
if dists[i] < dists[i-1] {
|
||||
t.Fatalf("distances not sorted: %v", dists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDTree_InsertDelete(t *testing.T) {
|
||||
pts := samplePoints()
|
||||
tree, err := NewKDTree(pts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree error: %v", err)
|
||||
}
|
||||
// Insert a new close point near (0,0)
|
||||
ok := tree.Insert(KDPoint[string]{ID: "Z", Coords: []float64{0.05, 0.05}, Value: "zulu"})
|
||||
if !ok {
|
||||
t.Fatalf("insert failed")
|
||||
}
|
||||
p, _, found := tree.Nearest([]float64{0.04, 0.04})
|
||||
if !found || p.ID != "Z" {
|
||||
t.Fatalf("expected nearest to be Z after insert, got %+v", p)
|
||||
}
|
||||
|
||||
// Delete and verify nearest changes back
|
||||
if !tree.DeleteByID("Z") {
|
||||
t.Fatalf("delete failed")
|
||||
}
|
||||
p, _, found = tree.Nearest([]float64{0.04, 0.04})
|
||||
if !found || p.ID != "A" {
|
||||
t.Fatalf("expected nearest to be A after delete, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDTree_DimAndLen(t *testing.T) {
|
||||
pts := samplePoints()
|
||||
tree, err := NewKDTree(pts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTree error: %v", err)
|
||||
}
|
||||
if tree.Len() != len(pts) {
|
||||
t.Fatalf("Len mismatch: %d vs %d", tree.Len(), len(pts))
|
||||
}
|
||||
if tree.Dim() != 2 {
|
||||
t.Fatalf("Dim mismatch: %d", tree.Dim())
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,12 @@ markdown_extensions:
|
|||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started: getting-started.md
|
||||
- WebAssembly (Browser): wasm.md
|
||||
- Examples:
|
||||
- Best Ping Peer (DHT): dht-best-ping.md
|
||||
- Multi-Dimensional KDTree (DHT): kdtree-multidimensional.md
|
||||
- API Reference: api.md
|
||||
- Performance: perf.md
|
||||
- License: license.md
|
||||
|
||||
copyright: Copyright © 2025 Snider
|
||||
|
|
|
|||
190
npm/poindexter-wasm/LICENSE
Normal file
190
npm/poindexter-wasm/LICENSE
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||
EUPL © the European Union 2007, 2016
|
||||
|
||||
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined below) which is provided under the
|
||||
terms of this Licence. Any use of the Work, other than as authorised under this Licence is prohibited (to the extent such
|
||||
use is covered by a right of the copyright holder of the Work).
|
||||
The Work is provided under the terms of this Licence when the Licensor (as defined below) has placed the following
|
||||
notice immediately following the copyright notice for the Work:
|
||||
Licensed under the EUPL
|
||||
or has expressed by any other means his willingness to license under the EUPL.
|
||||
|
||||
1.Definitions
|
||||
In this Licence, the following terms have the following meaning:
|
||||
— ‘The Licence’:this Licence.
|
||||
— ‘The Original Work’:the work or software distributed or communicated by the Licensor under this Licence, available
|
||||
as Source Code and also as Executable Code as the case may be.
|
||||
— ‘Derivative Works’:the works or software that could be created by the Licensee, based upon the Original Work or
|
||||
modifications thereof. This Licence does not define the extent of modification or dependence on the Original Work
|
||||
required in order to classify a work as a Derivative Work; this extent is determined by copyright law applicable in
|
||||
the country mentioned in Article 15.
|
||||
— ‘The Work’:the Original Work or its Derivative Works.
|
||||
— ‘The Source Code’:the human-readable form of the Work which is the most convenient for people to study and
|
||||
modify.
|
||||
— ‘The Executable Code’:any code which has generally been compiled and which is meant to be interpreted by
|
||||
a computer as a program.
|
||||
— ‘The Licensor’:the natural or legal person that distributes or communicates the Work under the Licence.
|
||||
— ‘Contributor(s)’:any natural or legal person who modifies the Work under the Licence, or otherwise contributes to
|
||||
the creation of a Derivative Work.
|
||||
— ‘The Licensee’ or ‘You’:any natural or legal person who makes any usage of the Work under the terms of the
|
||||
Licence.
|
||||
— ‘Distribution’ or ‘Communication’:any act of selling, giving, lending, renting, distributing, communicating,
|
||||
transmitting, or otherwise making available, online or offline, copies of the Work or providing access to its essential
|
||||
functionalities at the disposal of any other natural or legal person.
|
||||
|
||||
2.Scope of the rights granted by the Licence
|
||||
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable licence to do the following, for
|
||||
the duration of copyright vested in the Original Work:
|
||||
— use the Work in any circumstance and for all usage,
|
||||
— reproduce the Work,
|
||||
— modify the Work, and make Derivative Works based upon the Work,
|
||||
— communicate to the public, including the right to make available or display the Work or copies thereof to the public
|
||||
and perform publicly, as the case may be, the Work,
|
||||
— distribute the Work or copies thereof,
|
||||
— lend and rent the Work or copies thereof,
|
||||
— sublicense rights in the Work or copies thereof.
|
||||
Those rights can be exercised on any media, supports and formats, whether now known or later invented, as far as the
|
||||
applicable law permits so.
|
||||
In the countries where moral rights apply, the Licensor waives his right to exercise his moral right to the extent allowed
|
||||
by law in order to make effective the licence of the economic rights here above listed.
|
||||
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to any patents held by the Licensor, to the
|
||||
extent necessary to make use of the rights granted on the Work under this Licence.
|
||||
|
||||
3.Communication of the Source Code
|
||||
The Licensor may provide the Work either in its Source Code form, or as Executable Code. If the Work is provided as
|
||||
Executable Code, the Licensor provides in addition a machine-readable copy of the Source Code of the Work along with
|
||||
each copy of the Work that the Licensor distributes or indicates, in a notice following the copyright notice attached to
|
||||
the Work, a repository where the Source Code is easily and freely accessible for as long as the Licensor continues to
|
||||
distribute or communicate the Work.
|
||||
|
||||
4.Limitations on copyright
|
||||
Nothing in this Licence is intended to deprive the Licensee of the benefits from any exception or limitation to the
|
||||
exclusive rights of the rights owners in the Work, of the exhaustion of those rights or of other applicable limitations
|
||||
thereto.
|
||||
|
||||
5.Obligations of the Licensee
|
||||
The grant of the rights mentioned above is subject to some restrictions and obligations imposed on the Licensee. Those
|
||||
obligations are the following:
|
||||
|
||||
Attribution right: The Licensee shall keep intact all copyright, patent or trademarks notices and all notices that refer to
|
||||
the Licence and to the disclaimer of warranties. The Licensee must include a copy of such notices and a copy of the
|
||||
Licence with every copy of the Work he/she distributes or communicates. The Licensee must cause any Derivative Work
|
||||
to carry prominent notices stating that the Work has been modified and the date of modification.
|
||||
|
||||
Copyleft clause: If the Licensee distributes or communicates copies of the Original Works or Derivative Works, this
|
||||
Distribution or Communication will be done under the terms of this Licence or of a later version of this Licence unless
|
||||
the Original Work is expressly distributed only under this version of the Licence — for example by communicating
|
||||
‘EUPL v. 1.2 only’. The Licensee (becoming Licensor) cannot offer or impose any additional terms or conditions on the
|
||||
Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||
|
||||
Compatibility clause: If the Licensee Distributes or Communicates Derivative Works or copies thereof based upon both
|
||||
the Work and another work licensed under a Compatible Licence, this Distribution or Communication can be done
|
||||
under the terms of this Compatible Licence. For the sake of this clause, ‘Compatible Licence’ refers to the licences listed
|
||||
in the appendix attached to this Licence. Should the Licensee's obligations under the Compatible Licence conflict with
|
||||
his/her obligations under this Licence, the obligations of the Compatible Licence shall prevail.
|
||||
|
||||
Provision of Source Code: When distributing or communicating copies of the Work, the Licensee will provide
|
||||
a machine-readable copy of the Source Code or indicate a repository where this Source will be easily and freely available
|
||||
for as long as the Licensee continues to distribute or communicate the Work.
|
||||
Legal Protection: This Licence does not grant permission to use the trade names, trademarks, service marks, or names
|
||||
of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the copyright notice.
|
||||
|
||||
6.Chain of Authorship
|
||||
The original Licensor warrants that the copyright in the Original Work granted hereunder is owned by him/her or
|
||||
licensed to him/her and that he/she has the power and authority to grant the Licence.
|
||||
Each Contributor warrants that the copyright in the modifications he/she brings to the Work are owned by him/her or
|
||||
licensed to him/her and that he/she has the power and authority to grant the Licence.
|
||||
Each time You accept the Licence, the original Licensor and subsequent Contributors grant You a licence to their contributions
|
||||
to the Work, under the terms of this Licence.
|
||||
|
||||
7.Disclaimer of Warranty
|
||||
The Work is a work in progress, which is continuously improved by numerous Contributors. It is not a finished work
|
||||
and may therefore contain defects or ‘bugs’ inherent to this type of development.
|
||||
For the above reason, the Work is provided under the Licence on an ‘as is’ basis and without warranties of any kind
|
||||
concerning the Work, including without limitation merchantability, fitness for a particular purpose, absence of defects or
|
||||
errors, accuracy, non-infringement of intellectual property rights other than copyright as stated in Article 6 of this
|
||||
Licence.
|
||||
This disclaimer of warranty is an essential part of the Licence and a condition for the grant of any rights to the Work.
|
||||
|
||||
8.Disclaimer of Liability
|
||||
Except in the cases of wilful misconduct or damages directly caused to natural persons, the Licensor will in no event be
|
||||
liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the
|
||||
Work, including without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss
|
||||
of data or any commercial damage, even if the Licensor has been advised of the possibility of such damage. However,
|
||||
the Licensor will be liable under statutory product liability laws as far such laws apply to the Work.
|
||||
|
||||
9.Additional agreements
|
||||
While distributing the Work, You may choose to conclude an additional agreement, defining obligations or services
|
||||
consistent with this Licence. However, if accepting obligations, You may act only on your own behalf and on your sole
|
||||
responsibility, not on behalf of the original Licensor or any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against such Contributor by
|
||||
the fact You have accepted any warranty or additional liability.
|
||||
|
||||
10.Acceptance of the Licence
|
||||
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ placed under the bottom of a window
|
||||
displaying the text of this Licence or by affirming consent in any other similar way, in accordance with the rules of
|
||||
applicable law. Clicking on that icon indicates your clear and irrevocable acceptance of this Licence and all of its terms
|
||||
and conditions.
|
||||
Similarly, you irrevocably accept this Licence and all of its terms and conditions by exercising any rights granted to You
|
||||
by Article 2 of this Licence, such as the use of the Work, the creation by You of a Derivative Work or the Distribution
|
||||
or Communication by You of the Work or copies thereof.
|
||||
|
||||
11.Information to the public
|
||||
In case of any Distribution or Communication of the Work by means of electronic communication by You (for example,
|
||||
by offering to download the Work from a remote location) the distribution channel or media (for example, a website)
|
||||
must at least provide to the public the information requested by the applicable law regarding the Licensor, the Licence
|
||||
and the way it may be accessible, concluded, stored and reproduced by the Licensee.
|
||||
|
||||
12.Termination of the Licence
|
||||
The Licence and the rights granted hereunder will terminate automatically upon any breach by the Licensee of the terms
|
||||
of the Licence.
|
||||
Such a termination will not terminate the licences of any person who has received the Work from the Licensee under
|
||||
the Licence, provided such persons remain in full compliance with the Licence.
|
||||
|
||||
13.Miscellaneous
|
||||
Without prejudice of Article 9 above, the Licence represents the complete agreement between the Parties as to the
|
||||
Work.
|
||||
If any provision of the Licence is invalid or unenforceable under applicable law, this will not affect the validity or
|
||||
enforceability of the Licence as a whole. Such provision will be construed or reformed so as necessary to make it valid
|
||||
and enforceable.
|
||||
The European Commission may publish other linguistic versions or new versions of this Licence or updated versions of
|
||||
the Appendix, so far this is required and reasonable, without reducing the scope of the rights granted by the Licence.
|
||||
New versions of the Licence will be published with a unique version number.
|
||||
All linguistic versions of this Licence, approved by the European Commission, have identical value. Parties can take
|
||||
advantage of the linguistic version of their choice.
|
||||
|
||||
14.Jurisdiction
|
||||
Without prejudice to specific agreement between parties,
|
||||
— any litigation resulting from the interpretation of this License, arising between the European Union institutions,
|
||||
bodies, offices or agencies, as a Licensor, and any Licensee, will be subject to the jurisdiction of the Court of Justice
|
||||
of the European Union, as laid down in article 272 of the Treaty on the Functioning of the European Union,
|
||||
— any litigation arising between other parties and resulting from the interpretation of this License, will be subject to
|
||||
the exclusive jurisdiction of the competent court where the Licensor resides or conducts its primary business.
|
||||
|
||||
15.Applicable Law
|
||||
Without prejudice to specific agreement between parties,
|
||||
— this Licence shall be governed by the law of the European Union Member State where the Licensor has his seat,
|
||||
resides or has his registered office,
|
||||
— this licence shall be governed by Belgian law if the Licensor has no seat, residence or registered office inside
|
||||
a European Union Member State.
|
||||
|
||||
|
||||
Appendix
|
||||
|
||||
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||
— GNU General Public License (GPL) v. 2, v. 3
|
||||
— GNU Affero General Public License (AGPL) v. 3
|
||||
— Open Software License (OSL) v. 2.1, v. 3.0
|
||||
— Eclipse Public License (EPL) v. 1.0
|
||||
— CeCILL v. 2.0, v. 2.1
|
||||
— Mozilla Public Licence (MPL) v. 2
|
||||
— GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||
— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for works other than software
|
||||
— European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||
— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity (LiLiQ-R+).
|
||||
|
||||
The European Commission may update this Appendix to later versions of the above licences without producing
|
||||
a new version of the EUPL, as long as they provide the rights granted in Article 2 of this Licence and protect the
|
||||
covered Source Code from exclusive appropriation.
|
||||
All other changes or additions to this Appendix require the production of a new EUPL version.
|
||||
219
npm/poindexter-wasm/PROJECT_README.md
Normal file
219
npm/poindexter-wasm/PROJECT_README.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# 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)
|
||||
[](https://codecov.io/gh/Snider/Poindexter)
|
||||
[](https://github.com/Snider/Poindexter/releases)
|
||||
|
||||
A Go library package providing utility functions including sorting algorithms with custom comparators.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔢 **Sorting Utilities**: Sort integers, strings, and floats in ascending or descending order
|
||||
- 🎯 **Custom Sorting**: Sort any type with custom comparison functions or key extractors
|
||||
- 🔍 **Binary Search**: Fast search on sorted data
|
||||
- 🧭 **KDTree (NN Search)**: Build a KDTree over points with generic payloads; nearest, k-NN, and radius queries with Euclidean, Manhattan, Chebyshev, and Cosine metrics
|
||||
- 📦 **Generic Functions**: Type-safe operations using Go generics
|
||||
- ✅ **Well-Tested**: Comprehensive test coverage
|
||||
- 📖 **Documentation**: Full documentation available at GitHub Pages
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/Snider/Poindexter
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
poindexter "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Basic sorting
|
||||
numbers := []int{3, 1, 4, 1, 5, 9}
|
||||
poindexter.SortInts(numbers)
|
||||
fmt.Println(numbers) // [1 1 3 4 5 9]
|
||||
|
||||
// Custom sorting with key function
|
||||
type Product struct {
|
||||
Name string
|
||||
Price float64
|
||||
}
|
||||
|
||||
products := []Product{{"Apple", 1.50}, {"Banana", 0.75}, {"Cherry", 3.00}}
|
||||
poindexter.SortByKey(products, func(p Product) float64 { return p.Price })
|
||||
|
||||
// KDTree quick demo
|
||||
pts := []poindexter.KDPoint[string]{
|
||||
{ID: "A", Coords: []float64{0, 0}, Value: "alpha"},
|
||||
{ID: "B", Coords: []float64{1, 0}, Value: "bravo"},
|
||||
{ID: "C", Coords: []float64{0, 1}, Value: "charlie"},
|
||||
}
|
||||
tree, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
nearest, dist, _ := tree.Nearest([]float64{0.9, 0.1})
|
||||
fmt.Println(nearest.ID, nearest.Value, dist) // B bravo ~0.141...
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
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
|
||||
- examples/wasm-browser (browser demo using the ESM loader)
|
||||
|
||||
### 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.
|
||||
- Complexity: Linear backend is O(n) per query. Optimized KD backend is typically sub-linear on prunable datasets and dims ≤ ~8, especially as N grows (≥10k–100k).
|
||||
- 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/`.
|
||||
- Performance guide: see docs/Performance for benchmark guidance and tips: [docs/perf.md](docs/perf.md) • Hosted: https://snider.github.io/Poindexter/perf/
|
||||
|
||||
### Backend selection
|
||||
- Default backend is Linear. If you build with `-tags=gonum`, the default becomes the optimized KD backend.
|
||||
- You can override per tree at construction:
|
||||
|
||||
```go
|
||||
// Force Linear (always available)
|
||||
kdt1, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendLinear))
|
||||
|
||||
// Force Gonum (requires build tag)
|
||||
kdt2, _ := poindexter.NewKDTree(pts, poindexter.WithBackend(poindexter.BackendGonum))
|
||||
```
|
||||
|
||||
- Supported metrics in the optimized backend: Euclidean (L2), Manhattan (L1), Chebyshev (L∞).
|
||||
- Cosine and Weighted-Cosine currently run on the Linear backend.
|
||||
- See the Performance guide for measured comparisons and when to choose which backend.
|
||||
|
||||
#### Choosing a metric (quick tips)
|
||||
- Euclidean (L2): smooth trade-offs across axes; solid default for blended preferences.
|
||||
- Manhattan (L1): emphasizes per-axis absolute differences; good when each unit of ping/hop matters equally.
|
||||
- Chebyshev (L∞): dominated by the worst axis; useful for strict thresholds (e.g., reject high hop count regardless of ping).
|
||||
- Cosine: angle-based for vector similarity; pair it with normalized/weighted features when direction matters more than magnitude.
|
||||
|
||||
See the multi-dimensional KDTree docs for end-to-end examples and weighting/normalization helpers: [Multi-Dimensional KDTree (DHT)](docs/kdtree-multidimensional.md).
|
||||
|
||||
## Maintainer Makefile
|
||||
|
||||
The repository includes a maintainer-friendly `Makefile` that mirrors CI tasks and speeds up local workflows.
|
||||
|
||||
- help — list available targets
|
||||
- tidy / tidy-check — run `go mod tidy`, optionally verify no diffs
|
||||
- fmt — format code (`go fmt ./...`)
|
||||
- vet — `go vet ./...`
|
||||
- build — `go build ./...`
|
||||
- examples — build all programs under `examples/` (if present)
|
||||
- test — run unit tests
|
||||
- race — run tests with the race detector
|
||||
- cover — run tests with race + coverage (writes `coverage.out` and prints summary)
|
||||
- coverhtml — render HTML coverage report to `coverage.html`
|
||||
- coverfunc — print per-function coverage (from `coverage.out`)
|
||||
- cover-kdtree — print coverage details filtered to `kdtree.go`
|
||||
- fuzz — run Go fuzzing for a configurable time (default 10s) matching CI
|
||||
- bench — run benchmarks with `-benchmem` (writes `bench.txt`)
|
||||
- lint — run `golangci-lint` (if installed)
|
||||
- vuln — run `govulncheck` (if installed)
|
||||
- ci — CI-parity aggregate: tidy-check, build, vet, cover, examples, bench, lint, vuln
|
||||
- release — run GoReleaser with the canonical `.goreleaser.yaml` (for tagged releases)
|
||||
- snapshot — GoReleaser snapshot (no publish)
|
||||
- docs-serve — serve MkDocs locally on 127.0.0.1:8000
|
||||
- docs-build — build MkDocs site into `site/`
|
||||
|
||||
Quick usage:
|
||||
|
||||
- See all targets:
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
- Fast local cycle:
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make vet
|
||||
make test
|
||||
```
|
||||
|
||||
- CI-parity run (what GitHub Actions does, locally):
|
||||
|
||||
```bash
|
||||
make ci
|
||||
```
|
||||
|
||||
- Coverage summary:
|
||||
|
||||
```bash
|
||||
make cover
|
||||
```
|
||||
|
||||
- Generate HTML coverage report (writes coverage.html):
|
||||
|
||||
```bash
|
||||
make coverhtml
|
||||
```
|
||||
|
||||
- Fuzz for 10 seconds (default):
|
||||
|
||||
```bash
|
||||
make fuzz
|
||||
```
|
||||
|
||||
- Fuzz with a custom time (e.g., 30s):
|
||||
|
||||
```bash
|
||||
make fuzz FUZZTIME=30s
|
||||
```
|
||||
|
||||
- Run benchmarks (writes bench.txt):
|
||||
|
||||
```bash
|
||||
make bench
|
||||
```
|
||||
|
||||
- Build examples (if any under ./examples):
|
||||
|
||||
```bash
|
||||
make examples
|
||||
```
|
||||
|
||||
- Serve docs locally (requires mkdocs-material):
|
||||
|
||||
```bash
|
||||
make docs-serve
|
||||
```
|
||||
|
||||
Configurable variables:
|
||||
|
||||
- `FUZZTIME` (default `10s`) — e.g. `make fuzz FUZZTIME=30s`
|
||||
- `BENCHOUT` (default `bench.txt`), `COVEROUT` (default `coverage.out`), `COVERHTML` (default `coverage.html`)
|
||||
- Tool commands are overridable via env: `GO`, `GOLANGCI_LINT`, `GORELEASER`, `MKDOCS`
|
||||
|
||||
Requirements for optional targets:
|
||||
|
||||
- `golangci-lint` for `make lint`
|
||||
- `golang.org/x/vuln/cmd/govulncheck` for `make vuln`
|
||||
- `goreleaser` for `make release` / `make snapshot`
|
||||
- `mkdocs` + `mkdocs-material` for `make docs-serve` / `make docs-build`
|
||||
|
||||
See the full Makefile at the repo root for authoritative target definitions.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2). See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
79
npm/poindexter-wasm/README.md
Normal file
79
npm/poindexter-wasm/README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# @snider/poindexter-wasm
|
||||
|
||||
WebAssembly build of the Poindexter KD-Tree library for browsers. Designed to be consumed from Angular, React, or any ESM-capable bundler.
|
||||
|
||||
Status: experimental preview. API surface can evolve.
|
||||
|
||||
## Install
|
||||
|
||||
Until published to npm, you can use a local file/path install:
|
||||
|
||||
```bash
|
||||
# From the repo root where this folder exists
|
||||
npm pack ./npm/poindexter-wasm
|
||||
# Produces a tarball like snider-poindexter-wasm-0.0.0-development.tgz
|
||||
# In your Angular project:
|
||||
npm install ../Poindexter/snider-poindexter-wasm-0.0.0-development.tgz
|
||||
```
|
||||
|
||||
Once published:
|
||||
|
||||
```bash
|
||||
npm install @snider/poindexter-wasm
|
||||
```
|
||||
|
||||
## Usage (Angular/ESM)
|
||||
|
||||
```ts
|
||||
// app.module.ts or a dedicated provider file
|
||||
import { init } from '@snider/poindexter-wasm';
|
||||
|
||||
async function bootstrapPoindexter() {
|
||||
const px = await init();
|
||||
console.log(await px.version());
|
||||
|
||||
const tree = await px.newTree(2);
|
||||
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
|
||||
await tree.insert({ id: 'b', coords: [1, 1], value: 'B' });
|
||||
|
||||
const nearest = await tree.nearest([0.2, 0.1]);
|
||||
console.log('nearest:', nearest);
|
||||
|
||||
return { px, tree };
|
||||
}
|
||||
|
||||
// Call bootstrapPoindexter() during app initialization
|
||||
```
|
||||
|
||||
If your bundler cannot resolve asset URLs from `import.meta.url`, pass explicit URLs:
|
||||
|
||||
```ts
|
||||
const px = await init({
|
||||
wasmURL: '/assets/poindexter/poindexter.wasm',
|
||||
wasmExecURL: '/assets/poindexter/wasm_exec.js',
|
||||
});
|
||||
```
|
||||
|
||||
To host the assets, copy `node_modules/@snider/poindexter-wasm/dist/*` into your app's public/assets folder during build (e.g., with Angular `assets` config in `angular.json`).
|
||||
|
||||
## API
|
||||
|
||||
- `version(): Promise<string>` – Poindexter library version.
|
||||
- `hello(name?: string): Promise<string>` – simple sanity check.
|
||||
- `newTree(dim: number): Promise<Tree>` – create a new KD-Tree with given dimension.
|
||||
|
||||
Tree methods:
|
||||
- `dim(): Promise<number>`
|
||||
- `len(): Promise<number>`
|
||||
- `insert(point: {id: string, coords: number[], value?: string}): Promise<boolean>`
|
||||
- `deleteByID(id: string): Promise<boolean>`
|
||||
- `nearest(query: number[]): Promise<{point, dist, found}>`
|
||||
- `kNearest(query: number[], k: number): Promise<{points, dists}>`
|
||||
- `radius(query: number[], r: number): Promise<{points, dists}>`
|
||||
- `exportJSON(): Promise<string>` – minimal metadata export for now.
|
||||
|
||||
## Notes
|
||||
|
||||
- Values are strings in this WASM build for simplicity across the boundary.
|
||||
- This package ships `dist/poindexter.wasm` and Go's `wasm_exec.js`. The loader adds required shims at runtime.
|
||||
- Requires a modern browser with WebAssembly support.
|
||||
41
npm/poindexter-wasm/index.d.ts
vendored
Normal file
41
npm/poindexter-wasm/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export interface PxPoint {
|
||||
id: string;
|
||||
coords: number[];
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface NearestResult {
|
||||
point: PxPoint;
|
||||
dist: number;
|
||||
found: boolean;
|
||||
}
|
||||
|
||||
export interface KNearestResult {
|
||||
points: PxPoint[];
|
||||
dists: number[];
|
||||
}
|
||||
|
||||
export interface PxTree {
|
||||
len(): Promise<number>;
|
||||
dim(): Promise<number>;
|
||||
insert(point: PxPoint): Promise<boolean>;
|
||||
deleteByID(id: string): Promise<boolean>;
|
||||
nearest(query: number[]): Promise<NearestResult>;
|
||||
kNearest(query: number[], k: number): Promise<KNearestResult>;
|
||||
radius(query: number[], r: number): Promise<KNearestResult>;
|
||||
exportJSON(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface InitOptions {
|
||||
wasmURL?: string;
|
||||
wasmExecURL?: string;
|
||||
instantiateWasm?: (source: ArrayBuffer, importObject: WebAssembly.Imports) => Promise<WebAssembly.Instance> | WebAssembly.Instance;
|
||||
}
|
||||
|
||||
export interface PxAPI {
|
||||
version(): Promise<string>;
|
||||
hello(name?: string): Promise<string>;
|
||||
newTree(dim: number): Promise<PxTree>;
|
||||
}
|
||||
|
||||
export function init(options?: InitOptions): Promise<PxAPI>;
|
||||
11
npm/poindexter-wasm/loader.cjs
Normal file
11
npm/poindexter-wasm/loader.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// CommonJS loader placeholder for @snider/poindexter-wasm
|
||||
// This package is intended for browser bundlers (Angular/webpack/Vite) using ESM.
|
||||
// If you are in a CommonJS environment, please switch to ESM import:
|
||||
// import { init } from '@snider/poindexter-wasm';
|
||||
// Or configure your bundler to use the ESM entry.
|
||||
|
||||
module.exports = {
|
||||
init: function () {
|
||||
throw new Error("@snider/poindexter-wasm: CommonJS is not supported; use ESM import instead.");
|
||||
}
|
||||
};
|
||||
90
npm/poindexter-wasm/loader.js
Normal file
90
npm/poindexter-wasm/loader.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// ESM loader for Poindexter WASM
|
||||
// Usage:
|
||||
// import { init } from '@snider/poindexter-wasm';
|
||||
// const px = await init();
|
||||
// const tree = await px.newTree(2);
|
||||
// await tree.insert({ id: 'a', coords: [0,0], value: 'A' });
|
||||
// const res = await tree.nearest([0.1, 0.2]);
|
||||
|
||||
async function loadScriptOnce(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already present, resolve immediately
|
||||
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = (e) => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWasmExec(url) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap(result) {
|
||||
if (!result || typeof result !== 'object') throw new Error('bad result');
|
||||
if (result.ok) return result.data;
|
||||
throw new Error(result.error || 'unknown error');
|
||||
}
|
||||
|
||||
function call(name, ...args) {
|
||||
const fn = globalThis[name];
|
||||
if (typeof fn !== 'function') throw new Error(`WASM function ${name} not found`);
|
||||
return unwrap(fn(...args));
|
||||
}
|
||||
|
||||
class PxTree {
|
||||
constructor(treeId) { this.treeId = treeId; }
|
||||
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) { 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); }
|
||||
}
|
||||
|
||||
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: (source, importObject) => WebAssembly.Instance
|
||||
} = options;
|
||||
|
||||
await ensureWasmExec(wasmExecURL);
|
||||
const go = new window.Go();
|
||||
|
||||
let result;
|
||||
if (instantiateWasm) {
|
||||
const source = await fetch(wasmURL).then(r => r.arrayBuffer());
|
||||
const inst = await instantiateWasm(source, go.importObject);
|
||||
result = { instance: inst };
|
||||
} else if (WebAssembly.instantiateStreaming) {
|
||||
result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject);
|
||||
} else {
|
||||
const resp = await fetch(wasmURL);
|
||||
const bytes = await resp.arrayBuffer();
|
||||
result = await WebAssembly.instantiate(bytes, go.importObject);
|
||||
}
|
||||
|
||||
// Run the Go program (it registers globals like pxNewTree, etc.)
|
||||
// Do not await: the Go WASM main may block (e.g., via select{}), so awaiting never resolves.
|
||||
go.run(result.instance);
|
||||
|
||||
const api = {
|
||||
version: async () => call('pxVersion'),
|
||||
hello: async (name) => call('pxHello', name ?? ''),
|
||||
newTree: async (dim) => {
|
||||
const info = call('pxNewTree', dim);
|
||||
return new PxTree(info.treeId);
|
||||
}
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
27
npm/poindexter-wasm/package.json
Normal file
27
npm/poindexter-wasm/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@snider/poindexter-wasm",
|
||||
"version": "0.0.0-development",
|
||||
"description": "Poindexter KD-Tree WebAssembly build for browsers (Angular/SPA ready)",
|
||||
"license": "MIT",
|
||||
"author": "Snider",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./loader.js",
|
||||
"require": "./loader.cjs"
|
||||
}
|
||||
},
|
||||
"module": "./loader.js",
|
||||
"main": "./loader.cjs",
|
||||
"types": "./index.d.ts",
|
||||
"files": [
|
||||
"dist/poindexter.wasm",
|
||||
"dist/wasm_exec.js",
|
||||
"loader.js",
|
||||
"loader.cjs",
|
||||
"index.d.ts",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"PROJECT_README.md"
|
||||
]
|
||||
}
|
||||
26
npm/poindexter-wasm/smoke.mjs
Normal file
26
npm/poindexter-wasm/smoke.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Minimal Node smoke test for the WASM loader.
|
||||
// Assumes npm-pack has prepared npm/poindexter-wasm with loader and dist assets.
|
||||
|
||||
import { init } from './loader.js';
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
const px = await init({
|
||||
// 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');
|
||||
|
||||
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' });
|
||||
const nn = await tree.nearest([0.9, 0.1]);
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -3,7 +3,7 @@ package poindexter
|
|||
|
||||
// Version returns the current version of the library.
|
||||
func Version() string {
|
||||
return "0.1.0"
|
||||
return "0.3.0"
|
||||
}
|
||||
|
||||
// Hello returns a greeting message.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ func TestVersion(t *testing.T) {
|
|||
if version == "" {
|
||||
t.Error("Version should not be empty")
|
||||
}
|
||||
if version != "0.1.0" {
|
||||
t.Errorf("Expected version 0.1.0, got %s", version)
|
||||
if version != "0.3.0" {
|
||||
t.Errorf("Expected version 0.3.0, got %s", version)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
2
sort.go
2
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])
|
||||
|
|
|
|||
242
wasm/main.go
Normal file
242
wasm/main.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"syscall/js"
|
||||
|
||||
pd "github.com/Snider/Poindexter"
|
||||
)
|
||||
|
||||
// Simple registry for KDTree instances created from JS.
|
||||
// We keep values as string for simplicity across the WASM boundary.
|
||||
var (
|
||||
treeRegistry = map[int]*pd.KDTree[string]{}
|
||||
nextTreeID = 1
|
||||
)
|
||||
|
||||
func export(name string, fn func(this js.Value, args []js.Value) (any, error)) {
|
||||
js.Global().Set(name, js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
res, err := fn(this, args)
|
||||
if err != nil {
|
||||
return map[string]any{"ok": false, "error": err.Error()}
|
||||
}
|
||||
return map[string]any{"ok": true, "data": res}
|
||||
}))
|
||||
}
|
||||
|
||||
func getInt(v js.Value, idx int) (int, error) {
|
||||
if len := v.Length(); len > idx {
|
||||
return v.Index(idx).Int(), nil
|
||||
}
|
||||
return 0, errors.New("missing integer argument")
|
||||
}
|
||||
|
||||
func getFloatSlice(arg js.Value) ([]float64, error) {
|
||||
if arg.IsUndefined() || arg.IsNull() {
|
||||
return nil, errors.New("coords/query is undefined or null")
|
||||
}
|
||||
ln := arg.Length()
|
||||
res := make([]float64, ln)
|
||||
for i := 0; i < ln; i++ {
|
||||
res[i] = arg.Index(i).Float()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func version(_ js.Value, _ []js.Value) (any, error) {
|
||||
return pd.Version(), nil
|
||||
}
|
||||
|
||||
func hello(_ js.Value, args []js.Value) (any, error) {
|
||||
name := ""
|
||||
if len(args) > 0 {
|
||||
name = args[0].String()
|
||||
}
|
||||
return pd.Hello(name), nil
|
||||
}
|
||||
|
||||
func newTree(_ js.Value, args []js.Value) (any, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, errors.New("newTree(dim) requires dim")
|
||||
}
|
||||
dim := args[0].Int()
|
||||
if dim <= 0 {
|
||||
return nil, pd.ErrZeroDim
|
||||
}
|
||||
t, err := pd.NewKDTreeFromDim[string](dim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := nextTreeID
|
||||
nextTreeID++
|
||||
treeRegistry[id] = t
|
||||
return map[string]any{"treeId": id, "dim": dim}, nil
|
||||
}
|
||||
|
||||
func treeLen(_ js.Value, args []js.Value) (any, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, errors.New("len(treeId)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
return t.Len(), nil
|
||||
}
|
||||
|
||||
func treeDim(_ js.Value, args []js.Value) (any, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, errors.New("dim(treeId)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
return t.Dim(), nil
|
||||
}
|
||||
|
||||
func insert(_ js.Value, args []js.Value) (any, error) {
|
||||
// insert(treeId, {id: string, coords: number[], value?: string})
|
||||
if len(args) < 2 {
|
||||
return nil, errors.New("insert(treeId, point)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
pt := args[1]
|
||||
pid := pt.Get("id").String()
|
||||
coords, err := getFloatSlice(pt.Get("coords"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val := pt.Get("value").String()
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
okIns := t.Insert(pd.KDPoint[string]{ID: pid, Coords: coords, Value: val})
|
||||
return okIns, nil
|
||||
}
|
||||
|
||||
func deleteByID(_ js.Value, args []js.Value) (any, error) {
|
||||
// deleteByID(treeId, id)
|
||||
if len(args) < 2 {
|
||||
return nil, errors.New("deleteByID(treeId, id)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
pid := args[1].String()
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
return t.DeleteByID(pid), nil
|
||||
}
|
||||
|
||||
func nearest(_ js.Value, args []js.Value) (any, error) {
|
||||
// nearest(treeId, query:number[]) -> {point, dist, found}
|
||||
if len(args) < 2 {
|
||||
return nil, errors.New("nearest(treeId, query)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
query, err := getFloatSlice(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
p, d, found := t.Nearest(query)
|
||||
out := map[string]any{
|
||||
"point": map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value},
|
||||
"dist": d,
|
||||
"found": found,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func kNearest(_ js.Value, args []js.Value) (any, error) {
|
||||
// kNearest(treeId, query:number[], k:int) -> {points:[...], dists:[...]}
|
||||
if len(args) < 3 {
|
||||
return nil, errors.New("kNearest(treeId, query, k)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
query, err := getFloatSlice(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := args[2].Int()
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
pts, dists := t.KNearest(query, k)
|
||||
jsPts := make([]any, len(pts))
|
||||
for i, p := range pts {
|
||||
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
|
||||
}
|
||||
return map[string]any{"points": jsPts, "dists": dists}, nil
|
||||
}
|
||||
|
||||
func radius(_ js.Value, args []js.Value) (any, error) {
|
||||
// radius(treeId, query:number[], r:number) -> {points:[...], dists:[...]}
|
||||
if len(args) < 3 {
|
||||
return nil, errors.New("radius(treeId, query, r)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
query, err := getFloatSlice(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := args[2].Float()
|
||||
t, ok := treeRegistry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown treeId %d", id)
|
||||
}
|
||||
pts, dists := t.Radius(query, r)
|
||||
jsPts := make([]any, len(pts))
|
||||
for i, p := range pts {
|
||||
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
|
||||
}
|
||||
return map[string]any{"points": jsPts, "dists": dists}, nil
|
||||
}
|
||||
|
||||
func exportJSON(_ js.Value, args []js.Value) (any, error) {
|
||||
// exportJSON(treeId) -> string (all points)
|
||||
if len(args) < 1 {
|
||||
return nil, errors.New("exportJSON(treeId)")
|
||||
}
|
||||
id := args[0].Int()
|
||||
t, ok := treeRegistry[id]
|
||||
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()}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Export core API
|
||||
export("pxVersion", version)
|
||||
export("pxHello", hello)
|
||||
export("pxNewTree", newTree)
|
||||
export("pxTreeLen", treeLen)
|
||||
export("pxTreeDim", treeDim)
|
||||
export("pxInsert", insert)
|
||||
export("pxDeleteByID", deleteByID)
|
||||
export("pxNearest", nearest)
|
||||
export("pxKNearest", kNearest)
|
||||
export("pxRadius", radius)
|
||||
export("pxExportJSON", exportJSON)
|
||||
|
||||
// Keep running
|
||||
select {}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue