From 9881516bb73dde946149f9a5dc42fc2a86fad8d0 Mon Sep 17 00:00:00 2001 From: chelproc Date: Sat, 31 Oct 2026 10:51:38 +0900 Subject: [PATCH 1/6] feat(store): count circuit revisions in the store Add a revision counter to CCStore that increases on every change to the circuit (components, component pins, node registration and config, node pins, connections). Moving a node does not change the circuit, so it does not count. Anything derived from the circuit can be cached against this number. Extracted from 788b5c9 (WIP: netlist-based simulation). Co-Authored-By: Claude Opus 5.5 --- src/store/index.ts | 53 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 150b03a..fcf6621 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,10 +1,19 @@ import { CCStoreAutoSaver } from "./autoSaver"; -import { CCComponentStore } from "./component"; -import { CCComponentPinStore } from "./componentPin"; -import { CCConnectionStore } from "./connection"; +import { + CCComponentStore, + ccComponentStoreChangeEventTypes, +} from "./component"; +import { + CCComponentPinStore, + ccComponentPinStoreChangeEventTypes, +} from "./componentPin"; +import { + CCConnectionStore, + ccConnectionStoreChangeEventTypes, +} from "./connection"; import * as intrinsics from "./intrinsics/definitions"; import { CCNodeStore } from "./node"; -import { CCNodePinStore } from "./nodePin"; +import { CCNodePinStore, ccNodePinStoreChangeEventTypes } from "./nodePin"; import TransactionManager from "./transaction"; /** @@ -25,6 +34,17 @@ export default class CCStore { autoSaver: CCStoreAutoSaver; + #revision = 0; + + /** + * How many circuit changes the store has seen. Anything derived from the circuit, such + * as a simulation netlist, can be cached against this number and rebuilt once it + * changes. Moving a node does not change the circuit, so it does not count. + */ + get revision(): number { + return this.#revision; + } + /** * Constructor of CCStore * @param rootComponent root component @@ -45,6 +65,31 @@ export default class CCStore { this.componentPins.register(pin); } } + + const countRevision = () => { + this.#revision += 1; + }; + for (const eventType of ccComponentStoreChangeEventTypes) { + this.components.on(eventType, countRevision); + } + for (const eventType of ccComponentPinStoreChangeEventTypes) { + this.componentPins.on(eventType, countRevision); + } + // `didUpdate` of the node store also fires for position changes, which the circuit + // does not depend on, so only the config is watched here. + for (const eventType of [ + "didRegister", + "didUnregister", + "didUpdateConfig", + ] as const) { + this.nodes.on(eventType, countRevision); + } + for (const eventType of ccNodePinStoreChangeEventTypes) { + this.nodePins.on(eventType, countRevision); + } + for (const eventType of ccConnectionStoreChangeEventTypes) { + this.connections.on(eventType, countRevision); + } } mount() { From e5e1d64acb589a8fa244234b8a49db256ba3acb6 Mon Sep 17 00:00:00 2001 From: chelproc Date: Sat, 31 Oct 2026 10:52:08 +0900 Subject: [PATCH 2/6] feat(simulation): simulate a flattened netlist of the component Replace the recursive per-component evaluation with a netlist built from the store: the component hierarchy is flattened into intrinsic node instances, pins carrying the same value are grouped into nets, and the instances are sorted so that each one is evaluated after its drivers. Intrinsic components can now be marked as sequential. A sequential instance (the FlipFlop) derives its outputs from the previous time step, so it does not wait for its inputs, which lets a circuit contain a feedback loop through it, including one that runs through a user-defined component. A loop without one is reported as a SimulationCombinationalLoopError instead of hanging, and an unconnected input pin as a SimulationUnconnectedPinError. The netlist is cached against the store revision, and the editor keys its frame cache on the revision and the input values only. Extracted from 788b5c9 (WIP: netlist-based simulation). Co-Authored-By: Claude Opus 5.5 --- .../edit/Editor/store/slices/core/index.ts | 41 +- src/store/intrinsics/base.ts | 15 +- src/store/intrinsics/definitions.ts | 1 + src/store/netlist.ts | 523 ++++++++++++++++++ src/store/simulation.test.ts | 405 ++++++++++++++ src/store/simulation.ts | 402 +++++--------- src/store/testUtils.ts | 177 +++++- 7 files changed, 1284 insertions(+), 280 deletions(-) create mode 100644 src/store/netlist.ts create mode 100644 src/store/simulation.test.ts diff --git a/src/pages/edit/Editor/store/slices/core/index.ts b/src/pages/edit/Editor/store/slices/core/index.ts index c127bf5..f0aca6c 100644 --- a/src/pages/edit/Editor/store/slices/core/index.ts +++ b/src/pages/edit/Editor/store/slices/core/index.ts @@ -152,12 +152,10 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator< })); }, getNodePinValue(nodePinId: CCNodePinId): SimulationValue | undefined { - const { nodeId } = nullthrows(store.nodePins.get(nodePinId)); return nullthrows( - nullthrows(simulationCachedFrames[get().timeStep]).nodes.get( - nodeId, - ), - ).pins.get(nodePinId); + simulationCachedFrames[get().timeStep], + `Time step ${get().timeStep} has not been simulated`, + ).getNodePinValue(nodePinId); }, getComponentPinValue( componentPinId: CCComponentPinId, @@ -175,22 +173,14 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator< const executeSimulation = () => { if (editorStore.getState().editorMode !== "play") return; - const newSimulationCacheKey = - store.nodes - .getMany() - .map((node) => node.id) - .join() + - store.nodePins - .getMany() - .map((nodePin) => `${nodePin.id}_${nodePin.manualBitWidth || 0}`) - .join(",") + - store.connections - .getMany() - .map((connection) => connection.id) - .join() + - [...editorStore.getState().inputValues.entries()] - .map(([key, value]) => key + value.join()) - .join(); + // The netlist is cached against the revision of the store, so the key only + // has to cover that revision and the values fed to the input pins. + const newSimulationCacheKey = [ + store.revision, + ...[...editorStore.getState().inputValues.entries()].map( + ([key, value]) => `${key}${value.join()}`, + ), + ].join("|"); if (newSimulationCacheKey !== simulationCacheKey) { simulationCacheKey = newSimulationCacheKey; simulationCachedFrames = []; @@ -211,14 +201,15 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator< if (pin.type === "input") { inputValues.set( pin.id, - editorState.getInputValue({ componentPinId: pin.id, timeStep }), + editorState.getInputValue({ + componentPinId: pin.id, + timeStep, + }), ); } } simulationCachedFrames.push( - nullthrows( - simulateComponent(store, componentId, inputValues, previousFrame), - ), + simulateComponent(store, componentId, inputValues, previousFrame), ); isUpdated = true; } diff --git a/src/store/intrinsics/base.ts b/src/store/intrinsics/base.ts index d676c26..23b6159 100644 --- a/src/store/intrinsics/base.ts +++ b/src/store/intrinsics/base.ts @@ -3,7 +3,7 @@ import type { CCComponent, CCComponentId } from "../component"; import type { CCComponentPin, CCComponentPinId } from "../componentPin"; import type { CCNodeId } from "../node"; import type { CCNodePinId } from "../nodePin"; -import type { SimulationFrame } from "../simulation"; +import type { IntrinsicEvaluationFrame } from "../simulation"; import type { CCIntrinsicComponentSpec, CCIntrinsicComponentType, @@ -20,8 +20,8 @@ export type CCComponentPinInstanceShapes = { }[]; export type ComponentEvaluationContext = { - previousFrame: SimulationFrame | null; - currentFrame: SimulationFrame; + previousFrame: IntrinsicEvaluationFrame | null; + currentFrame: IntrinsicEvaluationFrame; defaultBitWidth: number; }; @@ -78,6 +78,12 @@ type Props = { out: Record>; initialConfig: Spec["config"]; evaluate: IntrinsicComponentEvaluationFunction; + /** + * Whether the outputs are derived from the previous time step instead of the current + * inputs. Such a component is what lets a circuit contain a feedback loop: the + * simulation can evaluate it before the values of its inputs are known. + */ + isSequential?: boolean; }; export class IntrinsicComponentDefinition< Spec extends CCIntrinsicComponentSpec = CCIntrinsicComponentSpec, @@ -91,6 +97,8 @@ export class IntrinsicComponentDefinition< readonly outputPin: Record; readonly initialConfig: Spec["config"]; readonly evaluate: IntrinsicComponentEvaluationFunction; + /** @see {@link Props.isSequential} */ + readonly isSequential: boolean; private static _lastIndex = 0; @@ -123,6 +131,7 @@ export class IntrinsicComponentDefinition< this._registerPin("output", key, attributes), ); this.initialConfig = props.initialConfig; + this.isSequential = props.isSequential ?? false; } private _registerPin( diff --git a/src/store/intrinsics/definitions.ts b/src/store/intrinsics/definitions.ts index 441f033..5ac838e 100644 --- a/src/store/intrinsics/definitions.ts +++ b/src/store/intrinsics/definitions.ts @@ -380,6 +380,7 @@ export const flipflop = in: { In: { name: "In", bitWidthPolicy: { type: "inferred" } } }, out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } }, initialConfig: null, + isSequential: true, evaluate: (context, nodeId, shape) => { const inputShape = shape.inputShape.In; invariant(inputShape[0] && !inputShape[1]); diff --git a/src/store/netlist.ts b/src/store/netlist.ts new file mode 100644 index 0000000..93fbca1 --- /dev/null +++ b/src/store/netlist.ts @@ -0,0 +1,523 @@ +/** + * Flattening a component for simulation: the hierarchy is resolved into the intrinsic + * nodes the component is built from, and the pins carrying the same value are grouped + * into nets. + * + * - A **terminal** is one pin of one node instance. A component can be used more than + * once, so the node pins inside it exist once per use and are only unique together + * with the path of node ids leading from the simulated component down to the node the + * pin belongs to. {@link simulationTerminalKey} builds the key `path#nodePinId`. + * - A **net** is the terminals that always carry the same value: the ones tied together + * by a connection or by a component boundary. + * - The **driver** is the one terminal of a net that produces the value, which is either + * an output pin of an intrinsic node or the output pin of an `Input` node of the + * simulated component itself (a value supplied from the outside). Every other terminal + * of the net only receives the value, so the driver identifies the net. + * + * In `Const -> Delay(D -> FlipFlop -> Q)`, the value of the constant is one net of four + * terminals: + * + * ``` + * Const#Out the driver, an output pin of an intrinsic node + * DelayNode#D the pin of the node using the component + * DelayNode/Input#Out the `Input` node inside the component + * DelayNode/FlipFlop#In the pin consuming the value + * ``` + * + * `Input` and `Output` nodes only carry a value across a component boundary, so they are + * resolved away while flattening and never become an instance to evaluate. + */ + +import nullthrows from "nullthrows"; +import invariant from "tiny-invariant"; +import type { Opaque } from "type-fest"; +import type CCStore from "."; +import type { CCComponentId } from "./component"; +import type { CCComponentPinId } from "./componentPin"; +import type { IntrinsicComponentDefinition } from "./intrinsics/base"; +import { definitionByComponentId } from "./intrinsics/definitions"; +import { ccIntrinsicComponentTypes } from "./intrinsics/types"; +import type { CCNode, CCNodeId } from "./node"; +import type { CCNodePinId } from "./nodePin"; + +/** Identity of a net: the key of the terminal driving it. */ +export type SimulationNetId = Opaque; + +/** Identity of a terminal: the path of its node instance plus its node pin. */ +export type SimulationTerminalKey = Opaque; + +/** + * Key of the pin `nodePinId` of the node instance at `path`. + * @param path node ids from the simulated component down to the node of the pin + * @param nodePinId id of the node pin + */ +export function simulationTerminalKey( + path: readonly CCNodeId[], + nodePinId: CCNodePinId, +): SimulationTerminalKey { + return `${path.join("/")}#${nodePinId}` as SimulationTerminalKey; +} + +/** One pin of an intrinsic node instance, resolved for simulation. */ +export type SimulationPinInstance = { + readonly nodePinId: CCNodePinId; + /** net the pin is attached to */ + readonly netId: SimulationNetId; + /** bit width of the pin in this instance */ + readonly bitWidth: number; +}; + +/** + * One intrinsic node, instantiated at a concrete place of the component hierarchy. + * `Input` and `Output` nodes are not instances: they only pass values across a + * component boundary, so they are resolved away while building the netlist. + */ +export type SimulationInstance = { + /** node ids from the simulated component down to this node */ + readonly path: readonly CCNodeId[]; + readonly nodeId: CCNodeId; + readonly definition: IntrinsicComponentDefinition; + readonly config: CCNode["config"]; + /** bit width used for the pins of this instance whose width is not fixed */ + readonly defaultBitWidth: number; + /** pins grouped by the pin key of the definition (e.g. `A`, `In`), ordered by pin order */ + readonly inputShape: Record; + readonly outputShape: Record; + /** whether the outputs are derived from the previous time step instead of the inputs */ + readonly isSequential: boolean; +}; + +/** + * The flattened form of a component: every intrinsic node of the component and of the + * components it uses, in an order where each instance is evaluated after the instances + * driving its inputs. + */ +export type SimulationNetlist = { + readonly componentId: CCComponentId; + /** revision of the store this netlist was built from */ + readonly revision: number; + readonly instances: readonly SimulationInstance[]; + /** net driven by each input pin of the simulated component */ + readonly netByInputComponentPin: ReadonlyMap< + CCComponentPinId, + SimulationNetId + >; + /** net of each node pin of the nodes of the simulated component itself */ + readonly netByNodePin: ReadonlyMap; + /** + * Net of the pins that were resolved while flattening: every pin of the nodes of the + * simulated component and of the intrinsic instances inside it, plus the pins of the + * `Input`, `Output` and component nodes on the way between them. + */ + readonly netByTerminal: ReadonlyMap; +}; + +/** + * Thrown when a value would have to be known before it can be computed. Such a loop + * can only be resolved by a node that holds its value for one time step, so the fix is + * to insert a `FlipFlop` somewhere along the reported path. + */ +export class SimulationCombinationalLoopError extends Error { + constructor(readonly loop: readonly string[]) { + super( + `Combinational loop detected: ${loop.join(" -> ")}. Insert a FlipFlop to break it.`, + ); + this.name = "SimulationCombinationalLoopError"; + } +} + +/** Thrown when an input pin has nothing connected to it. */ +export class SimulationUnconnectedPinError extends Error { + constructor( + readonly nodeId: CCNodeId, + readonly nodePinId: CCNodePinId, + ) { + super(`Input pin ${nodePinId} of node ${nodeId} is not connected.`); + this.name = "SimulationUnconnectedPinError"; + } +} + +/** + * The bit width the pins of a node instance fall back to when their own width is not + * fixed. It is inherited from the pin of the node whose width the surrounding circuit + * has already fixed, which is how a component written for one bit width can be reused + * for another. + */ +function resolveInstanceDefaultBitWidth( + store: CCStore, + node: CCNode, + parentDefaultBitWidth: number, +): number { + for (const nodePin of store.nodePins.getManyByNodeId(node.id)) { + const componentPinBitWidthStatus = + store.componentPins.getComponentPinBitWidthStatus(nodePin.componentPinId); + if ( + !componentPinBitWidthStatus.isFixed && + componentPinBitWidthStatus.fixMode === "automatic" + ) { + const nodePinBitWidthStatus = store.nodePins.getNodePinBitWidthStatus( + nodePin.id, + ); + if (nodePinBitWidthStatus.isFixed) { + return nodePinBitWidthStatus.bitWidth; + } + } + } + return parentDefaultBitWidth; +} + +/** + * Flatten `componentId` into intrinsic node instances and the nets between them. + * @param store store to read the circuit from + * @param componentId component to flatten + * @param defaultBitWidth bit width of the pins of the component whose width is not fixed + * @returns the flattened netlist, ready to be evaluated time step by time step + */ +export function buildSimulationNetlist( + store: CCStore, + componentId: CCComponentId, + defaultBitWidth = 1, +): SimulationNetlist { + const netByTerminal = new Map(); + const instances: SimulationInstance[] = []; + + /** + * Walk from a pin to the pin driving it, hopping over the `Input` and `Output` nodes + * and the component nodes in between, and remember the net for every pin on the way. + */ + const resolveNet = ( + startPath: readonly CCNodeId[], + startNodePinId: CCNodePinId, + ): SimulationNetId => { + const visited: SimulationTerminalKey[] = []; + const visitedKeys = new Set(); + /** the nodes walked through so far, for the error message of a loop */ + const visitedNodes: string[] = []; + let path = startPath; + let nodePinId = startNodePinId; + let netId: SimulationNetId | null = null; + while (netId === null) { + const terminal = simulationTerminalKey(path, nodePinId); + const known = netByTerminal.get(terminal); + if (known) { + netId = known; + break; + } + if (visitedKeys.has(terminal)) { + throw new SimulationCombinationalLoopError(visitedNodes); + } + visited.push(terminal); + visitedKeys.add(terminal); + + const nodePin = nullthrows(store.nodePins.get(nodePinId)); + const node = nullthrows(store.nodes.get(nodePin.nodeId)); + const componentPin = nullthrows( + store.componentPins.get(nodePin.componentPinId), + ); + const component = nullthrows(store.components.get(node.componentId)); + const walkedNode = `${component.name}(${path.join("/")})`; + if (visitedNodes.at(-1) !== walkedNode) visitedNodes.push(walkedNode); + + if (componentPin.type === "input") { + // An input pin is driven by a connection inside the component it sits in + const connection = store.connections + .getConnectionsByNodePinId(nodePinId) + .find((candidate) => candidate.to === nodePinId); + if (!connection) { + throw new SimulationUnconnectedPinError(node.id, nodePinId); + } + const sourceNodePin = nullthrows(store.nodePins.get(connection.from)); + path = [...path.slice(0, -1), sourceNodePin.nodeId]; + nodePinId = sourceNodePin.id; + continue; + } + + if ( + component.intrinsicType === ccIntrinsicComponentTypes.INPUT && + path.length > 1 + ) { + // Fed from the pin of the node that uses the component this node sits in + const boundaryPin = nullthrows( + store.componentPins.getByImplementation(nodePinId), + `Input node ${node.id} does not expose a component pin`, + ); + const outerNodeId = nullthrows(path[path.length - 2]); + const outerNodePin = nullthrows( + store.nodePins + .getManyByNodeId(outerNodeId) + .find((candidate) => candidate.componentPinId === boundaryPin.id), + `Node ${outerNodeId} is missing the pin of ${boundaryPin.id}`, + ); + path = path.slice(0, -1); + nodePinId = outerNodePin.id; + continue; + } + + if (!component.intrinsicType) { + // Driven from inside the component, by the node implementing the pin + const implementation = nullthrows( + componentPin.implementation, + `Pin ${componentPin.id} of a user-defined component has no implementation`, + ); + const innerNodePin = nullthrows(store.nodePins.get(implementation)); + path = [...path, innerNodePin.nodeId]; + nodePinId = innerNodePin.id; + continue; + } + + // An output pin of an intrinsic node, or of an `Input` node of the simulated + // component itself: the value originates here, so this pin drives the net. + netId = terminal as string as SimulationNetId; + } + for (const terminal of visited) netByTerminal.set(terminal, netId); + return netId; + }; + + /** Resolve the pins of one intrinsic node instance, grouped by definition pin key. */ + const resolveShape = ( + path: readonly CCNodeId[], + nodeId: CCNodeId, + pinByKey: Record, + instanceDefaultBitWidth: number, + ): Record => { + const shape: Record = {}; + for (const [key, componentPin] of Object.entries(pinByKey)) { + shape[key] = store.nodePins + .getManyByNodeIdAndComponentPinId(nodeId, componentPin.id) + .toSorted((a, b) => a.order - b.order) + .map((nodePin) => { + const bitWidthStatus = store.nodePins.getNodePinBitWidthStatus( + nodePin.id, + ); + return { + nodePinId: nodePin.id, + netId: resolveNet(path, nodePin.id), + bitWidth: bitWidthStatus.isFixed + ? bitWidthStatus.bitWidth + : instanceDefaultBitWidth, + }; + }); + } + return shape; + }; + + const visit = ( + path: readonly CCNodeId[], + ownerComponentId: CCComponentId, + instanceDefaultBitWidth: number, + componentStack: readonly CCComponentId[], + ) => { + invariant( + !componentStack.includes(ownerComponentId), + `Component ${ownerComponentId} contains itself`, + ); + for (const node of store.nodes.getManyByParentComponentId( + ownerComponentId, + )) { + const nodePath = [...path, node.id]; + for (const nodePin of store.nodePins.getManyByNodeId(node.id)) { + const componentPin = nullthrows( + store.componentPins.get(nodePin.componentPinId), + ); + if ( + componentPin.type === "input" && + store.connections.getConnectionsByNodePinId(nodePin.id).length === 0 + ) { + throw new SimulationUnconnectedPinError(node.id, nodePin.id); + } + } + + const component = nullthrows(store.components.get(node.componentId)); + if (!component.intrinsicType) { + visit( + nodePath, + node.componentId, + resolveInstanceDefaultBitWidth(store, node, instanceDefaultBitWidth), + [...componentStack, ownerComponentId], + ); + continue; + } + if ( + component.intrinsicType === ccIntrinsicComponentTypes.INPUT || + component.intrinsicType === ccIntrinsicComponentTypes.OUTPUT + ) { + continue; // resolved away as part of the nets crossing the boundary + } + + const definition = nullthrows( + definitionByComponentId.get(node.componentId), + `Intrinsic component ${node.componentId} has no definition`, + ); + instances.push({ + path: nodePath, + nodeId: node.id, + definition, + config: node.config, + defaultBitWidth: instanceDefaultBitWidth, + inputShape: resolveShape( + nodePath, + node.id, + definition.inputPin, + instanceDefaultBitWidth, + ), + outputShape: resolveShape( + nodePath, + node.id, + definition.outputPin, + instanceDefaultBitWidth, + ), + isSequential: definition.isSequential, + }); + } + }; + visit([], componentId, defaultBitWidth, []); + + // Nets of the pins the editor renders, and of the values supplied from the outside + const netByNodePin = new Map(); + for (const node of store.nodes.getManyByParentComponentId(componentId)) { + for (const nodePin of store.nodePins.getManyByNodeId(node.id)) { + netByNodePin.set(nodePin.id, resolveNet([node.id], nodePin.id)); + } + } + const netByInputComponentPin = new Map(); + for (const componentPin of store.componentPins.getManyByComponentId( + componentId, + )) { + if (componentPin.type !== "input") continue; + const implementation = nullthrows( + componentPin.implementation, + `Pin ${componentPin.id} of a simulated component has no implementation`, + ); + const nodePin = nullthrows(store.nodePins.get(implementation)); + netByInputComponentPin.set( + componentPin.id, + resolveNet([nodePin.nodeId], nodePin.id), + ); + } + + return { + componentId, + revision: store.revision, + instances: sortInstancesByDependency(instances), + netByInputComponentPin, + netByNodePin, + netByTerminal, + }; +} + +/** Every pin of a shape, with the grouping by pin key flattened away. */ +export function simulationPinsOfShape( + shape: Record, +): SimulationPinInstance[] { + return Object.values(shape).flat(); +} + +function describeInstance(instance: SimulationInstance): string { + return `${instance.definition.name}(${instance.path.join("/")})`; +} + +/** + * Order the instances so that each one comes after the instances driving its inputs. + * A sequential instance derives its outputs from the previous time step, so it does not + * wait for its own inputs and is what allows a circuit to contain a loop at all. + * @throws {SimulationCombinationalLoopError} if a loop contains no sequential instance + */ +function sortInstancesByDependency( + instances: readonly SimulationInstance[], +): SimulationInstance[] { + const driverByNet = new Map(); + for (const [index, instance] of instances.entries()) { + for (const pin of simulationPinsOfShape(instance.outputShape)) { + driverByNet.set(pin.netId, index); + } + } + + /** indices of the instances waiting for each net, once per waiting pin */ + const consumersByNet = new Map(); + const successors = new Map(); + const remainingDependencies = instances.map(() => 0); + const appendTo = (map: Map, key: Key, value: number) => { + const values = map.get(key); + if (values) values.push(value); + else map.set(key, [value]); + }; + for (const [index, instance] of instances.entries()) { + // A sequential instance answers with the value of the previous time step, so it + // does not wait for the instances driving its inputs. That is what makes a loop + // containing one resolvable. + if (instance.isSequential) continue; + for (const pin of simulationPinsOfShape(instance.inputShape)) { + const driver = driverByNet.get(pin.netId); + if (driver === undefined) continue; // supplied from outside the component + appendTo(consumersByNet, pin.netId, index); + appendTo(successors, driver, index); + remainingDependencies[index] = + nullthrows(remainingDependencies[index]) + 1; + } + } + + const sorted: SimulationInstance[] = []; + const queue = instances + .map((_, index) => index) + .filter((index) => remainingDependencies[index] === 0); + const settled = new Set(queue); + while (queue.length > 0) { + const index = nullthrows(queue.shift()); + const instance = nullthrows(instances[index]); + sorted.push(instance); + for (const pin of simulationPinsOfShape(instance.outputShape)) { + for (const consumer of consumersByNet.get(pin.netId) ?? []) { + const remaining = nullthrows(remainingDependencies[consumer]) - 1; + remainingDependencies[consumer] = remaining; + if (remaining === 0 && !settled.has(consumer)) { + settled.add(consumer); + queue.push(consumer); + } + } + } + } + + if (sorted.length !== instances.length) { + const stuck = new Set( + instances.map((_, index) => index).filter((index) => !settled.has(index)), + ); + throw new SimulationCombinationalLoopError( + findLoop(successors, stuck).map((index) => + describeInstance(nullthrows(instances[index])), + ), + ); + } + return sorted; +} + +/** Find one loop among the instances that could not be ordered, for the error message. */ +function findLoop( + successors: ReadonlyMap, + stuck: ReadonlySet, +): number[] { + const state = new Map(); + const stack: number[] = []; + const visit = (index: number): number[] | null => { + state.set(index, "visiting"); + stack.push(index); + for (const next of successors.get(index) ?? []) { + if (!stuck.has(next)) continue; + if (state.get(next) === "visiting") { + return [...stack.slice(stack.indexOf(next)), next]; + } + if (!state.has(next)) { + const found = visit(next); + if (found) return found; + } + } + stack.pop(); + state.set(index, "done"); + return null; + }; + for (const index of stuck) { + if (state.has(index)) continue; + const found = visit(index); + if (found) return found; + } + return [...stuck].slice(0, 1); +} diff --git a/src/store/simulation.test.ts b/src/store/simulation.test.ts new file mode 100644 index 0000000..c1a3102 --- /dev/null +++ b/src/store/simulation.test.ts @@ -0,0 +1,405 @@ +import nullthrows from "nullthrows"; +import { describe, expect, it } from "vitest"; +import type CCStore from "."; +import { and, const_, flipflop, not } from "./intrinsics/definitions"; +import { + SimulationCombinationalLoopError, + SimulationUnconnectedPinError, +} from "./netlist"; +import simulateComponent, { type SimulationFrame } from "./simulation"; +import { + connectTestNodePins, + createTestStore, + registerTestComponent, + registerTestInputNode, + registerTestNode, + registerTestOutputNode, + testBits, + testComponentPin, + testNodePin, + testNumber, +} from "./testUtils"; + +/** `D` -> flip-flop -> `Q`: a component that holds its input for one time step. */ +function registerDelayComponent(store: CCStore) { + const component = registerTestComponent(store, "Delay"); + const d = registerTestInputNode(store, component.id, "D"); + const ff = registerTestNode(store, component.id, flipflop.component.id); + const q = registerTestOutputNode(store, component.id, "Q"); + connectTestNodePins( + store, + component.id, + d.out, + testNodePin(store, ff, flipflop.inputPin.In), + ); + connectTestNodePins( + store, + component.id, + testNodePin(store, ff, flipflop.outputPin.Out), + q.in, + ); + return { + component, + ff, + d: testComponentPin(store, component, "D"), + q: testComponentPin(store, component, "Q"), + }; +} + +/** `In` -> `Out`: a component that only passes a value through. */ +function registerBufferComponent(store: CCStore) { + const component = registerTestComponent(store, "Buffer"); + const into = registerTestInputNode(store, component.id, "In"); + const out = registerTestOutputNode(store, component.id, "Out"); + connectTestNodePins(store, component.id, into.out, out.in); + return { + component, + into: testComponentPin(store, component, "In"), + out: testComponentPin(store, component, "Out"), + }; +} + +/** Simulate `steps` time steps in a row, feeding the same input values to each. */ +function simulateSteps( + store: CCStore, + componentId: Parameters[1], + inputValues: Parameters[2], + steps: number, +): SimulationFrame[] { + const frames: SimulationFrame[] = []; + let previous: SimulationFrame | null = null; + for (let timeStep = 0; timeStep < steps; timeStep += 1) { + previous = simulateComponent(store, componentId, inputValues, previous); + frames.push(previous); + } + return frames; +} + +describe("simulateComponent", () => { + it("evaluates a combinational circuit", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const a = registerTestInputNode(store, root.id, "A"); + const b = registerTestInputNode(store, root.id, "B"); + const andNode = registerTestNode(store, root.id, and.component.id); + const notNode = registerTestNode(store, root.id, not.component.id); + const out = registerTestOutputNode(store, root.id, "Out"); + connectTestNodePins( + store, + root.id, + a.out, + testNodePin(store, andNode, and.inputPin.A), + ); + connectTestNodePins( + store, + root.id, + b.out, + testNodePin(store, andNode, and.inputPin.B), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, andNode, and.outputPin.Out), + testNodePin(store, notNode, not.inputPin.In), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, notNode, not.outputPin.Out), + out.in, + ); + + const nand = (left: boolean, right: boolean) => + simulateComponent( + store, + root.id, + new Map([ + [a.componentPinId, [left]], + [b.componentPinId, [right]], + ]), + ).getNodePinValue(out.in); + expect(nand(true, true)).toEqual([false]); + expect(nand(true, false)).toEqual([true]); + expect(nand(false, false)).toEqual([true]); + }); + + it("simulates a loop that is broken by a flip-flop inside a component", () => { + // Delay.Q -> Not -> Delay.D. The input of the component depends on its own output, + // which is only resolvable because the flip-flop inside it needs no input value. + const store = createTestStore(); + const delay = registerDelayComponent(store); + const root = registerTestComponent(store, "Root"); + const delayNode = registerTestNode(store, root.id, delay.component.id); + const notNode = registerTestNode(store, root.id, not.component.id); + const q = testNodePin(store, delayNode, delay.q); + connectTestNodePins( + store, + root.id, + q, + testNodePin(store, notNode, not.inputPin.In), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, notNode, not.outputPin.Out), + testNodePin(store, delayNode, delay.d), + ); + + const frames = simulateSteps(store, root.id, new Map(), 4); + expect(frames.map((frame) => frame.getNodePinValue(q))).toEqual([ + [false], + [true], + [false], + [true], + ]); + }); + + it("delays by one time step per flip-flop of a chain", () => { + const store = createTestStore(); + const delay = registerDelayComponent(store); + const root = registerTestComponent(store, "Root"); + const source = registerTestInputNode(store, root.id, "In"); + const first = registerTestNode(store, root.id, delay.component.id); + const second = registerTestNode(store, root.id, delay.component.id); + connectTestNodePins( + store, + root.id, + source.out, + testNodePin(store, first, delay.d), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, first, delay.q), + testNodePin(store, second, delay.d), + ); + const secondQ = testNodePin(store, second, delay.q); + + const frames = simulateSteps( + store, + root.id, + new Map([[source.componentPinId, [true]]]), + 4, + ); + expect(frames.map((frame) => frame.getNodePinValue(secondQ))).toEqual([ + [false], + [false], + [true], + [true], + ]); + }); + + it("keeps the state of two nodes of the same component apart", () => { + const store = createTestStore(); + const delay = registerDelayComponent(store); + const root = registerTestComponent(store, "Root"); + const a = registerTestInputNode(store, root.id, "A"); + const b = registerTestInputNode(store, root.id, "B"); + const first = registerTestNode(store, root.id, delay.component.id); + const second = registerTestNode(store, root.id, delay.component.id); + connectTestNodePins( + store, + root.id, + a.out, + testNodePin(store, first, delay.d), + ); + connectTestNodePins( + store, + root.id, + b.out, + testNodePin(store, second, delay.d), + ); + + const frames = simulateSteps( + store, + root.id, + new Map([ + [a.componentPinId, [true]], + [b.componentPinId, [false]], + ]), + 2, + ); + const lastFrame = nullthrows(frames[1]); + expect( + lastFrame.getNodePinValue(testNodePin(store, first, delay.q)), + ).toEqual([true]); + expect( + lastFrame.getNodePinValue(testNodePin(store, second, delay.q)), + ).toEqual([false]); + // the same flip-flop inside the two nodes holds two different values + const flipFlopOut = testNodePin(store, delay.ff, flipflop.outputPin.Out); + expect( + lastFrame.getNodePinValueAt([first.id, delay.ff.id], flipFlopOut), + ).toEqual([true]); + expect( + lastFrame.getNodePinValueAt([second.id, delay.ff.id], flipFlopOut), + ).toEqual([false]); + }); + + it("takes the bit width of a component node over to the nodes inside it", () => { + const store = createTestStore(); + const delay = registerDelayComponent(store); + const root = registerTestComponent(store, "Root"); + const constant = registerTestNode(store, root.id, const_.component.id, { + mode: "binary", + data: testBits(0b10100101, 8), + }); + const delayNode = registerTestNode(store, root.id, delay.component.id); + connectTestNodePins( + store, + root.id, + testNodePin(store, constant, const_.outputPin.Out), + testNodePin(store, delayNode, delay.d), + ); + const q = testNodePin(store, delayNode, delay.q); + + const frames = simulateSteps(store, root.id, new Map(), 2); + // The width of the flip-flop inside the component is not fixed by the component + // itself, so it has to be inherited from the node using it. + expect(nullthrows(frames[0]).getNodePinValue(q)).toEqual(testBits(0, 8)); + expect(testNumber(nullthrows(frames[1]).getNodePinValue(q))).toBe( + 0b10100101, + ); + }); + + it("reports a combinational loop instead of hanging", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const first = registerTestNode(store, root.id, not.component.id); + const second = registerTestNode(store, root.id, not.component.id); + connectTestNodePins( + store, + root.id, + testNodePin(store, first, not.outputPin.Out), + testNodePin(store, second, not.inputPin.In), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, second, not.outputPin.Out), + testNodePin(store, first, not.inputPin.In), + ); + + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + SimulationCombinationalLoopError, + ); + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + /Insert a FlipFlop/, + ); + }); + + it("reports a combinational loop that runs through components", () => { + const store = createTestStore(); + const buffer = registerBufferComponent(store); + const root = registerTestComponent(store, "Root"); + const first = registerTestNode(store, root.id, buffer.component.id); + const second = registerTestNode(store, root.id, buffer.component.id); + connectTestNodePins( + store, + root.id, + testNodePin(store, first, buffer.out), + testNodePin(store, second, buffer.into), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, second, buffer.out), + testNodePin(store, first, buffer.into), + ); + + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + SimulationCombinationalLoopError, + ); + }); + + it("reports an input pin that is not connected", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const andNode = registerTestNode(store, root.id, and.component.id); + const out = registerTestOutputNode(store, root.id, "Out"); + connectTestNodePins( + store, + root.id, + testNodePin(store, andNode, and.outputPin.Out), + out.in, + ); + + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + SimulationUnconnectedPinError, + ); + }); + + it("picks up a change of the circuit", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const constant = registerTestNode(store, root.id, const_.component.id, { + mode: "binary", + data: testBits(0b0011, 4), + }); + const out = registerTestOutputNode(store, root.id, "Out"); + connectTestNodePins( + store, + root.id, + testNodePin(store, constant, const_.outputPin.Out), + out.in, + ); + expect( + testNumber( + simulateComponent(store, root.id, new Map()).getNodePinValue(out.in), + ), + ).toBe(0b0011); + + // a new value of the constant has to reach the next simulation + store.nodes.update(constant.id, { + config: { mode: "binary", data: testBits(0b1010, 4) }, + }); + expect( + testNumber( + simulateComponent(store, root.id, new Map()).getNodePinValue(out.in), + ), + ).toBe(0b1010); + + // and so has a new node + registerTestNode(store, root.id, and.component.id); + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + SimulationUnconnectedPinError, + ); + }); + + it("refuses to continue a frame simulated before a change", () => { + // The values of a frame are keyed by the nets of the netlist it was simulated + // from, so continuing from it after a change would restore the state of the + // flip-flops from nets that may no longer mean the same thing. + const store = createTestStore(); + const delay = registerDelayComponent(store); + const root = registerTestComponent(store, "Root"); + const source = registerTestInputNode(store, root.id, "In"); + const delayNode = registerTestNode(store, root.id, delay.component.id); + connectTestNodePins( + store, + root.id, + source.out, + testNodePin(store, delayNode, delay.d), + ); + const inputValues = new Map([[source.componentPinId, [true]]]); + + const frame = simulateComponent(store, root.id, inputValues); + // the circuit stays valid, so only the revision stands in the way + const observer = registerTestOutputNode(store, root.id, "Out"); + connectTestNodePins( + store, + root.id, + testNodePin(store, delayNode, delay.q), + observer.in, + ); + expect(() => simulateComponent(store, root.id, inputValues, frame)).toThrow( + /another revision/, + ); + // without the stale frame the changed circuit simulates as usual + expect( + simulateComponent(store, root.id, inputValues).getNodePinValue( + testNodePin(store, delayNode, delay.q), + ), + ).toEqual([false]); + }); +}); diff --git a/src/store/simulation.ts b/src/store/simulation.ts index d17e82f..e584a47 100644 --- a/src/store/simulation.ts +++ b/src/store/simulation.ts @@ -1,294 +1,194 @@ -import nullthrows from "nullthrows"; import invariant from "tiny-invariant"; import type CCStore from "."; import type { CCComponentId } from "./component"; -import type { CCComponentPin, CCComponentPinId } from "./componentPin"; -import type { - CCComponentPinInstanceShapes, - CCIntrinsicComponentShape, - ComponentEvaluationContext, -} from "./intrinsics/base"; -import { definitionByComponentId } from "./intrinsics/definitions"; -import type { CCIntrinsicComponentSpec } from "./intrinsics/types"; +import type { CCComponentPinId } from "./componentPin"; +import { + buildSimulationNetlist, + type SimulationInstance, + type SimulationNetId, + type SimulationNetlist, + simulationPinsOfShape, + simulationTerminalKey, +} from "./netlist"; import type { CCNodeId } from "./node"; -import type { CCNodePin, CCNodePinId } from "./nodePin"; +import type { CCNodePinId } from "./nodePin"; export type SimulationValue = boolean[]; -export type SimulationFrame = { +/** + * The pin values of a single node instance, shaped like a frame of a whole component so + * that the evaluation function of an intrinsic component does not have to know how the + * circuit is flattened for simulation. + */ +export type IntrinsicEvaluationFrame = { componentId: CCComponentId; nodes: Map< CCNodeId, { pins: Map; - /** null if intrinsic */ - child: SimulationFrame | null; + /** always null: a flattened instance has no frame of its own */ + child: IntrinsicEvaluationFrame | null; } >; }; -function createShape( - store: CCStore, - nodeId: CCNodeId, - pin: Record, - context: ComponentEvaluationContext, -): Record { - const node = nullthrows(store.nodes.get(nodeId)); - const { componentId } = node; - const nodePins = store.nodePins.getManyByNodeId(nodeId); - const componentDefinition = definitionByComponentId.get(componentId); - invariant(componentDefinition); - const shape: Record = {}; - for (const key in pin) { - const componentPin = nullthrows(pin[key]); - const inputNodePins = nodePins.filter( - (nodePin: CCNodePin) => componentPin.id === nodePin.componentPinId, - ); - inputNodePins.sort((a, b) => a.order - b.order); - shape[key] = []; - for (const nodePin of inputNodePins) { - const bitWidthStatus = store.nodePins.getNodePinBitWidthStatus( - nodePin.id, - ); - const bitWidth = !bitWidthStatus.isFixed - ? context.defaultBitWidth - : bitWidthStatus.bitWidth; - shape[key].push({ nodePinId: nodePin.id, bitWidth }); - } +/** + * The values of one time step. Every pin that carries the same value shares one net, so + * a frame stores one value per net and resolves pins through the netlist. + */ +export class SimulationFrame { + constructor( + readonly netlist: SimulationNetlist, + readonly netValues: ReadonlyMap, + ) {} + + get componentId(): CCComponentId { + return this.netlist.componentId; } - return shape; -} - -function createIntrinsicComponentShape( - store: CCStore, - nodeId: CCNodeId, - context: ComponentEvaluationContext, -): CCIntrinsicComponentShape { - const node = nullthrows(store.nodes.get(nodeId)); - const { componentId } = node; - const componentDefinition = definitionByComponentId.get(componentId); - invariant(componentDefinition); - const inputPin = componentDefinition.inputPin; - const outputPin = componentDefinition.outputPin; - const inputShape = createShape(store, nodeId, inputPin, context); - const outputShape = createShape(store, nodeId, outputPin, context); - - return { inputShape, outputShape }; -} - -function simulateIntrinsic( - store: CCStore, - nodeId: CCNodeId, - context: ComponentEvaluationContext, -): boolean { - const node = nullthrows(store.nodes.get(nodeId)); - const { componentId } = node; - const componentDefinition = definitionByComponentId.get(componentId); - invariant(componentDefinition); - const shape = createIntrinsicComponentShape(store, nodeId, context); - return componentDefinition.evaluate(context, nodeId, shape, node.config); -} -function simulateNode( - store: CCStore, - nodeId: CCNodeId, - context: ComponentEvaluationContext, -): boolean { - const node = nullthrows(store.nodes.get(nodeId)); - const component = store.components.get(node.componentId); - if (!component) throw new Error(`Component ${component} is not defined.`); - if (component.intrinsicType) { - return simulateIntrinsic(store, nodeId, context); + /** + * Value at a pin of a node of the simulated component. + * @param nodePinId id of the node pin + * @returns the value, or undefined if the pin carries no value in this time step + */ + getNodePinValue(nodePinId: CCNodePinId): SimulationValue | undefined { + const netId = this.netlist.netByNodePin.get(nodePinId); + return netId === undefined ? undefined : this.netValues.get(netId); } - const nodePins = store.nodePins.getManyByNodeId(nodeId); - const outerNodePinValues: Map = nullthrows( - context.currentFrame.nodes.get(nodeId)?.pins, - ); - const inputValues: Map = new Map(); - // check all input values are in context - for (const outerNodePin of nodePins) { - const componentPin = nullthrows( - store.componentPins.get(outerNodePin.componentPinId), + /** + * Value at a pin of a node inside one of the components the simulated component uses. + * @param path node ids from the simulated component down to the node of the pin + * @param nodePinId id of the node pin + */ + getNodePinValueAt( + path: readonly CCNodeId[], + nodePinId: CCNodePinId, + ): SimulationValue | undefined { + const netId = this.netlist.netByTerminal.get( + simulationTerminalKey(path, nodePinId), ); - if (componentPin.type === "input") { - if (!outerNodePinValues.has(outerNodePin.id)) { - return false; - } - inputValues.set( - componentPin.id, - nullthrows(outerNodePinValues.get(outerNodePin.id)), - ); - } + return netId === undefined ? undefined : this.netValues.get(netId); } +} - let childDefaultBitWidth = context.defaultBitWidth; - for (const nodePin of nodePins) { - const componentPin = nullthrows( - store.componentPins.get(nodePin.componentPinId), - ); - const componentPinBitWidthStatus = - store.componentPins.getComponentPinBitWidthStatus(componentPin.id); - if ( - !componentPinBitWidthStatus.isFixed && - componentPinBitWidthStatus.fixMode === "automatic" - ) { - const nodePinBitWidthStatus = store.nodePins.getNodePinBitWidthStatus( - nodePin.id, - ); - if (nodePinBitWidthStatus.isFixed) { - childDefaultBitWidth = nodePinBitWidthStatus.bitWidth; - break; - } - } +const netlistCacheByStore = new WeakMap< + CCStore, + Map +>(); + +/** + * The netlist of a component, rebuilt whenever the store changed since it was built. + */ +function getSimulationNetlist( + store: CCStore, + componentId: CCComponentId, + defaultBitWidth: number, +): SimulationNetlist { + let cacheByComponent = netlistCacheByStore.get(store); + if (!cacheByComponent) { + cacheByComponent = new Map(); + netlistCacheByStore.set(store, cacheByComponent); } + const cacheKey = `${componentId}:${defaultBitWidth}`; + const cached = cacheByComponent.get(cacheKey); + if (cached && cached.revision === store.revision) return cached; + const netlist = buildSimulationNetlist(store, componentId, defaultBitWidth); + cacheByComponent.set(cacheKey, netlist); + return netlist; +} - const innerSimulationFrame = simulateComponent( - store, - component.id, - inputValues, - context.previousFrame - ? nullthrows(context.previousFrame.nodes.get(nodeId)).child - : null, - childDefaultBitWidth, - ); +function createEvaluationFrame( + componentId: CCComponentId, + nodeId: CCNodeId, + pins: Map, +): IntrinsicEvaluationFrame { + return { componentId, nodes: new Map([[nodeId, { pins, child: null }]]) }; +} - // Set output values for component to parent - for (const nodePin of nodePins) { - const componentPin = nullthrows( - store.componentPins.get(nodePin.componentPinId), - ); - if (componentPin.type === "output") { - const implementationNodePin = nullthrows( - store.nodePins.get(nullthrows(componentPin.implementation)), - ); - const value = nullthrows( - innerSimulationFrame.nodes - .get(implementationNodePin.nodeId) - ?.pins.get(implementationNodePin.id), - ); - outerNodePinValues.set(nodePin.id, value); - } +/** The pin values this instance had in the previous time step. */ +function collectPreviousPins( + instance: SimulationInstance, + previousFrame: SimulationFrame, +): Map { + const pins = new Map(); + for (const pin of [ + ...simulationPinsOfShape(instance.inputShape), + ...simulationPinsOfShape(instance.outputShape), + ]) { + const value = previousFrame.netValues.get(pin.netId); + if (value) pins.set(pin.nodePinId, value); } - - nullthrows(context.currentFrame.nodes.get(nodeId)).child = - innerSimulationFrame; - return true; + return pins; } +/** + * Simulate one time step of a component. Every flip-flop of the component and of the + * components it uses is clocked once, so a flip-flop outputs what its input carried in + * the previous time step. + * @param store store to read the circuit from + * @param componentId component to simulate + * @param inputValues values supplied to the input pins of the component + * @param previousFrame the previous time step, or null for the first one + * @param defaultBitWidth bit width of the pins of the component whose width is not fixed + * @returns the values of this time step + */ export default function simulateComponent( store: CCStore, componentId: CCComponentId, inputValues: Map, - previousFrame: SimulationFrame | null, - defaultBitWidth: number = 1, + previousFrame: SimulationFrame | null = null, + defaultBitWidth = 1, ): SimulationFrame { - const currentSimulationFrame = { - componentId: componentId, - nodes: new Map< - CCNodeId, - { - pins: Map; - /** null if intrinsic */ - child: SimulationFrame | null; - } - >(), - }; - const children = store.nodes.getManyByParentComponentId(componentId); + const netlist = getSimulationNetlist(store, componentId, defaultBitWidth); + invariant( + previousFrame === null || previousFrame.netlist === netlist, + "The previous frame was simulated from another revision of the store", + ); - for (const child of children) { - const pins = store.nodePins.getManyByNodeId(child.id); - for (const pin of pins) { - const componentPin = nullthrows( - store.componentPins.get(pin.componentPinId), - ); - if (componentPin.type === "input") { - const connections = nullthrows( - store.connections.getConnectionsByNodePinId(pin.id), - ); - if (connections.length === 0) { - throw new Error( - `Input pin ${pin.id} of node ${child.id} is not connected.`, - ); - } - } - } + const netValues = new Map(); + for (const [componentPinId, value] of inputValues) { + const netId = netlist.netByInputComponentPin.get(componentPinId); + if (netId !== undefined) netValues.set(netId, value); } - const nodePinInputNumber = new Map(); - - // Initialize maps - for (const child of children) { - const innerPins = store.nodePins.getManyByNodeId(child.id); - let inputPinNumber = 0; - for (const innerPin of innerPins) { - const componentPin = nullthrows( - store.componentPins.get(innerPin.componentPinId), - ); - if (componentPin.type === "input") { - inputPinNumber += 1; - } + for (const instance of netlist.instances) { + const pins = new Map(); + for (const pin of simulationPinsOfShape(instance.inputShape)) { + const value = netValues.get(pin.netId); + if (value) pins.set(pin.nodePinId, value); } - nodePinInputNumber.set(child.id, inputPinNumber); - currentSimulationFrame.nodes.set(child.id, { - pins: new Map(), - child: null, - }); - } - - for (const [componentPinId, inputValue] of inputValues) { - const componentPin = nullthrows(store.componentPins.get(componentPinId)); - if (componentPin.type === "input") { - const connectedNodePin = nullthrows( - store.nodePins.get(nullthrows(componentPin.implementation)), + const succeeded = instance.definition.evaluate( + { + previousFrame: + previousFrame && + createEvaluationFrame( + componentId, + instance.nodeId, + collectPreviousPins(instance, previousFrame), + ), + currentFrame: createEvaluationFrame(componentId, instance.nodeId, pins), + defaultBitWidth: instance.defaultBitWidth, + }, + instance.nodeId, + { inputShape: instance.inputShape, outputShape: instance.outputShape }, + instance.config, + ); + if (!succeeded) { + // The order guarantees that every driver ran first, so a value can only be + // missing when it was never supplied to an input pin of the component. + const missing = simulationPinsOfShape(instance.inputShape) + .filter((pin) => !pins.has(pin.nodePinId)) + .map((pin) => pin.nodePinId); + throw new Error( + `Node ${instance.nodeId} (${instance.definition.name}) could not be evaluated. Pins without a value: ${missing.join(", ") || "none"}`, ); - currentSimulationFrame.nodes - .get(connectedNodePin.nodeId) - ?.pins.set(connectedNodePin.id, inputValue); } - } - - const unevaluatedNodes = new Set(); - for (const child of children) { - unevaluatedNodes.add(child.id); - } - - const childContext = { - previousFrame: previousFrame, - currentFrame: currentSimulationFrame, - defaultBitWidth, - }; - - while (unevaluatedNodes.size > 0) { - const currentNodeId = nullthrows([...unevaluatedNodes][0]); - unevaluatedNodes.delete(currentNodeId); - - if (simulateNode(store, currentNodeId, childContext)) { - // propagate output values through connections - const currentNodePinValues = nullthrows( - childContext.currentFrame.nodes.get(currentNodeId)?.pins, - ); - for (const [nodePinId, outputValue] of currentNodePinValues) { - const nodePin = nullthrows(store.nodePins.get(nodePinId)); - const componentPin = nullthrows( - store.componentPins.get(nodePin.componentPinId), - ); - if (componentPin.type === "output") { - const connections = nullthrows( - store.connections.getConnectionsByNodePinId(nodePinId), - ); - for (const connection of connections) { - const toNodePin = nullthrows(store.nodePins.get(connection.to)); - const toNodePinValues = nullthrows( - childContext.currentFrame.nodes.get(toNodePin.nodeId)?.pins, - ); - toNodePinValues.set(toNodePin.id, outputValue); - } - } - } - } else { - unevaluatedNodes.add(currentNodeId); + for (const pin of simulationPinsOfShape(instance.outputShape)) { + const value = pins.get(pin.nodePinId); + if (value) netValues.set(pin.netId, value); } } - return currentSimulationFrame; + + return new SimulationFrame(netlist, netValues); } diff --git a/src/store/testUtils.ts b/src/store/testUtils.ts index cd4903f..7663c0c 100644 --- a/src/store/testUtils.ts +++ b/src/store/testUtils.ts @@ -1,6 +1,19 @@ import { uniqBy } from "es-toolkit"; +import nullthrows from "nullthrows"; +import invariant from "tiny-invariant"; import { expect } from "vitest"; -import type CCStore from "."; +import CCStore from "."; +import { + type CCComponent, + type CCComponentId, + CCComponentStore, +} from "./component"; +import type { CCComponentPin } from "./componentPin"; +import { CCConnectionStore } from "./connection"; +import { input, output } from "./intrinsics/definitions"; +import { type CCNode, CCNodeStore } from "./node"; +import { type CCNodePinId, CCNodePinStore } from "./nodePin"; +import type { SimulationValue } from "./simulation"; export function expectConsistentStore(store: CCStore) { const nodes = store.nodes.getMany(); @@ -29,3 +42,165 @@ export function expectConsistentStore(store: CCStore) { // Ensure an input pin is only connected to one output pin expect(connections).toEqual(uniqBy(connections, (c) => c.to)); } + +/** + * Builders for the circuits used in tests. They go through the same stores the editor + * uses, so a circuit built with them behaves like one drawn by hand. + */ + +/** A store with the intrinsic components registered and the stores mounted. */ +export function createTestStore(): CCStore { + const store = new CCStore(); + store.mount(); + return store; +} + +let nextTestPosition = 0; +function testPosition() { + nextTestPosition += 1; + return { x: nextTestPosition * 150, y: 0 }; +} + +export function registerTestComponent( + store: CCStore, + name: string, +): CCComponent { + const component = CCComponentStore.create({ name }); + store.components.register(component); + return component; +} + +/** + * Add a node of `componentId` to `parentComponentId`. Mounting the store takes care of + * the node pins and, for `Input` and `Output` nodes, of the pin of the parent component. + */ +export function registerTestNode( + store: CCStore, + parentComponentId: CCComponentId, + componentId: CCComponentId, + config?: CCNode["config"], +): CCNode { + const node = CCNodeStore.create({ + parentComponentId, + componentId, + position: testPosition(), + }); + if (config !== undefined) node.config = config; + store.nodes.register(node); + return node; +} + +/** The pin of `node` instantiating `componentPin` (the `index`-th one if it is split). */ +export function testNodePin( + store: CCStore, + node: CCNode, + componentPin: CCComponentPin, + index = 0, +): CCNodePinId { + const nodePins = store.nodePins + .getManyByNodeIdAndComponentPinId(node.id, componentPin.id) + .toSorted((a, b) => a.order - b.order); + return nullthrows(nodePins[index], "node pin not found").id; +} + +/** Split a configurable pin of `node` into pins of the given bit widths. */ +export function splitTestNodePin( + store: CCStore, + node: CCNode, + componentPin: CCComponentPin, + widths: number[], +): void { + const existing = store.nodePins.getManyByNodeIdAndComponentPinId( + node.id, + componentPin.id, + ); + invariant(existing.length === 1 && existing[0]); + store.nodePins.update(existing[0].id, { + manualBitWidth: nullthrows(widths[0]), + }); + for (let i = 1; i < widths.length; i += 1) { + store.nodePins.register( + CCNodePinStore.create({ + nodeId: node.id, + componentPinId: componentPin.id, + order: i, + manualBitWidth: nullthrows(widths[i]), + }), + ); + } +} + +export function connectTestNodePins( + store: CCStore, + parentComponentId: CCComponentId, + from: CCNodePinId, + to: CCNodePinId, +): void { + const connection = CCConnectionStore.create({ + from, + to, + parentComponentId, + bentPortion: 0.5, + }); + store.connections.register(connection); + invariant( + store.connections.get(connection.id), + "the store rejected the connection", + ); +} + +/** Add an `Input` node, which exposes an input pin on the component it is added to. */ +export function registerTestInputNode( + store: CCStore, + parentComponentId: CCComponentId, + name: string, +) { + const node = registerTestNode(store, parentComponentId, input.component.id); + const out = testNodePin(store, node, input.outputPin.Out); + const componentPin = nullthrows(store.componentPins.getByImplementation(out)); + store.componentPins.update(componentPin.id, { name }); + return { node, out, componentPinId: componentPin.id }; +} + +/** Add an `Output` node, which exposes an output pin on the component it is added to. */ +export function registerTestOutputNode( + store: CCStore, + parentComponentId: CCComponentId, + name: string, +) { + const node = registerTestNode(store, parentComponentId, output.component.id); + const into = testNodePin(store, node, output.inputPin.In); + const componentPin = nullthrows( + store.componentPins.getByImplementation(into), + ); + store.componentPins.update(componentPin.id, { name }); + return { node, in: into, componentPinId: componentPin.id }; +} + +/** The pin named `name` of a user-defined component. */ +export function testComponentPin( + store: CCStore, + component: CCComponent, + name: string, +): CCComponentPin { + return nullthrows( + store.componentPins + .getManyByComponentId(component.id) + .find((pin) => pin.name === name), + `Component ${component.name} has no pin named ${name}`, + ); +} + +/** MSB-first bit array of `value` in `width` bits. */ +export function testBits(value: number, width: number): SimulationValue { + return Array.from( + { length: width }, + (_, i) => ((value >> (width - 1 - i)) & 1) === 1, + ); +} + +/** Decimal reading of an MSB-first bit array. */ +export function testNumber(value: SimulationValue | undefined): number | null { + if (!value) return null; + return Number.parseInt(value.map((bit) => (bit ? "1" : "0")).join(""), 2); +} From 4bc23bc3f5a1081e7168921613f3cbe176047f48 Mon Sep 17 00:00:00 2001 From: chelproc Date: Sat, 31 Oct 2026 10:52:20 +0900 Subject: [PATCH 3/6] feat(editor): show simulation errors and fall back to edit mode When the circuit cannot be simulated, for example because a loop has no flip-flop to break it or an input pin is unconnected, keep the error message in the editor store, switch back to edit mode and show the message in a snackbar. A failed circuit is not simulated again until it or its input values change, or the editor mode is switched. Extracted from 788b5c9 (WIP: netlist-based simulation). Co-Authored-By: Claude Opus 5.5 --- .../components/SimulationErrorAlert.tsx | 29 ++++++++ src/pages/edit/Editor/index.tsx | 2 + .../edit/Editor/store/slices/core/index.ts | 69 +++++++++++++------ .../edit/Editor/store/slices/core/types.ts | 3 + 4 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 src/pages/edit/Editor/components/SimulationErrorAlert.tsx diff --git a/src/pages/edit/Editor/components/SimulationErrorAlert.tsx b/src/pages/edit/Editor/components/SimulationErrorAlert.tsx new file mode 100644 index 0000000..575bd5e --- /dev/null +++ b/src/pages/edit/Editor/components/SimulationErrorAlert.tsx @@ -0,0 +1,29 @@ +import { Alert, Snackbar } from "@mui/material"; +import { useComponentEditorStore } from "../store"; + +/** + * Shows why the circuit could not be simulated, e.g. because a loop needs a flip-flop + * to break it or because an input pin is left unconnected. + */ +export default function CCComponentEditorSimulationErrorAlert() { + const componentEditorState = useComponentEditorStore()(); + const message = componentEditorState.simulationError; + + return ( + + { + componentEditorState.dismissSimulationError(); + }} + > + {message} + + + ); +} diff --git a/src/pages/edit/Editor/index.tsx b/src/pages/edit/Editor/index.tsx index 7512744..e593c14 100644 --- a/src/pages/edit/Editor/index.tsx +++ b/src/pages/edit/Editor/index.tsx @@ -9,6 +9,7 @@ import CCComponentEditorContextMenu from "./components/ContextMenu"; import CCComponentEditorFrameControl from "./components/FrameControl"; import CCComponentEditorGrid from "./components/Grid"; import { CCComponentEditorNodePinPropertyEditor } from "./components/NodePinPropertyEditor"; +import CCComponentEditorSimulationErrorAlert from "./components/SimulationErrorAlert"; import CCComponentEditorTitleBar from "./components/TitleBar"; import CCComponentEditorViewModeSwitcher from "./components/ViewModeSwitcher"; import CCComponentEditorRenderer from "./renderer"; @@ -50,6 +51,7 @@ function CCComponentEditorContent({ + {isComponentPropertyDialogOpen && ( { @@ -104,11 +106,17 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator< setRangeSelect(rangeSelect) { set((state) => ({ ...state, rangeSelect })); }, + simulationError: null, + dismissSimulationError() { + set((state) => ({ ...state, simulationError: null })); + }, setEditorMode(mode) { + hasSimulationFailed = false; set((state) => ({ ...state, editorMode: mode, playbackMode: "manual", + simulationError: null, })); }, setTimeStep(timeStep: number) { @@ -184,34 +192,51 @@ export const createComponentEditorStoreCoreSlice: ComponentEditorSliceCreator< if (newSimulationCacheKey !== simulationCacheKey) { simulationCacheKey = newSimulationCacheKey; simulationCachedFrames = []; + hasSimulationFailed = false; } + if (hasSimulationFailed) return; const editorState = editorStore.getState(); let isUpdated = false; - for ( - let timeStep = simulationCachedFrames.length; - timeStep <= editorState.timeStep; - timeStep += 1 - ) { - const previousFrame = simulationCachedFrames[timeStep - 1] ?? null; - const inputValues = new Map(); - const pins = store.componentPins.getManyByComponentId(componentId); - for (const pin of pins) { - invariant(pin.implementation); - if (pin.type === "input") { - inputValues.set( - pin.id, - editorState.getInputValue({ - componentPinId: pin.id, - timeStep, - }), - ); + try { + for ( + let timeStep = simulationCachedFrames.length; + timeStep <= editorState.timeStep; + timeStep += 1 + ) { + const previousFrame = simulationCachedFrames[timeStep - 1] ?? null; + const inputValues = new Map(); + const pins = store.componentPins.getManyByComponentId(componentId); + for (const pin of pins) { + invariant(pin.implementation); + if (pin.type === "input") { + inputValues.set( + pin.id, + editorState.getInputValue({ + componentPinId: pin.id, + timeStep, + }), + ); + } } + simulationCachedFrames.push( + simulateComponent(store, componentId, inputValues, previousFrame), + ); + isUpdated = true; } - simulationCachedFrames.push( - simulateComponent(store, componentId, inputValues, previousFrame), - ); - isUpdated = true; + } catch (error) { + // Back to edit mode: without frames the renderers have no values to show. + hasSimulationFailed = true; + simulationCachedFrames = []; + editorStore.setState((state) => ({ + ...state, + editorMode: "edit", + playbackMode: "manual", + timeStep: 0, + simulationError: + error instanceof Error ? error.message : String(error), + })); + return; } if (isUpdated) editorStore.setState((s) => ({ ...s })); }; diff --git a/src/pages/edit/Editor/store/slices/core/types.ts b/src/pages/edit/Editor/store/slices/core/types.ts index c9863a1..dab3a47 100644 --- a/src/pages/edit/Editor/store/slices/core/types.ts +++ b/src/pages/edit/Editor/store/slices/core/types.ts @@ -63,6 +63,9 @@ export type EditorStoreCoreSlice = { selectNode(ids: CCNodeId[], exclusive: boolean): void; unselectNode(ids: CCNodeId[]): void; selectConnection(ids: CCConnectionId[], exclusive: boolean): void; + /** message the last simulation attempt failed with, if it failed */ + simulationError: string | null; + dismissSimulationError(): void; getNodePinValue(nodePinId: CCNodePinId): SimulationValue | undefined; getComponentPinValue( componentPinId: CCComponentPinId, From 0c126a12c22a65d911ee5a0ddc6caed97335a1ba Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 31 Oct 2026 11:01:10 +0900 Subject: [PATCH 4/6] refactor(simulation): make intrinsic evaluation pure functions The evaluation functions of the intrinsic components were still written against the per-component frames of the old simulation, so the netlist simulation wrapped every instance in a frame holding that one node, let the function write its outputs into it and read them back. Replace `evaluate` and `isSequential` with a `behavior` that is either combinational, computing the outputs from the current inputs, or sequential, computing them from the inputs of the previous time step. A sequential behavior cannot see the current inputs at all, since it is evaluated before they are known. Input and Output have no behavior, as they are resolved away while flattening. The simulation now hands the input values grouped by pin key to the function and writes the returned values to the nets. Errors thrown while evaluating are rethrown with the instance they come from. Co-Authored-By: Claude Opus 5.5 --- src/store/intrinsics/base.ts | 88 +++++----- src/store/intrinsics/definitions.ts | 243 +++++++++------------------- src/store/netlist.ts | 23 +-- src/store/simulation.ts | 133 ++++++++------- 4 files changed, 202 insertions(+), 285 deletions(-) diff --git a/src/store/intrinsics/base.ts b/src/store/intrinsics/base.ts index 23b6159..36b4344 100644 --- a/src/store/intrinsics/base.ts +++ b/src/store/intrinsics/base.ts @@ -1,34 +1,12 @@ import { mapValues } from "es-toolkit"; import type { CCComponent, CCComponentId } from "../component"; import type { CCComponentPin, CCComponentPinId } from "../componentPin"; -import type { CCNodeId } from "../node"; -import type { CCNodePinId } from "../nodePin"; -import type { IntrinsicEvaluationFrame } from "../simulation"; +import type { SimulationValue } from "../simulation"; import type { CCIntrinsicComponentSpec, CCIntrinsicComponentType, } from "./types"; -export type CCIntrinsicComponentShape = { - inputShape: Record; - outputShape: Record; -}; - -export type CCComponentPinInstanceShapes = { - nodePinId: CCNodePinId; - bitWidth: number; -}[]; - -export type ComponentEvaluationContext = { - previousFrame: IntrinsicEvaluationFrame | null; - currentFrame: IntrinsicEvaluationFrame; - defaultBitWidth: number; -}; - -export type Context = { - componentId: CCComponentId; -}; - /** * How the bit width of an intrinsic component pin is determined. * - `inferred` — inferred from the pins it is transitively connected to. @@ -62,14 +40,48 @@ type IntrinsicComponentPinAttributes = { export type RegisteredIntrinsicComponentPinAttributes = IntrinsicComponentPinAttributes & { key: string }; -type IntrinsicComponentEvaluationFunction< +/** + * The values of the pins of one intrinsic node, grouped by pin key. A pin key can have + * more than one node pin when it is split, so each key holds the values in pin order. + */ +export type IntrinsicPinValues = Record< + Key, + SimulationValue[] +>; + +/** What an intrinsic node needs to know about itself besides its input values. */ +export type IntrinsicEvaluationEnvironment< Spec extends CCIntrinsicComponentSpec, -> = ( - context: ComponentEvaluationContext, - nodeId: CCNodeId, - shape: CCIntrinsicComponentShape, - config: Spec["config"], -) => boolean; +> = { + /** bit widths of the output pins, grouped like {@link IntrinsicPinValues} */ + outputBitWidths: Record; + config: Spec["config"]; +}; + +/** + * How the outputs of an intrinsic node are computed in one time step. + * - `combinational` — from the inputs of the same time step. + * - `sequential` — from the inputs of the previous time step (`null` in the first one). + * It does not see the current inputs at all, because it is evaluated before they are + * known. That is what lets a circuit contain a feedback loop through such a node. + */ +export type IntrinsicComponentBehavior< + Spec extends CCIntrinsicComponentSpec = CCIntrinsicComponentSpec, +> = + | { + kind: "combinational"; + evaluate( + inputs: IntrinsicPinValues, + environment: IntrinsicEvaluationEnvironment, + ): IntrinsicPinValues; + } + | { + kind: "sequential"; + evaluate( + previousInputs: IntrinsicPinValues | null, + environment: IntrinsicEvaluationEnvironment, + ): IntrinsicPinValues; + }; type Props = { type: CCIntrinsicComponentType; @@ -77,13 +89,11 @@ type Props = { in: Record>; out: Record>; initialConfig: Spec["config"]; - evaluate: IntrinsicComponentEvaluationFunction; /** - * Whether the outputs are derived from the previous time step instead of the current - * inputs. Such a component is what lets a circuit contain a feedback loop: the - * simulation can evaluate it before the values of its inputs are known. + * null for `Input` and `Output`, which only carry a value across a component + * boundary and are resolved away before the simulation evaluates anything */ - isSequential?: boolean; + behavior: IntrinsicComponentBehavior | null; }; export class IntrinsicComponentDefinition< Spec extends CCIntrinsicComponentSpec = CCIntrinsicComponentSpec, @@ -96,9 +106,8 @@ export class IntrinsicComponentDefinition< readonly inputPin: Record; readonly outputPin: Record; readonly initialConfig: Spec["config"]; - readonly evaluate: IntrinsicComponentEvaluationFunction; - /** @see {@link Props.isSequential} */ - readonly isSequential: boolean; + /** @see {@link Props.behavior} */ + readonly behavior: IntrinsicComponentBehavior | null; private static _lastIndex = 0; @@ -122,7 +131,7 @@ export class IntrinsicComponentDefinition< name: this.name, }; IntrinsicComponentDefinition._byId.set(this.id, this); - this.evaluate = props.evaluate; + this.behavior = props.behavior; this.inputPin = mapValues(props.in, (attributes, key) => this._registerPin("input", key, attributes), @@ -131,7 +140,6 @@ export class IntrinsicComponentDefinition< this._registerPin("output", key, attributes), ); this.initialConfig = props.initialConfig; - this.isSequential = props.isSequential ?? false; } private _registerPin( diff --git a/src/store/intrinsics/definitions.ts b/src/store/intrinsics/definitions.ts index 5ac838e..2ceeb34 100644 --- a/src/store/intrinsics/definitions.ts +++ b/src/store/intrinsics/definitions.ts @@ -16,6 +16,12 @@ import { ccIntrinsicComponentTypes, } from "./types"; +/** The one entry of a pin key that cannot be split into multiple node pins. */ +function single(values: readonly T[]): T { + invariant(values.length === 1, `Expected a single pin, got ${values.length}`); + return nullthrows(values[0]); +} + function createUnaryOperator( type: CCIntrinsicComponentType, name: string, @@ -28,22 +34,9 @@ function createUnaryOperator( in: { In: { name: "In", bitWidthPolicy: { type: "inferred" } } }, out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - invariant(inputShape[0] && !inputShape[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const inputValue = nodePinIdToValue?.get(inputShape[0].nodePinId); - if (!inputValue || !nodePinIdToValue) { - return false; - } - const outputValue = Array.from({ length: inputValue.length }, (_, i) => - evaluate(nullthrows(inputValue[i])), - ); - const outputShape = shape.outputShape.Out; - invariant(outputShape[0] && !outputShape[1]); - - nodePinIdToValue.set(outputShape[0].nodePinId, outputValue); - return true; + behavior: { + kind: "combinational", + evaluate: ({ In }) => ({ Out: [single(In).map((a) => evaluate(a))] }), }, }, ); @@ -64,37 +57,17 @@ function createBinaryOperator( }, out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShapeA = shape.inputShape.A; - const inputShapeB = shape.inputShape.B; - invariant(inputShapeA[0] && !inputShapeA[1]); - invariant(inputShapeB[0] && !inputShapeB[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const inputValueA = nodePinIdToValue?.get(inputShapeA[0].nodePinId); - const inputValueB = nodePinIdToValue?.get(inputShapeB[0].nodePinId); - if (!inputValueA || !inputValueB || !nodePinIdToValue) { - return false; - } - invariant( - inputValueA.length === inputValueB.length, - "Input lengths must match (name: " + - name + - ", nodeId: " + - nodeId + - ", inputValueA: " + - inputValueA + - ", inputValueB: " + - inputValueB + - ")", - ); - const outputValue = Array.from({ length: inputValueA.length }, (_, i) => - evaluate(nullthrows(inputValueA[i]), nullthrows(inputValueB[i])), - ); - const outputShape = shape.outputShape.Out; - invariant(outputShape[0] && !outputShape[1]); - - nodePinIdToValue.set(outputShape[0].nodePinId, outputValue); - return true; + behavior: { + kind: "combinational", + evaluate: ({ A, B }) => { + const a = single(A); + const b = single(B); + invariant( + a.length === b.length, + `Input lengths must match (A: ${a.length}, B: ${b.length})`, + ); + return { Out: [a.map((bit, i) => evaluate(bit, nullthrows(b[i])))] }; + }, }, }, ); @@ -112,14 +85,11 @@ function createNullaryOperator( in: {}, out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const outputShape = shape.outputShape.Out; - invariant(outputShape[0] && !outputShape[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - if (!nodePinIdToValue) return false; - const outputValue = new Array(outputShape[0].bitWidth).fill(value); - nodePinIdToValue.set(outputShape[0].nodePinId, outputValue); - return true; + behavior: { + kind: "combinational", + evaluate: (_, { outputBitWidths }) => ({ + Out: [new Array(single(outputBitWidths.Out)).fill(value)], + }), }, }, ); @@ -163,9 +133,7 @@ export const input = in: {}, out: { Out: { name: "In", bitWidthPolicy: { type: "inferred" } } }, initialConfig: null, - evaluate: (_context, _nodeId, _shape) => { - return true; - }, + behavior: null, }); export const output = @@ -175,9 +143,7 @@ export const output = in: { In: { name: "Out", bitWidthPolicy: { type: "inferred" } } }, out: {}, initialConfig: null, - evaluate: (_context, _nodeId, _shape) => { - return true; - }, + behavior: null, }); export const aggregate = @@ -202,21 +168,9 @@ export const aggregate = }, }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const inputValues = inputShape.map((s) => - nodePinIdToValue?.get(s.nodePinId), - ); - if (inputValues.some((v) => !v) || !nodePinIdToValue) { - return false; - } - const outputValue = inputValues.flatMap((v) => nullthrows(v)); - nodePinIdToValue.set( - nullthrows(shape.outputShape.Out[0]?.nodePinId), - outputValue, - ); - return true; + behavior: { + kind: "combinational", + evaluate: ({ In }) => ({ Out: [In.flat()] }), }, }); @@ -244,24 +198,19 @@ export const decompose = }, }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - invariant(inputShape[0] && !inputShape[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const outputShape = shape.outputShape.Out; - const inputValue = nodePinIdToValue?.get(inputShape[0].nodePinId); - if (!inputValue || !nodePinIdToValue) { - return false; - } - let currentIndex = 0; - for (const shape of outputShape) { - nodePinIdToValue.set( - shape.nodePinId, - inputValue.slice(currentIndex, currentIndex + shape.bitWidth), - ); - currentIndex += shape.bitWidth; - } - return true; + behavior: { + kind: "combinational", + evaluate: ({ In }, { outputBitWidths }) => { + const value = single(In); + let offset = 0; + return { + Out: outputBitWidths.Out.map((bitWidth) => { + const part = value.slice(offset, offset + bitWidth); + offset += bitWidth; + return part; + }), + }; + }, }, }); @@ -282,22 +231,11 @@ export const and_n = }, }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const inputValues = inputShape.map((s) => - nodePinIdToValue?.get(s.nodePinId), - ); - if (inputValues.some((v) => !v) || !nodePinIdToValue) { - return false; - } - const outputValue = inputValues.every((v) => - nullthrows(v).every(Boolean), - ); - nodePinIdToValue.set(nullthrows(shape.outputShape.Out[0]?.nodePinId), [ - outputValue, - ]); - return true; + behavior: { + kind: "combinational", + evaluate: ({ In }) => ({ + Out: [[In.every((value) => value.every(Boolean))]], + }), }, }); @@ -318,20 +256,11 @@ export const or_n = }, }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const inputValues = inputShape.map((s) => - nodePinIdToValue?.get(s.nodePinId), - ); - if (inputValues.some((v) => !v) || !nodePinIdToValue) { - return false; - } - const outputValue = inputValues.some((v) => nullthrows(v).some(Boolean)); - nodePinIdToValue.set(nullthrows(shape.outputShape.Out[0]?.nodePinId), [ - outputValue, - ]); - return true; + behavior: { + kind: "combinational", + evaluate: ({ In }) => ({ + Out: [[In.some((value) => value.some(Boolean))]], + }), }, }); @@ -352,24 +281,14 @@ export const broadcast = }, }, initialConfig: null, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - invariant(inputShape[0] && !inputShape[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const outputShape = shape.outputShape.Out; - invariant(outputShape[0] && !outputShape[1]); - const inputValue = nodePinIdToValue?.get(inputShape[0].nodePinId); - if (!inputValue || !nodePinIdToValue) { - return false; - } - invariant(inputValue.length === 1); - nodePinIdToValue.set( - outputShape[0].nodePinId, - Array.from({ length: outputShape[0].bitWidth }, () => - nullthrows(inputValue[0]), - ), - ); - return true; + behavior: { + kind: "combinational", + evaluate: ({ In }, { outputBitWidths }) => { + const bit = single(single(In)); + return { + Out: [new Array(single(outputBitWidths.Out)).fill(bit)], + }; + }, }, }); @@ -380,23 +299,16 @@ export const flipflop = in: { In: { name: "In", bitWidthPolicy: { type: "inferred" } } }, out: { Out: { name: "Out", bitWidthPolicy: { type: "inferred" } } }, initialConfig: null, - isSequential: true, - evaluate: (context, nodeId, shape) => { - const inputShape = shape.inputShape.In; - invariant(inputShape[0] && !inputShape[1]); - const outputShape = shape.outputShape.Out; - invariant(outputShape[0] && !outputShape[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - const previousValue = - context.previousFrame?.nodes - .get(nodeId) - ?.pins.get(inputShape[0].nodePinId) ?? - new Array(inputShape[0].bitWidth).fill(false); - if (!nodePinIdToValue) { - return false; - } - nodePinIdToValue.set(outputShape[0].nodePinId, previousValue); - return true; + behavior: { + kind: "sequential", + // Nothing has been fed to it before the first time step, so it starts from zero + evaluate: (previousInputs, { outputBitWidths }) => ({ + Out: [ + previousInputs + ? single(previousInputs.In) + : new Array(single(outputBitWidths.Out)).fill(false), + ], + }), }, }); @@ -419,7 +331,8 @@ export const display = resolution: { x: 20, y: 15 }, pixelOrder: "row-major", }, - evaluate: () => true, + // The renderer reads the pixels from the input pin; there is nothing to compute + behavior: { kind: "combinational", evaluate: () => ({}) }, }); export const const_ = @@ -437,13 +350,9 @@ export const const_ = }, }, initialConfig: { mode: "binary", data: new Array(8).fill(false) }, - evaluate: (context, nodeId, shape, config) => { - const outputShape = shape.outputShape.Out; - invariant(outputShape[0] && !outputShape[1]); - const nodePinIdToValue = context.currentFrame.nodes.get(nodeId)?.pins; - if (!nodePinIdToValue) return false; - nodePinIdToValue.set(outputShape[0].nodePinId, config.data); - return true; + behavior: { + kind: "combinational", + evaluate: (_, { config }) => ({ Out: [config.data] }), }, }); diff --git a/src/store/netlist.ts b/src/store/netlist.ts index 93fbca1..893a725 100644 --- a/src/store/netlist.ts +++ b/src/store/netlist.ts @@ -34,7 +34,10 @@ import type { Opaque } from "type-fest"; import type CCStore from "."; import type { CCComponentId } from "./component"; import type { CCComponentPinId } from "./componentPin"; -import type { IntrinsicComponentDefinition } from "./intrinsics/base"; +import type { + IntrinsicComponentBehavior, + IntrinsicComponentDefinition, +} from "./intrinsics/base"; import { definitionByComponentId } from "./intrinsics/definitions"; import { ccIntrinsicComponentTypes } from "./intrinsics/types"; import type { CCNode, CCNodeId } from "./node"; @@ -78,13 +81,11 @@ export type SimulationInstance = { readonly nodeId: CCNodeId; readonly definition: IntrinsicComponentDefinition; readonly config: CCNode["config"]; - /** bit width used for the pins of this instance whose width is not fixed */ - readonly defaultBitWidth: number; /** pins grouped by the pin key of the definition (e.g. `A`, `In`), ordered by pin order */ readonly inputShape: Record; readonly outputShape: Record; - /** whether the outputs are derived from the previous time step instead of the inputs */ - readonly isSequential: boolean; + /** how the outputs are computed, taken from the definition */ + readonly behavior: IntrinsicComponentBehavior; }; /** @@ -348,12 +349,15 @@ export function buildSimulationNetlist( definitionByComponentId.get(node.componentId), `Intrinsic component ${node.componentId} has no definition`, ); + const behavior = nullthrows( + definition.behavior, + `Intrinsic component ${definition.name} cannot be evaluated`, + ); instances.push({ path: nodePath, nodeId: node.id, definition, config: node.config, - defaultBitWidth: instanceDefaultBitWidth, inputShape: resolveShape( nodePath, node.id, @@ -366,7 +370,7 @@ export function buildSimulationNetlist( definition.outputPin, instanceDefaultBitWidth, ), - isSequential: definition.isSequential, + behavior, }); } }; @@ -412,7 +416,8 @@ export function simulationPinsOfShape( return Object.values(shape).flat(); } -function describeInstance(instance: SimulationInstance): string { +/** Name of an instance for error messages, e.g. `Not(nodeA/nodeB)`. */ +export function describeInstance(instance: SimulationInstance): string { return `${instance.definition.name}(${instance.path.join("/")})`; } @@ -445,7 +450,7 @@ function sortInstancesByDependency( // A sequential instance answers with the value of the previous time step, so it // does not wait for the instances driving its inputs. That is what makes a loop // containing one resolvable. - if (instance.isSequential) continue; + if (instance.behavior.kind === "sequential") continue; for (const pin of simulationPinsOfShape(instance.inputShape)) { const driver = driverByNet.get(pin.netId); if (driver === undefined) continue; // supplied from outside the component diff --git a/src/store/simulation.ts b/src/store/simulation.ts index e584a47..bcadc28 100644 --- a/src/store/simulation.ts +++ b/src/store/simulation.ts @@ -1,13 +1,20 @@ +import { mapValues } from "es-toolkit"; +import nullthrows from "nullthrows"; import invariant from "tiny-invariant"; import type CCStore from "."; import type { CCComponentId } from "./component"; import type { CCComponentPinId } from "./componentPin"; +import type { + IntrinsicEvaluationEnvironment, + IntrinsicPinValues, +} from "./intrinsics/base"; +import type { CCIntrinsicComponentSpec } from "./intrinsics/types"; import { buildSimulationNetlist, + describeInstance, type SimulationInstance, type SimulationNetId, type SimulationNetlist, - simulationPinsOfShape, simulationTerminalKey, } from "./netlist"; import type { CCNodeId } from "./node"; @@ -15,23 +22,6 @@ import type { CCNodePinId } from "./nodePin"; export type SimulationValue = boolean[]; -/** - * The pin values of a single node instance, shaped like a frame of a whole component so - * that the evaluation function of an intrinsic component does not have to know how the - * circuit is flattened for simulation. - */ -export type IntrinsicEvaluationFrame = { - componentId: CCComponentId; - nodes: Map< - CCNodeId, - { - pins: Map; - /** always null: a flattened instance has no frame of its own */ - child: IntrinsicEvaluationFrame | null; - } - >; -}; - /** * The values of one time step. Every pin that carries the same value shares one net, so * a frame stores one value per net and resolves pins through the netlist. @@ -98,28 +88,54 @@ function getSimulationNetlist( return netlist; } -function createEvaluationFrame( - componentId: CCComponentId, - nodeId: CCNodeId, - pins: Map, -): IntrinsicEvaluationFrame { - return { componentId, nodes: new Map([[nodeId, { pins, child: null }]]) }; +/** The values arriving at the input pins of an instance, read from the nets. */ +function collectInputs( + instance: SimulationInstance, + netValues: ReadonlyMap, +): IntrinsicPinValues { + return mapValues(instance.inputShape, (pins) => + pins.map((pin) => + // The order guarantees that every driver ran first, so a value can only be + // missing when it was never supplied to an input pin of the component. + nullthrows( + netValues.get(pin.netId), + `No value reaches pin ${pin.nodePinId}`, + ), + ), + ); } -/** The pin values this instance had in the previous time step. */ -function collectPreviousPins( +/** Compute the outputs of one instance in the current time step. */ +function evaluateInstance( instance: SimulationInstance, - previousFrame: SimulationFrame, -): Map { - const pins = new Map(); - for (const pin of [ - ...simulationPinsOfShape(instance.inputShape), - ...simulationPinsOfShape(instance.outputShape), - ]) { - const value = previousFrame.netValues.get(pin.netId); - if (value) pins.set(pin.nodePinId, value); + netValues: ReadonlyMap, + previousFrame: SimulationFrame | null, +): IntrinsicPinValues { + const { behavior } = instance; + const environment: IntrinsicEvaluationEnvironment = + { + outputBitWidths: mapValues(instance.outputShape, (pins) => + pins.map((pin) => pin.bitWidth), + ), + config: instance.config, + }; + try { + if (behavior.kind === "sequential") { + return behavior.evaluate( + previousFrame && collectInputs(instance, previousFrame.netValues), + environment, + ); + } + return behavior.evaluate(collectInputs(instance, netValues), environment); + } catch (error) { + // The evaluation functions only see values, so tell which node they belong to + throw new Error( + `${describeInstance(instance)} could not be evaluated: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); } - return pins; } /** @@ -153,40 +169,19 @@ export default function simulateComponent( } for (const instance of netlist.instances) { - const pins = new Map(); - for (const pin of simulationPinsOfShape(instance.inputShape)) { - const value = netValues.get(pin.netId); - if (value) pins.set(pin.nodePinId, value); - } - const succeeded = instance.definition.evaluate( - { - previousFrame: - previousFrame && - createEvaluationFrame( - componentId, - instance.nodeId, - collectPreviousPins(instance, previousFrame), - ), - currentFrame: createEvaluationFrame(componentId, instance.nodeId, pins), - defaultBitWidth: instance.defaultBitWidth, - }, - instance.nodeId, - { inputShape: instance.inputShape, outputShape: instance.outputShape }, - instance.config, - ); - if (!succeeded) { - // The order guarantees that every driver ran first, so a value can only be - // missing when it was never supplied to an input pin of the component. - const missing = simulationPinsOfShape(instance.inputShape) - .filter((pin) => !pins.has(pin.nodePinId)) - .map((pin) => pin.nodePinId); - throw new Error( - `Node ${instance.nodeId} (${instance.definition.name}) could not be evaluated. Pins without a value: ${missing.join(", ") || "none"}`, + const outputs = evaluateInstance(instance, netValues, previousFrame); + for (const [key, pins] of Object.entries(instance.outputShape)) { + const values = nullthrows( + outputs[key], + `${describeInstance(instance)} did not output ${key}`, ); - } - for (const pin of simulationPinsOfShape(instance.outputShape)) { - const value = pins.get(pin.nodePinId); - if (value) netValues.set(pin.netId, value); + invariant( + values.length === pins.length, + `${describeInstance(instance)} output ${values.length} values for the ${pins.length} pins of ${key}`, + ); + for (const [index, pin] of pins.entries()) { + netValues.set(pin.netId, nullthrows(values[index])); + } } } From d6ca1066887441bc34c1faaca14db800742cf4b7 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 31 Oct 2026 11:02:10 +0900 Subject: [PATCH 5/6] test(intrinsics): add unit tests for intrinsic evaluation Now that the evaluation functions are pure, cover each intrinsic on its own: the bitwise operators, the constants, Aggregate/Decompose/Broadcast, the AndN/OrN reductions, the initial value of the FlipFlop, and which intrinsics are sequential or have no behavior at all. Co-Authored-By: Claude Opus 5.5 --- src/store/intrinsics/definitions.test.ts | 231 +++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/store/intrinsics/definitions.test.ts diff --git a/src/store/intrinsics/definitions.test.ts b/src/store/intrinsics/definitions.test.ts new file mode 100644 index 0000000..880d91b --- /dev/null +++ b/src/store/intrinsics/definitions.test.ts @@ -0,0 +1,231 @@ +import nullthrows from "nullthrows"; +import invariant from "tiny-invariant"; +import { describe, expect, it } from "vitest"; +import { testBits } from "../testUtils"; +import type { + IntrinsicComponentDefinition, + IntrinsicEvaluationEnvironment, + IntrinsicPinValues, +} from "./base"; +import { + aggregate, + and, + and_n, + broadcast, + const_, + decompose, + definitions, + false_, + flipflop, + input, + not, + or, + or_n, + output, + true_, + xor, +} from "./definitions"; +import type { CCIntrinsicComponentSpec } from "./types"; + +function evaluateCombinational( + definition: IntrinsicComponentDefinition, + inputs: IntrinsicPinValues, + environment: IntrinsicEvaluationEnvironment, +): IntrinsicPinValues { + const behavior = nullthrows(definition.behavior); + invariant(behavior.kind === "combinational"); + return behavior.evaluate(inputs, environment); +} + +function evaluateSequential( + definition: IntrinsicComponentDefinition, + previousInputs: IntrinsicPinValues | null, + environment: IntrinsicEvaluationEnvironment, +): IntrinsicPinValues { + const behavior = nullthrows(definition.behavior); + invariant(behavior.kind === "sequential"); + return behavior.evaluate(previousInputs, environment); +} + +describe("bitwise operators", () => { + it("applies Not to each bit", () => { + expect( + evaluateCombinational( + not, + { In: [testBits(0b0110, 4)] }, + { outputBitWidths: { Out: [4] }, config: null }, + ), + ).toEqual({ Out: [testBits(0b1001, 4)] }); + }); + + it.each([ + { definition: and, expected: 0b1000 }, + { definition: or, expected: 0b1110 }, + { definition: xor, expected: 0b0110 }, + ])( + "applies $definition.name to each pair of bits", + ({ definition, expected }) => { + expect( + evaluateCombinational( + definition, + { A: [testBits(0b1100, 4)], B: [testBits(0b1010, 4)] }, + { outputBitWidths: { Out: [4] }, config: null }, + ), + ).toEqual({ Out: [testBits(expected, 4)] }); + }, + ); + + it("rejects operands of different widths", () => { + expect(() => + evaluateCombinational( + and, + { A: [testBits(0, 4)], B: [testBits(0, 2)] }, + { outputBitWidths: { Out: [4] }, config: null }, + ), + ).toThrow(); + }); +}); + +describe("constants", () => { + it.each([ + { definition: true_, bit: true }, + { definition: false_, bit: false }, + ])( + "fills the output of $definition.name to its bit width", + ({ definition, bit }) => { + expect( + evaluateCombinational( + definition, + {}, + { outputBitWidths: { Out: [3] }, config: null }, + ), + ).toEqual({ Out: [[bit, bit, bit]] }); + }, + ); + + it("outputs the data configured on a Const node", () => { + const data = testBits(0b101, 3); + expect( + evaluateCombinational( + const_, + {}, + { outputBitWidths: { Out: [3] }, config: { mode: "binary", data } }, + ), + ).toEqual({ Out: [data] }); + }); +}); + +describe("bit width conversions", () => { + it("concatenates the split input pins of Aggregate in pin order", () => { + expect( + evaluateCombinational( + aggregate, + { In: [testBits(0b10, 2), testBits(0b011, 3)] }, + { outputBitWidths: { Out: [5] }, config: null }, + ), + ).toEqual({ Out: [testBits(0b10011, 5)] }); + }); + + it("splits the input of Decompose by the widths of its output pins", () => { + expect( + evaluateCombinational( + decompose, + { In: [testBits(0b10011, 5)] }, + { outputBitWidths: { Out: [2, 3] }, config: null }, + ), + ).toEqual({ Out: [testBits(0b10, 2), testBits(0b011, 3)] }); + }); + + it("repeats the input bit of Broadcast to the output width", () => { + expect( + evaluateCombinational( + broadcast, + { In: [[true]] }, + { outputBitWidths: { Out: [4] }, config: null }, + ), + ).toEqual({ Out: [testBits(0b1111, 4)] }); + }); + + it("rejects a Broadcast input wider than one bit", () => { + expect(() => + evaluateCombinational( + broadcast, + { In: [testBits(0b11, 2)] }, + { outputBitWidths: { Out: [4] }, config: null }, + ), + ).toThrow(); + }); +}); + +describe("reductions", () => { + it("reduces every bit of every input pin of AndN", () => { + const environment = { outputBitWidths: { Out: [1] }, config: null }; + expect( + evaluateCombinational( + and_n, + { In: [testBits(0b11, 2), [true]] }, + environment, + ), + ).toEqual({ Out: [[true]] }); + expect( + evaluateCombinational( + and_n, + { In: [testBits(0b11, 2), [false]] }, + environment, + ), + ).toEqual({ Out: [[false]] }); + }); + + it("reduces every bit of every input pin of OrN", () => { + const environment = { outputBitWidths: { Out: [1] }, config: null }; + expect( + evaluateCombinational( + or_n, + { In: [testBits(0b00, 2), [true]] }, + environment, + ), + ).toEqual({ Out: [[true]] }); + expect( + evaluateCombinational( + or_n, + { In: [testBits(0b00, 2), [false]] }, + environment, + ), + ).toEqual({ Out: [[false]] }); + }); +}); + +describe("FlipFlop", () => { + it("is the only sequential intrinsic", () => { + const sequential = Object.values(definitions).filter( + (definition) => definition.behavior?.kind === "sequential", + ); + expect(sequential).toEqual([flipflop]); + }); + + it("starts from zero before anything has been fed to it", () => { + expect( + evaluateSequential(flipflop, null, { + outputBitWidths: { Out: [3] }, + config: null, + }), + ).toEqual({ Out: [testBits(0, 3)] }); + }); + + it("outputs its input of the previous time step", () => { + expect( + evaluateSequential( + flipflop, + { In: [testBits(0b101, 3)] }, + { outputBitWidths: { Out: [3] }, config: null }, + ), + ).toEqual({ Out: [testBits(0b101, 3)] }); + }); +}); + +describe("component boundaries", () => { + it("leaves Input and Output without a behavior to evaluate", () => { + expect(input.behavior).toBeNull(); + expect(output.behavior).toBeNull(); + }); +}); From 76cd1d089606cb0d638dcff36bc328547f5998f2 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 31 Oct 2026 12:28:17 +0900 Subject: [PATCH 6/6] refactor(simulation): replace the deprecated Opaque type with Tagged Follow #63 in the netlist module, which was added after the rest of the codebase had been migrated. Co-Authored-By: Claude Opus 5.5 --- src/store/netlist.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/store/netlist.ts b/src/store/netlist.ts index 893a725..120b81d 100644 --- a/src/store/netlist.ts +++ b/src/store/netlist.ts @@ -30,7 +30,7 @@ import nullthrows from "nullthrows"; import invariant from "tiny-invariant"; -import type { Opaque } from "type-fest"; +import type { Tagged } from "type-fest"; import type CCStore from "."; import type { CCComponentId } from "./component"; import type { CCComponentPinId } from "./componentPin"; @@ -44,10 +44,10 @@ import type { CCNode, CCNodeId } from "./node"; import type { CCNodePinId } from "./nodePin"; /** Identity of a net: the key of the terminal driving it. */ -export type SimulationNetId = Opaque; +export type SimulationNetId = Tagged; /** Identity of a terminal: the path of its node instance plus its node pin. */ -export type SimulationTerminalKey = Opaque; +export type SimulationTerminalKey = Tagged; /** * Key of the pin `nodePinId` of the node instance at `path`.