diff --git a/src/pages/edit/Editor/components/ContextMenu.tsx b/src/pages/edit/Editor/components/ContextMenu.tsx index 86d525d..cc0fbba 100644 --- a/src/pages/edit/Editor/components/ContextMenu.tsx +++ b/src/pages/edit/Editor/components/ContextMenu.tsx @@ -110,8 +110,12 @@ export default function CCComponentEditorContextMenu({ }); }, ); + // A connection in a bit width conflict is moved as it is, since the + // original is removed below for (const connection of newConnections) - store.connections.register(connection); + store.connections.register(connection, { + allowBitWidthConflict: true, + }); store.connections.unregister([ ...componentEditorState.selectedConnectionIds, ]); diff --git a/src/pages/edit/Editor/components/NodePinPropertyEditor.tsx b/src/pages/edit/Editor/components/NodePinPropertyEditor.tsx index 57db9ed..e8f78b5 100644 --- a/src/pages/edit/Editor/components/NodePinPropertyEditor.tsx +++ b/src/pages/edit/Editor/components/NodePinPropertyEditor.tsx @@ -4,7 +4,6 @@ import nullthrows from "nullthrows"; import { useState } from "react"; import invariant from "tiny-invariant"; import { rect } from "../../../../common/rect"; -import { CCConnectionStore } from "../../../../store/connection"; import { IntrinsicComponentDefinition } from "../../../../store/intrinsics/base"; import { CCNodePinStore } from "../../../../store/nodePin"; import { useStore } from "../../../../store/react"; @@ -116,30 +115,10 @@ export function CCComponentEditorNodePinPropertyEditor() { if (nodePin && bitWidth) { maxOrder = nodePin.order; // nodePins are sorted by order if (nodePin.manualBitWidth !== bitWidth) { + // A connection the new width conflicts with is kept and highlighted store.nodePins.update(nodePin.id, { manualBitWidth: bitWidth, }); - const connections = store.connections.getConnectionsByNodePinId( - nodePin.id, - ); - for (const connection of connections) { - const from = connection.from; - const to = connection.to; - const parentComponentId = connection.parentComponentId; - store.connections.unregister([connection.id]).then(() => { - if (store.nodePins.isConnectable(from, to)) { - // reconnect if still connectable after bit width change - store.connections.register( - CCConnectionStore.create({ - parentComponentId, - from, - to, - bentPortion: 0.5, - }), - ); - } - }); - } } continue; } diff --git a/src/pages/edit/Editor/components/SimulationErrorAlert.tsx b/src/pages/edit/Editor/components/SimulationErrorAlert.tsx index 575bd5e..54ab431 100644 --- a/src/pages/edit/Editor/components/SimulationErrorAlert.tsx +++ b/src/pages/edit/Editor/components/SimulationErrorAlert.tsx @@ -3,7 +3,8 @@ 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. + * to break it, because an input pin is left unconnected, or because connected pins + * disagree on their bit width. */ export default function CCComponentEditorSimulationErrorAlert() { const componentEditorState = useComponentEditorStore()(); diff --git a/src/pages/edit/Editor/renderer/Connection/index.tsx b/src/pages/edit/Editor/renderer/Connection/index.tsx index c4e466a..4ff2b66 100644 --- a/src/pages/edit/Editor/renderer/Connection/index.tsx +++ b/src/pages/edit/Editor/renderer/Connection/index.tsx @@ -5,7 +5,10 @@ import type { CCComponentPinType } from "../../../../../store/componentPin"; import type { CCConnectionId } from "../../../../../store/connection"; import { useStore } from "../../../../../store/react"; import ensureStoreItem from "../../../../../store/react/error"; -import { useNode } from "../../../../../store/react/selectors"; +import { + useIsConnectionInBitWidthConflict, + useNode, +} from "../../../../../store/react/selectors"; import { theme } from "../../../../../style"; import { useComponentEditorStore } from "../../store"; import { stringifySimulationValue } from "../../store/slices/core/index"; @@ -20,6 +23,8 @@ export type CCComponentEditorRendererConnectionCoreProps = { from: CCComponentEditorRendererConnectionEndpoint; to: CCComponentEditorRendererConnectionEndpoint; connectionId?: CCConnectionId; + /** whether the bit widths of the pins it ties together conflict */ + isInBitWidthConflict?: boolean; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; }; @@ -33,6 +38,7 @@ export function CCComponentEditorRendererConnectionCore({ from, to, connectionId, + isInBitWidthConflict = false, onMouseEnter, onMouseLeave, }: CCComponentEditorRendererConnectionCoreProps) { @@ -65,9 +71,12 @@ export function CCComponentEditorRendererConnectionCore({ connectionId && componentEditorState.selectedConnectionIds.has(connectionId) ? theme.colors.primary - : theme.colors.textPrimary + : isInBitWidthConflict + ? theme.colors.error + : theme.colors.textPrimary } strokeWidth="2" + strokeDasharray={isInBitWidthConflict ? "6 4" : undefined} fill="none" onClick={handleClick} onContextMenu={(e) => { @@ -121,6 +130,8 @@ const CCComponentEditorRendererConnection = ensureStoreItem( ); const [isHovered, setIsHovered] = useState(false); + const isInBitWidthConflict = + useIsConnectionInBitWidthConflict(connectionId); return ( <> @@ -131,6 +142,7 @@ const CCComponentEditorRendererConnection = ensureStoreItem( }} to={{ direction: toComponentPin.type, position: toNodePinPosition }} connectionId={connectionId} + isInBitWidthConflict={isInBitWidthConflict} onMouseEnter={() => { setIsHovered(true); }} diff --git a/src/store/bitWidth.test.ts b/src/store/bitWidth.test.ts new file mode 100644 index 0000000..aa94d2b --- /dev/null +++ b/src/store/bitWidth.test.ts @@ -0,0 +1,662 @@ +import nullthrows from "nullthrows"; +import { describe, expect, it } from "vitest"; +import type CCStore from "."; +import type { CCComponentId } from "./component"; +import { CCConnectionStore } from "./connection"; +import { + aggregate, + and, + broadcast, + const_, + display, + input, + not, +} from "./intrinsics/definitions"; +import { SimulationBitWidthConflictError } from "./netlist"; +import type { CCNodePinId } from "./nodePin"; +import simulateComponent from "./simulation"; +import { + connectTestNodePins, + createTestStore, + registerTestComponent, + registerTestInputNode, + registerTestNode, + registerTestOutputNode, + splitTestNodePin, + testBits, + testComponentPin, + testNodePin, + testNumber, +} from "./testUtils"; + +function registerConst( + store: CCStore, + parentComponentId: CCComponentId, + bitWidth: number, +) { + const node = registerTestNode(store, parentComponentId, const_.component.id, { + mode: "binary", + data: testBits(0, bitWidth), + }); + return { node, out: testNodePin(store, node, const_.outputPin.Out) }; +} + +function setConstBitWidth( + store: CCStore, + constant: ReturnType, + bitWidth: number, +) { + store.nodes.update(constant.node.id, { + config: { mode: "binary", data: testBits(0, bitWidth) }, + }); +} + +/** A display whose pixels input takes `width * height` bits. */ +function registerDisplay( + store: CCStore, + parentComponentId: CCComponentId, + width: number, + height: number, +) { + const node = registerTestNode( + store, + parentComponentId, + display.component.id, + { + resolution: { x: width, y: height }, + pixelOrder: "row-major", + }, + ); + return { node, pixels: testNodePin(store, node, display.inputPin.Pixels) }; +} + +/** `In` -> Not -> `Out`: a component whose two pins are tied to one bit width. */ +function registerInverterComponent(store: CCStore) { + const component = registerTestComponent(store, "Inverter"); + const into = registerTestInputNode(store, component.id, "In"); + const notNode = registerTestNode(store, component.id, not.component.id); + const out = registerTestOutputNode(store, component.id, "Out"); + connectTestNodePins( + store, + component.id, + into.out, + testNodePin(store, notNode, not.inputPin.In), + ); + connectTestNodePins( + store, + component.id, + testNodePin(store, notNode, not.outputPin.Out), + out.in, + ); + return { + component, + into: testComponentPin(store, component, "In"), + out: testComponentPin(store, component, "Out"), + }; +} + +/** Two independent inverters side by side, so `In1`/`Out1` and `In2`/`Out2`. */ +function registerDualInverterComponent(store: CCStore) { + const component = registerTestComponent(store, "DualInverter"); + for (const suffix of ["1", "2"]) { + const into = registerTestInputNode(store, component.id, `In${suffix}`); + const notNode = registerTestNode(store, component.id, not.component.id); + const out = registerTestOutputNode(store, component.id, `Out${suffix}`); + connectTestNodePins( + store, + component.id, + into.out, + testNodePin(store, notNode, not.inputPin.In), + ); + connectTestNodePins( + store, + component.id, + testNodePin(store, notNode, not.outputPin.Out), + out.in, + ); + } + return { + component, + in1: testComponentPin(store, component, "In1"), + out1: testComponentPin(store, component, "Out1"), + in2: testComponentPin(store, component, "In2"), + out2: testComponentPin(store, component, "Out2"), + }; +} + +function tryConnect( + store: CCStore, + parentComponentId: CCComponentId, + from: CCNodePinId, + to: CCNodePinId, +): boolean { + const connection = CCConnectionStore.create({ + from, + to, + parentComponentId, + bentPortion: 0.5, + }); + store.connections.register(connection); + return store.connections.get(connection.id) !== undefined; +} + +function conflictingConnectionCount(store: CCStore): number { + return store.connections + .getMany() + .filter((connection) => + store.connections.isInBitWidthConflict(connection.id), + ).length; +} + +describe("bit width groups", () => { + it("keeps a connection that a config change makes inconsistent and marks it", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 12); + const screen = registerDisplay(store, root.id, 4, 3); + connectTestNodePins(store, root.id, constant.out, screen.pixels); + expect(conflictingConnectionCount(store)).toBe(0); + + store.nodes.update(screen.node.id, { + config: { resolution: { x: 4, y: 2 }, pixelOrder: "row-major" }, + }); + + expect(store.connections.getMany()).toHaveLength(1); + expect(conflictingConnectionCount(store)).toBe(1); + + // Going back resolves the conflict without having to connect again + store.nodes.update(screen.node.id, { + config: { resolution: { x: 3, y: 4 }, pixelOrder: "row-major" }, + }); + expect(conflictingConnectionCount(store)).toBe(0); + }); + + it("marks the output of an aggregate node whose split inputs are resized", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const aggregateNode = registerTestNode( + store, + root.id, + aggregate.component.id, + ); + splitTestNodePin(store, aggregateNode, aggregate.inputPin.In, [4, 4]); + const screen = registerDisplay(store, root.id, 4, 2); + connectTestNodePins( + store, + root.id, + testNodePin(store, aggregateNode, aggregate.outputPin.Out), + screen.pixels, + ); + + store.nodePins.update( + testNodePin(store, aggregateNode, aggregate.inputPin.In, 1), + { manualBitWidth: 3 }, + ); + + expect(conflictingConnectionCount(store)).toBe(1); + }); + + it("marks the connections outside a component whose pin changes its width inside", () => { + const store = createTestStore(); + const source = registerTestComponent(store, "Source"); + const constant = registerConst(store, source.id, 8); + const sourceOut = registerTestOutputNode(store, source.id, "Out"); + connectTestNodePins(store, source.id, constant.out, sourceOut.in); + const root = registerTestComponent(store, "Root"); + const sourceNode = registerTestNode(store, root.id, source.id); + const screen = registerDisplay(store, root.id, 4, 2); + connectTestNodePins( + store, + root.id, + testNodePin(store, sourceNode, testComponentPin(store, source, "Out")), + screen.pixels, + ); + expect(conflictingConnectionCount(store)).toBe(0); + + setConstBitWidth(store, constant, 4); + + expect(conflictingConnectionCount(store)).toBe(1); + }); + + it("marks every connection of the pins tied to a conflicting width", () => { + // Const(8) -> Not -> Display(8), then the display shrinks. Which connection is + // wrong depends on the intent, so both are marked. + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 8); + const notNode = registerTestNode(store, root.id, not.component.id); + const screen = registerDisplay(store, root.id, 4, 2); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, notNode, not.inputPin.In), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, notNode, not.outputPin.Out), + screen.pixels, + ); + + store.nodes.update(screen.node.id, { + config: { resolution: { x: 2, y: 2 }, pixelOrder: "row-major" }, + }); + + expect(conflictingConnectionCount(store)).toBe(2); + // The pins fixing a width keep it; the pins between them have none to take + expect(store.nodePins.getNodePinBitWidthStatus(constant.out)).toEqual({ + isFixed: true, + bitWidth: 8, + }); + expect( + store.nodePins.getNodePinBitWidthStatus( + testNodePin(store, notNode, not.outputPin.Out), + ), + ).toEqual({ isFixed: false }); + }); + + it("resolves the conflict when one of the conflicting connections is removed", async () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 8); + const notNode = registerTestNode(store, root.id, not.component.id); + const screen = registerDisplay(store, root.id, 4, 2); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, notNode, not.inputPin.In), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, notNode, not.outputPin.Out), + screen.pixels, + ); + setConstBitWidth(store, constant, 4); + expect(conflictingConnectionCount(store)).toBe(2); + + const [toScreen] = store.connections.getConnectionsByNodePinId( + screen.pixels, + ); + await store.connections.unregister([nullthrows(toScreen).id]); + + expect(conflictingConnectionCount(store)).toBe(0); + }); + + it("lets a conflict grow only by pins that fix no width", () => { + // Const(8) -> And.A, And.Out -> Display(4), with And.B left open + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 8); + const andNode = registerTestNode(store, root.id, and.component.id); + const screen = registerDisplay(store, root.id, 4, 2); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, andNode, and.inputPin.A), + ); + const andOut = testNodePin(store, andNode, and.outputPin.Out); + const andB = testNodePin(store, andNode, and.inputPin.B); + connectTestNodePins(store, root.id, andOut, screen.pixels); + const probe = registerTestNode(store, root.id, not.component.id); + const sameWidth = registerConst(store, root.id, 8); + const otherWidth = registerConst(store, root.id, 4); + // Allowed before the conflict, so what is refused below is due to it + expect(store.nodePins.isConnectable(sameWidth.out, andB)).toBe(true); + store.nodes.update(screen.node.id, { + config: { resolution: { x: 2, y: 2 }, pixelOrder: "row-major" }, + }); + expect(conflictingConnectionCount(store)).toBe(2); + + // A node fixing no width takes part in the conflict, and leaves it once fixed + expect( + tryConnect( + store, + root.id, + andOut, + testNodePin(store, probe, not.inputPin.In), + ), + ).toBe(true); + expect(conflictingConnectionCount(store)).toBe(3); + // A pin fixing a width of its own is refused, even one the group has already + expect(tryConnect(store, root.id, sameWidth.out, andB)).toBe(false); + expect(tryConnect(store, root.id, otherWidth.out, andB)).toBe(false); + + store.nodes.update(screen.node.id, { + config: { resolution: { x: 4, y: 2 }, pixelOrder: "row-major" }, + }); + expect(conflictingConnectionCount(store)).toBe(0); + }); + + it("refuses a new connection that would tie conflicting widths together", () => { + const store = createTestStore(); + const inverter = registerInverterComponent(store); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 8); + const inverterNode = registerTestNode( + store, + root.id, + inverter.component.id, + ); + const screen = registerDisplay(store, root.id, 2, 2); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, inverterNode, inverter.into), + ); + + // The output of the inverter is tied to its input inside, so it is 8 bits wide + expect( + store.nodePins.getNodePinBitWidthStatus( + testNodePin(store, inverterNode, inverter.out), + ), + ).toEqual({ isFixed: true, bitWidth: 8 }); + expect( + tryConnect( + store, + root.id, + testNodePin(store, inverterNode, inverter.out), + screen.pixels, + ), + ).toBe(false); + }); + + it("keeps the independent paths of a component apart", () => { + const store = createTestStore(); + const dual = registerDualInverterComponent(store); + const root = registerTestComponent(store, "Root"); + const wide = registerConst(store, root.id, 8); + const narrow = registerConst(store, root.id, 4); + const dualNode = registerTestNode(store, root.id, dual.component.id); + const wideScreen = registerDisplay(store, root.id, 4, 2); + const narrowScreen = registerDisplay(store, root.id, 2, 2); + + for (const [from, to] of [ + [wide.out, testNodePin(store, dualNode, dual.in1)], + [narrow.out, testNodePin(store, dualNode, dual.in2)], + [testNodePin(store, dualNode, dual.out1), wideScreen.pixels], + [testNodePin(store, dualNode, dual.out2), narrowScreen.pixels], + ] as const) { + expect(tryConnect(store, root.id, from, to)).toBe(true); + } + expect(conflictingConnectionCount(store)).toBe(0); + }); + + it("accepts a connection inside a component that causes a conflict where it is used", () => { + // Only the component the connection is drawn in is checked, like a config change + const store = createTestStore(); + const component = registerTestComponent(store, "Open"); + const into = registerTestInputNode(store, component.id, "A"); + const out = registerTestOutputNode(store, component.id, "B"); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 8); + const node = registerTestNode(store, root.id, component.id); + const screen = registerDisplay(store, root.id, 2, 2); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, node, testComponentPin(store, component, "A")), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, node, testComponentPin(store, component, "B")), + screen.pixels, + ); + + expect(tryConnect(store, component.id, into.out, out.in)).toBe(true); + + expect(conflictingConnectionCount(store)).toBe(2); + expect(store.nodePins.getComponentBitWidths(root.id).hasConflict()).toBe( + true, + ); + }); + + it("copies a connection in conflict only when asked to keep it as it is", () => { + const store = createTestStore(); + const original = registerTestComponent(store, "Original"); + const constant = registerConst(store, original.id, 4); + const screen = registerDisplay(store, original.id, 2, 2); + connectTestNodePins(store, original.id, constant.out, screen.pixels); + setConstBitWidth(store, constant, 8); + const copy = registerTestComponent(store, "Copy"); + const copiedConstant = registerConst(store, copy.id, 8); + const copiedScreen = registerDisplay(store, copy.id, 2, 2); + const copied = CCConnectionStore.create({ + from: copiedConstant.out, + to: copiedScreen.pixels, + parentComponentId: copy.id, + bentPortion: 0.5, + }); + + store.connections.register(copied); + expect(store.connections.get(copied.id)).toBeUndefined(); + + store.connections.register(copied, { allowBitWidthConflict: true }); + expect(store.connections.isInBitWidthConflict(copied.id)).toBe(true); + }); + + it("answers bit width queries in the middle of a change", async () => { + // The editor queries on every change event, while a removal is half done + const store = createTestStore(); + const inverter = registerInverterComponent(store); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 8); + const inverterNode = registerTestNode( + store, + root.id, + inverter.component.id, + ); + const aggregateNode = registerTestNode( + store, + root.id, + aggregate.component.id, + ); + splitTestNodePin(store, aggregateNode, aggregate.inputPin.In, [8, 4]); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, inverterNode, inverter.into), + ); + connectTestNodePins( + store, + root.id, + testNodePin(store, inverterNode, inverter.out), + testNodePin(store, aggregateNode, aggregate.inputPin.In, 0), + ); + const errors: unknown[] = []; + store.subscribeToCircuitChanges(() => { + try { + for (const connection of store.connections.getMany()) { + store.connections.isInBitWidthConflict(connection.id); + } + for (const nodePin of store.nodePins.getMany()) { + store.nodePins.getNodePinBitWidthStatus(nodePin.id); + } + } catch (error) { + errors.push(error); + } + }); + + await store.nodePins.unregister( + testNodePin(store, aggregateNode, aggregate.inputPin.In, 1), + ); + const innerInput = nullthrows( + store.nodes + .getManyByParentComponentId(inverter.component.id) + .find((node) => node.componentId === input.component.id), + ); + await store.nodes.unregister([innerInput.id]); + await store.components.unregister(inverter.component.id); + + expect(errors).toEqual([]); + expect(store.connections.getMany()).toHaveLength(0); + }); +}); + +describe("simulation of bit width groups", () => { + it("refuses to simulate a component with a bit width conflict", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const constant = registerConst(store, root.id, 4); + const screen = registerDisplay(store, root.id, 2, 2); + connectTestNodePins(store, root.id, constant.out, screen.pixels); + setConstBitWidth(store, constant, 8); + + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + SimulationBitWidthConflictError, + ); + }); + + it("refuses to simulate a component using one with a bit width conflict", () => { + const store = createTestStore(); + const inner = registerTestComponent(store, "Inner"); + const constant = registerConst(store, inner.id, 4); + const screen = registerDisplay(store, inner.id, 2, 2); + connectTestNodePins(store, inner.id, constant.out, screen.pixels); + setConstBitWidth(store, constant, 8); + const root = registerTestComponent(store, "Root"); + registerTestNode(store, root.id, inner.id); + + expect(() => simulateComponent(store, root.id, new Map())).toThrow( + /in Inner do not match/, + ); + }); + + it("gives each independent path of a component the width it is used with", () => { + const store = createTestStore(); + const dual = registerDualInverterComponent(store); + const root = registerTestComponent(store, "Root"); + const wide = registerConst(store, root.id, 8); + const narrow = registerConst(store, root.id, 2); + const dualNode = registerTestNode(store, root.id, dual.component.id); + connectTestNodePins( + store, + root.id, + wide.out, + testNodePin(store, dualNode, dual.in1), + ); + connectTestNodePins( + store, + root.id, + narrow.out, + testNodePin(store, dualNode, dual.in2), + ); + + const frame = simulateComponent(store, root.id, new Map()); + + expect( + testNumber( + frame.getNodePinValue(testNodePin(store, dualNode, dual.out1)), + ), + ).toBe(0b11111111); + expect( + frame.getNodePinValue(testNodePin(store, dualNode, dual.out2)), + ).toEqual(testBits(0b11, 2)); + }); + + it("gives each use of a component the width it is used with", () => { + // Root -> Wrapper -> Inverter, with the wrapper used once for 8 and once for 3 bits + const store = createTestStore(); + const inverter = registerInverterComponent(store); + const wrapper = registerTestComponent(store, "Wrapper"); + const wrapperIn = registerTestInputNode(store, wrapper.id, "In"); + const wrapperOut = registerTestOutputNode(store, wrapper.id, "Out"); + const inner = registerTestNode(store, wrapper.id, inverter.component.id); + connectTestNodePins( + store, + wrapper.id, + wrapperIn.out, + testNodePin(store, inner, inverter.into), + ); + connectTestNodePins( + store, + wrapper.id, + testNodePin(store, inner, inverter.out), + wrapperOut.in, + ); + const root = registerTestComponent(store, "Root"); + const outs = [8, 3].map((bitWidth) => { + const constant = registerConst(store, root.id, bitWidth); + const node = registerTestNode(store, root.id, wrapper.id); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, node, testComponentPin(store, wrapper, "In")), + ); + return testNodePin(store, node, testComponentPin(store, wrapper, "Out")); + }); + + const frame = simulateComponent(store, root.id, new Map()); + + expect(outs.map((out) => frame.getNodePinValue(out))).toEqual([ + testBits(0b11111111, 8), + testBits(0b111, 3), + ]); + }); + + it("expects an input value as wide as the pins tied to its input pin", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const into = registerTestInputNode(store, root.id, "In"); + const constant = registerConst(store, root.id, 4); + const andNode = registerTestNode(store, root.id, and.component.id); + connectTestNodePins( + store, + root.id, + into.out, + testNodePin(store, andNode, and.inputPin.A), + ); + connectTestNodePins( + store, + root.id, + constant.out, + testNodePin(store, andNode, and.inputPin.B), + ); + + // The editor sizes the input value by this status, so it must match the netlist + expect(store.nodePins.getNodePinBitWidthStatus(into.out)).toEqual({ + isFixed: true, + bitWidth: 4, + }); + expect(() => + simulateComponent( + store, + root.id, + new Map([[into.componentPinId, testBits(0b1010, 4)]]), + ), + ).not.toThrow(); + }); + + it("rejects an input value that does not fit its pin", () => { + const store = createTestStore(); + const root = registerTestComponent(store, "Root"); + const into = registerTestInputNode(store, root.id, "In"); + const broadcastNode = registerTestNode( + store, + root.id, + broadcast.component.id, + ); + connectTestNodePins( + store, + root.id, + into.out, + testNodePin(store, broadcastNode, broadcast.inputPin.In), + ); + + expect(() => + simulateComponent( + store, + root.id, + new Map([[into.componentPinId, testBits(0b101, 3)]]), + ), + ).toThrow(/carries 3 bits, but the pin is 1 bits wide/); + }); +}); diff --git a/src/store/bitWidth.ts b/src/store/bitWidth.ts new file mode 100644 index 0000000..8d17bf9 --- /dev/null +++ b/src/store/bitWidth.ts @@ -0,0 +1,296 @@ +/** + * Bit widths of the pins of a component. + * + * The pins that always carry values of the same width form a **bit width group**. Within + * one component, two pins share a group when + * + * - a connection ties them together, + * - they are inferred pins of the same intrinsic node (e.g. `A`, `B` and `Out` of `And`), + * or + * - they are pins of the same user-defined node whose implementations share a group + * inside that component (e.g. the input and output of a component passing a value + * through). Pins of independent paths inside it stay in separate groups. + * + * Some pins fix their own width: a configurable pin by its manual bit width, a calculated + * pin by the config of its node, and a pin of a user-defined node by the group of its + * implementation when that group is fixed. A group takes the width its pins fix. When + * they fix more than one width, the group is in **conflict**: its connections cannot + * carry a value, so the editor highlights them and the simulation refuses to run. + */ + +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 { CCNodePinBitWidthStatus } from "./componentPin"; +import { IntrinsicComponentDefinition } from "./intrinsics/base"; +import type { CCNodePin, CCNodePinId } from "./nodePin"; + +/** Identity of a bit width group: the id of one pin representing it. */ +export type BitWidthGroupId = Tagged; + +/** The bit width of a node pin of a configurable component pin. */ +function requireManualBitWidth(nodePin: CCNodePin): number { + invariant( + nodePin.manualBitWidth !== null && nodePin.manualBitWidth > 0, + `Node pin ${nodePin.id} of a configurable component pin must have a positive manual bit width, but got ${nodePin.manualBitWidth}`, + ); + return nodePin.manualBitWidth; +} + +/** + * Collect the manually specified bit widths of the pins of a single node, grouped by + * the pin key of its intrinsic component definition (e.g. `In`, `Out`) and ordered by + * the order of the node pins. + * @param nodePins all pins of one node + * @returns the bit widths of the pins of configurable component pins + */ +function collectManualBitWidths( + nodePins: readonly CCNodePin[], +): Partial> { + const manualBitWidths: Partial> = {}; + for (const nodePin of nodePins.toSorted((a, b) => a.order - b.order)) { + const attributes = IntrinsicComponentDefinition.getPinAttributesByPinId( + nodePin.componentPinId, + ); + // Only a configurable pin carries a manual bit width; the others are null + if (attributes?.bitWidthPolicy.type !== "configurable") continue; + const bitWidths = manualBitWidths[attributes.key] ?? []; + bitWidths.push(requireManualBitWidth(nodePin)); + manualBitWidths[attributes.key] = bitWidths; + } + return manualBitWidths; +} + +/** Disjoint sets of node pins, merged by {@link union}. */ +class NodePinUnionFind { + readonly #parent = new Map(); + + find(nodePinId: CCNodePinId): CCNodePinId { + let root = nodePinId; + for (;;) { + const parent = this.#parent.get(root); + if (parent === undefined || parent === root) break; + root = parent; + } + // Compress the path so that later lookups reach the root at once + let current = nodePinId; + while (current !== root) { + const next = nullthrows(this.#parent.get(current)); + this.#parent.set(current, root); + current = next; + } + return root; + } + + union(a: CCNodePinId, b: CCNodePinId): void { + const rootA = this.find(a); + const rootB = this.find(b); + if (rootA !== rootB) this.#parent.set(rootA, rootB); + } +} + +/** The bit width groups of the pins of the nodes inside one component. */ +export class ComponentBitWidths { + readonly #groupByNodePin: ReadonlyMap; + + /** the width a pin fixes by itself, regardless of what it is connected to */ + readonly #ownBitWidthByNodePin: ReadonlyMap; + + readonly #bitWidthsByGroup: ReadonlyMap>; + + constructor( + readonly componentId: CCComponentId, + groupByNodePin: ReadonlyMap, + ownBitWidthByNodePin: ReadonlyMap, + ) { + this.#groupByNodePin = groupByNodePin; + this.#ownBitWidthByNodePin = ownBitWidthByNodePin; + const bitWidthsByGroup = new Map>(); + for (const [nodePinId, groupId] of groupByNodePin) { + let bitWidths = bitWidthsByGroup.get(groupId); + if (!bitWidths) { + bitWidths = new Set(); + bitWidthsByGroup.set(groupId, bitWidths); + } + const ownBitWidth = ownBitWidthByNodePin.get(nodePinId); + if (ownBitWidth !== undefined) bitWidths.add(ownBitWidth); + } + this.#bitWidthsByGroup = bitWidthsByGroup; + } + + /** The group of a pin of a node inside the component. */ + getGroup(nodePinId: CCNodePinId): BitWidthGroupId { + return nullthrows( + this.#groupByNodePin.get(nodePinId), + `Node pin ${nodePinId} is not a pin of a node in component ${this.componentId}`, + ); + } + + /** + * The bit width of a pin. A pin fixing its own width keeps it even when its group is + * in conflict, because that is the width it produces or expects; the other pins of a + * group in conflict have no width to take. + */ + getStatus(nodePinId: CCNodePinId): CCNodePinBitWidthStatus { + const ownBitWidth = this.#ownBitWidthByNodePin.get(nodePinId); + if (ownBitWidth !== undefined) + return { isFixed: true, bitWidth: ownBitWidth }; + const bitWidths = this.#getBitWidths(this.getGroup(nodePinId)); + const [bitWidth] = bitWidths; + return bitWidths.size === 1 && bitWidth !== undefined + ? { isFixed: true, bitWidth } + : { isFixed: false }; + } + + /** Whether the pins of the group of a pin fix different widths. */ + isInConflict(nodePinId: CCNodePinId): boolean { + return this.#getBitWidths(this.getGroup(nodePinId)).size > 1; + } + + /** Whether any group of the component is in conflict. */ + hasConflict(): boolean { + for (const bitWidths of this.#bitWidthsByGroup.values()) { + if (bitWidths.size > 1) return true; + } + return false; + } + + /** + * Whether two pins may be tied together. It is refused only when it brings a width + * into a group that does not have it yet and leaves that group in conflict. + * + * A group already in conflict may still grow by pins fixing no width, e.g. a node + * appended to probe it. They take whatever width the group settles on, so fixing + * the original conflict fixes them too. A pin fixing a width of its own is refused + * even if the group has that width already, since it would stay in conflict when + * the conflict is fixed the other way. + */ + canTie(a: CCNodePinId, b: CCNodePinId): boolean { + const groupA = this.getGroup(a); + const groupB = this.getGroup(b); + if (groupA === groupB) return true; + const bitWidthsA = this.#getBitWidths(groupA); + const bitWidthsB = this.#getBitWidths(groupB); + if (bitWidthsA.size === 0 || bitWidthsB.size === 0) return true; + return new Set([...bitWidthsA, ...bitWidthsB]).size <= 1; + } + + #getBitWidths(groupId: BitWidthGroupId): ReadonlySet { + return nullthrows(this.#bitWidthsByGroup.get(groupId)); + } +} + +/** + * Group the pins of the nodes inside a component by bit width. + * @param store store to read the circuit from + * @param componentId a user-defined component + * @param getChildBitWidths the groups of a user-defined component used by a node inside + * @returns the groups of the component + */ +export function analyzeComponentBitWidths( + store: CCStore, + componentId: CCComponentId, + getChildBitWidths: (componentId: CCComponentId) => ComponentBitWidths, +): ComponentBitWidths { + const nodes = store.nodes.getManyByParentComponentId(componentId); + const nodeIds = new Set(nodes.map((node) => node.id)); + const nodePinsByNode = Map.groupBy( + store.nodePins.getMany().filter((nodePin) => nodeIds.has(nodePin.nodeId)), + (nodePin) => nodePin.nodeId, + ); + + const unionFind = new NodePinUnionFind(); + const ownBitWidthByNodePin = new Map(); + for (const node of nodes) { + const nodePins = nodePinsByNode.get(node.id) ?? []; + const component = nullthrows(store.components.get(node.componentId)); + if (component.intrinsicType) { + let firstInferredNodePin: CCNodePinId | null = null; + for (const nodePin of nodePins) { + const attributes = nullthrows( + IntrinsicComponentDefinition.getPinAttributesByPinId( + nodePin.componentPinId, + ), + `Pin ${nodePin.componentPinId} of an intrinsic component has no attributes`, + ); + const policy = attributes.bitWidthPolicy; + switch (policy.type) { + case "configurable": + ownBitWidthByNodePin.set( + nodePin.id, + requireManualBitWidth(nodePin), + ); + break; + case "calculated": + ownBitWidthByNodePin.set( + nodePin.id, + policy.calculateBitWidth( + node.config, + collectManualBitWidths(nodePins), + ), + ); + break; + case "inferred": + // The inferred pins of an intrinsic node all carry the same width + if (firstInferredNodePin === null) + firstInferredNodePin = nodePin.id; + else unionFind.union(firstInferredNodePin, nodePin.id); + break; + default: + throw new Error( + `Unknown bit width policy: ${policy satisfies never}`, + ); + } + } + continue; + } + + const childBitWidths = getChildBitWidths(node.componentId); + /** a pin of this node per group inside the child, to tie the others to */ + const nodePinByChildGroup = new Map(); + for (const nodePin of nodePins) { + const componentPin = nullthrows( + store.componentPins.get(nodePin.componentPinId), + ); + const implementation = nullthrows( + componentPin.implementation, + `Pin ${componentPin.id} of a user-defined component has no implementation`, + ); + const implementationStatus = childBitWidths.getStatus(implementation); + if (implementationStatus.isFixed) { + ownBitWidthByNodePin.set(nodePin.id, implementationStatus.bitWidth); + continue; + } + const childGroup = childBitWidths.getGroup(implementation); + const tiedNodePin = nodePinByChildGroup.get(childGroup); + if (tiedNodePin === undefined) { + nodePinByChildGroup.set(childGroup, nodePin.id); + } else { + unionFind.union(tiedNodePin, nodePin.id); + } + } + } + + for (const connection of store.connections.getManyByParentComponentId( + componentId, + )) { + unionFind.union(connection.from, connection.to); + } + + const groupByNodePin = new Map(); + for (const nodePins of nodePinsByNode.values()) { + for (const nodePin of nodePins) { + groupByNodePin.set( + nodePin.id, + unionFind.find(nodePin.id) as string as BitWidthGroupId, + ); + } + } + return new ComponentBitWidths( + componentId, + groupByNodePin, + ownBitWidthByNodePin, + ); +} diff --git a/src/store/connection.ts b/src/store/connection.ts index 8ba1a8b..7af4615 100644 --- a/src/store/connection.ts +++ b/src/store/connection.ts @@ -46,65 +46,25 @@ export class CCConnectionStore extends CCBaseStore< this.unregister(connections.map((connection) => connection.id)); } }); - // A config change can change the bit width of a pin (e.g. the resolution of a - // display), which invalidates the connections that were valid when they were made. - // CCStore mounts CCNodePinStore first, so its bit width cache is already dropped. - this.store.nodes.on("didUpdateConfig", (node) => { - // TODO: Dropping a connection can in turn invalidate another one. Resolving that - // needs a repeated sweep, which the re-validation on `didRegister` below lacks too. - const invalidatedConnections = this.getMany().filter( - (connection) => - connection.parentComponentId === node.parentComponentId && - !this.store.nodePins.hasCompatibleBitWidths( - connection.from, - connection.to, - ), - ); - if (invalidatedConnections.length > 0) { - this.unregister( - invalidatedConnections.map((connection) => connection.id), - ); - } - }); - this.store.connections.on("didRegister", (connection) => { - const component = nullthrows( - this.store.components.get(connection.parentComponentId), - ); - const nodes = this.store.nodes.getManyByComponentId(component.id); - for (const node of nodes) { - const nodePins = this.store.nodePins.getManyByNodeId(node.id); - for (const nodePin of nodePins) { - const connections = this.getConnectionsByNodePinId(nodePin.id); - for (const connection of connections) { - const parentComponentId = connection.parentComponentId; - const from = connection.from; - const to = connection.to; - this.unregister([connection.id]).then(() => { - if (this.store.nodePins.isConnectable(from, to)) { - this.register( - CCConnectionStore.create({ - from, - to, - parentComponentId, - bentPortion: 0.5, - }), - ); - } - }); - } - } - } - }); } /** - * Register a connection + * Register a connection, unless its pins cannot be connected * @param connection connection to be registered + * @param options.allowBitWidthConflict keep the connection even if its pins fix + * different bit widths, to copy one that is in conflict as it is */ - register(connection: CCConnection): void { - const fromNodePinId = connection.from; - const toNodePinId = connection.to; - if (!this.store.nodePins.isConnectable(fromNodePinId, toNodePinId)) { + register( + connection: CCConnection, + options: { allowBitWidthConflict?: boolean } = {}, + ): void { + if ( + !this.store.nodePins.isConnectable( + connection.from, + connection.to, + options, + ) + ) { return; } this.objects.set(connection.id, connection); @@ -134,19 +94,21 @@ export class CCConnectionStore extends CCBaseStore< } /** - * Get all of connections - * @returns map of id and connection (read only) + * Whether the pins a connection ties together fix different bit widths. Such a + * connection is kept, since a later change can resolve the conflict, but it cannot + * carry a value, so the editor highlights it and the simulation refuses to run. + * @param id id of the connection */ - getConnectionIdsByParentComponentId( - parentComponentId: CCComponentId, - ): CCConnectionId[] { - return [...this.objects.values()] - .filter( - (connection) => connection.parentComponentId === parentComponentId, - ) - .map((connection) => connection.id); + isInBitWidthConflict(id: CCConnectionId): boolean { + const connection = nullthrows(this.objects.get(id)); + return this.store.nodePins.isInBitWidthConflict(connection.from); } + /** + * Get the connections inside a component + * @param parentComponentId id of the component + * @returns connections inside the component + */ getManyByParentComponentId(parentComponentId: CCComponentId): CCConnection[] { return [...this.objects.values()].filter( (connection) => connection.parentComponentId === parentComponentId, diff --git a/src/store/index.ts b/src/store/index.ts index fcf6621..fb79db7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -66,30 +66,46 @@ export default class CCStore { } } - const countRevision = () => { + this.subscribeToCircuitChanges(() => { this.#revision += 1; + }); + } + + /** + * Listen to every change of the circuit, i.e. of what the store {@link revision} + * counts. Anything derived from more than one object, such as a bit width that a + * change in another component can alter, has to be recomputed on any of them. + * @param listener called synchronously on each change + * @returns a function to stop listening + */ + subscribeToCircuitChanges(listener: () => void): () => void { + const subscriptions: (() => void)[] = []; + const subscribe = ( + emitter: { + on(event: keyof Events, listener: () => void): unknown; + off(event: keyof Events, listener: () => void): unknown; + }, + eventTypes: readonly (keyof Events)[], + ) => { + for (const eventType of eventTypes) { + emitter.on(eventType, listener); + subscriptions.push(() => emitter.off(eventType, listener)); + } }; - for (const eventType of ccComponentStoreChangeEventTypes) { - this.components.on(eventType, countRevision); - } - for (const eventType of ccComponentPinStoreChangeEventTypes) { - this.componentPins.on(eventType, countRevision); - } + subscribe(this.components, ccComponentStoreChangeEventTypes); + subscribe(this.componentPins, ccComponentPinStoreChangeEventTypes); // `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 [ + subscribe(this.nodes, [ "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); - } + ] as const); + subscribe(this.nodePins, ccNodePinStoreChangeEventTypes); + subscribe(this.connections, ccConnectionStoreChangeEventTypes); + return () => { + for (const unsubscribe of subscriptions) unsubscribe(); + }; } mount() { @@ -115,6 +131,11 @@ export default class CCStore { }); } + /** + * Restore the objects of a store saved with {@link toJSON}. Some stores add them + * without announcing them, so this must run before {@link mount}, while nothing + * derived from them (such as the bit widths cached by the node pin store) exists yet. + */ importJson(json: string) { const { components, nodes, componentPins, nodePins, connections } = JSON.parse(json); diff --git a/src/store/netlist.ts b/src/store/netlist.ts index 1404551..c62daee 100644 --- a/src/store/netlist.ts +++ b/src/store/netlist.ts @@ -32,6 +32,7 @@ import nullthrows from "nullthrows"; import invariant from "tiny-invariant"; import type { Tagged } from "type-fest"; import type CCStore from "."; +import type { BitWidthGroupId } from "./bitWidth"; import type { CCComponentId } from "./component"; import type { CCComponentPinId } from "./componentPin"; import type { @@ -145,39 +146,33 @@ export class SimulationUnconnectedPinError extends Error { } /** - * 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. + * Thrown when the pins tied together in a component fix different bit widths. The + * editor highlights the connections between them. */ -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; - } - } +export class SimulationBitWidthConflictError extends Error { + constructor( + readonly componentId: CCComponentId, + componentName: string, + ) { + super( + `The bit widths of some connected pins in ${componentName} do not match. Fix the highlighted connections.`, + ); + this.name = "SimulationBitWidthConflictError"; } - return parentDefaultBitWidth; } +/** + * The bit widths that the surroundings of a node instance give to the groups inside its + * component that do not fix a width by themselves. + */ +type BitWidthByGroup = ReadonlyMap; + /** * 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 + * @param defaultBitWidth bit width of the pins whose width neither the circuit nor the + * pins of the component around them fix * @returns the flattened netlist, ready to be evaluated time step by time step */ export function buildSimulationNetlist( @@ -285,25 +280,18 @@ export function buildSimulationNetlist( path: readonly CCNodeId[], nodeId: CCNodeId, pinByKey: Record, - instanceDefaultBitWidth: number, + getBitWidth: (nodePinId: CCNodePinId) => 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, - }; - }); + .map((nodePin) => ({ + nodePinId: nodePin.id, + netId: resolveNet(path, nodePin.id), + bitWidth: getBitWidth(nodePin.id), + })); } return shape; }; @@ -311,13 +299,29 @@ export function buildSimulationNetlist( const visit = ( path: readonly CCNodeId[], ownerComponentId: CCComponentId, - instanceDefaultBitWidth: number, + bitWidthByGroup: BitWidthByGroup, componentStack: readonly CCComponentId[], ) => { invariant( !componentStack.includes(ownerComponentId), `Component ${ownerComponentId} contains itself`, ); + const bitWidths = store.nodePins.getComponentBitWidths(ownerComponentId); + if (bitWidths.hasConflict()) { + const ownerComponent = nullthrows(store.components.get(ownerComponentId)); + throw new SimulationBitWidthConflictError( + ownerComponentId, + ownerComponent.name, + ); + } + /** a group fixing no width takes it from the surroundings, or else the default */ + const getBitWidth = (nodePinId: CCNodePinId): number => { + const status = bitWidths.getStatus(nodePinId); + if (status.isFixed) return status.bitWidth; + return ( + bitWidthByGroup.get(bitWidths.getGroup(nodePinId)) ?? defaultBitWidth + ); + }; for (const node of store.nodes.getManyByParentComponentId( ownerComponentId, )) { @@ -336,12 +340,26 @@ export function buildSimulationNetlist( const component = nullthrows(store.components.get(node.componentId)); if (!component.intrinsicType) { - visit( - nodePath, + // Each pin of the node hands its width to the group of its implementation + const childBitWidths = store.nodePins.getComponentBitWidths( node.componentId, - resolveInstanceDefaultBitWidth(store, node, instanceDefaultBitWidth), - [...componentStack, ownerComponentId], ); + const childBitWidthByGroup = new Map(); + for (const nodePin of store.nodePins.getManyByNodeId(node.id)) { + const implementation = nullthrows( + nullthrows(store.componentPins.get(nodePin.componentPinId)) + .implementation, + `Pin ${nodePin.componentPinId} of a user-defined component has no implementation`, + ); + childBitWidthByGroup.set( + childBitWidths.getGroup(implementation), + getBitWidth(nodePin.id), + ); + } + visit(nodePath, node.componentId, childBitWidthByGroup, [ + ...componentStack, + ownerComponentId, + ]); continue; } if ( @@ -368,19 +386,19 @@ export function buildSimulationNetlist( nodePath, node.id, definition.inputPin, - instanceDefaultBitWidth, + getBitWidth, ), outputShape: resolveShape( nodePath, node.id, definition.outputPin, - instanceDefaultBitWidth, + getBitWidth, ), behavior, }); } }; - visit([], componentId, defaultBitWidth, []); + visit([], componentId, new Map(), []); // Nets of the pins the editor renders, and of the values supplied from the outside const netByNodePin = new Map(); diff --git a/src/store/nodePin.test.ts b/src/store/nodePin.test.ts index ddf6f4c..91df6d0 100644 --- a/src/store/nodePin.test.ts +++ b/src/store/nodePin.test.ts @@ -254,86 +254,4 @@ describe("Node pin store", () => { ); }); }); - - describe("bit width consistency of connections", () => { - function connectBroadcastToDisplay(bitWidth: number) { - const { store, rootComponent } = createRootStore(); - const displayNode = registerNode( - store, - rootComponent, - intrinsics.display, - ); - const broadcastNode = registerNode( - store, - rootComponent, - intrinsics.broadcast, - ); - const pixelsNodePin = nullthrows( - store.nodePins - .getManyByNodeId(displayNode.id) - .find( - (pin) => - pin.componentPinId === intrinsics.display.inputPin.Pixels.id, - ), - ); - const broadcastOutNodePin = nullthrows( - store.nodePins - .getManyByNodeId(broadcastNode.id) - .find( - (pin) => - pin.componentPinId === intrinsics.broadcast.outputPin.Out.id, - ), - ); - store.nodePins.update(broadcastOutNodePin.id, { - manualBitWidth: bitWidth, - }); - store.connections.register( - CCConnectionStore.create({ - parentComponentId: rootComponent.id, - from: broadcastOutNodePin.id, - to: pixelsNodePin.id, - bentPortion: 0.5, - }), - ); - expect(store.connections.getMany()).toHaveLength(1); - return { store, displayNode }; - } - - it("should drop the connections that a display config change makes inconsistent", () => { - const { store, displayNode } = connectBroadcastToDisplay(20 * 15); - - store.nodes.update(displayNode.id, { - config: { resolution: { x: 4, y: 3 } }, - }); - - expect(store.connections.getMany()).toHaveLength(0); - }); - - it("should announce a dropped connection before the config change that drops it", () => { - const { store, displayNode } = connectBroadcastToDisplay(20 * 15); - const events: string[] = []; - store.connections.on("didUnregister", () => - events.push("connection removed"), - ); - store.nodes.on("didUpdate", () => events.push("node updated")); - - // A renderer of the connection subscribed to the display re-renders on "node - // updated", so by then the list of connections must no longer contain it - store.nodes.update(displayNode.id, { - config: { resolution: { x: 4, y: 3 } }, - }); - - expect(events).toEqual(["connection removed", "node updated"]); - }); - - it("should keep the connections that stay consistent across a display config change", () => { - const { store, displayNode } = connectBroadcastToDisplay(20 * 15); - - store.nodes.update(displayNode.id, { - config: { resolution: { x: 15, y: 20 } }, - }); - - expect(store.connections.getMany()).toHaveLength(1); - }); - }); }); diff --git a/src/store/nodePin.ts b/src/store/nodePin.ts index 6862b3b..32a0ecf 100644 --- a/src/store/nodePin.ts +++ b/src/store/nodePin.ts @@ -2,6 +2,8 @@ import nullthrows from "nullthrows"; import invariant from "tiny-invariant"; import type { Tagged } from "type-fest"; import { CCBaseStore, type CCBaseStoreEvents } from "./base"; +import { analyzeComponentBitWidths, type ComponentBitWidths } from "./bitWidth"; +import type { CCComponentId } from "./component"; import type { CCComponentPinId, CCNodePinBitWidthStatus } from "./componentPin"; import { IntrinsicComponentDefinition } from "./intrinsics/base"; import type { CCNodeId } from "./node"; @@ -32,10 +34,13 @@ export class CCNodePinStore extends CCBaseStore< > { #markedAsDeleted: Set = new Set(); - #bitWidthCache: Map = new Map(); + #bitWidthsByComponent: Map = new Map(); + + /** components whose bit widths are being analyzed, to catch one containing itself */ + #componentsBeingAnalyzed: Set = new Set(); #clearBitWidthCache(): void { - this.#bitWidthCache.clear(); + this.#bitWidthsByComponent.clear(); } import(nodePins: CCNodePin[]) { @@ -167,146 +172,79 @@ export class CCNodePinStore extends CCBaseStore< ); } - static #requireManualBitWidth(nodePin: CCNodePin): number { - invariant( - nodePin.manualBitWidth !== null && nodePin.manualBitWidth > 0, - `Node pin ${nodePin.id} of a configurable component pin must have a positive manual bit width, but got ${nodePin.manualBitWidth}`, - ); - return nodePin.manualBitWidth; - } - /** - * Collect the manually specified bit widths of the pins of a single node, grouped by - * the pin key of its intrinsic component definition (e.g. `In`, `Out`) and ordered by - * the order of the node pins. - * @param nodePins all pins of one node - * @returns the bit widths of the pins of configurable component pins + * The bit width groups of the pins of the nodes inside a user-defined component. + * They are cached until the circuit changes. + * @param componentId a user-defined component */ - static #collectManualBitWidths( - nodePins: CCNodePin[], - ): Partial> { - const manualBitWidths: Partial> = {}; - for (const nodePin of nodePins.toSorted((a, b) => a.order - b.order)) { - const attributes = IntrinsicComponentDefinition.getPinAttributesByPinId( - nodePin.componentPinId, + getComponentBitWidths(componentId: CCComponentId): ComponentBitWidths { + const cached = this.#bitWidthsByComponent.get(componentId); + if (cached) return cached; + invariant( + !this.#componentsBeingAnalyzed.has(componentId), + `Component ${componentId} contains itself`, + ); + this.#componentsBeingAnalyzed.add(componentId); + try { + const bitWidths = analyzeComponentBitWidths( + this.store, + componentId, + (childComponentId) => this.getComponentBitWidths(childComponentId), ); - // Only a configurable pin carries a manual bit width; the others are null - if (attributes?.bitWidthPolicy.type !== "configurable") continue; - const bitWidths = manualBitWidths[attributes.key] ?? []; - bitWidths.push(CCNodePinStore.#requireManualBitWidth(nodePin)); - manualBitWidths[attributes.key] = bitWidths; + this.#bitWidthsByComponent.set(componentId, bitWidths); + return bitWidths; + } finally { + this.#componentsBeingAnalyzed.delete(componentId); } - return manualBitWidths; + } + + #getBitWidthsAround(nodePinId: CCNodePinId): ComponentBitWidths { + const nodePin = nullthrows(this.get(nodePinId)); + const node = nullthrows(this.store.nodes.get(nodePin.nodeId)); + return this.getComponentBitWidths(node.parentComponentId); } /** * Get the bit width status of a node pin - * @param pinId id of pin - * @param nodeId id of node + * @param nodePinId id of the node pin * @returns bit width status of the pin */ getNodePinBitWidthStatus(nodePinId: CCNodePinId): CCNodePinBitWidthStatus { - const cached = this.#bitWidthCache.get(nodePinId); - if (cached) return cached; - const traverseNodePinBitWidthStatus = ( - targetNodePinId: CCNodePinId, - seen: Set, - ): CCNodePinBitWidthStatus => { - const targetNodePin = nullthrows(this.get(targetNodePinId)); - const { nodeId: targetNodeId, componentPinId: targetComponentPinId } = - targetNodePin; + return this.#getBitWidthsAround(nodePinId).getStatus(nodePinId); + } - seen.add(targetNodeId); - const targetNode = nullthrows(this.store.nodes.get(targetNodeId)); - const targetNodePins = this.getManyByNodeId(targetNode.id); - const attributes = - IntrinsicComponentDefinition.getPinAttributesByPinId( - targetComponentPinId, - ); - if (attributes) { - switch (attributes.bitWidthPolicy.type) { - case "configurable": - return { - isFixed: true, - bitWidth: CCNodePinStore.#requireManualBitWidth(targetNodePin), - }; - case "calculated": - return { - isFixed: true, - bitWidth: attributes.bitWidthPolicy.calculateBitWidth( - targetNode.config, - CCNodePinStore.#collectManualBitWidths(targetNodePins), - ), - }; - case "inferred": - // Resolved from the pins it is connected to, below - break; - default: - throw new Error( - `Unknown bit width policy: ${attributes.bitWidthPolicy satisfies never}`, - ); - } - } else { - // A user defined component pin is fixed by the implementation of its component - const componentPinBitWidthStatus = - this.store.componentPins.getComponentPinBitWidthStatus( - targetComponentPinId, - ); - if (componentPinBitWidthStatus.isFixed) { - return componentPinBitWidthStatus; - } - } - // The bit width of an inferred pin is shared with the other inferred pins of its - // node, so any of them may be the one that is connected to a pin of a known width. - for (const siblingNodePin of targetNodePins) { - const siblingComponentPinBitWidthStatus = - this.store.componentPins.getComponentPinBitWidthStatus( - siblingNodePin.componentPinId, - ); - if (siblingComponentPinBitWidthStatus.isFixed) { - continue; - } - if (siblingComponentPinBitWidthStatus.fixMode === "nodeDependent") { - // The bit width of a node dependent pin never propagates to its sibling - // pins, so no intrinsic component mixes it with inferred pins. - throw new Error( - `Component pin ${siblingNodePin.componentPinId} must not mix a node dependent bit width with inferred sibling pins`, - ); - } - const connections = nullthrows( - this.store.connections.getConnectionsByNodePinId(siblingNodePin.id), - ); - for (const connection of connections) { - const componentPin = nullthrows( - this.store.componentPins.get(siblingNodePin.componentPinId), - ); - const connectedNodePinId = - componentPin.type === "input" ? connection.from : connection.to; - const connectedNodePin = nullthrows(this.get(connectedNodePinId)); - if (seen.has(connectedNodePin.nodeId)) { - continue; - } - const connectedPinBitWidthStatus = traverseNodePinBitWidthStatus( - connectedNodePinId, - seen, - ); - if (connectedPinBitWidthStatus.isFixed) { - return connectedPinBitWidthStatus; - } - } - } - return { isFixed: false }; - }; - const result = traverseNodePinBitWidthStatus(nodePinId, new Set()); - this.#bitWidthCache.set(nodePinId, result); - return result; + /** + * Whether the pins tied to a node pin fix different bit widths, which leaves its + * connections unable to carry a value. + * @param nodePinId id of the node pin + */ + isInBitWidthConflict(nodePinId: CCNodePinId): boolean { + return this.#getBitWidthsAround(nodePinId).isInConflict(nodePinId); } isMarkedAsDeleted(id: CCNodePinId) { return this.#markedAsDeleted.has(id); } - isConnectable(a: CCNodePinId, b: CCNodePinId) { + /** + * Check whether two node pins can be connected. + * + * Only the component the connection is drawn in is checked for a bit width + * conflict. A connection inside a component can still cause one in the components + * using it, e.g. by tying an input to an output whose widths differ outside. That is + * accepted, just like a config change inside a component that does the same: the + * conflicting connections are highlighted where they are, and the simulation refuses + * to run until they are fixed. + * @param a id of a pin + * @param b id of the other pin + * @param options.allowBitWidthConflict skip the bit width check, to copy a connection + * as it is even when it is in conflict + */ + isConnectable( + a: CCNodePinId, + b: CCNodePinId, + { allowBitWidthConflict = false }: { allowBitWidthConflict?: boolean } = {}, + ) { const aNodePin = this.get(a); const bNodePin = this.get(b); if (!aNodePin || !bNodePin) { @@ -362,7 +300,7 @@ export class CCNodePinStore extends CCBaseStore< console.warn(`Input pin already has a connection: ${bNodePin.id}`); return false; } - if (!this.hasCompatibleBitWidths(a, b)) { + if (!allowBitWidthConflict && !this.hasCompatibleBitWidths(a, b)) { console.warn( `Cannot connect pins with conflicting bit widths: ${aNodePin.id} and ${bNodePin.id}`, ); @@ -372,17 +310,16 @@ export class CCNodePinStore extends CCBaseStore< } /** - * Check whether two node pins can carry the same value. A pin whose bit width is not - * fixed yet adapts to the pin it is connected to, so it is compatible with any pin. + * Check whether two node pins of the same component can be connected without + * causing a bit width conflict. A pin whose bit width is not fixed yet adapts to the + * pin it is connected to, and so do the pins tied to it. * @param a id of a pin * @param b id of the other pin - * @returns whether the bit widths of the two pins do not conflict + * @returns whether the connection brings no new width into a conflict + * @see ComponentBitWidths.canTie */ hasCompatibleBitWidths(a: CCNodePinId, b: CCNodePinId): boolean { - const aBitWidthStatus = this.getNodePinBitWidthStatus(a); - const bBitWidthStatus = this.getNodePinBitWidthStatus(b); - if (!aBitWidthStatus.isFixed || !bBitWidthStatus.isFixed) return true; - return aBitWidthStatus.bitWidth === bBitWidthStatus.bitWidth; + return this.#getBitWidthsAround(a).canTie(a, b); } /** diff --git a/src/store/react/selectors.ts b/src/store/react/selectors.ts index 0c32215..fcfd27a 100644 --- a/src/store/react/selectors.ts +++ b/src/store/react/selectors.ts @@ -7,6 +7,7 @@ import { isEachInputPinConnected, } from "../component"; import type { CCComponentPin, CCComponentPinId } from "../componentPin"; +import type { CCConnectionId } from "../connection"; import type { CCNode, CCNodeId } from "../node"; import type { CCNodePin } from "../nodePin"; import { useStore } from "."; @@ -224,28 +225,32 @@ export function useCanSimulate(componentId: CCComponentId) { [store, componentId], ); const subscribe = useCallback( - (onStoreChange: () => void) => { - store.nodes.on("didRegister", onStoreChange); - store.nodes.on("didUnregister", onStoreChange); - store.nodePins.on("didRegister", onStoreChange); - store.nodePins.on("didUnregister", onStoreChange); - store.componentPins.on("didRegister", onStoreChange); - store.componentPins.on("didUpdate", onStoreChange); - store.componentPins.on("didUnregister", onStoreChange); - store.connections.on("didRegister", onStoreChange); - store.connections.on("didUnregister", onStoreChange); - return () => { - store.nodes.off("didRegister", onStoreChange); - store.nodes.off("didUnregister", onStoreChange); - store.nodePins.off("didRegister", onStoreChange); - store.nodePins.off("didUnregister", onStoreChange); - store.componentPins.off("didRegister", onStoreChange); - store.componentPins.off("didUpdate", onStoreChange); - store.componentPins.off("didUnregister", onStoreChange); - store.connections.off("didRegister", onStoreChange); - store.connections.off("didUnregister", onStoreChange); - }; - }, + (onStoreChange: () => void) => + store.subscribeToCircuitChanges(onStoreChange), + [store], + ); + return useSyncExternalStore(subscribe, getSnapshot); +} + +/** + * Whether the pins a connection ties together fix different bit widths. A change + * anywhere in the circuit can cause or resolve a conflict, e.g. inside a component used + * by a node at one end, so this is recomputed on every change of the circuit. + */ +export function useIsConnectionInBitWidthConflict( + connectionId: CCConnectionId, +) { + const { store } = useStore(); + const getSnapshot = useCallback( + () => + // The connection can be gone before its renderer is unmounted + store.connections.get(connectionId) !== undefined && + store.connections.isInBitWidthConflict(connectionId), + [store, connectionId], + ); + const subscribe = useCallback( + (onStoreChange: () => void) => + store.subscribeToCircuitChanges(onStoreChange), [store], ); return useSyncExternalStore(subscribe, getSnapshot); diff --git a/src/store/simulation.ts b/src/store/simulation.ts index 3d8f682..0eb6ab2 100644 --- a/src/store/simulation.ts +++ b/src/store/simulation.ts @@ -16,6 +16,7 @@ import { type SimulationInstance, type SimulationNetId, type SimulationNetlist, + type SimulationPinInstance, simulationTerminalKey, } from "./netlist"; import type { CCNodeId } from "./node"; @@ -89,20 +90,43 @@ function getSimulationNetlist( return netlist; } +/** + * Make sure a value is as wide as the pin it passes through. The netlist refuses a + * circuit whose connected pins disagree on their bit width, so a mismatch here means an + * intrinsic node or the caller supplying the input values got the width wrong. + */ +function assertValueFitsPin( + value: SimulationValue, + pin: SimulationPinInstance, + describePin: () => string, +): void { + if (value.length !== pin.bitWidth) { + throw new Error( + `${describePin()} carries ${value.length} bits, but the pin is ${pin.bitWidth} bits wide`, + ); + } +} + /** 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) => + return mapValues(instance.inputShape, (pins, key) => + 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( + const value = nullthrows( netValues.get(pin.netId), `No value reaches pin ${pin.nodePinId}`, - ), - ), + ); + assertValueFitsPin( + value, + pin, + () => `Input ${key} of ${describeInstance(instance)}`, + ); + return value; + }), ); } @@ -120,14 +144,16 @@ function evaluateInstance( ), config: instance.config, }; + // Collected outside the `try`, since a missing or misfit input already names its pin + const inputs = + behavior.kind === "sequential" + ? previousFrame && collectInputs(instance, previousFrame.netValues) + : collectInputs(instance, netValues); try { if (behavior.kind === "sequential") { - return behavior.evaluate( - previousFrame && collectInputs(instance, previousFrame.netValues), - environment, - ); + return behavior.evaluate(inputs, environment); } - return behavior.evaluate(collectInputs(instance, netValues), environment); + return behavior.evaluate(nullthrows(inputs), environment); } catch (error) { // The evaluation functions only see values, so tell which node they belong to throw new Error( @@ -147,7 +173,8 @@ function evaluateInstance( * @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 + * @param defaultBitWidth bit width of the pins whose width neither the circuit nor the + * pins of the component around them fix * @returns the values of this time step */ export default function simulateComponent( @@ -181,7 +208,13 @@ export default function simulateComponent( `${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])); + const value = nullthrows(values[index]); + assertValueFitsPin( + value, + pin, + () => `Output ${key} of ${describeInstance(instance)}`, + ); + netValues.set(pin.netId, value); } } }