Skip to content

Commit

Permalink
Adding SignalList(SList) and SignalTree. QuickSort for SList.
Browse files Browse the repository at this point in the history
Fix a bug with caching that required a new state INITIAL.

Add debug global flag so that one can dump the state of a signal,
in the read callback without throwing.

Adding debugName as part of external inteface.
  • Loading branch information
rkirov committed Feb 25, 2023
1 parent c90a636 commit 7f31d24
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 12 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ reuses the continuation to perform all recomputation.
I consider this library still a work-in-progress. My next tasks would be:

- [ ] add more tests, I still have doubts about the correctness of the algorithms used.
- [ ] add more examples, especially full incremental algorithms like [incremental quicksort](https://github.com/rkirov/adapt-comp/blob/master/examples/aqsort_simple.test.ts)
- [ ] more efficient aggregation of inputs and invalidation like Angular Signals.
- [ ] add support for propagating thrown errors during recomputation.
- [ ] add support for 'multi-read', something like `read([xS,yS], (x,y) => {...})`. While `x.read(x => y.read(y => {...}))` works, it recreates the inner signal on each recomputation for `x`. In a way the library, assumes `y` was read as by-product of the specific value for `x`, so it has to be recreated. But there is
Expand Down
11 changes: 11 additions & 0 deletions caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,15 @@ test('detached signals do not trigger a recomputation', () => {
expect(bCount).toBe(4);
expect(xCount).toBe(2);
expect(yCount).toBe(2);
});

test('uncomputed signals are not skipped', () => {
const x = input(0, 'x');
const y = x.read(x => x % 2, 'y');
const z = y.read(x => x + 2, 'z');
// compute but not z
expect(y.value).toBe(0);

x.value = 2;
expect(z.value).toBe(2);
});
32 changes: 21 additions & 11 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@

export interface Signal<T> {
get value(): T;
read<S>(f: (t: T) => S|Signal<S>): Signal<S>;
read<S>(f: (t: T) => S|Signal<S>, debugName?: string): Signal<S>;
}

export interface Input<T> extends Signal<T> {
set value(t: T);
}

export function input<T>(t: T, name?: string): Input<T> {
return new InputImpl(t, name);
export function input<T>(t: T, debugName?: string): Input<T> {
return new InputImpl(t, debugName);
}

const REFERENTIAL_EQUALITY = <T>(a: T, b: T) => a === b;

enum State {
INITIAL,
// For optimization each signal keeps track of whether its value is the same as previous value.
CLEAN_AND_SAME_VALUE,
CLEAN_AND_DIFFERENT_VALUE,
Expand All @@ -34,6 +35,10 @@ enum GLOBAL_STATE {

let globalState = GLOBAL_STATE.READY;

// Allows to skip the non-reactive read check for debugging.
// TODO: expose API for this.
let DEBUG = false;

/**
* We need two extra pieces of information to propagate through the
* continuations:
Expand All @@ -43,7 +48,11 @@ let globalState = GLOBAL_STATE.READY;
type Continuation<T> = (t: T, inputs: Set<InputImpl<any>>, upstreamState: State) => void;
// In Haskell, this would be a value in the continuation monad, but I can't
// find a standard way to name it so it is different from the continuation type.
type ContValue<T> = (ct: Continuation<T>) => void;
/**
* One extra piece of information compared to the continuation type:
* - the current state of the signal.
*/
type ContValue<T> = (ct: Continuation<T>, curState: State) => void;

/**
* Global count of signals created. Used for debugging only.
Expand All @@ -55,7 +64,7 @@ class SignalImpl<T> implements Signal<T> {
* True initially, so that the first read will trigger a computation.
* After that, false when at least one input in the transitive closure has changed.
*/
state = State.DIRTY;
state = State.INITIAL;

/**
* Cache value of the computation. Represents the signal value only if state is CLEAN_.
Expand All @@ -82,9 +91,9 @@ class SignalImpl<T> implements Signal<T> {
*/
constructor(private ct: ContValue<T>, private name?: string) { }
read<S>(f: (t: T) => S|SignalImpl<S>, name?: string): SignalImpl<S> {
return new SignalImpl<S>(ct => {
return new SignalImpl<S>((ct, curState) => {
const val = this.value;
if (this.state === State.CLEAN_AND_SAME_VALUE) {
if (this.state === State.CLEAN_AND_SAME_VALUE && curState !== State.INITIAL) {
// the first two values don't matter, because we are not going to use them.
// TODO: find a better way to do this.
ct(null as any, null as any, this.state);
Expand All @@ -95,7 +104,6 @@ class SignalImpl<T> implements Signal<T> {
const res = f(val);
globalState = GLOBAL_STATE.READY;


// Adding auto-wrapping of pure values, akin to JS promises.
// This means we can never create Signal<Signal<T>>.
// Are we trading off some capabilities for syntactic convenience?
Expand All @@ -114,13 +122,14 @@ class SignalImpl<T> implements Signal<T> {
}
get value(): T {
this.checkGlobalState();
if (this.state !== State.DIRTY) return this.#cachedValue;
if (this.state === State.CLEAN_AND_DIFFERENT_VALUE ||
this.state === State.CLEAN_AND_SAME_VALUE) return this.#cachedValue;
// during recomputation the readers can change, so we remove them first.
// TODO: use counters trick to optimize this.
// https://github.com/angular/angular/tree/a1b4c281f384cfd273d81ce10edc3bb2530f6ecf/packages/core/src/signals#equality-semantics
for (let i of this.inputs) i.readers.delete(this.#ref);
this.ct((x: T, inputs, upstreamState) => {
if (upstreamState === State.CLEAN_AND_SAME_VALUE) {
if (upstreamState === State.CLEAN_AND_SAME_VALUE && this.state !== State.INITIAL) {
this.state = State.CLEAN_AND_SAME_VALUE;
// inputs can't change if the downstream value was the same.
return;
Expand All @@ -133,12 +142,13 @@ class SignalImpl<T> implements Signal<T> {
}
// note that inputs can change even if the value is the same.
this.inputs = inputs;
});
}, this.state);
for (let i of this.inputs) i.readers.add(this.#ref);
return this.#cachedValue;
}

protected checkGlobalState() {
if (DEBUG) return;
if (globalState === GLOBAL_STATE.COMPUTING) {
// reset global state before throwing.
globalState = GLOBAL_STATE.READY;
Expand Down
61 changes: 61 additions & 0 deletions qsort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {input} from '.';
import {fromArray, toArray} from './slist';
import {qsort, filter} from './qsort';

test('basic filter', () => {
const list = fromArray([1, 2, 3]);
const filtered = filter(list, x => x % 2 === 0);
expect(toArray(filtered)).toEqual([2]);
});

test('filter reacts to mutations', () => {
const list = fromArray([1, 2, 3]);
const filtered = filter(list, x => x % 2 === 0);
// mutating value at head
// new list [4, 2, 3]
list.value = {value: 4, tail: list.value!.tail};
expect(toArray(filtered)).toEqual([4, 2]);
});

test('it sorts', () => {
const list = fromArray([3, 1, 2]);
const sorted = qsort(list);
expect(toArray(sorted)).toEqual([1, 2, 3]);
});

test('it reacts to changes in number', () => {
const list = fromArray([3, 1, 2]);
const sorted = qsort(list);

// mutating a value in the front
// new list [0, 1, 2]
list.value = {value: 0, tail: list.value!.tail};
expect(toArray(sorted)).toEqual([0, 1, 2]);

// mutating a value in the middle
// new list [0, 3, 2]
list.value!.tail.value = {value: 3, tail: list.value!.tail.value!.tail};
expect(toArray(sorted)).toEqual([0, 2, 3]);

});

test('it reacts to appending new values', () => {
const list = fromArray([3, 1, 2]);
const sorted = qsort(list);

// appending a new value;
// new list [3, 4, 1, 2]
const mid = list.value!.tail;
list.value = {value: list.value!.value, tail: input({value: 4, tail: mid})};
expect(toArray(sorted)).toEqual([1, 2, 3, 4]);
});

test('it reacts to removing part of the list', () => {
const list = fromArray([3, 1, 2]);
const sorted = qsort(list);

// removing part of the list starting in the middle
// new list [3, 0]
list.value!.tail.value = {value: 0, tail: input(null)};
expect(toArray(sorted)).toEqual([0, 3]);
});
25 changes: 25 additions & 0 deletions qsort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {input} from '.';
import {SList, toArray} from './slist';

export function filter<T>(slist: SList<T>, pred: (x: T) => boolean): SList<T> {
return slist.read(list => {
if (!list) {
return input(null);
}
const rest = filter(list.tail, pred);
return pred(list.value) ? input({value: list.value, tail: rest}) : rest;
});
}

export function qsort<T>(slist: SList<T>, rest?: SList<T>): SList<T> {
return slist.read(list => {
if (list === null) {
return rest ? rest : input(null);
}
const pivot = list.value;
const left = filter(list.tail, x => x < pivot);
const right = filter(list.tail, x => x >= pivot);
const half = input({value: pivot, tail: qsort(right, rest)});
return qsort(left, half);
});
}
28 changes: 28 additions & 0 deletions slist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {input, Signal} from '.';
import {fromArray, toArray, SList} from './slist';

test('basic slist', () => {
const list = fromArray([1, 2, 3]);
expect(toArray(list)).toEqual([1, 2, 3]);
});

test('slist sum', () => {
const list = fromArray([1, 2, 3]);
function sum(list: SList<number>): Signal<number> {
return list.read(list => list ? sum(list.tail).read(rest => rest + list.value) : 0);
}
const s = sum(list);
expect(s.value).toBe(6);
// mutate value at head
list.value = {value: 4, tail: list.value!.tail};
expect(s.value).toBe(9);

// mutate value in the middle

list.value.tail.value = {value: 0, tail: list.value.tail.value!.tail};
expect(s.value).toBe(7);

// mutate at tail
list.value = {value: 5, tail: input(null)};
expect(s.value).toBe(5);
});
26 changes: 26 additions & 0 deletions slist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {input} from '.';
import type {Input} from '.';

interface Node<T> {
value: T;
tail: SList<T>;
}

export type SList<T> = Input<Node<T>|null>;

export function fromArray<T>(arr: T[]): SList<T> {
if (arr.length === 0) {
return input(null);
}
const [head, ...tail] = arr;
return input({value: head, tail: fromArray(tail)});
}

export function toArray<T>(list: SList<T>): T[] {
const arr: T[] = [];
while (list.value) {
arr.push(list.value.value);
list = list.value.tail;
}
return arr;
}
8 changes: 8 additions & 0 deletions stree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type {Signal} from '.';

export type IncrTree<T> = Signal<TreeNode<T> | null>;
interface TreeNode<T> {
readonly value: T;
readonly left: IncrTree<T>;
readonly right: IncrTree<T>;
}

0 comments on commit 7f31d24

Please sign in to comment.