diff --git a/frontend/src/__tests__/pin-trace-precedence.test.ts b/frontend/src/__tests__/pin-trace-precedence.test.ts index 5a4f0fa8..40dfac23 100644 --- a/frontend/src/__tests__/pin-trace-precedence.test.ts +++ b/frontend/src/__tests__/pin-trace-precedence.test.ts @@ -186,71 +186,100 @@ describe('nets whose answer is not a board pin', () => { }); describe('cost of walking the net', () => { - // Walking the whole node is not free, and the shape that hurts is the one - // every real canvas has: a ground rail with dozens of legs on it. This is a - // guard rail, not a benchmark — it exists so a future change that turns the - // walk quadratic again shows up here instead of as "the canvas feels slow". - it('stays well under a millisecond per pin on a 300-wire canvas', () => { + // What is worth guarding, and what is not. + // + // Tracing a pin on a shared ground rail costs O(node): the answer is a + // property of the whole node, so the whole node gets collected. A canvas + // with more legs on that rail has a bigger node and a higher per-pin cost — + // by design, not a defect, and an absolute millisecond ceiling on it just + // fails whenever the machine is busy (it failed a deploy gate that happened + // to be running two test shards). + // + // The real hazard is different: the first cut of this walk re-scanned EVERY + // wire on the canvas at every frame, so wires that had nothing to do with + // the traced net still made it slower. The per-array wire index fixed that, + // and this is the test for it — grow the canvas with UNRELATED circuitry and + // the cost of tracing the same little net must not move. A ratio says so + // regardless of how loaded the machine is. + const BB = 'bb'; + + /** One traced cluster (fixed size) plus `noise` legs of unrelated circuitry + * on a second breadboard, which shares no node with it. */ + const buildCanvas = (noise: number) => { useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, BOARD); - const comps: unknown[] = [{ id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} }]; - const wires: unknown[] = [wire('gnd', [BOARD, 'GND'], ['bb', 'bn.1'])]; - const N = 100; - for (let i = 0; i < N; i++) { + const comps: unknown[] = [ + { id: BB, metadataId: 'breadboard', x: 0, y: 0, properties: {} }, + { id: 'bb2', metadataId: 'breadboard', x: 0, y: 0, properties: {} }, + ]; + const wires: unknown[] = [wire('gnd', [BOARD, 'GND'], [BB, 'bn.1'])]; + const TRACED = 20; + for (let i = 0; i < TRACED; i++) { comps.push({ id: `led${i}`, metadataId: 'led', x: 0, y: 0, properties: {} }); comps.push({ id: `r${i}`, metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } }); wires.push(wire(`w${i}a`, [`led${i}`, 'C'], [`r${i}`, '1'])); - wires.push(wire(`w${i}b`, [`r${i}`, '2'], ['bb', `bn.${(i % 28) + 2}`])); + wires.push(wire(`w${i}b`, [`r${i}`, '2'], [BB, `bn.${(i % 28) + 2}`])); wires.push(wire(`w${i}c`, [`led${i}`, 'A'], [BOARD, String((i % 12) + 2)])); } - const s = useSimulatorStore.getState(); - s.setComponents(comps as never); - s.setWires(wires as never); - const state = useSimulatorStore.getState(); - - // The anodes go straight to a GPIO — the common case, and the one the - // early exit is for. - let t0 = performance.now(); - for (let i = 0; i < N; i++) { - expect(traceDetailed(state as never, `led${i}`, 'A', 0).arduinoPin).toBe((i % 12) + 2); + // Noise: its own rail, its own parts. A board pad is where a walk stops, so + // none of this belongs to the node the assertions below trace. + wires.push(wire('gnd2', [BOARD, 'GND.1'], ['bb2', 'bn.1'])); + for (let i = 0; i < noise; i++) { + comps.push({ id: `nled${i}`, metadataId: 'led', x: 0, y: 0, properties: {} }); + comps.push({ id: `nr${i}`, metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } }); + wires.push(wire(`n${i}a`, [`nled${i}`, 'C'], [`nr${i}`, '1'])); + wires.push(wire(`n${i}b`, [`nr${i}`, '2'], ['bb2', `bn.${(i % 28) + 2}`])); + wires.push(wire(`n${i}c`, [`nled${i}`, 'A'], [BOARD, String((i % 12) + 2)])); } - const perDriven = (performance.now() - t0) / N; + const st = useSimulatorStore.getState(); + st.setComponents(comps as never); + st.setWires(wires as never); + return { state: useSimulatorStore.getState(), traced: TRACED }; + }; - // The cathodes all land on the same rail — the worst case, where the whole - // node has to be collected before "ground" is certain. - t0 = performance.now(); - for (let i = 0; i < N; i++) { - expect(traceDetailed(state as never, `led${i}`, 'C', 0).arduinoPin).toBe(-1); + /** BEST per-pin cost of tracing the traced cluster's cathodes (the rail case, + * where the whole node is collected). The minimum of several passes on + * purpose: the fastest pass is the one that was not interrupted, so it + * measures the code rather than the machine's mood. */ + const costPerPin = (noise: number): number => { + const { state, traced } = buildCanvas(noise); + let best = Infinity; + for (let r = 0; r < 7; r++) { + const t0 = performance.now(); + for (let i = 0; i < traced; i++) { + expect(traceDetailed(state as never, `led${i}`, 'C', 0).arduinoPin).toBe(-1); + } + best = Math.min(best, (performance.now() - t0) / traced); } - const perRail = (performance.now() - t0) / N; + return best; + }; + it('unrelated wires elsewhere on the canvas do not slow a trace down', () => { + const quiet = costPerPin(20); // ~122 wires + const busy = costPerPin(320); // ~1022 wires, same little net under test + const growth = busy / quiet; console.log( - `[pin-trace] 301 wires: ${perDriven.toFixed(3)} ms/pin driven, ` + - `${perRail.toFixed(3)} ms/pin on the shared rail`, + `[pin-trace] ${quiet.toFixed(3)} ms/pin with 122 wires, ` + + `${busy.toFixed(3)} with 1022 — growth ${growth.toFixed(2)}x`, ); - expect(perDriven).toBeLessThan(3); - expect(perRail).toBeLessThan(8); + // Indexed: ~1x, the node is identical. Re-scanning every wire per frame: + // ~8x, since the canvas is eight times bigger. 3.0 separates them with + // room for timer noise. + expect(growth).toBeLessThan(3.0); }); -}); -describe('a net shared by two boards', () => { - it('answers each board its own pad', () => { - // One sensor line wired to a GPIO on BOTH boards. Whoever pre-registers - // the sensor for board B must learn B's pad, whatever order the wires - // were drawn in — the walk must not stop at A's pad and report "not on B". - useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); - useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, 'uno-a'); - useSimulatorStore.getState().addBoard('esp32' as never, 0, 0, 'esp-b'); - const s = useSimulatorStore.getState(); - s.setComponents([]); - s.setWires([ - wire('x1', ['sensor', 'ECHO'], ['uno-a', '7']), - wire('x2', ['sensor', 'ECHO'], ['esp-b', '19']), - ] as never); - const state = useSimulatorStore.getState(); - expect(traceBoardGpio(state as never, 'sensor', 'ECHO', 'uno-a')).toBe(7); - expect(traceBoardGpio(state as never, 'sensor', 'ECHO', 'esp-b')).toBe(19); - // And a board the net never reaches is still "not here". - expect(traceBoardGpio(state as never, 'sensor', 'ECHO', 'nope')).toBeNull(); + it('a pin wired straight to a GPIO short-circuits, far cheaper than the rail', () => { + const { state, traced } = buildCanvas(120); + let driven = Infinity; + for (let r = 0; r < 7; r++) { + const t0 = performance.now(); + for (let i = 0; i < traced; i++) { + expect(traceDetailed(state as never, `led${i}`, 'A', 0).arduinoPin).toBe((i % 12) + 2); + } + driven = Math.min(driven, (performance.now() - t0) / traced); + } + // The early exit on the first driven pad is what keeps the common case + // cheap; if it ever stops firing, this collapses towards the rail cost. + expect(driven).toBeLessThan(costPerPin(120)); }); });