feat(tp): replace scan reactivity with binding graph

Introduce exact signal and binding dependencies, transactional scheduling, rollback, cycle detection, scoped variable adapters, owner disposal, and scheduler counters. Keep the legacy scanner isolated until retained surfaces complete the cutover.\n\nVerified: 681 ERT tests, 92 doctests, shuffled ERT seed 20260806, and WERROR byte compilation.
This commit is contained in:
Kinneyzhang 2026-08-06 02:36:11 +08:00
parent ef31a67beb
commit 183f49ed3e
8 changed files with 1031 additions and 14 deletions

View File

@ -6,6 +6,7 @@ All notable changes to the tp library are documented here.
### Added ### Added
- TP 1.0 signals and bindings now form an exact source→binding and binding→binding dependency graph with conditional rewiring, memoized equality cutoffs, transaction-local candidate signal values, deduplicated topological flushing, nested-write stabilization, rollback, cycle paths, owner disposal, buffer-scoped sources, variable adapters, and public scheduler counters. The legacy layer scanner remains isolated only until the retained-surface cutover.
- The first TP 1.0 runtime slice: `tp-style.el` provides atomic namespaced property schemas, structured subject selectors and combinators, deterministic origin/importance/layer/specificity/scope/source-order cascade, property-specific inheritance, tagged CSS-wide values, custom-property fallback/cycle handling, explicit `tp-computed` value sources, named declarations, provenance, and final Emacs-property projection. Ordinary function values remain literal. - The first TP 1.0 runtime slice: `tp-style.el` provides atomic namespaced property schemas, structured subject selectors and combinators, deterministic origin/importance/layer/specificity/scope/source-order cascade, property-specific inheritance, tagged CSS-wide values, custom-property fallback/cycle handling, explicit `tp-computed` value sources, named declarations, provenance, and final Emacs-property projection. Ordinary function values remain literal.
- Internal Stage 2 canonical façade records and dataflow: - Internal Stage 2 canonical façade records and dataflow:
`tp--native-range`, `tp--presence`, `tp--request`, `tp--match`, `tp--native-range`, `tp--presence`, `tp--request`, `tp--match`,

View File

@ -176,6 +176,8 @@ Since 0.2.0 the library is organized as a family of layered modules (`tp-core`,
The TP 1.0 migration has begun with a pure schema-driven cascade kernel. `tp-style.el` adds namespaced property schemas, structured selectors, origin/importance/layer/specificity/scope/source-order precedence, property-specific inheritance, tagged CSS-wide values, inherited custom properties, explicit `tp-computed` sources, projection to Emacs properties, and read-only winner provenance. It deliberately owns no buffers, markers, mounts, or reactive subscriptions; retained surfaces will build on this kernel in later migration phases. The TP 1.0 migration has begun with a pure schema-driven cascade kernel. `tp-style.el` adds namespaced property schemas, structured selectors, origin/importance/layer/specificity/scope/source-order precedence, property-specific inheritance, tagged CSS-wide values, inherited custom properties, explicit `tp-computed` sources, projection to Emacs properties, and read-only winner provenance. It deliberately owns no buffers, markers, mounts, or reactive subscriptions; retained surfaces will build on this kernel in later migration phases.
`tp-reactive.el` now also provides the TP 1.0 exact dependency runtime: signals invalidate only their subscribed bindings, binding reads form memoized binding→binding edges, conditional computations replace obsolete dependencies, and the outermost `tp-with-transaction` flushes each dirty binding once. Candidate signal writes and binding values/dependencies commit together; compute or cycle failures roll them back. Global and buffer-local variable adapters reuse this graph without making text properties or buffer scans the runtime database.
### Core Innovations ### Core Innovations
1. **Unified API Parameter Conventions**: All functions support multiple flexible calling patterns, working seamlessly with both strings and buffers 1. **Unified API Parameter Conventions**: All functions support multiple flexible calling patterns, working seamlessly with both strings and buffers
@ -432,7 +434,7 @@ The modules and their roles:
|---|---| |---|---|
| `tp-core.el` | Intervals, plist/face merge engine, debug logging, `$var` utilities | | `tp-core.el` | Intervals, plist/face merge engine, debug logging, `$var` utilities |
| `tp-style.el` | Namespaced property schemas, structured selectors, cascade, custom properties, and explicit computed values | | `tp-style.el` | Namespaced property schemas, structured selectors, cascade, custom properties, and explicit computed values |
| `tp-reactive.el` | Reactive dependency registry, variable watchers, batching queue | | `tp-reactive.el` | Exact signals, memoized bindings, transactions, scoped variable adapters, and temporary legacy watcher state |
| `tp-layer.el` | `define-tp` / `define-tps`, layer registry and resolution | | `tp-layer.el` | `define-tp` / `define-tps`, layer registry and resolution |
| `tp-ops.el` | `tp-set` / `tp-reset` / `tp-add` / `tp-get` / `tp-at` / `tp-remove` / `tp-clear` | | `tp-ops.el` | `tp-set` / `tp-reset` / `tp-add` / `tp-get` / `tp-at` / `tp-remove` / `tp-clear` |
| `tp-search.el` | `tp-match-*`, `tp-regexp-*`, `tp-search`, navigation | | `tp-search.el` | `tp-match-*`, `tp-regexp-*`, `tp-search`, navigation |
@ -515,6 +517,19 @@ A complete overview of all tp.el functions organized by category:
| `tp-compute-style` / `tp-project-style` | Compute canonical values/provenance and project final Emacs text properties | | `tp-compute-style` / `tp-project-style` | Compute canonical values/provenance and project final Emacs text properties |
| `tp-style-reset-rules` / `tp-style-reset` | Reset stylesheet-only state or the complete style registry | | `tp-style-reset-rules` / `tp-style-reset` | Reset stylesheet-only state or the complete style registry |
#### Signals and Bindings
| Function | Description |
|----------|-------------|
| `tp-signal-create` / `tp-signal-read` / `tp-signal-set` | Create, dependency-track, and transactionally update a reactive source |
| `tp-signal-peek` / `tp-signal-live-p` / `tp-signal-subscriber-count` / `tp-signal-dispose` | Inspect or explicitly end signal lifecycle without collecting a dependency |
| `tp-bind` / `tp-binding-read` | Idempotently install a memoized owner+key computation and read it as a dependency |
| `tp-binding-live-p` / `tp-binding-dependency-count` / `tp-binding-subscriber-count` | Inspect binding lifecycle and exact graph degree |
| `tp-binding-dispose-owner` | Remove an owner's bindings and all graph edges |
| `tp-with-transaction` | Batch candidate writes and publish one deduplicated dirty closure atomically |
| `tp-variable-signal` | Adapt a global or buffer-local Elisp variable into a scoped signal |
| `tp-reactive-counters` / `tp-reactive-reset-counters` | Read or reset public scheduler work counters |
#### Property Layer Definition Functions #### Property Layer Definition Functions
| Function | Description | | Function | Description |
|----------|-------------| |----------|-------------|

View File

@ -175,6 +175,8 @@
TP 1.0 迁移已经从纯 schema-driven cascade kernel 开始。`tp-style.el` 新增 namespaced property schema、结构化 selector、origin/importance/layer/specificity/scope/source-order 优先级、逐属性继承、带 tag 的 CSS-wide 值、可继承 custom property、显式 `tp-computed` source、Emacs 属性投影和只读 winner provenance。这个模块刻意不拥有 buffer、marker、mount 或响应式订阅;后续 retained surface 会建立在同一个 kernel 上。 TP 1.0 迁移已经从纯 schema-driven cascade kernel 开始。`tp-style.el` 新增 namespaced property schema、结构化 selector、origin/importance/layer/specificity/scope/source-order 优先级、逐属性继承、带 tag 的 CSS-wide 值、可继承 custom property、显式 `tp-computed` source、Emacs 属性投影和只读 winner provenance。这个模块刻意不拥有 buffer、marker、mount 或响应式订阅;后续 retained surface 会建立在同一个 kernel 上。
`tp-reactive.el` 现在也提供 TP 1.0 的精确依赖 runtimesignal 只 invalidates 真实订阅的 bindingbinding read 建立 memoized binding→binding edge条件计算会替换已经失效的旧依赖最外层 `tp-with-transaction` 对每个 dirty binding 最多 flush 一次。candidate signal write 与 binding value/dependencies 一起提交compute 或 cycle 失败时一起回滚。global 和 buffer-local variable adapter 复用同一 graph不再把文本属性或 buffer scan 当作新 runtime database。
### 核心创新 ### 核心创新
1. **统一的 API 参数规范**:所有函数支持多种灵活的调用方式,同时适用于字符串和缓冲区 1. **统一的 API 参数规范**:所有函数支持多种灵活的调用方式,同时适用于字符串和缓冲区
@ -428,7 +430,7 @@ OBJECT 总是位于最后nil 表示当前缓冲区)。所有核心函数和
|---|---| |---|---|
| `tp-core.el` | 区间、plist/face 合并引擎、调试日志、`$var` 工具 | | `tp-core.el` | 区间、plist/face 合并引擎、调试日志、`$var` 工具 |
| `tp-style.el` | namespaced property schema、结构化 selector、cascade、custom property 和显式 computed value | | `tp-style.el` | namespaced property schema、结构化 selector、cascade、custom property 和显式 computed value |
| `tp-reactive.el` | 响应式依赖注册表、变量监视器、批量更新队列 | | `tp-reactive.el` | 精确 signal、memoized binding、transaction、scoped variable adapter 和临时 legacy watcher state |
| `tp-layer.el` | `define-tp` / `define-tps`、属性层注册表与解析 | | `tp-layer.el` | `define-tp` / `define-tps`、属性层注册表与解析 |
| `tp-ops.el` | `tp-set` / `tp-reset` / `tp-add` / `tp-get` / `tp-at` / `tp-remove` / `tp-clear` | | `tp-ops.el` | `tp-set` / `tp-reset` / `tp-add` / `tp-get` / `tp-at` / `tp-remove` / `tp-clear` |
| `tp-search.el` | `tp-match-*`、`tp-regexp-*`、`tp-search`、导航 | | `tp-search.el` | `tp-match-*`、`tp-regexp-*`、`tp-search`、导航 |
@ -510,6 +512,19 @@ tp.el 所有函数按类别组织的完整概览:
| `tp-compute-style` / `tp-project-style` | 计算 canonical values/provenance 并投影最终 Emacs text properties | | `tp-compute-style` / `tp-project-style` | 计算 canonical values/provenance 并投影最终 Emacs text properties |
| `tp-style-reset-rules` / `tp-style-reset` | 仅重置 stylesheet state或重置完整 style registry | | `tp-style-reset-rules` / `tp-style-reset` | 仅重置 stylesheet state或重置完整 style registry |
#### Signals 与 Bindings
| 函数 | 描述 |
|------|------|
| `tp-signal-create` / `tp-signal-read` / `tp-signal-set` | 创建、依赖收集并 transactionally 更新 reactive source |
| `tp-signal-peek` / `tp-signal-live-p` / `tp-signal-subscriber-count` / `tp-signal-dispose` | 不收集依赖地检查或显式结束 signal lifecycle |
| `tp-bind` / `tp-binding-read` | 按 owner+key 幂等安装 memoized computation并把读取登记为依赖 |
| `tp-binding-live-p` / `tp-binding-dependency-count` / `tp-binding-subscriber-count` | 检查 binding lifecycle 与精确 graph degree |
| `tp-binding-dispose-owner` | 删除 owner 的 bindings 及全部 graph edges |
| `tp-with-transaction` | 批量冻结 candidate write并原子发布一次去重后的 dirty closure |
| `tp-variable-signal` | 把 global 或 buffer-local Elisp variable 适配为 scoped signal |
| `tp-reactive-counters` / `tp-reactive-reset-counters` | 读取或重置 public scheduler work counters |
#### 属性层定义函数 #### 属性层定义函数
| 函数 | 描述 | | 函数 | 描述 |
|------|------| |------|------|

View File

@ -182,3 +182,13 @@ tp 不会把外部属性收编成新的匿名层,也不会静默覆盖它。
- `--name` custom property 默认继承;`tp-var` 支持 fallback 和 cycle invalidation。属性 schema 的 normalizer/validator 在变量与 wide value 求值后执行。 - `--name` custom property 默认继承;`tp-var` 支持 fallback 和 cycle invalidation。属性 schema 的 normalizer/validator 在变量与 wide value 求值后执行。
- `tp-compute-style` 返回 `tp-computed-style`,保存 canonical values、resolved custom properties 和可选 provenance`tp-project-style` 是把 schema projector 汇总为最终 Emacs text properties 的唯一纯投影入口。 - `tp-compute-style` 返回 `tp-computed-style`,保存 canonical values、resolved custom properties 和可选 provenance`tp-project-style` 是把 schema projector 汇总为最终 Emacs text properties 的唯一纯投影入口。
- 静态 `define-tp` 和静态 `define-tps` 生成层会同步编译为同名 canonical style参数化或旧 `$var` 响应式定义不会冻结当前值为 style待新的 signal/binding runtime 接管其动态 source。 - 静态 `define-tp` 和静态 `define-tps` 生成层会同步编译为同名 canonical style参数化或旧 `$var` 响应式定义不会冻结当前值为 style待新的 signal/binding runtime 接管其动态 source。
## 13. Signals、bindings 与 transaction
- `tp-signal-create` 创建 global 或 buffer-scoped source`tp-signal-read` 仅在 binding compute context 中登记依赖,`tp-signal-peek` 永不登记依赖。buffer kill 会 dispose scoped signalglobal signal 可用 `tp-signal-dispose` 显式结束 lifecycle两者都会移除 subscriptions。
- `tp-bind` 的 identity 是 owner object identity 加 caller-namespaced key重复安装复用同一 bindingcompute definition 变化才使它 dirty。`tp-binding-read` 读取 memoized value 并建立 binding→binding edge。
- 每次成功 compute 以本次实际读取的依赖替换旧依赖;条件分支切换后旧 signal 不再触发。binding value 经其 equality comparator 判等,相等结果不 invalidates downstream。
- signal write 先写入 transaction-local candidate state。最外层 `tp-with-transaction` 只遍历 exact dirty closure去重并按 binding dependency 拓扑惰性求值compute 内嵌 write 排队稳定,不递归执行。
- 任一 compute 或 cycle 失败会恢复 committed signal values、last successful binding values、dependencies、dirty state、owner registry 和 scheduler counters。cycle condition 携带 namespaced binding-key path。
- `tp-variable-signal` 是 global/buffer-local Elisp variable包括后续 `$var` compiler的 source adapter它只转发精确 scope 的 write不调用 legacy layer renderer。
- `tp-reactive-counters` 只公开 invalidated、recomputed、skipped、subscription-added、subscription-removed 五个工作量计数,不暴露 internal hash shape。

View File

@ -1,8 +1,8 @@
# tp 代码架构文档 # tp 代码架构文档
> 未来主版本目标TP 将重构为独立 retained/reactive text runtime并可作为 Ebox 等高级 consumer 的通用底层执行器。TP 自身完整、可独立阅读的已批准目标见 [TP Retained/Reactive Text Runtime 目标架构](retained-runtime-target-architecture.md)[English](retained-runtime-target-architecture-en.md));它不依赖 sibling Ebox checkout。本文在最终切换完成前仍只描述 TP 0.3.x 当前实现 > 未来主版本目标TP 将重构为独立 retained/reactive text runtime并可作为 Ebox 等高级 consumer 的通用底层执行器。TP 自身完整、可独立阅读的已批准目标见 [TP Retained/Reactive Text Runtime 目标架构](retained-runtime-target-architecture.md)[English](retained-runtime-target-architecture-en.md));它不依赖 sibling Ebox checkout。本文描述当前已经落地的实现,并明确标出仍处于迁移期的旧 runtime 与 TP 1.0 功能切片
本文档描述 tp 库的模块分层结构与函数调用层次从底层基础模块到上层功能模块的分层组织。TP 1.0 的第一层纯 style/cascade kernel 已在 `tp-style.el` 落地retained surface 与新 reactive runtime 尚未切换。 本文档描述 tp 库的模块分层结构与函数调用层次从底层基础模块到上层功能模块的分层组织。TP 1.0 的纯 style/cascade kernel 与 exact signal/binding graph 分别`tp-style.el`、`tp-reactive.el` 落地retained surface 尚未切换。
当前 API 的规范契约见 [API-SEMANTICS.md](API-SEMANTICS.md)Emacs 原生 当前 API 的规范契约见 [API-SEMANTICS.md](API-SEMANTICS.md)Emacs 原生
文本属性覆盖范围、已确认问题与演进路线见 文本属性覆盖范围、已确认问题与演进路线见
@ -177,7 +177,9 @@ Stage 2 canonical façade 是内部模型,不改变公开入口和历史返回
### tp-reactive.el响应式基础设施 ### tp-reactive.el响应式基础设施
只依赖 tp-core。维护响应式依赖注册表、变量监听器、批量更新队列与 0.3.0 新增的**层→缓冲区注册表****不包含任何渲染逻辑**,重渲染通过钩子变量委托给 tp-render.el。批量更新的队列`tp--batch-update-pending`、`tp--queue-batch-update`)定义在这里,但 `tp-with-batch-updates` 宏与刷新逻辑自 0.3.0 起位于 tp-render.el。 只依赖 tp-core。文件上半部是 TP 1.0 的唯一新响应式执行语义global/buffer-scoped signal、owner+key binding identity、dynamic dependency collection、binding→binding graph、transaction-local candidate signal values、dirty dedupe/topological lazy flush、nested write stabilization、rollback、cycle path、owner disposal、variable adapter 和 public counters。正常新热路径只从 source subscriber set 到 dirty binding不读取 `buffer-list`、文本上的 `tp-name`/`tp-layers` 或 layer→buffer registry。
文件下半部仍暂存 0.3 façade 所需的变量→layer watcher、batch queue 和 layer→buffer registry以保持旧测试在 retained surface 建成前可运行;它们没有被新 graph 调用,并将在最终 cutover phase 与 `tp-render.el` 扫描路径一起删除。这个暂存区不是第二套长期 public runtime。
#### 依赖注册与管理 #### 依赖注册与管理
| 函数/变量 | 描述 | | 函数/变量 | 描述 |
@ -571,6 +573,7 @@ insert/copy/yank/stickiness/narrowing/indirect buffer 均不在 tp-query 中封
|------|------|------|------| |------|------|------|------|
| tp-core | —— | **无可变状态**(仅 `tp-debug-mode`/`tp-debug-echo` 两个用户选项;调试日志写入 *tp-debug* 缓冲区,由 `tp-debug-clear` 清除) | - | | tp-core | —— | **无可变状态**(仅 `tp-debug-mode`/`tp-debug-echo` 两个用户选项;调试日志写入 *tp-debug* 缓冲区,由 `tp-debug-clear` 清除) | - |
| tp-style | schema、named style、stylesheet rule、cascade layer/source order registries | 纯 style definition 和 rule 状态;不保存 object、buffer 或 mount | `tp-style-reset` / `tp-style-reset-rules` | | tp-style | schema、named style、stylesheet rule、cascade layer/source order registries | 纯 style definition 和 rule 状态;不保存 object、buffer 或 mount | `tp-style-reset` / `tp-style-reset-rules` |
| tp-reactive | signals、owner bindings、dependency subscriber sets、variable adapters、transaction-local scheduler state、public counters | TP 1.0 exact reactive graphnormal update 不扫描 buffer | `tp-reactive-reset`buffer-scoped signal 随 buffer kill |
| tp-reactive | `tp-reactive-deps` | 变量 → 依赖层 注册表 | `tp-reactive-reset` | | tp-reactive | `tp-reactive-deps` | 变量 → 依赖层 注册表 | `tp-reactive-reset` |
| tp-reactive | `tp-layer-watchers` / `tp-layer-computed` / `tp-layer-data` / `tp-reactive-observer-errors` | `:watch` / `:compute` / `:data` 注册表与结构化 observer 错误 | `tp-reactive-reset` | | tp-reactive | `tp-layer-watchers` / `tp-layer-computed` / `tp-layer-data` / `tp-reactive-observer-errors` | `:watch` / `:compute` / `:data` 注册表与结构化 observer 错误 | `tp-reactive-reset` |
| tp-reactive | `tp--batch-update-pending` | 批量更新队列0.3.0 起也被 reset 清空,防止残留条目对新定义的层重放) | `tp-reactive-reset` | | tp-reactive | `tp--batch-update-pending` | 批量更新队列0.3.0 起也被 reset 清空,防止残留条目对新定义的层重放) | `tp-reactive-reset` |

315
tests/tp-binding-tests.el Normal file
View File

@ -0,0 +1,315 @@
;;; tp-binding-tests.el --- Tests for TP binding graph -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Geekinney
;;; Commentary:
;; Contract tests for TP 1.0 signals, bindings, and transactions.
;;; Code:
(require 'ert)
(require 'tp-reactive)
(defvar tp-binding-test-variable nil
"Variable used by scoped signal adapter tests.")
(defmacro tp-binding-test--isolated (&rest body)
"Run BODY with an isolated reactive runtime."
(declare (indent 0) (debug t))
`(progn
(tp-reactive-reset)
(unwind-protect (progn ,@body)
(tp-reactive-reset))))
(ert-deftest tp-binding-test-signal-invalidates-only-direct-subscribers ()
"A sparse signal write never visits unrelated bindings."
(tp-binding-test--isolated
(let ((target (tp-signal-create 0))
(unrelated (tp-signal-create 0))
(target-calls 0)
(unrelated-calls 0))
(tp-with-transaction
(tp-bind 'target-owner '(test . value)
(lambda ()
(cl-incf target-calls)
(tp-signal-read target)))
(dotimes (index 9999)
(tp-bind (list 'unrelated-owner index) '(test . value)
(lambda ()
(cl-incf unrelated-calls)
(tp-signal-read unrelated)))))
(should (= target-calls 1))
(should (= unrelated-calls 9999))
(tp-reactive-reset-counters)
(cl-letf (((symbol-function 'buffer-list)
(lambda (&rest _) (error "Legacy scan")))
((symbol-function 'text-property-search-forward)
(lambda (&rest _) (error "Legacy scan")))
((symbol-function 'tp-reactive--buffer-layer-names)
(lambda (&rest _) (error "Legacy scan")))
((symbol-function 'tp-reactive-layer-buffers)
(lambda (&rest _) (error "Legacy scan"))))
(tp-signal-set target 1))
(should (= target-calls 2))
(should (= unrelated-calls 9999))
(should (equal (tp-reactive-counters)
'(:invalidated 1 :recomputed 1 :skipped 0
:subscription-added 0 :subscription-removed 0))))))
(ert-deftest tp-binding-test-conditional-dependencies-rewire ()
"A binding unsubscribes from the branch it no longer reads."
(tp-binding-test--isolated
(let ((enabled (tp-signal-create t))
(active (tp-signal-create 'active))
(disabled (tp-signal-create 'disabled))
(calls 0))
(tp-bind 'owner '(test . branch)
(lambda ()
(cl-incf calls)
(if (tp-signal-read enabled)
(tp-signal-read active)
(tp-signal-read disabled))))
(tp-signal-set enabled nil)
(should (= calls 2))
(tp-signal-set active 'ignored)
(should (= calls 2))
(tp-signal-set disabled 'changed)
(should (= calls 3)))))
(ert-deftest tp-binding-test-equal-signal-write-is-noop ()
"Setting an equal signal value does not dirty its binding."
(tp-binding-test--isolated
(let ((source (tp-signal-create '(1 2) :equality #'equal))
(calls 0))
(tp-bind 'owner '(test . value)
(lambda ()
(cl-incf calls)
(tp-signal-read source)))
(tp-reactive-reset-counters)
(tp-signal-set source (list 1 2))
(should (= calls 1))
(should (equal (tp-reactive-counters)
'(:invalidated 0 :recomputed 0 :skipped 0
:subscription-added 0 :subscription-removed 0))))))
(ert-deftest tp-binding-test-transaction-deduplicates-writes ()
"Repeated writes in one transaction recompute each binding once."
(tp-binding-test--isolated
(let ((source (tp-signal-create 0))
(calls 0))
(tp-bind 'owner '(test . value)
(lambda ()
(cl-incf calls)
(tp-signal-read source)))
(tp-reactive-reset-counters)
(tp-with-transaction
(dotimes (value 100)
(tp-signal-set source (1+ value))))
(should (= (tp-signal-peek source) 100))
(should (= calls 2))
(should (= (plist-get (tp-reactive-counters) :recomputed) 1)))))
(ert-deftest tp-binding-test-nested-write-queues-a-second-pass ()
"A compute write queues stabilization instead of recursing."
(tp-binding-test--isolated
(let ((source (tp-signal-create 0))
(calls 0))
(let ((binding
(tp-bind 'owner '(test . stabilizing)
(lambda ()
(cl-incf calls)
(let ((value (tp-signal-read source)))
(when (zerop value)
(tp-signal-set source 1))
value)))))
(should (= (tp-binding-read binding) 1))
(should (= (tp-signal-peek source) 1))
(should (= calls 2))))))
(ert-deftest tp-binding-test-chain-stops-at-equal-computed-value ()
"An equal intermediate value prevents downstream recomputation."
(tp-binding-test--isolated
(let* ((source (tp-signal-create 10))
(middle-calls 0)
(leaf-calls 0)
(middle
(tp-bind 'middle-owner '(test . quotient)
(lambda ()
(cl-incf middle-calls)
(/ (tp-signal-read source) 10))))
(_leaf
(tp-bind 'leaf-owner '(test . display)
(lambda ()
(cl-incf leaf-calls)
(format "%s" (tp-binding-read middle))))))
(tp-signal-set source 11)
(should (= middle-calls 2))
(should (= leaf-calls 1))
(tp-signal-set source 20)
(should (= middle-calls 3))
(should (= leaf-calls 2)))))
(ert-deftest tp-binding-test-owner-disposal-cleans-graph-edges ()
"Disposing an owner removes all incoming and outgoing subscriptions."
(tp-binding-test--isolated
(let* ((source (tp-signal-create 1))
(owner (list 'owner))
(base (tp-bind owner '(test . base)
(lambda () (tp-signal-read source))))
(derived (tp-bind owner '(test . derived)
(lambda () (1+ (tp-binding-read base))))))
(should (= (tp-signal-subscriber-count source) 1))
(should (= (tp-binding-subscriber-count base) 1))
(should (= (tp-binding-dependency-count derived) 1))
(tp-binding-dispose-owner owner)
(should (= (tp-signal-subscriber-count source) 0))
(should (= (tp-binding-subscriber-count base) 0))
(should-not (tp-binding-live-p base))
(should-not (tp-binding-live-p derived)))))
(ert-deftest tp-binding-test-failed-compute-rolls-back-values-and-dependencies ()
"A failed transaction restores signal, binding, and dependency state."
(tp-binding-test--isolated
(let* ((switch (tp-signal-create t))
(left (tp-signal-create 10))
(right (tp-signal-create 20))
(binding
(tp-bind 'owner '(test . branch)
(lambda ()
(if (tp-signal-read switch)
(tp-signal-read left)
(progn
(tp-signal-read right)
(error "Broken branch")))))))
(let ((counters-before (tp-reactive-counters)))
(should-error (tp-signal-set switch nil) :type 'error)
(should (equal (tp-reactive-counters) counters-before)))
(should (tp-signal-peek switch))
(should (= (tp-binding-read binding) 10))
(should (= (tp-signal-subscriber-count left) 1))
(should (= (tp-signal-subscriber-count right) 0))
(tp-signal-set left 11)
(should (= (tp-binding-read binding) 11)))))
(ert-deftest tp-binding-test-failed-new-binding-is-unregistered ()
"A failed initial compute invalidates and unregisters the new binding."
(tp-binding-test--isolated
(let (failed)
(should-error
(tp-bind 'owner '(test . failing)
(lambda ()
(setq failed tp--current-binding)
(error "Initial failure"))))
(should-not (tp-binding-live-p failed))
(let ((replacement
(tp-bind 'owner '(test . failing) (lambda () 42))))
(should-not (eq failed replacement))
(should (= (tp-binding-read replacement) 42))))))
(ert-deftest tp-binding-test-cycle-error-reports-binding-path ()
"A binding dependency cycle reports the keys in cycle order."
(tp-binding-test--isolated
(let ((switch (tp-signal-create nil)) first second)
(setq first
(tp-bind 'first-owner '(test . first)
(lambda ()
(if (tp-signal-read switch)
(tp-binding-read second)
1))))
(setq second
(tp-bind 'second-owner '(test . second)
(lambda () (1+ (tp-binding-read first)))))
(let ((failure
(should-error (tp-signal-set switch t)
:type 'tp-binding-cycle)))
(should
(equal (cadr failure)
'((test . first) (test . second) (test . first)))))
(should-not (tp-signal-peek switch))
(should (= (tp-binding-read first) 1))
(should (= (tp-binding-read second) 2)))))
(ert-deftest tp-binding-test-dirty-target-can-break-an-old-cycle-edge ()
"A dirty target rewires before cycle validation examines its old edges."
(tp-binding-test--isolated
(let ((first-mode (tp-signal-create nil))
(second-mode (tp-signal-create t))
first second)
(setq first
(tp-bind 'first-owner '(test . first)
(lambda ()
(if (tp-signal-read first-mode)
(tp-binding-read second)
1))))
(setq second
(tp-bind 'second-owner '(test . second)
(lambda ()
(if (tp-signal-read second-mode)
(tp-binding-read first)
2))))
(tp-with-transaction
(tp-signal-set second-mode nil)
(tp-signal-set first-mode t))
(should (= (tp-binding-read first) 2))
(should (= (tp-binding-read second) 2)))))
(ert-deftest tp-binding-test-buffer-signal-dies-with-its-scope ()
"Killing a buffer-local source detaches all subscriptions."
(tp-binding-test--isolated
(let* ((buffer (generate-new-buffer " *tp-binding-scope*"))
(signal (tp-signal-create 1 :scope buffer))
(binding
(tp-bind 'owner '(test . local)
(lambda () (tp-signal-read signal)))))
(should (= (tp-binding-dependency-count binding) 1))
(kill-buffer buffer)
(should-not (tp-signal-live-p signal))
(should (= (tp-binding-dependency-count binding) 0)))))
(ert-deftest tp-binding-test-global-signal-can-be-disposed-explicitly ()
"Explicit disposal releases a global signal's graph edges."
(tp-binding-test--isolated
(let* ((signal (tp-signal-create 1))
(binding
(tp-bind 'owner '(test . global)
(lambda () (tp-signal-read signal)))))
(tp-signal-dispose signal)
(should-not (tp-signal-live-p signal))
(should (= (tp-signal-subscriber-count signal) 0))
(should (= (tp-binding-dependency-count binding) 0)))))
(ert-deftest tp-binding-test-variable-adapter-separates-global-and-buffer-scope ()
"Variable adapters route global and buffer-local writes precisely."
(tp-binding-test--isolated
(let* ((symbol 'tp-binding-test-variable)
(buffer (generate-new-buffer " *tp-binding-variable*"))
(global-calls 0)
(local-calls 0))
(unwind-protect
(progn
(set symbol 1)
(with-current-buffer buffer
(set (make-local-variable symbol) 10))
(let ((global (tp-variable-signal symbol))
(local (tp-variable-signal symbol buffer)))
(tp-bind 'global-owner '(test . global)
(lambda ()
(cl-incf global-calls)
(tp-signal-read global)))
(tp-bind 'local-owner '(test . local)
(lambda ()
(cl-incf local-calls)
(tp-signal-read local)))
(set symbol 2)
(should (= global-calls 2))
(should (= local-calls 1))
(with-current-buffer buffer
(set symbol 11))
(should (= global-calls 2))
(should (= local-calls 2))))
(when (buffer-live-p buffer) (kill-buffer buffer))
(makunbound symbol)))))
(provide 'tp-binding-tests)
;;; tp-binding-tests.el ends here

View File

@ -1,4 +1,4 @@
;;; tp-reactive.el --- Reactive state storage and registration for tp -*- lexical-binding: t -*- ;;; tp-reactive.el --- Exact signals and binding scheduler -*- lexical-binding: t -*-
;; Copyright (C) 2024-2026 Geekinney ;; Copyright (C) 2024-2026 Geekinney
@ -11,18 +11,675 @@
;;; Commentary: ;;; Commentary:
;; Reactive core of tp: storage for variable dependencies, watchers, ;; TP 1.0's exact signal-to-binding dependency graph, transaction-local
;; computed properties and data variables; registration/unregistration; ;; scheduler, scoped variable adapters, and rollback state. The lower legacy
;; the variable-watcher shell and the batching queue state. The ;; section remains temporarily available to the 0.3 layer/render facade during
;; actual re-rendering of buffers - including the queue flush and the ;; the staged cutover; new graph execution never calls its scan renderer.
;; public `tp-with-batch-updates' macro - lives in tp-render.el, which
;; installs itself via `tp--reactive-update-function'.
;;; Code: ;;; Code:
(require 'cl-lib) (require 'cl-lib)
(require 'tp-core) (require 'tp-core)
(define-error 'tp-reactive-error "TP reactive runtime error")
(define-error 'tp-invalid-signal-scope "Invalid TP signal scope"
'tp-reactive-error)
(define-error 'tp-disposed-signal "TP signal is disposed" 'tp-reactive-error)
(define-error 'tp-disposed-binding "TP binding is disposed" 'tp-reactive-error)
(define-error 'tp-binding-cycle "TP binding dependency cycle"
'tp-reactive-error)
(cl-defstruct (tp-signal (:constructor tp--make-signal))
"Reactive source with a committed value and exact subscribers."
id committed-value equality subscribers scope adapter-key disposed revision)
(cl-defstruct (tp-binding (:constructor tp--make-binding))
"Memoized computation installed on one owner and namespaced key."
id owner key compute equality last-value initialized-p dependencies
subscribers dirty state revision lifecycle disposed)
(cl-defstruct (tp--binding-snapshot
(:constructor tp--make-binding-snapshot))
compute equality last-value initialized-p dependencies dirty state
revision lifecycle disposed)
(defconst tp--reactive-absent (make-symbol "tp-reactive-absent"))
(defvar tp--signal-id-counter 0)
(defvar tp--binding-id-counter 0)
(defvar tp--signals nil)
(defvar tp--bindings nil)
(defvar tp--owner-bindings (make-hash-table :test #'eq))
(defvar tp--variable-signals (make-hash-table :test #'equal))
(defvar tp--variable-signal-watched nil)
(defvar-local tp--buffer-signals nil)
(defvar tp--reactive-counters
(list :invalidated 0 :recomputed 0 :skipped 0
:subscription-added 0 :subscription-removed 0))
(defvar tp--transaction-active nil)
(defvar tp--transaction-signal-values nil)
(defvar tp--transaction-signals nil)
(defvar tp--transaction-dirty-set nil)
(defvar tp--transaction-dirty-queue nil)
(defvar tp--transaction-binding-snapshots nil)
(defvar tp--transaction-created-bindings nil)
(defvar tp--transaction-recompute-counts nil)
(defvar tp--current-binding nil)
(defvar tp--binding-compute-stack nil)
(defvar tp--collected-dependency-set nil)
(defvar tp--collected-dependencies nil)
(defun tp--counter-increment (key)
"Increment reactive counter KEY."
(setq tp--reactive-counters
(plist-put tp--reactive-counters key
(1+ (plist-get tp--reactive-counters key)))))
(defun tp-reactive-counters ()
"Return public signal and binding scheduler counters."
(copy-sequence tp--reactive-counters))
(defun tp-reactive-reset-counters ()
"Reset public signal and binding scheduler counters."
(setq tp--reactive-counters
(list :invalidated 0 :recomputed 0 :skipped 0
:subscription-added 0 :subscription-removed 0)))
(defun tp--dependency-subscribers (dependency)
"Return the subscriber table owned by DEPENDENCY."
(cond ((tp-signal-p dependency) (tp-signal-subscribers dependency))
((tp-binding-p dependency) (tp-binding-subscribers dependency))
(t (signal 'wrong-type-argument
(list '(or tp-signal-p tp-binding-p) dependency)))))
(defun tp--subscription-add (dependency binding)
"Subscribe BINDING to DEPENDENCY."
(let ((subscribers (tp--dependency-subscribers dependency)))
(unless (gethash binding subscribers)
(puthash binding t subscribers)
(tp--counter-increment :subscription-added))))
(defun tp--subscription-remove (dependency binding)
"Unsubscribe BINDING from DEPENDENCY."
(let ((subscribers (tp--dependency-subscribers dependency)))
(when (gethash binding subscribers)
(remhash binding subscribers)
(tp--counter-increment :subscription-removed))))
(defun tp--sorted-subscribers (dependency)
"Return DEPENDENCY subscribers ordered by stable binding id."
(let (bindings)
(maphash (lambda (binding _present) (push binding bindings))
(tp--dependency-subscribers dependency))
(sort bindings (lambda (left right)
(< (tp-binding-id left) (tp-binding-id right))))))
(defun tp--binding-snapshot (binding)
"Return a rollback snapshot of BINDING."
(tp--make-binding-snapshot
:compute (tp-binding-compute binding)
:equality (tp-binding-equality binding)
:last-value (tp-binding-last-value binding)
:initialized-p (tp-binding-initialized-p binding)
:dependencies (copy-sequence (tp-binding-dependencies binding))
:dirty (tp-binding-dirty binding)
:state (tp-binding-state binding)
:revision (tp-binding-revision binding)
:lifecycle (tp-binding-lifecycle binding)
:disposed (tp-binding-disposed binding)))
(defun tp--snapshot-binding (binding)
"Save BINDING once for the active transaction."
(when (and tp--transaction-active
(not (memq binding tp--transaction-created-bindings))
(not (gethash binding tp--transaction-binding-snapshots)))
(puthash binding (tp--binding-snapshot binding)
tp--transaction-binding-snapshots)))
(defun tp--owner-binding-table (owner &optional create)
"Return OWNER's binding table, creating it when CREATE is non-nil."
(or (gethash owner tp--owner-bindings)
(when create
(let ((table (make-hash-table :test #'equal)))
(puthash owner table tp--owner-bindings)
table))))
(defun tp--register-binding (binding)
"Register BINDING under its owner and key."
(puthash (tp-binding-key binding) binding
(tp--owner-binding-table (tp-binding-owner binding) t))
(cl-pushnew binding tp--bindings :test #'eq))
(defun tp--unregister-binding (binding)
"Remove BINDING from owner and global registries."
(when-let ((table (tp--owner-binding-table (tp-binding-owner binding))))
(remhash (tp-binding-key binding) table)
(when (zerop (hash-table-count table))
(remhash (tp-binding-owner binding) tp--owner-bindings)))
(setq tp--bindings (delq binding tp--bindings)))
(defun tp--enqueue-binding (binding)
"Add dirty BINDING to the current transaction queue once."
(unless (gethash binding tp--transaction-dirty-set)
(puthash binding t tp--transaction-dirty-set)
(push binding tp--transaction-dirty-queue)))
(defun tp--mark-binding-dirty (binding)
"Mark live BINDING dirty in the current transaction."
(when (and (tp-binding-p binding) (not (tp-binding-disposed binding)))
(tp--snapshot-binding binding)
(unless (tp-binding-dirty binding)
(setf (tp-binding-dirty binding) t)
(tp--counter-increment :invalidated))
(tp--enqueue-binding binding)))
(defun tp--invalidate-subscribers (dependency &optional skip)
"Dirty DEPENDENCY subscribers except SKIP."
(dolist (binding (tp--sorted-subscribers dependency))
(unless (eq binding skip)
(tp--mark-binding-dirty binding))))
(defun tp--record-dependency (dependency)
"Record DEPENDENCY for the binding currently being computed."
(when tp--current-binding
(unless (gethash dependency tp--collected-dependency-set)
(puthash dependency t tp--collected-dependency-set)
(push dependency tp--collected-dependencies))))
(defun tp--signal-value-in-transaction (signal)
"Return SIGNAL's candidate or committed value."
(if tp--transaction-active
(let ((candidate (gethash signal tp--transaction-signal-values
tp--reactive-absent)))
(if (eq candidate tp--reactive-absent)
(tp-signal-committed-value signal)
candidate))
(tp-signal-committed-value signal)))
(defun tp--validate-live-signal (signal)
"Signal an error unless SIGNAL is live."
(unless (tp-signal-p signal)
(signal 'wrong-type-argument (list 'tp-signal-p signal)))
(when (tp-signal-disposed signal)
(signal 'tp-disposed-signal (list (tp-signal-id signal)))))
(defun tp-signal-live-p (signal)
"Return non-nil when SIGNAL is a live TP signal."
(and (tp-signal-p signal) (not (tp-signal-disposed signal))))
(defun tp--buffer-signal-kill ()
"Dispose signals scoped to the buffer being killed."
(let ((signals tp--buffer-signals))
(setq tp--buffer-signals nil)
(dolist (signal signals)
(tp--dispose-signal signal))))
(cl-defun tp-signal-create (initial-value &key (equality #'equal) (scope 'global))
"Create a signal holding INITIAL-VALUE.
EQUALITY compares writes. SCOPE is `global' or a live buffer."
(unless (functionp equality)
(signal 'wrong-type-argument (list 'functionp equality)))
(unless (or (eq scope 'global) (buffer-live-p scope))
(signal 'tp-invalid-signal-scope (list scope)))
(let ((signal (tp--make-signal
:id (cl-incf tp--signal-id-counter)
:committed-value initial-value :equality equality
:subscribers (make-hash-table :test #'eq)
:scope scope :revision 0)))
(push signal tp--signals)
(when (bufferp scope)
(with-current-buffer scope
(push signal tp--buffer-signals)
(add-hook 'kill-buffer-hook #'tp--buffer-signal-kill nil t)))
signal))
(defun tp-signal-peek (signal)
"Return SIGNAL's effective value without collecting a dependency."
(tp--validate-live-signal signal)
(tp--signal-value-in-transaction signal))
(defun tp-signal-read (signal)
"Read SIGNAL and register the current binding as a subscriber."
(tp--validate-live-signal signal)
(tp--record-dependency signal)
(tp--signal-value-in-transaction signal))
(defun tp--set-signal-candidate (signal value)
"Set candidate VALUE for SIGNAL in the active transaction."
(let ((old (tp--signal-value-in-transaction signal)))
(unless (funcall (tp-signal-equality signal) old value)
(when (eq (gethash signal tp--transaction-signal-values
tp--reactive-absent)
tp--reactive-absent)
(push signal tp--transaction-signals))
(puthash signal value tp--transaction-signal-values)
(tp--invalidate-subscribers signal)
(when (and tp--current-binding
(gethash signal tp--collected-dependency-set))
(tp--mark-binding-dirty tp--current-binding))))
value)
(defun tp-signal-set (signal value)
"Set SIGNAL to VALUE transactionally and return VALUE."
(tp--validate-live-signal signal)
(if tp--transaction-active
(tp--set-signal-candidate signal value)
(tp--call-with-transaction
(lambda () (tp--set-signal-candidate signal value)))))
(defun tp-signal-subscriber-count (signal)
"Return the number of subscribers attached to SIGNAL.
Disposed signals return zero."
(unless (tp-signal-p signal)
(signal 'wrong-type-argument (list 'tp-signal-p signal)))
(hash-table-count (tp-signal-subscribers signal)))
(defun tp--binding-dependency-path (start target visited)
"Return a binding path from START to TARGET, avoiding VISITED."
(cond
((eq start target) (list target))
((memq start visited) nil)
(t
(cl-loop for dependency in (tp-binding-dependencies start)
when (tp-binding-p dependency)
for path = (tp--binding-dependency-path
dependency target (cons start visited))
when path return (cons start path)))))
(defun tp--stack-cycle-path (target)
"Return the active compute-stack cycle ending at TARGET."
(let ((path (reverse tp--binding-compute-stack)))
(while (and path (not (eq (car path) target)))
(setq path (cdr path)))
(append path (list target))))
(defun tp--signal-binding-cycle (path)
"Signal a binding-cycle error for binding PATH."
(signal 'tp-binding-cycle
(list (mapcar (lambda (binding)
(copy-tree (tp-binding-key binding)))
path))))
(defun tp--validate-binding-read (binding &optional computing-only)
"Validate reading BINDING from the current computation.
When COMPUTING-ONLY is non-nil, defer old-graph traversal until after a
dirty target has recomputed."
(when tp--current-binding
(when (eq (tp-binding-state binding) 'computing)
(tp--signal-binding-cycle (tp--stack-cycle-path binding)))
(unless computing-only
(when-let ((path (tp--binding-dependency-path
binding tp--current-binding nil)))
(tp--signal-binding-cycle (cons tp--current-binding path))))))
(defun tp--replace-binding-dependencies (binding dependencies)
"Replace BINDING dependencies with DEPENDENCIES."
(let ((old (tp-binding-dependencies binding)))
(dolist (dependency old)
(unless (memq dependency dependencies)
(tp--subscription-remove dependency binding)))
(dolist (dependency dependencies)
(unless (memq dependency old)
(tp--subscription-add dependency binding)))
(setf (tp-binding-dependencies binding) dependencies)))
(defun tp--note-binding-recompute (binding)
"Record one recomputation of BINDING and reject runaway feedback."
(let ((count (1+ (gethash binding tp--transaction-recompute-counts 0))))
(puthash binding count tp--transaction-recompute-counts)
(when (> count 100)
(tp--signal-binding-cycle (list binding binding)))))
(defun tp--binding-recompute (binding)
"Recompute dirty BINDING inside the active transaction."
(tp--snapshot-binding binding)
(tp--note-binding-recompute binding)
(let ((requester tp--current-binding)
(old (tp-binding-last-value binding))
(initialized (tp-binding-initialized-p binding))
(tp--current-binding binding)
(tp--binding-compute-stack (cons binding tp--binding-compute-stack))
(tp--collected-dependency-set (make-hash-table :test #'eq))
(tp--collected-dependencies nil))
(setf (tp-binding-state binding) 'computing
(tp-binding-dirty binding) nil)
(let* ((value (funcall (tp-binding-compute binding)))
(dependencies (nreverse tp--collected-dependencies))
(changed (or (not initialized)
(not (funcall (tp-binding-equality binding)
old value)))))
(tp--replace-binding-dependencies binding dependencies)
(setf (tp-binding-state binding) 'clean)
(tp--counter-increment :recomputed)
(if changed
(progn
(setf (tp-binding-last-value binding) value
(tp-binding-initialized-p binding) t
(tp-binding-revision binding)
(1+ (tp-binding-revision binding)))
(tp--invalidate-subscribers binding requester))
(tp--counter-increment :skipped))
value)))
(defun tp--validate-live-binding (binding)
"Signal an error unless BINDING is live."
(unless (tp-binding-p binding)
(signal 'wrong-type-argument (list 'tp-binding-p binding)))
(when (tp-binding-disposed binding)
(signal 'tp-disposed-binding (list (tp-binding-id binding)))))
(defun tp-binding-live-p (binding)
"Return non-nil when BINDING is a live TP binding."
(and (tp-binding-p binding) (not (tp-binding-disposed binding))))
(defun tp-binding-read (binding)
"Read BINDING's memoized value and collect a dependency."
(tp--validate-live-binding binding)
(tp--validate-binding-read binding t)
(when (tp-binding-dirty binding)
(if tp--transaction-active
(tp--binding-recompute binding)
(tp--call-with-transaction (lambda () (tp--binding-recompute binding)))))
(tp--validate-binding-read binding)
(tp--record-dependency binding)
(tp-binding-last-value binding))
(defun tp-binding-subscriber-count (binding)
"Return the number of bindings depending directly on BINDING.
Disposed bindings return zero."
(unless (tp-binding-p binding)
(signal 'wrong-type-argument (list 'tp-binding-p binding)))
(hash-table-count (tp-binding-subscribers binding)))
(defun tp-binding-dependency-count (binding)
"Return BINDING's current direct dependency count.
Disposed bindings return zero."
(unless (tp-binding-p binding)
(signal 'wrong-type-argument (list 'tp-binding-p binding)))
(length (tp-binding-dependencies binding)))
(defun tp--validate-binding-options (compute equality lifecycle)
"Validate binding COMPUTE, EQUALITY, and LIFECYCLE."
(unless (functionp compute)
(signal 'wrong-type-argument (list 'functionp compute)))
(unless (functionp equality)
(signal 'wrong-type-argument (list 'functionp equality)))
(unless (memq lifecycle '(delete retain))
(signal 'tp-reactive-error (list :lifecycle lifecycle))))
(defun tp--bind-in-transaction (owner key compute equality lifecycle)
"Install COMPUTE for OWNER and KEY in the active transaction.
EQUALITY compares values and LIFECYCLE controls retention."
(let* ((table (tp--owner-binding-table owner t))
(binding (gethash key table)))
(if binding
(unless (and (eq compute (tp-binding-compute binding))
(eq equality (tp-binding-equality binding))
(eq lifecycle (tp-binding-lifecycle binding)))
(tp--snapshot-binding binding)
(setf (tp-binding-compute binding) compute
(tp-binding-equality binding) equality
(tp-binding-lifecycle binding) lifecycle)
(tp--mark-binding-dirty binding))
(setq binding
(tp--make-binding
:id (cl-incf tp--binding-id-counter)
:owner owner :key (copy-tree key) :compute compute
:equality equality :subscribers (make-hash-table :test #'eq)
:dirty t :state 'clean :revision 0 :lifecycle lifecycle))
(tp--register-binding binding)
(push binding tp--transaction-created-bindings)
(tp--enqueue-binding binding))
binding))
(cl-defun tp-bind (owner key compute &key (equality #'equal) (lifecycle 'delete))
"Idempotently install COMPUTE on OWNER under namespaced KEY.
EQUALITY suppresses unchanged downstream updates. LIFECYCLE is `delete'
or `retain'."
(when (null owner)
(signal 'tp-reactive-error (list :owner owner)))
(when (null key)
(signal 'tp-reactive-error (list :binding-key key)))
(tp--validate-binding-options compute equality lifecycle)
(if tp--transaction-active
(tp--bind-in-transaction owner key compute equality lifecycle)
(tp--call-with-transaction
(lambda () (tp--bind-in-transaction
owner key compute equality lifecycle)))))
(defun tp--detach-binding-dependencies (binding)
"Remove BINDING from all dependency subscriber tables."
(dolist (dependency (tp-binding-dependencies binding))
(tp--subscription-remove dependency binding))
(setf (tp-binding-dependencies binding) nil))
(defun tp--detach-binding-subscribers (binding)
"Detach every direct subscriber from BINDING."
(dolist (subscriber (tp--sorted-subscribers binding))
(tp--snapshot-binding subscriber)
(setf (tp-binding-dependencies subscriber)
(delq binding (tp-binding-dependencies subscriber))
(tp-binding-dirty subscriber) t)
(tp--subscription-remove binding subscriber)))
(defun tp--dispose-binding (binding)
"Dispose live BINDING without starting a transaction."
(unless (tp-binding-disposed binding)
(tp--snapshot-binding binding)
(tp--detach-binding-dependencies binding)
(tp--detach-binding-subscribers binding)
(tp--unregister-binding binding)
(setf (tp-binding-disposed binding) t
(tp-binding-dirty binding) nil
(tp-binding-state binding) 'disposed)))
(defun tp-binding-dispose-owner (owner)
"Dispose every binding installed on OWNER and return the count."
(let ((bindings (when-let ((table (tp--owner-binding-table owner)))
(let (items)
(maphash (lambda (_key binding) (push binding items)) table)
(sort items (lambda (left right)
(< (tp-binding-id left)
(tp-binding-id right))))))))
(if tp--transaction-active
(dolist (binding bindings) (tp--dispose-binding binding))
(tp--call-with-transaction
(lambda () (dolist (binding bindings) (tp--dispose-binding binding)))))
(length bindings)))
(defun tp--dequeue-dirty-binding ()
"Return and remove the next queued dirty binding."
(let (binding)
(while (and tp--transaction-dirty-queue (null binding))
(let ((candidate (pop tp--transaction-dirty-queue)))
(when (gethash candidate tp--transaction-dirty-set)
(remhash candidate tp--transaction-dirty-set)
(setq binding candidate))))
binding))
(defun tp--flush-dirty-bindings ()
"Recompute the transaction's exact dirty closure."
(let (binding)
(while (setq binding (tp--dequeue-dirty-binding))
(when (and (tp-binding-live-p binding) (tp-binding-dirty binding))
(tp--binding-recompute binding)))))
(defun tp--restore-binding (binding snapshot)
"Restore BINDING fields from SNAPSHOT."
(setf (tp-binding-compute binding) (tp--binding-snapshot-compute snapshot)
(tp-binding-equality binding) (tp--binding-snapshot-equality snapshot)
(tp-binding-last-value binding) (tp--binding-snapshot-last-value snapshot)
(tp-binding-initialized-p binding)
(tp--binding-snapshot-initialized-p snapshot)
(tp-binding-dependencies binding)
(copy-sequence (tp--binding-snapshot-dependencies snapshot))
(tp-binding-dirty binding) (tp--binding-snapshot-dirty snapshot)
(tp-binding-state binding) (tp--binding-snapshot-state snapshot)
(tp-binding-revision binding) (tp--binding-snapshot-revision snapshot)
(tp-binding-lifecycle binding) (tp--binding-snapshot-lifecycle snapshot)
(tp-binding-disposed binding) (tp--binding-snapshot-disposed snapshot)))
(defun tp--rollback-bindings ()
"Restore all bindings touched by the active transaction."
(let (snapshots)
(maphash (lambda (binding snapshot)
(push (cons binding snapshot) snapshots))
tp--transaction-binding-snapshots)
(dolist (binding (append tp--transaction-created-bindings
(mapcar #'car snapshots)))
(tp--detach-binding-dependencies binding))
(dolist (binding tp--transaction-created-bindings)
(tp--unregister-binding binding)
(setf (tp-binding-disposed binding) t
(tp-binding-state binding) 'disposed))
(dolist (entry snapshots)
(pcase-let ((`(,binding . ,snapshot) entry))
(tp--restore-binding binding snapshot)
(unless (tp-binding-disposed binding)
(tp--register-binding binding)
(dolist (dependency (tp-binding-dependencies binding))
(tp--subscription-add dependency binding)))))))
(defun tp--commit-signal-values ()
"Commit candidate signal values after a successful flush."
(dolist (signal (nreverse tp--transaction-signals))
(setf (tp-signal-committed-value signal)
(gethash signal tp--transaction-signal-values)
(tp-signal-revision signal) (1+ (tp-signal-revision signal)))))
(defun tp--call-with-transaction (function)
"Call FUNCTION in one atomic signal and binding transaction."
(if tp--transaction-active
(funcall function)
(let ((tp--transaction-active t)
(tp--transaction-signal-values (make-hash-table :test #'eq))
(tp--transaction-signals nil)
(tp--transaction-dirty-set (make-hash-table :test #'eq))
(tp--transaction-dirty-queue nil)
(tp--transaction-binding-snapshots (make-hash-table :test #'eq))
(tp--transaction-created-bindings nil)
(tp--transaction-recompute-counts (make-hash-table :test #'eq))
(counter-snapshot (copy-sequence tp--reactive-counters))
success result)
(unwind-protect
(progn
(setq result (funcall function))
(tp--flush-dirty-bindings)
(tp--commit-signal-values)
(setq success t)
result)
(unless success
(let ((inhibit-quit t))
(tp--rollback-bindings)
(setq tp--reactive-counters counter-snapshot)))))))
;;;###autoload
(defmacro tp-with-transaction (&rest body)
"Evaluate BODY in one atomic signal and binding transaction."
(declare (indent 0) (debug t))
`(tp--call-with-transaction (lambda () ,@body)))
(defun tp--variable-signal-key (symbol scope)
"Return the adapter key for SYMBOL and SCOPE."
(cons symbol scope))
(defun tp--variable-signal-watcher (symbol new-value operation where)
"Forward SYMBOL's NEW-VALUE write into its exact signal adapter.
OPERATION and WHERE follow the standard variable watcher protocol."
(when (eq operation 'set)
(when-let ((signal (gethash (tp--variable-signal-key
symbol (or where 'global))
tp--variable-signals)))
(when (tp-signal-live-p signal)
(tp-signal-set signal new-value)))))
(defun tp--variable-signal-initial-value (symbol scope)
"Return SYMBOL's initial adapter value in SCOPE."
(if (eq scope 'global)
(if (default-boundp symbol) (default-value symbol) nil)
(with-current-buffer scope
(if (boundp symbol) (symbol-value symbol) nil))))
(defun tp-variable-signal (symbol &optional buffer)
"Return the signal adapting SYMBOL globally or in BUFFER."
(unless (symbolp symbol)
(signal 'wrong-type-argument (list 'symbolp symbol)))
(when (and buffer (not (buffer-live-p buffer)))
(signal 'tp-invalid-signal-scope (list buffer)))
(let* ((scope (or buffer 'global))
(key (tp--variable-signal-key symbol scope)))
(or (gethash key tp--variable-signals)
(let ((signal (tp-signal-create
(tp--variable-signal-initial-value symbol scope)
:scope scope)))
(setf (tp-signal-adapter-key signal) key)
(puthash key signal tp--variable-signals)
(unless (memq symbol tp--variable-signal-watched)
(add-variable-watcher symbol #'tp--variable-signal-watcher)
(push symbol tp--variable-signal-watched))
signal))))
(defun tp--symbol-has-variable-signal-p (symbol)
"Return non-nil when SYMBOL still owns an adapter signal."
(let ((found nil))
(maphash (lambda (key _signal)
(when (eq (car key) symbol) (setq found t)))
tp--variable-signals)
found))
(defun tp--dispose-signal (signal)
"Dispose SIGNAL and detach its graph and adapter state."
(unless (tp-signal-disposed signal)
(dolist (binding (tp--sorted-subscribers signal))
(setf (tp-binding-dependencies binding)
(delq signal (tp-binding-dependencies binding))
(tp-binding-dirty binding) t))
(clrhash (tp-signal-subscribers signal))
(when-let ((key (tp-signal-adapter-key signal)))
(remhash key tp--variable-signals)
(let ((symbol (car key)))
(unless (tp--symbol-has-variable-signal-p symbol)
(remove-variable-watcher symbol #'tp--variable-signal-watcher)
(setq tp--variable-signal-watched
(delq symbol tp--variable-signal-watched)))))
(when-let ((scope (and (bufferp (tp-signal-scope signal))
(tp-signal-scope signal))))
(when (buffer-live-p scope)
(with-current-buffer scope
(setq tp--buffer-signals (delq signal tp--buffer-signals)))))
(setf (tp-signal-disposed signal) t)
(setq tp--signals (delq signal tp--signals))))
(defun tp-signal-dispose (signal)
"Dispose SIGNAL, detach its subscriptions, and return nil."
(unless (tp-signal-p signal)
(signal 'wrong-type-argument (list 'tp-signal-p signal)))
(when tp--transaction-active
(signal 'tp-reactive-error (list :dispose-during-transaction)))
(tp--dispose-signal signal)
nil)
(defun tp--reactive-graph-reset ()
"Clear all signal, binding, adapter, and scheduler state."
(when tp--transaction-active
(signal 'tp-reactive-error (list :reset-during-transaction)))
(dolist (binding tp--bindings)
(tp--detach-binding-dependencies binding)
(clrhash (tp-binding-subscribers binding))
(setf (tp-binding-disposed binding) t
(tp-binding-state binding) 'disposed))
(dolist (signal (copy-sequence tp--signals))
(tp--dispose-signal signal))
(clrhash tp--owner-bindings)
(clrhash tp--variable-signals)
(setq tp--bindings nil
tp--signals nil
tp--variable-signal-watched nil)
(tp-reactive-reset-counters))
(defvar tp-reactive-deps nil (defvar tp-reactive-deps nil
"Alist mapping reactive variables to dependent layers. "Alist mapping reactive variables to dependent layers.
Each element: (VAR-SYMBOL . ((LAYER-NAME . REACTIVE-PROPS) ...)).") Each element: (VAR-SYMBOL . ((LAYER-NAME . REACTIVE-PROPS) ...)).")
@ -436,6 +1093,7 @@ it to allow re-definition to change initial values."
(defun tp-reactive-reset () (defun tp-reactive-reset ()
"Reset all reactive text property watchers and dependencies." "Reset all reactive text property watchers and dependencies."
(interactive) (interactive)
(tp--reactive-graph-reset)
;; Remove all variable watchers ;; Remove all variable watchers
(dolist (dep tp-reactive-deps) (dolist (dep tp-reactive-deps)
(let ((var-sym (car dep))) (let ((var-sym (car dep)))

4
tp.el
View File

@ -24,8 +24,8 @@
;; debug logging, pure $var utilities. ;; debug logging, pure $var utilities.
;; tp-style.el Property schemas, structured selectors, cascade, ;; tp-style.el Property schemas, structured selectors, cascade,
;; custom properties, and explicit computed values. ;; custom properties, and explicit computed values.
;; tp-reactive.el Reactive state: dependency registry, variable ;; tp-reactive.el Exact signals, bindings, transactions, scoped variable
;; watchers, batching queue. ;; adapters, plus temporary legacy layer watcher state.
;; tp-layer.el Layer registry: `define-tp', `define-tps', ;; tp-layer.el Layer registry: `define-tp', `define-tps',
;; layer/group resolution and expansion. ;; layer/group resolution and expansion.
;; tp-ops.el Core primitives: `tp-set', `tp-reset', `tp-add', ;; tp-ops.el Core primitives: `tp-set', `tp-reset', `tp-add',