@tinyvolt/pie 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/LICENSE +21 -0
- package/README.md +56 -2
- package/functions/midpoint.ts +29 -0
- package/functions/square.ts +20 -0
- package/init/index.js +87 -0
- package/init/site-agents.md +253 -0
- package/init/site-gitignore +28 -0
- package/init/site-tsconfig.json +20 -0
- package/init/site-vite.config.ts +6 -0
- package/layouts/header.tsx +16 -0
- package/layouts/index.tsx +10 -0
- package/nodes/square.tsx +36 -0
- package/package.json +59 -4
- package/pages/example.md +7 -0
- package/pages/python-editor.md +29 -0
- package/public/favicon.ico +0 -0
- package/public/logo.svg +5 -0
- package/site.yml +1 -0
- package/src/app.css +343 -0
- package/src/app.tsx +30 -0
- package/src/authorStyles.ts +3 -0
- package/src/components/Header.tsx +16 -0
- package/src/components/PageTitle.tsx +10 -0
- package/src/components/SplitPane.tsx +170 -0
- package/src/components/icons.tsx +22 -0
- package/src/entry-client.tsx +4 -0
- package/src/entry-server.tsx +21 -0
- package/src/global.d.ts +7 -0
- package/src/lib/appDir.ts +4 -0
- package/src/lib/autodiff/index.ts +49 -0
- package/src/lib/layouts/layout.ts +20 -0
- package/src/lib/layouts/vitePlugin.ts +39 -0
- package/src/lib/markdown/components/CanvasPage.tsx +25 -0
- package/src/lib/markdown/components/MarkdownBody.tsx +41 -0
- package/src/lib/markdown/components/PythonEditor.tsx +166 -0
- package/src/lib/markdown/frontmatter.ts +81 -0
- package/src/lib/markdown/pages.ts +23 -0
- package/src/lib/markdown/render.ts +54 -0
- package/src/lib/markdown/vitePlugin.ts +94 -0
- package/src/lib/nodes/components/CenteredGrid.tsx +59 -0
- package/src/lib/nodes/components/NodeCanvas.tsx +58 -0
- package/src/lib/nodes/execute.ts +166 -0
- package/src/lib/nodes/functions/Node2NodeFunc.ts +307 -0
- package/src/lib/nodes/functions/array.ts +36 -0
- package/src/lib/nodes/functions/autodiff.ts +64 -0
- package/src/lib/nodes/functions/basic.ts +298 -0
- package/src/lib/nodes/functions/registry.ts +46 -0
- package/src/lib/nodes/functions/sideEffects.ts +193 -0
- package/src/lib/nodes/functions/vitePlugin.ts +40 -0
- package/src/lib/nodes/hooks/useCanvasResize.ts +38 -0
- package/src/lib/nodes/jax.ts +191 -0
- package/src/lib/nodes/render/renderLogic.tsx +306 -0
- package/src/lib/nodes/state/NameGenerator.ts +31 -0
- package/src/lib/nodes/state/Node.ts +211 -0
- package/src/lib/nodes/state/NodeStore.ts +110 -0
- package/src/lib/nodes/tensors/broadcast.ts +101 -0
- package/src/lib/nodes/tensors/tensor.ts +42 -0
- package/src/lib/python/lift.ts +105 -0
- package/src/lib/python/py/__pycache__/pie.cpython-314.pyc +0 -0
- package/src/lib/python/py/pie.py +231 -0
- package/src/lib/python/py/pie_mpl.py +15 -0
- package/src/lib/python/py/show_figures.py +5 -0
- package/src/lib/python/runner.ts +202 -0
- package/src/lib/python/worker.ts +109 -0
- package/src/lib/site/vitePlugin.ts +43 -0
- package/src/routes/[...slug].tsx +38 -0
- package/src/routes/about.tsx +10 -0
- package/src/routes/index.tsx +14 -0
- package/src/routes/pages/index.tsx +20 -0
- package/styles/theme.css +27 -0
- package/vite.ts +61 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { For, createMemo, JSX } from 'solid-js';
|
|
2
|
+
import { NodeStore, type NodeSignal } from '../state/NodeStore';
|
|
3
|
+
import { getNodeDefinition } from '../state/Node';
|
|
4
|
+
import { CenteredGrid } from './CenteredGrid';
|
|
5
|
+
import { useCanvasResize } from '../hooks/useCanvasResize';
|
|
6
|
+
|
|
7
|
+
interface NodeCanvasProps {
|
|
8
|
+
store: NodeStore;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function NodeCanvas(props: NodeCanvasProps): JSX.Element {
|
|
12
|
+
let svgContainerRef: HTMLDivElement | undefined;
|
|
13
|
+
useCanvasResize(() => svgContainerRef, props.store);
|
|
14
|
+
|
|
15
|
+
// Canvas size in grid units: the viewBox maps one unit to `unit` pixels.
|
|
16
|
+
const viewWidth = () => props.store.canvasBounds().width / props.store.unit();
|
|
17
|
+
const viewHeight = () => props.store.canvasBounds().height / props.store.unit();
|
|
18
|
+
|
|
19
|
+
// One pass over the store, partitioned into the two draw layers.
|
|
20
|
+
const layers = createMemo(() => {
|
|
21
|
+
const geometry: NodeSignal[] = [];
|
|
22
|
+
const points: NodeSignal[] = [];
|
|
23
|
+
for (const nodeSignal of Object.values(props.store.nodes)) {
|
|
24
|
+
if (!nodeSignal) continue;
|
|
25
|
+
const node = nodeSignal.signal();
|
|
26
|
+
if (!node || node.type === 'Dummy' || node.hidden) continue;
|
|
27
|
+
if (node.type === 'Point') points.push(nodeSignal);
|
|
28
|
+
else geometry.push(nodeSignal);
|
|
29
|
+
}
|
|
30
|
+
return { geometry, points };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const nodeGroup = (nodeSignal: NodeSignal) => {
|
|
34
|
+
const node = createMemo(() => nodeSignal?.signal());
|
|
35
|
+
// The element is rebuilt only when the type changes.
|
|
36
|
+
const type = createMemo(() => node()?.type);
|
|
37
|
+
return (
|
|
38
|
+
<g data-node-id={node()?.id}>
|
|
39
|
+
{/* The browser shows the `<title>` as a tooltip when hovering any shape in the group. */}
|
|
40
|
+
<title>{node()?.id}</title>
|
|
41
|
+
{type() && (getNodeDefinition(type()!)?.render?.(node, props.store.unit) ?? null)}
|
|
42
|
+
</g>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div class="node-canvas" ref={svgContainerRef}>
|
|
48
|
+
<svg viewBox={`0 0 ${viewWidth()} ${viewHeight()}`}>
|
|
49
|
+
<CenteredGrid width={viewWidth()} height={viewHeight()} unit={props.store.unit()}>
|
|
50
|
+
{/* Step 1: Draw non-`Point` nodes first */}
|
|
51
|
+
<For each={layers().geometry}>{(nodeSignal) => nodeGroup(nodeSignal)}</For>
|
|
52
|
+
{/* Step 2: Draw `Point` nodes above geometry */}
|
|
53
|
+
<For each={layers().points}>{(nodeSignal) => nodeGroup(nodeSignal)}</For>
|
|
54
|
+
</CenteredGrid>
|
|
55
|
+
</svg>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { batch, createMemo, createRoot } from 'solid-js';
|
|
2
|
+
import { getFunction, getSideEffect } from './functions/registry';
|
|
3
|
+
import { NodeReconciler, ParameterDefinition } from './functions/Node2NodeFunc';
|
|
4
|
+
import { NodeStore } from './state/NodeStore';
|
|
5
|
+
import { AnyNode, ArrayNode, NodeType, ScalarNode, TextNode, checkColor } from './state/Node';
|
|
6
|
+
import { scalar } from './jax';
|
|
7
|
+
import { leaves, listItemType, listShape, sha256 } from './tensors/tensor';
|
|
8
|
+
import { broadcastItems, broadcastShapes, isBroadcast } from './tensors/broadcast';
|
|
9
|
+
|
|
10
|
+
/** One argument of a Python call: a number, a string, a handle to an existing node, or a nested list of these. */
|
|
11
|
+
export type Arg = number | string | { id: string } | Arg[];
|
|
12
|
+
|
|
13
|
+
export interface ExecuteResult {
|
|
14
|
+
id: string;
|
|
15
|
+
type: string;
|
|
16
|
+
/** Only for an Array. */
|
|
17
|
+
shape?: number[];
|
|
18
|
+
itemType?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function saveLiteral(store: NodeStore, node: AnyNode): string {
|
|
22
|
+
createRoot((dispose) => {
|
|
23
|
+
store.saveNode(node.id, createMemo(() => node), dispose);
|
|
24
|
+
});
|
|
25
|
+
return node.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What an argument is, before any node is created for it. */
|
|
29
|
+
function argType(arg: Arg, store: NodeStore): string {
|
|
30
|
+
if (Array.isArray(arg)) {
|
|
31
|
+
listItemType(arg, item => argType(item, store));
|
|
32
|
+
return 'Array';
|
|
33
|
+
}
|
|
34
|
+
if (typeof arg === 'number') return 'Scalar';
|
|
35
|
+
if (typeof arg === 'string') return 'Text';
|
|
36
|
+
const node = store.getNodeValue(arg.id);
|
|
37
|
+
if (!node) throw new Error(`Unknown node '${arg.id}'`);
|
|
38
|
+
return node.type;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The item type and shape of an argument that is an array, before any node is created for it. */
|
|
42
|
+
function arrayOf(arg: Arg, store: NodeStore): { itemType: NodeType; shape: number[] } {
|
|
43
|
+
if (Array.isArray(arg)) return { itemType: listItemType(arg, item => argType(item, store)), shape: listShape(arg) };
|
|
44
|
+
return store.getNodeValue((arg as { id: string }).id) as ArrayNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A list gets one hidden Array node, reused by every call that passes the same list.
|
|
49
|
+
* Its items follow the nodes they name.
|
|
50
|
+
*/
|
|
51
|
+
async function resolveList(list: Arg[], store: NodeStore): Promise<string> {
|
|
52
|
+
const id = `_literal_arr_${await sha256(JSON.stringify(list))}`;
|
|
53
|
+
if (store.getNodeValue(id)) return id;
|
|
54
|
+
const shape = listShape(list);
|
|
55
|
+
const itemType = listItemType(list, item => argType(item, store));
|
|
56
|
+
const itemIds = await Promise.all(leaves(list).map(item => resolveArg(item, store)));
|
|
57
|
+
createRoot((dispose) => {
|
|
58
|
+
const node = createMemo(() => ({ id, type: 'Array', itemType, shape, items: itemIds.map(itemId => store.getNodeValue(itemId)) } as ArrayNode));
|
|
59
|
+
store.saveNode(id, node, dispose);
|
|
60
|
+
});
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A number or string gets one hidden node, reused by every call that passes the same value. */
|
|
65
|
+
async function resolveArg(arg: Arg, store: NodeStore): Promise<string> {
|
|
66
|
+
if (Array.isArray(arg)) return resolveList(arg, store);
|
|
67
|
+
if (typeof arg === 'object') return arg.id;
|
|
68
|
+
const id = typeof arg === 'number' ? `_literal_num_${arg}` : `_literal_str_${arg}`;
|
|
69
|
+
if (store.getNodeValue(id)) return id;
|
|
70
|
+
if (typeof arg === 'number') {
|
|
71
|
+
return saveLiteral(store, { id, type: 'Scalar', value: scalar(arg) } as ScalarNode);
|
|
72
|
+
}
|
|
73
|
+
return saveLiteral(store, { id, type: 'Text', value: arg } as TextNode);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Checks `out`, the id asked for the output node of a call with arguments `args`. */
|
|
77
|
+
export function checkOut(keyword: string, out: unknown, args: Arg[]) {
|
|
78
|
+
if (out === undefined) return;
|
|
79
|
+
if (typeof out !== 'string' || out === '') {
|
|
80
|
+
throw new Error(`${keyword}: out must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
// Ids starting with '_' are kept for the hidden number and text nodes.
|
|
83
|
+
if (out.startsWith('_')) {
|
|
84
|
+
throw new Error(`${keyword}: out must not start with '_'`);
|
|
85
|
+
}
|
|
86
|
+
// A node that is its own input would update forever.
|
|
87
|
+
if (leaves(args).some(arg => typeof arg === 'object' && arg.id === out)) {
|
|
88
|
+
throw new Error(`${keyword}: out='${out}' is also an input`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The keyword arguments of a Python call. */
|
|
93
|
+
export interface CallOptions {
|
|
94
|
+
/** The id for the output node. Without it the id is generated. */
|
|
95
|
+
out?: string;
|
|
96
|
+
hidden?: boolean;
|
|
97
|
+
fill?: string;
|
|
98
|
+
stroke?: string;
|
|
99
|
+
/** Only for `animatable` side effects: numbers move to their new values instead of jumping. */
|
|
100
|
+
animate?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function execute(store: NodeStore, keyword: string, args: Arg[], { out, hidden, fill, stroke, animate }: CallOptions = {}): Promise<ExecuteResult> {
|
|
104
|
+
const func = getFunction(keyword) ?? getSideEffect(keyword);
|
|
105
|
+
if (!func) {
|
|
106
|
+
throw new Error(`Unknown function: ${keyword}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const total = func.parameters.length;
|
|
110
|
+
const required = func.parameters.filter(p => p.defaultValue === undefined).length;
|
|
111
|
+
if (args.length < required || args.length > total) {
|
|
112
|
+
const names = func.parameters.map((p: ParameterDefinition) => p.name).join(', ');
|
|
113
|
+
const count = required === total ? `exactly ${total}` : `${required} to ${total}`;
|
|
114
|
+
throw new Error(`${keyword} needs ${count} argument${total === 1 ? '' : 's'} (${names}), got ${args.length}`);
|
|
115
|
+
}
|
|
116
|
+
// Left-out arguments take their defaults. A null default makes no node: `compute` gets undefined.
|
|
117
|
+
const allArgs = func.parameters.map((p, i) => (i < args.length ? args[i] : p.defaultValue!));
|
|
118
|
+
|
|
119
|
+
// Check every type and shape first, so a bad call leaves no literal nodes behind.
|
|
120
|
+
const broadcastInputShapes: number[][] = [];
|
|
121
|
+
allArgs.forEach((arg, i) => {
|
|
122
|
+
if (arg === null) return;
|
|
123
|
+
const param = func.parameters[i];
|
|
124
|
+
const types = Array.isArray(param.type) ? param.type : [param.type];
|
|
125
|
+
let got = argType(arg, store);
|
|
126
|
+
// TODO: maybe add `broadcastable` flag to each param instead of the entire func?
|
|
127
|
+
if (isBroadcast(got, param)) {
|
|
128
|
+
if (func.broadcastable === false) throw new Error(`${keyword} does not take arrays`);
|
|
129
|
+
const array = arrayOf(arg, store);
|
|
130
|
+
broadcastInputShapes.push(array.shape);
|
|
131
|
+
got = `Array of ${array.itemType}`;
|
|
132
|
+
if (types.includes(array.itemType)) return;
|
|
133
|
+
}
|
|
134
|
+
if (!types.includes(got as NodeType)) {
|
|
135
|
+
throw new Error(`${keyword}: expected ${types.join(' or ')} but got ${got} for argument '${param.name}'`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (broadcastInputShapes.length > 0) broadcastShapes(broadcastInputShapes);
|
|
139
|
+
|
|
140
|
+
checkOut(keyword, out, args);
|
|
141
|
+
if ('run' in func && hidden !== undefined) throw new Error(`${keyword} does not take hidden`);
|
|
142
|
+
if ('run' in func && fill !== undefined) throw new Error(`${keyword} does not take fill`);
|
|
143
|
+
if ('run' in func && stroke !== undefined) throw new Error(`${keyword} does not take stroke`);
|
|
144
|
+
if (animate !== undefined && !('run' in func && func.animatable)) throw new Error(`${keyword} does not take animate`);
|
|
145
|
+
checkColor(keyword, 'fill', fill);
|
|
146
|
+
checkColor(keyword, 'stroke', stroke);
|
|
147
|
+
|
|
148
|
+
const inputNodeIds = await Promise.all(allArgs.map(arg => (arg === null ? undefined : resolveArg(arg, store))));
|
|
149
|
+
const inputs = () => inputNodeIds.map(id => (id === undefined ? undefined : store.getNodeValue(id)!) as AnyNode);
|
|
150
|
+
|
|
151
|
+
if ('run' in func) {
|
|
152
|
+
const runs = batch(() => broadcastItems(func.parameters, inputs()).map(itemInputs => func.run(itemInputs, store, animate ?? false)));
|
|
153
|
+
await Promise.all(runs);
|
|
154
|
+
return { id: '', type: 'Dummy' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const outputType = broadcastInputShapes.length > 0
|
|
158
|
+
? 'Array'
|
|
159
|
+
: func.resolveOutputType?.(inputs()) ?? func.outputType;
|
|
160
|
+
const id = out ?? store.nameGenerator.generateAutoName(store.nameGenerator.getPrefixForType(outputType));
|
|
161
|
+
new NodeReconciler().evaluate(func, inputNodeIds, store, id, hidden, fill, stroke);
|
|
162
|
+
|
|
163
|
+
const node = store.getNodeValue(id);
|
|
164
|
+
if (node?.type === 'Array') return { id, type: node.type, shape: node.shape, itemType: node.itemType };
|
|
165
|
+
return { id, type: node?.type ?? func.outputType };
|
|
166
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { NodeType } from '../state/Node';
|
|
2
|
+
import { batch, createComputed, createMemo, createRoot, onCleanup } from 'solid-js';
|
|
3
|
+
import { AnyNode } from '../state/Node';
|
|
4
|
+
import { NodeStore } from '../state/NodeStore';
|
|
5
|
+
import { computeBlueprint } from '../tensors/broadcast';
|
|
6
|
+
|
|
7
|
+
export interface ParameterDefinition {
|
|
8
|
+
name: string;
|
|
9
|
+
/** A list means any one of these types is accepted. */
|
|
10
|
+
type: NodeType | NodeType[];
|
|
11
|
+
/**
|
|
12
|
+
* Makes the parameter optional: a left-out argument takes this value. `null`: a left-out argument
|
|
13
|
+
* makes no node, and `compute` gets `undefined`. Optional parameters come after the required ones.
|
|
14
|
+
*/
|
|
15
|
+
defaultValue?: number | string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BlueprintNode = any;
|
|
19
|
+
|
|
20
|
+
/** Keyword prefix of nodes made by `pie.lift`ed Python functions, followed by the function's name. Not in the registry. */
|
|
21
|
+
export const PYTHON_KEYWORD_PREFIX = '_python:';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long a reactive recompute error may persist before it is reported.
|
|
25
|
+
*/
|
|
26
|
+
const TRANSIENT_ERROR_GRACE_MS = 500;
|
|
27
|
+
|
|
28
|
+
export interface Node2NodeFunc {
|
|
29
|
+
keyword: string;
|
|
30
|
+
outputType: NodeType;
|
|
31
|
+
/** For a function whose output type depends on its inputs. Used to name the output. */
|
|
32
|
+
resolveOutputType?: (inputs: AnyNode[]) => NodeType;
|
|
33
|
+
parameters: ParameterDefinition[];
|
|
34
|
+
/** False: passing an array is an error instead of a loop over its items. Default true. */
|
|
35
|
+
broadcastable?: boolean;
|
|
36
|
+
compute: (inputs: AnyNode[]) => Record<string, BlueprintNode>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A function that changes the store instead of making a node. It runs once per call, not reactively. */
|
|
40
|
+
export interface SideEffectFunc {
|
|
41
|
+
keyword: string;
|
|
42
|
+
parameters: ParameterDefinition[];
|
|
43
|
+
/** False: passing an array is an error instead of a loop over its items. Default true. */
|
|
44
|
+
broadcastable?: boolean;
|
|
45
|
+
/** True: takes `animate`. Default false. */
|
|
46
|
+
animatable?: boolean;
|
|
47
|
+
/** The call waits for the returned promise, e.g. until an animation ends. */
|
|
48
|
+
run: (inputs: AnyNode[], store: NodeStore, animate: boolean) => void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type LocalMeta = {
|
|
52
|
+
globalId: string;
|
|
53
|
+
disposeNode: () => void;
|
|
54
|
+
initialized: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class NodeReconciler {
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `resolveReference`
|
|
61
|
+
* When the dep has an allocated id but hasn't been saved yet, we return the original
|
|
62
|
+
* static value. `getNodeValue` is reactive, so the enclosing memo re-runs automatically
|
|
63
|
+
* once the dep is saved, upgrading the static reference to the live node.
|
|
64
|
+
*
|
|
65
|
+
* `blueprintIndex` maps each blueprint object to its local key, so finding the
|
|
66
|
+
* key that owns `item` is a hash lookup.
|
|
67
|
+
*/
|
|
68
|
+
private resolveReference(
|
|
69
|
+
item: any,
|
|
70
|
+
localKey: string,
|
|
71
|
+
localToGlobal: Map<string, LocalMeta>,
|
|
72
|
+
blueprintIndex: Map<object, string>,
|
|
73
|
+
store: NodeStore
|
|
74
|
+
): { node: any; missing: boolean } | null {
|
|
75
|
+
if (typeof item !== 'object' || item === null) return null;
|
|
76
|
+
|
|
77
|
+
const otherLocalKey = blueprintIndex.get(item);
|
|
78
|
+
if (otherLocalKey === undefined || otherLocalKey === localKey) return null;
|
|
79
|
+
const foundMeta = localToGlobal.get(otherLocalKey);
|
|
80
|
+
if (!foundMeta) return null;
|
|
81
|
+
|
|
82
|
+
const globalNode = store.getNodeValue(foundMeta.globalId);
|
|
83
|
+
if (globalNode) return { node: globalNode, missing: false };
|
|
84
|
+
|
|
85
|
+
// Not in store: either not-yet-saved (keep static) or deleted (cascade)
|
|
86
|
+
return { node: item, missing: foundMeta.initialized };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Index every top-level blueprint object by identity, so `resolveReference` is
|
|
91
|
+
* O(1). First-wins on key order.
|
|
92
|
+
*/
|
|
93
|
+
private buildBlueprintIndex(blueprint: Record<string, BlueprintNode> | null): Map<object, string> {
|
|
94
|
+
const index = new Map<object, string>();
|
|
95
|
+
if (!blueprint) return index;
|
|
96
|
+
for (const key in blueprint) {
|
|
97
|
+
const value = blueprint[key];
|
|
98
|
+
if (value && typeof value === 'object' && !index.has(value)) index.set(value, key);
|
|
99
|
+
}
|
|
100
|
+
return index;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Walks the node's properties and rewrites any static blueprint references to
|
|
105
|
+
* reactive lookups via `getNodeValue`. Mutates `transformedData` in place.
|
|
106
|
+
*
|
|
107
|
+
* Example: `ellipse_from_foci` yields an ellipse whose `center` is a static
|
|
108
|
+
* point object that is also a blueprint entry of its own. This rewrites it to
|
|
109
|
+
* the global reactive node for that point.
|
|
110
|
+
*/
|
|
111
|
+
private linkBlueprintReferences(
|
|
112
|
+
localKey: string,
|
|
113
|
+
transformedData: AnyNode,
|
|
114
|
+
localToGlobal: Map<string, LocalMeta>,
|
|
115
|
+
blueprintIndex: Map<object, string>,
|
|
116
|
+
store: NodeStore
|
|
117
|
+
): { hasMissingDependency: boolean } {
|
|
118
|
+
let hasMissingDependency = false;
|
|
119
|
+
|
|
120
|
+
for (const key in transformedData) {
|
|
121
|
+
const value = transformedData[key as keyof AnyNode];
|
|
122
|
+
if (typeof value !== 'object' || value === null) continue;
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
(transformedData as any)[key] = (value as any[]).map((item: any) => {
|
|
126
|
+
const resolved = this.resolveReference(item, localKey, localToGlobal, blueprintIndex, store);
|
|
127
|
+
if (!resolved) return item;
|
|
128
|
+
if (resolved.missing) hasMissingDependency = true;
|
|
129
|
+
return resolved.node;
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
const resolved = this.resolveReference(value, localKey, localToGlobal, blueprintIndex, store);
|
|
133
|
+
if (!resolved) continue;
|
|
134
|
+
if (resolved.missing) hasMissingDependency = true;
|
|
135
|
+
(transformedData as any)[key] = resolved.node;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { hasMissingDependency };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `hidden`, `fill` and `stroke` are set on every node the call makes: the main node and the others in the blueprint.
|
|
144
|
+
* An `undefined` input id is a left-out optional parameter: `compute` gets `undefined` for it.
|
|
145
|
+
*/
|
|
146
|
+
evaluate(func: Node2NodeFunc, inputNodeIds: (string | undefined)[], store: NodeStore, assignedId?: string, hidden = false, fill?: string, stroke?: string) {
|
|
147
|
+
createRoot((disposeRoot) => {
|
|
148
|
+
const localToGlobal = new Map<string, LocalMeta>();
|
|
149
|
+
|
|
150
|
+
// A compute error during recompute keeps the previous blueprint instead of
|
|
151
|
+
// escaping mid-propagation. It is reported only if it persists past the
|
|
152
|
+
// grace window.
|
|
153
|
+
let firstRunDone = false;
|
|
154
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
155
|
+
let lastErrorMessage = '';
|
|
156
|
+
onCleanup(() => { if (graceTimer !== undefined) clearTimeout(graceTimer); });
|
|
157
|
+
|
|
158
|
+
// The inputs the current blueprint was computed from. Set together with the
|
|
159
|
+
// blueprint, so a kept previous blueprint keeps its own inputs.
|
|
160
|
+
let blueprintInputs: AnyNode[] = [];
|
|
161
|
+
|
|
162
|
+
const blueprintMemo = createMemo<Record<string, BlueprintNode> | null>((prev) => {
|
|
163
|
+
const inputs = inputNodeIds.map(id => id === undefined ? undefined : store.getNodeValue(id));
|
|
164
|
+
if (inputs.some((node, i) => inputNodeIds[i] !== undefined && (!node || node.type === 'Dummy'))) return null;
|
|
165
|
+
try {
|
|
166
|
+
const blueprint = computeBlueprint(func, inputs as AnyNode[]);
|
|
167
|
+
blueprintInputs = inputs as AnyNode[];
|
|
168
|
+
if (graceTimer !== undefined) {
|
|
169
|
+
clearTimeout(graceTimer);
|
|
170
|
+
graceTimer = undefined;
|
|
171
|
+
}
|
|
172
|
+
return blueprint;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// First run happens at call time — let the caller report it.
|
|
175
|
+
if (!firstRunDone) throw err;
|
|
176
|
+
lastErrorMessage = err instanceof Error ? err.message : String(err);
|
|
177
|
+
if (graceTimer === undefined) {
|
|
178
|
+
graceTimer = setTimeout(() => {
|
|
179
|
+
graceTimer = undefined;
|
|
180
|
+
console.error(`${func.keyword}: ${lastErrorMessage}`);
|
|
181
|
+
}, TRANSIENT_ERROR_GRACE_MS);
|
|
182
|
+
}
|
|
183
|
+
return prev;
|
|
184
|
+
}
|
|
185
|
+
}, null);
|
|
186
|
+
firstRunDone = true;
|
|
187
|
+
|
|
188
|
+
this.reconcile(blueprintMemo, () => blueprintInputs, localToGlobal, func, assignedId, hidden, fill, stroke, store, disposeRoot);
|
|
189
|
+
}) // outer `createRoot` ends here
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private reconcile(
|
|
193
|
+
blueprintAccessor: () => Record<string, BlueprintNode> | null,
|
|
194
|
+
inputsAccessor: () => AnyNode[],
|
|
195
|
+
localToGlobal: Map<string, LocalMeta>,
|
|
196
|
+
func: Node2NodeFunc,
|
|
197
|
+
assignedId: string | undefined,
|
|
198
|
+
hidden: boolean,
|
|
199
|
+
fill: string | undefined,
|
|
200
|
+
stroke: string | undefined,
|
|
201
|
+
store: NodeStore,
|
|
202
|
+
disposeRoot: () => void
|
|
203
|
+
) {
|
|
204
|
+
// One identity index per blueprint value, shared by every node memo below.
|
|
205
|
+
const blueprintIndex = createMemo(() => this.buildBlueprintIndex(blueprintAccessor()));
|
|
206
|
+
|
|
207
|
+
createComputed(() => {
|
|
208
|
+
const blueprint = blueprintAccessor();
|
|
209
|
+
const currentKeys = blueprint ? new Set<string>(Object.keys(blueprint)) : new Set<string>();
|
|
210
|
+
|
|
211
|
+
// Phase A: create missing nodes
|
|
212
|
+
if (blueprint) {
|
|
213
|
+
const newlyAddedKeys: string[] = [];
|
|
214
|
+
for (const localKey of currentKeys) {
|
|
215
|
+
if (!localToGlobal.has(localKey)) {
|
|
216
|
+
const isMain = localKey === 'main';
|
|
217
|
+
const nodeData = blueprint[localKey];
|
|
218
|
+
const autoNamePrefix = store.nameGenerator.getPrefixForType(nodeData.type);
|
|
219
|
+
|
|
220
|
+
const globalId = isMain && assignedId ? assignedId : store.nameGenerator.generateAutoName(autoNamePrefix);
|
|
221
|
+
|
|
222
|
+
localToGlobal.set(localKey, { globalId, disposeNode: () => { }, initialized: false });
|
|
223
|
+
newlyAddedKeys.push(localKey);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Phase A2: build reactive roots and save each node to the global store.
|
|
228
|
+
batch(() => {
|
|
229
|
+
for (const localKey of newlyAddedKeys) {
|
|
230
|
+
const meta = localToGlobal.get(localKey)!;
|
|
231
|
+
const { globalId } = meta;
|
|
232
|
+
const isMain = localKey === 'main';
|
|
233
|
+
|
|
234
|
+
createRoot((disposeNode) => {
|
|
235
|
+
meta.disposeNode = disposeNode;
|
|
236
|
+
|
|
237
|
+
const nodeSignal = createMemo(() => {
|
|
238
|
+
const latestBlueprint = blueprintAccessor();
|
|
239
|
+
if (!latestBlueprint) return { id: globalId, type: 'Dummy' } as unknown as AnyNode;
|
|
240
|
+
const data = latestBlueprint[localKey];
|
|
241
|
+
// Guard against the reconciler not yet having disposed this node
|
|
242
|
+
// after the blueprint shrank.
|
|
243
|
+
if (!data) return { id: globalId, type: 'Dummy' } as unknown as AnyNode;
|
|
244
|
+
|
|
245
|
+
const transformedData = {
|
|
246
|
+
...data,
|
|
247
|
+
id: globalId,
|
|
248
|
+
hidden,
|
|
249
|
+
fill,
|
|
250
|
+
stroke,
|
|
251
|
+
__graph_deps: {
|
|
252
|
+
funcKeyword: func.keyword,
|
|
253
|
+
inputNodes: inputsAccessor(),
|
|
254
|
+
blueprintKey: localKey,
|
|
255
|
+
...(localKey !== 'main' ? { parentId: localToGlobal.get('main')?.globalId } : {})
|
|
256
|
+
}
|
|
257
|
+
} as AnyNode;
|
|
258
|
+
|
|
259
|
+
const { hasMissingDependency } = this.linkBlueprintReferences(
|
|
260
|
+
localKey, transformedData, localToGlobal, blueprintIndex(), store
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (hasMissingDependency) {
|
|
264
|
+
queueMicrotask(() => {
|
|
265
|
+
if (store.getNodeValue(globalId)) store.removeNode(globalId);
|
|
266
|
+
});
|
|
267
|
+
return { id: globalId, type: 'Dummy' } as unknown as AnyNode;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return transformedData;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const customDispose = () => {
|
|
274
|
+
disposeNode();
|
|
275
|
+
if (isMain) {
|
|
276
|
+
for (const [otherLocalKey, otherMeta] of localToGlobal.entries()) {
|
|
277
|
+
if (otherLocalKey !== localKey && store.getNodeValue(otherMeta.globalId)) {
|
|
278
|
+
store.removeNode(otherMeta.globalId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
disposeRoot();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
store.saveNode(globalId, nodeSignal, customDispose);
|
|
286
|
+
meta.initialized = true;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}); // `batch` ends here
|
|
290
|
+
} // `if (blueprint)` ends here
|
|
291
|
+
|
|
292
|
+
// Phase B: clean up removed nodes
|
|
293
|
+
for (const [localKey, meta] of localToGlobal.entries()) {
|
|
294
|
+
if (!currentKeys.has(localKey)) {
|
|
295
|
+
if (store.getNodeValue(meta.globalId)) {
|
|
296
|
+
store.removeNode(meta.globalId);
|
|
297
|
+
}
|
|
298
|
+
localToGlobal.delete(localKey);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!blueprint) {
|
|
303
|
+
disposeRoot();
|
|
304
|
+
}
|
|
305
|
+
}); // `createComputed` ends here
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Node2NodeFunc } from './Node2NodeFunc';
|
|
2
|
+
import { ArrayNode, TextNode } from '../state/Node';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The items of an array that a slice picks, for Python's `ps[0:2]` or `ps[:, 1]`.
|
|
6
|
+
* Hidden: `pie._index` is blocked, so it is only reached through `Array.__getitem__`.
|
|
7
|
+
* @param array - Array to slice
|
|
8
|
+
* @param key - JSON list with one entry per leading dimension: an index (the dimension is dropped) or a list of indices (the dimension is kept). Dimensions past the key are kept whole.
|
|
9
|
+
* @returns Array node holding the existing item nodes
|
|
10
|
+
* @auxiliaryNodes None
|
|
11
|
+
*/
|
|
12
|
+
export const Index: Node2NodeFunc = {
|
|
13
|
+
keyword: '_index', outputType: 'Array',
|
|
14
|
+
parameters: [{ name: 'array', type: 'Array' }, { name: 'key', type: 'Text' }],
|
|
15
|
+
compute(inputs) {
|
|
16
|
+
const array = inputs[0] as ArrayNode;
|
|
17
|
+
const key: (number | number[])[] = JSON.parse((inputs[1] as TextNode).value);
|
|
18
|
+
const picks = array.shape.map((size, d) => {
|
|
19
|
+
const entry = key[d];
|
|
20
|
+
if (entry === undefined) return [...Array(size).keys()];
|
|
21
|
+
return typeof entry === 'number' ? [entry] : entry;
|
|
22
|
+
});
|
|
23
|
+
const shape = picks.filter((_, d) => typeof key[d] !== 'number').map(pick => pick.length);
|
|
24
|
+
|
|
25
|
+
// Row-major positions in `array.items` of every picked item.
|
|
26
|
+
let positions = [0];
|
|
27
|
+
picks.forEach((pick, d) => {
|
|
28
|
+
positions = positions.flatMap(position => pick.map(i => position * array.shape[d] + i));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { main: { type: 'Array', itemType: array.itemType, shape, items: positions.map(p => array.items[p]) } };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Docs category for the functions defined in this file. */
|
|
36
|
+
export const category = 'Arrays';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Node2NodeFunc, PYTHON_KEYWORD_PREFIX } from './Node2NodeFunc';
|
|
2
|
+
import { getFunction } from './registry';
|
|
3
|
+
import { AnyNode, ScalarNode } from '../state/Node';
|
|
4
|
+
import { ref, Dtype } from '../jax';
|
|
5
|
+
import { Graph, replay, gradient } from '~/lib/autodiff';
|
|
6
|
+
import { computeBlueprint } from '../tensors/broadcast';
|
|
7
|
+
|
|
8
|
+
/** The number fields of each node type `partial` can differentiate with respect to. */
|
|
9
|
+
const NUMBER_FIELDS: Record<string, string[]> = { Scalar: ['value'], Point: ['x', 'y'] };
|
|
10
|
+
|
|
11
|
+
/** Nodes as an autodiff graph: each node carries its input nodes in `__graph_deps`. */
|
|
12
|
+
const nodeGraph: Graph<AnyNode> = {
|
|
13
|
+
id: (node) => node.id,
|
|
14
|
+
inputs: (node) => node.__graph_deps?.inputNodes,
|
|
15
|
+
recompute(node, inputs) {
|
|
16
|
+
const deps = node.__graph_deps!;
|
|
17
|
+
if (deps.funcKeyword.startsWith(PYTHON_KEYWORD_PREFIX)) {
|
|
18
|
+
throw new Error(`partial can't differentiate through Python function '${deps.funcKeyword.slice(PYTHON_KEYWORD_PREFIX.length)}'`);
|
|
19
|
+
}
|
|
20
|
+
const func = getFunction(deps.funcKeyword);
|
|
21
|
+
if (!func) throw new Error(`Unknown function: ${deps.funcKeyword}`);
|
|
22
|
+
const key = deps.blueprintKey ?? 'main';
|
|
23
|
+
const data = computeBlueprint(func, inputs)[key];
|
|
24
|
+
if (!data) throw new Error(`${deps.funcKeyword} gave no '${key}' output`);
|
|
25
|
+
return { ...data, id: node.id, __graph_deps: { ...deps, inputNodes: inputs } };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Partial derivative of a scalar with respect to a scalar or a point.
|
|
31
|
+
* Composes: the target may itself be a `partial`.
|
|
32
|
+
* @param target - Scalar to differentiate
|
|
33
|
+
* @param var - Scalar or Point to differentiate with respect to. Its own inputs are ignored.
|
|
34
|
+
* @returns Scalar (var is a Scalar) or PointGrad (var is a Point)
|
|
35
|
+
* @auxiliaryNodes None
|
|
36
|
+
*/
|
|
37
|
+
export const Partial: Node2NodeFunc = {
|
|
38
|
+
keyword: 'partial',
|
|
39
|
+
outputType: 'Scalar',
|
|
40
|
+
resolveOutputType: (inputs) => (inputs[1].type === 'Point' ? 'PointGrad' : 'Scalar'),
|
|
41
|
+
broadcastable: false,
|
|
42
|
+
parameters: [{ name: 'target', type: 'Scalar' }, { name: 'var', type: ['Scalar', 'Point'] }],
|
|
43
|
+
compute(inputs) {
|
|
44
|
+
const target = inputs[0] as ScalarNode;
|
|
45
|
+
const v = inputs[1];
|
|
46
|
+
if (v.id.startsWith('_literal_')) {
|
|
47
|
+
throw new Error('partial: var must be a node, not a number');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fields = NUMBER_FIELDS[v.type];
|
|
51
|
+
// The variable with new values and no inputs, so the replay stops at it.
|
|
52
|
+
const varAt = (values: Dtype[]) => ({
|
|
53
|
+
...v, ...Object.fromEntries(fields.map((field, i) => [field, values[i]])), __graph_deps: undefined,
|
|
54
|
+
}) as AnyNode;
|
|
55
|
+
const f = (...values: Dtype[]) => ref((replay(nodeGraph, target, v.id, varAt(values)) as ScalarNode).value);
|
|
56
|
+
const grads = gradient(f, fields.map(field => ref((v as any)[field])));
|
|
57
|
+
|
|
58
|
+
if (v.type === 'Scalar') return { main: { type: 'Scalar', value: grads[0] } };
|
|
59
|
+
return { main: { type: 'PointGrad', point: v, target, dx: grads[0], dy: grads[1] } };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Docs category for the functions defined in this file. */
|
|
64
|
+
export const category = 'Autodiff';
|