Last active
February 18, 2026 18:54
-
-
Save mikesol/f30bc6960cd156bf7ee7444eb2e6b988 to your computer and use it in GitHub Desktop.
Expr<O, R> spike: content-addressed DAG with type-safe AST rewrites (issue #190)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Bidirectional graph + type-level gc via parent chains | |
| // ============================================================ | |
| // | |
| // Hypothesis: storing reverse adj (child → parents) enables gc | |
| // without forward graph traversal. Each orphan check walks UP | |
| // the parent chain to root — bounded by tree DEPTH, not node COUNT. | |
| // ============================================================ | |
| // ============================================================ | |
| // Core types | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| // RevEntry: who are my parents? | |
| type RevEntry<ParentIDs extends string[]> = { | |
| readonly parents: ParentIDs; | |
| }; | |
| // Desc now carries both forward and reverse adj | |
| type Desc< | |
| Out, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| Rev extends Record<string, any>, | |
| C extends string, | |
| > = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly rev: Rev; | |
| readonly c: C; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| } | |
| type IdOf<R> = R extends Desc<any, infer ID, any, any, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A, any, any> ? A : never; | |
| type RevOf<R> = R extends Desc<any, any, any, infer Rev, any> ? Rev : never; | |
| type CtrOf<R> = R extends Desc<any, any, any, any, infer C> ? C : never; | |
| // ============================================================ | |
| // IsLive: walk parent chain to root — O(depth) recursion | |
| // ============================================================ | |
| type IsLive< | |
| Rev extends Record<string, any>, | |
| RootID extends string, | |
| K extends string, | |
| Visited extends string = never, | |
| > = | |
| K extends Visited ? false : // cycle guard | |
| K extends RootID ? true : | |
| K extends keyof Rev | |
| ? Rev[K] extends RevEntry<infer Parents extends string[]> | |
| ? AnyParentLive<Rev, RootID, Parents, Visited | K> | |
| : false | |
| : false; | |
| type AnyParentLive< | |
| Rev extends Record<string, any>, | |
| RootID extends string, | |
| Parents extends string[], | |
| Visited extends string, | |
| > = | |
| Parents extends [infer H extends string, ...infer T extends string[]] | |
| ? IsLive<Rev, RootID, H, Visited> extends true ? true | |
| : AnyParentLive<Rev, RootID, T, Visited> | |
| : false; | |
| // ============================================================ | |
| // GC: filter adj to live nodes only | |
| // ============================================================ | |
| type GCAdj< | |
| Adj extends Record<string, any>, | |
| Rev extends Record<string, any>, | |
| RootID extends string, | |
| > = { | |
| [K in keyof Adj as K extends string | |
| ? IsLive<Rev, RootID, K> extends true ? K : never | |
| : never | |
| ]: Adj[K]; | |
| }; | |
| type GCRev< | |
| Rev extends Record<string, any>, | |
| Adj extends Record<string, any>, | |
| RootID extends string, | |
| > = { | |
| [K in keyof Rev as K extends string | |
| ? IsLive<Rev, RootID, K> extends true ? K : never | |
| : never | |
| ]: Rev[K]; | |
| }; | |
| // ============================================================ | |
| // Test 1: clean graph — nothing removed | |
| // ============================================================ | |
| // (3 + 4) * 5 | |
| // a: lit(3), b: lit(4), c: add(a,b), d: lit(5), e: mul(c,d) | |
| type TA = { | |
| a: NodeEntry<"num/literal", [], number>; | |
| b: NodeEntry<"num/literal", [], number>; | |
| c: NodeEntry<"num/add", ["a", "b"], number>; | |
| d: NodeEntry<"num/literal", [], number>; | |
| e: NodeEntry<"num/mul", ["c", "d"], number>; | |
| }; | |
| type TR = { | |
| a: RevEntry<["c"]>; // a's parent is c | |
| b: RevEntry<["c"]>; // b's parent is c | |
| c: RevEntry<["e"]>; // c's parent is e | |
| d: RevEntry<["e"]>; // d's parent is e | |
| e: RevEntry<[]>; // root, no parents | |
| }; | |
| type GC1 = GCAdj<TA, TR, "e">; | |
| type GC1Keys = keyof GC1; | |
| // All nodes reachable | |
| const _1a: GC1Keys = "a"; | |
| const _1b: GC1Keys = "b"; | |
| const _1c: GC1Keys = "c"; | |
| const _1d: GC1Keys = "d"; | |
| const _1e: GC1Keys = "e"; | |
| // ============================================================ | |
| // Test 2: direct orphan — removed by single pass | |
| // ============================================================ | |
| type TA2 = TA & { | |
| x: NodeEntry<"orphan", [], string>; | |
| }; | |
| type TR2 = TR & { | |
| x: RevEntry<[]>; // no parents | |
| }; | |
| type GC2 = GCAdj<TA2, TR2, "e">; | |
| type GC2Keys = keyof GC2; | |
| const _2a: GC2Keys = "a"; | |
| const _2e: GC2Keys = "e"; | |
| // @ts-expect-error — x is orphan, removed | |
| const _2x: GC2Keys = "x"; | |
| // ============================================================ | |
| // Test 3: ORPHAN CHAIN — the hard case | |
| // ============================================================ | |
| type TA3 = TA & { | |
| x: NodeEntry<"orphan/root", [], string>; | |
| y: NodeEntry<"orphan/mid", ["x"], string>; | |
| z: NodeEntry<"orphan/leaf", ["y"], string>; | |
| }; | |
| type TR3 = TR & { | |
| x: RevEntry<["y"]>; // x's parent is y | |
| y: RevEntry<["z"]>; // y's parent is z | |
| z: RevEntry<[]>; // z has no parent — chain root | |
| }; | |
| type GC3 = GCAdj<TA3, TR3, "e">; | |
| type GC3Keys = keyof GC3; | |
| // Original nodes still live | |
| const _3a: GC3Keys = "a"; | |
| const _3e: GC3Keys = "e"; | |
| // ENTIRE orphan chain removed — IsLive walks up z→(no parent)→dead, | |
| // then y→z→dead, then x→y→z→dead | |
| // @ts-expect-error — x is in orphan chain | |
| const _3x: GC3Keys = "x"; | |
| // @ts-expect-error — y is in orphan chain | |
| const _3y: GC3Keys = "y"; | |
| // @ts-expect-error — z is orphan chain root | |
| const _3z: GC3Keys = "z"; | |
| // ============================================================ | |
| // Test 4: diamond DAG — shared node reachable via either parent | |
| // ============================================================ | |
| type TA4 = { | |
| a: NodeEntry<"val", [], number>; | |
| b: NodeEntry<"use1", ["a"], number>; | |
| c: NodeEntry<"use2", ["a"], number>; | |
| d: NodeEntry<"join", ["b", "c"], number>; | |
| }; | |
| type TR4 = { | |
| a: RevEntry<["b", "c"]>; // a has TWO parents | |
| b: RevEntry<["d"]>; | |
| c: RevEntry<["d"]>; | |
| d: RevEntry<[]>; | |
| }; | |
| type GC4 = GCAdj<TA4, TR4, "d">; | |
| type GC4Keys = keyof GC4; | |
| const _4a: GC4Keys = "a"; | |
| const _4b: GC4Keys = "b"; | |
| const _4c: GC4Keys = "c"; | |
| const _4d: GC4Keys = "d"; | |
| // ============================================================ | |
| // Test 5: mixed — some live, some orphaned, some in chains | |
| // ============================================================ | |
| type TA5 = { | |
| a: NodeEntry<"lit", [], number>; | |
| b: NodeEntry<"lit", [], number>; | |
| c: NodeEntry<"add", ["a", "b"], number>; | |
| // orphan island | |
| p: NodeEntry<"dead/1", [], string>; | |
| q: NodeEntry<"dead/2", ["p"], string>; | |
| // another orphan | |
| r: NodeEntry<"dead/3", [], boolean>; | |
| }; | |
| type TR5 = { | |
| a: RevEntry<["c"]>; | |
| b: RevEntry<["c"]>; | |
| c: RevEntry<[]>; // root | |
| p: RevEntry<["q"]>; | |
| q: RevEntry<[]>; // orphan chain root | |
| r: RevEntry<[]>; // lone orphan | |
| }; | |
| type GC5 = GCAdj<TA5, TR5, "c">; | |
| type GC5Keys = keyof GC5; | |
| const _5a: GC5Keys = "a"; | |
| const _5b: GC5Keys = "b"; | |
| const _5c: GC5Keys = "c"; | |
| // @ts-expect-error — p in orphan chain | |
| const _5p: GC5Keys = "p"; | |
| // @ts-expect-error — q orphan chain root | |
| const _5q: GC5Keys = "q"; | |
| // @ts-expect-error — r lone orphan | |
| const _5r: GC5Keys = "r"; | |
| // ============================================================ | |
| // Test 6: deeper chain — depth 6 (tests recursion limits) | |
| // ============================================================ | |
| type TA6 = { | |
| a: NodeEntry<"n", [], number>; | |
| b: NodeEntry<"n", ["a"], number>; | |
| c: NodeEntry<"n", ["b"], number>; | |
| d: NodeEntry<"n", ["c"], number>; | |
| e: NodeEntry<"n", ["d"], number>; | |
| f: NodeEntry<"n", ["e"], number>; | |
| // orphan chain depth 6 | |
| o1: NodeEntry<"dead", [], string>; | |
| o2: NodeEntry<"dead", ["o1"], string>; | |
| o3: NodeEntry<"dead", ["o2"], string>; | |
| o4: NodeEntry<"dead", ["o3"], string>; | |
| o5: NodeEntry<"dead", ["o4"], string>; | |
| o6: NodeEntry<"dead", ["o5"], string>; | |
| }; | |
| type TR6 = { | |
| a: RevEntry<["b"]>; | |
| b: RevEntry<["c"]>; | |
| c: RevEntry<["d"]>; | |
| d: RevEntry<["e"]>; | |
| e: RevEntry<["f"]>; | |
| f: RevEntry<[]>; // root | |
| o1: RevEntry<["o2"]>; | |
| o2: RevEntry<["o3"]>; | |
| o3: RevEntry<["o4"]>; | |
| o4: RevEntry<["o5"]>; | |
| o5: RevEntry<["o6"]>; | |
| o6: RevEntry<[]>; // orphan chain root | |
| }; | |
| type GC6 = GCAdj<TA6, TR6, "f">; | |
| type GC6Keys = keyof GC6; | |
| // Live chain | |
| const _6a: GC6Keys = "a"; | |
| const _6f: GC6Keys = "f"; | |
| // Dead chain — all 6 removed | |
| // @ts-expect-error — o1 in orphan chain | |
| const _6o1: GC6Keys = "o1"; | |
| // @ts-expect-error — o6 orphan chain root | |
| const _6o6: GC6Keys = "o6"; | |
| // ============================================================ | |
| // Test 7: compare recursion — forward vs reverse | |
| // ============================================================ | |
| // Forward Reachable from spike-typed-transforms.ts (for comparison): | |
| type ReachableFwd<Adj, ID extends string, Visited extends string = never> = | |
| ID extends Visited ? Visited : | |
| ID extends keyof Adj | |
| ? Adj[ID] extends NodeEntry<any, infer Children extends string[], any> | |
| ? ReachableAllFwd<Adj, Children, Visited | ID> | |
| : Visited | ID | |
| : Visited | ID; | |
| type ReachableAllFwd<Adj, IDs extends string[], Visited extends string> = | |
| IDs extends [infer H extends string, ...infer T extends string[]] | |
| ? ReachableAllFwd<Adj, T, ReachableFwd<Adj, H, Visited>> | |
| : Visited; | |
| // Forward gc processes ALL nodes in one recursive tree. | |
| // Depth = total reachable nodes (worst case = all nodes). | |
| // | |
| // Reverse gc checks each node independently. | |
| // Depth per node = path from node to root via parents. | |
| // For a balanced binary tree of 1000 nodes: depth ~10. | |
| // Forward would be: depth ~1000. | |
| // | |
| // The reverse approach scales to much larger graphs. | |
| // ============================================================ | |
| // Verify: forward and reverse agree on test 5 | |
| // ============================================================ | |
| type FwdGC5 = { | |
| [K in keyof TA5 as K extends ReachableFwd<TA5, "c"> ? K : never]: TA5[K]; | |
| }; | |
| type FwdGC5Keys = keyof FwdGC5; | |
| // Both approaches produce the same result | |
| const _fwd5a: FwdGC5Keys = "a"; | |
| const _fwd5b: FwdGC5Keys = "b"; | |
| const _fwd5c: FwdGC5Keys = "c"; | |
| // @ts-expect-error | |
| const _fwd5p: FwdGC5Keys = "p"; | |
| // @ts-expect-error | |
| const _fwd5q: FwdGC5Keys = "q"; | |
| // @ts-expect-error | |
| const _fwd5r: FwdGC5Keys = "r"; | |
| console.log("spike-bidir-gc compiled successfully"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Bridging types and runtime — no `any`, no `declare` | |
| // ============================================================ | |
| // | |
| // Previous spikes proved types and runtime SEPARATELY: | |
| // - spike-typed-transforms.ts: `declare function` (no runtime) | |
| // - spike-dagql-runtime.ts: real runtime (all `any` types) | |
| // | |
| // This spike proves they work TOGETHER in a single implementation. | |
| // ============================================================ | |
| import { IDChain } from "./spike-idchain"; | |
| // ============================================================ | |
| // Core types | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; readonly id: ID; readonly adj: Adj; readonly c: C; | |
| }; | |
| type NextID<C extends keyof IDChain> = IDChain[C]; | |
| declare const exprBrand: unique symbol; | |
| // Expr: branded with phantom type, but also carries runtime data | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly __id: string; | |
| readonly __adj: Record<string, { kind: string; children: string[]; out: unknown }>; | |
| readonly __counter: string; | |
| } | |
| type IdOf<R> = R extends Desc<any, infer ID, any, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A, any> ? A : never; | |
| type CtrOf<R> = R extends Desc<any, any, any, infer C> ? C : never; | |
| // ============================================================ | |
| // Predicates | |
| // ============================================================ | |
| interface KindPred<K extends string> { | |
| readonly tag: "kind"; | |
| readonly kind: K; | |
| readonly eval: (e: { kind: string; children: string[]; out: unknown }) => boolean; | |
| } | |
| interface KindGlobPred<P extends string> { | |
| readonly tag: "kindGlob"; | |
| readonly prefix: P; | |
| readonly eval: (e: { kind: string; children: string[]; out: unknown }) => boolean; | |
| } | |
| interface LeafPred { | |
| readonly tag: "leaf"; | |
| readonly eval: (e: { kind: string; children: string[]; out: unknown }) => boolean; | |
| } | |
| interface NotPred<Inner> { | |
| readonly tag: "not"; | |
| readonly inner: Inner; | |
| readonly eval: (e: { kind: string; children: string[]; out: unknown }) => boolean; | |
| } | |
| interface AndPred<A, B> { | |
| readonly tag: "and"; | |
| readonly left: A; | |
| readonly right: B; | |
| readonly eval: (e: { kind: string; children: string[]; out: unknown }) => boolean; | |
| } | |
| type EvalPred<P, Entry> = | |
| P extends KindPred<infer K> | |
| ? Entry extends NodeEntry<K, any, any> ? true : false | |
| : P extends KindGlobPred<infer Prefix> | |
| ? Entry extends NodeEntry<`${Prefix}${string}`, any, any> ? true : false | |
| : P extends LeafPred | |
| ? Entry extends NodeEntry<any, infer C, any> ? C extends [] ? true : false : false | |
| : P extends NotPred<infer Inner> | |
| ? EvalPred<Inner, Entry> extends true ? false : true | |
| : P extends AndPred<infer A, infer B> | |
| ? EvalPred<A, Entry> extends true | |
| ? EvalPred<B, Entry> extends true ? true : false : false | |
| : never; | |
| // ============================================================ | |
| // Predicate constructors (real runtime) | |
| // ============================================================ | |
| type RE = { kind: string; children: string[]; out: unknown }; | |
| function byKind<K extends string>(k: K): KindPred<K> { | |
| return { tag: "kind", kind: k, eval: (e: RE) => e.kind === k }; | |
| } | |
| function byKindGlob<P extends string>(prefix: P): KindGlobPred<P> { | |
| return { tag: "kindGlob", prefix, eval: (e: RE) => e.kind.startsWith(prefix) }; | |
| } | |
| function isLeaf(): LeafPred { | |
| return { tag: "leaf", eval: (e: RE) => e.children.length === 0 }; | |
| } | |
| function not<Inner extends { eval: (e: RE) => boolean }>(inner: Inner): NotPred<Inner> { | |
| return { tag: "not", inner, eval: (e: RE) => !inner.eval(e) }; | |
| } | |
| function and< | |
| A extends { eval: (e: RE) => boolean }, | |
| B extends { eval: (e: RE) => boolean }, | |
| >(a: A, b: B): AndPred<A, B> { | |
| return { tag: "and", left: a, right: b, eval: (e: RE) => a.eval(e) && b.eval(e) }; | |
| } | |
| // ============================================================ | |
| // MapAdj type (from spike-typed-transforms, proven) | |
| // ============================================================ | |
| type MatchingEntries<Adj, P> = { | |
| [K in keyof Adj]: EvalPred<P, Adj[K]> extends true ? Adj[K] : never; | |
| }[keyof Adj]; | |
| type MapAdj<Adj, P, NewEntry> = { | |
| [K in keyof Adj]: EvalPred<P, Adj[K]> extends true ? NewEntry : Adj[K]; | |
| }; | |
| type MapOut<O, Adj, RootID extends string & keyof Adj, P, NewEntry> = | |
| EvalPred<P, Adj[RootID]> extends true | |
| ? NewEntry extends NodeEntry<any, any, infer NO> ? NO : O | |
| : O; | |
| // ============================================================ | |
| // makeExpr: the ONE place we bridge types and runtime | |
| // ============================================================ | |
| function makeExpr< | |
| O, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| >( | |
| id: ID, | |
| adj: Record<string, { kind: string; children: string[]; out: unknown }>, | |
| counter: C, | |
| ): Expr<O, Desc<O, ID, Adj, C>> { | |
| return { | |
| __id: id, | |
| __adj: adj, | |
| __counter: counter, | |
| } as unknown as Expr<O, Desc<O, ID, Adj, C>>; | |
| // ^^^ This single cast is the bridge. The runtime fields (__id, __adj, __counter) | |
| // carry the data. The phantom type R tracks the precise types. We trust that | |
| // callers construct adj correctly — which is enforced by the typed API above them. | |
| } | |
| // ============================================================ | |
| // selectWhere: real runtime + real types | |
| // ============================================================ | |
| function selectWhere< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| P, | |
| >( | |
| expr: Expr<O, R>, | |
| pred: { eval: (e: RE) => boolean }, | |
| ): Set<string & keyof { [K in keyof AdjOf<R> as EvalPred<P, AdjOf<R>[K]> extends true ? K : never]: 1 }> { | |
| const result = new Set<string>(); | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if (!id.startsWith("@") && pred.eval(entry as RE)) { | |
| result.add(id); | |
| } | |
| } | |
| return result as any; // runtime Set<string> → typed Set<matched keys> | |
| } | |
| // ============================================================ | |
| // mapWhere: real runtime + real types | |
| // ============================================================ | |
| function mapWhere< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| P extends { eval: (e: RE) => boolean }, | |
| NE extends NodeEntry<string, string[], any>, | |
| >( | |
| expr: Expr<O, R>, | |
| pred: P, | |
| fn: (entry: MatchingEntries<AdjOf<R>, P>) => NE, | |
| ): Expr< | |
| MapOut<O, AdjOf<R>, IdOf<R> & string & keyof AdjOf<R>, P, NE>, | |
| Desc< | |
| MapOut<O, AdjOf<R>, IdOf<R> & string & keyof AdjOf<R>, P, NE>, | |
| IdOf<R>, | |
| MapAdj<AdjOf<R>, P, NE>, | |
| CtrOf<R> | |
| > | |
| > { | |
| const newAdj: Record<string, { kind: string; children: string[]; out: unknown }> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if (!id.startsWith("@") && pred.eval(entry as RE)) { | |
| newAdj[id] = fn(entry as MatchingEntries<AdjOf<R>, P>); | |
| } else { | |
| newAdj[id] = entry; | |
| } | |
| } | |
| return makeExpr(expr.__id, newAdj, expr.__counter) as any; | |
| } | |
| // ============================================================ | |
| // wrapByName: real runtime + real types | |
| // ============================================================ | |
| type RewireList<T extends string[], Old extends string, New extends string> = | |
| T extends [infer H extends string, ...infer Rest extends string[]] | |
| ? [H extends Old ? New : H, ...RewireList<Rest, Old, New>] | |
| : []; | |
| type RewireParents<Adj, TargetID extends string, WrapperID extends string> = { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<infer Kind, infer Children extends string[], infer Out> | |
| ? NodeEntry<Kind, RewireList<Children, TargetID, WrapperID>, Out> | |
| : Adj[K]; | |
| }; | |
| type WrapOneResult< | |
| Adj extends Record<string, any>, | |
| TargetID extends string, | |
| WrapperKind extends string, | |
| C extends string & keyof IDChain, | |
| > = RewireParents<Adj, TargetID, C> & Record< | |
| C, | |
| NodeEntry<WrapperKind, [TargetID], Adj[TargetID] extends NodeEntry<any, any, infer O> ? O : never> | |
| >; | |
| function wrapByName< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| TargetID extends string & keyof AdjOf<R>, | |
| WrapperKind extends string, | |
| >( | |
| expr: Expr<O, R>, | |
| targetId: TargetID, | |
| wrapperKind: WrapperKind, | |
| ): CtrOf<R> extends string & keyof IDChain | |
| ? Expr< | |
| O, | |
| Desc< | |
| O, | |
| IdOf<R> extends TargetID ? CtrOf<R> & string : IdOf<R>, | |
| WrapOneResult<AdjOf<R>, TargetID, WrapperKind, CtrOf<R> & keyof IDChain>, | |
| NextID<CtrOf<R> & keyof IDChain> & string | |
| > | |
| > | |
| : never { | |
| const wrapperId = expr.__counter; | |
| const targetEntry = expr.__adj[targetId] as RE; | |
| // Build new adj: rewire parents, add wrapper | |
| const newAdj: Record<string, { kind: string; children: string[]; out: unknown }> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| const e = entry as RE; | |
| newAdj[id] = { | |
| kind: e.kind, | |
| children: e.children.map((c: string) => c === targetId ? wrapperId : c), | |
| out: e.out, | |
| }; | |
| } | |
| // Add wrapper entry | |
| newAdj[wrapperId] = { | |
| kind: wrapperKind, | |
| children: [targetId], | |
| out: targetEntry.out, | |
| }; | |
| // Root changes if we wrapped the root | |
| const newRoot = expr.__id === targetId ? wrapperId : expr.__id; | |
| // Advance counter (runtime: look up next ID) | |
| // In real impl this would use a runtime ID chain. For spike, hardcode the pattern. | |
| const nextCounter = String.fromCharCode(wrapperId.charCodeAt(0) + 1); | |
| return makeExpr(newRoot, newAdj, nextCounter) as any; | |
| } | |
| // ============================================================ | |
| // BuildCtx: construct typed graphs with real runtime | |
| // ============================================================ | |
| interface BuildCtx<C extends string> { | |
| numLit<V extends number>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, Record<C, NodeEntry<"num/literal", [], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| strLit<V extends string>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<string, Desc<string, C, Record<C, NodeEntry<"str/literal", [], string>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| } | |
| function createCtx<C extends string>(counter: C): BuildCtx<C> { | |
| return { | |
| numLit(value: number) { | |
| const id = counter; | |
| const adj = { [id]: { kind: "num/literal", children: [], out: value } }; | |
| const next = String.fromCharCode(id.charCodeAt(0) + 1); | |
| return [makeExpr(id, adj, next), createCtx(next)] as any; | |
| }, | |
| add(left: Expr<any, any>, right: Expr<any, any>) { | |
| const id = counter; | |
| const adj = { | |
| ...left.__adj, | |
| ...right.__adj, | |
| [id]: { kind: "num/add", children: [left.__id, right.__id], out: ((left.__adj[left.__id] as RE).out as number) + ((right.__adj[right.__id] as RE).out as number) }, | |
| }; | |
| const next = String.fromCharCode(id.charCodeAt(0) + 1); | |
| return [makeExpr(id, adj, next), createCtx(next)] as any; | |
| }, | |
| mul(left: Expr<any, any>, right: Expr<any, any>) { | |
| const id = counter; | |
| const adj = { | |
| ...left.__adj, | |
| ...right.__adj, | |
| [id]: { kind: "num/mul", children: [left.__id, right.__id], out: ((left.__adj[left.__id] as RE).out as number) * ((right.__adj[right.__id] as RE).out as number) }, | |
| }; | |
| const next = String.fromCharCode(id.charCodeAt(0) + 1); | |
| return [makeExpr(id, adj, next), createCtx(next)] as any; | |
| }, | |
| strLit(value: string) { | |
| const id = counter; | |
| const adj = { [id]: { kind: "str/literal", children: [], out: value } }; | |
| const next = String.fromCharCode(id.charCodeAt(0) + 1); | |
| return [makeExpr(id, adj, next), createCtx(next)] as any; | |
| }, | |
| } as any; | |
| } | |
| // ============================================================ | |
| // TESTS: types AND runtime, no `declare`, no `any` in user code | |
| // ============================================================ | |
| let passed = 0; | |
| let failed = 0; | |
| function assert(cond: boolean, msg: string) { | |
| if (cond) { passed++; console.log(` ✓ ${msg}`); } | |
| else { failed++; console.log(` ✗ ${msg}`); } | |
| } | |
| // Build: (3 + 4) * 5 | |
| const $ = createCtx("a"); | |
| const [lit3, $1] = $.numLit(3); | |
| const [lit4, $2] = $1.numLit(4); | |
| const [sum, $3] = $2.add(lit3, lit4); | |
| const [lit5, $4] = $3.numLit(5); | |
| const [prod, _$5] = $4.mul(sum, lit5); | |
| // ---- Type-level: verify IDs ---- | |
| type ProdR = (typeof prod)[typeof exprBrand]; | |
| type ProdID = IdOf<ProdR>; | |
| const _prodId: ProdID = "e"; | |
| type ProdCtr = CtrOf<ProdR>; | |
| const _prodCtr: ProdCtr = "f"; | |
| console.log("Test 1: build — types and runtime agree on IDs"); | |
| assert(prod.__id === "e", "root ID is 'e'"); | |
| assert(prod.__counter === "f", "counter is 'f'"); | |
| assert(Object.keys(prod.__adj).length === 5, "5 nodes in adj"); | |
| console.log("Test 2: mapWhere byKind — rename num/add to num/sub"); | |
| const mapped = mapWhere(prod, byKind("num/add"), (entry) => ({ | |
| kind: "num/sub" as const, | |
| children: entry.children, | |
| out: entry.out, | |
| })); | |
| // Type-level: c is now num/sub | |
| type MR = (typeof mapped)[typeof exprBrand]; | |
| type MAdj = AdjOf<MR>; | |
| type MC = MAdj["c"]; | |
| const _mcKind: MC["kind"] = "num/sub"; | |
| // @ts-expect-error — c is no longer num/add | |
| const _mcWrong: MC["kind"] = "num/add"; | |
| // Runtime: verify | |
| assert(mapped.__id === "e", "root unchanged"); | |
| assert((mapped.__adj["c"] as RE).kind === "num/sub", "c is now num/sub"); | |
| assert((mapped.__adj["a"] as RE).kind === "num/literal", "a unchanged"); | |
| assert((mapped.__adj["e"] as RE).kind === "num/mul", "e unchanged"); | |
| console.log("Test 3: mapWhere byKindGlob — transform all num/* nodes"); | |
| const globbed = mapWhere(prod, byKindGlob("num/"), (_entry) => ({ | |
| kind: "opt/node" as const, | |
| children: [] as const, | |
| out: 0 as number, | |
| })); | |
| // Type-level: all nodes are opt/node (a,b,c,d,e are all num/*) | |
| type GR = (typeof globbed)[typeof exprBrand]; | |
| type GAdj = AdjOf<GR>; | |
| const _gaKind: GAdj["a"]["kind"] = "opt/node"; | |
| const _geKind: GAdj["e"]["kind"] = "opt/node"; | |
| // Runtime | |
| assert((globbed.__adj["a"] as RE).kind === "opt/node", "a → opt/node"); | |
| assert((globbed.__adj["c"] as RE).kind === "opt/node", "c → opt/node"); | |
| assert((globbed.__adj["e"] as RE).kind === "opt/node", "e → opt/node"); | |
| console.log("Test 4: mapWhere on non-matching — nothing changes"); | |
| const noMatch = mapWhere(prod, byKind("str/concat"), (_entry) => ({ | |
| kind: "never" as const, | |
| children: [] as const, | |
| out: "" as string, | |
| })); | |
| // Type-level: adj unchanged | |
| type NMAdj = AdjOf<(typeof noMatch)[typeof exprBrand]>; | |
| const _nmaKind: NMAdj["a"]["kind"] = "num/literal"; | |
| const _nmcKind: NMAdj["c"]["kind"] = "num/add"; | |
| // Runtime | |
| assert((noMatch.__adj["a"] as RE).kind === "num/literal", "a still num/literal"); | |
| assert((noMatch.__adj["c"] as RE).kind === "num/add", "c still num/add"); | |
| assert(noMatch.__id === "e", "root unchanged"); | |
| console.log("Test 5: mapWhere with and(glob, leaf) — only num literals"); | |
| const numLeaves = mapWhere(prod, and(byKindGlob("num/"), isLeaf()), (_entry) => ({ | |
| kind: "const/zero" as const, | |
| children: [] as const, | |
| out: 0 as number, | |
| })); | |
| // Type-level: a,b,d are leaves and num/ → a,b,d become const/zero | |
| // c (num/add) has children → not matched | |
| // e (num/mul) has children → not matched | |
| type NLAdj = AdjOf<(typeof numLeaves)[typeof exprBrand]>; | |
| const _nlaKind: NLAdj["a"]["kind"] = "const/zero"; | |
| const _nlcKind: NLAdj["c"]["kind"] = "num/add"; // unchanged! | |
| const _nleKind: NLAdj["e"]["kind"] = "num/mul"; // unchanged! | |
| // Runtime | |
| assert((numLeaves.__adj["a"] as RE).kind === "const/zero", "a → const/zero"); | |
| assert((numLeaves.__adj["b"] as RE).kind === "const/zero", "b → const/zero"); | |
| assert((numLeaves.__adj["c"] as RE).kind === "num/add", "c unchanged"); | |
| assert((numLeaves.__adj["d"] as RE).kind === "const/zero", "d → const/zero"); | |
| assert((numLeaves.__adj["e"] as RE).kind === "num/mul", "e unchanged"); | |
| console.log("Test 6: wrapByName — wrap node 'c' with telemetry/span"); | |
| const wrapped = wrapByName(prod, "c", "telemetry/span"); | |
| // Type-level | |
| type WR = (typeof wrapped)[typeof exprBrand]; | |
| type WAdj = AdjOf<WR>; | |
| type WWrapper = WAdj["f"]; | |
| const _wwKind: WWrapper["kind"] = "telemetry/span"; | |
| const _wwChildren: WWrapper["children"] = ["c"]; | |
| // Parent "e" now references "f" | |
| type WE = WAdj["e"]; | |
| const _weChildren: WE["children"] = ["f", "d"]; | |
| // @ts-expect-error — e no longer references "c" | |
| const _weWrong: WE["children"] = ["c", "d"]; | |
| // Runtime | |
| assert(wrapped.__id === "e", "root unchanged"); | |
| assert((wrapped.__adj["f"] as RE).kind === "telemetry/span", "wrapper at f"); | |
| assert((wrapped.__adj["f"] as RE).children[0] === "c", "wrapper child is c"); | |
| assert((wrapped.__adj["e"] as RE).children[0] === "f", "e now references f"); | |
| assert((wrapped.__adj["c"] as RE).kind === "num/add", "c still exists"); | |
| console.log("Test 7: wrapByName — wrap root 'e'"); | |
| const wrappedRoot = wrapByName(prod, "e", "telemetry/root"); | |
| // Type-level: root is now "f" | |
| type WRR = (typeof wrappedRoot)[typeof exprBrand]; | |
| type WRID = IdOf<WRR>; | |
| const _wrid: WRID = "f"; | |
| // Runtime | |
| assert(wrappedRoot.__id === "f", "root is now f"); | |
| assert((wrappedRoot.__adj["f"] as RE).kind === "telemetry/root", "wrapper is root"); | |
| assert((wrappedRoot.__adj["f"] as RE).children[0] === "e", "wrapper child is e"); | |
| console.log("Test 8: chained transforms — mapWhere then wrapByName"); | |
| const step1 = mapWhere(prod, byKind("num/add"), (entry) => ({ | |
| kind: "num/sub" as const, | |
| children: entry.children, | |
| out: entry.out, | |
| })); | |
| const step2 = wrapByName(step1, "c", "debug/trace"); | |
| // Type-level: c is num/sub, wrapper at f | |
| type S2R = (typeof step2)[typeof exprBrand]; | |
| type S2Adj = AdjOf<S2R>; | |
| const _s2cKind: S2Adj["c"]["kind"] = "num/sub"; | |
| const _s2fKind: S2Adj["f"]["kind"] = "debug/trace"; | |
| // Runtime | |
| assert((step2.__adj["c"] as RE).kind === "num/sub", "c is num/sub (from mapWhere)"); | |
| assert((step2.__adj["f"] as RE).kind === "debug/trace", "f is wrapper (from wrapByName)"); | |
| assert((step2.__adj["e"] as RE).children[0] === "f", "e points to wrapper f"); | |
| console.log(`\nResults: ${passed} passed, ${failed} failed`); | |
| if (failed > 0) process.exit(1); | |
| console.log("\nBridge spike: types and runtime agree!"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Cascading ID updates on node replacement | |
| // ============================================================ | |
| // | |
| // The fundamental write operation: replace a node, cascade ID | |
| // changes up through all ancestors. | |
| // | |
| // Approach: type-level recursive cascade. At each step: | |
| // 1. Find entries whose children reference the old ID | |
| // 2. Replace old ID with new ID in their children arrays | |
| // 3. Recompute their content-addressed IDs | |
| // 4. Repeat until no more changes | |
| // | |
| // Bounded by DAG depth, not total node count. | |
| // ============================================================ | |
| // Core types | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| } | |
| type OutOf<R> = R extends Desc<infer O, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A> ? A : never; | |
| type LitID<V extends string | number | boolean> = `lit(${V})`; | |
| type AddID<L extends string, R extends string> = `add(${L},${R})`; | |
| type MulID<L extends string, R extends string> = `mul(${L},${R})`; | |
| type NegID<A extends string> = `neg(${A})`; | |
| type NameAlias<Name extends string, TargetID extends string, Out> = { | |
| readonly kind: "@alias"; | |
| readonly target: TargetID; | |
| readonly out: Out; | |
| }; | |
| type NameKey<N extends string> = `@${N}`; | |
| // ============================================================ | |
| // ID generation: given a kind + children IDs, produce content ID | |
| // ============================================================ | |
| /** Recompute the content-addressed ID for an entry given new children */ | |
| type RecomputeID<Kind extends string, Children extends string[]> = | |
| Kind extends "num/literal" ? Children extends [] ? Kind : never // leaves keep their ID | |
| : Kind extends "num/add" ? Children extends [infer L extends string, infer R extends string] ? AddID<L, R> : never | |
| : Kind extends "num/mul" ? Children extends [infer L extends string, infer R extends string] ? MulID<L, R> : never | |
| : Kind extends "num/neg" ? Children extends [infer A extends string] ? NegID<A> : never | |
| : string; // fallback for unknown kinds | |
| // ============================================================ | |
| // Child replacement: swap OldID → NewID in a children array | |
| // ============================================================ | |
| type ReplaceInChildren<C extends string[], Old extends string, New extends string> = | |
| C extends [infer H extends string, ...infer T extends string[]] | |
| ? [H extends Old ? New : H, ...ReplaceInChildren<T, Old, New>] | |
| : []; | |
| // ============================================================ | |
| // Single cascade step: find all entries referencing OldID, | |
| // update their children, recompute their IDs | |
| // ============================================================ | |
| /** Does this entry reference OldID in its children? */ | |
| type ReferencesOld<Entry, OldID extends string> = | |
| Entry extends NodeEntry<any, infer C, any> | |
| ? OldID extends C[number] ? true : false | |
| : Entry extends NameAlias<any, infer T, any> | |
| ? T extends OldID ? true : false | |
| : false; | |
| /** Update a single entry: replace OldID with NewID in children */ | |
| type UpdateEntry<Entry, OldID extends string, NewID extends string> = | |
| Entry extends NodeEntry<infer K, infer C, infer O> | |
| ? NodeEntry<K, ReplaceInChildren<C, OldID, NewID>, O> | |
| : Entry extends NameAlias<infer N, infer T, infer O> | |
| ? NameAlias<N, T extends OldID ? NewID : T, O> | |
| : Entry; | |
| /** Result of processing one entry during cascade: its new ID and new entry */ | |
| type CascadedEntry<ID extends string, Entry, OldID extends string, NewID extends string> = | |
| ReferencesOld<Entry, OldID> extends true | |
| ? Entry extends NodeEntry<infer K, infer C, infer O> | |
| ? { | |
| oldId: ID; | |
| newId: RecomputeID<K, ReplaceInChildren<C, OldID, NewID>>; | |
| entry: NodeEntry<K, ReplaceInChildren<C, OldID, NewID>, O>; | |
| } | |
| : Entry extends NameAlias<infer N, infer T, infer O> | |
| ? { | |
| oldId: ID; | |
| newId: ID; // alias keys don't change | |
| entry: NameAlias<N, T extends OldID ? NewID : T, O>; | |
| } | |
| : never | |
| : { oldId: ID; newId: ID; entry: Entry }; // unchanged | |
| // ============================================================ | |
| // Full cascade: apply one replacement and propagate | |
| // ============================================================ | |
| /** Single-step cascade: replace OldID→NewID throughout the adj map. | |
| * Returns [newAdj, array of (oldId, newId) pairs for entries that changed] */ | |
| type CascadeStep<Adj extends Record<string, any>, OldID extends string, NewID extends string> = { | |
| [K in keyof Adj as | |
| K extends OldID ? NewID : // rename the key if it's the old ID | |
| CascadedEntry<K & string, Adj[K], OldID, NewID> extends { newId: infer NID } | |
| ? NID & string | |
| : K | |
| ]: K extends OldID | |
| ? Adj[K] // keep the entry but under new key... actually we're removing old, adding new | |
| : CascadedEntry<K & string, Adj[K], OldID, NewID> extends { entry: infer E } | |
| ? E | |
| : Adj[K]; | |
| }; | |
| // Hmm, the above is getting complex. Let me try a simpler approach: | |
| // Instead of cascading IDs through the adj map keys, separate the | |
| // cascade into phases: | |
| // | |
| // Phase 1: Replace the target entry (remove old, add new) | |
| // Phase 2: Update all children arrays referencing old ID | |
| // Phase 3: Recompute IDs for changed entries (this IS the cascade) | |
| // | |
| // Actually, let me try the simplest possible approach first and see if it works. | |
| // ============================================================ | |
| // Simpler approach: Replace + Rekey in one pass | |
| // ============================================================ | |
| /** Remove an entry by key */ | |
| type RemoveKey<Adj, K extends string> = { | |
| [ID in keyof Adj as ID extends K ? never : ID]: Adj[ID]; | |
| }; | |
| /** Add an entry */ | |
| type AddEntry<Adj, K extends string, V> = Adj & Record<K, V>; | |
| /** Replace all occurrences of OldRef with NewRef in children of all entries */ | |
| type UpdateAllChildren<Adj, OldRef extends string, NewRef extends string> = { | |
| [K in keyof Adj]: UpdateEntry<Adj[K], OldRef, NewRef>; | |
| }; | |
| /** Rekey: for entries whose children changed, compute new ID and remap */ | |
| type RekeyChanged<Adj extends Record<string, any>, OldRef extends string, NewRef extends string> = { | |
| [K in keyof Adj as | |
| Adj[K] extends NodeEntry<infer Kind, infer C, any> | |
| ? OldRef extends C[number] | |
| ? RecomputeID<Kind, ReplaceInChildren<C, OldRef, NewRef>> | |
| : K | |
| : K | |
| ]: UpdateEntry<Adj[K], OldRef, NewRef>; | |
| }; | |
| // ============================================================ | |
| // The actual cascade: iterative rekey | |
| // One pass handles one level. For depth D, we need D passes. | |
| // We detect convergence when no keys change. | |
| // ============================================================ | |
| /** Collect IDs that changed in a rekey step */ | |
| type ChangedIDs<Before extends Record<string, any>, After extends Record<string, any>> = { | |
| [K in keyof Before]: K extends keyof After ? never : K | |
| }[keyof Before]; | |
| /** One full cascade pass: rekey all entries whose children reference OldRef */ | |
| type CascadeOnce<Adj extends Record<string, any>, OldRef extends string, NewRef extends string> = | |
| RekeyChanged<RemoveKey<Adj, OldRef>, OldRef, NewRef>; | |
| /** Multi-pass cascade (up to 8 levels deep — covers realistic programs) */ | |
| type Cascade<Adj extends Record<string, any>, OldRef extends string, NewRef extends string, Depth extends any[] = []> = | |
| Depth["length"] extends 8 ? CascadeOnce<Adj, OldRef, NewRef> : // safety limit | |
| CascadeOnce<Adj, OldRef, NewRef> extends infer Result extends Record<string, any> | |
| // Check if any keys changed (indicating we need another pass) | |
| ? ChangedIDs<Adj, Result> extends never | |
| ? Result // converged | |
| : Cascade<Result, OldRef, NewRef, [...Depth, unknown]> // but we'd need the NEW old/new pairs... | |
| : never; | |
| // Actually, multi-pass is hard because each pass produces NEW (old, new) pairs. | |
| // Let me try a different approach: since content-addressed IDs encode the full | |
| // subtree, replacing at the bottom and re-evaluating each parent level handles it. | |
| // ============================================================ | |
| // PRACTICAL APPROACH: Replace by name, cascade by walking up | |
| // ============================================================ | |
| // For the spike, let's prove single-level cascade works (parent of replaced node | |
| // gets a new ID). Multi-level is the same operation applied recursively. | |
| // Builders | |
| function numLit<V extends number>(value: V): Expr<number, Desc<number, LitID<V>, Record<LitID<V>, NodeEntry<"num/literal", [], number>>>> { | |
| return {} as any; | |
| } | |
| function add<RA extends Desc<number, string, Record<string, any>>, RB extends Desc<number, string, Record<string, any>>>( | |
| left: Expr<number, RA>, right: Expr<number, RB>, | |
| ): Expr<number, Desc<number, AddID<IdOf<RA>, IdOf<RB>>, AdjOf<RA> & AdjOf<RB> & Record<AddID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>>> { | |
| return {} as any; | |
| } | |
| function mul<RA extends Desc<number, string, Record<string, any>>, RB extends Desc<number, string, Record<string, any>>>( | |
| left: Expr<number, RA>, right: Expr<number, RB>, | |
| ): Expr<number, Desc<number, MulID<IdOf<RA>, IdOf<RB>>, AdjOf<RA> & AdjOf<RB> & Record<MulID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>>> { | |
| return {} as any; | |
| } | |
| function neg<RA extends Desc<number, string, Record<string, any>>>(operand: Expr<number, RA>): Expr<number, Desc<number, NegID<IdOf<RA>>, AdjOf<RA> & Record<NegID<IdOf<RA>>, NodeEntry<"num/neg", [IdOf<RA>], number>>>> { | |
| return {} as any; | |
| } | |
| // ============================================================ | |
| // Test: single-level cascade | |
| // ============================================================ | |
| // Program: add(lit(3), lit(4)) | |
| // Replace lit(3) → lit(99) | |
| // Expected: add(lit(99), lit(4)) with correct cascaded IDs | |
| type OrigAdj = { | |
| "lit(3)": NodeEntry<"num/literal", [], number>; | |
| "lit(4)": NodeEntry<"num/literal", [], number>; | |
| "add(lit(3),lit(4))": NodeEntry<"num/add", ["lit(3)", "lit(4)"], number>; | |
| }; | |
| // Step 1: Remove old entry, add new entry | |
| type Step1 = RemoveKey<OrigAdj, "lit(3)"> & { | |
| "lit(99)": NodeEntry<"num/literal", [], number>; | |
| }; | |
| // Step 2: Rekey entries that referenced "lit(3)" → "lit(99)" | |
| type Step2 = RekeyChanged<Step1, "lit(3)", "lit(99)">; | |
| // Step2 should have: | |
| // "lit(99)": NodeEntry<"num/literal", [], number> | |
| // "lit(4)": NodeEntry<"num/literal", [], number> | |
| // "add(lit(99),lit(4))": NodeEntry<"num/add", ["lit(99)", "lit(4)"], number> | |
| // Verify: | |
| type Step2HasNewAdd = "add(lit(99),lit(4))" extends keyof Step2 ? true : false; | |
| // ^? should be true | |
| type Step2LacksOldAdd = "add(lit(3),lit(4))" extends keyof Step2 ? true : false; | |
| // ^? should be false | |
| // Force the type checker to verify these | |
| const _checkNewAdd: Step2HasNewAdd = true; | |
| const _checkNoOldAdd: Step2LacksOldAdd = false; | |
| // ============================================================ | |
| // Test: two-level cascade | |
| // ============================================================ | |
| // Program: mul(add(lit(3), lit(4)), lit(5)) | |
| // Replace lit(3) → lit(99) | |
| // Level 1: add(lit(3),lit(4)) → add(lit(99),lit(4)) | |
| // Level 2: mul(add(lit(3),lit(4)),lit(5)) → mul(add(lit(99),lit(4)),lit(5)) | |
| type DeepAdj = { | |
| "lit(3)": NodeEntry<"num/literal", [], number>; | |
| "lit(4)": NodeEntry<"num/literal", [], number>; | |
| "lit(5)": NodeEntry<"num/literal", [], number>; | |
| "add(lit(3),lit(4))": NodeEntry<"num/add", ["lit(3)", "lit(4)"], number>; | |
| "mul(add(lit(3),lit(4)),lit(5))": NodeEntry<"num/mul", ["add(lit(3),lit(4))", "lit(5)"], number>; | |
| }; | |
| // Level 1 cascade: lit(3) → lit(99) | |
| type Level1 = RekeyChanged< | |
| RemoveKey<DeepAdj, "lit(3)"> & { "lit(99)": NodeEntry<"num/literal", [], number> }, | |
| "lit(3)", "lit(99)" | |
| >; | |
| // Level 1 should have renamed add but mul still references old add ID | |
| type L1HasNewAdd = "add(lit(99),lit(4))" extends keyof Level1 ? true : false; | |
| const _l1NewAdd: L1HasNewAdd = true; // ✓ | |
| // Level 2 cascade: add(lit(3),lit(4)) → add(lit(99),lit(4)) | |
| type Level2 = RekeyChanged<Level1, "add(lit(3),lit(4))", "add(lit(99),lit(4))">; | |
| type L2HasNewMul = "mul(add(lit(99),lit(4)),lit(5))" extends keyof Level2 ? true : false; | |
| type L2LacksOldMul = "mul(add(lit(3),lit(4)),lit(5))" extends keyof Level2 ? true : false; | |
| const _l2NewMul: L2HasNewMul = true; // ✓ | |
| const _l2NoOldMul: L2LacksOldMul = false; // ✓ | |
| // ============================================================ | |
| // Automated multi-level cascade | |
| // ============================================================ | |
| /** Find entries in Adj whose children reference OldRef, and return [oldKey, newKey] pairs. | |
| * This tells us which keys will be renamed by RekeyChanged. */ | |
| type FindRenamedEntries<Adj extends Record<string, any>, OldRef extends string, NewRef extends string> = { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<infer Kind, infer C, any> | |
| ? OldRef extends C[number] | |
| ? [K & string, RecomputeID<Kind, ReplaceInChildren<C, OldRef, NewRef>>] | |
| : never | |
| : never; | |
| }[keyof Adj]; | |
| /** Apply cascade iteratively. | |
| * 1. RekeyChanged replaces children refs and recomputes keys for ONE level. | |
| * 2. We find which entries got renamed from Adj + OldID/NewID. | |
| * 3. For each renamed entry, we cascade further up. */ | |
| type AutoCascade< | |
| Adj extends Record<string, any>, | |
| OldID extends string, | |
| NewID extends string, | |
| Fuel extends any[] = [], | |
| > = Fuel["length"] extends 10 ? Adj : | |
| FindRenamedEntries<Adj, OldID, NewID> extends never | |
| ? RekeyChanged<Adj, OldID, NewID> // no renames at this level, just update children & done | |
| : FindRenamedEntries<Adj, OldID, NewID> extends [infer NextOld extends string, infer NextNew extends string] | |
| ? AutoCascade<RekeyChanged<Adj, OldID, NewID>, NextOld, NextNew, [...Fuel, unknown]> | |
| : RekeyChanged<Adj, OldID, NewID>; | |
| // ============================================================ | |
| // Test: automated cascade on the deep example | |
| // ============================================================ | |
| type AutoResult = AutoCascade< | |
| RemoveKey<DeepAdj, "lit(3)"> & { "lit(99)": NodeEntry<"num/literal", [], number> }, | |
| "lit(3)", "lit(99)" | |
| >; | |
| type AutoHasNewMul = "mul(add(lit(99),lit(4)),lit(5))" extends keyof AutoResult ? true : false; | |
| type AutoLacksOldMul = "mul(add(lit(3),lit(4)),lit(5))" extends keyof AutoResult ? true : false; | |
| type AutoLacksOldAdd = "add(lit(3),lit(4))" extends keyof AutoResult ? true : false; | |
| const _autoNewMul: AutoHasNewMul = true; | |
| const _autoNoOldMul: AutoLacksOldMul = false; | |
| const _autoNoOldAdd: AutoLacksOldAdd = false; | |
| // ============================================================ | |
| // Wrap it up: replaceNode function with full cascade | |
| // ============================================================ | |
| /** Replace a node by its content-addressed ID with a new expression. | |
| * Cascades ID updates through all ancestors. */ | |
| function replaceNode< | |
| O, | |
| R extends Desc<O, string, Record<string, any>>, | |
| TargetID extends string & keyof AdjOf<R>, | |
| ReplacementO extends AdjOf<R>[TargetID] extends NodeEntry<any, any, infer TO> ? TO : never, | |
| RR extends Desc<ReplacementO, string, Record<string, any>>, | |
| >( | |
| program: Expr<O, R>, | |
| targetId: TargetID, | |
| replacement: Expr<ReplacementO, RR>, | |
| ): Expr<O, Desc< | |
| O, | |
| string, // root ID will change — would need cascade to compute | |
| AutoCascade< | |
| RemoveKey<AdjOf<R>, TargetID> & AdjOf<RR>, | |
| TargetID, IdOf<RR> | |
| > | |
| >> { | |
| return {} as any; | |
| } | |
| // ============================================================ | |
| // Test: replaceNode with type safety | |
| // ============================================================ | |
| const prog = mul(add(numLit(3), numLit(4)), numLit(5)); | |
| // Replace lit(3) with lit(99) — valid (number → number) | |
| const replaced = replaceNode(prog, "lit(3)", numLit(99)); | |
| // The result's adj should have cascaded IDs | |
| type ReplacedAdj = AdjOf<(typeof replaced)[typeof exprBrand]>; | |
| type RHasNewMul = "mul(add(lit(99),lit(4)),lit(5))" extends keyof ReplacedAdj ? true : false; | |
| const _rNewMul: RHasNewMul = true; | |
| console.log("Cascade spike compiled successfully"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: DAGQL convenience layer — mapByKind, wrapByKind | |
| // with real runtime, building on dirty/commit primitives | |
| // ============================================================ | |
| import { IDChain, FirstID } from "./spike-idchain"; | |
| // ============================================================ | |
| // Core types (condensed from spike-dirty-commit) | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type NextID<Current extends keyof IDChain> = IDChain[Current]; | |
| type NameAlias<Name extends string, TargetID extends string, Out> = { | |
| readonly kind: "@alias"; | |
| readonly target: TargetID; | |
| readonly out: Out; | |
| }; | |
| type NameKey<N extends string> = `@${N}`; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly c: C; | |
| readonly __clean: true; | |
| }; | |
| type DirtyDesc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly c: C; | |
| readonly __clean: false; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| readonly __id: string; | |
| readonly __adj: Record<string, any>; | |
| readonly __counter: string; | |
| } | |
| interface DirtyExpr<O, R extends DirtyDesc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| readonly __id: string; | |
| readonly __adj: Record<string, any>; | |
| readonly __counter: string; | |
| } | |
| type IdOf<R> = R extends { id: infer ID } ? ID : never; | |
| type AdjOf<R> = R extends { adj: infer A } ? A : never; | |
| type CtrOf<R> = R extends { c: infer C } ? C : never; | |
| type OutOf<R> = R extends { o: infer O } ? O : never; | |
| // ============================================================ | |
| // Runtime helpers | |
| // ============================================================ | |
| const ALPHA = "abcdefghijklmnopqrstuvwxyz"; | |
| function nextIdRuntime(current: string): string { | |
| const chars = current.split(""); | |
| let carry = true; | |
| for (let i = chars.length - 1; i >= 0 && carry; i--) { | |
| const idx = ALPHA.indexOf(chars[i]); | |
| if (idx === 25) { chars[i] = "a"; carry = true; } | |
| else { chars[i] = ALPHA[idx + 1]; carry = false; } | |
| } | |
| if (carry) return "a" + chars.join(""); | |
| return chars.join(""); | |
| } | |
| function makeExpr(kind: string, id: string, adj: Record<string, any>, ctr: string): any { | |
| return { kind, __id: id, __adj: adj, __counter: ctr }; | |
| } | |
| function assertEq<T>(actual: T, expected: T, msg: string) { | |
| if (actual !== expected) { | |
| throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); | |
| } | |
| } | |
| function assert(cond: boolean, msg: string) { | |
| if (!cond) throw new Error(msg); | |
| } | |
| // ============================================================ | |
| // DagView — real runtime | |
| // ============================================================ | |
| interface DagView { | |
| get(id: string): { kind: string; children?: string[]; target?: string; out?: any }; | |
| nodeIds(): string[]; | |
| children(id: string): any[]; | |
| parents(childId: string): string[]; | |
| resolve(name: string): string; | |
| entries(): [string, any][]; | |
| } | |
| function createDagView(adj: Record<string, any>): DagView { | |
| const parentIndex: Record<string, string[]> = {}; | |
| for (const [id, entry] of Object.entries(adj)) { | |
| if (id.startsWith("@")) continue; | |
| for (const childId of (entry as any).children || []) { | |
| if (!parentIndex[childId]) parentIndex[childId] = []; | |
| parentIndex[childId].push(id); | |
| } | |
| } | |
| return { | |
| get(id: string) { return adj[id]; }, | |
| nodeIds() { return Object.keys(adj).filter(k => !k.startsWith("@")); }, | |
| children(id: string) { | |
| const e = adj[id]; | |
| if (!e || !e.children) return []; | |
| return e.children.map((cid: string) => adj[cid]); | |
| }, | |
| parents(childId: string) { return parentIndex[childId] || []; }, | |
| resolve(name: string) { | |
| const alias = adj[`@${name}`]; | |
| if (!alias) throw new Error(`Name not found: ${name}`); | |
| return alias.target; | |
| }, | |
| entries() { return Object.entries(adj).filter(([k]) => !k.startsWith("@")); }, | |
| }; | |
| } | |
| // ============================================================ | |
| // Reachability — walk from root, collect all reachable IDs | |
| // ============================================================ | |
| function reachableIds(adj: Record<string, any>, rootId: string): Set<string> { | |
| const visited = new Set<string>(); | |
| const stack = [rootId]; | |
| while (stack.length > 0) { | |
| const id = stack.pop()!; | |
| if (visited.has(id)) continue; | |
| visited.add(id); | |
| const entry = adj[id]; | |
| if (!entry) continue; | |
| const children = (entry as any).children || []; | |
| for (const childId of children) { | |
| if (!visited.has(childId)) stack.push(childId); | |
| } | |
| } | |
| // Also mark aliases as reachable if their target is reachable | |
| for (const [key, entry] of Object.entries(adj)) { | |
| if (key.startsWith("@")) { | |
| const target = (entry as any).target; | |
| if (visited.has(target)) { | |
| visited.add(key); | |
| } | |
| } | |
| } | |
| return visited; | |
| } | |
| // ============================================================ | |
| // gc() — remove all unreachable nodes | |
| // ============================================================ | |
| // | |
| // Returns a new expr with only reachable nodes. | |
| // This is a dirty→dirty operation (doesn't commit). | |
| // On a coherent graph, it's a no-op. | |
| function gc(expr: any): any { | |
| const reachable = reachableIds(expr.__adj, expr.__id); | |
| const newAdj: Record<string, any> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if (reachable.has(id)) { | |
| newAdj[id] = entry; | |
| } | |
| } | |
| return makeExpr(expr.kind, expr.__id, newAdj, expr.__counter); | |
| } | |
| // ============================================================ | |
| // Commit — real runtime validation | |
| // ============================================================ | |
| // | |
| // Rejects if: | |
| // 1. Root doesn't exist in adj | |
| // 2. Any node references a missing child | |
| // 3. Any node is unreachable from root (orphans) | |
| function commit(dirty: any): any { | |
| const adj = dirty.__adj; | |
| const rootId = dirty.__id; | |
| // Check 1: root exists | |
| if (!(rootId in adj)) { | |
| throw new Error(`commit: root "${rootId}" not in adj`); | |
| } | |
| // Check 2: all children exist | |
| for (const [id, entry] of Object.entries(adj)) { | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (e.children) { | |
| for (const childId of e.children) { | |
| if (!(childId in adj)) { | |
| throw new Error(`commit: node "${id}" refs missing child "${childId}"`); | |
| } | |
| } | |
| } | |
| } | |
| // Check 3: no orphans | |
| const reachable = reachableIds(adj, rootId); | |
| const allIds = Object.keys(adj); | |
| const orphans = allIds.filter(id => !reachable.has(id)); | |
| if (orphans.length > 0) { | |
| throw new Error(`commit: orphaned nodes [${orphans.join(", ")}] — call gc() first`); | |
| } | |
| return dirty; // valid — now "clean" | |
| } | |
| // ============================================================ | |
| // BuildCtx — real runtime | |
| // ============================================================ | |
| interface BuildCtx<C extends string> { | |
| numLit<V extends number>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, Record<C, NodeEntry<"num/literal", [], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| boolLit<V extends boolean>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<boolean, Desc<boolean, C, Record<C, NodeEntry<"bool/literal", [], boolean>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >(left: Expr<number, RA>, right: Expr<number, RB>): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >(left: Expr<number, RA>, right: Expr<number, RB>): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| neg<RA extends Desc<number, string, Record<string, any>, string>>(operand: Expr<number, RA>): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, AdjOf<RA> & Record<C, NodeEntry<"num/neg", [IdOf<RA>], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| cond< | |
| T, | |
| RP extends Desc<boolean, string, Record<string, any>, string>, | |
| RT extends Desc<T, string, Record<string, any>, string>, | |
| RE extends Desc<NoInfer<T>, string, Record<string, any>, string>, | |
| >(pred: Expr<boolean, RP>, then_: Expr<T, RT>, else_: Expr<NoInfer<T>, RE>): | |
| C extends keyof IDChain | |
| ? [Expr<T, Desc<T, C, AdjOf<RP> & AdjOf<RT> & AdjOf<RE> & Record<C, NodeEntry<"core/cond", [IdOf<RP>, IdOf<RT>, IdOf<RE>], T>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| named<Name extends string, O2, R extends Desc<O2, string, Record<string, any>, string>>( | |
| name: Name, expr: Expr<O2, R>, | |
| ): [Expr<O2, Desc<O2, IdOf<R>, AdjOf<R> & Record<NameKey<Name>, NameAlias<Name, IdOf<R>, O2>>, CtrOf<R>>>, BuildCtx<C>]; | |
| freshId(): C extends keyof IDChain ? [C, BuildCtx<NextID<C> & string>] : never; | |
| } | |
| function createCtx<C extends string>(counter: C): BuildCtx<C> { | |
| const ctx: BuildCtx<any> = { | |
| numLit(value: number) { | |
| const id = counter; const next = nextIdRuntime(counter); | |
| return [makeExpr("num/literal", id, { [id]: { kind: "num/literal", children: [], out: value } }, next), createCtx(next)]; | |
| }, | |
| boolLit(value: boolean) { | |
| const id = counter; const next = nextIdRuntime(counter); | |
| return [makeExpr("bool/literal", id, { [id]: { kind: "bool/literal", children: [], out: value } }, next), createCtx(next)]; | |
| }, | |
| add(left: any, right: any) { | |
| const id = counter; const next = nextIdRuntime(counter); | |
| return [makeExpr("num/add", id, { ...left.__adj, ...right.__adj, [id]: { kind: "num/add", children: [left.__id, right.__id] } }, next), createCtx(next)]; | |
| }, | |
| mul(left: any, right: any) { | |
| const id = counter; const next = nextIdRuntime(counter); | |
| return [makeExpr("num/mul", id, { ...left.__adj, ...right.__adj, [id]: { kind: "num/mul", children: [left.__id, right.__id] } }, next), createCtx(next)]; | |
| }, | |
| neg(operand: any) { | |
| const id = counter; const next = nextIdRuntime(counter); | |
| return [makeExpr("num/neg", id, { ...operand.__adj, [id]: { kind: "num/neg", children: [operand.__id] } }, next), createCtx(next)]; | |
| }, | |
| cond(pred: any, then_: any, else_: any) { | |
| const id = counter; const next = nextIdRuntime(counter); | |
| return [makeExpr("core/cond", id, { ...pred.__adj, ...then_.__adj, ...else_.__adj, [id]: { kind: "core/cond", children: [pred.__id, then_.__id, else_.__id] } }, next), createCtx(next)]; | |
| }, | |
| named(name: string, expr: any) { | |
| const adj = { ...expr.__adj, [`@${name}`]: { kind: "@alias", target: expr.__id } }; | |
| return [makeExpr(expr.kind, expr.__id, adj, expr.__counter), ctx]; | |
| }, | |
| freshId() { | |
| const next = nextIdRuntime(counter); | |
| return [counter, createCtx(next)]; | |
| }, | |
| }; | |
| return ctx; | |
| } | |
| function freshCtx(): BuildCtx<FirstID> { | |
| return createCtx("a" as FirstID); | |
| } | |
| // ============================================================ | |
| // selectByKind — real runtime | |
| // ============================================================ | |
| function selectByKind(expr: any, kind: string): Record<string, any> { | |
| const result: Record<string, any> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if ((entry as any).kind === kind) result[id] = entry; | |
| } | |
| return result; | |
| } | |
| // ============================================================ | |
| // mapByKind — the workhorse | |
| // ============================================================ | |
| // | |
| // For each node matching `kind`, call fn(entry, id, dagView). | |
| // fn returns either: | |
| // - a new entry (swap in place), or | |
| // - null (leave unchanged) | |
| // | |
| // Internally: dirty → swapEntry for each match → commit | |
| // The callback gets full DagView for context-sensitive transforms. | |
| function mapByKind( | |
| expr: any, | |
| kind: string, | |
| fn: (entry: any, id: string, dag: DagView) => any | null, | |
| ctx?: any, // optional BuildCtx for operations that need fresh IDs | |
| ): any { | |
| const dag = createDagView(expr.__adj); | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| let currentCtx = ctx; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if (id.startsWith("@")) continue; | |
| if ((entry as any).kind !== kind) continue; | |
| const result = fn(entry, id, dag); | |
| if (result !== null && result !== undefined) { | |
| // If result has __adj, it's a full Expr — merge its adj and point to its root | |
| if (result.__adj) { | |
| Object.assign(newAdj, result.__adj); | |
| newAdj[id] = result.__adj[result.__id]; | |
| } else { | |
| // Plain entry replacement | |
| newAdj[id] = result; | |
| } | |
| } | |
| } | |
| const dirty = makeExpr(expr.kind, expr.__id, newAdj, expr.__counter); | |
| return commit(gc(dirty)); | |
| } | |
| // ============================================================ | |
| // wrapByKind — bulk wrap | |
| // ============================================================ | |
| // | |
| // For each node matching `kind`, insert a wrapper between it | |
| // and all its parents. | |
| // | |
| // Internally: for each match, allocate fresh ID, add wrapper | |
| // entry, rewire parents. Then commit. | |
| function wrapByKind( | |
| expr: any, | |
| kind: string, | |
| wrapperKind: string, | |
| ctx: any, // BuildCtx — need fresh IDs | |
| ): [any, any] { | |
| const dag = createDagView(expr.__adj); | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| let currentCtx = ctx; | |
| // Collect all matching IDs first (don't modify while iterating) | |
| const matchIds: string[] = []; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if (id.startsWith("@")) continue; | |
| if ((entry as any).kind === kind) matchIds.push(id); | |
| } | |
| // For each match: allocate wrapper, rewire parents | |
| const rewireMap: Record<string, string> = {}; // targetId → wrapperId | |
| for (const targetId of matchIds) { | |
| const [wrapperId, nextCtx] = currentCtx.freshId(); | |
| currentCtx = nextCtx; | |
| // Add wrapper entry | |
| newAdj[wrapperId] = { kind: wrapperKind, children: [targetId] }; | |
| rewireMap[targetId] = wrapperId; | |
| } | |
| // Rewire all parents: in every non-wrapper entry, replace targetId → wrapperId | |
| for (const [id, entry] of Object.entries(newAdj)) { | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (!e.children) continue; | |
| // Don't rewire the wrapper entries themselves | |
| if (Object.values(rewireMap).includes(id)) continue; | |
| const newChildren = e.children.map((c: string) => rewireMap[c] || c); | |
| if (newChildren.some((c: string, i: number) => c !== e.children[i])) { | |
| newAdj[id] = { ...e, children: newChildren }; | |
| } | |
| } | |
| // Handle root rewiring | |
| const rootId = rewireMap[expr.__id] || expr.__id; | |
| const result = commit(gc(makeExpr(expr.kind, rootId, newAdj, (currentCtx as any).__counter || ""))); | |
| return [result, currentCtx]; | |
| } | |
| // ============================================================ | |
| // wrapByName — wrap a single named node | |
| // ============================================================ | |
| function wrapByName( | |
| expr: any, | |
| name: string, | |
| wrapperKind: string, | |
| ctx: any, | |
| ): [any, any] { | |
| const dag = createDagView(expr.__adj); | |
| const targetId = dag.resolve(name); | |
| const [wrapperId, nextCtx] = ctx.freshId(); | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| // Add wrapper | |
| newAdj[wrapperId] = { kind: wrapperKind, children: [targetId] }; | |
| // Rewire parents (not the wrapper itself) | |
| for (const [id, entry] of Object.entries(newAdj)) { | |
| if (id === wrapperId) continue; | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (e.children && e.children.includes(targetId)) { | |
| newAdj[id] = { ...e, children: e.children.map((c: string) => c === targetId ? wrapperId : c) }; | |
| } | |
| } | |
| const rootId = expr.__id === targetId ? wrapperId : expr.__id; | |
| return [commit(gc(makeExpr(expr.kind, rootId, newAdj, ""))), nextCtx]; | |
| } | |
| // ============================================================ | |
| // replaceByName — swap a named node with a new expr | |
| // ============================================================ | |
| function replaceByName(expr: any, name: string, newExpr: any): any { | |
| const dag = createDagView(expr.__adj); | |
| const targetId = dag.resolve(name); | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| // Merge replacement's adj | |
| for (const [id, entry] of Object.entries(newExpr.__adj)) { | |
| newAdj[id] = entry; | |
| } | |
| // Overwrite target with replacement's root entry | |
| newAdj[targetId] = newExpr.__adj[newExpr.__id]; | |
| return commit(gc(makeExpr(expr.kind, expr.__id, newAdj, expr.__counter))); | |
| } | |
| // ============================================================ | |
| // replaceByKind — bulk kind swap (simple rename) | |
| // ============================================================ | |
| function replaceByKind(expr: any, fromKind: string, toKind: string): any { | |
| const newAdj: Record<string, any> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if ((entry as any).kind === fromKind) { | |
| newAdj[id] = { ...entry as any, kind: toKind }; | |
| } else { | |
| newAdj[id] = entry; | |
| } | |
| } | |
| // replaceByKind doesn't create orphans (same structure, just kind rename) | |
| // but gc is cheap on coherent graphs, so call it anyway for consistency | |
| return commit(gc(makeExpr(expr.kind, expr.__id, newAdj, expr.__counter))); | |
| } | |
| // ============================================================ | |
| // spliceByKind — remove nodes of a kind, rewire parents | |
| // ============================================================ | |
| // | |
| // For each node matching `kind`: | |
| // - If it has exactly 1 child: rewire parents to point to that child | |
| // - If it has 0 children (leaf): rewire parents to remove the ref | |
| // (parent's children array shrinks) | |
| // - If it has >1 child: error — ambiguous which child replaces it | |
| // (caller must mapByKind first to reduce to 1 child) | |
| // | |
| // After splicing, the target is orphaned and gc'd. | |
| function spliceByKind(expr: any, kind: string): any { | |
| const dag = createDagView(expr.__adj); | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| let rootId = expr.__id; | |
| // Collect targets first | |
| const targets: string[] = []; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if (id.startsWith("@")) continue; | |
| if ((entry as any).kind === kind) targets.push(id); | |
| } | |
| for (const targetId of targets) { | |
| const target = newAdj[targetId]; | |
| const children: string[] = target.children || []; | |
| if (children.length > 1) { | |
| throw new Error( | |
| `splice: node "${targetId}" (${kind}) has ${children.length} children — ambiguous. ` + | |
| `Use mapByKind to reduce to 1 child first.` | |
| ); | |
| } | |
| const replacementId = children.length === 1 ? children[0] : null; | |
| // Rewire all parents: replace targetId with replacementId in their children | |
| for (const [id, entry] of Object.entries(newAdj)) { | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (!e.children) continue; | |
| if (!e.children.includes(targetId)) continue; | |
| if (replacementId) { | |
| // Unary splice: swap target → its child | |
| newAdj[id] = { | |
| ...e, | |
| children: e.children.map((c: string) => c === targetId ? replacementId : c), | |
| }; | |
| } else { | |
| // Leaf splice: remove from parent's children | |
| newAdj[id] = { | |
| ...e, | |
| children: e.children.filter((c: string) => c !== targetId), | |
| }; | |
| } | |
| } | |
| // Rewire aliases pointing to this target | |
| for (const [key, entry] of Object.entries(newAdj)) { | |
| if (!key.startsWith("@")) continue; | |
| if ((entry as any).target === targetId && replacementId) { | |
| newAdj[key] = { ...entry as any, target: replacementId }; | |
| } | |
| } | |
| // Handle root splice | |
| if (rootId === targetId) { | |
| if (replacementId) { | |
| rootId = replacementId; | |
| } else { | |
| throw new Error(`splice: cannot remove root "${targetId}" — it's a leaf with no replacement`); | |
| } | |
| } | |
| } | |
| return commit(gc(makeExpr(expr.kind, rootId, newAdj, expr.__counter))); | |
| } | |
| // ============================================================ | |
| // spliceByName — remove a single named node, rewire parents | |
| // ============================================================ | |
| function spliceByName(expr: any, name: string): any { | |
| const dag = createDagView(expr.__adj); | |
| const targetId = dag.resolve(name); | |
| const target = expr.__adj[targetId]; | |
| const children: string[] = target.children || []; | |
| if (children.length > 1) { | |
| throw new Error( | |
| `splice: named node "${name}" (${targetId}) has ${children.length} children — ambiguous.` | |
| ); | |
| } | |
| const replacementId = children.length === 1 ? children[0] : null; | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| let rootId = expr.__id; | |
| // Rewire parents | |
| for (const [id, entry] of Object.entries(newAdj)) { | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (!e.children) continue; | |
| if (!e.children.includes(targetId)) continue; | |
| if (replacementId) { | |
| newAdj[id] = { | |
| ...e, | |
| children: e.children.map((c: string) => c === targetId ? replacementId : c), | |
| }; | |
| } else { | |
| newAdj[id] = { | |
| ...e, | |
| children: e.children.filter((c: string) => c !== targetId), | |
| }; | |
| } | |
| } | |
| // Rewire aliases (including the name itself) | |
| for (const [key, entry] of Object.entries(newAdj)) { | |
| if (!key.startsWith("@")) continue; | |
| if ((entry as any).target === targetId && replacementId) { | |
| newAdj[key] = { ...entry as any, target: replacementId }; | |
| } | |
| } | |
| if (rootId === targetId) { | |
| if (replacementId) { | |
| rootId = replacementId; | |
| } else { | |
| throw new Error(`splice: cannot remove root "${name}" — it's a leaf`); | |
| } | |
| } | |
| return commit(gc(makeExpr(expr.kind, rootId, newAdj, expr.__counter))); | |
| } | |
| // ============================================================ | |
| // Test 1: mapByKind — constant folding | |
| // ============================================================ | |
| console.log("=== Test 1: mapByKind — constant folding ==="); | |
| const $ = freshCtx(); | |
| const [l3, $1] = $.numLit(3); | |
| const [l4, $2] = $1.numLit(4); | |
| const [sum, $3] = $2.add(l3, l4); // add(3, 4) → should fold to 7 | |
| const [l5, $4] = $3.numLit(5); | |
| const [prod, $5] = $4.mul(sum, l5); // mul(add(3,4), 5) | |
| const folded = mapByKind(prod, "num/add", (entry, id, dag) => { | |
| const [leftId, rightId] = entry.children; | |
| const left = dag.get(leftId); | |
| const right = dag.get(rightId); | |
| if (left.kind === "num/literal" && right.kind === "num/literal") { | |
| // Fold: replace add node with a literal | |
| return { kind: "num/literal", children: [], out: left.out + right.out }; | |
| } | |
| return null; // not foldable | |
| }); | |
| assertEq(folded.__adj["c"].kind, "num/literal", "add folded to literal"); | |
| assertEq(folded.__adj["c"].out, 7, "folded value is 7"); | |
| assertEq(folded.__adj["e"].kind, "num/mul", "mul unchanged"); | |
| assertEq(folded.__adj["e"].children[0], "c", "mul still refs c"); | |
| console.log(" mul(add(3,4), 5) → mul(7, 5) via constant folding"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 2: mapByKind — dead branch elimination | |
| // ============================================================ | |
| console.log("\n=== Test 2: mapByKind — dead branch elimination ==="); | |
| const $d = freshCtx(); | |
| const [pred, $d1] = $d.boolLit(true); | |
| const [then_, $d2] = $d1.numLit(42); | |
| const [else_, $d3] = $d2.numLit(0); | |
| const [condExpr, $d4] = $d3.cond(pred, then_, else_); | |
| const pruned = mapByKind(condExpr, "core/cond", (entry, id, dag) => { | |
| const [predId] = entry.children; | |
| const predNode = dag.get(predId); | |
| if (predNode.kind === "bool/literal" && predNode.out === true) { | |
| const thenId = entry.children[1]; | |
| return dag.get(thenId); // collapse to then branch | |
| } | |
| if (predNode.kind === "bool/literal" && predNode.out === false) { | |
| const elseId = entry.children[2]; | |
| return dag.get(elseId); | |
| } | |
| return null; | |
| }); | |
| assertEq(pruned.__adj["d"].kind, "num/literal", "cond collapsed to literal"); | |
| assertEq(pruned.__adj["d"].out, 42, "kept then branch value"); | |
| console.log(" cond(true, 42, 0) → 42 via dead branch elimination"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 3: wrapByKind — wrap all literals with neg | |
| // ============================================================ | |
| console.log("\n=== Test 3: wrapByKind — wrap all literals ==="); | |
| const $w = freshCtx(); | |
| const [wa, $w1] = $w.numLit(3); | |
| const [wb, $w2] = $w1.numLit(4); | |
| const [wc, $w3] = $w2.add(wa, wb); | |
| const [wrapped, $w4] = wrapByKind(wc, "num/literal", "num/neg", $w3); | |
| // Two literals → two wrappers (d, e) | |
| assertEq(wrapped.__adj["d"].kind, "num/neg", "wrapper d is neg"); | |
| assertEq(wrapped.__adj["d"].children[0], "a", "wrapper d → a"); | |
| assertEq(wrapped.__adj["e"].kind, "num/neg", "wrapper e is neg"); | |
| assertEq(wrapped.__adj["e"].children[0], "b", "wrapper e → b"); | |
| // add now points to wrappers, not originals | |
| assertEq(wrapped.__adj["c"].children[0], "d", "add left → wrapper d"); | |
| assertEq(wrapped.__adj["c"].children[1], "e", "add right → wrapper e"); | |
| // originals untouched | |
| assertEq(wrapped.__adj["a"].kind, "num/literal", "a untouched"); | |
| assertEq(wrapped.__adj["b"].kind, "num/literal", "b untouched"); | |
| console.log(" add(3, 4) → add(neg(3), neg(4))"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 4: wrapByName — wrap specific named node | |
| // ============================================================ | |
| console.log("\n=== Test 4: wrapByName — wrap named node ==="); | |
| const $n = freshCtx(); | |
| const [price, $n1] = $n.numLit(100); | |
| const [nPrice, $n2] = $n1.named("price", price); | |
| const [tax, $n3] = $n2.numLit(21); | |
| const [nTax, $n4] = $n3.named("tax", tax); | |
| const [total, $n5] = $n4.add(nPrice, nTax); | |
| const [wrappedTotal, $n6] = wrapByName(total, "price", "telemetry/timed", $n5); | |
| // Wrapper inserted between price and add | |
| const dag = createDagView(wrappedTotal.__adj); | |
| assertEq(wrappedTotal.__adj["d"].kind, "telemetry/timed", "wrapper is timed"); | |
| assertEq(wrappedTotal.__adj["d"].children[0], "a", "wrapper → price"); | |
| assertEq(wrappedTotal.__adj["c"].children[0], "d", "add rewired to wrapper"); | |
| assertEq(wrappedTotal.__adj["c"].children[1], "b", "add right unchanged"); | |
| assertEq(wrappedTotal.__adj["@price"].target, "a", "name still → a"); | |
| console.log(" add(price:100, tax:21) → add(timed(price:100), tax:21)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 5: replaceByName — swap named node with fixture | |
| // ============================================================ | |
| console.log("\n=== Test 5: replaceByName — mock fixture ==="); | |
| // Reuse total from test 4 (before wrapping) | |
| const [$rep, $rep1] = freshCtx().numLit(0); // mock zero price | |
| const mocked = replaceByName(total, "price", $rep); | |
| assertEq(mocked.__adj["a"].out, 0, "price replaced with 0"); | |
| assertEq(mocked.__adj["a"].kind, "num/literal", "still a literal"); | |
| assertEq(mocked.__adj["c"].children[0], "a", "add still refs a"); | |
| console.log(" price:100 → price:0 (mocked)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 6: replaceByKind — transpile | |
| // ============================================================ | |
| console.log("\n=== Test 6: replaceByKind — transpile ==="); | |
| const transpiled = replaceByKind(total, "num/add", "num/sub"); | |
| assertEq(transpiled.__adj["c"].kind, "num/sub", "add → sub"); | |
| assertEq(transpiled.__adj["c"].children[0], "a", "children preserved"); | |
| console.log(" add → sub (transpile)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 7: mapByKind — smart transform with DagView context | |
| // ============================================================ | |
| console.log("\n=== Test 7: mapByKind — context-sensitive transform ==="); | |
| // Double all literals that are children of mul (but not add) | |
| const $s = freshCtx(); | |
| const [s1, $s1] = $s.numLit(3); | |
| const [s2, $s2] = $s1.numLit(4); | |
| const [sSum, $s3] = $s2.add(s1, s2); // add(3, 4) | |
| const [s3, $s4] = $s3.numLit(5); | |
| const [sProd, $s5] = $s4.mul(sSum, s3); // mul(add(3,4), 5) | |
| const doubled = mapByKind(sProd, "num/literal", (entry, id, dag) => { | |
| // Only double literals that are direct children of mul | |
| const parents = dag.parents(id); | |
| const isChildOfMul = parents.some(pid => dag.get(pid).kind === "num/mul"); | |
| if (isChildOfMul) { | |
| return { ...entry, out: entry.out * 2 }; | |
| } | |
| return null; // leave unchanged | |
| }); | |
| // lit(5) at "d" is child of mul → doubled to 10 | |
| assertEq(doubled.__adj["d"].out, 10, "lit(5) doubled to 10"); | |
| // lit(3) at "a" and lit(4) at "b" are children of add, not mul → unchanged | |
| assertEq(doubled.__adj["a"].out, 3, "lit(3) unchanged (child of add)"); | |
| assertEq(doubled.__adj["b"].out, 4, "lit(4) unchanged (child of add)"); | |
| console.log(" mul(add(3,4), 5) → mul(add(3,4), 10) — only mul's children doubled"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 8: chained transforms — fold then wrap | |
| // ============================================================ | |
| console.log("\n=== Test 8: chained transforms ==="); | |
| // Start: mul(add(3, 4), 5) | |
| // Step 1: constant fold add(3,4) → 7 | |
| // Step 2: wrap all literals with neg | |
| const step1 = mapByKind(sProd, "num/add", (entry, id, dag) => { | |
| const [lid, rid] = entry.children; | |
| const l = dag.get(lid), r = dag.get(rid); | |
| if (l.kind === "num/literal" && r.kind === "num/literal") { | |
| return { kind: "num/literal", children: [], out: l.out + r.out }; | |
| } | |
| return null; | |
| }); | |
| // After fold+gc: mul(7, 5) — only c(lit7), d(lit5), e(mul) remain | |
| // a and b were orphaned by folding and gc'd away | |
| assertEq(step1.__adj["c"].kind, "num/literal", "folded"); | |
| assertEq(step1.__adj["c"].out, 7, "folded to 7"); | |
| assertEq("a" in step1.__adj, false, "orphan a gc'd"); | |
| assertEq("b" in step1.__adj, false, "orphan b gc'd"); | |
| assertEq(Object.keys(step1.__adj).length, 3, "only 3 nodes remain after gc"); | |
| const [step2, _] = wrapByKind(step1, "num/literal", "num/neg", $s5); | |
| // After wrap: mul(neg(7), neg(5)) — only 2 literals so 2 wrappers | |
| const negIds = Object.entries(step2.__adj) | |
| .filter(([_, e]) => (e as any).kind === "num/neg") | |
| .map(([id]) => id); | |
| assertEq(negIds.length, 2, "2 neg wrappers (c:7 and d:5)"); | |
| // mul's children should be the wrappers, not c and d directly | |
| assertEq(step2.__adj["e"].children[0] !== "c", true, "mul left rewired"); | |
| assertEq(step2.__adj["e"].children[1] !== "d", true, "mul right rewired"); | |
| console.log(" mul(add(3,4), 5) → fold+gc → mul(7, 5) → wrap → mul(neg(7), neg(5))"); | |
| console.log(" Orphans a,b cleaned by gc before wrap"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 9: mapByKind — return null leaves node unchanged | |
| // ============================================================ | |
| console.log("\n=== Test 9: mapByKind — selective no-op ==="); | |
| const $q = freshCtx(); | |
| const [qa, $q1] = $q.numLit(1); | |
| const [qb, $q2] = $q1.numLit(2); | |
| const [qc, $q3] = $q2.numLit(3); | |
| const [qab, $q4] = $q3.add(qa, qb); | |
| const [qabc, $q5] = $q4.add(qab, qc); | |
| // Only fold adds where BOTH children are literals (not nested adds) | |
| const partialFold = mapByKind(qabc, "num/add", (entry, id, dag) => { | |
| const [lid, rid] = entry.children; | |
| const l = dag.get(lid), r = dag.get(rid); | |
| if (l.kind === "num/literal" && r.kind === "num/literal") { | |
| return { kind: "num/literal", children: [], out: l.out + r.out }; | |
| } | |
| return null; // inner add foldable, outer not (one child is add) | |
| }); | |
| // Inner add(1,2) folded to lit(3), outer add(lit(3), lit(3)) NOT folded | |
| // because dagView was built before mutations — it sees original structure | |
| assertEq(partialFold.__adj["d"].kind, "num/literal", "inner add folded"); | |
| assertEq(partialFold.__adj["d"].out, 3, "folded to 3"); | |
| // Outer add: its left child "d" was originally an add, dag saw it as add → returned null | |
| assertEq(partialFold.__adj["e"].kind, "num/add", "outer add unchanged"); | |
| // Orphans a,b gc'd (they were children of the inner add, now folded to a literal) | |
| assertEq("a" in partialFold.__adj, false, "orphan a gc'd"); | |
| assertEq("b" in partialFold.__adj, false, "orphan b gc'd"); | |
| console.log(" add(add(1,2), 3) → add(3, 3) — only innermost folded, orphans gc'd"); | |
| console.log(" (single-pass: dagView sees pre-mutation state)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 10: gc removes orphans | |
| // ============================================================ | |
| console.log("\n=== Test 10: gc removes orphans ==="); | |
| // Manually build adj with an orphan | |
| const manualAdj: Record<string, any> = { | |
| a: { kind: "num/literal", children: [], out: 1 }, | |
| b: { kind: "num/literal", children: [], out: 2 }, // orphan — nothing refs it | |
| c: { kind: "num/neg", children: ["a"] }, | |
| }; | |
| const withOrphan = makeExpr("num/neg", "c", manualAdj, "d"); | |
| // gc removes orphan b | |
| const cleaned = gc(withOrphan); | |
| assertEq("a" in cleaned.__adj, true, "a reachable (child of c)"); | |
| assertEq("b" in cleaned.__adj, false, "b orphaned and gc'd"); | |
| assertEq("c" in cleaned.__adj, true, "c reachable (root)"); | |
| assertEq(Object.keys(cleaned.__adj).length, 2, "2 nodes after gc"); | |
| console.log(" Orphan b removed, reachable a,c kept"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 11: gc is no-op on coherent graph | |
| // ============================================================ | |
| console.log("\n=== Test 11: gc no-op on coherent graph ==="); | |
| const $g = freshCtx(); | |
| const [ga, $g1] = $g.numLit(1); | |
| const [gb, $g2] = $g1.numLit(2); | |
| const [gsum, $g3] = $g2.add(ga, gb); | |
| const before = Object.keys(gsum.__adj).length; | |
| const after = Object.keys(gc(gsum).__adj).length; | |
| assertEq(before, after, "gc is no-op — no orphans"); | |
| console.log(" Coherent graph: " + before + " nodes before and after gc"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 12: gc preserves reachable aliases | |
| // ============================================================ | |
| console.log("\n=== Test 12: gc preserves reachable aliases ==="); | |
| const $a = freshCtx(); | |
| const [ap, $a1] = $a.numLit(100); | |
| const [nap, $a2] = $a1.named("price", ap); | |
| const [at, $a3] = $a2.numLit(21); | |
| const [asum, $a4] = $a3.add(nap, at); | |
| const gcResult = gc(asum); | |
| assertEq("@price" in gcResult.__adj, true, "alias preserved (target reachable)"); | |
| console.log(" @price alias preserved, target 'a' reachable"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 13: gc removes aliases pointing to orphans | |
| // ============================================================ | |
| console.log("\n=== Test 13: gc removes orphaned aliases ==="); | |
| // Manually create an alias pointing to an orphan | |
| const adjWithBadAlias: Record<string, any> = { | |
| a: { kind: "num/literal", children: [], out: 1 }, | |
| b: { kind: "num/literal", children: [], out: 999 }, // orphan | |
| "@stale": { kind: "@alias", target: "b" }, // alias to orphan | |
| c: { kind: "num/neg", children: ["a"] }, | |
| }; | |
| const withBadAlias = makeExpr("num/neg", "c", adjWithBadAlias, "d"); | |
| const cleaned2 = gc(withBadAlias); | |
| assertEq("b" in cleaned2.__adj, false, "orphan b gc'd"); | |
| assertEq("@stale" in cleaned2.__adj, false, "alias to orphan gc'd"); | |
| assertEq("a" in cleaned2.__adj, true, "a kept"); | |
| assertEq("c" in cleaned2.__adj, true, "c kept"); | |
| console.log(" Orphan b and its alias @stale both gc'd"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 14: commit rejects orphans (must gc first) | |
| // ============================================================ | |
| console.log("\n=== Test 14: commit rejects orphans ==="); | |
| // Build adj with orphan — DON'T gc | |
| const adjOrphan: Record<string, any> = { | |
| a: { kind: "num/literal", children: [], out: 1 }, | |
| b: { kind: "num/literal", children: [], out: 2 }, // orphan | |
| c: { kind: "num/neg", children: ["a"] }, | |
| }; | |
| const dirtyOrphan = makeExpr("num/neg", "c", adjOrphan, "d"); | |
| let caught = false; | |
| try { | |
| commit(dirtyOrphan); | |
| } catch (e: any) { | |
| caught = true; | |
| assert(e.message.includes("orphaned"), "error mentions orphans"); | |
| assert(e.message.includes("b"), "error mentions orphan b"); | |
| console.log(" Caught: " + e.message); | |
| } | |
| assertEq(caught, true, "commit rejected orphaned graph"); | |
| // Now gc + commit works | |
| const cleanedAndCommitted = commit(gc(dirtyOrphan)); | |
| assertEq("b" in cleanedAndCommitted.__adj, false, "b gone after gc+commit"); | |
| assertEq(Object.keys(cleanedAndCommitted.__adj).length, 2, "2 nodes after gc+commit"); | |
| console.log(" gc() + commit() succeeds after gc cleans orphans"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 15: manual cleanup with sharp knives + gc + commit | |
| // ============================================================ | |
| console.log("\n=== Test 15: manual cleanup pipeline ==="); | |
| // User builds a graph, manually removes a node, gc cleans up, commit validates | |
| const $m = freshCtx(); | |
| const [ma, $m1] = $m.numLit(10); | |
| const [mb, $m2] = $m1.numLit(20); | |
| const [mc, $m3] = $m2.numLit(30); | |
| const [mab, $m4] = $m3.add(ma, mb); | |
| const [mabc, $m5] = $m4.add(mab, mc); | |
| // User wants to replace inner add(10,20) with just lit(10) | |
| // Step 1: swap the add entry with a literal (children become orphans) | |
| const manualDirty = makeExpr(mabc.kind, mabc.__id, { | |
| ...mabc.__adj, | |
| d: { kind: "num/literal", children: [], out: 10 }, // overwrite add node | |
| }, mabc.__counter); | |
| // At this point: a(10), b(20) are orphaned (d no longer refs them) | |
| // c(30) is still referenced by e (outer add) | |
| // Step 2: gc cleans orphans | |
| const manualCleaned = gc(manualDirty); | |
| assertEq("a" in manualCleaned.__adj, false, "orphan a cleaned"); | |
| assertEq("b" in manualCleaned.__adj, false, "orphan b cleaned"); | |
| assertEq("c" in manualCleaned.__adj, true, "c still reachable"); | |
| assertEq("d" in manualCleaned.__adj, true, "d still reachable (replaced)"); | |
| assertEq("e" in manualCleaned.__adj, true, "e still reachable (root)"); | |
| // Step 3: commit validates | |
| const manualResult = commit(manualCleaned); | |
| assertEq(Object.keys(manualResult.__adj).length, 3, "3 nodes: c, d, e"); | |
| console.log(" Manual: swap entry → gc orphans → commit"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 16: spliceByKind — remove unary wrapper | |
| // ============================================================ | |
| console.log("\n=== Test 16: spliceByKind — remove unary wrapper ==="); | |
| // Build: mul(neg(3), neg(4)) | |
| // Splice out all neg → mul(3, 4) | |
| const $sp = freshCtx(); | |
| const [sp3, $sp1] = $sp.numLit(3); | |
| const [spn3, $sp2] = $sp1.neg(sp3); | |
| const [sp4, $sp3] = $sp2.numLit(4); | |
| const [spn4, $sp4a] = $sp3.neg(sp4); | |
| const [spProd, $sp5] = $sp4a.mul(spn3, spn4); | |
| // Before: e:mul(b:neg(a:3), d:neg(c:4)) | |
| assertEq(spProd.__adj["e"].children[0], "b", "mul left = neg"); | |
| assertEq(spProd.__adj["e"].children[1], "d", "mul right = neg"); | |
| const spliced = spliceByKind(spProd, "num/neg"); | |
| // After: mul(3, 4) — negs removed, mul points directly to literals | |
| assertEq(spliced.__adj["e"].children[0], "a", "mul left = lit(3)"); | |
| assertEq(spliced.__adj["e"].children[1], "c", "mul right = lit(4)"); | |
| assertEq("b" in spliced.__adj, false, "neg b gc'd"); | |
| assertEq("d" in spliced.__adj, false, "neg d gc'd"); | |
| assertEq(Object.keys(spliced.__adj).length, 3, "3 nodes: a, c, e"); | |
| console.log(" mul(neg(3), neg(4)) → splice neg → mul(3, 4)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 17: spliceByKind — strip debug logging | |
| // ============================================================ | |
| console.log("\n=== Test 17: spliceByKind — strip debug logging ==="); | |
| // Simulate: add(log(3), log(4)) → strip "debug/log" → add(3, 4) | |
| const $dl = freshCtx(); | |
| const [dl3, $dl1] = $dl.numLit(3); | |
| const [dlLog3, $dl2] = $dl1.neg(dl3); // pretend neg = debug/log | |
| const [dl4, $dl3] = $dl2.numLit(4); | |
| const [dlLog4, $dl4a] = $dl3.neg(dl4); | |
| const [dlSum, $dl5] = $dl4a.add(dlLog3, dlLog4); | |
| // Rename neg → debug/log for realism | |
| const dlAdj: Record<string, any> = { ...dlSum.__adj }; | |
| dlAdj["b"] = { ...dlAdj["b"], kind: "debug/log" }; | |
| dlAdj["d"] = { ...dlAdj["d"], kind: "debug/log" }; | |
| const dlProg = makeExpr("num/add", "e", dlAdj, dlSum.__counter); | |
| const stripped = spliceByKind(dlProg, "debug/log"); | |
| assertEq(stripped.__adj["e"].children[0], "a", "add left = lit(3)"); | |
| assertEq(stripped.__adj["e"].children[1], "c", "add right = lit(4)"); | |
| assertEq("b" in stripped.__adj, false, "log b gone"); | |
| assertEq("d" in stripped.__adj, false, "log d gone"); | |
| console.log(" add(log(3), log(4)) → splice debug/log → add(3, 4)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 18: spliceByName — remove a specific named wrapper | |
| // ============================================================ | |
| console.log("\n=== Test 18: spliceByName — remove named wrapper ==="); | |
| // Build: add(named("wrapper", neg(3)), 4) | |
| // Splice "wrapper" → add(3, 4) | |
| const $sn = freshCtx(); | |
| const [sn3, $sn1] = $sn.numLit(3); | |
| const [snNeg, $sn2] = $sn1.neg(sn3); | |
| const [snNamed, $sn3] = $sn2.named("wrapper", snNeg); | |
| const [sn4, $sn4] = $sn3.numLit(4); | |
| const [snSum, $sn5] = $sn4.add(snNamed, sn4); | |
| assertEq(snSum.__adj["d"].children[0], "b", "add left = neg (named)"); | |
| const splicedNamed = spliceByName(snSum, "wrapper"); | |
| assertEq(splicedNamed.__adj["d"].children[0], "a", "add left = lit(3) directly"); | |
| assertEq("b" in splicedNamed.__adj, false, "neg b gc'd"); | |
| // The @wrapper alias should now point to a (the neg's child) | |
| assertEq(splicedNamed.__adj["@wrapper"].target, "a", "alias rewired to lit"); | |
| console.log(" add(wrapper:neg(3), 4) → splice wrapper → add(3, 4)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 19: spliceByKind — splice root (unary) | |
| // ============================================================ | |
| console.log("\n=== Test 19: splice root ==="); | |
| // Build: neg(lit(7)) | |
| // Splice neg → lit(7) becomes root | |
| const $sr = freshCtx(); | |
| const [sr7, $sr1] = $sr.numLit(7); | |
| const [srNeg, $sr2] = $sr1.neg(sr7); | |
| assertEq(srNeg.__id, "b", "root is neg"); | |
| const splicedRoot = spliceByKind(srNeg, "num/neg"); | |
| assertEq(splicedRoot.__id, "a", "root is now lit(7)"); | |
| assertEq("b" in splicedRoot.__adj, false, "neg gone"); | |
| assertEq(Object.keys(splicedRoot.__adj).length, 1, "just lit(7)"); | |
| console.log(" neg(7) → splice neg → 7 (root changed)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 20: splice rejects multi-child nodes | |
| // ============================================================ | |
| console.log("\n=== Test 20: splice rejects multi-child ==="); | |
| // Build: add(3, 4) | |
| // Try to splice add — it has 2 children, ambiguous | |
| const $sm = freshCtx(); | |
| const [sm3, $sm1] = $sm.numLit(3); | |
| const [sm4, $sm2] = $sm1.numLit(4); | |
| const [smSum, $sm3a] = $sm2.add(sm3, sm4); | |
| let caughtMulti = false; | |
| try { | |
| spliceByKind(smSum, "num/add"); | |
| } catch (e: any) { | |
| caughtMulti = true; | |
| assert(e.message.includes("ambiguous"), "error mentions ambiguous"); | |
| assert(e.message.includes("2 children"), "error mentions child count"); | |
| console.log(" Caught: " + e.message); | |
| } | |
| assertEq(caughtMulti, true, "splice rejected multi-child node"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 21: splice leaf node (0 children) — shrinks parent | |
| // ============================================================ | |
| console.log("\n=== Test 21: splice leaf — shrinks parent children ==="); | |
| // This is a weird case: splice a leaf removes it from parent's children. | |
| // Build: add(3, 4) with a "debug/marker" leaf attached | |
| // We'll manually add a 3-child node to test this. | |
| const leafAdj: Record<string, any> = { | |
| a: { kind: "num/literal", children: [], out: 3 }, | |
| b: { kind: "num/literal", children: [], out: 4 }, | |
| c: { kind: "debug/marker", children: [] }, // leaf to splice | |
| d: { kind: "num/add", children: ["a", "b", "c"] }, // 3 children | |
| }; | |
| const leafProg = makeExpr("num/add", "d", leafAdj, "e"); | |
| const splicedLeaf = spliceByKind(leafProg, "debug/marker"); | |
| assertEq(splicedLeaf.__adj["d"].children.length, 2, "parent shrunk to 2 children"); | |
| assertEq(splicedLeaf.__adj["d"].children[0], "a", "first child preserved"); | |
| assertEq(splicedLeaf.__adj["d"].children[1], "b", "second child preserved"); | |
| assertEq("c" in splicedLeaf.__adj, false, "marker gc'd"); | |
| console.log(" add(3, 4, marker) → splice marker → add(3, 4)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 22: wrap then splice roundtrip | |
| // ============================================================ | |
| console.log("\n=== Test 22: wrap + splice roundtrip ==="); | |
| // Build: add(3, 4) | |
| // Wrap all literals with neg: add(neg(3), neg(4)) | |
| // Splice all neg: add(3, 4) — back to original structure | |
| const $rt = freshCtx(); | |
| const [rt3, $rt1] = $rt.numLit(3); | |
| const [rt4, $rt2] = $rt1.numLit(4); | |
| const [rtSum, $rt3] = $rt2.add(rt3, rt4); | |
| const [rtWrapped, $rt4] = wrapByKind(rtSum, "num/literal", "num/neg", $rt3); | |
| // Verify wrapped: add(neg(3), neg(4)) | |
| assertEq(rtWrapped.__adj["c"].children[0] !== "a", true, "add left rewired"); | |
| const rtRestored = spliceByKind(rtWrapped, "num/neg"); | |
| // Back to: add(3, 4) | |
| assertEq(rtRestored.__adj["c"].children[0], "a", "add left = a again"); | |
| assertEq(rtRestored.__adj["c"].children[1], "b", "add right = b again"); | |
| assertEq(Object.keys(rtRestored.__adj).length, 3, "3 nodes: a, b, c"); | |
| console.log(" add(3,4) → wrap(neg) → add(neg(3),neg(4)) → splice(neg) → add(3,4)"); | |
| console.log(" Roundtrip clean!"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Summary | |
| // ============================================================ | |
| console.log("\n=== All tests passed ==="); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // DAGQL Spike — Typed Query Language over Content-Addressed DAG | |
| // ============================================================ | |
| // | |
| // Core primitive: query(program).where(predicate).map(transform) | |
| // | |
| // Proves: | |
| // 1. DagView: typed read access to the adjacency map | |
| // 2. where(): filter nodes by kind, name, or predicate (with dag access) | |
| // 3. map(): type-safe replacement (output type must match) | |
| // 4. wrap(): inject a wrapper node between parent and child | |
| // 5. Queries compose (chained .where() narrows further) | |
| // 6. Type-level tracking of mutations | |
| // ============================================================ | |
| // Core types (from winning spike) | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| } | |
| type OutOf<R> = R extends Desc<infer O, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A> ? A : never; | |
| type LitID<V extends string | number | boolean> = `lit(${V})`; | |
| type AddID<L extends string, R extends string> = `add(${L},${R})`; | |
| type MulID<L extends string, R extends string> = `mul(${L},${R})`; | |
| type NegID<A extends string> = `neg(${A})`; | |
| type GtID<L extends string, R extends string> = `gt(${L},${R})`; | |
| type CondID<P extends string, T extends string, E extends string> = `cond(${P},${T},${E})`; | |
| type NameAlias<Name extends string, TargetID extends string, Out> = { | |
| readonly kind: "@alias"; | |
| readonly target: TargetID; | |
| readonly out: Out; | |
| }; | |
| type NameKey<N extends string> = `@${N}`; | |
| // ============================================================ | |
| // Builders (abbreviated — same as previous spikes) | |
| // ============================================================ | |
| function numLit<V extends number>(value: V): Expr<number, Desc<number, LitID<V>, Record<LitID<V>, NodeEntry<"num/literal", [], number>>>> { | |
| return {} as any; | |
| } | |
| function strLit<V extends string>(value: V): Expr<string, Desc<string, LitID<V>, Record<LitID<V>, NodeEntry<"str/literal", [], string>>>> { | |
| return {} as any; | |
| } | |
| function add<RA extends Desc<number, string, Record<string, any>>, RB extends Desc<number, string, Record<string, any>>>( | |
| left: Expr<number, RA>, right: Expr<number, RB>, | |
| ): Expr<number, Desc<number, AddID<IdOf<RA>, IdOf<RB>>, AdjOf<RA> & AdjOf<RB> & Record<AddID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>>> { | |
| return {} as any; | |
| } | |
| function mul<RA extends Desc<number, string, Record<string, any>>, RB extends Desc<number, string, Record<string, any>>>( | |
| left: Expr<number, RA>, right: Expr<number, RB>, | |
| ): Expr<number, Desc<number, MulID<IdOf<RA>, IdOf<RB>>, AdjOf<RA> & AdjOf<RB> & Record<MulID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>>> { | |
| return {} as any; | |
| } | |
| function neg<RA extends Desc<number, string, Record<string, any>>>(operand: Expr<number, RA>): Expr<number, Desc<number, NegID<IdOf<RA>>, AdjOf<RA> & Record<NegID<IdOf<RA>>, NodeEntry<"num/neg", [IdOf<RA>], number>>>> { | |
| return {} as any; | |
| } | |
| function gt<RA extends Desc<number, string, Record<string, any>>, RB extends Desc<number, string, Record<string, any>>>( | |
| left: Expr<number, RA>, right: Expr<number, RB>, | |
| ): Expr<boolean, Desc<boolean, GtID<IdOf<RA>, IdOf<RB>>, AdjOf<RA> & AdjOf<RB> & Record<GtID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/gt", [IdOf<RA>, IdOf<RB>], boolean>>>> { | |
| return {} as any; | |
| } | |
| function cond<T, RP extends Desc<boolean, string, Record<string, any>>, RT extends Desc<T, string, Record<string, any>>, RE extends Desc<NoInfer<T>, string, Record<string, any>>>( | |
| pred: Expr<boolean, RP>, then_: Expr<T, RT>, else_: Expr<NoInfer<T>, RE>, | |
| ): Expr<T, Desc<T, CondID<IdOf<RP>, IdOf<RT>, IdOf<RE>>, AdjOf<RP> & AdjOf<RT> & AdjOf<RE> & Record<CondID<IdOf<RP>, IdOf<RT>, IdOf<RE>>, NodeEntry<"core/cond", [IdOf<RP>, IdOf<RT>, IdOf<RE>], T>>>> { | |
| return {} as any; | |
| } | |
| function named<Name extends string, O, R extends Desc<O, string, Record<string, any>>>( | |
| name: Name, expr: Expr<O, R>, | |
| ): Expr<O, Desc<O, IdOf<R>, AdjOf<R> & Record<NameKey<Name>, NameAlias<Name, IdOf<R>, O>>>> { | |
| return expr as any; | |
| } | |
| // ============================================================ | |
| // DagView: typed read access to the adjacency map | |
| // ============================================================ | |
| /** Runtime view into the DAG for use inside query predicates and transforms */ | |
| interface DagView<Adj extends Record<string, any>> { | |
| /** Get a node entry by its content-addressed ID */ | |
| get<ID extends string & keyof Adj>(id: ID): Adj[ID]; | |
| /** Get all node IDs (excluding @aliases) */ | |
| nodeIds(): Array<string & keyof Adj>; | |
| /** Get child entries of a node */ | |
| children<ID extends string & keyof Adj>( | |
| id: ID | |
| ): Adj[ID] extends NodeEntry<any, infer C, any> | |
| ? { [I in keyof C]: C[I] extends keyof Adj ? Adj[C[I] & string] : never } | |
| : never; | |
| /** Find the parent IDs of a node */ | |
| parents(childId: string): string[]; | |
| /** Resolve a name to its target ID */ | |
| resolve<Name extends string>( | |
| name: Name | |
| ): NameKey<Name> extends keyof Adj | |
| ? Adj[NameKey<Name>] extends NameAlias<any, infer T, any> ? T : never | |
| : never; | |
| } | |
| // ============================================================ | |
| // Node handle: what predicates and transforms receive | |
| // ============================================================ | |
| /** A handle to a specific node within the DAG, with typed access */ | |
| interface NodeHandle< | |
| ID extends string, | |
| Entry extends NodeEntry<string, string[], any>, | |
| Adj extends Record<string, any>, | |
| > { | |
| readonly id: ID; | |
| readonly kind: Entry extends NodeEntry<infer K, any, any> ? K : never; | |
| readonly out: Entry extends NodeEntry<any, any, infer O> ? O : never; | |
| readonly childIds: Entry extends NodeEntry<any, infer C, any> ? C : never; | |
| readonly dag: DagView<Adj>; | |
| } | |
| // ============================================================ | |
| // DAGQL: query().where().map() | |
| // ============================================================ | |
| /** Filter: select entries from adj matching a kind */ | |
| type EntriesOfKind<Adj, K extends string> = { | |
| [ID in keyof Adj as Adj[ID] extends NodeEntry<K, any, any> ? ID : never]: Adj[ID]; | |
| }; | |
| /** Filter: select entries matching a name */ | |
| type EntryOfName<Adj, Name extends string> = | |
| NameKey<Name> extends keyof Adj | |
| ? Adj[NameKey<Name>] extends NameAlias<any, infer TargetID, any> | |
| ? TargetID extends keyof Adj | |
| ? Record<TargetID, Adj[TargetID]> | |
| : {} | |
| : {} | |
| : {}; | |
| /** The selected subset — what .where() produces */ | |
| type DagSelection<Adj extends Record<string, any>, Selected extends Record<string, any>> = { | |
| readonly adj: Adj; | |
| readonly selected: Selected; | |
| }; | |
| /** The query builder */ | |
| interface DagQuery< | |
| O, | |
| R extends Desc<O, string, Record<string, any>>, | |
| Selected extends Record<string, any>, | |
| > { | |
| /** Filter by node kind (glob-like: "num/*" matches "num/add", "num/mul", etc.) */ | |
| where<K extends string>( | |
| kindFilter: K | |
| ): DagQuery<O, R, EntriesOfKind<Selected, K>>; | |
| /** Filter by name */ | |
| whereNamed<Name extends string>( | |
| name: Name | |
| ): DagQuery<O, R, EntryOfName<AdjOf<R>, Name>>; | |
| /** Map: replace each selected node. Replacement must have same output type. | |
| * Transform receives a NodeHandle with full dag access. */ | |
| map<NewAdj extends Record<string, any>>( | |
| fn: ( | |
| handle: SelectedHandle<Selected, AdjOf<R>>, | |
| dag: DagView<AdjOf<R>> | |
| ) => MappedResult<Selected, NewAdj> | |
| ): Expr<O, Desc<O, IdOf<R>, Omit<AdjOf<R>, keyof Selected> & NewAdj>>; | |
| /** Count selected nodes (runtime only) */ | |
| count(): number; | |
| /** Collect selected node IDs (runtime only) */ | |
| ids(): (keyof Selected)[]; | |
| } | |
| /** Handle type for selected nodes — ensures output type preservation */ | |
| type SelectedHandle<Selected extends Record<string, any>, Adj extends Record<string, any>> = { | |
| [ID in keyof Selected]: NodeHandle< | |
| ID & string, | |
| Selected[ID] extends NodeEntry<any, any, any> ? Selected[ID] : never, | |
| Adj | |
| >; | |
| }[keyof Selected]; | |
| /** Result of .map() — must preserve output types for each replaced node */ | |
| type MappedResult<Selected extends Record<string, any>, NewAdj extends Record<string, any>> = { | |
| [ID in keyof Selected]: Selected[ID] extends NodeEntry<any, any, infer O> | |
| ? NodeEntry<string, string[], O> // output type O must match | |
| : never; | |
| }; | |
| /** Entry point: create a query over a program */ | |
| function query<O, R extends Desc<O, string, Record<string, any>>>( | |
| program: Expr<O, R> | |
| ): DagQuery<O, R, AdjOf<R>> { | |
| return {} as any; | |
| } | |
| // ============================================================ | |
| // Simpler DAGQL operations (not chained — standalone functions) | |
| // ============================================================ | |
| /** SELECT: find all nodes of a given kind */ | |
| type SelectByKind<R, K extends string> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [ID in keyof Adj as Adj[ID] extends NodeEntry<K, any, any> ? ID : never]: Adj[ID] } | |
| : never; | |
| /** UPDATE by kind: replace all nodes of kind From, transform must return same output type */ | |
| type UpdateByKind<R, From extends string, To extends string> = | |
| R extends Desc<infer O, infer ID, infer Adj> | |
| ? Desc<O, ID, { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<From, infer C, infer Out> | |
| ? NodeEntry<To, C, Out> | |
| : Adj[K] | |
| }> | |
| : never; | |
| /** UPDATE by name: replace a named node */ | |
| type UpdateByName<R, Name extends string, NewEntry extends NodeEntry<string, string[], any>> = | |
| R extends Desc<infer O, infer RootID, infer Adj> | |
| ? NameKey<Name> extends keyof Adj | |
| ? Adj[NameKey<Name>] extends NameAlias<any, infer TargetID, infer TargetO> | |
| ? NewEntry extends NodeEntry<any, any, TargetO> // output type must match | |
| ? Desc<O, RootID, Omit<Adj, TargetID & string> & Record<TargetID & string, NewEntry>> | |
| : never // type mismatch | |
| : never | |
| : never // name not found | |
| : never; | |
| /** DELETE by kind: remove all nodes of a kind (only if they're leaves) */ | |
| type LeafNodesOfKind<Adj, K extends string> = { | |
| [ID in keyof Adj as Adj[ID] extends NodeEntry<K, [], any> ? ID : never]: Adj[ID]; | |
| }; | |
| // ============================================================ | |
| // Runtime: standalone DAGQL functions | |
| // ============================================================ | |
| function selectByKind< | |
| O, R extends Desc<O, string, Record<string, any>>, | |
| K extends string, | |
| >(program: Expr<O, R>, kind: K): SelectByKind<R, K> { | |
| return {} as any; | |
| } | |
| function updateByKind< | |
| O, R extends Desc<O, string, Record<string, any>>, | |
| From extends string, To extends string, | |
| >(program: Expr<O, R>, from: From, to: To): Expr<O, UpdateByKind<R, From, To>> { | |
| return {} as any; | |
| } | |
| function updateByName< | |
| O, R extends Desc<O, string, Record<string, any>>, | |
| Name extends string, | |
| ReplacementO extends NamedOutputType<R, Name>, | |
| RR extends Desc<ReplacementO, string, Record<string, any>>, | |
| >( | |
| program: Expr<O, R>, | |
| name: Name, | |
| replacement: Expr<ReplacementO, RR> | |
| ): Expr<O, Desc<O, IdOf<R>, AdjOf<R> & AdjOf<RR>>> { | |
| return {} as any; | |
| } | |
| type NamedOutputType<R, Name extends string> = | |
| R extends Desc<any, any, infer Adj> | |
| ? NameKey<Name> extends keyof Adj | |
| ? Adj[NameKey<Name>] extends NameAlias<any, any, infer O> ? O : never | |
| : never | |
| : never; | |
| // ============================================================ | |
| // Test: Build a realistic program | |
| // ============================================================ | |
| const price = named("price", numLit(100)); | |
| const taxRate = named("tax-rate", numLit(21)); | |
| const tax = named("tax", mul(price, taxRate)); | |
| const subtotal = named("subtotal", add(price, tax)); | |
| const discount = named("discount", numLit(10)); | |
| const total = named("total", add(subtotal, neg(discount))); | |
| // ============================================================ | |
| // Test: SELECT queries | |
| // ============================================================ | |
| type ROf<E> = E extends Expr<any, infer R> ? R : never; | |
| type TotalR = ROf<typeof total>; | |
| // Select all addition nodes | |
| type AllAdds = SelectByKind<TotalR, "num/add">; | |
| // Should contain the add nodes for subtotal and total | |
| // Select all multiplication nodes | |
| type AllMuls = SelectByKind<TotalR, "num/mul">; | |
| // Should contain just the tax calculation | |
| // Select all literals | |
| type AllLits = SelectByKind<TotalR, "num/literal">; | |
| // Should contain 100, 21, 10 | |
| // ============================================================ | |
| // Test: Collect all names | |
| // ============================================================ | |
| type CollectNames<R> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [K in keyof Adj]: K extends `@${infer Name}` ? Name : never }[keyof Adj] | |
| : never; | |
| type TotalNames = CollectNames<TotalR>; | |
| // "price" | "tax-rate" | "tax" | "subtotal" | "discount" | "total" | |
| // ============================================================ | |
| // Test: UPDATE by kind (num/add → num/mul everywhere) | |
| // ============================================================ | |
| const allMul = updateByKind(total, "num/add", "num/mul"); | |
| // Type tracks that all num/add entries are now num/mul | |
| type AllMulR = ROf<typeof allMul>; | |
| type CollectKinds<R> = R extends Desc<any, any, infer Adj> | |
| ? { [K in keyof Adj]: Adj[K] extends NodeEntry<infer Kind, any, any> ? Kind : never }[keyof Adj] | |
| : never; | |
| type AllMulKinds = CollectKinds<AllMulR>; | |
| // Should NOT contain "num/add", should contain "num/mul" | |
| // ============================================================ | |
| // Test: UPDATE by name — replace tax rate | |
| // ============================================================ | |
| const newRate = updateByName(total, "tax-rate", numLit(15)); | |
| // Valid: number → number | |
| // @ts-expect-error: can't replace tax-rate (number) with string | |
| const badRate = updateByName(total, "tax-rate", strLit("nope")); | |
| // @ts-expect-error: name doesn't exist | |
| const badName = updateByName(total, "nonexistent", numLit(0)); | |
| // ============================================================ | |
| // Test: Querying the DAG inside a predicate (runtime concept) | |
| // ============================================================ | |
| // This shows what the runtime API looks like. | |
| // The DagView gives access to the full adjacency map inside predicates. | |
| function findConstantAdds<O, R extends Desc<O, string, Record<string, any>>>( | |
| program: Expr<O, R> | |
| ): string[] { | |
| // Runtime: build DagView from program's adj map | |
| const dag: DagView<AdjOf<R>> = {} as any; | |
| const results: string[] = []; | |
| for (const id of dag.nodeIds()) { | |
| const entry = dag.get(id); | |
| if (entry.kind === "num/add") { | |
| // Query children through the DAG | |
| const [leftId, rightId] = entry.children; | |
| const left = dag.get(leftId); | |
| const right = dag.get(rightId); | |
| // Both children are literals → constant fold candidate | |
| if (left.kind === "num/literal" && right.kind === "num/literal") { | |
| results.push(id); | |
| } | |
| } | |
| } | |
| return results; | |
| } | |
| // ============================================================ | |
| // Test: Transaction-style composition (sketch) | |
| // ============================================================ | |
| // Multiple DAGQL operations composed: | |
| const optimized = updateByKind( | |
| updateByName(total, "tax-rate", numLit(15)), | |
| "num/add", "num/mul" | |
| ); | |
| // Both operations applied, types track the full transformation | |
| // ============================================================ | |
| // Test: Wrap pattern — inject a wrapper between parent and child | |
| // ============================================================ | |
| // Wrapping "discount" with neg() is equivalent to: | |
| // 1. SELECT the named node | |
| // 2. CREATE a new node that wraps it | |
| // 3. UPDATE all parents to point to the wrapper | |
| // With DAGQL this would be: | |
| // query(program).whereNamed("discount").wrap(node => neg(node)) | |
| // For now, prove it works manually: | |
| const wrappedDiscount = named("discount", neg(named("original-discount", numLit(10)))); | |
| // The name "discount" now points to neg(original-discount) | |
| console.log("DAGQL spike compiled successfully"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Dirty/Commit — phantom-typed graph integrity | |
| // ============================================================ | |
| // | |
| // Sharp knives (addEntry, removeEntry, rewireChildren) produce | |
| // DirtyExpr. You can chain dirty ops freely. Only commit() | |
| // returns a clean Expr — and only if the graph is valid. | |
| // | |
| // Convenience methods (wrap, replaceByName) use sharp knives | |
| // internally and commit at the end. | |
| import { IDChain, FirstID } from "./spike-idchain"; | |
| // ============================================================ | |
| // Core types | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type NextID<Current extends keyof IDChain> = IDChain[Current]; | |
| type NameAlias<Name extends string, TargetID extends string, Out> = { | |
| readonly kind: "@alias"; | |
| readonly target: TargetID; | |
| readonly out: Out; | |
| }; | |
| type NameKey<N extends string> = `@${N}`; | |
| // Clean descriptor — graph is valid | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly c: C; | |
| readonly __clean: true; | |
| }; | |
| // Dirty descriptor — graph may be invalid | |
| type DirtyDesc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly c: C; | |
| readonly __clean: false; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| // Clean Expr — can be passed to foldAST, consumers, etc. | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| readonly __id: string; | |
| readonly __adj: Record<string, any>; | |
| readonly __counter: string; | |
| } | |
| // Dirty Expr — can only be committed or further mutated | |
| interface DirtyExpr<O, R extends DirtyDesc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| readonly __id: string; | |
| readonly __adj: Record<string, any>; | |
| readonly __counter: string; | |
| } | |
| type IdOf<R> = R extends { id: infer ID } ? ID : never; | |
| type AdjOf<R> = R extends { adj: infer A } ? A : never; | |
| type CtrOf<R> = R extends { c: infer C } ? C : never; | |
| type OutOf<R> = R extends { o: infer O } ? O : never; | |
| // ============================================================ | |
| // Validation: does the graph hold together? | |
| // ============================================================ | |
| // Check: every child ID referenced by every entry exists as a key | |
| type AllChildrenExist<Adj extends Record<string, any>> = { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<any, infer C extends string[], any> | |
| ? C[number] extends keyof Adj ? true : false | |
| : true; // aliases don't have children | |
| }; | |
| type HasInvalidChildren<Adj extends Record<string, any>> = | |
| false extends AllChildrenExist<Adj>[keyof AllChildrenExist<Adj>] | |
| ? true | |
| : false; | |
| // Check: root ID exists in adj | |
| type RootExists<ID extends string, Adj extends Record<string, any>> = | |
| ID extends keyof Adj ? true : false; | |
| // Combined validation | |
| type IsValidGraph<ID extends string, Adj extends Record<string, any>> = | |
| RootExists<ID, Adj> extends true | |
| ? HasInvalidChildren<Adj> extends false | |
| ? true | |
| : false | |
| : false; | |
| // Error messages as types | |
| type GraphError<Msg extends string> = { readonly __graphError: Msg }; | |
| type MissingChildren<Adj extends Record<string, any>> = { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<any, infer C extends string[], any> | |
| ? { [I in keyof C]: C[I] extends keyof Adj ? never : C[I] }[number] | |
| : never; | |
| }[keyof Adj]; | |
| // ============================================================ | |
| // commit(): dirty → clean (or type error) | |
| // ============================================================ | |
| // Overload 1: valid graph → clean Expr | |
| // Overload 2: invalid graph → compile error with diagnostic | |
| function commit< | |
| O, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| >( | |
| dirty: DirtyExpr<O, DirtyDesc<O, ID, Adj, C>>, | |
| ): IsValidGraph<ID, Adj> extends true | |
| ? Expr<O, Desc<O, ID, Adj, C>> | |
| : GraphError<`Invalid graph: missing children [${MissingChildren<Adj> & string}]`> | |
| { | |
| // Runtime validation | |
| const adj = dirty.__adj; | |
| const rootId = dirty.__id; | |
| // Check root exists | |
| if (!(rootId in adj)) { | |
| throw new Error(`commit failed: root ID "${rootId}" not in adj map`); | |
| } | |
| // Check all children exist | |
| for (const [id, entry] of Object.entries(adj)) { | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (e.children) { | |
| for (const childId of e.children) { | |
| if (!(childId in adj)) { | |
| throw new Error(`commit failed: node "${id}" references child "${childId}" which doesn't exist`); | |
| } | |
| } | |
| } | |
| } | |
| // Valid — return as clean | |
| return dirty as any; | |
| } | |
| // ============================================================ | |
| // Sharp knives: all return DirtyExpr | |
| // ============================================================ | |
| // Make any Expr dirty (entry point into mutation) | |
| function dirty< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| >(expr: Expr<O, R>): DirtyExpr<O, DirtyDesc<O, IdOf<R>, AdjOf<R>, CtrOf<R>>> { | |
| return expr as any; | |
| } | |
| // Also accept DirtyExpr (for chaining) | |
| function dirtyOf< | |
| O, | |
| R extends DirtyDesc<O, string, Record<string, any>, string>, | |
| >(expr: DirtyExpr<O, R>): DirtyExpr<O, R> { | |
| return expr; | |
| } | |
| // addEntry: add a node to the adj map | |
| function addEntry< | |
| O, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| NewID extends string, | |
| NewEntry extends NodeEntry<string, string[], any>, | |
| >( | |
| expr: DirtyExpr<O, DirtyDesc<O, ID, Adj, C>>, | |
| newId: NewID, | |
| newEntry: NewEntry, | |
| ): DirtyExpr<O, DirtyDesc<O, ID, Adj & Record<NewID, NewEntry>, C>> { | |
| const newAdj = { ...expr.__adj, [newId]: newEntry }; | |
| return { ...expr, __adj: newAdj } as any; | |
| } | |
| // removeEntry: remove a node (may break children refs) | |
| function removeEntry< | |
| O, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| TargetID extends string & keyof Adj, | |
| >( | |
| expr: DirtyExpr<O, DirtyDesc<O, ID, Adj, C>>, | |
| targetId: TargetID, | |
| ): DirtyExpr<O, DirtyDesc<O, ID, Omit<Adj, TargetID>, C>> { | |
| const newAdj = { ...expr.__adj }; | |
| delete newAdj[targetId]; | |
| return { ...expr, __adj: newAdj } as any; | |
| } | |
| // swapEntry: replace the value at a key | |
| function swapEntry< | |
| O, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| TargetID extends string & keyof Adj, | |
| NewEntry extends NodeEntry<string, string[], any>, | |
| >( | |
| expr: DirtyExpr<O, DirtyDesc<O, ID, Adj, C>>, | |
| targetId: TargetID, | |
| newEntry: NewEntry, | |
| ): DirtyExpr<O, DirtyDesc<O, ID, Omit<Adj, TargetID> & Record<TargetID, NewEntry>, C>> { | |
| const newAdj = { ...expr.__adj, [targetId]: newEntry }; | |
| return { ...expr, __adj: newAdj } as any; | |
| } | |
| // rewireChildren: in all entries, replace oldRef → newRef in children arrays | |
| function rewireChildren< | |
| O, | |
| ID extends string, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| OldRef extends string, | |
| NewRef extends string, | |
| >( | |
| expr: DirtyExpr<O, DirtyDesc<O, ID, Adj, C>>, | |
| oldRef: OldRef, | |
| newRef: NewRef, | |
| ): DirtyExpr<O, DirtyDesc<O, ID, RewiredAdj<Adj, OldRef, NewRef>, C>> { | |
| const newAdj: Record<string, any> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| const e = entry as any; | |
| if (e.children) { | |
| newAdj[id] = { | |
| ...e, | |
| children: e.children.map((c: string) => c === oldRef ? newRef : c), | |
| }; | |
| } else { | |
| newAdj[id] = entry; | |
| } | |
| } | |
| return { ...expr, __adj: newAdj } as any; | |
| } | |
| // Type-level children rewiring | |
| type RewireChildren<C extends string[], Old extends string, New extends string> = { | |
| [I in keyof C]: C[I] extends Old ? New : C[I]; | |
| }; | |
| type RewiredAdj<Adj, Old extends string, New extends string> = { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<infer Kind, infer C extends string[], infer Out> | |
| ? NodeEntry<Kind, RewireChildren<C, Old, New> & string[], Out> | |
| : Adj[K]; | |
| }; | |
| // setRoot: change the root ID | |
| function setRoot< | |
| O, | |
| Adj extends Record<string, any>, | |
| C extends string, | |
| OldID extends string, | |
| NewID extends string, | |
| >( | |
| expr: DirtyExpr<O, DirtyDesc<O, OldID, Adj, C>>, | |
| newRoot: NewID, | |
| ): DirtyExpr<O, DirtyDesc<O, NewID, Adj, C>> { | |
| return { ...expr, __id: newRoot } as any; | |
| } | |
| // ============================================================ | |
| // ID helpers | |
| // ============================================================ | |
| const ALPHA = "abcdefghijklmnopqrstuvwxyz"; | |
| function nextIdRuntime(current: string): string { | |
| const chars = current.split(""); | |
| let carry = true; | |
| for (let i = chars.length - 1; i >= 0 && carry; i--) { | |
| const idx = ALPHA.indexOf(chars[i]); | |
| if (idx === 25) { chars[i] = "a"; carry = true; } | |
| else { chars[i] = ALPHA[idx + 1]; carry = false; } | |
| } | |
| if (carry) return "a" + chars.join(""); | |
| return chars.join(""); | |
| } | |
| function assertEq<T>(actual: T, expected: T, msg: string) { | |
| if (actual !== expected) { | |
| throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); | |
| } | |
| } | |
| // ============================================================ | |
| // BuildCtx (same as spike-runtime, produces clean Expr) | |
| // ============================================================ | |
| interface BuildCtx<C extends string> { | |
| numLit<V extends number>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, Record<C, NodeEntry<"num/literal", [], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| strLit<V extends string>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<string, Desc<string, C, Record<C, NodeEntry<"str/literal", [], string>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| neg< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| operand: Expr<number, RA>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & Record<C, NodeEntry<"num/neg", [IdOf<RA>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| named< | |
| Name extends string, | |
| O2, | |
| R extends Desc<O2, string, Record<string, any>, string>, | |
| >( | |
| name: Name, | |
| expr: Expr<O2, R>, | |
| ): [Expr<O2, Desc<O2, IdOf<R>, AdjOf<R> & Record<NameKey<Name>, NameAlias<Name, IdOf<R>, O2>>, CtrOf<R>>>, BuildCtx<C>]; | |
| // Expose current counter for fresh IDs during mutations | |
| freshId(): C extends keyof IDChain ? [C, BuildCtx<NextID<C> & string>] : never; | |
| } | |
| function createCtx<C extends string>(counter: C): BuildCtx<C> { | |
| function makeExpr(kind: string, id: string, adj: Record<string, any>, ctr: string): any { | |
| return { kind, __id: id, __adj: adj, __counter: ctr }; | |
| } | |
| const ctx: BuildCtx<any> = { | |
| numLit(value: number) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| return [makeExpr("num/literal", id, { [id]: { kind: "num/literal", children: [], out: value } }, next), createCtx(next)] as any; | |
| }, | |
| strLit(value: string) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| return [makeExpr("str/literal", id, { [id]: { kind: "str/literal", children: [], out: value } }, next), createCtx(next)] as any; | |
| }, | |
| add(left: any, right: any) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| return [makeExpr("num/add", id, { ...left.__adj, ...right.__adj, [id]: { kind: "num/add", children: [left.__id, right.__id] } }, next), createCtx(next)] as any; | |
| }, | |
| mul(left: any, right: any) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| return [makeExpr("num/mul", id, { ...left.__adj, ...right.__adj, [id]: { kind: "num/mul", children: [left.__id, right.__id] } }, next), createCtx(next)] as any; | |
| }, | |
| neg(operand: any) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| return [makeExpr("num/neg", id, { ...operand.__adj, [id]: { kind: "num/neg", children: [operand.__id] } }, next), createCtx(next)] as any; | |
| }, | |
| named(name: string, expr: any) { | |
| const adj = { ...expr.__adj, [`@${name}`]: { kind: "@alias", target: expr.__id } }; | |
| return [makeExpr(expr.kind, expr.__id, adj, expr.__counter), ctx] as any; | |
| }, | |
| freshId() { | |
| const next = nextIdRuntime(counter); | |
| return [counter, createCtx(next)] as any; | |
| }, | |
| }; | |
| return ctx; | |
| } | |
| function freshCtx(): BuildCtx<FirstID> { | |
| return createCtx("a" as FirstID); | |
| } | |
| // ============================================================ | |
| // Test 1: commit succeeds on valid graph | |
| // ============================================================ | |
| console.log("=== Test 1: commit valid graph ==="); | |
| const $ = freshCtx(); | |
| const [lit3, $1] = $.numLit(3); | |
| const [lit4, $2] = $1.numLit(4); | |
| const [sum, $3] = $2.add(lit3, lit4); | |
| // Make dirty, then commit | |
| const dirtySum = dirty(sum); | |
| const cleanSum = commit(dirtySum); | |
| assertEq(cleanSum.__id, "c", "root preserved"); | |
| assertEq(cleanSum.__adj["a"].kind, "num/literal", "a intact"); | |
| assertEq(cleanSum.__adj["c"].kind, "num/add", "c intact"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 2: commit fails at runtime on broken graph | |
| // ============================================================ | |
| console.log("\n=== Test 2: commit rejects missing children (runtime) ==="); | |
| const dirtied = dirty(sum); | |
| // Add an entry referencing nonexistent child | |
| const broken = addEntry(dirtied, "z" as any, { | |
| kind: "num/neg", | |
| children: ["doesnt_exist"], | |
| out: undefined, | |
| } as any); | |
| let caught = false; | |
| try { | |
| commit(broken as any); | |
| } catch (e: any) { | |
| caught = true; | |
| assertEq(e.message.includes("doesnt_exist"), true, "error mentions missing child"); | |
| console.log(" Caught: " + e.message); | |
| } | |
| assertEq(caught, true, "commit threw on invalid graph"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 3: commit rejects missing root (runtime) | |
| // ============================================================ | |
| console.log("\n=== Test 3: commit rejects missing root ==="); | |
| const dirtied2 = dirty(sum); | |
| const badRoot = setRoot(dirtied2, "nonexistent" as any); | |
| let caught2 = false; | |
| try { | |
| commit(badRoot as any); | |
| } catch (e: any) { | |
| caught2 = true; | |
| assertEq(e.message.includes("nonexistent"), true, "error mentions missing root"); | |
| console.log(" Caught: " + e.message); | |
| } | |
| assertEq(caught2, true, "commit threw on missing root"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 4: type-level rejection — missing children | |
| // ============================================================ | |
| console.log("\n=== Test 4: type-level commit validation ==="); | |
| // Build a valid program | |
| const $t = freshCtx(); | |
| const [ta, $t1] = $t.numLit(1); | |
| const [tb, $t2] = $t1.numLit(2); | |
| const [tc, $t3] = $t2.add(ta, tb); | |
| // Valid commit — should type as Expr | |
| const validCommit = commit(dirty(tc)); | |
| // Type test: this should be assignable to Expr | |
| const _exprCheck: Expr<number, any> = validCommit; | |
| // Now break it: remove a child that's referenced | |
| const broken2 = removeEntry(dirty(tc), "a"); | |
| // This commit should produce GraphError at the type level | |
| // At runtime it also throws, so wrap in try/catch | |
| let caught3 = false; | |
| try { | |
| const invalidCommit = commit(broken2); | |
| // @ts-expect-error — GraphError is not assignable to Expr | |
| const _exprCheck2: Expr<number, any> = invalidCommit; | |
| } catch (e: any) { | |
| caught3 = true; | |
| console.log(" Runtime also caught: " + e.message); | |
| } | |
| assertEq(caught3, true, "commit threw on broken graph"); | |
| console.log(" @ts-expect-error correctly catches broken commit at type level"); | |
| console.log(" Runtime commit also rejects"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 5: chain multiple dirty ops, then commit | |
| // ============================================================ | |
| console.log("\n=== Test 5: chained dirty ops ==="); | |
| const $c = freshCtx(); | |
| const [ca, $c1] = $c.numLit(10); | |
| const [cb, $c2] = $c1.numLit(20); | |
| const [cc, $c3] = $c2.add(ca, cb); | |
| // Chain: swap lit(10) → lit(99), then add a neg wrapper | |
| const [freshId, $c4] = $c3.freshId(); | |
| const result = commit( | |
| rewireChildren( | |
| addEntry( | |
| swapEntry( | |
| dirty(cc), | |
| "a", | |
| { kind: "num/literal", children: [] as const, out: 99 } as NodeEntry<"num/literal", [], number>, | |
| ), | |
| freshId, | |
| { kind: "num/neg", children: ["b"], out: 0 } as NodeEntry<"num/neg", ["b"], number>, | |
| ), | |
| "b" as const, // in "c" (add), replace ref to "b" with freshId | |
| freshId, | |
| ) | |
| ); | |
| assertEq(result.__adj["a"].out, 99, "a swapped to 99"); | |
| assertEq(result.__adj[freshId].kind, "num/neg", "neg wrapper added"); | |
| assertEq(result.__adj["c"].children[0], "a", "add left still a"); | |
| assertEq(result.__adj["c"].children[1], freshId, "add right rewired to neg"); | |
| console.log(" Chained: swap(a→99) + addEntry(neg) + rewire(b→neg)"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 6: wrap as a convenience that uses dirty+commit internally | |
| // ============================================================ | |
| console.log("\n=== Test 6: wrap = dirty ops + commit ==="); | |
| function safeWrap< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| C extends string & keyof IDChain, | |
| >( | |
| expr: Expr<O, R>, | |
| targetId: string & keyof AdjOf<R>, | |
| wrapperKind: string, | |
| ctx: BuildCtx<C>, | |
| ): [any, BuildCtx<NextID<C> & string>] { | |
| const [freshId, nextCtx] = ctx.freshId(); | |
| // All sharp knives, producing dirty: | |
| const step1 = dirty(expr); | |
| const step2 = addEntry(step1, freshId, { | |
| kind: wrapperKind, | |
| children: [targetId], | |
| out: undefined, | |
| } as any); | |
| const step3 = rewireChildren(step2, targetId, freshId); | |
| // The wrapper itself shouldn't have its own child rewired | |
| // (it should point to targetId, not to itself) | |
| // Fix: swap the wrapper entry back to point to targetId | |
| const step4 = swapEntry(step3, freshId, { | |
| kind: wrapperKind, | |
| children: [targetId], | |
| out: undefined, | |
| } as any); | |
| // commit validates everything | |
| const result = commit(step4 as any); | |
| return [result, nextCtx as any]; | |
| } | |
| const $sw = freshCtx(); | |
| const [sw3, $sw1] = $sw.numLit(3); | |
| const [sw4, $sw2] = $sw1.numLit(4); | |
| const [swSum, $sw3] = $sw2.add(sw3, sw4); | |
| const [swWrapped, $sw4] = safeWrap(swSum, "a", "num/neg", $sw3); | |
| assertEq(swWrapped.__adj["d"].kind, "num/neg", "wrapper is neg"); | |
| assertEq(swWrapped.__adj["d"].children[0], "a", "wrapper → a"); | |
| assertEq(swWrapped.__adj["c"].children[0], "d", "add rewired to wrapper"); | |
| assertEq(swWrapped.__adj["c"].children[1], "b", "add right unchanged"); | |
| assertEq(swWrapped.__adj["a"].kind, "num/literal", "a untouched"); | |
| assertEq(swWrapped.__id, "c", "root unchanged"); | |
| console.log(" wrap(add(3,4), 'a', neg) → add(neg(3), 4) via dirty+commit"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 7: type-level prevents passing DirtyExpr to consumers | |
| // ============================================================ | |
| console.log("\n=== Test 7: DirtyExpr not assignable to Expr ==="); | |
| function consume<O, R extends Desc<O, string, Record<string, any>, string>>( | |
| expr: Expr<O, R>, | |
| ): string { | |
| return expr.__id; | |
| } | |
| const $p = freshCtx(); | |
| const [pa, $p1] = $p.numLit(42); | |
| const dirtyPa = dirty(pa); | |
| // This works — clean Expr | |
| consume(pa); | |
| // @ts-expect-error — DirtyExpr not assignable to Expr | |
| consume(dirtyPa); | |
| console.log(" DirtyExpr correctly rejected by consumer function"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Summary | |
| // ============================================================ | |
| console.log("\n=== All tests passed ==="); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Expr<O, R> Spike — Content-Addressed DAG with Type-Safe Rewrites | |
| // ============================================================ | |
| // | |
| // Design decisions proven by this spike: | |
| // 1. Expr<O, R> — O is output type (inference hint), R is full descriptor | |
| // 2. R = Desc<O, ID, Adj> — single descriptor with output, identity, adjacency | |
| // 3. Content-addressed IDs via template literals — zero ceremony, auto-dedup | |
| // 4. Intersection-based adjacency map — flat, O(1) depth queries/rewrites | |
| // 5. DAG sharing via intersection dedup — same content = same key | |
| // 6. NoInfer<T> on cond's else branch — catches mismatched branches | |
| // | |
| // Verified: 400+ node programs, all queries/rewrites pass, ~2s compile time. | |
| // ============================================================ | |
| // Core types | |
| // ============================================================ | |
| /** Entry in the adjacency map describing a single node */ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| /** Full descriptor: output type + content-addressed ID + adjacency map */ | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| }; | |
| /** Branded Expr with two phantom params: O (output type) and R (descriptor) */ | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| } | |
| // ============================================================ | |
| // Descriptor field extractors | |
| // ============================================================ | |
| type OutOf<R> = R extends Desc<infer O, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A> ? A : never; | |
| // ============================================================ | |
| // Content-addressed IDs via template literal types | |
| // ============================================================ | |
| type LitID<V extends string | number | boolean> = `lit(${V})`; | |
| type AddID<L extends string, R extends string> = `add(${L},${R})`; | |
| type MulID<L extends string, R extends string> = `mul(${L},${R})`; | |
| type NegID<A extends string> = `neg(${A})`; | |
| type GtID<L extends string, R extends string> = `gt(${L},${R})`; | |
| type CondID<P extends string, T extends string, E extends string> = `cond(${P},${T},${E})`; | |
| type ShowID<A extends string> = `show(${A})`; | |
| type ConcatID<L extends string, R extends string> = `concat(${L},${R})`; | |
| // ============================================================ | |
| // Builders | |
| // ============================================================ | |
| function numLit<V extends number>( | |
| value: V, | |
| ): Expr<number, Desc<number, LitID<V>, Record<LitID<V>, NodeEntry<"num/literal", [], number>>>> { | |
| return { kind: "num/literal", value } as any; | |
| } | |
| function strLit<V extends string>( | |
| value: V, | |
| ): Expr<string, Desc<string, LitID<V>, Record<LitID<V>, NodeEntry<"str/literal", [], string>>>> { | |
| return { kind: "str/literal", value } as any; | |
| } | |
| function add< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): Expr<number, Desc< | |
| number, | |
| AddID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<AddID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>> | |
| >> { | |
| return { kind: "num/add", left, right } as any; | |
| } | |
| function mul< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): Expr<number, Desc< | |
| number, | |
| MulID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<MulID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>> | |
| >> { | |
| return { kind: "num/mul", left, right } as any; | |
| } | |
| function neg< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| >( | |
| operand: Expr<number, RA>, | |
| ): Expr<number, Desc< | |
| number, | |
| NegID<IdOf<RA>>, | |
| AdjOf<RA> & Record<NegID<IdOf<RA>>, NodeEntry<"num/neg", [IdOf<RA>], number>> | |
| >> { | |
| return { kind: "num/neg", operand } as any; | |
| } | |
| function gt< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): Expr<boolean, Desc< | |
| boolean, | |
| GtID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<GtID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/gt", [IdOf<RA>, IdOf<RB>], boolean>> | |
| >> { | |
| return { kind: "num/gt", left, right } as any; | |
| } | |
| function show< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| >( | |
| value: Expr<number, RA>, | |
| ): Expr<string, Desc< | |
| string, | |
| ShowID<IdOf<RA>>, | |
| AdjOf<RA> & Record<ShowID<IdOf<RA>>, NodeEntry<"str/show", [IdOf<RA>], string>> | |
| >> { | |
| return { kind: "str/show", operand: value } as any; | |
| } | |
| function concat< | |
| RA extends Desc<string, string, Record<string, any>>, | |
| RB extends Desc<string, string, Record<string, any>>, | |
| >( | |
| left: Expr<string, RA>, | |
| right: Expr<string, RB>, | |
| ): Expr<string, Desc< | |
| string, | |
| ConcatID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<ConcatID<IdOf<RA>, IdOf<RB>>, NodeEntry<"str/concat", [IdOf<RA>, IdOf<RB>], string>> | |
| >> { | |
| return { kind: "str/concat", left, right } as any; | |
| } | |
| function cond< | |
| T, | |
| RP extends Desc<boolean, string, Record<string, any>>, | |
| RT extends Desc<T, string, Record<string, any>>, | |
| RE extends Desc<NoInfer<T>, string, Record<string, any>>, | |
| >( | |
| pred: Expr<boolean, RP>, | |
| then_: Expr<T, RT>, | |
| else_: Expr<NoInfer<T>, RE>, | |
| ): Expr<T, Desc< | |
| T, | |
| CondID<IdOf<RP>, IdOf<RT>, IdOf<RE>>, | |
| AdjOf<RP> & AdjOf<RT> & AdjOf<RE> & Record<CondID<IdOf<RP>, IdOf<RT>, IdOf<RE>>, NodeEntry<"core/cond", [IdOf<RP>, IdOf<RT>, IdOf<RE>], T>> | |
| >> { | |
| return { kind: "core/cond", pred, then: then_, else: else_ } as any; | |
| } | |
| // ============================================================ | |
| // Type-level queries — all O(1) depth (flat mapped types) | |
| // ============================================================ | |
| /** Extract R from an Expr */ | |
| type ROf<E> = E extends Expr<any, infer R> ? R : never; | |
| /** Does the program contain a node of the given kind? */ | |
| type ContainsKind<R, K extends string> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [Key in keyof Adj]: Adj[Key] extends NodeEntry<K, any, any> ? true : never }[keyof Adj] extends never | |
| ? false | |
| : true | |
| : false; | |
| /** Collect all output types across all nodes */ | |
| type CollectOutputs<R> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [Key in keyof Adj]: Adj[Key] extends NodeEntry<any, any, infer O> ? O : never }[keyof Adj] | |
| : never; | |
| /** Collect all node kinds */ | |
| type CollectKinds<R> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [Key in keyof Adj]: Adj[Key] extends NodeEntry<infer K, any, any> ? K : never }[keyof Adj] | |
| : never; | |
| // ============================================================ | |
| // Type-level rewrites — R → R in one type function | |
| // ============================================================ | |
| /** Rewrite all nodes of kind From to kind To */ | |
| type RewriteKind<R, From extends string, To extends string> = | |
| R extends Desc<infer O, infer ID, infer Adj> | |
| ? Desc<O, ID, { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<From, infer C, infer Out> | |
| ? NodeEntry<To, C, Out> | |
| : Adj[K] | |
| }> | |
| : never; | |
| /** Term-level rewrite (type tracks the transformation) */ | |
| function rewriteKind< | |
| O, R extends Desc<O, string, Record<string, any>>, | |
| From extends string, To extends string, | |
| >(expr: Expr<O, R>, _from: From, _to: To): Expr<O, RewriteKind<R, From, To>> { | |
| // Runtime: walk term, swap kind strings | |
| return expr as any; | |
| } | |
| // ============================================================ | |
| // Test 1: Basic type checking | |
| // ============================================================ | |
| const a = numLit(3); | |
| const b = numLit(4); | |
| const c = add(a, b); | |
| const d = mul(c, numLit(5)); | |
| const e = neg(d); | |
| // Cross-type composition | |
| const num = add(numLit(1), numLit(2)); | |
| const str = show(num); | |
| const msg = concat(strLit("result: "), str); | |
| // Type errors caught: | |
| // @ts-expect-error: can't add string to number | |
| const bad1 = add(numLit(1), strLit("oops")); | |
| // @ts-expect-error: can't concat number with string | |
| const bad2 = concat(numLit(1), strLit("hi")); | |
| // ============================================================ | |
| // Test 2: cond — branch type enforcement | |
| // ============================================================ | |
| const pred = gt(numLit(1), numLit(2)); | |
| // Valid: both branches number | |
| const ok1 = cond(pred, numLit(3), numLit(4)); | |
| // Valid: both branches string | |
| const ok2 = cond(pred, strLit("yes"), strLit("no")); | |
| // @ts-expect-error: number vs string mismatch | |
| const bad3 = cond(pred, numLit(3), strLit("nope")); | |
| // @ts-expect-error: string vs number mismatch (reversed) | |
| const bad4 = cond(pred, strLit("yes"), numLit(3)); | |
| // Nested cond: valid | |
| const nested_ok = cond(pred, cond(pred, numLit(1), numLit(2)), numLit(3)); | |
| // @ts-expect-error: nested cond with mismatch | |
| const nested_bad = cond(pred, cond(pred, numLit(1), numLit(2)), strLit("nope")); | |
| // ============================================================ | |
| // Test 3: DAG sharing | |
| // ============================================================ | |
| // Same node referenced twice — appears once in adjacency map | |
| const shared = numLit(42); | |
| const dag = add(shared, shared); | |
| // Content-addressed structural equality | |
| const tree1 = add(numLit(1), numLit(2)); | |
| const tree2 = add(numLit(1), numLit(2)); | |
| // tree1 and tree2 have identical R types — free structural equality | |
| // ============================================================ | |
| // Test 4: Queries | |
| // ============================================================ | |
| type MsgR = ROf<typeof msg>; | |
| type MsgOutput = OutOf<MsgR>; // string | |
| type MsgHasAdd = ContainsKind<MsgR, "num/add">; // true | |
| type MsgHasConcat = ContainsKind<MsgR, "str/concat">; // true | |
| type MsgHasFetch = ContainsKind<MsgR, "fetch/get">; // false | |
| type MsgAllOutputs = CollectOutputs<MsgR>; // number | string | |
| type MsgAllKinds = CollectKinds<MsgR>; // "num/literal" | "num/add" | "str/show" | "str/literal" | "str/concat" | |
| // ============================================================ | |
| // Test 5: Rewrites | |
| // ============================================================ | |
| type MsgRewritten = RewriteKind<MsgR, "num/add", "num/mul">; | |
| type MsgRewrittenHasAdd = ContainsKind<MsgRewritten, "num/add">; // false | |
| type MsgRewrittenHasMul = ContainsKind<MsgRewritten, "num/mul">; // true | |
| type MsgRewrittenOutput = OutOf<MsgRewritten>; // string (preserved) | |
| // Chained rewrites | |
| type DoubleRewrite = RewriteKind<RewriteKind<MsgR, "num/add", "num/mul">, "str/concat", "str/join">; | |
| type DRHasMul = ContainsKind<DoubleRewrite, "num/mul">; // true | |
| type DRHasJoin = ContainsKind<DoubleRewrite, "str/join">; // true | |
| type DRHasAdd = ContainsKind<DoubleRewrite, "num/add">; // false | |
| type DRHasConcat = ContainsKind<DoubleRewrite, "str/concat">; // false | |
| // Term-level rewrite with type tracking | |
| const rewritten = rewriteKind(msg, "num/add", "num/mul"); | |
| // rewritten: Expr<string, RewriteKind<MsgR, "num/add", "num/mul">> | |
| // ============================================================ | |
| // Test 6: Stress test — deep chains | |
| // ============================================================ | |
| function deepChain<R extends Desc<number, string, Record<string, any>>>( | |
| base: Expr<number, R>, | |
| ) { | |
| const l1 = numLit(1); | |
| const a = add(base, l1); | |
| const l2 = numLit(2); | |
| const b = mul(a, l2); | |
| const l3 = numLit(3); | |
| const c = add(b, l3); | |
| const l4 = numLit(4); | |
| const d = mul(c, l4); | |
| const l5 = numLit(5); | |
| const e = add(d, l5); | |
| return e; | |
| } | |
| const ch1 = deepChain(numLit(0)); | |
| const ch2 = deepChain(ch1); | |
| const ch3 = deepChain(ch2); | |
| const ch4 = deepChain(ch3); | |
| const ch5 = deepChain(ch4); | |
| const ch10 = deepChain(deepChain(deepChain(deepChain(deepChain(ch5))))); | |
| const ch20 = deepChain(deepChain(deepChain(deepChain(deepChain( | |
| deepChain(deepChain(deepChain(deepChain(deepChain(ch10)))))))))); | |
| const ch30 = deepChain(deepChain(deepChain(deepChain(deepChain( | |
| deepChain(deepChain(deepChain(deepChain(deepChain(ch20)))))))))); | |
| const ch40 = deepChain(deepChain(deepChain(deepChain(deepChain( | |
| deepChain(deepChain(deepChain(deepChain(deepChain(ch30)))))))))); | |
| // Queries at 400+ node depth — all O(1) due to flat adjacency | |
| type Ch40R = ROf<typeof ch40>; | |
| type Ch40HasAdd = ContainsKind<Ch40R, "num/add">; // true | |
| type Ch40HasMul = ContainsKind<Ch40R, "num/mul">; // true | |
| type Ch40Outputs = CollectOutputs<Ch40R>; // number | |
| type Ch40Kinds = CollectKinds<Ch40R>; // "num/literal" | "num/add" | "num/mul" | |
| // Rewrite at scale | |
| type Ch40Rewritten = RewriteKind<Ch40R, "num/add", "num/mul">; | |
| type Ch40RewrittenHasAdd = ContainsKind<Ch40Rewritten, "num/add">; // false | |
| type Ch40RewrittenKinds = CollectKinds<Ch40Rewritten>; // "num/literal" | "num/mul" | |
| console.log("All tests pass"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: foldAST bridge — tree ↔ adjacency map round-trip | |
| // ============================================================ | |
| // | |
| // Proves: DAGQL transforms on adjacency maps can be converted | |
| // back to tree-shaped AST that the existing foldAST consumes. | |
| // | |
| // The bridge: | |
| // app() → Program (tree AST) | |
| // → toDAG: flatten to adjacency map | |
| // → DAGQL transforms (mapWhere, wrapWhere, splice, etc.) | |
| // → toAST: reconstruct tree from adjacency map | |
| // → foldAST(interpreter, reconstructed tree) | |
| // ============================================================ | |
| import { mvfm } from "./packages/core/src/core"; | |
| import { foldAST, defaults } from "./packages/core/src"; | |
| import { num } from "./packages/core/src/plugins/num"; | |
| import { str } from "./packages/core/src/plugins/str"; | |
| import { semiring } from "./packages/core/src/plugins/semiring"; | |
| // ============================================================ | |
| // Types for the adjacency map | |
| // ============================================================ | |
| interface DagEntry { | |
| kind: string; | |
| // Named fields → child IDs (preserves field names for tree reconstruction) | |
| fields: Record<string, string | string[]>; | |
| // For leaf values (core/literal, etc.) | |
| data: Record<string, unknown>; | |
| } | |
| interface Dag { | |
| rootId: string; | |
| entries: Record<string, DagEntry>; | |
| counter: number; | |
| } | |
| // ============================================================ | |
| // toDAG: flatten tree AST → adjacency map | |
| // ============================================================ | |
| function toDAG(ast: any): Dag { | |
| const entries: Record<string, DagEntry> = {}; | |
| let counter = 0; | |
| function walk(node: any): string { | |
| // Reuse existing __id if present, otherwise assign fresh | |
| const id = String(counter++); | |
| const fields: Record<string, string | string[]> = {}; | |
| const data: Record<string, unknown> = {}; | |
| for (const [key, val] of Object.entries(node)) { | |
| if (key === "kind" || key === "__id" || key === "__T") continue; | |
| if (Array.isArray(val)) { | |
| // Array of child nodes (e.g., values, steps) | |
| if (val.length > 0 && typeof val[0] === "object" && val[0] !== null && "kind" in val[0]) { | |
| fields[key] = val.map((child: any) => walk(child)); | |
| } else { | |
| data[key] = val; // primitive array | |
| } | |
| } else if (typeof val === "object" && val !== null && "kind" in val) { | |
| // Single child node | |
| fields[key] = walk(val); | |
| } else { | |
| // Primitive data (value, etc.) | |
| data[key] = val; | |
| } | |
| } | |
| entries[id] = { kind: node.kind, fields, data }; | |
| return id; | |
| } | |
| const rootId = walk(ast); | |
| return { rootId, entries, counter }; | |
| } | |
| // ============================================================ | |
| // toAST: reconstruct tree from adjacency map | |
| // ============================================================ | |
| function toAST(dag: Dag): any { | |
| const { rootId, entries } = dag; | |
| function materialize(id: string): any { | |
| const entry = entries[id]; | |
| if (!entry) throw new Error(`Missing entry for id ${id}`); | |
| const node: any = { kind: entry.kind }; | |
| // Restore named fields as child nodes | |
| for (const [key, val] of Object.entries(entry.fields)) { | |
| if (Array.isArray(val)) { | |
| node[key] = val.map((childId) => materialize(childId)); | |
| } else { | |
| node[key] = materialize(val); | |
| } | |
| } | |
| // Restore leaf data | |
| for (const [key, val] of Object.entries(entry.data)) { | |
| node[key] = val; | |
| } | |
| return node; | |
| } | |
| return materialize(rootId); | |
| } | |
| // ============================================================ | |
| // Simple DAGQL-style transforms on the adjacency map | |
| // ============================================================ | |
| function selectByKind(dag: Dag, kind: string): string[] { | |
| return Object.entries(dag.entries) | |
| .filter(([_, e]) => e.kind === kind) | |
| .map(([id]) => id); | |
| } | |
| function mapByKind( | |
| dag: Dag, | |
| kind: string, | |
| fn: (entry: DagEntry, id: string) => DagEntry, | |
| ): Dag { | |
| const newEntries: Record<string, DagEntry> = {}; | |
| for (const [id, entry] of Object.entries(dag.entries)) { | |
| newEntries[id] = entry.kind === kind ? fn(entry, id) : entry; | |
| } | |
| return { ...dag, entries: newEntries }; | |
| } | |
| function replaceEntry(dag: Dag, id: string, newEntry: DagEntry): Dag { | |
| return { | |
| ...dag, | |
| entries: { ...dag.entries, [id]: newEntry }, | |
| }; | |
| } | |
| // ============================================================ | |
| // Tests | |
| // ============================================================ | |
| let passed = 0; | |
| let failed = 0; | |
| function assert(cond: boolean, msg: string) { | |
| if (cond) { passed++; console.log(` ✓ ${msg}`); } | |
| else { failed++; console.log(` ✗ ${msg}`); } | |
| } | |
| async function run() { | |
| const app = mvfm(num, str, semiring); | |
| const interp = defaults(app); | |
| // ---- Test 1: round-trip without transform ---- | |
| console.log("Test 1: round-trip — tree → DAG → tree → foldAST"); | |
| const prog1 = app(($) => $.add(1, 2)); | |
| const original = await foldAST(interp, prog1); | |
| assert(original === 3, `original: add(1,2) = ${original}`); | |
| const dag1 = toDAG(prog1.ast); | |
| const rebuilt1 = toAST(dag1); | |
| const roundTripped = await foldAST(interp, rebuilt1); | |
| assert(roundTripped === 3, `round-tripped: add(1,2) = ${roundTripped}`); | |
| // ---- Test 2: modify a literal, then foldAST ---- | |
| console.log("Test 2: modify literal — change 1 to 99, expect 99+2=101"); | |
| const dag2 = toDAG(prog1.ast); | |
| // Find the literal with value 1 | |
| const litIds = Object.entries(dag2.entries) | |
| .filter(([_, e]) => e.kind === "core/literal" && e.data.value === 1) | |
| .map(([id]) => id); | |
| assert(litIds.length === 1, `found literal(1) at id ${litIds[0]}`); | |
| const modified = replaceEntry(dag2, litIds[0], { | |
| kind: "core/literal", | |
| fields: {}, | |
| data: { value: 99 }, | |
| }); | |
| const rebuilt2 = toAST(modified); | |
| const result2 = await foldAST(interp, rebuilt2); | |
| assert(result2 === 101, `modified: add(99,2) = ${result2}`); | |
| // ---- Test 3: deeper program — (3+4)*5 ---- | |
| console.log("Test 3: deeper program — (3+4)*5 = 35"); | |
| const prog3 = app(($) => $.mul($.add(3, 4), 5)); | |
| const orig3 = await foldAST(interp, prog3); | |
| assert(orig3 === 35, `original: (3+4)*5 = ${orig3}`); | |
| const dag3 = toDAG(prog3.ast); | |
| const rebuilt3 = toAST(dag3); | |
| const rt3 = await foldAST(interp, rebuilt3); | |
| assert(rt3 === 35, `round-tripped: (3+4)*5 = ${rt3}`); | |
| // ---- Test 4: mapByKind — replace all num/add with num/mul ---- | |
| console.log("Test 4: mapByKind — replace num/add with num/mul → (3*4)*5 = 60"); | |
| const dag4 = toDAG(prog3.ast); | |
| const swapped = mapByKind(dag4, "num/add", (entry) => ({ | |
| ...entry, | |
| kind: "num/mul", | |
| })); | |
| const rebuilt4 = toAST(swapped); | |
| const result4 = await foldAST(interp, rebuilt4); | |
| assert(result4 === 60, `swapped add→mul: (3*4)*5 = ${result4}`); | |
| // ---- Test 5: constant folding via DAGQL ---- | |
| console.log("Test 5: constant fold — replace add(3,4) with literal(7)"); | |
| const dag5 = toDAG(prog3.ast); | |
| const addIds = selectByKind(dag5, "num/add"); | |
| assert(addIds.length === 1, `found 1 num/add node`); | |
| // Replace the add node with a literal | |
| const folded = replaceEntry(dag5, addIds[0], { | |
| kind: "core/literal", | |
| fields: {}, | |
| data: { value: 7 }, | |
| }); | |
| const rebuilt5 = toAST(folded); | |
| const result5 = await foldAST(interp, rebuilt5); | |
| assert(result5 === 35, `folded: 7*5 = ${result5}`); | |
| // ---- Test 6: string program round-trip ---- | |
| console.log("Test 6: string program round-trip"); | |
| const prog6 = app(($) => $.concat("hello", " ", "world")); | |
| const orig6 = await foldAST(interp, prog6); | |
| assert(orig6 === "hello world", `original: concat = "${orig6}"`); | |
| const dag6 = toDAG(prog6.ast); | |
| const rebuilt6 = toAST(dag6); | |
| const rt6 = await foldAST(interp, rebuilt6); | |
| assert(rt6 === "hello world", `round-tripped: concat = "${rt6}"`); | |
| // ---- Test 7: selectByKind on adjacency map ---- | |
| console.log("Test 7: selectByKind works on flattened DAG"); | |
| const dag7 = toDAG(prog3.ast); | |
| const literals = selectByKind(dag7, "core/literal"); | |
| assert(literals.length === 3, `found ${literals.length} literals (3, 4, 5)`); | |
| const adds = selectByKind(dag7, "num/add"); | |
| assert(adds.length === 1, `found ${adds.length} num/add`); | |
| const muls = selectByKind(dag7, "num/mul"); | |
| assert(muls.length === 1, `found ${muls.length} num/mul`); | |
| // ---- Test 8: defaults(app) is unaffected ---- | |
| console.log("Test 8: defaults(app) doesn't depend on AST shape"); | |
| // defaults only reads app.plugins, not the AST. | |
| // If we transform the AST and use the same interpreter, it works. | |
| const interp2 = defaults(app); | |
| const result8 = await foldAST(interp2, rebuilt4); // the add→mul swapped tree | |
| assert(result8 === 60, `same interpreter on transformed tree: ${result8}`); | |
| console.log(`\nResults: ${passed} passed, ${failed} failed`); | |
| if (failed > 0) process.exit(1); | |
| console.log("\nfoldAST bridge spike passed!"); | |
| } | |
| run().catch((e) => { console.error(e); process.exit(1); }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Codegenned ID chain — O(1) fresh IDs, no recursion | |
| // ============================================================ | |
| import { IDChain, FirstID } from "./spike-idchain"; | |
| // ============================================================ | |
| // Core types | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| // NextID: O(1) property lookup, no recursion | |
| type NextID<Current extends keyof IDChain> = IDChain[Current]; | |
| // Desc now carries a counter string instead of a dispenser | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly c: C; // counter: next available ID | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| } | |
| type OutOf<R> = R extends Desc<infer O, any, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A, any> ? A : never; | |
| type CtrOf<R> = R extends Desc<any, any, any, infer C> ? C : never; | |
| // ============================================================ | |
| // Builders: each uses the counter as node ID, advances counter | |
| // ============================================================ | |
| function numLit<V extends number, C extends string & keyof IDChain>( | |
| value: V, | |
| _counter: C, | |
| ): Expr<number, Desc< | |
| number, | |
| C, | |
| Record<C, NodeEntry<"num/literal", [], number>>, | |
| NextID<C> & string | |
| >> { | |
| return {} as any; | |
| } | |
| function strLit<V extends string, C extends string & keyof IDChain>( | |
| value: V, | |
| _counter: C, | |
| ): Expr<string, Desc< | |
| string, | |
| C, | |
| Record<C, NodeEntry<"str/literal", [], string>>, | |
| NextID<C> & string | |
| >> { | |
| return {} as any; | |
| } | |
| function add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): CtrOf<RB> extends keyof IDChain | |
| ? Expr<number, Desc< | |
| number, | |
| CtrOf<RB> & string, | |
| AdjOf<RA> & AdjOf<RB> & Record<CtrOf<RB> & string, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<CtrOf<RB> & keyof IDChain> & string | |
| >> | |
| : never { | |
| return {} as any; | |
| } | |
| function mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): CtrOf<RB> extends keyof IDChain | |
| ? Expr<number, Desc< | |
| number, | |
| CtrOf<RB> & string, | |
| AdjOf<RA> & AdjOf<RB> & Record<CtrOf<RB> & string, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<CtrOf<RB> & keyof IDChain> & string | |
| >> | |
| : never { | |
| return {} as any; | |
| } | |
| // ============================================================ | |
| // But wait — same threading problem. Let's use a context. | |
| // ============================================================ | |
| interface BuildCtx<C extends string> { | |
| numLit<V extends number>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, Record<C, NodeEntry<"num/literal", [], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| strLit<V extends string>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<string, Desc<string, C, Record<C, NodeEntry<"str/literal", [], string>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, | |
| C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, | |
| C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| cond< | |
| T, | |
| RP extends Desc<boolean, string, Record<string, any>, string>, | |
| RT extends Desc<T, string, Record<string, any>, string>, | |
| RE extends Desc<NoInfer<T>, string, Record<string, any>, string>, | |
| >( | |
| pred: Expr<boolean, RP>, | |
| then_: Expr<T, RT>, | |
| else_: Expr<NoInfer<T>, RE>, | |
| ): C extends keyof IDChain | |
| ? [Expr<T, Desc< | |
| T, | |
| C, | |
| AdjOf<RP> & AdjOf<RT> & AdjOf<RE> & Record<C, NodeEntry<"core/cond", [IdOf<RP>, IdOf<RT>, IdOf<RE>], T>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| } | |
| declare function createCtx(): BuildCtx<FirstID>; | |
| // ============================================================ | |
| // Test: build a program | |
| // ============================================================ | |
| const $ = createCtx(); | |
| const [lit3, $1] = $.numLit(3); | |
| const [lit4, $2] = $1.numLit(4); | |
| const [sum, $3] = $2.add(lit3, lit4); | |
| const [lit5, $4] = $3.numLit(5); | |
| const [prog, $5] = $4.mul(sum, lit5); | |
| // Verify IDs | |
| type ProgR = (typeof prog)[typeof exprBrand]; | |
| type ProgID = IdOf<ProgR>; | |
| type ProgAdj = AdjOf<ProgR>; | |
| const _progId: ProgID = "e"; // 5th ID: a,b,c,d,e | |
| // Check adj entries | |
| type HasA = "a" extends keyof ProgAdj ? true : false; | |
| type HasB = "b" extends keyof ProgAdj ? true : false; | |
| type HasC = "c" extends keyof ProgAdj ? true : false; | |
| type HasD = "d" extends keyof ProgAdj ? true : false; | |
| type HasE = "e" extends keyof ProgAdj ? true : false; | |
| const _a: HasA = true; | |
| const _b: HasB = true; | |
| const _c: HasC = true; | |
| const _d: HasD = true; | |
| const _e: HasE = true; | |
| // Check add's children | |
| type AddEntry = ProgAdj["c"]; | |
| type AddKids = AddEntry extends NodeEntry<any, infer C, any> ? C : never; | |
| const _addKids: AddKids = ["a", "b"]; | |
| // Check mul's children | |
| type MulEntry = ProgAdj["e"]; | |
| type MulKids = MulEntry extends NodeEntry<any, infer C, any> ? C : never; | |
| const _mulKids: MulKids = ["c", "d"]; | |
| // ============================================================ | |
| // Replacement: just swap the entry. No cascade. | |
| // ============================================================ | |
| type ReplaceEntry< | |
| Adj extends Record<string, any>, | |
| TargetID extends string, | |
| NewEntry, | |
| > = { | |
| [K in keyof Adj]: K extends TargetID ? NewEntry : Adj[K]; | |
| }; | |
| function replaceNode< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| TargetID extends string & keyof AdjOf<R>, | |
| NewKind extends string, | |
| NewChildren extends string[], | |
| >( | |
| program: Expr<O, R>, | |
| targetId: TargetID, | |
| newKind: NewKind, | |
| newChildren: NewChildren, | |
| ): Expr<O, Desc< | |
| O, | |
| IdOf<R>, | |
| ReplaceEntry<AdjOf<R>, TargetID, NodeEntry<NewKind, NewChildren, AdjOf<R>[TargetID] extends NodeEntry<any, any, infer TO> ? TO : never>>, | |
| CtrOf<R> | |
| >> { | |
| return {} as any; | |
| } | |
| const replaced = replaceNode(prog, "a", "num/literal", []); | |
| // Root ID unchanged | |
| type ReplacedR = (typeof replaced)[typeof exprBrand]; | |
| type ReplacedID = IdOf<ReplacedR>; | |
| const _replacedId: ReplacedID = "e"; // still e! | |
| // Parents still reference "a" — no cascade needed | |
| type ReplacedAdj = AdjOf<ReplacedR>; | |
| type ReplacedAddKids = ReplacedAdj["c"] extends NodeEntry<any, infer C, any> ? C : never; | |
| const _rAddKids: ReplacedAddKids = ["a", "b"]; // unchanged! | |
| // ============================================================ | |
| // Queries: identical to before | |
| // ============================================================ | |
| type ContainsKind<R, K extends string> = | |
| R extends Desc<any, any, infer Adj, any> | |
| ? { [Key in keyof Adj]: Adj[Key] extends NodeEntry<K, any, any> ? true : never }[keyof Adj] extends never | |
| ? false | |
| : true | |
| : false; | |
| type CollectKinds<R> = | |
| R extends Desc<any, any, infer Adj, any> | |
| ? { [K in keyof Adj]: Adj[K] extends NodeEntry<infer Kind, any, any> ? Kind : never }[keyof Adj] | |
| : never; | |
| type ProgHasAdd = ContainsKind<ProgR, "num/add">; | |
| type ProgHasSub = ContainsKind<ProgR, "num/sub">; | |
| const _hasAdd: ProgHasAdd = true; | |
| const _hasSub: ProgHasSub = false; | |
| type Kinds = CollectKinds<ProgR>; | |
| // ^? "num/literal" | "num/add" | "num/mul" | |
| // ============================================================ | |
| // Scale test: build a longer chain | |
| // ============================================================ | |
| const $$ = createCtx(); | |
| const [n0, $$1] = $$.numLit(1); | |
| const [n1, $$2] = $$1.numLit(2); | |
| const [s0, $$3] = $$2.add(n0, n1); | |
| const [n2, $$4] = $$3.numLit(3); | |
| const [s1, $$5] = $$4.add(s0, n2); | |
| const [n3, $$6] = $$5.numLit(4); | |
| const [s2, $$7] = $$6.add(s1, n3); | |
| const [n4, $$8] = $$7.numLit(5); | |
| const [s3, $$9] = $$8.add(s2, n4); | |
| const [n5, $$10] = $$9.numLit(6); | |
| const [s4, $$11] = $$10.add(s3, n5); | |
| const [n6, $$12] = $$11.numLit(7); | |
| const [s5, $$13] = $$12.add(s4, n6); | |
| const [n7, $$14] = $$13.numLit(8); | |
| const [s6, $$15] = $$14.add(s5, n7); | |
| const [n8, $$16] = $$15.numLit(9); | |
| const [s7, $$17] = $$16.add(s6, n8); | |
| const [n9, $$18] = $$17.numLit(10); | |
| const [s8, $$19] = $$18.add(s7, n9); | |
| // 19 nodes, counter should be at "t" (20th letter) | |
| type ScaleR = (typeof s8)[typeof exprBrand]; | |
| type ScaleCtr = CtrOf<ScaleR>; | |
| const _scaleCtr: ScaleCtr = "t"; | |
| type ScaleKinds = CollectKinds<ScaleR>; | |
| // ^? "num/literal" | "num/add" | |
| console.log("ID chain spike compiled successfully"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Type-level string increment — replacing 18k IDChain | |
| // ============================================================ | |
| // | |
| // Instead of a codegenned 18k-line lookup table, compute NextID | |
| // algorithmically: base-26 increment on lowercase strings. | |
| // a → b → ... → z → aa → ab → ... → az → ba → ... → zz → aaa → ... | |
| // | |
| // Recursion depth = number of trailing z's (max = string length). | |
| // For "abc": 0. For "zzz": 3. Well within TS limits. | |
| // ============================================================ | |
| // 26-entry char successor table (the ONLY lookup) | |
| type CharNext = { | |
| a: "b"; b: "c"; c: "d"; d: "e"; e: "f"; f: "g"; g: "h"; h: "i"; | |
| i: "j"; j: "k"; k: "l"; l: "m"; m: "n"; n: "o"; o: "p"; p: "q"; | |
| q: "r"; r: "s"; s: "t"; t: "u"; u: "v"; v: "w"; w: "x"; x: "y"; | |
| y: "z"; | |
| }; | |
| // IncrementLast: advance the last character (non-z case) | |
| // Each pattern is unambiguous — trailing literal forces single-char match | |
| type IncrementLast<S extends string> = | |
| S extends `${infer R}a` ? `${R}b` : S extends `${infer R}b` ? `${R}c` : | |
| S extends `${infer R}c` ? `${R}d` : S extends `${infer R}d` ? `${R}e` : | |
| S extends `${infer R}e` ? `${R}f` : S extends `${infer R}f` ? `${R}g` : | |
| S extends `${infer R}g` ? `${R}h` : S extends `${infer R}h` ? `${R}i` : | |
| S extends `${infer R}i` ? `${R}j` : S extends `${infer R}j` ? `${R}k` : | |
| S extends `${infer R}k` ? `${R}l` : S extends `${infer R}l` ? `${R}m` : | |
| S extends `${infer R}m` ? `${R}n` : S extends `${infer R}n` ? `${R}o` : | |
| S extends `${infer R}o` ? `${R}p` : S extends `${infer R}p` ? `${R}q` : | |
| S extends `${infer R}q` ? `${R}r` : S extends `${infer R}r` ? `${R}s` : | |
| S extends `${infer R}s` ? `${R}t` : S extends `${infer R}t` ? `${R}u` : | |
| S extends `${infer R}u` ? `${R}v` : S extends `${infer R}v` ? `${R}w` : | |
| S extends `${infer R}w` ? `${R}x` : S extends `${infer R}x` ? `${R}y` : | |
| S extends `${infer R}y` ? `${R}z` : never; | |
| // Increment: right-to-left with carry | |
| // - Trailing z? Carry: increment rest, reset to 'a' | |
| // - Non-z? Advance last char via IncrementLast | |
| // - All z's? Grow length (z→aa, zz→aaa, zzz→aaaa) | |
| type Increment<S extends string> = | |
| S extends `${infer Rest}z` | |
| ? Rest extends "" | |
| ? "aa" | |
| : `${Increment<Rest>}a` | |
| : IncrementLast<S>; | |
| type FirstID = "a"; | |
| // ============================================================ | |
| // TEST: single char | |
| // ============================================================ | |
| const _t01: Increment<"a"> = "b"; | |
| const _t02: Increment<"b"> = "c"; | |
| const _t03: Increment<"m"> = "n"; | |
| const _t04: Increment<"y"> = "z"; | |
| const _t05: Increment<"z"> = "aa"; | |
| // NEGATIVE | |
| // @ts-expect-error — a→b, not c | |
| const _t06: Increment<"a"> = "c"; | |
| // @ts-expect-error — z→aa, not ba | |
| const _t07: Increment<"z"> = "ba"; | |
| // ============================================================ | |
| // TEST: two char — no carry | |
| // ============================================================ | |
| const _t10: Increment<"aa"> = "ab"; | |
| const _t11: Increment<"ab"> = "ac"; | |
| const _t12: Increment<"ay"> = "az"; | |
| const _t13: Increment<"ba"> = "bb"; | |
| const _t14: Increment<"zy"> = "zz"; | |
| // ============================================================ | |
| // TEST: two char — carry | |
| // ============================================================ | |
| const _t20: Increment<"az"> = "ba"; | |
| const _t21: Increment<"bz"> = "ca"; | |
| const _t22: Increment<"yz"> = "za"; | |
| const _t23: Increment<"zz"> = "aaa"; | |
| // NEGATIVE | |
| // @ts-expect-error — az→ba, not aa | |
| const _t24: Increment<"az"> = "aa"; | |
| // ============================================================ | |
| // TEST: three char | |
| // ============================================================ | |
| const _t30: Increment<"aaa"> = "aab"; | |
| const _t31: Increment<"aaz"> = "aba"; | |
| const _t32: Increment<"abz"> = "aca"; | |
| const _t33: Increment<"azz"> = "baa"; | |
| const _t34: Increment<"zzz"> = "aaaa"; | |
| const _t35: Increment<"zzy"> = "zzz"; | |
| const _t36: Increment<"abc"> = "abd"; | |
| // ============================================================ | |
| // TEST: four char (proves no depth issues) | |
| // ============================================================ | |
| const _t40: Increment<"aaaa"> = "aaab"; | |
| const _t41: Increment<"zzzz"> = "aaaaa"; | |
| const _t42: Increment<"abcd"> = "abce"; | |
| const _t43: Increment<"azzz"> = "baaa"; | |
| // ============================================================ | |
| // Chained increments (simulating counter threading) | |
| // ============================================================ | |
| type ID0 = FirstID; // "a" | |
| type ID1 = Increment<ID0>; // "b" | |
| type ID2 = Increment<ID1>; // "c" | |
| type ID3 = Increment<ID2>; // "d" | |
| type ID4 = Increment<ID3>; // "e" | |
| const _c0: ID0 = "a"; | |
| const _c1: ID1 = "b"; | |
| const _c2: ID2 = "c"; | |
| const _c3: ID3 = "d"; | |
| const _c4: ID4 = "e"; | |
| // Jump ahead: what happens after 26 increments? | |
| type After26 = Increment<Increment<Increment<Increment<Increment< | |
| Increment<Increment<Increment<Increment<Increment< | |
| Increment<Increment<Increment<Increment<Increment< | |
| Increment<Increment<Increment<Increment<Increment< | |
| Increment<Increment<Increment<Increment<Increment< | |
| "a" | |
| >>>>>>>>>>>>>>>>>>>>>>>>>; | |
| const _after26: After26 = "z"; | |
| const _after27: Increment<After26> = "aa"; | |
| // ============================================================ | |
| // Integration: use with Desc/Expr types | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; readonly id: ID; readonly adj: Adj; readonly c: C; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| } | |
| type IdOf<R> = R extends Desc<any, infer ID, any, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A, any> ? A : never; | |
| type CtrOf<R> = R extends Desc<any, any, any, infer C> ? C : never; | |
| // BuildCtx using Increment instead of IDChain | |
| interface BuildCtx<C extends string> { | |
| numLit<V extends number>(value: V): | |
| [Expr<number, Desc<number, C, Record<C, NodeEntry<"num/literal", [], number>>, Increment<C>>>, | |
| BuildCtx<Increment<C>>]; | |
| add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, | |
| Increment<C> | |
| >>, BuildCtx<Increment<C>>]; | |
| mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): [Expr<number, Desc< | |
| number, C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, | |
| Increment<C> | |
| >>, BuildCtx<Increment<C>>]; | |
| } | |
| declare function createCtx(): BuildCtx<FirstID>; | |
| // ============================================================ | |
| // Integration test: build (3 + 4) * 5 | |
| // ============================================================ | |
| const $ = createCtx(); | |
| const [lit3, $1] = $.numLit(3); | |
| const [lit4, $2] = $1.numLit(4); | |
| const [sum, $3] = $2.add(lit3, lit4); | |
| const [lit5, $4] = $3.numLit(5); | |
| const [prod, $5] = $4.mul(sum, lit5); | |
| type ProdR = (typeof prod)[typeof exprBrand]; | |
| type ProdID = IdOf<ProdR>; | |
| type ProdCtr = CtrOf<ProdR>; | |
| const _prodId: ProdID = "e"; | |
| const _prodCtr: ProdCtr = "f"; | |
| // @ts-expect-error — prod ID is "e", not "d" | |
| const _prodIdWrong: ProdID = "d"; | |
| // Check adj | |
| type ProdAdj = AdjOf<ProdR>; | |
| type AddEntry = ProdAdj["c"]; | |
| type AddKids = AddEntry extends NodeEntry<any, infer C, any> ? C : never; | |
| const _addKids: AddKids = ["a", "b"]; | |
| type MulEntry = ProdAdj["e"]; | |
| type MulKids = MulEntry extends NodeEntry<any, infer C, any> ? C : never; | |
| const _mulKids: MulKids = ["c", "d"]; | |
| // ============================================================ | |
| // Scale: 20-node chain (same as spike-idchain-test) | |
| // ============================================================ | |
| const $$ = createCtx(); | |
| const [n0, $$1] = $$.numLit(1); | |
| const [n1, $$2] = $$1.numLit(2); | |
| const [s0, $$3] = $$2.add(n0, n1); | |
| const [n2, $$4] = $$3.numLit(3); | |
| const [s1, $$5] = $$4.add(s0, n2); | |
| const [n3, $$6] = $$5.numLit(4); | |
| const [s2, $$7] = $$6.add(s1, n3); | |
| const [n4, $$8] = $$7.numLit(5); | |
| const [s3, $$9] = $$8.add(s2, n4); | |
| const [n5, $$10] = $$9.numLit(6); | |
| const [s4, $$11] = $$10.add(s3, n5); | |
| const [n6, $$12] = $$11.numLit(7); | |
| const [s5, $$13] = $$12.add(s4, n6); | |
| const [n7, $$14] = $$13.numLit(8); | |
| const [s6, $$15] = $$14.add(s5, n7); | |
| const [n8, $$16] = $$15.numLit(9); | |
| const [s7, $$17] = $$16.add(s6, n8); | |
| const [n9, $$18] = $$17.numLit(10); | |
| const [s8, $$19] = $$18.add(s7, n9); | |
| type ScaleR = (typeof s8)[typeof exprBrand]; | |
| type ScaleCtr = CtrOf<ScaleR>; | |
| const _scaleCtr: ScaleCtr = "t"; // 20th ID: a..t | |
| // @ts-expect-error — counter is "t", not "s" | |
| const _scaleCtrWrong: ScaleCtr = "s"; | |
| console.log("spike-increment compiled successfully — 18k lines → 8 lines of types"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Named nodes + type-safe transforms | |
| // ============================================================ | |
| // | |
| // Proves: | |
| // 1. .as("name") adds a named alias to the adjacency map | |
| // 2. Duplicate names are caught at compile time (intersection conflict) | |
| // 3. Transforms target nodes by name | |
| // 4. Transforms must preserve output type (type-safe replacement) | |
| // 5. Cascading: replacing a named node updates all parents | |
| // ============================================================ | |
| // Core types (carried forward from winning spike) | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| } | |
| type OutOf<R> = R extends Desc<infer O, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A> ? A : never; | |
| // Content-addressed IDs | |
| type LitID<V extends string | number | boolean> = `lit(${V})`; | |
| type AddID<L extends string, R extends string> = `add(${L},${R})`; | |
| type MulID<L extends string, R extends string> = `mul(${L},${R})`; | |
| type NegID<A extends string> = `neg(${A})`; | |
| type GtID<L extends string, R extends string> = `gt(${L},${R})`; | |
| type CondID<P extends string, T extends string, E extends string> = `cond(${P},${T},${E})`; | |
| // ============================================================ | |
| // Name alias: @name -> content ID mapping in adj | |
| // ============================================================ | |
| /** Alias entry: maps a human-readable name to a content-addressed ID */ | |
| type NameAlias<Name extends string, TargetID extends string, Out> = { | |
| readonly kind: "@alias"; | |
| readonly target: TargetID; | |
| readonly out: Out; | |
| }; | |
| type NameKey<N extends string> = `@${N}`; | |
| // ============================================================ | |
| // .as() — name a node | |
| // ============================================================ | |
| /** Name an expression. Adds Record<@name, NameAlias> to adj. | |
| * Duplicate names are caught by intersection conflict. */ | |
| function named< | |
| Name extends string, | |
| O, | |
| R extends Desc<O, string, Record<string, any>>, | |
| >( | |
| name: Name, | |
| expr: Expr<O, R>, | |
| ): Expr<O, Desc<O, IdOf<R>, AdjOf<R> & Record<NameKey<Name>, NameAlias<Name, IdOf<R>, O>>>> { | |
| return expr as any; | |
| } | |
| // ============================================================ | |
| // Builders (same as winning spike) | |
| // ============================================================ | |
| function numLit<V extends number>( | |
| value: V, | |
| ): Expr<number, Desc<number, LitID<V>, Record<LitID<V>, NodeEntry<"num/literal", [], number>>>> { | |
| return { kind: "num/literal", value } as any; | |
| } | |
| function strLit<V extends string>( | |
| value: V, | |
| ): Expr<string, Desc<string, LitID<V>, Record<LitID<V>, NodeEntry<"str/literal", [], string>>>> { | |
| return { kind: "str/literal", value } as any; | |
| } | |
| function add< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): Expr<number, Desc< | |
| number, | |
| AddID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<AddID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>> | |
| >> { | |
| return { kind: "num/add", left, right } as any; | |
| } | |
| function mul< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): Expr<number, Desc< | |
| number, | |
| MulID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<MulID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>> | |
| >> { | |
| return { kind: "num/mul", left, right } as any; | |
| } | |
| function neg<RA extends Desc<number, string, Record<string, any>>>( | |
| operand: Expr<number, RA>, | |
| ): Expr<number, Desc<number, NegID<IdOf<RA>>, AdjOf<RA> & Record<NegID<IdOf<RA>>, NodeEntry<"num/neg", [IdOf<RA>], number>>>> { | |
| return { kind: "num/neg", operand } as any; | |
| } | |
| function gt< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): Expr<boolean, Desc< | |
| boolean, | |
| GtID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<GtID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/gt", [IdOf<RA>, IdOf<RB>], boolean>> | |
| >> { | |
| return { kind: "num/gt", left, right } as any; | |
| } | |
| function cond< | |
| T, | |
| RP extends Desc<boolean, string, Record<string, any>>, | |
| RT extends Desc<T, string, Record<string, any>>, | |
| RE extends Desc<NoInfer<T>, string, Record<string, any>>, | |
| >( | |
| pred: Expr<boolean, RP>, | |
| then_: Expr<T, RT>, | |
| else_: Expr<NoInfer<T>, RE>, | |
| ): Expr<T, Desc< | |
| T, | |
| CondID<IdOf<RP>, IdOf<RT>, IdOf<RE>>, | |
| AdjOf<RP> & AdjOf<RT> & AdjOf<RE> & Record<CondID<IdOf<RP>, IdOf<RT>, IdOf<RE>>, NodeEntry<"core/cond", [IdOf<RP>, IdOf<RT>, IdOf<RE>], T>> | |
| >> { | |
| return { kind: "core/cond", pred, then: then_, else: else_ } as any; | |
| } | |
| // ============================================================ | |
| // Test 1: Basic naming | |
| // ============================================================ | |
| const base = numLit(10); | |
| const tax = named("tax-rate", numLit(21)); | |
| const total = named("total", mul(base, tax)); | |
| // Tax and total have their names in the adj map | |
| // ============================================================ | |
| // Test 2: Duplicate name detection | |
| // ============================================================ | |
| // Duplicate name detection: intersection doesn't error by itself, | |
| // but we can detect it with a type-level check: | |
| type HasDuplicateNames<Adj1, Adj2> = { | |
| [K in keyof Adj1 & keyof Adj2]: K extends `@${string}` | |
| ? Adj1[K] extends Adj2[K] ? never : K // same key, different value = duplicate | |
| : never | |
| }[keyof Adj1 & keyof Adj2]; | |
| // Builder that rejects duplicate names | |
| function safeAdd< | |
| RA extends Desc<number, string, Record<string, any>>, | |
| RB extends Desc<number, string, Record<string, any>>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: HasDuplicateNames<AdjOf<RA>, AdjOf<RB>> extends never | |
| ? Expr<number, RB> | |
| : { __error: `Duplicate name: ${HasDuplicateNames<AdjOf<RA>, AdjOf<RB>> & string}` }, | |
| ): Expr<number, Desc< | |
| number, | |
| AddID<IdOf<RA>, IdOf<RB>>, | |
| AdjOf<RA> & AdjOf<RB> & Record<AddID<IdOf<RA>, IdOf<RB>>, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>> | |
| >> { | |
| return { kind: "num/add", left, right } as any; | |
| } | |
| const a = named("my-val", numLit(1)); | |
| const b = named("my-val", numLit(2)); | |
| // @ts-expect-error: duplicate name detected | |
| const bad_dup = safeAdd(a, b); | |
| // Non-duplicate names work fine | |
| const c = named("val-a", numLit(1)); | |
| const d = named("val-b", numLit(2)); | |
| const ok_dup = safeAdd(c, d); // no error | |
| // ============================================================ | |
| // Test 3: Type-safe named replacement | |
| // ============================================================ | |
| /** Look up the output type of a named node */ | |
| type NamedOutput<R, Name extends string> = | |
| R extends Desc<any, any, infer Adj> | |
| ? NameKey<Name> extends keyof Adj | |
| ? Adj[NameKey<Name>] extends NameAlias<any, any, infer O> ? O : never | |
| : never | |
| : never; | |
| /** Check that a name exists in the program */ | |
| type HasName<R, Name extends string> = | |
| R extends Desc<any, any, infer Adj> | |
| ? NameKey<Name> extends keyof Adj ? true : false | |
| : false; | |
| /** | |
| * Replace a named node. The replacement must produce the same output type. | |
| * At the type level, we update the adj map. | |
| */ | |
| function replaceNamed< | |
| Name extends string, | |
| O, | |
| RProgram extends Desc<O, string, Record<string, any>>, | |
| ReplacementO extends NamedOutput<RProgram, Name>, | |
| RReplacement extends Desc<ReplacementO, string, Record<string, any>>, | |
| >( | |
| name: Name, | |
| program: Expr<O, RProgram>, | |
| replacement: Expr<ReplacementO, RReplacement>, | |
| ): Expr<O, Desc<O, IdOf<RProgram>, AdjOf<RProgram> & AdjOf<RReplacement>>> { | |
| // Runtime: walk adj, find name's target ID, replace all references | |
| return program as any; | |
| } | |
| // Build a program with named nodes | |
| const price = named("price", numLit(100)); | |
| const rate = named("rate", numLit(20)); | |
| const result = named("result", add(price, rate)); | |
| // Replace "rate" with a different number — valid (number → number) | |
| const modified = replaceNamed("rate", result, numLit(25)); | |
| // @ts-expect-error: can't replace "rate" (number) with a string | |
| const bad_type = replaceNamed("rate", result, strLit("oops")); | |
| // ============================================================ | |
| // Test 4: Targeting non-existent names | |
| // ============================================================ | |
| // @ts-expect-error: "nonexistent" is not a named node in this program | |
| const bad_name = replaceNamed("nonexistent", result, numLit(0)); | |
| // ============================================================ | |
| // Test 5: Real-world-ish example — wrapping with instrumentation | |
| // ============================================================ | |
| // Simulate: wrap a node with logging (add a neg as a stand-in for "instrument") | |
| // In real MVFM this would be wrapping with console.log or timing | |
| const computation = named("expensive", | |
| mul(add(numLit(1), numLit(2)), add(numLit(3), numLit(4))) | |
| ); | |
| const pred = gt(numLit(0), numLit(1)); | |
| const program = named("output", cond(pred, computation, numLit(0))); | |
| // "Instrument" the expensive computation by wrapping it | |
| // (using neg as a stand-in for a logging wrapper) | |
| const instrumented = replaceNamed( | |
| "expensive", | |
| program, | |
| neg(computation), // same output type (number), but "wrapped" | |
| ); | |
| // ============================================================ | |
| // Test 6: Multiple named nodes in one program | |
| // ============================================================ | |
| const step1 = named("step1", numLit(10)); | |
| const step2 = named("step2", mul(step1, numLit(2))); | |
| const step3 = named("step3", add(step2, numLit(5))); | |
| // Replace step2, keeping step1 and step3 | |
| const patched = replaceNamed("step2", step3, numLit(99)); | |
| // ============================================================ | |
| // Queries work with names | |
| // ============================================================ | |
| type ROf<E> = E extends Expr<any, infer R> ? R : never; | |
| type CollectKinds<R> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [Key in keyof Adj]: Adj[Key] extends NodeEntry<infer K, any, any> ? K : never }[keyof Adj] | |
| : never; | |
| type CollectNames<R> = | |
| R extends Desc<any, any, infer Adj> | |
| ? { [Key in keyof Adj]: Key extends `@${infer Name}` ? Name : never }[keyof Adj] | |
| : never; | |
| type ProgramR = ROf<typeof program>; | |
| type ProgramKinds = CollectKinds<ProgramR>; // "num/literal" | "num/add" | "num/mul" | "num/gt" | "core/cond" | |
| type ProgramNames = CollectNames<ProgramR>; // "expensive" | "output" | |
| type Step3R = ROf<typeof step3>; | |
| type Step3Names = CollectNames<Step3R>; // "step1" | "step2" | "step3" | |
| console.log("Named transforms spike compiled successfully"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Structured predicates — type-level computable, no callbacks | |
| // ============================================================ | |
| // ============================================================ | |
| // Core types (from previous spikes) | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| // ============================================================ | |
| // Predicate types — tagged unions | |
| // ============================================================ | |
| interface KindPred<K extends string> { readonly tag: "kind"; readonly kind: K } | |
| interface KindGlobPred<Prefix extends string> { | |
| readonly tag: "kindGlob"; | |
| readonly prefix: Prefix; | |
| } | |
| interface OutputPred<O> { readonly tag: "output"; readonly _o?: O } | |
| interface LeafPred { readonly tag: "leaf" } | |
| interface ChildCountPred<N extends number> { | |
| readonly tag: "childCount"; | |
| readonly count: N; | |
| } | |
| interface NotPred<P> { readonly tag: "not"; readonly inner: P } | |
| interface AndPred<A, B> { readonly tag: "and"; readonly left: A; readonly right: B } | |
| interface OrPred<A, B> { readonly tag: "or"; readonly left: A; readonly right: B } | |
| // ============================================================ | |
| // Type-level predicate evaluation | |
| // ============================================================ | |
| type EvalPred<P, Entry> = | |
| P extends KindPred<infer K> | |
| ? Entry extends NodeEntry<K, any, any> ? true : false | |
| : P extends KindGlobPred<infer Prefix> | |
| ? Entry extends NodeEntry<`${Prefix}${string}`, any, any> ? true : false | |
| : P extends OutputPred<infer O> | |
| ? Entry extends NodeEntry<any, any, infer AO> | |
| ? AO extends O ? true : false | |
| : false | |
| : P extends LeafPred | |
| ? Entry extends NodeEntry<any, infer C, any> | |
| ? C extends [] ? true : false | |
| : false | |
| : P extends ChildCountPred<infer N> | |
| ? Entry extends NodeEntry<any, infer C, any> | |
| ? C extends { length: N } ? true : false | |
| : false | |
| : P extends NotPred<infer Inner> | |
| ? EvalPred<Inner, Entry> extends true ? false : true | |
| : P extends AndPred<infer A, infer B> | |
| ? EvalPred<A, Entry> extends true | |
| ? EvalPred<B, Entry> extends true ? true : false | |
| : false | |
| : P extends OrPred<infer A, infer B> | |
| ? EvalPred<A, Entry> extends true ? true | |
| : EvalPred<B, Entry> extends true ? true : false | |
| : never; | |
| // Type-level select: filter adjacency map by predicate | |
| type SelectKeys<Adj, P> = | |
| keyof { [K in keyof Adj as EvalPred<P, Adj[K]> extends true ? K : never]: 1 }; | |
| // ============================================================ | |
| // Test adjacency map | |
| // ============================================================ | |
| type TA = { | |
| a: NodeEntry<"num/literal", [], number>; | |
| b: NodeEntry<"num/literal", [], number>; | |
| c: NodeEntry<"num/add", ["a", "b"], number>; | |
| d: NodeEntry<"str/literal", [], string>; | |
| e: NodeEntry<"str/concat", ["d", "d"], string>; | |
| f: NodeEntry<"num/mul", ["c", "c"], number>; | |
| g: NodeEntry<"core/cond", ["a", "c", "f"], number>; | |
| }; | |
| // ============================================================ | |
| // TYPE TESTS: byKind | |
| // ============================================================ | |
| // Positive | |
| const _k1: EvalPred<KindPred<"num/add">, TA["c"]> = true; | |
| // Negative | |
| const _k2: EvalPred<KindPred<"num/add">, TA["a"]> = false; | |
| const _k3: EvalPred<KindPred<"num/add">, TA["d"]> = false; | |
| // Select | |
| const _k4: SelectKeys<TA, KindPred<"num/add">> = "c"; | |
| // @ts-expect-error — a is num/literal, not num/add | |
| const _k5: SelectKeys<TA, KindPred<"num/add">> = "a"; | |
| // ============================================================ | |
| // TYPE TESTS: byKindGlob (template literal matching!) | |
| // ============================================================ | |
| // Positive | |
| const _g1: EvalPred<KindGlobPred<"num/">, TA["a"]> = true; | |
| const _g2: EvalPred<KindGlobPred<"num/">, TA["c"]> = true; | |
| const _g3: EvalPred<KindGlobPred<"num/">, TA["f"]> = true; | |
| // Negative | |
| const _g4: EvalPred<KindGlobPred<"num/">, TA["d"]> = false; | |
| const _g5: EvalPred<KindGlobPred<"num/">, TA["g"]> = false; | |
| // Select | |
| const _g6: SelectKeys<TA, KindGlobPred<"num/">> = "a"; | |
| const _g7: SelectKeys<TA, KindGlobPred<"num/">> = "b"; | |
| const _g8: SelectKeys<TA, KindGlobPred<"num/">> = "c"; | |
| const _g9: SelectKeys<TA, KindGlobPred<"num/">> = "f"; | |
| // @ts-expect-error — d is str/literal | |
| const _g10: SelectKeys<TA, KindGlobPred<"num/">> = "d"; | |
| // @ts-expect-error — e is str/concat | |
| const _g11: SelectKeys<TA, KindGlobPred<"num/">> = "e"; | |
| // @ts-expect-error — g is core/cond | |
| const _g12: SelectKeys<TA, KindGlobPred<"num/">> = "g"; | |
| // ============================================================ | |
| // TYPE TESTS: byOutput | |
| // ============================================================ | |
| const _o1: EvalPred<OutputPred<number>, TA["a"]> = true; | |
| const _o2: EvalPred<OutputPred<number>, TA["g"]> = true; // core/cond→number | |
| const _o3: EvalPred<OutputPred<number>, TA["d"]> = false; // string | |
| const _o4: EvalPred<OutputPred<string>, TA["d"]> = true; | |
| const _o5: EvalPred<OutputPred<string>, TA["a"]> = false; // number | |
| // Select number-outputting: a,b,c,f,g | |
| const _o6: SelectKeys<TA, OutputPred<number>> = "a"; | |
| const _o7: SelectKeys<TA, OutputPred<number>> = "g"; | |
| // @ts-expect-error — d outputs string | |
| const _o8: SelectKeys<TA, OutputPred<number>> = "d"; | |
| // @ts-expect-error — e outputs string | |
| const _o9: SelectKeys<TA, OutputPred<number>> = "e"; | |
| // ============================================================ | |
| // TYPE TESTS: isLeaf | |
| // ============================================================ | |
| const _l1: EvalPred<LeafPred, TA["a"]> = true; | |
| const _l2: EvalPred<LeafPred, TA["d"]> = true; | |
| const _l3: EvalPred<LeafPred, TA["c"]> = false; | |
| const _l4: SelectKeys<TA, LeafPred> = "a"; | |
| const _l5: SelectKeys<TA, LeafPred> = "b"; | |
| const _l6: SelectKeys<TA, LeafPred> = "d"; | |
| // @ts-expect-error — c has children | |
| const _l7: SelectKeys<TA, LeafPred> = "c"; | |
| // ============================================================ | |
| // TYPE TESTS: hasChildCount | |
| // ============================================================ | |
| const _cc1: EvalPred<ChildCountPred<2>, TA["c"]> = true; | |
| const _cc2: EvalPred<ChildCountPred<2>, TA["a"]> = false; | |
| const _cc3: EvalPred<ChildCountPred<3>, TA["g"]> = true; | |
| // @ts-expect-error — g has 3 children, not 2 | |
| const _cc4: EvalPred<ChildCountPred<2>, TA["g"]> = true; | |
| const _cc5: SelectKeys<TA, ChildCountPred<2>> = "c"; | |
| const _cc6: SelectKeys<TA, ChildCountPred<2>> = "e"; | |
| const _cc7: SelectKeys<TA, ChildCountPred<2>> = "f"; | |
| // @ts-expect-error — g has 3 children | |
| const _cc8: SelectKeys<TA, ChildCountPred<2>> = "g"; | |
| // ============================================================ | |
| // TYPE TESTS: not | |
| // ============================================================ | |
| const _n1: EvalPred<NotPred<LeafPred>, TA["c"]> = true; | |
| const _n2: EvalPred<NotPred<LeafPred>, TA["a"]> = false; | |
| const _n3: SelectKeys<TA, NotPred<LeafPred>> = "c"; | |
| const _n4: SelectKeys<TA, NotPred<LeafPred>> = "g"; | |
| // @ts-expect-error — a IS a leaf | |
| const _n5: SelectKeys<TA, NotPred<LeafPred>> = "a"; | |
| // ============================================================ | |
| // TYPE TESTS: and | |
| // ============================================================ | |
| // num/* AND leaf = num literals only | |
| type NumLeaf = AndPred<KindGlobPred<"num/">, LeafPred>; | |
| const _a1: EvalPred<NumLeaf, TA["a"]> = true; // num/literal, leaf | |
| const _a2: EvalPred<NumLeaf, TA["c"]> = false; // num/add, not leaf | |
| const _a3: EvalPred<NumLeaf, TA["d"]> = false; // str/literal, leaf but wrong prefix | |
| const _a4: SelectKeys<TA, NumLeaf> = "a"; | |
| const _a5: SelectKeys<TA, NumLeaf> = "b"; | |
| // @ts-expect-error — c is not leaf | |
| const _a6: SelectKeys<TA, NumLeaf> = "c"; | |
| // @ts-expect-error — d is str/ | |
| const _a7: SelectKeys<TA, NumLeaf> = "d"; | |
| // ============================================================ | |
| // TYPE TESTS: or | |
| // ============================================================ | |
| type AddOrConcat = OrPred<KindPred<"num/add">, KindPred<"str/concat">>; | |
| const _r1: EvalPred<AddOrConcat, TA["c"]> = true; | |
| const _r2: EvalPred<AddOrConcat, TA["e"]> = true; | |
| const _r3: EvalPred<AddOrConcat, TA["a"]> = false; | |
| const _r4: SelectKeys<TA, AddOrConcat> = "c"; | |
| const _r5: SelectKeys<TA, AddOrConcat> = "e"; | |
| // @ts-expect-error — a is literal | |
| const _r6: SelectKeys<TA, AddOrConcat> = "a"; | |
| // ============================================================ | |
| // TYPE TESTS: deep combinator — not(or(isLeaf, childCount(3))) = binary | |
| // ============================================================ | |
| type BinaryOnly = NotPred<OrPred<LeafPred, ChildCountPred<3>>>; | |
| const _b1: EvalPred<BinaryOnly, TA["c"]> = true; // 2 kids | |
| const _b2: EvalPred<BinaryOnly, TA["e"]> = true; // 2 kids | |
| const _b3: EvalPred<BinaryOnly, TA["f"]> = true; // 2 kids | |
| const _b4: EvalPred<BinaryOnly, TA["a"]> = false; // leaf | |
| const _b5: EvalPred<BinaryOnly, TA["g"]> = false; // 3 kids | |
| const _b6: SelectKeys<TA, BinaryOnly> = "c"; | |
| const _b7: SelectKeys<TA, BinaryOnly> = "e"; | |
| const _b8: SelectKeys<TA, BinaryOnly> = "f"; | |
| // @ts-expect-error — a is leaf | |
| const _b9: SelectKeys<TA, BinaryOnly> = "a"; | |
| // @ts-expect-error — g is ternary | |
| const _b10: SelectKeys<TA, BinaryOnly> = "g"; | |
| // ============================================================ | |
| // Runtime predicate constructors | |
| // ============================================================ | |
| type RuntimePred = (e: { kind: string; children: string[]; out: unknown }) => boolean; | |
| type RE = { kind: string; children: string[]; out: unknown }; | |
| function byKind<K extends string>(k: K): KindPred<K> & { eval: RuntimePred } { | |
| return { tag: "kind", kind: k, eval: (e: RE) => e.kind === k }; | |
| } | |
| function byKindGlob<P extends string>(prefix: P): KindGlobPred<P> & { eval: RuntimePred } { | |
| return { tag: "kindGlob", prefix, eval: (e: RE) => e.kind.startsWith(prefix) }; | |
| } | |
| function byOutput<O>(check: (x: unknown) => x is O): OutputPred<O> & { eval: RuntimePred } { | |
| return { tag: "output", eval: (e: RE) => check(e.out) } as any; | |
| } | |
| function isLeaf(): LeafPred & { eval: RuntimePred } { | |
| return { tag: "leaf", eval: (e: RE) => e.children.length === 0 }; | |
| } | |
| function hasChildCount<N extends number>(n: N): ChildCountPred<N> & { eval: RuntimePred } { | |
| return { tag: "childCount", count: n, eval: (e: RE) => e.children.length === n }; | |
| } | |
| function not<P extends { eval: RuntimePred }>(p: P): NotPred<P> & { eval: RuntimePred } { | |
| return { tag: "not", inner: p, eval: (e) => !p.eval(e) }; | |
| } | |
| function and<A extends { eval: RuntimePred }, B extends { eval: RuntimePred }>( | |
| a: A, b: B, | |
| ): AndPred<A, B> & { eval: RuntimePred } { | |
| return { tag: "and", left: a, right: b, eval: (e) => a.eval(e) && b.eval(e) }; | |
| } | |
| function or<A extends { eval: RuntimePred }, B extends { eval: RuntimePred }>( | |
| a: A, b: B, | |
| ): OrPred<A, B> & { eval: RuntimePred } { | |
| return { tag: "or", left: a, right: b, eval: (e) => a.eval(e) || b.eval(e) }; | |
| } | |
| // ============================================================ | |
| // Runtime selectWhere | |
| // ============================================================ | |
| function selectWhere( | |
| adj: Record<string, { kind: string; children: string[]; out: unknown }>, | |
| pred: { eval: RuntimePred }, | |
| ): Set<string> { | |
| const result = new Set<string>(); | |
| for (const [id, entry] of Object.entries(adj)) { | |
| if (pred.eval(entry)) result.add(id); | |
| } | |
| return result; | |
| } | |
| // ============================================================ | |
| // Runtime test data | |
| // ============================================================ | |
| const adj = { | |
| a: { kind: "num/literal", children: [] as string[], out: 3 }, | |
| b: { kind: "num/literal", children: [] as string[], out: 4 }, | |
| c: { kind: "num/add", children: ["a", "b"], out: 7 }, | |
| d: { kind: "str/literal", children: [] as string[], out: "hello" }, | |
| e: { kind: "str/concat", children: ["d", "d"], out: "hellohello" }, | |
| f: { kind: "num/mul", children: ["c", "c"], out: 49 }, | |
| g: { kind: "core/cond", children: ["a", "c", "f"], out: 7 }, | |
| }; | |
| let passed = 0; | |
| let failed = 0; | |
| function assert(cond: boolean, msg: string) { | |
| if (cond) { passed++; console.log(` ✓ ${msg}`); } | |
| else { failed++; console.log(` ✗ ${msg}`); } | |
| } | |
| function sameSet(a: Set<string>, b: Set<string>): boolean { | |
| if (a.size !== b.size) return false; | |
| for (const x of a) if (!b.has(x)) return false; | |
| return true; | |
| } | |
| // ============================================================ | |
| // Runtime tests | |
| // ============================================================ | |
| console.log("Test 1: byKind"); | |
| const p1 = byKind("num/add"); | |
| assert(p1.eval(adj.c), "matches num/add"); | |
| assert(!p1.eval(adj.a), "rejects num/literal"); | |
| assert(sameSet(selectWhere(adj, p1), new Set(["c"])), "selects {c}"); | |
| console.log("Test 2: byKindGlob"); | |
| const p2 = byKindGlob("num/"); | |
| assert(p2.eval(adj.a), "matches num/literal"); | |
| assert(p2.eval(adj.f), "matches num/mul"); | |
| assert(!p2.eval(adj.d), "rejects str/literal"); | |
| assert(!p2.eval(adj.g), "rejects core/cond"); | |
| assert(sameSet(selectWhere(adj, p2), new Set(["a", "b", "c", "f"])), "selects {a,b,c,f}"); | |
| console.log("Test 3: byOutput"); | |
| const isNum = (x: unknown): x is number => typeof x === "number"; | |
| const p3 = byOutput<number>(isNum); | |
| assert(p3.eval(adj.a), "matches number output"); | |
| assert(p3.eval(adj.g), "matches core/cond→number"); | |
| assert(!p3.eval(adj.d), "rejects string output"); | |
| assert(sameSet(selectWhere(adj, p3), new Set(["a", "b", "c", "f", "g"])), "selects {a,b,c,f,g}"); | |
| console.log("Test 4: isLeaf"); | |
| const p4 = isLeaf(); | |
| assert(p4.eval(adj.a), "matches leaf"); | |
| assert(!p4.eval(adj.c), "rejects non-leaf"); | |
| assert(sameSet(selectWhere(adj, p4), new Set(["a", "b", "d"])), "selects {a,b,d}"); | |
| console.log("Test 5: hasChildCount"); | |
| const p5a = hasChildCount(2); | |
| assert(p5a.eval(adj.c), "matches binary"); | |
| assert(!p5a.eval(adj.a), "rejects leaf"); | |
| assert(!p5a.eval(adj.g), "rejects ternary"); | |
| assert(sameSet(selectWhere(adj, p5a), new Set(["c", "e", "f"])), "selects {c,e,f}"); | |
| const p5b = hasChildCount(3); | |
| assert(p5b.eval(adj.g), "matches ternary"); | |
| assert(sameSet(selectWhere(adj, p5b), new Set(["g"])), "selects {g}"); | |
| console.log("Test 6: not"); | |
| const p6 = not(isLeaf()); | |
| assert(p6.eval(adj.c), "non-leaf matches"); | |
| assert(!p6.eval(adj.a), "leaf rejected"); | |
| assert(sameSet(selectWhere(adj, p6), new Set(["c", "e", "f", "g"])), "selects {c,e,f,g}"); | |
| console.log("Test 7: and — num/* AND leaf"); | |
| const p7 = and(byKindGlob("num/"), isLeaf()); | |
| assert(p7.eval(adj.a), "num/literal leaf matches"); | |
| assert(!p7.eval(adj.c), "num/add not leaf rejects"); | |
| assert(!p7.eval(adj.d), "str/literal not num/ rejects"); | |
| assert(sameSet(selectWhere(adj, p7), new Set(["a", "b"])), "selects {a,b}"); | |
| console.log("Test 8: or — num/add OR str/concat"); | |
| const p8 = or(byKind("num/add"), byKind("str/concat")); | |
| assert(p8.eval(adj.c), "num/add matches"); | |
| assert(p8.eval(adj.e), "str/concat matches"); | |
| assert(!p8.eval(adj.a), "num/literal rejects"); | |
| assert(sameSet(selectWhere(adj, p8), new Set(["c", "e"])), "selects {c,e}"); | |
| console.log("Test 9: not(or(leaf, childCount(3))) = binary nodes"); | |
| const p9 = not(or(isLeaf(), hasChildCount(3))); | |
| assert(p9.eval(adj.c), "binary matches"); | |
| assert(!p9.eval(adj.a), "leaf rejects"); | |
| assert(!p9.eval(adj.g), "ternary rejects"); | |
| assert(sameSet(selectWhere(adj, p9), new Set(["c", "e", "f"])), "selects {c,e,f}"); | |
| console.log("Test 10: and(num/*, not(leaf)) = num operations"); | |
| const p10 = and(byKindGlob("num/"), not(isLeaf())); | |
| assert(p10.eval(adj.c), "num/add matches"); | |
| assert(p10.eval(adj.f), "num/mul matches"); | |
| assert(!p10.eval(adj.a), "num/literal rejects (leaf)"); | |
| assert(!p10.eval(adj.e), "str/concat rejects (not num/)"); | |
| assert(sameSet(selectWhere(adj, p10), new Set(["c", "f"])), "selects {c,f}"); | |
| console.log(`\nResults: ${passed} passed, ${failed} failed`); | |
| if (failed > 0) process.exit(1); | |
| console.log("\nPredicate spike compiled and ran successfully!"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Real runtime backing the type-level DAG encoding | |
| // ============================================================ | |
| // | |
| // Proves: types and runtime agree. No {} as any (except branded phantom). | |
| import { IDChain, FirstID } from "./spike-idchain"; | |
| // ============================================================ | |
| // Core types (same as spike-idchain-test) | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type NextID<Current extends keyof IDChain> = IDChain[Current]; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; | |
| readonly id: ID; | |
| readonly adj: Adj; | |
| readonly c: C; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| readonly kind: string; | |
| // Real runtime fields | |
| readonly __id: string; | |
| readonly __adj: Record<string, { kind: string; children?: string[]; target?: string; out?: unknown }>; | |
| readonly __counter: string; | |
| } | |
| type OutOf<R> = R extends Desc<infer O, any, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A, any> ? A : never; | |
| type CtrOf<R> = R extends Desc<any, any, any, infer C> ? C : never; | |
| type NameAlias<Name extends string, TargetID extends string, Out> = { | |
| readonly kind: "@alias"; | |
| readonly target: TargetID; | |
| readonly out: Out; | |
| }; | |
| type NameKey<N extends string> = `@${N}`; | |
| // ============================================================ | |
| // ID chain at runtime: same sequence as the type | |
| // ============================================================ | |
| const ALPHA = "abcdefghijklmnopqrstuvwxyz"; | |
| function nextIdRuntime(current: string): string { | |
| // Increment: a→b, z→aa, az→ba, zz→aaa, etc. | |
| const chars = current.split(""); | |
| let carry = true; | |
| for (let i = chars.length - 1; i >= 0 && carry; i--) { | |
| const idx = ALPHA.indexOf(chars[i]); | |
| if (idx === 25) { | |
| chars[i] = "a"; | |
| carry = true; | |
| } else { | |
| chars[i] = ALPHA[idx + 1]; | |
| carry = false; | |
| } | |
| } | |
| if (carry) return "a" + chars.join(""); | |
| return chars.join(""); | |
| } | |
| // Verify runtime matches type-level | |
| function assertEq<T>(actual: T, expected: T, msg: string) { | |
| if (actual !== expected) { | |
| throw new Error(`${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); | |
| } | |
| } | |
| assertEq(nextIdRuntime("a"), "b", "a→b"); | |
| assertEq(nextIdRuntime("z"), "aa", "z→aa"); | |
| assertEq(nextIdRuntime("az"), "ba", "az→ba"); | |
| assertEq(nextIdRuntime("zz"), "aaa", "zz→aaa"); | |
| // ============================================================ | |
| // Real BuildCtx | |
| // ============================================================ | |
| function makeExpr( | |
| kind: string, | |
| id: string, | |
| adj: Record<string, any>, | |
| counter: string, | |
| ): any { | |
| return { | |
| kind, | |
| __id: id, | |
| __adj: adj, | |
| __counter: counter, | |
| // exprBrand is phantom — no runtime representation needed | |
| }; | |
| } | |
| interface BuildCtx<C extends string> { | |
| numLit<V extends number>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<number, Desc<number, C, Record<C, NodeEntry<"num/literal", [], number>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| strLit<V extends string>(value: V): | |
| C extends keyof IDChain | |
| ? [Expr<string, Desc<string, C, Record<C, NodeEntry<"str/literal", [], string>>, NextID<C> & string>>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| add< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, | |
| C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/add", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| mul< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| RB extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| left: Expr<number, RA>, | |
| right: Expr<number, RB>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, | |
| C, | |
| AdjOf<RA> & AdjOf<RB> & Record<C, NodeEntry<"num/mul", [IdOf<RA>, IdOf<RB>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| neg< | |
| RA extends Desc<number, string, Record<string, any>, string>, | |
| >( | |
| operand: Expr<number, RA>, | |
| ): C extends keyof IDChain | |
| ? [Expr<number, Desc< | |
| number, | |
| C, | |
| AdjOf<RA> & Record<C, NodeEntry<"num/neg", [IdOf<RA>], number>>, | |
| NextID<C> & string | |
| >>, BuildCtx<NextID<C> & string>] | |
| : never; | |
| named< | |
| Name extends string, | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| >( | |
| name: Name, | |
| expr: Expr<O, R>, | |
| ): [Expr<O, Desc< | |
| O, | |
| IdOf<R>, | |
| AdjOf<R> & Record<NameKey<Name>, NameAlias<Name, IdOf<R>, O>>, | |
| CtrOf<R> | |
| >>, BuildCtx<C>]; // named doesn't consume an ID | |
| } | |
| function createCtx<C extends string>(counter: C): BuildCtx<C> { | |
| const ctx: BuildCtx<any> = { | |
| numLit(value: number) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| const adj = { [id]: { kind: "num/literal", children: [], out: value } }; | |
| const expr = makeExpr("num/literal", id, adj, next); | |
| return [expr, createCtx(next)] as any; | |
| }, | |
| strLit(value: string) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| const adj = { [id]: { kind: "str/literal", children: [], out: value } }; | |
| const expr = makeExpr("str/literal", id, adj, next); | |
| return [expr, createCtx(next)] as any; | |
| }, | |
| add(left: any, right: any) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| const adj = { | |
| ...left.__adj, | |
| ...right.__adj, | |
| [id]: { kind: "num/add", children: [left.__id, right.__id] }, | |
| }; | |
| const expr = makeExpr("num/add", id, adj, next); | |
| return [expr, createCtx(next)] as any; | |
| }, | |
| mul(left: any, right: any) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| const adj = { | |
| ...left.__adj, | |
| ...right.__adj, | |
| [id]: { kind: "num/mul", children: [left.__id, right.__id] }, | |
| }; | |
| const expr = makeExpr("num/mul", id, adj, next); | |
| return [expr, createCtx(next)] as any; | |
| }, | |
| neg(operand: any) { | |
| const id = counter; | |
| const next = nextIdRuntime(counter); | |
| const adj = { | |
| ...operand.__adj, | |
| [id]: { kind: "num/neg", children: [operand.__id] }, | |
| }; | |
| const expr = makeExpr("num/neg", id, adj, next); | |
| return [expr, createCtx(next)] as any; | |
| }, | |
| named(name: string, expr: any) { | |
| const aliasKey = `@${name}`; | |
| const adj = { | |
| ...expr.__adj, | |
| [aliasKey]: { kind: "@alias", target: expr.__id }, | |
| }; | |
| const newExpr = makeExpr(expr.kind, expr.__id, adj, expr.__counter); | |
| // named doesn't consume an ID, so return same ctx | |
| return [newExpr, ctx] as any; | |
| }, | |
| }; | |
| return ctx; | |
| } | |
| function freshCtx(): BuildCtx<FirstID> { | |
| return createCtx("a" as FirstID); | |
| } | |
| // ============================================================ | |
| // DagView: real runtime implementation | |
| // ============================================================ | |
| interface DagView<Adj extends Record<string, any>> { | |
| get<ID extends string & keyof Adj>(id: ID): Adj[ID]; | |
| nodeIds(): string[]; | |
| children(id: string): any[]; | |
| parents(childId: string): string[]; | |
| resolve<Name extends string>( | |
| name: Name | |
| ): NameKey<Name> extends keyof Adj | |
| ? Adj[NameKey<Name>] extends NameAlias<any, infer T, any> ? T : never | |
| : never; | |
| } | |
| function createDagView(adj: Record<string, any>): DagView<any> { | |
| // Build reverse index for parents | |
| const parentIndex: Record<string, string[]> = {}; | |
| for (const [id, entry] of Object.entries(adj)) { | |
| if (id.startsWith("@")) continue; | |
| for (const childId of (entry as any).children || []) { | |
| if (!parentIndex[childId]) parentIndex[childId] = []; | |
| parentIndex[childId].push(id); | |
| } | |
| } | |
| return { | |
| get(id: string) { | |
| return adj[id]; | |
| }, | |
| nodeIds() { | |
| return Object.keys(adj).filter(k => !k.startsWith("@")); | |
| }, | |
| children(id: string) { | |
| const entry = adj[id]; | |
| if (!entry || !entry.children) return []; | |
| return entry.children.map((cid: string) => adj[cid]); | |
| }, | |
| parents(childId: string) { | |
| return parentIndex[childId] || []; | |
| }, | |
| resolve(name: string) { | |
| const alias = adj[`@${name}`]; | |
| if (!alias) throw new Error(`Name not found: ${name}`); | |
| return alias.target; | |
| }, | |
| } as any; | |
| } | |
| // ============================================================ | |
| // DAGQL runtime operations | |
| // ============================================================ | |
| function selectByKind(expr: any, kind: string): Record<string, any> { | |
| const result: Record<string, any> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if ((entry as any).kind === kind) { | |
| result[id] = entry; | |
| } | |
| } | |
| return result; | |
| } | |
| function updateByKind(expr: any, fromKind: string, toKind: string): any { | |
| const newAdj: Record<string, any> = {}; | |
| for (const [id, entry] of Object.entries(expr.__adj)) { | |
| if ((entry as any).kind === fromKind) { | |
| newAdj[id] = { ...entry as any, kind: toKind }; | |
| } else { | |
| newAdj[id] = entry; | |
| } | |
| } | |
| return makeExpr(expr.kind, expr.__id, newAdj, expr.__counter); | |
| } | |
| function replaceNode(expr: any, targetId: string, newEntry: any): any { | |
| const newAdj = { ...expr.__adj, [targetId]: newEntry }; | |
| return makeExpr(expr.kind, expr.__id, newAdj, expr.__counter); | |
| } | |
| function replaceByName(expr: any, name: string, newExpr: any): any { | |
| const alias = expr.__adj[`@${name}`]; | |
| if (!alias) throw new Error(`Name not found: ${name}`); | |
| const targetId = alias.target; | |
| // Merge new expr's adj into existing, overwrite the target entry | |
| const newAdj: Record<string, any> = { ...expr.__adj }; | |
| // Add all entries from replacement | |
| for (const [id, entry] of Object.entries(newExpr.__adj)) { | |
| newAdj[id] = entry; | |
| } | |
| // Point the target ID to the replacement's root entry | |
| newAdj[targetId] = newExpr.__adj[newExpr.__id]; | |
| // Update alias to still point to same ID | |
| // (it already does — the ID is stable) | |
| return makeExpr(expr.kind, expr.__id, newAdj, expr.__counter); | |
| } | |
| // ============================================================ | |
| // Test 1: Build and inspect a program | |
| // ============================================================ | |
| console.log("=== Test 1: Build mul(add(3, 4), 5) ==="); | |
| const $ = freshCtx(); | |
| const [lit3, $1] = $.numLit(3); | |
| const [lit4, $2] = $1.numLit(4); | |
| const [sum, $3] = $2.add(lit3, lit4); | |
| const [lit5, $4] = $3.numLit(5); | |
| const [prog, $5] = $4.mul(sum, lit5); | |
| // Verify runtime IDs match type-level | |
| assertEq(lit3.__id, "a", "lit3 id"); | |
| assertEq(lit4.__id, "b", "lit4 id"); | |
| assertEq(sum.__id, "c", "sum id"); | |
| assertEq(lit5.__id, "d", "lit5 id"); | |
| assertEq(prog.__id, "e", "prog id"); | |
| // Type-level agrees | |
| type ProgR = (typeof prog)[typeof exprBrand]; | |
| const _progId: IdOf<ProgR> = "e"; | |
| // Verify adj map structure | |
| assertEq(Object.keys(prog.__adj).length, 5, "adj size"); | |
| assertEq(prog.__adj["a"].kind, "num/literal", "a is literal"); | |
| assertEq(prog.__adj["a"].out, 3, "a value is 3"); | |
| assertEq(prog.__adj["b"].kind, "num/literal", "b is literal"); | |
| assertEq(prog.__adj["c"].kind, "num/add", "c is add"); | |
| assertEq(prog.__adj["c"].children![0], "a", "add left child"); | |
| assertEq(prog.__adj["c"].children![1], "b", "add right child"); | |
| assertEq(prog.__adj["e"].kind, "num/mul", "e is mul"); | |
| assertEq(prog.__adj["e"].children![0], "c", "mul left child"); | |
| assertEq(prog.__adj["e"].children![1], "d", "mul right child"); | |
| console.log(" adj:", JSON.stringify(prog.__adj, null, 2)); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 2: DagView | |
| // ============================================================ | |
| console.log("\n=== Test 2: DagView ==="); | |
| const dag = createDagView(prog.__adj); | |
| assertEq(dag.get("a").kind, "num/literal", "dag.get a"); | |
| assertEq(dag.get("c").kind, "num/add", "dag.get c"); | |
| assertEq(dag.nodeIds().length, 5, "dag nodeIds count"); | |
| assertEq(dag.children("c").length, 2, "dag children of add"); | |
| assertEq(dag.children("c")[0].kind, "num/literal", "add left child is literal"); | |
| assertEq(dag.parents("a")[0], "c", "parent of a is c (add)"); | |
| assertEq(dag.parents("c")[0], "e", "parent of c is e (mul)"); | |
| assertEq(dag.parents("e").length, 0, "root has no parents"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 3: Named nodes | |
| // ============================================================ | |
| console.log("\n=== Test 3: Named nodes ==="); | |
| const $n = freshCtx(); | |
| const [price, $n1] = $n.numLit(100); | |
| const [namedPrice, $n2] = $n1.named("price", price); | |
| const [taxRate, $n3] = $n2.numLit(21); | |
| const [namedRate, $n4] = $n3.named("tax-rate", taxRate); | |
| const [tax, $n5] = $n4.mul(namedPrice, namedRate); | |
| const [namedTax, $n6] = $n5.named("tax", tax); | |
| assertEq(namedPrice.__adj["@price"].kind, "@alias", "price alias exists"); | |
| assertEq(namedPrice.__adj["@price"].target, "a", "price points to a"); | |
| assertEq(namedRate.__adj["@tax-rate"].target, "b", "tax-rate points to b"); | |
| const dagN = createDagView(namedTax.__adj); | |
| assertEq(dagN.resolve("price"), "a", "resolve price → a"); | |
| assertEq(dagN.resolve("tax-rate"), "b", "resolve tax-rate → b"); | |
| assertEq(dagN.resolve("tax"), "c", "resolve tax → c"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 4: selectByKind | |
| // ============================================================ | |
| console.log("\n=== Test 4: selectByKind ==="); | |
| const lits = selectByKind(prog, "num/literal"); | |
| assertEq(Object.keys(lits).length, 3, "3 literals"); | |
| assertEq(lits["a"].kind, "num/literal", "a is literal"); | |
| const adds = selectByKind(prog, "num/add"); | |
| assertEq(Object.keys(adds).length, 1, "1 add"); | |
| const subs = selectByKind(prog, "num/sub"); | |
| assertEq(Object.keys(subs).length, 0, "0 subs"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 5: updateByKind | |
| // ============================================================ | |
| console.log("\n=== Test 5: updateByKind ==="); | |
| const allMul = updateByKind(prog, "num/add", "num/mul"); | |
| assertEq(allMul.__adj["c"].kind, "num/mul", "add became mul"); | |
| assertEq(allMul.__adj["e"].kind, "num/mul", "original mul unchanged"); | |
| // Children preserved | |
| assertEq(allMul.__adj["c"].children[0], "a", "children preserved"); | |
| assertEq(allMul.__adj["c"].children[1], "b", "children preserved"); | |
| // Root unchanged | |
| assertEq(allMul.__id, "e", "root id unchanged"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 6: replaceNode (the zero-cascade replacement) | |
| // ============================================================ | |
| console.log("\n=== Test 6: replaceNode ==="); | |
| const replaced = replaceNode(prog, "a", { | |
| kind: "num/literal", | |
| children: [], | |
| out: 99, | |
| }); | |
| // Root unchanged | |
| assertEq(replaced.__id, "e", "root unchanged after replace"); | |
| // Target replaced | |
| assertEq(replaced.__adj["a"].out, 99, "a now holds 99"); | |
| // Parents still reference same ID — no cascade needed | |
| assertEq(replaced.__adj["c"].children[0], "a", "add still points to a"); | |
| // Other entries untouched | |
| assertEq(replaced.__adj["b"].out, 4, "b still 4"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 7: replaceByName | |
| // ============================================================ | |
| console.log("\n=== Test 7: replaceByName ==="); | |
| // Build: total = price(100) + tax(price * rate(21)) | |
| const $r = freshCtx(); | |
| const [rPrice, $r1] = $r.numLit(100); | |
| const [rNPrice, $r2] = $r1.named("price", rPrice); | |
| const [rRate, $r3] = $r2.numLit(21); | |
| const [rNRate, $r4] = $r3.named("tax-rate", rRate); | |
| const [rTax, $r5] = $r4.mul(rNPrice, rNRate); | |
| const [rTotal, $r6] = $r5.add(rNPrice, rTax); | |
| // Replace tax-rate with 15 | |
| // We need a fresh expr for the replacement | |
| const $rep = createCtx("z"); // use a different counter region to avoid collision | |
| const [newRate, _] = $rep.numLit(15); | |
| const updated = replaceByName(rTotal, "tax-rate", newRate); | |
| assertEq(updated.__id, "d", "root unchanged"); | |
| assertEq(updated.__adj["b"].out, 15, "tax-rate now 15"); | |
| assertEq(updated.__adj["@tax-rate"].target, "b", "alias still points to b"); | |
| // Parents still reference b | |
| assertEq(updated.__adj["c"].children[1], "b", "mul still refs b"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 8: DagView.parents with shared nodes (DAG not tree) | |
| // ============================================================ | |
| console.log("\n=== Test 8: Shared nodes (DAG) ==="); | |
| // x = lit(7), prog = add(x, x) — x has two parents (same node) | |
| const $d = freshCtx(); | |
| const [x, $d1] = $d.numLit(7); | |
| const [doubled, $d2] = $d1.add(x, x); | |
| assertEq(doubled.__adj["b"].children![0], "a", "add left = a"); | |
| assertEq(doubled.__adj["b"].children![1], "a", "add right = a"); | |
| const dagD = createDagView(doubled.__adj); | |
| // x ("a") should have "b" as parent (appears once even though referenced twice) | |
| const parentsOfA = dagD.parents("a"); | |
| // In our implementation, "b" will appear twice since we iterate children | |
| // That's fine for a spike — a Set would dedupe in production | |
| assertEq(parentsOfA.includes("b"), true, "a's parent includes b"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 9: Scale — build 100 nodes | |
| // ============================================================ | |
| console.log("\n=== Test 9: Scale (100 nodes) ==="); | |
| let ctx: any = freshCtx(); | |
| let prev: any; | |
| [prev, ctx] = ctx.numLit(0); | |
| for (let i = 1; i < 50; i++) { | |
| let next: any; | |
| [next, ctx] = ctx.numLit(i); | |
| [prev, ctx] = ctx.add(prev, next); | |
| } | |
| // 50 numLits + 49 adds = 99 nodes, plus one more add = 100 | |
| const [lastLit, ctx2] = ctx.numLit(50); | |
| const [bigProg, _ctx3] = ctx2.add(prev, lastLit); | |
| assertEq(Object.keys(bigProg.__adj).length, 101, "101 nodes in adj"); | |
| assertEq(bigProg.__id.length > 0, true, "has root id"); | |
| // Queries still work at scale | |
| const bigLits = selectByKind(bigProg, "num/literal"); | |
| assertEq(Object.keys(bigLits).length, 51, "51 literals"); | |
| const bigAdds = selectByKind(bigProg, "num/add"); | |
| assertEq(Object.keys(bigAdds).length, 50, "50 adds"); | |
| // Replace a deep node — still no cascade | |
| const bigReplaced = replaceNode(bigProg, "a", { | |
| kind: "num/literal", | |
| children: [], | |
| out: 999, | |
| }); | |
| assertEq(bigReplaced.__adj["a"].out, 999, "deep replace works"); | |
| assertEq(bigReplaced.__id, bigProg.__id, "root unchanged"); | |
| console.log(" adj size:", Object.keys(bigProg.__adj).length); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Test 10: wrap as a composition of existing primitives | |
| // ============================================================ | |
| console.log("\n=== Test 10: wrap = addEntry + rewire parents ==="); | |
| // wrap(expr, targetId, wrapperKind, ctx): | |
| // 1. Take a fresh ID from ctx for the wrapper | |
| // 2. Add wrapper entry with target as its child | |
| // 3. Rewrite every parent of target to reference wrapper instead | |
| // | |
| // That's: one adj insert + one bulk children rewrite. Both already exist. | |
| function wrap( | |
| expr: any, | |
| targetId: string, | |
| wrapperKind: string, | |
| ctx: any, // BuildCtx — we just need a fresh ID | |
| ): [any, any] { | |
| // Step 1: get fresh ID (just read the counter from ctx) | |
| // We'll cheat slightly: call numLit to get an ID, then throw away the entry | |
| const [dummy, nextCtx] = ctx.numLit(0); | |
| const wrapperId = dummy.__id; | |
| // Step 2: add wrapper entry pointing to target | |
| const newAdj = { | |
| ...expr.__adj, | |
| [wrapperId]: { kind: wrapperKind, children: [targetId] }, | |
| }; | |
| // Step 3: rewire parents — swap targetId → wrapperId in their children | |
| for (const [id, entry] of Object.entries(newAdj)) { | |
| if (id === wrapperId) continue; // skip the wrapper itself | |
| if (id.startsWith("@")) continue; | |
| const e = entry as any; | |
| if (e.children && e.children.includes(targetId)) { | |
| newAdj[id] = { | |
| ...e, | |
| children: e.children.map((c: string) => c === targetId ? wrapperId : c), | |
| }; | |
| } | |
| } | |
| // Also update root ID if the root was the target | |
| const rootId = expr.__id === targetId ? wrapperId : expr.__id; | |
| return [makeExpr(expr.kind, rootId, newAdj, dummy.__counter), nextCtx]; | |
| } | |
| // Test: add(lit(3), lit(4)) → wrap lit(3) with neg → add(neg(lit(3)), lit(4)) | |
| const $w = freshCtx(); | |
| const [w3, $w1] = $w.numLit(3); | |
| const [w4, $w2] = $w1.numLit(4); | |
| const [wSum, $w3] = $w2.add(w3, w4); | |
| // Before: c.children = ["a", "b"] | |
| assertEq(wSum.__adj["c"].children![0], "a", "before wrap: add left = a"); | |
| assertEq(wSum.__adj["c"].children![1], "b", "before wrap: add right = b"); | |
| // Wrap "a" (lit(3)) with neg | |
| const [wrapped, $w4] = wrap(wSum, "a", "num/neg", $w3); | |
| // After: | |
| // - "d" is the new neg wrapper: children = ["a"] | |
| // - "c" (add) now points to "d" instead of "a": children = ["d", "b"] | |
| // - "a" is untouched | |
| assertEq(wrapped.__adj["d"].kind, "num/neg", "wrapper is neg"); | |
| assertEq(wrapped.__adj["d"].children![0], "a", "wrapper child = a"); | |
| assertEq(wrapped.__adj["c"].children![0], "d", "add left rewired to d"); | |
| assertEq(wrapped.__adj["c"].children![1], "b", "add right unchanged"); | |
| assertEq(wrapped.__adj["a"].kind, "num/literal", "a untouched"); | |
| assertEq(wrapped.__adj["a"].out, 3, "a still 3"); | |
| assertEq(wrapped.__id, "c", "root unchanged"); | |
| console.log(" Before: add(a:lit(3), b:lit(4))"); | |
| console.log(" After: add(d:neg(a:lit(3)), b:lit(4))"); | |
| console.log(" PASS"); | |
| // Test: wrap by name | |
| console.log("\n=== Test 11: wrap by name ==="); | |
| const $wn = freshCtx(); | |
| const [np, $wna] = $wn.numLit(100); | |
| const [nnp, $wnb] = $wna.named("price", np); | |
| const [nt, $wnc] = $wnb.numLit(21); | |
| const [nnt, $wnd] = $wnc.named("tax", nt); | |
| const [total, $wne] = $wnd.add(nnp, nnt); | |
| // Wrap "price" (a) with neg → total becomes add(neg(price), tax) | |
| const dag2 = createDagView(total.__adj); | |
| const priceId = dag2.resolve("price"); | |
| const [wrappedTotal, $wnf] = wrap(total, priceId, "num/neg", $wne); | |
| // a:lit(100), b:lit(21), c:add(a,b), wrapper gets d | |
| assertEq(wrappedTotal.__adj["d"].kind, "num/neg", "wrapper is neg"); | |
| assertEq(wrappedTotal.__adj["d"].children![0], "a", "wrapper points to price"); | |
| assertEq(wrappedTotal.__adj["c"].children![0], "d", "add rewired to wrapper"); | |
| assertEq(wrappedTotal.__adj["c"].children![1], "b", "add right unchanged"); | |
| assertEq(wrappedTotal.__adj["@price"].target, "a", "name still points to a"); | |
| console.log(" Resolved @price → " + priceId + ", wrapped with neg"); | |
| console.log(" PASS"); | |
| // Test: wrap the root node itself | |
| console.log("\n=== Test 12: wrap root ==="); | |
| const $wr = freshCtx(); | |
| const [r7, $wra] = $wr.numLit(7); | |
| const [wrappedRoot, $wrb] = wrap(r7, "a", "num/neg", $wra); | |
| assertEq(wrappedRoot.__id, "b", "root is now the wrapper"); | |
| assertEq(wrappedRoot.__adj["b"].kind, "num/neg", "wrapper is neg"); | |
| assertEq(wrappedRoot.__adj["b"].children![0], "a", "wrapper child = original root"); | |
| assertEq(wrappedRoot.__adj["a"].kind, "num/literal", "original untouched"); | |
| console.log(" lit(7) → neg(lit(7)), root changed from a to b"); | |
| console.log(" PASS"); | |
| // ============================================================ | |
| // Summary | |
| // ============================================================ | |
| console.log("\n=== All tests passed ==="); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Spike: Proving type holes — mapWhere, wrapWhere, gc | |
| // ============================================================ | |
| import { IDChain, FirstID } from "./spike-idchain"; | |
| // ============================================================ | |
| // Core types (compact, from previous spikes) | |
| // ============================================================ | |
| type NodeEntry<Kind extends string, ChildIDs extends string[], Out> = { | |
| readonly kind: Kind; | |
| readonly children: ChildIDs; | |
| readonly out: Out; | |
| }; | |
| type Desc<Out, ID extends string, Adj extends Record<string, any>, C extends string> = { | |
| readonly o: Out; readonly id: ID; readonly adj: Adj; readonly c: C; | |
| }; | |
| declare const exprBrand: unique symbol; | |
| interface Expr<O, R extends Desc<O, string, Record<string, any>, string>> { | |
| readonly [exprBrand]: R; | |
| } | |
| type OutOf<R> = R extends Desc<infer O, any, any, any> ? O : never; | |
| type IdOf<R> = R extends Desc<any, infer ID, any, any> ? ID : never; | |
| type AdjOf<R> = R extends Desc<any, any, infer A, any> ? A : never; | |
| type CtrOf<R> = R extends Desc<any, any, any, infer C> ? C : never; | |
| type NextID<C extends keyof IDChain> = IDChain[C]; | |
| // ============================================================ | |
| // Predicates (minimal subset) | |
| // ============================================================ | |
| interface KindPred<K extends string> { readonly tag: "kind"; readonly kind: K } | |
| interface KindGlobPred<P extends string> { readonly tag: "kindGlob"; readonly prefix: P } | |
| interface LeafPred { readonly tag: "leaf" } | |
| interface NotPred<P> { readonly tag: "not"; readonly inner: P } | |
| interface AndPred<A, B> { readonly tag: "and"; readonly left: A; readonly right: B } | |
| type EvalPred<P, Entry> = | |
| P extends KindPred<infer K> | |
| ? Entry extends NodeEntry<K, any, any> ? true : false | |
| : P extends KindGlobPred<infer Prefix> | |
| ? Entry extends NodeEntry<`${Prefix}${string}`, any, any> ? true : false | |
| : P extends LeafPred | |
| ? Entry extends NodeEntry<any, infer C, any> ? C extends [] ? true : false : false | |
| : P extends NotPred<infer Inner> | |
| ? EvalPred<Inner, Entry> extends true ? false : true | |
| : P extends AndPred<infer A, infer B> | |
| ? EvalPred<A, Entry> extends true | |
| ? EvalPred<B, Entry> extends true ? true : false : false | |
| : never; | |
| // ============================================================ | |
| // CRITICAL: mapWhere result typing | |
| // ============================================================ | |
| // Union of entry types that match the predicate | |
| type MatchingEntries<Adj, P> = { | |
| [K in keyof Adj]: EvalPred<P, Adj[K]> extends true ? Adj[K] : never; | |
| }[keyof Adj]; | |
| // Transform adj: matching → NewEntry, non-matching → unchanged | |
| type MapAdj<Adj, P, NewEntry> = { | |
| [K in keyof Adj]: EvalPred<P, Adj[K]> extends true ? NewEntry : Adj[K]; | |
| }; | |
| // Output type: changes only if root was matched | |
| type MapOut<O, Adj, RootID extends string & keyof Adj, P, NewEntry> = | |
| EvalPred<P, Adj[RootID]> extends true | |
| ? NewEntry extends NodeEntry<any, any, infer NO> ? NO : O | |
| : O; | |
| // The function signature | |
| declare function mapWhere< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| P, | |
| NE extends NodeEntry<string, string[], any>, | |
| >( | |
| expr: Expr<O, R>, | |
| pred: P, | |
| fn: (entry: MatchingEntries<AdjOf<R>, P>) => NE, | |
| ): Expr< | |
| MapOut<O, AdjOf<R>, IdOf<R> & string, P, NE>, | |
| Desc< | |
| MapOut<O, AdjOf<R>, IdOf<R> & string, P, NE>, | |
| IdOf<R>, | |
| MapAdj<AdjOf<R>, P, NE>, | |
| CtrOf<R> | |
| > | |
| >; | |
| // ============================================================ | |
| // Test fixture: (3 + 4) * 5, root at "e" | |
| // ============================================================ | |
| type TA = { | |
| a: NodeEntry<"num/literal", [], number>; | |
| b: NodeEntry<"num/literal", [], number>; | |
| c: NodeEntry<"num/add", ["a", "b"], number>; | |
| d: NodeEntry<"str/literal", [], string>; | |
| e: NodeEntry<"num/mul", ["c", "b"], number>; | |
| }; | |
| type TD = Desc<number, "e", TA, "f">; | |
| declare const prog: Expr<number, TD>; | |
| // ============================================================ | |
| // Test 1: mapWhere byKind("num/add") → kind stays, out changes | |
| // Callback: num/add → num/sub (same children, same out type) | |
| // ============================================================ | |
| const r1 = mapWhere( | |
| prog, | |
| { tag: "kind", kind: "num/add" } as const satisfies KindPred<"num/add">, | |
| (_e) => ({ kind: "num/sub" as const, children: ["a", "b"] as const, out: 0 as number }), | |
| ); | |
| // Result adj should have "c" changed to num/sub, everything else same | |
| type R1R = (typeof r1)[typeof exprBrand]; | |
| type R1Adj = AdjOf<R1R>; | |
| // c is now num/sub | |
| type R1C = R1Adj["c"]; | |
| const _r1cKind: R1C["kind"] = "num/sub"; | |
| // a is unchanged | |
| type R1A = R1Adj["a"]; | |
| const _r1aKind: R1A["kind"] = "num/literal"; | |
| // d is unchanged | |
| type R1D = R1Adj["d"]; | |
| const _r1dKind: R1D["kind"] = "str/literal"; | |
| // root ID unchanged | |
| type R1ID = IdOf<R1R>; | |
| const _r1id: R1ID = "e"; | |
| // output type unchanged (root "e" is num/mul, not matched) | |
| type R1Out = OutOf<R1R>; | |
| const _r1out: R1Out = 42; | |
| // NEGATIVE: c is NOT num/add anymore | |
| // @ts-expect-error — c was transformed to num/sub | |
| const _r1cWrong: R1C["kind"] = "num/add"; | |
| // ============================================================ | |
| // Test 2: mapWhere byKindGlob("num/") → all num nodes changed | |
| // ============================================================ | |
| const r2 = mapWhere( | |
| prog, | |
| { tag: "kindGlob", prefix: "num/" } as const satisfies KindGlobPred<"num/">, | |
| (_e) => ({ kind: "opt/node" as const, children: [] as const, out: 0 as number }), | |
| ); | |
| type R2R = (typeof r2)[typeof exprBrand]; | |
| type R2Adj = AdjOf<R2R>; | |
| // a, b, c, e all become opt/node | |
| const _r2a: R2Adj["a"]["kind"] = "opt/node"; | |
| const _r2b: R2Adj["b"]["kind"] = "opt/node"; | |
| const _r2c: R2Adj["c"]["kind"] = "opt/node"; | |
| const _r2e: R2Adj["e"]["kind"] = "opt/node"; | |
| // d unchanged (str/literal) | |
| const _r2d: R2Adj["d"]["kind"] = "str/literal"; | |
| // NEGATIVE: d is NOT opt/node | |
| // @ts-expect-error — d was not matched by num/ glob | |
| const _r2dWrong: R2Adj["d"]["kind"] = "opt/node"; | |
| // ============================================================ | |
| // Test 3: mapWhere on root → output type changes | |
| // ============================================================ | |
| // Change root "e" (num/mul) to output string instead of number | |
| const r3 = mapWhere( | |
| prog, | |
| { tag: "kind", kind: "num/mul" } as const satisfies KindPred<"num/mul">, | |
| (_e) => ({ kind: "str/repr" as const, children: ["c", "b"] as const, out: "" as string }), | |
| ); | |
| type R3R = (typeof r3)[typeof exprBrand]; | |
| type R3Out = OutOf<R3R>; | |
| // Output type is now string! | |
| const _r3out: R3Out = "hello"; | |
| // NEGATIVE: output is NOT number anymore | |
| // @ts-expect-error — root was transformed, output is now string | |
| const _r3outWrong: R3Out = 42; | |
| // ============================================================ | |
| // Test 4: mapWhere on non-root → output type unchanged | |
| // ============================================================ | |
| const r4 = mapWhere( | |
| prog, | |
| { tag: "leaf" } as const satisfies LeafPred, | |
| (_e) => ({ kind: "const/zero" as const, children: [] as const, out: "" as string }), | |
| ); | |
| type R4R = (typeof r4)[typeof exprBrand]; | |
| type R4Out = OutOf<R4R>; | |
| // Root "e" is num/mul (not leaf), so output type stays number | |
| const _r4out: R4Out = 42; | |
| // @ts-expect-error — output is still number, not string | |
| const _r4outWrong: R4Out = "nope"; | |
| // ============================================================ | |
| // Test 5: counter unchanged (mapWhere allocates no IDs) | |
| // ============================================================ | |
| type R1Ctr = CtrOf<R1R>; | |
| const _r1ctr: R1Ctr = "f"; // same as input | |
| // ============================================================ | |
| // HIGH: wrapWhere — single wrap (wrapByName) is fully typeable | |
| // ============================================================ | |
| // Rewire: parents that referenced TargetID now reference WrapperID | |
| type RewireParents<Adj, TargetID extends string, WrapperID extends string> = { | |
| [K in keyof Adj]: Adj[K] extends NodeEntry<infer Kind, infer Children extends string[], infer Out> | |
| ? NodeEntry<Kind, RewireList<Children, TargetID, WrapperID>, Out> | |
| : Adj[K]; | |
| }; | |
| type RewireList<T extends string[], Old extends string, New extends string> = | |
| T extends [infer H extends string, ...infer Rest extends string[]] | |
| ? [H extends Old ? New : H, ...RewireList<Rest, Old, New>] | |
| : []; | |
| // WrapOneComplete: rewire parents in original adj, then add wrapper entry | |
| type WrapOneComplete< | |
| Adj extends Record<string, any>, | |
| TargetID extends string, | |
| WrapperKind extends string, | |
| C extends string & keyof IDChain, | |
| > = RewireParents<Adj, TargetID, C> & Record< | |
| C, | |
| NodeEntry< | |
| WrapperKind, | |
| [TargetID], | |
| Adj[TargetID] extends NodeEntry<any, any, infer O> ? O : never | |
| > | |
| >; | |
| declare function wrapByName< | |
| O, | |
| R extends Desc<O, string, Record<string, any>, string>, | |
| TargetID extends string & keyof AdjOf<R>, | |
| WrapperKind extends string, | |
| >( | |
| expr: Expr<O, R>, | |
| targetId: TargetID, | |
| wrapperKind: WrapperKind, | |
| ): CtrOf<R> extends string & keyof IDChain | |
| ? Expr< | |
| O, | |
| Desc< | |
| O, | |
| // Root changes if we wrapped the root | |
| IdOf<R> extends TargetID ? CtrOf<R> & string : IdOf<R>, | |
| WrapOneComplete<AdjOf<R>, TargetID, WrapperKind, CtrOf<R> & keyof IDChain>, | |
| NextID<CtrOf<R> & keyof IDChain> & string | |
| > | |
| > | |
| : never; | |
| // ============================================================ | |
| // Test 6: wrapByName — wrap node "c" (num/add) with "telemetry/span" | |
| // ============================================================ | |
| const w1 = wrapByName(prog, "c", "telemetry/span"); | |
| type W1R = (typeof w1)[typeof exprBrand]; | |
| type W1Adj = AdjOf<W1R>; | |
| // Wrapper at "f" (next counter) | |
| type W1Wrapper = W1Adj["f"]; | |
| const _w1wKind: W1Wrapper["kind"] = "telemetry/span"; | |
| const _w1wChildren: W1Wrapper["children"] = ["c"]; | |
| // Original "c" still exists | |
| const _w1cKind: W1Adj["c"]["kind"] = "num/add"; | |
| // Parent "e" (num/mul) now references "f" instead of "c" | |
| type W1E = W1Adj["e"]; | |
| const _w1eChildren: W1E["children"] = ["f", "b"]; | |
| // Counter advanced | |
| type W1Ctr = CtrOf<W1R>; | |
| const _w1ctr: W1Ctr = "g"; | |
| // Root unchanged (we didn't wrap root) | |
| type W1ID = IdOf<W1R>; | |
| const _w1id: W1ID = "e"; | |
| // NEGATIVE: parent should NOT still reference "c" | |
| // @ts-expect-error — "e" now points to wrapper "f", not "c" | |
| const _w1eWrong: W1E["children"] = ["c", "b"]; | |
| // ============================================================ | |
| // Test 7: wrapByName — wrap root "e" | |
| // ============================================================ | |
| const w2 = wrapByName(prog, "e", "telemetry/root-span"); | |
| type W2R = (typeof w2)[typeof exprBrand]; | |
| // Root is now the wrapper "f" | |
| type W2ID = IdOf<W2R>; | |
| const _w2id: W2ID = "f"; | |
| // Wrapper's children = ["e"] | |
| type W2Wrapper = AdjOf<W2R>["f"]; | |
| const _w2wKind: W2Wrapper["kind"] = "telemetry/root-span"; | |
| const _w2wChildren: W2Wrapper["children"] = ["e"]; | |
| // Output type unchanged | |
| type W2Out = OutOf<W2R>; | |
| const _w2out: W2Out = 42; | |
| // ============================================================ | |
| // BULK wrapWhere: here's the wall | |
| // ============================================================ | |
| // The problem: wrapWhere(expr, byKindGlob("num/"), "span") matches N nodes. | |
| // Each needs a fresh ID. We must iterate the matching keys at the type level, | |
| // allocating one ID per match. TypeScript has no built-in "iterate a union." | |
| // | |
| // Options we considered: | |
| // 1. UnionToTuple — fragile, order-dependent, limited depth | |
| // 2. Approximate typing — lose precision on new IDs | |
| // 3. wrapByName only — user loops explicitly with context threading | |
| // | |
| // Verdict: wrapByName is fully typed. Bulk wrapWhere would need UnionToTuple | |
| // or a fold-over-union trick. Both are hacks. For now, bulk wrap = user code: | |
| // | |
| // const [e1, ctx1] = wrapByName(expr, "a", "span"); | |
| // const [e2, ctx2] = wrapByName(e1, "b", "span"); | |
| // // each step fully typed, counter threads correctly | |
| // ============================================================ | |
| // MEDIUM: gc at the type level — here's the other wall | |
| // ============================================================ | |
| // Computing reachability requires walking from root through children. | |
| // That's recursive graph traversal over the adj type. | |
| // Let's try it and see where we hit limits. | |
| type Reachable<Adj, ID extends string, Visited extends string = never> = | |
| ID extends Visited ? Visited : | |
| ID extends keyof Adj | |
| ? Adj[ID] extends NodeEntry<any, infer Children extends string[], any> | |
| ? ReachableAll<Adj, Children, Visited | ID> | |
| : Visited | ID | |
| : Visited | ID; | |
| type ReachableAll<Adj, IDs extends string[], Visited extends string> = | |
| IDs extends [infer H extends string, ...infer T extends string[]] | |
| ? ReachableAll<Adj, T, Reachable<Adj, H, Visited>> | |
| : Visited; | |
| // GC: keep only reachable keys | |
| type GCAdj<Adj, RootID extends string> = { | |
| [K in keyof Adj as K extends Reachable<Adj, RootID> ? K : never]: Adj[K]; | |
| }; | |
| // ============================================================ | |
| // Test 8: gc on a clean graph — nothing removed | |
| // ============================================================ | |
| type GC1 = GCAdj<TA, "e">; | |
| // e→[c,b], c→[a,b], a→[], b→[], so a,b,c,e reachable. d is orphan. | |
| type GC1Keys = keyof GC1; | |
| const _gc1a: GC1Keys = "a"; | |
| const _gc1b: GC1Keys = "b"; | |
| const _gc1c: GC1Keys = "c"; | |
| const _gc1e: GC1Keys = "e"; | |
| // d is unreachable (str/literal, no parent) | |
| // @ts-expect-error — d is garbage collected | |
| const _gc1d: GC1Keys = "d"; | |
| // ============================================================ | |
| // Test 9: gc preserves entries correctly | |
| // ============================================================ | |
| type GC1A = GC1["a"]; | |
| const _gc1aKind: GC1A["kind"] = "num/literal"; | |
| type GC1E = GC1["e"]; | |
| const _gc1eKind: GC1E["kind"] = "num/mul"; | |
| // ============================================================ | |
| // Test 10: gc on a graph with more orphans | |
| // ============================================================ | |
| type Orphaned = { | |
| a: NodeEntry<"num/literal", [], number>; | |
| b: NodeEntry<"num/literal", [], number>; | |
| c: NodeEntry<"num/add", ["a", "b"], number>; | |
| x: NodeEntry<"orphan/1", [], string>; | |
| y: NodeEntry<"orphan/2", ["x"], string>; | |
| z: NodeEntry<"orphan/3", [], boolean>; | |
| }; | |
| type GC2 = GCAdj<Orphaned, "c">; | |
| type GC2Keys = keyof GC2; | |
| const _gc2a: GC2Keys = "a"; | |
| const _gc2b: GC2Keys = "b"; | |
| const _gc2c: GC2Keys = "c"; | |
| // @ts-expect-error — x is orphan | |
| const _gc2x: GC2Keys = "x"; | |
| // @ts-expect-error — y is orphan | |
| const _gc2y: GC2Keys = "y"; | |
| // @ts-expect-error — z is orphan | |
| const _gc2z: GC2Keys = "z"; | |
| // ============================================================ | |
| // Test 11: gc on diamond DAG (shared nodes) | |
| // ============================================================ | |
| type Diamond = { | |
| a: NodeEntry<"val", [], number>; | |
| b: NodeEntry<"use1", ["a"], number>; | |
| c: NodeEntry<"use2", ["a"], number>; | |
| d: NodeEntry<"join", ["b", "c"], number>; | |
| }; | |
| type GC3 = GCAdj<Diamond, "d">; | |
| type GC3Keys = keyof GC3; | |
| const _gc3a: GC3Keys = "a"; | |
| const _gc3b: GC3Keys = "b"; | |
| const _gc3c: GC3Keys = "c"; | |
| const _gc3d: GC3Keys = "d"; | |
| // ============================================================ | |
| // Scale test: does gc recurse too deep? | |
| // ============================================================ | |
| type Chain8 = { | |
| a: NodeEntry<"n", [], number>; | |
| b: NodeEntry<"n", ["a"], number>; | |
| c: NodeEntry<"n", ["b"], number>; | |
| d: NodeEntry<"n", ["c"], number>; | |
| e: NodeEntry<"n", ["d"], number>; | |
| f: NodeEntry<"n", ["e"], number>; | |
| g: NodeEntry<"n", ["f"], number>; | |
| h: NodeEntry<"n", ["g"], number>; | |
| }; | |
| type GC4 = GCAdj<Chain8, "h">; | |
| type GC4Keys = keyof GC4; | |
| // All 8 reachable | |
| const _gc4a: GC4Keys = "a"; | |
| const _gc4h: GC4Keys = "h"; | |
| // Depth 8 works. Real programs may be deeper — runtime gc() is the fallback. | |
| console.log("spike-typed-transforms compiled successfully"); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment