From 94cb3bd721ae354021b1375751b6d27ded6eb14b Mon Sep 17 00:00:00 2001
From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com>
Date: Sun, 8 Feb 2026 21:01:43 +0100
Subject: [PATCH] Initial signal runtime
---
src/lang/SIGNAL-EXPERIMENT.md | 86 ++++++
src/lang/SIGNAL.md | 485 ++++++++++++++++++++++++++++++++++
src/lang/eval/error.ts | 5 +-
src/lang/eval/evaluator.ts | 2 +-
src/lang/eval/signal.ts | 154 +++++++++++
src/lang/expr.ts | 18 +-
src/lang/program.ts | 185 ++++++++++---
7 files changed, 900 insertions(+), 35 deletions(-)
create mode 100644 src/lang/SIGNAL-EXPERIMENT.md
create mode 100644 src/lang/SIGNAL.md
create mode 100644 src/lang/eval/signal.ts
diff --git a/src/lang/SIGNAL-EXPERIMENT.md b/src/lang/SIGNAL-EXPERIMENT.md
new file mode 100644
index 0000000..b39f6d9
--- /dev/null
+++ b/src/lang/SIGNAL-EXPERIMENT.md
@@ -0,0 +1,86 @@
+
+```javascript
+const Thing = (initState) => ({
+ state: initState,
+ subscribers: [],
+ set(transform) {
+ const state = transform(this.state);
+ this.state = state;
+ this.subscribers.forEach(f => {
+ f(state);
+ });
+ },
+ get() {
+ return this.state;
+ },
+ subscribe(f) {
+ this.subscribers.push(f);
+ },
+ map(transform) {
+ const Y = Thing(this.state);
+ this.subscribe(x => {
+ Y.set(() => transform(x));
+ });
+
+ return Y;
+ },
+});
+
+function pair(X, Y) {
+ const Z = Thing([X.get(), Y.get()]);
+ X.subscribe(x => {
+ Z.set(() => [x, Y.get()]);
+ });
+ Y.subscribe(y => {
+ Z.set(() => [X.get(), y]);
+ });
+ return Z;
+}
+
+// Comonad lift
+// Signal(a), (Signal(a) -> b) -> Signal(b)
+
+// X: Signal(A), f: Signal(A) -> B
+// Y: Signal(B)
+// function extend(X, f) {
+// const y0 = f(X);
+// const Y = Thing(y0);
+// X.subscribe(x => {
+// Y.set(f(???)); I need to somehow feed it a new signal...
+// // TODO: Ofcourse I can feed it `X` again, but that feels wrong... I thought
+// });
+// return Y;
+// }
+
+const count = Thing(0);
+console.log("COUNT EXISTS");
+
+console.log(count.get())
+
+count.subscribe(x => {
+ console.log("count is now", x);
+});
+
+count.set(() => 1)
+count.set(() => 2)
+
+const double = count.map(x => 2*x);
+console.log("DOUBLE EXISTS");
+
+
+double.subscribe(x => {
+ console.log("double is now", x);
+});
+
+count.set(() => 3);
+count.set(() => 9);
+
+const WTF = pair(count, double)
+console.log("-> WTF EXISTS");
+
+WTF.subscribe(([x, y]) => {
+ console.log("WTF is now ", [x, y]);
+});
+
+count.set(() => 13);
+```
diff --git a/src/lang/SIGNAL.md b/src/lang/SIGNAL.md
new file mode 100644
index 0000000..abbfaf6
--- /dev/null
+++ b/src/lang/SIGNAL.md
@@ -0,0 +1,485 @@
+
+# Language Design: Two Worlds
+
+The language into two fragments: Normal Expressions vs Signal Expressions.
+- The Normal (Values) world is the standard (call-by-value) functional programming.
+- The Signal (Reactive) world declares connections between time-varying signals.
+
+The basic constraint is: Normal world has no conception of Signals. We can't name in a normal world a signal, we can't create signals, we can't pass them to functions or return signals from functions.
+
+# Applicative Structure
+Basic Signal expression is `@foo` which we can think of as reading (and being dependent on) a signal `foo`.
+Then we have another Signal expression `let-signal` which is the applicative structure. It is the only construct that connects the Normal world to the Signal world.
+It allows embedding of Normal Expressions into Signal Expressions. Recall applicative structure
+```
+Signal(A), Signal(B), (A, B -> C) -> Signal(C)
+```
+we directly adopt this into
+```
+let-signal {
+ // SIGNAL WORLD (RHS of :=)
+ // We extract values from the graph.
+ x := signal-expr-1,
+ y := signal-expr-2
+.
+ // NORMAL WORLD (Body)
+ // We compute a new value using the extracted data.
+ // $x and $y are plain values here.
+ normal-expression($x, $y)
+}
+```
+For example given a number signal `count`, we can create a signal that doubles it by
+```
+let-signal {
+ x := @count
+. *($x, 2)
+}
+```
+
+We also have a constant signal expression `const e` where `e` is a normal expression.
+
+Note that a normal expression can't contain `@` nor `let-expr`.
+
+# Top-Level Constructs
+
+We introduce two new top-level constructs, via keywords `signal` and `fn-signal`.
+
+The following
+```
+signal double {
+ let-signal {
+ x := @count
+ . *($x, 2)
+ }
+}
+```
+defines a new top-level signal named `double` and starts it up.
+General syntax
+```
+signal sigName {
+ signal-expression
+}
+```
+
+We also have parametrised top-level signal expressions. These can be used to define UI components
+```
+fn-signal Counter(color) {
+ let-signal {
+ // We must hardwire to global signals or internal state
+ n := @global-count
+ .
+
+ { $n }
+
+ }
+}
+```
+
+More generally
+```
+fn-signal ParametrizedSignalName(x1, x2, x3) {
+ signal-expression // $x1, $x2, $x3 are normal values
+}
+```
+
+Here is how we can define App Root
+```
+signal App {
+ let-signal {
+ // SIGNAL WORLD: Instantiate the component
+ // We pass "red" (Normal Expr), not a signal.
+ main-counter := Counter("red")
+ .
+ // NORMAL WORLD: Compose the HTML
+
+
My App
+ { $main-counter }
+
+ }
+}
+```
+
+TODO: Now that I'm thinking about it, I think we should separate Views from Components.
+Basically a `View` is a pure function that happens to return UI. While a Component is a View together with all the signals feds into it.
+
+
+# Runtime Constraints & Implementation
+
+- Static Topology? We should be able to know the dependency graph between signals at compile-time.
+- Rank-based Update, topological-srot on graph once at startup. Each node gets a rank (how close it is to the root signal).
+ Updates ar eprocessed via priority-queue - low-rank dependencies are processed earlier
+
+
+
+# TODO: Linear Types
+
+What exactly is the meaning of the below?
+Seems a bit weird that the signal `state` can be accessed multiple times like this.
+```
+let-signal {
+ x0 := @state,
+ x1 := @state,
+. ...
+}
+It would make sense to me if we sampled once, then duplicated the value we got...
+let-signal {
+ x := @state,
+ x0 := $x,
+ x1 := $x,
+. ...
+}
+```
+But then again, I guess it does make sense to sample twice - but there's no guarantee of getting the same result... e.g.
+```
+@ : Signal(A) -> Signal(A), A
+```
+the signal read operation may potentially change the signal itself.
+
+
+
+-----------------------
+
+This is Solidjs. How does it work?
+
+```
+function Counter() {
+ const [ count, setCount ] = createSignal(0);
+ return (
+
+ );
+}
+
+let-signal foo = x0;
+@foo // read
+@foo := x1 // write
+
+@foo { x . body }
+
+
+function Counter() {
+ let-signal count = 0;
+
+
+}
+
+
+
+let count = 0
+let double = count * 2
+
+count = 11
+console.log(double) // 0
+
+
+
+// ===Count===
+let-signal count = 0;
+
+//asignment
+@count := 1
+@count := 2
+
+// subscription
+@count { x .
+ console.log(x);
+}
+
+// POSSIBILITY 1: manual...
+let-signal double = @count
+@count { x .
+ @double := 2*x
+}
+
+// POSSIBILITY 2: automatic
+let-signal??? double = 2*@count
+// but where' sthe dependency on count? Is it just auto?
+// I guess you can just analyze the code and see that this deopends on the count...
+// but what if I wanted to just initialize `double` with the current value of the @count signal...
+//
+// maybe that's not how signals should be used...
+// if a signal is used in a thing - that thing must change according to the signal - no other way?
+```
+
+
+What can we do?
+- Creation: We can introduce a new signal
+- Reading/Subsribing: We can read the current value of a signal
+ - should we then automatically change with that signal?
+ Are we dependent on it?
+ This is like we can read only by subscription.
+- Updating: If we have the capability, we can update the signal to new value (can be a function of the current value)
+- ???Subscription: We can subscribe to changes in the signal
+- Map: We can create new signals that are a function of the original signal:
+ But this is a bit weird... in this output signal we can still set it...
+ Maybe we should be able to create only read-only signals?
+
+
+Read/Write Signal
+ReadOnly Signal (from map?)
+
+What about stuff like `andThen`?
+
+```
+Signal(A)
+```
+
+
+
+
+
+```
+type Model
+create initState: Model
+type Msg
+create update : Msg, Model -> Model
+
+const { dispatchMsg: (Msg) -> Void } = useElm(initState, update)
+```
+
+how to call the combination of a signal together with the events?
+
+
+
+=== Tracking Scopes ===
+
+When are tracking-scopes created?
+```
+// effects
+createEffect(() => {
+ ...
+})
+
+// returning jsx from a component
+// would be better if we had to actually wrap it explicitely into something.
+return (
+