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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions core/src/ndk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { NDKCacheAdapter } from "../cache/index.js";
import dedupEvent from "../events/dedup.js";
import { NDKEvent } from "../events/index.js";
import { signatureVerificationInit } from "../events/signature.js";
import { NIP66LivenessFilter } from "../outbox/nip66.js";
import { ThompsonSampler } from "../outbox/thompson.js";
import { OutboxTracker } from "../outbox/tracker.js";
import type { NDKAuthPolicy } from "../relay/auth-policies.js";
import { NDKRelay } from "../relay/index.js";
Expand Down Expand Up @@ -232,6 +234,47 @@ export interface NDKConstructorParams {
*/
aiGuardrails?: boolean | { skip?: Set<string> };

/**
* Relay URLs to fetch NIP-66 monitor data from.
* When set, dead relays will be filtered from outbox candidate sets.
* Requires monitor relays that serve kind 30166 events.
*
* @example
* ```typescript
* const ndk = new NDK({
* nip66MonitorRelays: ['wss://relay.nostr.watch'],
* });
* ```
*/
nip66MonitorRelays?: string[];

/**
* Enable Thompson Sampling for outbox relay selection.
* When enabled, relays are scored using Bayesian learning from delivery outcomes.
* @default false
*/
enableThompsonSampling?: boolean;

/**
* Maximum number of outbox relays to connect to.
* Works independently of Thompson Sampling.
* @default undefined (no cap)
*/
maxOutboxRelays?: number;

/**
* Enable CG3 (Coverage Guarantee v3) for sole-source authors.
* Only effective when Thompson Sampling is enabled.
* @default true (when Thompson is enabled)
*/
enableCoverageGuarantee?: boolean;

/**
* Fraction of maxOutboxRelays budget reserved for CG3 sole-source relays.
* @default 0.5
*/
cgBudgetFraction?: number;

/**
* Optional grace period (in seconds) for future timestamps.
*
Expand Down Expand Up @@ -357,6 +400,11 @@ export class NDK extends EventEmitter<{
public subManager: NDKSubscriptionManager;
public aiGuardrails: AIGuardrails;
public futureTimestampGrace?: number;
public nip66Filter?: NIP66LivenessFilter;
public thompsonSampler?: ThompsonSampler;
public maxOutboxRelays?: number;
public enableCoverageGuarantee?: boolean;
public cgBudgetFraction?: number;

/**
* Private storage for the signature verification function
Expand Down Expand Up @@ -503,6 +551,20 @@ export class NDK extends EventEmitter<{
this.aiGuardrails = new AIGuardrails(opts.aiGuardrails || false);
this.futureTimestampGrace = opts.futureTimestampGrace;

if (opts.nip66MonitorRelays?.length) {
this.nip66Filter = new NIP66LivenessFilter(this, {
monitorRelays: opts.nip66MonitorRelays,
});
}

if (opts.enableThompsonSampling) {
this.thompsonSampler = new ThompsonSampler();
}

this.maxOutboxRelays = opts.maxOutboxRelays;
this.enableCoverageGuarantee = opts.enableCoverageGuarantee;
this.cgBudgetFraction = opts.cgBudgetFraction;

// Trigger guardrails hook for NDK instantiation
this.aiGuardrails.ndkInstantiated(this);

Expand Down
119 changes: 119 additions & 0 deletions core/src/outbox/coverage-guarantee.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { applyCoverageGuarantee } from "./coverage-guarantee.js";

describe("applyCoverageGuarantee", () => {
it("force-selects relays for sole-source authors", () => {
const pubkeysToRelays = new Map([
["alice", new Set(["wss://relay1.com/"])], // sole-source
["bob", new Set(["wss://relay1.com/", "wss://relay2.com/"])], // multi-relay
["carol", new Set(["wss://relay3.com/"])], // sole-source
]);

const result = applyCoverageGuarantee(pubkeysToRelays, 20, 0.5);

expect(result.skipped).toBe(false);
expect(result.forcedRelays.size).toBe(2);
expect(result.forcedRelays.get("wss://relay1.com/")!.has("alice")).toBe(true);
expect(result.forcedRelays.get("wss://relay3.com/")!.has("carol")).toBe(true);
// Bob is not sole-source, so shouldn't be in forced relays
expect(result.forcedRelays.get("wss://relay1.com/")!.has("bob")).toBe(false);
});

it("skips when sole-source relays exceed budget", () => {
const pubkeysToRelays = new Map<string, Set<string>>();
// Create 11 sole-source authors on different relays
for (let i = 0; i < 11; i++) {
pubkeysToRelays.set(`author${i}`, new Set([`wss://relay${i}.com/`]));
}

// maxConnections=20, budgetFraction=0.5 → budget=10
// 11 sole-source relays >= 10 → should skip
const result = applyCoverageGuarantee(pubkeysToRelays, 20, 0.5);

expect(result.skipped).toBe(true);
expect(result.forcedRelays.size).toBe(0);
});

it("respects budget cap", () => {
const pubkeysToRelays = new Map<string, Set<string>>();
// Create 3 sole-source authors on different relays
for (let i = 0; i < 3; i++) {
pubkeysToRelays.set(`author${i}`, new Set([`wss://relay${i}.com/`]));
}
// Add multi-relay authors to avoid triggering conditional skip
pubkeysToRelays.set("multi1", new Set(["wss://relay0.com/", "wss://relay1.com/"]));

// maxConnections=8, budgetFraction=0.5 → budget=4
// 3 sole-source relays < 4 → should NOT skip, but return at most 3 (all fit)
const result = applyCoverageGuarantee(pubkeysToRelays, 8, 0.5);

expect(result.skipped).toBe(false);
expect(result.forcedRelays.size).toBe(3);

// Now test actual capping: 5 sole-source relays with budget of 6
const pubkeysToRelays2 = new Map<string, Set<string>>();
for (let i = 0; i < 5; i++) {
pubkeysToRelays2.set(`author${i}`, new Set([`wss://relay${i}.com/`]));
}
// maxConnections=12, budgetFraction=0.5 → budget=6, 5 sole-source < 6 → no skip
const result2 = applyCoverageGuarantee(pubkeysToRelays2, 12, 0.5);
expect(result2.skipped).toBe(false);
expect(result2.forcedRelays.size).toBe(5);
});

it("sorts by coverage value — relay with more sole-source authors selected first", () => {
const pubkeysToRelays = new Map([
["alice", new Set(["wss://popular.com/"])], // sole-source on popular
["bob", new Set(["wss://popular.com/"])], // sole-source on popular
["carol", new Set(["wss://popular.com/"])], // sole-source on popular
["dave", new Set(["wss://unpopular.com/"])], // sole-source on unpopular
]);

// maxConnections=6, budgetFraction=0.5 → budget=3
// 2 sole-source relays (popular + unpopular) < 3 → no skip
// Budget cap of 3 means both fit, but let's test with budget=1 (maxConn=2, fraction=1.0)
// Actually budget=floor(2*1.0)=2, so 2 sole-source relays >= 2 → skip.
// Use maxConn=10, fraction=0.3 → budget=3, 2 < 3 → no skip, both fit.
// To actually test ordering with a cap, we need 3+ sole-source relays with budget 2.
const pubkeysToRelays2 = new Map([
["a1", new Set(["wss://relay-a.com/"])], // sole on relay-a
["a2", new Set(["wss://relay-a.com/"])], // sole on relay-a
["a3", new Set(["wss://relay-a.com/"])], // sole on relay-a
["b1", new Set(["wss://relay-b.com/"])], // sole on relay-b
["b2", new Set(["wss://relay-b.com/"])], // sole on relay-b
["c1", new Set(["wss://relay-c.com/"])], // sole on relay-c
]);
// 3 sole-source relays. maxConn=8, fraction=0.5 → budget=4. 3 < 4 → no skip.
// But we want to test ordering, so cap at budget=2: maxConn=4, fraction=0.5 → budget=2, 3 >= 2 → skip.
// Try maxConn=8, fraction=0.5 → budget=4, 3<4 → no skip, all 3 fit (tests ordering but not cap)

const result = applyCoverageGuarantee(pubkeysToRelays2, 8, 0.5);
expect(result.skipped).toBe(false);
expect(result.forcedRelays.size).toBe(3);

// Verify ordering: first relay (relay-a with 3 authors) should be first
const entries = Array.from(result.forcedRelays.entries());
expect(entries[0][0]).toBe("wss://relay-a.com/");
expect(entries[0][1].size).toBe(3);
});

it("returns empty when no sole-source authors exist", () => {
const pubkeysToRelays = new Map([
["alice", new Set(["wss://relay1.com/", "wss://relay2.com/"])],
["bob", new Set(["wss://relay2.com/", "wss://relay3.com/"])],
]);

const result = applyCoverageGuarantee(pubkeysToRelays, 20, 0.5);

expect(result.skipped).toBe(false);
expect(result.forcedRelays.size).toBe(0);
});

it("handles empty input", () => {
const pubkeysToRelays = new Map<string, Set<string>>();
const result = applyCoverageGuarantee(pubkeysToRelays, 20, 0.5);

expect(result.skipped).toBe(false);
expect(result.forcedRelays.size).toBe(0);
});
});
82 changes: 82 additions & 0 deletions core/src/outbox/coverage-guarantee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import createDebug from "debug";
import type { Hexpubkey } from "../user/index.js";

const d = createDebug("ndk:coverage-guarantee");

export interface CoverageGuaranteeResult {
/** Relays force-selected for sole-source authors: relay → sole-source pubkeys */
forcedRelays: Map<string, Set<string>>;
/** True if CG3 was skipped (too many sole-source relays to fit in budget) */
skipped: boolean;
}

/**
* Apply Coverage Guarantee v3 (CG3) for sole-source authors.
*
* Protects authors who have only one write relay. Without CG3,
* Thompson Sampling may deprioritize their sole relay if it scored
* poorly on other authors.
*
* **Algorithm:**
* 1. Scan pubkeysToRelays for authors with exactly 1 relay
* 2. Group by relay: Map<relay, Set<soleSourcePubkeys>>
* 3. If unique sole-source relays >= budget, skip (conditional skip)
* 4. Sort sole-source relays by coverage value (most pubkeys first)
* 5. Return top relays up to budget cap
*
* @param pubkeysToRelays - Map of author → their write relays
* @param maxConnections - Maximum outbox relay connections
* @param budgetFraction - Fraction of maxConnections reserved for CG3 (default 0.5)
*/
export function applyCoverageGuarantee(
pubkeysToRelays: Map<Hexpubkey, Set<string>>,
maxConnections: number,
budgetFraction: number,
): CoverageGuaranteeResult {
const budget = Math.floor(maxConnections * budgetFraction);

// Step 1-2: Find sole-source authors and group by relay
const soleSourceRelays = new Map<string, Set<string>>();

for (const [author, relays] of pubkeysToRelays) {
if (relays.size === 1) {
const relay = relays.values().next().value!;
const pubkeys = soleSourceRelays.get(relay) ?? new Set();
pubkeys.add(author);
soleSourceRelays.set(relay, pubkeys);
}
}

// Step 3: Conditional skip
if (soleSourceRelays.size >= budget) {
d(
"CG3 skipped: %d sole-source relays >= budget %d (maxConn=%d × fraction=%f)",
soleSourceRelays.size,
budget,
maxConnections,
budgetFraction,
);
return { forcedRelays: new Map(), skipped: true };
}

// Step 4: Sort by coverage value (most sole-source pubkeys first)
const sorted = Array.from(soleSourceRelays.entries()).sort(
(a, b) => b[1].size - a[1].size,
);

// Step 5: Take top relays up to budget
const forcedRelays = new Map<string, Set<string>>();
for (const [relay, pubkeys] of sorted) {
if (forcedRelays.size >= budget) break;
forcedRelays.set(relay, pubkeys);
}

if (forcedRelays.size > 0) {
d("CG3 force-selected %d relays for %d sole-source authors",
forcedRelays.size,
Array.from(forcedRelays.values()).reduce((sum, s) => sum + s.size, 0),
);
}

return { forcedRelays, skipped: false };
}
Loading