This file provides guidance to coding agents collaborating on this repository.
Hann is an approximate nearest neighbor search library for Go. It provides a set of index data structures (HNSW, PQIVF, and RPT) behind one interface, with distance computation written in C and vectorized with AVX instructions. Priorities, in order:
- Correctness of index operations: insertion, deletion, update, and search must keep the index consistent.
- Search quality and speed, measured by recall and query latency on the example datasets.
- Clean separation between the shared interface and helpers (
core/) and the index implementations (hnsw/,pqivf/, andrpt/). - Safety of the cgo boundary: no out-of-bounds reads, and no pointers into Go memory that outlive the call.
- Use English for code, comments, docs, and tests.
- Prefer small, focused changes over large refactoring.
- Add comments only when they clarify non-obvious behavior.
- Do not add features, error handling, or abstractions beyond what is needed for the current task.
- Keep external dependencies minimal: do not add new
go.modentries without prior discussion.
Hann is a public Go module that other programs import. The following must stay backward-compatible:
- The
core.Indexinterface. Adding a method breaks every implementation outside this repository, so a new capability belongs on the concrete index types, or on a separate optional interface that callers can assert, likecore.BulkIndexandcore.Trainer. - Exported types and constructor signatures (
hnsw.New,pqivf.New, andrpt.New, each returning(*Index, error)). A new tuning parameter is a new functional option, never a change to an existing signature. - The shapes of
core.Neighborandcore.IndexStats: fields may be added, not removed or renamed. - The gob encoding written by
Save. An index file written by an older version must still load. TheserializedIndex,serializedPQIVF, andrptSerializedstructs are the on-disk format, so fields may be added with sensible zero values, but they may not be removed, renamed, or reordered in meaning. - The names of the built-in metrics in the
coreregistry and the names reported byIndexStats.Distance. - The environment variables
HANN_SEEDandHANN_BENCH_NTRD, along with the values they accept.HANN_LOGis accepted and ignored, because the library no longer logs. - The minimum Go version declared in
go.mod. Raising it drops users, so it is a deliberate decision, not a side effect of using a newer standard library function.
- Use Oxford commas in inline lists: "a, b, and c" not "a, b, c".
- Do not use em dashes, in documentation or in code comments. Restructure the sentence, or use a colon or semicolon instead.
- Avoid colorful adjectives and adverbs. Write "rate limiter" not "smart rate limiter".
- Prefer noun phrases for checklist items over imperative verbs. Write "rate limit enforcement" not "enforce rate limits".
- Headings in Markdown files must be in title case: "Build from Source" not "Build from source". Minor words stay lowercase unless they are the first word: the articles (a, an, the), the coordinating conjunctions (and, but, or, nor, so, yet, for), and the short prepositions (in, on, at, to, by, of, up, as, from, with, into, over). The prepositions are named because "from" has to be lowercase for "Build from Source" to be correct.
- Do not bold the lead-in of a list item. Write "Unit tests: ..." not "Unit tests: ...".
- Use sentence case for the lead-in of a list item. Write "Seed selection: ..." not "Seed Selection: ...". Proper nouns keep their capitals.
- Capitalize only the first part of a hyphenated compound: "Nearest-neighbor Search" in a heading, "Nearest-neighbor" at the start of a sentence, and "nearest-neighbor search" elsewhere. Never write "Nearest-Neighbor".
- Start each sentence with a capital letter, capitalize proper nouns (Go, AVX, SIMD, HNSW, PQIVF, RPT), and leave common nouns lowercase in the middle of a sentence.
- Write correct and complete sentences.
- Avoid made-up words.
- Do not use a colon in place of a verb. Three uses are fine: joining two clauses inside a complete sentence (the replacement the em-dash rule above
calls for), introducing the gloss of a list item, and introducing an enumeration, whether as a list or inline ("Targets:
make test,make lint, ..."). What a colon must not do is turn a sentence into a label and a definition: write "Splits a vector into subspaces, then quantizes each one" rather than "Product quantization: splits a vector into subspaces". That shape belongs to a list item, and carrying it into prose (a doc comment summary, a paragraph) leaves a fragment where a sentence was required. - Use participial phrases and abbreviations scarcely.
core/: the shared interface and helpers.index.godeclaresIndex, the optionalBulkIndexandTrainerinterfaces,Neighbor, andIndexStats;distance.gowraps the C distance functions;metric.godeclaresMetricand the metric registry;vector_ops.goholds normalization, single and batched;cpu_check.godetects AVX and AVX2 on x86, selects NEON on arm64, and tells the C side which implementation to install;utils.goreadsHANN_SEED.core/*.candcore/*.h: the C implementations.simd_distance.cholds Euclidean, squared Euclidean, Manhattan, and cosine distance;simd_ops.cholds normalization and thehann_cpu_initentry point. Each function has a fallback variant, AVX and AVX2 variants on x86_64, and a NEON variant on arm64, selected once through a function pointer. The vector bodies are written once insimd_kernels.inc.hagainst a macro vocabulary,simd_kernels_avx.inc.h,simd_kernels_avx2.inc.h, andsimd_kernels_neon.inc.hinstantiate them per ISA, andsimd_isa.hdeclares the target attribute macros and the shared reduction helper.hnsw/index.go: the HNSW graph index, its layered neighbor lists, and its gob codec.pqivf/index.go: the PQIVF index, coarse clustering, product quantization, andTrain.rpt/index.go: the RPT index and its random projection tree.example/: dataset loading (load_data.go), shared helpers and recall computation (utils.go), and the index runners (run_datasets.go).example/cmd/: onemainper example and per benchmark, run withgo run. This directory is excluded from the test and lint targets.example/data/: the dataset download script and notes on the datasets..github/workflows/: CI workflows for tests and lints.Makefile: all developer tasks (format, test, lint, examples, benchmarks, and datasets).
Hann is organized into three layers that should not have upward dependencies:
core/: the interface, distance functions, and vector operations; no knowledge of any index.hnsw/,pqivf/, andrpt/: index implementations; each depends oncore/and on nothing else in the repository, and the three never import each other.example/andexample/cmd/: programs that exercise the indexes; nothing imports them.
- An index is reached through
core.Index. Code that is written against a concrete type gives up the ability to swap indexes, so keep example and benchmark code on the interface where the operation is part of it. Capabilities outside the interface are reached through the optional interfaces:core.Trainerfor training, andcore.BulkIndex(or thecore.BulkAdd,core.BulkDelete, andcore.BulkUpdatehelpers) for batches. - A metric travels as one
core.Metricvalue that bundles the name, the distance function, and the normalization requirement. The name is whatStatsreports and what the gob codec stores, and loading resolves it through the registry, so a custom metric must be registered withcore.RegisterMetricbeforeLoad. Never switch behavior on a metric's name; useMetric.Normalizes. - Each index owns a mutex and is safe for concurrent use through its exported methods. The unexported helpers assume the lock is already held. Keep that split: a helper must not take the lock itself, and an exported method must not call another exported method of the same index while holding it.
- Random behavior goes through the package-level
seededRand, guarded byseededRandMu, so a run withHANN_SEEDset is reproducible. Do not call the globalmath/randfunctions, and do not create a new generator per operation. - The library does not log. Failures are reported through returned errors, and conditions the caller cannot see otherwise, such as a search falling
back to a brute-force scan, are counted in
IndexStats. Do not add log lines; extend the stats instead.
core/distance.go and core/vector_ops.go pass &slice[0] into C together with the length. The rules that keep this sound:
- Check for an empty slice before taking the address of its first element, and check that both operands have the same length before the call. The C side trusts the length it is given.
- Do not retain a Go pointer on the C side. Every call reads or writes the vector and returns.
- A new distance function needs a scalar fallback, a kernel body in
simd_kernels.inc.hwhose instantiations provide the AVX, AVX2, and NEON variants, an entry in the function pointer table ininit_distance_functions, a declaration in the header, and acore.Metricvalue pre-registered incore/metric.go. The same applies to its batch variant, which computes the distances from one query to a flat buffer of candidate vectors and backsMetric.DistanceBatchandMetric.RankBatch; the batch loop lives once insimd_kernels.inc.hand calls the per-pair kernel of the same instantiation. - The AVX variants carry per-function target attributes behind the
HANN_TARGET_AVXmacro and are selected at runtime byhann_cpu_init. A machine without AVX must still build and run through the fallback path, which is why no-mISA flag may appear in the cgo CFLAGS. The NEON variants compile behind the architecture guard alone, because every arm64 CPU has NEON, andhann_cpu_initinstalls them unconditionally on arm64. NormalizeBatchfans out over a worker pool, so each worker must own its own vector. Do not share a slice between tasks.
Save and Load are gob over an io.Writer and an io.Reader. Each index has a serialized form (a plain struct of exported fields) and a
GobEncode/GobDecode pair that converts between it and the live index, which is what lets pointer-linked structures such as the HNSW graph and the
RPT tree round-trip. Types are registered in each package's init. A change to a serialized struct is a change to the on-disk format, so read the
backward compatibility section above before making one.
- Go version: the minimum is declared in
go.mod, and CI runs the test suite against every release from that version onward. - A C compiler is needed, because
core/uses cgo.CGO_ENABLED=0does not produce a working build. - Formatting is enforced by
gofmt(viamake format). Run it before committing. - Naming follows Go standard conventions:
PascalCasefor exported identifiers,camelCasefor unexported identifiers and local variables, andSCREAMING_SNAKE_CASEfor top-level constants where idiomatic. - Errors are returned, never logged and swallowed. Wrap with context using
fmt.Errorf("…: %w", err)so callers can useerrors.Is/errors.As. - The index packages and
coremust not log or print; diagnostic state belongs in returned errors andIndexStats. The example programs use the standard librarylogpackage. - Every exported identifier carries a doc comment that starts with its name.
Run the relevant targets for any change:
| Target | Command | What It Runs |
|---|---|---|
| Format | make format |
go fmt ./... |
| Unit tests | make test |
go test with coverage and the race detector |
| Lint | make lint |
golangci-lint run ./... |
| Coverage report | make showcov |
Displays per-function coverage after running the tests |
| Examples | make run-examples |
Runs the examples that use the small datasets |
| Large examples | make run-examples-large |
Runs the examples that use the large datasets |
| Benchmarks | make run-benches |
Runs the local benchmarks |
| Go benchmarks | make bench |
Runs the Go benchmarks for the kernels and the indexes |
| Datasets | make download-data |
Downloads the datasets the examples use |
| Large datasets | make download-data-large |
Downloads the large datasets |
| Git hooks | make setup-hooks |
Installs the pre-commit and pre-push hooks |
The examples and the benchmarks need the datasets, so run make download-data first. The large variants need a machine with a lot of memory (32 GB or
more).
- Read
core/index.goto see the contract, then the index package the change touches. - Add or update
_test.gofiles in the changed package to describe the new behavior. - Run
make testand watch the new test fail. - Implement the smallest change that makes it pass.
- Run
make testandmake lintagain, then refactor with the tests green. - If the change touches search, distance computation, or serialization, also run
make run-examplesand check that the reported recall has not dropped.
Good first tasks:
- New unit test for an existing untested helper in
core/. - Error message refinement in an index package, paired with a test that asserts the returned error.
- New
maketarget or script improvement inMakefileorexample/data/download_datasets.sh. - A doc comment for an exported identifier that lacks one.
Follow a red-green cycle. Write the test first, run it, and see it fail for the reason you expect; a test that passes before the change is not testing the change. Then write the smallest change that makes it pass, and refactor with the test green. A bug fix starts with a test that reproduces the bug, so the failure is captured before it disappears.
- Unit tests live in
_test.gofiles alongside the package they cover. The index packages are tested from outside (package hnsw_test), so the tests exercise the exported surface the same way a user does. - Each index package splits its tests into six files by kind, and a new test belongs in the matching file:
index_test.gofor behavioral unit tests and shared helpers,quality_test.gofor recall and differential tests,concurrency_test.gofor stress and race tests,property_test.gofor property-based tests,golden_test.gofor the golden fixture test and the-updateflag, andbench_test.gofor benchmarks. - Every new exported function or behavior change must ship with at least one test that exercises it, including error paths where applicable.
- Test the interface, not the internals. An index test asserts on
Searchresults, onStats, and on returned errors, not on the shape of the graph or the tree. - Cover the four operations that can leave an index inconsistent: delete, bulk delete, update, and bulk update. Each must be followed by a search that shows the removed ids are gone and the surviving ids are still reachable.
- Serialization round-trips are tested by saving to a buffer or a
t.TempDir()file, loading into a fresh index, and comparing search results with the original. Do not write into the repository. - Set
HANN_SEEDin a test that depends on the outcome of a random choice, and use the returned error rather than an exact distance value where the result is approximate. - Run the race detector on anything that touches goroutines, which includes bulk operations, parallel search, and
NormalizeBatch:go test -race -count=1on the affected packages, more than once. - A test must not depend on the example datasets, because they are downloaded separately and are not present in CI.
- Tests for the AVX paths must still pass on a machine without AVX, so assert on distance values with a tolerance rather than on bit-exact equality.
Before coding:
- Packages affected by the change (
core,hnsw,pqivf,rpt, orexample). - Whether the change alters the
core.Indexinterface, an exported signature, or the gob format. - Whether the change touches C code, and if so, whether every one of the fallback, AVX, AVX2, and NEON paths was updated.
- Whether a new external dependency is required, and if so, whether it has been discussed.
- Whether the change affects recall or query latency, and how that will be measured.
Before submitting:
make formatpasses (no diff).make testpasses with the race detector enabled.make lintpasses.- The examples run locally if the change touches search, distance computation, or serialization.
- Keep commits scoped to one logical change.
- PR descriptions should include:
- Behavioral change summary.
- Tests added or updated.
- Whether the examples or the benchmarks were run locally (yes/no), and on which CPU, since the SIMD path taken depends on it.