From 584f1d48258f6b5480a729e741eb82a2613315c1 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:49:50 +0100 Subject: [PATCH] feat(js): m08 stream (event streams as pure automata) with tests Co-Authored-By: Claude Sonnet 5.5 --- js/solutions/stream.js | 71 ++++++++++++++++++++++++++++++++++++++ js/src/stream.js | 46 ++++++++++++++++++++++++ js/test/m08-stream.test.js | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 js/solutions/stream.js create mode 100644 js/src/stream.js create mode 100644 js/test/m08-stream.test.js diff --git a/js/solutions/stream.js b/js/solutions/stream.js new file mode 100644 index 0000000..c0e1e38 --- /dev/null +++ b/js/solutions/stream.js @@ -0,0 +1,71 @@ +// m08 stream: an event stream is a pure automaton (a Mealy machine). +// Stream = { step: input => [outputs, nextStream] } +// There is no hidden state: whatever a stream "remembers" lives in the next stream it returns. + +// Sources may return themselves as the next stream, since they remember nothing. +export const keyDowns = { step: (i) => [i.tag === 'KeyDown' ? [i.key] : [], keyDowns] } + +export const keyUps = { step: (i) => [i.tag === 'KeyUp' ? [i.key] : [], keyUps] } + +// `last` is the time of the previous Time input. Multiples of ms in (last, t] fire, ascending. +const intervalFrom = (ms, last) => ({ + step: (i) => { + if (i.tag !== 'Time') return [[], intervalFrom(ms, last)] + const first = Math.floor(last / ms) + 1 + const count = Math.max(0, Math.floor(i.t / ms) - first + 1) + return [Array.from({ length: count }, (_, n) => (first + n) * ms), intervalFrom(ms, i.t)] + }, +}) + +export const interval = (ms) => intervalFrom(ms, 0) + +// Each combinator steps its inputs and wraps the *next* streams in the same combinator. +export const map = (s, f) => ({ + step: (i) => { + const [out, next] = s.step(i) + return [out.map(f), map(next, f)] + }, +}) + +export const filter = (s, p) => ({ + step: (i) => { + const [out, next] = s.step(i) + return [out.filter(p), filter(next, p)] + }, +}) + +export const filterMap = (s, f) => filter(map(s, f), (b) => b !== null) + +export const merge = (a, b) => ({ + step: (i) => { + const [outA, nextA] = a.step(i) + const [outB, nextB] = b.step(i) + return [[...outA, ...outB], merge(nextA, nextB)] + }, +}) + +// The accumulator travels in the arguments of the next scan, not in a variable. +export const scan = (s, f, z) => ({ + step: (i) => { + const [out, next] = s.step(i) + const accs = out.reduce((xs, o) => [...xs, f(xs.length === 0 ? z : xs[xs.length - 1], o)], []) + return [accs, scan(next, f, accs.length === 0 ? z : accs[accs.length - 1])] + }, +}) + +// Only the first step is special; afterwards we are just `next`. +export const startWith = (s, v) => ({ + step: (i) => { + const [out, next] = s.step(i) + return [[v, ...out], next] + }, +}) + +export const run = (s, inputs) => + inputs.reduce( + ([outs, stream], i) => { + const [out, next] = stream.step(i) + return [[...outs, ...out], next] + }, + [[], s], + )[0] diff --git a/js/src/stream.js b/js/src/stream.js new file mode 100644 index 0000000..c6a0a65 --- /dev/null +++ b/js/src/stream.js @@ -0,0 +1,46 @@ +import { todo } from './todo.js' + +/** + * An event stream as a pure automaton (a Mealy machine). + * `Stream = { step: input => [outputs, nextStream] }`: feed one input, get the (possibly empty) array + * of outputs it produced and the stream to use for the next input. Nothing is mutated, so a stream value + * can be run any number of times with the same results. + * Inputs: `{tag:'Time', t}`, `{tag:'KeyDown', key}`, `{tag:'KeyUp', key}`. + */ + +/** Emits the `key` of every KeyDown input; ignores everything else. */ +export const keyDowns = { step: () => todo('m08') } + +/** Emits the `key` of every KeyUp input; ignores everything else. */ +export const keyUps = { step: () => todo('m08') } + +/** + * On `Time t`, emits every multiple `k*ms` with `last < k*ms <= t`, ascending, where `last` starts at 0 + * and becomes `t` afterwards. A big time jump can emit several values. Ignores key inputs. + * Hint: write a private helper that carries `last`. + */ +export const interval = (ms) => todo('m08') + +/** Apply `f` to every output of `s`. */ +export const map = (s, f) => todo('m08') + +/** Keep only the outputs of `s` for which `p` is true. */ +export const filter = (s, p) => todo('m08') + +/** Apply `f` to every output of `s`; `f` returns a value or `null`, and nulls are dropped. */ +export const filterMap = (s, f) => todo('m08') + +/** Both streams step on every input; the outputs of `a` come before the outputs of `b`. */ +export const merge = (a, b) => todo('m08') + +/** Fold the outputs of `s` into an accumulator, starting from `z`; emits each new accumulator (not `z` itself). */ +export const scan = (s, f, z) => todo('m08') + +/** + * Emit `v` once, before the outputs of the first step; later steps are unaffected. + * Note: streams only act when an input arrives, so if no input ever arrives, `v` is never emitted. + */ +export const startWith = (s, v) => todo('m08') + +/** Feed all inputs through the stream and collect every output, in order. A pure fold (reduce). */ +export const run = (s, inputs) => todo('m08') diff --git a/js/test/m08-stream.test.js b/js/test/m08-stream.test.js new file mode 100644 index 0000000..73f4676 --- /dev/null +++ b/js/test/m08-stream.test.js @@ -0,0 +1,70 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { keyDowns, keyUps, interval, map, filter, filterMap, merge, scan, startWith, run } from '../src/stream.js' +import { freeze as deepFreeze } from './helpers.js' + +const time = (t) => ({ tag: 'Time', t }) +const down = (key) => ({ tag: 'KeyDown', key }) +const up = (key) => ({ tag: 'KeyUp', key }) +const inputs = (...xs) => deepFreeze(xs) + +test('m08 keyDowns emits keys of KeyDown inputs only', () => + assert.deepStrictEqual(run(keyDowns, inputs(down('a'), time(5), up('a'), down('b'))), ['a', 'b'])) + +test('m08 keyUps emits keys of KeyUp inputs only', () => + assert.deepStrictEqual(run(keyUps, inputs(down('a'), time(5), up('a'), down('b'), up('b'))), ['a', 'b'])) + +test('m08 run of no inputs is empty', () => + assert.deepStrictEqual(run(keyDowns, []), [])) + +test('m08 interval emits each multiple once as time passes', () => + assert.deepStrictEqual(run(interval(10), inputs(time(5), time(10), time(35))), [10, 20, 30])) + +test('m08 interval emits several multiples after one big time jump', () => + assert.deepStrictEqual(run(interval(10), inputs(time(35))), [10, 20, 30])) + +test('m08 interval ignores key inputs', () => + assert.deepStrictEqual(run(interval(10), inputs(down('a'), up('a'))), [])) + +test('m08 map transforms every output', () => + assert.deepStrictEqual(run(map(keyDowns, (k) => k + '!'), inputs(down('a'), down('b'))), ['a!', 'b!'])) + +test('m08 filter keeps outputs satisfying the predicate', () => + assert.deepStrictEqual(run(filter(keyDowns, (k) => k !== 'a'), inputs(down('a'), down('b'))), ['b'])) + +test('m08 filterMap transforms outputs and drops nulls', () => + assert.deepStrictEqual( + run(filterMap(keyDowns, (k) => (k === 'a' ? null : k.toUpperCase())), inputs(down('a'), down('b'))), + ['B'])) + +test('m08 merge puts outputs of the first stream before the second at the same input', () => + assert.deepStrictEqual( + run(merge(map(keyDowns, (k) => 'first ' + k), map(keyDowns, (k) => 'second ' + k)), inputs(down('x'))), + ['first x', 'second x'])) + +test('m08 merge interleaves sources by input order', () => + assert.deepStrictEqual( + run(merge(interval(10), map(keyDowns, () => -1)), inputs(time(10), down('a'), time(20))), + [10, -1, 20])) + +test('m08 scan emits each new accumulator, not the initial value', () => + assert.deepStrictEqual(run(scan(keyDowns, (n) => n + 1, 0), inputs(down('a'), up('a'), down('b'), down('c'))), [1, 2, 3])) + +test('m08 scan passes the output to the folding function', () => + assert.deepStrictEqual(run(scan(keyDowns, (acc, k) => acc + k, ''), inputs(down('a'), down('b'))), ['a', 'ab'])) + +test('m08 startWith emits the value once, before the outputs of the first step', () => + assert.deepStrictEqual(run(startWith(keyDowns, 'init'), inputs(down('a'), down('b'))), ['init', 'a', 'b'])) + +test('m08 startWith emits at the first input even if that step has no outputs', () => + assert.deepStrictEqual(run(startWith(keyDowns, 'init'), inputs(time(1), down('a'))), ['init', 'a'])) + +test('m08 startWith emits nothing when no input ever arrives', () => + assert.deepStrictEqual(run(startWith(keyDowns, 'init'), []), [])) + +test('m08 running the same stream value twice gives the same outputs', () => { + const s = scan(merge(interval(10), map(keyDowns, () => 100)), (a, x) => a + x, 0) + const xs = inputs(time(25), down('a'), time(40)) + assert.deepStrictEqual(run(s, xs), run(s, xs)) + assert.deepStrictEqual(run(s, xs), [10, 30, 130, 160, 200]) +})