From 54768dda8adcca4be7123df6dbd6b3f52dee39a6 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:02:04 +0900 Subject: [PATCH 1/8] refactor(store): share the subscription to every circuit change The store counted its revision on a hand-written list of events, and `useCanSimulate` subscribed to a similar but slightly different list. Expose that list once as `subscribeToCircuitChanges`, so that anything derived from the whole circuit listens to exactly the changes the revision counts. Co-Authored-By: Claude Opus 5.5 --- src/store/index.ts | 50 ++++++++++++++++++++++++------------ src/store/react/selectors.ts | 24 ++--------------- 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index fcf6621..862eed4 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() { diff --git a/src/store/react/selectors.ts b/src/store/react/selectors.ts index 0c32215..798d24a 100644 --- a/src/store/react/selectors.ts +++ b/src/store/react/selectors.ts @@ -224,28 +224,8 @@ 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); From f74b4b493328b232bd668d600c71917a212ac3b4 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:02:28 +0900 Subject: [PATCH 2/8] fix(store): infer bit widths per group of tied pins and keep conflicts Connections whose two ends came to disagree on their bit width were only dropped on a config change, and only inside the component of the node. They survived when a split pin of an aggregate node was resized, or when a change inside a component changed the width of one of its pins, so the simulation silently fed values of the wrong width onward. Dropping them everywhere was tried before and rejected: re-checking every connection after each change grew with the cube of the circuit size, and it dropped valid connections. The inference assumed that every pin of a user-defined node whose width is not fixed shares one width, so two independent paths through a component were forced to the same width. Group the pins instead. Within a component, pins share a bit width group when a connection ties them, when they are inferred pins of one intrinsic node, or when they are pins of one user-defined node whose implementations share a group inside it. A group takes the width its pins fix, and is in conflict when they fix more than one. The groups of a component are computed in one pass and cached until the circuit changes. A connection in a conflicting group is kept, since there is no undo and a later change can resolve the conflict. A new connection that would cause a conflict is still refused. The netlist gives each group of a component instance the width the pins of the node using it hand over, instead of a single width for the whole instance, and refuses to simulate a component with a conflict. In a chain of a constant and 100 not nodes, adding a node now takes 0.4 ms and deleting five 1.2 ms, against 207 ms and 1.4 s for the rejected re-check of every connection. Co-Authored-By: Claude Opus 5.5 --- .../components/NodePinPropertyEditor.tsx | 23 +- src/store/bitWidth.test.ts | 396 ++++++++++++++++++ src/store/bitWidth.ts | 285 +++++++++++++ src/store/connection.ts | 71 +--- src/store/netlist.ts | 110 +++-- src/store/nodePin.test.ts | 82 ---- src/store/nodePin.ts | 178 +++----- 7 files changed, 806 insertions(+), 339 deletions(-) create mode 100644 src/store/bitWidth.test.ts create mode 100644 src/store/bitWidth.ts 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/store/bitWidth.test.ts b/src/store/bitWidth.test.ts new file mode 100644 index 0000000..a3aa2ad --- /dev/null +++ b/src/store/bitWidth.test.ts @@ -0,0 +1,396 @@ +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, const_, display, 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("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); + }); +}); + +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)); + }); +}); diff --git a/src/store/bitWidth.ts b/src/store/bitWidth.ts new file mode 100644 index 0000000..fc6b1ce --- /dev/null +++ b/src/store/bitWidth.ts @@ -0,0 +1,285 @@ +/** + * 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. */ +export 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 tying two pins together would leave their merged group without conflict. */ + canTie(a: CCNodePinId, b: CCNodePinId): boolean { + const merged = new Set([ + ...this.#getBitWidths(this.getGroup(a)), + ...this.#getBitWidths(this.getGroup(b)), + ]); + return merged.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..f01259b 100644 --- a/src/store/connection.ts +++ b/src/store/connection.ts @@ -46,55 +46,6 @@ 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, - }), - ); - } - }); - } - } - } - }); } /** @@ -134,19 +85,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/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..0a1bb34 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,139 +172,54 @@ 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) { @@ -372,17 +292,15 @@ 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 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 pins tied to either of them would agree on one bit width */ 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); } /** From 099f8caa8912dd4a427a2a94d13ae20483eeb77f Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:02:46 +0900 Subject: [PATCH 3/8] feat(simulation): check that each value is as wide as its pin The netlist refuses a circuit whose connected pins disagree on their bit width, but a value can still come out wrong: an intrinsic node can compute a value of the wrong width, and the caller can supply an input value that does not fit its pin. Either used to flow on silently. Check the width of every value an instance reads or writes, and name the pin in the error. Co-Authored-By: Claude Opus 5.5 --- src/store/bitWidth.test.ts | 33 +++++++++++++++++++++++++++++- src/store/simulation.ts | 42 ++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/store/bitWidth.test.ts b/src/store/bitWidth.test.ts index a3aa2ad..748e8cf 100644 --- a/src/store/bitWidth.test.ts +++ b/src/store/bitWidth.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it } from "vitest"; import type CCStore from "."; import type { CCComponentId } from "./component"; import { CCConnectionStore } from "./connection"; -import { aggregate, const_, display, not } from "./intrinsics/definitions"; +import { + aggregate, + broadcast, + const_, + display, + not, +} from "./intrinsics/definitions"; import { SimulationBitWidthConflictError } from "./netlist"; import type { CCNodePinId } from "./nodePin"; import simulateComponent from "./simulation"; @@ -393,4 +399,29 @@ describe("simulation of bit width groups", () => { frame.getNodePinValue(testNodePin(store, dualNode, dual.out2)), ).toEqual(testBits(0b11, 2)); }); + + 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/simulation.ts b/src/store/simulation.ts index 3d8f682..91ae7fb 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; + }), ); } @@ -181,7 +205,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); } } } From 9e4da80d2ff6a605587bfa504e256532fd514564 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:02:46 +0900 Subject: [PATCH 4/8] feat(editor): highlight the connections in a bit width conflict Connections whose tied pins fix different bit widths are now kept rather than dropped, so the editor has to show them. Draw them red and dashed, and mention the conflict among the reasons the simulation can fail. The conflict can be caused or resolved by a change anywhere in the circuit, such as inside a component used by a node at one end, so the renderer re-checks it on every circuit change. The check reads a cached analysis, so that costs a lookup per connection. Co-Authored-By: Claude Opus 5.5 --- .../components/SimulationErrorAlert.tsx | 3 ++- .../edit/Editor/renderer/Connection/index.tsx | 16 ++++++++++-- src/store/react/selectors.ts | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) 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/react/selectors.ts b/src/store/react/selectors.ts index 798d24a..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 "."; @@ -230,3 +231,27 @@ export function useCanSimulate(componentId: CCComponentId) { ); 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); +} From 39c4135bea6bbecd323b73312c9bfaedb21f113f Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:52:11 +0900 Subject: [PATCH 5/8] fix(editor): move a connection in a bit width conflict into a new component "Create a new component" copies the selected connections into the new component and then removes the originals. The copies went through the bit width check, so a connection in conflict, which is now kept instead of dropped, was refused as a copy while its original was still removed. Let `register` keep a connection in conflict when asked to, and use that for the copies. Also document that a connection is only checked against the component it is drawn in, so one inside a component can still cause a conflict in the components using it, like a config change inside it can. Co-Authored-By: Claude Opus 5.5 --- .../edit/Editor/components/ContextMenu.tsx | 6 ++++- src/store/connection.ts | 19 +++++++++++----- src/store/nodePin.ts | 22 +++++++++++++++++-- 3 files changed, 39 insertions(+), 8 deletions(-) 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/store/connection.ts b/src/store/connection.ts index f01259b..7af4615 100644 --- a/src/store/connection.ts +++ b/src/store/connection.ts @@ -49,13 +49,22 @@ export class CCConnectionStore extends CCBaseStore< } /** - * 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); diff --git a/src/store/nodePin.ts b/src/store/nodePin.ts index 0a1bb34..92483ae 100644 --- a/src/store/nodePin.ts +++ b/src/store/nodePin.ts @@ -226,7 +226,25 @@ export class CCNodePinStore extends CCBaseStore< 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) { @@ -282,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}`, ); From 92135caeaeee394be538721ef4ae0a07e4a68d05 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:52:11 +0900 Subject: [PATCH 6/8] refactor(store): tidy up the bit width checks after review - Collect the inputs of an instance outside the `try` that prefixes evaluation errors with the instance, so a misfit input no longer names the instance twice. - Update the description of `defaultBitWidth` of `simulateComponent` to match the netlist. - Stop exporting `requireManualBitWidth`, which only its module uses. - Note that `importJson` must run before `mount`, since some stores add the objects without announcing them and so without clearing the bit widths derived from them. Co-Authored-By: Claude Opus 5.5 --- src/store/bitWidth.ts | 2 +- src/store/index.ts | 5 +++++ src/store/simulation.ts | 15 +++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/store/bitWidth.ts b/src/store/bitWidth.ts index fc6b1ce..c320a03 100644 --- a/src/store/bitWidth.ts +++ b/src/store/bitWidth.ts @@ -31,7 +31,7 @@ import type { CCNodePin, CCNodePinId } from "./nodePin"; export type BitWidthGroupId = Tagged; /** The bit width of a node pin of a configurable component pin. */ -export function requireManualBitWidth(nodePin: CCNodePin): number { +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}`, diff --git a/src/store/index.ts b/src/store/index.ts index 862eed4..fb79db7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -131,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/simulation.ts b/src/store/simulation.ts index 91ae7fb..0eb6ab2 100644 --- a/src/store/simulation.ts +++ b/src/store/simulation.ts @@ -144,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( @@ -171,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( From 919676fa2ee0fe4231eda346770c33e5dd9d6a5a Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:52:11 +0900 Subject: [PATCH 7/8] test(store): cover the reuse of components and queries during a change - A component used twice at different widths, two levels deep. - An input value as wide as the pins tied to its input pin. - Bit width queries on every change event while objects are removed. - A connection inside a component causing a conflict where it is used. - A connection in conflict copied as it is only when asked to. Co-Authored-By: Claude Opus 5.5 --- src/store/bitWidth.test.ts | 189 +++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/src/store/bitWidth.test.ts b/src/store/bitWidth.test.ts index 748e8cf..0d0bfe5 100644 --- a/src/store/bitWidth.test.ts +++ b/src/store/bitWidth.test.ts @@ -5,9 +5,11 @@ import type { CCComponentId } from "./component"; import { CCConnectionStore } from "./connection"; import { aggregate, + and, broadcast, const_, display, + input, not, } from "./intrinsics/definitions"; import { SimulationBitWidthConflictError } from "./netlist"; @@ -337,6 +339,119 @@ describe("bit width groups", () => { } 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", () => { @@ -400,6 +515,80 @@ describe("simulation of bit width groups", () => { ).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"); From 6e5586501a2ff12e19d4105a55c004931b488b78 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 12:58:07 +0900 Subject: [PATCH 8/8] feat(store): let a bit width conflict grow by pins that fix no width A connection touching a group in conflict was always refused, so a conflicting net could not be extended or probed until it was fixed. Allow a connection whose other side fixes no width. Those pins take whatever width the group settles on, so fixing the original conflict fixes them too. A pin fixing a width of its own is still refused, even one the group already has, since it would stay in conflict if the conflict were fixed the other way. Co-Authored-By: Claude Opus 5.5 --- src/store/bitWidth.test.ts | 46 ++++++++++++++++++++++++++++++++++++++ src/store/bitWidth.ts | 23 ++++++++++++++----- src/store/nodePin.ts | 9 ++++---- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/store/bitWidth.test.ts b/src/store/bitWidth.test.ts index 0d0bfe5..aa94d2b 100644 --- a/src/store/bitWidth.test.ts +++ b/src/store/bitWidth.test.ts @@ -285,6 +285,52 @@ describe("bit width groups", () => { 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); diff --git a/src/store/bitWidth.ts b/src/store/bitWidth.ts index c320a03..8d17bf9 100644 --- a/src/store/bitWidth.ts +++ b/src/store/bitWidth.ts @@ -157,13 +157,24 @@ export class ComponentBitWidths { return false; } - /** Whether tying two pins together would leave their merged group without conflict. */ + /** + * 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 merged = new Set([ - ...this.#getBitWidths(this.getGroup(a)), - ...this.#getBitWidths(this.getGroup(b)), - ]); - return merged.size <= 1; + 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 { diff --git a/src/store/nodePin.ts b/src/store/nodePin.ts index 92483ae..32a0ecf 100644 --- a/src/store/nodePin.ts +++ b/src/store/nodePin.ts @@ -310,12 +310,13 @@ export class CCNodePinStore extends CCBaseStore< } /** - * Check whether two node pins of the same component can be connected without 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. + * 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 pins tied to either of them would agree on one bit width + * @returns whether the connection brings no new width into a conflict + * @see ComponentBitWidths.canTie */ hasCompatibleBitWidths(a: CCNodePinId, b: CCNodePinId): boolean { return this.#getBitWidthsAround(a).canTie(a, b);