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:
parent
7b483f6109
commit
c050ae6e49
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue