pysimlin: file-backed models and an in-notebook model editor - #1024
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 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".
| fmt = resolve_read_format(p, data) | ||
| project = cls( | ||
| cls._open_bytes(data, fmt), | ||
| path=p, | ||
| format=fmt, | ||
| autosave=autosave, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| "LtmMode", | ||
| "Model", | ||
| "ModelIssue", | ||
| "ModelWidget", |
There was a problem hiding this comment.
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 👍 / 👎.
| r.seed = { revision: pair.revision, projectJson: pair.projectJson }; | ||
| setSeed((prev) => ({ | ||
| revision: pair.revision, | ||
| projectJson: pair.projectJson, | ||
| generation: prev.generation + 1, | ||
| initialViewport, | ||
| })); |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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; |
There was a problem hiding this comment.
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 👍 / 👎.
| cannot be determined. | ||
| SimlinRuntimeError: if the engine cannot parse the file. | ||
| """ | ||
| p = Path(path) |
There was a problem hiding this comment.
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 👍 / 👎.
| self.watch(False) | ||
| with self._file_lock: | ||
| self._listeners.clear() | ||
| self._closed = True |
There was a problem hiding this comment.
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 👍 / 👎.
| 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; |
There was a problem hiding this comment.
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
commented
Aug 19, 2026
@codex review Addressed all seven findings from the first round:
|
There was a problem hiding this comment.
💡 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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
commented
Aug 20, 2026
Rebased onto main ( Both findings in the last review were already fixed: it snapshotted
@codex review |
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Uh oh!
There was an error while loading. Please reload this page.
Why
Jupyter notebooks are where humans and AI agents already collaborate on modeling work, but pysimlin had no persistence story (a
Modelforgot 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 thesimlinMCP server), and puts the real@simlin/diagramEditor 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 2021src/jupyterattempt is the negative example: a labextension inside JupyterLab's build system, and the kernel never saw edits.What
simlin.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 extrapip install "pysimlin[notebook]") — kernel-ownedproject_json/revisiontraits; requests and replies as custom messages (snapshot→saved/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 barepip install pysimlinshows 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 theinlineglobal); DirectBackend; own-ack/remount logic; theme tracking; portals contained in the widget box.@simlin/diagramembeddability — keyboard scoping per Editor instance (+ focus stamping so JupyterLab shortcuts never fire on a focused widget), noposition:fixed/vw/vhinside the tree,portalContainer,showHomeLink, self-sufficient styles withoutreset.css, details slot sized on the positioned wrapper (also fixes a width/height issue in app and serve)..mdlfirst-class everywhere — libsimlinserialize_mdl(+ export warnings on the collected-errors channel) andreplace_contents(in-place reload keepingModelhandles valid); simlin-serve and simlin-mcp-core write.mdlin place (no sidecar; legacy.mdl+.sd.jsonpairs are listed as two projects with a startup warning); MDL writer warns when it drops loop metadata; comment line endings normalised.zoomis a percentage (spec §5.1) but was passed through as a factor (Stella files rendered 200×); views now writetype=(spec) notview_type=; incremental layout no longer re-chooseslabel_sideof untouched elements nor overwrites the editor viewport; multi-line labels use the XMILE\nescape; free-text attributes escape backslashes.scripts/stage_widget_assets.py(deterministicASSETS.json), wheel/sdist guard,check_wheel_assets.py(cross-wheel identity +--installedprobe),release.ymlbuilds assets once and every platform wheel carries identical bytes;ci.yamlpysimlin-e2ejob runs the JupyterLab journey + static-export check.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
patchper 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)._esmfrom ablob: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.save()refuses unlessforce=True.import ipywidgetsin the kernel and marimo onanywidget.AnyWidget, so it would lose two hosts to save two packages.read_only=True; a sketch-less.mdlis 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)
<body>before any pointer/focus activity reaches nobody.<body>.zoom="20000"..stmx/.xmileand.mdlare 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 --workspace7058 pass; pysimlin 969 pass; diagram 1676, notebook-widget 177 (rstest). On the merged tip:make -C src/pysimlin e2e3/3 (17 s),pnpm -C src/notebook-widget test:e2e6/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→ Pythonedit()toast → external-process write toast, revision +1 each; aProject.new()-built model displays with a canvas; AC2.6:d d/x/aleave 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,.mdledited 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,nbconvertdefault vsstore_widget_state=FalsevsSIMLIN_WIDGET_ASSET=inline. Its first pass found six blockers (viewless model → blank editor;d ddeleting 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 innotebook-hosts.md; AC3.1 established; AC4.1 established (wheel built +check_wheel_assets.py --installedfrom 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
.mdlwriter'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
_esmout of.ipynbin Colab; multi-peer merge (CRDT design doc); running the non-JupyterLab host checklists;test/xmutil_test_models/C-LEARN v77 for Vensim.xmileis a header-only fixture (pre-existing); MDL unit-range lossiness (pre-existing writer).🤖 Generated with Claude Code
https://claude.ai/code/session_01W6uTA7YvtnQmwob6bTJ59p