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) { @@ -152,12 +160,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,52 +181,62 @@ 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 = []; + 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( - nullthrows( + simulationCachedFrames.push( simulateComponent(store, componentId, inputValues, previousFrame), - ), - ); - isUpdated = true; + ); + 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, 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() { diff --git a/src/store/intrinsics/base.ts b/src/store/intrinsics/base.ts index d676c26..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 { SimulationFrame } 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: SimulationFrame | null; - currentFrame: SimulationFrame; - 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,7 +89,11 @@ type Props = { in: Record>; out: Record>; initialConfig: Spec["config"]; - evaluate: IntrinsicComponentEvaluationFunction; + /** + * null for `Input` and `Output`, which only carry a value across a component + * boundary and are resolved away before the simulation evaluates anything + */ + behavior: IntrinsicComponentBehavior | null; }; export class IntrinsicComponentDefinition< Spec extends CCIntrinsicComponentSpec = CCIntrinsicComponentSpec, @@ -90,7 +106,8 @@ export class IntrinsicComponentDefinition< readonly inputPin: Record; readonly outputPin: Record; readonly initialConfig: Spec["config"]; - readonly evaluate: IntrinsicComponentEvaluationFunction; + /** @see {@link Props.behavior} */ + readonly behavior: IntrinsicComponentBehavior | null; private static _lastIndex = 0; @@ -114,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), 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(); + }); +}); diff --git a/src/store/intrinsics/definitions.ts b/src/store/intrinsics/definitions.ts index 441f033..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,22 +299,16 @@ export const flipflop = 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 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), + ], + }), }, }); @@ -418,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_ = @@ -436,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 new file mode 100644 index 0000000..120b81d --- /dev/null +++ b/src/store/netlist.ts @@ -0,0 +1,528 @@ +/** + * 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 { Tagged } from "type-fest"; +import type CCStore from "."; +import type { CCComponentId } from "./component"; +import type { CCComponentPinId } from "./componentPin"; +import type { + IntrinsicComponentBehavior, + 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 = Tagged; + +/** Identity of a terminal: the path of its node instance plus its node pin. */ +export type SimulationTerminalKey = Tagged; + +/** + * 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"]; + /** pins grouped by the pin key of the definition (e.g. `A`, `In`), ordered by pin order */ + readonly inputShape: Record; + readonly outputShape: Record; + /** how the outputs are computed, taken from the definition */ + readonly behavior: IntrinsicComponentBehavior; +}; + +/** + * 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`, + ); + const behavior = nullthrows( + definition.behavior, + `Intrinsic component ${definition.name} cannot be evaluated`, + ); + instances.push({ + path: nodePath, + nodeId: node.id, + definition, + config: node.config, + inputShape: resolveShape( + nodePath, + node.id, + definition.inputPin, + instanceDefaultBitWidth, + ), + outputShape: resolveShape( + nodePath, + node.id, + definition.outputPin, + instanceDefaultBitWidth, + ), + behavior, + }); + } + }; + 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(); +} + +/** 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("/")})`; +} + +/** + * 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.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 + 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..bcadc28 100644 --- a/src/store/simulation.ts +++ b/src/store/simulation.ts @@ -1,294 +1,189 @@ +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 { CCComponentPin, CCComponentPinId } from "./componentPin"; +import type { CCComponentPinId } from "./componentPin"; import type { - CCComponentPinInstanceShapes, - CCIntrinsicComponentShape, - ComponentEvaluationContext, + IntrinsicEvaluationEnvironment, + IntrinsicPinValues, } from "./intrinsics/base"; -import { definitionByComponentId } from "./intrinsics/definitions"; import type { CCIntrinsicComponentSpec } from "./intrinsics/types"; +import { + buildSimulationNetlist, + describeInstance, + type SimulationInstance, + type SimulationNetId, + type SimulationNetlist, + 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 = { - componentId: CCComponentId; - nodes: Map< - CCNodeId, - { - pins: Map; - /** null if intrinsic */ - child: SimulationFrame | 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. + */ +export class SimulationFrame { + constructor( + readonly netlist: SimulationNetlist, + readonly netValues: ReadonlyMap, + ) {} + + get componentId(): CCComponentId { + return this.netlist.componentId; + } -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, + /** + * 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); + } + + /** + * 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), ); - 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 }); - } + return netId === undefined ? undefined : this.netValues.get(netId); } - 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 }; -} +const netlistCacheByStore = new WeakMap< + CCStore, + Map +>(); -function simulateIntrinsic( +/** + * The netlist of a component, rebuilt whenever the store changed since it was built. + */ +function getSimulationNetlist( 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); + 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; } -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); - } - const nodePins = store.nodePins.getManyByNodeId(nodeId); - const outerNodePinValues: Map = nullthrows( - context.currentFrame.nodes.get(nodeId)?.pins, +/** 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}`, + ), + ), ); +} - 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), - ); - if (componentPin.type === "input") { - if (!outerNodePinValues.has(outerNodePin.id)) { - return false; - } - inputValues.set( - componentPin.id, - nullthrows(outerNodePinValues.get(outerNodePin.id)), - ); - } - } - - 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, +/** Compute the outputs of one instance in the current time step. */ +function evaluateInstance( + instance: SimulationInstance, + 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, ); - if (nodePinBitWidthStatus.isFixed) { - childDefaultBitWidth = nodePinBitWidthStatus.bitWidth; - break; - } } - } - - const innerSimulationFrame = simulateComponent( - store, - component.id, - inputValues, - context.previousFrame - ? nullthrows(context.previousFrame.nodes.get(nodeId)).child - : null, - childDefaultBitWidth, - ); - - // Set output values for component to parent - for (const nodePin of nodePins) { - const componentPin = nullthrows( - store.componentPins.get(nodePin.componentPinId), + 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 }, ); - 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); - } } - - nullthrows(context.currentFrame.nodes.get(nodeId)).child = - innerSimulationFrame; - return true; } +/** + * 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); - - 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 nodePinInputNumber = new Map(); + const netlist = getSimulationNetlist(store, componentId, defaultBitWidth); + invariant( + previousFrame === null || previousFrame.netlist === netlist, + "The previous frame was simulated from another revision of the store", + ); - // 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; - } - } - nodePinInputNumber.set(child.id, inputPinNumber); - currentSimulationFrame.nodes.set(child.id, { - pins: new Map(), - child: null, - }); + const netValues = new Map(); + for (const [componentPinId, value] of inputValues) { + const netId = netlist.netByInputComponentPin.get(componentPinId); + if (netId !== undefined) netValues.set(netId, value); } - 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)), + for (const instance of netlist.instances) { + 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}`, ); - 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, + invariant( + values.length === pins.length, + `${describeInstance(instance)} output ${values.length} values for the ${pins.length} pins of ${key}`, ); - 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); - } - } + for (const [index, pin] of pins.entries()) { + netValues.set(pin.netId, nullthrows(values[index])); } - } else { - unevaluatedNodes.add(currentNodeId); } } - 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); +}