From d5cb8654b84010ad6da5c53bdce5f979199030a9 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Wed, 19 Aug 2026 07:58:51 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20parity=20port=20=E2=80=94=20split?= =?UTF-8?q?=20services,=20RequestOptions,=20errors,=20paging,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the staged SDK parity work that has been sitting uncommitted. This is not optional cleanup: `main` does not currently compile. MemMeshClient and MemoryService reference RequestOptions, MemMeshException, SeekPage<>, RequestInterceptor/ResponseInterceptor, and six service classes that exist only in these files — 25 CS0246 errors on a clean checkout. Splits the monolithic Services.cs into per-service files (Alerts, Brains, Consent, Events, Financial, Lattice, Typed), adds Errors.cs, Pagination.cs, and RequestOptions.cs, plus an xunit suite with a stubbed HttpMessageHandler. 103 tests pass. --- .github/workflows/publish.yml | 30 +++ src/AlertsService.cs | 56 ++++++ src/BrainsService.cs | 86 +++++++++ src/ConsentService.cs | 166 +++++++++++++++++ src/Errors.cs | 65 +++++++ src/EventsService.cs | 108 +++++++++++ src/FinancialService.cs | 132 +++++++++++++ src/LatticeService.cs | 219 ++++++++++++++++++++++ src/MemMesh.csproj | 8 + src/Pagination.cs | 36 ++++ src/RequestOptions.cs | 27 +++ src/Services.cs | 326 +++++++++++++++++++++++++++------ src/TypedService.cs | 90 +++++++++ test/AlertsServiceTests.cs | 192 +++++++++++++++++++ test/BrainsServiceTests.cs | 156 ++++++++++++++++ test/ComplianceServiceTests.cs | 195 ++++++++++++++++++++ test/ConsentServiceTests.cs | 138 ++++++++++++++ test/EventsServiceTests.cs | 131 +++++++++++++ test/FinancialServiceTests.cs | 234 +++++++++++++++++++++++ test/HealthServiceTests.cs | 168 +++++++++++++++++ test/InfraTests.cs | 183 ++++++++++++++++++ test/LatticeServiceTests.cs | 318 ++++++++++++++++++++++++++++++++ test/LearningServiceTests.cs | 178 ++++++++++++++++++ test/MemMesh.Tests.csproj | 22 +++ test/StubHandler.cs | 31 ++++ test/TypedServiceTests.cs | 160 ++++++++++++++++ 26 files changed, 3394 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 src/AlertsService.cs create mode 100644 src/BrainsService.cs create mode 100644 src/ConsentService.cs create mode 100644 src/Errors.cs create mode 100644 src/EventsService.cs create mode 100644 src/FinancialService.cs create mode 100644 src/LatticeService.cs create mode 100644 src/Pagination.cs create mode 100644 src/RequestOptions.cs create mode 100644 src/TypedService.cs create mode 100644 test/AlertsServiceTests.cs create mode 100644 test/BrainsServiceTests.cs create mode 100644 test/ComplianceServiceTests.cs create mode 100644 test/ConsentServiceTests.cs create mode 100644 test/EventsServiceTests.cs create mode 100644 test/FinancialServiceTests.cs create mode 100644 test/HealthServiceTests.cs create mode 100644 test/InfraTests.cs create mode 100644 test/LatticeServiceTests.cs create mode 100644 test/LearningServiceTests.cs create mode 100644 test/MemMesh.Tests.csproj create mode 100644 test/StubHandler.cs create mode 100644 test/TypedServiceTests.cs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f55d4f6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to NuGet + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore src/MemMesh.csproj + + - name: Build (gate) + run: dotnet build src/MemMesh.csproj -c Release --no-restore + + - name: Pack + run: dotnet pack src/MemMesh.csproj -c Release --no-build -o ./artifacts + + - name: Push to NuGet + run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/src/AlertsService.cs b/src/AlertsService.cs new file mode 100644 index 0000000..efab60e --- /dev/null +++ b/src/AlertsService.cs @@ -0,0 +1,56 @@ +namespace MemMesh; + +/// Alerts — user-defined "tell me when X happens, this way" rules that +/// hook into the engine event stream. Triggers match the same event types +/// returns; channels deliver via HTTP +/// webhook or write the alert back as a memory item for the LLM to pick up on the +/// next context build. +/// +/// Mirrors the TS reference surface (tf.alerts.*). Rules live at +/// /memory-alerts; a rule's recent fires at /memory-alerts/{id}/fires. +/// +/// var rule = await mm.Alerts.CreateAsync(new CreateAlertRuleRequest( +/// Name: "VIP at risk", +/// Trigger: new AlertTrigger("engine-event", EventTypes: ["risk.fired"]), +/// Notify: [new NotificationChannel("webhook", Url: "https://hooks.slack.com/...")], +/// Throttle: new ThrottleConfig(DedupOn: "subject", CooldownMinutes: 60))); +/// var fires = await mm.Alerts.ListFiresAsync(rule.Id); +/// +public sealed class AlertsService(MemMeshClient c) +{ + /// List every alert rule in the project. + public Task> ListAsync(RequestOptions? options = null, CancellationToken ct = default) + => c.Send>(HttpMethod.Get, "memory-alerts", null, options, ct); + + /// Fetch one alert rule by id. + public Task GetAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Get, $"memory-alerts/{Uri.EscapeDataString(alertId)}", null, options, ct); + + /// Create an alert rule. + public Task CreateAsync(CreateAlertRuleRequest body, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-alerts", body, options, ct); + + /// Patch an alert rule — only the fields you set are sent. + public Task UpdateAsync(string alertId, UpdateAlertRuleRequest body, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Patch, $"memory-alerts/{Uri.EscapeDataString(alertId)}", body, options, ct); + + /// Delete an alert rule. + public Task DeleteAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default) + => c.SendVoid(HttpMethod.Delete, $"memory-alerts/{Uri.EscapeDataString(alertId)}", null, options, ct); + + /// Convenience — patch only the enabled flag on. + public Task EnableAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default) + => UpdateAsync(alertId, new UpdateAlertRuleRequest(Enabled: true), options, ct); + + /// Convenience — patch only the enabled flag off. + public Task DisableAsync(string alertId, RequestOptions? options = null, CancellationToken ct = default) + => UpdateAsync(alertId, new UpdateAlertRuleRequest(Enabled: false), options, ct); + + /// The last ~100 fires for a rule, newest first. + public Task> ListFiresAsync(string alertId, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send>(HttpMethod.Get, + $"memory-alerts/{Uri.EscapeDataString(alertId)}/fires", null, options, ct); +} diff --git a/src/BrainsService.cs b/src/BrainsService.cs new file mode 100644 index 0000000..a27e83c --- /dev/null +++ b/src/BrainsService.cs @@ -0,0 +1,86 @@ +namespace MemMesh; + +/// Brains — the marketplace registry. Register, version, and manage the +/// brains a project publishes. A brain carries a Brain Card manifest (ontology, +/// provenance, coverage, eval, pricing) and a stable ExternalId slug the +/// Mesh Router addresses it by. +/// +/// Once a brain is PUBLISHED + PUBLIC, any caller can consume it +/// over the hosted MCP endpoint (/brains/{brainId}/mcp-server/http); +/// consumption is an MCP connection, not a REST call, so it lives outside this +/// resource. +/// +/// Mirrors the TS reference surface (tf.brains.*): create, the high-level +/// createFromProject path, cursor-paginated list, get, update/version, delete. +/// +/// var brain = await mm.Brains.CreateAsync(new CreateBrainRequest( +/// ExternalId: "sec-edgar-financials", Name: "SEC EDGAR Financials", +/// Domain: "finance", Version: "2026.07.0", +/// Card: new BrainCard(Provenance: [new BrainProvenance("SEC EDGAR", "public-domain")]))); +/// await mm.Brains.UpdateAsync(brain.Id, +/// new UpdateBrainRequest(Visibility: "PUBLIC", Status: "PUBLISHED")); +/// +/// var page = await mm.Brains.ListAsync(limit: 20); +/// foreach (var b in page.Data) Console.WriteLine($"{b.ExternalId} {b.Status}"); +/// +public sealed class BrainsService(MemMeshClient c) +{ + /// Register a new brain in the project's catalog. + public Task CreateAsync(CreateBrainRequest body, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "brains", body, options, ct); + + /// Create a brain from the calling project's memory — the easy, + /// high-level path. Where wants a full + /// , this builds a sensible one for you from + /// just a slug + name (plus optional domain / version / visibility) and an + /// empty-but-valid Brain Card. Coverage is computed server-side from the + /// project's own memory, so you don't pass it. The brain is created as a + /// DRAFT + PRIVATE; publishing and pricing are deliberate, + /// separate steps. + public Task CreateFromProjectAsync(string externalId, string name, + string? domain = null, string? version = null, string? visibility = null, + RequestOptions? options = null, CancellationToken ct = default) + { + // Empty-but-valid card: an empty provenance list and empty coverage. The + // server recomputes coverage from the project's memory; a real licensed + // provenance source is only required to publish PUBLIC (a separate step). + var body = new CreateBrainRequest( + ExternalId: externalId, + Name: name, + Domain: domain, + Version: version ?? "1.0.0", + Visibility: visibility ?? "PRIVATE", + Card: new BrainCard(Provenance: [], Coverage: new BrainCoverage())); + return CreateAsync(body, options, ct); + } + + /// List the project's brains (cursor-paginated). Pass the returned + /// page's Next cursor back as for the + /// following page; Next/Previous are null at the respective + /// ends. To stream every brain and follow cursors automatically, use + /// over the brains path. + public Task> ListAsync(int? limit = null, string? cursor = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var path = "brains" + (limit is not null ? $"?limit={limit}" : ""); + return c.GetPageAsync(path, cursor, options, ct); + } + + /// Fetch one brain by id. 404s if it doesn't exist or belongs to a + /// different project. + public Task GetAsync(string brainId, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Get, $"brains/{Uri.EscapeDataString(brainId)}", null, options, ct); + + /// Update / version a brain (name, version, visibility, status, card, + /// …). + public Task UpdateAsync(string brainId, UpdateBrainRequest body, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Patch, $"brains/{Uri.EscapeDataString(brainId)}", body, options, ct); + + /// Delete a brain from the catalog. + public Task DeleteAsync(string brainId, RequestOptions? options = null, + CancellationToken ct = default) + => c.SendVoid(HttpMethod.Delete, $"brains/{Uri.EscapeDataString(brainId)}", null, options, ct); +} diff --git a/src/ConsentService.cs b/src/ConsentService.cs new file mode 100644 index 0000000..5440644 --- /dev/null +++ b/src/ConsentService.cs @@ -0,0 +1,166 @@ +using System.Globalization; +using System.Text.Json; + +namespace MemMesh; + +/// Subject-level consent / opt-out. +/// +/// Records consent decisions as memory items of type='consent' so the +/// audit log captures every change. The Rust mining engine honors opt-outs at the +/// start of every mine pass — opted-out subjects are skipped, and their behavior +/// patterns aren't generated. +/// +/// Foundations of the EU AI Act / GDPR Art. 22 compliance story: subject-level +/// (per-person / per-team / per-workspace) opt-out; audit-traceable (every +/// opt-out is a confirmed memory item); reversible (opt-in re-enables mining, +/// without restoring prior patterns — those must be re-mined to honor the gap). +/// +/// Implementation note: this surface has no dedicated endpoints. It is +/// implemented entirely client-side over the admin memory CRUD — consent is +/// written and read as memory items — exactly mirroring the TS +/// ConsentResource. When the engine's dedicated subject_consent +/// table lands, this contract does not change. +public sealed class ConsentService(MemMeshClient c) +{ + /// Mark a subject as opted-out. Mining and recall must honor this: + /// the engine skips opted-out subjects at mine time. + /// + /// await mm.Consent.OptOutAsync(new Subject("contact", "sarah-pizza"), + /// reason: "GDPR Art. 17 request 2026-05-25"); + /// + public async Task OptOutAsync(Subject subject, string? reason = null, + RequestOptions? options = null, CancellationToken ct = default) + { + // Supersede any prior consent record so the audit log shows the history + // but only one row is "active". + await SupersedePriorConsentAsync(subject, options, ct).ConfigureAwait(false); + + var now = IsoNow(); + var memory = await CreateConsentMemoryAsync( + $"[consent] {subject.Kind}:{subject.ExternalId} opted out", + new Dictionary + { + ["subject"] = subject, + ["optedOut"] = true, + ["optedOutAt"] = now, + ["reason"] = reason, + ["recordKind"] = "consent", + }, options, ct).ConfigureAwait(false); + + return new ConsentStatus(subject, OptedOut: true, OptedOutAt: now, Reason: reason, MemoryId: memory.Id); + } + + /// Restore consent for a subject. Mining resumes from the next pass. + /// Prior patterns are NOT auto-restored — they must be re-mined so the gap + /// during opt-out is honored. + public async Task OptInAsync(Subject subject, RequestOptions? options = null, + CancellationToken ct = default) + { + await SupersedePriorConsentAsync(subject, options, ct).ConfigureAwait(false); + + var now = IsoNow(); + var memory = await CreateConsentMemoryAsync( + $"[consent] {subject.Kind}:{subject.ExternalId} opted in", + new Dictionary + { + ["subject"] = subject, + ["optedOut"] = false, + ["optedOutAt"] = null, + ["reason"] = null, + ["recordKind"] = "consent", + }, options, ct).ConfigureAwait(false); + + return new ConsentStatus(subject, OptedOut: false, OptedOutAt: null, Reason: null, MemoryId: memory.Id); + } + + /// Read the current consent status for a subject. Returns + /// OptedOut = false (default) if no consent record exists. + public async Task GetStatusAsync(Subject subject, RequestOptions? options = null, + CancellationToken ct = default) + { + var active = await FindActiveConsentAsync(subject, options, ct).ConfigureAwait(false); + if (active is null) + return new ConsentStatus(subject, OptedOut: false, OptedOutAt: null, Reason: null, MemoryId: null); + + var md = active.Metadata; + return new ConsentStatus( + subject, + OptedOut: ReadBool(md, "optedOut"), + OptedOutAt: ReadString(md, "optedOutAt"), + Reason: ReadString(md, "reason"), + MemoryId: active.Id); + } + + // ── private helpers ────────────────────────────────────────────────────── + + // Mirror the TS createMemory closure: spread content/type/scope/importance/ + // category/metadata onto the admin memory create body. + private Task CreateConsentMemoryAsync(string content, + IDictionary metadata, RequestOptions? options, CancellationToken ct) + { + var body = new Dictionary + { + ["content"] = content, + ["type"] = "consent", + ["scope"] = "project", + ["importance"] = 10, + ["category"] = "consent", + ["metadata"] = metadata, + }; + return c.Send(HttpMethod.Post, "admin/memory", body, options, ct); + } + + private async Task FindActiveConsentAsync(Subject subject, + RequestOptions? options, CancellationToken ct) + { + // The engine caps `limit` at 500 (querystring/limit must be <= 500); 1000 + // hard-fails every consent lookup. + var all = await c.Send>(HttpMethod.Get, "admin/memory?limit=500", + null, options, ct).ConfigureAwait(false); + + return all + .Where(m => m.Type == "consent" && SubjectMatches(m, subject)) + .OrderByDescending(m => ParseCreated(m.Created)) + .FirstOrDefault(); + } + + private async Task SupersedePriorConsentAsync(Subject subject, RequestOptions? options, + CancellationToken ct) + { + var prior = await FindActiveConsentAsync(subject, options, ct).ConfigureAwait(false); + if (prior is not null) + // Hard-delete the prior row. The audit log keeps the historical trail; + // we don't need the old row in the active set anymore. + await c.SendVoid(HttpMethod.Delete, $"admin/memory/{prior.Id}", null, options, ct) + .ConfigureAwait(false); + } + + private static bool SubjectMatches(MemoryItem item, Subject subject) + { + if (item.Metadata is null || + !item.Metadata.TryGetValue("subject", out var s) || + s.ValueKind != JsonValueKind.Object) + return false; + var kind = s.TryGetProperty("kind", out var k) && k.ValueKind == JsonValueKind.String + ? k.GetString() : null; + var externalId = s.TryGetProperty("externalId", out var e) && e.ValueKind == JsonValueKind.String + ? e.GetString() : null; + return kind == subject.Kind && externalId == subject.ExternalId; + } + + // toISOString() parity: millisecond precision, UTC 'Z' suffix. + private static string IsoNow() => + DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + + private static DateTimeOffset ParseCreated(string? created) => + DateTimeOffset.TryParse(created, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt) + ? dt : DateTimeOffset.MinValue; + + private static bool ReadBool(IReadOnlyDictionary? md, string key) => + md is not null && md.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.True; + + private static string? ReadString(IReadOnlyDictionary? md, string key) => + md is not null && md.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; +} diff --git a/src/Errors.cs b/src/Errors.cs new file mode 100644 index 0000000..d13ce60 --- /dev/null +++ b/src/Errors.cs @@ -0,0 +1,65 @@ +namespace MemMesh; + +/// Base type for every error the MemMesh API surfaces. Catch this to +/// handle any failure uniformly; catch a subclass to branch on the HTTP status. +/// Status is the HTTP status (0 for transport-level failures), +/// Code the machine-readable error code, and Params any structured +/// validation detail the server returned. +public class MemMeshException : Exception +{ + public int Status { get; } + public string Body { get; } + public string Code { get; } + public IReadOnlyDictionary? Params { get; } + + public MemMeshException(int status, string body, string code = "UNKNOWN", + IReadOnlyDictionary? @params = null) + : base($"memmesh: {status} {body}") + { + Status = status; + Body = body; + Code = code; + Params = @params; + } +} + +/// 401 — invalid or missing API key. +public sealed class AuthenticationException(string body = "Invalid or missing API key") + : MemMeshException(401, body, "AUTHENTICATION_ERROR"); + +/// 403 — the key is valid but lacks permission for this resource. +public sealed class AuthorizationException(string body = "Insufficient permissions") + : MemMeshException(403, body, "AUTHORIZATION_ERROR"); + +/// 404 — resource not found. +public sealed class NotFoundException(string body = "Resource not found") + : MemMeshException(404, body, "NOT_FOUND"); + +/// 400 / 422 — the request failed validation. Params carries the +/// per-field detail when the server supplies it. +public sealed class ValidationException(int status, string body = "Validation failed", + IReadOnlyDictionary? @params = null) + : MemMeshException(status, body, "VALIDATION_ERROR", @params); + +/// 429 — rate limited. RetryAfter is the server-advised wait when +/// a Retry-After header was present; the client already honors it on retry. +public sealed class RateLimitException(string body = "Rate limit exceeded", TimeSpan? retryAfter = null) + : MemMeshException(429, body, "RATE_LIMIT") +{ + public TimeSpan? RetryAfter { get; } = retryAfter; +} + +/// 5xx — the server failed to fulfill an otherwise valid request. Retried +/// with backoff before it surfaces. +public sealed class ServerException(int status, string body = "Internal server error") + : MemMeshException(status, body, "SERVER_ERROR"); + +/// The request exceeded its timeout (default 30s, per-call overridable). +/// Not retried — a slow endpoint is unlikely to be faster on an immediate retry. +public sealed class RequestTimeoutException(string body = "Request timed out") + : MemMeshException(0, body, "TIMEOUT"); + +/// A transport-level failure (DNS, connection reset, TLS) with no HTTP +/// response. Retried with backoff before it surfaces. +public sealed class NetworkException(string body = "Network error") + : MemMeshException(0, body, "NETWORK_ERROR"); diff --git a/src/EventsService.cs b/src/EventsService.cs new file mode 100644 index 0000000..8c94d42 --- /dev/null +++ b/src/EventsService.cs @@ -0,0 +1,108 @@ +namespace MemMesh; + +/// Events — the durable memory event log. The engine emits events on +/// interesting state changes (pattern emergence, risk firing, segment shift, +/// consent change); consumers walk the log with (reads +/// /memory-events) or subscribe to a background poll loop with +/// . is the write side, appending +/// to the log (/lattice/events/emit) and firing matching alert rules +/// synchronously. +/// +/// Mirrors the TS reference surface (tf.events.*). +/// +/// using var sub = mm.Events.Subscribe(async e => +/// Console.WriteLine($"[{e.Severity}] {e.EventType}"), +/// eventTypes: ["risk.fired", "segment.changed"]); +/// // ... later: sub.Dispose() stops the loop. +/// +public sealed class EventsService(MemMeshClient c) +{ + /// Pull events newer than (an ISO timestamp; + /// use the last event's OccurredAt as the next cursor). + /// defaults to 100, max 1000; restricts to specific + /// types. + public async Task> PollAsync(string? since = null, int? limit = null, + IEnumerable? eventTypes = null, RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (since is not null) q.Add($"since={Uri.EscapeDataString(since)}"); + if (limit is not null) q.Add($"limit={limit}"); + var types = eventTypes?.ToList(); + if (types is { Count: > 0 }) q.Add($"eventTypes={Uri.EscapeDataString(string.Join(",", types))}"); + var path = "memory-events" + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + return await c.Send>(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + } + + /// Append an event to the durable log. Matching alert rules fire + /// synchronously; the write-side counterpart to . + public Task EmitAsync(EmitEventRequest body, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "lattice/events/emit", body, options, ct); + + /// Convenience: poll in the background and invoke + /// for each event, threading the cursor forward automatically. Handler exceptions + /// and transient network errors are swallowed so the loop survives. The interval + /// defaults to 5s and is floored at 500ms. Returns an + /// Dispose() it to stop the loop. + public EventSubscription Subscribe(Func handler, string? since = null, + int? limit = null, IEnumerable? eventTypes = null, TimeSpan? interval = null, + RequestOptions? options = null) + { + var ms = interval?.TotalMilliseconds ?? 5000; + var effective = TimeSpan.FromMilliseconds(Math.Max(500, ms)); + return new EventSubscription(this, handler, since, limit, eventTypes?.ToList(), effective, options); + } +} + +/// A running background poll loop started by +/// . Owns a +/// and a background ; cancels the loop (idempotent). +/// Await after disposing to join the loop — mainly for tests. +public sealed class EventSubscription : IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private readonly Task _loop; + + internal EventSubscription(EventsService events, Func handler, string? since, + int? limit, IReadOnlyList? eventTypes, TimeSpan interval, RequestOptions? options) + => _loop = RunAsync(events, handler, since, limit, eventTypes, interval, options); + + private async Task RunAsync(EventsService events, Func handler, string? cursor, + int? limit, IReadOnlyList? eventTypes, TimeSpan interval, RequestOptions? options) + { + var ct = _cts.Token; + try + { + while (!ct.IsCancellationRequested) + { + try + { + var batch = await events.PollAsync(cursor, limit, eventTypes, options, ct).ConfigureAwait(false); + foreach (var e in batch) + { + if (ct.IsCancellationRequested) break; + try { await handler(e).ConfigureAwait(false); } + catch { /* handler errors are the caller's concern; don't kill the loop */ } + cursor = e.OccurredAt; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + catch { /* network blip — back off briefly and retry */ } + + try { await Task.Delay(interval, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + finally { _cts.Dispose(); } + } + + /// Completes once the background poll loop has stopped. + public Task Completion => _loop; + + /// Stop polling. Idempotent. + public void Dispose() + { + try { _cts.Cancel(); } + catch (ObjectDisposedException) { /* already stopped */ } + } +} diff --git a/src/FinancialService.cs b/src/FinancialService.cs new file mode 100644 index 0000000..4db75c0 --- /dev/null +++ b/src/FinancialService.cs @@ -0,0 +1,132 @@ +using System.Globalization; + +namespace MemMesh; + +/// Financial — technical indicators, portfolio risk, and a +/// self-calibrating directional prediction loop for the engine's financial +/// vertical. Financial data IS memory data: ingest price bars, fundamentals, +/// holdings, and news as fact memories (stored via /admin/memory) and the +/// engine derives indicators, risk, and buy/sell/hold calls whose reported +/// confidence is structural agreement × realized hit-rate. Score due calls with +/// and inspect honesty with +/// at /lattice/financial/*. +/// +/// Requires the @thinkfleet/pack-financial pack; the read methods return +/// FAILED_PRECONDITION otherwise (ingestion works regardless — it's plain +/// memory). Mirrors the TS reference surface (tf.financial.*). +/// Informational only — NOT investment advice. +/// +/// Market data (prices / fundamentals / news) is pooled across the project; +/// holdings are private to their subject. A subject is caller-asserted — +/// the engine does not verify ownership, so only ever pass a subject the current +/// user is entitled to. +public sealed class FinancialService(MemMeshClient c) +{ + // ── Input — ingest market data + positions (stored as fact memories) ── + + /// Ingest a single price bar. Market data — not subject-attributed. + public Task IngestPriceAsync(PriceInput price, RequestOptions? options = null, + CancellationToken ct = default) + { + var content = $"{price.Ticker} close {price.Close.ToString(CultureInfo.InvariantCulture)}" + + (price.AsOf is not null ? $" @ {price.AsOf}" : ""); + return RecordAsync(content, new Dictionary { ["price"] = price }, options, ct); + } + + /// Ingest many price bars (e.g. a backfill). Issued concurrently; + /// resolves once all are stored. For very large histories, batch yourself. + public async Task> IngestPricesAsync(IEnumerable prices, + RequestOptions? options = null, CancellationToken ct = default) + { + var results = await Task.WhenAll(prices.Select(p => IngestPriceAsync(p, options, ct))) + .ConfigureAwait(false); + return results.ToList(); + } + + /// Ingest/refresh a ticker's fundamentals. Latest values win. + public Task IngestFundamentalsAsync(FundamentalInput fundamental, + RequestOptions? options = null, CancellationToken ct = default) + => RecordAsync($"Fundamentals {fundamental.Ticker}", + new Dictionary { ["fundamental"] = fundamental }, options, ct); + + /// Record a portfolio position. Subject-private — attributed to the + /// owner (use a { kind: "portfolio", externalId } subject). Restated, + /// not summed: re-recording a ticker replaces the prior position. + public Task IngestHoldingAsync(Subject subject, HoldingInput holding, + RequestOptions? options = null, CancellationToken ct = default) + => RecordAsync($"Holding {holding.Shares.ToString(CultureInfo.InvariantCulture)} {holding.Ticker}", + new Dictionary { ["subject"] = subject, ["holding"] = holding }, options, ct); + + /// Ingest a news event. Market data — tag one or many tickers. + public Task IngestNewsAsync(NewsInput news, RequestOptions? options = null, + CancellationToken ct = default) + { + var label = news.Ticker + ?? (news.Tickers is { Count: > 0 } t ? string.Join(",", t) : null) + ?? "news"; + return RecordAsync($"News [{label}]: {news.Headline}", + new Dictionary { ["newsEvent"] = news }, options, ct); + } + + // ── Read — indicators, risk, calibrated predictions ── + + /// Technical indicators + (for a portfolio subject) a risk rollup, + /// derived from ingested market data and holdings. Read-only and forecast-free. + /// subject.kind == "ticker" → single-name analysis (externalId is the + /// ticker); any other kind → portfolio mode over the subject's holdings. + public Task GetProfileAsync(Subject subject, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "lattice/financial/profile", + new Dictionary { ["subject"] = subject }, options, ct); + + /// Generate directional buy/sell/hold calls. Reported confidence = + /// structural agreement × the strategy's realized reliability. By default each + /// call is persisted so it can be scored at horizon by + /// . defaults to 30 + /// (clamped [1, 365]); defaults to true. + public Task PredictAsync(Subject subject, int? horizonDays = null, + bool? persist = null, RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary { ["subject"] = subject }; + if (horizonDays is not null) body["horizonDays"] = horizonDays; + if (persist is not null) body["persist"] = persist; + return c.Send(HttpMethod.Post, "lattice/financial/predict", body, options, ct); + } + + /// Run the feedback loop: score every persisted prediction whose horizon + /// has elapsed against the realized close, and mark it resolved. Recomputes + /// reliability and calibration from these outcomes. Idempotent; safe to run on a + /// schedule. + public Task ReconcileAsync(RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "lattice/financial/reconcile", + new Dictionary(), options, ct); + + /// The honesty proof: resolved predictions bucketed by the confidence we + /// reported, with the realized hit-rate per band. + /// defaults to 5 (clamped [1, 20]); pass to filter to + /// one strategy, omit for all. + public Task GetCalibrationAsync(int? bucketCount = null, + string? strategy = null, RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary(); + if (bucketCount is not null) body["bucketCount"] = bucketCount; + if (strategy is not null) body["strategy"] = strategy; + return c.Send(HttpMethod.Post, "lattice/financial/calibration", + body, options, ct); + } + + // Financial signals are plain fact memories (category "financial", source + // "sdk:financial"), so ingestion works even without the financial pack enabled. + private Task RecordAsync(string content, Dictionary metadata, + RequestOptions? options, CancellationToken ct) + => c.Send(HttpMethod.Post, "admin/memory", new Dictionary + { + ["content"] = content, + ["type"] = "fact", + ["scope"] = "project", + ["category"] = "financial", + ["source"] = "sdk:financial", + ["metadata"] = metadata, + }, options, ct); +} diff --git a/src/LatticeService.cs b/src/LatticeService.cs new file mode 100644 index 0000000..32298c7 --- /dev/null +++ b/src/LatticeService.cs @@ -0,0 +1,219 @@ +namespace MemMesh; + +/// Lattice — behavioral pattern intelligence. Mines a subject's event / +/// memory history for repeatable behaviors, projects them forward, and exposes a +/// context bundle a downstream AI step can hand to a message renderer. +/// +/// Mirrors the TS reference surface (tf.lattice.*): extract/mine patterns, +/// inspect/list them, fetch the context bundle, run the pattern-break monitor, +/// predict (pattern-projection or declared-target), profile a subject, compute +/// cohorts, run deterministic estimators, and read the calibration report. +public sealed class LatticeService(MemMeshClient c) +{ + // ── Pattern extraction / mining ───────────────────────────────────────── + + /// Force pattern (re-)extraction. Omit + /// and for a project-wide bulk run. Defaults to + /// mining the memory corpus (source='memories'); pass + /// source='contact_events' for the legacy contact-event path. + public Task ExtractPatternsAsync(string? contactId = null, + int? windowDays = null, bool? force = null, string? source = null, + Subject? subject = null, RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary(); + if (contactId is not null) body["contactId"] = contactId; + if (windowDays is not null) body["windowDays"] = windowDays; + if (force is not null) body["force"] = force; + if (source is not null) body["source"] = source; + if (subject is not null) body["subject"] = subject; + return c.Send(HttpMethod.Post, "lattice/patterns/extract", body, options, ct); + } + + /// Mine behavioral patterns from the memory corpus. Subject-agnostic: + /// works on any subject kind. Thin wrapper over + /// with source='memories'. + public Task MineMemoriesAsync(Subject? subject = null, + int? windowDays = null, bool? force = null, RequestOptions? options = null, + CancellationToken ct = default) + { + var body = new Dictionary { ["source"] = "memories" }; + if (subject is not null) body["subject"] = subject; + if (windowDays is not null) body["windowDays"] = windowDays; + if (force is not null) body["force"] = force; + return c.Send(HttpMethod.Post, "lattice/patterns/extract", body, options, ct); + } + + /// Inspect a single pattern by id. 404s if it doesn't exist or belongs + /// to a different project. + public Task GetPatternAsync(string patternId, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Get, + $"lattice/patterns/{Uri.EscapeDataString(patternId)}", null, options, ct); + + /// List behavior patterns Lattice has learned for a contact. + /// Cursor-paginated — pass the response's NextCursor back as + /// for the next page. + public Task ListPatternsAsync(string contactId, + bool? activeOnly = null, int? limit = null, string? cursor = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (activeOnly is not null) q.Add($"activeOnly={(activeOnly.Value ? "true" : "false")}"); + if (limit is not null) q.Add($"limit={limit}"); + if (cursor is not null) q.Add($"cursor={Uri.EscapeDataString(cursor)}"); + var path = $"lattice/contacts/{Uri.EscapeDataString(contactId)}/patterns" + + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + return c.Send(HttpMethod.Get, path, null, options, ct); + } + + /// Full retrieval bundle for a contact — profile, active patterns, + /// recent events, recent memories, and (optionally) the entity/edge graph. + /// Supports bi-temporal replay via (ISO-8601). + public Task GetContextAsync(string contactId, string? asOf = null, + int? eventsLimit = null, int? memoriesLimit = null, int? graphHops = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (asOf is not null) q.Add($"asOf={Uri.EscapeDataString(asOf)}"); + if (eventsLimit is not null) q.Add($"eventsLimit={eventsLimit}"); + if (memoriesLimit is not null) q.Add($"memoriesLimit={memoriesLimit}"); + if (graphHops is not null) q.Add($"graphHops={graphHops}"); + var path = $"lattice/contacts/{Uri.EscapeDataString(contactId)}/context" + + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + return c.Send(HttpMethod.Get, path, null, options, ct); + } + + // ── Monitor ───────────────────────────────────────────────────────────── + + /// Manually run the pattern-break monitor tick. The platform runs + /// this on a cron; manual triggers exist for debugging and tests that need an + /// overdue pattern to fire immediately. + public Task RunMonitorTickAsync(RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "lattice/monitor/tick", + new Dictionary(), options, ct); + + /// Monitor health: timestamp of the last tick and a count of patterns + /// due for the next check — a liveness probe for the pattern-break dispatcher. + public Task GetMonitorStatusAsync(RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Get, "lattice/monitor/status", null, options, ct); + + // ── Predict ───────────────────────────────────────────────────────────── + + /// Predict for a subject. Two modes: + /// + /// Pattern projection (default). Omit to + /// project the subject's active behavior patterns forward. + /// Declared target (v2). Pass a to + /// predict anything from the subject's observation history; the estimate + /// arrives in PredictResult.TargetPrediction with first-class + /// abstention. Prefer the typed helper. + /// + public Task PredictAsync(Subject subject, int? horizonDays = null, + int? limit = null, double? minConfidence = null, int? occurrencesPerPattern = null, + bool? emitEvents = null, int? imminentWithinHours = null, PredictionTarget? target = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary { ["subject"] = subject }; + if (horizonDays is not null) body["horizonDays"] = horizonDays; + if (limit is not null) body["limit"] = limit; + if (minConfidence is not null) body["minConfidence"] = minConfidence; + if (occurrencesPerPattern is not null) body["occurrencesPerPattern"] = occurrencesPerPattern; + if (emitEvents is not null) body["emitEvents"] = emitEvents; + if (imminentWithinHours is not null) body["imminentWithinHours"] = imminentWithinHours; + if (target is not null) body["target"] = target; + return c.Send(HttpMethod.Post, "lattice/predict", body, options, ct); + } + + /// v2 general prediction, typed: declare *what* to predict and get back + /// the single calibrated (or an abstention). + /// Thin wrapper over . Always check + /// Abstained before reading a value — an abstention means "unknown", + /// never "low risk". If the engine returns none, a synthetic abstention is + /// returned so callers never have to null-check. + public async Task PredictTargetAsync(Subject subject, PredictionTarget target, + int? horizonDays = null, RequestOptions? options = null, CancellationToken ct = default) + { + var result = await PredictAsync(subject, horizonDays: horizonDays, target: target, + options: options, ct: ct).ConfigureAwait(false); + return result.TargetPrediction ?? new TargetPrediction( + TargetKind: target.Kind, + EventType: target.EventType ?? target.AttributeKey ?? "", + Probability: 0, ProbabilityLower: 0, ProbabilityUpper: 0, + Value: 0, ValueLower: 0, ValueUpper: 0, + ExpectedAt: "", ExpectedAtLower: "", ExpectedAtUpper: "", + DaysUntil: 0, AnomalyScore: 0, IsAnomaly: false, + Abstained: true, + AbstentionReason: string.IsNullOrEmpty(result.AbstentionReason) + ? "insufficient_signal: engine returned no target estimate" + : result.AbstentionReason!, + Explanation: "", EvidenceMemoryIds: []); + } + + // ── Profile / cohort ──────────────────────────────────────────────────── + + /// Behavioral profile snapshot for a subject — RFM segment + top + /// entity + cadence summary + risk indicators. Non-temporal counterpart to + /// : predict answers "what will they do?", + /// getProfile answers "who are they?". + public Task GetProfileAsync(Subject subject, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "lattice/profile", + new Dictionary { ["subject"] = subject }, options, ct); + + /// Find subjects whose behavior looks similar to the target — top-K + /// nearest neighbors ranked by a blended similarity score (RFM + entity + /// Jaccard + pattern-kind Jaccard). + public Task GetCohortAsync(Subject subject, int? k = null, + double? minSimilarity = null, RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary { ["subject"] = subject }; + if (k is not null) body["k"] = k; + if (minSimilarity is not null) body["minSimilarity"] = minSimilarity; + return c.Send(HttpMethod.Post, "lattice/cohort", body, options, ct); + } + + /// Cohort-aware predictions — "people like the target also did X." + /// Computes the target's cohort, then aggregates each member's predictions + /// weighted by similarity. Every prediction carries SupportingSubjects + /// + SourceMemoryIds so the answer is fully traceable. + public Task PredictByCohortAsync(Subject subject, int? cohortK = null, + int? predictionLimit = null, double? minSimilarity = null, RequestOptions? options = null, + CancellationToken ct = default) + { + var body = new Dictionary { ["subject"] = subject }; + if (cohortK is not null) body["cohortK"] = cohortK; + if (predictionLimit is not null) body["predictionLimit"] = predictionLimit; + if (minSimilarity is not null) body["minSimilarity"] = minSimilarity; + return c.Send(HttpMethod.Post, "lattice/cohort/predict", body, options, ct); + } + + // ── Estimate / calibration ────────────────────────────────────────────── + + /// Run a deterministic estimator (e.g. PhenoAge biological age) over a + /// subject's biomarker signals. Returns a wellness estimate with per-signal + /// contributors and a not-a-diagnosis disclaimer — never a medical verdict. + /// Pass to store the score as a memory so it builds + /// a trajectory and feeds the calibration loop. + public Task EstimateAsync(Subject subject, string estimatorId, + bool? persist = null, RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary + { + ["subject"] = subject, ["estimatorId"] = estimatorId, + }; + if (persist is not null) body["persist"] = persist; + return c.Send(HttpMethod.Post, "lattice/estimate", body, options, ct); + } + + /// Prediction calibration report: confidence buckets mapped to + /// realized hit-rates. Tells you whether "80% confident" predictions actually + /// fire ~80% of the time. + public Task GetCalibrationAsync(int? bucketCount = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var path = "lattice/calibration" + (bucketCount is not null ? $"?bucketCount={bucketCount}" : ""); + return c.Send(HttpMethod.Get, path, null, options, ct); + } +} diff --git a/src/MemMesh.csproj b/src/MemMesh.csproj index b626942..15fc3af 100644 --- a/src/MemMesh.csproj +++ b/src/MemMesh.csproj @@ -16,7 +16,15 @@ Apache-2.0 https://memmesh.ai https://github.com/ThinkfleetAI/memmesh-dotnet + git ai;agents;memory;llm;prediction;memmesh + README.md + true + snupkg + + + + diff --git a/src/Pagination.cs b/src/Pagination.cs new file mode 100644 index 0000000..d2281a6 --- /dev/null +++ b/src/Pagination.cs @@ -0,0 +1,36 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace MemMesh; + +/// One page of a seek-paginated list endpoint. Next and +/// Previous are opaque cursors — pass Next back to fetch the +/// following page; both are null at the respective ends. +public sealed record SeekPage( + [property: JsonPropertyName("data")] IReadOnlyList Data, + [property: JsonPropertyName("next")] string? Next, + [property: JsonPropertyName("previous")] string? Previous); + +/// Cursor-following helpers over . +public static class Pagination +{ + /// Flatten a seek-paginated endpoint into a single async stream, + /// following Next cursors until exhausted. Resources supply a + /// that fetches one page given a cursor + /// (null for the first page). + public static async IAsyncEnumerable ListAllAsync( + Func>> fetchPage, + [EnumeratorCancellation] CancellationToken ct = default) + { + string? cursor = null; + do + { + ct.ThrowIfCancellationRequested(); + var page = await fetchPage(cursor, ct).ConfigureAwait(false); + if (page.Data is not null) + foreach (var item in page.Data) + yield return item; + cursor = page.Next; + } while (cursor is not null); + } +} diff --git a/src/RequestOptions.cs b/src/RequestOptions.cs new file mode 100644 index 0000000..006c86d --- /dev/null +++ b/src/RequestOptions.cs @@ -0,0 +1,27 @@ +namespace MemMesh; + +/// Per-call overrides threaded through the core send path. Pass one to +/// any resource method that accepts a to redirect a +/// single call to a different project or give it a different timeout, without +/// building a second client. +public sealed record RequestOptions +{ + /// Override the client's default project ID for this one call. + public string? ProjectId { get; init; } + + /// Override the client's default request timeout for this one call. + public TimeSpan? Timeout { get; init; } +} + +/// Runs before each request leaves the client. Mutate the request in +/// place to add headers or swap the bearer token — e.g. exchange the API key for +/// a short-lived Cognito JWT: +/// +/// client.RequestInterceptors.Add(async (req, ct) => +/// req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetJwtAsync(ct))); +/// +public delegate Task RequestInterceptor(HttpRequestMessage request, CancellationToken ct); + +/// Runs after each response arrives, before its body is read or parsed. +/// Useful for logging, metrics, or inspecting response headers. +public delegate Task ResponseInterceptor(HttpResponseMessage response, CancellationToken ct); diff --git a/src/Services.cs b/src/Services.cs index 7d62024..c14edc0 100644 --- a/src/Services.cs +++ b/src/Services.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Text.Json; +using System.Text.Json.Serialization; namespace MemMesh; @@ -55,87 +57,289 @@ private sealed record BatchBundles(List Bundles); private sealed record GraphResult(List Edges); } -public sealed class LatticeService(MemMeshClient c) +/// Learning — the closed-loop decision → action → outcome +/// primitive. Where answers "what will +/// happen?", the learning loop answers "did acting on it work?": record a +/// decision (with links to the patterns/predictions that informed it), record its +/// realized outcome, and every informing pattern's calibrated confidence moves +/// toward what actually happened. rolls "what +/// worked" up per action_type / decision_type / policy / pattern_kind. +/// +/// Mirrors the TS reference surface (tf.learning.*). Domain-agnostic by +/// design — subject/decision/action/outcome/reward only. +public sealed class LearningService(MemMeshClient c) { - public Task PredictAsync(Subject subject, IDictionary target, - CancellationToken ct = default) - => c.Send(HttpMethod.Post, "lattice/predict", - new Dictionary { ["subject"] = subject, ["target"] = target }, ct); + /// Record a decision and its causal provenance. + /// defaults to "executed" server-side; re-sending an + /// returns the existing decision rather than duplicating. + public Task RecordDecisionAsync(Subject subject, string? actor = null, + string? decisionType = null, string? policy = null, IEnumerable? informedBy = null, + string? actionType = null, IDictionary? parameters = null, string? status = null, + string? occurredAt = null, IDictionary? metadata = null, + string? idempotencyKey = null, RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary { ["subject"] = subject }; + if (actor is not null) body["actor"] = actor; + if (decisionType is not null) body["decisionType"] = decisionType; + if (policy is not null) body["policy"] = policy; + if (informedBy is not null) body["informedBy"] = informedBy; + if (actionType is not null) body["actionType"] = actionType; + if (parameters is not null) body["params"] = parameters; + if (status is not null) body["status"] = status; + if (occurredAt is not null) body["occurredAt"] = occurredAt; + if (metadata is not null) body["metadata"] = metadata; + if (idempotencyKey is not null) body["idempotencyKey"] = idempotencyKey; + return c.Send(HttpMethod.Post, "lattice/decisions", body, options, ct); + } - public Task MineAsync(Subject? subject = null, CancellationToken ct = default) + /// Record the realized outcome of a decision. Folds the result into + /// the online calibrated confidence of every pattern the decision was + /// informedBy, and returns the before/after for each. A replayed + /// won't double-count calibration. + public Task RecordOutcomeAsync(string decisionId, string result, + Subject? subject = null, string? outcomeType = null, double? reward = null, + string? realizedAt = null, int? attributionWindowSecs = null, + IDictionary? metadata = null, string? idempotencyKey = null, + RequestOptions? options = null, CancellationToken ct = default) { - var body = new Dictionary(); + var body = new Dictionary { ["decisionId"] = decisionId, ["result"] = result }; if (subject is not null) body["subject"] = subject; - return c.Send(HttpMethod.Post, "lattice/patterns/extract", body, ct); + if (outcomeType is not null) body["outcomeType"] = outcomeType; + if (reward is not null) body["reward"] = reward; + if (realizedAt is not null) body["realizedAt"] = realizedAt; + if (attributionWindowSecs is not null) body["attributionWindowSecs"] = attributionWindowSecs; + if (metadata is not null) body["metadata"] = metadata; + if (idempotencyKey is not null) body["idempotencyKey"] = idempotencyKey; + return c.Send(HttpMethod.Post, "lattice/outcomes", body, options, ct); } - public Task ProfileAsync(Subject subject, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "lattice/profile", - new Dictionary { ["subject"] = subject }, ct); + /// List recorded outcomes for a subject (or the whole scope), newest + /// first. defaults to 100, clamped [1, 1000]. + public async Task> GetOutcomesAsync(Subject? subject = null, + string? decisionType = null, string? actionType = null, int? limit = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (subject is not null) + { + q.Add($"subjectKind={Uri.EscapeDataString(subject.Kind)}"); + q.Add($"subjectExternalId={Uri.EscapeDataString(subject.ExternalId)}"); + } + if (decisionType is not null) q.Add($"decisionType={Uri.EscapeDataString(decisionType)}"); + if (actionType is not null) q.Add($"actionType={Uri.EscapeDataString(actionType)}"); + if (limit is not null) q.Add($"limit={limit}"); + var path = "lattice/outcomes" + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + var res = await c.Send(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + return res.Outcomes; + } - public Task PredictByCohortAsync(Subject subject, IDictionary target, - CancellationToken ct = default) - => c.Send(HttpMethod.Post, "lattice/cohort/predict", - new Dictionary { ["subject"] = subject, ["target"] = target }, ct); + /// "What worked" roll-up — success rate, average reward, and posterior + /// confidence per group. is one of action_type / + /// decision_type / policy / pattern_kind (defaults to action_type); + /// returns only groups with at least that many + /// outcomes. Per-scope only. + public async Task> GetEffectivenessAsync(string? groupBy = null, + int? minSupport = null, RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (groupBy is not null) q.Add($"groupBy={Uri.EscapeDataString(groupBy)}"); + if (minSupport is not null) q.Add($"minSupport={minSupport}"); + var path = "lattice/effectiveness" + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + var res = await c.Send(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + return res.Rows; + } - public Task CalibrationAsync(CancellationToken ct = default) - => c.Send(HttpMethod.Get, "lattice/calibration", null, ct); + private sealed record OutcomesResponse( + [property: JsonPropertyName("outcomes")] List Outcomes); + private sealed record EffectivenessResponse( + [property: JsonPropertyName("rows")] List Rows); } -public sealed class EventsService(MemMeshClient c) +/// Behaviors — emergent behavior discovery. Where +/// answers "what will this subject do?" +/// and answers "who is this +/// subject?", answers a project-wide question: "what +/// behaviors exist in my data that nobody defined?" It clusters subjects by their +/// feature vectors and surfaces the dense, cohesive groups as behaviors. +/// +/// Mirrors the TS reference surface (tf.behaviors.*). +public sealed class BehaviorsService(MemMeshClient c) { - public Task EmitAsync(IDictionary ev, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "events", ev, ct); + /// Discover emergent behaviors across the project. Returns clusters of + /// like-behaving subjects, sorted most-common-and-cohesive first. An empty + /// result means the engine abstained — not enough signal to assert any + /// behavior — never "there are no behaviors". All params are optional; the + /// engine clamps to safe ranges. + public Task DiscoverAsync(double? simThreshold = null, int? minClusterSize = null, + double? minStability = null, int? maxMembers = null, RequestOptions? options = null, + CancellationToken ct = default) + { + var body = new Dictionary(); + if (simThreshold is not null) body["simThreshold"] = simThreshold; + if (minClusterSize is not null) body["minClusterSize"] = minClusterSize; + if (minStability is not null) body["minStability"] = minStability; + if (maxMembers is not null) body["maxMembers"] = maxMembers; + return c.Send(HttpMethod.Post, "lattice/discover", body, options, ct); + } } -public sealed class AlertsService(MemMeshClient c) +/// Compliance — GDPR-grade export, erasure, audit, and pack enablement. +/// Two subject-scoped operations ( for Art. 15, +/// for Art. 17) plus the audit log and the +/// compliance-pack surface (installed packs + per-project enablement). +/// +/// Mirrors the TS reference surface (tf.compliance.*); operations live +/// under /memory-compliance/* and /memory-compliance-packs. +public sealed class ComplianceService(MemMeshClient c) { - public Task CreateAsync(IDictionary rule, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "alerts", rule, ct); - public Task> ListAsync(CancellationToken ct = default) - => c.Send>(HttpMethod.Get, "alerts", null, ct); - public Task DeleteAsync(string id, CancellationToken ct = default) - => c.SendVoid(HttpMethod.Delete, $"alerts/{id}", null, ct); -} + /// Art. 15 subject-access: return every memory, pattern, observation, + /// event, and alert fire for the subject in a single bundle. + public Task ExportSubjectAsync(Subject subject, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-compliance/export", + new Dictionary { ["subject"] = subject }, options, ct); -public sealed class LearningService(MemMeshClient c) -{ - public Task RecordDecisionAsync(IDictionary d, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "learning/decisions", d, ct); - public Task RecordOutcomeAsync(IDictionary o, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "learning/outcomes", o, ct); - public Task GetEffectivenessAsync(IDictionary q, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "learning/effectiveness", q, ct); -} + /// Art. 17 right-to-erasure: cascade-delete the same set and write a + /// tombstone audit row. is required (a case id); + /// previews without touching any rows. + public Task HardDeleteSubjectAsync(Subject subject, string reason, + bool dryRun = false, RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-compliance/hard-delete", + new Dictionary { ["subject"] = subject, ["reason"] = reason, ["dryRun"] = dryRun }, + options, ct); -public sealed class TypedService(MemMeshClient c) -{ - public Task RegisterAttributeAsync(IDictionary def, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "typed/attributes", def, ct); - public Task IngestAsync(IEnumerable observations, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "typed/observations", - new Dictionary { ["observations"] = observations }, ct); -} + /// Read the memory_audit_event log ("who accessed my data and when"). + /// Pass a to narrow to one person; omit for the + /// project-wide log. defaults to 100, max 1000. + public async Task> ListAuditEventsAsync(Subject? subject = null, + string? actor = null, IEnumerable? eventTypes = null, string? since = null, + int? limit = null, RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (subject is not null) + { + q.Add($"subjectKind={Uri.EscapeDataString(subject.Kind)}"); + q.Add($"subjectExternalId={Uri.EscapeDataString(subject.ExternalId)}"); + } + if (actor is not null) q.Add($"actor={Uri.EscapeDataString(actor)}"); + if (since is not null) q.Add($"since={Uri.EscapeDataString(since)}"); + if (limit is not null) q.Add($"limit={limit}"); + var types = eventTypes?.ToList(); + if (types is { Count: > 0 }) q.Add($"eventTypes={Uri.EscapeDataString(string.Join(",", types))}"); + var path = "memory-compliance/audit" + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + return await c.Send>(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + } -public sealed class ComplianceService(MemMeshClient c) -{ - public Task ExportSubjectAsync(Subject subject, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "admin/memory/compliance/export", - new Dictionary { ["subject"] = subject }, ct); - public Task HardDeleteSubjectAsync(Subject subject, string reason, bool dryRun = false, + /// List installed compliance packs and the memory classes / regulations + /// each enforces on every read. + public Task> ListPacksAsync(RequestOptions? options = null, + CancellationToken ct = default) + => c.Send>(HttpMethod.Get, "memory-compliance/packs", null, options, ct); + + /// List which compliance packs are enabled (or explicitly disabled) for + /// the current project. Packs absent from the list fall back to the platform + /// default set. + public Task> ListProjectPacksAsync(RequestOptions? options = null, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "admin/memory/compliance/erase", - new Dictionary { ["subject"] = subject, ["reason"] = reason, ["dryRun"] = dryRun }, ct); - public Task> ListPacksAsync(CancellationToken ct = default) - => c.Send>(HttpMethod.Get, "admin/memory/compliance/packs", null, ct); + => c.Send>(HttpMethod.Get, "memory-compliance-packs", null, options, ct); + + /// Enable, disable, or reconfigure a compliance pack for the current + /// project. Idempotent on PackId — per-pack config survives + /// enable/disable cycles. + public Task UpsertProjectPackAsync(UpsertProjectPackRequest body, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-compliance-packs", body, options, ct); + + /// Remove the per-project enablement row for a pack; the project then + /// falls back to the platform default set for it. To explicitly disable instead, + /// use with enabled: false. + public Task RemoveProjectPackAsync(string packId, RequestOptions? options = null, + CancellationToken ct = default) + => c.SendVoid(HttpMethod.Delete, $"memory-compliance-packs/{Uri.EscapeDataString(packId)}", + null, options, ct); } +/// Health — biological ("health") age + condition prediction for the +/// engine's health vertical. Health data IS memory data: record biomarkers, +/// demographics, and ICD-10 diagnoses as fact memories (stored via +/// /admin/memory) and the engine derives a biological age + condition +/// predictions () and cohort base rates +/// () at /lattice/health/*. +/// +/// Requires the @thinkfleet/pack-healthcare pack; the read methods return +/// FAILED_PRECONDITION otherwise. Mirrors the TS reference surface +/// (tf.health.*). Screening indicators — not a diagnosis. public sealed class HealthService(MemMeshClient c) { - public Task GetProfileAsync(Subject subject, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "health/profile", - new Dictionary { ["subject"] = subject }, ct); - public Task GetCohortRiskAsync(Subject subject, CancellationToken ct = default) - => c.Send(HttpMethod.Post, "health/cohort-risk", - new Dictionary { ["subject"] = subject }, ct); + /// Record a biomarker reading. Send whatever unit the lab reported via + /// ; the engine normalizes it. + public Task RecordBiomarkerAsync(Subject subject, string biomarker, double value, + string? unit = null, string? observedAt = null, RequestOptions? options = null, + CancellationToken ct = default) + { + var health = new Dictionary + { + ["biomarker"] = biomarker, + ["value"] = value, + }; + if (unit is not null) health["unit"] = unit; + if (observedAt is not null) health["observedAt"] = observedAt; + var content = $"{biomarker} = {value.ToString(CultureInfo.InvariantCulture)}" + + (unit is not null ? $" {unit}" : ""); + return RecordAsync(content, new Dictionary + { + ["subject"] = subject, + ["health"] = health, + }, options, ct); + } + + /// Record/refresh a subject's demographics. Latest values win. + public Task RecordDemographicsAsync(Subject subject, DemographicsInput demographics, + RequestOptions? options = null, CancellationToken ct = default) + => RecordAsync("Demographics update", new Dictionary + { + ["subject"] = subject, + ["demographic"] = demographics, + }, options, ct); + + /// Record an ICD-10 diagnosis. + public Task RecordConditionAsync(Subject subject, ConditionInput condition, + RequestOptions? options = null, CancellationToken ct = default) + => RecordAsync($"Diagnosis {condition.Icd10}", new Dictionary + { + ["subject"] = subject, + ["condition"] = condition, + }, options, ct); + + /// Biological-age estimate + condition predictions + latest biomarkers + /// for a subject, derived from their recorded health data. + public Task GetProfileAsync(Subject subject, RequestOptions? options = null, + CancellationToken ct = default) + => c.Send(HttpMethod.Post, "lattice/health/profile", + new Dictionary { ["subject"] = subject }, options, ct); + + /// Cohort outcomes — condition prevalence among the patients most + /// similar to this subject. is the cohort size (default 25). + public Task GetCohortRiskAsync(Subject subject, int? k = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary { ["subject"] = subject }; + if (k is not null) body["k"] = k; + return c.Send(HttpMethod.Post, "lattice/health/cohort-risk", body, options, ct); + } + + // Health signals are plain fact memories (category "health", source + // "sdk:health"), so they feed the health engine without being mined as + // behavioral patterns. + private Task RecordAsync(string content, Dictionary metadata, + RequestOptions? options, CancellationToken ct) + => c.Send(HttpMethod.Post, "admin/memory", new Dictionary + { + ["content"] = content, + ["type"] = "fact", + ["scope"] = "project", + ["category"] = "health", + ["source"] = "sdk:health", + ["metadata"] = metadata, + }, options, ct); } diff --git a/src/TypedService.cs b/src/TypedService.cs new file mode 100644 index 0000000..771c9c2 --- /dev/null +++ b/src/TypedService.cs @@ -0,0 +1,90 @@ +using System.Globalization; + +namespace MemMesh; + +/// Typed attributes — structured/numeric data the engine reasons over +/// (credit scores, sensor readings, balances) instead of opaque metadata. Register +/// an attribute's schema once, then ingest observations: each is validated against +/// the definition (accepted or quarantined) and accepted numeric values are folded +/// into per-subject accumulators you can read back with running +/// mean/variance/min/max/cumulative. +/// +/// Mirrors the TS reference surface (tf.typed.*); everything lives under +/// /memory-typed/*. +/// +/// await mm.Typed.RegisterAttributeAsync(new RegisterAttributeRequest( +/// "credit_score", "numeric", MinValid: 300, MaxValid: 850)); +/// var report = await mm.Typed.IngestAsync([new TypedObservationInput( +/// "credit_score", "contact", "sarah", DateTime.UtcNow.ToString("O"), ValueNumeric: 650)]); +/// var acc = await mm.Typed.AccumulatorAsync("contact", "sarah", "credit_score"); +/// Console.WriteLine(acc.Mean); // 650 +/// +public sealed class TypedService(MemMeshClient c) +{ + /// Register or update an attribute definition (type + plausibility range). + public Task RegisterAttributeAsync(RegisterAttributeRequest body, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-typed/attributes", body, options, ct); + + /// List registered attribute definitions for the project. + public async Task> ListAttributesAsync(string? attributeKey = null, + int? limit = null, int? offset = null, RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (attributeKey is not null) q.Add($"attributeKey={Uri.EscapeDataString(attributeKey)}"); + if (limit is not null) q.Add($"limit={limit}"); + if (offset is not null) q.Add($"offset={offset}"); + var path = "memory-typed/attributes" + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + return await c.Send>(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + } + + /// Ingest a batch of typed observations synchronously and return the + /// report (accepted / quarantined / duplicate counts + quarantine reasons). + public Task IngestAsync(IEnumerable observations, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-typed/observations", + new Dictionary { ["observations"] = observations }, options, ct); + + /// Queue a batch for asynchronous ingest (the scalable path for high + /// volume). Returns the count accepted onto the queue. + public Task EnqueueAsync(IEnumerable observations, + RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Post, "memory-typed/observations/enqueue", + new Dictionary { ["observations"] = observations }, options, ct); + + /// Query raw observations by subject, attribute, time window, and value + /// range. + public async Task> QueryObservationsAsync(string? subjectKind = null, + string? subjectExternalId = null, string? attributeKey = null, string? since = null, + string? until = null, double? minValue = null, double? maxValue = null, string? status = null, + int? limit = null, int? offset = null, RequestOptions? options = null, CancellationToken ct = default) + { + var q = new List(); + if (subjectKind is not null) q.Add($"subjectKind={Uri.EscapeDataString(subjectKind)}"); + if (subjectExternalId is not null) q.Add($"subjectExternalId={Uri.EscapeDataString(subjectExternalId)}"); + if (attributeKey is not null) q.Add($"attributeKey={Uri.EscapeDataString(attributeKey)}"); + if (since is not null) q.Add($"since={Uri.EscapeDataString(since)}"); + if (until is not null) q.Add($"until={Uri.EscapeDataString(until)}"); + if (minValue is not null) q.Add($"minValue={minValue.Value.ToString(CultureInfo.InvariantCulture)}"); + if (maxValue is not null) q.Add($"maxValue={maxValue.Value.ToString(CultureInfo.InvariantCulture)}"); + if (status is not null) q.Add($"status={Uri.EscapeDataString(status)}"); + if (limit is not null) q.Add($"limit={limit}"); + if (offset is not null) q.Add($"offset={offset}"); + var path = "memory-typed/observations" + (q.Count > 0 ? "?" + string.Join("&", q) : ""); + return await c.Send>(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + } + + /// Read the running statistics for a subject + attribute. + public async Task AccumulatorAsync(string subjectKind, string subjectExternalId, + string attributeKey, RequestOptions? options = null, CancellationToken ct = default) + { + var q = new[] + { + $"subjectKind={Uri.EscapeDataString(subjectKind)}", + $"subjectExternalId={Uri.EscapeDataString(subjectExternalId)}", + $"attributeKey={Uri.EscapeDataString(attributeKey)}", + }; + var path = "memory-typed/accumulator?" + string.Join("&", q); + return await c.Send(HttpMethod.Get, path, null, options, ct).ConfigureAwait(false); + } +} diff --git a/test/AlertsServiceTests.cs b/test/AlertsServiceTests.cs new file mode 100644 index 0000000..3f1dd39 --- /dev/null +++ b/test/AlertsServiceTests.cs @@ -0,0 +1,192 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Alerts surface (8/8), mirrored from +/// the TS reference and driven through the scripted . +/// Confirms every route sits under /memory-alerts. +public class AlertsServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string RuleJson = + """{"id":"a1","projectId":"proj_1","name":"VIP at risk","description":null,"enabled":true,"trigger":{"kind":"engine-event","eventTypes":["risk.fired"]},"filter":{"metadataMatch":{"riskKind":"rfm_at_risk_high_value"}},"notify":[{"kind":"webhook","url":"https://hooks.slack.com/x"}],"throttle":{"dedupOn":"subject","cooldownMinutes":60},"created":"2026-01-01T00:00:00Z","updated":"2026-01-02T00:00:00Z"}"""; + + // ── list ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task List_gets_memory_alerts() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, $"[{RuleJson}]")); + using var mm = Client(handler); + + var rules = await mm.Alerts.ListAsync(); + + Assert.Single(rules); + Assert.Equal("a1", rules[0].Id); + Assert.Equal("engine-event", rules[0].Trigger.Kind); + Assert.Equal("risk.fired", rules[0].Trigger.EventTypes![0]); + Assert.Equal("webhook", rules[0].Notify[0].Kind); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-alerts", req.RequestUri!.AbsolutePath); + } + + // ── get ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Get_fetches_alert_by_id() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, RuleJson)); + using var mm = Client(handler); + + var rule = await mm.Alerts.GetAsync("a1"); + + Assert.Equal("a1", rule.Id); + Assert.Equal("subject", rule.Throttle!.DedupOn); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-alerts/a1", req.RequestUri!.AbsolutePath); + } + + // ── create ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_posts_memory_alerts_with_trigger_and_channels() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, RuleJson); }); + using var mm = Client(handler); + + var rule = await mm.Alerts.CreateAsync(new CreateAlertRuleRequest( + Name: "VIP at risk", + Trigger: new AlertTrigger("engine-event", EventTypes: ["risk.fired"]), + Notify: [new NotificationChannel("webhook", Url: "https://hooks.slack.com/x")], + Throttle: new ThrottleConfig(DedupOn: "subject", CooldownMinutes: 60))); + + Assert.Equal("a1", rule.Id); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/memory-alerts", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.Equal("engine-event", root.GetProperty("trigger").GetProperty("kind").GetString()); + Assert.Equal("risk.fired", root.GetProperty("trigger").GetProperty("eventTypes")[0].GetString()); + Assert.Equal("webhook", root.GetProperty("notify")[0].GetProperty("kind").GetString()); + // Unset optional segment-change/pattern fields are omitted from the trigger. + Assert.False(root.GetProperty("trigger").TryGetProperty("patternKind", out _)); + } + + [Fact] + public async Task Create_memory_channel_serializes_write_as_template() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, RuleJson); }); + using var mm = Client(handler); + + await mm.Alerts.CreateAsync(new CreateAlertRuleRequest( + Name: "Churn risk to memory", + Trigger: new AlertTrigger("engine-event", EventTypes: ["risk.fired"]), + Notify: [new NotificationChannel("memory", + WriteAs: new NotificationChannelWriteAs("Risk fired for {{subject.externalId}}.", Scope: "project"))])); + + using var doc = JsonDocument.Parse(body); + var channel = doc.RootElement.GetProperty("notify")[0]; + Assert.Equal("memory", channel.GetProperty("kind").GetString()); + Assert.Equal("project", channel.GetProperty("writeAs").GetProperty("scope").GetString()); + Assert.False(channel.TryGetProperty("url", out _)); + } + + // ── update ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Update_patches_memory_alert_with_set_fields_only() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, RuleJson); }); + using var mm = Client(handler); + + await mm.Alerts.UpdateAsync("a1", new UpdateAlertRuleRequest(Name: "renamed")); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Patch, req.Method); + Assert.EndsWith("/memory-alerts/a1", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("renamed", doc.RootElement.GetProperty("name").GetString()); + Assert.False(doc.RootElement.TryGetProperty("trigger", out _)); + } + + // ── enable / disable ────────────────────────────────────────────────────── + + [Fact] + public async Task Enable_patches_enabled_true() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, RuleJson); }); + using var mm = Client(handler); + + await mm.Alerts.EnableAsync("a1"); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Patch, req.Method); + Assert.EndsWith("/memory-alerts/a1", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.True(doc.RootElement.GetProperty("enabled").GetBoolean()); + } + + [Fact] + public async Task Disable_patches_enabled_false() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, RuleJson); }); + using var mm = Client(handler); + + await mm.Alerts.DisableAsync("a1"); + + using var doc = JsonDocument.Parse(body); + Assert.False(doc.RootElement.GetProperty("enabled").GetBoolean()); + Assert.False(doc.RootElement.TryGetProperty("name", out _)); + } + + // ── delete ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_sends_delete_to_memory_alert_route() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, """{"success":true}""")); + using var mm = Client(handler); + + await mm.Alerts.DeleteAsync("a1"); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Delete, req.Method); + Assert.EndsWith("/memory-alerts/a1", req.RequestUri!.AbsolutePath); + } + + // ── listFires (nested) ──────────────────────────────────────────────────── + + [Fact] + public async Task ListFires_gets_nested_fires_route() + { + const string fireJson = + """[{"id":"f1","alertRuleId":"a1","eventId":"e1","dedupeKey":"subject:sarah","deliveryResults":[{"channel":"webhook","ok":true}],"firedAt":"2026-01-03T00:00:00Z"}]"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, fireJson)); + using var mm = Client(handler); + + var fires = await mm.Alerts.ListFiresAsync("a1"); + + Assert.Single(fires); + Assert.Equal("f1", fires[0].Id); + Assert.True(fires[0].DeliveryResults[0].Ok); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-alerts/a1/fires", req.RequestUri!.AbsolutePath); + } +} diff --git a/test/BrainsServiceTests.cs b/test/BrainsServiceTests.cs new file mode 100644 index 0000000..70b3f64 --- /dev/null +++ b/test/BrainsServiceTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Brains marketplace-registry surface, +/// mirrored from the TS reference, driven through the scripted +/// . +public class BrainsServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string BrainJson = + """{"id":"b1","projectId":"proj_1","externalId":"sec-edgar","name":"SEC EDGAR","domain":"finance","brainInterface":"v1","version":"2026.07.0","visibility":"PRIVATE","status":"DRAFT","rightsAttested":false,"card":{"provenance":[{"source":"SEC EDGAR","license":"public-domain"}],"coverage":{"subjects":10,"facts":200}},"created":"2026-01-01T00:00:00Z","updated":"2026-01-02T00:00:00Z"}"""; + + // ── create ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_posts_brains_with_full_body() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, BrainJson); }); + using var mm = Client(handler); + + var brain = await mm.Brains.CreateAsync(new CreateBrainRequest( + ExternalId: "sec-edgar", Name: "SEC EDGAR", Domain: "finance", Version: "2026.07.0", + Card: new BrainCard(Provenance: [new BrainProvenance("SEC EDGAR", "public-domain")]))); + + Assert.Equal("b1", brain.Id); + Assert.Equal("finance", brain.Domain); + Assert.Equal("v1", brain.BrainInterface); + Assert.NotNull(brain.Card); + Assert.Equal("SEC EDGAR", brain.Card!.Provenance![0].Source); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/brains", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("sec-edgar", doc.RootElement.GetProperty("externalId").GetString()); + Assert.Equal("public-domain", + doc.RootElement.GetProperty("card").GetProperty("provenance")[0].GetProperty("license").GetString()); + } + + // ── createFromProject ──────────────────────────────────────────────────── + + [Fact] + public async Task CreateFromProject_defaults_to_draft_private_with_empty_card() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, BrainJson); }); + using var mm = Client(handler); + + await mm.Brains.CreateFromProjectAsync(externalId: "support-playbook", name: "Support Playbook", + domain: "support"); + + Assert.EndsWith("/brains", handler.Requests[0].RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.Equal("support-playbook", root.GetProperty("externalId").GetString()); + Assert.Equal("1.0.0", root.GetProperty("version").GetString()); + Assert.Equal("PRIVATE", root.GetProperty("visibility").GetString()); + // Empty-but-valid card: empty provenance list + empty coverage object. + var card = root.GetProperty("card"); + Assert.Equal(0, card.GetProperty("provenance").GetArrayLength()); + Assert.Equal(JsonValueKind.Object, card.GetProperty("coverage").ValueKind); + // status is never sent on create — the server assigns DRAFT. + Assert.False(root.TryGetProperty("status", out _)); + } + + // ── list (cursor pagination) ───────────────────────────────────────────── + + [Fact] + public async Task List_paginates_by_following_the_next_cursor() + { + var page1 = $$"""{"data":[{{BrainJson}}],"next":"cur2","previous":null}"""; + var page2 = """{"data":[],"next":null,"previous":"cur2"}"""; + var handler = new StubHandler( + _ => StubHandler.Json(HttpStatusCode.OK, page1), + _ => StubHandler.Json(HttpStatusCode.OK, page2)); + using var mm = Client(handler); + + var first = await mm.Brains.ListAsync(limit: 20); + Assert.Single(first.Data); + Assert.Equal("b1", first.Data[0].Id); + Assert.Equal("cur2", first.Next); + Assert.Null(first.Previous); + Assert.Contains("limit=20", handler.Requests[0].RequestUri!.Query); + + var second = await mm.Brains.ListAsync(limit: 20, cursor: first.Next); + Assert.Empty(second.Data); + Assert.Null(second.Next); + // The cursor from page 1 is threaded into page 2's request. + Assert.Contains("cursor=cur2", handler.Requests[1].RequestUri!.Query); + Assert.Contains("limit=20", handler.Requests[1].RequestUri!.Query); + } + + // ── get ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Get_fetches_brain_by_id() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, BrainJson)); + using var mm = Client(handler); + + var brain = await mm.Brains.GetAsync("b1"); + + Assert.Equal("b1", brain.Id); + Assert.Equal(10, brain.Card!.Coverage!.Subjects); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/brains/b1", req.RequestUri!.AbsolutePath); + } + + // ── update ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Update_patches_only_the_set_fields() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, BrainJson); }); + using var mm = Client(handler); + + await mm.Brains.UpdateAsync("b1", new UpdateBrainRequest(Visibility: "PUBLIC", Status: "PUBLISHED")); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Patch, req.Method); + Assert.EndsWith("/brains/b1", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("PUBLIC", doc.RootElement.GetProperty("visibility").GetString()); + Assert.Equal("PUBLISHED", doc.RootElement.GetProperty("status").GetString()); + // Unset optional fields are omitted from the PATCH body. + Assert.False(doc.RootElement.TryGetProperty("name", out _)); + Assert.False(doc.RootElement.TryGetProperty("card", out _)); + } + + // ── delete ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_sends_delete_to_brain_route() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, """{"success":true}""")); + using var mm = Client(handler); + + await mm.Brains.DeleteAsync("b1"); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Delete, req.Method); + Assert.EndsWith("/brains/b1", req.RequestUri!.AbsolutePath); + } +} diff --git a/test/ComplianceServiceTests.cs b/test/ComplianceServiceTests.cs new file mode 100644 index 0000000..ac72a8e --- /dev/null +++ b/test/ComplianceServiceTests.cs @@ -0,0 +1,195 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Compliance surface (7/7), mirrored +/// from thinkfleet-memory-sdk/src/resources/compliance.ts. Confirms the canonical +/// routes under /memory-compliance/* and /memory-compliance-packs +/// (export, hard-delete [NOT erase], audit, packs, project packs). +public class ComplianceServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + // ── exportSubject ─────────────────────────────────────────────────────────── + + [Fact] + public async Task ExportSubject_posts_memory_compliance_export() + { + const string json = + """{"subject":{"kind":"contact","externalId":"sarah"},"export":{"subject":{"kind":"contact","externalId":"sarah"},"memories":[{"id":"m1"}],"patterns":[],"observations":[],"events":[],"alert_fires":[],"generated_at":"2026-05-25T00:00:00Z"},"counts":{"memories":1,"patterns":0,"observations":0,"events":0,"alertFires":0},"generatedAt":"2026-05-25T00:00:00Z","durationMs":12.5}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + var res = await mm.Compliance.ExportSubjectAsync(new Subject("contact", "sarah")); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/memory-compliance/export", req.RequestUri!.AbsolutePath); + Assert.Equal("sarah", JsonDocument.Parse(body).RootElement.GetProperty("subject").GetProperty("externalId").GetString()); + Assert.Equal(1, res.Counts.Memories); + Assert.Equal(12.5, res.DurationMs); + Assert.NotNull(res.Export); + Assert.Single(res.Export!.Memories); + } + + // ── hardDeleteSubject ─────────────────────────────────────────────────────── + + [Fact] + public async Task HardDeleteSubject_posts_memory_compliance_hard_delete() + { + const string json = + """{"subject":{"kind":"contact","externalId":"sarah"},"memoriesDeleted":3,"patternsDeleted":0,"observationsDeleted":1,"eventsDeleted":2,"alertFiresDeleted":0,"dryRun":true,"auditEventId":"a1","generatedAt":"2026-05-25T00:00:00Z","durationMs":7}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + var res = await mm.Compliance.HardDeleteSubjectAsync( + new Subject("contact", "sarah"), reason: "GDPR Art. 17 case A", dryRun: true); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + // Route is hard-delete, NOT erase. + Assert.EndsWith("/memory-compliance/hard-delete", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("GDPR Art. 17 case A", doc.RootElement.GetProperty("reason").GetString()); + Assert.True(doc.RootElement.GetProperty("dryRun").GetBoolean()); + Assert.Equal(3, res.MemoriesDeleted); + Assert.True(res.DryRun); + Assert.Equal("a1", res.AuditEventId); + } + + // ── listAuditEvents ───────────────────────────────────────────────────────── + + [Fact] + public async Task ListAuditEvents_gets_memory_compliance_audit_with_filters() + { + const string json = + """[{"id":"e1","created":"2026-05-01T00:00:00Z","actor":"svc","eventType":"read.export","query":null,"memoryIds":null,"resultCount":3,"metadata":{"k":"v"}}]"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, json)); + using var mm = Client(handler); + + var events = await mm.Compliance.ListAuditEventsAsync( + subject: new Subject("contact", "sarah"), actor: "svc", + eventTypes: ["read.context", "read.export"], since: "2026-05-01T00:00:00Z", limit: 50); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-compliance/audit", req.RequestUri!.AbsolutePath); + var query = req.RequestUri!.Query; + Assert.Contains("subjectKind=contact", query); + Assert.Contains("subjectExternalId=sarah", query); + Assert.Contains("actor=svc", query); + Assert.Contains("since=", query); + Assert.Contains("limit=50", query); + // eventTypes are joined with a comma (URL-encoded). + Assert.Contains("eventTypes=read.context%2Cread.export", query); + Assert.Single(events); + Assert.Equal("read.export", events[0].EventType); + Assert.Equal(3, events[0].ResultCount); + } + + // ── listPacks ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task ListPacks_gets_memory_compliance_packs() + { + const string json = + """[{"id":"hipaa","version":"1.0","description":"HIPAA","ownsClasses":["phi"],"regulatoryTags":["HIPAA"]}]"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, json)); + using var mm = Client(handler); + + var packs = await mm.Compliance.ListPacksAsync(); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-compliance/packs", req.RequestUri!.AbsolutePath); + Assert.Single(packs); + Assert.Equal("hipaa", packs[0].Id); + Assert.Equal("phi", packs[0].OwnsClasses[0]); + } + + // ── listProjectPacks ──────────────────────────────────────────────────────── + + [Fact] + public async Task ListProjectPacks_gets_memory_compliance_packs_enablement() + { + const string json = + """[{"id":"pp1","packId":"@thinkfleet/pack-healthcare","enabled":true,"config":{"deidentificationMode":"safe-harbor"},"enabledByUserId":"u1","created":"2026-01-01T00:00:00Z","updated":"2026-01-02T00:00:00Z"}]"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, json)); + using var mm = Client(handler); + + var enabled = await mm.Compliance.ListProjectPacksAsync(); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-compliance-packs", req.RequestUri!.AbsolutePath); + Assert.Single(enabled); + Assert.True(enabled[0].Enabled); + Assert.Equal("@thinkfleet/pack-healthcare", enabled[0].PackId); + Assert.Equal("safe-harbor", enabled[0].Config["deidentificationMode"].GetString()); + } + + // ── upsertProjectPack ─────────────────────────────────────────────────────── + + [Fact] + public async Task UpsertProjectPack_posts_memory_compliance_packs() + { + const string json = + """{"id":"pp1","packId":"@thinkfleet/pack-healthcare","enabled":true,"config":{"deidentificationMode":"safe-harbor"},"enabledByUserId":"u1","created":"2026-01-01T00:00:00Z","updated":"2026-01-02T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + var row = await mm.Compliance.UpsertProjectPackAsync(new UpsertProjectPackRequest( + "@thinkfleet/pack-healthcare", Enabled: true, + Config: new Dictionary { ["deidentificationMode"] = "safe-harbor" })); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/memory-compliance-packs", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("@thinkfleet/pack-healthcare", doc.RootElement.GetProperty("packId").GetString()); + Assert.True(doc.RootElement.GetProperty("enabled").GetBoolean()); + Assert.Equal("safe-harbor", doc.RootElement.GetProperty("config").GetProperty("deidentificationMode").GetString()); + Assert.True(row.Enabled); + } + + [Fact] + public async Task UpsertProjectPack_omits_config_when_absent() + { + const string json = + """{"id":"pp1","packId":"gdpr","enabled":false,"config":{},"enabledByUserId":null,"created":"2026-01-01T00:00:00Z","updated":"2026-01-02T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + await mm.Compliance.UpsertProjectPackAsync(new UpsertProjectPackRequest("gdpr", Enabled: false)); + + using var doc = JsonDocument.Parse(body); + Assert.False(doc.RootElement.TryGetProperty("config", out _)); + } + + // ── removeProjectPack ─────────────────────────────────────────────────────── + + [Fact] + public async Task RemoveProjectPack_deletes_encoded_pack_id() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.NoContent, "")); + using var mm = Client(handler); + + await mm.Compliance.RemoveProjectPackAsync("@thinkfleet/pack-healthcare"); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Delete, req.Method); + // The packId is URL-encoded into the path segment. + Assert.EndsWith("/memory-compliance-packs/%40thinkfleet%2Fpack-healthcare", req.RequestUri!.AbsolutePath); + } +} diff --git a/test/ConsentServiceTests.cs b/test/ConsentServiceTests.cs new file mode 100644 index 0000000..310f60a --- /dev/null +++ b/test/ConsentServiceTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Coverage for the Consent surface, which has no dedicated endpoints — +/// it is implemented client-side over the admin memory CRUD (list / create / +/// delete). These tests drive the scripted to assert +/// the multi-request choreography matches the TS reference. +public class ConsentServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private static string ConsentItem(string id, string kind, string externalId, bool optedOut, + string? reason, string created) => + $$""" + {"id":"{{id}}","type":"consent","content":"[consent] {{kind}}:{{externalId}}","importance":10,"scope":"project","status":"confirmed","confidence":1,"supersededById":null,"metadata":{"subject":{"kind":"{{kind}}","externalId":"{{externalId}}"},"optedOut":{{(optedOut ? "true" : "false")}},"optedOutAt":{{(optedOut ? $"\"{created}\"" : "null")}},"reason":{{(reason is null ? "null" : $"\"{reason}\"")}},"recordKind":"consent"},"created":"{{created}}"} + """; + + // ── optOut, no prior record ────────────────────────────────────────────── + + [Fact] + public async Task OptOut_with_no_prior_lists_then_creates_a_consent_memory() + { + string createBody = ""; + var handler = new StubHandler( + // 1) findActiveConsent → GET admin/memory?limit=500 (nothing yet) + _ => StubHandler.Json(HttpStatusCode.OK, "[]"), + // 2) createMemory → POST admin/memory + r => { createBody = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, + ConsentItem("new1", "contact", "sarah", optedOut: true, reason: "gdpr", "2026-05-25T00:00:00.000Z")); }); + using var mm = Client(handler); + + var status = await mm.Consent.OptOutAsync(new Subject("contact", "sarah"), reason: "gdpr"); + + // No prior row → no DELETE; exactly a GET (list) then a POST (create). + Assert.Equal(2, handler.Calls); + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Contains("limit=500", handler.Requests[0].RequestUri!.Query); + Assert.EndsWith("/admin/memory", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal(HttpMethod.Post, handler.Requests[1].Method); + Assert.EndsWith("/admin/memory", handler.Requests[1].RequestUri!.AbsolutePath); + + // The created consent memory carries the right type / metadata shape. + using var doc = JsonDocument.Parse(createBody); + var root = doc.RootElement; + Assert.Equal("consent", root.GetProperty("type").GetString()); + Assert.Equal("consent", root.GetProperty("category").GetString()); + Assert.Equal(10, root.GetProperty("importance").GetInt32()); + Assert.Equal("[consent] contact:sarah opted out", root.GetProperty("content").GetString()); + var md = root.GetProperty("metadata"); + Assert.True(md.GetProperty("optedOut").GetBoolean()); + Assert.Equal("gdpr", md.GetProperty("reason").GetString()); + Assert.Equal("sarah", md.GetProperty("subject").GetProperty("externalId").GetString()); + + // Returned status echoes the write and links the new memory id. + Assert.True(status.OptedOut); + Assert.Equal("gdpr", status.Reason); + Assert.Equal("new1", status.MemoryId); + Assert.NotNull(status.OptedOutAt); + } + + // ── optIn supersedes a prior record ────────────────────────────────────── + + [Fact] + public async Task OptIn_supersedes_prior_record_by_deleting_it_first() + { + var prior = ConsentItem("old1", "contact", "sarah", optedOut: true, reason: "gdpr", "2026-01-01T00:00:00Z"); + string createBody = ""; + var handler = new StubHandler( + // 1) findActiveConsent → prior opt-out exists + _ => StubHandler.Json(HttpStatusCode.OK, $"[{prior}]"), + // 2) supersede → DELETE admin/memory/old1 + _ => StubHandler.Json(HttpStatusCode.OK, """{"success":true}"""), + // 3) createMemory → POST admin/memory + r => { createBody = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, + ConsentItem("new1", "contact", "sarah", optedOut: false, reason: null, "2026-05-25T00:00:00.000Z")); }); + using var mm = Client(handler); + + var status = await mm.Consent.OptInAsync(new Subject("contact", "sarah")); + + Assert.Equal(3, handler.Calls); + // The prior row is hard-deleted before the new one is written. + Assert.Equal(HttpMethod.Delete, handler.Requests[1].Method); + Assert.EndsWith("/admin/memory/old1", handler.Requests[1].RequestUri!.AbsolutePath); + Assert.Equal(HttpMethod.Post, handler.Requests[2].Method); + + // opt-in clears optedOut / optedOutAt / reason on the new record. + using var doc = JsonDocument.Parse(createBody); + var md = doc.RootElement.GetProperty("metadata"); + Assert.False(md.GetProperty("optedOut").GetBoolean()); + Assert.Equal(JsonValueKind.Null, md.GetProperty("optedOutAt").ValueKind); + Assert.Equal("[consent] contact:sarah opted in", doc.RootElement.GetProperty("content").GetString()); + + Assert.False(status.OptedOut); + Assert.Null(status.OptedOutAt); + Assert.Null(status.Reason); + Assert.Equal("new1", status.MemoryId); + } + + // ── getStatus: newest matching record wins; default when none ──────────── + + [Fact] + public async Task GetStatus_picks_newest_matching_record_and_defaults_when_none() + { + var older = ConsentItem("m-old", "contact", "sarah", optedOut: false, reason: null, "2026-01-01T00:00:00Z"); + var newer = ConsentItem("m-new", "contact", "sarah", optedOut: true, reason: "gdpr", "2026-06-01T00:00:00Z"); + var otherSubject = ConsentItem("m-x", "contact", "mike", optedOut: true, reason: "n/a", "2026-07-01T00:00:00Z"); + // Intentionally out of order; the service must sort by `created` desc. + var listBody = $"[{older},{otherSubject},{newer}]"; + + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, listBody)); + using var mm = Client(handler); + + var status = await mm.Consent.GetStatusAsync(new Subject("contact", "sarah")); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.True(status.OptedOut); + Assert.Equal("gdpr", status.Reason); + Assert.Equal("m-new", status.MemoryId); // newest of the two sarah rows + Assert.NotNull(status.OptedOutAt); + + // No matching record → default (opted-in) status, memoryId null. + var handler2 = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, "[]")); + using var mm2 = Client(handler2); + var missing = await mm2.Consent.GetStatusAsync(new Subject("contact", "nobody")); + Assert.False(missing.OptedOut); + Assert.Null(missing.MemoryId); + Assert.Null(missing.OptedOutAt); + Assert.Null(missing.Reason); + } +} diff --git a/test/EventsServiceTests.cs b/test/EventsServiceTests.cs new file mode 100644 index 0000000..346d5b2 --- /dev/null +++ b/test/EventsServiceTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Events surface (3/3), mirrored from +/// the TS reference. Confirms emit posts to /lattice/events/emit and poll +/// reads from /memory-events, plus a subscribe start/stop lifecycle. +public class EventsServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string EventJson = + """{"id":"ev1","eventType":"risk.fired","subject":{"kind":"contact","externalId":"sarah"},"severity":"warn","payload":{"riskKind":"churn"},"sourceMemoryIds":["m1"],"sourcePatternId":"p1","emittedByPack":"risk-pack","occurredAt":"2026-01-01T00:00:00Z"}"""; + + // ── emit ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task Emit_posts_to_lattice_events_emit() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, + """{"emitted":true,"event":{"id":"ev1","eventType":"cart.abandoned","severity":"warn","occurredAt":"2026-01-01T00:00:00Z"},"alertDispatches":2}"""); }); + using var mm = Client(handler); + + var res = await mm.Events.EmitAsync(new EmitEventRequest( + EventType: "cart.abandoned", + Subject: new Subject("contact", "sarah"), + Severity: "warn", + PayloadJson: """{"cartValue":84}""")); + + Assert.True(res.Emitted); + Assert.Equal("ev1", res.Event!.Id); + Assert.Equal(2, res.AlertDispatches); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/events/emit", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("cart.abandoned", doc.RootElement.GetProperty("eventType").GetString()); + Assert.Equal("sarah", doc.RootElement.GetProperty("subject").GetProperty("externalId").GetString()); + // Unset optional fields are omitted. + Assert.False(doc.RootElement.TryGetProperty("sourcePatternId", out _)); + } + + // ── poll ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task Poll_gets_memory_events_with_query_params() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, $"[{EventJson}]")); + using var mm = Client(handler); + + var events = await mm.Events.PollAsync( + since: "2026-01-01T00:00:00Z", limit: 50, eventTypes: ["risk.fired", "segment.changed"]); + + Assert.Single(events); + Assert.Equal("ev1", events[0].Id); + Assert.Equal("sarah", events[0].Subject!.ExternalId); + Assert.Equal("churn", events[0].Payload["riskKind"].GetString()); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-events", req.RequestUri!.AbsolutePath); + var query = req.RequestUri!.Query; + Assert.Contains("since=", query); + Assert.Contains("limit=50", query); + // eventTypes are comma-joined (comma URL-encoded as %2C). + Assert.Contains("eventTypes=risk.fired%2Csegment.changed", query); + } + + [Fact] + public async Task Poll_without_params_hits_bare_route() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, "[]")); + using var mm = Client(handler); + + var events = await mm.Events.PollAsync(); + + Assert.Empty(events); + Assert.EndsWith("/memory-events", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Equal("", handler.Requests[0].RequestUri!.Query); + } + + // ── subscribe (start / stop lifecycle) ───────────────────────────────────── + + [Fact] + public async Task Subscribe_polls_delivers_events_then_stops_on_dispose() + { + // First poll returns one event, subsequent polls return empty. + var handler = new StubHandler( + _ => StubHandler.Json(HttpStatusCode.OK, $"[{EventJson}]"), + _ => StubHandler.Json(HttpStatusCode.OK, "[]")); + using var mm = Client(handler); + + var received = new List(); + var gotOne = new TaskCompletionSource(); + var sub = mm.Events.Subscribe(async e => + { + received.Add(e); + gotOne.TrySetResult(); + await Task.CompletedTask; + }, interval: TimeSpan.FromMilliseconds(500)); + + // Wait until the handler saw the first event, then stop. + await gotOne.Task.WaitAsync(TimeSpan.FromSeconds(5)); + sub.Dispose(); + await sub.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Single(received); + Assert.Equal("ev1", received[0].Id); + // The loop polled the canonical route and stopped cleanly on dispose. + Assert.All(handler.Requests, r => Assert.EndsWith("/memory-events", r.RequestUri!.AbsolutePath)); + Assert.True(sub.Completion.IsCompleted); + } + + [Fact] + public void Subscribe_dispose_is_idempotent() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, "[]")); + using var mm = Client(handler); + + var sub = mm.Events.Subscribe(_ => Task.CompletedTask, interval: TimeSpan.FromMilliseconds(500)); + sub.Dispose(); + sub.Dispose(); // must not throw + } +} diff --git a/test/FinancialServiceTests.cs b/test/FinancialServiceTests.cs new file mode 100644 index 0000000..bf381e0 --- /dev/null +++ b/test/FinancialServiceTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Financial surface (9/9), mirrored +/// from thinkfleet-memory-sdk/src/resources/financial.ts. Ingestion records fact +/// memories via /admin/memory; reads sit under /lattice/financial/*. +public class FinancialServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string MemJson = + """{"id":"m1","type":"fact","content":"x","importance":5,"scope":"project","status":"active","confidence":1,"supersededById":null}"""; + + // ── ingestPrice ───────────────────────────────────────────────────────────── + + [Fact] + public async Task IngestPrice_posts_fact_memory_with_price_metadata() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Financial.IngestPriceAsync(new PriceInput("AAPL", 150, AsOf: "2026-01-01")); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/admin/memory", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.Equal("AAPL close 150 @ 2026-01-01", root.GetProperty("content").GetString()); + Assert.Equal("financial", root.GetProperty("category").GetString()); + Assert.Equal("sdk:financial", root.GetProperty("source").GetString()); + Assert.Equal("AAPL", root.GetProperty("metadata").GetProperty("price").GetProperty("ticker").GetString()); + } + + // ── ingestPrices (concurrent batch) ───────────────────────────────────────── + + [Fact] + public async Task IngestPrices_posts_each_bar_and_returns_all() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, MemJson)); + using var mm = Client(handler); + + var items = await mm.Financial.IngestPricesAsync([ + new PriceInput("AAPL", 150), new PriceInput("AAPL", 151), new PriceInput("AAPL", 152)]); + + Assert.Equal(3, items.Count); + Assert.Equal(3, handler.Calls); + Assert.All(handler.Requests, r => Assert.EndsWith("/admin/memory", r.RequestUri!.AbsolutePath)); + } + + // ── ingestFundamentals ────────────────────────────────────────────────────── + + [Fact] + public async Task IngestFundamentals_posts_fundamental_metadata() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Financial.IngestFundamentalsAsync(new FundamentalInput("AAPL", PeRatio: 28.5, MarketCap: 3_000_000)); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("Fundamentals AAPL", doc.RootElement.GetProperty("content").GetString()); + var f = doc.RootElement.GetProperty("metadata").GetProperty("fundamental"); + Assert.Equal(28.5, f.GetProperty("peRatio").GetDouble()); + Assert.False(f.TryGetProperty("beta", out _)); // unset optional omitted + } + + // ── ingestHolding ─────────────────────────────────────────────────────────── + + [Fact] + public async Task IngestHolding_posts_subject_and_holding_metadata() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Financial.IngestHoldingAsync(new Subject("portfolio", "acct-123"), + new HoldingInput("AAPL", 100, CostBasis: 150)); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.Equal("Holding 100 AAPL", root.GetProperty("content").GetString()); + Assert.Equal("acct-123", root.GetProperty("metadata").GetProperty("subject").GetProperty("externalId").GetString()); + Assert.Equal(100, root.GetProperty("metadata").GetProperty("holding").GetProperty("shares").GetDouble()); + } + + // ── ingestNews ────────────────────────────────────────────────────────────── + + [Fact] + public async Task IngestNews_labels_content_with_single_ticker() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Financial.IngestNewsAsync(new NewsInput("Apple beats earnings", Ticker: "AAPL", Sentiment: 0.7)); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("News [AAPL]: Apple beats earnings", doc.RootElement.GetProperty("content").GetString()); + Assert.Equal("AAPL", doc.RootElement.GetProperty("metadata").GetProperty("newsEvent").GetProperty("ticker").GetString()); + } + + [Fact] + public async Task IngestNews_joins_multiple_tickers_for_label() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Financial.IngestNewsAsync(new NewsInput("Sector rally", Tickers: ["AAPL", "MSFT"])); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("News [AAPL,MSFT]: Sector rally", doc.RootElement.GetProperty("content").GetString()); + } + + // ── getProfile ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetProfile_posts_lattice_financial_profile_and_parses() + { + const string json = + """{"subject":{"kind":"ticker","externalId":"AAPL"},"indicators":[{"ticker":"AAPL","lastClose":152,"asOf":"2026-01-03","betaSource":"computed","sampleSize":200,"sourceMemoryIds":["m1"],"rsi14":61.2,"beta":1.1}],"fundamentals":[],"positions":[],"unpricedHoldings":[],"disclaimer":"not advice","generatedAt":"2026-01-03T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + var profile = await mm.Financial.GetProfileAsync(new Subject("ticker", "AAPL")); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/financial/profile", req.RequestUri!.AbsolutePath); + Assert.Equal("AAPL", JsonDocument.Parse(body).RootElement.GetProperty("subject").GetProperty("externalId").GetString()); + Assert.Single(profile.Indicators); + Assert.Equal(152, profile.Indicators[0].LastClose); + Assert.Equal(61.2, profile.Indicators[0].Rsi14); + Assert.Equal("computed", profile.Indicators[0].BetaSource); + Assert.Null(profile.PortfolioRisk); + } + + // ── predict ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task Predict_posts_lattice_financial_predict_with_options() + { + const string json = + """{"signals":[{"ticker":"AAPL","strategy":"trend","direction":"buy","score":0.4,"structuralConfidence":0.6,"reportedConfidence":0.45,"expectedReturn":0.03,"horizonDays":30,"basisClose":152,"dueAt":"2026-02-02T00:00:00Z","rationale":["sma cross"],"newsUsed":true,"sourceMemoryIds":["m1"],"predictionId":"pred1"}],"strategy":"trend","strategyReliability":0.75,"resolvedSample":40,"disclaimer":"not advice","generatedAt":"2026-01-03T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + var res = await mm.Financial.PredictAsync(new Subject("ticker", "AAPL"), horizonDays: 30, persist: true); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/financial/predict", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal(30, doc.RootElement.GetProperty("horizonDays").GetInt32()); + Assert.True(doc.RootElement.GetProperty("persist").GetBoolean()); + Assert.Single(res.Signals); + Assert.Equal("buy", res.Signals[0].Direction); + Assert.Equal("pred1", res.Signals[0].PredictionId); + Assert.Equal(0.75, res.StrategyReliability); + } + + [Fact] + public async Task Predict_omits_unset_options() + { + const string json = + """{"signals":[],"strategy":"trend","strategyReliability":1,"resolvedSample":0,"disclaimer":"d","generatedAt":"2026-01-03T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + await mm.Financial.PredictAsync(new Subject("ticker", "AAPL")); + + using var doc = JsonDocument.Parse(body); + Assert.False(doc.RootElement.TryGetProperty("horizonDays", out _)); + Assert.False(doc.RootElement.TryGetProperty("persist", out _)); + } + + // ── reconcile ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Reconcile_posts_lattice_financial_reconcile() + { + const string json = + """{"scored":5,"hits":3,"misses":2,"stillPending":7,"generatedAt":"2026-01-03T00:00:00Z"}"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, json)); + using var mm = Client(handler); + + var res = await mm.Financial.ReconcileAsync(); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/financial/reconcile", req.RequestUri!.AbsolutePath); + Assert.Equal(5, res.Scored); + Assert.Equal(3, res.Hits); + Assert.Equal(7, res.StillPending); + } + + // ── getCalibration ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetCalibration_posts_lattice_financial_calibration_with_options() + { + const string json = + """{"buckets":[{"lower":0.6,"upper":0.8,"predictions":10,"hits":7,"misses":3,"realizedHitRate":0.7,"hasData":true}],"strategy":"trend","strategyReliability":0.8,"totalResolved":40,"generatedAt":"2026-01-03T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, json); }); + using var mm = Client(handler); + + var report = await mm.Financial.GetCalibrationAsync(bucketCount: 5, strategy: "trend"); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/financial/calibration", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal(5, doc.RootElement.GetProperty("bucketCount").GetInt32()); + Assert.Equal("trend", doc.RootElement.GetProperty("strategy").GetString()); + Assert.Single(report.Buckets); + Assert.Equal(0.7, report.Buckets[0].RealizedHitRate); + Assert.Equal(40, report.TotalResolved); + } +} diff --git a/test/HealthServiceTests.cs b/test/HealthServiceTests.cs new file mode 100644 index 0000000..2b0dbbb --- /dev/null +++ b/test/HealthServiceTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Health surface (5/5), mirrored from +/// thinkfleet-memory-sdk/src/resources/health.ts. Signals are recorded as fact +/// memories via /admin/memory; reads sit under /lattice/health/*. +public class HealthServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string MemJson = + """{"id":"m1","type":"fact","content":"x","importance":5,"scope":"project","status":"active","confidence":1,"supersededById":null}"""; + + // ── recordBiomarker ───────────────────────────────────────────────────────── + + [Fact] + public async Task RecordBiomarker_posts_fact_memory_to_admin_memory() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + var item = await mm.Health.RecordBiomarkerAsync( + new Subject("patient", "p-123"), "hba1c", 6.2, unit: "%", observedAt: "2026-01-01T00:00:00Z"); + + Assert.Equal("m1", item.Id); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/admin/memory", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.Equal("hba1c = 6.2 %", root.GetProperty("content").GetString()); + Assert.Equal("fact", root.GetProperty("type").GetString()); + Assert.Equal("project", root.GetProperty("scope").GetString()); + Assert.Equal("health", root.GetProperty("category").GetString()); + Assert.Equal("sdk:health", root.GetProperty("source").GetString()); + var health = root.GetProperty("metadata").GetProperty("health"); + Assert.Equal("hba1c", health.GetProperty("biomarker").GetString()); + Assert.Equal(6.2, health.GetProperty("value").GetDouble()); + Assert.Equal("%", health.GetProperty("unit").GetString()); + Assert.Equal("patient", root.GetProperty("metadata").GetProperty("subject").GetProperty("kind").GetString()); + } + + [Fact] + public async Task RecordBiomarker_omits_unit_from_content_when_absent() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Health.RecordBiomarkerAsync(new Subject("patient", "p-1"), "ldl", 130); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("ldl = 130", doc.RootElement.GetProperty("content").GetString()); + var health = doc.RootElement.GetProperty("metadata").GetProperty("health"); + Assert.False(health.TryGetProperty("unit", out _)); + } + + // ── recordDemographics ────────────────────────────────────────────────────── + + [Fact] + public async Task RecordDemographics_posts_demographic_metadata() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Health.RecordDemographicsAsync(new Subject("patient", "p-1"), + new DemographicsInput(AgeYears: 54, Sex: "female", WeightKg: 82, HeightCm: 170, Activity: "low")); + + var req = handler.Requests[0]; + Assert.EndsWith("/admin/memory", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("Demographics update", doc.RootElement.GetProperty("content").GetString()); + var demo = doc.RootElement.GetProperty("metadata").GetProperty("demographic"); + Assert.Equal(54, demo.GetProperty("ageYears").GetDouble()); + Assert.Equal("female", demo.GetProperty("sex").GetString()); + } + + // ── recordCondition ───────────────────────────────────────────────────────── + + [Fact] + public async Task RecordCondition_posts_icd10_diagnosis() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, MemJson); }); + using var mm = Client(handler); + + await mm.Health.RecordConditionAsync(new Subject("patient", "p-1"), + new ConditionInput("I10", Status: "active")); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("Diagnosis I10", doc.RootElement.GetProperty("content").GetString()); + var cond = doc.RootElement.GetProperty("metadata").GetProperty("condition"); + Assert.Equal("I10", cond.GetProperty("icd10").GetString()); + Assert.Equal("active", cond.GetProperty("status").GetString()); + } + + // ── getProfile ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetProfile_posts_lattice_health_profile_and_parses() + { + const string profileJson = + """{"subject":{"kind":"patient","externalId":"p-1"},"predictedConditions":[{"condition":"type2_diabetes","label":"Type 2 Diabetes","basis":"above_threshold_now","biomarker":"hba1c","currentValue":6.2,"threshold":6.5,"confidence":0.8,"rationale":"trending","sourceMemoryIds":["m1"]}],"diagnosedConditions":["I10"],"latestBiomarkers":[{"biomarker":"hba1c","value":6.2,"unit":"%","observedAt":"2026-01-01T00:00:00Z"}],"disclaimer":"screening only","generatedAt":"2026-01-02T00:00:00Z","biologicalAge":{"biologicalAgeYears":58,"chronologicalAgeYears":54,"deltaYears":4,"method":"phenoage_hybrid","confidence":0.7,"components":[{"label":"crp","yearsDelta":1.2}],"mortalityScore":0.1}}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, profileJson); }); + using var mm = Client(handler); + + var profile = await mm.Health.GetProfileAsync(new Subject("patient", "p-1")); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/health/profile", req.RequestUri!.AbsolutePath); + Assert.Equal("p-1", JsonDocument.Parse(body).RootElement.GetProperty("subject").GetProperty("externalId").GetString()); + Assert.Equal(58, profile.BiologicalAge!.BiologicalAgeYears); + Assert.Single(profile.PredictedConditions); + Assert.Equal("type2_diabetes", profile.PredictedConditions[0].Condition); + Assert.Equal("I10", profile.DiagnosedConditions[0]); + Assert.Equal("hba1c", profile.LatestBiomarkers[0].Biomarker); + } + + // ── getCohortRisk ─────────────────────────────────────────────────────────── + + [Fact] + public async Task GetCohortRisk_posts_lattice_health_cohort_risk_with_k() + { + const string cohortJson = + """{"subject":{"kind":"patient","externalId":"p-1"},"cohortSize":25,"populationSize":1000,"risks":[{"condition":"type2_diabetes","cohortPrevalence":0.32,"cohortSize":25,"countWith":8,"meanSimilarity":0.9,"confidence":0.6,"rationale":"cohort"}],"disclaimer":"screening only","generatedAt":"2026-01-02T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, cohortJson); }); + using var mm = Client(handler); + + var cohort = await mm.Health.GetCohortRiskAsync(new Subject("patient", "p-1"), k: 25); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/health/cohort-risk", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal(25, doc.RootElement.GetProperty("k").GetInt32()); + Assert.Equal(25, cohort.CohortSize); + Assert.Equal("type2_diabetes", cohort.Risks[0].Condition); + Assert.Equal(0.32, cohort.Risks[0].CohortPrevalence); + } + + [Fact] + public async Task GetCohortRisk_omits_k_when_not_supplied() + { + const string cohortJson = + """{"subject":{"kind":"patient","externalId":"p-1"},"cohortSize":25,"populationSize":1000,"risks":[],"disclaimer":"d","generatedAt":"2026-01-02T00:00:00Z"}"""; + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, cohortJson); }); + using var mm = Client(handler); + + await mm.Health.GetCohortRiskAsync(new Subject("patient", "p-1")); + + using var doc = JsonDocument.Parse(body); + Assert.False(doc.RootElement.TryGetProperty("k", out _)); + } +} diff --git a/test/InfraTests.cs b/test/InfraTests.cs new file mode 100644 index 0000000..083b064 --- /dev/null +++ b/test/InfraTests.cs @@ -0,0 +1,183 @@ +using System.Net; +using System.Net.Http; +using Xunit; + +namespace MemMesh.Tests; + +public class InfraTests +{ + private const string ItemJson = + """{"id":"m1","type":"fact","content":"hi","importance":5,"scope":"project","status":"active","confidence":0.9,"supersededById":null}"""; + + private static MemMeshClient Client(HttpMessageHandler handler, int maxRetries = 2, TimeSpan? timeout = null) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), + maxRetries: maxRetries, timeout: timeout); + + // ── Retry / backoff ─────────────────────────────────────────────────── + + [Fact] + public async Task Retries_429_then_succeeds() + { + var handler = new StubHandler( + _ => StubHandler.Json(HttpStatusCode.TooManyRequests, "{}"), + _ => StubHandler.Json(HttpStatusCode.OK, ItemJson)); + using var mm = Client(handler); + + var item = await mm.Memory.GetAsync("m1"); + + Assert.Equal("m1", item.Id); + Assert.Equal(2, handler.Calls); // one retry + } + + [Fact] + public async Task Retries_5xx_up_to_maxRetries_then_throws_ServerException() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.BadGateway, "{}")); + using var mm = Client(handler, maxRetries: 2); + + var ex = await Assert.ThrowsAsync(() => mm.Memory.GetAsync("m1")); + + Assert.Equal(502, ex.Status); + Assert.Equal(3, handler.Calls); // initial + 2 retries + } + + [Fact] + public async Task RateLimit_surfaces_RetryAfter_and_does_not_retry_when_maxRetries_zero() + { + var handler = new StubHandler(_ => + { + var r = StubHandler.Json(HttpStatusCode.TooManyRequests, "{}"); + r.Headers.Add("Retry-After", "2"); + return r; + }); + using var mm = Client(handler, maxRetries: 0); + + var ex = await Assert.ThrowsAsync(() => mm.Memory.GetAsync("m1")); + + Assert.Equal(TimeSpan.FromSeconds(2), ex.RetryAfter); + Assert.Equal(1, handler.Calls); + } + + [Fact] + public async Task Timeout_is_not_retried_and_throws_RequestTimeoutException() + { + var slow = new SlowHandler(); + using var mm = Client(slow, maxRetries: 2, timeout: TimeSpan.FromMilliseconds(80)); + + await Assert.ThrowsAsync(() => mm.Memory.GetAsync("m1")); + Assert.Equal(1, slow.Calls); // timeouts are not retried + } + + // ── Typed error mapping ─────────────────────────────────────────────── + + [Theory] + [InlineData(HttpStatusCode.Unauthorized, typeof(AuthenticationException))] + [InlineData(HttpStatusCode.Forbidden, typeof(AuthorizationException))] + [InlineData(HttpStatusCode.NotFound, typeof(NotFoundException))] + public async Task Maps_status_to_typed_exception(HttpStatusCode status, Type expected) + { + var handler = new StubHandler(_ => StubHandler.Json(status, """{"message":"nope"}""")); + using var mm = Client(handler); + + var ex = await Assert.ThrowsAnyAsync(() => mm.Memory.GetAsync("m1")); + Assert.IsType(expected, ex); + Assert.Equal("nope", ex.Body); + } + + [Fact] + public async Task Validation_422_carries_params() + { + var handler = new StubHandler(_ => StubHandler.Json( + (HttpStatusCode)422, """{"message":"bad","params":{"field":"name"}}""")); + using var mm = Client(handler); + + var ex = await Assert.ThrowsAsync(() => mm.Memory.GetAsync("m1")); + Assert.NotNull(ex.Params); + Assert.True(ex.Params!.ContainsKey("field")); + } + + // ── Interceptors ────────────────────────────────────────────────────── + + [Fact] + public async Task RequestInterceptor_can_swap_the_bearer_token() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, ItemJson)); + using var mm = Client(handler); + mm.RequestInterceptors.Add((req, _) => + { + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "jwt-123"); + return Task.CompletedTask; + }); + + await mm.Memory.GetAsync("m1"); + + Assert.Equal("Bearer jwt-123", handler.Requests[0].Headers.Authorization!.ToString()); + } + + [Fact] + public async Task ResponseInterceptor_runs_on_each_response() + { + var seen = 0; + var handler = new StubHandler( + _ => StubHandler.Json(HttpStatusCode.ServiceUnavailable, "{}"), + _ => StubHandler.Json(HttpStatusCode.OK, ItemJson)); + using var mm = Client(handler); + mm.ResponseInterceptors.Add((_, _) => { seen++; return Task.CompletedTask; }); + + await mm.Memory.GetAsync("m1"); + + Assert.Equal(2, seen); // once per response, including the retried 503 + } + + // ── Pagination ──────────────────────────────────────────────────────── + + [Fact] + public async Task ListAll_follows_cursors_and_flattens() + { + var page1 = $$"""{"data":[{{ItemJson}}],"next":"c2","previous":null}"""; + var page2 = $$"""{"data":[{{ItemJson}}],"next":null,"previous":"c1"}"""; + var handler = new StubHandler( + _ => StubHandler.Json(HttpStatusCode.OK, page1), + _ => StubHandler.Json(HttpStatusCode.OK, page2)); + using var mm = Client(handler); + + var all = new List(); + await foreach (var item in mm.ListAllAsync("admin/memory")) + all.Add(item); + + Assert.Equal(2, all.Count); + Assert.Equal(2, handler.Calls); + Assert.DoesNotContain("cursor", handler.Requests[0].RequestUri!.Query); + Assert.Contains("cursor=c2", handler.Requests[1].RequestUri!.Query); + } + + // ── Per-call options ────────────────────────────────────────────────── + + [Fact] + public async Task RequestOptions_overrides_projectId_in_url() + { + var handler = new StubHandler(_ => + StubHandler.Json(HttpStatusCode.OK, """{"data":[],"next":null,"previous":null}""")); + using var mm = Client(handler); + + await foreach (var _ in mm.ListAllAsync("admin/memory", + new RequestOptions { ProjectId = "other_proj" })) { } + + Assert.Contains("/projects/other_proj/", handler.Requests[0].RequestUri!.AbsolutePath); + } +} + +/// Blocks until its request is cancelled, so the client's per-call +/// timeout fires. +internal sealed class SlowHandler : HttpMessageHandler +{ + public int Calls { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + } +} diff --git a/test/LatticeServiceTests.cs b/test/LatticeServiceTests.cs new file mode 100644 index 0000000..608c885 --- /dev/null +++ b/test/LatticeServiceTests.cs @@ -0,0 +1,318 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Lattice surface brought to full +/// parity with the TS reference, driven through the scripted +/// . +public class LatticeServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string PatternJson = + """{"id":"p1","projectId":"proj_1","contactId":"sarah","summary":"orders fridays","metadata":{"patternKind":"day_of_week","contactId":"sarah","confidence":0.8,"observationCount":12,"observationWindowDays":90,"lastObservedAt":"2026-01-01T00:00:00Z","active":true},"active":true,"confidence":0.8,"created":"2026-01-01T00:00:00Z","updated":"2026-01-02T00:00:00Z"}"""; + + private const string ExtractResultJson = + """{"contactsProcessed":3,"patternsCreated":5,"patternsRefreshed":2,"patternsDeactivated":1,"durationMs":42}"""; + + // ── mineMemories ────────────────────────────────────────────────────── + + [Fact] + public async Task MineMemories_posts_extract_route_with_source_memories() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, ExtractResultJson); }); + using var mm = Client(handler); + + var res = await mm.Lattice.MineMemoriesAsync(new Subject("contact", "sarah"), windowDays: 30); + + Assert.Equal(5, res.PatternsCreated); + Assert.Equal(42, res.DurationMs); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/patterns/extract", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("memories", doc.RootElement.GetProperty("source").GetString()); + Assert.Equal(30, doc.RootElement.GetProperty("windowDays").GetInt32()); + Assert.Equal("sarah", doc.RootElement.GetProperty("subject").GetProperty("externalId").GetString()); + } + + // ── getPattern ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetPattern_gets_pattern_by_id_and_parses_metadata() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, PatternJson)); + using var mm = Client(handler); + + var p = await mm.Lattice.GetPatternAsync("p1"); + + Assert.Equal("p1", p.Id); + Assert.Equal("day_of_week", p.Metadata.PatternKind); + Assert.Equal(12, p.Metadata.ObservationCount); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/lattice/patterns/p1", req.RequestUri!.AbsolutePath); + } + + // ── listPatterns ────────────────────────────────────────────────────── + + [Fact] + public async Task ListPatterns_gets_contact_route_with_paging_query() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + $$"""{"data":[{{PatternJson}}],"nextCursor":"c2"}""")); + using var mm = Client(handler); + + var res = await mm.Lattice.ListPatternsAsync("sarah", activeOnly: false, limit: 25, cursor: "c1"); + + Assert.Single(res.Data); + Assert.Equal("c2", res.NextCursor); + var req = handler.Requests[0]; + Assert.EndsWith("/lattice/contacts/sarah/patterns", req.RequestUri!.AbsolutePath); + Assert.Contains("activeOnly=false", req.RequestUri!.Query); + Assert.Contains("limit=25", req.RequestUri!.Query); + Assert.Contains("cursor=c1", req.RequestUri!.Query); + } + + // ── getContext ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetContext_gets_bundle_with_asOf_and_limits() + { + var bundle = + $$"""{"contactId":"sarah","contact":{"id":"sarah","displayName":"Sarah"},"activePatterns":[{{PatternJson}}],"recentEvents":[],"recentMemories":[]}"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, bundle)); + using var mm = Client(handler); + + var res = await mm.Lattice.GetContextAsync("sarah", asOf: "2026-03-01T00:00:00Z", + eventsLimit: 10, memoriesLimit: 5, graphHops: 2); + + Assert.Equal("sarah", res.ContactId); + Assert.Equal("Sarah", res.Contact.DisplayName); + Assert.Single(res.ActivePatterns); + var req = handler.Requests[0]; + Assert.EndsWith("/lattice/contacts/sarah/context", req.RequestUri!.AbsolutePath); + Assert.Contains("asOf=2026-03-01", req.RequestUri!.Query); + Assert.Contains("eventsLimit=10", req.RequestUri!.Query); + Assert.Contains("graphHops=2", req.RequestUri!.Query); + } + + // ── runMonitorTick ──────────────────────────────────────────────────── + + [Fact] + public async Task RunMonitorTick_posts_tick_and_parses_result() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"patternsChecked":9,"patternsBroken":1,"breaksEmitted":1,"durationMs":15,"capped":false,"failures":[{"patternId":"p9","error":"boom"}]}""")); + using var mm = Client(handler); + + var res = await mm.Lattice.RunMonitorTickAsync(); + + Assert.Equal(9, res.PatternsChecked); + Assert.Single(res.Failures); + Assert.Equal("p9", res.Failures[0].PatternId); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/monitor/tick", req.RequestUri!.AbsolutePath); + } + + // ── getMonitorStatus ────────────────────────────────────────────────── + + [Fact] + public async Task GetMonitorStatus_gets_status_with_nullable_last_tick() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"lastTickAt":null,"lastTickDurationMs":null,"patternsDue":4,"activePatternCount":20}""")); + using var mm = Client(handler); + + var res = await mm.Lattice.GetMonitorStatusAsync(); + + Assert.Null(res.LastTickAt); + Assert.Equal(4, res.PatternsDue); + Assert.Equal(20, res.ActivePatternCount); + Assert.EndsWith("/lattice/monitor/status", handler.Requests[0].RequestUri!.AbsolutePath); + } + + // ── predictTarget ───────────────────────────────────────────────────── + + [Fact] + public async Task PredictTarget_posts_target_and_returns_target_prediction() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"subject":{"kind":"customer","externalId":"acct-42"},"predictions":[],"activePatternCount":0,"generatedAt":"2026-01-01T00:00:00Z","durationMs":7,"targetPrediction":{"targetKind":"event_occurrence","eventType":"subscription_cancelled","probability":0.3,"probabilityLower":0.1,"probabilityUpper":0.5,"value":0,"valueLower":0,"valueUpper":0,"expectedAt":"","expectedAtLower":"","expectedAtUpper":"","daysUntil":0,"anomalyScore":0,"isAnomaly":false,"abstained":false,"abstentionReason":"","explanation":"12 of 40","evidenceMemoryIds":["m1"]}}"""); + }); + using var mm = Client(handler); + + var p = await mm.Lattice.PredictTargetAsync( + new Subject("customer", "acct-42"), + new PredictionTarget("event_occurrence", EventType: "subscription_cancelled"), + horizonDays: 90); + + Assert.False(p.Abstained); + Assert.Equal(0.3, p.Probability); + Assert.Equal("subscription_cancelled", p.EventType); + Assert.Single(p.EvidenceMemoryIds); + var req = handler.Requests[0]; + Assert.EndsWith("/lattice/predict", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("event_occurrence", doc.RootElement.GetProperty("target").GetProperty("kind").GetString()); + Assert.Equal(90, doc.RootElement.GetProperty("horizonDays").GetInt32()); + } + + [Fact] + public async Task PredictTarget_synthesizes_abstention_when_engine_returns_none() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"subject":{"kind":"customer","externalId":"acct-42"},"predictions":[],"activePatternCount":0,"generatedAt":"2026-01-01T00:00:00Z","durationMs":7,"abstained":true,"abstentionReason":"not_enough_history"}""")); + using var mm = Client(handler); + + var p = await mm.Lattice.PredictTargetAsync( + new Subject("customer", "acct-42"), + new PredictionTarget("numeric", AttributeKey: "order_total")); + + Assert.True(p.Abstained); + Assert.Equal("not_enough_history", p.AbstentionReason); + // eventType falls back to attributeKey when the target had no eventType. + Assert.Equal("order_total", p.EventType); + } + + // ── getCohort ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetCohort_posts_subject_k_and_parses_members() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"target":{"kind":"contact","externalId":"sarah"},"members":[{"subject":{"kind":"contact","externalId":"mike"},"similarity":0.72,"rfmSegment":"loyal","patternKinds":["day_of_week"]}],"candidateCount":50,"generatedAt":"2026-01-01T00:00:00Z","durationMs":11}"""); + }); + using var mm = Client(handler); + + var res = await mm.Lattice.GetCohortAsync(new Subject("contact", "sarah"), k: 10, minSimilarity: 0.5); + + Assert.Single(res.Members); + Assert.Equal("mike", res.Members[0].Subject.ExternalId); + Assert.Equal(0.72, res.Members[0].Similarity); + var req = handler.Requests[0]; + Assert.EndsWith("/lattice/cohort", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal(10, doc.RootElement.GetProperty("k").GetInt32()); + Assert.Equal(0.5, doc.RootElement.GetProperty("minSimilarity").GetDouble()); + } + + // ── estimate ────────────────────────────────────────────────────────── + + [Fact] + public async Task Estimate_posts_estimator_and_parses_result() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"subject":{"kind":"patient","externalId":"p-123"},"estimatorId":"phenoage","ok":true,"value":41.2,"unit":"years","contributors":[{"signal":"crp","contribution":1.3}],"confidence":0.9,"provenance":["m1"],"framing":"estimate","disclaimer":"not a diagnosis","missingSignals":[]}"""); + }); + using var mm = Client(handler); + + var res = await mm.Lattice.EstimateAsync(new Subject("patient", "p-123"), "phenoage", persist: true); + + Assert.True(res.Ok); + Assert.Equal(41.2, res.Value); + Assert.Equal("years", res.Unit); + Assert.Single(res.Contributors); + var req = handler.Requests[0]; + Assert.EndsWith("/lattice/estimate", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("phenoage", doc.RootElement.GetProperty("estimatorId").GetString()); + Assert.True(doc.RootElement.GetProperty("persist").GetBoolean()); + } + + // ── extractPatterns / predict / calibration alignment (renamed surface) ── + + [Fact] + public async Task ExtractPatterns_posts_extract_route_with_filters() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, ExtractResultJson); }); + using var mm = Client(handler); + + var res = await mm.Lattice.ExtractPatternsAsync(contactId: "sarah", windowDays: 60, force: true); + + Assert.Equal(3, res.ContactsProcessed); + Assert.EndsWith("/lattice/patterns/extract", handler.Requests[0].RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("sarah", doc.RootElement.GetProperty("contactId").GetString()); + Assert.True(doc.RootElement.GetProperty("force").GetBoolean()); + // extractPatterns must NOT force source=memories (that's mineMemories). + Assert.False(doc.RootElement.TryGetProperty("source", out _)); + } + + [Fact] + public async Task Predict_projects_patterns_when_no_target_given() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"subject":{"kind":"contact","externalId":"sarah"},"predictions":[{"patternId":"p1","patternKind":"day_of_week","description":"orders friday","expectedAt":"2026-02-06T19:00:00Z","confidence":0.8,"windowMinutes":120,"sourceMemoryIds":["m1"]}],"activePatternCount":1,"generatedAt":"2026-01-01T00:00:00Z","durationMs":9}"""); + }); + using var mm = Client(handler); + + var res = await mm.Lattice.PredictAsync(new Subject("contact", "sarah"), horizonDays: 30); + + Assert.Single(res.Predictions); + Assert.Equal("day_of_week", res.Predictions[0].PatternKind); + Assert.Equal(1, res.ActivePatternCount); + using var doc = JsonDocument.Parse(body); + Assert.Equal(30, doc.RootElement.GetProperty("horizonDays").GetInt32()); + // no target in pattern-projection mode + Assert.False(doc.RootElement.TryGetProperty("target", out _)); + Assert.EndsWith("/lattice/predict", handler.Requests[0].RequestUri!.AbsolutePath); + } + + [Fact] + public async Task GetCalibration_gets_report_with_bucket_count_query() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"buckets":[{"lower":0,"upper":0.2,"patterns":3,"predictions":10,"hits":7,"misses":3,"realizedHitRate":0.7,"hasData":true}],"totalPatterns":3,"totalPredictions":10}""")); + using var mm = Client(handler); + + var res = await mm.Lattice.GetCalibrationAsync(bucketCount: 5); + + Assert.Single(res.Buckets); + Assert.Equal(0.7, res.Buckets[0].RealizedHitRate); + Assert.Equal(10, res.TotalPredictions); + var req = handler.Requests[0]; + Assert.EndsWith("/lattice/calibration", req.RequestUri!.AbsolutePath); + Assert.Contains("bucketCount=5", req.RequestUri!.Query); + } + + // ── options thread through ──────────────────────────────────────────── + + [Fact] + public async Task Lattice_methods_honor_RequestOptions_project_override() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"lastTickAt":null,"lastTickDurationMs":null,"patternsDue":0,"activePatternCount":0}""")); + using var mm = Client(handler); + + await mm.Lattice.GetMonitorStatusAsync(options: new RequestOptions { ProjectId = "other_proj" }); + + Assert.Contains("/projects/other_proj/", handler.Requests[0].RequestUri!.AbsolutePath); + } +} diff --git a/test/LearningServiceTests.cs b/test/LearningServiceTests.cs new file mode 100644 index 0000000..2b882cc --- /dev/null +++ b/test/LearningServiceTests.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Learning + Behaviors surfaces brought +/// to parity with the TS reference, driven through the scripted +/// . +public class LearningServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + // ── recordDecision ──────────────────────────────────────────────────── + + [Fact] + public async Task RecordDecision_posts_lattice_decisions_with_provenance() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"decision":{"decisionId":"d1","subject":{"kind":"contact","externalId":"sarah"},"actor":"policy:winback-v1","decisionType":"offer","policy":"winback-v1","informedBy":[{"memoryId":"p1","refType":"pattern","weight":1.0}],"actionType":"apply_discount","status":"executed","occurredAt":"2026-01-01T00:00:00Z","created":"2026-01-01T00:00:01Z"}}"""); + }); + using var mm = Client(handler); + + var res = await mm.Learning.RecordDecisionAsync( + new Subject("contact", "sarah"), actor: "policy:winback-v1", decisionType: "offer", + informedBy: new[] { new ProvenanceRef("p1", RefType: "pattern") }, + actionType: "apply_discount", parameters: new Dictionary { ["pct"] = "15" }); + + Assert.NotNull(res.Decision); + Assert.Equal("d1", res.Decision!.DecisionId); + Assert.Equal("sarah", res.Decision.Subject!.ExternalId); + Assert.Single(res.Decision.InformedBy); + Assert.Equal("p1", res.Decision.InformedBy[0].MemoryId); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/decisions", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("sarah", doc.RootElement.GetProperty("subject").GetProperty("externalId").GetString()); + Assert.Equal("p1", doc.RootElement.GetProperty("informedBy")[0].GetProperty("memoryId").GetString()); + // `params` (reserved keyword in C#) must serialize under its TS name. + Assert.Equal("15", doc.RootElement.GetProperty("params").GetProperty("pct").GetString()); + } + + // ── recordOutcome ───────────────────────────────────────────────────── + + [Fact] + public async Task RecordOutcome_posts_lattice_outcomes_and_parses_updates() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"outcomeId":"o1","updates":[{"refId":"p1","refType":"pattern","priorConfidence":0.5,"posteriorConfidence":0.62,"hits":3,"misses":1}]}"""); + }); + using var mm = Client(handler); + + var res = await mm.Learning.RecordOutcomeAsync("d1", "success", outcomeType: "conversion", reward: 84.0); + + Assert.Equal("o1", res.OutcomeId); + Assert.Single(res.Updates); + Assert.Equal(0.62, res.Updates[0].PosteriorConfidence); + Assert.Equal(3, res.Updates[0].Hits); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/outcomes", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("d1", doc.RootElement.GetProperty("decisionId").GetString()); + Assert.Equal("success", doc.RootElement.GetProperty("result").GetString()); + Assert.Equal(84.0, doc.RootElement.GetProperty("reward").GetDouble()); + } + + // ── getOutcomes ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetOutcomes_gets_lattice_outcomes_with_subject_query_and_unwraps() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"outcomes":[{"outcomeId":"o1","decisionId":"d1","subject":{"kind":"contact","externalId":"sarah"},"decisionType":"offer","actionType":"apply_discount","outcomeType":"conversion","result":"success","reward":84.0,"occurredAt":"2026-01-01T00:00:00Z","realizedAt":"2026-01-02T00:00:00Z"}]}""")); + using var mm = Client(handler); + + var res = await mm.Learning.GetOutcomesAsync( + new Subject("contact", "sarah"), decisionType: "offer", limit: 50); + + Assert.Single(res); + Assert.Equal("o1", res[0].OutcomeId); + Assert.Equal("success", res[0].Result); + Assert.Equal(84.0, res[0].Reward); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/lattice/outcomes", req.RequestUri!.AbsolutePath); + Assert.Contains("subjectKind=contact", req.RequestUri!.Query); + Assert.Contains("subjectExternalId=sarah", req.RequestUri!.Query); + Assert.Contains("decisionType=offer", req.RequestUri!.Query); + Assert.Contains("limit=50", req.RequestUri!.Query); + } + + // ── getEffectiveness ────────────────────────────────────────────────── + + [Fact] + public async Task GetEffectiveness_gets_lattice_effectiveness_with_query_and_unwraps() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, + """{"rows":[{"groupKey":"apply_discount","n":40,"successRate":0.3,"avgReward":12.5,"confidence":0.31}]}""")); + using var mm = Client(handler); + + var res = await mm.Learning.GetEffectivenessAsync(groupBy: "action_type", minSupport: 5); + + Assert.Single(res); + Assert.Equal("apply_discount", res[0].GroupKey); + Assert.Equal(40, res[0].N); + Assert.Equal(0.3, res[0].SuccessRate); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/lattice/effectiveness", req.RequestUri!.AbsolutePath); + Assert.Contains("groupBy=action_type", req.RequestUri!.Query); + Assert.Contains("minSupport=5", req.RequestUri!.Query); + } + + // ── behaviors.discover ──────────────────────────────────────────────── + + [Fact] + public async Task Discover_posts_lattice_discover_with_params_and_parses_behaviors() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"behaviors":[{"label":"weekly friday orderers","prevalence":0.42,"stability":0.81,"size":37,"memberSubjects":[{"kind":"contact","externalId":"sarah"}],"exemplarEvidence":["pattern: recurring_event"]}],"subjectsAnalyzed":88,"generatedAt":"2026-01-01T00:00:00Z","durationMs":120}"""); + }); + using var mm = Client(handler); + + var res = await mm.Behaviors.DiscoverAsync(simThreshold: 0.8, minClusterSize: 5, maxMembers: 50); + + Assert.Single(res.Behaviors); + Assert.Equal("weekly friday orderers", res.Behaviors[0].Label); + Assert.Equal(0.42, res.Behaviors[0].Prevalence); + Assert.Equal(37, res.Behaviors[0].Size); + Assert.Equal("sarah", res.Behaviors[0].MemberSubjects[0].ExternalId); + Assert.Equal(88, res.SubjectsAnalyzed); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/lattice/discover", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal(0.8, doc.RootElement.GetProperty("simThreshold").GetDouble()); + Assert.Equal(5, doc.RootElement.GetProperty("minClusterSize").GetInt32()); + Assert.Equal(50, doc.RootElement.GetProperty("maxMembers").GetInt32()); + } + + [Fact] + public async Task Discover_posts_empty_body_when_no_params_given() + { + string body = ""; + var handler = new StubHandler(r => + { + body = BodyOf(r); + return StubHandler.Json(HttpStatusCode.OK, + """{"behaviors":[],"subjectsAnalyzed":0,"generatedAt":"2026-01-01T00:00:00Z","durationMs":3}"""); + }); + using var mm = Client(handler); + + var res = await mm.Behaviors.DiscoverAsync(); + + Assert.Empty(res.Behaviors); + using var doc = JsonDocument.Parse(body); + Assert.False(doc.RootElement.TryGetProperty("simThreshold", out _)); + } +} diff --git a/test/MemMesh.Tests.csproj b/test/MemMesh.Tests.csproj new file mode 100644 index 0000000..4bfdb11 --- /dev/null +++ b/test/MemMesh.Tests.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + latest + enable + enable + MemMesh.Tests + false + + + + + + + + + + + + + diff --git a/test/StubHandler.cs b/test/StubHandler.cs new file mode 100644 index 0000000..0f7f227 --- /dev/null +++ b/test/StubHandler.cs @@ -0,0 +1,31 @@ +using System.Net; +using System.Net.Http; + +namespace MemMesh.Tests; + +/// An that replays a scripted sequence +/// of responses and records the requests it saw — so tests can drive the client's +/// retry / interceptor / pagination behavior without a real server. +internal sealed class StubHandler(params Func[] steps) + : HttpMessageHandler +{ + private int _i; + public List Requests { get; } = []; + public int Calls => _i; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + // Buffer the body now — the request is disposed once we return. + if (request.Content is not null) + await request.Content.LoadIntoBufferAsync().ConfigureAwait(false); + Requests.Add(request); + + var step = steps[Math.Min(_i, steps.Length - 1)]; + _i++; + return step(request); + } + + public static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; +} diff --git a/test/TypedServiceTests.cs b/test/TypedServiceTests.cs new file mode 100644 index 0000000..06beed9 --- /dev/null +++ b/test/TypedServiceTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the Typed-attributes surface (6/6), +/// mirrored from the TS reference. Confirms every route sits under +/// /memory-typed/*. +public class TypedServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string AttrJson = + """{"id":"attr1","attributeKey":"credit_score","dataType":"numeric","required":false,"minValid":300,"maxValid":850,"projectId":"proj_1"}"""; + + // ── registerAttribute ────────────────────────────────────────────────────── + + [Fact] + public async Task RegisterAttribute_posts_memory_typed_attributes() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, AttrJson); }); + using var mm = Client(handler); + + var def = await mm.Typed.RegisterAttributeAsync(new RegisterAttributeRequest( + "credit_score", "numeric", MinValid: 300, MaxValid: 850)); + + Assert.Equal("attr1", def.Id); + Assert.Equal(300, def.MinValid); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/memory-typed/attributes", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + Assert.Equal("credit_score", doc.RootElement.GetProperty("attributeKey").GetString()); + Assert.Equal(850, doc.RootElement.GetProperty("maxValid").GetDouble()); + // Unset optional fields are omitted. + Assert.False(doc.RootElement.TryGetProperty("unit", out _)); + } + + // ── listAttributes ───────────────────────────────────────────────────────── + + [Fact] + public async Task ListAttributes_gets_memory_typed_attributes_with_params() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, $"[{AttrJson}]")); + using var mm = Client(handler); + + var defs = await mm.Typed.ListAttributesAsync(attributeKey: "credit_score", limit: 10, offset: 5); + + Assert.Single(defs); + Assert.Equal("credit_score", defs[0].AttributeKey); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-typed/attributes", req.RequestUri!.AbsolutePath); + var query = req.RequestUri!.Query; + Assert.Contains("attributeKey=credit_score", query); + Assert.Contains("limit=10", query); + Assert.Contains("offset=5", query); + } + + // ── ingest (sync) ────────────────────────────────────────────────────────── + + [Fact] + public async Task Ingest_posts_observations_and_returns_report() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, + """{"accepted":1,"quarantined":0,"duplicates":0,"quarantineReasons":{}}"""); }); + using var mm = Client(handler); + + var report = await mm.Typed.IngestAsync([new TypedObservationInput( + "credit_score", "contact", "sarah", "2026-01-01T00:00:00Z", ValueNumeric: 650)]); + + Assert.Equal(1, report.Accepted); + Assert.Empty(report.QuarantineReasons); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/memory-typed/observations", req.RequestUri!.AbsolutePath); + using var doc = JsonDocument.Parse(body); + var obs = doc.RootElement.GetProperty("observations")[0]; + Assert.Equal("credit_score", obs.GetProperty("attributeKey").GetString()); + Assert.Equal(650, obs.GetProperty("valueNumeric").GetDouble()); + // Unset optional value fields are omitted from the observation. + Assert.False(obs.TryGetProperty("valueText", out _)); + } + + // ── enqueue (async) ──────────────────────────────────────────────────────── + + [Fact] + public async Task Enqueue_posts_to_observations_enqueue() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, """{"enqueued":3}""")); + using var mm = Client(handler); + + var res = await mm.Typed.EnqueueAsync([ + new TypedObservationInput("credit_score", "contact", "sarah", "2026-01-01T00:00:00Z", ValueNumeric: 650)]); + + Assert.Equal(3, res.Enqueued); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.EndsWith("/memory-typed/observations/enqueue", req.RequestUri!.AbsolutePath); + } + + // ── queryObservations ────────────────────────────────────────────────────── + + [Fact] + public async Task QueryObservations_gets_observations_with_filters() + { + const string obsJson = + """[{"id":"o1","attributeKey":"credit_score","subjectKind":"contact","subjectExternalId":"sarah","observedAt":"2026-01-01T00:00:00Z","valueNumeric":650,"status":"accepted"}]"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, obsJson)); + using var mm = Client(handler); + + var obs = await mm.Typed.QueryObservationsAsync( + subjectKind: "contact", subjectExternalId: "sarah", attributeKey: "credit_score", + minValue: 600, maxValue: 700, status: "accepted", limit: 20); + + Assert.Single(obs); + Assert.Equal("accepted", obs[0].Status); + Assert.Equal(650, obs[0].ValueNumeric); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-typed/observations", req.RequestUri!.AbsolutePath); + var query = req.RequestUri!.Query; + Assert.Contains("subjectExternalId=sarah", query); + // Doubles are formatted invariantly (no locale comma). + Assert.Contains("minValue=600", query); + Assert.Contains("maxValue=700", query); + Assert.Contains("status=accepted", query); + } + + // ── accumulator ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Accumulator_gets_running_stats() + { + const string accJson = + """{"subjectKind":"contact","subjectExternalId":"sarah","attributeKey":"credit_score","count":1,"sum":650,"sumSq":422500,"cumulative":650,"mean":650}"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, accJson)); + using var mm = Client(handler); + + var acc = await mm.Typed.AccumulatorAsync("contact", "sarah", "credit_score"); + + Assert.Equal(1, acc.Count); + Assert.Equal(650, acc.Mean); + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/memory-typed/accumulator", req.RequestUri!.AbsolutePath); + var query = req.RequestUri!.Query; + Assert.Contains("subjectKind=contact", query); + Assert.Contains("subjectExternalId=sarah", query); + Assert.Contains("attributeKey=credit_score", query); + } +} From fefd080bb4bd2a9d1e41994d2e4429a4e88967ef Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Wed, 19 Aug 2026 08:02:05 -0400 Subject: [PATCH 2/2] feat: knowledge-graph service + observe identity provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with @memmesh/sdk v0.9.0 (thinkfleet-memory-sdk#21). New `mm.Graph` — StatsAsync, ListEntitiesAsync, GetEntityAsync, ListEdgesAsync, TraverseAsync. There was no graph surface before, so the structural half of memory was unreachable from .NET. Edges deserialize as GraphTraversalEdge, the shape the read routes actually return: Subject and Object are hydrated entities, not ids, plus a Hop counter. The raw memory_edge row is not modelled — no read route returns it. Modelling it that way failed outright in the Rust port. Query values are percent-encoded via a small QueryString builder. An unescaped `&` in a search filter would truncate it server-side and quietly return the wrong page. ObserveAsync gains userId / agentId / sessionId. The server route has always accepted them; the SDK was dropping them, so provenance never arrived. Added only when set, so existing call sites send identical bodies. They are provenance, NOT a tenancy boundary. Verified live against app.memmesh.ai — 12142 entities / 287698 edges, decoding NVIDIA CORP -[reported_metric]-> Cost of Revenue. 113 tests pass. --- src/GraphService.cs | 109 +++++++++++++++++++++ src/MemMeshClient.cs | 3 + src/MemoryService.cs | 7 ++ src/Types.cs | 78 +++++++++++++++ test/GraphServiceTests.cs | 193 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 390 insertions(+) create mode 100644 src/GraphService.cs create mode 100644 test/GraphServiceTests.cs diff --git a/src/GraphService.cs b/src/GraphService.cs new file mode 100644 index 0000000..6ec19eb --- /dev/null +++ b/src/GraphService.cs @@ -0,0 +1,109 @@ +namespace MemMesh; + +/// The knowledge graph built from observed memory — the structural +/// half of what MemMesh stores. +/// +/// Observing text doesn't only produce embeddable rows; extraction also +/// resolves entities and writes typed edges between them. That graph is what +/// reaches a fact no single memory states outright ("who does Sarah report +/// to?" answered from sarah -[member_of]-> team plus +/// team -[led_by]-> priya). +/// +/// Every route here is admin-tier (/admin/memory/...); a project-scoped +/// key gets a 403. +/// +/// Read-only by design. Entities and edges are written by extraction when you +/// ; the server's manual create/retire +/// routes exist for annotation tooling, and exposing them here would invite +/// hand-maintained graphs — the work the engine exists to do for you. +/// +/// var st = await mm.Graph.StatsAsync(); +/// Console.WriteLine($"{st.EntityCount} entities, {st.EdgeCount} edges"); +/// +/// var ents = await mm.Graph.ListEntitiesAsync(search: "Sarah", limit: 1); +/// var chain = await mm.Graph.TraverseAsync(ents[0].Id, hops: 2, +/// predicates: ["member_of", "led_by"]); +/// +public sealed class GraphService(MemMeshClient c) +{ + /// Aggregate counts for the whole graph. + /// + /// Prefer this over (await ListEntitiesAsync()).Count for any "how + /// big is it" question: these are SQL COUNT(*)s over the full table, + /// where the list routes page and would report the page size as the + /// total. + public Task StatsAsync(RequestOptions? options = null, CancellationToken ct = default) + => c.Send(HttpMethod.Get, "admin/memory/graph/stats", null, options, ct); + + /// Entities, filtered by type/scope or a substring of name or + /// alias. Unset filters are omitted from the query string. + public Task> ListEntitiesAsync(string? type = null, string? scope = null, + string? search = null, int? limit = null, int? offset = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var q = new QueryString() + .Add("type", type).Add("scope", scope).Add("search", search) + .Add("limit", limit).Add("offset", offset); + return c.Send>(HttpMethod.Get, $"admin/memory/entities{q}", null, options, ct); + } + + /// One entity plus its 1-hop neighbourhood. + public Task GetEntityAsync(string entityId, string? asOf = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var q = new QueryString().Add("asOf", asOf); + return c.Send(HttpMethod.Get, $"admin/memory/entities/{entityId}{q}", + null, options, ct); + } + + /// Every currently-valid edge. Use for rendering a whole small + /// graph; for a large one, seed from an entity and + /// instead. + public Task> ListEdgesAsync(string? asOf = null, int? limit = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var q = new QueryString().Add("asOf", asOf).Add("limit", limit); + return c.Send>(HttpMethod.Get, $"admin/memory/graph/edges{q}", + null, options, ct); + } + + /// Walk out from a seed entity (1-3 hops). + /// + /// This is the multi-hop path: the edges returned here connect facts no + /// single memory states together, which is how a question gets answered + /// from a chain rather than from one lucky vector hit. + public Task> TraverseAsync(string entityId, int? hops = null, + IEnumerable? predicates = null, string? asOf = null, + RequestOptions? options = null, CancellationToken ct = default) + { + var body = new Dictionary { ["entityId"] = entityId }; + if (hops is not null) body["hops"] = hops; + if (predicates is not null) body["predicates"] = predicates; + if (asOf is not null) body["asOf"] = asOf; + return c.Send>(HttpMethod.Post, "admin/memory/graph/traverse", + body, options, ct); + } +} + +/// Builds a query string, skipping unset values and percent-encoding +/// the rest. +/// +/// The encoding is not cosmetic: search carries user input, and an +/// unescaped & would truncate the filter server-side and quietly +/// return the wrong page. +internal sealed class QueryString +{ + private readonly List _parts = []; + + public QueryString Add(string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + _parts.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + return this; + } + + public QueryString Add(string key, int? value) + => value is null ? this : Add(key, value.Value.ToString()); + + public override string ToString() => _parts.Count == 0 ? "" : "?" + string.Join("&", _parts); +} diff --git a/src/MemMeshClient.cs b/src/MemMeshClient.cs index d97d7be..15ac85a 100644 --- a/src/MemMeshClient.cs +++ b/src/MemMeshClient.cs @@ -48,6 +48,8 @@ public sealed class MemMeshClient : IDisposable public FinancialService Financial { get; } public BrainsService Brains { get; } public ConsentService Consent { get; } + /// The knowledge graph extraction builds from observed memory. + public GraphService Graph { get; } /// Project API key (sk-...). /// Default project for all calls; override per-call via . @@ -87,6 +89,7 @@ public MemMeshClient(string apiKey, string projectId, Financial = new FinancialService(this); Brains = new BrainsService(this); Consent = new ConsentService(this); + Graph = new GraphService(this); } // Relative to the project scope (no leading slash on `path`). diff --git a/src/MemoryService.cs b/src/MemoryService.cs index b46b9e3..0bde6ed 100644 --- a/src/MemoryService.cs +++ b/src/MemoryService.cs @@ -25,6 +25,7 @@ public async Task ObserveAsync(string? text = null, string role string? content = null, Subject? subject = null, string type = "event", string scope = "project", int importance = 5, string? category = null, string? activityType = null, string? occurredAt = null, + string? userId = null, string? agentId = null, string? sessionId = null, IDictionary? metadata = null, CancellationToken ct = default) { // PRIMARY path: raw text through the engine's noise filter. POST @@ -34,6 +35,12 @@ public async Task ObserveAsync(string? text = null, string role { var observeBody = new Dictionary { ["text"] = text, ["role"] = role }; if (occurredAt is not null) observeBody["occurredAt"] = occurredAt; + // Provenance, all optional server-side. Added only when set, so a + // turn without them is indistinguishable from one made by an older + // client rather than carrying explicit nulls. + if (userId is not null) observeBody["userId"] = userId; + if (agentId is not null) observeBody["agentId"] = agentId; + if (sessionId is not null) observeBody["sessionId"] = sessionId; return await c.Send(HttpMethod.Post, "memory/observe", observeBody, ct) .ConfigureAwait(false); } diff --git a/src/Types.cs b/src/Types.cs index e609b65..d913f65 100644 --- a/src/Types.cs +++ b/src/Types.cs @@ -1218,3 +1218,81 @@ public sealed record FinancialCalibrationReport( [property: JsonPropertyName("strategyReliability")] double StrategyReliability, [property: JsonPropertyName("totalResolved")] int TotalResolved, [property: JsonPropertyName("generatedAt")] string GeneratedAt); + +// ── Knowledge graph ───────────────────────────────────────────────────────── + +/// A resolved thing — person, org, product, concept — filed under +/// CanonicalName, with Aliases resolving to it. +public sealed record MemoryEntity( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("canonicalName")] string CanonicalName, + [property: JsonPropertyName("type")] string? Type = null, + [property: JsonPropertyName("scope")] string? Scope = null, + [property: JsonPropertyName("aliases")] List? Aliases = null, + [property: JsonPropertyName("description")] string? Description = null, + [property: JsonPropertyName("projectId")] string? ProjectId = null, + // The brain that first created this entity. Entities dedupe per project, so + // this is provenance, NOT an isolation key — brain-scoped graph work filters + // on the edge's brain, which the read routes apply server-side. + [property: JsonPropertyName("brainId")] string? BrainId = null, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary? Metadata = null, + [property: JsonPropertyName("validFrom")] string? ValidFrom = null, + // Null while the entity is still current. + [property: JsonPropertyName("validTo")] string? ValidTo = null, + [property: JsonPropertyName("supersededById")] string? SupersededById = null); + +/// An edge as the READ routes return it — hydrated, not the raw +/// memory_edge row. Subject and Object are resolved +/// entities rather than ids, plus a Hop counter. +/// +/// This is the server's GraphTraversalEdge, returned by +/// ListEdgesAsync, TraverseAsync, and the edges of +/// GetEntityAsync. The raw row shape (subjectId / objectId) +/// is not exposed by any read route, so it is deliberately not modelled — a +/// type nothing returns is a trap. +public sealed record GraphTraversalEdge( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("subject")] MemoryEntity Subject, + [property: JsonPropertyName("predicate")] string Predicate, + // Null when ObjectLiteral carries the value instead. + [property: JsonPropertyName("object")] MemoryEntity? Object = null, + [property: JsonPropertyName("objectLiteral")] string? ObjectLiteral = null, + [property: JsonPropertyName("weight")] double Weight = 0, + [property: JsonPropertyName("validFrom")] string? ValidFrom = null, + [property: JsonPropertyName("validTo")] string? ValidTo = null, + [property: JsonPropertyName("sourceMemoryId")] string? SourceMemoryId = null, + // Distance from the seed on a traverse — 1 for a direct neighbour. + // ListEdges has no seed, so every edge comes back with Hop = 0. + [property: JsonPropertyName("hop")] int Hop = 0); + +/// Whether KG extraction is on, platform-wide and for this project. +public sealed record ExtractionState( + [property: JsonPropertyName("platformEnabled")] bool PlatformEnabled = false, + [property: JsonPropertyName("projectEnabled")] bool ProjectEnabled = false); + +/// Aggregate graph counts. +/// +/// MemoriesWithEdges against your total memory count is the useful ratio: +/// it says how much of what you remember made it into the graph rather than +/// remaining an isolated embedding. A low ratio usually means extraction is off +/// — check Extraction before concluding the corpus simply had no +/// relations in it. +public sealed record GraphStats +{ + [JsonPropertyName("entityCount")] public long EntityCount { get; init; } + [JsonPropertyName("edgeCount")] public long EdgeCount { get; init; } + /// Distinct memories that produced at least one edge. + [JsonPropertyName("memoriesWithEdges")] public long MemoriesWithEdges { get; init; } + [JsonPropertyName("retiredEntities")] public long RetiredEntities { get; init; } + [JsonPropertyName("retiredEdges")] public long RetiredEdges { get; init; } + /// Live entity counts keyed by entity type. + [JsonPropertyName("entitiesByType")] public Dictionary EntitiesByType { get; init; } = new(); + [JsonPropertyName("extraction")] public ExtractionState? Extraction { get; init; } +} + +/// An entity plus its 1-hop neighbourhood. +public sealed record EntityWithEdges +{ + [JsonPropertyName("entity")] public MemoryEntity? Entity { get; init; } + [JsonPropertyName("edges")] public List Edges { get; init; } = new(); +} diff --git a/test/GraphServiceTests.cs b/test/GraphServiceTests.cs new file mode 100644 index 0000000..c3b9e40 --- /dev/null +++ b/test/GraphServiceTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using Xunit; + +namespace MemMesh.Tests; + +/// Route + payload coverage for the knowledge-graph surface, and for +/// the identity provenance raw-text observe now carries. +public class GraphServiceTests +{ + private static MemMeshClient Client(HttpMessageHandler handler) => + new("sk-test", "proj_1", "https://example.test", new HttpClient(handler), maxRetries: 0); + + private static string BodyOf(HttpRequestMessage req) => + req.Content is null ? "" : req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + + private const string StatsJson = + """{"entityCount":12142,"edgeCount":287698,"memoriesWithEdges":184737,"retiredEntities":0,"retiredEdges":2,"entitiesByType":{"concept":5463,"org":3863},"extraction":{"platformEnabled":true,"projectEnabled":false}}"""; + + /// The shape the read routes actually return: hydrated subject/object, plus + /// a hop counter. There is no subjectId on the wire. + private const string EdgeJson = + """[{"id":"g1","subject":{"id":"e1","canonicalName":"NVIDIA CORP","type":"org"},"predicate":"reported_metric","object":{"id":"e2","canonicalName":"Cost of Revenue","type":"concept"},"objectLiteral":null,"weight":0.85,"validFrom":"2026-07-24T19:45:04.420Z","validTo":null,"sourceMemoryId":"m1","hop":0}]"""; + + // ── stats ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Stats_hits_graph_stats_and_parses_counts() + { + HttpRequestMessage? seen = null; + var handler = new StubHandler(r => { seen = r; return StubHandler.Json(HttpStatusCode.OK, StatsJson); }); + using var mm = Client(handler); + + var st = await mm.Graph.StatsAsync(); + + Assert.Equal(12142, st.EntityCount); + Assert.Equal(287698, st.EdgeCount); + Assert.Equal(184737, st.MemoriesWithEdges); + Assert.Equal(5463, st.EntitiesByType["concept"]); + Assert.False(st.Extraction!.ProjectEnabled); + Assert.Contains("/admin/memory/graph/stats", seen!.RequestUri!.ToString()); + } + + // ── entities ─────────────────────────────────────────────────────────── + + [Fact] + public async Task ListEntities_sends_only_set_filters() + { + HttpRequestMessage? seen = null; + var handler = new StubHandler(r => { seen = r; return StubHandler.Json(HttpStatusCode.OK, "[]"); }); + using var mm = Client(handler); + + await mm.Graph.ListEntitiesAsync(search: "Sarah", limit: 5); + + var url = seen!.RequestUri!.ToString(); + Assert.Contains("search=Sarah", url); + Assert.Contains("limit=5", url); + Assert.DoesNotContain("scope=", url); + Assert.DoesNotContain("offset=", url); + } + + [Fact] + public async Task ListEntities_without_filters_sends_no_query() + { + HttpRequestMessage? seen = null; + var handler = new StubHandler(r => { seen = r; return StubHandler.Json(HttpStatusCode.OK, "[]"); }); + using var mm = Client(handler); + + await mm.Graph.ListEntitiesAsync(); + + Assert.EndsWith("/admin/memory/entities", seen!.RequestUri!.ToString()); + } + + [Fact] + public async Task ListEntities_percent_encodes_filter_values() + { + // An unescaped `&` would truncate the filter server-side and quietly + // return the wrong page — a correctness test, not a style one. + HttpRequestMessage? seen = null; + var handler = new StubHandler(r => { seen = r; return StubHandler.Json(HttpStatusCode.OK, "[]"); }); + using var mm = Client(handler); + + await mm.Graph.ListEntitiesAsync(search: "a&b c"); + + // AbsoluteUri, not ToString(): Uri.ToString() un-escapes for display, + // so it would hide whether the escaping happened at all. + Assert.Contains("search=a%26b%20c", seen!.RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task GetEntity_returns_entity_with_hydrated_edges() + { + const string json = + """{"entity":{"id":"e1","canonicalName":"Sarah"},"edges":[{"id":"g1","subject":{"id":"e1","canonicalName":"Sarah"},"predicate":"works_at","object":{"id":"e2","canonicalName":"Acme"},"weight":0.9,"hop":1}]}"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, json)); + using var mm = Client(handler); + + var hood = await mm.Graph.GetEntityAsync("e1"); + + Assert.Equal("Sarah", hood.Entity!.CanonicalName); + Assert.Equal("Acme", hood.Edges[0].Object!.CanonicalName); + Assert.Equal(1, hood.Edges[0].Hop); + } + + // ── edges ────────────────────────────────────────────────────────────── + + [Fact] + public async Task ListEdges_decodes_hydrated_traversal_shape() + { + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, EdgeJson)); + using var mm = Client(handler); + + var edges = await mm.Graph.ListEdgesAsync(limit: 1); + + Assert.Equal("NVIDIA CORP", edges[0].Subject.CanonicalName); + Assert.Equal("Cost of Revenue", edges[0].Object!.CanonicalName); + Assert.Equal("reported_metric", edges[0].Predicate); + Assert.Equal(0, edges[0].Hop); + Assert.Equal(0.85, edges[0].Weight, 3); + } + + [Fact] + public async Task ListEdges_decodes_literal_object() + { + // `object` is null when the value is a literal rather than an entity. + const string json = + """[{"id":"g2","subject":{"id":"e1","canonicalName":"NVIDIA CORP"},"predicate":"ticker_symbol","object":null,"objectLiteral":"NVDA","weight":0.85,"hop":0}]"""; + var handler = new StubHandler(_ => StubHandler.Json(HttpStatusCode.OK, json)); + using var mm = Client(handler); + + var edges = await mm.Graph.ListEdgesAsync(); + + Assert.Null(edges[0].Object); + Assert.Equal("NVDA", edges[0].ObjectLiteral); + } + + // ── traverse ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Traverse_posts_entity_id_and_omits_unset() + { + string body = ""; + HttpRequestMessage? seen = null; + var handler = new StubHandler(r => { seen = r; body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, "[]"); }); + using var mm = Client(handler); + + await mm.Graph.TraverseAsync("e1", hops: 2, predicates: ["member_of", "led_by"]); + + Assert.Equal(HttpMethod.Post, seen!.Method); + Assert.Contains("/admin/memory/graph/traverse", seen.RequestUri!.ToString()); + using var doc = JsonDocument.Parse(body); + Assert.Equal("e1", doc.RootElement.GetProperty("entityId").GetString()); + Assert.Equal(2, doc.RootElement.GetProperty("hops").GetInt32()); + Assert.Equal("member_of", doc.RootElement.GetProperty("predicates")[0].GetString()); + Assert.False(doc.RootElement.TryGetProperty("asOf", out _)); + } + + // ── observe provenance ───────────────────────────────────────────────── + + [Fact] + public async Task Observe_text_forwards_identity_fields() + { + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, """{"saved":[],"candidateCount":0}"""); }); + using var mm = Client(handler); + + await mm.Memory.ObserveAsync(text: "I just moved to Denver.", + userId: "user-123", agentId: "agent-9", sessionId: "thread-456"); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("user-123", doc.RootElement.GetProperty("userId").GetString()); + Assert.Equal("agent-9", doc.RootElement.GetProperty("agentId").GetString()); + Assert.Equal("thread-456", doc.RootElement.GetProperty("sessionId").GetString()); + } + + [Fact] + public async Task Observe_text_omits_identity_when_unset() + { + // An older call site must produce the request it always did — the + // fields are absent, not explicit nulls. + string body = ""; + var handler = new StubHandler(r => { body = BodyOf(r); return StubHandler.Json(HttpStatusCode.OK, """{"saved":[],"candidateCount":0}"""); }); + using var mm = Client(handler); + + await mm.Memory.ObserveAsync(text: "hello"); + + using var doc = JsonDocument.Parse(body); + Assert.False(doc.RootElement.TryGetProperty("userId", out _)); + Assert.False(doc.RootElement.TryGetProperty("agentId", out _)); + Assert.False(doc.RootElement.TryGetProperty("sessionId", out _)); + } +}