John Wiegley <[email protected]> writes:
>>>>>> Ihor Radchenko <[email protected]> writes:
>
>> Not yet converged :)
>> Let this human pass the next input from the mighty LLM.
>> (Jokes aside, iterative convergence of the cells is what I use by
>> myself, and I'd prefer to keep that feature)
>
> Let’s keep the fun going. Many fixes posted to
>
> https://github.com/jwiegley/org-table-loeb
I finally got to this.
Now, testing with my latest version of the custom harness :)
The generated report is below:
Summary: six behavioral divergences, three of which are critical. They
cluster into a small number of root causes in the substitution and
iteration layers.
---
1. Iteration freezes self-referential cells instead of iterating
=org-table-loeb--make-cell-closure= returns the initial value for any cell
inside an SCC:
#+begin_src elisp
((and cycle-cells (gethash (cons row col) cycle-cells) formula)
(lambda (_matrix) value))
#+end_src
So between passes of =org-table-loeb--iterate= the cell value never changes.
Classic recomputes from the previous pass.
Example (converges to a wrong value):
#+begin_example
| 100 | |
#+TBLFM: @1$2=floor(($1+@1$2)/2)
#+end_example
- classic (C-u C-u C-c /): =("100" "99")=
- loeb: =("100" "")=, reports "Convergence after 2 iterations"
Example (divergent):
#+begin_example
| 0 | |
#+TBLFM: @1$1=1+@1$1
#+end_example
- classic: =("10" "")=, then "No convergence after 10 iterations"
- loeb: =("0" "")=, "Table was already stable"
Fix: implement real fixed-point iteration — each pass recomputes every SCC cell
from the previous pass's values. This means SCC cells must not go through the
Löb thunk layer at all; freeze-at-initial-value is not iteration.
---
2. Negative precision flags are ignored
Classic parses =f=, =n=, =s=, =e=, =p= with an optional sign:
=\([pnfse]\)\(-?[0-9]+\)=. Loeb's regex in =org-table-loeb--parse-format-flags=
is =\([pnfse]\)\([0-9]+\)= — no =-?=.
- =@1$2=$1/4;f-1=: classic ="1.3"=, loeb ="1.25"=
- =@1$2=$1/2;f-2=: classic ="2.50"=, loeb ="2.5"=
Also, the =f-1= branch in =org-table-loeb--format-result= ("preserve empty
results") is dead code and encodes a wrong reading: =f-1= means one decimal
place, not "preserve empty". Either fix the parse (add =-?=) and remove the
misleading branch, or drop it.
---
3. Range endpoint with omitted column (breaks the most common sum idiom)
Org's rule: "if you omit the column, the current column is implied."
=org-table-loeb--substitute-ranges= does two things wrong:
- For =@R1$C1..@R2= (end column omitted), it defaults the end column to the
/start/ column: =(c2 (or (match-string 5 result) c1))=.
- It has no case for a bare row range with no columns at all, =@2..@-1=.
Consequences:
#+begin_example
| 10 |
| 20 |
| 30 |
| |
#+TBLFM: @>$1=vsum(@2..@-1)
#+end_example
- classic: =... "50"=
- loeb: =... "#ERROR: Expected a number"=
=vsum(@2..@-1)= is the canonical "sum this column" idiom (top SO answer for
summing a column). This is a hard regression.
Same root cause, with a column on the start only:
#+begin_example
| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 0 | 0 | |
#+TBLFM: @3$3=vsum(@1$1..@3)
#+end_example
- classic: =... "21"= (current column 3 implied)
- loeb: =... "5"=
And with a relative start:
#+begin_example
#+TBLFM: @3$3=vsum(@-1$-2..@-1)
#+end_example
- classic: =... "15"=
- loeb: =... "4"=
Fix: when either endpoint omits its column, substitute the /current/ column
(the column of the target field), not the other endpoint's column; and add a
case for bare row ranges =@R1..@R2=.
Note: your existing =test-org-table-loeb/range-implicit-column= currently
encodes the wrong semantics (it expects the omitted column to mean "same as the
other endpoint", result 60). It blesses the bug and will need to change once
this is fixed.
---
4. Self-reference through the =$>= alias is dropped, not computed
#+begin_example
| 1 | 2 | 3 | 4 | 5 | |
#+TBLFM: $6=$>+$>>
#+end_example
- classic: =... "5"= (=$>= expands to =$6=, reads the pre-existing empty cell
as 0, adds =$>>==5)
- loeb: =... ""= (silently leaves the cell empty)
Same class as #1: =$>= → =$6= makes this a self-cycle, and loeb freezes it. A
single-pass =C-c /= should compute from the current value, not drop the result.
---
5. =L= flag does not interpolate unquoted numeric references
='(substring "$1" $2 $3);L= with =("hello world" "0" "5")=:
- classic: ="hello"=
- loeb: =#ERROR: Wrong type argument: integerp, "0"=
Root cause: =org-table-loeb--substitute-refs= only branches on
=:force-numbers=, never =:literal=. Under =L=, =$2= and =$3= should be
interpolated bare (=0=, =5=), not wrapped in =org-table-loeb--get= (which
returns ="0"=, ="5"=).
---
6. =E= flag does not make quoted ="nan"= comparisons work
=if("$1" == "nan" || "$2" == "nan", string(""), $1 + $2); E= with =("1" "")=:
- classic: =""= (the =if= branch fires)
- loeb: ="nan"= (falls through to =$1+$2=)
Root cause: the empty→ ="nan"= substitution under =E= is not applied inside the
quoted string =""$2""=, so ="$2" == "nan"= is false and Calc computes =1 + nan=.
---
How I reproduced it
Self-contained harness (run against an Org checkout with =org-test= and
=org-table-loeb= on the load path):
#+begin_src elisp
;;; compare-loeb.el --- classic vs loeb differential runner -*-
lexical-binding: t; -*-
(require 'org)
(require 'org-table)
(require 'org-table-loeb)
(org-table-loeb-install)
(defun cmp--result (text engine all)
"Run TEXT through ENGINE with ALL recalc arg; return stripped table as list."
(let ((buf (generate-new-buffer " *cmp*")))
(unwind-protect
(with-current-buffer buf
(org-mode)
(insert text)
(goto-char (point-min))
(let ((org-table-formula-engine engine))
(condition-case err
(org-table-recalculate all)
(error (format "ERROR<%s>" (error-message-string err)))))
(mapcar (lambda (row)
(if (eq row 'hline) 'hline
(mapcar (lambda (c) (if (stringp c)
(substring-no-properties c) c))
row)))
(org-table-to-lisp)))
(kill-buffer buf))))
(defun cmp--case (label text &optional all)
(let* ((all (or all 'all))
(c (cmp--result text 'classic all))
(l (cmp--result text 'loeb all)))
(princ (format "%-28s %s\n" label (if (equal c l) "MATCH" "DIVERGE")))
(unless (equal c l)
(princ (format " classic=%S\n loeb =%S\n" c l)))))
(cmp--case "iter-convergent" "| 100 | |
#+TBLFM: @1$2=floor(($1+@1$2)/2)" 'iterate)
(cmp--case "iter-divergent" "| 0 | |
#+TBLFM: @1$1=1+@1$1" 'iterate)
(cmp--case "f-neg-1" "| 5 | |
#+TBLFM: @1$2=@1$1/4;f-1")
(cmp--case "f-neg-2" "| 5 | |
#+TBLFM: @1$2=@1$1/2;f-2")
(cmp--case "bare-row-range" "| 10 |
| 20 |
| 30 |
| |
#+TBLFM: @>$1=vsum(@2..@-1)")
(cmp--case "end-col-omitted" "| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 0 | 0 | |
#+TBLFM: @3$3=vsum(@1$1..@3)")
(cmp--case "rel-range-end-col" "| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 0 | 0 | |
#+TBLFM: @3$3=vsum(@-1$-2..@-1)")
(cmp--case "self-ref-dollar-gt" "| 1 | 2 | 3 | 4 | 5 | |
#+TBLFM: $6=$>+$>>")
(cmp--case "L-flag-substring" "| hello world | 0 | 5 | |
#+TBLFM: @1$4='(substring \"$1\" $2 $3);L")
(cmp--case "E-nan-if" "| 1 | | |
#+TBLFM: @1$3=if(\"$1\" == \"nan\" || \"$2\" == \"nan\", string(\"\"), $1 +
$2); E f-1")
#+end_src
Run:
#+begin_src bash
emacs -Q --batch \
-L /path/to/org-mode/lisp -L /path/to/org-mode/testing \
-L /path/to/org-table-loeb \
-l compare-loeb.el
#+end_src
---
ERT tests to add to =test-org-table-loeb.el=
Note: your =test-org-table-loeb--compare-engines= macro hardcodes ='all= as the
recalc argument, so it cannot be used for the iteration cases. Either extend it
with an optional =ALL= argument, or add a second macro. Below I assume the
extended form =(... (text) &optional all)=.
#+begin_src elisp
;; A. iteration
(ert-deftest test-org-table-loeb/parity-iterate-convergent ()
(test-org-table-loeb--compare-engines
"| 100 | |
#+TBLFM: @1$2=floor(($1+@1$2)/2)" 'iterate))
(ert-deftest test-org-table-loeb/parity-iterate-divergent ()
(test-org-table-loeb--compare-engines
"| 0 | |
#+TBLFM: @1$1=1+@1$1" 'iterate))
;; B. negative precision
(ert-deftest test-org-table-loeb/parity-f-neg-1 ()
(test-org-table-loeb--compare-engines
"| 5 | |
#+TBLFM: @1$2=@1$1/4;f-1"))
(ert-deftest test-org-table-loeb/parity-f-neg-2 ()
(test-org-table-loeb--compare-engines
"| 5 | |
#+TBLFM: @1$2=@1$1/2;f-2"))
;; C. omitted column in ranges
(ert-deftest test-org-table-loeb/parity-bare-row-range ()
(test-org-table-loeb--compare-engines
"| 10 |
| 20 |
| 30 |
| |
#+TBLFM: @>$1=vsum(@2..@-1)"))
(ert-deftest test-org-table-loeb/parity-end-col-omitted ()
(test-org-table-loeb--compare-engines
"| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 0 | 0 | |
#+TBLFM: @3$3=vsum(@1$1..@3)"))
;; E. L flag literal numbers
(ert-deftest test-org-table-loeb/parity-L-flag-substring ()
(test-org-table-loeb--compare-engines
"| hello world | 0 | 5 | |
#+TBLFM: @1$4='(substring \"$1\" $2 $3);L"))
;; F. E flag + quoted nan comparison
(ert-deftest test-org-table-loeb/parity-E-nan-if ()
(test-org-table-loeb--compare-engines
"| 1 | | |
#+TBLFM: @1$3=if(\"$1\" == \"nan\" || \"$2\" == \"nan\", string(\"\"), $1 +
$2); E"))
#+end_src
---
Additional:
1. The =defcustom= has no =:package-version= and no =:safe= — both required to
be explicit by Org convention.
2. The file Commentary claims "topological sort" and "fall back to iteration
for SCCs"; neither exists (order is thunk-forcing; SCCs either error or
freeze). CLAUDE.md marks #5 "(Reserved)", but the source Commentary does not.
What I did not cover: cross-file and =org-id= remote references, and
=constants.el= unit references (=$c/$1/$cm=), since they need an external
file/package. Worth a follow-up pass.
[Disclaimer: all values are from the sandbox copies at /tmp/scratch/org-mode
(your checkout) and /tmp/scratch/org-table-loeb, compared via
org-table-to-lisp. I did not modify the real tree.]
--
Ihor Radchenko // yantar92,
Org mode maintainer,
Learn more about Org mode at <https://orgmode.org/>.
Support Org development at <https://liberapay.com/org-mode>,
or support my work at <https://liberapay.com/yantar92>