Skip to content

pysimlin: file-backed models and an in-notebook model editor - #1024

Merged
bpowers merged 127 commits into
mainfrom
pysimlin-widget
Aug 20, 2026
Merged

pysimlin: file-backed models and an in-notebook model editor#1024
bpowers merged 127 commits into
mainfrom
pysimlin-widget

Conversation

@bpowers

Copy link
Copy Markdown
Owner

Why

Jupyter notebooks are where humans and AI agents already collaborate on modeling work, but pysimlin had no persistence story (a Model forgot where it came from) and no way to edit a model interactively. This branch makes the model file on disk the single authority shared by a human editing in a notebook cell, Python code in the kernel, and an agent (Claude Code editing the file, or the simlin MCP server), and puts the real @simlin/diagram Editor into a notebook cell without any JupyterLab build coupling.

Design: docs/design-plans/2026-08-17-pysimlin-widget.md (the ACs below reference it). The 2021 src/jupyter attempt is the negative example: a labextension inside JupyterLab's build system, and the kernel never saw edits.

What

  • pysimlin file-backed projectssimlin.open(path); Project.path/format/revision/dirty/save(force)/save_as/reload/watch/on_change; one format table shared with simlin-serve (.stmx/.xmile, .mdl, .sd.json, sniffed .json, protobuf); atomic writes; a stdlib poll-thread watcher with echo suppression; a pure sync state machine (_sync.py); the dirty-holdback + save() conflict rule (the file is never clobbered silently); edit() gets a stale-base guard and incremental diagram layout; Model.diagram()/_repr_svg_.
  • ModelWidget (anywidget, optional extra pip install "pysimlin[notebook]") — kernel-owned project_json/revision traits; requests and replies as custom messages (snapshotsaved/rejected, wasm, notice, oversize); exactly one snapshot in flight and exactly one reply per snapshot; applied-but-unwritten is an accept; wire-size cap (8 MiB, measured JSON-escaped) with a visible refusal instead of a tornado hang; SVG + text/plain fallback in every mimebundle. A bare pip install pysimlin shows the SVG diagram and a one-line hint.
  • src/notebook-widget — one self-contained ESM (~1.5 MB; CSS scoped to the widget root with :where()), engine wasm (5.3 MB opt) compiled once per page from bytes the kernel sends (or the inline global); DirectBackend; own-ack/remount logic; theme tracking; portals contained in the widget box.
  • @simlin/diagram embeddability — keyboard scoping per Editor instance (+ focus stamping so JupyterLab shortcuts never fire on a focused widget), no position:fixed/vw/vh inside the tree, portalContainer, showHomeLink, self-sufficient styles without reset.css, details slot sized on the positioned wrapper (also fixes a width/height issue in app and serve).
  • .mdl first-class everywhere — libsimlin serialize_mdl (+ export warnings on the collected-errors channel) and replace_contents (in-place reload keeping Model handles valid); simlin-serve and simlin-mcp-core write .mdl in place (no sidecar; legacy .mdl+.sd.json pairs are listed as two projects with a startup warning); MDL writer warns when it drops loop metadata; comment line endings normalised.
  • Engine fixes found along the way — XMILE zoom is a percentage (spec §5.1) but was passed through as a factor (Stella files rendered 200×); views now write type= (spec) not view_type=; incremental layout no longer re-chooses label_side of untouched elements nor overwrites the editor viewport; multi-line labels use the XMILE \n escape; free-text attributes escape backslashes.
  • Packaging/CIscripts/stage_widget_assets.py (deterministic ASSETS.json), wheel/sdist guard, check_wheel_assets.py (cross-wheel identity + --installed probe), release.yml builds assets once and every platform wheel carries identical bytes; ci.yamlpysimlin-e2e job runs the JupyterLab journey + static-export check.
  • Docs — README notebook section, src/pysimlin/docs/notebook-hosts.md (per-host checklists; only JupyterLab is VERIFIED), two example notebooks, CLAUDE.md updates across engine/libsimlin/diagram/notebook-widget/pysimlin/serve/mcp.

Non-obvious decisions

  • Snapshot + revision, not op-based sync: patch ops are non-commutative and uids are minted per side; all three existing hosts already do snapshot + optimistic version.
  • One snapshot in flight over custom messages: ipywidgets allows one in-flight patch per model and assign-merges anything buffered behind it, so trait-based optimistic chaining lost edits under a busy kernel (found by adversarial review against the real transport).
  • DirectBackend, wasm over the comm: anywidget loads _esm from a blob: URL (no relative assets) and evaluates it per instance; inlining 5 MB of base64 per widget is what everyone regrets. Compiled module is cached page-wide, keyed by the built-against wasm sha.
  • The file is authoritative, but never clobbered silently: external change while dirty is held back with a warning; save() refuses unless force=True.
  • anywidget is an optional extra: its chain is ~22 packages/~56 MB, paid mostly by MCP/script installs that never display. Owning the stack (comm-only Python + a 21 KB rspack-MF labextension) was spiked and works on JupyterLab/Notebook 7 but VS Code keys on import ipywidgets in the kernel and marimo on anywidget.AnyWidget, so it would lose two hosts to save two packages.
  • Displaying a viewless model writes the file (auto layout on first display, even with read_only=True; a sketch-less .mdl is regenerated) — the Editor needs a view, and a laid-out file is what serve/app need too.

Behaviour changes for existing hosts (app, simlin-serve)

  • Keys typed while focus is inside a portaled drawer/menu/dialog no longer reach the Editor's shortcuts; a key on <body> before any pointer/focus activity reaches nobody.
  • Canvas clicks now keep focus inside the editor (root or canvas container) instead of blurring to <body>.
  • Legacy Firestore projects that stored a Stella percent (e.g. 200) as a zoom factor heal to 1.0 on open and persist that on the next save; a file exported before ever being opened would write zoom="20000".
  • The details card is now height-bounded and scrolls (it clipped before), and its width matches the search bar.
  • .stmx/.xmile and .mdl are regenerated on save, not byte-preserved (a Vensim file under git shows a whole-file diff after one edit; unit ranges like [0,?] are dropped by the pre-existing writer).

Evidence (matched to the claim's boundary)

Every commit passed the pre-commit hook (Rust fmt/clippy/tests, TS lint/tsc/tests, wasm build, pysimlin tests). cargo test --workspace 7058 pass; pysimlin 969 pass; diagram 1676, notebook-widget 177 (rstest). On the merged tip: make -C src/pysimlin e2e 3/3 (17 s), pnpm -C src/notebook-widget test:e2e 6/6.

Journeys (real browsers): make -C src/pysimlin e2e — 3 Playwright tests against real headless JupyterLab 4.6 + ipykernel 7 (AC4.2: display → add variable via UI → file on disk changes → m.run() has it → m.selection → Python edit() toast → external-process write toast, revision +1 each; a Project.new()-built model displays with a canvas; AC2.6: d d/x/a leave cells alone while Delete removes the selected variable). pnpm -C src/notebook-widget test:e2e — 5 tests loading the bundle from a blob URL with a fake kernel (protocol, inline wasm, layout, portals). Two fresh-user passes built the release-style wheel into a clean venv outside the repo (pip install <wheel> with and without the [notebook] extra) and drove JupyterLab by hand: build a model from scratch in the widget, .mdl edited in place, Claude-style external edits ("Updated on disk" in ~200 ms with the viewport kept), holdback/save() conflict/save(force=True), two views of one model, three drags during a 15 s cell on ipykernel 7 (no lying toast), remount-while-in-flight, kernel restart, reopen without a kernel, nbconvert default vs store_widget_state=False vs SIMLIN_WIDGET_ASSET=inline. Its first pass found six blockers (viewless model → blank editor; d d deleting notebook cells; inert inline mode; viewport clobber; dead default export; ipykernel-7 threading) — all fixed and re-verified in the second pass on this HEAD.

AC status (design doc numbering): AC1.1–1.6 established (tests named in tests/test_file_backed.py, e.g. test_ac1_*); AC2.1–2.3, 2.5, 2.7 established (journey + tests/test_widget.py::test_ac2_* + rstest); AC2.4 established at unit/e2e level (compiled once per page, instantiated per widget); AC2.6 established by the real-Lab keyboard journey; AC2.8 NOT established — Colab, VS Code (local/Remote-SSH), Notebook 7, marimo have written checklists but are marked UNVERIFIED in notebook-hosts.md; AC3.1 established; AC4.1 established (wheel built + check_wheel_assets.py --installed from a fresh venv; guard tests); AC4.2 established locally, the CI job has never run on GitHub (this PR is its first run); AC4.3 established; AC4.4 by reading + local equivalents (release workflow unexecuted).

What this evidence does NOT establish: any host other than JupyterLab 4.6/Notebook 7 in a browser we did not run; Colab wasm-over-comm delivery; the workflows on GitHub runners; Vensim rendering elements at negative sketch coordinates; that the .mdl writer's pre-existing lossiness (unit ranges) is acceptable to Vensim users.

Known limitations / follow-ups

Result overlays/LTM in the widget; a CDN asset mode to keep _esm out of .ipynb in Colab; multi-peer merge (CRDT design doc); running the non-JupyterLab host checklists; test/xmutil_test_models/C-LEARN v77 for Vensim.xmile is a header-only fixture (pre-existing); MDL unit-range lossiness (pre-existing writer).

🤖 Generated with Claude Code

https://claude.ai/code/session_01W6uTA7YvtnQmwob6bTJ59p

@codecov

codecovBot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.78313% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.34%. Comparing base (9fdfb5e) to head (e07a60d).

Files with missing linesPatch %Lines
src/libsimlin/src/lib.rs76.19%5 Missing ⚠️
src/libsimlin/src/serialization.rs97.95%1 Missing ⚠️
src/libsimlin/src/tests_concurrency.rs98.66%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #1024 +/- ##
==========================================
+ Coverage 92.24% 92.34% +0.09% 
==========================================
Files 250 250 Lines 161187 161557 +370 ==========================================
+ Hits 148684 149183 +499 + Misses 12503 12374 -129 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:e2cf8768b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +387 to +392
fmt = resolve_read_format(p, data)
project = cls(
cls._open_bytes(data, fmt),
path=p,
format=fmt,
autosave=autosave,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep read-only suffixes read-only after open

Opening a .vpm or .proto file stores the detected MDL/protobuf format directly and leaves autosave enabled by default, bypassing resolve_write_format()'s read-only check. Consequently, the first edit—or merely displaying a viewless model in the widget, which persists a layout—can overwrite a packaged .vpm with regenerated MDL or a .proto schema-named file with binary protobuf. File-backed writes need to enforce the same shared suffix policy as save_as(), or these paths must be opened without writable backing.

AGENTS.md reference: src/pysimlin/AGENTS.md:L11-L12

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 681680a (b380fda): open() passes writable=not is_read_only_suffix(p) (this exact line); a read-only-suffix project reports Project.writable == False, its effective autosave is False and cannot be enabled for that path, save()/save(force=True) refuse naming save_as(), and a viewless display marks the project dirty instead of writing. Pinned by tests/test_file_backed.py::TestReadOnlySuffixes (.vpm and .proto: edit and display leave the bytes unchanged) and tests/test_widget.py::test_viewless_read_only_suffix_lays_out_in_memory_and_still_edits.

Comment threadsrc/pysimlin/simlin/__init__.py Outdated
"LtmMode",
"Model",
"ModelIssue",
"ModelWidget",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the optional widget from wildcard exports

On a base pysimlin installation without the notebook extra, from simlin import * iterates __all__, resolves ModelWidget through __getattr__, and raises SimlinDependencyError while importing anywidget. This makes an otherwise supported import style fail solely because an optional dependency is absent; keep ModelWidget available through explicit lazy access, but do not include it in __all__ unless the extra is installed.

AGENTS.md reference: src/pysimlin/AGENTS.md:L16-L16

Useful? React with 👍 / 👎.

Comment on lines +220 to +226
r.seed = { revision: pair.revision, projectJson: pair.projectJson };
setSeed((prev) => ({
revision: pair.revision,
projectJson: pair.projectJson,
generation: prev.generation + 1,
initialViewport,
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the selection trait when remounting

When a Python edit, disk reload, or other kernel push remounts the Editor after the user selected variables, this updates only the seed. The replacement Editor starts with an empty selection, and Editor intentionally suppresses onSelectionChanged on initial mount, so no debounced trait write occurs; ModelWidget.selection and Model.selection continue reporting the old names, including variables removed by the push, while the UI shows nothing selected. Explicitly publish an empty selection during the remount.

AGENTS.md reference: src/notebook-widget/AGENTS.md:L25-L25

Useful? React with 👍 / 👎.

Comment threadsrc/diagram/editor-key-scope.ts Outdated
// than a DOM attribute because "active" is a relation between instances, not a
// property of one; a document-level owner would need the same singleton.

let activeRoot: Element | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share the active Editor root across widget modules

In a notebook with two displayed widgets, each blob import gets its own copy of this module and therefore its own activeRoot. After both editors have received focus, neither copy clears when the other becomes active; if focus subsequently falls to <body>, every document-level key handler sees its own root as last-active and shortcuts such as Delete or undo can be applied to both models. Put this arbitration in page-global state, or otherwise coordinate across module copies, rather than module-local state.

AGENTS.md reference: src/notebook-widget/AGENTS.md:L13-L13

Useful? React with 👍 / 👎.

Comment threadsrc/pysimlin/simlin/project.py Outdated
cannot be determined.
SimlinRuntimeError: if the engine cannot parse the file.
"""
p = Path(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Anchor file-backed paths before retaining them

When a project is opened through a relative path, the retained path and its watcher continue resolving it against the process's current working directory. If a notebook later runs %cd (or application code calls os.chdir()), autosave and polling switch to a different location; when no same-named file exists there, the conflict check treats that as safe and creates a new file while leaving the originally opened model unchanged. Convert the backing path to an absolute path before storing it, and do the same when adopting a save_as() target.

Useful? React with 👍 / 👎.

Comment threadsrc/pysimlin/simlin/project.py Outdated
Comment on lines +1531 to +1534
self.watch(False)
with self._file_lock:
self._listeners.clear()
self._closed = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the watcher in the same locked transaction

If one thread calls watch(True) after close()'s watch(False) returns but before _closed is set, it still sees an open project and installs a new watcher. close() then clears the path and engine pointer without detaching that watcher, leaving watching true and a polling thread alive after the project has closed. Remove the watcher and mark the project closed atomically under _file_lock, then retire the captured watcher after releasing the lock.

AGENTS.md reference: src/pysimlin/AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

Comment on lines +272 to +276
if (r.staleRepliesOwed > 0) {
// The reply for a snapshot whose Editor a kernel push already
// replaced (see onKernelState); replies arrive in order, so this
// one is that snapshot's, whatever the new Editor has in flight.
r.staleRepliesOwed -= 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correlate save replies across multiple widget views

When the same ModelWidget object is displayed in two cells and both views submit edits, every msg:custom reply is delivered to both React views while staleRepliesOwed is local to each one. A state push can mark view B's pending save stale, but B then consumes view A's earlier saved as that stale reply; if B submits another edit before its actual rejection arrives, that rejection resolves the newer save's promise instead. Add a request or view identifier to snapshots and replies, or coordinate the reply queue once at the shared model level.

Useful? React with 👍 / 👎.

@bpowers

Copy link
Copy Markdown
OwnerAuthor

@codex review

Addressed all seven findings from the first round:

  • read-only suffixes (.vpm/.proto) open without write backing (Project.writable, effective autosave, save() refuses, display marks dirty instead of writing);
  • file-backed paths are anchored absolute at open()/save_as();
  • ModelWidget is no longer in __all__ (star import works on a base install);
  • a kernel-originated remount publishes an empty selection;
  • the last-active Editor root is page-global across module copies;
  • close() detaches the watcher and marks closed atomically;
  • snapshot/saved/rejected carry a per-view request id and replies are matched by id (replaces the stale-reply counter).

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:68f27d8da2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if dispatch is None:
deliver()
else:
dispatch(deliver)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Contain synchronous dispatcher failures

When a subscriber's dispatch callable raises synchronously—for example, an executor or kernel I/O loop that has already closed—the exception escapes _notify. At that point an edit(), reload(), or auto_layout() has already committed and possibly written the change, so the caller sees a failure for an operation that succeeded and may retry it; the exception also prevents later subscribers from receiving the event. Catch dispatcher invocation failures just as callback failures are caught and warned about.

Useful? React with 👍 / 👎.

os.fsync(f.fileno())
if target.exists():
shutil.copymode(target, tmp)
os.replace(tmp, target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve symlink targets when autosaving

When a project is opened through a symlink, the retained backing path deliberately remains the symlink name, so save() and autosave pass that path here. os.replace(tmp, target) replaces the symlink directory entry itself rather than following it: the first edit destroys the link, creates a regular file at its former location, and leaves the actual model target unchanged. Keep a separate resolved write target or otherwise follow the link while preserving the user-facing path.

AGENTS.md reference: src/pysimlin/AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

The Editor handles Delete/Backspace, Escape and undo/redo from a
document-level keydown listener, because the canvas is an <svg> that
never holds focus. With one Editor per page (src/app, simlin-serve)
that is fine; with several sharing a document (a notebook with an
Editor per output cell) every instance acted on every key.
Each instance now decides via the pure editorOwnsKeyEvent
(editor-key-scope.ts): the event is ours when its composedPath()
includes our root; not ours when it includes another Editor's root;
when focus is nowhere (the path reaches only body/html) it goes to the
instance that most recently saw pointer or focus activity inside it;
and when focus is on some other element of the host page it goes to
nobody. Focus alone cannot carry the "most recent" notion here: focus
falls to body after a canvas click and whenever the focused control
unmounts (a details panel closing on delete), and Ctrl+Z is expected
to keep working through both. The Editor root carries a data attribute
so instances recognize each other in a path without a registry, and a
tabindex of -1 so a click on non-focusable chrome settles focus inside
the editor rather than on body.
The Canvas now focuses its container after a click instead of merely
blurring the active element: the text field the user was typing in
still blurs (and commits) exactly as before, but focus stays inside the
editor, so the key events that follow name this instance directly and
hosts that suppress their own shortcuts by event target (JupyterLab)
see them land inside the widget.
The two-instance test drives all four arms through real DOM events;
the pure module has a table test. Existing keyboard tests that select
through the Canvas contract now press inside the root once, as the
user's canvas click would have.
The Editor's floating chrome (search bar, detail cards, banners,
bottom-left controls) is position: absolute, but the root was not
positioned, so it anchored to whatever positioned ancestor the host
happened to provide -- the full-page fixed shell in src/app, the
viewport itself in simlin-serve -- and the toast viewport was position:
fixed outright. The width clamps used 100vw and the card height caps
100vh. All of that assumes the editor IS the page, which is false for a
host that gives it one box on a page it owns (a notebook output cell,
several per page).
The root is now position: relative and the chrome sizes against it:
100vw -> calc(100% - 16px), 100vh -> calc(100% - 18px), the toast
viewport is absolute. In src/app the editor box is the viewport, so
nothing moves. In simlin-serve the chrome now anchors to the editor's
region instead of the viewport, which is where it belonged: the
undo/zoom cards and the tool FAB sat over the bottom of the project
list, and the search bar overlaid any banner above the editor.
The panel-width breakpoints stay viewport media queries on purpose:
they size the panel by how much room the user has, and panelWidth()
reads window.innerWidth to match them; only the overflow clamp is
container-relative. Container queries were not used because
container-type implies layout containment (a stacking context and
size containment) that would change the app's toast/banner layering
for no gain here.
tests/editor-embeddable-css.test.ts pins the contract: no position:
fixed and no viewport units in any stylesheet that renders inside the
Editor tree (portaled Drawer/Dialog content, the app-level AppBar, the
HostedWebEditor shell and reset.css are the enumerated exemptions), the
root is relative, the toast viewport absolute, and every clamp is
percent-based at every breakpoint.
simlin_project_serialize_mdl exposes the engine's Vensim writer through
the C ABI so a file-backed .mdl model can be written back in place. The
writer's lossiness contract has two halves and the FFI keeps them apart
the way apply_patch keeps a rejection apart from collected diagnostics:
a structural impossibility (second ordinary model, module instance) is a
hard error with no buffer, while an ExportWarning never fails the export
and rides out as a Warning-severity detail on out_collected_errors.
simlin_project_replace_contents is the in-place reload primitive for
hosts that mirror a model file on disk: dst's datamodel becomes a deep
clone of src's and dst's salsa db is re-synced incrementally, so the
SimlinModel handles a caller already holds (project pointer plus model
name) keep working and observe the new contents. Composing with the
existing simlin_project_open_* functions covers every format, so there
are no per-format replace variants. A handle for a model absent from the
replacement errors cleanly rather than dangling; a sim created before
the replace stays a stale snapshot of the program it compiled. src's
lock is released before dst's are taken so opposite-direction replaces
cannot deadlock and dst == src is a permitted no-op.
The malloc-and-copy output helper moves from model.rs to lib.rs so the
new serializer shares it instead of adding a seventh inline copy.
The model-properties drawer's "Exit" is a wouter <Link to="/">: the
project list in the app and in simlin-serve. A host that embeds the
Editor in a page it owns (a notebook cell) has no such route, and the
link would pushState on the host page. showHomeLink (default true, so
the existing hosts are unchanged) hides it. wouter's Link already
resolves a default router when none is mounted, so the Editor mounts
outside any <Router> either way; the new test pins that by rendering
the real drawer without one in both configurations.
The three _ffi primitives a file-backed Project builds on. serialize_mdl
returns (bytes, warnings) so a lossy-but-successful Vensim export reads
like apply_patch_json's accepted-with-diagnostics shape rather than an
exception. replace_contents wraps the in-place reload so Model objects a
notebook already holds keep working across a reload from disk.
diagram_sync now forwards an applied patch when given one, so a variable
created through edit() gets a diagram element without moving the rest of
a hand-arranged view; None keeps the full relayout auto_layout wants.
The new wrappers are covered by tests/test_ffi.py and churned under the
CI ASan run via tests/test_memory.py; a local valgrind pass of the new
tests reports zero definite leaks. The valgrind suppression file had an
invalid "Memcheck:Value*" kind that made valgrind refuse to start, so it
now lists the fixed widths and DEVELOPMENT.md carries a working command.
A host embedding the Editor in a page it owns (a notebook cell)
deep-imports and loads theme.css but not reset.css: putting a page-wide
box-sizing/body-typography reset into someone else's document is not
acceptable. The Editor's stylesheets therefore stop leaning on the
reset. The editor root and each portaled surface (drawer sheet, dialog,
menu, autocomplete listbox) pin their own font-family (a new
--font-family-base token), font-size and line-height instead of
inheriting them from body; the drawer's <h2> loses its UA margins
itself; the snapshot <img> is display: block; the module-reference
<select> inherits the face; and the ErrorBoundary box declares
border-box for its width+padding. Every value equals what the reset
gives a full-page host, so the app and simlin-serve are unchanged.
The new test compiles the package's CSS modules into jsdom (class names
are [local], so the raw text applies) WITHOUT reset.css, mounts the
Editor, and reads computed values: no thrown or logged errors, the
root's box model and typography, the search bar's border-box, the toast
viewport anchored inside the root, nothing under the root computing
position: fixed, and the portaled drawer's typography and heading
margin.
Review of the serialize_mdl / replace_contents work found the code sound
but the tests weaker than their names claimed. The datamodel-then-db lock
order in replace_contents was documented but nothing failed when it was
inverted, so a concurrency test now has two projects replace each other
while LTM compiles, runs and get_errors hammer both: an inversion
deadlocks against those readers and trips the positive-wait timeout
(verified by inverting the two lock lines). The pysimlin diagram_sync
tests passed even when the patch was ignored, because a full relayout of
the tiny fixture reproduces the same coordinates; they now perturb the
persisted positions first so only genuine incremental layout preserves
them (verified by making the wrapper pass NULL: three tests fail).
Docs now say precisely what a pre-existing sim is after a replace: a
stale snapshot for the simulation entry points, but the sim-bearing
analysis FFIs enumerate from the current db and read the stale results by
position, so callers should re-run before analyzing. The _ffi docstring
also names the Python-side cache the caller must invalidate. Also on the
way: serialize_mdl frees its buffer in the same finally that frees the
warnings handle; the MDL round-trip test asserts on a sketch element line
rather than the always-emitted header and null-checks detail strings
before reading them; and stale references to a nonexistent
simlin_project_enable_ltm are removed from the libsimlin docs.
Rewrites the Hosting Requirements section of src/diagram/CLAUDE.md as
the standing contract between the Editor and its hosts: what a host
must provide (a container with a definite height, theme.css, katex's
stylesheet, the Roboto faces), what it may omit (reset.css, a router),
how the portaled surfaces relate to the editor box, and the keyboard
scoping decision every instance applies to document-level key events.
The Editor.tsx, Canvas.tsx and ModelPropertiesDrawer.tsx entries pick
up the root attributes, the click-to-focus behavior and showHomeLink;
editor-key-scope.ts joins the file list.
…widget
Records the investigation and decisions for an in-notebook model editor:
anywidget-hosted @simlin/diagram Editor with the wasm engine in the
browser, snapshot sync through the kernel, and the model file on disk as
the single authority so humans, Python, and agents converge on one
artifact. Op-based sync and a simlin-serve sidecar were considered and
rejected (non-commutative ops; Colab needs no local server).
…ked projects
Three self-contained modules that the file-backed Project (next commit)
executes against. _formats.py is the single owner of suffix/content ->
format resolution, mirroring simlin-serve's format_for_path and
simlin-mcp-core's JSON key sniffing (models = native, variables = SD-AI),
so open(), load(), and save() cannot drift apart. .sd.json is the write
default for native JSON but is sniffed on read because SD-AI payloads
exist under that suffix in test/.
_sync.py is a pure state machine over (revision, disk hash, dirty,
declined hashes) deciding write / mark-dirty / reject-stale / ignore-echo /
attempt-reload / reload / keep-last-known-good for local edits, widget
snapshots, disk observations, and explicit reloads; the tests enumerate
every event x state arm. External changes arriving while unsaved local
edits exist are held back (with one warning) rather than silently
clobbering them; an explicit reload() still wins.
_disk.py holds the sha256 content hash, an atomic tempfile+rename writer
that preserves the target's mode (a mkstemp 0600 tempfile would make a
saved model private), and a stdlib polling FileWatcher (one file per
project, works on FUSE/network mounts, no native wheel) that survives
every error by warning once per distinct message.
The engine's JsonModelOperation vocabulary (libsimlin/src/patch.rs) has
had updateStockFlows -- replace a stock's inflow/outflow lists without
re-upserting the stock -- but pysimlin's wire types did not, so a patch
built by the widget or read back from the engine could not represent it.
Add the dataclass, both converter directions, and a test that pins the
Python op set to the engine's list so the next engine op cannot go
missing here silently.
Project.open() / simlin.open() attach a project to its file: path and
format are remembered (the format table decides both), every accepted
change bumps a monotonic revision, and with autosave the change is
serialized in the file's own format and written atomically before
edit() returns. Non-autosave projects mark dirty until save(); save_as()
adopts a path for in-memory projects (Project.new(), simlin.load()).
All mutation paths -- edit(), set_sim_specs(), auto_layout() -- now funnel
through _apply_patch_json/_commit_change, which also invalidates every
attached Model's cached base_case (set_sim_specs previously left a stale
cache behind). File-backed projects additionally run the engine's
incremental layout after variable ops so new variables get diagram
elements without moving existing ones, matching simlin-mcp-core's
edit_model; in-memory projects keep the documented contract of no
persisted layout until auto_layout().
watch() polls the file on a stdlib daemon thread; disk changes, explicit
reload(), and widget snapshots (_apply_snapshot) all execute decisions
from the pure _sync machine, with echo suppression by content hash,
keep-last-known-good for unparsable content, and one warning per distinct
declined content. on_change(callback, dispatch=...) lets the widget layer
marshal notifications onto the kernel loop; callbacks never run under a
lock. Model gets path/revision/dirty/save/reload proxies, selection, and
diagram() (a _repr_svg_ value; _svg_mimebundle() is the static seam for
the widget's mimebundle).
The in-place reload (_ffi.replace_contents) and Vensim writer
(_ffi.serialize_mdl) come from libsimlin on the pw/ffi branch; the calls
here resolve them lazily and the tests that need them are gated on their
presence so they run unchanged once merged. The incremental layout call
uses the existing simlin_project_diagram_sync entry directly until
_ffi.diagram_sync accepts a patch.
The reload primitive composes with the existing open_* functions instead of
taking a format enum, keeping the FFI surface orthogonal.
The engine's MDL writer emits display names ("new aux", not "new_aux")
and the sketch element for it; validated against the pw/ffi branch's
serialize_mdl on a trial merge. The test stays gated until that FFI lands.
…rotocol core
The notebook editor widget (design doc 2026-08-17-pysimlin-widget.md,
Section 3) needs three things before the widget class itself: anywidget as
a hard dependency (pip install pysimlin must be enough to display a model),
a package-data home for the JS module and engine wasm (simlin/_widget/,
build outputs of the notebook-widget package, git-ignored and shipped by
package-data/MANIFEST), and the widget's decisions as pure functions so
each arm can be pinned without a comm or a kernel: SIMLIN_WIDGET_ASSET
parsing, the inline-wasm shim contract, incoming-message classification,
the accept/stale/raised reply plan, own-change detection, and per-source
notices.
Project gains _snapshot(), which reads the (contents, revision) pair under
_file_lock: a widget seeds and re-seeds the browser from it, and two
separate reads could hand the browser a torn pair while another thread
edits (the new test fails against a two-read implementation).
SimlinAssetError is the error a missing package asset raises when a widget
is created -- never on import.
Deliberate: the accepted arm pushes (exact json received, base + 1) rather
than the revision read back, because an accept bumps by exactly one under
the lock and any concurrent change announces itself; the stale and raised
arms re-assert the pair from the project because the browser's remount is
idempotent on the pair, so a notification still queued behind the message
cannot leave the browser on an old revision.
…ping docs
Review follow-ups on the embeddability work. Tests: the four portaled
surfaces (drawer sheet, dialog content, menu, autocomplete listbox) are
now each checked for the font-family/size/line-height pin, and the raw
element rules (snapshot img display, module select font, ErrorBoundary
box-sizing, drawer h2 margin) are asserted directly; the unmount test
asserts activeEditorRoot() is null right after the active instance
unmounts (so dropping releaseEditorRoot fails), and the scoping test
pins the root's data attribute and tabindex. Docs and comments: the
Hosting Requirements state that keys typed with focus inside a portaled
surface reach no Editor's shortcuts (the portal is not in the root's
DOM path), the Portals paragraph scopes React's event routing to the
activity mark rather than key ownership, and the descriptions of
focusCanvas read as standing constraints. focusCanvas loses its
unreachable blur fallback: it runs from pointer handlers on a rendered
canvas whose container ref is always attached.
…en rustdoc
The wasmgen comment referenced simlin_project_enable_ltm, which does not
exist; the LTM flag is set per simulation via simlin_sim_new.
ModelWidget(anywidget.AnyWidget) is the kernel side of the notebook editor
protocol: traits project_json/revision (kernel-owned, always assigned in one
hold_sync), selection (browser-owned, mirrored to Model.selection),
height/theme/read_only; custom messages for everything that is a request or
a reply (wasm bytes as a binary buffer, snapshot -> saved/rejected, notice).
A snapshot goes through Project._apply_snapshot, so a browser edit is
written to the file, bumps the revision, and reaches on_change subscribers
exactly like edit(); the widget then pushes the exact bytes it received at
base + 1 so the browser recognises its own edit and keeps its undo history.
Every other change (edit(), disk, reload, another widget) is pushed from
Project._snapshot() with a notice, so N widgets on one project stay in step.
Notifications are marshalled onto shell.kernel.io_loop when running under
ipykernel so trait state is only touched from the kernel thread.
Model.widget() creates a widget; Model._repr_mimebundle_ displays a fresh
one merged with the SVG so static renderers show the picture. Each display
is a new widget (ipywidgets keeps it alive until closed, and it keeps the
project alive with it), and the model caches none of them. ModelWidget is
exported lazily from the package: anywidget/ipywidgets cost a few hundred
milliseconds that scripts and servers which never display a widget should
not pay.
The asset delivery (SIMLIN_WIDGET_ASSET: bundled | inline | http(s) URL) is
resolved once at import; a missing widget.js never breaks import simlin --
creating a widget raises SimlinAssetError naming the file and how to build
it, and a missing wasm answers the browser's request with an error instead
of a hang. _esm is a declared trait supplied per instance rather than a
class-level string so tests can inject a fake asset directory without
reloading the module.
Tests drive the widget through a recording comm subclass installed via
comm.create_comm and feed browser messages through comm.handle_msg; they
enumerate the message-type x state table and the change-source x delivery
table in the module docstring, including the write-failure and
queued-notification arms.
… flavor
The three ways the engine gets its wasm (Node reads a file, browser SPAs
adopt the bundler-instantiated artifact, and now a host that hands the
bytes over at runtime) differ only in the source default, yet the node and
browser flavors each carried a full copy of the singleton, the panic
accessors, and the single-flight guard -- and the browser copy silently
ignored any source the caller passed. internal/wasm-runtime.ts now owns all
of that once; the flavors are thin resolvers over it, and the browser
flavor honours a caller-supplied source.
wasm.supplied.ts is the new flavor for single-file browser bundles that
cannot fetch a relative asset (the notebook widget's module is loaded from
a blob: URL and receives the wasm over the kernel comm). It references no
.wasm file, so aliasing the specifier to it keeps the bundler from emitting
or fetching one, and init() with no source fails loudly instead of guessing
a path.
WasmSource additionally accepts a precompiled WebAssembly.Module, skipping
WebAssembly.compile. anywidget loads each widget instance's module from a
fresh blob: URL (separate module state per instance), so the only thing
that CAN be shared page-wide is the compiled module; this makes
"compile once per page" achievable. The worker protocol carries a module
in the message body (structured clone) rather than the transfer list.
backend-factory.node.ts is renamed backend-factory.direct.ts: it never
used a Node API, every package's test suite already aliases to it, and the
notebook widget will select it for a main-thread engine, so the name should
say what it is. It and the supplied flavor compile into lib.browser too.
…ngs directly
With serialize_mdl, replace_contents, and the patch-aware diagram_sync
merged from pw/ffi, the file-backed Project imports them like every other
_ffi primitive instead of resolving them lazily, and the tests that
exercise in-place reload from disk, the poll thread, snapshot
accept/reject, and .mdl round-trips run unconditionally. Adds a test that
a reload drops the cached base_case on every Model handle of the project
(the weak registry in Project._models), not only the handle that
triggered it.
README gains "Interactive Editing in Notebooks" (what displaying a model
does, the file-as-truth guarantees, selection, read_only/theme, and the
SIMLIN_WIDGET_ASSET modes; the display snippet is skipped by the README
example test because the JS assets are build outputs); CLAUDE.md lists
widget.py, _widget_core.py and _widget/ and records the no-lock threading
contract; docs/dev/python.md states why ModelWidget owns no lock and how it
stays on the kernel thread.
Phase 0 of the pysimlin widget design (docs/design-plans/2026-08-17-
pysimlin-widget.md): the package, its rsbuild config, the AFM module, and
the evidence that one self-contained ES module can host the Editor with the
engine wasm supplied at runtime as bytes.
anywidget imports _esm through a fresh blob: URL per widget instance, so
the bundle can fetch nothing relative to itself and every instance gets its
own module state. The build therefore injects CSS from JS, inlines only the
KaTeX woff2 faces (the woff/ttf fallbacks in katex.min.css become empty
data URIs -- browsers never read past woff2), aliases the engine's wasm
specifier to the no-artifact wasm.supplied flavor and its backend factory to
the main-thread DirectBackend, and forces one chunk. css-loader's ESM
output had to be turned off: rspack's ESM runtime resolves './' against
import.meta.url at module init, which throws for blob URLs.
Cross-instance sharing goes through globalThis: the compiled
WebAssembly.Module is cached page-wide and each instance instantiates its
own engine from it, so N cells compile the wasm once and ask the kernel for
it once. The kernel handshake is a {type:'wasm'} custom message answered
with the artifact as a binary buffer. initialize() only kicks this off:
anywidget drops every comm message for a model whose load+initialize
exceed 2s.
Sync: only the kernel sets `revision`, so change:revision decides remount
vs keep -- an echo of a snapshot this widget sent (bounded pending queue)
is an ack; anything else remounts the Editor on the new snapshot. onSave
returns base+1 optimistically; a wrong guess is a rejected snapshot and a
reseed, never a wrong write.
The Web Worker variant was measured (SIMLIN_WIDGET_BACKEND=worker): its
chunk is a self-contained ~38 KB, but it is a second file resolved against
import.meta.url, so it is not loadable here without an inline-blob stage;
DirectBackend is the decision, documented with the numbers in CLAUDE.md.
The Playwright journey under e2e/ loads dist/widget.js from a blob URL,
boots the engine from comm bytes, adds a variable through the real Editor
UI, asserts the snapshot lands on the model, that a second module instance
reuses the compiled wasm, that a kernel push remounts, and that nothing
outside the harness is fetched. rstest covers the functional core, the
bootstrap, and the AFM shell against a fake AnyModel.
…bbers, and leaks
Review findings on the file-backed Project, each reproduced by a failing
test first.
Watcher lifecycle: the poll thread only consulted its weak project
reference when the file changed, so a collected project on an idle file
left the thread polling forever; a weakref.finalize now asks the thread to
exit (request_stop, never a join inside GC), and a stopped FileWatcher
cannot be restarted so "is this the project's current watcher" stays an
identity check. The first tick always reads and delivers, so a change that
landed before watch() (or in Project.open's read-then-watch window) is not
missed; the sync machine answers IgnoreEcho for our own bytes.
Staleness: a delivery from the poll thread is re-verified under _file_lock
(current watcher, current path, file still holds those bytes) so bytes
read before our own write can never be loaded over it. edit() records the
revision it read `current` at and the patch is rejected unapplied if the
project moved underneath (reload from disk, another edit), symmetric with
the widget snapshot check.
Conflicts: save() and autosave refuse to overwrite a file another tool
changed since we last read or wrote it (content-hash check under the
lock), raising with the two resolutions -- reload() or save(force=True);
save_as to a different path is unaffected. A failed write of a clean
project no longer marks it dirty. Unknown-format bad bytes on disk are
recorded as declined so the warning fires once, not per poll.
Also: notifications from edit()/set_sim_specs()/auto_layout() were
delivered while the re-entrant _file_lock was still held by the caller
(the "callbacks fire outside locks" claim was false for that path); the
locked commit now returns what to notify and the caller notifies after
release, pinned by a test that probes both locks from another thread
inside a callback. base_case only caches a run if the revision did not
move while it ran; close() clears the path and refuses watch(True); .vpm
and .proto are read-only suffixes for save_as; open()/load() on a
directory raise SimlinImportError; `open` leaves __all__ so a star import
cannot shadow the builtin; duplicate model entries in a patch lay out
once; on_change documents that events may arrive out of revision order.
…riables on display
A handler exception after the snapshot was applied replied rejected with
the project's pair pushed first. In the steady state that pair equals the
sent bytes byte for byte (same engine on both sides), so the widget
classified the push as its own ack and the following rejected re-seeded
onto the pair it already held -- the controller's acknowledged base stayed
one behind and every later save was stale. The change is real in that
case, so it is answered as obligation 5 answers a write failure: the sent
bytes pushed at base+1 (best effort), saved, and a warn notice; only a
failure before anything applied is a reject. _apply_and_reply reports
"applied" the moment it is known so the except arm can branch on it; the
tests pin the accept arm with the engine's own bytes (the steady state).
A display also lays out a model whose first view places nothing although
the model has variables (a project written before its variables existed),
which mounts as blank exactly like a missing view; an empty view over an
empty model is left alone. The docs say in one place that displaying a
viewless file-backed model writes the file even with read_only=True, the
lock note says _state_lock is necessarily held across the pair's own state
send (never a custom-message send), and the O(model) serialization behind
the has-view check carries its rationale.
… arrives
`jupyter nbconvert --to html --execute` stores widget state by default,
so the exported page shows the widget itself -- "Loading the Simlin
engine..." for 60 s, then a bare timeout -- instead of the SVG the
output also carries, and nothing told the reader why or what to do.
The timeout text now names that case first (a static export has no
kernel to answer the wasm request) and its fix
(--ExecutePreprocessor.store_widget_state=False embeds the diagram as
an image), then the live-kernel possibilities. The case is explained
at the timeout rather than detected up front because the widget cannot
tell a missing kernel from a slow one: anywidget's model proxy exposes
no comm liveness (widget_manager is on the proxy but flagged as an
over-wide surface to be narrowed, with a per-host shape) and send() on
a comm-less model is a silent no-op.
anywidget moves from the core dependencies to the notebook extra. Its
chain (ipywidgets, traitlets, IPython, ...) is a couple of dozen packages
that scripts, MCP servers, and CI installs which never display a model
were carrying for nothing. It stays anywidget rather than a homegrown
stack because that is what makes one widget render on every host: VS
Code's kernel needs import ipywidgets and marimo needs anywidget.AnyWidget.
import simlin never needs the extra (the widget import was already lazy;
it now goes through _widget_core.import_widget_module). Without it,
Model.widget() and simlin.ModelWidget raise SimlinDependencyError -- a
SimlinError that is also an ImportError -- carrying the install line for
the running host (%pip under Colab, plain pip elsewhere), and displaying a
model degrades to the SVG diagram plus text/plain with one RuntimeWarning
carrying the same line, never a traceback. tests/test_widget_optional.py
pins that arm by blocking the import; the dev and e2e extras include
notebook so every other widget test drives the real widget.
The release workflow installs the wheel with the extra for the asset probe
and the test run (check_wheel_assets --installed says so when it is
missing), and the README, notebook-hosts checklist, both example
notebooks, DEVELOPMENT.md, and CLAUDE.md name the extra.
…esult
The dependency chain is paid by installs that never display; owning the
widget stack works on JupyterLab and Notebook 7 but VS Code keys on
import ipywidgets in the kernel and marimo on anywidget.AnyWidget.
Editor: the pointerdown-capture arm that leaves focus alone when the
focused element is the pressed element or one of its ancestors inside a
portaled surface (the drawer panel focused, a field in it pressed) is
now exercised by a test that opens the real drawer; its comment says
what path.includes(active) actually decides. Drawer: every focus() it
issues (panel on open, restore on close, the Tab-wrap of the focus
trap) passes preventScroll, since in contained mode the sheet lives in
a host box on a page that may be scrolled to it; a test records the
options of each call.
Widget: the JupyterLab command-mode selector is cited as it stands in
4.6's notebook-extension tracker.json schema --
`.jp-Notebook.jp-mod-commandMode:not(.jp-mod-readWrite) :focus` -- with
the reason text fields are exempt (the Notebook toggles jp-mod-readWrite
while an editable is active), replacing an unverified `:not(:read-write)`
in the comments and CLAUDE.md. The inline-wasm payload is dropped on
the page-cache-hit path too, not only after compiling it. The
details-panel layout journey now pins that the CARD scrolls: computed
overflow-y auto/scroll, card.scrollTop > 0 after scrolling Delete into
view, editor root scrollTop 0 (removing overflow-y: auto fails it).
Comment tidy-ups: the card min-height: 0 is belt and braces (a flex
item with non-visible overflow already has a 0 minimum), and the focus
mechanism is described in the present tense.
Address four Codex review findings on the file-backed project shell.
A project opened from a .vpm or .proto file now backs WITHOUT write
permission: `writable`/`autosave` read False, `autosave = True` raises,
`save()` refuses (force does not override), and every accepted change --
including the layout a display commits for a sketch-less model -- stays
in memory with `dirty` set. Previously the first edit (or merely
displaying a viewless model) regenerated a packaged .vpm as plain MDL
text or a .proto schema-named file as binary protobuf. The rule is the
one `resolve_write_format` already applied to `save_as()`, now shared
as `_formats.is_read_only_suffix`; `save_as()` to a writable target (or
with an explicit format=) makes the new path writable, and the caller's
autosave wish -- kept separately from the effective value -- applies
again, so `open("x.vpm")` then `save_as("x.mdl")` autosaves.
Paths are anchored with `Path.absolute()` (not `resolve()`, so `path`
keeps reporting the name the user gave) when adopted by `open()` or
`save_as()`, so a later `%cd`/`os.chdir()` cannot retarget autosave, the
watcher, or the conflict check to a same-named file elsewhere.
`close()` detaches the watcher and marks the project closed in one
`_file_lock` section and retires the watcher after releasing it, so a
concurrent `watch(True)` can no longer slip between the two and leave a
poll thread alive past the close; the test pins the interleaving
deterministically by racing from the retire step.
`ModelWidget` leaves `__all__` (still reachable lazily as
`simlin.ModelWidget`): `from simlin import *` resolved it through
`__getattr__` and raised on a base install without the notebook extra.
Resolves the notebook-widget CLAUDE.md (both the stale-reply bookkeeping
and the apply-then-fail reject note; both the identity-keyed/inline
engine-bootstrap and the withEditorView widget-core entries) and the
JupyterLab journey spec (the AC2.6 keyboard test follows the
Project.new() display test). The journey's runCell now waits for the
notebook's kernel to connect (execution indicator idle/busy) and accepts
a "Select Kernel" dialog: on a cold server the first Shift+Enter could
open that dialog instead of running the cell.
The keyboard-scoping slot that decides which Editor owns a key typed
with focus on <body> was module-local. A notebook loads the widget
bundle -- and this module with it -- once per displayed widget, so two
widgets held two copies of the slot: once both had been focused and
focus fell to <body>, each copy saw its own root as last active and
Delete/undo applied to both models.
The slot now lives on globalThis under a registry symbol
(Symbol.for('@simlin/diagram:activeEditorRoot')), so every copy of the
module on the page -- and every build of it -- arbitrates through one
value. The API (markEditorRootActive/releaseEditorRoot/activeEditorRoot)
is unchanged; the new test evaluates the module twice via the test
runner's module registry reset and proves only the last-active root
claims a <body> key.
…emount
Canvas labels were the one primitive that ignored theme.css: Label pinned
fill:#000000 inline (added for the resvg export path, where class-based
text styling is not applied) and the halo filter recoloured its plate to
literal white, so a dark host got black text with a white glow over dark
primitives -- and the inline fill also beat every element's
`.selected text { fill: var(--color-selected) }`, leaving selected labels
black. Canvas now provides a CanvasRenderContext: the export arm (embedded
Canvas: renderSvgToString, StaticDiagram) keeps the literal fill and the
fixed `labelBackground` colour-matrix filter, byte-identical to the Rust
renderer (svg-rendering.test.ts still passes); the interactive arm declares
no inline fill (Canvas.module.css's `var(--color-black)` and the selected
rules apply) and its halo is an feFlood whose colour is the --color-white
token set as a CSS property, composited `in` the blurred plate -- the same
alpha product the matrix produced, so light mode is pixel-identical. Each
interactive Canvas defines that filter under a per-mount random id, not
React.useId: the token resolves in the filter's own ancestor chain and
url(#id) resolves document-wide, and useId is only unique per React root --
the notebook widget mounts one root per cell from its own React copy, so
two cells both got `_r_1_` and the light widget wore the dark one's halo.
A pan or zoom is never persisted by itself (queueViewUpdate does not save),
so a host that remounts the Editor on new project bytes while the user is
looking (the notebook widget on a kernel push) silently reset the user's
viewport. Editor gains `onViewportChange` (a post-commit effect keyed on the
controller snapshot: fires with the model name and committed viewport
whenever it changes by value, never per gesture frame or for a
content-equal republish) and `initialViewport`, forwarded to the
controller config: openInitialProject splices it into the first published
project, so the canvas never renders or fits the stored viewport, and then
round-trips it through queueViewUpdate -- view-only, no undo entry, no save,
the same footing as a pan. Both are optional; app and simlin-serve are
unchanged.
…mount
A Python edit(), a disk reload, or a reject re-seed remounts the Editor
on the kernel's bytes. The new Editor starts with nothing selected and,
like every Editor, suppresses onSelectionChanged on its initial mount,
so no trait write followed and `selection` -- and Model.selection --
kept naming whatever the replaced Editor had selected, possibly
variables the push had just removed, while the UI showed none.
remountFrom now publishes [] through the same 150 ms debounce the
Editor's own selection changes use, so a selection still pending when
the push lands is superseded rather than published stale, and a burst
of pushes is one sync; own-ack and idempotent pushes (no remount)
publish nothing. The JupyterLab journey checks `m.selection == ()`
after the Python edit and the disk change.
…rnel pushes
The wrapper now paints var(--color-background) itself (the token lives on
the wrapper via the scoped theme.css and flips with data-theme), because
the Editor root and canvas are transparent: a forced theme="dark" in a
light JupyterLab showed dark chrome and light-on-dark primitives over the
cell's white. The loading/error placeholder resolves the same theme so a
dark notebook does not get a light box while the engine loads, and its
text uses the token that exists (--color-text-muted; --color-text-secondary
never did, so it always fell back).
A kernel-originated remount (Python edit(), disk reload, reject re-seed)
mounted the new Editor on the kernel's stored viewport, but a pan or zoom is
never saved on its own, so the user's framing was silently reset -- and a
project whose stored viewBox was still 0/0/0/0 (a converted model never
edited in the browser) re-centred on the grown diagram every time the
kernel added a variable. WidgetApp keeps the live Editor's last committed
viewport (Editor.onViewportChange) and remountFrom hands it to the new
mount as initialViewport when viewportToCarry says the kernel change did
not itself move the viewport: the incoming stored viewBox/zoom equal the
outgoing seed's, or the incoming one is unset. A kernel that moved the
viewport wins, and a module's live viewport is never carried onto the root
the remount opens. Nothing is saved by the carry; the Editor applies it
before its first render.
The bundle-level Playwright journey gains the theming claim only a style
engine can make (dark label fill, halo flood colour and wrapper background
compute to their dark token values; light keeps black/white; a dark and a
light widget on one page each reference their own halo filter), and the
JupyterLab journey shift-pans then edits from Python and asserts the stock
did not move on screen (canvas-relative, since running a cell scrolls the
notebook), plus the no-shift claim for the laid-out-on-display model. Both
JupyterLab assertions were confirmed to fail with the carry disabled.
Project._apply_snapshot now raises SimlinWriteError for ANY failure past
its commit point (contents replaced, revision bumped), not only the
autosave write: a subscriber dispatcher raising on a closed kernel loop
used to escape as a raw RuntimeError, which the widget could only read
as "nothing applied" and answer with rejected -- for a change that was
real, wedging the view. The type alone now means "applied but ...";
write_failed says whether the file lags (dirty; save() retries) so the
widget's notice only sends the user to model.save() when that is true.
The widget tracks how far each snapshot got (applied, replied): a
failure after the reply already went out sends nothing more (a second
reply would be consumed by the browser's next snapshot), and a
BaseException (KeyboardInterrupt) still gets its one reply before it
propagates.
Warnings raised by a display are attributed to the user's cell:
user_stacklevel() (shared by ModelWidget and Model._repr_mimebundle_)
skips pysimlin's own frames and IPython's display-formatter frames,
which sit between the cell and the repr; attributed to formatters.py a
warning would be one location for every display and Python's
once-per-location filter would show it once per kernel session. The
degrade path's install hint also rides in the bundle's text/plain, which
that filter never dedupes.
Also: the "no stale own-revision marker" test now observes the marker
through a second widget's accepted snapshot -- the only change source
is_own_change ever treats as own -- instead of a Python edit() that
could never have been skipped; the empty-view-over-empty-model guard is
pinned by a second display committing no revision; install_hint detects
Colab exactly as anywidget does ("google.colab.output" in sys.modules,
importing nothing); and the README no longer claims .mdl identifiers are
lower-cased (casing is normalised).
Every `msg:custom` reply reaches every view of a model, but the
in-flight slot and the "stale replies owed" count were per view. With
one ModelWidget displayed in two cells, a state push could mark view B's
pending save stale, B would then consume view A's `saved` as that stale
reply, and if B submitted another edit before its real rejection
arrived, that rejection resolved the newer save's promise instead.
Each snapshot now carries an `id` (unique per view and per request:
`requestId(viewToken, seq)`), the kernel echoes it verbatim in
`saved`/`rejected` -- from every arm, including a malformed snapshot's
rejection -- and the widget resolves a save only on the reply naming
the snapshot it has in flight; every other reply is ignored. That also
replaces the stale-reply counter: the reply owed to a snapshot whose
slot a remount freed simply matches nothing. `oversize` is unchanged
(no reply owed, no id).
The seed is no longer adopted on an `own-ack` push but only on the
matching `saved`: two views can send byte-identical bytes against the
same base, and the loser -- whose reject then follows the winner's push
-- would otherwise find the pair already seeded, remount nothing, and
stay acknowledged one revision behind with every later save stale.
Both two-view interleavings are pinned in index.test.tsx; the fake
kernels (unit and e2e) echo the id, and both e2e suites pass.
The issue #52 safety net re-centres a diagram whose STORED viewport strands
it offscreen, once per mount. On a remount whose viewport a host carried in
(Editor initialViewport: the user's own live pan, or the fit the previous
mount already applied) that framing is what the user is looking at, so a
diagram they panned offscreen was yanked back by every kernel push. Canvas
gains `recenterOffscreenOnMount` (default true) and the Editor passes
`initialViewport === undefined`, so a viewport that came from data keeps
the safety net.
Also documents that the embedded/export arm of the label theming covers
the live sd-model iframe embed (light-only) as well as the string
renderers, and that with two views of one model both converge onto the
editing view's framing when it saves (inherent to the stored-viewBox
signal).
With the seed adopted only on the matching `saved`, a `rejected` that
follows an own-ack push remounts the view at base+1 rather than leaving
it acknowledged one revision behind. The kernel-side policy (a snapshot
applied before a failure is answered `saved`) is unchanged, but its
stated reason was the old wedge; the docs, docstrings and classifyPush
comment now give the reason that holds: a `rejected` there costs the
view a remount -- undo history and any local edits since -- for a change
that is real.
Also: the ignored-replies test awaits act() so it pins the claim (it
fails under a replyIsFor->true mutant); the dirty-holdback warning from
the poll thread mentions save_as() beside save(force=True), which a
read-only suffix refuses.
A project opened through a symlink keeps the link as its path (the name
the user gave), so every save goes through the link; atomic_write's
rename onto the link itself replaced the link's directory entry with a
regular file, leaving the real file stale. The write now resolves the
final target and creates/renames the tempfile in that target's
directory (same filesystem, mode copied from the target); a dangling
link gets its missing target created, since the file the link points to
is the one the user named. Project.path still reports the link; the
conflict check, echo suppression and watcher read through it as before.
_notify treated a subscriber's dispatch callable raising synchronously
(a closed kernel IO loop) as a failure of the edit, though the change was
already committed and written, and later subscribers missed the event.
It is now handled like a raising callback: a once-per-distinct-message
RuntimeWarning, delivery continuing with the remaining subscribers. The
SimlinWriteError wrapping in _apply_snapshot stays as the guard for any
post-commit failure; its tests use a warnings filter escalating the
subscriber warning (python -W error) as the realistic vehicle.
@bpowers

Copy link
Copy Markdown
OwnerAuthor

Rebased onto main (9fdfb5ea, the exact-LTM-discovery PR) and force-pushed as e07a60d8.

Both findings in the last review were already fixed: it snapshotted 68f27d8d, and e6b85d1f ("pysimlin: write through symlinks and survive a dead dispatcher") landed twelve minutes later.

  • Preserve symlink targets when autosaving (_disk.py): atomic_write now resolves path to its final target and creates/renames the tempfile in that target's directory, so the link's directory entry survives and the real file gets the bytes; mode is copied from the target, and a dangling link gets its named target created. Project.path still reports the link. Pinned by tests/test_disk.py::TestAtomicWrite (link, link chain, mode, dangling link, symlinked parent directory) and tests/test_file_backed.py::test_saving_through_a_symlink_writes_the_target_and_keeps_the_link (autosave through the link, watcher recognises its own echo).
  • Contain synchronous dispatcher failures (project.py): a dispatch callable raising synchronously is now handled exactly like a raising callback -- a once-per-distinct-message RuntimeWarning, delivery continuing with the remaining subscribers -- so a closed kernel IO loop no longer surfaces as a failure of an edit that already committed and wrote. Pinned by tests/test_file_backed.py::test_dispatcher_exception_is_a_warning_and_later_subscribers_still_hear.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:e07a60d80a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@bpowers
bpowers merged commit ae1ec83 into mainAug 20, 2026
17 checks passed
@bpowers
bpowers deleted the pysimlin-widget branch August 20, 2026 05:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bpowers