Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ a hard constraint when changing build/package configuration.
## Referencing the Turso source

A read-only `turso-src` git submodule pins the upstream Turso Rust core at a
specific release tag (currently `v0.8.0-pre.7`, commit `277ddd050`) so agents
specific release tag (currently `v0.8.0-pre.13`, commit `64b8ef574`) so agents
can read the original Rust sources while porting or comparing behavior,
including the tagged browser WASM binding, without cloning ad hoc or guessing
at API shape. Historical gap/MVCC contracts that explicitly name `v0.7.2`
retain that version as their behavioral analysis baseline.
retain that version as their behavioral analysis baseline, and the gap
inventory and audits that name `v0.8.0-pre.7` (commit `277ddd050`) keep that
tag as theirs until they are refreshed against the current pin.

```powershell
git submodule update --init --recursive # first checkout / fresh clone
Expand Down
29 changes: 28 additions & 1 deletion docs/managed-vector-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Ahtola implements Turso-compatible vector scalar functions without a native
companion. The implementation mirrors the serialized BLOB layouts and scalar
semantics in Turso commit `277ddd050b1243bc19792e845c77f1ccd31896c8`:
semantics in Turso `v0.8.0-pre.13` (commit `64b8ef5742fc18937f9c89806c81e3f6475dc7a3`):

- `core/vector/vector_types.rs` and `core/vector/operations/serialize.rs`
define the dense float32/float64, sparse float32, 1-bit, and 8-bit formats.
Expand Down Expand Up @@ -35,8 +35,35 @@ the same dimensions and encoded type. Text constructors reject non-finite
values. Results that are NaN under Turso's operation rules become SQL `NULL`,
matching SQLite's real-value behavior.

Dense `float32`/`float64` cosine distance follows the rule Turso adopted in
`v0.8.0-pre.13` (commit `b9414023d`): two zero vectors are at distance `0`,
and any pair whose dot product is zero (which includes a zero vector against a
non-zero one) is at distance `1`, decided before the division. The arithmetic
is Turso's pure-Rust fallback — single-precision accumulation for `float32` —
which is the code path Turso itself runs on WebAssembly. Turso's native builds
use simsimd instead and can differ from it in the last bits.

The implementation is scalar managed code and is NativeAOT/trimming safe.

## Parity with Turso and platform coverage

Against Turso's Rust engine through `v0.8.0-pre.13`, the vector surface is
complete: every built-in vector function, the serialized formats, and the
`turso/vector.sqltest` corpus (byte-identical between `v0.8.0-pre.7` and
`v0.8.0-pre.13`). The only behavioral change in `core/vector` over that range
is the cosine rule above; the rest is build-feature plumbing for simsimd.
Turso's Rust engine has no `vector_top_k`, `libsql_vector_idx`, `F32_BLOB(N)`
typing, or DiskANN index — those belong to libSQL, not to the engine Ahtola
ports. `F32_BLOB(N)`-style declared types are accepted as ordinary column
types with BLOB affinity.

The same engine code runs unchanged in desktop .NET and in browser
WebAssembly. The browser package consumer smoke test
(`samples/BrowserWasmConsumer`, gated by
`scripts/Invoke-BrowserPackageConsumer.ps1`) builds a `USING vector` index over
OPFS storage, reopens it, and requires the planner to choose it and to return
the same rows as the unindexed scan.

A dense vector index is available as `CREATE INDEX … USING vector (col) WITH (…)`, built on the
managed index-method foundation and documented in
[managed-vector-index.md](managed-vector-index.md). It answers
Expand Down
2 changes: 1 addition & 1 deletion docs/managed-vector-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ arithmetics disagree at the extremes, and the disagreement is not a rounding dif

| Components | `float` norm accumulator | Reported distance |
| --- | --- | --- |
| `1e-24` | squares are `1e-48`, below the smallest subnormal ⇒ `0` | the degenerate branch: `0` or `1` |
| `1e-24` | squares are `1e-48`, below the smallest subnormal ⇒ `0` | `0` against another zero-norm row; otherwise `1 − dot/0`, which is `+∞` (or `1` when the dot product is also zero) |
| `1e20` | squares are `1e40`, above `float.MaxValue` ⇒ `+∞` | `1 − dot/∞` ⇒ `1` |

In both cases the widened `double` direction is perfectly ordinary, so an angular bound built from it
Expand Down
90 changes: 89 additions & 1 deletion samples/BrowserWasmConsumer/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
var encryptedWrongKeyRejected = await RunEncryptedWrongKeyAsync();
var encryptedCipherMismatchRejected = await RunEncryptedCipherMismatchAsync();
var syncReadMirror = await RunSynchronousReadMirrorAsync();
var vectorSearch = await RunPersistentVectorSearchAsync();
status =
$"PASS:capabilities={capabilities.IsSupported};"
+ $"storage={storage.Succeeded};"
Expand All @@ -67,7 +68,8 @@
+ $"persistent-encrypted-raw-key={persistentEncryptedRawKey};"
+ $"encrypted-wrong-key-rejected={encryptedWrongKeyRejected};"
+ $"encrypted-cipher-mismatch-rejected={encryptedCipherMismatchRejected};"
+ $"sync-read-mirror={syncReadMirror}";
+ $"sync-read-mirror={syncReadMirror};"
+ $"vector-search={vectorSearch}";
}
catch (Exception exception)
{
Expand Down Expand Up @@ -849,6 +851,92 @@
return false;
}

// Turso-compatible vector search end to end in WebAssembly: a dense `USING vector` index is
// built over OPFS storage, survives a reopen, is chosen by the planner, and answers exactly
// what the unindexed scan answers. The corpus is a closed-form function of the row id so the
// expected status is the same on every engine and every run.
private static async Task<string> RunPersistentVectorSearchAsync()
{
const string path = "packed-consumer-vector/main.db";
const int dimensions = 64;
const int rowCount = 2048;
await using (var source = new AhtolaBrowserDataSource(path))
{
await using var connection = await source.OpenConnectionAsync();
var insert = new System.Text.StringBuilder("INSERT INTO docs(id, embedding) VALUES ");
for (var id = 1; id <= rowCount; id++)
{
if (id > 1)
insert.Append(',');
insert.Append('(').Append(id).Append(", vector('[");
for (var component = 0; component < dimensions; component++)
{
if (component > 0)
insert.Append(',');
var value = (component == id % dimensions ? 10.0 : 0.0)
+ ((id * 31 + component * 17) % 97) / 97.0;
insert.Append(value.ToString("R", CultureInfo.InvariantCulture));
}

insert.Append("]'))");
}

await ExecuteFeatureAsync(
connection,
"CREATE TABLE docs(id INTEGER PRIMARY KEY, embedding BLOB)",
"vector create table");
await ExecuteFeatureAsync(connection, insert.ToString(), "vector insert");
await ExecuteFeatureAsync(
connection,
"CREATE INDEX docs_knn ON docs USING vector (embedding) "
+ $"WITH (metric = 'l2', dims = {dimensions}, lists = 64)",
"vector create index");
}

await using (var source = new AhtolaBrowserDataSource(path))
{
await using var connection = await source.OpenConnectionAsync();
var query = "vector('[" + string.Join(",", Enumerable.Range(0, dimensions)
.Select(static component => component == 3 ? "10" : "0")) + "]')";
var knn = $"SELECT id FROM docs ORDER BY vector_distance_l2(embedding, {query}) LIMIT 3";
await using (var explain = connection.CreateCommand())
{
explain.CommandText = "EXPLAIN QUERY PLAN " + knn;
await using var reader = await explain.ExecuteReaderAsync();
var plan = new List<string>();
while (await reader.ReadAsync())
plan.Add(reader.GetString(3));
if (!plan.Any(static detail => detail.Contains(
"USING INDEX METHOD vector INDEX docs_knn",
StringComparison.Ordinal)))
{
throw new InvalidOperationException(
$"vector search was not planned through the index: {string.Join(" / ", plan)}");
}
}

// A residual WHERE makes the index decline, so this is the exact unindexed answer.
var indexed = await ReadIdsAsync(connection, knn);
var scanned = await ReadIdsAsync(
connection,
$"SELECT id FROM docs WHERE id > 0 ORDER BY vector_distance_l2(embedding, {query}) LIMIT 3");
if (indexed != scanned)
throw new InvalidOperationException($"vector index answered {indexed}, scan answered {scanned}");
return indexed;
}

static async Task<string> ReadIdsAsync(SqliteConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync();
var ids = new List<long>();
while (await reader.ReadAsync())
ids.Add(reader.GetInt64(0));
return string.Join("-", ids);
}
}

private static async Task ExecuteFeatureAsync(
SqliteConnection connection,
string sql,
Expand Down
2 changes: 1 addition & 1 deletion scripts/Invoke-BrowserPackageConsumer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ try {
$env:AHTOLA_BROWSER_EXECUTABLE = $BrowserExecutable
}
if ($BrowserEngine -eq 'chromium') {
$expectedStatus = 'PASS:capabilities=True;storage=True;crypto=True;ado=42;persistent-ado=126;persistent-core=210;persistent-features=546;persistent-fts=8;browser-memory=504;reserved-path-rejected=True;worker-isolation=3;persistent-blob=01020908;persistent-backup=336;persistent-inbound-backup=378;persistent-encrypted=420;encrypted-bounded=36;encrypted-maintenance=9368;persistent-encrypted-raw-key=462;encrypted-wrong-key-rejected=True;encrypted-cipher-mismatch-rejected=True;sync-read-mirror=True'
$expectedStatus = 'PASS:capabilities=True;storage=True;crypto=True;ado=42;persistent-ado=126;persistent-core=210;persistent-features=546;persistent-fts=8;browser-memory=504;reserved-path-rejected=True;worker-isolation=3;persistent-blob=01020908;persistent-backup=336;persistent-inbound-backup=378;persistent-encrypted=420;encrypted-bounded=36;encrypted-maintenance=9368;persistent-encrypted-raw-key=462;encrypted-wrong-key-rejected=True;encrypted-cipher-mismatch-rejected=True;sync-read-mirror=True;vector-search=195-1987-643'
& node $probeScript $uri $expectedStatus
} else {
& node $playwrightProbeScript $BrowserEngine $uri 'PASS:'
Expand Down
Loading
Loading