UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

281 lines (280 loc) • 11.5 kB
/** * Trust-but-verify for complete-mode shares. * * A complete-mode borrower calls the provider directly, so the lender never sees * its requests and its reported spend is, strictly, an assertion. This module is * what stops that assertion from being the only evidence. * * **The signal.** The provider reports the account's own utilization, and the * borrower cannot influence that number. If an account's window moved between * two heartbeats, someone spent it. If the lender's own node served nothing on * that account in the same interval, and the borrower reported nothing either, * then something consumed the account that neither party is accounting for — * which is exactly the shape of a borrower that stopped reporting. * * **Why it is conservative.** The check only fires when the lender's own traffic * on that account was zero for the interval. A busy lender moves the same * numbers, and a false accusation is worse than a missed one: the remedy here is * to pause someone's access. Everything ambiguous is reported as `attributable` * and left alone. * * This detects a borrower that under-reports. It does not, and cannot, detect * one that reports honestly and simply spends what it was lent. It is also blind * across a window reset: utilization that falls between two check-ins reads as * no movement, so spend timed around a rollover goes unremarked. Closing that * would mean trusting a reset timestamp the borrower also influences, which is * a worse trade than the gap. * * @module proxy/shareAudit */ import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { AsyncMutex } from "../utils/asyncMutex.js"; import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js"; const AUDIT_FILE = "proxy-share-audit.json"; /** * How much of a window may move unexplained before it counts as drift. * * Utilization is reported coarsely and can tick from rounding or from a request * that was in flight across the boundary, so a hair-trigger would cry wolf. */ export const DRIFT_TOLERANCE_PCT = 2; /** Consecutive drifting heartbeats tolerated before the grant is paused. */ export const DRIFT_STREAK_LIMIT = 3; /** * How long a read may trust the cache before it stats the file again. * * The CLI reads and clears this trail from a separate process (`share audit`, * `share resume`), so a load-once cache would let the proxy keep counting a * drift streak an operator already cleared. */ const RELOAD_TTL_MS = 1_000; let customAuditFilePath = null; let cache = {}; let cacheLoadedAt = 0; let cacheMtimeMs = -1; let cacheValid = false; const mutationMutex = new AsyncMutex(); export function initShareAudit(auditFilePath) { customAuditFilePath = auditFilePath; cache = {}; cacheLoadedAt = 0; cacheMtimeMs = -1; cacheValid = false; } function getAuditFilePath() { return customAuditFilePath ?? join(homedir(), ".neurolink", AUDIT_FILE); } /** * Is this error simply "the file is not there yet"? * * The distinction is load-bearing. An absent file genuinely is an empty map — * nothing has been written yet. Every *other* `stat`/read failure (`EACCES`, * `EIO`, `EMFILE`, a full descriptor table) is a failure to observe the file, * and answering one with an empty map is how a whole store gets erased: a * caller passing `force` is about to `persist()` the map back over the real * contents it just failed to read. */ function isMissingFileError(error) { return error?.code === "ENOENT"; } async function ensureLoaded(options = {}) { const now = Date.now(); if (!options.force && cacheValid && now - cacheLoadedAt < RELOAD_TTL_MS) { return; } const path = getAuditFilePath(); let mtimeMs; try { mtimeMs = (await stat(path)).mtimeMs; } catch (error) { if (!isMissingFileError(error)) { // Not "no file" but "could not look" — see `isMissingFileError`. Let it // out: a mutation must abort rather than persist an empty map over a // store it never managed to read. throw error; } cache = {}; cacheMtimeMs = -1; cacheLoadedAt = now; cacheValid = true; return; } // A forced load skips this. mtime is the fast path for a read, not a // correctness check for a write: several filesystems stamp it at one-second // granularity, so a write landing in the same second as our last read is // indistinguishable from no write at all — and every caller passing `force` // is about to persist the whole map back over whatever it missed. if (!options.force && cacheValid && mtimeMs === cacheMtimeMs) { cacheLoadedAt = now; return; } try { const parsed = JSON.parse(await readFile(path, "utf8")); cache = parsed?.records ?? {}; } catch (error) { if (options.force) { // A mutation is about to write the whole map back; treating a corrupt // file as empty here would make that write finish the corruption off. // Abort instead and leave the file for a human. Reads stay tolerant. throw error; } cache = {}; } cacheMtimeMs = mtimeMs; cacheLoadedAt = now; cacheValid = true; } async function persist() { const file = { schemaVersion: 1, records: cache }; await writeJsonSnapshotAtomically(getAuditFilePath(), file); try { cacheMtimeMs = (await stat(getAuditFilePath())).mtimeMs; } catch { cacheMtimeMs = -1; } cacheLoadedAt = Date.now(); cacheValid = true; } /** Utilization movement between two observations, in whole percent. */ function movementPct(before, after) { if (before === null || after === null) { return 0; } return Math.max(0, (after - before) * 100); } /** * Compare one heartbeat against the account's real movement. * * Pure: the caller supplies the previous observation and the current one, so the * decision is inspectable and testable without any I/O. */ export function evaluateDrift(previous, current, tolerancePct = DRIFT_TOLERANCE_PCT) { if (!previous) { return { drifted: false, reason: "no_baseline" }; } // The lender's own traffic moves the same windows. With no way to split the // two apart, an interval the lender also used is not evidence of anything. if (current.lenderRequests > 0) { return { drifted: false, reason: "attributable" }; } const sessionMoved = movementPct(previous.sessionUsed, current.sessionUsed); const weeklyMoved = movementPct(previous.weeklyUsed, current.weeklyUsed); const moved = Math.max(sessionMoved, weeklyMoved); if (moved <= tolerancePct) { return { drifted: false, reason: "quiet" }; } if (current.reportedCoins > 0) { // The borrower owned up to spending. Whether the amount is exactly right is // not knowable from utilization alone, and guessing would invent precision. return { drifted: false, reason: "attributable" }; } return { drifted: true, unexplainedSessionPct: sessionMoved, unexplainedWeeklyPct: weeklyMoved, detail: `the account moved ${moved.toFixed(1)}% of a window since the last check-in ` + `while this node served none of it and the borrower reported no spend`, }; } export async function getAuditRecord(grantId) { await ensureLoaded(); return cache[grantId]; } export async function listAuditRecords() { await ensureLoaded(); return Object.values(cache); } /** * Fold a heartbeat into the audit trail. * * Returns whether the grant has now drifted past tolerance often enough to be * paused — the caller owns that decision, because pausing is a policy action and * this module only supplies evidence. */ export async function recordAuditObservation(args) { const limit = args.streakLimit ?? DRIFT_STREAK_LIMIT; return mutationMutex.runExclusive(async () => { // Force: `persist()` writes the whole map back, so a mutation that ran on a // TTL-fresh snapshot would resurrect a record the CLI cleared in the window // since this process last read the file. await ensureLoaded({ force: true }); const existing = cache[args.grantId]; // Difference the lifetime counter here so callers can hand over the raw // total; an observation always carries the per-interval delta. const observation = args.lenderRequestsTotal === undefined ? args.observation : { ...args.observation, lenderRequests: Math.max(0, args.lenderRequestsTotal - (existing?.lenderRequestsTotal ?? 0)), }; const verdict = evaluateDrift(existing?.lastObservation, observation, args.tolerancePct); const streak = verdict.drifted ? (existing?.driftStreak ?? 0) + 1 : 0; const record = { grantId: args.grantId, accountLabel: args.accountLabel, lastObservation: observation, ...(args.lenderRequestsTotal !== undefined ? { lenderRequestsTotal: args.lenderRequestsTotal } : existing?.lenderRequestsTotal !== undefined ? { lenderRequestsTotal: existing.lenderRequestsTotal } : {}), driftStreak: streak, ...(verdict.drifted ? { lastDriftAt: observation.at, lastDriftDetail: verdict.detail } : { ...(existing?.lastDriftAt !== undefined ? { lastDriftAt: existing.lastDriftAt } : {}), ...(existing?.lastDriftDetail !== undefined ? { lastDriftDetail: existing.lastDriftDetail } : {}), }), ...(existing?.autoPausedAt !== undefined ? { autoPausedAt: existing.autoPausedAt } : {}), }; const shouldPause = streak >= limit && record.autoPausedAt === undefined; if (shouldPause) { record.autoPausedAt = observation.at; } cache[args.grantId] = record; await persist(); return { verdict, shouldPause }; }); } /** * Clear a grant's drift streak and its auto-pause marker. * * Called when the lender resumes a grant. Without it the marker is permanent * and `shouldPause` can never fire again, so a grant auto-paused once would * drift freely for the rest of its life. The observation baseline is kept: it * is the account's real utilization and is still the right thing to difference * the next heartbeat against. */ export async function clearAuditDrift(grantId) { await mutationMutex.runExclusive(async () => { await ensureLoaded({ force: true }); const record = cache[grantId]; if (!record) { return; } const { autoPausedAt: _autoPausedAt, ...rest } = record; cache[grantId] = { ...rest, driftStreak: 0 }; await persist(); }); } /** Forget a grant's audit trail — used when the grant itself is deleted. */ export async function clearAuditRecord(grantId) { await mutationMutex.runExclusive(async () => { await ensureLoaded({ force: true }); if (cache[grantId]) { delete cache[grantId]; await persist(); } }); }