diff --git a/sdks/dotnet/.gitignore b/sdks/dotnet/.gitignore new file mode 100644 index 00000000..1baf383b --- /dev/null +++ b/sdks/dotnet/.gitignore @@ -0,0 +1,16 @@ +## .NET build output +bin/ +obj/ +*.user + +## Test / coverage artifacts +[Tt]est[Rr]esults/ +*.trx +*.coverage +coverage*.json +coverage*.xml +coverage*.cobertura.xml + +## NuGet +*.nupkg +*.snupkg diff --git a/sdks/dotnet/Moss.sln b/sdks/dotnet/Moss.sln new file mode 100644 index 00000000..38e06c11 --- /dev/null +++ b/sdks/dotnet/Moss.sln @@ -0,0 +1,27 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moss", "src\Moss\Moss.csproj", "{A1B2C3D4-0001-4000-8000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moss.Tests", "tests\Moss.Tests\Moss.Tests.csproj", "{A1B2C3D4-0002-4000-8000-000000000002}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-0002-4000-8000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0002-4000-8000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0002-4000-8000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0002-4000-8000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/sdks/dotnet/README.md b/sdks/dotnet/README.md new file mode 100644 index 00000000..678fae13 --- /dev/null +++ b/sdks/dotnet/README.md @@ -0,0 +1,112 @@ +# Moss .NET SDK + +The .NET SDK for [Moss](https://github.com/usemoss/moss) — fast on-device +retrieval. It wraps the native `libmoss` runtime through P/Invoke and exposes an +idiomatic, async C# API for index management, hybrid search, and metadata +filtering. + +## Architecture + +``` + ┌──────────────────────────────────┐ + │ Your application code │ + └──────────────┬───────────────────┘ + │ + ┌──────────────▼───────────────────┐ + │ Moss (managed C#) │ ← src/Moss + │ MossClient — async API for │ + │ indexing, querying, management │ + └──────────────┬───────────────────┘ + │ P/Invoke ([DllImport("moss")]) + ┌──────────────▼───────────────────┐ + │ libmoss (native C ABI) │ ← prebuilt runtime + │ hybrid search, data models │ + └──────────────────────────────────┘ +``` + +- `src/Moss/` — the public SDK. `MossClient` plus the data models. +- `src/Moss/Interop/` — the P/Invoke layer: raw `libmoss` declarations, C-ABI + struct mirrors, UTF-8 marshaling, and native-memory conversion/cleanup. + +The interop layer targets the same stable C ABI (`libmoss.h`) that the Go +bindings bind via cgo. + +## Quick start + +```csharp +using Moss; + +using var client = new MossClient("your_project_id", "your_project_key"); + +await client.CreateIndexAsync("support-docs", new[] +{ + new DocumentInfo("1", "Refunds are processed within 3-5 business days."), + new DocumentInfo("2", "You can track your order on the dashboard."), +}); + +await client.LoadIndexAsync("support-docs"); + +var results = await client.QueryAsync( + "support-docs", "how long do refunds take?", new QueryOptions { TopK = 3 }); + +foreach (var doc in results.Docs) + Console.WriteLine($"[{doc.Score:F3}] {doc.Text}"); +``` + +### Metadata filtering + +Attach string metadata at index time and pass a JSON filter at query time: + +```csharp +await client.AddDocsAsync("support-docs", new[] +{ + new DocumentInfo("3", "EU refund policy…", + metadata: new Dictionary { ["region"] = "eu" }), +}); + +var results = await client.QueryAsync("support-docs", "refund policy", + new QueryOptions + { + TopK = 5, + FilterJson = "{\"region\": \"eu\"}", + }); +``` + +## API surface + +| Area | Methods | +|------|---------| +| Indexes | `CreateIndexAsync`, `GetIndexAsync`, `ListIndexesAsync`, `DeleteIndexAsync` | +| Documents | `AddDocsAsync`, `DeleteDocsAsync`, `GetDocsAsync` | +| Jobs | `GetJobStatusAsync` | +| Local runtime | `LoadIndexAsync`, `UnloadIndexAsync`, `RefreshIndexAsync`, `QueryAsync` | + +All methods are asynchronous and accept a `CancellationToken`. Failures from the +native runtime surface as `MossException` (carrying the status `Code` and the +`moss_last_error` message). + +## The native runtime + +The SDK calls into `libmoss`, distributed as a prebuilt native library +(`libmoss.so` on Linux, `libmoss.dylib` on macOS, `moss.dll` on Windows). It +must be discoverable at runtime — on the standard library search path, next to +your application, or via `NativeLibrary` resolution. Building and unit-testing +the SDK does **not** require the native library; only running queries does. + +## Building and testing + +Requires the [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0). + +```bash +cd sdks/dotnet +dotnet build +dotnet test # unit tests run without libmoss +``` + +The unit tests cover the managed logic and the marshaling layer (UTF-8 +round-trips, native buffer packing, ABI struct sizes) and do not load the native +library. + +## License + +[BSD 2-Clause License](../../LICENSE) diff --git a/sdks/dotnet/src/Moss/Interop/MossClientHandle.cs b/sdks/dotnet/src/Moss/Interop/MossClientHandle.cs new file mode 100644 index 00000000..3c5c388e --- /dev/null +++ b/sdks/dotnet/src/Moss/Interop/MossClientHandle.cs @@ -0,0 +1,26 @@ +using System; +using System.Runtime.InteropServices; + +namespace Moss.Interop; + +/// +/// Owns the native MossClient*. Deriving from +/// means the handle is reference-counted during P/Invoke calls and freed via +/// moss_client_free from — including during +/// finalization, so a leaked (undisposed) client still releases native state. +/// +internal sealed class MossClientHandle : SafeHandle +{ + public MossClientHandle() : base(IntPtr.Zero, ownsHandle: true) { } + + public override bool IsInvalid => handle == IntPtr.Zero; + + /// Adopt a raw handle produced by moss_client_new. + internal void SetRawHandle(IntPtr raw) => SetHandle(raw); + + protected override bool ReleaseHandle() + { + NativeMethods.moss_client_free(handle); + return true; + } +} diff --git a/sdks/dotnet/src/Moss/Interop/Native.cs b/sdks/dotnet/src/Moss/Interop/Native.cs new file mode 100644 index 00000000..3c23b419 --- /dev/null +++ b/sdks/dotnet/src/Moss/Interop/Native.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Moss.Interop; + +/// UTF-8 string marshaling helpers for the native boundary. +internal static class Utf8 +{ + /// Allocates a NUL-terminated UTF-8 copy of , or + /// when it is null. Free with . + public static IntPtr Alloc(string? value) + => value is null ? IntPtr.Zero : Marshal.StringToCoTaskMemUTF8(value); + + /// Reads a NUL-terminated UTF-8 string, mapping a null pointer to "". + public static string Read(IntPtr ptr) + => ptr == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(ptr) ?? string.Empty; + + /// Reads a NUL-terminated UTF-8 string, preserving null as null (for optional fields). + public static string? ReadOptional(IntPtr ptr) + => ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr); +} + +/// +/// Tracks native allocations made while marshaling inputs and frees them all on +/// . Strings are allocated via CoTaskMem; raw blocks via HGlobal. +/// +internal sealed class NativeArena : IDisposable +{ + private readonly List _coTaskMem = new(); + private readonly List _hGlobal = new(); + + /// Allocate a UTF-8 string tracked by this arena. + public IntPtr String(string? value) + { + IntPtr p = Utf8.Alloc(value); + if (p != IntPtr.Zero) _coTaskMem.Add(p); + return p; + } + + /// Allocate a raw block of bytes tracked by this arena. + public IntPtr Alloc(int bytes) + { + IntPtr p = Marshal.AllocHGlobal(bytes); + _hGlobal.Add(p); + return p; + } + + /// Marshal an array of contiguous structs into a tracked native block. + public IntPtr StructArray(IReadOnlyList items) where T : struct + { + if (items.Count == 0) return IntPtr.Zero; + int size = Marshal.SizeOf(); + IntPtr block = Alloc(size * items.Count); + for (int i = 0; i < items.Count; i++) + Marshal.StructureToPtr(items[i], block + i * size, false); + return block; + } + + /// Marshal an array of floats into a tracked native block. + public IntPtr FloatArray(IReadOnlyList? values) + { + if (values is null || values.Count == 0) return IntPtr.Zero; + IntPtr block = Alloc(sizeof(float) * values.Count); + var tmp = new float[values.Count]; + for (int i = 0; i < values.Count; i++) tmp[i] = values[i]; + Marshal.Copy(tmp, 0, block, tmp.Length); + return block; + } + + /// Marshal an array of UTF-8 strings into a tracked native array of char*. + public IntPtr StringArray(IReadOnlyList values) + { + if (values.Count == 0) return IntPtr.Zero; + IntPtr block = Alloc(IntPtr.Size * values.Count); + for (int i = 0; i < values.Count; i++) + Marshal.WriteIntPtr(block, i * IntPtr.Size, String(values[i])); + return block; + } + + public void Dispose() + { + foreach (IntPtr p in _coTaskMem) Marshal.FreeCoTaskMem(p); + foreach (IntPtr p in _hGlobal) Marshal.FreeHGlobal(p); + _coTaskMem.Clear(); + _hGlobal.Clear(); + } +} diff --git a/sdks/dotnet/src/Moss/Interop/NativeClient.cs b/sdks/dotnet/src/Moss/Interop/NativeClient.cs new file mode 100644 index 00000000..6399fa5c --- /dev/null +++ b/sdks/dotnet/src/Moss/Interop/NativeClient.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Moss.Interop; + +/// +/// Thin, synchronous wrapper over the native libmoss client handle. It +/// owns the MossClient*, marshals managed inputs, checks status codes, +/// reads outputs back into managed models, and releases every native +/// allocation. All public methods are serialized behind a lock, mirroring the +/// per-client mutex the Go bindings use. +/// +internal sealed class NativeClient : IDisposable +{ + private readonly object _gate = new(); + private readonly MossClientHandle _handle; + + public NativeClient(string projectId, string projectKey) + { + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_new(arena.String(projectId), arena.String(projectKey), out IntPtr raw)); + if (raw == IntPtr.Zero) + throw new MossException(-1, "moss_client_new returned a null client"); + _handle = new MossClientHandle(); + _handle.SetRawHandle(raw); + } + + // ---- Management ------------------------------------------------------ + + public MutationResult CreateIndex(string name, IReadOnlyList docs, string? modelId) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + IntPtr docsPtr = BuildDocuments(arena, docs); + Check(NativeMethods.moss_client_create_index( + _handle, arena.String(name), docsPtr, (nuint)docs.Count, arena.String(modelId), out IntPtr outPtr)); + return ReadMutationResult(outPtr); + } + } + + public MutationResult AddDocs(string name, IReadOnlyList docs, MutationOptions? options) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + IntPtr docsPtr = BuildDocuments(arena, docs); + IntPtr optsPtr = IntPtr.Zero; + if (options?.Upsert is bool upsert) + { + var native = new MossMutationOptions { upsert = upsert }; + optsPtr = arena.StructArray(new[] { native }); + } + Check(NativeMethods.moss_client_add_docs( + _handle, arena.String(name), docsPtr, (nuint)docs.Count, optsPtr, out IntPtr outPtr)); + return ReadMutationResult(outPtr); + } + } + + public MutationResult DeleteDocs(string name, IReadOnlyList docIds) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_delete_docs( + _handle, arena.String(name), arena.StringArray(docIds), (nuint)docIds.Count, out IntPtr outPtr)); + return ReadMutationResult(outPtr); + } + } + + public IReadOnlyList GetDocs(string name, IReadOnlyList docIds) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_get_docs( + _handle, arena.String(name), arena.StringArray(docIds), (nuint)docIds.Count, + out IntPtr outDocs, out nuint count)); + try + { + return ReadDocuments(outDocs, count); + } + finally + { + NativeMethods.moss_free_documents(outDocs, count); + } + } + } + + public IndexInfo GetIndex(string name) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_get_index(_handle, arena.String(name), out IntPtr outPtr)); + try + { + return ReadIndexInfo(Marshal.PtrToStructure(outPtr)); + } + finally + { + NativeMethods.moss_free_index_info(outPtr); + } + } + } + + public IReadOnlyList ListIndexes() + { + lock (_gate) + { + EnsureOpen(); + Check(NativeMethods.moss_client_list_indexes(_handle, out IntPtr outPtr, out nuint count)); + try + { + int size = Marshal.SizeOf(); + var result = new List((int)count); + for (nuint i = 0; i < count; i++) + { + var native = Marshal.PtrToStructure(outPtr + (int)i * size); + result.Add(ReadIndexInfo(native)); + } + return result; + } + finally + { + NativeMethods.moss_free_index_info_list(outPtr, count); + } + } + } + + public bool DeleteIndex(string name) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_delete_index(_handle, arena.String(name), out bool deleted)); + return deleted; + } + } + + public JobStatusResponse GetJobStatus(string jobId) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_get_job_status(_handle, arena.String(jobId), out IntPtr outPtr)); + try + { + var n = Marshal.PtrToStructure(outPtr); + return new JobStatusResponse + { + JobId = Utf8.Read(n.job_id), + Status = Utf8.Read(n.status), + Progress = n.progress, + CurrentPhase = Utf8.ReadOptional(n.current_phase), + Error = Utf8.ReadOptional(n.error), + CreatedAt = Utf8.Read(n.created_at), + UpdatedAt = Utf8.Read(n.updated_at), + CompletedAt = Utf8.ReadOptional(n.completed_at), + }; + } + finally + { + NativeMethods.moss_free_job_status_response(outPtr); + } + } + } + + // ---- Local runtime --------------------------------------------------- + + public IndexInfo LoadIndex(string name, LoadIndexOptions? options) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + IntPtr optsPtr = IntPtr.Zero; + if (options is not null) + { + var native = new MossLoadIndexOptions + { + auto_refresh = options.AutoRefresh, + polling_interval_secs = options.PollingIntervalInSeconds, + }; + optsPtr = arena.StructArray(new[] { native }); + } + Check(NativeMethods.moss_client_load_index(_handle, arena.String(name), optsPtr, out IntPtr outPtr)); + try + { + return ReadIndexInfo(Marshal.PtrToStructure(outPtr)); + } + finally + { + NativeMethods.moss_free_index_info(outPtr); + } + } + } + + public void UnloadIndex(string name) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_unload_index(_handle, arena.String(name))); + } + } + + public RefreshResult RefreshIndex(string name) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + Check(NativeMethods.moss_client_refresh_index(_handle, arena.String(name), out IntPtr outPtr)); + try + { + var n = Marshal.PtrToStructure(outPtr); + return new RefreshResult + { + IndexName = Utf8.Read(n.index_name), + PreviousUpdatedAt = Utf8.Read(n.previous_updated_at), + NewUpdatedAt = Utf8.Read(n.new_updated_at), + WasUpdated = n.was_updated, + }; + } + finally + { + NativeMethods.moss_free_refresh_result(outPtr); + } + } + } + + public SearchResult Query(string name, string query, QueryOptions options) + { + lock (_gate) + { + EnsureOpen(); + using var arena = new NativeArena(); + var native = new MossQueryOptions + { + top_k = (nuint)Math.Max(0, options.TopK), + alpha = options.Alpha, + filter_json = arena.String(options.FilterJson), + embedding = arena.FloatArray(options.Embedding), + embedding_dim = (nuint)(options.Embedding?.Count ?? 0), + }; + IntPtr optsPtr = arena.StructArray(new[] { native }); + Check(NativeMethods.moss_client_query( + _handle, arena.String(name), arena.String(query), optsPtr, out IntPtr outPtr)); + try + { + return ReadSearchResult(outPtr); + } + finally + { + NativeMethods.moss_free_search_result(outPtr); + } + } + } + + // ---- Input marshaling ------------------------------------------------ + + private static IntPtr BuildDocuments(NativeArena arena, IReadOnlyList docs) + { + if (docs.Count == 0) return IntPtr.Zero; + var natives = new MossDocumentInfo[docs.Count]; + for (int i = 0; i < docs.Count; i++) + { + DocumentInfo doc = docs[i]; + IntPtr metaPtr = IntPtr.Zero; + int metaCount = 0; + if (doc.Metadata is { Count: > 0 }) + { + var entries = new List(doc.Metadata.Count); + foreach (KeyValuePair kv in doc.Metadata) + entries.Add(new MossMetadataEntry { key = arena.String(kv.Key), value = arena.String(kv.Value) }); + metaPtr = arena.StructArray(entries); + metaCount = entries.Count; + } + natives[i] = new MossDocumentInfo + { + id = arena.String(doc.Id), + text = arena.String(doc.Text), + metadata = metaPtr, + metadata_count = (nuint)metaCount, + embedding = arena.FloatArray(doc.Embedding), + embedding_dim = (nuint)(doc.Embedding?.Count ?? 0), + }; + } + return arena.StructArray(natives); + } + + // ---- Output marshaling ----------------------------------------------- + + private static MutationResult ReadMutationResult(IntPtr ptr) + { + try + { + var n = Marshal.PtrToStructure(ptr); + return new MutationResult + { + JobId = Utf8.Read(n.job_id), + IndexName = Utf8.Read(n.index_name), + DocCount = (int)n.doc_count, + }; + } + finally + { + NativeMethods.moss_free_mutation_result(ptr); + } + } + + private static IndexInfo ReadIndexInfo(MossIndexInfo n) => new() + { + Id = Utf8.Read(n.id), + Name = Utf8.Read(n.name), + Version = Utf8.ReadOptional(n.version), + Status = Utf8.Read(n.status), + DocCount = (int)n.doc_count, + CreatedAt = Utf8.ReadOptional(n.created_at), + UpdatedAt = Utf8.ReadOptional(n.updated_at), + Model = new ModelRef { Id = Utf8.Read(n.model.id), Version = Utf8.ReadOptional(n.model.version) }, + }; + + private static IReadOnlyList ReadDocuments(IntPtr ptr, nuint count) + { + if (ptr == IntPtr.Zero || count == 0) return Array.Empty(); + int size = Marshal.SizeOf(); + var result = new List((int)count); + for (nuint i = 0; i < count; i++) + { + var n = Marshal.PtrToStructure(ptr + (int)i * size); + result.Add(new DocumentInfo + { + Id = Utf8.Read(n.id), + Text = Utf8.Read(n.text), + Metadata = ReadMetadata(n.metadata, n.metadata_count), + Embedding = ReadFloats(n.embedding, n.embedding_dim), + }); + } + return result; + } + + private static SearchResult ReadSearchResult(IntPtr ptr) + { + var n = Marshal.PtrToStructure(ptr); + var docs = new List((int)n.doc_count); + if (n.docs != IntPtr.Zero && n.doc_count > 0) + { + int size = Marshal.SizeOf(); + for (nuint i = 0; i < n.doc_count; i++) + { + var d = Marshal.PtrToStructure(n.docs + (int)i * size); + docs.Add(new ScoredDocument + { + Id = Utf8.Read(d.id), + Text = Utf8.Read(d.text), + Metadata = ReadMetadata(d.metadata, d.metadata_count), + Score = d.score, + }); + } + } + return new SearchResult + { + Docs = docs, + Query = Utf8.Read(n.query), + IndexName = Utf8.ReadOptional(n.index_name), + TimeTakenMs = (int)n.time_taken_ms, + }; + } + + private static IReadOnlyDictionary? ReadMetadata(IntPtr entries, nuint count) + { + if (entries == IntPtr.Zero || count == 0) return null; + int size = Marshal.SizeOf(); + var map = new Dictionary((int)count); + for (nuint i = 0; i < count; i++) + { + var e = Marshal.PtrToStructure(entries + (int)i * size); + map[Utf8.Read(e.key)] = Utf8.Read(e.value); + } + return map; + } + + private static IReadOnlyList? ReadFloats(IntPtr ptr, nuint count) + { + if (ptr == IntPtr.Zero || count == 0) return null; + var values = new float[(int)count]; + Marshal.Copy(ptr, values, 0, (int)count); + return values; + } + + // ---- Lifetime + errors ---------------------------------------------- + + private void EnsureOpen() + { + if (_handle.IsInvalid || _handle.IsClosed) + throw new ObjectDisposedException(nameof(MossClient)); + } + + private static void Check(MossResult result) + { + if (result == MossResult.Ok) return; + IntPtr msgPtr = NativeMethods.moss_last_error(); + string message = msgPtr == IntPtr.Zero ? "libmoss call failed" : Utf8.Read(msgPtr); + throw new MossException((int)result, message); + } + + public void Dispose() + { + lock (_gate) + { + _handle.Dispose(); + } + } +} diff --git a/sdks/dotnet/src/Moss/Interop/NativeMethods.cs b/sdks/dotnet/src/Moss/Interop/NativeMethods.cs new file mode 100644 index 00000000..97c3a753 --- /dev/null +++ b/sdks/dotnet/src/Moss/Interop/NativeMethods.cs @@ -0,0 +1,92 @@ +using System; +using System.Runtime.InteropServices; + +namespace Moss.Interop; + +/// +/// Raw P/Invoke declarations for the native libmoss runtime. The client +/// parameter is a so the marshaler keeps it alive +/// (and prevents handle recycling) for the duration of each call. Other pointers +/// are passed as so marshaling stays explicit. The library +/// name "moss" resolves to libmoss.so / libmoss.dylib / +/// moss.dll via the standard .NET native-library search. +/// +internal static class NativeMethods +{ + private const string Lib = "moss"; + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_new(IntPtr projectId, IntPtr projectKey, out IntPtr client); + + // Called by MossClientHandle.ReleaseHandle with the raw handle. + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_client_free(IntPtr client); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_create_index( + MossClientHandle client, IntPtr name, IntPtr docs, nuint count, IntPtr modelId, out IntPtr outResult); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_add_docs( + MossClientHandle client, IntPtr name, IntPtr docs, nuint count, IntPtr options, out IntPtr outResult); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_delete_docs( + MossClientHandle client, IntPtr name, IntPtr ids, nuint count, out IntPtr outResult); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_get_docs( + MossClientHandle client, IntPtr name, IntPtr ids, nuint count, out IntPtr outDocs, out nuint outCount); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_get_index(MossClientHandle client, IntPtr name, out IntPtr outInfo); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_list_indexes(MossClientHandle client, out IntPtr outInfos, out nuint outCount); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_delete_index( + MossClientHandle client, IntPtr name, [MarshalAs(UnmanagedType.I1)] out bool deleted); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_get_job_status(MossClientHandle client, IntPtr jobId, out IntPtr outResult); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_load_index( + MossClientHandle client, IntPtr name, IntPtr options, out IntPtr outInfo); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_unload_index(MossClientHandle client, IntPtr name); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_refresh_index(MossClientHandle client, IntPtr name, out IntPtr outResult); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern MossResult moss_client_query( + MossClientHandle client, IntPtr name, IntPtr query, IntPtr options, out IntPtr outResult); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr moss_last_error(); + + // Deallocators for every heap object handed back across the boundary. + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_documents(IntPtr docs, nuint count); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_index_info(IntPtr info); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_index_info_list(IntPtr infos, nuint count); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_job_status_response(IntPtr response); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_mutation_result(IntPtr result); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_refresh_result(IntPtr result); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + public static extern void moss_free_search_result(IntPtr result); +} diff --git a/sdks/dotnet/src/Moss/Interop/NativeStructs.cs b/sdks/dotnet/src/Moss/Interop/NativeStructs.cs new file mode 100644 index 00000000..8ecf2dad --- /dev/null +++ b/sdks/dotnet/src/Moss/Interop/NativeStructs.cs @@ -0,0 +1,128 @@ +using System; +using System.Runtime.InteropServices; + +namespace Moss.Interop; + +// These structs mirror the C ABI declared in libmoss.h (the same surface the +// Go bindings bind via cgo). Field order, types, and layout must match exactly. +// char* -> IntPtr (NUL-terminated UTF-8) +// uintptr_t / size_t -> nuint +// bool -> [MarshalAs(I1)] bool +// float -> float, double -> double, uint64_t -> ulong + +/// Status code returned by every native call. 0 == success. +internal enum MossResult +{ + Ok = 0, +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossMetadataEntry +{ + public IntPtr key; + public IntPtr value; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossDocumentInfo +{ + public IntPtr id; + public IntPtr text; + public IntPtr metadata; // MossMetadataEntry* + public nuint metadata_count; + public IntPtr embedding; // float* + public nuint embedding_dim; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossMutationOptions +{ + [MarshalAs(UnmanagedType.I1)] public bool upsert; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossMutationResult +{ + public IntPtr job_id; + public IntPtr index_name; + public nuint doc_count; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossModelRef +{ + public IntPtr id; + public IntPtr version; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossIndexInfo +{ + public IntPtr id; + public IntPtr name; + public IntPtr version; + public IntPtr status; + public nuint doc_count; + public IntPtr created_at; + public IntPtr updated_at; + public MossModelRef model; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossJobStatusResponse +{ + public IntPtr job_id; + public IntPtr status; + public double progress; + public IntPtr current_phase; + public IntPtr error; + public IntPtr created_at; + public IntPtr updated_at; + public IntPtr completed_at; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossLoadIndexOptions +{ + [MarshalAs(UnmanagedType.I1)] public bool auto_refresh; + public ulong polling_interval_secs; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossQueryOptions +{ + public nuint top_k; + public float alpha; + public IntPtr filter_json; // char* + public IntPtr embedding; // float* + public nuint embedding_dim; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossSearchResultDoc +{ + public IntPtr id; + public IntPtr text; + public IntPtr metadata; // MossMetadataEntry* + public nuint metadata_count; + public double score; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossSearchResult +{ + public IntPtr docs; // MossSearchResultDoc* + public nuint doc_count; + public IntPtr query; + public IntPtr index_name; + public nuint time_taken_ms; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct MossRefreshResult +{ + public IntPtr index_name; + public IntPtr previous_updated_at; + public IntPtr new_updated_at; + [MarshalAs(UnmanagedType.I1)] public bool was_updated; +} diff --git a/sdks/dotnet/src/Moss/Models.cs b/sdks/dotnet/src/Moss/Models.cs new file mode 100644 index 00000000..1d2a28c5 --- /dev/null +++ b/sdks/dotnet/src/Moss/Models.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; + +namespace Moss; + +/// A document to index or a document returned from the store. +public sealed class DocumentInfo +{ + /// Stable, caller-supplied identifier for the document. + public string Id { get; set; } = string.Empty; + + /// The document text that gets embedded and searched. + public string Text { get; set; } = string.Empty; + + /// Optional string-to-string metadata used for filtering. + public IReadOnlyDictionary? Metadata { get; set; } + + /// Optional precomputed embedding. When null, Moss embeds the text. + public IReadOnlyList? Embedding { get; set; } + + public DocumentInfo() { } + + public DocumentInfo(string id, string text, + IReadOnlyDictionary? metadata = null, + IReadOnlyList? embedding = null) + { + Id = id; + Text = text; + Metadata = metadata; + Embedding = embedding; + } +} + +/// Options controlling how documents are written. +public sealed class MutationOptions +{ + /// When true, existing documents with the same id are overwritten. + public bool? Upsert { get; set; } +} + +/// Result of a create/add/delete documents operation. +public sealed class MutationResult +{ + public string JobId { get; init; } = string.Empty; + public string IndexName { get; init; } = string.Empty; + public int DocCount { get; init; } +} + +/// Reference to the embedding model backing an index. +public sealed class ModelRef +{ + public string Id { get; init; } = string.Empty; + public string? Version { get; init; } +} + +/// Metadata describing an index. +public sealed class IndexInfo +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string? Version { get; init; } + public string Status { get; init; } = string.Empty; + public int DocCount { get; init; } + public string? CreatedAt { get; init; } + public string? UpdatedAt { get; init; } + public ModelRef Model { get; init; } = new(); +} + +/// Status of an asynchronous indexing job. +public sealed class JobStatusResponse +{ + public string JobId { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public double Progress { get; init; } + public string? CurrentPhase { get; init; } + public string? Error { get; init; } + public string CreatedAt { get; init; } = string.Empty; + public string UpdatedAt { get; init; } = string.Empty; + public string? CompletedAt { get; init; } +} + +/// Options for loading an index into the local runtime. +public sealed class LoadIndexOptions +{ + /// Poll the cloud for updates and hot-reload the local copy. + public bool AutoRefresh { get; set; } + + /// Interval between refresh polls, in seconds. + public ulong PollingIntervalInSeconds { get; set; } +} + +/// Options controlling a query. +public sealed class QueryOptions +{ + /// Maximum number of results to return. + public int TopK { get; set; } = 10; + + /// + /// Hybrid weighting between lexical and semantic scores, in [0, 1]. + /// 0 = lexical only, 1 = semantic only. + /// + public float Alpha { get; set; } = 0.5f; + + /// Optional metadata filter, expressed as a JSON string. + public string? FilterJson { get; set; } + + /// Optional precomputed query embedding. When null, Moss embeds the query text. + public IReadOnlyList? Embedding { get; set; } +} + +/// A single scored document returned by a query. +public sealed class ScoredDocument +{ + public string Id { get; init; } = string.Empty; + public string Text { get; init; } = string.Empty; + public IReadOnlyDictionary? Metadata { get; init; } + public double Score { get; init; } +} + +/// Result of a query. +public sealed class SearchResult +{ + public IReadOnlyList Docs { get; init; } = System.Array.Empty(); + public string Query { get; init; } = string.Empty; + public string? IndexName { get; init; } + public int TimeTakenMs { get; init; } +} + +/// Result of refreshing a locally loaded index. +public sealed class RefreshResult +{ + public string IndexName { get; init; } = string.Empty; + public string PreviousUpdatedAt { get; init; } = string.Empty; + public string NewUpdatedAt { get; init; } = string.Empty; + public bool WasUpdated { get; init; } +} diff --git a/sdks/dotnet/src/Moss/Moss.csproj b/sdks/dotnet/src/Moss/Moss.csproj new file mode 100644 index 00000000..2a743af1 --- /dev/null +++ b/sdks/dotnet/src/Moss/Moss.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + 12 + enable + enable + true + true + + $(NoWarn);CS1591 + + + Moss + 0.1.0 + Moss contributors + Official .NET SDK for Moss — fast on-device retrieval. Wraps the native libmoss runtime for index management, hybrid search, and metadata filtering. + https://github.com/usemoss/moss + https://github.com/usemoss/moss + git + BSD-2-Clause + moss;vector-search;retrieval;rag;embeddings;semantic-search;on-device + README.md + + + + + + + + + + + diff --git a/sdks/dotnet/src/Moss/MossClient.cs b/sdks/dotnet/src/Moss/MossClient.cs new file mode 100644 index 00000000..c2ffd668 --- /dev/null +++ b/sdks/dotnet/src/Moss/MossClient.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moss.Interop; + +namespace Moss; + +/// +/// Client for Moss — fast on-device retrieval. Wraps the native libmoss +/// runtime and exposes an async API for index management, hybrid search, and +/// metadata filtering. +/// +/// +/// The client owns native resources; dispose it when finished. Native calls are +/// blocking and are marshaled onto the thread pool. Operations are serialized +/// through a cancellable gate, so a single client may be shared across tasks. +/// Inputs are snapshotted before work is queued, so callers may safely mutate +/// their own collections after an async method returns. +/// +public sealed class MossClient : IDisposable +{ + private readonly NativeClient _native; + private readonly SemaphoreSlim _gate = new(1, 1); + private int _disposed; + + /// Create a client for the given Moss project credentials. + /// A credential is null or empty. + /// The native runtime failed to initialize. + public MossClient(string projectId, string projectKey) + { + if (string.IsNullOrEmpty(projectId)) throw new ArgumentException("projectId is required", nameof(projectId)); + if (string.IsNullOrEmpty(projectKey)) throw new ArgumentException("projectKey is required", nameof(projectKey)); + _native = new NativeClient(projectId, projectKey); + } + + // ---- Management ------------------------------------------------------ + + /// Create a new index and enqueue the supplied documents for indexing. + public Task CreateIndexAsync( + string name, IEnumerable docs, string? modelId = null, CancellationToken cancellationToken = default) + { + RequireName(name); + DocumentInfo[] snapshot = SnapshotDocs(docs); + return RunAsync(() => _native.CreateIndex(name, snapshot, modelId), cancellationToken); + } + + /// Add (or upsert) documents to an existing index. + public Task AddDocsAsync( + string name, IEnumerable docs, MutationOptions? options = null, CancellationToken cancellationToken = default) + { + RequireName(name); + DocumentInfo[] snapshot = SnapshotDocs(docs); + MutationOptions? optionsCopy = options is null ? null : new MutationOptions { Upsert = options.Upsert }; + return RunAsync(() => _native.AddDocs(name, snapshot, optionsCopy), cancellationToken); + } + + /// Delete documents from an index by id. + public Task DeleteDocsAsync( + string name, IEnumerable docIds, CancellationToken cancellationToken = default) + { + RequireName(name); + string[] snapshot = SnapshotIds(docIds); + return RunAsync(() => _native.DeleteDocs(name, snapshot), cancellationToken); + } + + /// Fetch documents from an index by id. + public Task> GetDocsAsync( + string name, IEnumerable docIds, CancellationToken cancellationToken = default) + { + RequireName(name); + string[] snapshot = SnapshotIds(docIds); + return RunAsync(() => _native.GetDocs(name, snapshot), cancellationToken); + } + + /// Get metadata for a single index. + public Task GetIndexAsync(string name, CancellationToken cancellationToken = default) + { + RequireName(name); + return RunAsync(() => _native.GetIndex(name), cancellationToken); + } + + /// List all indexes in the project. + public Task> ListIndexesAsync(CancellationToken cancellationToken = default) + => RunAsync(() => _native.ListIndexes(), cancellationToken); + + /// Delete an index. Returns true if an index was removed. + public Task DeleteIndexAsync(string name, CancellationToken cancellationToken = default) + { + RequireName(name); + return RunAsync(() => _native.DeleteIndex(name), cancellationToken); + } + + /// Poll the status of an asynchronous indexing job. + public Task GetJobStatusAsync(string jobId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(jobId)) throw new ArgumentException("jobId is required", nameof(jobId)); + return RunAsync(() => _native.GetJobStatus(jobId), cancellationToken); + } + + // ---- Local runtime --------------------------------------------------- + + /// Load an index into the local runtime so it can be queried on-device. + public Task LoadIndexAsync( + string name, LoadIndexOptions? options = null, CancellationToken cancellationToken = default) + { + RequireName(name); + LoadIndexOptions? optionsCopy = options is null + ? null + : new LoadIndexOptions { AutoRefresh = options.AutoRefresh, PollingIntervalInSeconds = options.PollingIntervalInSeconds }; + return RunAsync(() => _native.LoadIndex(name, optionsCopy), cancellationToken); + } + + /// Unload a previously loaded index from the local runtime. + public Task UnloadIndexAsync(string name, CancellationToken cancellationToken = default) + { + RequireName(name); + return RunAsync(() => { _native.UnloadIndex(name); return true; }, cancellationToken); + } + + /// Refresh a locally loaded index against the latest cloud state. + public Task RefreshIndexAsync(string name, CancellationToken cancellationToken = default) + { + RequireName(name); + return RunAsync(() => _native.RefreshIndex(name), cancellationToken); + } + + /// Run a hybrid (lexical + semantic) query against a loaded index. + public Task QueryAsync( + string name, string query, QueryOptions? options = null, CancellationToken cancellationToken = default) + { + RequireName(name); + if (query is null) throw new ArgumentNullException(nameof(query)); + QueryOptions snapshot = SnapshotQuery(options ?? new QueryOptions()); + return RunAsync(() => _native.Query(name, query, snapshot), cancellationToken); + } + + // ---- Serialization + cancellation ------------------------------------ + + private async Task RunAsync(Func action, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + // WaitAsync honors cancellation while the operation is still queued, so a + // cancelled call never reaches the native layer. + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + return await Task.Run(action, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + // ---- Input snapshots ------------------------------------------------- + + private static DocumentInfo[] SnapshotDocs(IEnumerable docs) + { + if (docs is null) throw new ArgumentNullException(nameof(docs)); + return docs.Select(SnapshotDoc).ToArray(); + } + + internal static DocumentInfo SnapshotDoc(DocumentInfo doc) + { + if (doc is null) throw new ArgumentNullException(nameof(doc)); + // Required C-ABI strings must never be marshaled as NULL. Nullable + // annotations don't protect against nullable-disabled callers, + // deserialization, or `null!`, so validate explicitly. + if (doc.Id is null) throw new ArgumentException("DocumentInfo.Id must not be null", nameof(doc)); + if (doc.Text is null) throw new ArgumentException("DocumentInfo.Text must not be null", nameof(doc)); + + IReadOnlyDictionary? metadata = null; + if (doc.Metadata is not null) + { + var copy = new Dictionary(doc.Metadata.Count); + foreach (KeyValuePair kv in doc.Metadata) + { + if (kv.Key is null) throw new ArgumentException("DocumentInfo.Metadata keys must not be null", nameof(doc)); + if (kv.Value is null) throw new ArgumentException("DocumentInfo.Metadata values must not be null", nameof(doc)); + copy[kv.Key] = kv.Value; + } + metadata = copy; + } + + IReadOnlyList? embedding = doc.Embedding?.ToArray(); + return new DocumentInfo(doc.Id, doc.Text, metadata, embedding); + } + + internal static string[] SnapshotIds(IEnumerable docIds) + { + if (docIds is null) throw new ArgumentNullException(nameof(docIds)); + string[] array = docIds.ToArray(); + foreach (string id in array) + if (id is null) throw new ArgumentException("document ids must not be null", nameof(docIds)); + return array; + } + + internal static QueryOptions SnapshotQuery(QueryOptions options) => new() + { + TopK = options.TopK, + Alpha = options.Alpha, + FilterJson = options.FilterJson, + Embedding = options.Embedding?.ToArray(), + }; + + // ---- Helpers --------------------------------------------------------- + + private static void RequireName(string name) + { + if (string.IsNullOrEmpty(name)) throw new ArgumentException("index name is required", nameof(name)); + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(MossClient)); + } + + /// Release the native client and its resources. + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _native.Dispose(); + // Intentionally not disposing _gate: an in-flight RunAsync still owns it + // and releases it in its finally block, so disposing here would race and + // surface a spurious ObjectDisposedException from Release(). SemaphoreSlim + // holds no unmanaged resource unless its AvailableWaitHandle is + // materialized, which this type never does. + } +} diff --git a/sdks/dotnet/src/Moss/MossException.cs b/sdks/dotnet/src/Moss/MossException.cs new file mode 100644 index 00000000..cfb4a2f4 --- /dev/null +++ b/sdks/dotnet/src/Moss/MossException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Moss; + +/// +/// Thrown when a call into the native libmoss runtime fails. +/// carries the raw MossResult status code and +/// the human-readable message from +/// moss_last_error. +/// +public sealed class MossException : Exception +{ + /// The raw MossResult status code returned by the native call. + public int Code { get; } + + public MossException(int code, string message) : base(message) + { + Code = code; + } +} diff --git a/sdks/dotnet/tests/Moss.Tests/ClientValidationTests.cs b/sdks/dotnet/tests/Moss.Tests/ClientValidationTests.cs new file mode 100644 index 00000000..6c7070f9 --- /dev/null +++ b/sdks/dotnet/tests/Moss.Tests/ClientValidationTests.cs @@ -0,0 +1,28 @@ +using System; +using Moss; +using Xunit; + +namespace Moss.Tests; + +/// +/// Credential validation happens before any native call, so these run without +/// libmoss present. +/// +public class ClientValidationTests +{ + [Theory] + [InlineData("")] + [InlineData(null)] + public void Constructor_RejectsMissingProjectId(string? projectId) + { + Assert.Throws(() => new MossClient(projectId!, "key")); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Constructor_RejectsMissingProjectKey(string? projectKey) + { + Assert.Throws(() => new MossClient("project", projectKey!)); + } +} diff --git a/sdks/dotnet/tests/Moss.Tests/InteropTests.cs b/sdks/dotnet/tests/Moss.Tests/InteropTests.cs new file mode 100644 index 00000000..1b38ff36 --- /dev/null +++ b/sdks/dotnet/tests/Moss.Tests/InteropTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Moss.Interop; +using Xunit; + +namespace Moss.Tests; + +/// +/// Exercises the marshaling helpers and native struct layouts. None of these +/// call into libmoss, so they run anywhere. +/// +public class InteropTests +{ + [Theory] + [InlineData("hello")] + [InlineData("")] + [InlineData("café über 日本語 🚀")] + public void Utf8_RoundTrips(string value) + { + IntPtr p = Utf8.Alloc(value); + try + { + Assert.Equal(value, Utf8.Read(p)); + } + finally + { + Marshal.FreeCoTaskMem(p); + } + } + + [Fact] + public void Utf8_NullPointerReadsAsEmptyOrNull() + { + Assert.Equal(string.Empty, Utf8.Read(IntPtr.Zero)); + Assert.Null(Utf8.ReadOptional(IntPtr.Zero)); + } + + [Fact] + public void Utf8_AllocNullReturnsZero() + { + Assert.Equal(IntPtr.Zero, Utf8.Alloc(null)); + } + + [Fact] + public void NativeArena_FloatArrayRoundTrips() + { + var values = new[] { 1.5f, -2.25f, 3.0f }; + using var arena = new NativeArena(); + IntPtr p = arena.FloatArray(values); + Assert.NotEqual(IntPtr.Zero, p); + + var read = new float[values.Length]; + Marshal.Copy(p, read, 0, values.Length); + Assert.Equal(values, read); + } + + [Fact] + public void NativeArena_EmptyCollectionsReturnZero() + { + using var arena = new NativeArena(); + Assert.Equal(IntPtr.Zero, arena.FloatArray(Array.Empty())); + Assert.Equal(IntPtr.Zero, arena.StringArray(Array.Empty())); + Assert.Equal(IntPtr.Zero, arena.FloatArray(null)); + } + + [Fact] + public void NativeArena_StringArrayWritesReadablePointers() + { + var values = new[] { "alpha", "beta" }; + using var arena = new NativeArena(); + IntPtr block = arena.StringArray(values); + Assert.NotEqual(IntPtr.Zero, block); + + for (int i = 0; i < values.Length; i++) + { + IntPtr strPtr = Marshal.ReadIntPtr(block, i * IntPtr.Size); + Assert.Equal(values[i], Utf8.Read(strPtr)); + } + } + + [Fact] + public void NativeArena_DisposeIsIdempotent() + { + var arena = new NativeArena(); + arena.String("x"); + arena.Dispose(); + arena.Dispose(); // must not throw + } + + // Layout sanity: sizes are pointer-derived, so these catch accidental field + // reordering or type changes in the ABI mirror structs. + [Fact] + public void NativeStructs_HaveExpectedSizes() + { + Assert.Equal(2 * IntPtr.Size, Marshal.SizeOf()); + Assert.Equal(2 * IntPtr.Size, Marshal.SizeOf()); + // 4 pointers + 2 nuint + Assert.Equal(6 * IntPtr.Size, Marshal.SizeOf()); + } +} diff --git a/sdks/dotnet/tests/Moss.Tests/ModelTests.cs b/sdks/dotnet/tests/Moss.Tests/ModelTests.cs new file mode 100644 index 00000000..26a431ae --- /dev/null +++ b/sdks/dotnet/tests/Moss.Tests/ModelTests.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Moss; +using Xunit; + +namespace Moss.Tests; + +public class ModelTests +{ + [Fact] + public void QueryOptions_HasSensibleDefaults() + { + var opts = new QueryOptions(); + Assert.Equal(10, opts.TopK); + Assert.Equal(0.5f, opts.Alpha); + Assert.Null(opts.FilterJson); + Assert.Null(opts.Embedding); + } + + [Fact] + public void DocumentInfo_ConstructorSetsFields() + { + var meta = new Dictionary { ["lang"] = "en" }; + var embedding = new[] { 0.1f, 0.2f }; + var doc = new DocumentInfo("doc-1", "hello world", meta, embedding); + + Assert.Equal("doc-1", doc.Id); + Assert.Equal("hello world", doc.Text); + Assert.Same(meta, doc.Metadata); + Assert.Same(embedding, doc.Embedding); + } + + [Fact] + public void DocumentInfo_DefaultsAreEmptyNotNull() + { + var doc = new DocumentInfo(); + Assert.Equal(string.Empty, doc.Id); + Assert.Equal(string.Empty, doc.Text); + Assert.Null(doc.Metadata); + Assert.Null(doc.Embedding); + } + + [Fact] + public void SearchResult_DefaultsToEmptyDocs() + { + var result = new SearchResult(); + Assert.NotNull(result.Docs); + Assert.Empty(result.Docs); + } +} diff --git a/sdks/dotnet/tests/Moss.Tests/Moss.Tests.csproj b/sdks/dotnet/tests/Moss.Tests/Moss.Tests.csproj new file mode 100644 index 00000000..07cc7657 --- /dev/null +++ b/sdks/dotnet/tests/Moss.Tests/Moss.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + diff --git a/sdks/dotnet/tests/Moss.Tests/SnapshotTests.cs b/sdks/dotnet/tests/Moss.Tests/SnapshotTests.cs new file mode 100644 index 00000000..42fb1ee9 --- /dev/null +++ b/sdks/dotnet/tests/Moss.Tests/SnapshotTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Moss; +using Xunit; + +namespace Moss.Tests; + +/// +/// Verifies that inputs are defensively copied, so a caller mutating their own +/// collections after an async call cannot change what gets sent to native code. +/// +public class SnapshotTests +{ + [Fact] + public void SnapshotDoc_DeepCopiesMetadataAndEmbedding() + { + var meta = new Dictionary { ["k"] = "v" }; + var embedding = new List { 1f, 2f }; + var doc = new DocumentInfo("1", "text", meta, embedding); + + DocumentInfo snap = MossClient.SnapshotDoc(doc); + + // Mutate the caller's originals after snapshotting. + meta["k"] = "changed"; + embedding[0] = 9f; + + Assert.Equal("v", snap.Metadata!["k"]); + Assert.Equal(1f, snap.Embedding![0]); + Assert.NotSame(doc.Metadata, snap.Metadata); + Assert.NotSame(doc.Embedding, snap.Embedding); + } + + [Fact] + public void SnapshotDoc_PreservesNullMetadataAndEmbedding() + { + DocumentInfo snap = MossClient.SnapshotDoc(new DocumentInfo("1", "text")); + Assert.Null(snap.Metadata); + Assert.Null(snap.Embedding); + } + + [Fact] + public void SnapshotQuery_CopiesEmbeddingAndScalars() + { + var embedding = new List { 0.5f }; + var opts = new QueryOptions { TopK = 3, Alpha = 0.2f, FilterJson = "{}", Embedding = embedding }; + + QueryOptions snap = MossClient.SnapshotQuery(opts); + embedding[0] = 9f; + + Assert.Equal(3, snap.TopK); + Assert.Equal(0.2f, snap.Alpha); + Assert.Equal("{}", snap.FilterJson); + Assert.Equal(0.5f, snap.Embedding![0]); + } + + [Fact] + public void SnapshotDoc_ThrowsOnNullId() + { + Assert.Throws(() => MossClient.SnapshotDoc(new DocumentInfo(null!, "text"))); + } + + [Fact] + public void SnapshotDoc_ThrowsOnNullText() + { + Assert.Throws(() => MossClient.SnapshotDoc(new DocumentInfo("1", null!))); + } + + [Fact] + public void SnapshotDoc_ThrowsOnNullMetadataValue() + { + var meta = new Dictionary { ["k"] = null! }; + Assert.Throws(() => MossClient.SnapshotDoc(new DocumentInfo("1", "text", meta))); + } + + [Fact] + public void SnapshotIds_ThrowsOnNullEntry() + { + Assert.Throws(() => MossClient.SnapshotIds(new[] { "a", null! })); + } +}