From e4840cc453880f77a298082ca854d2a6a0aaa87b Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Tue, 27 Oct 2026 22:59:04 +0900 Subject: [PATCH] A long float list, or a float64 array in a named parameter, crosses as one buffer (#106) JVM: a list of 32 or more Python floats and nothing else, or a native-order float64 vector, becomes one double[] that Java boxes into the same ArrayList the element-wise conversion built (505 us -> 44 us for 1536 numbers, 570 us -> 60 us for the named float64 array). Mixed lists, ints, bools, None, strings, numpy scalars and short lists keep the old path, so an int stays a Long. Native: a list of 64 or more Python floats that float32 holds exactly (the check double -> float32 -> double is the identity, finite numbers only) goes in the float32 side buffer under a new tag BL that Json.java reads as a List; every other list stays JSON text. _prep also stops walking a list of plain scalars element by element. Tests state the bytes (codec), the Java objects (spy on the one buffer crossing), and that IN, list properties, equality, CONTAINS and vectorNeighbors give the same results. The docs name the float32 array as the fast path. Co-Authored-By: Claude Opus 5.5 --- .../pages/how-to/databases-and-queries.adoc | 8 + .../main/java/org/humemai/nativelib/Json.java | 15 +- src/arcadedb/_native/codec.py | 76 ++++- src/arcadedb/type_conversion.py | 38 +++ tests/conftest.py | 1 + tests/test_query_vector_params.py | 288 ++++++++++++++++++ tests/test_query_vector_params_jvm.py | 178 +++++++++++ 7 files changed, 597 insertions(+), 7 deletions(-) create mode 100644 tests/test_query_vector_params.py create mode 100644 tests/test_query_vector_params_jvm.py diff --git a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc index 9d41722..9df11c2 100644 --- a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc +++ b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc @@ -220,6 +220,14 @@ db.query("sql", "SELECT FROM V WHERE @rid IN ?", [[rid_a, rid_b]]) The check counts the `?`, `$` and `:` characters of the SQL text, so the count is an upper bound: a `?` inside a string literal counts as a placeholder, and a call that binds is never refused. Fewer values than placeholders are not checked, and the engine binds null for the missing ones. `db.query` and `db.command` run the check; the async executor does not. +==== A query vector + +A query vector for `vectorNeighbors` can be a numpy array or a list. The conversion before the statement runs is the cost to watch: a query takes about 0.3 to 0.75 ms, and a vector of 1536 numbers took 0.5 to 0.6 ms to convert when it was a list. + +* A `float32` array crosses as one buffer on both builds: about 5 to 15 us. This is the fast path. `np.asarray(v, dtype=np.float32)` makes it from a list or from another array. +* A list that holds only Python floats (no `int`, `bool`, `None`, `str` or numpy scalar) also crosses as one buffer, and so does a `float64` array on the JVM build. The statement reads the same list of doubles it always read: no value changes, and an `int` in a list stays an `int`. A list shorter than 32 numbers (JVM build) or 64 numbers (native build) is not worth a buffer and goes number by number. On the native build the list crosses as a buffer only when `float32` holds every number exactly, which is true of a list from a float32 model (`array.tolist()`); a list of other doubles, `NaN` or an infinity is sent as text, because a buffer would round it. +* A `float64` array is a `float32` array to the engine on the native build and when it is the one positional parameter on the JVM build, so its numbers are rounded to `float32` (a vector index stores `float32`). As a named parameter on the JVM build it keeps its doubles. + === Results `db.query` returns a result set you can iterate. Two methods cover the usual needs: diff --git a/native/src/main/java/org/humemai/nativelib/Json.java b/native/src/main/java/org/humemai/nativelib/Json.java index 809d115..3cd3a28 100644 --- a/native/src/main/java/org/humemai/nativelib/Json.java +++ b/native/src/main/java/org/humemai/nativelib/Json.java @@ -408,7 +408,7 @@ private Object object(final Map into) { return out; } - /** Python -> Java tagged values: n Decimal, i big int, m map with non-str keys, D date, T datetime (UTC wall clock), s set, b bytes (base64), F float[] (as a list), B float[] (from the side buffer), R RID. */ + /** Python -> Java tagged values: n Decimal, i big int, m map with non-str keys, D date, T datetime (UTC wall clock), s set, b bytes (base64), F float[] (as a list), B float[] (from the side buffer), BL List (from the side buffer), R RID. */ @SuppressWarnings("unchecked") private Object decodeTag(final String kind, final List tag) { final Object payload = tag.get(1); @@ -425,6 +425,19 @@ private Object decodeTag(final String kind, final List tag) { f[k] = fp.read((int) off + k); return f; } + case "BL": { + // the same side buffer read as a java.util.List of Double: a list of Python floats that float32 holds exactly (the Python + // side checks double -> float32 -> double is the identity), so the engine reads the List the JSON text would have been + final long off = ((Number) payload).longValue(); + final int count = ((Number) tag.get(2)).intValue(); + if (buf == 0 || off < 0 || count < 0 || off + count > bufFloats) + throw new IllegalArgumentException("binary parameter outside the side buffer"); + final List list = new ArrayList<>(count); + final CFloatPointer fp = WordFactory.pointer(buf); + for (int k = 0; k < count; k++) + list.add((double) fp.read((int) off + k)); + return list; + } case "n": return new BigDecimal((String) payload); case "i": diff --git a/src/arcadedb/_native/codec.py b/src/arcadedb/_native/codec.py index 211c91f..c4b9e4a 100644 --- a/src/arcadedb/_native/codec.py +++ b/src/arcadedb/_native/codec.py @@ -14,6 +14,48 @@ TAG = "\u0001" _EPOCH_NAIVE = datetime(1970, 1, 1) +# A list of this many Python floats or more may cross in the float32 side buffer (see _exact_float_list). A shorter one is cheaper +# as JSON text, and a short IN list is the common use of a list parameter. +_FLOAT_LIST_MIN = 64 + + +class _ExactFloats: + """A list of Python floats that float32 holds exactly: ``f32`` is the side-buffer form, ``values`` the list it stands for.""" + + __slots__ = ("f32", "values") + + def __init__(self, f32, values): + self.f32 = f32 + self.values = values + + +def _exact_float_list(value): + """``value`` as an :class:`_ExactFloats` when it is a list of at least ``_FLOAT_LIST_MIN`` Python floats (no int, bool, None, + numpy scalar) that all survive the round trip double -> float32 -> double unchanged; None otherwise. + + The engine then reads the same ``List`` it read from the JSON text, value for value: the check is exact, never a + tolerance. A list from a float32 model (``ndarray.tolist()``) passes; a list of doubles that float32 would round (0.1), NaN, + or an infinity fails the check and stays JSON text, so no result changes. The text costs 340 ns a number + (611 us for 1536), the buffer 60 us. + """ + if ( + _np is None + or len(value) < _FLOAT_LIST_MIN + or set(map(type, value)) != _FLOAT_ONLY + ): + return None + doubles = _np.array(value, dtype=_np.float64) + with _np.errstate( + over="ignore" + ): # a double beyond float32 becomes an infinity, which fails the check below + f32 = doubles.astype(_np.float32) + if ( + not _np.array_equal(f32.astype(_np.float64), doubles) + or not _np.isfinite(f32).all() + ): + return None # NaN and the infinities stay text: they are what the engine's JSON reader decides about, not this check + return _ExactFloats(f32, value) + def _default(value, _bin=None): """json.dumps hook: the values JSON cannot carry, as tagged objects (see Json.java). @@ -23,6 +65,13 @@ def _default(value, _bin=None): """ if isinstance(value, RID): return {TAG: ["R", value.text]} + if isinstance(value, _ExactFloats): + if _bin is None: + return value.values + off = _bin[1] + _bin[0].append(value.f32) + _bin[1] = off + value.f32.size + return {TAG: ["BL", off, value.f32.size]} if isinstance(value, Decimal): return {TAG: ["n", str(value)]} if isinstance(value, datetime): @@ -65,23 +114,38 @@ def _default(value, _bin=None): _PLAIN = frozenset((str, int, float, bool, type(None))) +_FLOAT_ONLY = frozenset((float,)) _dumps = json.dumps _fast = os.environ.get("ARCADEDB_NATIVE_FASTENC", "1") != "0" -def _prep(value): +def _prep(value, binary=False): """A value ready for json.dumps: a dict with a key that is not a str becomes a tagged ``[[k, v], ..]`` map (JSON would turn the key into text; the JPype path keeps it), and containers are walked for such dicts. Plain scalars pass untouched. + + ``binary``: a long list of floats that float32 holds exactly is returned as an :class:`_ExactFloats` for the side buffer. """ t = type(value) if t in _PLAIN: return value if isinstance(value, Mapping): if all(type(k) is str for k in value): - return {k: _prep(v) for k, v in value.items()} - return {TAG: ["m", [[_prep(k), _prep(v)] for k, v in value.items()]]} + return {k: _prep(v, binary) for k, v in value.items()} + return { + TAG: [ + "m", + [[_prep(k, binary), _prep(v, binary)] for k, v in value.items()], + ] + } if t is list or t is tuple: - return [_prep(v) for v in value] + if len(value) > 8: + if binary and t is list: + exact = _exact_float_list(value) + if exact is not None: + return exact + if set(map(type, value)) <= _PLAIN: + return value # nothing inside to rewrite: skip the per-element walk (69 us for 1536 numbers) + return [_prep(v, binary) for v in value] return value @@ -102,10 +166,10 @@ def encode_params(args, binary=False): if len(values) == 1 and isinstance(values[0], Mapping): named = values[0] if not all(type(v) in plain for v in named.values()): - named = {k: _prep(v) for k, v in named.items()} + named = {k: _prep(v, binary) for k, v in named.items()} return _dump({"named": dict(named)}, binary) if not all(type(v) in plain for v in values): - values = [_prep(v) for v in values] + values = [_prep(v, binary) for v in values] return _dump({"pos": list(values)}, binary) diff --git a/src/arcadedb/type_conversion.py b/src/arcadedb/type_conversion.py index d18f1a6..420d42a 100644 --- a/src/arcadedb/type_conversion.py +++ b/src/arcadedb/type_conversion.py @@ -65,6 +65,30 @@ class _PythonToJavaTypes(NamedTuple): # Element types convert_python_to_java hands to the JVM as one Object[]. _BULK_SCALAR_TYPES = frozenset((int, float, str, bool, type(None))) +# A list of this many Python floats or more crosses as ONE double[] and is boxed on the Java side (see _boxed_doubles); a shorter one +# is cheaper through the plain Object[] path (measured: the two cost the same at about 20 numbers on the laptop). +_FLOAT_LIST_MIN = 32 +_FLOAT_ONLY = frozenset((float,)) +_DOUBLE_ARRAY = None + + +def _boxed_doubles(values, java_python_types): + """``values`` (a list of Python floats, or a float64 ndarray) as the ``java.util.ArrayList`` of ``Double`` the element-wise + conversion builds, from ONE ``double[]``. + + JPype boxes a Python float with one call to the JVM (about 0.33 us each, 505 us for a 1536-d vector); the buffer crosses + once (20 to 45 us) and ``DoubleStream.boxed()`` boxes on the Java side. The result is the same ``ArrayList``, value for + value (NaN, the infinities and -0.0 included), so a statement reads it exactly as it read the boxed list. + """ + global _DOUBLE_ARRAY + if _DOUBLE_ARRAY is None: + _DOUBLE_ARRAY = jpype.JArray(jpype.JDouble) + arrays = java_python_types.arrays + return java_python_types.array_list( + arrays.stream(_DOUBLE_ARRAY(values)).boxed().toList() + ) + + _TYPE_CACHE = { "java_core": None, "java_collections": None, @@ -519,6 +543,16 @@ def convert_python_to_java(value: Any) -> Any: # (exact values: a float64 array keeps its doubles). if value.ndim == 1 and value.dtype == _np.float32: return jpype.JArray(jpype.JFloat)(value) + if ( + value.ndim == 1 + and value.dtype == _np.float64 + and value.dtype.isnative + and value.size >= _FLOAT_LIST_MIN + ): + # the same ArrayList value.tolist() becomes, from one double[] instead of one boxed object per number + java_python_types = _get_java_python_types() + if java_python_types is not None: + return _boxed_doubles(_np.ascontiguousarray(value), java_python_types) return convert_python_to_java(value.tolist()) java_python_types = _get_java_python_types() @@ -554,6 +588,10 @@ def convert_python_to_java(value: Any) -> Any: # Long, float -> Double, str, bool -> Boolean, None -> null) and # raises the same OverflowError past 64 bits; anything else (nested # collections, dates, Decimal, numpy scalars) takes the loop below. + # A list of Python floats and nothing else (no int, bool, None, str, numpy scalar) crosses as one double[] and is boxed + # in Java: 505 us -> 44 us for 1536 numbers. Anything mixed keeps the element-wise path, so an int stays a Long. + if len(value) >= _FLOAT_LIST_MIN and set(map(type, value)) == _FLOAT_ONLY: + return _boxed_doubles(value, java_python_types) if all(type(item) in _BULK_SCALAR_TYPES for item in value): return java_python_types.array_list( java_python_types.arrays.asList(jpype.JArray(jpype.JObject)(value)) diff --git a/tests/conftest.py b/tests/conftest.py index f37b3fc..3e03cdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,6 +86,7 @@ "test_build_scripts.py": "build scripts of the JVM wheel", "test_build_no_uv_refresh.py": "build scripts of the JVM wheel", "test_build_wheel_check.py": "build scripts of the JVM wheel", + "test_query_vector_params_jvm.py": "the java.util objects the JVM build's parameter conversion builds", } if BACKEND == "native" and not os.environ.get("NATIVE_PARITY_ALL"): collect_ignore.extend(NATIVE_NOT_APPLICABLE) diff --git a/tests/test_query_vector_params.py b/tests/test_query_vector_params.py new file mode 100644 index 0000000..f81a8df --- /dev/null +++ b/tests/test_query_vector_params.py @@ -0,0 +1,288 @@ +"""A query vector passed as a Python list or a float64 numpy array crosses into the engine as one buffer, and no result changes. + +The cost was in the conversion, not in the query: a 1536-number list became 1536 boxed objects on the JVM build (505 us) or +34 KB of JSON number text on the native build (611 us) before a 0.3 to 0.75 ms query started. A float32 array already crossed as +one buffer (7 us). The two builds now send an all-float list (and, on the JVM build, a float64 array) as one buffer of the same +numbers, and the engine reads the same ``List`` it read before. Everything that is not unambiguously such a list keeps its +old path: an ``int`` stays an int, a short list stays a short list, a number float32 would round stays exact. + +The codec tests state the bytes the native build hands over; the statement tests state that every use of a list parameter +(``IN``, a list property, equality, ``vectorNeighbors``) gives the result it gave. ``test_query_vector_params_jvm.py`` states the +Java objects of the JVM build. +""" + +import json +import math + +import numpy as np +import pytest + +from arcadedb._native.codec import encode_params + +DIM = 96 +BASE = 120 +NN = "SELECT id FROM (SELECT expand(vectorNeighbors('Vec[embedding]', {q}, 10)))" + + +def _f32_exact_list(seed, n=DIM): + """The list a float32 model hands over: ``ndarray.tolist()`` of float32 numbers, so float32 holds every one exactly.""" + return np.random.default_rng(seed).random(n).astype(np.float32).tolist() + + +def _f64_list(seed, n=DIM): + """Doubles float32 would round (``random()`` is a 53-bit fraction).""" + return np.random.default_rng(seed).random(n).tolist() + + +def _repr(values): + """Value and type of every element: ``0.0 == -0.0`` and ``1 == 1.0`` in Python, so == alone cannot see a change.""" + return [(type(v).__name__, repr(v)) for v in values] + + +# --------------------------------------------------------------------------- +# the bytes the native build hands over (pure functions of the parameters: they run on both builds) + + +class TestNativeCodec: + def test_an_exact_float_list_goes_in_the_float32_side_buffer(self): + values = _f32_exact_list(1) + text, buf, count = encode_params(([values],), binary=True) + assert json.loads(text) == {"pos": [{"\u0001": ["BL", 0, DIM]}]} + assert count == DIM and buf == np.asarray(values, dtype=np.float32).tobytes() + assert len(text) + len(buf) < 500 # was 1536 numbers of text + + def test_a_named_parameter_and_a_nested_one_too(self): + values = _f32_exact_list(2) + text, buf, count = encode_params(({"q": values, "k": 5},), binary=True) + assert json.loads(text) == {"named": {"q": {"\u0001": ["BL", 0, DIM]}, "k": 5}} + assert count == DIM and buf is not None + text, buf, count = encode_params(({"o": {"q": values}},), binary=True) + assert json.loads(text)["named"]["o"]["q"] == {"\u0001": ["BL", 0, DIM]} + + def test_offsets_count_every_array_in_the_call(self): + first, second = _f32_exact_list(3), _f32_exact_list(4, n=70) + array = np.arange(10, dtype=np.float32) + text, buf, count = encode_params((first, array, second), binary=True) + assert json.loads(text)["pos"] == [ + {"\u0001": ["BL", 0, DIM]}, + {"\u0001": ["B", DIM, 10]}, + {"\u0001": ["BL", DIM + 10, 70]}, + ] + assert count == DIM + 10 + 70 + assert ( + buf == np.concatenate([first, array, second]).astype(np.float32).tobytes() + ) + + @pytest.mark.parametrize( + "values", + [ + pytest.param(_f64_list(5), id="doubles-float32-would-round"), + pytest.param([0.1] * DIM, id="point-one"), + pytest.param(_f32_exact_list(6)[:-1] + [float("nan")], id="nan"), + pytest.param(_f32_exact_list(6)[:-1] + [float("inf")], id="infinity"), + pytest.param(_f32_exact_list(6)[:-1] + [1e300], id="beyond-float32"), + pytest.param(_f32_exact_list(6)[:-1] + [5e-324], id="subnormal-double"), + pytest.param(_f32_exact_list(7, n=63), id="short"), + pytest.param(_f32_exact_list(7)[:-1] + [1], id="an-int-among-floats"), + pytest.param(_f32_exact_list(7)[:-1] + [True], id="a-bool-among-floats"), + pytest.param(_f32_exact_list(7)[:-1] + [None], id="a-null-among-floats"), + pytest.param(_f32_exact_list(7)[:-1] + ["a"], id="a-str-among-floats"), + pytest.param(list(range(DIM)), id="ints"), + pytest.param(list(np.float32(1) * np.arange(DIM)), id="numpy-scalars"), + ], + ) + def test_anything_else_stays_json_text_as_before(self, values): + text, buf, count = encode_params(([values],), binary=True) + assert buf is None and count == 0 + # the same text a plain json.dumps writes: no value changes on its way + assert text == json.dumps({"pos": [values]}, default=_numpy_default) + + def test_the_lists_inside_a_list_cross_on_their_own(self): + inner = _f32_exact_list(12) + text, buf, count = encode_params(([[inner, [1, 2], inner]],), binary=True) + assert json.loads(text)["pos"] == [ + [{"\u0001": ["BL", 0, DIM]}, [1, 2], {"\u0001": ["BL", DIM, DIM]}] + ] + assert count == 2 * DIM + + def test_a_tuple_stays_json_text(self): + text, buf, _ = encode_params(([tuple(_f32_exact_list(8))],), binary=True) + assert buf is None + + def test_without_the_side_buffer_the_text_is_unchanged(self): + values = _f32_exact_list(9) + assert encode_params(([values],)) == json.dumps({"pos": [values]}) + assert encode_params(({"q": values},)) == json.dumps({"named": {"q": values}}) + + def test_the_buffer_holds_back_exactly_the_list(self): + values = _f32_exact_list(10) + _, buf, count = encode_params(([values],), binary=True) + back = np.frombuffer(buf, dtype=np.float32, count=count).astype(np.float64) + assert ( + back.tolist() == values + ) # float32 -> double is the identity on these numbers + + def test_negative_zero_keeps_its_sign(self): + values = _f32_exact_list(11)[:-1] + [-0.0] + _, buf, count = encode_params(([values],), binary=True) + back = np.frombuffer(buf, dtype=np.float32, count=count).tolist() + assert math.copysign(1.0, back[-1]) == -1.0 + + +def _numpy_default(value): + if isinstance(value, np.generic): + return value.item() + raise TypeError(type(value).__name__) + + +# --------------------------------------------------------------------------- +# what a statement does with the list: every use gives the result it gave + + +@pytest.fixture +def db(temp_db): + temp_db.command("sql", "CREATE VERTEX TYPE Vec") + temp_db.command("sql", "CREATE PROPERTY Vec.embedding ARRAY_OF_FLOATS") + temp_db.command("sql", "CREATE DOCUMENT TYPE Item") + with temp_db.transaction(): + for i in range(BASE): + temp_db.command( + "sql", + "CREATE VERTEX Vec SET id = ?, embedding = ?", + i, + np.random.default_rng(1000 + i).random(DIM).astype(np.float32), + ) + temp_db.command("sql", "INSERT INTO Item SET k = 'a', x = 0.25") + temp_db.command("sql", "INSERT INTO Item SET k = 'b', x = 0.1") + temp_db.command("sql", "INSERT INTO Item SET k = 'c', x = 0.3") + temp_db.command("sql", "INSERT INTO Item SET k = 'd', x = 7") + temp_db.command( + "sql", + 'CREATE INDEX ON Vec (embedding) LSM_VECTOR METADATA {"dimensions": %d, "similarity": "EUCLIDEAN"}' + % DIM, + ) + return temp_db + + +def _ids(db, sql, *args): + return [r["id"] for r in db.query("sql", sql, *args).to_list()] + + +class TestStatements: + @pytest.mark.parametrize( + "make", + [ + pytest.param(_f32_exact_list, id="float32-exact"), + pytest.param(_f64_list, id="doubles"), + ], + ) + @pytest.mark.parametrize("n", [3, 40, DIM, 500]) + def test_a_float_list_comes_back_value_for_value(self, db, make, n): + values = make(21, n=n) + for params in ({"v": values},): + row = db.query("sql", "SELECT :v AS v", params).to_list()[0] + assert _repr(row["v"]) == _repr(values) + row = db.query("sql", "SELECT ? AS v", [values]).to_list()[0] + assert _repr(row["v"]) == _repr(values) + + def test_the_awkward_numbers_come_back_exact(self, db): + values = _f32_exact_list(22)[:-4] + [-0.0, 1e300, 5e-324, 0.1] + row = db.query("sql", "SELECT :v AS v", {"v": values}).to_list()[0] + assert _repr(row["v"]) == _repr(values) + + @pytest.mark.parametrize( + "values", + [ + pytest.param(list(range(DIM)), id="ints"), + pytest.param([i + 0.5 if i % 2 else i for i in range(DIM)], id="mixed"), + pytest.param([i % 2 == 0 for i in range(DIM)], id="bools"), + pytest.param([None if i % 3 else 0.5 for i in range(DIM)], id="nulls"), + pytest.param([str(i) for i in range(DIM)], id="strings"), + ], + ) + def test_a_list_that_is_not_all_floats_keeps_every_type(self, db, values): + row = db.query("sql", "SELECT :v AS v", {"v": values}).to_list()[0] + assert _repr(row["v"]) == _repr(values) + + def test_a_nested_list_keeps_its_shape(self, db): + values = [_f32_exact_list(23), _f64_list(24), [1, 2, 3]] + row = db.query("sql", "SELECT :v AS v", {"v": values}).to_list()[0] + assert [_repr(v) for v in row["v"]] == [_repr(v) for v in values] + + @pytest.mark.parametrize( + "make", + [ + pytest.param(_f32_exact_list, id="float32-exact"), + pytest.param(_f64_list, id="doubles"), + ], + ) + def test_in_matches_the_numbers_it_matched(self, db, make): + numbers = make(25, n=100) + numbers[0], numbers[1] = 0.25, 0.1 # the stored items a and b + for param in ({"xs": numbers},): + rows = db.query( + "sql", "SELECT k FROM Item WHERE x IN :xs ORDER BY k", param + ) + assert [r["k"] for r in rows.to_list()] == ["a", "b"] + # the int the list does not hold is not found by a float list + rows = db.query("sql", "SELECT k FROM Item WHERE x IN :xs", {"xs": numbers}) + assert "d" not in [r["k"] for r in rows.to_list()] + + def test_an_int_list_still_finds_an_int(self, db): + rows = db.query( + "sql", "SELECT k FROM Item WHERE x IN :xs", {"xs": list(range(100))} + ) + assert [r["k"] for r in rows.to_list()] == ["d"] + + @pytest.mark.parametrize( + "make", + [ + pytest.param(_f32_exact_list, id="float32-exact"), + pytest.param(_f64_list, id="doubles"), + ], + ) + def test_a_list_property_stores_and_equals_the_list(self, db, make): + values = make(26) + with db.transaction(): + db.command("sql", "INSERT INTO Item SET k = 'p', vals = :v", {"v": values}) + db.command("sql", "INSERT INTO Item SET k = 'q', vals = ?", [values]) + for key in ("p", "q"): + stored = db.query("sql", "SELECT vals FROM Item WHERE k = ?", key).to_list() + assert _repr(stored[0]["vals"]) == _repr(values) + found = db.query( + "sql", "SELECT k FROM Item WHERE vals = :v ORDER BY k", {"v": values} + ) + assert [r["k"] for r in found.to_list()] == ["p", "q"] + + def test_contains_finds_a_number_of_the_list(self, db): + values = _f64_list(27) + with db.transaction(): + db.command("sql", "INSERT INTO Item SET k = 'p', vals = :v", {"v": values}) + rows = db.query( + "sql", "SELECT k FROM Item WHERE vals CONTAINS :x", {"x": values[7]} + ).to_list() + assert [r["k"] for r in rows] == ["p"] + + @pytest.mark.parametrize( + "make", + [ + pytest.param(_f32_exact_list, id="float32-exact"), + pytest.param(_f64_list, id="doubles"), + ], + ) + def test_vector_neighbors_agree_across_every_input_form(self, db, make): + values = make(28) + sql = "SELECT id FROM (SELECT expand(vectorNeighbors('Vec[embedding]', ?, 10)))" + named = ( + "SELECT id FROM (SELECT expand(vectorNeighbors('Vec[embedding]', :q, 10)))" + ) + want = _ids(db, sql, np.asarray(values, dtype=np.float32)) + assert len(want) == 10 + assert _ids(db, sql, [values]) == want # a list + assert ( + _ids(db, sql, np.asarray(values, dtype=np.float64)) == want + ) # float64 array + assert _ids(db, named, {"q": values}) == want + assert _ids(db, named, {"q": np.asarray(values, dtype=np.float64)}) == want + assert _ids(db, named, {"q": np.asarray(values, dtype=np.float32)}) == want + assert _ids(db, NN.format(q=values)) == want # the literal text, no parameter diff --git a/tests/test_query_vector_params_jvm.py b/tests/test_query_vector_params_jvm.py new file mode 100644 index 0000000..6da7518 --- /dev/null +++ b/tests/test_query_vector_params_jvm.py @@ -0,0 +1,178 @@ +"""The Java objects a long float list or a float64 array becomes on the JVM build (see test_query_vector_params.py). + +Before: ``list`` and float64 ``ndarray`` parameters became a ``java.util.ArrayList`` filled one boxed object at a time (about 0.33 us +each through JPype, 505 us for 1536 numbers). Now they cross as one ``double[]`` that Java boxes (20 to 45 us). The object +the engine receives is the same ``ArrayList``; the tests count the crossings and compare the objects. +""" + +import jpype +import numpy as np +import pytest + +from arcadedb import type_conversion +from arcadedb.core import Database +from arcadedb.type_conversion import convert_python_to_java + +N = 100 + + +@pytest.fixture +def crossings(monkeypatch): + """The calls of ``_boxed_doubles``, the one place a vector crosses as a ``double[]``.""" + calls = [] + original = type_conversion._boxed_doubles + + def spy(values, java_python_types): + calls.append(len(values)) + return original(values, java_python_types) + + monkeypatch.setattr(type_conversion, "_boxed_doubles", spy) + return calls + + +def _elementwise(values): + """The list as it was built before: one boxed object per number.""" + java_list = jpype.JClass("java.util.ArrayList")() + for value in values: + java_list.add(value) + return java_list + + +def _classes(java_list): + return {str(java_list.get(i).getClass().getName()) for i in range(java_list.size())} + + +def _floats(n=N, seed=1): + return np.random.default_rng(seed).random(n).tolist() + + +class TestFloatLists: + def test_a_float_list_crosses_as_one_buffer(self, crossings): + values = _floats() + converted = convert_python_to_java(values) + assert crossings == [N] + assert str(converted.getClass().getName()) == "java.util.ArrayList" + assert _classes(converted) == {"java.lang.Double"} + assert converted.equals(_elementwise(values)) + + def test_the_awkward_numbers_are_the_same_objects(self, crossings): + values = _floats()[:-5] + [ + float("nan"), + float("inf"), + -float("inf"), + -0.0, + 5e-324, + ] + converted = convert_python_to_java(values) + assert crossings == [N] + # Double.equals compares the bits: NaN equals NaN, -0.0 differs from 0.0 + assert converted.equals(_elementwise(values)) + + def test_a_tuple_of_floats_too(self, crossings): + values = tuple(_floats()) + assert convert_python_to_java(values).equals(_elementwise(values)) + assert crossings == [N] + + def test_a_nested_list_crosses_its_long_float_lists(self, crossings): + values = {"q": _floats(), "k": 10, "o": [_floats(40), [1, 2]]} + converted = convert_python_to_java(values) + assert crossings == [N, 40] + assert converted.get("k") == 10 + assert converted.get("q").equals(_elementwise(values["q"])) + + @pytest.mark.parametrize( + "values", + [ + pytest.param(_floats(31), id="short"), + pytest.param(_floats()[:-1] + [1], id="an-int"), + pytest.param(_floats()[:-1] + [True], id="a-bool"), + pytest.param(_floats()[:-1] + [None], id="a-null"), + pytest.param(_floats()[:-1] + ["x"], id="a-str"), + pytest.param(list(range(N)), id="ints"), + pytest.param(list(np.random.default_rng(2).random(N)), id="numpy-scalars"), + ], + ) + def test_anything_else_keeps_the_boxed_path(self, crossings, values): + converted = convert_python_to_java(values) + assert crossings == [] + assert converted.size() == len(values) + + def test_an_int_stays_a_long(self, crossings): + converted = convert_python_to_java(_floats()[:-1] + [7]) + assert crossings == [] + assert str(converted.get(N - 1).getClass().getName()) == "java.lang.Long" + assert str(converted.get(0).getClass().getName()) == "java.lang.Double" + + def test_a_float_subclass_stays_on_the_boxed_path(self, crossings): + class Fl(float): + pass + + convert_python_to_java([Fl(0.5)] * N) + assert crossings == [] + + +class TestFloat64Arrays: + def test_a_float64_array_crosses_as_one_buffer(self, crossings): + array = np.random.default_rng(3).random(N) + converted = convert_python_to_java(array) + assert crossings == [N] + assert str(converted.getClass().getName()) == "java.util.ArrayList" + assert converted.equals( + _elementwise(array.tolist()) + ) # the list tolist() made before + + def test_a_strided_view_keeps_its_values(self, crossings): + array = np.random.default_rng(4).random(2 * N)[::2] + assert not array.flags.c_contiguous + assert convert_python_to_java(array).equals(_elementwise(array.tolist())) + assert crossings == [N] + + def test_a_big_endian_array_goes_through_its_list(self, crossings): + array = np.random.default_rng(5).random(N).astype(">f8") + assert convert_python_to_java(array).equals(_elementwise(array.tolist())) + assert crossings == [N] # tolist() gives Python floats, which cross as the list + + def test_a_short_or_two_dimensional_array_keeps_the_old_path(self, crossings): + convert_python_to_java(np.random.default_rng(6).random(31)) + two = convert_python_to_java(np.random.default_rng(6).random((4, N))) + assert ( + crossings == [N] * 4 + ) # each row is the float list it was; the matrix itself never crosses whole + assert two.size() == 4 + + def test_a_float32_array_is_still_a_float_array(self, crossings): + converted = convert_python_to_java( + np.random.default_rng(7).random(N).astype(np.float32) + ) + assert crossings == [] + assert str(converted.getClass().getName()) == "[F" + + def test_an_int_array_is_still_a_list_of_longs(self, crossings): + converted = convert_python_to_java(np.arange(N, dtype=np.int64)) + assert crossings == [] + assert _classes(converted) == {"java.lang.Long"} + + +class TestQueryParameters: + """The statement entry points reach the same conversion.""" + + @pytest.mark.parametrize( + "args, crossed", + [ + pytest.param(lambda v: ({"q": v},), 1, id="named-list"), + pytest.param(lambda v: ([v],), 1, id="positional-list"), + pytest.param( + lambda v: ({"q": np.asarray(v)},), 1, id="named-float64-array" + ), + pytest.param(lambda v: (np.asarray(v),), 0, id="positional-float64-array"), + pytest.param( + lambda v: ({"q": np.asarray(v, dtype=np.float32)},), + 0, + id="named-float32-array", + ), + ], + ) + def test_the_parameter_object_of_query(self, crossings, args, crossed): + # a positional float64 array was already one float[] (to_java_float_array); the others were boxed lists + Database._java_parameters(args(_floats())) + assert len(crossings) == crossed