feat(chipbus): Phase 0 - net-identity shared key for chip-to-chip buses

Fixes root cause A of the multi-chip digital bus track
(project/multichip-bus/): chip-to-chip nets were keyed per-endpoint by
syntheticChipPin(chipId, pinName), so two chips on one wire resolved to
two different PinManager keys and never shared a net.

- chipNets.ts: union-find over the wire graph mints one canonical
  syntheticNetPin per net; resolveChipNetKey returns it only for pure
  chip-to-chip nets (>=2 chip endpoints, no board pin). Reuses the
  existing spice/unionFind.ts.
- syntheticPins.ts: add syntheticNetPin(netId), same allocator/space.
- DynamicComponent.tsx: traceDetailed consults resolveChipNetKey at
  depth 0 before the chipNeighbour fallback. Board priority (rule 1) and
  chip-to-component (rules 2/3) are unchanged.
- Gated behind ?chipbus=on / localStorage.velxio.chipbus (off by default).

Proof (D-008 go/no-go): __tests__/chipbus-netkey.test.ts - a byte written
on one chip's keys is visible synchronously to another via PinManager.
9 new tests; 85 resolver/PinManager/parts regression tests green flag-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-06-05 01:18:08 -03:00
parent 7b483f6109
commit c050ae6e49
4 changed files with 375 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -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) {

View File

@ -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_<ts>_<rand>`) 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<string>;
}
interface ChipNetIndex {
/** Net representative for an endpoint key, or undefined if not on any wire. */
rootOf(key: string): string | undefined;
nets: Map<string, NetInfo>;
}
// ── 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<string, NetInfo>();
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);
}