forked from Snider/Poindexter
Compare commits
1 commit
main
...
fix-wasm-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b6c09e3f |
5 changed files with 186 additions and 27 deletions
90
kdtree_failure_test.go
Normal file
90
kdtree_failure_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package poindexter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewKDTree_Empty(t *testing.T) {
|
||||
_, err := NewKDTree[any](nil)
|
||||
if err != ErrEmptyPoints {
|
||||
t.Errorf("expected ErrEmptyPoints, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWasmSmokeEquivalent(t *testing.T) {
|
||||
tree, err := NewKDTreeFromDim[string](2)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKDTreeFromDim failed: %v", err)
|
||||
}
|
||||
tree.Insert(KDPoint[string]{ID: "a", Coords: []float64{0, 0}, Value: "A"})
|
||||
tree.Insert(KDPoint[string]{ID: "b", Coords: []float64{1, 0}, Value: "B"})
|
||||
_, _, found := tree.Nearest([]float64{0.9, 0.1})
|
||||
if !found {
|
||||
t.Error("expected to find a nearest point")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKDTree_ZeroDim(t *testing.T) {
|
||||
pts := []KDPoint[any]{{Coords: []float64{}}}
|
||||
_, err := NewKDTree[any](pts)
|
||||
if err != ErrZeroDim {
|
||||
t.Errorf("expected ErrZeroDim, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKDTree_DimMismatch(t *testing.T) {
|
||||
pts := []KDPoint[any]{
|
||||
{Coords: []float64{1, 2}},
|
||||
{Coords: []float64{3}},
|
||||
}
|
||||
_, err := NewKDTree[any](pts)
|
||||
if err != ErrDimMismatch {
|
||||
t.Errorf("expected ErrDimMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKDTree_DuplicateID(t *testing.T) {
|
||||
pts := []KDPoint[any]{
|
||||
{ID: "a", Coords: []float64{1, 2}},
|
||||
{ID: "a", Coords: []float64{3, 4}},
|
||||
}
|
||||
_, err := NewKDTree[any](pts)
|
||||
if err != ErrDuplicateID {
|
||||
t.Errorf("expected ErrDuplicateID, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildND_InvalidFeatures(t *testing.T) {
|
||||
_, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, nil, nil, nil)
|
||||
if err != ErrInvalidFeatures {
|
||||
t.Errorf("expected ErrInvalidFeatures, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildND_InvalidWeights(t *testing.T) {
|
||||
_, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, nil, nil)
|
||||
if err != ErrInvalidWeights {
|
||||
t.Errorf("expected ErrInvalidWeights, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildND_InvalidInvert(t *testing.T) {
|
||||
_, err := BuildND[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, []float64{0}, nil)
|
||||
if err != ErrInvalidInvert {
|
||||
t.Errorf("expected ErrInvalidInvert, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNDWithStats_DimMismatch(t *testing.T) {
|
||||
_, err := BuildNDWithStats[struct{}]([]struct{}{{}}, func(a struct{}) string { return "" }, []func(struct{}) float64{func(a struct{}) float64 { return 0 }}, []float64{0}, []bool{false}, NormStats{})
|
||||
if err != ErrStatsDimMismatch {
|
||||
t.Errorf("expected ErrStatsDimMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGonumStub_BuildBackend(t *testing.T) {
|
||||
_, err := buildGonumBackend[any](nil, nil)
|
||||
if err != ErrEmptyPoints {
|
||||
t.Errorf("expected ErrEmptyPoints, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +71,9 @@ Explore runnable examples in the repository:
|
|||
- examples/kdtree_2d_ping_hop
|
||||
- examples/kdtree_3d_ping_hop_geo
|
||||
- examples/kdtree_4d_ping_hop_geo_score
|
||||
- examples/dht_helpers (convenience wrappers for common DHT schemas)
|
||||
- examples/wasm-browser (browser demo using the ESM loader)
|
||||
- examples/wasm-browser-ts (TypeScript + Vite local demo)
|
||||
|
||||
### KDTree performance and notes
|
||||
- Dual backend support: Linear (always available) and an optimized KD backend enabled when building with `-tags=gonum`. Linear is the default; with the `gonum` tag, the optimized backend becomes the default.
|
||||
|
|
@ -216,4 +218,21 @@ 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.
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@
|
|||
// await tree.insert({ id: 'a', coords: [0,0], value: 'A' });
|
||||
// const res = await tree.nearest([0.1, 0.2]);
|
||||
|
||||
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
|
||||
|
||||
async function loadScriptOnce(src) {
|
||||
// Browser-only
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already present, resolve immediately
|
||||
if (typeof document === 'undefined') return reject(new Error('loadScriptOnce requires a browser environment'));
|
||||
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
|
|
@ -19,10 +22,23 @@ async function loadScriptOnce(src) {
|
|||
}
|
||||
|
||||
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');
|
||||
if (globalThis.Go) return;
|
||||
|
||||
if (isNode) {
|
||||
const { fileURLToPath } = await import('url');
|
||||
const fs = await import('fs/promises');
|
||||
const vm = await import('vm');
|
||||
const wasmExecPath = fileURLToPath(url);
|
||||
const wasmExecCode = await fs.readFile(wasmExecPath, 'utf8');
|
||||
vm.runInThisContext(wasmExecCode, { filename: wasmExecPath });
|
||||
} else if (typeof window !== 'undefined') {
|
||||
await loadScriptOnce(url);
|
||||
} else {
|
||||
throw new Error('Unsupported environment: not Node.js or a browser');
|
||||
}
|
||||
|
||||
if (typeof globalThis.Go !== 'function') {
|
||||
throw new Error('wasm_exec.js did not define globalThis.Go');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +60,15 @@ class PxTree {
|
|||
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 nearest(query) {
|
||||
const res = await call('pxNearest', this.treeId, query);
|
||||
if (!res.found) return { point: null, dist: 0, found: false };
|
||||
return {
|
||||
point: { id: res.id, coords: res.coords, value: res.value },
|
||||
dist: res.dist,
|
||||
found: true,
|
||||
};
|
||||
}
|
||||
async 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); }
|
||||
|
|
@ -54,21 +78,31 @@ 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
|
||||
instantiateWasm, // optional custom instantiator
|
||||
fetch: customFetch,
|
||||
} = options;
|
||||
|
||||
await ensureWasmExec(wasmExecURL);
|
||||
const go = new window.Go();
|
||||
const go = new globalThis.Go();
|
||||
|
||||
const fetchFn = customFetch || (isNode ? (async (url) => {
|
||||
const { fileURLToPath } = await import('url');
|
||||
const fs = await import('fs/promises');
|
||||
const path = fileURLToPath(url);
|
||||
const bytes = await fs.readFile(path);
|
||||
return new Response(bytes, { 'Content-Type': 'application/wasm' });
|
||||
}) : fetch);
|
||||
|
||||
|
||||
let result;
|
||||
if (instantiateWasm) {
|
||||
const source = await fetch(wasmURL).then(r => r.arrayBuffer());
|
||||
const source = await fetchFn(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 if (WebAssembly.instantiateStreaming && !isNode) {
|
||||
result = await WebAssembly.instantiateStreaming(fetchFn(wasmURL), go.importObject);
|
||||
} else {
|
||||
const resp = await fetch(wasmURL);
|
||||
const resp = await fetchFn(wasmURL);
|
||||
const bytes = await resp.arrayBuffer();
|
||||
result = await WebAssembly.instantiate(bytes, go.importObject);
|
||||
}
|
||||
|
|
@ -83,7 +117,7 @@ export async function init(options = {}) {
|
|||
newTree: async (dim) => {
|
||||
const info = call('pxNewTree', dim);
|
||||
return new PxTree(info.treeId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ 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,
|
||||
wasmURL: new URL('dist/poindexter.wasm', import.meta.url),
|
||||
wasmExecURL: new URL('dist/wasm_exec.js', import.meta.url),
|
||||
});
|
||||
const ver = await px.version();
|
||||
if (!ver || typeof ver !== 'string') throw new Error('version not string');
|
||||
|
|
@ -17,8 +16,8 @@ import { init } from './loader.js';
|
|||
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
|
||||
await tree.insert({ id: 'b', coords: [1, 0], value: 'B' });
|
||||
const nn = await tree.nearest([0.9, 0.1]);
|
||||
if (!nn || !nn.id) throw new Error('nearest failed');
|
||||
console.log('WASM smoke ok:', ver, 'nearest.id=', nn.id);
|
||||
if (!nn || !nn.point) throw new Error('nearest failed');
|
||||
console.log('WASM smoke ok:', ver, 'nearest.id=', nn.point.id);
|
||||
} catch (err) {
|
||||
console.error('WASM smoke failed:', err);
|
||||
process.exit(1);
|
||||
|
|
|
|||
33
wasm/main.go
33
wasm/main.go
|
|
@ -151,11 +151,12 @@ func nearest(_ js.Value, args []js.Value) (any, error) {
|
|||
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,
|
||||
if !found {
|
||||
return map[string]any{"found": false}, nil
|
||||
}
|
||||
out := kdPointToJS(p)
|
||||
out["dist"] = d
|
||||
out["found"] = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -177,9 +178,13 @@ func kNearest(_ js.Value, args []js.Value) (any, error) {
|
|||
pts, dists := t.KNearest(query, k)
|
||||
jsPts := make([]any, len(pts))
|
||||
for i, p := range pts {
|
||||
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
|
||||
jsPts[i] = kdPointToJS(p)
|
||||
}
|
||||
return map[string]any{"points": jsPts, "dists": dists}, nil
|
||||
jsDists := make([]any, len(dists))
|
||||
for i, d := range dists {
|
||||
jsDists[i] = d
|
||||
}
|
||||
return map[string]any{"points": jsPts, "dists": jsDists}, nil
|
||||
}
|
||||
|
||||
func radius(_ js.Value, args []js.Value) (any, error) {
|
||||
|
|
@ -200,9 +205,13 @@ func radius(_ js.Value, args []js.Value) (any, error) {
|
|||
pts, dists := t.Radius(query, r)
|
||||
jsPts := make([]any, len(pts))
|
||||
for i, p := range pts {
|
||||
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}
|
||||
jsPts[i] = kdPointToJS(p)
|
||||
}
|
||||
return map[string]any{"points": jsPts, "dists": dists}, nil
|
||||
jsDists := make([]any, len(dists))
|
||||
for i, d := range dists {
|
||||
jsDists[i] = d
|
||||
}
|
||||
return map[string]any{"points": jsPts, "dists": jsDists}, nil
|
||||
}
|
||||
|
||||
func exportJSON(_ js.Value, args []js.Value) (any, error) {
|
||||
|
|
@ -223,6 +232,14 @@ func exportJSON(_ js.Value, args []js.Value) (any, error) {
|
|||
return string(b), nil
|
||||
}
|
||||
|
||||
func kdPointToJS(p pd.KDPoint[string]) map[string]any {
|
||||
coords := make([]any, len(p.Coords))
|
||||
for i, v := range p.Coords {
|
||||
coords[i] = v
|
||||
}
|
||||
return map[string]any{"id": p.ID, "coords": coords, "value": p.Value}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Export core API
|
||||
export("pxVersion", version)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue