Skip to content

Commit 959fb8b

Browse files
authored
feat: Binary runtime helper. (#255)
1 parent b865bab commit 959fb8b

File tree

3 files changed

+483
-4
lines changed

3 files changed

+483
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { OP_CODE, runtime } from "./runtime";
2+
3+
// Convenience re-exports for ergonomic APIs (see test file).
4+
export const OR = OP_CODE.OR;
5+
export const AND = OP_CODE.AND;
6+
export const EQ = OP_CODE.EQUAL;
7+
// the NOT operation only exists transiently because negation is expressed in the
8+
// arguments of the other operations. This allows it to use the 3rd bit while
9+
// there's only two bits at runtime.
10+
const NOT_OP: 4 = 4;
11+
12+
// Maps OP_CODE values to human readable strings.
13+
const OP_STR = {
14+
[OR]: "OR",
15+
[AND]: "AND",
16+
[EQ]: "EQ",
17+
[NOT_OP]: "NOT",
18+
[OP_CODE.SEP]: "SEP",
19+
};
20+
21+
/**
22+
* The index of an input value to be resolved at runtime.
23+
**/
24+
export type Value = number;
25+
type UID = number;
26+
27+
/**
28+
* Represents the negation of a resolved or computed value.
29+
**/
30+
export interface NotValue {
31+
val: Value | Expression;
32+
op: typeof NOT_OP;
33+
}
34+
35+
/**
36+
* Constructs an expression that negates a value or expression.
37+
*/
38+
export function NOT(val: Value | Expression): NotValue {
39+
return { val, op: NOT_OP };
40+
}
41+
42+
/**
43+
* Determine if a provided value is a `NotValue` interface.
44+
* @param v Any value.
45+
*/
46+
function isNotValue(v: Operand): v is NotValue { return typeof v === "object" && v.op === NOT_OP; }
47+
48+
/**
49+
* Represents a single node in a boolean expression binary tree.
50+
*/
51+
export interface Expression {
52+
left: Expression | Value;
53+
notLeft: boolean;
54+
op: OP_CODE;
55+
right: Expression | Value;
56+
notRight: boolean;
57+
}
58+
59+
/**
60+
* Valid operand values for our expr() function.
61+
**/
62+
export type Operand = Value | Expression | NotValue;
63+
64+
/**
65+
* SimpleExpression objects are a special representation of Expressions
66+
* that will only reference resolved values at runtime. They are completely flat.
67+
*/
68+
export interface SimpleExpression extends Expression {
69+
left: Value;
70+
right: Value;
71+
}
72+
73+
/**
74+
* The flattened representation of an Expression binary tree.
75+
* This is converted basically 1:1 to the binary runtime shapes.
76+
*/
77+
export interface Flattened {
78+
arguments: Set<number>;
79+
expressions: SimpleExpression[];
80+
indices: Map<UID, number>;
81+
}
82+
83+
/**
84+
* Given a left operand, opcode, and right expression, return an expression object.
85+
* Left and right operands may be the index of an input value, a `NotValue` expression,
86+
* or another Expression object, making this a very fancy binary tree.
87+
* @param left The Left Operand
88+
* @param op OP_CODE to process left and right with
89+
* @param right The Right Operand.
90+
*/
91+
export function expr(left: Operand, op: OP_CODE, right: Operand): Expression {
92+
let notLeft = false;
93+
let notRight = false;
94+
if (isNotValue(left)) {
95+
notLeft = true;
96+
left = left.val;
97+
}
98+
if (isNotValue(right)) {
99+
notRight = true;
100+
right = right.val;
101+
}
102+
return { left, op, right, notLeft, notRight };
103+
}
104+
105+
/**
106+
* Generate a human readable string for any SimpleExpression.
107+
* @param expr The SimpleExpression we're generating a UID for.
108+
*/
109+
export function debugExpression(expr: SimpleExpression): string {
110+
return `VAR ${expr.notLeft ? "!" : ""}${expr.left} ${OP_STR[expr.op]} VAR ${expr.notRight ? "!" : ""}${expr.right}`;
111+
}
112+
113+
/**
114+
* Generates a unique identifier for an expression.
115+
* This only works for values referencing an index of 16383 or less.
116+
*/
117+
function genUID(expr: SimpleExpression): UID {
118+
if (expr.left > 16383 || expr.right > 16383) {
119+
throw new Error("index out of bounds."); // just in case
120+
}
121+
let uid = (expr.notLeft ? 1 : 0) << 31;
122+
uid |= expr.left << 17;
123+
uid |= (expr.notRight ? 1 : 0) << 16;
124+
uid |= (expr.right) << 2;
125+
uid |= expr.op;
126+
return uid;
127+
}
128+
129+
/**
130+
* Discover all `Value`s referenced in a boolean Expression tree.
131+
* @param expr The Expression tree to crawl.
132+
* @param out The Flattened output object to read values in to.
133+
*/
134+
function getArgs(expr: Expression, out: Flattened) {
135+
if (typeof expr.left === "number") { out.arguments.add(expr.left); }
136+
else { getArgs(expr.left, out); }
137+
138+
if (typeof expr.right === "number") { out.arguments.add(expr.right); }
139+
else { getArgs(expr.right, out); }
140+
}
141+
142+
/**
143+
* Provided an Expression binary tree, read all values in to the Flattened data object.
144+
* @param expr The Expression tree to crawl.
145+
* @param out The Flattened object to read data in to.
146+
*/
147+
function visit(expr: Expression, out: Flattened): number {
148+
149+
// Convert this expression to a SimpleExpression
150+
let simpleExpr: SimpleExpression = {
151+
left: (typeof expr.left !== "number") ? visit(expr.left, out) : expr.left,
152+
right: (typeof expr.right !== "number") ? visit(expr.right, out) : expr.right,
153+
op: expr.op,
154+
notLeft: expr.notLeft,
155+
notRight: expr.notRight,
156+
};
157+
158+
// Generate a UID for this expression.
159+
let uid = genUID(simpleExpr);
160+
161+
// Save this expression in the simple expressions array, if an equivalent expression is not there.
162+
if (!out.indices.has(uid)) {
163+
out.expressions.push(simpleExpr);
164+
out.indices.set(uid, out.expressions.length - 1);
165+
}
166+
167+
// Return the expression's index reference.
168+
return out.indices.get(uid)! + out.arguments.size;
169+
}
170+
171+
/**
172+
* Flatten an ExpressionContainer object, reading values in to a Flattened data structure.
173+
* @param el The ExpressionContainer to flatten.
174+
*/
175+
function flattenExpressions(el: ExpressionContainer): Flattened {
176+
const out: Flattened = {
177+
arguments: new Set(),
178+
expressions: [],
179+
indices: new Map(),
180+
};
181+
182+
// Fetch all referenced args.
183+
el.forEachClass((_c, e) => getArgs(e, out));
184+
185+
// For every root note, crawl its descendents and populate the Flattened object
186+
// with the used expressions.
187+
const classExprs = el.getExprs().map((expr): SimpleExpression => ({
188+
left: (typeof expr.left !== "number") ? visit(expr.left, out) : expr.left,
189+
right: (typeof expr.right !== "number") ? visit(expr.right, out) : expr.right,
190+
op: expr.op,
191+
notLeft: expr.notLeft,
192+
notRight: expr.notRight,
193+
}));
194+
195+
// Ensure all classExpr root nodes are at the end of the Flattened expression list.
196+
out.expressions = [...out.expressions, ...classExprs];
197+
198+
return out;
199+
}
200+
201+
export class ExpressionContainer {
202+
203+
private classes: {[key: string]: Expression} = {};
204+
205+
/**
206+
* Add a new class to this ExpressionContainer.
207+
* @param name The class name we're determining presence for.
208+
* @param expr The Expression that evaluates to true or false, representing class application.
209+
*/
210+
class(name: string, expr: Expression) { this.classes[name] = expr; }
211+
212+
/**
213+
* Iterate over all classes stored on this ExpressionContainer.
214+
* @param cb forEachClass callback function. Passed the class name, and Expression.
215+
*/
216+
forEachClass(cb: (c: string, e: Expression) => void): ExpressionContainer {
217+
for (let key of Object.keys(this.classes)) { cb(key, this.classes[key]); }
218+
return this;
219+
}
220+
221+
// Expression and Class introspection methods.
222+
getExprs(): Expression[] { return Object.values(this.classes); }
223+
getClasses(): string[] { return Object.keys(this.classes); }
224+
225+
/**
226+
* Get the binary string (ex: "1001001001010") that represents all the registered classes'
227+
* application logic in this ExpressionContainer.
228+
*/
229+
getBinaryString(): string {
230+
const flattened = flattenExpressions(this);
231+
const argCount = flattened.arguments.size;
232+
const classesCount = Object.keys(this.classes).length;
233+
let exprCount = 0;
234+
let out = "";
235+
for (let idx = 0; idx < flattened.expressions.length; idx++) {
236+
let expr = flattened.expressions[idx];
237+
let isSep = (flattened.expressions.length - classesCount) === idx;
238+
let size = ~~Math.log2(exprCount + argCount - 1) + 1;
239+
let left = expr.left.toString(2).padStart(size, "0") + (expr.notLeft ? "1" : "0");
240+
let op = expr.op.toString(2).padStart(2, "0");
241+
let right = expr.right.toString(2).padStart(size, "0") + (expr.notRight ? "1" : "0");
242+
out += (isSep ? OP_CODE.SEP.toString(2) : "") + op + left + right;
243+
exprCount++;
244+
// console.log(`${debugExpression(expr)}:`, op, left, right);
245+
}
246+
247+
return out;
248+
}
249+
250+
/**
251+
* Calculate the base36 binary string encoding of this expression's logic shape.
252+
*/
253+
getBinaryEncoding(): string[] {
254+
return this.getBinaryString().match(/.{1,32}/g)!.map((s) => parseInt(s.split("").reverse().join(""), 2).toString(36));
255+
}
256+
257+
/**
258+
* Convenience method to test class application. Calls the binary runtime using
259+
* the binary encoded expression shapes and provided arguments.
260+
* @param args Arguments to evaluate this expression using.
261+
*/
262+
exec(...args: unknown[]) {
263+
return runtime(this.getBinaryEncoding(), this.getClasses(), args);
264+
}
265+
}
+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// All four op codes that we can find in one of our binary strings.
2+
// The SEP opcode is found when we have finished processing all
3+
// sub-expressions required by the class boolean expressions. Remaining
4+
// triples map directly to input classes.
5+
export const enum OP_CODE {
6+
OR = 0,
7+
AND = 1,
8+
EQUAL = 2,
9+
SEP = 3,
10+
}
11+
12+
// The three discovery states that our parser can be in. Looking for an
13+
// op code, the left operand, or the right operand.
14+
const enum STATE {
15+
OP = 0,
16+
LEFT = 1,
17+
RIGHT = 2,
18+
}
19+
20+
/* I'm special. (The rules don't apply when you're coding for a minifier) */
21+
/* tslint:disable:triple-equals typedef-whitespace no-unnecessary-type-assertion prefer-for-of*/
22+
export function runtime(shape: string[], classes: string[], args: unknown[]): string {
23+
24+
const exprs = args.slice(); // Expressions storage.
25+
let exprCount = args.length; // Running count of expressions.
26+
27+
let out = ""; // Output class string.
28+
29+
let left: boolean; // The left side of a boolean expression.
30+
let op: OP_CODE; // The operator for a boolean expression.
31+
let val: boolean; // Stores the right side of a boolean expression, and the final expression result.
32+
33+
// Holds state on token ingestion.
34+
// We're either discovering an OP_CODE, a left, or a right value.
35+
let state: STATE = STATE.OP;
36+
37+
let classIdx = -1; // The class index we're determining presence for – set to 0 when `SEP` is encountered.
38+
let working = 0; // Next ingested value will be stored here.
39+
let size = 2; // Variable window size, re-computed as expressions are calculated and added.
40+
41+
// For every binary encoded string...
42+
for (let i = 0; i < shape.length; i++) {
43+
44+
// Convert binary string segment to a base 10 integer.
45+
let integer = parseInt(shape[i], 36);
46+
47+
// Process each bit in this integer. The parser ensures 32 bit integers
48+
// are emitted. This is important because bitwise operations in JS clamp
49+
// app operands to 32 bit integers.
50+
// Note: `while` loop is faster than a `for` loop here.
51+
let iters = 32;
52+
while (iters--) {
53+
54+
// If we've discovered the separator opcode, begin applying classes.
55+
if (op! == OP_CODE.SEP) { state = classIdx = 0; }
56+
57+
// Variable token size is dependant on the number of values it is possible to reference.
58+
// Add an extra bit to token size to accommodate the "not" bit encoded with every var reference.
59+
// This code block must happen *before* the !size check below to properly construct opcode vals.
60+
if (!size) {
61+
// This is a very clever way to do Math.ceil(Math.log2(exprCount-1)).
62+
while(exprCount - 1 >> size++); // tslint:disable-line
63+
// If state == 0, we know we're looking for an opcode and size is 2.
64+
size = (!state) ? 2 : size;
65+
}
66+
67+
// Construct our next value and "pop" a bit off the end. `<<` serves as a faster Base2 Math.pow()
68+
working += integer % 2 * (1 << (--size) || 1); // tslint:disable-line
69+
integer >>>= 1; // tslint:disable-line
70+
71+
// If we have a full value or opcode, process the expression.
72+
// Otherwise, continue to consume the next bit until we do.
73+
if (!size) {
74+
75+
// Fetch the resolved expression value if we're looking for the LEFT or RIGHT value.
76+
// The last bit of every VAL token is the NOT bit. If `1`, invert the recovered value.
77+
// This is a throwaway value if we're discovering the OP.
78+
val = !!(state && (+!!exprs[working >>> 1] ^ (working % 2))); // tslint:disable-line
79+
80+
// If discovering as opcode, save as an opcode.
81+
if (state == STATE.OP) { op = working; }
82+
83+
// If discovering a left side operation value, save as the left value.
84+
if (state == STATE.LEFT) { left = val; }
85+
86+
// If we've found the right side value...
87+
if (state == STATE.RIGHT) {
88+
89+
// Run the correct operation on our left and right values.
90+
// Not a switch for code size reasons.
91+
if (op == OP_CODE.OR) { val = left! || val; }
92+
if (op == OP_CODE.AND) { val = left! && val; }
93+
if (op == OP_CODE.EQUAL) { val = left! === val; }
94+
95+
// Save our computed expression value to the next expression index.
96+
exprs[exprCount++] = val;
97+
98+
// If classIdx > -1, start concatenating classes based on result of `val`.
99+
// Increment to the next class. If this was the last class, break
100+
// out of the loops so we don't process extra 0's.
101+
if (!!~classIdx) {
102+
out += val ? (out ? " " : "") + classes[classIdx] : "";
103+
if (++classIdx == classes.length) { break; }
104+
}
105+
}
106+
107+
// Reset our working state and begin discovering the next token.
108+
working = 0;
109+
state = ++state % 3;
110+
}
111+
}
112+
}
113+
114+
// Return the concatenated classes!
115+
return out;
116+
}

0 commit comments

Comments
 (0)