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