From 487dd20da0be22cc6b60e59ff25e81717a391cb5 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:04:12 -0500 Subject: [PATCH] feat(kernel-exo): add sheaf programming module Introduce operational presheaf + sheafify for guard-based dispatch: - Section/guard types, presheaf construction, stalk filtering - Late decider selects winner when multiple sections match - Modular sheaf/ directory with single-concern files and e2e tests Co-Authored-By: Claude Opus 4.6 --- packages/kernel-exo/package.json | 1 + packages/kernel-exo/src/index.ts | 8 + packages/kernel-exo/src/sheaf/guard.test.ts | 117 +++++++ packages/kernel-exo/src/sheaf/guard.ts | 103 ++++++ .../kernel-exo/src/sheaf/presheaf.test.ts | 114 +++++++ packages/kernel-exo/src/sheaf/presheaf.ts | 56 ++++ .../kernel-exo/src/sheaf/sheafify.e2e.test.ts | 298 ++++++++++++++++++ .../kernel-exo/src/sheaf/sheafify.test.ts | 249 +++++++++++++++ packages/kernel-exo/src/sheaf/sheafify.ts | 75 +++++ packages/kernel-exo/src/sheaf/stalk.test.ts | 130 ++++++++ packages/kernel-exo/src/sheaf/stalk.ts | 54 ++++ packages/kernel-exo/src/sheaf/types.ts | 69 ++++ yarn.lock | 1 + 13 files changed, 1275 insertions(+) create mode 100644 packages/kernel-exo/src/sheaf/guard.test.ts create mode 100644 packages/kernel-exo/src/sheaf/guard.ts create mode 100644 packages/kernel-exo/src/sheaf/presheaf.test.ts create mode 100644 packages/kernel-exo/src/sheaf/presheaf.ts create mode 100644 packages/kernel-exo/src/sheaf/sheafify.e2e.test.ts create mode 100644 packages/kernel-exo/src/sheaf/sheafify.test.ts create mode 100644 packages/kernel-exo/src/sheaf/sheafify.ts create mode 100644 packages/kernel-exo/src/sheaf/stalk.test.ts create mode 100644 packages/kernel-exo/src/sheaf/stalk.ts create mode 100644 packages/kernel-exo/src/sheaf/types.ts diff --git a/packages/kernel-exo/package.json b/packages/kernel-exo/package.json index afc2bbc51..6803f95ff 100644 --- a/packages/kernel-exo/package.json +++ b/packages/kernel-exo/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", + "@endo/eventual-send": "^1.3.4", "@metamask/auto-changelog": "^5.3.0", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", diff --git a/packages/kernel-exo/src/index.ts b/packages/kernel-exo/src/index.ts index 7ccc9c9c9..5c0a75460 100644 --- a/packages/kernel-exo/src/index.ts +++ b/packages/kernel-exo/src/index.ts @@ -2,3 +2,11 @@ export { makeDefaultInterface, makeDefaultExo } from './exo.ts'; export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; export type { JsonSchema, MethodSchema } from './schema.ts'; + +// Sheaf types +export type { Section, Germ, Lift, Presheaf } from './sheaf/types.ts'; + +// Sheaf functions +export { makePresheaf } from './sheaf/presheaf.ts'; +export { sheafify } from './sheaf/sheafify.ts'; +export { collectSheafGuard } from './sheaf/guard.ts'; diff --git a/packages/kernel-exo/src/sheaf/guard.test.ts b/packages/kernel-exo/src/sheaf/guard.test.ts new file mode 100644 index 000000000..3a4e5762e --- /dev/null +++ b/packages/kernel-exo/src/sheaf/guard.test.ts @@ -0,0 +1,117 @@ +import { makeExo } from '@endo/exo'; +import { + M, + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { MethodGuard, Pattern } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { collectSheafGuard } from './guard.ts'; +import type { Section } from './types.ts'; + +const makeSection = ( + tag: string, + guards: Record, + methods: Record unknown>, +): Section => { + const interfaceGuard = M.interface(tag, guards); + return makeExo(tag, interfaceGuard, methods) as unknown as Section; +}; + +describe('collectSheafGuard', () => { + it('variable arity: add with 1, 2, and 3 args', () => { + const sections = [ + makeSection( + 'Calc:0', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + ), + makeSection( + 'Calc:1', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + ), + makeSection( + 'Calc:2', + { + add: M.call(M.number(), M.number(), M.number()).returns(M.number()), + }, + { add: (a: number, b: number, cc: number) => a + b + cc }, + ), + ]; + + const guard = collectSheafGuard('Calc', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const payload = getMethodGuardPayload(methodGuards.add) as unknown as { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + }; + + // 1 required arg (present in all), 2 optional (variable arity) + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(2); + }); + + it('return guard union', () => { + const sections = [ + makeSection( + 'S:0', + { f: M.call(M.eq(0)).returns(M.eq(0)) }, + { f: (_: number) => 0 }, + ), + makeSection( + 'S:1', + { f: M.call(M.eq(1)).returns(M.eq(1)) }, + { f: (_: number) => 1 }, + ), + ]; + + const guard = collectSheafGuard('S', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const { returnGuard } = getMethodGuardPayload( + methodGuards.f, + ) as unknown as { returnGuard: Pattern }; + + // Return guard is union of eq(0) and eq(1) + expect(matches(0, returnGuard)).toBe(true); + expect(matches(1, returnGuard)).toBe(true); + }); + + it('multi-method guard collection', () => { + const sections = [ + makeSection( + 'Multi:0', + { + translate: M.call(M.string(), M.string()).returns(M.string()), + }, + { + translate: (from: string, to: string) => `${from}->${to}`, + }, + ), + makeSection( + 'Multi:1', + { + translate: M.call(M.string(), M.string()).returns(M.string()), + summarize: M.call(M.string()).returns(M.string()), + }, + { + translate: (from: string, to: string) => `${from}->${to}`, + summarize: (text: string) => `summary: ${text}`, + }, + ), + ]; + + const guard = collectSheafGuard('Multi', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + expect('translate' in methodGuards).toBe(true); + expect('summarize' in methodGuards).toBe(true); + }); +}); diff --git a/packages/kernel-exo/src/sheaf/guard.ts b/packages/kernel-exo/src/sheaf/guard.ts new file mode 100644 index 000000000..9ce7e4483 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/guard.ts @@ -0,0 +1,103 @@ +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import type { Methods } from '@endo/exo'; +import { + M, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; + +import type { Section } from './types.ts'; + +type MethodGuardPayload = { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + returnGuard: Pattern; +}; + +/** + * Naive union of guards via M.or — no pattern canonicalization. + * + * @param guards - Guards to union. + * @returns A single guard representing the union. + */ +const unionGuard = (guards: Pattern[]): Pattern => { + if (guards.length === 1) { + const [first] = guards; + return first; + } + return M.or(...guards); +}; + +/** + * Compute the known space K = union of section guards. + * + * For each method name across all sections, collects the arg guards at each + * position and produces a union via M.or. Sections with fewer args than + * the maximum contribute to required args; the remainder become optional. + * + * @param name - The name for the collected interface guard. + * @param sections - The sections whose guards are collected. + * @returns An interface guard covering all sections. + */ +export const collectSheafGuard = ( + name: string, + sections: Section[], +): InterfaceGuard => { + const payloadsByMethod = new Map(); + + for (const section of sections) { + const interfaceGuard = section[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + continue; + } + const { methodGuards } = getInterfaceGuardPayload( + interfaceGuard, + ) as unknown as { methodGuards: Record }; + for (const [methodName, methodGuard] of Object.entries(methodGuards)) { + const payload = getMethodGuardPayload( + methodGuard, + ) as unknown as MethodGuardPayload; + if (!payloadsByMethod.has(methodName)) { + payloadsByMethod.set(methodName, []); + } + const existing = payloadsByMethod.get(methodName); + existing?.push(payload); + } + } + + const unionMethodGuards: Record = {}; + for (const [methodName, payloads] of payloadsByMethod) { + const arities = payloads.map((payload) => payload.argGuards.length); + const minArity = Math.min(...arities); + const maxArity = Math.max(...arities); + + const requiredArgGuards = []; + for (let idx = 0; idx < minArity; idx++) { + requiredArgGuards.push( + unionGuard(payloads.map((payload) => payload.argGuards[idx])), + ); + } + + const optionalArgGuards = []; + for (let idx = minArity; idx < maxArity; idx++) { + const guards = payloads + .filter((payload) => idx < payload.argGuards.length) + .map((payload) => payload.argGuards[idx]); + optionalArgGuards.push(unionGuard(guards)); + } + + const returnGuard = unionGuard( + payloads.map((payload) => payload.returnGuard), + ); + + unionMethodGuards[methodName] = + optionalArgGuards.length > 0 + ? M.callWhen(...requiredArgGuards) + .optional(...optionalArgGuards) + .returns(returnGuard) + : M.callWhen(...requiredArgGuards).returns(returnGuard); + } + + return M.interface(name, unionMethodGuards); +}; diff --git a/packages/kernel-exo/src/sheaf/presheaf.test.ts b/packages/kernel-exo/src/sheaf/presheaf.test.ts new file mode 100644 index 000000000..e6281828f --- /dev/null +++ b/packages/kernel-exo/src/sheaf/presheaf.test.ts @@ -0,0 +1,114 @@ +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { makePresheaf } from './presheaf.ts'; + +// --------------------------------------------------------------------------- +// Unit: makePresheaf +// --------------------------------------------------------------------------- + +describe('makePresheaf', () => { + it('addSection stores operational metadata alongside section', () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 10 }, + ); + + expect(presheaf.entries).toHaveLength(1); + expect(presheaf.sections).toHaveLength(1); + expect(presheaf.entries[0]!.operational).toStrictEqual({ cost: 10 }); + }); + + it('chaining returns the same presheaf', () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + const ret = presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 10 }, + ); + expect(ret).toBe(presheaf); + }); + + it('parallel arrays stay in sync', () => { + const presheaf = makePresheaf<{ tag: string }>('Multi'); + presheaf.addSection( + { f: M.call(M.number()).returns(M.number()) }, + { f: (val: number) => val }, + { tag: 'first' }, + ); + presheaf.addSection( + { f: M.call(M.number()).returns(M.number()) }, + { f: (val: number) => val * 2 }, + { tag: 'second' }, + ); + + expect(presheaf.entries).toHaveLength(2); + expect(presheaf.sections).toHaveLength(2); + // entries[i].section === sections[i] + expect(presheaf.entries[0]!.section).toBe(presheaf.sections[0]); + expect(presheaf.entries[1]!.section).toBe(presheaf.sections[1]); + expect(presheaf.entries[0]!.operational.tag).toBe('first'); + expect(presheaf.entries[1]!.operational.tag).toBe('second'); + }); +}); + +// --------------------------------------------------------------------------- +// Unit: makePresheaf addExo +// --------------------------------------------------------------------------- + +describe('makePresheaf addExo', () => { + it('stores exo + operational metadata', () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ); + presheaf.addExo(exo, { cost: 10 }); + + expect(presheaf.entries).toHaveLength(1); + expect(presheaf.sections).toHaveLength(1); + expect(presheaf.entries[0]!.operational).toStrictEqual({ cost: 10 }); + expect(presheaf.entries[0]!.section).toBe(presheaf.sections[0]); + }); + + it('chaining returns the same presheaf', () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ); + const ret = presheaf.addExo(exo, { cost: 10 }); + expect(ret).toBe(presheaf); + }); + + it('mixing addSection and addExo keeps arrays in sync', () => { + const presheaf = makePresheaf<{ tag: string }>('Multi'); + presheaf.addSection( + { f: M.call(M.number()).returns(M.number()) }, + { f: (val: number) => val }, + { tag: 'section' }, + ); + const exo = makeExo( + 'g', + M.interface('g', { g: M.call(M.number()).returns(M.number()) }), + { g: (val: number) => val * 2 }, + ); + presheaf.addExo(exo, { tag: 'exo' }); + + expect(presheaf.entries).toHaveLength(2); + expect(presheaf.sections).toHaveLength(2); + expect(presheaf.entries[0]!.section).toBe(presheaf.sections[0]); + expect(presheaf.entries[1]!.section).toBe(presheaf.sections[1]); + expect(presheaf.entries[0]!.operational.tag).toBe('section'); + expect(presheaf.entries[1]!.operational.tag).toBe('exo'); + }); +}); diff --git a/packages/kernel-exo/src/sheaf/presheaf.ts b/packages/kernel-exo/src/sheaf/presheaf.ts new file mode 100644 index 000000000..233a42f94 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/presheaf.ts @@ -0,0 +1,56 @@ +/** + * Operational presheaf construction. + * + * `makePresheaf(name)` returns a presheaf whose sections are decorated + * with operational metadata. Each `addSection(guards, handlers, operational)` + * creates an exo and stores `{ section, operational }` in a parallel entries array. + */ + +import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; +import type { Methods } from '@endo/exo'; +import { M } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +import type { Section, Presheaf, Germ } from './types.ts'; + +export const makePresheaf = < + Operational, + Core extends Methods = Record, +>( + name: string, +): Presheaf => { + const entries: Germ[] = []; + const sections: Section[] = []; + + const presheaf: Presheaf = { + name, + entries, + sections, + addSection( + guards: { [K in keyof Interface]: MethodGuard }, + handlers: Interface, + operational: Operational, + ) { + const tag = `${name}:${sections.length}`; + const interfaceGuard = M.interface(tag, guards); + const exo = makeExo(tag, interfaceGuard, handlers); + const section = exo as unknown as Section; + sections.push(section); + entries.push({ section, operational }); + return presheaf as unknown as Presheaf; + }, + addExo( + exo: Interface & { + [K in typeof GET_INTERFACE_GUARD]: () => InterfaceGuard; + }, + operational: Operational, + ) { + const section = exo as unknown as Section; + sections.push(section); + entries.push({ section, operational }); + return presheaf as unknown as Presheaf; + }, + }; + + return presheaf; +}; diff --git a/packages/kernel-exo/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-exo/src/sheaf/sheafify.e2e.test.ts new file mode 100644 index 000000000..131faea86 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/sheafify.e2e.test.ts @@ -0,0 +1,298 @@ +import { E } from '@endo/eventual-send'; +import { M } from '@endo/patterns'; +import { describe, it, expect, vi } from 'vitest'; + +import { makePresheaf } from './presheaf.ts'; +import { sheafify } from './sheafify.ts'; +import type { Lift } from './types.ts'; + +vi.mock('@endo/eventual-send', () => ({ + E: vi.fn((obj) => obj), +})); + +// --------------------------------------------------------------------------- +// E2E: cost-optimal routing +// --------------------------------------------------------------------------- + +describe('e2e: cost-optimal routing', () => { + it('argmin picks cheapest section, re-sheafification expands landscape', async () => { + const argmin: Lift<{ cost: number }> = (_m, _a, stalk) => + stalk.reduce((best, entry) => + entry.operational.cost < best.operational.cost ? entry : best, + ); + + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + + // Remote: covers all accounts, expensive + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (acct: string) => (acct === 'alice' ? 1000 : 500) }, + { cost: 100 }, + ); + + // Local cache: covers only 'alice', cheap + presheaf.addSection( + { getBalance: M.call(M.eq('alice')).returns(M.number()) }, + { getBalance: (_acct: string) => 1000 }, + { cost: 1 }, + ); + + let wallet = sheafify(presheaf, argmin); + + // alice: both sections match, argmin picks local (cost=1) + expect(await E(wallet).getBalance('alice')).toBe(1000); + + // bob: only remote matches (stalk=1, lift not invoked) + expect(await E(wallet).getBalance('bob')).toBe(500); + + // Expand the presheaf with a broader local cache (cost=2), re-sheafify. + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (acct: string) => (acct === 'alice' ? 1000 : 500) }, + { cost: 2 }, + ); + wallet = sheafify(presheaf, argmin); + + // bob: now remote (cost=100) and new local (cost=2) both match, argmin picks cost=2 + expect(await E(wallet).getBalance('bob')).toBe(500); + + // alice: three sections match, argmin still picks cost=1 + expect(await E(wallet).getBalance('alice')).toBe(1000); + }); +}); + +// --------------------------------------------------------------------------- +// E2E: multi-tier capability routing +// --------------------------------------------------------------------------- + +describe('e2e: multi-tier capability routing', () => { + // A wallet integrates multiple data sources. Each declares its coverage + // via guards and carries latency metadata. The sheaf routes every call + // to the fastest matching source — no manual if/else, no strategy + // registration, just: + // guards (what can handle it) + operational metadata (how fast) + lift (pick best) + + type Tier = { latencyMs: number; label: string }; + + const fastest: Lift = (_method, _args, stalk) => + stalk.reduce((best, entry) => + entry.operational.latencyMs < best.operational.latencyMs ? entry : best, + ); + + it('routes reads to the fastest matching tier and writes to the only capable section', async () => { + // Dispatch log — sections push their label on every call so we can + // observe which tier actually handled each request. + const log: string[] = []; + + // Shared ledger — all sections read from this, so the sheaf condition + // (effect-equivalence) holds by construction. + const ledger: Record = { + alice: 1000, + bob: 500, + carol: 250, + }; + + const presheaf = makePresheaf('Wallet'); + + // ── Tier 1: Network RPC ────────────────────────────────── + // Covers ALL accounts (M.string()), but slow (500ms). + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { + getBalance: (acct: string) => { + log.push('network'); + return ledger[acct] ?? 0; + }, + }, + { latencyMs: 500, label: 'network' }, + ); + + let wallet = sheafify(presheaf, fastest); + + // Phase 1 — single backend: stalk is always 1, lift never fires. + expect(await E(wallet).getBalance('alice')).toBe(1000); + expect(await E(wallet).getBalance('bob')).toBe(500); + expect(await E(wallet).getBalance('dave')).toBe(0); + expect(log).toStrictEqual(['network', 'network', 'network']); + log.length = 0; + + // ── Tier 2: Local state for owned account ──────────────── + // Only covers 'alice' (M.eq), 1ms. + presheaf.addSection( + { getBalance: M.call(M.eq('alice')).returns(M.number()) }, + { + getBalance: (_acct: string) => { + log.push('local'); + return ledger.alice ?? 0; + }, + }, + { latencyMs: 1, label: 'local' }, + ); + wallet = sheafify(presheaf, fastest); + + // Phase 2 — alice routes to local (1ms < 500ms), bob still hits network. + expect(await E(wallet).getBalance('alice')).toBe(1000); + expect(await E(wallet).getBalance('bob')).toBe(500); + expect(log).toStrictEqual(['local', 'network']); + log.length = 0; + + // ── Tier 3: In-memory cache for specific accounts ──────── + // Covers bob and carol via M.or, instant (0ms). + presheaf.addSection( + { + getBalance: M.call(M.or(M.eq('bob'), M.eq('carol'))).returns( + M.number(), + ), + }, + { + getBalance: (acct: string) => { + log.push('cache'); + return ledger[acct] ?? 0; + }, + }, + { latencyMs: 0, label: 'cache' }, + ); + wallet = sheafify(presheaf, fastest); + + // Phase 3 — every known account hits its optimal tier. + expect(await E(wallet).getBalance('alice')).toBe(1000); // local (1ms) + expect(await E(wallet).getBalance('bob')).toBe(500); // cache (0ms) + expect(await E(wallet).getBalance('carol')).toBe(250); // cache (0ms) + expect(await E(wallet).getBalance('dave')).toBe(0); // network (only match) + expect(log).toStrictEqual(['local', 'cache', 'cache', 'network']); + log.length = 0; + + // ── Tier 4: Heterogeneous methods ──────────────────────── + // A write-capable section that declares `transfer`. None of the + // read-only tiers above declared it, so writes route here + // automatically — the guard algebra handles it, no config needed. + presheaf.addSection( + { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }, + { + getBalance: (acct: string) => { + log.push('write-backend'); + return ledger[acct] ?? 0; + }, + transfer: (from: string, to: string, amt: number) => { + log.push('write-backend'); + const fromBal = ledger[from] ?? 0; + if (fromBal < amt) { + return false; + } + ledger[from] = fromBal - amt; + ledger[to] = (ledger[to] ?? 0) + amt; + return true; + }, + }, + { latencyMs: 200, label: 'write-backend' }, + ); + wallet = sheafify(presheaf, fastest); + + // transfer: only write-backend declares it → stalk=1, lift bypassed. + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + expect(await E(facade).transfer('alice', 'dave', 100)).toBe(true); + expect(log).toStrictEqual(['write-backend']); + log.length = 0; + + // The shared ledger is mutated. All tiers see the new state because + // they all close over the same ledger (sheaf condition by construction). + expect(await E(wallet).getBalance('alice')).toBe(900); // local (1ms), was 1000 + expect(await E(wallet).getBalance('dave')).toBe(100); // write-backend (200ms < 500ms) + expect(await E(wallet).getBalance('bob')).toBe(500); // cache, unchanged + expect(log).toStrictEqual(['local', 'write-backend', 'cache']); + }); + + it('same presheaf structure, different lifts, different routing', async () => { + // The lift is the operational policy — swap it and the same + // set of sections produces different routing behavior. + const ledger: Record = { alice: 1000, bob: 500 }; + + const build = (lift: Lift) => { + const log: string[] = []; + const presheaf = makePresheaf('Wallet'); + + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { + getBalance: (acct: string) => { + log.push('network'); + return ledger[acct] ?? 0; + }, + }, + { latencyMs: 500, label: 'network' }, + ); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { + getBalance: (acct: string) => { + log.push('mirror'); + return ledger[acct] ?? 0; + }, + }, + { latencyMs: 50, label: 'mirror' }, + ); + + return { wallet: sheafify(presheaf, lift), log }; + }; + + // Policy A: fastest wins (mirror at 50ms < network at 500ms). + const { wallet: walletA, log: logA } = build(fastest); + expect(await E(walletA).getBalance('alice')).toBe(1000); + expect(logA).toStrictEqual(['mirror']); + + // Policy B: highest latency wins (simulate "prefer-canonical-source"). + const slowest: Lift = (_m, _a, stalk) => + stalk.reduce((best, entry) => + entry.operational.latencyMs > best.operational.latencyMs ? entry : best, + ); + const { wallet: walletB, log: logB } = build(slowest); + expect(await E(walletB).getBalance('alice')).toBe(1000); + expect(logB).toStrictEqual(['network']); + }); +}); + +// --------------------------------------------------------------------------- +// E2E: preferAutonomous recovered as degenerate case +// --------------------------------------------------------------------------- + +describe('e2e: preferAutonomous recovered as degenerate case', () => { + it('binary push metadata recovers push-pull lift rule', async () => { + // Binary metadata: { push: true } = push section, { push: false } = pull + const preferPush: Lift<{ push: boolean }> = (_m, _a, stalk) => { + const pushOnly = stalk.filter((entry) => entry.operational.push); + return pushOnly.length > 0 ? pushOnly[0]! : stalk[0]!; + }; + + const presheaf = makePresheaf<{ push: boolean }>('PushPull'); + + // Pull section: M.any() guards, push=false + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 999 }, + { push: false }, + ); + + // Push section: narrow guard, push=true + presheaf.addSection( + { getBalance: M.call(M.eq('alice')).returns(M.number()) }, + { getBalance: (_acct: string) => 42 }, + { push: true }, + ); + + const wallet = sheafify(presheaf, preferPush); + + // alice: both match, preferPush picks push section + expect(await E(wallet).getBalance('alice')).toBe(42); + + // bob: only pull matches (stalk=1, lift bypassed) + expect(await E(wallet).getBalance('bob')).toBe(999); + }); +}); diff --git a/packages/kernel-exo/src/sheaf/sheafify.test.ts b/packages/kernel-exo/src/sheaf/sheafify.test.ts new file mode 100644 index 000000000..145d81c93 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/sheafify.test.ts @@ -0,0 +1,249 @@ +import { E } from '@endo/eventual-send'; +import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; +import { M, getInterfaceGuardPayload } from '@endo/patterns'; +import { describe, it, expect, vi } from 'vitest'; + +import { makePresheaf } from './presheaf.ts'; +import { sheafify } from './sheafify.ts'; +import type { Lift } from './types.ts'; + +vi.mock('@endo/eventual-send', () => ({ + E: vi.fn((obj) => obj), +})); + +// --------------------------------------------------------------------------- +// Unit: sheafify +// --------------------------------------------------------------------------- + +describe('sheafify', () => { + it('single-section bypass: lift not invoked', async () => { + let liftCalled = false; + const lift: Lift<{ cost: number }> = (_m, _a, stalk) => { + liftCalled = true; + return stalk[0]!; + }; + + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 42 }, + { cost: 1 }, + ); + + const wallet = sheafify(presheaf, lift); + expect(await E(wallet).getBalance('alice')).toBe(42); + expect(liftCalled).toBe(false); + }); + + it('zero-coverage throws', async () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.eq('alice')).returns(M.number()) }, + { getBalance: (_acct: string) => 42 }, + { cost: 1 }, + ); + + const wallet = sheafify(presheaf, (_m, _a, stalk) => stalk[0]!); + await expect(E(wallet).getBalance('bob')).rejects.toThrow( + 'No section covers', + ); + }); + + it('lift receives operational metadata and picks winner', async () => { + const argmin: Lift<{ cost: number }> = (_m, _a, stalk) => + stalk.reduce((best, entry) => + entry.operational.cost < best.operational.cost ? entry : best, + ); + + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 100 }, + ); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 42 }, + { cost: 1 }, + ); + + const wallet = sheafify(presheaf, argmin); + // argmin picks cost=1 section which returns 42 + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + // eslint-disable-next-line vitest/prefer-lowercase-title + it('GET_INTERFACE_GUARD returns collected guard', () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.eq('alice')).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 100 }, + ); + presheaf.addSection( + { getBalance: M.call(M.eq('bob')).returns(M.number()) }, + { getBalance: (_acct: string) => 50 }, + { cost: 1 }, + ); + + const wallet = sheafify(presheaf, (_m, _a, stalk) => stalk[0]!); + const guard = wallet[GET_INTERFACE_GUARD](); + expect(guard).toBeDefined(); + + const { methodGuards } = getInterfaceGuardPayload(guard); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('re-sheafification picks up new sections and methods', async () => { + const argmin: Lift<{ cost: number }> = (_m, _a, stalk) => + stalk.reduce((best, entry) => + entry.operational.cost < best.operational.cost ? entry : best, + ); + + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 100 }, + ); + + let wallet = sheafify(presheaf, argmin); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a cheaper section with a new method to the presheaf, re-sheafify. + presheaf.addSection( + { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }, + { + getBalance: (_acct: string) => 42, + transfer: (_from: string, _to: string, _amt: number) => true, + }, + { cost: 1 }, + ); + wallet = sheafify(presheaf, argmin); + + // argmin picks the cheaper section + expect(await E(wallet).getBalance('alice')).toBe(42); + // New method is available on the re-sheafified facade + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + expect(await E(facade).transfer('alice', 'bob', 10)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Unit: sheafify addExo +// --------------------------------------------------------------------------- + +describe('sheafify addExo', () => { + it('pre-built exo dispatches correctly', async () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + presheaf.addExo(exo, { cost: 1 }); + + const wallet = sheafify(presheaf, (_m, _a, stalk) => stalk[0]!); + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + it('re-sheafification after addExo picks up new methods', async () => { + const argmin: Lift<{ cost: number }> = (_m, _a, stalk) => + stalk.reduce((best, entry) => + entry.operational.cost < best.operational.cost ? entry : best, + ); + + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 100 }, + ); + + let wallet = sheafify(presheaf, argmin); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a pre-built exo with a cheaper getBalance + new transfer method + const exo = makeExo( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }), + { + getBalance: (_acct: string) => 42, + transfer: (_from: string, _to: string, _amt: number) => true, + }, + ); + presheaf.addExo(exo, { cost: 1 }); + wallet = sheafify(presheaf, argmin); + + // argmin picks the cheaper section + expect(await E(wallet).getBalance('alice')).toBe(42); + // New method is available on the re-sheafified facade + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + expect(await E(facade).transfer('alice', 'bob', 10)).toBe(true); + }); + + it('guard reflected in GET_INTERFACE_GUARD', () => { + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + presheaf.addExo(exo, { cost: 1 }); + + const wallet = sheafify(presheaf, (_m, _a, stalk) => stalk[0]!); + const guard = wallet[GET_INTERFACE_GUARD](); + expect(guard).toBeDefined(); + + const { methodGuards } = getInterfaceGuardPayload(guard); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('mixed addSection/addExo sections participate in lift', async () => { + const argmin: Lift<{ cost: number }> = (_m, _a, stalk) => + stalk.reduce((best, entry) => + entry.operational.cost < best.operational.cost ? entry : best, + ); + + const presheaf = makePresheaf<{ cost: number }>('Wallet'); + // addSection: expensive + presheaf.addSection( + { getBalance: M.call(M.string()).returns(M.number()) }, + { getBalance: (_acct: string) => 100 }, + { cost: 100 }, + ); + // addExo: cheap + const exo = makeExo( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + presheaf.addExo(exo, { cost: 1 }); + + const wallet = sheafify(presheaf, argmin); + // argmin picks the exo section (cost=1) + expect(await E(wallet).getBalance('alice')).toBe(42); + }); +}); diff --git a/packages/kernel-exo/src/sheaf/sheafify.ts b/packages/kernel-exo/src/sheaf/sheafify.ts new file mode 100644 index 000000000..33c0a8ef9 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/sheafify.ts @@ -0,0 +1,75 @@ +/** + * Sheafify a presheaf with a lift. + * + * The facade dispatches through `getStalk`, + * then switches: 0 -> throw, 1 -> return directly (lift not invoked), + * 2+ -> invoke lift. + */ + +import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; +import type { Methods } from '@endo/exo'; +import { getInterfaceGuardPayload } from '@endo/patterns'; +import type { MethodGuard } from '@endo/patterns'; + +import { collectSheafGuard } from './guard.ts'; +import { getStalk } from './stalk.ts'; +import type { Presheaf, Germ, Lift } from './types.ts'; + +export const sheafify = < + Operational, + Core extends Methods = Record, +>( + presheaf: Presheaf, + lift: Lift, +): ReturnType> => { + const { entries, sections } = presheaf; + + const dispatch = async ( + method: string, + args: unknown[], + ): Promise => { + const stalk = getStalk(entries, method, args); + let winner: Germ; + switch (stalk.length) { + case 0: + throw new Error(`No section covers ${method}(${String(args)})`); + case 1: + winner = stalk[0] as Germ; + break; + default: + winner = await lift(method, args, stalk); + break; + } + // Call as a method on the section to preserve `this` (exo proxy receiver). + const sect = winner.section as Record unknown>; + if (sect[method] === undefined) { + return undefined; + } + return sect[method](...args); + }; + + // Collect all method names across existing sections. + const knownMethods = new Set(); + for (const { section } of entries) { + const interfaceGuard = section[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + continue; + } + const { methodGuards } = getInterfaceGuardPayload( + interfaceGuard, + ) as unknown as { methodGuards: Record }; + for (const methodName of Object.keys(methodGuards)) { + knownMethods.add(methodName); + } + } + + const handlers: Record Promise> = {}; + for (const method of knownMethods) { + handlers[method] = async (...args: unknown[]) => dispatch(method, args); + } + + const unionGuard = collectSheafGuard(presheaf.name, sections); + return makeExo(presheaf.name, unionGuard, handlers) as unknown as ReturnType< + typeof makeExo + >; +}; diff --git a/packages/kernel-exo/src/sheaf/stalk.test.ts b/packages/kernel-exo/src/sheaf/stalk.test.ts new file mode 100644 index 000000000..e1ba33c29 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/stalk.test.ts @@ -0,0 +1,130 @@ +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import type { MethodGuard } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { getStalk } from './stalk.ts'; +import type { Section } from './types.ts'; +import type { Germ } from './types.ts'; + +const makeEntry = ( + tag: string, + guards: Record, + methods: Record unknown>, + operational: { cost: number }, +): Germ<{ cost: number }> => { + const interfaceGuard = M.interface(tag, guards); + const exo = makeExo(tag, interfaceGuard, methods); + return { section: exo as unknown as Section, operational }; +}; + +describe('getStalk', () => { + it('returns matching entries for a method and args', () => { + const entries = [ + makeEntry( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + makeEntry( + 'B', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 2 }, + ), + ]; + + const stalk = getStalk(entries, 'add', [1, 2]); + expect(stalk).toHaveLength(2); + }); + + it('filters out entries without matching method', () => { + const entries = [ + makeEntry( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + makeEntry( + 'B', + { sub: M.call(M.number()).returns(M.number()) }, + { sub: (a: number) => -a }, + { cost: 2 }, + ), + ]; + + const stalk = getStalk(entries, 'add', [1]); + expect(stalk).toHaveLength(1); + expect(stalk[0]!.operational.cost).toBe(1); + }); + + it('filters out entries with arg count mismatch', () => { + const entries = [ + makeEntry( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(entries, 'add', [1]); + expect(stalk).toHaveLength(0); + }); + + it('filters out entries with arg type mismatch', () => { + const entries = [ + makeEntry( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(entries, 'add', ['not-a-number']); + expect(stalk).toHaveLength(0); + }); + + it('returns empty array when no entries match', () => { + const entries = [ + makeEntry( + 'A', + { add: M.call(M.eq('alice')).returns(M.number()) }, + { add: (_a: string) => 42 }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(entries, 'add', ['bob']); + expect(stalk).toHaveLength(0); + }); + + it('returns all entries when all match', () => { + const entries = [ + makeEntry( + 'A', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 1 }, + { cost: 1 }, + ), + makeEntry( + 'B', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 2 }, + { cost: 2 }, + ), + makeEntry( + 'C', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 3 }, + { cost: 3 }, + ), + ]; + + const stalk = getStalk(entries, 'f', ['hello']); + expect(stalk).toHaveLength(3); + }); +}); diff --git a/packages/kernel-exo/src/sheaf/stalk.ts b/packages/kernel-exo/src/sheaf/stalk.ts new file mode 100644 index 000000000..6cffa488d --- /dev/null +++ b/packages/kernel-exo/src/sheaf/stalk.ts @@ -0,0 +1,54 @@ +/** + * Stalk computation: filter presheaf germs by guard matching. + */ + +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { MethodGuard, Pattern } from '@endo/patterns'; + +import type { Germ } from './types.ts'; + +/** + * Get the stalk at an invocation point. + * + * Returns the germs (entries) whose section guards accept + * the given method + args. + * + * @param entries - The presheaf's germs to filter. + * @param method - The method name being invoked. + * @param args - The arguments to the method invocation. + * @returns The germs whose guards accept the invocation. + */ +export const getStalk = ( + entries: Germ[], + method: string, + args: unknown[], +): Germ[] => { + return entries.filter(({ section }) => { + const interfaceGuard = section[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + return false; + } + const { methodGuards } = getInterfaceGuardPayload( + interfaceGuard, + ) as unknown as { methodGuards: Record }; + if (!(method in methodGuards)) { + return false; + } + const methodGuard = methodGuards[method]; + if (!methodGuard) { + return false; + } + const { argGuards } = getMethodGuardPayload(methodGuard) as unknown as { + argGuards: Pattern[]; + }; + return ( + args.length === argGuards.length && + args.every((arg: unknown, idx: number) => matches(arg, argGuards[idx])) + ); + }); +}; diff --git a/packages/kernel-exo/src/sheaf/types.ts b/packages/kernel-exo/src/sheaf/types.ts new file mode 100644 index 000000000..e92df2c67 --- /dev/null +++ b/packages/kernel-exo/src/sheaf/types.ts @@ -0,0 +1,69 @@ +/** + * Presheaf types: the product decomposition F_sem x F_op. + * + * The section (guard + behavior) is the semantic component F_sem. + * The metadata is the operational component F_op. + * Effect-equivalence (the sheaf condition) is asserted by the interface: + * sections covering the same open set produce the same observable result. + */ + +import type { GET_INTERFACE_GUARD, Methods } from '@endo/exo'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +/** A section: a capability covering a region of the interface topology. */ +export type Section = Partial & { + [K in typeof GET_INTERFACE_GUARD]?: (() => InterfaceGuard) | undefined; +}; + +/** + * A germ: a section (F_sem) paired with operational metadata (F_op). + */ +export type Germ = { + section: Section; + operational: Operational; +}; + +/** + * Lift: selects one germ from the stalk when |stalk| > 1. + * + * The lift receives the method name, args, and the full stalk (all germs + * at that point). It returns the winning germ — lifting the invocation + * point from the base space into the étale space. + */ +export type Lift = ( + method: string, + args: unknown[], + stalk: Germ[], +) => Germ | Promise>; + +/** + * A presheaf: sections (F_sem) decorated with operational metadata (F_op). + * + * `entries[i].section === sections[i]` always holds. + * + * The optional `Core` parameter tracks accumulated method types: + * - Open mode (default): `Core` starts as `Record` and grows + * via intersection on each `addSection` -> + * `Presheaf`. + * - Compact mode: caller fixes `Core` upfront, e.g. + * `makePresheaf('Wallet')`. + */ +export type Presheaf< + Operational, + Core extends Methods = Record, +> = { + name: string; + entries: Germ[]; + sections: Section[]; + addSection: ( + guards: { [K in keyof Interface]: MethodGuard }, + handlers: Interface, + operational: Operational, + ) => Presheaf; + addExo: ( + exo: Interface & { + [K in typeof GET_INTERFACE_GUARD]: () => InterfaceGuard; + }, + operational: Operational, + ) => Presheaf; +}; diff --git a/yarn.lock b/yarn.lock index a7af4a181..42b638999 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2475,6 +2475,7 @@ __metadata: resolution: "@metamask/kernel-exo@workspace:packages/kernel-exo" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" "@endo/exo": "npm:^1.5.12" "@endo/patterns": "npm:^1.7.0" "@metamask/auto-changelog": "npm:^5.3.0"