diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index ee997ac..7287544 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -65,6 +65,11 @@ steps: - name: transpiler-tests image: registry.niovi.voyage/harb/node-ci:latest + when: + - event: pull_request + path: + include: + - tools/push3-transpiler/** commands: - | bash -c ' @@ -73,8 +78,7 @@ steps: cd tools/push3-transpiler npm install --silent npm run build - bash test_inject_extraction.sh - bash test_transpiler_clamping.sh + npm test ' - name: single-package-manager diff --git a/tools/push3-transpiler/package.json b/tools/push3-transpiler/package.json index 347e0d9..1c8a4ff 100644 --- a/tools/push3-transpiler/package.json +++ b/tools/push3-transpiler/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc", "transpile": "tsx src/index.ts", - "test": "bash test_inject_extraction.sh && bash test_transpiler_clamping.sh" + "test": "tsx --test test/*.test.ts && bash test_inject_extraction.sh && bash test_transpiler_clamping.sh" }, "dependencies": {}, "devDependencies": { diff --git a/tools/push3-transpiler/test/parser.test.ts b/tools/push3-transpiler/test/parser.test.ts new file mode 100644 index 0000000..d3d6b9b --- /dev/null +++ b/tools/push3-transpiler/test/parser.test.ts @@ -0,0 +1,70 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parse, Node } from '../src/parser.js'; + +describe('parser', () => { + it('parses integer literals', () => { + const node = parse('(42)'); + assert.equal(node.kind, 'list'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.equal(node.items.length, 1); + assert.deepStrictEqual(node.items[0], { kind: 'int', value: 42n }); + }); + + it('parses negative integer literals', () => { + const node = parse('(-5)'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.deepStrictEqual(node.items[0], { kind: 'int', value: -5n }); + }); + + it('parses boolean literals', () => { + const node = parse('(TRUE FALSE)'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.deepStrictEqual(node.items[0], { kind: 'bool', value: true }); + assert.deepStrictEqual(node.items[1], { kind: 'bool', value: false }); + }); + + it('parses instructions', () => { + const node = parse('(DYADIC.+ BOOLEAN.NOT EXEC.IF)'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.deepStrictEqual(node.items[0], { kind: 'instr', name: 'DYADIC.+' }); + assert.deepStrictEqual(node.items[1], { kind: 'instr', name: 'BOOLEAN.NOT' }); + assert.deepStrictEqual(node.items[2], { kind: 'instr', name: 'EXEC.IF' }); + }); + + it('parses unbound names', () => { + const node = parse('(TAXRATE STAKED)'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.deepStrictEqual(node.items[0], { kind: 'name', text: 'TAXRATE' }); + assert.deepStrictEqual(node.items[1], { kind: 'name', text: 'STAKED' }); + }); + + it('parses nested lists', () => { + const node = parse('(1 (2 3))'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.equal(node.items.length, 2); + const inner = node.items[1]; + assert.equal(inner.kind, 'list'); + if (inner.kind !== 'list') throw new Error('unreachable'); + assert.equal(inner.items.length, 2); + }); + + it('strips comments', () => { + const node = parse('(;; this is a comment\n42)'); + if (node.kind !== 'list') throw new Error('unreachable'); + assert.equal(node.items.length, 1); + assert.deepStrictEqual(node.items[0], { kind: 'int', value: 42n }); + }); + + it('throws on empty program', () => { + assert.throws(() => parse(''), /Empty program/); + }); + + it('throws on unmatched open paren', () => { + assert.throws(() => parse('(1 2'), /Unmatched/); + }); + + it('throws on trailing tokens', () => { + assert.throws(() => parse('(1) 2'), /Unexpected tokens/); + }); +}); diff --git a/tools/push3-transpiler/test/transpiler.test.ts b/tools/push3-transpiler/test/transpiler.test.ts new file mode 100644 index 0000000..4a53f44 --- /dev/null +++ b/tools/push3-transpiler/test/transpiler.test.ts @@ -0,0 +1,520 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parse } from '../src/parser.js'; +import { transpile } from '../src/transpiler.js'; + +/** + * Helper: transpile a Push3 source string, returning the result. + * The transpiler primes 8 input slots on the DYADIC stack, so the program + * operates on top of those. + */ +function run(src: string) { + const ast = parse(src); + return transpile(ast); +} + +// --------------------------------------------------------------------------- +// Stack underflow detection +// --------------------------------------------------------------------------- + +describe('stack underflow', () => { + it('DYADIC.+ underflows when fewer than 2 extra values beyond inputs', () => { + // After 8 input slots are primed, DYADIC.+ pops two. Two pops from 8 is + // fine (no underflow). But after popping all 8 inputs + 1 more, underflow. + // Program: pop all 8 inputs, then try DYADIC.+ on an empty stack. + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.+ + )`; + assert.throws(() => run(src), /DYADIC stack underflow/); + }); + + it('DYADIC.SWAP underflows on empty stack', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.SWAP + )`; + assert.throws(() => run(src), /DYADIC stack underflow/); + }); + + it('DYADIC.DUP underflows on empty stack', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.DUP + )`; + assert.throws(() => run(src), /DYADIC stack underflow/); + }); + + it('DYADIC.POP underflows on empty stack', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP + )`; + assert.throws(() => run(src), /DYADIC stack underflow/); + }); + + it('BOOLEAN.NOT underflows on empty boolean stack', () => { + const src = '(BOOLEAN.NOT)'; + assert.throws(() => run(src), /BOOLEAN stack underflow/); + }); + + it('BOOLEAN.AND underflows on empty boolean stack', () => { + const src = '(BOOLEAN.AND)'; + assert.throws(() => run(src), /BOOLEAN stack underflow/); + }); + + it('BOOLEAN.OR underflows with only one boolean', () => { + const src = '(TRUE BOOLEAN.OR)'; + assert.throws(() => run(src), /BOOLEAN stack underflow/); + }); + + it('DYADIC.DEFINE underflows NAME stack', () => { + // Push a value on DYADIC but no name on NAME stack + const src = '(42 DYADIC.DEFINE)'; + assert.throws(() => run(src), /NAME stack underflow/); + }); + + it('DYADIC.> underflows when only one value on stack after clearing', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.> + )`; + assert.throws(() => run(src), /DYADIC stack underflow/); + }); + + it('EXEC.IF underflows when boolean stack is empty', () => { + // EXEC.IF needs a boolean condition — pops from bStack + const src = '(EXEC.IF (1) (2))'; + assert.throws(() => run(src), /BOOLEAN stack underflow/); + }); +}); + +// --------------------------------------------------------------------------- +// Stack overflow / excess values — transpiler handles gracefully +// --------------------------------------------------------------------------- + +describe('stack overflow / excess values', () => { + it('extra values on DYADIC stack are silently discarded (top 4 taken)', () => { + // Push 6 values on top of the 8 inputs = 14 total. Top 4 become outputs. + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 100 200 300 400 500 600 + )`; + const result = run(src); + // Top of stack = 600 → ci, 500 → anchorShare, 400 → anchorWidth, 300 → discoveryDepth + assert.equal(result.ciVar, '600'); + assert.equal(result.anchorShareVar, '500'); + assert.equal(result.anchorWidthVar, '400'); + assert.equal(result.discoveryDepthVar, '300'); + }); + + it('fewer than 4 values uses bear defaults for missing positions', () => { + // Clear all 8, push only 1 value + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 42 + )`; + const result = run(src); + assert.equal(result.ciVar, '42'); + // Missing positions get bear defaults + assert.equal(result.anchorShareVar, '300000000000000000'); + assert.equal(result.anchorWidthVar, '100'); + assert.equal(result.discoveryDepthVar, '300000000000000000'); + }); + + it('empty program (no instructions) uses inputs as outputs', () => { + // No instructions at all — the 8 primed input slots remain. + // Top 4 = inputs[0] through inputs[3]. + const src = '()'; + const result = run(src); + assert.equal(result.ciVar, 'uint256(inputs[0].mantissa)'); + assert.equal(result.anchorShareVar, 'uint256(inputs[1].mantissa)'); + assert.equal(result.anchorWidthVar, 'uint256(inputs[2].mantissa)'); + assert.equal(result.discoveryDepthVar, 'uint256(inputs[3].mantissa)'); + }); +}); + +// --------------------------------------------------------------------------- +// EXEC.IF branching — balanced branches +// --------------------------------------------------------------------------- + +describe('EXEC.IF balanced branches', () => { + it('emits if/else for simple balanced branches', () => { + // Clear stack, push condition value, compare, then branch + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 100 50 DYADIC.> + EXEC.IF + (10 20 30 40) + (1 2 3 4) + )`; + const result = run(src); + // Both branches push 4 values; the result depends on the condition + // The output should contain if/else structure + const body = result.functionBody.join('\n'); + assert.ok(body.includes('if ('), 'should contain if statement'); + // Result vars should be used for differing positions + assert.ok(result.ciVar !== undefined); + assert.ok(result.anchorShareVar !== undefined); + }); + + it('both branches produce same value → no result variable needed', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 100 50 DYADIC.> + EXEC.IF + (10 20 30 99) + (1 2 3 99) + )`; + const result = run(src); + // Both branches have 99 at bottom (same position from before) — doesn't need result var for that + // But top 3 differ → need result vars for those + const body = result.functionBody.join('\n'); + assert.ok(body.includes('if ('), 'should emit if/else'); + }); +}); + +// --------------------------------------------------------------------------- +// EXEC.IF branching — unbalanced branches +// --------------------------------------------------------------------------- + +describe('EXEC.IF unbalanced branches', () => { + it('true branch pushes more values than false branch', () => { + // True branch pushes 2 values, false branch pushes 1 + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 100 50 DYADIC.> + EXEC.IF + (10 20) + (10) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('if ('), 'should emit if/else'); + // The missing value in false branch defaults to '0' + }); + + it('false branch pushes more values than true branch', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 100 50 DYADIC.> + EXEC.IF + (10) + (10 20) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('if ('), 'should emit if/else'); + }); + + it('one branch is empty (no-op)', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 50 + 100 50 DYADIC.> + EXEC.IF + (DYADIC.POP 99) + () + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('if ('), 'should emit if/else'); + }); + + it('EXEC.IF with missing branches throws', () => { + // EXEC.IF needs exactly 2 subsequent items + const src = `( + TRUE EXEC.IF (1) + )`; + assert.throws(() => run(src), /EXEC\.IF: missing branches/); + }); +}); + +// --------------------------------------------------------------------------- +// EXEC.IF — nested branching +// --------------------------------------------------------------------------- + +describe('EXEC.IF nested branches', () => { + it('nested EXEC.IF produces valid output', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 100 50 DYADIC.> + EXEC.IF + ( + 100 80 DYADIC.> + EXEC.IF + (1 2 3 4) + (5 6 7 8) + ) + (10 20 30 40) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + // Should contain nested if blocks + const ifCount = (body.match(/if \(/g) || []).length; + assert.ok(ifCount >= 2, `expected at least 2 if statements, got ${ifCount}`); + }); +}); + +// --------------------------------------------------------------------------- +// Arithmetic and comparisons +// --------------------------------------------------------------------------- + +describe('arithmetic operations', () => { + it('DYADIC.+ produces addition expression', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 10 20 DYADIC.+ + )`; + const result = run(src); + assert.equal(result.ciVar, '(10 + 20)'); + }); + + it('DYADIC.- produces subtraction expression', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 10 3 DYADIC.- + )`; + const result = run(src); + assert.equal(result.ciVar, '(10 - 3)'); + }); + + it('DYADIC.* produces multiplication expression', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 5 6 DYADIC.* + )`; + const result = run(src); + assert.equal(result.ciVar, '(5 * 6)'); + }); + + it('DYADIC./ produces safe division expression', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 10 3 DYADIC./ + )`; + const result = run(src); + assert.equal(result.ciVar, '(3 == 0 ? 0 : 10 / 3)'); + }); + + it('DYADIC.> produces comparison on boolean stack', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 10 5 DYADIC.> + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('(10 > 5)'), 'condition should contain (10 > 5)'); + }); + + it('DYADIC.< produces less-than comparison', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 3 9 DYADIC.< + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('(3 < 9)')); + }); + + it('DYADIC.>= produces >= comparison', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 3 3 DYADIC.>= + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('(3 >= 3)')); + }); + + it('DYADIC.<= produces <= comparison', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 5 10 DYADIC.<= + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('(5 <= 10)')); + }); +}); + +// --------------------------------------------------------------------------- +// Boolean operations +// --------------------------------------------------------------------------- + +describe('boolean operations', () => { + it('BOOLEAN.NOT negates a boolean', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + TRUE BOOLEAN.NOT + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('!(true)')); + }); + + it('BOOLEAN.AND combines two booleans', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + TRUE FALSE BOOLEAN.AND + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('(true && false)')); + }); + + it('BOOLEAN.OR combines two booleans', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + TRUE FALSE BOOLEAN.OR + EXEC.IF (1 2 3 4) (5 6 7 8) + )`; + const result = run(src); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('(true || false)')); + }); +}); + +// --------------------------------------------------------------------------- +// Name binding and DEFINE +// --------------------------------------------------------------------------- + +describe('DYADIC.DEFINE and name binding', () => { + it('binds a name and retrieves it', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 42 FOO DYADIC.DEFINE + FOO + )`; + const result = run(src); + // FOO should be bound to 'foo' variable, and ci should reference it + assert.equal(result.ciVar, 'foo'); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('uint256 foo = uint256(42);')); + }); + + it('bound names push to dStack, not nameStack', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 99 X DYADIC.DEFINE + X X DYADIC.+ + )`; + const result = run(src); + assert.equal(result.ciVar, '(x + x)'); + }); +}); + +// --------------------------------------------------------------------------- +// DYADIC.DUP and DYADIC.SWAP +// --------------------------------------------------------------------------- + +describe('DYADIC.DUP and DYADIC.SWAP', () => { + it('DYADIC.DUP duplicates top of stack', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 42 DYADIC.DUP DYADIC.+ + )`; + const result = run(src); + // DUP materialises into dup0, then + is (dup0 + dup0) + assert.ok(result.ciVar.includes('dup0')); + const body = result.functionBody.join('\n'); + assert.ok(body.includes('uint256 dup0 = uint256(42);')); + }); + + it('DYADIC.SWAP exchanges top two values', () => { + const src = `( + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + 10 20 DYADIC.SWAP + )`; + const result = run(src); + // After SWAP: bottom=20, top=10 + assert.equal(result.ciVar, '10'); + assert.equal(result.anchorShareVar, '20'); + }); +}); + +// --------------------------------------------------------------------------- +// Unsupported instruction +// --------------------------------------------------------------------------- + +describe('unsupported instructions', () => { + it('throws on unknown instruction', () => { + const src = '(CODE.NOOP)'; + assert.throws(() => run(src), /Unsupported instruction: CODE.NOOP/); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: optimizer_seed.push3-style programs +// --------------------------------------------------------------------------- + +describe('integration: bear/bull program', () => { + it('simple bear/bull program transpiles without error', () => { + // Simplified version of the optimizer_v3.push3 structure + const src = `( + PERCENTAGESTAKED DYADIC.DEFINE + TAXRATE DYADIC.DEFINE + DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP + DYADIC.POP DYADIC.POP + + PERCENTAGESTAKED 100 DYADIC.* 1000000000000000000 DYADIC./ + STAKED DYADIC.DEFINE + + STAKED 91 DYADIC.> + EXEC.IF + ( + 1000000000000000000 + 20 + 1000000000000000000 + 0 + ) + ( + 300000000000000000 + 100 + 300000000000000000 + 0 + ) + )`; + const result = run(src); + // Should produce valid output with if/else + const body = result.functionBody.join('\n'); + assert.ok(body.includes('if (')); + // Output vars should be result variables (rN) since branches differ + assert.ok(result.ciVar !== undefined); + assert.ok(result.functionBody.length > 0); + }); +});