Skip to content

Instantly share code, notes, and snippets.

@rileyjshaw
Last active May 25, 2022 23:08
Show Gist options
  • Save rileyjshaw/cccfc4c055d4701a85556c9a2aad1607 to your computer and use it in GitHub Desktop.
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.
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);
@rileyjshaw
Copy link
Author

rileyjshaw commented May 25, 2022

Limited two-column font:

// C
##
#-
#-
#-
##

// E
##
#-
##
#-
##

// F
##
#-
##
#-
#-

// I
#-
#-
#-
#-
#-

// J
-#
-#
-#
##
##

// L
#-
#-
#-
#-
##

// P
##
##
##
#-
#-

// S
##
#-
##
-#
##

// Z
##
-#
##
#-
##

// 1
#-
#-
#-
#-
#-

// 2
##
-#
##
#-
##

// 3
##
-#
##
-#
##

// 5
##
#-
##
-#
##

// 6
##
#-
##
##
##

// 7
##
-#
-#
-#
-#

// 9
##
##
##
-#
-#

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:

// 555, tight wraparound (17 gates)
##-##-##
#--#--#-
##-##-##
-#--#--#
##-##-##

// PIE, well spaced (13 gates)
##-#-##-
##-#-#--
##-#-##-
#--#-#--
#--#-##-

3x5 fonts can support almost any character 👾

// HAHAHAHA (6 gates!?)
#-#-###-
#-#-#-#-
###-###-
#-#-#-#-
#-#-#-#-

// YAYAYAYAYAY (9 gates)
#-#-###-
#-#-#-#-
###-###-
-#--#-#-
-#--#-#-

@rileyjshaw
Copy link
Author

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