diff --git a/internal/core/diskann.go b/internal/core/diskann.go index f290f50..b1d4146 100644 --- a/internal/core/diskann.go +++ b/internal/core/diskann.go @@ -66,7 +66,7 @@ func DefaultDiskANNBuildOptions(metric Metric) DiskANNBuildOptions { // Validate checks invariants that do not depend on vector dimension. func (o DiskANNBuildOptions) Validate() error { - if !o.Metric.valid() { + if !o.Metric.Valid() { return fmt.Errorf("%w: invalid metric", ErrInvalidDiskANNOptions) } if o.MaxDegree <= 0 || o.MaxDegree > MaxVamanaDegree { diff --git a/internal/core/diskann_storage.go b/internal/core/diskann_storage.go index eef5729..3782657 100644 --- a/internal/core/diskann_storage.go +++ b/internal/core/diskann_storage.go @@ -59,7 +59,7 @@ type DiskANNLayout struct { // NewDiskANNLayout calculates the pinned packed-or-multi-sector node layout. func NewDiskANNLayout(metric Metric, count, dimension, maxDegree int) (DiskANNLayout, error) { - if !metric.valid() { + if !metric.Valid() { return DiskANNLayout{}, fmt.Errorf("%w: invalid metric", ErrInvalidDiskANNLayout) } if count < 0 || uint64(count) > math.MaxUint32 { diff --git a/internal/core/flat.go b/internal/core/flat.go index e0c7662..183f351 100644 --- a/internal/core/flat.go +++ b/internal/core/flat.go @@ -81,7 +81,7 @@ func NewDenseFlatIndex(dimension int, metric Metric) (*DenseFlatIndex, error) { if dimension <= 0 { return nil, fmt.Errorf("%w: got %d", ErrInvalidDimension, dimension) } - if !metric.valid() { + if !metric.Valid() { return nil, errors.New("core: invalid metric") } return &DenseFlatIndex{ @@ -244,10 +244,6 @@ func (b *DenseFlatIndexBuilder) Build(ctx context.Context) (DenseIndex, error) { return b.index, nil } -func (m Metric) valid() bool { - return m >= MetricL2 && m <= MetricMIPSL2 -} - var ( _ DenseProvider = (*DenseFlatIndex)(nil) _ DenseSearcher = (*DenseFlatIndex)(nil) @@ -698,7 +694,7 @@ func QueryDenseGroups( if err := ctx.Err(); err != nil { return nil, err } - if !metric.valid() { + if !metric.Valid() { return nil, errors.New("core: invalid group-by metric") } if err := options.Validate(); err != nil { diff --git a/internal/core/hnsw.go b/internal/core/hnsw.go index 292206e..5e7b585 100644 --- a/internal/core/hnsw.go +++ b/internal/core/hnsw.go @@ -67,7 +67,7 @@ func DefaultHNSWBuildOptions(metric Metric) HNSWBuildOptions { // Validate checks graph degree and construction-search invariants. func (o HNSWBuildOptions) Validate() error { - if !o.Metric.valid() { + if !o.Metric.Valid() { return fmt.Errorf("%w: invalid metric", ErrInvalidHNSWOptions) } if o.M <= 0 || o.M > MaxHNSWM { @@ -173,7 +173,7 @@ func (b *HNSWBuilder) build(ctx context.Context, workers int) (*HNSWIndex, error if err := ctx.Err(); err != nil { return nil, err } - distance, err := b.options.Metric.prevalidatedDistance() + distance, err := b.options.Metric.PrevalidatedDistance() if err != nil { return nil, err } @@ -559,7 +559,7 @@ func (i *HNSWIndex) computeDistance(left, right []float32) (float32, error) { // Keep package-local literal fixtures usable while production indexes // always install the scorer at build or open time. var err error - distance, err = i.options.Metric.prevalidatedDistance() + distance, err = i.options.Metric.PrevalidatedDistance() if err != nil { return 0, err } @@ -1203,7 +1203,7 @@ func decodeHNSWIndex(ctx context.Context, encoded []byte) (*HNSWIndex, error) { if count > maxPlatformInt()/dimension { return nil, fmt.Errorf("%w: vector storage exceeds platform capacity", ErrInvalidHNSWFile) } - distance, err := options.Metric.prevalidatedDistance() + distance, err := options.Metric.PrevalidatedDistance() if err != nil { return nil, fmt.Errorf("%w: invalid metric", ErrInvalidHNSWFile) } diff --git a/internal/core/ivf.go b/internal/core/ivf.go index 8132662..7802321 100644 --- a/internal/core/ivf.go +++ b/internal/core/ivf.go @@ -63,7 +63,7 @@ func DefaultIVFBuildOptions(metric Metric) IVFBuildOptions { // Validate checks IVF build invariants. func (o IVFBuildOptions) Validate() error { - if !o.Metric.valid() { + if !o.Metric.Valid() { return fmt.Errorf("%w: invalid metric", ErrInvalidIVFOptions) } if o.NList <= 0 { diff --git a/internal/core/kmeans.go b/internal/core/kmeans.go index 0b6c7d0..ef26d4f 100644 --- a/internal/core/kmeans.go +++ b/internal/core/kmeans.go @@ -299,7 +299,7 @@ func validateKMeansOptions(options KMeansOptions) error { if options.Tolerance < 0 || math.IsNaN(options.Tolerance) || math.IsInf(options.Tolerance, 0) { return fmt.Errorf("%w: Tolerance must be finite and non-negative", ErrInvalidKMeansOptions) } - if !options.Metric.valid() { + if !options.Metric.Valid() { return fmt.Errorf("%w: invalid metric", ErrInvalidKMeansOptions) } if options.Initializer != KMeansInitReservoir && options.Initializer != KMeansInitPlusPlus { @@ -512,7 +512,7 @@ func cloneVectorsContext(ctx context.Context, vectors [][]float32) ([][]float32, } func (m *KMeansModel) validate() error { - if m == nil || !m.metric.valid() || m.dimension <= 0 || len(m.centroids) == 0 { + if m == nil || !m.metric.Valid() || m.dimension <= 0 || len(m.centroids) == 0 { return errors.New("core: invalid k-means model") } return nil diff --git a/internal/core/metric/metric.go b/internal/core/metric/metric.go new file mode 100644 index 0000000..5812e66 --- /dev/null +++ b/internal/core/metric/metric.go @@ -0,0 +1,78 @@ +// Copyright 2026-present the xvec project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package metric provides dense-vector score computation and ordering. +package metric + +import ( + "errors" + + "github.com/gorse-io/xvec/internal/ailego/math" +) + +// Metric selects score computation and ordering for vector search. +type Metric uint8 + +const ( + L2 Metric = iota + 1 + IP + Cosine + MIPSL2 +) + +// Compute calculates the score for left and right. +func (m Metric) Compute(left, right []float32) (float32, error) { + switch m { + case L2: + return mathutil.L2Squared(left, right) + case IP: + return mathutil.InnerProduct(left, right) + case Cosine: + return mathutil.CosineDistance(left, right) + case MIPSL2: + return mathutil.MIPSL2Squared(left, right) + default: + return 0, errors.New("core: invalid metric") + } +} + +// PrevalidatedDistance selects the allocation-free kernel used by index hot +// paths after vectors have passed their storage or query boundary validation. +func (m Metric) PrevalidatedDistance() (mathutil.DenseDistance, error) { + switch m { + case L2: + return mathutil.L2SquaredPrevalidated, nil + case IP: + return mathutil.InnerProductPrevalidated, nil + case Cosine: + return mathutil.CosineDistancePrevalidated, nil + case MIPSL2: + return mathutil.MIPSL2SquaredPrevalidated, nil + default: + return nil, errors.New("core: invalid metric") + } +} + +// Better reports whether left should rank before right. +func (m Metric) Better(left, right float32) bool { + if m == IP { + return left > right + } + return left < right +} + +// Valid reports whether m identifies a supported metric. +func (m Metric) Valid() bool { + return m >= L2 && m <= MIPSL2 +} diff --git a/internal/core/metric/metric_test.go b/internal/core/metric/metric_test.go new file mode 100644 index 0000000..aeaebc7 --- /dev/null +++ b/internal/core/metric/metric_test.go @@ -0,0 +1,37 @@ +// Copyright 2026-present the xvec project +// +// Licensed under the Apache License, Version 2.0 (the "License"); + +package metric + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMetricComputeOrderingAndValidation(t *testing.T) { + t.Parallel() + + left := []float32{0.2, 0.9, -0.4, 0.7} + right := []float32{0.3, 0.5, 0.8, -0.1} + for _, value := range []Metric{L2, IP, Cosine, MIPSL2} { + require.True(t, value.Valid()) + expected, err := value.Compute(left, right) + require.NoError(t, err) + distance, err := value.PrevalidatedDistance() + require.NoError(t, err) + actual, err := distance(left, right) + require.NoError(t, err) + require.Equal(t, expected, actual) + } + + require.False(t, Metric(0).Valid()) + _, err := Metric(0).Compute(left, right) + require.Error(t, err) + _, err = Metric(0).PrevalidatedDistance() + require.Error(t, err) + + require.True(t, IP.Better(2, 1)) + require.True(t, L2.Better(1, 2)) +} diff --git a/internal/core/quantization.go b/internal/core/quantization.go index e661455..05392f6 100644 --- a/internal/core/quantization.go +++ b/internal/core/quantization.go @@ -169,7 +169,7 @@ func (v QuantizedVector) Decode() ([]float32, error) { // QuantizedDistance calculates a metric directly from scalar codes. Both // vectors must use the same encoding and logical dimension. func QuantizedDistance(metric Metric, left, right QuantizedVector) (float32, error) { - if !metric.valid() { + if !metric.Valid() { return 0, errors.New("core: invalid metric") } if err := left.validate(); err != nil { diff --git a/internal/core/quantized_index.go b/internal/core/quantized_index.go index 509261b..d250248 100644 --- a/internal/core/quantized_index.go +++ b/internal/core/quantized_index.go @@ -197,7 +197,7 @@ func newScalarQuantizedVectors( if dimension <= 0 || dimension > MaxRotationDimension { return nil, fmt.Errorf("%w: got %d", ErrInvalidDimension, dimension) } - if !metric.valid() { + if !metric.Valid() { return nil, errors.New("core: invalid scalar-quantized index metric") } if !kind.valid() { diff --git a/internal/core/query.go b/internal/core/query.go index bc66d74..db0bffd 100644 --- a/internal/core/query.go +++ b/internal/core/query.go @@ -82,7 +82,7 @@ func QueryDense( if err := ctx.Err(); err != nil { return nil, err } - if !metric.valid() { + if !metric.Valid() { return nil, errors.New("core: invalid metric") } if err := options.Validate(); err != nil { diff --git a/internal/core/refiner.go b/internal/core/refiner.go index 974ad61..10e5dde 100644 --- a/internal/core/refiner.go +++ b/internal/core/refiner.go @@ -47,7 +47,7 @@ func NewOriginalVectorRefiner(provider DenseProvider, metric Metric) (*OriginalV if provider.Dimension() <= 0 { return nil, ErrInvalidDimension } - if !metric.valid() { + if !metric.Valid() { return nil, errors.New("core: invalid metric") } return &OriginalVectorRefiner{provider: provider, metric: metric}, nil diff --git a/internal/core/topk.go b/internal/core/topk.go index 5b4a9f8..d5565b8 100644 --- a/internal/core/topk.go +++ b/internal/core/topk.go @@ -23,16 +23,17 @@ import ( "github.com/gorse-io/xvec/internal/ailego/container" "github.com/gorse-io/xvec/internal/ailego/math" "github.com/gorse-io/xvec/internal/ailego/parallel" + "github.com/gorse-io/xvec/internal/core/metric" ) // Metric selects score computation and ordering for exact search. -type Metric uint8 +type Metric = metric.Metric const ( - MetricL2 Metric = iota + 1 - MetricIP - MetricCosine - MetricMIPSL2 + MetricL2 = metric.L2 + MetricIP = metric.IP + MetricCosine = metric.Cosine + MetricMIPSL2 = metric.MIPSL2 ) // Candidate is one immutable dense vector considered by exact search. @@ -48,47 +49,6 @@ type Result struct { Score float32 } -// Compute calculates the score for left and right. -func (m Metric) Compute(left, right []float32) (float32, error) { - switch m { - case MetricL2: - return mathutil.L2Squared(left, right) - case MetricIP: - return mathutil.InnerProduct(left, right) - case MetricCosine: - return mathutil.CosineDistance(left, right) - case MetricMIPSL2: - return mathutil.MIPSL2Squared(left, right) - default: - return 0, errors.New("core: invalid metric") - } -} - -// prevalidatedDistance selects the allocation-free kernel used by index hot -// paths after vectors have passed their storage or query boundary validation. -func (m Metric) prevalidatedDistance() (mathutil.DenseDistance, error) { - switch m { - case MetricL2: - return mathutil.L2SquaredPrevalidated, nil - case MetricIP: - return mathutil.InnerProductPrevalidated, nil - case MetricCosine: - return mathutil.CosineDistancePrevalidated, nil - case MetricMIPSL2: - return mathutil.MIPSL2SquaredPrevalidated, nil - default: - return nil, errors.New("core: invalid metric") - } -} - -// Better reports whether left should rank before right. -func (m Metric) Better(left, right float32) bool { - if m == MetricIP { - return left > right - } - return left < right -} - // TopK computes exact scores and returns at most k results. It uses O(k) // memory and checks ctx between candidates. func TopK( diff --git a/internal/core/topk_test.go b/internal/core/topk_test.go index 160e142..4e853b1 100644 --- a/internal/core/topk_test.go +++ b/internal/core/topk_test.go @@ -85,24 +85,6 @@ func TestTopKMetricOrdering(t *testing.T) { } } -func TestMetricPrevalidatedDistanceMatchesCheckedCompute(t *testing.T) { - t.Parallel() - - left := []float32{0.2, 0.9, -0.4, 0.7} - right := []float32{0.3, 0.5, 0.8, -0.1} - for _, metric := range []Metric{MetricL2, MetricIP, MetricCosine, MetricMIPSL2} { - distance, err := metric.prevalidatedDistance() - require.NoError(t, err) - expected, err := metric.Compute(left, right) - require.NoError(t, err) - actual, err := distance(left, right) - require.NoError(t, err) - require.Equal(t, expected, actual) - } - _, err := Metric(0).prevalidatedDistance() - require.Error(t, err) -} - func TestTopKStableAcrossCandidateOrder(t *testing.T) { t.Parallel() diff --git a/internal/core/vamana.go b/internal/core/vamana.go index deb4f90..de749a5 100644 --- a/internal/core/vamana.go +++ b/internal/core/vamana.go @@ -64,7 +64,7 @@ func DefaultVamanaBuildOptions(metric Metric) VamanaBuildOptions { // Validate checks graph degree, construction width, and RobustPrune settings. func (o VamanaBuildOptions) Validate() error { - if !o.Metric.valid() { + if !o.Metric.Valid() { return fmt.Errorf("%w: invalid metric", ErrInvalidVamanaOptions) } if o.MaxDegree <= 0 || o.MaxDegree > MaxVamanaDegree {