Skip to content

Commit 4cf8584

Browse files
feat: add jupyter-compatible progress UI path for PySR
1 parent 2ac7e71 commit 4cf8584

12 files changed

Lines changed: 675 additions & 45 deletions

JUPYTER_PROGRESS_HANDOFF.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# PySR Jupyter Progress Handoff (local)
2+
3+
## What changed
4+
- `pysr/jupyter_progress.py` (new): notebook progress context/parser and notebook UI rendering helpers.
5+
- `pysr/sr.py`:
6+
- imports `JupyterProgressContext` + `should_use_jupyter_progress`
7+
- computes `equation_search_progress`
8+
- in notebook mode, disables Julia native bar for the call (`progress=False`) and captures textual progress lines into Python-side notebook UI
9+
- removed old behavior that force-disabled progress in Jupyter when `sys.stdout` had no `buffer`
10+
- `pysr/test/test_main.py`: expectation updated to avoid blanket progress-disable behavior.
11+
- `pysr/test/test_jupyter_progress_helpers.py` (new): helper/parser tests.
12+
13+
## Local evidence
14+
- Notebook execution (nbconvert):
15+
- output notebook: `tmp_jupyter_smoke.out.ipynb`
16+
- contains widget output: `application/vnd.jupyter.widget-view+json`
17+
- contains terminal marker: `NB_FIT_DONE`
18+
- Helper tests:
19+
- `python3 scripts/test_jupyter_progress_helpers.py` => `helpers-tests=ok`
20+
- `python -m pytest -q pysr/test/test_jupyter_progress_helpers.py` => `3 passed`
21+
22+
## Repro commands
23+
```bash
24+
cd /root/.openclaw/workspace/worktrees/pysr-jupyter-progress
25+
source .venv/bin/activate
26+
27+
# helper tests
28+
python3 scripts/test_jupyter_progress_helpers.py
29+
python -m pytest -q pysr/test/test_jupyter_progress_helpers.py
30+
31+
# notebook execution smoke
32+
jupyter nbconvert --to notebook --execute tmp_jupyter_smoke.ipynb --output tmp_jupyter_smoke.out.ipynb
33+
```
34+
35+
## Note
36+
- In this host environment, pytest exits with a late segfault after printing `3 passed` (likely runtime/finalizer issue), but the tests themselves complete and report pass before process teardown.

JUPYTER_PROGRESS_INVESTIGATION.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Jupyter Progress Investigation (PySR `fit()`)
2+
3+
## Scope
4+
- Focused on Jupyter notebook progress UX for `fit()`.
5+
- Explicitly did not modify `input_stream`, `devnull`, or input-stream auto-selection logic.
6+
7+
## 1) Current progress path (Julia -> Python)
8+
- `pysr/sr.py` passes `progress=...` to `SymbolicRegression.equation_search(...)`.
9+
- Existing behavior had a Python guard in `_mutate_parameter` that disabled progress when `sys.stdout` lacked `buffer` (Jupyter heuristic).
10+
- In SymbolicRegression.jl:
11+
- `progress=true` uses Julia progress bar (`update_progress_bar!`).
12+
- `progress=false` and `verbosity>0` prints textual status including:
13+
- `Progress: X / Y total iterations (...)`
14+
15+
Command evidence:
16+
```bash
17+
rg -n "progress|equation_search\\(" pysr/sr.py -S
18+
```
19+
```text
20+
2295: progress=runtime_params.progress ...
21+
```
22+
```bash
23+
sed -n '1000,1115p' ~/.julia/packages/SymbolicRegression/VApOk/src/SymbolicRegression.jl
24+
```
25+
```text
26+
if ropt.verbosity > 0 && !ropt.progress ...
27+
print_search_state(...)
28+
```
29+
30+
## 2) Alternatives implemented + tested
31+
32+
### A) Python-side notebook UI driven by parsed progress signals
33+
- Prototype file: `scripts/jupyter_progress_prototype_a_python_ui.py`
34+
- Mechanism:
35+
- Parse text lines matching `Progress: X / Y total iterations`.
36+
- Update a Python-side display state.
37+
- Command:
38+
```bash
39+
python3 scripts/jupyter_progress_prototype_a_python_ui.py
40+
```
41+
- Output:
42+
```text
43+
prototype=A
44+
updates= [(3, 10), (10, 10)]
45+
```
46+
- Result: Works reliably with split/chunked stream writes.
47+
48+
### B) Julia-side progress emission bridged into Python output parsing
49+
- Prototype file: `scripts/jupyter_progress_prototype_b_julia_output_bridge.py`
50+
- Mechanism:
51+
- Julia emits machine-parseable progress lines.
52+
- Python parses bridge output and reconstructs progress.
53+
- Command:
54+
```bash
55+
python3 scripts/jupyter_progress_prototype_b_julia_output_bridge.py
56+
```
57+
- Output:
58+
```text
59+
prototype=B
60+
updates= [(1, 8), (2, 8), (3, 8), (4, 8), (5, 8), (6, 8), (7, 8), (8, 8)]
61+
```
62+
- Result: Strong signal quality; requires explicit Julia-side emission contract.
63+
64+
### C) File-polling progress transport
65+
- Prototype file: `scripts/jupyter_progress_prototype_c_file_poll.py`
66+
- Mechanism:
67+
- Producer writes JSON progress snapshots.
68+
- Consumer polls file and updates UI.
69+
- Command:
70+
```bash
71+
python3 scripts/jupyter_progress_prototype_c_file_poll.py
72+
```
73+
- Output:
74+
```text
75+
prototype=C
76+
updates= [(1, 12), (2, 12), (3, 12), (4, 12), (5, 12), (6, 12), (7, 12), (8, 12), (9, 12), (10, 12), (11, 12), (12, 12)]
77+
```
78+
- Result: Functional but introduces file I/O overhead and synchronization complexity.
79+
80+
## 3) Selected design
81+
- Chosen: **A (Python-side notebook UI + parsed progress lines)**.
82+
- Why:
83+
- No backend protocol changes required.
84+
- Preserves existing terminal behavior.
85+
- Works with optional notebook UI dependencies (`tqdm.notebook`, then `ipywidgets`, then no-op fallback).
86+
- Implemented in:
87+
- `pysr/jupyter_progress.py`
88+
- `pysr/sr.py`
89+
90+
Design details:
91+
- Detect notebook sessions (`ZMQInteractiveShell`).
92+
- In notebook + eligible progress case:
93+
- Disable Julia bar for that call (`progress=False` for `equation_search`) to force textual progress lines.
94+
- Wrap `sys.stdout` and `sys.stderr`.
95+
- Parse `Progress: X / Y total iterations` lines.
96+
- Render notebook progress via:
97+
1. `tqdm.notebook`
98+
2. `ipywidgets` (`IntProgress`)
99+
3. no-op fallback
100+
- Outside notebook: unchanged behavior (Julia progress/CLI remains as before).
101+
102+
## 4) Tests and validation
103+
104+
### Added tests
105+
- `pysr/test/test_jupyter_progress_helpers.py`
106+
- Updated:
107+
- `pysr/test/test_main.py`
108+
- `test_progress_disabled_when_stdout_lacks_buffer` ->
109+
`test_progress_not_disabled_when_stdout_lacks_buffer`
110+
- Also added standalone script tests:
111+
- `scripts/test_jupyter_progress_helpers.py`
112+
113+
### Commands run
114+
```bash
115+
python3 scripts/test_jupyter_progress_helpers.py
116+
```
117+
```text
118+
helpers-tests=ok
119+
```
120+
121+
```bash
122+
python3 -m compileall pysr/jupyter_progress.py pysr/sr.py scripts/jupyter_progress_prototype_a_python_ui.py scripts/jupyter_progress_prototype_b_julia_output_bridge.py scripts/jupyter_progress_prototype_c_file_poll.py
123+
```
124+
```text
125+
Compiling 'pysr/sr.py'...
126+
Compiling 'scripts/jupyter_progress_prototype_a_python_ui.py'...
127+
Compiling 'scripts/jupyter_progress_prototype_b_julia_output_bridge.py'...
128+
Compiling 'scripts/jupyter_progress_prototype_c_file_poll.py'...
129+
```
130+
131+
## 5) Regression expectation
132+
- Terminal/non-notebook progress path remains unchanged.
133+
- Notebook path now uses Python-side UI instead of force-disabling progress.
134+
135+
## 6) Repro demo
136+
- Lightweight parser demo:
137+
```bash
138+
python3 scripts/jupyter_progress_prototype_a_python_ui.py
139+
```
140+
- Full PySR notebook demo command (in a real env with `juliacall` and notebook kernel installed):
141+
```python
142+
from pysr import PySRRegressor
143+
import numpy as np
144+
145+
X = np.random.randn(200, 3)
146+
y = X[:, 0] - X[:, 1]
147+
model = PySRRegressor(niterations=40, populations=8, progress=True, verbosity=1)
148+
model.fit(X, y)
149+
```

pysr/jupyter_progress.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Jupyter-compatible progress display helpers for PySR."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import sys
7+
from contextlib import contextmanager
8+
from dataclasses import dataclass
9+
from typing import Callable, Iterator, Protocol
10+
11+
12+
_PROGRESS_PATTERN = re.compile(r"Progress:\s*(\d+)\s*/\s*(\d+)\s*total iterations")
13+
14+
15+
class _ProgressDisplay(Protocol):
16+
def update(self, current: int, total: int) -> None: ...
17+
18+
def close(self) -> None: ...
19+
20+
21+
class _NullProgressDisplay:
22+
def update(self, current: int, total: int) -> None:
23+
return None
24+
25+
def close(self) -> None:
26+
return None
27+
28+
29+
class _TqdmProgressDisplay:
30+
def __init__(self, total: int):
31+
from tqdm.notebook import tqdm
32+
33+
self._bar = tqdm(total=total, desc="PySR fit", leave=True)
34+
self._current = 0
35+
36+
def update(self, current: int, total: int) -> None:
37+
if total != self._bar.total:
38+
self._bar.total = total
39+
delta = max(0, current - self._current)
40+
if delta > 0:
41+
self._bar.update(delta)
42+
self._current = current
43+
44+
def close(self) -> None:
45+
self._bar.close()
46+
47+
48+
class _IpywidgetsProgressDisplay:
49+
def __init__(self, total: int):
50+
from IPython.display import display
51+
from ipywidgets import HTML, IntProgress, VBox
52+
53+
self._bar = IntProgress(value=0, min=0, max=max(total, 1), description="PySR fit")
54+
self._label = HTML(value=f"0 / {total} iterations")
55+
self._widget = VBox([self._bar, self._label])
56+
display(self._widget)
57+
58+
def update(self, current: int, total: int) -> None:
59+
self._bar.max = max(total, 1)
60+
self._bar.value = min(max(current, 0), self._bar.max)
61+
self._label.value = f"{current} / {total} iterations"
62+
63+
def close(self) -> None:
64+
return None
65+
66+
67+
def _is_notebook_session() -> bool:
68+
try:
69+
from IPython import get_ipython
70+
except Exception:
71+
return False
72+
73+
ipython = get_ipython()
74+
if ipython is None:
75+
return False
76+
return ipython.__class__.__name__ == "ZMQInteractiveShell"
77+
78+
79+
def _create_display(total: int) -> _ProgressDisplay:
80+
try:
81+
return _TqdmProgressDisplay(total=total)
82+
except Exception:
83+
pass
84+
85+
try:
86+
return _IpywidgetsProgressDisplay(total=total)
87+
except Exception:
88+
return _NullProgressDisplay()
89+
90+
91+
@dataclass
92+
class _ProgressLineParser:
93+
on_progress: Callable[[int, int], None]
94+
95+
def parse_line(self, line: str) -> None:
96+
match = _PROGRESS_PATTERN.search(line)
97+
if match is None:
98+
return
99+
current = int(match.group(1))
100+
total = int(match.group(2))
101+
self.on_progress(current, total)
102+
103+
104+
class _ProgressCaptureStream:
105+
def __init__(self, target_stream, parser: _ProgressLineParser):
106+
self._target = target_stream
107+
self._parser = parser
108+
self._buffer = ""
109+
110+
def write(self, text: str) -> int:
111+
if not isinstance(text, str):
112+
text = str(text)
113+
written = self._target.write(text)
114+
self._buffer += text
115+
while "\n" in self._buffer:
116+
line, self._buffer = self._buffer.split("\n", 1)
117+
self._parser.parse_line(line)
118+
return written if isinstance(written, int) else len(text)
119+
120+
def flush(self) -> None:
121+
if self._buffer:
122+
self._parser.parse_line(self._buffer)
123+
self._buffer = ""
124+
if hasattr(self._target, "flush"):
125+
self._target.flush()
126+
127+
def __getattr__(self, name: str):
128+
return getattr(self._target, name)
129+
130+
131+
class JupyterProgressContext:
132+
"""Capture text progress lines and render a notebook progress widget."""
133+
134+
def __init__(self, total_iterations: int):
135+
self.total_iterations = max(int(total_iterations), 1)
136+
self.display: _ProgressDisplay = _NullProgressDisplay()
137+
self._parser = _ProgressLineParser(self._on_progress)
138+
self._current = 0
139+
140+
def _on_progress(self, current: int, total: int) -> None:
141+
self._current = current
142+
self.display.update(current, total)
143+
144+
@contextmanager
145+
def capture(self) -> Iterator[None]:
146+
self.display = _create_display(self.total_iterations)
147+
self.display.update(0, self.total_iterations)
148+
stdout_capture = _ProgressCaptureStream(sys.stdout, self._parser)
149+
stderr_capture = _ProgressCaptureStream(sys.stderr, self._parser)
150+
old_stdout, old_stderr = sys.stdout, sys.stderr
151+
try:
152+
sys.stdout = stdout_capture
153+
sys.stderr = stderr_capture
154+
yield
155+
finally:
156+
stdout_capture.flush()
157+
stderr_capture.flush()
158+
sys.stdout, sys.stderr = old_stdout, old_stderr
159+
self.display.update(self.total_iterations, self.total_iterations)
160+
self.display.close()
161+
162+
163+
def should_use_jupyter_progress(*, progress: bool, verbosity: int, is_single_output: bool) -> bool:
164+
"""Whether PySR should use Python-side notebook progress handling."""
165+
if not progress or verbosity <= 0 or not is_single_output:
166+
return False
167+
return _is_notebook_session()

0 commit comments

Comments
 (0)