From 64135499b0ec22aa00daebca6e268cfea8599f4e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:49:30 +0100 Subject: [PATCH] feat(js): m07 io (effects as values) with trace interpreter and tests Co-Authored-By: Claude Sonnet 5.5 --- js/solutions/io.js | 34 ++++++++++++++++++ js/src/io.js | 43 +++++++++++++++++++++++ js/test/m07-io.test.js | 78 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 js/solutions/io.js create mode 100644 js/src/io.js create mode 100644 js/test/m07-io.test.js diff --git a/js/solutions/io.js b/js/solutions/io.js new file mode 100644 index 0000000..0afdb5d --- /dev/null +++ b/js/solutions/io.js @@ -0,0 +1,34 @@ +// m07 io: effects as values. An IO is a plain-data description of a program; nothing runs until interpreted. +// {tag:'Pure', value} - done, with a value +// {tag:'Effect', op, k} - perform `op`, then continue with `k(result)` + +export const pure = (value) => ({ tag: 'Pure', value }) + +export const effect = (op) => ({ tag: 'Effect', op, k: pure }) + +// Pure: apply f now. Effect: keep the op, and extend the continuation so f runs after k. +// Nothing is performed here, we only build a bigger description. +export const flatMap = (io, f) => + io.tag === 'Pure' + ? f(io.value) + : { tag: 'Effect', op: io.op, k: (result) => flatMap(io.k(result), f) } + +export const map = (io, f) => flatMap(io, (a) => pure(f(a))) + +export const sequence = (ios) => + ios.reduceRight((rest, io) => flatMap(io, () => rest), pure(undefined)) + +export const draw = (shape) => effect({ tag: 'Draw', shape }) + +export const randomSeed = effect({ tag: 'RandomSeed' }) + +export const drawScene = (scene) => sequence(scene.map(draw)) + +// Answer each op with a canned result, collecting the ops as we go (recursion, no mutation). +const answer = (op, seed) => (op.tag === 'RandomSeed' ? seed : undefined) + +export const trace = (io, seed) => { + if (io.tag === 'Pure') return { result: io.value, ops: [] } + const rest = trace(io.k(answer(io.op, seed)), seed) + return { result: rest.result, ops: [io.op, ...rest.ops] } +} diff --git a/js/src/io.js b/js/src/io.js new file mode 100644 index 0000000..8a55a71 --- /dev/null +++ b/js/src/io.js @@ -0,0 +1,43 @@ +import { todo } from './todo.js' + +/** + * Effects as values. An `IO` is a description of a program that, when interpreted, produces an `A`: + * `{tag:'Pure', value}` - finished, nothing left to do + * `{tag:'Effect', op, k}` - perform `op`, then continue with `k(result)` (which returns another IO) + * Operations: `{tag:'Draw', shape}` (result `undefined`) and `{tag:'RandomSeed'}` (result: a uint32 number). + * Building an IO never performs anything; only an interpreter (`trace` here, `runIO` in m09) does. + */ + +/** An IO that does nothing and produces `a`. */ +export const pure = (a) => todo('m07') + +/** An IO that performs the single operation `op` and produces its result: `{tag:'Effect', op, k: pure}`. */ +export const effect = (op) => todo('m07') + +/** Transform the result of `io` with `f`. Must not call `f` until the IO is interpreted. */ +export const map = (io, f) => todo('m07') + +/** + * Run `io`, then feed its result to `f` to get the next IO. + * Pure: `f(value)`. Effect(op, k): the same op, with the continuation extended by `f`. + */ +export const flatMap = (io, f) => todo('m07') + +/** Run the IOs one after another, left to right, producing `undefined`. `sequence([])` performs nothing. */ +export const sequence = (ios) => todo('m07') + +/** An IO that draws one shape (a `Rect` or `Text` from m06). */ +export const draw = (shape) => todo('m07') + +/** An IO that asks the outside world for a random uint32 seed. A value, not a function. */ +export const randomSeed = { get tag() { return todo('m07') } } + +/** Draw every shape of a scene in order: `sequence(scene.map(draw))`. */ +export const drawScene = (scene) => todo('m07') + +/** + * A pure interpreter for tests. Answers every `RandomSeed` with `seed` and every `Draw` with `undefined`. + * Returns `{result, ops}`: the final value and the operations in the order they were executed. + * Implemented by recursion, with no mutation. + */ +export const trace = (io, seed) => todo('m07') diff --git a/js/test/m07-io.test.js b/js/test/m07-io.test.js new file mode 100644 index 0000000..aecf91c --- /dev/null +++ b/js/test/m07-io.test.js @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { pure, effect, map, flatMap, sequence, draw, randomSeed, drawScene, trace } from '../src/io.js' +import { freeze as deepFreeze } from './helpers.js' + +const r1 = deepFreeze({ tag: 'Rect', x: 0, y: 0, w: 1, h: 1, color: '#000000' }) +const t1 = deepFreeze({ tag: 'Text', x: 1, y: 2, text: 'hi', color: '#ffffff', size: 16, align: 'left' }) +const drawOp = (shape) => ({ tag: 'Draw', shape }) +const seedOp = { tag: 'RandomSeed' } + +test('m07 trace of pure(3) has result 3 and no ops', () => + assert.deepStrictEqual(trace(pure(3), 0), { result: 3, ops: [] })) + +test('m07 randomSeed is answered with the seed given to trace', () => + assert.equal(trace(randomSeed, 9).result, 9)) + +test('m07 randomSeed records a RandomSeed op', () => + assert.deepStrictEqual(trace(randomSeed, 9).ops, [seedOp])) + +test('m07 effect(op) is an Effect whose continuation is pure', () => { + const io = effect(seedOp) + assert.equal(io.tag, 'Effect') + assert.deepStrictEqual(io.op, seedOp) + assert.deepStrictEqual(io.k(5), pure(5)) +}) + +test('m07 draw records a Draw op and produces undefined', () => + assert.deepStrictEqual(trace(draw(r1), 0), { result: undefined, ops: [drawOp(r1)] })) + +test('m07 drawScene draws the shapes in order', () => + assert.deepStrictEqual(trace(drawScene([r1, t1]), 0).ops, [drawOp(r1), drawOp(t1)])) + +test('m07 drawScene of an empty scene has no ops', () => + assert.deepStrictEqual(trace(drawScene([]), 0).ops, [])) + +test('m07 sequence of nothing has no ops', () => + assert.deepStrictEqual(trace(sequence([]), 0), { result: undefined, ops: [] })) + +test('m07 sequence runs effects left to right', () => + assert.deepStrictEqual(trace(sequence([randomSeed, draw(t1), draw(r1)]), 0).ops, [seedOp, drawOp(t1), drawOp(r1)])) + +test('m07 map transforms the result without adding ops', () => + assert.deepStrictEqual(trace(map(randomSeed, (s) => s + 1), 41), { result: 42, ops: [seedOp] })) + +test('m07 flatMap passes the result of the first IO to the second', () => + assert.deepStrictEqual(trace(flatMap(randomSeed, (s) => draw({ ...r1, x: s })), 7).ops, [seedOp, drawOp({ ...r1, x: 7 })])) + +test('m07 building map(io, throwingF) does not throw', () => { + const boom = () => { throw new Error('boom') } + assert.doesNotThrow(() => map(randomSeed, boom)) +}) + +test('m07 tracing map(io, throwingF) runs f and throws', () => { + const boom = () => { throw new Error('boom') } + assert.throws(() => trace(map(randomSeed, boom), 0), /boom/) +}) + +// Monad laws, checked by interpreting both sides and comparing {result, ops}. +const programs = { + 'a pure value': () => pure(4), + 'randomSeed': () => randomSeed, + 'a draw followed by reading the seed': () => flatMap(draw(r1), () => randomSeed), +} +const f = (n) => flatMap(draw({ ...r1, x: n }), () => pure(n * 2)) +const g = (n) => map(randomSeed, (s) => s + n) + +Object.entries(programs).forEach(([name, program]) => { + test(`m07 left identity holds for ${name}`, () => { + const a = trace(program(), 5).result + assert.deepStrictEqual(trace(flatMap(pure(a), f), 5), trace(f(a), 5)) + }) + test(`m07 right identity holds for ${name}`, () => + assert.deepStrictEqual(trace(flatMap(program(), pure), 5), trace(program(), 5))) + test(`m07 associativity holds for ${name}`, () => + assert.deepStrictEqual( + trace(flatMap(flatMap(program(), f), g), 5), + trace(flatMap(program(), (x) => flatMap(f(x), g)), 5))) +})