From e4e2e5d28a343a85769575901b199f26aff9330a Mon Sep 17 00:00:00 2001 From: Gregory Paradis Date: Mon, 22 Jun 2026 19:33:36 +0000 Subject: [PATCH] Add literate notebook examples gallery --- CHANGE_LOG.md | 3 + MANIFEST.in | 2 +- README.md | 2 +- ROADMAP.md | 101 +++++- docs/examples.rst | 3 + .../fable-2020-notebook-interface.rst | 9 +- .../examples/synthetic-notebook-interface.rst | 9 +- docs/guides/notebook-interface.rst | 9 +- docs/guides/release-deployment.rst | 18 +- examples/README.md | 7 +- examples/notebooks/README.md | 11 + .../fable-2020-notebook-interface.ipynb | 300 ++++++++++++++++++ .../synthetic-notebook-interface.ipynb | 282 ++++++++++++++++ .../literate-notebook-examples-gallery.md | 62 ++++ pyproject.toml | 2 +- scripts/check_release_artifacts.sh | 2 +- src/modelwright/__init__.py | 2 +- tests/test_examples.py | 84 +++++ tests/test_import.py | 2 +- 19 files changed, 888 insertions(+), 22 deletions(-) create mode 100644 examples/notebooks/README.md create mode 100644 examples/notebooks/fable-2020-notebook-interface.ipynb create mode 100644 examples/notebooks/synthetic-notebook-interface.ipynb create mode 100644 planning/literate-notebook-examples-gallery.md diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index 6c12519..63cc028 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -4,6 +4,9 @@ This file records completed project work in chronological order. ## 2026-06-22 +- Activated Phase 31 on `feature/p31-literate-notebook-examples`, created GitHub parent issue #183 and child issues #187, #185, #186, #184, #188, and #189, and scoped the next `0.1.0a6` alpha around actual known-valid literate `.ipynb` notebook files for the synthetic and generated 2020 FABLE examples, plus matching Sphinx Examples Gallery documentation and validation. +- Completed P31.1 through P31.5 by adding real literate Jupyter notebooks under `examples/notebooks/` for the synthetic and generated 2020 FABLE notebook-interface workflows; each notebook includes headings, explanatory markdown, code cells, and stored outputs; Sphinx Examples Gallery pages now link to downloadable `.ipynb` files; and default tests validate notebook JSON, Python kernel metadata, markdown-before-code structure, stored outputs, synthetic notebook execution, and static FABLE notebook validation-boundary output without running the expensive generated FABLE model in default pytest. +- Prepared the `0.1.0a6` release candidate by bumping package/import metadata and release docs, passing repo-local bootstrap, Ruff, default `pytest -vv` with `167` passed and `1` benchmark skip, Sphinx warning-as-error docs with notebook downloads copied, Read the Docs theme verification, and `scripts/check_release_artifacts.sh`; the artifact checker built a roughly `56K` wheel and `2.2M` sdist, included the tracked literate notebooks in the sdist, installed the wheel into a clean ignored environment, imported `modelwright 0.1.0a6`, and smoke-tested the CLI. - Activated Phase 30 on `feature/p30-notebook-dataframe-interface`, created GitHub parent issue #174 and child issues #175, #178, #176, #177, #179, and #180, and scoped the next `0.1.0a5` alpha around an optional pandas-backed notebook/DataFrame layer over `modelwright.wrappers` for live-kernel inspection, scenario mutation, table rendering, and baseline-vs-scenario comparison workflows. - Completed P30.1 through P30.5 by adding `modelwright.notebooks` with lazy pandas-backed DataFrame helpers for declared inputs, outputs, scenarios, tables, reports, and baseline-vs-scenario comparisons; added a `notebook` optional extra with `pandas>=2` while keeping pandas out of core dependencies; documented the notebook workflow in Sphinx; added always-on synthetic notebook tests plus real generated synthetic model coverage; and extended the opt-in FABLE wrapper benchmark so `MODELWRIGHT_RUN_FABLE_BENCHMARKS=1 .venv/bin/python -m pytest -vv tests/test_fable_wrapper_benchmark.py` passed in 149.67 seconds against ignored local 2020 FABLE artifacts while preserving the existing `281,741` matches and `0` mismatches evidence boundary. - Added P30.6 release examples scope with a tracked `examples/` directory, a tiny synthetic notebook-interface example, a production-size 2020 FABLE notebook-interface example backed by compressed generated Python output rather than the original workbook, Sphinx Examples Gallery pages linked from the docs index, and lightweight tests that verify example integrity without running the expensive FABLE generated-model calculation in default pytest. diff --git a/MANIFEST.in b/MANIFEST.in index 01dcf21..29955ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -recursive-include examples *.md *.py *.xz +recursive-include examples *.ipynb *.md *.py *.xz diff --git a/README.md b/README.md index de0b5a5..d530abc 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths: scripts/bootstrap_dev_env.sh --benchmarks ``` -`modelwright` is pre-release. The current alpha line is `0.1.0a5`; alpha releases must not be described as full-workbook conversion guarantees. +`modelwright` is pre-release. The current alpha line is `0.1.0a6`; alpha releases must not be described as full-workbook conversion guarantees. Check release artifacts locally: diff --git a/ROADMAP.md b/ROADMAP.md index 6e2e48f..641967c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -863,7 +863,7 @@ Release target: `modelwright==0.1.0a5`. - [x] Add Sphinx Examples Gallery pages linked from the docs index. - [x] Add lightweight tests for example integrity without running expensive production-size FABLE calculation in default pytest. -- [ ] P30.7 Publish `modelwright==0.1.0a5`. Child issue: #180. +- [x] P30.7 Publish `modelwright==0.1.0a5`. Child issue: #180. - Status: complete. - [x] Confirm P30 notebook/DataFrame scope and evidence are complete. - [x] Bump package/import version and release docs to `0.1.0a5`. @@ -929,5 +929,100 @@ Release result: ## Current Next Steps -1. Keep Phase 30 closed unless a release defect is discovered. -2. Plan the next roadmap phase before opening a new feature branch. +## Phase 31: Literate Notebook Examples Gallery + +GitHub parent issue: #183 + +Active branch: `feature/p31-literate-notebook-examples` + +Status: active. + +Goal: substantially enhance the examples seed from Phase 30 with actual known-valid Jupyter +notebook files and matching Sphinx gallery documentation. The notebooks should follow a literate +programming style: headings and subheadings, explanatory markdown before code, code cells, stored +outputs, and enough context that motivated new users can understand the workflow without reading +package internals first. + +Release target: `modelwright==0.1.0a6`. + +- [x] P31.1 Define literate notebook example contract. Child issue: #187. + - Status: complete. + - [x] Decide notebook file placement and naming. + - [x] Define required markdown/code/output structure. + - [x] Define how notebooks import repo examples from source checkouts and installed packages. + - [x] Define validation strategy for notebook JSON, markdown/code alternation, expected outputs, and + expensive FABLE execution boundaries. + - [x] Record non-goals and release boundary in roadmap/planning docs. +- [x] P31.2 Add synthetic `.ipynb` notebook example. Child issue: #185. + - Status: complete. + - [x] Add a real `.ipynb` file with headings, explanatory markdown, code cells, and stored outputs. + - [x] Show import setup, facade construction, scenario creation, input/output frames, table frame, + and scenario comparison. + - [x] Keep the notebook fast enough for default validation. + - [x] Add tests that verify the notebook is valid and its expected outputs stay synchronized. +- [x] P31.3 Add 2020 FABLE `.ipynb` notebook example. Child issue: #186. + - Status: complete. + - [x] Add a real `.ipynb` file with headings, explanatory markdown, code cells, and stored outputs. + - [x] Explain the compressed generated-model artifact and why the original workbook is not tracked. + - [x] Show facade construction, calculation, output frames, table frame, report frames, and + validation-boundary context. + - [x] Avoid running the expensive FABLE generated-model calculation in default pytest; keep full + execution opt-in. + - [x] Keep raw workbooks and generated decompressed models ignored under `tmp/`. +- [x] P31.4 Integrate notebooks into examples gallery docs. Child issue: #184. + - Status: complete. + - [x] Add notebook download/open links to the examples gallery pages. + - [x] Explain the intended workflow for opening notebooks from a source checkout. + - [x] Keep generated workbook binaries and raw validation reports out of docs. + - [x] Verify Sphinx docs build warning-free. +- [x] P31.5 Validate notebook examples and docs. Child issue: #188. + - Status: complete. + - [x] Validate notebook JSON structure and metadata. + - [x] Validate literate structure: headings, explanatory markdown before code, code cells, and stored + outputs. + - [x] Execute or otherwise verify the synthetic notebook in default tests. + - [x] Keep production-size FABLE execution opt-in but verify static stored outputs and provenance in + default tests. + - [x] Run Ruff, pytest, Sphinx docs, and docs theme verification. + - [x] Record evidence in roadmap, changelog, and issue comments. +- [ ] P31.6 Publish `modelwright==0.1.0a6`. Child issue: #189. + - Status: active. + - [x] Confirm P31 notebook example scope and evidence are complete. + - [x] Bump package/import version and release docs to `0.1.0a6`. + - [x] Run local release checks, including Ruff, pytest, Sphinx docs, docs theme verification, and + release artifact checks. + - [ ] Open and merge the P31 PR to `main`. + - [ ] Create annotated tag `v0.1.0a6`. + - [ ] Publish through the gated release workflow after maintainer approval. + - [ ] Verify PyPI JSON, clean PyPI install, import version, CLI help, GitHub release, and docs deployment. + +Acceptance boundary: + +- May claim tracked, known-valid literate notebook examples for synthetic and generated 2020 FABLE + notebook/DataFrame workflows. +- May claim Sphinx Examples Gallery pages that link to actual `.ipynb` notebooks. +- Must not claim a full spreadsheet UI, automatic workbook semantic recovery, stable public API + compatibility, or Excel-backed recalculation equivalence. + +Implementation evidence: + +- Added `examples/notebooks/synthetic-notebook-interface.ipynb` with literate markdown, code cells, + and stored outputs for the synthetic notebook/DataFrame workflow. +- Added `examples/notebooks/fable-2020-notebook-interface.ipynb` with literate markdown, code cells, + and stored outputs for the generated 2020 FABLE wrapper workflow. +- Added notebook download links to the Sphinx Examples Gallery pages. +- Added default tests that parse notebook JSON, verify Python 3 kernel metadata, validate markdown + before code, require stored outputs, execute the synthetic notebook code cells, and verify the FABLE + notebook's stored validation-boundary output without running the expensive generated model. + +Verification evidence: + +- `scripts/bootstrap_dev_env.sh` passed and installed the `0.1.0a6` editable package. +- `.venv/bin/python -m ruff check .` passed. +- `.venv/bin/python -m pytest -vv` passed with `167` passed and `1` skipped benchmark. +- `.venv/bin/sphinx-build -b html docs _build/html -W` passed and copied the notebook downloads. +- `.venv/bin/python scripts/verify_docs_theme.py _build/html` passed. +- `scripts/check_release_artifacts.sh` passed for `0.1.0a6`; the clean wheel install imported + `modelwright 0.1.0a6` and the artifact inspection included the tracked notebook files in the sdist + without including source workbooks, ignored `tmp/`, or private validation material. +- Local release artifacts were about `56K` for the wheel and `2.2M` for the sdist. diff --git a/docs/examples.rst b/docs/examples.rst index 4b54294..7ac89dc 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -4,6 +4,9 @@ Examples Gallery These examples show the path from generated Python models to analyst-facing wrapper facades and notebook-friendly DataFrame displays. +The gallery includes both plain Python source examples and real Jupyter notebook files. The notebooks +are written in a literate style: markdown context, code cells, and stored outputs. + .. toctree:: :maxdepth: 1 diff --git a/docs/examples/fable-2020-notebook-interface.rst b/docs/examples/fable-2020-notebook-interface.rst index 221121b..d9208c2 100644 --- a/docs/examples/fable-2020-notebook-interface.rst +++ b/docs/examples/fable-2020-notebook-interface.rst @@ -10,6 +10,10 @@ The example wraps three validated ``SCENARIOS selection`` outputs, renders them keeps the validation boundary explicit: the source Phase 26 full-validation report recorded 281,741 comparable cached outputs, 281,741 matches, and 0 mismatches. +Open the literate notebook: + +- :download:`fable-2020-notebook-interface.ipynb <../../examples/notebooks/fable-2020-notebook-interface.ipynb>` + Run it from the repository root: .. code-block:: bash @@ -24,9 +28,12 @@ Use it from a notebook stored under ``tmp/notebooks/``: import sys repo_root = Path.cwd().resolve() - while repo_root.name != "sheetforge": + while repo_root != repo_root.parent and not (repo_root / "pyproject.toml").exists(): repo_root = repo_root.parent + if not (repo_root / "pyproject.toml").exists(): + raise RuntimeError("Could not find the Modelwright repository root.") + sys.path.insert(0, str(repo_root)) from examples.fable_2020.notebook_interface import build_facade diff --git a/docs/examples/synthetic-notebook-interface.rst b/docs/examples/synthetic-notebook-interface.rst index 49bddb6..cc26749 100644 --- a/docs/examples/synthetic-notebook-interface.rst +++ b/docs/examples/synthetic-notebook-interface.rst @@ -5,6 +5,10 @@ This tiny example uses a small generated-model-shaped ``calculate(inputs=None)`` with ``ModelFacade``, and renders outputs, a declared table, and a baseline-vs-scenario comparison as DataFrames. +Open the literate notebook: + +- :download:`synthetic-notebook-interface.ipynb <../../examples/notebooks/synthetic-notebook-interface.ipynb>` + Run it from the repository root: .. code-block:: bash @@ -19,9 +23,12 @@ Use it from a notebook stored under ``tmp/notebooks/``: import sys repo_root = Path.cwd().resolve() - while repo_root.name != "sheetforge": + while repo_root != repo_root.parent and not (repo_root / "pyproject.toml").exists(): repo_root = repo_root.parent + if not (repo_root / "pyproject.toml").exists(): + raise RuntimeError("Could not find the Modelwright repository root.") + sys.path.insert(0, str(repo_root)) from examples.synthetic.notebook_interface import build_facade diff --git a/docs/guides/notebook-interface.rst b/docs/guides/notebook-interface.rst index 2e8202e..45c6830 100644 --- a/docs/guides/notebook-interface.rst +++ b/docs/guides/notebook-interface.rst @@ -13,6 +13,10 @@ Install the optional notebook dependency when using these helpers: The core package and ``modelwright.wrappers`` do not require pandas. +The tracked examples also include real notebook files under ``examples/notebooks/``. They are the +best starting point for users who want a literate, open-and-run walkthrough rather than copying code +from this guide. + When using the tracked source-tree examples from a notebook stored under ``tmp/notebooks/``, add the repository root to ``sys.path`` before importing from ``examples``: @@ -22,9 +26,12 @@ repository root to ``sys.path`` before importing from ``examples``: import sys repo_root = Path.cwd().resolve() - while repo_root.name != "sheetforge": + while repo_root != repo_root.parent and not (repo_root / "pyproject.toml").exists(): repo_root = repo_root.parent + if not (repo_root / "pyproject.toml").exists(): + raise RuntimeError("Could not find the Modelwright repository root.") + sys.path.insert(0, str(repo_root)) Boundary diff --git a/docs/guides/release-deployment.rst b/docs/guides/release-deployment.rst index 5950817..c40ace2 100644 --- a/docs/guides/release-deployment.rst +++ b/docs/guides/release-deployment.rst @@ -8,17 +8,17 @@ all been checked. Current Alpha Target -------------------- -The current alpha target is ``0.1.0a5`` with Git tag ``v0.1.0a5``. +The current alpha target is ``0.1.0a6`` with Git tag ``v0.1.0a6``. The alpha may claim full comparable-output validation for the 2020 FABLE Calculator benchmark: 281,741 comparable cached workbook outputs, 281,741 matches, and zero mismatches. It may also claim the measured Phase 27 generated-runtime and generated-source-size improvements recorded in ``planning/phase-27-performance-memory-hardening.md`` and the initial ``modelwright.wrappers`` -facade helpers for building analyst-facing wrappers around generated models. The ``0.1.0a5`` alpha -may additionally claim optional pandas-backed notebook helpers and a tracked Examples Gallery with -synthetic and compressed generated 2020 FABLE model examples. It must not claim full-workbook -conversion, a full spreadsheet UI, Excel-backed recalculation equivalence, compact runtime IR -production readiness, or stable public API compatibility. +facade helpers for building analyst-facing wrappers around generated models. The ``0.1.0a6`` alpha +may additionally claim optional pandas-backed notebook helpers, a tracked Examples Gallery with +synthetic and compressed generated 2020 FABLE model examples, and real literate `.ipynb` notebooks +with stored outputs. It must not claim full-workbook conversion, a full spreadsheet UI, Excel-backed +recalculation equivalence, compact runtime IR production readiness, or stable public API compatibility. Local Release Checks -------------------- @@ -75,7 +75,7 @@ After TestPyPI publication, install the package from TestPyPI in a clean environ .. code-block:: bash - python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ modelwright==0.1.0a5 + python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ modelwright==0.1.0a6 python -c "import modelwright; print(modelwright.__version__)" modelwright --help @@ -89,7 +89,7 @@ Expected sequence: 1. Confirm ``CHANGE_LOG.md`` and release notes describe the actual alpha boundary. 2. Confirm local and CI release artifact checks pass. 3. Confirm TestPyPI rehearsal passes or document the exact blocker. -4. Create the annotated tag, for example ``v0.1.0a5``. +4. Create the annotated tag, for example ``v0.1.0a6``. 5. Run the ``Release`` workflow or push the tag, then approve the protected PyPI environment. 6. Verify the package page, wheel install, import, CLI help, docs deployment, and GitHub release notes. @@ -102,7 +102,7 @@ Use one of these responses: - yank the broken release on PyPI if installation should be discouraged but historical availability is still useful; -- publish a new alpha such as ``0.1.0a6`` after fixing the issue; +- publish a new alpha such as ``0.1.0a7`` after fixing the issue; - update release notes and roadmap entries with the failure mode and mitigation. Private Data Rules diff --git a/examples/README.md b/examples/README.md index f92cee3..2462d67 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,8 @@ facades and notebook-friendly DataFrame helpers. - `synthetic/`: a tiny generated-model example based on the tracked synthetic fixture shape. - `fable_2020/`: a production-size example using a compressed generated Python model converted from the public 2020 FABLE Calculator benchmark workbook. +- `notebooks/`: real Jupyter notebooks with markdown explanation, code cells, and stored outputs for + the synthetic and FABLE examples. The original FABLE workbook is not tracked here. The tracked FABLE example contains Modelwright's generated Python output, compressed as `generated_fable_2020_model.py.xz` because the uncompressed @@ -21,9 +23,12 @@ from pathlib import Path import sys repo_root = Path.cwd().resolve() -while repo_root.name != "sheetforge": +while repo_root != repo_root.parent and not (repo_root / "pyproject.toml").exists(): repo_root = repo_root.parent +if not (repo_root / "pyproject.toml").exists(): + raise RuntimeError("Could not find the Modelwright repository root.") + sys.path.insert(0, str(repo_root)) ``` diff --git a/examples/notebooks/README.md b/examples/notebooks/README.md new file mode 100644 index 0000000..36d835f --- /dev/null +++ b/examples/notebooks/README.md @@ -0,0 +1,11 @@ +# Literate Notebook Examples + +These notebooks are tracked examples for the Sphinx Examples Gallery. + +- `synthetic-notebook-interface.ipynb` is a tiny, fast notebook that can be checked in default tests. +- `fable-2020-notebook-interface.ipynb` documents the production-size generated 2020 FABLE model + workflow. The notebook contains known-valid stored outputs, while full execution remains opt-in + because the generated model import and calculation are intentionally large. + +Open them from a source checkout with JupyterLab or Jupyter Notebook. The first code cell adds the +repository root to `sys.path` so imports from the local `examples/` directory resolve. diff --git a/examples/notebooks/fable-2020-notebook-interface.ipynb b/examples/notebooks/fable-2020-notebook-interface.ipynb new file mode 100644 index 0000000..23c6339 --- /dev/null +++ b/examples/notebooks/fable-2020-notebook-interface.ipynb @@ -0,0 +1,300 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 2020 FABLE Notebook Interface Example\n", + "\n", + "This notebook shows the same notebook-facing workflow on a production-size generated Modelwright model from the public 2020 FABLE Calculator benchmark workbook.\n", + "\n", + "The original workbook is not tracked in this repository. The tracked artifact is Modelwright's generated Python output, compressed as `examples/fable_2020/generated_fable_2020_model.py.xz`. The example script decompresses that generated model into ignored `tmp/` working space before import." + ] + }, + { + "cell_type": "markdown", + "id": "2ceb2405", + "metadata": {}, + "source": [ + "## 1. Make The Source Checkout Importable\n", + "\n", + "This notebook is meant to be opened from a source checkout. The setup cell finds the repository root and places it on `sys.path`, so imports from `examples/` resolve even if the notebook file is copied under `tmp/notebooks/`.\n", + "\n", + "Expected output: the package project name, `modelwright`." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d40dcd53", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using project: modelwright\n" + ] + } + ], + "source": [ + "from pathlib import Path\n", + "import sys\n", + "import tomllib\n", + "\n", + "repo_root = Path.cwd().resolve()\n", + "while repo_root != repo_root.parent and not (repo_root / \"pyproject.toml\").exists():\n", + " repo_root = repo_root.parent\n", + "\n", + "if not (repo_root / \"pyproject.toml\").exists():\n", + " raise RuntimeError(\"Could not find the Modelwright repository root.\")\n", + "\n", + "project_name = tomllib.loads((repo_root / \"pyproject.toml\").read_text())[\"project\"][\"name\"]\n", + "\n", + "if str(repo_root) not in sys.path:\n", + " sys.path.insert(0, str(repo_root))\n", + "\n", + "print(f\"Using project: {project_name}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Load The Production-Size Example Facade\n", + "\n", + "`build_facade()` imports the generated FABLE Python model and declares a small analyst-facing wrapper around three validated `SCENARIOS selection` outputs. The first import may take time because the generated model is intentionally production-sized.\n", + "\n", + "Expected output: the compressed archive path, the decompressed generated-model path under `tmp/`, and the declared output names." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Archive: examples/fable_2020/generated_fable_2020_model.py.xz\n", + "Generated model path: tmp/examples/fable_2020/generated_fable_2020_model.py\n", + "Declared outputs: ['scenario_metric_1', 'scenario_metric_2', 'scenario_metric_3']\n" + ] + } + ], + "source": [ + "from examples.fable_2020.notebook_interface import ARCHIVE_PATH, MODEL_PATH, build_facade\n", + "from modelwright.notebooks import outputs_frame, report_frames, table_frame\n", + "\n", + "facade = build_facade()\n", + "\n", + "print(f\"Archive: {ARCHIVE_PATH.relative_to(repo_root)}\")\n", + "print(f\"Generated model path: {MODEL_PATH}\")\n", + "print(f\"Declared outputs: {list(facade.outputs())}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Calculate The Wrapped Model\n", + "\n", + "This cell calls the generated model through the facade. The stored output records the three wrapped FABLE outputs. The Phase 26 full-validation evidence for the underlying generated model remains: 281,741 comparable cached outputs, 281,741 matches, and 0 mismatches.\n", + "\n", + "Expected output: three `SCENARIOS selection` values." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'SCENARIOS selection!D20': 2.146115426018433,\n", + " 'SCENARIOS selection!D21': 1.8982220554032356,\n", + " 'SCENARIOS selection!D22': 1.462761288724012}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "values = facade.calculate()\n", + "{cell_ref: values[cell_ref] for cell_ref in [\n", + " \"SCENARIOS selection!D20\",\n", + " \"SCENARIOS selection!D21\",\n", + " \"SCENARIOS selection!D22\",\n", + "]}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Display Wrapped Outputs As A DataFrame\n", + "\n", + "The notebook helper presents the declared outputs with human names while preserving raw cell references for provenance.\n", + "\n", + "Expected output: a three-row DataFrame for the wrapped scenario metrics." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " name cell_ref value\n", + "0 scenario_metric_1 SCENARIOS selection!D20 2.146115\n", + "1 scenario_metric_2 SCENARIOS selection!D21 1.898222\n", + "2 scenario_metric_3 SCENARIOS selection!D22 1.462761" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "outputs_frame(facade)[[\"name\", \"cell_ref\", \"value\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Display A Declared FABLE Table Slice\n", + "\n", + "The wrapper declares `SCENARIOS selection!D20:D22` as a rectangular table with row labels. This turns a raw output slice into an inspectable table.\n", + "\n", + "Expected output: the three selected FABLE values as a one-column DataFrame." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " value\n", + "row \n", + "d20 2.146115\n", + "d21 1.898222\n", + "d22 1.462761" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "table_frame(facade, \"scenario_selection_slice\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Keep Provenance Available\n", + "\n", + "DataFrame display is the humane interface, but workbook provenance is still available. Table frame metadata stores the sheet, range, and cell references used to build the view.\n", + "\n", + "Expected output: the original workbook sheet and range." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'sheet': 'SCENARIOS selection',\n", + " 'range_ref': 'D20:D22',\n", + " 'cell_refs': [['SCENARIOS selection!D20'],\n", + " ['SCENARIOS selection!D21'],\n", + " ['SCENARIOS selection!D22']]}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "scenario_table = table_frame(facade, \"scenario_selection_slice\")\n", + "{\n", + " \"sheet\": scenario_table.attrs[\"sheet\"],\n", + " \"range_ref\": scenario_table.attrs[\"range_ref\"],\n", + " \"cell_refs\": scenario_table.attrs[\"cell_refs\"],\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Bundle Outputs Into A Report\n", + "\n", + "Reports collect declared cells and tables under one human-facing name. This gives a notebook user a repeatable reporting boundary without editing the generated model.\n", + "\n", + "Expected output: the report's cell DataFrame." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " name cell_ref value\n", + "0 scenario_metric_1 SCENARIOS selection!D20 2.146115\n", + "1 scenario_metric_2 SCENARIOS selection!D21 1.898222\n", + "2 scenario_metric_3 SCENARIOS selection!D22 1.462761" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "frames = report_frames(facade, \"scenario_selection\")\n", + "frames[\"cells\"][[\"name\", \"cell_ref\", \"value\"]]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/synthetic-notebook-interface.ipynb b/examples/notebooks/synthetic-notebook-interface.ipynb new file mode 100644 index 0000000..ec817f0 --- /dev/null +++ b/examples/notebooks/synthetic-notebook-interface.ipynb @@ -0,0 +1,282 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Synthetic Notebook Interface Example\n", + "\n", + "This notebook is the smallest useful Modelwright notebook workflow. It starts from a tiny generated-model-shaped Python function, wraps it with `ModelFacade`, and then uses `modelwright.notebooks` helpers to display the model as tidy pandas DataFrames.\n", + "\n", + "The point is not the toy arithmetic. The point is the workflow: a generated model becomes something a human can inspect, change, recalculate, and compare in a live notebook." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Make The Source Checkout Importable\n", + "\n", + "If this notebook is opened from `tmp/notebooks/` or another working directory inside the checkout, Python may not automatically see the repo-root `examples/` directory. This setup cell walks upward until it finds `pyproject.toml`, then puts that directory on `sys.path`.\n", + "\n", + "Expected output: the package project name, `modelwright`." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using project: modelwright\n" + ] + } + ], + "source": [ + "from pathlib import Path\n", + "import sys\n", + "import tomllib\n", + "\n", + "repo_root = Path.cwd().resolve()\n", + "while repo_root != repo_root.parent and not (repo_root / \"pyproject.toml\").exists():\n", + " repo_root = repo_root.parent\n", + "\n", + "if not (repo_root / \"pyproject.toml\").exists():\n", + " raise RuntimeError(\"Could not find the Modelwright repository root.\")\n", + "\n", + "project_name = tomllib.loads((repo_root / \"pyproject.toml\").read_text())[\"project\"][\"name\"]\n", + "\n", + "if str(repo_root) not in sys.path:\n", + " sys.path.insert(0, str(repo_root))\n", + "\n", + "print(f\"Using project: {project_name}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Load The Example Facade\n", + "\n", + "`build_facade()` creates a `ModelFacade` around a tiny generated-model-shaped `calculate(inputs=None)` function. The facade gives human names to spreadsheet-style cell references and declares one rectangular table view.\n", + "\n", + "Expected output: the declared input and output names." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Declared inputs: ['base', 'growth']\n", + "Declared outputs: ['projected', 'status']\n" + ] + } + ], + "source": [ + "from examples.synthetic.notebook_interface import build_facade\n", + "from modelwright.notebooks import compare_scenarios_frame, inputs_frame, outputs_frame, table_frame\n", + "\n", + "facade = build_facade()\n", + "\n", + "print(f\"Declared inputs: {list(facade.inputs())}\")\n", + "print(f\"Declared outputs: {list(facade.outputs())}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Create A Baseline And A Scenario\n", + "\n", + "Scenarios are immutable input override sets. Here the baseline uses a base volume of 100 and a growth rate of 0.1. The shock scenario changes only the base volume to 120.\n", + "\n", + "Expected output: the exact input overrides that will be sent to the generated model." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Baseline inputs: {'Inputs!B2': 100, 'Inputs!B3': 0.1}\n", + "Shock inputs: {'Inputs!B2': 120, 'Inputs!B3': 0.1}\n" + ] + } + ], + "source": [ + "baseline = facade.scenario(name=\"baseline\", inputs={\"Inputs!B2\": 100, \"Inputs!B3\": 0.1})\n", + "shock = baseline.with_input(\"Inputs!B2\", 120)\n", + "\n", + "print(f\"Baseline inputs: {baseline.inputs}\")\n", + "print(f\"Shock inputs: {shock.inputs}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Inspect Scenario Inputs As A DataFrame\n", + "\n", + "The notebook layer turns declared inputs into a DataFrame. The raw workbook references remain visible for provenance, but the human names and units are now the primary view.\n", + "\n", + "Expected output: two rows, one for `base` and one for `growth`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " name cell_ref value unit\n", + "0 base Inputs!B2 120.0 t\n", + "1 growth Inputs!B3 0.1 fraction" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "inputs_frame(facade, shock)[[\"name\", \"cell_ref\", \"value\", \"unit\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Recalculate And Inspect Outputs\n", + "\n", + "This cell calculates the shock scenario and displays declared outputs. The calculated projected volume is 132.0.\n", + "\n", + "Expected output: `projected = 132.0` and `status = ok`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " name cell_ref value unit\n", + "0 projected Summary!B2 132.0 t\n", + "1 status Summary!B3 ok NaN" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "outputs_frame(facade, shock)[[\"name\", \"cell_ref\", \"value\", \"unit\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Display A Declared Table\n", + "\n", + "The facade declared a rectangular table over `Summary!B2:C3`. The notebook helper displays it with row and column labels instead of requiring the user to reconstruct a table from cell references.\n", + "\n", + "Expected output: a two-by-two DataFrame with `volume` and `status` rows." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " primary secondary\n", + "row \n", + "volume 132.0 240\n", + "status ok 125" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "table_frame(facade, \"summary_grid\", shock)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Compare Baseline Against Scenario\n", + "\n", + "Scenario comparison is the core notebook loop. The baseline projected volume is 110.0 and the shock projected volume is 132.0, so the absolute change is 22.0 and the percent change is 0.2.\n", + "\n", + "Expected output: a tidy comparison table." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " name baseline_value scenario_value absolute_change percent_change\n", + "0 projected 110.0 132.0 22.0 0.2\n", + "1 status ok ok None None" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "compare_scenarios_frame(facade, baseline, shock)[\n", + " [\"name\", \"baseline_value\", \"scenario_value\", \"absolute_change\", \"percent_change\"]\n", + "]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/planning/literate-notebook-examples-gallery.md b/planning/literate-notebook-examples-gallery.md new file mode 100644 index 0000000..216b15e --- /dev/null +++ b/planning/literate-notebook-examples-gallery.md @@ -0,0 +1,62 @@ +# Literate Notebook Examples Gallery + +Phase 31 adds real tracked Jupyter notebooks to the examples gallery. + +The notebooks are teaching artifacts, not only smoke tests. They should follow a literate programming +shape: + +- a title markdown cell; +- short explanatory markdown before each code cell; +- code cells that perform one clear step; +- stored outputs after cells where a user should expect visible DataFrame or text output; +- enough context that a new user can follow why the step exists and what the output means. + +## Placement + +Notebook files live under: + +```text +examples/notebooks/ +``` + +Initial notebooks: + +- `examples/notebooks/synthetic-notebook-interface.ipynb` +- `examples/notebooks/fable-2020-notebook-interface.ipynb` + +The existing Python modules under `examples/synthetic/` and `examples/fable_2020/` remain importable +source examples and are reused by the notebooks. + +## Import Setup + +The notebooks are source-tree examples. They include a first code cell that adds the repository root +to `sys.path`, so they work when opened from `tmp/notebooks/` or from another working directory inside +the checkout. + +The setup cell intentionally uses the local checkout, not a hidden installed package path, because the +examples are meant to teach the source tree and docs together. + +## Validation Policy + +Default tests should: + +- parse every tracked notebook as JSON; +- verify notebook metadata declares a Python 3 kernel; +- verify each notebook has a title, explanatory markdown, code cells, and stored outputs; +- execute or otherwise verify the synthetic notebook outputs; +- verify the FABLE notebook's stored outputs and provenance without running the expensive generated + FABLE model in default pytest. + +Full FABLE notebook execution remains opt-in for local validation because the generated model import +and calculation are intentionally production-sized. + +## Non-Goals + +Phase 31 does not add: + +- a notebook execution service; +- widget dashboards; +- a full spreadsheet UI; +- automatic workbook semantic recovery; +- stable public API guarantees; +- Excel-backed recalculation equivalence. diff --git a/pyproject.toml b/pyproject.toml index 84c0080..31fd78b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "modelwright" -version = "0.1.0a5" +version = "0.1.0a6" description = "Tools for converting spreadsheet workbooks into transparent Python models." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/check_release_artifacts.sh b/scripts/check_release_artifacts.sh index 715fc7d..1613872 100755 --- a/scripts/check_release_artifacts.sh +++ b/scripts/check_release_artifacts.sh @@ -98,7 +98,7 @@ from __future__ import annotations import modelwright import importlib.util -assert modelwright.__version__ == "0.1.0a5", modelwright.__version__ +assert modelwright.__version__ == "0.1.0a6", modelwright.__version__ assert importlib.util.find_spec("sheetforge") is None print(f"[release-check] imported modelwright {modelwright.__version__}") PY diff --git a/src/modelwright/__init__.py b/src/modelwright/__init__.py index 1f6134b..1352a93 100644 --- a/src/modelwright/__init__.py +++ b/src/modelwright/__init__.py @@ -104,7 +104,7 @@ table, ) -__version__ = "0.1.0a5" +__version__ = "0.1.0a6" __all__ = [ "CellRecord", diff --git a/tests/test_examples.py b/tests/test_examples.py index 1d357fc..2faaf2a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json import lzma from pathlib import Path from types import ModuleType @@ -39,3 +40,86 @@ def test_fable_generated_model_archive_is_tracked_and_readable() -> None: prefix = archive.read(128) assert b"Generated Modelwright model" in prefix + + +def notebook(path: str) -> dict: + return json.loads((ROOT / path).read_text()) + + +def source_text(cell: dict) -> str: + source = cell.get("source", "") + if isinstance(source, list): + return "".join(source) + return source + + +def output_text(cell: dict) -> str: + chunks: list[str] = [] + for output in cell.get("outputs", []): + if "text" in output: + text = output["text"] + chunks.append("".join(text) if isinstance(text, list) else text) + data = output.get("data", {}) + text_plain = data.get("text/plain") + if text_plain is not None: + chunks.append("".join(text_plain) if isinstance(text_plain, list) else text_plain) + return "\n".join(chunks) + + +def test_literate_notebooks_have_expected_structure() -> None: + for notebook_path in [ + "examples/notebooks/synthetic-notebook-interface.ipynb", + "examples/notebooks/fable-2020-notebook-interface.ipynb", + ]: + payload = notebook(notebook_path) + cells = payload["cells"] + + assert payload["nbformat"] == 4 + assert payload["metadata"]["kernelspec"]["name"] == "python3" + assert cells[0]["cell_type"] == "markdown" + assert source_text(cells[0]).startswith("# ") + assert sum(cell["cell_type"] == "markdown" for cell in cells) >= 5 + assert sum(cell["cell_type"] == "code" for cell in cells) >= 5 + + for index, cell in enumerate(cells): + if cell["cell_type"] == "code": + assert index > 0 + assert cells[index - 1]["cell_type"] == "markdown" + assert cell.get("outputs"), f"{notebook_path} code cell {index} has no stored output" + + +def test_synthetic_notebook_outputs_are_known_valid() -> None: + payload = notebook("examples/notebooks/synthetic-notebook-interface.ipynb") + outputs = "\n".join(output_text(cell) for cell in payload["cells"] if cell["cell_type"] == "code") + + assert "Declared inputs: ['base', 'growth']" in outputs + assert "projected Summary!B2 132.0" in outputs + assert "volume 132.0 240" in outputs + assert "projected 110.0 132.0 22.0 0.2" in outputs + + +def test_synthetic_notebook_code_cells_execute() -> None: + payload = notebook("examples/notebooks/synthetic-notebook-interface.ipynb") + namespace: dict[str, object] = {} + for cell in payload["cells"]: + if cell["cell_type"] == "code": + exec(source_text(cell), namespace) + + comparison = namespace["compare_scenarios_frame"]( + namespace["facade"], + namespace["baseline"], + namespace["shock"], + ).set_index("name") + assert comparison.loc["projected", "scenario_value"] == 132.0 + assert comparison.loc["projected", "absolute_change"] == pytest.approx(22.0) + + +def test_fable_notebook_static_outputs_preserve_validation_boundary() -> None: + payload = notebook("examples/notebooks/fable-2020-notebook-interface.ipynb") + text = "\n".join(source_text(cell) + "\n" + output_text(cell) for cell in payload["cells"]) + + assert "generated_fable_2020_model.py.xz" in text + assert "281,741 comparable cached outputs" in text + assert "SCENARIOS selection!D20': 2.146115426018433" in text + assert "scenario_metric_3 SCENARIOS selection!D22 1.462761" in text + assert "range_ref': 'D20:D22'" in text diff --git a/tests/test_import.py b/tests/test_import.py index de10ad5..f04dd03 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -2,4 +2,4 @@ def test_package_imports() -> None: - assert modelwright.__version__ == "0.1.0a5" + assert modelwright.__version__ == "0.1.0a6"