Last active
May 25, 2022 23:08
-
-
Save rileyjshaw/cccfc4c055d4701a85556c9a2aad1607 to your computer and use it in GitHub Desktop.
A quick script I wrote to generate a 5-bit heart image (Persistence of Vision) using a 555 timer, a binary counter, 16 NAND gates, and some LEDs.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const ENABLE_IMAGE_ROTATIONS = true; | |
const ITERATIONS_PER_ROTATION = 10000000; | |
const MAX_GATES_TOTAL = 20; | |
const MAX_TREE_DEPTH = 6; | |
const rand = array => array[Math.floor(Math.random() * array.length)]; | |
const unique = (x, i, array) => array.indexOf(x) === i; | |
const limit = (name, f, lo, hi = lo) => (...args) => { | |
const { length: l } = args; | |
if (l < lo || l > hi) | |
throw `${name} expects ${lo} ${hi !== lo ? `to ${hi} ` : ''}argument${ | |
hi !== 1 ? 's' : '' | |
}; ${l} provided.`; | |
return f(...args); | |
}; | |
const print = (label, content) => { | |
clearProgress(); | |
console.log(`${label}:\n${content}\n`); | |
}; | |
const printSolution = (label, solution) => { | |
print( | |
label, | |
solution.lines | |
? solution.lines.map((line, i) => `${i}: ${isFinite(line.nGates) ? line.nGates: '∞'}\t|\t${line.isBlank ? 'Blank' : line.wires[0].name}`) | |
.join('\n') + | |
(solution.lines.some(line => line.isBlank) | |
? '' | |
: `\nTotal: ${solution.nGates} gates${ | |
ENABLE_IMAGE_ROTATIONS ? ` (ROT-${solution.rotation})` : ''}`) | |
: 'Unsolved.' | |
); | |
}; | |
const clearProgress = () => { | |
if (typeof process !== 'undefined') { | |
process.stdout.clearLine(); | |
process.stdout.cursorTo(0); | |
} | |
}; | |
const printProgress = (progress, rotation) => { | |
const formatted = `Processing: ${progress}${ENABLE_IMAGE_ROTATIONS ? ` (ROT-${rotation})` : ''}`; | |
if (typeof process !== 'undefined') { | |
clearProgress(); | |
process.stdout.write(formatted); | |
} else console.log(formatted); | |
}; | |
// Logic helpers. | |
const and = limit('and()', (a, b) => a & b, 2); | |
const or = limit('or()', (a, b) => a | b, 2); | |
const xor = limit('xor()', (a, b) => a ^ b, 2); | |
const not = limit('not()', a => a ^ 1, 1); | |
// A gate takes an associative function and applies it to [2, Infinity] inputs. | |
const gate = f => | |
limit( | |
'A gate', | |
(...inputs) => state => | |
inputs | |
.map(input => input(state)) | |
.reduce((acc, input) => f(acc, input)), | |
2, | |
Infinity | |
); | |
const invert = fn => (...input) => state => not(fn(...input)(state)); // Inverts a gate. | |
// Logic gates from the circuit. | |
const VCC = () => 1; | |
const GND = () => 0; | |
const AND = gate(and); | |
const OR = gate(or); | |
const XOR = gate(xor); | |
const NAND = invert(AND); | |
const NOR = invert(OR); | |
const NOT = limit('A NOT gate', g => NAND(g, VCC), 1); | |
const _tests = [ | |
[1, AND(VCC, VCC)], | |
[0, AND(VCC, VCC, GND)], | |
[0, OR(GND, GND)], | |
[1, OR(GND, GND, VCC)], | |
[1, OR(VCC, VCC, VCC)], | |
[0, XOR(GND, GND)], | |
[0, XOR(VCC, VCC)], | |
[0, XOR(VCC, GND, VCC)], | |
[1, XOR(GND, VCC)], | |
[1, XOR(GND, GND, VCC)], | |
[0, NAND(VCC, VCC)], | |
[1, NAND(VCC, VCC, GND, VCC)], | |
[0, NOR(VCC, VCC)], | |
[1, NOR(GND, GND)], | |
[1, NOT(GND)], | |
[0, NOT(XOR(VCC, GND))], | |
[1, AND(NAND(XOR(VCC, VCC), OR(VCC, VCC)), NOT(NOT(NOR(GND, GND))))], | |
]; | |
console.log( | |
`${_tests.filter(([expected, fn]) => expected === fn()).length} of ${ | |
_tests.length | |
} tests passed.\n` | |
); | |
// Generate a printable truth table. | |
const tt = (lines, nBits, lsbFirst) => | |
// Bit state table. | |
Array.from( | |
{ length: nBits }, | |
(_, i) => | |
Array.from( | |
{ length: 1 << nBits }, | |
(_, j) => (j >> (nBits - i - 1)) & 1 | |
) | |
// Truth table for the inputs passed to `lines`. | |
) | |
.concat( | |
lines.map(line => | |
Array.from({ length: 1 << nBits }, (_, i) => { | |
const state = i | |
.toString(2) | |
.padStart(nBits, 0) | |
.split('') | |
.map(s => +s); | |
return line(state) ? '#' : '-'; | |
}) | |
) | |
) | |
.flatMap(a => { | |
if (!lsbFirst) a.reverse(); | |
return a.join(''); | |
}) | |
.join('\n'); | |
// Bits and bobs. | |
const bit = n => state => state[state.length - 1 - n]; | |
// I've got some Sharpies, so I'm coloring each line out of the binary counter | |
// Red, Green, and Blue. | |
const r = bit(0); // Least significant bit. | |
const g = bit(1); | |
const b = bit(2); // Most significant bit. | |
// These combined outputs, generated by the code, can make a heart out of 16 | |
// NAND gates. | |
const U = NAND(b, g), | |
V = NAND(U, r), | |
W = NAND(g, V), | |
X = NAND(g, W), | |
Y = NAND(NAND(g, g), r), | |
Z = NAND(U, NAND(NAND(b, V), W)); | |
const lines = [ | |
NAND(Z, Y), | |
NAND(X, NAND(Z, NAND(r, r))), | |
NAND(VCC, Y), | |
NAND(Z, VCC), | |
NAND(X, VCC), | |
]; | |
// Print the heart. | |
console.log(tt(lines, 3, true)); | |
console.log(); | |
// THE FOLLOWING CODE WAS USED TO GENERATE THE ABOVE SOLUTION. | |
let image = [ | |
'-##-##--', | |
'#--#--#-', | |
'-#---#--', | |
'--#-#---', | |
'---#----', | |
]; | |
// Each "wire" represents an output from a NAND gate that can be reused in other | |
// components. It keeps track of: | |
// name: the full gate composition, so it can be reconstructed from logs. | |
// fn: a function to test different states through. | |
// dependencies: the other LED wires it depends on, in case that line becomes unavailable later. | |
// | |
// Each "line" represents the full chain of gates and wires that drives an LED. | |
// It keeps track of: | |
// nGates: how many NAND gates this line alone uses, excluding gates from dependencies up the chain. | |
// wires: individual, reusable wire outputs used in this line. See above. | |
// isBlank: indicates a placeholder line that hasn't been solved yet. | |
const blankLine = { | |
nGates: MAX_GATES_TOTAL, | |
wires: [], | |
isBlank: true, | |
}; | |
// A list of all default wires, i.e. available before composing them through | |
// logic gates. | |
const initialWires = [[VCC], [GND], [r, 'r'], [g, 'g'], [b, 'b']].map( | |
([fn, name]) => ({ | |
name: name || fn.name, | |
fn, | |
dependencies: [-1], | |
}) | |
); | |
// Creates a random path through an array of NAND gates, given a set of initial wires. | |
const generateNandTree = (wires, depth, idx) => { | |
const [left, right] = Array.from({ length: 2 }, () => | |
depth < MAX_TREE_DEPTH && Math.random() < 0.3 | |
? generateNandTree(wires, depth + 1, idx) | |
: { | |
nGates: 0, | |
wires: [rand(wires)], | |
} | |
).sort((a, b) => a.wires[0].name.localeCompare(b.wires[0].name)); | |
const name = `NAND(${left.wires[0].name}, ${right.wires[0].name})`; | |
const fn = NAND(left.wires[0].fn, right.wires[0].fn); | |
const wire = { | |
name, | |
fn, | |
dependencies: [ | |
idx, | |
...[left, right] | |
.flatMap(x => x.wires.map(wire => wire.dependencies)) | |
.flat(), | |
].filter(unique), | |
}; | |
return { | |
nGates: left.nGates + right.nGates + 1, | |
wires: [wire, ...left.wires, ...right.wires], | |
}; | |
}; | |
// Recursively delete any lines that depended on wires from bestSolution.lines[idx]. | |
const removeDependents = (idx, bestLines, alternates) => { | |
return bestLines.some((line, i) => { | |
if (i === idx) return; | |
if (line.wires.some(wire => wire.dependencies.includes(idx))) { | |
bestLines[i] = blankLine; | |
alternates[i] = []; | |
removeDependents(i, bestLines, alternates); | |
return true; | |
} | |
}); | |
}; | |
// Repeat, a *lot* of times, the following: | |
// - Pick a random line of output to generate. | |
// - Generate a random NAND tree, built from the default wires and NAND outputs from other lines. | |
// - If the random NAND tree produces the correct output and uses less gates than the prior solution, use it. | |
// | |
// It's not smart, but it works great! | |
const nLines = image.length; | |
const nRotations = ENABLE_IMAGE_ROTATIONS ? image[0].length : 1; | |
const progressUpdatePeriod = ITERATIONS_PER_ROTATION * nRotations / 1000; // Update progress indicator every 0.1%. | |
const logPeriod = ITERATIONS_PER_ROTATION * nRotations / 20; // Log progress every 5%. | |
let bestSolutionOverall = { nGates: MAX_GATES_TOTAL * nLines }; | |
for (let rotation = 0; rotation < nRotations; ++rotation) { | |
let bestSolution = { | |
rotation, | |
nGates: MAX_GATES_TOTAL * nLines, | |
lines: Array(nLines).fill(blankLine), | |
}; | |
let alternates = Array.from({ length: nLines }, () => []); | |
for (let i = 0; i < ITERATIONS_PER_ROTATION; ++i) { | |
// Log progress. | |
if ((rotation || i) && !(i % progressUpdatePeriod)) { | |
const progress = `${((i / ITERATIONS_PER_ROTATION + rotation) * 100 / nRotations).toFixed(1)}%`; | |
printProgress(progress, rotation); | |
if (!(i % logPeriod)) printSolution(`So far (${progress} complete)`, bestSolutionOverall); | |
} | |
// Pick a random line of the image to attempt, skewed towards unsolved lines and lines with higher nGates. | |
const nGatesMap = bestSolution.lines.map(line => line.nGates / bestSolution.nGates); | |
let rnd = Math.random(), | |
idx = 0; | |
while ((rnd -= nGatesMap[idx]) >= 0) ++idx; | |
// Add wires from the other lines, minus any that rely on the previous definition of this line. | |
const wires = initialWires | |
.concat( | |
bestSolution.lines | |
.flatMap(line => line.wires) | |
.filter(wire => !wire.dependencies.includes(idx)) | |
) | |
.filter(unique); | |
// Generate a completely random NAND tree, and see if it matches the output. Seriously. | |
const result = generateNandTree(wires, 0, idx); | |
const output = result.wires[0]; | |
// If the result produces the intended output and uses less gates than the last solution, | |
// save it and delete anything that depended on wires from the old version. | |
const isCorrectOutput = tt([output.fn], 3, true).slice(-8) === image[idx]; | |
if (isCorrectOutput) { | |
const gateDiff = result.nGates - bestSolution.lines[idx].nGates; | |
if (gateDiff === 0) { | |
if (!alternates[idx].includes(output.name)) { | |
// print( | |
// `Found an alternate solution for line ${idx}`, | |
// output.name | |
// ); | |
alternates[idx].push(output.name); | |
} | |
} else if (gateDiff < 0) { | |
// Replace the previous line with the new and improved one. | |
bestSolution.lines[idx] = result; | |
alternates[idx] = []; | |
bestSolution.nGates = bestSolution.lines.reduce((acc, line) => acc + line.nGates, 0); | |
printSolution(`Found a better solution for line ${idx}`, bestSolution); | |
const hasLessGates = bestSolutionOverall.nGates > bestSolution.nGates; | |
if (hasLessGates && bestSolution.nGates <= MAX_GATES_TOTAL) { | |
// Store a copy so the lines don't get mutated. | |
bestSolutionOverall = { | |
...bestSolution, | |
lines: [...bestSolution.lines], | |
}; | |
} | |
// Reset any lines that relied on wires from the old line. This cascades. | |
const didRemoveDependents = removeDependents(idx, bestSolution.lines, alternates); | |
if (didRemoveDependents) { | |
printSolution('After removing dependents', bestSolution); | |
bestSolution.nGates = bestSolution.lines.reduce((acc, line) => acc + line.nGates, 0); | |
} | |
} | |
} | |
} | |
// Rotate the image forward one bit. | |
if (ENABLE_IMAGE_ROTATIONS) { | |
image = image.map(row => row.slice(1).concat(row[0])); | |
} | |
} | |
printSolution('Done', bestSolutionOverall); |
Update 2022-05-25: I added an ENABLE_IMAGE_ROTATIONS
flag that considers “rotations” of the image, eg:
// Original heart:
-##-##--
#--#--#-
-#---#--
--#-#---
---#----
// Rot-4 heart:
##---##-
--#-#--#
-#---#--
#-----#-
-------#
With this flag enabled, we save 6 gates on the heart (for a total of 10), 1 gate on YAYAYA (for a total of 8), etc.
I also re-ran the original script and got the heart down to 15 gates, so I’ve included that below.
// Original heart script:
0: 1 | NAND(NAND(NAND(b, g), NAND(NAND(b, NAND(NAND(g, g), r)), NAND(g, NAND(NAND(NAND(NAND(NAND(g, g), r), VCC), VCC), r)))), NAND(NAND(g, g), r))
1: 3 | NAND(NAND(NAND(b, NAND(NAND(g, g), r)), NAND(NAND(NAND(NAND(NAND(NAND(g, g), r), VCC), VCC), r), VCC)), NAND(NAND(NAND(NAND(b, NAND(NAND(g, g), r)), NAND(NAND(NAND(NAND(NAND(NAND(g, g), r), VCC), VCC), r), VCC)), NAND(NAND(NAND(b, g), NAND(NAND(b, NAND(NAND(g, g), r)), NAND(g, NAND(NAND(NAND(NAND(NAND(g, g), r), VCC), VCC), r)))), NAND(NAND(g, g), r))), NAND(NAND(NAND(NAND(NAND(g, g), r), VCC), VCC), r)))
2: 2 | NAND(NAND(NAND(g, NAND(NAND(NAND(b, g), r), VCC)), NAND(NAND(NAND(b, g), r), VCC)), VCC)
3: 5 | NAND(NAND(NAND(b, g), NAND(NAND(b, NAND(NAND(g, g), r)), NAND(g, NAND(NAND(NAND(NAND(NAND(g, g), r), VCC), VCC), r)))), VCC)
4: 4 | NAND(NAND(g, NAND(NAND(NAND(b, g), r), VCC)), VCC)
Total: 15 gates (ROT-0)
// Revised heart script:
0: 3 | NAND(NAND(b, NAND(r, r)), NAND(g, r))
1: 2 | NAND(NAND(b, NAND(b, NAND(NAND(b, g), r))), NAND(NAND(b, NAND(NAND(b, g), r)), NAND(r, r)))
2: 2 | NAND(NAND(g, r), VCC)
3: 1 | NAND(NAND(b, NAND(r, r)), VCC)
4: 2 | NAND(NAND(b, NAND(b, NAND(NAND(b, g), r))), VCC)
Total: 10 gates (ROT-6)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Limited two-column font:
A two-column font can fit 3 characters with spacing into 8 bits, but the wraparound will be tight unless a one-column width character (eg. I) is used:
3x5 fonts can support almost any character 👾