diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 6ae4a716e..ffc478fd1 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -12,9 +12,14 @@ describe('index', () => { 'GET_DESCRIPTION', 'abortableDelay', 'calculateReconnectionBackoff', + 'callable', + 'collectSheafGuard', + 'constant', 'delay', 'fetchValidatedJson', 'fromHex', + 'getStalk', + 'guardCoversPoint', 'ifDefined', 'installWakeDetector', 'isJsonRpcCall', @@ -30,6 +35,8 @@ describe('index', () => { 'mergeDisjointRecords', 'retry', 'retryWithBackoff', + 'sheafify', + 'source', 'stringify', 'toHex', 'waitUntilQuiescent', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 67103c015..c85225605 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -37,3 +37,17 @@ export { DEFAULT_MAX_DELAY_MS, } from './retry.ts'; export type { RetryBackoffOptions, RetryOnRetryInfo } from './retry.ts'; +export type { + Section, + PresheafSection, + EvaluatedSection, + MetaDataSpec, + Lift, + LiftContext, + Presheaf, + Sheaf, +} from './sheaf/types.ts'; +export { constant, source, callable } from './sheaf/metadata.ts'; +export { sheafify } from './sheaf/sheafify.ts'; +export { collectSheafGuard } from './sheaf/guard.ts'; +export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; diff --git a/packages/kernel-utils/src/sheaf/README.md b/packages/kernel-utils/src/sheaf/README.md new file mode 100644 index 000000000..19b5f7d2a --- /dev/null +++ b/packages/kernel-utils/src/sheaf/README.md @@ -0,0 +1,103 @@ +# Sheaf + +Runtime capability routing adapted from sheaf theory in algebraic topology. + +`sheafify({ name, sections })` produces a **sheaf** — an authority manager +over a presheaf of capabilities. The sheaf grants revocable dispatch sections +via `getSection`, tracks all delegated authority, and supports point-wise +revocation. + +## Concepts + +**Presheaf section** (`PresheafSection`) — The input data: a capability (exo) +paired with operational metadata, assigned over the open set defined by the +exo's guard. This is an element of the presheaf F = F_sem x F_op. + +> A `getBalance(string)` provider with `{ cost: 100 }` is one presheaf +> section. A `getBalance("alice")` provider with `{ cost: 1 }` is another, +> covering a narrower open set. + +**Germ** — An equivalence class of presheaf sections at an invocation point, +identified by metadata. At dispatch time, sections in the stalk with identical +metadata are collapsed into a single germ; the system picks an arbitrary +representative for dispatch. If two capabilities are indistinguishable by +metadata, the sheaf has no data to prefer one over the other. + +> Two `getBalance(string)` providers both with `{ cost: 1 }` collapse into +> one germ. The lift never sees both — it receives one representative. + +**Stalk** — The set of germs matching a specific `(method, args)` invocation, +computed at dispatch time by guard filtering and then collapsing equivalent +entries. + +> Stalk at `("getBalance", "alice")` might contain two germs (cost 1 vs 100); +> stalk at `("transfer", ...)` might contain one. + +**Lift** — An async function that selects one germ from a multi-germ stalk. +At dispatch time, metadata is decomposed into **constraints** (keys with the +same value across every germ — topologically determined, not a choice) and +**options** (the remaining keys — the lift's actual decision space). The lift +receives only options on each germ; constraints arrive separately in the +context. + +> `argmin` by cost, `argmin` by latency, or any custom selection logic. The +> lift is never invoked for single-germ stalks. + +**Sheaf** — The authority manager returned by `sheafify`. Holds the presheaf +data (captured at construction time) and a registry of all granted sections. + +``` +const sheaf = sheafify({ name: 'Wallet', sections }); +``` + +- `sheaf.getSection({ guard?, lift })` — produce a revocable dispatch exo +- `sheaf.revokePoint(method, ...args)` — revoke every granted section whose + guard covers the point +- `sheaf.getExported()` — union guard of all active (non-revoked) sections +- `sheaf.revokeAll()` — revoke every granted section + +## Dispatch pipeline + +At each invocation point `(method, args)` within a granted section: + +``` +getStalk(sections, method, args) presheaf → stalk (filter by guard) +collapseEquivalent(stalk) locality condition (quotient by metadata) +decomposeMetadata(collapsed) restriction map (constraints / options) +lift(stripped, { method, args, operational selection (extra-theoretic) + constraints }) +dispatch to collapsed[index].exo evaluation +``` + +## Design choices + +**Germ identity is metadata identity.** The collapse step quotients by +metadata: if two sections should be distinguishable, the caller must give them +distinguishable metadata. Sections with identical metadata are treated as +interchangeable. Under the sheaf condition (effect-equivalence), this recovers +the classical equivalence relation on germs. + +**Pseudosheafification.** The sheafification functor would precompute the full +etale space. This system defers to invocation time: compute the stalk, +collapse, decompose, lift. The trade-off is that global coherence (a lift +choosing consistently across points) is not guaranteed. + +**Restriction and gluing are implicit.** Guard restriction induces a +restriction map on metadata: restricting to a point filters the presheaf to +covering sections (`getStalk`), then `decomposeMetadata` strips the metadata +to distinguishing keys — the restricted metadata over that point. The join +works dually: the union of two sections has the join of their metadata, and +restriction at any point recovers the local distinguishing keys in O(n). +Gluing follows: compatible sections (equal metadata on their overlap) produce a +well-defined join. The dispatch pipeline computes all of this implicitly. The +remaining gap is `revokeSite` (revoking over an open set rather than a point), +which requires an `intersects` operator on guards not yet available. + +## Relationship to stacks + +This construction is more properly a **stack** in algebraic geometry. We call +it a sheaf because engineers already know "stack" as a LIFO data structure, and +the algebraic geometry term is unrelated. Within a germ, any representative +will do — authority-equivalence is asserted by constructor contract, not +verified at runtime. Between germs, metadata distinguishes them and the lift +resolves the choice. diff --git a/packages/kernel-utils/src/sheaf/guard.test.ts b/packages/kernel-utils/src/sheaf/guard.test.ts new file mode 100644 index 000000000..ffac24fd8 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/guard.test.ts @@ -0,0 +1,218 @@ +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 { guardCoversPoint } from './stalk.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('section with its own optional args: optional preserved in union', () => { + const sections = [ + makeSection( + 'Greeter', + { + greet: M.callWhen(M.string()) + .optional(M.string()) + .returns(M.string()), + }, + { greet: (name: string, _greeting?: string) => `hello ${name}` }, + ), + ]; + + const guard = collectSheafGuard('Greeter', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const payload = getMethodGuardPayload(methodGuards.greet) as unknown as { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + }; + + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(1); + }); + + it('rest arg guard preserved in collected union', () => { + const sections = [ + makeSection( + 'Logger', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('Logger', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const payload = getMethodGuardPayload(methodGuards.log) as unknown as { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + restArgGuard?: Pattern; + }; + + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards ?? []).toHaveLength(0); + expect(payload.restArgGuard).toBeDefined(); + }); + + it('rest arg guards unioned across sections', () => { + const sections = [ + makeSection( + 'A', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + ), + makeSection( + 'B', + { log: M.call(M.string()).rest(M.number()).returns(M.any()) }, + { log: (..._args: unknown[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('AB', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const { restArgGuard } = getMethodGuardPayload( + methodGuards.log, + ) as unknown as { restArgGuard?: Pattern }; + + expect(matches('hello', restArgGuard)).toBe(true); + expect(matches(42, restArgGuard)).toBe(true); + }); + + it('rest-arg section covers optional positions (no false negative)', () => { + // Section A requires 1 number; Section B requires 0 args but accepts any + // number of strings via rest. A call ['hello'] is covered by B — the + // collected guard must pass it too. + const sections = [ + makeSection( + 'AB:0', + { f: M.call(M.number()).returns(M.any()) }, + { f: (_: number) => undefined }, + ), + makeSection( + 'AB:1', + { f: M.call().rest(M.string()).returns(M.any()) }, + { f: (..._args: string[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('AB', sections); + + expect(guardCoversPoint(guard, 'f', ['hello'])).toBe(true); // covered by B + expect(guardCoversPoint(guard, 'f', [42])).toBe(true); // covered by A + expect(guardCoversPoint(guard, 'f', [])).toBe(true); // covered by B (0 required) + }); + + 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-utils/src/sheaf/guard.ts b/packages/kernel-utils/src/sheaf/guard.ts new file mode 100644 index 000000000..36b29df97 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/guard.ts @@ -0,0 +1,144 @@ +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'; + +export type MethodGuardPayload = { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + restArgGuard?: 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 union of all section guards — the open set covered by the sheafified facade. + * + * 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 getGuardAt = ( + payload: MethodGuardPayload, + idx: number, + ): Pattern | undefined => { + if (idx < payload.argGuards.length) { + return payload.argGuards[idx]; + } + const optIdx = idx - payload.argGuards.length; + if ( + payload.optionalArgGuards && + optIdx < payload.optionalArgGuards.length + ) { + return payload.optionalArgGuards[optIdx]; + } + return payload.restArgGuard; + }; + + const unionMethodGuards: Record = {}; + for (const [methodName, payloads] of payloadsByMethod) { + const minArity = Math.min( + ...payloads.map((payload) => payload.argGuards.length), + ); + const maxArity = Math.max( + ...payloads.map( + (payload) => + payload.argGuards.length + (payload.optionalArgGuards?.length ?? 0), + ), + ); + + 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 + .map((payload) => getGuardAt(payload, idx)) + .filter((guard): guard is Pattern => guard !== undefined); + optionalArgGuards.push(unionGuard(guards)); + } + + const restArgGuards = payloads + .map((payload) => payload.restArgGuard) + .filter((restGuard): restGuard is Pattern => restGuard !== undefined); + const unionRestArgGuard = + restArgGuards.length > 0 ? unionGuard(restArgGuards) : undefined; + + const returnGuard = unionGuard( + payloads.map((payload) => payload.returnGuard), + ); + + const base = M.callWhen(...requiredArgGuards); + if (optionalArgGuards.length > 0 && unionRestArgGuard !== undefined) { + unionMethodGuards[methodName] = base + .optional(...optionalArgGuards) + .rest(unionRestArgGuard) + .returns(returnGuard); + } else if (optionalArgGuards.length > 0) { + unionMethodGuards[methodName] = base + .optional(...optionalArgGuards) + .returns(returnGuard); + } else if (unionRestArgGuard === undefined) { + unionMethodGuards[methodName] = base.returns(returnGuard); + } else { + unionMethodGuards[methodName] = base + .rest(unionRestArgGuard) + .returns(returnGuard); + } + } + + return M.interface(name, unionMethodGuards); +}; diff --git a/packages/kernel-utils/src/sheaf/metadata.test.ts b/packages/kernel-utils/src/sheaf/metadata.test.ts new file mode 100644 index 000000000..5421094a1 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/metadata.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { + callable, + constant, + evaluateMetadata, + resolveMetaDataSpec, + source, +} from './metadata.ts'; + +describe('constant', () => { + it('returns a constant spec with the given value', () => { + expect(constant(42)).toStrictEqual({ kind: 'constant', value: 42 }); + }); + + it('evaluateMetadata returns the value regardless of args', () => { + const spec = resolveMetaDataSpec(constant({ cost: 7 })); + expect(evaluateMetadata(spec, [])).toStrictEqual({ cost: 7 }); + expect(evaluateMetadata(spec, [1, 2, 3])).toStrictEqual({ cost: 7 }); + }); +}); + +describe('callable', () => { + it('returns a callable spec wrapping the function', () => { + const fn = (args: unknown[]) => args[0] as number; + const spec = callable(fn); + expect(spec).toStrictEqual({ kind: 'callable', fn }); + }); + + it('evaluateMetadata calls fn with args', () => { + const fn = vi.fn((args: unknown[]) => (args[0] as number) * 2); + const spec = resolveMetaDataSpec(callable(fn)); + expect(evaluateMetadata(spec, [5])).toBe(10); + expect(fn).toHaveBeenCalledWith([5]); + }); +}); + +describe('source', () => { + it('returns a source spec with the src string', () => { + expect(source('(args) => args[0]')).toStrictEqual({ + kind: 'source', + src: '(args) => args[0]', + }); + }); + + it('resolveMetaDataSpec compiles source to callable via compartment', () => { + const mockFn = (args: unknown[]) => args[0] as number; + const compartment = { evaluate: vi.fn(() => mockFn) }; + const spec = resolveMetaDataSpec(source('(args) => args[0]'), compartment); + expect(spec.kind).toBe('callable'); + expect(compartment.evaluate).toHaveBeenCalledWith('(args) => args[0]'); + expect(evaluateMetadata(spec, [99])).toBe(99); + }); +}); + +describe('resolveMetaDataSpec', () => { + it('passes constant spec through unchanged', () => { + const spec = constant(42); + expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); + }); + + it('passes callable spec through unchanged', () => { + const fn = (_args: unknown[]) => 0; + const spec = callable(fn); + expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); + }); + + it("throws if kind is 'source' and no compartment supplied", () => { + expect(() => resolveMetaDataSpec(source('() => 0'))).toThrow( + "compartment required to evaluate 'source' metadata", + ); + }); +}); + +describe('evaluateMetadata', () => { + it('returns undefined when spec is undefined', () => { + expect(evaluateMetadata(undefined, [])).toBeUndefined(); + expect(evaluateMetadata(undefined, [1, 2])).toBeUndefined(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/metadata.ts b/packages/kernel-utils/src/sheaf/metadata.ts new file mode 100644 index 000000000..33846c247 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/metadata.ts @@ -0,0 +1,85 @@ +/** + * MetaDataSpec constructors and evaluation helpers. + */ + +import type { MetaDataSpec } from './types.ts'; + +/** Resolved spec: 'source' has been compiled away; only constant or callable remain. */ +export type ResolvedMetaDataSpec = + | { kind: 'constant'; value: M } + | { kind: 'callable'; fn: (args: unknown[]) => M }; + +/** + * Wrap a static value as a constant metadata spec. + * + * @param value - The static metadata value. + * @returns A constant MetaDataSpec wrapping the value. + */ +export const constant = (value: M): MetaDataSpec => + harden({ kind: 'constant', value }); + +/** + * Wrap JS function source. Evaluated in a Compartment at sheafify construction time. + * + * @param src - JS source string of the form `(args) => M`. + * @returns A source MetaDataSpec wrapping the source string. + */ +export const source = (src: string): MetaDataSpec => + harden({ kind: 'source', src }); + +/** + * Wrap a live function as a callable metadata spec. + * + * @param fn - Function from invocation args to metadata value. + * @returns A callable MetaDataSpec wrapping the function. + */ +export const callable = (fn: (args: unknown[]) => M): MetaDataSpec => + harden({ kind: 'callable', fn }); + +/** + * Compile a 'source' spec to 'callable' using the supplied compartment. + * 'constant' and 'callable' pass through unchanged. + * + * @param spec - The MetaDataSpec to resolve. + * @param compartment - Compartment used to evaluate 'source' specs. Required when spec is 'source'. + * @param compartment.evaluate - Evaluate a JS source string and return the result. + * @returns A ResolvedMetaDataSpec with no 'source' variant. + */ +export const resolveMetaDataSpec = ( + spec: MetaDataSpec, + compartment?: { evaluate: (src: string) => unknown }, +): ResolvedMetaDataSpec => { + if (spec.kind === 'source') { + if (!compartment) { + throw new Error( + `sheafify: compartment required to evaluate 'source' metadata`, + ); + } + return { + kind: 'callable', + fn: compartment.evaluate(spec.src) as (args: unknown[]) => M, + }; + } + return spec; +}; + +/** + * Evaluate a resolved metadata spec against the invocation args. + * Returns undefined if spec is undefined (no metadata on the section). + * + * @param spec - The resolved spec to evaluate, or undefined. + * @param args - The invocation arguments. + * @returns The evaluated metadata value, or undefined. + */ +export const evaluateMetadata = ( + spec: ResolvedMetaDataSpec | undefined, + args: unknown[], +): M | undefined => { + if (spec === undefined) { + return undefined; + } + if (spec.kind === 'constant') { + return spec.value; + } + return spec.fn(args); +}; diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts new file mode 100644 index 000000000..976b819fb --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -0,0 +1,493 @@ +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import { describe, expect, it, vi } from 'vitest'; + +import { callable, constant } from './metadata.ts'; +import { sheafify } from './sheafify.ts'; +import type { Lift, PresheafSection, Section } from './types.ts'; + +// Thin cast for calling exo methods directly in tests without going through +// HandledPromise (which is not available in the test environment). +// eslint-disable-next-line id-length +const E = (obj: unknown) => + obj as Record Promise>; + +// --------------------------------------------------------------------------- +// E2E: cost-optimal routing +// --------------------------------------------------------------------------- + +describe('e2e: cost-optimal routing', () => { + it('argmin picks cheapest section, re-sheafification expands landscape', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const remote0GetBalance = vi.fn((_acct: string): number => 0); + const local1GetBalance = vi.fn((_acct: string): number => 0); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + // Remote: covers all accounts, expensive + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: remote0GetBalance }, + ) as unknown as Section, + metadata: constant({ cost: 100 }), + }, + { + // Local cache: covers only 'alice', cheap + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: local1GetBalance }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + + // alice: both sections match, argmin picks local (cost=1) + await E(wallet).getBalance('alice'); + expect(local1GetBalance).toHaveBeenCalledWith('alice'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + local1GetBalance.mockClear(); + + // bob: only remote matches (stalk=1, lift not invoked) + await E(wallet).getBalance('bob'); + expect(remote0GetBalance).toHaveBeenCalledWith('bob'); + expect(local1GetBalance).not.toHaveBeenCalled(); + remote0GetBalance.mockClear(); + + // Expand with a broader local cache (cost=2), re-sheafify. + const local2GetBalance = vi.fn((_acct: string): number => 0); + sections.push({ + exo: makeExo( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: local2GetBalance }, + ) as unknown as Section, + metadata: constant({ cost: 2 }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + + // bob: now remote (cost=100) and new local (cost=2) both match, argmin picks cost=2 + await E(wallet).getBalance('bob'); + expect(local2GetBalance).toHaveBeenCalledWith('bob'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + local2GetBalance.mockClear(); + + // alice: three sections match, argmin still picks cost=1 + await E(wallet).getBalance('alice'); + expect(local1GetBalance).toHaveBeenCalledWith('alice'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + expect(local2GetBalance).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 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) + metadata (how fast) + lift (pick best) + + type Tier = { latencyMs: number; label: string }; + + const fastest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.latencyMs ?? Infinity) < + (germs[bestIdx]!.metadata?.latencyMs ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + it('routes reads to the fastest matching tier and writes to the only capable section', async () => { + // 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 networkGetBalance = vi.fn( + (acct: string): number => ledger[acct] ?? 0, + ); + const localGetBalance = vi.fn((_acct: string): number => ledger.alice ?? 0); + const cacheGetBalance = vi.fn((acct: string): number => ledger[acct] ?? 0); + const writeBackendGetBalance = vi.fn( + (acct: string): number => ledger[acct] ?? 0, + ); + const writeBackendTransfer = vi.fn( + (from: string, to: string, amt: number): boolean => { + const fromBal = ledger[from] ?? 0; + if (fromBal < amt) { + return false; + } + ledger[from] = fromBal - amt; + ledger[to] = (ledger[to] ?? 0) + amt; + return true; + }, + ); + + const sections: PresheafSection[] = []; + + // ── Tier 1: Network RPC ────────────────────────────────── + // Covers ALL accounts (M.string()), but slow (500ms). + sections.push({ + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: networkGetBalance }, + ) as unknown as Section, + metadata: constant({ latencyMs: 500, label: 'network' }), + }); + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // Phase 1 — single backend: stalk is always 1, lift never fires. + await E(wallet).getBalance('alice'); + await E(wallet).getBalance('bob'); + await E(wallet).getBalance('dave'); + expect(networkGetBalance).toHaveBeenCalledTimes(3); + expect(networkGetBalance).toHaveBeenCalledWith('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('bob'); + expect(networkGetBalance).toHaveBeenCalledWith('dave'); + networkGetBalance.mockClear(); + + // ── Tier 2: Local state for owned account ──────────────── + // Only covers 'alice' (M.eq), 1ms. + sections.push({ + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: localGetBalance }, + ) as unknown as Section, + metadata: constant({ latencyMs: 1, label: 'local' }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // Phase 2 — alice routes to local (1ms < 500ms), bob still hits network. + await E(wallet).getBalance('alice'); + await E(wallet).getBalance('bob'); + expect(localGetBalance).toHaveBeenCalledWith('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('bob'); + expect(networkGetBalance).not.toHaveBeenCalledWith('alice'); + expect(localGetBalance).not.toHaveBeenCalledWith('bob'); + localGetBalance.mockClear(); + networkGetBalance.mockClear(); + + // ── Tier 3: In-memory cache for specific accounts ──────── + // Covers bob and carol via M.or, instant (0ms). + sections.push({ + exo: makeExo( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.or(M.eq('bob'), M.eq('carol'))).returns( + M.number(), + ), + }), + { getBalance: cacheGetBalance }, + ) as unknown as Section, + metadata: constant({ latencyMs: 0, label: 'cache' }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // Phase 3 — every known account hits its optimal tier. + await E(wallet).getBalance('alice'); // local (1ms) + await E(wallet).getBalance('bob'); // cache (0ms) + await E(wallet).getBalance('carol'); // cache (0ms) + await E(wallet).getBalance('dave'); // network (only match) + expect(localGetBalance).toHaveBeenCalledWith('alice'); + expect(cacheGetBalance).toHaveBeenCalledWith('bob'); + expect(cacheGetBalance).toHaveBeenCalledWith('carol'); + expect(networkGetBalance).toHaveBeenCalledWith('dave'); + expect(networkGetBalance).toHaveBeenCalledTimes(1); + expect(localGetBalance).toHaveBeenCalledTimes(1); + expect(cacheGetBalance).toHaveBeenCalledTimes(2); + localGetBalance.mockClear(); + cacheGetBalance.mockClear(); + networkGetBalance.mockClear(); + + // ── 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. + sections.push({ + exo: makeExo( + 'Wallet:3', + M.interface('Wallet:3', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }), + { + getBalance: writeBackendGetBalance, + transfer: writeBackendTransfer, + }, + ) as unknown as Section, + metadata: constant({ latencyMs: 200, label: 'write-backend' }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // transfer: only write-backend declares it → stalk=1, lift bypassed. + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + await E(facade).transfer('alice', 'dave', 100); + expect(writeBackendTransfer).toHaveBeenCalledWith('alice', 'dave', 100); + writeBackendTransfer.mockClear(); + + // The shared ledger is mutated. All tiers see the new state because + // they all close over the same ledger (sheaf condition by construction). + await E(wallet).getBalance('alice'); // local (1ms), was 1000 + await E(wallet).getBalance('dave'); // write-backend (200ms < 500ms for dave) + await E(wallet).getBalance('bob'); // cache, unchanged + expect(localGetBalance).toHaveBeenCalledWith('alice'); + expect(writeBackendGetBalance).toHaveBeenCalledWith('dave'); + expect(cacheGetBalance).toHaveBeenCalledWith('bob'); + expect(ledger.alice).toBe(900); + expect(ledger.dave).toBe(100); + expect(ledger.bob).toBe(500); + }); + + it('same germ 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 networkGetBalance = vi.fn((_acct: string): number => 0); + const mirrorGetBalance = vi.fn((_acct: string): number => 0); + + const makeSections = (): PresheafSection[] => [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: networkGetBalance }, + ) as unknown as Section, + metadata: constant({ latencyMs: 500, label: 'network' }), + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: mirrorGetBalance }, + ) as unknown as Section, + metadata: constant({ latencyMs: 50, label: 'mirror' }), + }, + ]; + + // Policy A: fastest wins (mirror at 50ms < network at 500ms). + const walletA = sheafify({ + name: 'Wallet', + sections: makeSections(), + }).getGlobalSection({ lift: fastest }); + await E(walletA).getBalance('alice'); + expect(mirrorGetBalance).toHaveBeenCalledWith('alice'); + expect(networkGetBalance).not.toHaveBeenCalled(); + mirrorGetBalance.mockClear(); + + // Policy B: highest latency wins (simulate "prefer-canonical-source"). + const slowest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.latencyMs ?? 0) > + (germs[bestIdx]!.metadata?.latencyMs ?? 0) + ? idx + : bestIdx, + 0, + ), + ); + const walletB = sheafify({ + name: 'Wallet', + sections: makeSections(), + }).getGlobalSection({ lift: slowest }); + await E(walletB).getBalance('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('alice'); + expect(mirrorGetBalance).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 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: constant({ push: true }) = push section, { push: false } = pull + const preferPush: Lift<{ push: boolean }> = async (germs) => { + const pushIdx = germs.findIndex((entry) => entry.metadata?.push); + return Promise.resolve(pushIdx >= 0 ? pushIdx : 0); + }; + + const pullGetBalance = vi.fn((_acct: string): number => 0); + const pushGetBalance = vi.fn((_acct: string): number => 0); + + const sections: PresheafSection<{ push: boolean }>[] = [ + { + // Pull section: M.string() guards, push=false + exo: makeExo( + 'PushPull:0', + M.interface('PushPull:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: pullGetBalance }, + ) as unknown as Section, + metadata: constant({ push: false }), + }, + { + // Push section: narrow guard, push=true + exo: makeExo( + 'PushPull:1', + M.interface('PushPull:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: pushGetBalance }, + ) as unknown as Section, + metadata: constant({ push: true }), + }, + ]; + + const wallet = sheafify({ name: 'PushPull', sections }).getGlobalSection({ + lift: preferPush, + }); + + // alice: both match, preferPush picks push section + await E(wallet).getBalance('alice'); + expect(pushGetBalance).toHaveBeenCalledWith('alice'); + expect(pullGetBalance).not.toHaveBeenCalled(); + pushGetBalance.mockClear(); + + // bob: only pull matches (stalk=1, lift bypassed) + await E(wallet).getBalance('bob'); + expect(pullGetBalance).toHaveBeenCalledWith('bob'); + expect(pushGetBalance).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// E2E: callable metadata — cost varies with invocation args +// --------------------------------------------------------------------------- + +describe('e2e: callable metadata — cost varies with invocation args', () => { + // Two swap sections whose cost is a function of the swap amount. + // Swap A is cheaper for small amounts; Swap B is cheaper for large amounts. + // Breakeven ≈ 90.9 (1 + 0.1x = 10 + 0.001x → 0.099x = 9 → x ≈ 90.9) + + type SwapCost = { cost: number }; + + const cheapest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + it('routes swap(50) to A and swap(100) to B based on callable cost metadata', async () => { + const swapAFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + const swapBFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'SwapA', + M.interface('SwapA', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapAFn }, + ) as unknown as Section, + // cost(amount) = 1 + 0.1 * amount + metadata: callable((args) => ({ + cost: 1 + 0.1 * (args[0] as number), + })), + }, + { + exo: makeExo( + 'SwapB', + M.interface('SwapB', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapBFn }, + ) as unknown as Section, + // cost(amount) = 10 + 0.001 * amount + metadata: callable((args) => ({ + cost: 10 + 0.001 * (args[0] as number), + })), + }, + ]; + + const facade = sheafify({ name: 'Swap', sections }).getGlobalSection({ + lift: cheapest, + }) as unknown as Record Promise>; + + // swap(50): A costs 6, B costs 10.05 → A wins + await facade.swap(50, 'FUZ', 'BIZ'); + expect(swapAFn).toHaveBeenCalledWith(50, 'FUZ', 'BIZ'); + expect(swapBFn).not.toHaveBeenCalled(); + swapAFn.mockClear(); + + // swap(100): A costs 11, B costs 10.1 → B wins + await facade.swap(100, 'FUZ', 'BIZ'); + expect(swapBFn).toHaveBeenCalledWith(100, 'FUZ', 'BIZ'); + expect(swapAFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts new file mode 100644 index 000000000..302d70300 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts @@ -0,0 +1,110 @@ +// This test verifies that source-kind metadata specs are compiled via a +// compartment at sheafify construction time and evaluated at dispatch time. +// +// We use a new Function()-based compartment rather than a real SES Compartment +// because importing 'ses' alongside '@endo/exo' triggers a module-evaluation +// ordering conflict in the test environment: @endo/patterns module initialization +// calls assertPattern() under SES lockdown before its internal objects are frozen. +// That conflict is an environment limitation, not a feature limitation. +// +// The functional properties under test are identical regardless of which +// Compartment implementation compiles the source string. + +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import { describe, it, expect, vi } from 'vitest'; + +import { source } from './metadata.ts'; +import { sheafify } from './sheafify.ts'; +import type { Lift, PresheafSection, Section } from './types.ts'; + +// Thin cast for calling exo methods directly in tests without going through +// HandledPromise (which is not available in the test environment). +// eslint-disable-next-line id-length +const E = (obj: unknown) => + obj as Record Promise>; + +// A Compartment-shaped object that actually evaluates JS source strings. +/* eslint-disable @typescript-eslint/no-implied-eval, no-new-func */ +const makeTestCompartment = () => ({ + evaluate: (src: string) => new Function(`return (${src})`)(), +}); +/* eslint-enable @typescript-eslint/no-implied-eval, no-new-func */ + +describe('e2e: source metadata — compartment evaluates cost function', () => { + // Same two-swap scenario as the callable e2e test, but cost functions are + // provided as JS source strings and compiled via the test compartment. + // Breakeven ≈ 90.9 (same arithmetic as callable variant). + + type SwapCost = { cost: number }; + + const cheapest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + it('routes swap(50) to A and swap(100) to B using source-kind metadata', async () => { + const swapAFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + const swapBFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'SwapA', + M.interface('SwapA', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapAFn }, + ) as unknown as Section, + // cost(amount) = 1 + 0.1 * amount + metadata: source(`(args) => ({ cost: 1 + 0.1 * args[0] })`), + }, + { + exo: makeExo( + 'SwapB', + M.interface('SwapB', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapBFn }, + ) as unknown as Section, + // cost(amount) = 10 + 0.001 * amount + metadata: source(`(args) => ({ cost: 10 + 0.001 * args[0] })`), + }, + ]; + + const facade = sheafify({ + name: 'Swap', + sections, + compartment: makeTestCompartment(), + }).getGlobalSection({ lift: cheapest }) as unknown as Record< + string, + (...args: unknown[]) => Promise + >; + + // swap(50): A costs 6, B costs 10.05 → A wins + await E(facade).swap(50, 'FUZ', 'BIZ'); + expect(swapAFn).toHaveBeenCalledWith(50, 'FUZ', 'BIZ'); + expect(swapBFn).not.toHaveBeenCalled(); + swapAFn.mockClear(); + + // swap(100): A costs 11, B costs 10.1 → B wins + await E(facade).swap(100, 'FUZ', 'BIZ'); + expect(swapBFn).toHaveBeenCalledWith(100, 'FUZ', 'BIZ'); + expect(swapAFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts new file mode 100644 index 000000000..82fb42444 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -0,0 +1,605 @@ +import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; +import { M, getInterfaceGuardPayload } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { constant } from './metadata.ts'; +import { sheafify } from './sheafify.ts'; +import type { + EvaluatedSection, + Lift, + LiftContext, + PresheafSection, + Section, +} from './types.ts'; + +// Thin cast for calling exo methods directly in tests without going through +// HandledPromise (which is not available in the test environment). +// eslint-disable-next-line id-length +const E = (obj: unknown) => + obj as Record Promise>; + +// --------------------------------------------------------------------------- +// Unit: sheafify +// --------------------------------------------------------------------------- + +describe('sheafify', () => { + it('single-section bypass: lift not invoked', async () => { + let liftCalled = false; + const lift: Lift<{ cost: number }> = async (_germs) => { + liftCalled = true; + return Promise.resolve(0); + }; + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift, + }); + expect(await E(wallet).getBalance('alice')).toBe(42); + expect(liftCalled).toBe(false); + }); + + it('zero-coverage throws', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: async (_germs) => Promise.resolve(0), + }); + await expect(E(wallet).getBalance('bob')).rejects.toThrow( + 'No section covers', + ); + }); + + it('lift receives metadata and picks winner', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ cost: 100 }), + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: 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 sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ cost: 100 }), + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('bob')).returns(M.number()), + }), + { getBalance: (_acct: string) => 50 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: async (_germs) => Promise.resolve(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 }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ cost: 100 }), + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a cheaper section with a new method to the sections array, re-sheafify. + sections.push({ + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + 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, + }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: 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('pre-built exo dispatches correctly', async () => { + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: async (_germs) => Promise.resolve(0), + }); + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + it('re-sheafification with pre-built exo picks up new methods', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ cost: 100 }), + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: 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, + }, + ); + sections.push({ + exo: exo as unknown as Section, + metadata: constant({ cost: 1 }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: 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 for pre-built exo', () => { + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: async (_germs) => Promise.resolve(0), + }); + const guard = wallet[GET_INTERFACE_GUARD](); + expect(guard).toBeDefined(); + + const { methodGuards } = getInterfaceGuardPayload(guard); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('lift receives constraints in context and only distinguishing metadata', async () => { + type Meta = { region: string; cost: number }; + let capturedGerms: EvaluatedSection>[] = []; + let capturedContext: LiftContext | undefined; + + const spy: Lift = async (germs, context) => { + capturedGerms = germs; + capturedContext = context; + return Promise.resolve(0); + }; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ region: 'us', cost: 100 }), + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ region: 'us', cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + expect(capturedContext).toStrictEqual({ + method: 'getBalance', + args: ['alice'], + constraints: { region: 'us' }, + }); + expect(capturedGerms.map((germ) => germ.metadata)).toStrictEqual([ + { cost: 100 }, + { cost: 1 }, + ]); + }); + + it('all-shared metadata yields empty distinguishing metadata', async () => { + type Meta = { region: string }; + let capturedGerms: EvaluatedSection>[] = []; + let capturedContext: LiftContext | undefined; + + const spy: Lift = async (germs, context) => { + capturedGerms = germs; + capturedContext = context; + return Promise.resolve(0); + }; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ region: 'us' }), + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ region: 'us' }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + // Both sections collapsed to one germ → lift not invoked + expect(capturedContext).toBeUndefined(); + expect(capturedGerms).toHaveLength(0); + }); + + it('collapses equivalent presheaf sections by metadata', async () => { + type Meta = { cost: number }; + let liftCalled = false; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: async (_germs) => { + liftCalled = true; + return Promise.resolve(0); + }, + }); + await E(wallet).getBalance('alice'); + + // Both sections have identical metadata → collapsed to one germ → lift bypassed + expect(liftCalled).toBe(false); + }); + + it('mixed sections participate in lift', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const exo = makeExo( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: constant({ cost: 100 }), + }, + { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + // argmin picks the exo section (cost=1) + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + // --------------------------------------------------------------------------- + // Revocation + // --------------------------------------------------------------------------- + + it('revokePoint revokes sections covering the point', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + const wallet = sheaf.getGlobalSection({ + lift: async () => Promise.resolve(0), + }); + + expect(await E(wallet).getBalance('alice')).toBe(42); + + sheaf.revokePoint('getBalance', 'alice'); + + // Entire section is revoked, not just the specific point + await expect(E(wallet).getBalance('alice')).rejects.toThrow( + 'Section revoked', + ); + await expect(E(wallet).getBalance('bob')).rejects.toThrow( + 'Section revoked', + ); + }); + + it('revokeAll revokes all sections', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + const wallet = sheaf.getGlobalSection({ + lift: async () => Promise.resolve(0), + }); + + expect(await E(wallet).getBalance('alice')).toBe(42); + + sheaf.revokeAll(); + + await expect(E(wallet).getBalance('alice')).rejects.toThrow( + 'Section revoked', + ); + }); + + it('getExported returns union of active section guards', () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + + // No sections granted yet + expect(sheaf.getExported()).toBeUndefined(); + + sheaf.getGlobalSection({ lift: async () => Promise.resolve(0) }); + + const exported = sheaf.getExported(); + expect(exported).toBeDefined(); + const { methodGuards } = getInterfaceGuardPayload(exported!); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('getExported excludes revoked sections', () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({ cost: 1 }), + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + sheaf.getGlobalSection({ lift: async () => Promise.resolve(0) }); + + expect(sheaf.getExported()).toBeDefined(); + + sheaf.revokeAll(); + expect(sheaf.getExported()).toBeUndefined(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts new file mode 100644 index 000000000..166e8268c --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -0,0 +1,335 @@ +/** + * Sheafify a presheaf into an authority manager. + * + * `sheafify({ name, sections })` returns a `Sheaf` — an immutable object + * that tracks granted authority and produces revocable dispatch sections. + * + * Each dispatch through a granted section: + * 1. Computes the stalk (getStalk — presheaf sections matching the point) + * 2. Collapses equivalent germs (same metadata → one representative) + * 3. Decomposes metadata into constraints + options + * 4. Invokes the lift on the distinguished options + * 5. Dispatches to some element of the opted germ + */ + +import { makeExo } from '@endo/exo'; +import { + M, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +import { stringify } from '../stringify.ts'; +import { collectSheafGuard } from './guard.ts'; +import type { MethodGuardPayload } from './guard.ts'; +import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; +import type { ResolvedMetaDataSpec } from './metadata.ts'; +import { getStalk, guardCoversPoint } from './stalk.ts'; +import type { + EvaluatedSection, + Lift, + PresheafSection, + Section, + Sheaf, +} from './types.ts'; + +/** + * Serialize metadata for equivalence-class keying (collapse step). + * + * @param metadata - The metadata value to serialize. + * @returns A string key for equivalence comparison. + */ +const metadataKey = (metadata: unknown): string => { + if (metadata === undefined || metadata === null) { + return 'null'; + } + if (typeof metadata !== 'object') { + return JSON.stringify(metadata); + } + const entries = Object.entries(metadata as Record).sort( + ([a], [b]) => a.localeCompare(b), + ); + return JSON.stringify(entries); +}; + +/** + * Collapse stalk entries into equivalence classes (germs) by metadata identity. + * Returns one representative per class; the choice within a class is arbitrary. + * + * @param stalk - The stalk entries to collapse. + * @returns One representative per equivalence class. + */ +const collapseEquivalent = ( + stalk: EvaluatedSection[], +): EvaluatedSection[] => { + const seen = new Set(); + const representatives: EvaluatedSection[] = []; + for (const entry of stalk) { + const key = metadataKey(entry.metadata); + if (!seen.has(key)) { + seen.add(key); + representatives.push(entry); + } + } + return representatives; +}; + +/** + * Decompose stalk metadata into constraints (shared by all germs) and + * stripped germs (carrying only distinguishing keys). + * + * @param stalk - The collapsed stalk entries. + * @returns Constraints and stripped germs. + */ +const decomposeMetadata = ( + stalk: EvaluatedSection[], +): { + constraints: Partial; + stripped: EvaluatedSection>[]; +} => { + const constraints: Record = {}; + + const first = stalk[0]?.metadata; + if (first !== undefined && first !== null && typeof first === 'object') { + for (const key of Object.keys(first as Record)) { + const val = (first as Record)[key]; + const shared = stalk.every((entry) => { + if ( + entry.metadata === undefined || + entry.metadata === null || + typeof entry.metadata !== 'object' + ) { + return false; + } + const meta = entry.metadata as Record; + return key in meta && meta[key] === val; + }); + if (shared) { + constraints[key] = val; + } + } + } + + const stripped = stalk.map((entry) => { + if ( + entry.metadata === undefined || + entry.metadata === null || + typeof entry.metadata !== 'object' + ) { + return { exo: entry.exo }; + } + const remaining: Record = {}; + for (const [key, val] of Object.entries( + entry.metadata as Record, + )) { + if (!(key in constraints)) { + remaining[key] = val; + } + } + return { exo: entry.exo, metadata: remaining as Partial }; + }); + + return { constraints: constraints as Partial, stripped }; +}; + +/** + * Upgrade all method guards to M.callWhen for async dispatch. + * + * @param resolvedGuard - The interface guard to upgrade. + * @returns A record of async method guards. + */ +const asyncifyMethodGuards = ( + resolvedGuard: InterfaceGuard, +): Record => { + const { methodGuards: resolvedMethodGuards } = getInterfaceGuardPayload( + resolvedGuard, + ) as unknown as { methodGuards: Record }; + + const asyncMethodGuards: Record = {}; + for (const [methodName, methodGuard] of Object.entries( + resolvedMethodGuards, + )) { + const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = + getMethodGuardPayload(methodGuard) as unknown as MethodGuardPayload; + const optionals = optionalArgGuards ?? []; + const base = M.callWhen(...argGuards); + if (optionals.length > 0 && restArgGuard !== undefined) { + asyncMethodGuards[methodName] = base + .optional(...optionals) + .rest(restArgGuard) + .returns(returnGuard); + } else if (optionals.length > 0) { + asyncMethodGuards[methodName] = base + .optional(...optionals) + .returns(returnGuard); + } else if (restArgGuard === undefined) { + asyncMethodGuards[methodName] = base.returns(returnGuard); + } else { + asyncMethodGuards[methodName] = base + .rest(restArgGuard) + .returns(returnGuard); + } + } + return asyncMethodGuards; +}; + +type Grant = { + exo: Section; + guard: InterfaceGuard; + revoke: () => void; + isRevoked: () => boolean; +}; + +type ResolvedSection = { + exo: Section; + spec: ResolvedMetaDataSpec | undefined; +}; + +export const sheafify = ({ + name, + sections, + compartment, +}: { + name: string; + sections: PresheafSection[]; + compartment?: { evaluate: (src: string) => unknown }; +}): Sheaf => { + const frozenSections: readonly ResolvedSection[] = Object.freeze( + sections.map((section) => ({ + exo: section.exo, + spec: + section.metadata === undefined + ? undefined + : resolveMetaDataSpec(section.metadata, compartment), + })), + ); + const grants: Grant[] = []; + + const getSection = ({ + guard, + lift, + }: { + guard: InterfaceGuard; + lift: Lift; + }): object => { + const resolvedGuard = guard; + + const asyncMethodGuards = asyncifyMethodGuards(resolvedGuard); + const asyncGuard = M.interface(`${name}:section`, asyncMethodGuards); + + let revoked = false; + + const dispatch = async ( + method: string, + args: unknown[], + ): Promise => { + if (revoked) { + throw new Error(`Section revoked: ${name}`); + } + + const stalk = getStalk(frozenSections, method, args); + const evaluatedStalk: EvaluatedSection[] = stalk.map( + (section) => { + const metadata = evaluateMetadata(section.spec, args); + return metadata === undefined + ? { exo: section.exo } + : { exo: section.exo, metadata }; + }, + ); + let winner: EvaluatedSection; + switch (evaluatedStalk.length) { + case 0: + throw new Error(`No section covers ${method}(${stringify(args, 0)})`); + case 1: + winner = evaluatedStalk[0] as EvaluatedSection; + break; + default: { + const collapsed = collapseEquivalent(evaluatedStalk); + if (collapsed.length === 1) { + winner = collapsed[0] as EvaluatedSection; + break; + } + const { constraints, stripped } = decomposeMetadata(collapsed); + const index = await lift(stripped, { method, args, constraints }); + winner = collapsed[index] as EvaluatedSection; + break; + } + } + + const obj = winner.exo as Record unknown>; + const fn = obj[method]; + if (fn === undefined) { + throw new Error(`Section has guard for '${method}' but no handler`); + } + return fn.call(obj, ...args); + }; + + const handlers: Record Promise> = + {}; + for (const method of Object.keys(asyncMethodGuards)) { + handlers[method] = async (...args: unknown[]) => dispatch(method, args); + } + + const exo = makeExo( + `${name}:section`, + asyncGuard, + handlers, + ) as unknown as Section; + + grants.push({ + exo, + guard: resolvedGuard, + revoke: () => { + revoked = true; + }, + isRevoked: () => revoked, + }); + + return exo; + }; + + const getGlobalSection = ({ lift }: { lift: Lift }): object => { + return getSection({ + guard: collectSheafGuard( + name, + frozenSections.map(({ exo }) => exo), + ), + lift, + }); + }; + + const revokePoint = (method: string, ...args: unknown[]): void => { + for (const grant of grants) { + if (!grant.isRevoked() && guardCoversPoint(grant.guard, method, args)) { + grant.revoke(); + } + } + }; + + const getExported = (): InterfaceGuard | undefined => { + const activeExos = grants + .filter((grant) => !grant.isRevoked()) + .map((grant) => grant.exo); + if (activeExos.length === 0) { + return undefined; + } + return collectSheafGuard(`${name}:exported`, activeExos); + }; + + const revokeAll = (): void => { + for (const grant of grants) { + if (!grant.isRevoked()) { + grant.revoke(); + } + } + }; + + return { + getSection, + getGlobalSection, + revokePoint, + getExported, + revokeAll, + }; +}; diff --git a/packages/kernel-utils/src/sheaf/stalk.test.ts b/packages/kernel-utils/src/sheaf/stalk.test.ts new file mode 100644 index 000000000..534f576b5 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/stalk.test.ts @@ -0,0 +1,169 @@ +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import type { MethodGuard } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { constant } from './metadata.ts'; +import { getStalk } from './stalk.ts'; +import type { PresheafSection, Section } from './types.ts'; + +const makePresheafSection = ( + tag: string, + guards: Record, + methods: Record unknown>, + metadata: { cost: number }, +): PresheafSection<{ cost: number }> => { + const interfaceGuard = M.interface(tag, guards); + const exo = makeExo(tag, interfaceGuard, methods); + return { exo: exo as unknown as Section, metadata: constant(metadata) }; +}; + +describe('getStalk', () => { + it('returns matching sections for a method and args', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + makePresheafSection( + 'B', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 2 }, + ), + ]; + + const stalk = getStalk(sections, 'add', [1, 2]); + expect(stalk).toHaveLength(2); + }); + + it('filters out sections without matching method', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + makePresheafSection( + 'B', + { sub: M.call(M.number()).returns(M.number()) }, + { sub: (a: number) => -a }, + { cost: 2 }, + ), + ]; + + const stalk = getStalk(sections, 'add', [1]); + expect(stalk).toHaveLength(1); + expect(stalk[0]!.metadata).toStrictEqual(constant({ cost: 1 })); + }); + + it('filters out sections with arg count mismatch', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(sections, 'add', [1]); + expect(stalk).toHaveLength(0); + }); + + it('filters out sections with arg type mismatch', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(sections, 'add', ['not-a-number']); + expect(stalk).toHaveLength(0); + }); + + it('returns empty array when no sections match', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.eq('alice')).returns(M.number()) }, + { add: (_a: string) => 42 }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(sections, 'add', ['bob']); + expect(stalk).toHaveLength(0); + }); + + it('matches sections with optional args when optional arg is provided', () => { + const sections = [ + makePresheafSection( + 'A', + { + greet: M.callWhen(M.string()) + .optional(M.string()) + .returns(M.string()), + }, + { greet: (name: string, _greeting?: string) => `hello ${name}` }, + { cost: 1 }, + ), + ]; + + expect(getStalk(sections, 'greet', ['alice'])).toHaveLength(1); + expect(getStalk(sections, 'greet', ['alice', 'hi'])).toHaveLength(1); + expect(getStalk(sections, 'greet', [])).toHaveLength(0); + expect(getStalk(sections, 'greet', ['alice', 'hi', 'extra'])).toHaveLength( + 0, + ); + }); + + it('matches sections with rest args', () => { + const sections = [ + makePresheafSection( + 'A', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + { cost: 1 }, + ), + ]; + + expect(getStalk(sections, 'log', ['info'])).toHaveLength(1); + expect(getStalk(sections, 'log', ['info', 'msg'])).toHaveLength(1); + expect(getStalk(sections, 'log', ['info', 'msg', 'extra'])).toHaveLength(1); + expect(getStalk(sections, 'log', [])).toHaveLength(0); + expect(getStalk(sections, 'log', [42])).toHaveLength(0); + }); + + it('returns all sections when all match', () => { + const sections = [ + makePresheafSection( + 'A', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 1 }, + { cost: 1 }, + ), + makePresheafSection( + 'B', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 2 }, + { cost: 2 }, + ), + makePresheafSection( + 'C', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 3 }, + { cost: 3 }, + ), + ]; + + const stalk = getStalk(sections, 'f', ['hello']); + expect(stalk).toHaveLength(3); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/stalk.ts b/packages/kernel-utils/src/sheaf/stalk.ts new file mode 100644 index 000000000..f7988ba15 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/stalk.ts @@ -0,0 +1,80 @@ +/** + * Stalk computation: filter presheaf sections by guard matching. + */ + +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +import type { MethodGuardPayload } from './guard.ts'; +import type { Section } from './types.ts'; + +/** + * Check whether an interface guard covers the invocation point (method, args). + * + * @param guard - The interface guard to test. + * @param method - The method name being invoked. + * @param args - The arguments to the method invocation. + * @returns True if the guard accepts the invocation. + */ +export const guardCoversPoint = ( + guard: InterfaceGuard, + method: string, + args: unknown[], +): boolean => { + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + if (!(method in methodGuards)) { + return false; + } + const methodGuard = methodGuards[method]; + if (!methodGuard) { + return false; + } + const { argGuards, optionalArgGuards, restArgGuard } = getMethodGuardPayload( + methodGuard, + ) as unknown as MethodGuardPayload; + const optionals = optionalArgGuards ?? []; + const maxFixedArgs = argGuards.length + optionals.length; + return ( + args.length >= argGuards.length && + (restArgGuard !== undefined || args.length <= maxFixedArgs) && + args + .slice(0, argGuards.length) + .every((arg, i) => matches(arg, argGuards[i])) && + args + .slice(argGuards.length, maxFixedArgs) + .every((arg, i) => matches(arg, optionals[i])) && + (restArgGuard === undefined || + args.slice(maxFixedArgs).every((arg) => matches(arg, restArgGuard))) + ); +}; + +/** + * Get the stalk at an invocation point. + * + * Returns the presheaf sections whose guards accept the given method + args. + * + * @param sections - The presheaf sections to filter. + * @param method - The method name being invoked. + * @param args - The arguments to the method invocation. + * @returns The presheaf sections whose guards accept the invocation. + */ +export const getStalk = ( + sections: readonly T[], + method: string, + args: unknown[], +): T[] => { + return sections.filter(({ exo }) => { + const interfaceGuard = exo[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + return false; + } + return guardCoversPoint(interfaceGuard, method, args); + }); +}; diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts new file mode 100644 index 000000000..ad31e5e1d --- /dev/null +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -0,0 +1,98 @@ +/** + * Sheaf 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 } 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 metadata specification: either a static value, a JS source string, or a + * live function. Source strings are compiled once at sheafify construction time. + */ +export type MetaDataSpec = + | { kind: 'constant'; value: M } + | { kind: 'source'; src: string } + | { kind: 'callable'; fn: (args: unknown[]) => M }; + +/** + * A presheaf section: a section (F_sem) paired with an optional metadata spec (F_op). + * + * This is the input data to sheafify — an (exo, metadata) pair assigned over + * the open set defined by the exo's guard. + */ +export type PresheafSection = { + exo: Section; + metadata?: MetaDataSpec; +}; + +/** + * A section with evaluated metadata: the metadata spec has been computed against + * the invocation args, yielding a concrete value. Used internally during dispatch + * and as the element type of the `germs` array received by Lift (where each entry + * is already a representative of an equivalence class after collapsing). + */ +export type EvaluatedSection = { + exo: Section; + metadata?: MetaData; +}; + +/** + * Context passed to the lift alongside the stalk. + * + * `constraints` holds metadata keys whose values are identical across every + * germ in the stalk — these are topologically determined and not a choice. + * Typed as `Partial` because the actual partition is runtime-dependent. + */ +export type LiftContext = { + method: string; + args: unknown[]; + constraints: Partial; +}; + +/** + * Lift: selects one germ from the stalk when multiple germs remain after + * collapsing equivalent presheaf sections. + * + * Each germ carries only distinguishing metadata (options); shared metadata + * (constraints) is delivered separately in the context. + * + * Returns a Promise — the index into the germs array. + */ +export type Lift = ( + germs: EvaluatedSection>[], + context: LiftContext, +) => Promise; + +/** + * A presheaf: a plain array of presheaf sections. + */ +export type Presheaf = PresheafSection[]; + +/** + * A sheaf: an authority manager over a presheaf. + * + * Produces revocable dispatch sections via `getSection` and tracks all + * granted authority for auditing and revocation. + */ +export type Sheaf = { + /** Produce a revocable dispatch exo over the given guard. */ + getSection: (opts: { guard: InterfaceGuard; lift: Lift }) => object; + /** Produce a revocable dispatch exo over the full union guard of all presheaf sections. */ + getGlobalSection: (opts: { lift: Lift }) => object; + /** Revoke every granted section whose guard covers the point (method, ...args). */ + revokePoint: (method: string, ...args: unknown[]) => void; + /** Union guard of all active (non-revoked) granted sections, or undefined. */ + getExported: () => InterfaceGuard | undefined; + /** Revoke all granted sections. */ + revokeAll: () => void; +};