diff --git a/frontend/src/__tests__/chipbus-netkey.test.ts b/frontend/src/__tests__/chipbus-netkey.test.ts new file mode 100644 index 00000000..2f7af5fc --- /dev/null +++ b/frontend/src/__tests__/chipbus-netkey.test.ts @@ -0,0 +1,153 @@ +/** + * Multi-chip digital bus — Phase 0 go/no-go proof (project/multichip-bus/). + * + * D-008: the cheapest falsification of the core assumption. If a shared net + * key does NOT make a byte written by one chip visible to another, the keying + * model is wrong and we stop before building the kernel. These tests prove: + * + * 1. Root cause A is fixed — two chips on one wire resolve to the SAME key. + * 2. The bug is real — per-endpoint syntheticChipPin keys differ. + * 3. Byte exchange works — a write on the driver's keys is visible + * synchronously to watchers the reader registered on its own keys. + * 4. The flag gates it — off by default (legacy path untouched). + * 5. No regression — a single-chip chip-to-component net is NOT collapsed, + * so rules 2/3 still own it. + * + * This is WASM-free on purpose: it exercises the resolver keying + the real + * PinManager fan-out directly. The full two-real-chips-light-8-LEDs milestone + * is verified live in the app once the flag is flipped (see 03-phases.md). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { PinManager } from '../simulation/PinManager'; +import { + resolveChipNetKey, + setChipBusEnabledForTest, + resetChipNetIndexForTest, + type ChipNetState, +} from '../simulation/customChips/chipNets'; +import { syntheticChipPin } from '../simulation/customChips/syntheticPins'; + +// ── Builders ───────────────────────────────────────────────────────────────── + +const chip = (id: string) => ({ id, metadataId: 'custom-chip' }); +const part = (id: string, metadataId: string) => ({ id, metadataId }); +const wire = (aId: string, aPin: string, bId: string, bPin: string) => ({ + start: { componentId: aId, pinName: aPin }, + end: { componentId: bId, pinName: bPin }, +}); +const range = (n: number) => Array.from({ length: n }, (_, i) => i); + +// A CPU chip and a ROM chip with D0..D7 wired straight across, no board. +function busState(): ChipNetState { + return { + wires: range(8).map((i) => wire('cpu', `D${i}`, 'rom', `D${i}`)), + components: [chip('cpu'), chip('rom')], + boards: [], + }; +} + +describe('chipbus Phase 0 — net-identity shared key', () => { + beforeEach(() => { + setChipBusEnabledForTest(true); + resetChipNetIndexForTest(); + }); + afterEach(() => { + setChipBusEnabledForTest(null); + resetChipNetIndexForTest(); + }); + + it('two chips on one wire resolve to the SAME key (root cause A fixed)', () => { + const state = busState(); + const kCpu = resolveChipNetKey(state, 'cpu', 'D0'); + const kRom = resolveChipNetKey(state, 'rom', 'D0'); + expect(kCpu).not.toBeNull(); + expect(kCpu).toBe(kRom); + }); + + it('distinct data lines get distinct keys (no cross-talk between D0 and D1)', () => { + const state = busState(); + expect(resolveChipNetKey(state, 'cpu', 'D0')).not.toBe( + resolveChipNetKey(state, 'cpu', 'D1'), + ); + }); + + it('documents the bug: per-endpoint synthetic keys differ for one net', () => { + expect(syntheticChipPin('cpu', 'D0')).not.toBe(syntheticChipPin('rom', 'D0')); + }); + + it('byte exchange — a write on the driver is visible synchronously to the reader', () => { + const state = busState(); + const pm = new PinManager(); + + // Reader (ROM) registers a watcher on EACH of its resolved data-bus keys, + // exactly as vx_pin_watch would after the net key fix. + let received = 0; + for (const i of range(8)) { + const key = resolveChipNetKey(state, 'rom', `D${i}`)!; + pm.onPinChange(key, (_p, s) => { + if (s) received |= 1 << i; + else received &= ~(1 << i); + }); + } + + // Driver (CPU) writes 0xA5 onto ITS resolved keys (vx_pin_write). + const byte = 0xa5; + for (const i of range(8)) { + const key = resolveChipNetKey(state, 'cpu', `D${i}`)!; + pm.triggerPinChange(key, ((byte >> i) & 1) === 1); + } + + // The reader latched exactly the driver's byte, within the same call stack. + expect(received).toBe(0xa5); + }); + + it('the same key reads back the driven level via getPinState', () => { + const state = busState(); + const pm = new PinManager(); + const driveKey = resolveChipNetKey(state, 'cpu', 'D3')!; + const readKey = resolveChipNetKey(state, 'rom', 'D3')!; + pm.triggerPinChange(driveKey, true); + expect(pm.getPinState(readKey)).toBe(true); + }); + + it('flag OFF (default): chip-to-chip net is NOT collapsed (legacy path)', () => { + setChipBusEnabledForTest(false); + resetChipNetIndexForTest(); + expect(resolveChipNetKey(busState(), 'cpu', 'D0')).toBeNull(); + }); + + it('chip-to-component (single chip on net) returns null — rules 2/3 preserved', () => { + const state: ChipNetState = { + wires: [wire('chip', 'LED0', 'led1', 'A')], + components: [chip('chip'), part('led1', 'led')], + boards: [], + }; + expect(resolveChipNetKey(state, 'chip', 'LED0')).toBeNull(); + }); + + it('a board on the net defers to board priority (returns null)', () => { + const state: ChipNetState = { + wires: [ + wire('cpu', 'D0', 'rom', 'D0'), + wire('cpu', 'D0', 'uno', '7'), + ], + components: [chip('cpu'), chip('rom')], + boards: [{ id: 'uno', boardKind: 'arduino-uno' }], + }; + expect(resolveChipNetKey(state, 'cpu', 'D0')).toBeNull(); + }); + + it('three chips on one bus line all share one key', () => { + const state: ChipNetState = { + wires: [wire('cpu', 'D0', 'rom', 'D0'), wire('rom', 'D0', 'ram', 'D0')], + components: [chip('cpu'), chip('rom'), chip('ram')], + boards: [], + }; + const a = resolveChipNetKey(state, 'cpu', 'D0'); + const b = resolveChipNetKey(state, 'rom', 'D0'); + const c = resolveChipNetKey(state, 'ram', 'D0'); + expect(a).not.toBeNull(); + expect(a).toBe(b); + expect(b).toBe(c); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 9d482aeb..7d831106 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -26,6 +26,7 @@ import { } from '../simulation/PinResolver'; import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups'; import { syntheticChipPin } from '../simulation/customChips/syntheticPins'; +import { resolveChipNetKey } from '../simulation/customChips/chipNets'; import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler'; import { getBoardLogicFamily } from '../simulation/LogicFamilies'; @@ -169,6 +170,23 @@ function traceDetailed( } } + // No board pin reachable. Multi-chip digital bus (chipbus flag, Phase 0 of + // project/multichip-bus/): when this net has two or more chip endpoints and + // no board pin, collapse every endpoint onto ONE net-canonical synthetic key + // so a write on one chip is visible to another through the synchronous + // PinManager fan-out (fixes root cause A: per-endpoint keys never matching). + // resolveChipNetKey returns null when the flag is off, when a board owns the + // net, or when there is a single chip endpoint — so the chip-to-component + // rules below (2 and 3) are left exactly as-is. Scoped to depth 0 (the + // starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT + // keeps the same key with no re-trace. + if (depth === 0) { + const netKey = resolveChipNetKey(state, fromId, fromPin); + if (netKey !== null) { + return { arduinoPin: netKey, crossedActiveDevice: activeSeen }; + } + } + // No board pin reachable. Fall back to a custom-chip pin on this net so the // chip can still drive / read it through the synthetic-pin PinManager key. if (chipNeighbour) { diff --git a/frontend/src/simulation/customChips/chipNets.ts b/frontend/src/simulation/customChips/chipNets.ts new file mode 100644 index 00000000..f719cb62 --- /dev/null +++ b/frontend/src/simulation/customChips/chipNets.ts @@ -0,0 +1,204 @@ +/** + * Chip-to-chip net identity — Phase 0 of the multi-chip digital bus track + * (see project/multichip-bus/ in the velxio-prod repo). + * + * THE PROBLEM (root cause A, 00-problem-analysis.md section 2): a digital net + * is keyed by ONE integer pin number in the per-board PinManager. A board pin + * (Uno D7 = 7) is net-symmetric — everyone on the net shares the number. But a + * chip-to-chip net is keyed per-endpoint by `syntheticChipPin(chipId, pinName)`, + * so the two chips on one wire resolve to two DIFFERENT keys and never share a + * net. Each chip writes into a key the other never reads. + * + * THE FIX: assign every electrically-connected net a single canonical id via + * union-find over the wire graph, and mint ONE shared `syntheticNetPin(netId)` + * for any net that has two or more chip endpoints and no board pin. Every + * endpoint on that net resolves to the same key, so a write on one chip is + * visible to another through the existing synchronous PinManager fan-out. + * + * SCOPE (D-006, never-clone boundary): this module ONLY decides the shared key + * for pure chip-to-chip nets. It returns null for: + * - nets with a board pin -> traceDetailed's rule 1 (board priority) handles it + * - nets with <2 chip endpoints -> traceDetailed's rules 2/3 (single-chip own + * synthetic) handle the chip-to-component case unchanged + * Board emulation never enters this path; the regression surface is the + * existing chip-to-component examples, gated behind the `chipbus` flag (D-007). + */ +import { UnionFind } from '../spice/unionFind'; +import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; +import { syntheticNetPin } from './syntheticPins'; + +// Structural view of the slice of simulator state this module needs. The real +// useSimulatorStore state is a superset, so it satisfies this shape directly — +// declaring it structurally keeps the module pure and unit-testable without +// pulling in React / the Zustand store. +interface NetEndpointRef { + componentId: string; + pinName: string; +} +interface WireLike { + start: NetEndpointRef; + end: NetEndpointRef; +} +interface ComponentLike { + id: string; + metadataId: string; +} +interface BoardLike { + id: string; + boardKind: string; +} +export interface ChipNetState { + wires: readonly WireLike[]; + components: readonly ComponentLike[]; + boards: readonly BoardLike[]; +} + +// Endpoint key = `${componentId}::${pinName}`. velxio chip ids +// (`custom_chip__`) and chip.json pin names are identifier-like and +// never contain `::`, so the split back to (componentId, pinName) is exact. +const SEP = '::'; +function epKey(componentId: string, pinName: string): string { + return `${componentId}${SEP}${pinName}`; +} +function parseEpKey(key: string): { componentId: string; pinName: string } { + const i = key.indexOf(SEP); + return { componentId: key.slice(0, i), pinName: key.slice(i + SEP.length) }; +} + +interface NetInfo { + /** Lexicographically-smallest endpoint key in the net — stable canonical id + * independent of union order, so the minted net pin number does not churn + * between resolve passes. */ + canonical: string; + /** True if any endpoint on the net is a board pin that resolves to a real + * GPIO number (board priority defers to traceDetailed's rule 1). */ + hasBoardPin: boolean; + /** Distinct custom-chip endpoint keys on the net. */ + chipEndpoints: Set; +} + +interface ChipNetIndex { + /** Net representative for an endpoint key, or undefined if not on any wire. */ + rootOf(key: string): string | undefined; + nets: Map; +} + +// ── Feature flag (D-007) ───────────────────────────────────────────────────── +// +// Off by default. Enable with `?chipbus=on` or +// `localStorage.velxio.chipbus = 'on'`, mirroring sim-mixedmode's `?mixedmode`. +// Guards every browser global so the module is safe under vitest/node. + +let testOverride: boolean | null = null; +/** Test seam: force the flag on/off, or pass null to restore real detection. */ +export function setChipBusEnabledForTest(v: boolean | null): void { + testOverride = v; +} + +export function chipBusEnabled(): boolean { + if (testOverride !== null) return testOverride; + try { + if (typeof window !== 'undefined' && window.location) { + const q = new URLSearchParams(window.location.search).get('chipbus'); + if (q === 'on' || q === '1' || q === 'true') return true; + if (q === 'off' || q === '0' || q === 'false') return false; + } + if (typeof localStorage !== 'undefined') { + const v = localStorage.getItem('velxio.chipbus'); + if (v === 'on' || v === '1' || v === 'true') return true; + } + } catch { + /* SecurityError on localStorage, missing globals in tests — treat as off */ + } + return false; +} + +// ── Net index (memoized by wire/component fingerprint) ─────────────────────── + +let cache: { sig: string; index: ChipNetIndex } | null = null; + +function fingerprint(state: ChipNetState): string { + const w = state.wires + .map( + (x) => + `${x.start.componentId}${SEP}${x.start.pinName}|${x.end.componentId}${SEP}${x.end.pinName}`, + ) + .join(','); + const c = state.components.map((x) => `${x.id}:${x.metadataId}`).join(','); + const b = state.boards.map((x) => `${x.id}:${x.boardKind}`).join(','); + return `${w}#${c}#${b}`; +} + +function buildIndex(state: ChipNetState): ChipNetIndex { + const uf = new UnionFind(); + for (const wire of state.wires) { + const a = epKey(wire.start.componentId, wire.start.pinName); + const b = epKey(wire.end.componentId, wire.end.pinName); + uf.union(a, b); + } + + const compById = new Map(state.components.map((c) => [c.id, c])); + const boardById = new Map(state.boards.map((b) => [b.id, b])); + const nets = new Map(); + + for (const [key, root] of uf.entries()) { + let info = nets.get(root); + if (!info) { + info = { canonical: key, hasBoardPin: false, chipEndpoints: new Set() }; + nets.set(root, info); + } + if (key < info.canonical) info.canonical = key; + + const { componentId, pinName } = parseEpKey(key); + const board = boardById.get(componentId); + if (board || isBoardComponent(componentId)) { + const kind = board?.boardKind ?? componentId; + // A real numbered board pin (including -1 power/GND) means a board owns + // this net; defer to traceDetailed's board-priority rule. + if (boardPinToNumber(kind, pinName) !== null) info.hasBoardPin = true; + } else if (compById.get(componentId)?.metadataId === 'custom-chip') { + info.chipEndpoints.add(key); + } + } + + return { + rootOf: (k) => (uf.has(k) ? uf.find(k) : undefined), + nets, + }; +} + +function getChipNetIndex(state: ChipNetState): ChipNetIndex { + const sig = fingerprint(state); + if (cache && cache.sig === sig) return cache.index; + const index = buildIndex(state); + cache = { sig, index }; + return index; +} + +/** Test seam: drop the memoized index (the fingerprint already invalidates it + * on real input changes; this is only for deterministic unit tests). */ +export function resetChipNetIndexForTest(): void { + cache = null; +} + +// ── Public resolver ────────────────────────────────────────────────────────── + +/** + * Shared net-canonical key for a chip pin on a pure chip-to-chip net, or null + * when the legacy resolver rules should handle it (flag off; board on the net; + * fewer than two chip endpoints). When non-null, EVERY endpoint of the same net + * gets the identical key, so writes and reads land on one PinManager slot. + */ +export function resolveChipNetKey( + state: ChipNetState, + componentId: string, + pinName: string, +): number | null { + if (!chipBusEnabled()) return null; + const idx = getChipNetIndex(state); + const root = idx.rootOf(epKey(componentId, pinName)); + if (root === undefined) return null; + const info = idx.nets.get(root); + if (!info || info.hasBoardPin || info.chipEndpoints.size < 2) return null; + return syntheticNetPin(info.canonical); +} diff --git a/frontend/src/simulation/customChips/syntheticPins.ts b/frontend/src/simulation/customChips/syntheticPins.ts index 147551ce..2de0b0a4 100644 Binary files a/frontend/src/simulation/customChips/syntheticPins.ts and b/frontend/src/simulation/customChips/syntheticPins.ts differ