>From Ali Alsuliman <[email protected]>:
Ali Alsuliman has uploaded this change for review. (
https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21686?usp=email )
Change subject: [ASTERIXDB-3817][STO] Share the scalar quantization formula
......................................................................
[ASTERIXDB-3817][STO] Share the scalar quantization formula
Two paths encode vectors for the same index. Bulk load and
static-structure build go through OptimizedScalarQuantizationCodec in
asterix-common; DML insert goes through VTreeDataTupleBuilder in
hyracks-storage-am-vtree, which cannot depend on asterix-common and so
re-implemented the arithmetic rather than calling it. Nothing compared
the two.
Drift between them would be close to invisible. Both paths keep
producing well-formed codes, the index stays structurally valid, and
every functional test still passes; what changes is that a DML-inserted
row's codes are no longer on the same scale as a bulk-loaded row's, so
approximate distances against them carry a bias that surfaces only as
recall falling on datasets that have been updated. No assertion about
which records came back can see that.
asterix-common already depends on hyracks-storage-am-vtree --
OptimizedScalarQuantizerFactory implements IVTreeQuantizerFactory -- so
the shared formula goes in VTreeScalarQuantization there and both
callers delegate down that existing edge. No new dependency, and the
codec keeps its validation, its Params/SimilarityFunction types and its
choice of storage width; only the per-dimension arithmetic moves.
They already agreed, and this is drift prevention rather than a fix:
with both files as they were, the new test passes. The reachable
configurations are SQ4 and SQ8 -- VectorQuantization is a closed enum of
the two -- and the two implementations were identical over them. Two
asymmetries did exist, both needing bits > 8:
- The DML path applied Math.toIntExact before the clamp. The codec
clamps in long deliberately, because for bits near 32 the pre-clamp
value can exceed Integer.MAX_VALUE, so the DML path would have thrown
ArithmeticException where the codec clamps. Gone with the shared
formula.
- The DML path wrote byte[] whatever the bit width, while the codec
selects byte[]/short[]/int[]. VTree leaf storage is byte[] only, so a
wider code has nowhere to go; VTreeDataTupleBuilder now refuses it at
construction instead of truncating silently. Unreachable today, so
this fails loudly if the enum grows without the storage format
following.
quantizeToShort and quantizeToInt are noted as having no reachable
caller and no reader on the VTree side, rather than removed.
QuantizationPathAgreementTest compares the two encoders byte for byte,
driving the DML side through the real tuple builder and reading it back
with VTreeDataTupleAccessor so the varlen framing of the embedding field
is covered too. Also asserted: agreement across every enum value, so
adding a scheme lands here; the quantile endpoints mapping to 0 and
levels - 1, since two paths agreeing proves nothing if both have the
same wrong alpha; the clamp holding outside the range; and decode
inverting encode to within half a step.
The fixture needed a second pass, which is worth recording. Round
quantile values make alpha round too -- 68 at SQ8, 4 at SQ4 -- and the
first draft's 0.0 and 1.0 put every product on an exact integer, so
floor and round agreed on all of them. With one path deliberately
changed to truncate, the test passed. It now uses values that round up
at both widths, and requireFixtureIsSensitiveToRounding fails the test
if none do, so the fixture demonstrates its own sensitivity rather than
resting on arithmetic done by hand. With that in place the truncating
path fails at element [4]: expected 111, got 110 at SQ8; expected 7, got
6 at SQ4.
Ext-ref: MB-73194
Co-Authored-By: Claude Opus 5 <[email protected]>
Change-Id: Ib7520274f950948b4fc8b9dcaeb7f5173ea57f4c
---
M
asterixdb/asterix-common/src/main/java/org/apache/asterix/common/vector/OptimizedScalarQuantizationCodec.java
A
asterixdb/asterix-common/src/test/java/org/apache/asterix/common/vector/QuantizationPathAgreementTest.java
M
hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeDataTupleBuilder.java
A
hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/utils/VTreeScalarQuantization.java
4 files changed, 339 insertions(+), 38 deletions(-)
git pull ssh://asterix-gerrit.ics.uci.edu:29418/asterixdb
refs/changes/86/21686/1
diff --git
a/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/vector/OptimizedScalarQuantizationCodec.java
b/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/vector/OptimizedScalarQuantizationCodec.java
index ae8d294..8a828ba 100644
---
a/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/vector/OptimizedScalarQuantizationCodec.java
+++
b/asterixdb/asterix-common/src/main/java/org/apache/asterix/common/vector/OptimizedScalarQuantizationCodec.java
@@ -21,6 +21,7 @@
import org.apache.asterix.common.exceptions.ErrorCode;
import org.apache.asterix.common.exceptions.RuntimeDataException;
import org.apache.hyracks.api.exceptions.HyracksDataException;
+import org.apache.hyracks.storage.am.vector.utils.VTreeScalarQuantization;
/**
* Optimized scalar quantization (OSQ) utilities for vector indexes.
@@ -187,25 +188,16 @@
}
/*
- * Per-dimension scalar encode/decode contract (used by quantizeToByte,
quantizeToShort, quantizeToInt).
- *
- * Parameter source: minQ, maxQ, alpha, and bits come from Params
(minQuantile, maxQuantile, alpha, bits),
- * populated at index creation by QuantizationConstantsAggregate:
- * levels = 2^bits
- * alpha = (levels - 1) / (maxQ - minQ)
- *
- * Encode (dimension i):
- * v = clamp(x[i], minQ, maxQ)
- * q = clamp(round((v - minQ) * alpha), 0, levels - 1)
- *
- * Decode (inverse, see dequantizeToDoubleArray):
- * x_hat[i] = q / alpha + minQ (minQ = Params.minQuantile)
- *
- * Endpoints: v = minQ -> q = 0; v = maxQ -> q = levels - 1 (after clamp
and round).
- * Rounding: Math.round selects the nearest integer code (standard
nearest-bin scalar quant).
+ * The per-dimension encode/decode formula itself lives in
VTreeScalarQuantization, in
+ * hyracks-storage-am-vtree, because the DML insert path
(VTreeDataTupleBuilder) has to apply exactly
+ * the same arithmetic and cannot depend on this module. Read the
contract, the parameter sources and
+ * the endpoint behaviour there; this class owns the validation, the
Params/SimilarityFunction types,
+ * and the choice of storage width.
*
* Storage by bits: bits <= 8 -> byte[] (SQ4 uses codes 0..15 in byte[]);
<= 16 -> short[]; <= 32 -> int[].
* Java byte/short are signed; codes above 127 or 32767 appear negative
unless read with & 0xFF / & 0xFFFF.
+ * Only SQ4 and SQ8 are reachable today -- VectorQuantization is a closed
enum of the two -- and VTree
+ * leaf storage is byte[] only, so the short[] and int[] widths have no
reader on the DML side.
*
* Debugging: log params.bits, levels, minQ, maxQ, alpha; check min/max q
across dims; compare
* dequantizeToDoubleArray(quantizeVector(x)) against x for round-trip
error on sample vectors.
@@ -227,11 +219,7 @@
private static byte[] quantizeToByte(double[] vector, float minQ, float
maxQ, float alpha, int levels) {
byte[] quantized = new byte[vector.length];
for (int i = 0; i < vector.length; i++) {
- // clamp to global quantile range, then map to integer code in [0,
levels - 1]
- double value = Math.max(minQ, Math.min(maxQ, vector[i]));
- long quantizedValue = Math.round((value - minQ) * alpha);
- quantizedValue = Math.max(0, Math.min(levels - 1, quantizedValue));
- quantized[i] = (byte) quantizedValue;
+ quantized[i] = (byte)
VTreeScalarQuantization.encodeDimension(vector[i], minQ, maxQ, alpha, levels);
}
return quantized;
}
@@ -249,11 +237,7 @@
private static short[] quantizeToShort(double[] vector, float minQ, float
maxQ, float alpha, int levels) {
short[] quantized = new short[vector.length];
for (int i = 0; i < vector.length; i++) {
- // clamp to global quantile range, then map to integer code in [0,
levels - 1]
- double value = Math.max(minQ, Math.min(maxQ, vector[i]));
- long quantizedValue = Math.round((value - minQ) * alpha);
- quantizedValue = Math.max(0, Math.min(levels - 1, quantizedValue));
- quantized[i] = (short) quantizedValue;
+ quantized[i] = (short)
VTreeScalarQuantization.encodeDimension(vector[i], minQ, maxQ, alpha, levels);
}
return quantized;
}
@@ -274,11 +258,7 @@
private static int[] quantizeToInt(double[] vector, float minQ, float
maxQ, float alpha, int levels) {
int[] quantized = new int[vector.length];
for (int i = 0; i < vector.length; i++) {
- // clamp to global quantile range, then map to integer code in [0,
levels - 1]
- double value = Math.max(minQ, Math.min(maxQ, vector[i]));
- long quantizedValue = Math.round((value - minQ) * alpha);
- quantizedValue = Math.max(0, Math.min(levels - 1, quantizedValue));
- quantized[i] = (int) quantizedValue;
+ quantized[i] = (int)
VTreeScalarQuantization.encodeDimension(vector[i], minQ, maxQ, alpha, levels);
}
return quantized;
}
@@ -331,7 +311,7 @@
throw new
RuntimeDataException(ErrorCode.VECTOR_DIMENSION_MISMATCH, dims, bytes.length);
}
for (int i = 0; i < dims; i++) {
- result[i] = ((double) (bytes[i] & 0xFF)) / params.alpha +
params.minQuantile;
+ result[i] = VTreeScalarQuantization.decodeDimension(bytes[i] &
0xFF, params.alpha, params.minQuantile);
}
} else if (bits <= 16) {
// short[] - treat as unsigned
@@ -344,7 +324,8 @@
throw new
RuntimeDataException(ErrorCode.VECTOR_DIMENSION_MISMATCH, dims, shorts.length);
}
for (int i = 0; i < dims; i++) {
- result[i] = ((double) (shorts[i] & 0xFFFF)) / params.alpha +
params.minQuantile;
+ result[i] =
+ VTreeScalarQuantization.decodeDimension(shorts[i] &
0xFFFF, params.alpha, params.minQuantile);
}
} else if (bits <= 32) {
// int[] - treat as unsigned
@@ -357,7 +338,8 @@
throw new
RuntimeDataException(ErrorCode.VECTOR_DIMENSION_MISMATCH, dims, ints.length);
}
for (int i = 0; i < dims; i++) {
- result[i] = ((double) (ints[i] & 0xFFFFFFFFL)) / params.alpha
+ params.minQuantile;
+ result[i] = VTreeScalarQuantization.decodeDimension(ints[i] &
0xFFFFFFFFL, params.alpha,
+ params.minQuantile);
}
} else {
throw new RuntimeDataException(ErrorCode.ILLEGAL_STATE,
diff --git
a/asterixdb/asterix-common/src/test/java/org/apache/asterix/common/vector/QuantizationPathAgreementTest.java
b/asterixdb/asterix-common/src/test/java/org/apache/asterix/common/vector/QuantizationPathAgreementTest.java
new file mode 100644
index 0000000..cbfc55f
--- /dev/null
+++
b/asterixdb/asterix-common/src/test/java/org/apache/asterix/common/vector/QuantizationPathAgreementTest.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.asterix.common.vector;
+
+import org.apache.hyracks.api.dataflow.value.ISerializerDeserializer;
+import org.apache.hyracks.api.exceptions.HyracksDataException;
+import org.apache.hyracks.dataflow.common.comm.io.ArrayTupleBuilder;
+import org.apache.hyracks.dataflow.common.comm.io.ArrayTupleReference;
+import org.apache.hyracks.dataflow.common.data.accessors.ITupleReference;
+import
org.apache.hyracks.dataflow.common.data.marshalling.DoubleArraySerializerDeserializer;
+import
org.apache.hyracks.dataflow.common.data.marshalling.Integer64SerializerDeserializer;
+import org.apache.hyracks.dataflow.common.utils.TupleUtils;
+import org.apache.hyracks.storage.am.vector.api.VTreeQuantizationParams;
+import org.apache.hyracks.storage.am.vector.impls.VTreeDataTupleBuilder;
+import org.apache.hyracks.storage.am.vector.utils.VTreeDataTupleAccessor;
+import org.apache.hyracks.storage.am.vector.utils.VTreeScalarQuantization;
+import org.apache.hyracks.util.annotations.AiProvenance;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Two paths encode vectors for the same index: bulk load and static-structure
build go through {@link
+ * OptimizedScalarQuantizationCodec} here in {@code asterix-common}, and DML
insert goes through {@code
+ * VTreeDataTupleBuilder} in {@code hyracks-storage-am-vtree}, which cannot
depend on this module. They
+ * used to implement the arithmetic independently, and nothing compared them.
+ * <p>
+ * Drift between them is close to invisible. Both paths keep producing
well-formed codes, the index stays
+ * structurally valid, and every functional test still passes; what changes is
that a DML-inserted row's
+ * codes are no longer on the same scale as a bulk-loaded row's, so
approximate distances computed against
+ * them are wrong by a bias that only shows up as recall falling on datasets
that have been updated. No
+ * assertion about which records came back can see that.
+ * <p>
+ * These tests compare the two encoders on identical input, byte for byte. The
DML side is driven through
+ * the real tuple builder and read back with the production accessor, so the
comparison covers the varlen
+ * framing of the embedding field as well as the arithmetic.
+ */
+@AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool =
AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind =
AiProvenance.ContributionKind.TEST_GENERATED)
+public class QuantizationPathAgreementTest {
+
+ private static final float MIN_Q = -1.25f;
+ private static final float MAX_Q = 2.5f;
+
+ /**
+ * Both endpoints, values outside the quantile range on each side (the
clamp is as much part of the
+ * formula as the scaling, and a divergence there would only affect
outliers), and interior values
+ * chosen so that some dimension's pre-round product has a fractional part
that rounds up at each
+ * supported bit width.
+ * <p>
+ * That last property is not decoration. {@code alpha} is {@code (levels -
1) / (maxQ - minQ)}, so round
+ * numbers for the quantiles make it a round number too -- here 68 at SQ8
and 4 at SQ4 -- and an
+ * obvious-looking fixture of {@code 0.0} and {@code 1.0} puts every
product on an exact integer. The
+ * two paths then agree even if one of them truncates instead of rounding,
and the comparison proves
+ * nothing. {@link #requireFixtureIsSensitiveToRounding} checks the
property rather than trusting the
+ * values below.
+ */
+ private static final double[] VECTOR =
+ { MIN_Q, MAX_Q, MIN_Q - 10.0, MAX_Q + 10.0, 0.375, -0.9, 1.703,
0.0088, 2.4999, -1.2499 };
+
+ /** SQ8: 256 levels, the default. */
+ @Test
+ public void bothPathsProduceTheSameCodesForSq8() throws
HyracksDataException {
+ assertPathsAgree(VectorQuantization.SQ8.bits(), VECTOR);
+ }
+
+ /** SQ4: 16 levels in a byte[], where a wrong level count is easy to miss.
*/
+ @Test
+ public void bothPathsProduceTheSameCodesForSq4() throws
HyracksDataException {
+ assertPathsAgree(VectorQuantization.SQ4.bits(), VECTOR);
+ }
+
+ /** Every quantization the product offers, so adding one to the enum lands
here. */
+ @Test
+ public void bothPathsAgreeForEveryQuantizationTheProductOffers() throws
HyracksDataException {
+ for (VectorQuantization quantization : VectorQuantization.values()) {
+ assertPathsAgree(quantization.bits(), VECTOR);
+ }
+ }
+
+ /**
+ * The endpoints pin the scale itself: {@code minQ} must land on code 0
and {@code maxQ} on the top
+ * code. A wrong {@code alpha} or an off-by-one in {@code levels} shifts
every other code with them,
+ * and the two paths agreeing would not catch it if both were wrong the
same way.
+ */
+ @Test
+ public void theQuantileEndpointsMapToTheEndsOfTheCodeRange() {
+ for (VectorQuantization quantization : VectorQuantization.values()) {
+ int levels = 1 << quantization.bits();
+ float alpha = (levels - 1) / (MAX_Q - MIN_Q);
+
+ Assert.assertEquals("minQ must encode to 0 for " + quantization, 0,
+ VTreeScalarQuantization.encodeDimension(MIN_Q, MIN_Q,
MAX_Q, alpha, levels));
+ Assert.assertEquals("maxQ must encode to the top code for " +
quantization, levels - 1,
+ VTreeScalarQuantization.encodeDimension(MAX_Q, MIN_Q,
MAX_Q, alpha, levels));
+ // And outside the range, the clamp holds them there rather than
wrapping.
+ Assert.assertEquals(0,
VTreeScalarQuantization.encodeDimension(MIN_Q - 1e6, MIN_Q, MAX_Q, alpha,
levels));
+ Assert.assertEquals(levels - 1,
+ VTreeScalarQuantization.encodeDimension(MAX_Q + 1e6,
MIN_Q, MAX_Q, alpha, levels));
+ }
+ }
+
+ /** Decode is the inverse to within one quantization step, which is what
bounds the recall loss. */
+ @Test
+ public void decodeInvertsEncodeToWithinOneStep() {
+ for (VectorQuantization quantization : VectorQuantization.values()) {
+ int levels = 1 << quantization.bits();
+ float alpha = (levels - 1) / (MAX_Q - MIN_Q);
+ double step = 1.0 / alpha;
+
+ for (double value : new double[] { MIN_Q, MAX_Q, 0.0, 1.0, 0.37,
-0.9 }) {
+ long code = VTreeScalarQuantization.encodeDimension(value,
MIN_Q, MAX_Q, alpha, levels);
+ double decoded = VTreeScalarQuantization.decodeDimension(code,
alpha, MIN_Q);
+ Assert.assertEquals("round-trip error above one step for " +
value + " at " + quantization, value,
+ decoded, step / 2 + 1e-6);
+ }
+ }
+ }
+
+ /**
+ * A quantized index whose codes are wider than a byte cannot round-trip
through the DML path, because
+ * leaf storage is {@code byte[]}. Unreachable while {@code
VectorQuantization} is SQ4/SQ8, so this
+ * asserts the guard rather than a behaviour: it is what turns a silent
truncation into a failure if
+ * the enum grows without the storage format following.
+ */
+ @Test
+ public void aCodeWiderThanAByteIsRefusedByTheDmlPath() {
+ VTreeQuantizationParams tooWide = new VTreeQuantizationParams(MIN_Q,
MAX_Q, 1.0f, 0.9f, 16, 1000);
+ Assert.assertThrows(IllegalArgumentException.class, () -> new
VTreeDataTupleBuilder(0, true, tooWide));
+ }
+
+ // ---- comparison
-----------------------------------------------------------------------------
+
+ /**
+ * Encode {@code vector} through both paths with the same parameters and
require identical bytes.
+ */
+ private void assertPathsAgree(int bits, double[] vector) throws
HyracksDataException {
+ int levels = 1 << bits;
+ float alpha = (levels - 1) / (MAX_Q - MIN_Q);
+
+ requireFixtureIsSensitiveToRounding(vector, bits, alpha);
+
+ byte[] fromBulkLoad = bulkLoadCodes(vector, bits, alpha);
+ byte[] fromDml = dmlCodes(vector, bits, alpha);
+
+ Assert.assertEquals("code count", fromBulkLoad.length, fromDml.length);
+ Assert.assertArrayEquals("the bulk-load and DML paths disagree at " +
bits
+ + " bits; a row inserted by DML would not be" + " comparable
with a bulk-loaded row", fromBulkLoad,
+ fromDml);
+ // Guard against both paths agreeing on nothing: at these parameters
the codes must actually differ
+ // across dimensions, or the comparison above would hold for any
constant encoder.
+ boolean allEqual = true;
+ for (byte code : fromDml) {
+ allEqual &= code == fromDml[0];
+ }
+ Assert.assertFalse("the fixture produced a constant code vector, so it
proves nothing", allEqual);
+ }
+
+ /**
+ * Require that at least one dimension's pre-round product would land
differently under a different
+ * rounding rule. Without that, the byte-for-byte comparison is blind to
the rounding half of the
+ * formula -- which is how this fixture was wrong on its first draft: it
passed while one path had been
+ * deliberately changed to truncate.
+ */
+ private void requireFixtureIsSensitiveToRounding(double[] vector, int
bits, float alpha) {
+ int sensitive = 0;
+ for (double value : vector) {
+ double product = (Math.max(MIN_Q, Math.min(MAX_Q, value)) - MIN_Q)
* alpha;
+ if (Math.round(product) != (long) Math.floor(product)) {
+ sensitive++;
+ }
+ }
+ Assert.assertTrue("the fixture is insensitive to the rounding rule at
" + bits + " bits: every"
+ + " dimension's pre-round product is an exact integer, so a
truncating encoder would agree"
+ + " with a rounding one", sensitive > 0);
+ }
+
+ /** The bulk-load / static-structure encoder. */
+ private byte[] bulkLoadCodes(double[] vector, int bits, float alpha)
throws HyracksDataException {
+ OptimizedScalarQuantizationCodec.Params params =
+ new OptimizedScalarQuantizationCodec.Params(bits,
vector.length, 1000, 0.9f, MIN_Q, MAX_Q, alpha);
+ OptimizedScalarQuantizationCodec.QuantizedVector quantized =
OptimizedScalarQuantizationCodec
+ .quantizeVector(vector, params,
OptimizedScalarQuantizationCodec.SimilarityFunction.EUCLIDEAN);
+ Assert.assertTrue("SQ4/SQ8 must encode to byte[]",
quantized.quantizedBytes instanceof byte[]);
+ return (byte[]) quantized.quantizedBytes;
+ }
+
+ /**
+ * The DML insert encoder, driven through the real tuple builder and read
back with the production
+ * accessor so the varlen framing of the embedding field is covered too.
+ */
+ private byte[] dmlCodes(double[] vector, int bits, float alpha) throws
HyracksDataException {
+ VTreeQuantizationParams params = new VTreeQuantizationParams(MIN_Q,
MAX_Q, alpha, 0.9f, bits, 1000);
+ VTreeDataTupleBuilder builder = new VTreeDataTupleBuilder(0, true,
params);
+
+ ITupleReference dataTuple = builder.buildDataTuple(vector, 0.5, 7,
inputTuple(vector, 42L));
+
+ return new
VTreeDataTupleAccessor(true).getQuantizedEmbedding(dataTuple);
+ }
+
+ /** {@code [vector, pk]} — the operator-side input layout with no include
fields. */
+ private static ITupleReference inputTuple(double[] vector, long
primaryKey) throws HyracksDataException {
+ ArrayTupleBuilder tupleBuilder = new ArrayTupleBuilder(2);
+ ArrayTupleReference tupleRef = new ArrayTupleReference();
+ ISerializerDeserializer[] serdes =
+ { DoubleArraySerializerDeserializer.INSTANCE,
Integer64SerializerDeserializer.INSTANCE };
+ TupleUtils.createTuple(tupleBuilder, tupleRef, serdes, new Object[] {
vector, primaryKey });
+ return tupleRef;
+ }
+}
diff --git
a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeDataTupleBuilder.java
b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeDataTupleBuilder.java
index 72d9ea8..4a24edc 100644
---
a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeDataTupleBuilder.java
+++
b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/impls/VTreeDataTupleBuilder.java
@@ -29,6 +29,7 @@
import org.apache.hyracks.storage.am.vector.api.IVTreeDataTupleBuilder;
import org.apache.hyracks.storage.am.vector.api.VTreeQuantizationParams;
import org.apache.hyracks.storage.am.vector.utils.VTreeDataTupleAccessor;
+import org.apache.hyracks.storage.am.vector.utils.VTreeScalarQuantization;
import org.apache.hyracks.util.encoding.VarLenIntEncoderDecoder;
/**
@@ -58,6 +59,15 @@
public VTreeDataTupleBuilder(int numIncludeFields, boolean isQuantized,
VTreeQuantizationParams quantizationParams) {
+ // Leaf storage for the quantized embedding is byte[] (see
VTreeDataTupleAccessor#getQuantizedEmbedding),
+ // so a code wider than 8 bits has nowhere to go: it would be silently
truncated here while the
+ // bulk-load codec stored it as short[]/int[], and the two paths' rows
would stop being comparable.
+ // Unreachable today -- VectorQuantization offers SQ4 and SQ8 only --
so this fails loudly if that
+ // enum ever grows without the storage format following.
+ if (isQuantized && quantizationParams != null &&
quantizationParams.bits() > Byte.SIZE) {
+ throw new IllegalArgumentException("VTree leaf storage holds " +
Byte.SIZE
+ + "-bit quantization codes; this index was built with " +
quantizationParams.bits() + " bits");
+ }
this.numIncludeFields = numIncludeFields;
this.isQuantized = isQuantized;
this.quantizationParams = quantizationParams;
@@ -134,10 +144,8 @@
}
byte[] result = quantizeScratch;
for (int i = 0; i < vector.length; i++) {
- double value = Math.max(minQ, Math.min(maxQ, vector[i]));
- int quantizedValue = Math.toIntExact(Math.round((value - minQ)
* alpha));
- quantizedValue = Math.max(0, Math.min(levels - 1,
quantizedValue));
- result[i] = (byte) quantizedValue;
+ // Same formula as the bulk-load path's codec, by construction
-- see VTreeScalarQuantization.
+ result[i] = (byte)
VTreeScalarQuantization.encodeDimension(vector[i], minQ, maxQ, alpha, levels);
}
return result;
}
diff --git
a/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/utils/VTreeScalarQuantization.java
b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/utils/VTreeScalarQuantization.java
new file mode 100644
index 0000000..2ddb975
--- /dev/null
+++
b/hyracks-fullstack/hyracks/hyracks-storage-am-vtree/src/main/java/org/apache/hyracks/storage/am/vector/utils/VTreeScalarQuantization.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.hyracks.storage.am.vector.utils;
+
+import org.apache.hyracks.util.annotations.AiProvenance;
+
+/**
+ * The per-dimension scalar quantization formula, in one place.
+ * <p>
+ * Two paths encode vectors for the same index and must produce identical
codes for identical input:
+ * bulk load and static-structure build go through AsterixDB's {@code
OptimizedScalarQuantizationCodec}
+ * (module {@code asterix-common}), while DML insert goes through {@link
+ * org.apache.hyracks.storage.am.vector.impls.VTreeDataTupleBuilder} in this
module. {@code
+ * hyracks-storage-am-vtree} cannot depend on {@code asterix-common}, so the
DML path used to
+ * re-implement the arithmetic rather than call it, and the two copies could
drift silently: a
+ * DML-inserted row's codes would stop being comparable with a bulk-loaded
row's, and the only symptom
+ * would be recall quietly falling for updated datasets. {@code
asterix-common} already depends on this
+ * module, so the shared formula lives here and both callers delegate down to
it.
+ * <p>
+ * The parameters are global to the index, computed at creation by {@code
+ * QuantizationConstantsAggregate} and carried on the resource: {@code levels
= 2^bits} and {@code alpha
+ * = (levels - 1) / (maxQ - minQ)}.
+ * <p>
+ * <b>Encode</b> (dimension i): {@code v = clamp(x[i], minQ, maxQ)}, then
+ * {@code q = clamp(round((v - minQ) * alpha), 0, levels - 1)}.<br>
+ * <b>Decode</b> (the inverse): {@code x_hat[i] = q / alpha + minQ}.
+ * <p>
+ * Endpoints: {@code v == minQ} yields {@code q == 0}; {@code v == maxQ}
yields {@code q == levels - 1}.
+ * The intermediate expressions are evaluated in {@code double} and the code
in {@code long} because the
+ * clamp comes after the rounding: for {@code bits} near 32 the pre-clamp
value can exceed {@code
+ * Integer.MAX_VALUE}, so narrowing before the clamp would overflow. Callers
narrow the returned code to
+ * their own storage width.
+ */
+@AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool =
AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind =
AiProvenance.ContributionKind.REFACTORED, notes = "Single implementation of a
formula previously duplicated across the asterix-common / hyracks module
boundary")
+public final class VTreeScalarQuantization {
+
+ private VTreeScalarQuantization() {
+ }
+
+ /**
+ * Encode one dimension to its integer code, clamped into {@code [0,
levels - 1]}.
+ *
+ * @param value the full-precision component
+ * @param minQ lower sample quantile over all dimensions
+ * @param maxQ upper sample quantile over all dimensions
+ * @param alpha {@code (levels - 1) / (maxQ - minQ)}
+ * @param levels {@code 1 << bits}
+ * @return the code, in {@code [0, levels - 1]}; narrow to the caller's
storage width
+ */
+ public static long encodeDimension(double value, float minQ, float maxQ,
float alpha, int levels) {
+ // Clamp to the global quantile range, then map onto the integer code
range.
+ double clamped = Math.max(minQ, Math.min(maxQ, value));
+ long code = Math.round((clamped - minQ) * alpha);
+ return Math.max(0, Math.min(levels - 1, code));
+ }
+
+ /**
+ * Decode one code back to an approximate component. Inverse of
+ * {@link #encodeDimension(double, float, float, float, int)}, up to the
quantization step.
+ * <p>
+ * Codes are unsigned: a caller reading a signed {@code byte} or {@code
short} must widen with
+ * {@code & 0xFF} / {@code & 0xFFFF} before passing it here.
+ *
+ * @param code the unsigned code
+ * @param alpha {@code (levels - 1) / (maxQ - minQ)}
+ * @param minQ lower sample quantile over all dimensions
+ */
+ public static double decodeDimension(long code, float alpha, float minQ) {
+ return (double) code / alpha + minQ;
+ }
+}
--
To view, visit https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21686?usp=email
To unsubscribe, or for help writing mail filters, visit
https://asterix-gerrit.ics.uci.edu/settings?usp=email
Gerrit-MessageType: newchange
Gerrit-Project: asterixdb
Gerrit-Branch: master
Gerrit-Change-Id: Ib7520274f950948b4fc8b9dcaeb7f5173ea57f4c
Gerrit-Change-Number: 21686
Gerrit-PatchSet: 1
Gerrit-Owner: Ali Alsuliman <[email protected]>