diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa141d7..8d00b06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,6 @@ jobs: sudo apt-get update sudo apt-get install -y cmake ninja-build gcc g++ gfortran - - name: Install uv - uses: astral-sh/setup-uv@v7 - - name: Cache CPM packages uses: actions/cache@v4 with: @@ -56,12 +53,25 @@ jobs: - name: Build run: cmake --build build --config ${{ env.BUILD_TYPE }} -j $(nproc) - # needed for test running - - name: Convert PET-MAD + # Pull a pre-exported graph-format GGUF from HuggingFace and place it + # where the GraphModel tests look for it (build/tests/gguf/pet-auto.gguf). + # Legacy fixed-PET tests skip cleanly when pet-mad.gguf is absent, which + # is the desired behaviour here — CI exercises the graph path only. + - name: Cache graph GGUF + uses: actions/cache@v4 + with: + path: build/tests/gguf/pet-auto.gguf + key: gguf-pet-auto-${{ hashFiles('scripts/convert_models.py') }} + + - name: Fetch graph GGUF from HuggingFace run: | - uv run scripts/convert_pet_mad.py --output pet-mad.gguf - rm -f build/tests/pet-mad.gguf - cp pet-mad.gguf build/tests/ + mkdir -p build/tests/gguf + if [ ! -s build/tests/gguf/pet-auto.gguf ]; then + curl -fL --retry 3 \ + -o build/tests/gguf/pet-auto.gguf \ + https://huggingface.co/peterspackman/mlip-gguf/resolve/main/pet-mad-xs.gguf + fi + ls -la build/tests/gguf/ - name: Run tests working-directory: build diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 29c9d91..7b76775 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -28,14 +28,16 @@ jobs: with: node-version: '20' + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install Emscripten uses: mymindstorm/setup-emsdk@v14 with: version: '3.1.50' - - name: Install uv - uses: astral-sh/setup-uv@v7 - - name: Cache CPM packages uses: actions/cache@v4 with: @@ -44,6 +46,15 @@ jobs: restore-keys: | cpm-wasm- + # Cache the bundled GGUF across runs so we don't re-download from HF on + # every build. Keyed on the URL + filename so a model bump in + # package.json invalidates the cache automatically. + - name: Cache bundled GGUF + uses: actions/cache@v4 + with: + path: website/public/pet-mad-xs.gguf + key: gguf-pet-mad-xs-${{ hashFiles('website/package.json') }} + - name: Build WASM run: | ./scripts/build_wasm.sh @@ -58,11 +69,11 @@ jobs: - name: Install website dependencies working-directory: website - run: npm install + run: bun install --frozen-lockfile - name: Build website working-directory: website - run: npm run build + run: bun run build - name: Setup Pages uses: actions/configure-pages@v4 diff --git a/.gitignore b/.gitignore index d7e4cc0..93ab9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,13 @@ stdout # Development directories testing/ + +# Local experiments and artifacts (not part of mlipcpp) local/ +petk_codegen/ +tinypet/ +scripts/repro_ase_optimizer_segfault.py +scripts/stress_ase_optimizer_segfault.py # WASM build output wasm/ diff --git a/CMakeLists.txt b/CMakeLists.txt index aa501d9..3e55147 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,11 +30,13 @@ option(MLIPCPP_INSTALL "Generate install target" OFF) option(MLIPCPP_USE_CUDA "Enable CUDA backend via ggml" OFF) option(MLIPCPP_USE_HIP "Enable HIP/ROCm backend via ggml (AMD)" OFF) option(MLIPCPP_USE_METAL "Enable Metal backend via ggml" OFF) +option(MLIPCPP_USE_WEBGPU "Enable WebGPU backend via ggml (requires Dawn)" OFF) option(MLIPCPP_USE_VULKAN "Enable Vulkan backend via ggml" OFF) option(MLIPCPP_USE_SYCL "Enable SYCL backend via ggml (Intel)" OFF) option(MLIPCPP_USE_CANN "Enable CANN backend via ggml (Huawei Ascend)" OFF) option(MLIPCPP_USE_BLAS "Enable BLAS acceleration via ggml" OFF) option(MLIPCPP_USE_SYSTEM_FMT "Use system-installed fmtlib instead of bundled" OFF) +option(MLIPCPP_WASM_ASYNCIFY "Use ASYNCIFY instead of JSPI for async WebGPU calls in WASM builds" OFF) # WASM-specific settings if(EMSCRIPTEN) @@ -43,7 +45,7 @@ if(EMSCRIPTEN) set(MLIPCPP_BUILD_TESTS OFF) set(MLIPCPP_BUILD_PYTHON OFF) set(MLIPCPP_BUILD_FORTRAN OFF) - # Disable all GPU backends for WASM + # Disable native GPU backends for WASM (WebGPU is the only option) set(MLIPCPP_USE_CUDA OFF) set(MLIPCPP_USE_HIP OFF) set(MLIPCPP_USE_METAL OFF) @@ -51,6 +53,7 @@ if(EMSCRIPTEN) set(MLIPCPP_USE_SYCL OFF) set(MLIPCPP_USE_CANN OFF) set(MLIPCPP_USE_BLAS OFF) + # MLIPCPP_USE_WEBGPU stays as the user set it (default OFF). # Tell GGML we're building for WASM so it uses SIMD-optimized kernels # (Emscripten defaults CMAKE_SYSTEM_PROCESSOR to x86, not wasm) set(CMAKE_SYSTEM_PROCESSOR "wasm32" CACHE STRING "" FORCE) @@ -122,6 +125,17 @@ endif() if(MLIPCPP_USE_VULKAN) set(GGML_VULKAN ON CACHE BOOL "" FORCE) endif() +if(MLIPCPP_USE_WEBGPU) + set(GGML_WEBGPU ON CACHE BOOL "" FORCE) + if(EMSCRIPTEN) + # Default to JSPI; opt in to ASYNCIFY via MLIPCPP_WASM_ASYNCIFY=ON. + if(MLIPCPP_WASM_ASYNCIFY) + set(GGML_WEBGPU_JSPI OFF CACHE BOOL "" FORCE) + else() + set(GGML_WEBGPU_JSPI ON CACHE BOOL "" FORCE) + endif() + endif() +endif() if(MLIPCPP_USE_SYCL) set(GGML_SYCL ON CACHE BOOL "" FORCE) endif() @@ -149,7 +163,7 @@ else() CPMAddPackage( NAME ggml GITHUB_REPOSITORY peterspackman/ggml - GIT_TAG 25574148 + GIT_TAG 833b864d EXCLUDE_FROM_ALL YES ) endif() @@ -168,6 +182,16 @@ elseif(NOT EMSCRIPTEN) ) endif() +# nlohmann_json (for graph inference metadata) +CPMAddPackage( + NAME nlohmann_json + GITHUB_REPOSITORY nlohmann/json + VERSION 3.11.3 + OPTIONS + "JSON_BuildTests OFF" + EXCLUDE_FROM_ALL YES +) + # ============================================================================= # Library # ============================================================================= @@ -314,24 +338,34 @@ if(EMSCRIPTEN) target_link_libraries(mlipcpp_wasm PRIVATE mlipcpp) target_include_directories(mlipcpp_wasm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) - # Emscripten-specific link flags + set(MLIPCPP_WASM_LINK_FLAGS + "-s WASM=1 " + "-s MODULARIZE=1 " + "-s EXPORT_ES6=1 " + "-s EXPORT_NAME='createMlipcpp' " + "-s ALLOW_MEMORY_GROWTH=1 " + "-s MAXIMUM_MEMORY=4GB " + "-s STACK_SIZE=1MB " + "-s SINGLE_FILE=1 " + "-s EXPORTED_RUNTIME_METHODS=['FS','cwrap','ccall'] " + "-s FORCE_FILESYSTEM=1 " + "--bind " + "-O3 " + ) + + # When WebGPU is enabled, ggml-webgpu already propagates -sJSPI or + # -sASYNCIFY via INTERFACE link options. Add a larger async stack for + # ASYNCIFY; JSPI doesn't need it. + if(MLIPCPP_USE_WEBGPU AND MLIPCPP_WASM_ASYNCIFY) + list(APPEND MLIPCPP_WASM_LINK_FLAGS "-s ASYNCIFY_STACK_SIZE=65536 ") + endif() + + string(REPLACE ";" "" MLIPCPP_WASM_LINK_FLAGS "${MLIPCPP_WASM_LINK_FLAGS}") + set_target_properties(mlipcpp_wasm PROPERTIES SUFFIX ".js" RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin - LINK_FLAGS "\ - -s WASM=1 \ - -s MODULARIZE=1 \ - -s EXPORT_ES6=1 \ - -s EXPORT_NAME='createMlipcpp' \ - -s ALLOW_MEMORY_GROWTH=1 \ - -s MAXIMUM_MEMORY=4GB \ - -s STACK_SIZE=1MB \ - -s SINGLE_FILE=1 \ - -s EXPORTED_RUNTIME_METHODS=['FS','cwrap','ccall'] \ - -s FORCE_FILESYSTEM=1 \ - --bind \ - -O3 \ - " + LINK_FLAGS "${MLIPCPP_WASM_LINK_FLAGS}" ) # Install WASM output diff --git a/README.md b/README.md index 21d3dfd..00c350a 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,86 @@ Standalone C++ implementation of Machine Learning Interatomic Potentials (MLIPs) using [ggml](https://github.com/ggml-org/ggml). -Currently supports [PET-MAD](https://github.com/lab-cosmo/pet-mad) for energies, forces, stresses +Currently supports [PET/uPET](https://github.com/lab-cosmo/pet-mad) models (energy, forces, stresses). -## Dependencies +## Quick start (Python) + +```bash +# Install the package +pip install . + +# Download and convert a model to GGUF +uv run scripts/convert_models.py --models pet-mad-s +``` + +```python +import numpy as np +import mlipcpp + +# Load a model +model = mlipcpp.Predictor("gguf/pet-mad-s.gguf") +print(f"Model type: {model.model_type}, cutoff: {model.cutoff} A") + +# Water molecule +positions = np.array([ + [0.000, 0.000, 0.000], # O + [0.757, 0.586, 0.000], # H + [-0.757, 0.586, 0.000], # H +], dtype=np.float32) +atomic_numbers = np.array([8, 1, 1], dtype=np.int32) + +# Predict energy +result = model.predict(positions, atomic_numbers, compute_forces=False) +print(f"Energy: {result.energy:.4f} eV") +# => Energy: -14.3693 eV + +# Predict energy + forces +result = model.predict(positions, atomic_numbers, compute_forces=True) +print(f"Energy: {result.energy:.4f} eV") +forces = np.array(result.forces).reshape(-1, 3) +print(f"Forces (eV/A):\n{forces}") +``` + +### ASE integration + +```python +from ase.io import read +from mlipcpp.ase import MLIPCalculator + +atoms = read("structure.xyz") +atoms.calc = MLIPCalculator("gguf/pet-mad-s.gguf") +print(f"Energy: {atoms.get_potential_energy():.4f} eV") +``` + +## Converting models + +Download and convert uPET models from HuggingFace to GGUF format: + +```bash +# Convert all available models +uv run scripts/convert_models.py + +# Convert a specific model +uv run scripts/convert_models.py --models pet-mad-s + +# List available models +uv run scripts/convert_models.py --list +``` + +Default models: `pet-mad-s`, `pet-oam-l`, `pet-omad-xs`, `pet-omad-s`, `pet-omat-xs`, `pet-omat-s`, `pet-spice-s` + +Use `--all` to also convert larger variants: `pet-oam-xl`, `pet-omad-l`, `pet-omat-m`, `pet-omat-l`, `pet-omat-xl`, `pet-omatpes-l`, `pet-spice-l` + +## Building from source + +### Dependencies - [ggml](https://github.com/ggml-org/ggml) - Tensor library (fetched automatically via CMake) - [fmt](https://github.com/fmtlib/fmt) - Formatting library (fetched automatically) **Note:** This project uses a [modified fork of ggml](https://github.com/peterspackman/ggml) with additional backpropagation support for `CONCAT` and `CLAMP` operations, required for force/stress computation. -## Building +### Build ```bash mkdir build && cd build @@ -22,30 +92,22 @@ cmake .. -DCMAKE_BUILD_TYPE=Release cmake --build . -j ``` -## Converting PET-MAD weights - -Download and convert the official PET-MAD model to GGUF format: - -```bash -uv run scripts/convert_pet_mad.py --output pet-mad.gguf -``` - -## Usage +### C++ CLI ```bash # Energy only -./build/bin/simple_inference pet-mad.gguf structure.xyz +./build/bin/simple_inference gguf/pet-mad-s.gguf structure.xyz # With forces -./build/bin/simple_inference pet-mad.gguf structure.xyz --forces +./build/bin/simple_inference gguf/pet-mad-s.gguf structure.xyz --forces # With forces and stress (periodic systems) -./build/bin/simple_inference pet-mad.gguf structure.xyz --forces --stress +./build/bin/simple_inference gguf/pet-mad-s.gguf structure.xyz --forces --stress ``` ## API -C, C++, and Fortran APIs are provided. See `examples/` for usage. +C, C++, Fortran, and Python APIs are provided. See `examples/` for usage. ## License diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 8a1f321..33698f9 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -11,6 +11,11 @@ add_executable(backend_benchmark backend_benchmark.cpp) target_link_libraries(backend_benchmark PRIVATE mlipcpp ggml fmt::fmt) target_include_directories(backend_benchmark PRIVATE ${PROJECT_SOURCE_DIR}/src) +# Graph-based inference using auto-exported models +add_executable(graph_inference ${PROJECT_SOURCE_DIR}/src/bin/graph_inference.cpp) +target_link_libraries(graph_inference PRIVATE mlipcpp ggml fmt::fmt nlohmann_json::nlohmann_json) +target_include_directories(graph_inference PRIVATE ${PROJECT_SOURCE_DIR}/src) + # Public API examples (only use public headers) add_executable(c_api_test c_api_test.c) target_link_libraries(c_api_test PRIVATE mlipcpp) diff --git a/examples/backend_benchmark.cpp b/examples/backend_benchmark.cpp index 34acc67..0b5394a 100644 --- a/examples/backend_benchmark.cpp +++ b/examples/backend_benchmark.cpp @@ -8,8 +8,13 @@ */ #include "../src/models/pet/pet.h" +#include "../src/runtime/graph_model.h" +#include "core/backend.h" +#include "core/gguf_loader.h" #include "core/log.h" +#include "mlipcpp/model.h" #include "mlipcpp/system.h" +#include #include #include #include @@ -74,6 +79,7 @@ int main(int argc, char **argv) { std::cerr << " --backend B Backend: auto, cpu, metal, cuda, etc. (default: auto)\n"; std::cerr << " --warmup N Warmup iterations (default: 2)\n"; std::cerr << " --iterations N Timed iterations (default: 10)\n"; + std::cerr << " --max-atoms N Cap supercell size (default: 1024)\n"; std::cerr << " --no-forces Benchmark energy only (no forces)\n"; std::cerr << " --nc-forces Use non-conservative forces (forward pass only)\n"; std::cerr << " --csv Output CSV format for scripting\n"; @@ -86,22 +92,10 @@ int main(int argc, char **argv) { bool compute_forces = true; bool compute_nc = false; bool csv_output = false; - pet::BackendPreference backend_pref = pet::BackendPreference::Auto; + int max_atoms = 1024; + BackendPreference backend_pref = BackendPreference::Auto; std::string backend_name = "auto"; - // Backend name lookup - static const std::unordered_map - backend_map = { - {"auto", pet::BackendPreference::Auto}, - {"cpu", pet::BackendPreference::CPU}, - {"cuda", pet::BackendPreference::CUDA}, - {"hip", pet::BackendPreference::HIP}, - {"metal", pet::BackendPreference::Metal}, - {"vulkan", pet::BackendPreference::Vulkan}, - {"sycl", pet::BackendPreference::SYCL}, - {"cann", pet::BackendPreference::CANN}, - }; - // Parse options for (int i = 2; i < argc; ++i) { std::string_view arg = argv[i]; @@ -116,41 +110,53 @@ int main(int argc, char **argv) { compute_forces = false; // nc-forces replaces gradient forces } else if (arg == "--csv") { csv_output = true; + } else if (arg == "--max-atoms" && i + 1 < argc) { + max_atoms = std::stoi(argv[++i]); } else if (arg == "--backend" && i + 1 < argc) { backend_name = argv[++i]; - auto it = backend_map.find(backend_name); - if (it != backend_map.end()) { - backend_pref = it->second; - } else { - std::cerr << "Unknown backend: " << backend_name << "\n"; + try { + backend_pref = parse_backend_preference(backend_name); + } catch (const std::exception &e) { + std::cerr << e.what() << "\n"; return 1; } } } - // System sizes to test (nx, ny, nz) -> 2 * nx * ny * nz atoms - std::vector> sizes = { - {1, 1, 1}, // 2 atoms - {2, 2, 2}, // 16 atoms - {4, 4, 2}, // 64 atoms - {4, 4, 4}, // 128 atoms - {4, 4, 8}, // 256 atoms - {4, 8, 8}, // 512 atoms - {8, 8, 8}, // 1024 atoms - {16, 8, 8}, // 2048 atoms - {16, 16, 8}, // 4096 atoms + // System sizes to test (nx, ny, nz) -> 2 * nx * ny * nz atoms. + // Filtered by --max-atoms. + std::vector> all_sizes = { + {1, 1, 1}, {2, 2, 2}, {4, 4, 2}, {4, 4, 4}, {4, 4, 8}, + {4, 8, 8}, {8, 8, 8}, {16, 8, 8}, {16, 16, 8}, }; + std::vector> sizes; + for (const auto &s : all_sizes) { + if (2 * s[0] * s[1] * s[2] <= max_atoms) sizes.push_back(s); + } - // Load model once - pet::PETHypers hypers; - pet::PETModel model(hypers); - - // Set backend preference BEFORE loading (backend is initialized during load) - model.set_backend_preference(backend_pref); - + // Load model via architecture dispatch + std::unique_ptr model; try { - if (!model.load_from_gguf(model_path)) { - std::cerr << "Failed to load model: " << model_path << "\n"; + GGUFLoader probe(model_path); + std::string arch = probe.get_string("general.architecture", ""); + if (arch == "pet") { + auto pm = std::make_unique(pet::PETHypers{}); + pm->set_backend_preference(backend_pref); + if (!pm->load_from_gguf(model_path)) { + std::cerr << "Failed to load PET model\n"; + return 1; + } + model = std::move(pm); + } else if (arch == "pet-graph") { + auto gm = std::make_unique(); + gm->set_backend_preference(backend_pref); + if (!gm->load_from_gguf(model_path)) { + std::cerr << "Failed to load graph model\n"; + return 1; + } + model = std::move(gm); + } else { + std::cerr << "Unsupported architecture: " << arch << "\n"; return 1; } } catch (const std::exception &e) { @@ -158,6 +164,12 @@ int main(int argc, char **argv) { return 1; } + auto *pet_model = dynamic_cast(model.get()); + if (compute_nc && !pet_model) { + std::cerr << "--nc-forces requires a PET model\n"; + return 1; + } + // Determine mode string std::string mode_str = "Energy"; if (compute_forces) { @@ -187,15 +199,19 @@ int main(int argc, char **argv) { // Warmup for (int i = 0; i < warmup; ++i) { - model.predict_batch({system}, compute_forces, compute_nc); + if (pet_model) pet_model->predict_batch({system}, compute_forces, compute_nc); + else model->predict(system, compute_forces); } // Timed runs auto start = std::chrono::high_resolution_clock::now(); ModelResult last_result; for (int i = 0; i < iterations; ++i) { - auto results = model.predict_batch({system}, compute_forces, compute_nc); - last_result = results[0]; + if (pet_model) { + last_result = pet_model->predict_batch({system}, compute_forces, compute_nc)[0]; + } else { + last_result = model->predict(system, compute_forces); + } } auto end = std::chrono::high_resolution_clock::now(); diff --git a/examples/python_ase.py b/examples/python_ase.py index 7a307cd..fd526f1 100644 --- a/examples/python_ase.py +++ b/examples/python_ase.py @@ -19,7 +19,7 @@ try: from ase import Atoms from ase.build import molecule, bulk - from ase.optimize import BFGS + from ase.optimize import LBFGSLineSearch except ImportError: print("ASE is required for this example. Install with: pip install ase") sys.exit(1) @@ -48,7 +48,8 @@ def example_molecule(model_path: str): # Optimize geometry print("\nOptimizing geometry...") - opt = BFGS(atoms, logfile=None) + # Line-search variant is generally more robust for graph-exported models. + opt = LBFGSLineSearch(atoms, logfile=None) opt.run(fmax=0.01) print(f"Final energy: {atoms.get_potential_energy():.6f} eV") diff --git a/examples/simple_inference.cpp b/examples/simple_inference.cpp index 9c32fdc..90f62f6 100644 --- a/examples/simple_inference.cpp +++ b/examples/simple_inference.cpp @@ -1,5 +1,9 @@ #include "../src/models/pet/pet.h" +#include "../src/runtime/graph_model.h" +#include "core/backend.h" +#include "core/gguf_loader.h" #include "core/log.h" +#include #include "mlipcpp/io.h" #include "mlipcpp/model.h" #include "mlipcpp/neighbor_list.h" @@ -75,22 +79,9 @@ int main(int argc, char **argv) { bool show_nc_stress = false; // Show non-conservative stress only bool quiet_mode = false; bool profile_mode = false; - pet::BackendPreference backend_pref = pet::BackendPreference::Auto; + BackendPreference backend_pref = BackendPreference::Auto; pet::ComputePrecision precision = pet::ComputePrecision::F32; - // Backend name lookup table - static const std::unordered_map - backend_map = { - {"auto", pet::BackendPreference::Auto}, - {"cpu", pet::BackendPreference::CPU}, - {"cuda", pet::BackendPreference::CUDA}, - {"hip", pet::BackendPreference::HIP}, - {"metal", pet::BackendPreference::Metal}, - {"vulkan", pet::BackendPreference::Vulkan}, - {"sycl", pet::BackendPreference::SYCL}, - {"cann", pet::BackendPreference::CANN}, - }; - static const std::unordered_map precision_map = { {"f32", pet::ComputePrecision::F32}, @@ -120,12 +111,10 @@ int main(int argc, char **argv) { } else if (arg == "--profile") { profile_mode = true; } else if (arg == "--backend" && i + 1 < argc) { - std::string_view backend_str = argv[++i]; - if (auto it = backend_map.find(backend_str); it != backend_map.end()) { - backend_pref = it->second; - } else { - std::cerr << "Unknown backend: " << backend_str - << " (use: auto, cpu, cuda, hip, metal, vulkan, sycl, cann)\n"; + try { + backend_pref = parse_backend_preference(argv[++i]); + } catch (const std::exception &e) { + std::cerr << e.what() << "\n"; return 1; } } else if (arg == "--precision" && i + 1 < argc) { @@ -191,54 +180,77 @@ int main(int argc, char **argv) { // Load model and run inference try { log::info("Loading model from {}", model_path); - Timer::instance().reset(); // Reset timers before loading and inference - - // Use PETModel directly for forces/stress support - pet::PETHypers hypers; - pet::PETModel pet_model(hypers); + Timer::instance().reset(); - // Set backend preference BEFORE loading (backend is initialized during load) - pet_model.set_backend_preference(backend_pref); - - if (!pet_model.load_from_gguf(model_path)) { - log::error("Failed to load model from {}", model_path); - return 1; + // Dispatch by architecture via load_model(); apply PET-only knobs only + // when the loaded model is a PETModel. + std::unique_ptr model; + { + GGUFLoader probe(model_path); + std::string arch = probe.get_string("general.architecture", ""); + if (arch == "pet") { + auto pm = std::make_unique(pet::PETHypers{}); + pm->set_backend_preference(backend_pref); + if (!pm->load_from_gguf(model_path)) { + log::error("Failed to load PET model"); + return 1; + } + model = std::move(pm); + } else if (arch == "pet-graph") { + auto gm = std::make_unique(); + gm->set_backend_preference(backend_pref); + if (!gm->load_from_gguf(model_path)) { + log::error("Failed to load graph model"); + return 1; + } + model = std::move(gm); + } else { + log::error("Unsupported architecture: {}", arch); + return 1; + } } - log::info("Model cutoff from GGUF: {:.2f} A", pet_model.cutoff()); + log::info("Model cutoff from GGUF: {:.2f} A", model->cutoff()); + + auto *pet_model = dynamic_cast(model.get()); - // Override cutoff if requested if (cutoff_override > 0.0f) { - pet_model.set_cutoff(cutoff_override); - log::info("Overriding cutoff to: {:.2f} A", cutoff_override); + if (pet_model) { + pet_model->set_cutoff(cutoff_override); + log::info("Overriding cutoff to: {:.2f} A", cutoff_override); + } else { + log::warn("--cutoff ignored (not a PET model)"); + } } - // Log neighbor count using model's cutoff { NeighborListBuilder nl_builder( - NeighborListOptions{pet_model.cutoff(), true, false}); + NeighborListOptions{model->cutoff(), true, false}); auto nlist = nl_builder.build(system); log::info("Neighbor pairs: {} (avg {:.1f} per atom)", nlist.num_pairs(), static_cast(nlist.num_pairs()) / system.num_atoms()); } - static constexpr std::array backend_names = {"auto", "cpu", "cuda", "hip", - "metal", "vulkan", "sycl", "cann"}; - log::info("Backend preference: {}", backend_names[static_cast(backend_pref)]); - - // Set compute precision - pet_model.set_precision(precision); - static constexpr std::array precision_names = {"f32", "f16"}; - log::info("Precision: {}", precision_names[static_cast(precision)]); - - // Set profiling mode - pet_model.set_profiling(profile_mode); + if (pet_model) { + pet_model->set_precision(precision); + static constexpr std::array precision_names = {"f32", "f16"}; + log::info("Precision: {}", precision_names[static_cast(precision)]); + pet_model->set_profiling(profile_mode); + } else if (precision != pet::ComputePrecision::F32 || profile_mode) { + log::warn("--precision/--profile ignored (not a PET model)"); + } log::info("Running inference..."); - // Use predict_batch for full control over compute_nc parameter bool compute_nc = show_nc_forces || show_nc_stress; - auto results = pet_model.predict_batch({system}, compute_forces, compute_nc); - auto result = results[0]; + ModelResult result; + if (pet_model) { + result = pet_model->predict_batch({system}, compute_forces, compute_nc)[0]; + } else { + if (compute_nc) { + log::warn("--nc-forces/--nc-stress ignored (not a PET model)"); + } + result = model->predict(system, compute_forces); + } // Print results if (quiet_mode) { diff --git a/geometries/si.xyz b/geometries/si.xyz index 7ef7746..1ce26e8 100644 --- a/geometries/si.xyz +++ b/geometries/si.xyz @@ -1,4 +1,4 @@ 2 -Lattice="5.43 0.0 0.0 0.0 5.43 0.0 0.0 0.0 5.43" Properties=species:S:1:pos:R:3:spacegroup_kinds:I:1 pbc="T T T" +Lattice="5.43 0.0 0.0 0.0 5.43 0.0 0.0 0.0 5.43" Properties=species:S:1:pos:R:3 pbc="T T T" Si 0.000000 0.000000 0.000000 Si 1.357500 1.357500 1.357500 diff --git a/gguf/LICENSE b/gguf/LICENSE new file mode 100644 index 0000000..36c5a10 --- /dev/null +++ b/gguf/LICENSE @@ -0,0 +1,30 @@ +BSD 3-Clause License + +Copyright (c) 2024, COSMO lab, EPFL. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/gguf/README.md b/gguf/README.md new file mode 100644 index 0000000..01487d4 --- /dev/null +++ b/gguf/README.md @@ -0,0 +1,53 @@ +--- +license: bsd-3-clause +tags: +- mlip +- machine-learning-potentials +- ggml +- gguf +--- + +# MLIP GGUFs + +GGUF-format conversions of the uPET family of machine-learning interatomic +potentials, for use with [mlip.cpp](https://github.com/peterspackman/mlip.cpp) +and [mlip.js](https://github.com/peterspackman/mlip.cpp/tree/main/packages/mlip.js). + +## Source + +Checkpoints converted from [`lab-cosmo/upet`](https://huggingface.co/lab-cosmo/upet) +(BSD-3-Clause). See the LICENSE file in this repo. + +## Usage + +```python +from huggingface_hub import hf_hub_download +path = hf_hub_download(repo_id="peterspackman/mlip-gguf", filename="pet-mad-s.gguf") +``` + +Or in the browser via `mlip.js`: + +```js +const buf = await fetch( + "https://huggingface.co/peterspackman/mlip-gguf/resolve/main/pet-mad-s.gguf" +).then(r => r.arrayBuffer()) +const model = await Model.loadFromBuffer(buf) +``` + +## Files + +- `pet-mad-s.gguf` (95.4 MB) +- `pet-mad-xs.gguf` (16.3 MB) +- `pet-oam-l.gguf` (721.9 MB) +- `pet-omad-s.gguf` (95.4 MB) +- `pet-omad-xs.gguf` (16.3 MB) +- `pet-omat-s.gguf` (95.4 MB) +- `pet-omat-xs.gguf` (16.3 MB) +- `pet-spice-s.gguf` (53.4 MB) + +## Conversion + +These files are produced by `scripts/convert_models.py` in the mlip.cpp repo, +which wraps `scripts/export_pytorch/export_pet_gguf.py` (an exact torch.export +of the PyTorch forward + backward graph into GGUF tensors + a graph interpreter +preamble). diff --git a/include/mlipcpp/mlipcpp.hpp b/include/mlipcpp/mlipcpp.hpp index ad81826..236b945 100644 --- a/include/mlipcpp/mlipcpp.hpp +++ b/include/mlipcpp/mlipcpp.hpp @@ -35,6 +35,7 @@ enum class Backend { HIP, ///< AMD HIP/ROCm GPU Metal, ///< Apple Metal GPU (macOS/iOS) Vulkan, ///< Vulkan GPU (cross-platform) + WebGPU, ///< WebGPU (Dawn native or browser) SYCL, ///< Intel SYCL (oneAPI) CANN, ///< Huawei Ascend NPU }; diff --git a/packages/mlip.js/examples/basic.html b/packages/mlip.js/examples/basic.html index 941edb6..59e2953 100644 --- a/packages/mlip.js/examples/basic.html +++ b/packages/mlip.js/examples/basic.html @@ -44,6 +44,16 @@

mlip.js Demo

+
+ + + +
+
@@ -77,9 +87,34 @@

mlip.js Demo

// Initialize mlip.js async function init() { try { + // Check WebGPU availability in the browser + const webgpuStatus = document.getElementById('webgpuStatus'); + if (navigator.gpu) { + try { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter) { + webgpuStatus.textContent = '✓ navigator.gpu adapter available'; + webgpuStatus.style.color = '#080'; + } else { + webgpuStatus.textContent = '✗ no WebGPU adapter'; + webgpuStatus.style.color = '#a00'; + } + } catch (e) { + webgpuStatus.textContent = '✗ adapter error: ' + e.message; + webgpuStatus.style.color = '#a00'; + } + } else { + webgpuStatus.textContent = '✗ navigator.gpu not present'; + webgpuStatus.style.color = '#a00'; + } + Module = await createMlip(); - status.textContent = `mlip.js loaded (version ${Module.getVersion()}). Please load a model file.`; - log(`mlip.js version: ${Module.getVersion()}`); + const ver = await Module.getVersion(); + status.textContent = `mlip.js loaded (version ${ver}). Please load a model file.`; + log(`mlip.js version: ${ver}`); + if (Module.getBackendName) { + log(`Initial backend: ${await Module.getBackendName()}`); + } } catch (err) { status.textContent = 'Failed to load mlip.js: ' + err.message; console.error(err); @@ -96,11 +131,17 @@

mlip.js Demo

try { const buffer = await file.arrayBuffer(); - model = Module.Model.loadFromBuffer(buffer); + const backend = document.getElementById('backendSelect').value; + if (Module.Model.loadFromBufferWithBackend) { + model = await Module.Model.loadFromBufferWithBackend(buffer, backend); + } else { + model = await Module.Model.loadFromBuffer(buffer); + } log(`Model loaded: ${file.name}`); - log(` Type: ${model.modelType()}`); - log(` Cutoff: ${model.cutoff().toFixed(2)} Å`); + log(` Backend: ${Module.getBackendName ? await Module.getBackendName() : '(unknown)'}`); + log(` Type: ${await model.modelType()}`); + log(` Cutoff: ${(await model.cutoff()).toFixed(2)} Å`); log(''); status.textContent = 'Model loaded! Click a button to predict.'; @@ -114,7 +155,7 @@

mlip.js Demo

}); // Predict water molecule - predictWaterBtn.addEventListener('click', () => { + predictWaterBtn.addEventListener('click', async () => { if (!model) return; clearOutput(); @@ -128,13 +169,13 @@

mlip.js Demo

]); const atomicNumbers = new Int32Array([8, 1, 1]); - const water = Module.AtomicSystem.create(positions, atomicNumbers, null, false); - log(`Atoms: ${water.numAtoms()}`); - log(`Periodic: ${water.isPeriodic()}`); + const water = await Module.AtomicSystem.create(positions, atomicNumbers, null, false); + log(`Atoms: ${await water.numAtoms()}`); + log(`Periodic: ${await water.isPeriodic()}`); log(''); const startTime = performance.now(); - const result = model.predict(water); + const result = await model.predict(water); const elapsed = performance.now() - startTime; log(`Energy: ${result.energy.toFixed(6)} eV`); @@ -152,7 +193,7 @@

mlip.js Demo

}); // Predict silicon crystal - predictSiliconBtn.addEventListener('click', () => { + predictSiliconBtn.addEventListener('click', async () => { if (!model) return; clearOutput(); @@ -173,15 +214,27 @@

mlip.js Demo

0, 0, a ]); - const silicon = Module.AtomicSystem.create(positions, atomicNumbers, cell, true); - log(`Atoms: ${silicon.numAtoms()}`); - log(`Periodic: ${silicon.isPeriodic()}`); + const silicon = await Module.AtomicSystem.create(positions, atomicNumbers, cell, true); + log(`Atoms: ${await silicon.numAtoms()}`); + log(`Periodic: ${await silicon.isPeriodic()}`); log(`Cell: ${a.toFixed(2)} × ${a.toFixed(2)} × ${a.toFixed(2)} Å`); log(''); const startTime = performance.now(); - const result = model.predict(silicon); + let result; + try { + result = await model.predict(silicon); + } catch (e) { + log('predict threw: ' + (e.message || e)); + console.error('silicon predict error', e); + return; + } const elapsed = performance.now() - startTime; + console.log('silicon result', result); + if (!result || typeof result.energy !== 'number') { + log('Unexpected result shape: ' + JSON.stringify(result)); + return; + } log(`Energy: ${result.energy.toFixed(6)} eV`); log(`Energy/atom: ${(result.energy/2).toFixed(6)} eV`); diff --git a/packages/mlip.js/scripts/build.js b/packages/mlip.js/scripts/build.js index d97a6c0..8a1b312 100644 --- a/packages/mlip.js/scripts/build.js +++ b/packages/mlip.js/scripts/build.js @@ -124,12 +124,15 @@ export interface Model { export interface ModelStatic { load(path: string): Model; loadFromBuffer(buffer: ArrayBuffer): Model; + loadFromBufferWithBackend(buffer: ArrayBuffer, backend: string): Model; } export interface MlipcppModule { AtomicSystem: AtomicSystemStatic; Model: ModelStatic; getVersion(): string; + getBackendName(): string; + setBackend(name: string): void; } declare function createMlipcpp(): Promise; diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/benchmark_graph_inference_forces.py b/scripts/benchmark_graph_inference_forces.py new file mode 100644 index 0000000..b18f487 --- /dev/null +++ b/scripts/benchmark_graph_inference_forces.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Benchmark graph_inference energy-only vs energy+forces compute times. + +Example: + ./.venv/bin/python scripts/benchmark_graph_inference_forces.py \ + --model /tmp/pet-oam-l-dyn.gguf \ + --structures geometries/si.xyz geometries/urea_molecule.xyz +""" + +from __future__ import annotations + +import argparse +import re +import statistics +import subprocess +from pathlib import Path + + +TIME_RE = re.compile(r"Compute time:\s*([0-9.]+)\s*ms") +NODES_RE = re.compile(r"Graph nodes \(forward\+backward\):\s*([0-9]+)") + + +def run_once(model: str, structure: str, forces: bool) -> tuple[float, int | None]: + cmd = ["./build/bin/graph_inference", model, structure] + if forces: + cmd.append("--forces") + out = subprocess.check_output(cmd, text=True) + + m = TIME_RE.search(out) + if m is None: + raise RuntimeError("Could not parse compute time from graph_inference output") + time_ms = float(m.group(1)) + + n = NODES_RE.search(out) + node_count = int(n.group(1)) if n else None + return time_ms, node_count + + +def benchmark_mode( + model: str, + structure: str, + forces: bool, + warmup: int, + runs: int, +) -> tuple[float, float, float, int | None]: + node_count = None + total = warmup + runs + samples: list[float] = [] + for i in range(total): + t_ms, nodes = run_once(model, structure, forces) + if nodes is not None: + node_count = nodes + if i >= warmup: + samples.append(t_ms) + return statistics.mean(samples), min(samples), max(samples), node_count + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--model", required=True, help="Path to .gguf model") + parser.add_argument( + "--structures", + nargs="+", + required=True, + help="One or more XYZ files", + ) + parser.add_argument("--warmup", type=int, default=1) + parser.add_argument("--runs", type=int, default=5) + args = parser.parse_args() + + model = str(Path(args.model)) + print("structure,mode,mean_ms,min_ms,max_ms,runs,forward_backward_nodes") + for structure in args.structures: + for forces in (False, True): + mode = "energy+forces" if forces else "energy" + mean_ms, min_ms, max_ms, nodes = benchmark_mode( + model=model, + structure=structure, + forces=forces, + warmup=args.warmup, + runs=args.runs, + ) + node_str = str(nodes) if nodes is not None else "" + print( + f"{structure},{mode},{mean_ms:.2f},{min_ms:.2f},{max_ms:.2f}," + f"{args.runs},{node_str}" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/benchmark_pet_full_torch_compile.py b/scripts/benchmark_pet_full_torch_compile.py new file mode 100644 index 0000000..0ebac5a --- /dev/null +++ b/scripts/benchmark_pet_full_torch_compile.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Benchmark PETFullModel eager vs torch.compile on CPU/CUDA/MPS. + +Examples: + ./.venv/bin/python scripts/benchmark_pet_full_torch_compile.py --model pet-mad-s --device cpu + ./.venv/bin/python scripts/benchmark_pet_full_torch_compile.py --model pet-mad-s --device mps --compile + ./.venv/bin/python scripts/benchmark_pet_full_torch_compile.py --model pet-mad-s --forces --with-backward --compile +""" + +from __future__ import annotations + +import argparse +import statistics +import time + +import torch +import ase.io +from ase.neighborlist import neighbor_list + +from export_pytorch.export_pet_full import ( + PETFullModel, + build_example_inputs, + get_model_params, + load_pet_model, +) + + +def synchronize(device: str) -> None: + if device == "cuda": + torch.cuda.synchronize() + elif device == "mps" and torch.backends.mps.is_available(): + torch.mps.synchronize() + + +def move_inputs_to_device( + inputs: tuple[torch.Tensor, ...], device: str +) -> tuple[torch.Tensor, ...]: + return tuple(t.to(device) for t in inputs) + + +def run_once( + model: torch.nn.Module, + base_inputs: tuple[torch.Tensor, ...], + with_backward: bool, + device: str, +) -> tuple[float, float]: + synchronize(device) + t0 = time.perf_counter() + + if with_backward: + inputs = list(base_inputs) + edge_vectors = inputs[2].detach().clone().requires_grad_(True) + inputs[2] = edge_vectors + + output = model(*inputs) + total_energy = output.sum() + grad = torch.autograd.grad(total_energy, edge_vectors, create_graph=False)[0] + checksum = float(total_energy.detach().item() + grad.abs().sum().detach().item()) + else: + with torch.no_grad(): + output = model(*base_inputs) + checksum = float(output.sum().detach().item()) + + synchronize(device) + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + return elapsed_ms, checksum + + +def benchmark( + model: torch.nn.Module, + base_inputs: tuple[torch.Tensor, ...], + with_backward: bool, + device: str, + warmup: int, + runs: int, +) -> tuple[float, float, float, float]: + samples: list[float] = [] + checksum = 0.0 + + for i in range(warmup + runs): + elapsed_ms, checksum = run_once( + model=model, + base_inputs=base_inputs, + with_backward=with_backward, + device=device, + ) + if i >= warmup: + samples.append(elapsed_ms) + + return statistics.mean(samples), min(samples), max(samples), checksum + + +def resolve_compile_backend(requested_backend: str, device: str) -> str: + if requested_backend != "auto": + return requested_backend + if device == "mps": + # MPS + inductor often falls back or fails; aot_eager is safer. + return "aot_eager" + return "inductor" + + +def validate_device(device: str) -> None: + if device == "cuda": + if not torch.cuda.is_available(): + raise RuntimeError("Requested --device cuda, but CUDA is not available.") + elif device == "mps": + if not torch.backends.mps.is_built(): + raise RuntimeError("Requested --device mps, but this PyTorch build has no MPS support.") + if not torch.backends.mps.is_available(): + raise RuntimeError("Requested --device mps, but MPS is not available on this machine/runtime.") + + +def infer_shape_from_structure(structure_path: str, cutoff: float) -> tuple[int, int]: + atoms = ase.io.read(structure_path) + n_atoms = len(atoms) + centers = neighbor_list("i", atoms, cutoff=cutoff, self_interaction=False) + + counts = [0] * n_atoms + for center in centers: + counts[int(center)] += 1 + max_neighbors = max(counts) if counts else 0 + return n_atoms, max_neighbors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark PETFullModel eager vs torch.compile") + parser.add_argument("--model", default="pet-mad-s", help="Model name, e.g. pet-mad-s") + parser.add_argument("--device", choices=["cpu", "cuda", "mps"], default="cpu") + parser.add_argument( + "--structure", + type=str, + default=None, + help="Optional structure file to infer example_n_atoms/example_max_neighbors from cutoff", + ) + parser.add_argument("--forces", action="store_true", help="Use forces-compatible wrapper mode") + parser.add_argument( + "--with-backward", + action="store_true", + help="Also measure backward pass (grad wrt edge_vectors); requires --forces", + ) + parser.add_argument("--example-n-atoms", type=int, default=None) + parser.add_argument("--example-max-neighbors", type=int, default=None) + parser.add_argument("--warmup", type=int, default=2) + parser.add_argument("--runs", type=int, default=10) + parser.add_argument("--compile", action="store_true", help="Enable torch.compile benchmark") + parser.add_argument( + "--compile-backend", + choices=["auto", "inductor", "aot_eager", "eager"], + default="auto", + help="torch.compile backend (default: auto)", + ) + parser.add_argument( + "--compile-mode", + choices=["default", "reduce-overhead", "max-autotune"], + default="reduce-overhead", + help="torch.compile mode", + ) + parser.add_argument("--fullgraph", action="store_true", help="Pass fullgraph=True to torch.compile") + args = parser.parse_args() + + if args.with_backward and not args.forces: + raise ValueError("--with-backward requires --forces (manual-attention backward path).") + + validate_device(args.device) + + print(f"Loading model: {args.model}") + pet = load_pet_model(args.model) + pet.eval() + params = get_model_params(pet) + cutoff = float(params["cutoff"]) + + inferred_n_atoms = None + inferred_max_neighbors = None + if args.structure is not None: + inferred_n_atoms, inferred_max_neighbors = infer_shape_from_structure( + structure_path=args.structure, + cutoff=cutoff, + ) + print( + f"Inferred from structure {args.structure}: " + f"n_atoms={inferred_n_atoms}, max_neighbors={inferred_max_neighbors} (cutoff={cutoff})" + ) + + example_n_atoms = ( + args.example_n_atoms if args.example_n_atoms is not None + else inferred_n_atoms if inferred_n_atoms is not None + else 32 + ) + example_max_neighbors = ( + args.example_max_neighbors if args.example_max_neighbors is not None + else inferred_max_neighbors if inferred_max_neighbors is not None + else 16 + ) + + wrapper = PETFullModel( + pet_model=pet, + n_atoms=example_n_atoms, + max_neighbors=example_max_neighbors, + d_pet=params["d_pet"], + forces=args.forces, + cutoff=cutoff, + cutoff_width=params["cutoff_width"], + cutoff_function=params["cutoff_function"], + ).to(args.device) + wrapper.eval() + + example_inputs, _ = build_example_inputs( + example_n_atoms=example_n_atoms, + example_max_neighbors=example_max_neighbors, + cutoff=cutoff, + forces=args.forces, + ) + example_inputs = move_inputs_to_device(example_inputs, args.device) + + mode = "energy+forces(backward)" if args.with_backward else "energy-only" + print( + f"Config: device={args.device}, mode={mode}, forces_wrapper={args.forces}, " + f"shape=({example_n_atoms}, {example_max_neighbors})" + ) + + eager_mean, eager_min, eager_max, eager_ck = benchmark( + model=wrapper, + base_inputs=example_inputs, + with_backward=args.with_backward, + device=args.device, + warmup=args.warmup, + runs=args.runs, + ) + print(f"Eager: mean={eager_mean:.2f} ms, min={eager_min:.2f}, max={eager_max:.2f}, checksum={eager_ck:.6f}") + + if args.compile: + backend = resolve_compile_backend(args.compile_backend, args.device) + print(f"Compiling with backend={backend}, mode={args.compile_mode}, fullgraph={args.fullgraph}") + compiled = torch.compile( + wrapper, + backend=backend, + mode=args.compile_mode, + fullgraph=args.fullgraph, + ) + comp_mean, comp_min, comp_max, comp_ck = benchmark( + model=compiled, + base_inputs=example_inputs, + with_backward=args.with_backward, + device=args.device, + warmup=args.warmup, + runs=args.runs, + ) + speedup = eager_mean / comp_mean if comp_mean > 0 else float("inf") + print(f"Compiled: mean={comp_mean:.2f} ms, min={comp_min:.2f}, max={comp_max:.2f}, checksum={comp_ck:.6f}") + print(f"Speedup (compiled/eager): {speedup:.2f}x") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_wasm.sh b/scripts/build_wasm.sh index da87954..c7e76ab 100755 --- a/scripts/build_wasm.sh +++ b/scripts/build_wasm.sh @@ -3,11 +3,31 @@ set -e # Build mlipcpp for WebAssembly using Emscripten # Requires: Emscripten SDK installed and activated (source emsdk_env.sh) +# +# Options: +# --webgpu Enable WebGPU backend via emdawnwebgpu +# --asyncify Use ASYNCIFY instead of JSPI (broader browser compat, slower) BUILD_DIR="wasm" BUILD_TYPE="${BUILD_TYPE:-Release}" +USE_WEBGPU=OFF +USE_ASYNCIFY=OFF + +for arg in "$@"; do + case "$arg" in + --webgpu) USE_WEBGPU=ON ;; + --asyncify) USE_ASYNCIFY=ON ;; + -h|--help) + sed -n '4,10p' "$0" + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; + esac +done -# Check if emcmake is available if ! command -v emcmake &> /dev/null; then echo "Error: emcmake not found. Please install and activate Emscripten SDK:" echo " git clone https://github.com/emscripten-core/emsdk.git" @@ -18,21 +38,19 @@ fi echo "=== Building mlipcpp for WebAssembly ===" echo "Build directory: ${BUILD_DIR}" -echo "Build type: ${BUILD_TYPE}" +echo "Build type: ${BUILD_TYPE}" +echo "WebGPU: ${USE_WEBGPU}" +echo "Async strategy: $([ $USE_ASYNCIFY = ON ] && echo ASYNCIFY || echo JSPI)" -# Configure with CMake via emcmake emcmake cmake . -B"${BUILD_DIR}" \ -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DMLIPCPP_USE_WEBGPU=${USE_WEBGPU} \ + -DMLIPCPP_WASM_ASYNCIFY=${USE_ASYNCIFY} \ -GNinja -# Build the WASM target cmake --build "${BUILD_DIR}" --target mlipcpp_wasm echo "" echo "=== Build complete ===" echo "Output files:" echo " ${BUILD_DIR}/bin/mlipcpp_wasm.js" -echo "" -echo "To use in Node.js:" -echo " const createMlipcpp = require('./${BUILD_DIR}/bin/mlipcpp_wasm.js');" -echo " createMlipcpp().then(Module => { ... });" diff --git a/scripts/calc_energy_pytorch.py b/scripts/calc_energy_pytorch.py index 3a650e0..a7ca78e 100755 --- a/scripts/calc_energy_pytorch.py +++ b/scripts/calc_energy_pytorch.py @@ -4,17 +4,15 @@ # dependencies = [ # "ase>=3.22.0", # "torch>=2.0.0", -# "pet-mad", +# "upet", # ] # /// """ -Calculate energy, forces, and stress using PET-MAD PyTorch reference. - -Useful for validating mlipcpp results against the official implementation. +Calculate energy, forces, and stress using upet PyTorch models. Usage: - uv run scripts/calc_energy_pytorch.py structure.xyz - uv run scripts/calc_energy_pytorch.py structure.xyz --device cuda + uv run scripts/calc_energy_pytorch.py structure.xyz --model pet-mad-s + uv run scripts/calc_energy_pytorch.py structure.xyz --model pet-omad-s --device cuda """ import argparse @@ -26,14 +24,15 @@ def main(): parser = argparse.ArgumentParser( - description="Calculate energy using PET-MAD PyTorch" + description="Calculate energy using upet PyTorch models" ) parser.add_argument("structure", type=str, help="Input structure file (XYZ, CIF, etc.)") parser.add_argument( - "--device", type=str, default="cpu", help="Device: cpu or cuda (default: cpu)" + "--model", type=str, default="pet-mad-s", + help="Model name: pet-mad-s, pet-omad-s, pet-omat-l, etc." ) parser.add_argument( - "--version", type=str, default="latest", help="PET-MAD version (default: latest)" + "--device", type=str, default="cpu", help="Device: cpu or cuda (default: cpu)" ) parser.add_argument( "--no-forces", action="store_true", help="Skip force calculation" @@ -60,9 +59,10 @@ def main(): print(f" PBC: {atoms.pbc.tolist()}") print() - from pet_mad.calculator import PETMADCalculator + from upet.calculator import UPETCalculator - calculator = PETMADCalculator(version=args.version, device=args.device) + print(f"Model: {args.model}") + calculator = UPETCalculator(model=args.model, device=args.device) atoms.calc = calculator energy = atoms.get_potential_energy() @@ -74,7 +74,7 @@ def main(): forces = atoms.get_forces() print("Forces (eV/A):") for i, (symbol, force) in enumerate(zip(atoms.get_chemical_symbols(), forces)): - print(f" {i:3d} {symbol:2s}: [{force[0]:12.6f}, {force[1]:12.6f}, {force[2]:12.6f}]") + print(f" Atom {i:3d} ({symbol:2s}): [{force[0]:12.6f}, {force[1]:12.6f}, {force[2]:12.6f}]") print() if not args.no_stress and all(atoms.pbc): diff --git a/scripts/convert_models.py b/scripts/convert_models.py new file mode 100644 index 0000000..3d8fb85 --- /dev/null +++ b/scripts/convert_models.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Batch convert uPET models to GGUF format. + +Wraps export_pet_gguf.py for each model, producing GGUF files +suitable for use with mlipcpp's GraphModel / Predictor API. + +Usage: + uv run scripts/convert_models.py # Convert default (small) models + uv run scripts/convert_models.py --all # Convert all models incl. large/xl + uv run scripts/convert_models.py --models pet-omat-xl # Convert specific model(s) + uv run scripts/convert_models.py --list # List available models + uv run scripts/convert_models.py --force # Re-convert existing files +""" + +import argparse +import subprocess +import sys +import time +from pathlib import Path + +# Default models converted by `convert_models.py` (no flags) +DEFAULT_MODELS = [ + "pet-mad-xs", + "pet-mad-s", + "pet-oam-l", + "pet-omad-xs", + "pet-omad-s", + "pet-omat-xs", + "pet-omat-s", + "pet-spice-s", +] + +# All available uPET models (including large/xl variants) +ALL_MODELS = DEFAULT_MODELS + [ + "pet-oam-xl", + "pet-omad-l", + "pet-omat-m", + "pet-omat-l", + "pet-omat-xl", + "pet-omatpes-l", + "pet-spice-l", +] + +EXPORT_SCRIPT = Path(__file__).parent / "export_pytorch" / "export_pet_gguf.py" + + +def convert_model( + model_name: str, + output_dir: Path, + n_atoms: int = 7, + max_neighbors: int = 11, +) -> bool: + """Convert a single model to GGUF format. + + Returns True on success, False on failure. + """ + output_path = output_dir / f"{model_name}.gguf" + + cmd = [ + sys.executable, + str(EXPORT_SCRIPT), + "--model", model_name, + "--output", str(output_path), + "--n-atoms", str(n_atoms), + "--max-neighbors", str(max_neighbors), + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + print(f" FAILED: {model_name}") + # Show last few lines of stderr for diagnosis + stderr_lines = result.stderr.strip().split("\n") + for line in stderr_lines[-5:]: + print(f" {line}") + return False + + if output_path.exists(): + size_mb = output_path.stat().st_size / (1024 * 1024) + print(f" OK: {output_path.name} ({size_mb:.1f} MB)") + return True + + print(f" FAILED: output file not created") + return False + + +def main(): + parser = argparse.ArgumentParser( + description="Batch convert uPET models to GGUF format" + ) + parser.add_argument( + "--models", nargs="+", default=None, + help="Specific models to convert (default: small/xs/s variants)", + ) + parser.add_argument( + "--all", action="store_true", + help="Convert all models including large/xl variants", + ) + parser.add_argument( + "--output-dir", "-o", type=str, default="gguf", + help="Output directory for GGUF files (default: gguf/)", + ) + parser.add_argument( + "--force", action="store_true", + help="Re-convert even if GGUF file already exists", + ) + parser.add_argument( + "--list", action="store_true", + help="List available models and exit", + ) + parser.add_argument( + "--n-atoms", type=int, default=7, + help="Export atoms dimension (default: 7)", + ) + parser.add_argument( + "--max-neighbors", type=int, default=11, + help="Export neighbors dimension (default: 11)", + ) + args = parser.parse_args() + + if args.list: + print("Default models:") + for m in DEFAULT_MODELS: + print(f" {m}") + print("\nAdditional models (use --all or --models):") + for m in ALL_MODELS: + if m not in DEFAULT_MODELS: + print(f" {m}") + return + + if args.models: + models = args.models + elif args.all: + models = ALL_MODELS + else: + models = DEFAULT_MODELS + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Validate model names + for m in models: + if m not in ALL_MODELS: + print(f"Warning: '{m}' not in known models list, attempting anyway") + + # Build list of conversions, skip already-converted unless --force + conversions = [] + for model_name in models: + output_path = output_dir / f"{model_name}.gguf" + if not args.force and output_path.exists(): + size_mb = output_path.stat().st_size / (1024 * 1024) + print(f" SKIP: {output_path.name} already exists ({size_mb:.1f} MB)") + else: + conversions.append(model_name) + + if not conversions: + print("Nothing to convert.") + return + + print(f"\nConverting {len(conversions)} model(s) to {output_dir}/\n") + + t0 = time.time() + success = 0 + failed = 0 + + for i, model_name in enumerate(conversions): + print(f"[{i+1}/{len(conversions)}] {model_name}...") + if convert_model(model_name, output_dir, + args.n_atoms, args.max_neighbors): + success += 1 + else: + failed += 1 + + elapsed = time.time() - t0 + print(f"\nDone in {elapsed:.1f}s: {success} succeeded, {failed} failed") + + # Summary of output files + if success > 0: + print(f"\nOutput files in {output_dir}/:") + total_size = 0 + for f in sorted(output_dir.glob("*.gguf")): + size = f.stat().st_size + total_size += size + print(f" {f.name:30s} {size / (1024*1024):6.1f} MB") + print(f" {'Total:':30s} {total_size / (1024*1024):6.1f} MB") + + +if __name__ == "__main__": + main() diff --git a/scripts/export_pytorch/__init__.py b/scripts/export_pytorch/__init__.py new file mode 100644 index 0000000..0aa50ec --- /dev/null +++ b/scripts/export_pytorch/__init__.py @@ -0,0 +1,15 @@ +""" +PyTorch to GGML automatic model export. + +This package provides tools for automatically converting PyTorch models +to GGML format using torch.export/torch.fx graph tracing. +""" + +from .graph_ir import GGMLGraph, GGMLNode, GGMLInput, GGMLOutput + +__all__ = [ + "GGMLGraph", + "GGMLNode", + "GGMLInput", + "GGMLOutput", +] diff --git a/scripts/export_pytorch/export_pet_full.py b/scripts/export_pytorch/export_pet_full.py new file mode 100644 index 0000000..859c24e --- /dev/null +++ b/scripts/export_pytorch/export_pet_full.py @@ -0,0 +1,957 @@ +#!/usr/bin/env python3 +"""Export PET models (pet-mad, upet) with neighbor list inputs to GIR format. + +Supports: +- Legacy pet-mad-1.0.2 via pet_mad package +- Any upet model (pet-mad-s, pet-omat-l, pet-spice-s, etc.) via metatrain + +Usage: + # Legacy pet-mad + uv run scripts/export_pytorch/export_pet_full.py --model pet-mad-1.0.2 -o /tmp/pet_export + + # upet models + uv run scripts/export_pytorch/export_pet_full.py --model pet-mad-s -o /tmp/pet_mad_s_export + + # With forces (manual attention, in-graph distance/cutoff) + uv run scripts/export_pytorch/export_pet_full.py --model pet-mad-s --forces -o /tmp/pet_forces +""" + +import json +import math +import argparse +import re +import torch +import numpy as np +import warnings +from pathlib import Path +import sys +from packaging.version import Version +from typing import Dict, List, Tuple + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from export_pytorch.fx_converter import export_torch_model, symbolize_dimensions + + +# --- Model Loading --- + +def resolve_upet_checkpoint_name(model_base: str, size: str) -> str: + """Resolve checkpoint filename for a upet model across API versions.""" + from huggingface_hub import list_repo_files + + version = None + try: + from upet._models import upet_get_version_to_load as _resolve_version # type: ignore + version = _resolve_version(model_base, size) + except Exception: + try: + # Compatibility with older naming on some installations. + from upet._models import get_version_to_load as _resolve_version # type: ignore + version = _resolve_version(model_base, size) + except Exception: + version = None + + if version is not None: + return f"{model_base}-{size}-v{version}.ckpt" + + pattern = re.compile(rf"^models/{re.escape(model_base)}-{re.escape(size)}-v(.+)\.ckpt$") + candidates = [] + for path in list_repo_files(repo_id="lab-cosmo/upet"): + match = pattern.match(path) + if match: + try: + candidates.append((Version(match.group(1)), path.split("/", 1)[1])) + except Exception: + continue + + if not candidates: + raise RuntimeError( + f"Could not resolve checkpoint for {model_base}-{size} " + "from upet API or Hugging Face file listing." + ) + candidates.sort(key=lambda item: item[0]) + return candidates[-1][1] + + +def load_pet_model(model_name: str): + """Load a raw PET model by name. + + Args: + model_name: One of: + - "pet-mad-1.0.2": Legacy pet-mad package + - "pet-xxx-{size}": upet model (e.g., "pet-mad-s", "pet-omat-l") + + Returns: + The raw PET model (with gnn_layers, node_embedders, etc.) + """ + if model_name == "pet-mad-1.0.2": + from pet_mad._models import get_pet_mad + atomistic = get_pet_mad(version="1.0.2") + return atomistic.module.model + + # Parse upet model name: "pet-xxx-{size}" -> model="pet-xxx", size="{size}" + valid_sizes = {"xs", "s", "m", "l", "xl"} + parts = model_name.rsplit("-", 1) + if len(parts) != 2 or parts[1] not in valid_sizes: + raise ValueError( + f"Invalid model name '{model_name}'. Expected format: " + f"'pet-xxx-size' where size is one of {valid_sizes}, " + f"or 'pet-mad-1.0.2' for legacy." + ) + model_base, size = parts[0], parts[1] + + from huggingface_hub import hf_hub_download + from metatrain.utils.io import load_model as load_metatrain_model + + path = None + model_string = None + try: + model_string = resolve_upet_checkpoint_name(model_base, size) + print(f"Downloading {model_string} from HuggingFace...") + path = hf_hub_download( + repo_id="lab-cosmo/upet", + filename=model_string, + subfolder="models", + ) + except Exception as e: + # Offline/cached fallback: resolve latest matching checkpoint from local HF cache. + cache_root = Path.home() / ".cache" / "huggingface" / "hub" / "models--lab-cosmo--upet" / "snapshots" + pattern = f"{model_base}-{size}-v*.ckpt" + candidates = sorted(cache_root.glob(f"*/models/{pattern}")) + if not candidates: + raise RuntimeError( + f"Failed to resolve {model_base}-{size} from HuggingFace and no cached " + f"checkpoint found matching {pattern} under {cache_root}" + ) from e + + def _ver_key(p: Path): + stem = p.stem # pet-oam-l-v0.1.0 + v = stem.rsplit("-v", 1)[-1] + try: + return Version(v) + except Exception: + return Version("0") + + path_obj = max(candidates, key=_ver_key) + model_string = path_obj.name + path = str(path_obj) + print(f"Using cached checkpoint {model_string} at {path}") + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + pet_model = load_metatrain_model(path) + + return _unwrap_to_pet(pet_model) + + +def _unwrap_to_pet(model): + """Peel metatrain wrappers (LLPRUncertaintyModel, etc.) until we reach the + raw PET module that exposes .gnn_layers. + + Newer metatrain checkpoints ship with uncertainty-quantification wrappers; + the checkpoints themselves used to hand back the bare PET. Try common + attribute names first, then fall back to scanning nn.Module children.""" + if hasattr(model, 'gnn_layers'): + return model + + candidates = ('model', 'module', 'pet', 'base_model', 'inner_model', + 'last_layer_features_model', 'backbone') + for attr in candidates: + inner = getattr(model, attr, None) + if inner is None: + continue + try: + unwrapped = _unwrap_to_pet(inner) + except AttributeError: + continue + if unwrapped is not None: + return unwrapped + + # Fall back: scan named children for any module that has .gnn_layers + # somewhere in its subtree. + import torch.nn as nn + if isinstance(model, nn.Module): + for _name, child in model.named_children(): + try: + unwrapped = _unwrap_to_pet(child) + except AttributeError: + continue + if unwrapped is not None: + return unwrapped + + raise AttributeError( + f"Could not find a PET module with .gnn_layers under {type(model).__name__}; " + f"tried attributes {candidates} and scanned child modules" + ) + + +def get_model_params(pet_model): + """Extract model parameters from a PET model (handles both old and new formats). + + Returns: + dict with keys: d_pet, cutoff, cutoff_width, cutoff_function, num_neighbors_adaptive + """ + # Metatrain PET caches these as direct attributes + if hasattr(pet_model, 'd_pet'): + return { + 'd_pet': pet_model.d_pet, + 'cutoff': getattr(pet_model, 'cutoff', 4.5), + 'cutoff_width': getattr(pet_model, 'cutoff_width', 0.2), + 'cutoff_function': getattr(pet_model, 'cutoff_function', 'Cosine').lower(), + 'num_neighbors_adaptive': getattr(pet_model, 'num_neighbors_adaptive', None), + } + + # Legacy pet-mad format + hypers = pet_model.hypers + if isinstance(hypers, dict): + return { + 'd_pet': hypers.get('d_pet', 256), + 'cutoff': hypers.get('cutoff', 4.5), + 'cutoff_width': hypers.get('cutoff_width', 0.2), + 'cutoff_function': 'cosine', + 'num_neighbors_adaptive': None, + } + + return { + 'd_pet': getattr(hypers, 'D_PET', 256), + 'cutoff': getattr(hypers, 'cutoff', 4.5), + 'cutoff_width': getattr(hypers, 'cutoff_width', 0.2), + 'cutoff_function': 'cosine', + 'num_neighbors_adaptive': None, + } + + +def get_species_mapping(pet_model): + """Get species-to-index mapping from a PET model.""" + # Metatrain PET stores atomic_types + if hasattr(pet_model, 'atomic_types'): + species_to_index = {} + for idx, Z in enumerate(pet_model.atomic_types): + species_to_index[int(Z)] = idx + return species_to_index + + # Default: atomic numbers 1-85 map to indices 0-84 + return {Z: Z - 1 for Z in range(1, 86)} + + +def get_composition_energies(pet_model): + """Extract composition energies from a PET model (if available).""" + composition_energies = {} + + # Legacy pet-mad format with additive_models + if hasattr(pet_model, 'additive_models') and len(pet_model.additive_models) > 0: + try: + comp_model = pet_model.additive_models[0] + if hasattr(comp_model, 'model'): + inner = comp_model.model + if hasattr(inner, 'weights') and 'energy' in inner.weights: + energy_weights = inner.weights['energy'] + block = energy_weights.block(0) + t2i = inner.type_to_index + for Z in range(1, 86): + idx = t2i[Z].item() + if idx >= 0 and idx < block.values.shape[0]: + composition_energies[Z] = float(block.values[idx, 0].item()) + except Exception as e: + print(f"Warning: could not extract composition energies: {e}") + + # Metatrain PET: composition energies are part of the training wrapper + # and may not be accessible from the raw model. The exported AtomisticModel + # includes them, but for our graph export we just skip them. + # Forces are unaffected by composition energies (they're constant per type). + + return composition_energies + + +def get_energy_scale(pet_model) -> float: + """Extract energy scale factor from a PET model's scaler (if available). + + The scaler multiplies raw model output to produce the final energy. + For models without a scaler, returns 1.0. + """ + if hasattr(pet_model, 'scaler'): + scaler = pet_model.scaler + if hasattr(scaler, 'model') and hasattr(scaler.model, 'scales'): + if 'energy' in scaler.model.scales: + scale_block = scaler.model.scales['energy'].block(0) + return float(scale_block.values.item()) + return 1.0 + + +# --- Model Wrapper --- + +class PETFullModel(torch.nn.Module): + """Full PET energy computation using actual GNN layers. + + Supports two featurization types: + - "residual" (pet-mad-s): Per-layer energy accumulation, multiple node embedders + - "feedforward" (pet-omad-s): combination_mlps between layers, final-only energy + + When forces=False (default): + Inputs: species, neighbor_species, edge_vectors, edge_distances, + padding_mask, reverse_neighbor_index, cutoff_factors + Uses flash attention (fast, no backward). + + When forces=True: + Inputs: species, neighbor_species, edge_vectors, + padding_mask, reverse_neighbor_index + Computes edge_distances and cutoff_factors in-graph (from edge_vectors). + Uses manual attention (supports backward pass for force computation). + + Output: + atomic_energies: [n_atoms] - per-atom energy predictions + """ + + def __init__(self, pet_model, n_atoms: int, max_neighbors: int, d_pet: int, + forces: bool = False, cutoff: float = 4.5, cutoff_width: float = 0.2, + cutoff_function: str = "cosine"): + super().__init__() + + # Store dimensions for tracing + self.n_atoms = n_atoms + self.max_neighbors = max_neighbors + self.d_pet = d_pet + self.forces = forces + self.cutoff = cutoff + self.cutoff_width = cutoff_width + self.cutoff_function = cutoff_function + + # Detect featurization type + self.featurizer_type = getattr(pet_model, 'featurizer_type', 'residual') + self.num_readout_layers = getattr(pet_model, 'num_readout_layers', len(pet_model.gnn_layers)) + + # Node embeddings + self.node_embedders = pet_model.node_embedders + + # Neighbor species embedding (top-level) + self.neighbor_embedder = pet_model.edge_embedder + + # GNN layers (CartesianTransformer) + self.gnn_layers = pet_model.gnn_layers + + # Feedforward-specific: combination MLPs and norms + if self.featurizer_type == 'feedforward': + self.combination_mlps = pet_model.combination_mlps + self.combination_norms = pet_model.combination_norms + + # Energy heads and final layers + # For residual: one per GNN layer + # For feedforward: one for final layer only (num_readout_layers=1) + self.node_energy_heads = pet_model.node_heads['energy'] + self.node_final_layers = torch.nn.ModuleList([ + pet_model.node_last_layers['energy'][i]['energy___0'] + for i in range(self.num_readout_layers) + ]) + + self.edge_energy_heads = pet_model.edge_heads['energy'] + self.edge_final_layers = torch.nn.ModuleList([ + pet_model.edge_last_layers['energy'][i]['energy___0'] + for i in range(self.num_readout_layers) + ]) + + def _compute_cutoff_factors(self, edge_distances, cutoff_values=None): + """Cutoff function computed in-graph for gradient flow. + + When cutoff_values is provided, uses per-pair cutoffs (for adaptive cutoff models). + Otherwise uses self.cutoff (global cutoff). + + Supports both cosine and bump cutoff functions. + """ + if cutoff_values is not None: + cutoff = cutoff_values + else: + cutoff = self.cutoff + + scaled = torch.clamp( + (edge_distances - (cutoff - self.cutoff_width)) / self.cutoff_width, + 0.0, 1.0 + ) + + if self.cutoff_function == "bump": + # Bump cutoff: 0.5 * (1 + tanh(1 / tan(pi * x))) + # Rewrite as: 0.5 * (1 + tanh(cos(pi*x) / sin(pi*x))) + # This avoids torch.tan which has no GGML equivalent. + # Clamp away from 0 and 1 to avoid singularities + scaled_safe = torch.clamp(scaled, min=1e-6, max=1.0 - 1e-6) + angle = torch.tensor(math.pi) * scaled_safe + return 0.5 * (1.0 + torch.tanh(torch.cos(angle) / torch.sin(angle))) + else: + # Cosine cutoff: 0.5 * (1 + cos(pi * x)) + return 0.5 * (1.0 + torch.cos(torch.tensor(math.pi) * scaled)) + + def forward(self, species, neighbor_species, edge_vectors, + *args): + """Forward pass with variable signature based on forces mode. + + When forces=False: args = (edge_distances, padding_mask, reverse_neighbor_index, cutoff_factors) + When forces=True: args = (padding_mask, reverse_neighbor_index, cutoff_values) + """ + if self.forces: + padding_mask = args[0] + reverse_neighbor_index = args[1] + cutoff_values = args[2] # per-pair cutoff radii [n_atoms, max_neighbors] + + # Compute distances from edge vectors (in-graph for gradient flow) + # Use explicit multiply instead of ** 2 to avoid POW op + edge_distances = torch.sqrt((edge_vectors * edge_vectors).sum(dim=-1)) + # Compute cutoff factors from distances and per-pair cutoffs + cutoff_factors = self._compute_cutoff_factors(edge_distances, cutoff_values) + else: + edge_distances = args[0] + padding_mask = args[1] + reverse_neighbor_index = args[2] + cutoff_factors = args[3] + + n_atoms = species.shape[0] + max_neighbors = neighbor_species.shape[1] + + # Initial neighbor species embeddings + neighbor_embeds_flat = self.neighbor_embedder(neighbor_species.flatten()) + input_messages = neighbor_embeds_flat.view(n_atoms, max_neighbors, self.d_pet) + + if self.featurizer_type == 'feedforward': + return self._forward_feedforward( + species, neighbor_species, edge_vectors, edge_distances, + padding_mask, reverse_neighbor_index, cutoff_factors, + input_messages, n_atoms, max_neighbors + ) + else: + return self._forward_residual( + species, neighbor_species, edge_vectors, edge_distances, + padding_mask, reverse_neighbor_index, cutoff_factors, + input_messages, n_atoms, max_neighbors + ) + + def _forward_residual(self, species, neighbor_species, edge_vectors, edge_distances, + padding_mask, reverse_neighbor_index, cutoff_factors, + input_messages, n_atoms, max_neighbors): + """Residual featurization: per-layer energy accumulation (pet-mad-s style).""" + # Initialize atomic energies accumulator + atomic_energies = species.new_zeros(n_atoms, dtype=torch.float32) + + # Process through GNN layers with per-layer energy readout + for gnn_idx, (node_embedder, gnn_layer) in enumerate( + zip(self.node_embedders, self.gnn_layers) + ): + # Get node embeddings for this layer + input_node_embeddings = node_embedder(species) + + # Run GNN layer + # Note: metatrain uses True=valid, False=padded convention + # Our wrapper uses True=padded, False=valid, so we invert here + output_node, output_edge = gnn_layer( + input_node_embeddings, + input_messages, + neighbor_species, + edge_vectors, + ~padding_mask, # Invert for metatrain convention + edge_distances, + cutoff_factors, + use_manual_attention=self.forces + ) + # Zero out padded edge positions (GNN may produce non-zero values) + output_edge = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(output_edge), + output_edge, + ) + + # Node energy readout + node_feat = self.node_energy_heads[gnn_idx](output_node) + node_e = self.node_final_layers[gnn_idx](node_feat) + + # Edge energy readout + edge_feat = self.edge_energy_heads[gnn_idx](output_edge) + edge_e = self.edge_final_layers[gnn_idx](edge_feat) + # Mask out padded edges and apply cutoff + # padding_mask: True=padded (invalid), False=valid + edge_e_masked = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(edge_e), # Zero out padded edges + edge_e, # Keep valid edges + ) + # Apply cutoff factors and sum over neighbors + edge_e_sum = (edge_e_masked.squeeze(-1) * cutoff_factors).sum(dim=1) + + # Accumulate both node and edge contributions + atomic_energies = atomic_energies + node_e.squeeze(-1) + edge_e_sum + + # Message passing: prepare input for next layer (simple average) + flat_output = output_edge.reshape( + n_atoms * max_neighbors, self.d_pet + ) + reversed_messages = flat_output[reverse_neighbor_index].reshape( + n_atoms, max_neighbors, self.d_pet + ) + # Zero out padded positions (reverse_idx for padded slots may point to valid edges) + reversed_messages = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(reversed_messages), + reversed_messages, + ) + input_messages = 0.5 * (input_messages + reversed_messages) + + return atomic_energies + + def _forward_feedforward(self, species, neighbor_species, edge_vectors, edge_distances, + padding_mask, reverse_neighbor_index, cutoff_factors, + input_messages, n_atoms, max_neighbors): + """Feedforward featurization: combination_mlps between layers, final-only energy (pet-omad-s style).""" + # Single node embedder used for all layers + input_node_embeddings = self.node_embedders[0](species) + + # Zero out padded positions in initial edge embeddings + input_messages = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(input_messages), + input_messages, + ) + + # Process through GNN layers with combination MLPs + for combination_norm, combination_mlp, gnn_layer in zip( + self.combination_norms, self.combination_mlps, self.gnn_layers + ): + # Note: metatrain uses True=valid, False=padded convention + output_node, output_edge = gnn_layer( + input_node_embeddings, + input_messages, + neighbor_species, + edge_vectors, + ~padding_mask, # Invert for metatrain convention + edge_distances, + cutoff_factors, + use_manual_attention=self.forces + ) + # Zero out padded edge positions (GNN may produce non-zero values) + output_edge = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(output_edge), + output_edge, + ) + + # Update node embeddings for next layer + input_node_embeddings = output_node + + # Message passing with combination MLPs + # Reverse the edge messages + flat_output = output_edge.reshape( + n_atoms * max_neighbors, self.d_pet + ) + new_input_messages = flat_output[reverse_neighbor_index].reshape( + n_atoms, max_neighbors, self.d_pet + ) + # Zero out padded positions (reverse_idx for padded slots may point to valid edges) + new_input_messages = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(new_input_messages), + new_input_messages, + ) + + # Concatenate forward and reversed, apply norm + MLP + concatenated = torch.cat([output_edge, new_input_messages], dim=-1) + # Residual connection: input + output + combination_mlp(norm(concat)) + # Zero out the update for padded positions (mlp(norm(zeros)) is non-zero due to bias) + update = output_edge + combination_mlp(combination_norm(concatenated)) + update = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(update), + update, + ) + input_messages = input_messages + update + + # Energy readout from final features only (num_readout_layers=1) + # Node energy + node_feat = self.node_energy_heads[0](input_node_embeddings) + node_e = self.node_final_layers[0](node_feat) + + # Edge energy + edge_feat = self.edge_energy_heads[0](input_messages) + edge_e = self.edge_final_layers[0](edge_feat) + + # Mask out padded edges and apply cutoff + # padding_mask: True=padded (invalid), False=valid + edge_e_masked = torch.where( + padding_mask.unsqueeze(-1), + torch.zeros_like(edge_e), # Zero out padded edges + edge_e, # Keep valid edges + ) + edge_e_sum = (edge_e_masked.squeeze(-1) * cutoff_factors).sum(dim=1) + + # Total atomic energies + atomic_energies = node_e.squeeze(-1) + edge_e_sum + return atomic_energies + + +def compute_reverse_neighbor_index(n_atoms: int, max_neighbors: int, + centers: list, neighbors: list) -> torch.Tensor: + """Compute the reverse neighbor index for message passing. + + For each edge (i -> j), find the index of the reverse edge (j -> i). + """ + # Build a lookup: (center, neighbor) -> flat_index + edge_to_idx = {} + for flat_idx, (c, n) in enumerate(zip(centers, neighbors)): + edge_to_idx[(c, n)] = flat_idx + + # For each edge, find its reverse + reverse_idx = torch.zeros(n_atoms * max_neighbors, dtype=torch.long) + + # Build per-atom neighbor slots + slot_counts = [0] * n_atoms + atom_neighbors = [[] for _ in range(n_atoms)] + for c, n in zip(centers, neighbors): + atom_neighbors[c].append(n) + + for atom_i in range(n_atoms): + for slot_j, neighbor_j in enumerate(atom_neighbors[atom_i]): + flat_idx = atom_i * max_neighbors + slot_j + # Find the reverse edge (neighbor_j -> atom_i) + reverse_key = (neighbor_j, atom_i) + if reverse_key in edge_to_idx: + # Find which slot atom_i is in for neighbor_j + for slot_k, n in enumerate(atom_neighbors[neighbor_j]): + if n == atom_i: + reverse_flat_idx = neighbor_j * max_neighbors + slot_k + reverse_idx[flat_idx] = reverse_flat_idx + break + + return reverse_idx + + +def build_example_inputs( + example_n_atoms: int, + example_max_neighbors: int, + cutoff: float, + forces: bool, +) -> Tuple[Tuple[torch.Tensor, ...], List[str]]: + """Build deterministic example inputs and input names for tracing/export.""" + torch.manual_seed(42) + species = torch.zeros(example_n_atoms, dtype=torch.long) + neighbor_species = torch.zeros(example_n_atoms, example_max_neighbors, dtype=torch.long) + edge_vectors = torch.randn(example_n_atoms, example_max_neighbors, 3) + padding_mask = torch.ones(example_n_atoms, example_max_neighbors, dtype=torch.bool) + reverse_neighbor_index = torch.arange(example_n_atoms * example_max_neighbors, dtype=torch.long) + + if forces: + cutoff_values = torch.full((example_n_atoms, example_max_neighbors), cutoff) + example_inputs = ( + species, + neighbor_species, + edge_vectors, + padding_mask, + reverse_neighbor_index, + cutoff_values, + ) + input_names = [ + "species", + "neighbor_species", + "edge_vectors", + "padding_mask", + "reverse_neighbor_index", + "cutoff_values", + ] + else: + edge_distances = torch.rand(example_n_atoms, example_max_neighbors) * 3.0 + cutoff_factors = torch.ones(example_n_atoms, example_max_neighbors) + example_inputs = ( + species, + neighbor_species, + edge_vectors, + edge_distances, + padding_mask, + reverse_neighbor_index, + cutoff_factors, + ) + input_names = [ + "species", + "neighbor_species", + "edge_vectors", + "edge_distances", + "padding_mask", + "reverse_neighbor_index", + "cutoff_factors", + ] + + return example_inputs, input_names + + +def save_weights(weights: Dict[str, torch.Tensor], output_dir: Path) -> None: + """Save all exported weights as float32 binary blobs.""" + print(f"\nSaving {len(weights)} weights...") + for name, tensor in weights.items(): + filepath = output_dir / f"{name}.bin" + tensor.detach().cpu().numpy().astype(np.float32).tofile(filepath) + + +def save_example_inputs( + input_names: List[str], + example_inputs: Tuple[torch.Tensor, ...], + output_dir: Path, +) -> None: + """Save tracing inputs in binary format used by local tooling.""" + for name, tensor in zip(input_names, example_inputs): + path = output_dir / f"input_{name}.bin" + if tensor.dtype in (torch.long, torch.int32, torch.int64): + tensor.cpu().numpy().astype(np.int32).tofile(path) + elif tensor.dtype == torch.bool: + tensor.cpu().numpy().astype(np.bool_).tofile(path) + else: + tensor.cpu().numpy().astype(np.float32).tofile(path) + + +def save_exported_program( + wrapper: torch.nn.Module, + example_inputs: Tuple[torch.Tensor, ...], + output_path: Path, +) -> None: + """Export and save a PyTorch ExportedProgram (.pt2).""" + print(f"\nSaving compiled exported program to {output_path} ...") + exported_program = torch.export.export(wrapper, example_inputs, strict=False) + torch.export.save(exported_program, str(output_path)) + print("Saved compiled exported program.") + + +def build_metadata( + example_n_atoms: int, + example_max_neighbors: int, + d_pet: int, + graph, + weights: Dict[str, torch.Tensor], + expected_output: torch.Tensor, + cutoff: float, + cutoff_width: float, + cutoff_function: str, + num_neighbors_adaptive, + forces: bool, + model_name: str, + featurizer_type: str, + num_gnn_layers: int, + num_readout_layers: int, + species_to_index: Dict[int, int], + composition_energies: Dict[int, float], + energy_scale: float, +) -> Dict: + """Assemble metadata payload persisted to metadata.json.""" + return { + "example_n_atoms": example_n_atoms, + "example_max_neighbors": example_max_neighbors, + # Backward-compatible aliases for existing tooling. + "n_atoms": example_n_atoms, + "max_neighbors": example_max_neighbors, + "d_pet": d_pet, + "num_nodes": len(graph.nodes), + "num_weights": len(weights), + "expected_total_energy": expected_output.sum().item(), + "cutoff": float(cutoff), + "cutoff_width": float(cutoff_width), + "cutoff_function": cutoff_function, + "num_neighbors_adaptive": float(num_neighbors_adaptive) if num_neighbors_adaptive is not None else None, + "forces": forces, + "model_name": model_name, + "featurizer_type": featurizer_type, + "num_gnn_layers": num_gnn_layers, + "num_readout_layers": num_readout_layers, + "species_to_index": species_to_index, + "composition_energies": composition_energies, + "energy_scale": energy_scale, + "weights": {name: list(t.shape) for name, t in weights.items()}, + } + + +# --- Export --- + +def export_pet_full( + output_dir: Path = Path("/tmp/pet_full_export"), + example_n_atoms: int = 7, + example_max_neighbors: int = 11, + model_name: str = "pet-mad-1.0.2", + forces: bool = False, + save_compiled: bool = False, + compiled_filename: str = "pet_full_exported.pt2", +): + """Export full PET computation path with neighbor list inputs. + + Args: + output_dir: Directory for output files + example_n_atoms: Example atom count used only for tracing/export + example_max_neighbors: Example max neighbors used only for tracing/export + model_name: Model identifier (see load_pet_model docstring) + forces: If True, export with manual attention and in-graph distance/cutoff + save_compiled: If True, save a compiled torch.export program (.pt2) + compiled_filename: File name for the compiled export artifact + """ + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Loading PET model: {model_name}...") + pet = load_pet_model(model_name) + pet.eval() + + params = get_model_params(pet) + d_pet = params['d_pet'] + cutoff = params['cutoff'] + cutoff_width = params['cutoff_width'] + cutoff_function = params['cutoff_function'] + num_neighbors_adaptive = params['num_neighbors_adaptive'] + + featurizer_type = getattr(pet, 'featurizer_type', 'residual') + num_gnn_layers = len(pet.gnn_layers) + num_readout_layers = getattr(pet, 'num_readout_layers', num_gnn_layers) + + print(f"d_pet: {d_pet}, cutoff: {cutoff}, cutoff_width: {cutoff_width}") + print(f"cutoff_function: {cutoff_function}, num_neighbors_adaptive: {num_neighbors_adaptive}") + print(f"featurizer_type: {featurizer_type}, gnn_layers: {num_gnn_layers}, readout_layers: {num_readout_layers}") + print(f"example_n_atoms: {example_n_atoms}, example_max_neighbors: {example_max_neighbors}") + print(f"forces: {forces}") + + # Create wrapper using actual GNN layers + wrapper = PETFullModel( + pet, n_atoms=example_n_atoms, max_neighbors=example_max_neighbors, d_pet=d_pet, + forces=forces, cutoff=cutoff, cutoff_width=cutoff_width, + cutoff_function=cutoff_function + ) + wrapper.eval() + + # Create deterministic tracing inputs. + example_inputs, input_names = build_example_inputs( + example_n_atoms=example_n_atoms, + example_max_neighbors=example_max_neighbors, + cutoff=cutoff, + forces=forces, + ) + + # Run forward pass + print("\nRunning forward pass...") + with torch.no_grad(): + expected_output = wrapper(*example_inputs) + + print(f"Output shape: {expected_output.shape}") + print(f"Atomic energies: {expected_output}") + print(f"Total energy: {expected_output.sum().item():.6f}") + + # Export via torch.export + print("\nExporting via torch.export...") + input_dtypes = { + "species": "i32", + "neighbor_species": "i32", + "reverse_neighbor_index": "i32", + } + + graph, weights = export_torch_model( + wrapper, + example_inputs, + output_dir / "pet_full.json", + input_names=input_names, + input_dtypes=input_dtypes, + strict=False, + ) + + # Symbolize dynamic dimensions + print("\nSymbolizing dimensions...") + model_constants = {1, 3, 4, 8, 32, 128, 256, 512, 768, d_pet} + protected = model_constants - { + example_n_atoms, + example_max_neighbors, + example_n_atoms * example_max_neighbors, + example_max_neighbors + 1, + example_n_atoms * (example_max_neighbors + 1), + } + graph = symbolize_dimensions( + graph, + {"n_atoms": example_n_atoms, "max_neighbors": example_max_neighbors}, + protected_values=protected, + ) + + # Re-save with symbolized dimensions + with open(output_dir / "pet_full.json", "w") as f: + json.dump(graph.to_dict(), f, indent=2) + print("Saved symbolized graph with dynamic dimensions") + + save_weights(weights=weights, output_dir=output_dir) + save_example_inputs( + input_names=input_names, + example_inputs=example_inputs, + output_dir=output_dir, + ) + expected_output.numpy().astype(np.float32).tofile(output_dir / "expected_output.bin") + + if save_compiled: + save_exported_program( + wrapper=wrapper, + example_inputs=example_inputs, + output_path=output_dir / compiled_filename, + ) + + # Get species mapping, composition energies, and scale factor + species_to_index = get_species_mapping(pet) + composition_energies = get_composition_energies(pet) + energy_scale = get_energy_scale(pet) + print(f"Energy scale factor: {energy_scale}") + + metadata = build_metadata( + example_n_atoms=example_n_atoms, + example_max_neighbors=example_max_neighbors, + d_pet=d_pet, + graph=graph, + weights=weights, + expected_output=expected_output, + cutoff=cutoff, + cutoff_width=cutoff_width, + cutoff_function=cutoff_function, + num_neighbors_adaptive=num_neighbors_adaptive, + forces=forces, + model_name=model_name, + featurizer_type=featurizer_type, + num_gnn_layers=num_gnn_layers, + num_readout_layers=num_readout_layers, + species_to_index=species_to_index, + composition_energies=composition_energies, + energy_scale=energy_scale, + ) + with open(output_dir / "metadata.json", "w") as f: + json.dump(metadata, f, indent=2) + + print(f"\nAll files saved to {output_dir}") + print(f"Graph: {len(graph.nodes)} nodes") + return graph, weights + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Export PET model to GIR format") + parser.add_argument("--output", "-o", type=str, default="/tmp/pet_full_export", + help="Output directory") + parser.add_argument("--model", type=str, default="pet-mad-1.0.2", + help="Model name: 'pet-mad-1.0.2' (legacy) or upet name like 'pet-mad-s'") + parser.add_argument("--forces", action="store_true", + help="Export with forces support (manual attention, in-graph distance/cutoff)") + parser.add_argument("--example-n-atoms", type=int, default=7, + help="Example atom count used only for tracing/export") + parser.add_argument("--example-max-neighbors", type=int, default=11, + help="Example max neighbors used only for tracing/export") + parser.add_argument("--n-atoms", dest="deprecated_n_atoms", type=int, default=None, + help=argparse.SUPPRESS) + parser.add_argument("--max-neighbors", dest="deprecated_max_neighbors", type=int, default=None, + help=argparse.SUPPRESS) + parser.add_argument("--save-compiled", action="store_true", + help="Also save compiled torch.export artifact (.pt2)") + parser.add_argument("--compiled-filename", type=str, default="pet_full_exported.pt2", + help="Filename for compiled export artifact") + args = parser.parse_args() + + example_n_atoms = args.example_n_atoms + example_max_neighbors = args.example_max_neighbors + if args.deprecated_n_atoms is not None: + print("Warning: --n-atoms is deprecated; use --example-n-atoms.") + example_n_atoms = args.deprecated_n_atoms + if args.deprecated_max_neighbors is not None: + print("Warning: --max-neighbors is deprecated; use --example-max-neighbors.") + example_max_neighbors = args.deprecated_max_neighbors + + export_pet_full( + output_dir=Path(args.output), + example_n_atoms=example_n_atoms, + example_max_neighbors=example_max_neighbors, + model_name=args.model, + forces=args.forces, + save_compiled=args.save_compiled, + compiled_filename=args.compiled_filename, + ) diff --git a/scripts/export_pytorch/export_pet_gguf.py b/scripts/export_pytorch/export_pet_gguf.py new file mode 100644 index 0000000..a0ca935 --- /dev/null +++ b/scripts/export_pytorch/export_pet_gguf.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +""" +Export PET-MAD model to GGUF format with embedded computation graph. + +Produces a single .gguf file containing: +1. Model weights as GGUF tensors +2. Computation graph as JSON string in metadata ("graph.json") +3. Model hyperparameters, species mappings, composition energies + +Usage: + uv run python3 scripts/export_pytorch/export_pet_gguf.py -o pet-auto.gguf +""" + +import json +import argparse +import struct +import sys +import numpy as np +from pathlib import Path +from typing import Dict, List, Tuple, Any +from dataclasses import dataclass + +import torch + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from export_pytorch.fx_converter import export_torch_model +from export_pytorch.export_pet_full import ( + PETFullModel, load_pet_model, get_model_params, + get_species_mapping, get_composition_energies, get_energy_scale, +) + +# GGUF format constants +GGUF_MAGIC = 0x46554747 # "GGUF" +GGUF_VERSION = 3 + +# GGML tensor types +GGML_TYPE_F32 = 0 +GGML_TYPE_F16 = 1 +GGML_TYPE_I32 = 4 + +# GGUF value types +GGUF_TYPE_UINT32 = 4 +GGUF_TYPE_INT32 = 5 +GGUF_TYPE_FLOAT32 = 6 +GGUF_TYPE_STRING = 8 +GGUF_TYPE_ARRAY = 9 + + +@dataclass +class GGUFTensor: + """GGUF tensor descriptor.""" + name: str + shape: List[int] + dtype: int + data: bytes + offset: int = 0 + + +class GGUFWriter: + """Simple GGUF file writer.""" + + def __init__(self): + self.metadata: Dict[str, Tuple[int, Any]] = {} + self.tensors: List[GGUFTensor] = [] + + def add_string(self, key: str, value: str): + self.metadata[key] = (GGUF_TYPE_STRING, value) + + def add_int32(self, key: str, value: int): + self.metadata[key] = (GGUF_TYPE_INT32, value) + + def add_uint32(self, key: str, value: int): + self.metadata[key] = (GGUF_TYPE_UINT32, value) + + def add_float32(self, key: str, value: float): + self.metadata[key] = (GGUF_TYPE_FLOAT32, value) + + def add_array_int32(self, key: str, values: List[int]): + self.metadata[key] = (GGUF_TYPE_ARRAY, (GGUF_TYPE_INT32, values)) + + def add_array_float32(self, key: str, values: List[float]): + self.metadata[key] = (GGUF_TYPE_ARRAY, (GGUF_TYPE_FLOAT32, values)) + + def add_tensor(self, name: str, tensor: torch.Tensor, transpose_2d: bool = True): + """Add a tensor to the GGUF file. + + Args: + name: Tensor name + tensor: PyTorch tensor + transpose_2d: If True, transpose 2D weight matrices for GGML MUL_MAT + """ + # Convert to float32 if needed + if tensor.dtype in (torch.float16, torch.bfloat16): + tensor = tensor.float() + + # Transpose 2D weight matrices for GGML MUL_MAT compatibility + # GGML MUL_MAT: C = A @ B where A is [out, in] -> need [in, out] in GGML + if transpose_2d and tensor.dim() == 2: + tensor = tensor.T.contiguous() + + # Get shape in GGML format (reversed PyTorch shape) + shape = list(tensor.shape) + + # Determine dtype + if tensor.dtype == torch.float32: + dtype = GGML_TYPE_F32 + elif tensor.dtype == torch.int32: + dtype = GGML_TYPE_I32 + else: + raise ValueError(f"Unsupported tensor dtype: {tensor.dtype}") + + # Convert to bytes + data = tensor.detach().contiguous().numpy().tobytes() + + self.tensors.append(GGUFTensor( + name=name, + shape=shape, + dtype=dtype, + data=data, + )) + + def write(self, path: str): + """Write GGUF file.""" + with open(path, "wb") as f: + # Write header + f.write(struct.pack(" str: + """Get string name from FX target.""" + if isinstance(target, str): + return target + if hasattr(target, "__module__") and hasattr(target, "__name__"): + return f"{target.__module__}.{target.__name__}" + if hasattr(target, "__name__"): + return target.__name__ + return str(target) + + +def convert_fx_to_gir( + traced_module: fx.GraphModule, + input_shapes: Dict[str, List[int]], + input_names: List[str] = None, + strict_mode: bool = False, +) -> Tuple[GGMLGraph, Dict[str, torch.Tensor]]: + """Convert a traced FX graph module to GIR. + + Args: + traced_module: FX traced and shape-propagated module + input_shapes: Dict mapping input names to shapes + input_names: Optional list of input names + strict_mode: If True, raise errors on unhandled ops instead of passing through + + Returns: + Tuple of (GGMLGraph, weights dict) + """ + gir_inputs = [] + gir_nodes = [] + gir_outputs = [] + weights = {} + + # Map from FX node name to GIR reference + name_map: Dict[str, str] = {} + node_id = 0 + + # Process all nodes + for node in traced_module.graph.nodes: + shape = None + dtype = GGMLDtype.F32 # Default dtype + if "tensor_meta" in node.meta: + meta = node.meta["tensor_meta"] + if hasattr(meta, "shape"): + shape = list(meta.shape) + if hasattr(meta, "dtype"): + try: + dtype = GGMLDtype.from_torch_dtype(meta.dtype) + except ValueError: + dtype = GGMLDtype.F32 + + if node.op == "placeholder": + # Input tensor + inp_name = node.name if input_names is None else input_names[len(gir_inputs)] + gir_inputs.append(GGMLInput( + name=inp_name, + dtype=dtype, + shape=shape or [], + )) + name_map[node.name] = f"input:{inp_name}" + + elif node.op == "call_module": + # Module call (Linear, LayerNorm, etc.) + module = traced_module.get_submodule(node.target) + module_type = type(module).__name__ + + if isinstance(module, torch.nn.Linear): + # Linear: y = x @ W.T + b + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + + # Extract weight and bias + weight_name = f"{node.target.replace('.', '_')}_weight" + weights[weight_name] = module.weight.data.clone() + + # MUL_MAT node + gir_nodes.append(GGMLNode( + id=node_id, + op="MUL_MAT", + name=f"{node.name}_matmul", + inputs=[f"weight:{weight_name}", input_ref], + output_shape=shape[:-1] + [module.out_features] if shape else [], + output_dtype=GGMLDtype.F32, + )) + matmul_id = node_id + node_id += 1 + + # ADD bias if present + if module.bias is not None: + bias_name = f"{node.target.replace('.', '_')}_bias" + weights[bias_name] = module.bias.data.clone() + gir_nodes.append(GGMLNode( + id=node_id, + op="ADD", + name=f"{node.name}_bias", + inputs=[f"node:{matmul_id}", f"weight:{bias_name}"], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + else: + name_map[node.name] = f"node:{matmul_id}" + + elif isinstance(module, torch.nn.LayerNorm): + # LayerNorm decomposition + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + + weight_name = f"{node.target.replace('.', '_')}_weight" + bias_name = f"{node.target.replace('.', '_')}_bias" + weights[weight_name] = module.weight.data.clone() + if module.bias is not None: + weights[bias_name] = module.bias.data.clone() + + gir_nodes.append(GGMLNode( + id=node_id, + op="LAYER_NORM", + name=node.name, + inputs=[input_ref, f"weight:{weight_name}", + f"weight:{bias_name}" if module.bias is not None else "const:0"], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params={"eps": module.eps}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + + elif isinstance(module, torch.nn.Embedding): + # Embedding: lookup table using indices + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + + weight_name = f"{node.target.replace('.', '_')}_weight" + weights[weight_name] = module.weight.data.clone() + + gir_nodes.append(GGMLNode( + id=node_id, + op="GET_ROWS", + name=node.name, + inputs=[f"weight:{weight_name}", input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + + elif isinstance(module, torch.nn.SiLU): + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + gir_nodes.append(GGMLNode( + id=node_id, + op="UNARY_SILU", + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + + elif isinstance(module, torch.nn.ReLU): + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + gir_nodes.append(GGMLNode( + id=node_id, + op="UNARY_RELU", + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + + elif isinstance(module, torch.nn.GELU): + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + gir_nodes.append(GGMLNode( + id=node_id, + op="UNARY_GELU", + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + + elif isinstance(module, torch.nn.Dropout): + # Skip dropout (identity in eval mode) + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + name_map[node.name] = input_ref + + elif hasattr(module, "weight") or hasattr(module, "bias"): + # Generic module with parameters - try to handle + if strict_mode: + raise ValueError(f"Unhandled module type {module_type} at {node.target}") + print(f"Warning: Unhandled module type {module_type} at {node.target}") + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + name_map[node.name] = input_ref + + else: + # Pass-through for unknown modules + if strict_mode: + raise ValueError(f"Unhandled module type {module_type} at {node.target}") + if node.args: + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + name_map[node.name] = input_ref + + elif node.op == "call_function": + target_name = get_target_name(node.target) + ggml_op = None + + # Check for known functions + if node.target == operator.add: + ggml_op = "ADD" + elif node.target == operator.sub: + ggml_op = "SUB" + elif node.target == operator.mul: + ggml_op = "MUL" + elif node.target == operator.truediv: + ggml_op = "DIV" + elif node.target == operator.getitem: + # Handle tensor indexing + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + idx = node.args[1] + if isinstance(idx, int): + # Simple integer index -> VIEW + gir_nodes.append(GGMLNode( + id=node_id, + op="VIEW", + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params={"index": idx}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + elif isinstance(idx, tuple): + # Check for pattern like [:, 0, :] = (slice(None), int, slice(None)) + # This selects a specific index from a dimension + select_dim = None + select_idx = None + for i, item in enumerate(idx): + if isinstance(item, int): + select_dim = i + select_idx = item + elif isinstance(item, slice) and item == slice(None): + continue # Full slice, no-op + else: + # Complex slice pattern, not yet supported + select_dim = None + break + + if select_dim is not None: + # Emit SELECT operation + gir_nodes.append(GGMLNode( + id=node_id, + op="SELECT", + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params={"dim": select_dim, "index": select_idx}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + else: + # Complex slice - pass through for now + name_map[node.name] = input_ref + else: + # Unknown index type - pass through + name_map[node.name] = input_ref + continue + elif "scaled_dot_product_attention" in target_name: + ggml_op = "FLASH_ATTN_EXT" + elif node.target == torch.cat: + # torch.cat([a, b, ...], dim=0) + # First arg is a list/tuple of tensors to concatenate + tensors_arg = node.args[0] + dim = node.args[1] if len(node.args) > 1 else node.kwargs.get("dim", 0) + + input_refs = [] + for tensor in tensors_arg: + if isinstance(tensor, fx.Node): + ref = name_map.get(tensor.name) + if ref: + input_refs.append(ref) + + gir_nodes.append(GGMLNode( + id=node_id, + op="CONCAT", + name=node.name, + inputs=input_refs, + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params={"dim": dim}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + continue + elif node.target == torch.clamp: + ggml_op = "CLAMP" + elif node.target == torch.chunk or "chunk" in target_name: + # torch.chunk(input, chunks, dim=0) -> split into chunks pieces along dim + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + num_chunks = node.args[1] if len(node.args) > 1 else 2 + dim = node.args[2] if len(node.args) > 2 else node.kwargs.get("dim", 0) + + gir_nodes.append(GGMLNode( + id=node_id, + op="CHUNK", + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params={"num_chunks": num_chunks, "dim": dim}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + continue + elif node.target == torch.log: + ggml_op = "LOG" + elif node.target == torch.exp: + ggml_op = "UNARY_EXP" + elif node.target == torch.sqrt: + ggml_op = "SQRT" + elif node.target == torch.tanh: + ggml_op = "UNARY_TANH" + elif node.target == torch.softmax: + ggml_op = "SOFT_MAX" + elif target_name in FX_TO_GGML: + ggml_op = FX_TO_GGML[target_name] + elif hasattr(node.target, "__name__") and node.target.__name__ in FX_TO_GGML: + ggml_op = FX_TO_GGML[node.target.__name__] + + if ggml_op: + # Build input refs + input_refs = [] + params = {} + + for arg in node.args: + if isinstance(arg, fx.Node): + ref = name_map.get(arg.name) + if ref: + input_refs.append(ref) + elif isinstance(arg, (int, float)): + # Scalar parameter + if ggml_op == "CLAMP": + if "min" not in params: + params["min"] = float(arg) + else: + params["max"] = float(arg) + + # Handle kwargs + if "attn_mask" in node.kwargs: + mask_node = node.kwargs["attn_mask"] + if isinstance(mask_node, fx.Node): + mask_ref = name_map.get(mask_node.name) + if mask_ref: + input_refs.append(mask_ref) + + gir_nodes.append(GGMLNode( + id=node_id, + op=ggml_op, + name=node.name, + inputs=input_refs, + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params=params if params else None, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + elif "getattr" in target_name.lower(): + # Attribute access (like .shape) - skip + pass + else: + if strict_mode: + raise ValueError(f"Unhandled function {target_name}") + print(f"Warning: Unhandled function {target_name}") + + elif node.op == "call_method": + method_name = node.target + ggml_op = FX_TO_GGML.get(method_name) + + if method_name == "new_zeros": + # tensor.new_zeros(size) - create a zero tensor + # We'll handle this as a constant zero creation + gir_nodes.append(GGMLNode( + id=node_id, + op="NEW_ZEROS", + name=node.name, + inputs=[], # No inputs - creates zeros + output_shape=shape or [], + output_dtype=dtype, + params={"shape": shape or []}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + continue + + if ggml_op: + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + params = {} + + if method_name == "reshape": + # Extract shape from args + shape_args = [] + for arg in node.args[1:]: + if isinstance(arg, int): + shape_args.append(arg) + elif isinstance(arg, fx.Node): + # Dynamic shape - use the propagated output shape + pass + if shape_args: + params["shape"] = shape_args + elif shape: + params["shape"] = shape + + elif method_name == "permute": + # Extract permutation from args + perm = [arg for arg in node.args[1:] if isinstance(arg, int)] + if perm: + params["axes"] = perm + + elif method_name == "transpose": + # Extract dimensions + dims = [arg for arg in node.args[1:] if isinstance(arg, int)] + if dims: + params["dims"] = dims + + elif method_name == "view": + # Similar to reshape + shape_args = [arg for arg in node.args[1:] if isinstance(arg, int)] + if shape_args: + params["shape"] = shape_args + elif shape: + params["shape"] = shape + + elif method_name == "clamp": + # Extract min/max from args or kwargs + if len(node.args) > 1: + params["min"] = float(node.args[1]) if node.args[1] is not None else None + if len(node.args) > 2: + params["max"] = float(node.args[2]) if node.args[2] is not None else None + if "min" in node.kwargs: + params["min"] = float(node.kwargs["min"]) + if "max" in node.kwargs: + params["max"] = float(node.kwargs["max"]) + + elif method_name == "chunk": + # chunk returns a tuple - subsequent getitem ops extract pieces + # Store the chunk info for downstream getitem handling + num_chunks = node.args[1] if len(node.args) > 1 else 1 + dim = node.args[2] if len(node.args) > 2 else node.kwargs.get("dim", -1) + params["num_chunks"] = num_chunks + params["dim"] = dim + # Pass through - getitem will extract pieces + name_map[node.name] = input_ref + continue + + gir_nodes.append(GGMLNode( + id=node_id, + op=ggml_op, + name=node.name, + inputs=[input_ref], + output_shape=shape or [], + output_dtype=GGMLDtype.F32, + params=params if params else None, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + else: + if strict_mode: + raise ValueError(f"Unhandled method {method_name}") + print(f"Warning: Unhandled method {method_name}") + if node.args: + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + name_map[node.name] = input_ref + + elif node.op == "output": + # Graph output + for arg in node.args[0] if isinstance(node.args[0], tuple) else [node.args[0]]: + if isinstance(arg, fx.Node): + ref = name_map.get(arg.name, f"node:{node_id-1}") + gir_outputs.append(GGMLOutput( + name="output", + node_ref=ref, + dtype=GGMLDtype.F32, + shape=shape or [], + )) + + return GGMLGraph( + version="1.0.0", + model_type="fx", + inputs=gir_inputs, + outputs=gir_outputs, + nodes=gir_nodes, + ), weights + + +def export_fx_model( + module: torch.nn.Module, + example_inputs: Tuple[torch.Tensor, ...], + output_path: Path, + input_names: List[str] = None, +) -> Tuple[GGMLGraph, Dict[str, torch.Tensor]]: + """Export a PyTorch module via torch.fx to GIR with shape inference. + + Args: + module: PyTorch module to export + example_inputs: Example inputs for tracing and shape propagation + output_path: Path for output JSON + input_names: Names for inputs + + Returns: + Tuple of (GGMLGraph, weights dict) + """ + module.eval() + + # FX symbolic trace + traced = fx.symbolic_trace(module) + + # Propagate shapes + ShapeProp(traced).propagate(*example_inputs) + + # Build input shapes dict + input_shapes = {} + for i, (name, inp) in enumerate(zip(input_names or [], example_inputs)): + input_shapes[name] = list(inp.shape) + + # Convert to GIR + gir_graph, weights = convert_fx_to_gir(traced, input_shapes, input_names) + + # Save graph + with open(output_path, "w") as f: + json.dump(gir_graph.to_dict(), f, indent=2) + + # Save weights + weights_path = output_path.with_suffix(".weights.pt") + torch.save(weights, weights_path) + + print(f"Saved graph to {output_path}") + print(f"Saved {len(weights)} weights to {weights_path}") + print(f"Graph has {len(gir_graph.nodes)} nodes") + + return gir_graph, weights + + +def get_aten_op_name(target) -> str: + """Get the ATen op name string from an FX target.""" + # Try common attribute patterns + if hasattr(target, "_name"): + name = target._name + # Remove "aten::" prefix if present + if name.startswith("aten::"): + name = name[6:] + return f"aten.{name}" + + if hasattr(target, "name"): + # OpOverload: e.g., torch.ops.aten.add.Tensor + try: + name = target.name() + # Some ops return "aten::add.Tensor" format + if "::" in name: + parts = name.split("::") + name = parts[-1] # e.g., "add.Tensor" + return f"aten.{name}" + except: + pass + + # Handle string representation like "aten.aten::embedding" + target_str = str(target) + if "aten::" in target_str: + # Extract the op name: "aten.aten::embedding" -> "embedding" + parts = target_str.split("aten::") + if len(parts) >= 2: + name = parts[-1].split()[0] # Get first word + return f"aten.{name}" + + return target_str + + +def convert_exported_to_gir( + exported_module: fx.GraphModule, + input_shapes: Dict[str, List[int]], + input_names: List[str] = None, + input_dtypes: Dict[str, GGMLDtype] = None, + pre_extracted_weights: Dict[str, torch.Tensor] = None, + dynamic_shapes: Optional[Any] = None, + strict_mode: bool = False, +) -> Tuple[GGMLGraph, Dict[str, torch.Tensor]]: + """Convert a torch.export exported graph to GIR. + + This handles the ATen ops produced by torch.export instead of the + higher-level ops from fx.symbolic_trace. + + Args: + exported_module: FX GraphModule from torch.export + input_shapes: Dict mapping input names to shapes + input_names: Optional list of input names + input_dtypes: Optional dict mapping input names to dtypes + pre_extracted_weights: Weights already extracted from ExportedProgram.state_dict + strict_mode: If True, raise errors on unhandled ops instead of passing through + + Returns: + Tuple of (GGMLGraph, weights dict) + """ + gir_inputs = [] + gir_nodes = [] + gir_outputs = [] + weights = pre_extracted_weights.copy() if pre_extracted_weights else {} + + # Map from FX node name to GIR reference + name_map: Dict[str, str] = {} + node_id = 0 + + # Track placeholder count for input names (excluding parameter placeholders) + placeholder_idx = 0 + symbol_token_to_name: Dict[str, str] = {} + + def _dim_name_from_spec(dim_spec: Any) -> Optional[str]: + if dim_spec is None: + return None + if hasattr(dim_spec, "__name__"): + return str(dim_spec.__name__) + if isinstance(dim_spec, str): + return dim_spec + return None + + def _sym_expr_to_runtime_name(expr: str) -> Optional[str]: + cleaned = expr.replace(" ", "") + if cleaned in {"n_atoms", "max_neighbors", "n_edges", "seq_len", "max_neighbors_plus_one"}: + return cleaned + if cleaned in {"n_atoms*max_neighbors", "max_neighbors*n_atoms"}: + return "n_edges" + if cleaned in {"max_neighbors+1", "1+max_neighbors", "(max_neighbors+1)", "(1+max_neighbors)"}: + return "max_neighbors_plus_one" + if cleaned in { + "n_atoms*(max_neighbors+1)", + "n_atoms*(1+max_neighbors)", + "(max_neighbors+1)*n_atoms", + "(1+max_neighbors)*n_atoms", + }: + return "seq_len" + return None + + def _to_runtime_dim(dim: Any) -> Union[int, str]: + if isinstance(dim, int): + return dim + + raw = str(dim) + compact = raw.replace(" ", "") + if compact in symbol_token_to_name: + return symbol_token_to_name[compact] + + # Replace raw torch symbolic tokens (e.g. s0, s11) with runtime names. + expr = compact + for token, name in sorted(symbol_token_to_name.items(), key=lambda x: len(x[0]), reverse=True): + expr = re.sub( + rf"(? List[Union[int, str]]: + if not shape: + return [] + return [_to_runtime_dim(dim) for dim in shape] + + # Get any additional parameters and buffers + for name, param in exported_module.named_parameters(): + weight_name = name.replace(".", "_") + if weight_name not in weights: + weights[weight_name] = param.data.clone() + + for name, buf in exported_module.named_buffers(): + weight_name = name.replace(".", "_") + if weight_name not in weights: + weights[weight_name] = buf.data.clone() + + # Process all nodes + for node in exported_module.graph.nodes: + shape = None + dtype = GGMLDtype.F32 # Default dtype + + # Try to get shape from various meta formats + if "val" in node.meta: + val = node.meta["val"] + if hasattr(val, "shape"): + shape = list(val.shape) + if hasattr(val, "dtype"): + try: + dtype = GGMLDtype.from_torch_dtype(val.dtype) + except ValueError: + dtype = GGMLDtype.F32 + elif "tensor_meta" in node.meta: + meta = node.meta["tensor_meta"] + if hasattr(meta, "shape"): + shape = list(meta.shape) + if hasattr(meta, "dtype"): + try: + dtype = GGMLDtype.from_torch_dtype(meta.dtype) + except ValueError: + dtype = GGMLDtype.F32 + + if node.op == "placeholder": + # torch.export lifts parameters/constants/buffers as placeholders: + # - p_... parameters + # - c_... lifted constants + # - b_... buffers + node_target = str(node.target) + if ( + node_target.startswith("p_") + or node_target.startswith("c_") + or node_target.startswith("b_") + ): + # This is a lifted parameter or constant - treat as weight + # The state_dict key matches the original module path + # p_node_embedders_0_weight -> node_embedders.0.weight in state_dict + # But we already converted state_dict keys to use underscores + weight_name = node_target[2:] # Remove p_ or c_ prefix + name_map[node.name] = f"weight:{weight_name}" + else: + # This is an actual input + inp_name = input_names[placeholder_idx] if input_names and placeholder_idx < len(input_names) else node.name + inp_dtype = input_dtypes.get(inp_name, dtype) if input_dtypes else dtype + if dynamic_shapes is not None and shape is not None and placeholder_idx < len(dynamic_shapes): + spec = dynamic_shapes[placeholder_idx] + if isinstance(spec, dict): + for dim_idx, dim_spec in spec.items(): + dim_name = _dim_name_from_spec(dim_spec) + if dim_name is None: + continue + if isinstance(dim_idx, int) and 0 <= dim_idx < len(shape): + token = str(shape[dim_idx]).replace(" ", "") + symbol_token_to_name[token] = dim_name + gir_inputs.append(GGMLInput( + name=inp_name, + dtype=inp_dtype, + shape=_to_runtime_shape(shape), + )) + name_map[node.name] = f"input:{inp_name}" + placeholder_idx += 1 + + elif node.op == "get_attr": + # Attribute access (parameters/buffers) + attr_name = node.target.replace(".", "_") + # Already in weights dict, just record mapping + name_map[node.name] = f"weight:{attr_name}" + + elif node.op == "call_function": + target_name = get_aten_op_name(node.target) + ggml_op = ATEN_TO_GGML.get(target_name) + + # Handle special cases + if node.target == operator.getitem: + # getitem is used for tuple unpacking (e.g., after split/chunk) + input_node = node.args[0] if len(node.args) > 0 else None + input_ref = name_map.get(input_node.name, f"node:{node_id-1}") if isinstance(input_node, fx.Node) else f"node:{node_id-1}" + idx = node.args[1] + if isinstance(idx, int): + if isinstance(input_node, fx.Node) and hasattr(input_node, "target"): + input_target_name = str(input_node.target) + # native_layer_norm_backward returns (grad_input, grad_weight, grad_bias). + # For force export we only need grad_input (getitem idx=0). + if "native_layer_norm_backward" in input_target_name: + name_map[node.name] = input_ref + continue + # Check if input is from a CHUNK node - need to compute proper shape + chunk_output_shape = shape or [] + if isinstance(input_node, fx.Node) and hasattr(input_node, 'target'): + # Use str() to get target name - works for both OpOverload and regular targets + input_target_name = str(input_node.target) + if "chunk" in input_target_name.lower(): + # This is getitem after chunk - compute output shape + # Get chunk params from the input node + chunk_num = input_node.args[1] if len(input_node.args) > 1 else 2 + chunk_dim = input_node.args[2] if len(input_node.args) > 2 else -1 + # Get input tensor shape from chunk's input + if len(input_node.args) > 0 and isinstance(input_node.args[0], fx.Node): + chunk_input = input_node.args[0] + if "val" in chunk_input.meta and hasattr(chunk_input.meta["val"], "shape"): + input_shape = list(chunk_input.meta["val"].shape) + # Compute chunk output shape + if chunk_dim < 0: + chunk_dim = len(input_shape) + chunk_dim + if 0 <= chunk_dim < len(input_shape): + chunk_size = input_shape[chunk_dim] // chunk_num + chunk_output_shape = input_shape.copy() + chunk_output_shape[chunk_dim] = chunk_size + + gir_nodes.append(GGMLNode( + id=node_id, + op="VIEW", + name=node.name, + inputs=[input_ref], + output_shape=_to_runtime_shape(chunk_output_shape), + output_dtype=dtype, + params={"index": idx}, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + else: + name_map[node.name] = input_ref + continue + + if not ggml_op: + # Try FX mapping as fallback + short_name = target_name.split(".")[-1].split("_")[0] if "." in target_name else target_name + ggml_op = FX_TO_GGML.get(short_name) + + if not ggml_op: + if strict_mode: + raise ValueError(f"Unhandled ATen op: {target_name}") + print(f"Warning: Unhandled ATen op {target_name}") + # Try to pass through + if node.args and isinstance(node.args[0], fx.Node): + name_map[node.name] = name_map.get(node.args[0].name, f"node:{node_id-1}") + continue + + # Build input refs and params + input_refs = [] + params = {} + + for arg in node.args: + if isinstance(arg, fx.Node): + ref = name_map.get(arg.name) + if ref: + input_refs.append(ref) + elif isinstance(arg, (list, tuple)): + # Could be a shape list or tensor list + for item in arg: + if isinstance(item, fx.Node): + ref = name_map.get(item.name) + if ref: + input_refs.append(ref) + elif not isinstance(item, fx.Node): + dim_value = _to_runtime_dim(item) + if "shape" not in params: + params["shape"] = [] + params["shape"].append(dim_value) + + # Handle specific ops + if ggml_op == "VIEW" or ggml_op == "RESHAPE": + # Prefer propagated output shape: this preserves symbolic dims + # (e.g. n_edges) and avoids leaking intermediate sym_size nodes. + if shape: + params["shape"] = _to_runtime_shape(shape) + # Fallback: infer from args when shape metadata is unavailable. + elif len(node.args) > 1: + shape_arg = node.args[1] + if isinstance(shape_arg, (list, tuple)): + params["shape"] = [_to_runtime_dim(dim) for dim in shape_arg] + elif isinstance(shape_arg, fx.Node): + params["shape"] = [] + + elif ggml_op == "PERMUTE": + # Permutation indices + if len(node.args) > 1: + perm = node.args[1] + if isinstance(perm, (list, tuple)): + params["axes"] = list(perm) + + elif ggml_op == "TRANSPOSE": + # Transpose dimensions + if len(node.args) > 1: + dims = [node.args[i] for i in range(1, min(3, len(node.args))) if isinstance(node.args[i], int)] + if dims: + params["dims"] = dims + elif target_name == "aten.t.default": + # 2D transpose: swap dims 0 and 1 + params["dims"] = [1, 0] + + elif ggml_op == "CONCAT": + # Cat: inputs are a list in args[0], dim in args[1] + input_refs = [] + if isinstance(node.args[0], (list, tuple)): + for t in node.args[0]: + if isinstance(t, fx.Node): + ref = name_map.get(t.name) + if ref: + input_refs.append(ref) + dim = node.args[1] if len(node.args) > 1 else 0 + params["dim"] = dim + + elif ggml_op == "SOFT_MAX": + # Softmax dim + if len(node.args) > 1 and isinstance(node.args[1], int): + params["dim"] = node.args[1] + + elif ggml_op == "SOFTMAX_BACKWARD": + # _softmax_backward_data(grad, output, dim, input_dtype) + input_refs = [] + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + grad_ref = name_map.get(node.args[0].name) + if grad_ref: + input_refs.append(grad_ref) + if len(node.args) >= 2 and isinstance(node.args[1], fx.Node): + out_ref = name_map.get(node.args[1].name) + if out_ref: + input_refs.append(out_ref) + if len(node.args) >= 3 and isinstance(node.args[2], int): + params["dim"] = node.args[2] + + elif ggml_op == "LAYER_NORM": + # native_layer_norm: input, normalized_shape, weight, bias, eps + # Reorder to: input, weight, bias + if len(node.args) >= 4: + inp_ref = name_map.get(node.args[0].name) if isinstance(node.args[0], fx.Node) else None + weight_ref = name_map.get(node.args[2].name) if isinstance(node.args[2], fx.Node) else None + bias_ref = name_map.get(node.args[3].name) if isinstance(node.args[3], fx.Node) else None + eps = node.args[4] if len(node.args) > 4 else 1e-5 + input_refs = [r for r in [inp_ref, weight_ref, bias_ref] if r] + params["eps"] = eps + + elif ggml_op == "LAYER_NORM_BACKWARD": + # native_layer_norm_backward(grad_out, input, normalized_shape, mean, rstd, weight, bias, output_mask) + # We model grad_input only. + grad_ref = name_map.get(node.args[0].name) if len(node.args) > 0 and isinstance(node.args[0], fx.Node) else None + inp_ref = name_map.get(node.args[1].name) if len(node.args) > 1 and isinstance(node.args[1], fx.Node) else None + mean_ref = name_map.get(node.args[3].name) if len(node.args) > 3 and isinstance(node.args[3], fx.Node) else None + rstd_ref = name_map.get(node.args[4].name) if len(node.args) > 4 and isinstance(node.args[4], fx.Node) else None + weight_ref = name_map.get(node.args[5].name) if len(node.args) > 5 and isinstance(node.args[5], fx.Node) else None + input_refs = [r for r in [grad_ref, inp_ref, mean_ref, rstd_ref, weight_ref] if r] + if len(node.args) > 2 and isinstance(node.args[2], (list, tuple)): + params["normalized_ndim"] = len(node.args[2]) + + elif ggml_op == "RMS_NORM": + # rms_norm: input, normalized_shape, weight, eps + # Args: (input, normalized_shape, weight, eps) or similar + # Reorder to: input, weight + if len(node.args) >= 3: + inp_ref = name_map.get(node.args[0].name) if isinstance(node.args[0], fx.Node) else None + # normalized_shape is args[1], weight is args[2] + weight_ref = name_map.get(node.args[2].name) if isinstance(node.args[2], fx.Node) else None + # When PyTorch RMSNorm has eps=None, torch.export only produces 3 args + # (input, normalized_shape, weight) - no eps arg at all + # eps=None in PyTorch means effectively 0, but we use a tiny value + # for numerical stability in GGML's rsqrt computation + if len(node.args) > 3 and node.args[3] is not None: + eps = float(node.args[3]) + else: + eps = 1e-8 # eps=None in PyTorch, use tiny value for GGML stability + input_refs = [r for r in [inp_ref, weight_ref] if r] + params["eps"] = eps + + elif ggml_op == "GET_ROWS": + # embedding: weight, indices + if len(node.args) >= 2: + weight_ref = name_map.get(node.args[0].name) if isinstance(node.args[0], fx.Node) else None + idx_ref = name_map.get(node.args[1].name) if isinstance(node.args[1], fx.Node) else None + if weight_ref and idx_ref: + input_refs = [weight_ref, idx_ref] + + elif ggml_op == "SELECT": + # select.int: input, dim, index + if len(node.args) >= 3: + params["dim"] = node.args[1] + params["index"] = node.args[2] + + elif ggml_op == "SUM_ROWS": + # aten.sum(dim=..., keepdim=...) or aten.sum() (reduce all dims) + params.pop("shape", None) + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + src_ref = name_map.get(node.args[0].name) + input_refs = [src_ref] if src_ref else [] + else: + input_refs = [] + + dims = None + reduce_all = False + if len(node.args) >= 2: + dim_arg = node.args[1] + if dim_arg is None: + reduce_all = True + elif isinstance(dim_arg, int): + dims = [int(dim_arg)] + elif isinstance(dim_arg, (list, tuple)): + dims = [int(d) for d in dim_arg if isinstance(d, int)] + if "dim" in node.kwargs: + dim_arg = node.kwargs["dim"] + if dim_arg is None: + reduce_all = True + elif isinstance(dim_arg, int): + dims = [int(dim_arg)] + elif isinstance(dim_arg, (list, tuple)): + dims = [int(d) for d in dim_arg if isinstance(d, int)] + + keepdim = False + if len(node.args) >= 3 and isinstance(node.args[2], bool): + keepdim = bool(node.args[2]) + if "keepdim" in node.kwargs and isinstance(node.kwargs["keepdim"], bool): + keepdim = bool(node.kwargs["keepdim"]) + + if dims is not None: + params["dims"] = dims + elif reduce_all or target_name == "aten.sum.default": + params["reduce_all"] = True + params["keepdim"] = keepdim + + elif ggml_op == "WHERE": + # WHERE supports: + # - aten.where.self(condition, x, y) + # - aten.where.ScalarSelf(condition, scalar, y) + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + cond_ref = name_map.get(node.args[0].name) + if cond_ref: + input_refs = [cond_ref] + else: + input_refs = [] + else: + input_refs = [] + + if len(node.args) >= 2: + if isinstance(node.args[1], fx.Node): + ref = name_map.get(node.args[1].name) + if ref: + input_refs.append(ref) + elif isinstance(node.args[1], (int, float)): + params["x_scalar"] = float(node.args[1]) + + if len(node.args) >= 3: + if isinstance(node.args[2], fx.Node): + ref = name_map.get(node.args[2].name) + if ref: + input_refs.append(ref) + elif isinstance(node.args[2], (int, float)): + params["y_scalar"] = float(node.args[2]) + + elif ggml_op in ("GE", "LE"): + # Comparison op: tensor >= scalar / tensor <= scalar + # or tensor vs tensor. + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + lhs_ref = name_map.get(node.args[0].name) + input_refs = [lhs_ref] if lhs_ref else [] + if len(node.args) >= 2: + if isinstance(node.args[1], fx.Node): + rhs_ref = name_map.get(node.args[1].name) + if rhs_ref: + input_refs.append(rhs_ref) + elif isinstance(node.args[1], (int, float)): + params["scalar"] = float(node.args[1]) + + elif ggml_op == "LOGICAL_AND": + if len(node.args) >= 2: + a_ref = name_map.get(node.args[0].name) if isinstance(node.args[0], fx.Node) else None + b_ref = name_map.get(node.args[1].name) if isinstance(node.args[1], fx.Node) else None + input_refs = [r for r in [a_ref, b_ref] if r] + + elif ggml_op == "SCALAR_CONST": + # aten.scalar_tensor(value, ...) + input_refs = [] + if len(node.args) >= 1 and isinstance(node.args[0], (int, float)): + params["scalar"] = float(node.args[0]) + elif "value" in node.kwargs and isinstance(node.kwargs["value"], (int, float)): + params["scalar"] = float(node.kwargs["value"]) + else: + params["scalar"] = 0.0 + + elif ggml_op == "SLICE": + # Handles: + # - aten.slice.Tensor(input, dim, start, end, step) + # - aten.narrow(input, dim, start, length) + params.pop("shape", None) + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + src_ref = name_map.get(node.args[0].name) + input_refs = [src_ref] if src_ref else [] + else: + input_refs = [] + + if "narrow" in target_name: + if len(node.args) >= 2 and isinstance(node.args[1], int): + params["dim"] = node.args[1] + if len(node.args) >= 3 and isinstance(node.args[2], int): + params["start"] = node.args[2] + if len(node.args) >= 4 and isinstance(node.args[3], int): + params["length"] = node.args[3] + if "dim" in node.kwargs and isinstance(node.kwargs["dim"], int): + params["dim"] = node.kwargs["dim"] + if "start" in node.kwargs and isinstance(node.kwargs["start"], int): + params["start"] = node.kwargs["start"] + if "length" in node.kwargs and isinstance(node.kwargs["length"], int): + params["length"] = node.kwargs["length"] + else: + if len(node.args) >= 2 and isinstance(node.args[1], int): + params["dim"] = node.args[1] + if len(node.args) >= 3 and isinstance(node.args[2], int): + params["start"] = node.args[2] + if len(node.args) >= 4 and isinstance(node.args[3], int): + params["end"] = node.args[3] + if len(node.args) >= 5 and isinstance(node.args[4], int): + params["step"] = node.args[4] + if "dim" in node.kwargs and isinstance(node.kwargs["dim"], int): + params["dim"] = node.kwargs["dim"] + if "start" in node.kwargs and isinstance(node.kwargs["start"], int): + params["start"] = node.kwargs["start"] + if "end" in node.kwargs and isinstance(node.kwargs["end"], int): + params["end"] = node.kwargs["end"] + if "step" in node.kwargs and isinstance(node.kwargs["step"], int): + params["step"] = node.kwargs["step"] + + elif ggml_op == "SLICE_BACKWARD": + # slice_backward(grad, input_sizes, dim, start, end, step) + input_refs = [] + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + grad_ref = name_map.get(node.args[0].name) + if grad_ref: + input_refs = [grad_ref] + params.pop("shape", None) + if len(node.args) >= 2 and isinstance(node.args[1], (list, tuple)): + params["input_shape"] = [_to_runtime_dim(d) for d in node.args[1]] + if len(node.args) >= 3 and isinstance(node.args[2], int): + params["dim"] = node.args[2] + if len(node.args) >= 4 and isinstance(node.args[3], int): + params["start"] = node.args[3] + if len(node.args) >= 5 and isinstance(node.args[4], int): + params["end"] = node.args[4] + if len(node.args) >= 6 and isinstance(node.args[5], int): + params["step"] = node.args[5] + + elif ggml_op == "SELECT_BACKWARD": + # select_backward(grad, input_sizes, dim, index) + input_refs = [] + if len(node.args) >= 1 and isinstance(node.args[0], fx.Node): + grad_ref = name_map.get(node.args[0].name) + if grad_ref: + input_refs = [grad_ref] + params.pop("shape", None) + if len(node.args) >= 2 and isinstance(node.args[1], (list, tuple)): + params["input_shape"] = [_to_runtime_dim(d) for d in node.args[1]] + if len(node.args) >= 3 and isinstance(node.args[2], int): + params["dim"] = node.args[2] + if len(node.args) >= 4 and isinstance(node.args[3], int): + params["index"] = node.args[3] + + elif ggml_op == "FLASH_ATTN_EXT": + # scaled_dot_product_attention: q, k, v, attn_mask, dropout_p, is_causal, scale + if len(node.args) >= 3: + q_ref = name_map.get(node.args[0].name) if isinstance(node.args[0], fx.Node) else None + k_ref = name_map.get(node.args[1].name) if isinstance(node.args[1], fx.Node) else None + v_ref = name_map.get(node.args[2].name) if isinstance(node.args[2], fx.Node) else None + input_refs = [r for r in [q_ref, k_ref, v_ref] if r] + # Handle mask if present + if len(node.args) > 3 and isinstance(node.args[3], fx.Node): + mask_ref = name_map.get(node.args[3].name) + if mask_ref: + input_refs.append(mask_ref) + # Handle scale + if "scale" in node.kwargs: + params["scale"] = float(node.kwargs["scale"]) + + elif ggml_op == "CLAMP": + # clamp: input, min, max + # Args can be: (input, min, max) or kwargs min/max + if len(node.args) >= 2 and node.args[1] is not None: + params["min"] = float(node.args[1]) + if len(node.args) >= 3 and node.args[2] is not None: + params["max"] = float(node.args[2]) + # Also check kwargs + if "min" in node.kwargs and node.kwargs["min"] is not None: + params["min"] = float(node.kwargs["min"]) + if "max" in node.kwargs and node.kwargs["max"] is not None: + params["max"] = float(node.kwargs["max"]) + + elif ggml_op == "POW": + # POW: input ** exponent (scalar) + if len(node.args) >= 2 and isinstance(node.args[1], (int, float)): + params["exponent"] = float(node.args[1]) + + elif ggml_op in ("MUL", "ADD", "SUB", "DIV"): + # Binary ops: handle scalar second arg + if len(node.args) >= 2: + if isinstance(node.args[1], (int, float)): + params["scalar"] = float(node.args[1]) + + elif ggml_op == "CHUNK": + # chunk: input, num_chunks, dim + # aten.chunk.default(tensor, num_chunks, dim) + if len(node.args) >= 2: + params["num_chunks"] = node.args[1] + if len(node.args) >= 3: + params["dim"] = node.args[2] + elif "dim" in node.kwargs: + params["dim"] = node.kwargs["dim"] + else: + params["dim"] = 0 # default dim + + gir_nodes.append(GGMLNode( + id=node_id, + op=ggml_op, + name=node.name, + inputs=input_refs, + output_shape=_to_runtime_shape(shape), + output_dtype=dtype, + params=params if params else None, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + + elif node.op == "call_method": + method_name = node.target + ggml_op = FX_TO_GGML.get(method_name) + + if ggml_op: + input_ref = name_map.get(node.args[0].name, f"node:{node_id-1}") + params = {} + + if method_name in ("view", "reshape"): + if shape: + params["shape"] = _to_runtime_shape(shape) + else: + shape_args = [_to_runtime_dim(a) for a in node.args[1:] if not isinstance(a, fx.Node)] + params["shape"] = shape_args + + elif method_name == "permute": + perm = [a for a in node.args[1:] if isinstance(a, int)] + params["axes"] = perm + + elif method_name == "transpose": + dims = [a for a in node.args[1:] if isinstance(a, int)] + params["dims"] = dims + + gir_nodes.append(GGMLNode( + id=node_id, + op=ggml_op, + name=node.name, + inputs=[input_ref], + output_shape=_to_runtime_shape(shape), + output_dtype=dtype, + params=params if params else None, + )) + name_map[node.name] = f"node:{node_id}" + node_id += 1 + else: + print(f"Warning: Unhandled method {method_name}") + if node.args: + name_map[node.name] = name_map.get(node.args[0].name, f"node:{node_id-1}") + + elif node.op == "output": + # Graph output - handle tuple outputs + output_args = node.args[0] + if isinstance(output_args, (tuple, list)): + for i, arg in enumerate(output_args): + if isinstance(arg, fx.Node): + ref = name_map.get(arg.name, f"node:{node_id-1}") + out_shape = [] + if "val" in arg.meta and hasattr(arg.meta["val"], "shape"): + out_shape = _to_runtime_shape(list(arg.meta["val"].shape)) + gir_outputs.append(GGMLOutput( + name=f"output_{i}" if len(output_args) > 1 else "output", + node_ref=ref, + dtype=dtype, + shape=out_shape, + )) + elif isinstance(output_args, fx.Node): + ref = name_map.get(output_args.name, f"node:{node_id-1}") + gir_outputs.append(GGMLOutput( + name="output", + node_ref=ref, + dtype=dtype, + shape=_to_runtime_shape(shape), + )) + + return GGMLGraph( + version="1.0.0", + model_type="torch_export", + inputs=gir_inputs, + outputs=gir_outputs, + nodes=gir_nodes, + ), weights + + +def decompose_5d_attention_pattern(graph: GGMLGraph) -> GGMLGraph: + """Decompose 5D attention reshape patterns into 4D-compatible operations. + + Detects patterns like: + RESHAPE [B, S, 3, H, D] (5D: batch, seq, QKV, heads, head_dim) + PERMUTE [2, 0, 3, 1, 4] (reorder to [3, B, H, S, D]) + SELECT dim=0, idx=0/1/2 (extract Q, K, V) + + And converts to: + VIEW [B, S, 3, H*D] (4D: combine heads*dim) + SELECT dim=2, idx=i (extract Q/K/V as [B, S, H*D]) + RESHAPE [B, S, H, D] (4D: split heads and dim) + PERMUTE [0, 2, 1, 3] (4D: reorder to [B, H, S, D]) + """ + nodes = graph.nodes[:] + new_nodes = [] + node_id_remap = {} # old_id -> new_ref + + # Track which nodes consume 5D RESHAPE output for pattern detection + reshape_5d_info = {} # node_id -> {"shape": [...], "input": "..."} + + # First pass: identify 5D RESHAPE nodes + for node in nodes: + if node.op == "RESHAPE": + shape = node.params.get("shape", []) if node.params else [] + if len(shape) == 5: + reshape_5d_info[node.id] = { + "shape": shape, + "input": node.inputs[0] if node.inputs else None, + "node": node, + } + + if not reshape_5d_info: + return graph # No 5D reshapes to decompose + + print(f"Decomposing {len(reshape_5d_info)} 5D reshape patterns...") + + # Build consumer map: which nodes use each node's output + consumers = {} # node_id -> list of consumer nodes + for node in nodes: + for inp in node.inputs: + if inp.startswith("node:"): + src_id = int(inp.split(":")[1]) + if src_id not in consumers: + consumers[src_id] = [] + consumers[src_id].append(node) + + # Identify patterns: 5D RESHAPE -> PERMUTE -> SELECT + patterns_to_decompose = [] + for reshape_id, info in reshape_5d_info.items(): + shape = info["shape"] + # Look for: [B, S, 3, H, D] pattern (QKV split) + if len(shape) == 5 and shape[2] == 3: + # This is likely QKV attention reshape + B, S, _, H, D = shape + # Find the PERMUTE that follows + if reshape_id in consumers: + for consumer in consumers[reshape_id]: + if consumer.op == "PERMUTE": + axes = consumer.params.get("axes", []) if consumer.params else [] + if axes == [2, 0, 3, 1, 4]: + # Found the pattern! Now find SELECTs + patterns_to_decompose.append({ + "reshape_id": reshape_id, + "reshape_node": info["node"], + "permute_node": consumer, + "B": B, "S": S, "H": H, "D": D, + "input_ref": info["input"], + }) + + # Track nodes to skip (will be replaced) + nodes_to_skip = set() + # Track SELECT replacements + select_replacements = {} # old SELECT node_id -> new node ref + + next_node_id = max(n.id for n in nodes) + 1 + + for pattern in patterns_to_decompose: + B, S, H, D = pattern["B"], pattern["S"], pattern["H"], pattern["D"] + input_ref = pattern["input_ref"] + reshape_node = pattern["reshape_node"] + permute_node = pattern["permute_node"] + + nodes_to_skip.add(reshape_node.id) + nodes_to_skip.add(permute_node.id) + + # Find SELECT nodes that use this PERMUTE output + selects = [] + if permute_node.id in consumers: + for consumer in consumers[permute_node.id]: + if consumer.op == "SELECT": + dim = consumer.params.get("dim", 0) if consumer.params else 0 + idx = consumer.params.get("index", 0) if consumer.params else 0 + if dim == 0: + selects.append((consumer, idx)) + nodes_to_skip.add(consumer.id) + + # Generate decomposed nodes: + # 1. VIEW input [B, S, 3*H*D] -> [B, S, 3, H*D] + view_shape = [B, S, 3, H * D] + view_node = GGMLNode( + id=next_node_id, + op="VIEW", + name=f"decomposed_view_{reshape_node.id}", + inputs=[input_ref], + output_shape=view_shape, + output_dtype=reshape_node.output_dtype, + params={"shape": view_shape}, + ) + new_nodes.append(view_node) + view_ref = f"node:{next_node_id}" + next_node_id += 1 + + # For each SELECT (Q, K, V), generate: + # SELECT -> RESHAPE -> PERMUTE + for select_node, qkv_idx in selects: + # SELECT from dim=2 (the QKV dimension) + select_new = GGMLNode( + id=next_node_id, + op="SELECT", + name=f"decomposed_select_{select_node.id}", + inputs=[view_ref], + output_shape=[B, S, H * D], + output_dtype=select_node.output_dtype, + params={"dim": 2, "index": qkv_idx}, + ) + new_nodes.append(select_new) + select_ref = f"node:{next_node_id}" + next_node_id += 1 + + # RESHAPE to [B, S, H, D] + reshape_new = GGMLNode( + id=next_node_id, + op="RESHAPE", + name=f"decomposed_reshape_{select_node.id}", + inputs=[select_ref], + output_shape=[B, S, H, D], + output_dtype=select_node.output_dtype, + params={"shape": [B, S, H, D]}, + ) + new_nodes.append(reshape_new) + reshape_ref = f"node:{next_node_id}" + next_node_id += 1 + + # PERMUTE to [B, H, S, D] + permute_new = GGMLNode( + id=next_node_id, + op="PERMUTE", + name=f"decomposed_permute_{select_node.id}", + inputs=[reshape_ref], + output_shape=[B, H, S, D], + output_dtype=select_node.output_dtype, + params={"axes": [0, 2, 1, 3]}, + ) + new_nodes.append(permute_new) + select_replacements[select_node.id] = f"node:{next_node_id}" + next_node_id += 1 + + # Build final node list with updated references + all_nodes = {} + for node in nodes: + if node.id not in nodes_to_skip: + # Update input references + updated_inputs = [] + for inp in node.inputs: + if inp.startswith("node:"): + src_id = int(inp.split(":")[1]) + if src_id in select_replacements: + updated_inputs.append(select_replacements[src_id]) + else: + updated_inputs.append(inp) + else: + updated_inputs.append(inp) + + all_nodes[node.id] = GGMLNode( + id=node.id, + op=node.op, + name=node.name, + inputs=updated_inputs, + output_shape=node.output_shape, + output_dtype=node.output_dtype, + params=node.params, + ) + + # Add the new decomposed nodes + for node in new_nodes: + all_nodes[node.id] = node + + # Topological sort based on dependencies + def topological_sort(nodes_dict): + # Build adjacency list + in_degree = {nid: 0 for nid in nodes_dict} + deps = {nid: [] for nid in nodes_dict} + + for nid, node in nodes_dict.items(): + for inp in node.inputs: + if inp.startswith("node:"): + src_id = int(inp.split(":")[1]) + if src_id in nodes_dict: + in_degree[nid] += 1 + deps[src_id].append(nid) + + # Start with nodes that have no node dependencies + queue = [nid for nid, deg in in_degree.items() if deg == 0] + result = [] + + while queue: + # Sort queue by original ID to maintain some stability + queue.sort() + nid = queue.pop(0) + result.append(nid) + + for consumer in deps[nid]: + in_degree[consumer] -= 1 + if in_degree[consumer] == 0: + queue.append(consumer) + + if len(result) != len(nodes_dict): + # Cycle or missing deps - fall back to ID sort + print(f"Warning: Topological sort incomplete ({len(result)}/{len(nodes_dict)}), using ID sort") + return sorted(nodes_dict.keys()) + + return result + + sorted_ids = topological_sort(all_nodes) + + # Renumber nodes sequentially + old_to_new_id = {old_id: new_id for new_id, old_id in enumerate(sorted_ids)} + + renumbered_nodes = [] + for old_id in sorted_ids: + node = all_nodes[old_id] + new_inputs = [] + for inp in node.inputs: + if inp.startswith("node:"): + src_id = int(inp.split(":")[1]) + if src_id in old_to_new_id: + new_inputs.append(f"node:{old_to_new_id[src_id]}") + else: + new_inputs.append(inp) + else: + new_inputs.append(inp) + + renumbered_nodes.append(GGMLNode( + id=old_to_new_id[old_id], + op=node.op, + name=node.name, + inputs=new_inputs, + output_shape=node.output_shape, + output_dtype=node.output_dtype, + params=node.params, + )) + + # Update output references + new_outputs = [] + for out in graph.outputs: + ref = out.node_ref + if ref.startswith("node:"): + old_id = int(ref.split(":")[1]) + if old_id in old_to_new_id: + ref = f"node:{old_to_new_id[old_id]}" + new_outputs.append(GGMLOutput( + name=out.name, + node_ref=ref, + dtype=out.dtype, + shape=out.shape, + )) + + print(f"Decomposition complete: {len(nodes)} -> {len(renumbered_nodes)} nodes") + + return GGMLGraph( + version=graph.version, + model_type=graph.model_type, + inputs=graph.inputs, + outputs=new_outputs, + nodes=renumbered_nodes, + ) + + +def export_torch_model( + module: torch.nn.Module, + example_inputs: Tuple[torch.Tensor, ...], + output_path: Path, + input_names: List[str] = None, + input_dtypes: Dict[str, str] = None, + dynamic_shapes: Optional[Any] = None, + strict: bool = False, + decompose_5d_attention: bool = True, +) -> Tuple[GGMLGraph, Dict[str, torch.Tensor]]: + """Export a PyTorch module via torch.export to GIR. + + This uses torch.export which can handle more dynamic operations than + fx.symbolic_trace (like torch.empty with dynamic attributes). + + Args: + module: PyTorch module to export + example_inputs: Example inputs for tracing + output_path: Path for output JSON + input_names: Names for inputs + input_dtypes: Dict mapping input names to dtype strings ("f32", "i32", etc.) + strict: Whether to use strict mode (default False for more flexibility) + + Returns: + Tuple of (GGMLGraph, weights dict) + """ + module.eval() + + # Use torch.export + print("Running torch.export...") + if dynamic_shapes is None: + exported = torch.export.export(module, example_inputs, strict=strict) + else: + exported = torch.export.export( + module, example_inputs, dynamic_shapes=dynamic_shapes, strict=strict + ) + + print(f"Export succeeded! Graph has {len(list(exported.graph_module.graph.nodes))} nodes") + + def _is_fake_like_tensor(t: Any) -> bool: + return isinstance(t, torch.Tensor) and t.__class__.__name__ != "Tensor" + + def _capture_saved_tensors_from_run() -> List[torch.Tensor]: + saved: List[torch.Tensor] = [] + + def _pack(x): + if isinstance(x, torch.Tensor): + saved.append(x.detach().cpu().clone()) + return x + + def _unpack(x): + return x + + with torch.autograd.graph.saved_tensors_hooks(_pack, _unpack): + with torch.enable_grad(): + _ = module(*example_inputs) + return saved + + def _flatten_dynamic_specs(spec: Any) -> List[Any]: + # Keep dict leaves intact; only recurse list/tuple containers. + if isinstance(spec, (list, tuple)): + out: List[Any] = [] + for item in spec: + out.extend(_flatten_dynamic_specs(item)) + return out + return [spec] + + flat_dynamic_shapes = ( + _flatten_dynamic_specs(dynamic_shapes) if dynamic_shapes is not None else None + ) + + # Build input shapes and dtypes dict + input_shapes = {} + input_dtype_map = {} + for i, inp in enumerate(example_inputs): + name = input_names[i] if input_names and i < len(input_names) else f"input_{i}" + input_shapes[name] = list(inp.shape) + if input_dtypes and name in input_dtypes: + input_dtype_map[name] = GGMLDtype.from_string(input_dtypes[name]) + else: + try: + input_dtype_map[name] = GGMLDtype.from_torch_dtype(inp.dtype) + except ValueError: + input_dtype_map[name] = GGMLDtype.F32 + + # Extract weights from state_dict + # torch.export lifts parameters as placeholders prefixed with "p_" + weights = {} + for name, tensor in exported.state_dict.items(): + weight_name = name.replace(".", "_") + weights[weight_name] = tensor.clone() + + # If torch.export produced FakeTensor lifted constants (common with + # non-strict export of forward+autodiff graphs), materialize concrete values + # from one eager reference run via saved_tensors_hooks. + lifted_constant_replacements: Dict[str, torch.Tensor] = {} + if hasattr(exported, "constants") and exported.constants: + fake_constant_count = sum( + 1 for t in exported.constants.values() if _is_fake_like_tensor(t) + ) + if fake_constant_count > 0: + print( + f"Detected {fake_constant_count} FakeTensor lifted constants; " + "capturing saved tensors for materialization..." + ) + saved_tensors = _capture_saved_tensors_from_run() + buckets: Dict[Tuple[torch.dtype, Tuple[int, ...]], deque] = defaultdict(deque) + for t in saved_tensors: + buckets[(t.dtype, tuple(int(d) for d in t.shape))].append(t) + + lifted_names: List[str] + if ( + hasattr(exported, "graph_signature") + and hasattr(exported.graph_signature, "lifted_tensor_constants") + ): + lifted_names = list(exported.graph_signature.lifted_tensor_constants) + else: + lifted_names = list(exported.constants.keys()) + + missing = 0 + for name in lifted_names: + t = exported.constants.get(name) + if not _is_fake_like_tensor(t): + continue + key = (t.dtype, tuple(int(d) for d in t.shape)) + if buckets[key]: + lifted_constant_replacements[name] = buckets[key].popleft().clone() + else: + missing += 1 + + if missing > 0: + print( + f"Warning: failed to materialize {missing} FakeTensor lifted constants; " + "remaining constants may be invalid." + ) + + # Also get constants (prefixed with "c_") + if hasattr(exported, 'constants'): + for name, tensor in exported.constants.items(): + if isinstance(tensor, torch.Tensor): + weight_name = name.replace(".", "_") + if name in lifted_constant_replacements: + weights[weight_name] = lifted_constant_replacements[name].clone() + else: + weights[weight_name] = tensor.clone() + + print(f"Extracted {len(weights)} weights from state_dict") + + # Convert to GIR using the graph_module + gir_graph, extra_weights = convert_exported_to_gir( + exported.graph_module, + input_shapes, + input_names, + input_dtype_map, + weights, # Pass pre-extracted weights + dynamic_shapes=flat_dynamic_shapes, + ) + + # Merge any additional weights found during conversion + weights.update(extra_weights) + + # Optional: decompose some 5D attention patterns to 4D forms. + # Keep this off when preserving symbolic dimensions is more important. + if decompose_5d_attention: + gir_graph = decompose_5d_attention_pattern(gir_graph) + + # Save graph (if output path provided) + if output_path is not None: + with open(output_path, "w") as f: + json.dump(gir_graph.to_dict(), f, indent=2) + + weights_path = output_path.with_suffix(".weights.pt") + torch.save(weights, weights_path) + + print(f"Saved graph to {output_path}") + print(f"Saved {len(weights)} weights to {weights_path}") + + print(f"Graph has {len(gir_graph.nodes)} nodes") + + return gir_graph, weights + + +def symbolize_dimensions( + graph: GGMLGraph, + dim_mapping: Dict[str, int], + protected_values: Optional[Set[int]] = None, +) -> GGMLGraph: + """Replace concrete dimension values with symbolic names. + + This enables the graph to be instantiated with different sizes at runtime. + + Args: + graph: The input GGMLGraph + dim_mapping: Dict mapping symbolic names to concrete values that were + used during export. E.g., {"n_atoms": 2, "max_neighbors": 8} + protected_values: Set of values that should NOT be symbolized even if + they match a dynamic dimension. Use this to protect + known model constants (e.g., 3 for xyz, 32 for head_dim). + + Returns: + Modified graph with symbolic dimension names in shapes + """ + if protected_values is None: + protected_values = set() + + # Create reverse mapping: concrete value -> symbolic name + # Note: if multiple symbols have the same value, we need to be careful + # We'll prioritize based on typical usage patterns + value_to_symbol = {} + for sym, val in dim_mapping.items(): + if val not in protected_values: + value_to_symbol[val] = sym + + # Add computed dimensions + if "n_atoms" in dim_mapping and "max_neighbors" in dim_mapping: + n_atoms = dim_mapping["n_atoms"] + max_neighbors = dim_mapping["max_neighbors"] + # n_edges = n_atoms * max_neighbors + n_edges = n_atoms * max_neighbors + if n_edges not in value_to_symbol and n_edges not in protected_values: + value_to_symbol[n_edges] = "n_edges" + # seq_len = n_atoms * (max_neighbors + 1) + seq_len = n_atoms * (max_neighbors + 1) + if seq_len not in value_to_symbol and seq_len not in protected_values: + value_to_symbol[seq_len] = "seq_len" + # max_neighbors_plus_one = max_neighbors + 1 (for concatenated node+neighbors) + mn_plus_one = max_neighbors + 1 + if mn_plus_one not in value_to_symbol and mn_plus_one not in protected_values: + value_to_symbol[mn_plus_one] = "max_neighbors_plus_one" + + def symbolize_shape(shape: List) -> List: + """Replace known concrete values with symbolic names.""" + result = [] + for dim in shape: + if isinstance(dim, int) and dim in value_to_symbol: + result.append(value_to_symbol[dim]) + else: + result.append(dim) + return result + + def symbolize_params(params: Dict) -> Dict: + """Symbolize dimension values in parameters. + + Only symbolizes shape-like parameters. Axis indices and other + positional parameters should not be symbolized. + """ + # Parameters that represent axis indices, not dimension sizes + # These should never be symbolized + axis_params = {"axes", "axis", "dim", "dim0", "dim1", "start_dim", "end_dim"} + + result = {} + for key, value in params.items(): + if key in axis_params: + # Don't symbolize axis indices + result[key] = value + elif key == "shape" and isinstance(value, list): + result[key] = symbolize_shape(value) + elif key == "new_shape" and isinstance(value, list): + result[key] = symbolize_shape(value) + elif key == "size" and isinstance(value, list): + result[key] = symbolize_shape(value) + elif key == "repeat_counts" and isinstance(value, list): + result[key] = symbolize_shape(value) + else: + # Don't symbolize other parameters by default + result[key] = value + return result + + # Symbolize input shapes + new_inputs = [] + for inp in graph.inputs: + new_inputs.append(GGMLInput( + name=inp.name, + dtype=inp.dtype, + shape=symbolize_shape(inp.shape), + dynamic_dims=inp.dynamic_dims, + )) + + # Symbolize output shapes + new_outputs = [] + for out in graph.outputs: + new_outputs.append(GGMLOutput( + name=out.name, + node_ref=out.node_ref, + dtype=out.dtype, + shape=symbolize_shape(out.shape), + )) + + # Symbolize node shapes and params + new_nodes = [] + for node in graph.nodes: + new_nodes.append(GGMLNode( + id=node.id, + op=node.op, + name=node.name, + inputs=node.inputs, + output_shape=symbolize_shape(node.output_shape), + output_dtype=node.output_dtype, + params=symbolize_params(node.params) if node.params else {}, + )) + + # Store dimension mapping in metadata + new_metadata = dict(graph.metadata) + new_metadata["dynamic_dims"] = dim_mapping + + return GGMLGraph( + version=graph.version, + model_type=graph.model_type, + inputs=new_inputs, + outputs=new_outputs, + nodes=new_nodes, + constants=graph.constants, + metadata=new_metadata, + ) diff --git a/scripts/export_pytorch/graph_ir.py b/scripts/export_pytorch/graph_ir.py new file mode 100644 index 0000000..bd94d04 --- /dev/null +++ b/scripts/export_pytorch/graph_ir.py @@ -0,0 +1,342 @@ +""" +GGML Intermediate Representation (GIR) data structures. + +This module defines the graph representation that can be serialized +to JSON and stored in GGUF files for runtime interpretation. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class GGMLDtype(Enum): + """GGML data types.""" + F32 = "f32" + F16 = "f16" + I32 = "i32" + I16 = "i16" + I8 = "i8" + BOOL = "bool" # Represented as I8 in GGML + + @classmethod + def from_torch_dtype(cls, dtype) -> "GGMLDtype": + """Convert torch dtype to GGML dtype.""" + import torch + mapping = { + torch.float32: cls.F32, + torch.float16: cls.F16, + torch.bfloat16: cls.F16, # Approximate as F16 + torch.int32: cls.I32, + torch.int16: cls.I16, + torch.int8: cls.I8, + torch.int64: cls.I32, # Downcast + torch.long: cls.I32, # Downcast + torch.bool: cls.BOOL, + torch.uint8: cls.I8, + } + if dtype not in mapping: + raise ValueError(f"Unsupported dtype: {dtype}") + return mapping[dtype] + + @classmethod + def from_string(cls, s: str) -> "GGMLDtype": + """Convert string to GGML dtype.""" + mapping = { + "f32": cls.F32, + "f16": cls.F16, + "i32": cls.I32, + "i16": cls.I16, + "i8": cls.I8, + "bool": cls.BOOL, + } + if s not in mapping: + raise ValueError(f"Unknown dtype string: {s}") + return mapping[s] + + +def _sanitize_shape(shape: list) -> list[int | str]: + """Convert shape to plain integers or symbolic dimension names. + + Symbolic dimensions are preserved as strings (e.g., "n_atoms", "max_neighbors"). + """ + result = [] + for dim in shape: + if isinstance(dim, int): + result.append(dim) + elif isinstance(dim, str): + # Symbolic dimension name - preserve it + result.append(dim) + else: + # SymInt or other symbolic type - try to convert to int + try: + result.append(int(dim)) + except (TypeError, ValueError): + result.append(-1) + return result + + +def _sanitize_params(params: dict) -> dict: + """Sanitize parameters for JSON serialization.""" + result = {} + for key, value in params.items(): + if isinstance(value, (int, float, str, bool, type(None))): + result[key] = value + elif isinstance(value, (list, tuple)): + result[key] = [_sanitize_value(v) for v in value] + elif isinstance(value, dict): + result[key] = _sanitize_params(value) + else: + result[key] = _sanitize_value(value) + return result + + +def _sanitize_value(value): + """Sanitize a single value for JSON serialization.""" + if isinstance(value, (int, float, str, bool, type(None))): + return value + try: + return int(value) + except (TypeError, ValueError): + try: + return float(value) + except (TypeError, ValueError): + return str(value) + + +@dataclass +class GGMLInput: + """Model input specification.""" + name: str + dtype: GGMLDtype + shape: list[int] # -1 for dynamic dimensions + dynamic_dims: list[int] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "name": self.name, + "dtype": self.dtype.value, + "shape": _sanitize_shape(self.shape), + "dynamic_dims": self.dynamic_dims, + } + + @classmethod + def from_dict(cls, d: dict) -> "GGMLInput": + return cls( + name=d["name"], + dtype=GGMLDtype(d["dtype"]), + shape=d["shape"], + dynamic_dims=d.get("dynamic_dims", []), + ) + + +@dataclass +class GGMLOutput: + """Model output specification.""" + name: str + node_ref: str # Reference to node that produces this output + dtype: GGMLDtype + shape: list[int] + + def to_dict(self) -> dict: + return { + "name": self.name, + "node_ref": self.node_ref, + "dtype": self.dtype.value, + "shape": _sanitize_shape(self.shape), + } + + @classmethod + def from_dict(cls, d: dict) -> "GGMLOutput": + return cls( + name=d["name"], + node_ref=d["node_ref"], + dtype=GGMLDtype(d["dtype"]), + shape=d["shape"], + ) + + +@dataclass +class GGMLNode: + """A node in the GGML computation graph.""" + id: int + op: str # GGML operation name (e.g., "ADD", "MUL_MAT") + name: str # Human-readable name for debugging + inputs: list[str] # References: "node:N", "input:name", "weight:name" + output_shape: list[int] + output_dtype: GGMLDtype + params: dict[str, Any] = field(default_factory=dict) # Op-specific parameters + + def to_dict(self) -> dict: + d = { + "id": self.id, + "op": self.op, + "name": self.name, + "inputs": self.inputs, + "output_shape": _sanitize_shape(self.output_shape), + "output_dtype": self.output_dtype.value, + } + if self.params: + d["params"] = _sanitize_params(self.params) + return d + + @classmethod + def from_dict(cls, d: dict) -> "GGMLNode": + return cls( + id=d["id"], + op=d["op"], + name=d["name"], + inputs=d["inputs"], + output_shape=d["output_shape"], + output_dtype=GGMLDtype(d["output_dtype"]), + params=d.get("params", {}), + ) + + +@dataclass +class GGMLGraph: + """Complete GGML computation graph.""" + version: str = "1.0.0" + model_type: str = "generic" + inputs: list[GGMLInput] = field(default_factory=list) + outputs: list[GGMLOutput] = field(default_factory=list) + nodes: list[GGMLNode] = field(default_factory=list) + constants: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + # For tracking during graph construction + _next_node_id: int = field(default=0, repr=False) + _node_name_counts: dict[str, int] = field(default_factory=dict, repr=False) + + def add_input(self, name: str, dtype: GGMLDtype, shape: list[int], + dynamic_dims: list[int] | None = None) -> GGMLInput: + """Add an input specification.""" + inp = GGMLInput( + name=name, + dtype=dtype, + shape=shape, + dynamic_dims=dynamic_dims or [], + ) + self.inputs.append(inp) + return inp + + def add_output(self, name: str, node_ref: str, dtype: GGMLDtype, + shape: list[int]) -> GGMLOutput: + """Add an output specification.""" + out = GGMLOutput( + name=name, + node_ref=node_ref, + dtype=dtype, + shape=shape, + ) + self.outputs.append(out) + return out + + def add_node(self, op: str, name: str, inputs: list[str], + output_shape: list[int], output_dtype: GGMLDtype, + params: dict[str, Any] | None = None) -> GGMLNode: + """Add a computation node.""" + # Generate unique name if needed + if name in self._node_name_counts: + self._node_name_counts[name] += 1 + unique_name = f"{name}_{self._node_name_counts[name]}" + else: + self._node_name_counts[name] = 0 + unique_name = name + + node = GGMLNode( + id=self._next_node_id, + op=op, + name=unique_name, + inputs=inputs, + output_shape=output_shape, + output_dtype=output_dtype, + params=params or {}, + ) + self.nodes.append(node) + self._next_node_id += 1 + return node + + def node_ref(self, node: GGMLNode) -> str: + """Get the reference string for a node.""" + return f"node:{node.id}" + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "$schema": "ggml-graph-v1", + "version": self.version, + "model_type": self.model_type, + "metadata": self.metadata, + "inputs": [i.to_dict() for i in self.inputs], + "outputs": [o.to_dict() for o in self.outputs], + "constants": self.constants, + "nodes": [n.to_dict() for n in self.nodes], + } + + def to_json(self, indent: int | None = None) -> str: + """Serialize to JSON string.""" + return json.dumps(self.to_dict(), indent=indent) + + @classmethod + def from_dict(cls, d: dict) -> "GGMLGraph": + """Create graph from dictionary.""" + graph = cls( + version=d.get("version", "1.0.0"), + model_type=d.get("model_type", "generic"), + metadata=d.get("metadata", {}), + constants=d.get("constants", {}), + ) + graph.inputs = [GGMLInput.from_dict(i) for i in d.get("inputs", [])] + graph.outputs = [GGMLOutput.from_dict(o) for o in d.get("outputs", [])] + graph.nodes = [GGMLNode.from_dict(n) for n in d.get("nodes", [])] + if graph.nodes: + graph._next_node_id = max(n.id for n in graph.nodes) + 1 + return graph + + @classmethod + def from_json(cls, json_str: str) -> "GGMLGraph": + """Deserialize from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + def __repr__(self) -> str: + return ( + f"GGMLGraph(model_type={self.model_type!r}, " + f"inputs={len(self.inputs)}, outputs={len(self.outputs)}, " + f"nodes={len(self.nodes)})" + ) + + def summary(self) -> str: + """Human-readable summary of the graph.""" + lines = [ + f"GGML Graph v{self.version}", + f"Model type: {self.model_type}", + "", + "Inputs:", + ] + for inp in self.inputs: + shape_str = str(_sanitize_shape(inp.shape)) + lines.append(f" {inp.name}: {inp.dtype.value} {shape_str}") + + lines.append("") + lines.append("Outputs:") + for out in self.outputs: + shape_str = str(_sanitize_shape(out.shape)) + lines.append(f" {out.name}: {out.dtype.value} {shape_str} <- {out.node_ref}") + + lines.append("") + lines.append(f"Nodes: {len(self.nodes)}") + + # Count ops + op_counts: dict[str, int] = {} + for node in self.nodes: + op_counts[node.op] = op_counts.get(node.op, 0) + 1 + + lines.append("Operation counts:") + for op, count in sorted(op_counts.items()): + lines.append(f" {op}: {count}") + + return "\n".join(lines) diff --git a/scripts/publish_ggufs.py b/scripts/publish_ggufs.py new file mode 100755 index 0000000..fca3043 --- /dev/null +++ b/scripts/publish_ggufs.py @@ -0,0 +1,205 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "huggingface_hub>=0.24", +# ] +# /// +"""Push converted GGUF models to a HuggingFace dataset/model repo. + +Typical flow: + # First time only + huggingface-cli login + + # Convert (produces gguf/*.gguf) + uv run scripts/convert_models.py + + # Publish + uv run scripts/publish_ggufs.py --repo peterspackman/mlip-gguf + +Re-run `publish_ggufs.py` any time you re-convert; only changed files are +uploaded (HF Hub content-addresses by hash). + +Attribution: the source checkpoints come from lab-cosmo/upet (BSD-3-Clause). +A README and LICENSE are written into the repo automatically on first push +unless they already exist in `--dir`. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from huggingface_hub import HfApi, create_repo +from huggingface_hub.utils import RepositoryNotFoundError + +REPO_DEFAULT = "peterspackman/mlip-gguf" +DIR_DEFAULT = Path("gguf") + +BSD_3_CLAUSE = """BSD 3-Clause License + +Copyright (c) 2024, COSMO lab, EPFL. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +""" + +README_TEMPLATE = """--- +license: bsd-3-clause +tags: +- mlip +- machine-learning-potentials +- ggml +- gguf +--- + +# MLIP GGUFs + +GGUF-format conversions of the uPET family of machine-learning interatomic +potentials, for use with [mlip.cpp](https://github.com/peterspackman/mlip.cpp) +and [mlip.js](https://github.com/peterspackman/mlip.cpp/tree/main/packages/mlip.js). + +## Source + +Checkpoints converted from [`lab-cosmo/upet`](https://huggingface.co/lab-cosmo/upet) +(BSD-3-Clause). See the LICENSE file in this repo. + +## Usage + +```python +from huggingface_hub import hf_hub_download +path = hf_hub_download(repo_id="{repo}", filename="pet-mad-s.gguf") +``` + +Or in the browser via `mlip.js`: + +```js +const buf = await fetch( + "https://huggingface.co/{repo}/resolve/main/pet-mad-s.gguf" +).then(r => r.arrayBuffer()) +const model = await Model.loadFromBuffer(buf) +``` + +## Files + +{file_list} + +## Conversion + +These files are produced by `scripts/convert_models.py` in the mlip.cpp repo, +which wraps `scripts/export_pytorch/export_pet_gguf.py` (an exact torch.export +of the PyTorch forward + backward graph into GGUF tensors + a graph interpreter +preamble). +""" + + +def ensure_license(directory: Path) -> Path: + path = directory / "LICENSE" + if not path.exists(): + path.write_text(BSD_3_CLAUSE) + print(f"wrote {path}") + return path + + +def ensure_readme(directory: Path, repo: str, gguf_files: list[Path]) -> Path: + path = directory / "README.md" + if path.exists(): + return path + entries = [] + for f in sorted(gguf_files): + size_mb = f.stat().st_size / (1024 * 1024) + entries.append(f"- `{f.name}` ({size_mb:.1f} MB)") + content = README_TEMPLATE.format( + repo=repo, + file_list="\n".join(entries) if entries else "_(no files yet)_", + ) + path.write_text(content) + print(f"wrote {path}") + return path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=REPO_DEFAULT, + help=f"HuggingFace repo id (default: {REPO_DEFAULT})") + parser.add_argument("--dir", type=Path, default=DIR_DEFAULT, + help=f"Directory containing .gguf files (default: {DIR_DEFAULT})") + parser.add_argument("--commit-message", default=None, + help="Custom commit message") + parser.add_argument("--private", action="store_true", + help="Create the repo as private if it doesn't exist") + parser.add_argument("--dry-run", action="store_true", + help="Print what would be uploaded without pushing") + args = parser.parse_args() + + if not args.dir.exists(): + print(f"Directory not found: {args.dir}") + print("Run `uv run scripts/convert_models.py` first.") + return 1 + + gguf_files = sorted(args.dir.glob("*.gguf")) + if not gguf_files: + print(f"No .gguf files found in {args.dir}") + return 1 + + ensure_license(args.dir) + ensure_readme(args.dir, args.repo, gguf_files) + + total_mb = sum(f.stat().st_size for f in gguf_files) / (1024 * 1024) + print(f"\nRepo: {args.repo}") + print(f"Source: {args.dir}") + print(f"Files: {len(gguf_files)} gguf + LICENSE + README ({total_mb:.1f} MB total)") + for f in gguf_files: + size_mb = f.stat().st_size / (1024 * 1024) + print(f" - {f.name} ({size_mb:.1f} MB)") + + if args.dry_run: + print("\n(dry-run — not uploading)") + return 0 + + api = HfApi() + try: + api.repo_info(args.repo) + print(f"\nRepo {args.repo} exists, uploading…") + except RepositoryNotFoundError: + print(f"\nRepo {args.repo} not found, creating…") + create_repo(args.repo, repo_type="model", private=args.private, exist_ok=True) + + commit_message = args.commit_message or f"Update GGUFs ({len(gguf_files)} models, {total_mb:.0f} MB)" + api.upload_folder( + folder_path=str(args.dir), + repo_id=args.repo, + repo_type="model", + allow_patterns=["*.gguf", "README.md", "LICENSE"], + commit_message=commit_message, + ) + print(f"\nPushed to https://huggingface.co/{args.repo}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_all_models.py b/scripts/test_all_models.py new file mode 100755 index 0000000..a0531cd --- /dev/null +++ b/scripts/test_all_models.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Test all PET models against PyTorch reference on all geometries. + +Usage: + uv run scripts/test_all_models.py [--models MODEL1,MODEL2] [--geometries water.xyz,urea.xyz] + +Examples: + uv run scripts/test_all_models.py # Test all models on all geometries + uv run scripts/test_all_models.py --models pet-mad-s # Test only pet-mad-s + uv run scripts/test_all_models.py --forces # Test with forces +""" + +import argparse +import subprocess +import sys +import json +import tempfile +import numpy as np +from pathlib import Path + +# Available PET models (from HuggingFace lab-cosmo/upet) +AVAILABLE_MODELS = [ + "pet-mad-s", + "pet-omad-xs", + "pet-omad-s", + "pet-omat-xs", + "pet-omat-s", + "pet-spice-s", +] + +def get_geometries(geometries_dir: Path) -> list[Path]: + """Get all XYZ files in the geometries directory.""" + return sorted(geometries_dir.glob("*.xyz")) + +EXPORT_TIMEOUT = 300 # 5 minutes for model download + tracing +INFERENCE_TIMEOUT = 120 # 2 minutes per inference + +def export_model(model_name: str, output_dir: Path, forces: bool = False) -> bool: + """Export a PET model using export_pet_full.py.""" + cmd = [ + "uv", "run", "scripts/export_pytorch/export_pet_full.py", + "--model", model_name, + "-o", str(output_dir), + ] + if forces: + cmd.append("--forces") + + print(f" Exporting {model_name}{'(forces)' if forces else ''}...") + try: + result = subprocess.run(cmd, capture_output=True, text=True, + timeout=EXPORT_TIMEOUT) + except subprocess.TimeoutExpired: + print(f" ERROR: Export timed out after {EXPORT_TIMEOUT}s") + return False + if result.returncode != 0: + print(f" ERROR: Export failed") + print(f" {result.stderr[:500]}") + return False + return True + +def run_cpp_inference(model_dir: Path, xyz_path: Path, forces: bool = False) -> dict | None: + """Run C++ graph_inference and parse results.""" + cmd = ["./build/bin/graph_inference", str(model_dir), str(xyz_path)] + if forces: + cmd.append("--forces") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, + timeout=INFERENCE_TIMEOUT) + except subprocess.TimeoutExpired: + print(f" C++ ERROR: timed out after {INFERENCE_TIMEOUT}s") + return None + if result.returncode != 0: + print(f" C++ ERROR: {result.stderr[:200]}") + return None + + # Parse output + output = result.stdout + data = {"atomic_energies": [], "forces": None} + + # Parse atomic energies + in_energies = False + in_forces = False + forces_list = [] + + for line in output.split("\n"): + if "Atomic energies:" in line: + in_energies = True + continue + if "Forces:" in line: + in_energies = False + in_forces = True + continue + if in_energies and line.strip().startswith("Atom"): + # " Atom 0: 1.234567 eV" + parts = line.split(":") + if len(parts) >= 2: + energy = float(parts[1].strip().split()[0]) + data["atomic_energies"].append(energy) + if in_forces and line.strip().startswith("Atom"): + # " Atom 0: [1.23, 4.56, 7.89] eV/A" + parts = line.split(":") + if len(parts) >= 2: + force_str = parts[1].strip() + # Extract [x, y, z] + import re + match = re.search(r'\[([-\d.e+]+),\s*([-\d.e+]+),\s*([-\d.e+]+)\]', force_str) + if match: + fx, fy, fz = map(float, match.groups()) + forces_list.append([fx, fy, fz]) + if "Model energy (raw):" in line: + data["raw_energy"] = float(line.split(":")[1].strip().split()[0]) + + if forces_list: + data["forces"] = forces_list + + return data + +def run_python_reference(model_name: str, xyz_path: Path, forces: bool = False) -> dict | None: + """Run PyTorch reference computation.""" + # Import here to avoid slow startup + sys.path.insert(0, str(Path(__file__).parent)) + from export_pytorch.export_pet_full import PETFullModel, load_pet_model, get_model_params, get_species_mapping + + import torch + from ase.io import read + + # Load model + try: + model = load_pet_model(model_name) + params = get_model_params(model) + species_map = get_species_mapping(model) + except Exception as e: + print(f" Python ERROR loading model: {e}") + return None + + # Read structure + atoms = read(str(xyz_path)) + positions = atoms.get_positions() + atomic_numbers = atoms.get_atomic_numbers() + n_atoms = len(atoms) + + # Build neighbor list + from ase.neighborlist import neighbor_list + cutoff = params['cutoff'] + + i_list, j_list, d_list, D_list = neighbor_list('ijdD', atoms, cutoff, self_interaction=False) + + # Build padded arrays + neighbor_counts = np.bincount(i_list, minlength=n_atoms) + max_neighbors = int(neighbor_counts.max()) if len(neighbor_counts) > 0 else 1 + + # Prepare tensors + species = torch.tensor([species_map.get(Z, 0) for Z in atomic_numbers], dtype=torch.long) + neighbor_species = torch.zeros(n_atoms, max_neighbors, dtype=torch.long) + edge_vectors = torch.zeros(n_atoms, max_neighbors, 3, dtype=torch.float32) + edge_distances = torch.zeros(n_atoms, max_neighbors, dtype=torch.float32) + padding_mask = torch.ones(n_atoms, max_neighbors, dtype=torch.bool) # True = padded + cutoff_factors = torch.zeros(n_atoms, max_neighbors, dtype=torch.float32) + reverse_neighbor_index = torch.zeros(n_atoms, max_neighbors, dtype=torch.long) + + # Fill arrays + slot_indices = np.zeros(n_atoms, dtype=np.int32) + edge_to_flat = {} + + for e, (i, j, d, D) in enumerate(zip(i_list, j_list, d_list, D_list)): + slot = slot_indices[i] + if slot >= max_neighbors: + continue + slot_indices[i] += 1 + + flat_idx = i * max_neighbors + slot + edge_to_flat[(i, j)] = flat_idx + + neighbor_species[i, slot] = species_map.get(atomic_numbers[j], 0) + edge_vectors[i, slot] = torch.tensor(D, dtype=torch.float32) + edge_distances[i, slot] = d + padding_mask[i, slot] = False # Valid edge + + # Cutoff factor + width = params.get('cutoff_width', 0.2) + if d <= cutoff - width: + cutoff_factors[i, slot] = 1.0 + elif d < cutoff: + scaled = (d - (cutoff - width)) / width + cutoff_factors[i, slot] = 0.5 * (1.0 + np.cos(np.pi * scaled)) + + # Build reverse neighbor index + for e, (i, j) in enumerate(zip(i_list, j_list)): + if (i, j) in edge_to_flat and (j, i) in edge_to_flat: + flat_ij = edge_to_flat[(i, j)] + flat_ji = edge_to_flat[(j, i)] + slot_ij = flat_ij % max_neighbors + reverse_neighbor_index[i, slot_ij] = flat_ji + + # Create wrapper and run + wrapper = PETFullModel( + model, n_atoms=n_atoms, max_neighbors=max_neighbors, + d_pet=params['d_pet'], forces=forces, + cutoff=params['cutoff'], cutoff_width=params.get('cutoff_width', 0.2) + ) + wrapper.eval() + + if forces: + edge_vectors.requires_grad_(True) + + with torch.set_grad_enabled(forces): + if forces: + result = wrapper(species, neighbor_species, edge_vectors, padding_mask, reverse_neighbor_index) + else: + result = wrapper(species, neighbor_species, edge_vectors, edge_distances, + padding_mask, reverse_neighbor_index, cutoff_factors) + + data = { + "atomic_energies": result.detach().numpy().flatten().tolist(), + "raw_energy": float(result.sum().item()), + } + + if forces: + # Compute forces via backward pass + total_energy = result.sum() + total_energy.backward() + + # Scatter edge gradients to atom forces + grad = edge_vectors.grad # [n_atoms, max_neighbors, 3] + forces_np = np.zeros((n_atoms, 3)) + + for i in range(n_atoms): + for slot in range(max_neighbors): + if not padding_mask[i, slot]: + forces_np[i] -= grad[i, slot].numpy() + + data["forces"] = forces_np.tolist() + + return data + +def compare_results(cpp_data: dict, py_data: dict, forces: bool = False) -> dict: + """Compare C++ and Python results.""" + cpp_energies = np.array(cpp_data["atomic_energies"]) + py_energies = np.array(py_data["atomic_energies"]) + + energy_diff = np.abs(cpp_energies - py_energies) + + result = { + "energy_max_diff": float(energy_diff.max()), + "energy_mean_diff": float(energy_diff.mean()), + "total_energy_diff": abs(cpp_energies.sum() - py_energies.sum()), + "pass": energy_diff.max() < 1e-2, # 10 meV tolerance + } + + if forces and cpp_data.get("forces") and py_data.get("forces"): + cpp_forces = np.array(cpp_data["forces"]) + py_forces = np.array(py_data["forces"]) + force_diff = np.abs(cpp_forces - py_forces) + + result["force_max_diff"] = float(force_diff.max()) + result["force_mean_diff"] = float(force_diff.mean()) + result["pass"] = result["pass"] and force_diff.max() < 1e-2 # 10 meV/A tolerance + + return result + +def main(): + parser = argparse.ArgumentParser(description="Test PET models against PyTorch reference") + parser.add_argument("--models", type=str, default=None, + help="Comma-separated list of models to test (default: all)") + parser.add_argument("--geometries", type=str, default=None, + help="Comma-separated list of geometry files (default: all in geometries/)") + parser.add_argument("--forces", action="store_true", + help="Test with forces computation") + parser.add_argument("--keep-exports", action="store_true", + help="Keep exported model directories (in /tmp/)") + args = parser.parse_args() + + # Get models to test + if args.models: + models = [m.strip() for m in args.models.split(",")] + else: + models = AVAILABLE_MODELS + + # Get geometries to test + geometries_dir = Path("geometries") + if args.geometries: + geometries = [geometries_dir / g.strip() for g in args.geometries.split(",")] + else: + geometries = get_geometries(geometries_dir) + + if not geometries: + print("No geometry files found!") + return 1 + + print(f"Testing {len(models)} model(s) on {len(geometries)} geometry file(s)") + print(f"Forces: {'Yes' if args.forces else 'No'}") + print("=" * 70) + + results_summary = [] + + for model_name in models: + print(f"\n[{model_name}]") + + # Export model + export_dir = Path(f"/tmp/test_model_{model_name.replace('-', '_')}") + if not export_model(model_name, export_dir, forces=args.forces): + results_summary.append({"model": model_name, "status": "EXPORT_FAILED"}) + continue + + for xyz_path in geometries: + if not xyz_path.exists(): + print(f" {xyz_path.name}: SKIP (file not found)") + continue + + print(f" {xyz_path.name}:") + + # Run C++ inference + cpp_data = run_cpp_inference(export_dir, xyz_path, forces=args.forces) + if cpp_data is None: + results_summary.append({ + "model": model_name, "geometry": xyz_path.name, + "status": "CPP_FAILED" + }) + continue + + # Run Python reference + py_data = run_python_reference(model_name, xyz_path, forces=args.forces) + if py_data is None: + results_summary.append({ + "model": model_name, "geometry": xyz_path.name, + "status": "PYTHON_FAILED" + }) + continue + + # Compare + comparison = compare_results(cpp_data, py_data, forces=args.forces) + + status = "PASS" if comparison["pass"] else "FAIL" + energy_info = f"E_diff: {comparison['energy_max_diff']:.6f} eV" + + if args.forces and "force_max_diff" in comparison: + force_info = f", F_diff: {comparison['force_max_diff']:.6f} eV/A" + else: + force_info = "" + + print(f" {status} - {energy_info}{force_info}") + print(f" C++: {cpp_data['raw_energy']:.6f} eV, Python: {py_data['raw_energy']:.6f} eV") + + results_summary.append({ + "model": model_name, + "geometry": xyz_path.name, + "status": status, + **comparison + }) + + # Cleanup export directory + if not args.keep_exports: + import shutil + shutil.rmtree(export_dir, ignore_errors=True) + + # Summary + print("\n" + "=" * 70) + print("SUMMARY") + print("=" * 70) + + passed = sum(1 for r in results_summary if r.get("status") == "PASS") + failed = sum(1 for r in results_summary if r.get("status") == "FAIL") + errors = sum(1 for r in results_summary if r.get("status") not in ("PASS", "FAIL")) + + print(f"Passed: {passed}, Failed: {failed}, Errors: {errors}") + + if failed > 0: + print("\nFailed tests:") + for r in results_summary: + if r.get("status") == "FAIL": + print(f" {r['model']} / {r['geometry']}: E_diff={r.get('energy_max_diff', '?'):.6f}") + + if errors > 0: + print("\nErrors:") + for r in results_summary: + if r.get("status") not in ("PASS", "FAIL"): + print(f" {r['model']}: {r.get('status', 'UNKNOWN')}") + + return 0 if (failed == 0 and errors == 0) else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_all_models.sh b/scripts/test_all_models.sh new file mode 100755 index 0000000..c26be10 --- /dev/null +++ b/scripts/test_all_models.sh @@ -0,0 +1,274 @@ +#!/bin/bash +# Test all PET models: export, run C++ inference, compare with PyTorch reference +# +# Usage: +# ./scripts/test_all_models.sh [--model ] [--energy-only] +# +# By default tests energy + forces. Use --energy-only to skip forces. +# If --model is given, only test that one model. Otherwise test all. + +# Portable timeout wrapper (macOS lacks GNU timeout). +# Runs command, captures stdout+stderr to a file, kills after $secs seconds. +# Usage: run_with_timeout [args...] +# Exit code: command's exit code, or 124 on timeout. +run_with_timeout() { + local secs=$1; shift + local outfile=$1; shift + "$@" > "$outfile" 2>&1 & + local pid=$! + ( sleep "$secs" && kill "$pid" 2>/dev/null ) & + local watchdog=$! + wait "$pid" 2>/dev/null + local ret=$? + kill "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null + # If killed by signal, return 124 (matching GNU timeout convention) + if [[ $ret -gt 128 ]]; then + return 124 + fi + return $ret +} + +ENERGY_ONLY="" +FILTER_MODEL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --energy-only) ENERGY_ONLY="1"; shift ;; + --model) FILTER_MODEL="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# Models to test (all available small/xs HuggingFace models) +MODELS=( + "pet-mad-s" + "pet-omad-xs" + "pet-omad-s" + "pet-omat-xs" + "pet-omat-s" + "pet-spice-s" +) + +# Filter to single model if requested +if [[ -n "$FILTER_MODEL" ]]; then + MODELS=("$FILTER_MODEL") +fi + +# Geometries to test +GEOMETRIES=( + "geometries/water.xyz" + "geometries/urea.xyz" + "geometries/urea_molecule.xyz" + "geometries/si.xyz" +) + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +FORCES_FLAG="" +if [[ -z "$ENERGY_ONLY" ]]; then + FORCES_FLAG="--forces" + echo "Testing energy + forces" +else + echo "Testing energy only" +fi + +echo "========================================" +echo "PET Model Comparison: C++ vs PyTorch" +echo "========================================" +echo "" + +# Create temp directory for intermediate files +TEST_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/test_pet_XXXXXX") +trap "rm -rf '$TEST_TMPDIR'" EXIT + +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 + +for MODEL in "${MODELS[@]}"; do + echo -e "${YELLOW}[$MODEL]${NC}" + + EXPORT_DIR="/tmp/test_${MODEL//-/_}" + + # Export model (with --forces unless energy-only) + # Timeout: 300s for export (model download + tracing can be slow) + echo " Exporting..." + run_with_timeout 300 "$TEST_TMPDIR/export_out.txt" uv run scripts/export_pytorch/export_pet_full.py --model "$MODEL" $FORCES_FLAG -o "$EXPORT_DIR" + if [[ $? -ne 0 ]]; then + echo -e " ${RED}EXPORT FAILED${NC}" + tail -5 "$TEST_TMPDIR/export_out.txt" | sed 's/^/ /' + ((FAIL_COUNT++)) + echo "" + continue + fi + + for GEOM in "${GEOMETRIES[@]}"; do + GEOM_NAME=$(basename "$GEOM") + + if [[ ! -f "$GEOM" ]]; then + echo " $GEOM_NAME: SKIP (not found)" + ((SKIP_COUNT++)) + continue + fi + + # Run C++ inference (timeout: 120s per geometry) + if [[ -n "$FORCES_FLAG" ]]; then + run_with_timeout 120 "$TEST_TMPDIR/cpp_out.txt" ./build/bin/graph_inference "$EXPORT_DIR" "$GEOM" --forces + else + run_with_timeout 120 "$TEST_TMPDIR/cpp_out.txt" ./build/bin/graph_inference "$EXPORT_DIR" "$GEOM" + fi + CPP_EXIT=$? + CPP_OUTPUT=$(cat "$TEST_TMPDIR/cpp_out.txt") + + if [[ $CPP_EXIT -ne 0 ]]; then + echo -e " $GEOM_NAME: ${RED}C++ FAILED${NC}" + tail -3 "$TEST_TMPDIR/cpp_out.txt" | sed 's/^/ /' + ((FAIL_COUNT++)) + continue + fi + + CPP_ENERGY=$(echo "$CPP_OUTPUT" | grep "Total energy:" | awk '{print $3}') + CPP_TIME=$(echo "$CPP_OUTPUT" | grep "Compute time:" | awk '{print $3}') + + # Run Python reference (timeout: 120s; forces on by default, skip stress) + run_with_timeout 120 "$TEST_TMPDIR/py_out.txt" uv run scripts/calc_energy_pytorch.py "$GEOM" --model "$MODEL" --no-stress + PY_OUTPUT=$(cat "$TEST_TMPDIR/py_out.txt") + PY_ENERGY=$(echo "$PY_OUTPUT" | grep "^Energy:" | head -1 | awk '{print $2}') + PY_TIME=$(echo "$PY_OUTPUT" | grep "^Time:" | awk '{print $2}' | sed 's/s$//') + + # Compare energies + if [[ -z "$CPP_ENERGY" ]] || [[ -z "$PY_ENERGY" ]]; then + # Check if Python failed due to unsupported species + if echo "$PY_OUTPUT" | grep -q "does not support the atomic type"; then + echo " $GEOM_NAME: SKIP (unsupported species)" + ((SKIP_COUNT++)) + else + echo -e " $GEOM_NAME: ${RED}ERROR${NC} - Could not parse energies" + echo " C++ output: ${CPP_ENERGY:-'(none)'}" + echo " Python output: ${PY_ENERGY:-'(none)'}" + ((FAIL_COUNT++)) + fi + continue + fi + + # Calculate energy difference + EDIFF=$(python3 -c "print(f'{abs($CPP_ENERGY - ($PY_ENERGY)):.6f}')") + # Energy tolerance: 0.01 eV accounts for float32 accumulation differences + # between GGML's graph interpreter and PyTorch's eager evaluation. + # Typical diffs are <10 μeV; 0.01 eV catches gross errors. + EPASS=$(python3 -c "print('PASS' if abs($CPP_ENERGY - ($PY_ENERGY)) < 0.01 else 'FAIL')") + + # Compare forces if enabled + FPASS="SKIP" + FMAE="" + FMAX_DIFF="" + if [[ -z "$ENERGY_ONLY" ]]; then + FORCE_RESULT=$(python3 -c " +import re, sys + +def parse_forces(text): + forces = [] + in_forces = False + for line in text.split('\n'): + if 'Forces' in line: + in_forces = True + continue + if in_forces: + m = re.match(r'\s*Atom\s+\d+\s*(?:\([^)]*\))?\s*:\s*\[([^\]]+)\]', line) + if m: + vals = [float(x.strip()) for x in m.group(1).split(',')] + forces.append(vals) + elif forces and not line.strip().startswith('Atom'): + break + return forces + +cpp_text = open('$TEST_TMPDIR/cpp_out.txt').read() +py_text = open('$TEST_TMPDIR/py_out.txt').read() + +cpp_forces = parse_forces(cpp_text) +py_forces = parse_forces(py_text) + +if not cpp_forces or not py_forces: + print(f'PARSE_ERROR cpp={len(cpp_forces)} py={len(py_forces)}') + sys.exit(0) + +if len(cpp_forces) != len(py_forces): + print(f'LENGTH_MISMATCH cpp={len(cpp_forces)} py={len(py_forces)}') + sys.exit(0) + +total_ae = 0.0 +max_ae = 0.0 +count = 0 +for cf, pf in zip(cpp_forces, py_forces): + for cv, pv in zip(cf, pf): + ae = abs(cv - pv) + total_ae += ae + max_ae = max(max_ae, ae) + count += 1 + +mae = total_ae / count if count > 0 else 0.0 +# Force tolerance: 0.05 eV/A max component error. Backward pass through +# decomposed layer norm and attention accumulates more error than the +# forward pass. Typical max diffs are <0.01 eV/A. +status = 'PASS' if max_ae < 0.05 else 'FAIL' +print(f'{status} {mae:.6f} {max_ae:.6f}') +") + FPASS=$(echo "$FORCE_RESULT" | awk '{print $1}') + FMAE=$(echo "$FORCE_RESULT" | awk '{print $2}') + FMAX_DIFF=$(echo "$FORCE_RESULT" | awk '{print $3}') + fi + + # Print results + if [[ "$EPASS" == "PASS" ]] && { [[ "$FPASS" == "PASS" ]] || [[ "$FPASS" == "SKIP" ]]; }; then + echo -e " $GEOM_NAME: ${GREEN}PASS${NC} (E diff: ${EDIFF} eV)" + ((PASS_COUNT++)) + else + echo -e " $GEOM_NAME: ${RED}FAIL${NC} (E diff: ${EDIFF} eV)" + ((FAIL_COUNT++)) + fi + + echo " E: C++=${CPP_ENERGY} Py=${PY_ENERGY} eV" + + if [[ -n "$FMAE" ]] && [[ "$FPASS" != "PARSE_ERROR" ]] && [[ "$FPASS" != "LENGTH_MISMATCH" ]]; then + if [[ "$FPASS" == "PASS" ]]; then + echo -e " F: ${GREEN}PASS${NC} MAE=${FMAE} max=${FMAX_DIFF} eV/A" + else + echo -e " F: ${RED}FAIL${NC} MAE=${FMAE} max=${FMAX_DIFF} eV/A" + fi + elif [[ "$FPASS" == "PARSE_ERROR" ]] || [[ "$FPASS" == "LENGTH_MISMATCH" ]]; then + echo -e " F: ${RED}${FORCE_RESULT}${NC}" + fi + + # Timing + if [[ -n "$CPP_TIME" ]] && [[ -n "$PY_TIME" ]]; then + SPEEDUP=$(python3 -c " +cpp_s = $CPP_TIME / 1000.0 +py_s = $PY_TIME +if cpp_s > 0: + print(f'{py_s/cpp_s:.1f}') +else: + print('inf') +") + echo -e " ${CYAN}Time: C++=${CPP_TIME}ms Py=${PY_TIME}s (${SPEEDUP}x)${NC}" + elif [[ -n "$CPP_TIME" ]]; then + echo -e " ${CYAN}Time: C++=${CPP_TIME}ms${NC}" + fi + done + + # Cleanup + rm -rf "$EXPORT_DIR" + echo "" +done + +echo "========================================" +echo "Summary: $PASS_COUNT passed, $FAIL_COUNT failed, $SKIP_COUNT skipped" +echo "========================================" + +if [[ $FAIL_COUNT -gt 0 ]]; then + exit 1 +fi diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 04989ce..c65b57c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,13 @@ set(MLIPCPP_IO_SOURCES io/xyz.cpp ) +# Runtime (graph interpreter) +set(MLIPCPP_RUNTIME_SOURCES + runtime/graph_ir.cpp + runtime/graph_interpreter.cpp + runtime/graph_model.cpp +) + # Utilities set(MLIPCPP_UTIL_SOURCES ggml_attention.cpp @@ -42,6 +49,7 @@ foreach(src IN LISTS MLIPCPP_CORE_SOURCES MLIPCPP_MODEL_SOURCES MLIPCPP_IO_SOURCES + MLIPCPP_RUNTIME_SOURCES MLIPCPP_UTIL_SOURCES MLIPCPP_API_SOURCES) list(APPEND MLIPCPP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/${src}) @@ -63,6 +71,7 @@ target_include_directories(mlipcpp target_link_libraries(mlipcpp PRIVATE ggml + nlohmann_json::nlohmann_json $<$>:fmt::fmt> ) diff --git a/src/api/c/mlipcpp_api.cpp b/src/api/c/mlipcpp_api.cpp index 288e1e2..7e83cdc 100644 --- a/src/api/c/mlipcpp_api.cpp +++ b/src/api/c/mlipcpp_api.cpp @@ -12,10 +12,12 @@ #include "mlipcpp/mlipcpp.h" #include "core/backend.h" +#include "core/gguf_loader.h" #include "core/log.h" #include "mlipcpp/model.h" #include "mlipcpp/system.h" #include "models/pet/pet.h" +#include "runtime/graph_model.h" #include #include #include @@ -26,10 +28,13 @@ /** * @brief Internal model implementation * - * Wraps the C++ PETModel and stores the last result for zero-copy access. + * Wraps the C++ Model interface and stores the last result for zero-copy access. + * Model creation is deferred until load time so we can read the architecture + * from the GGUF file and create the appropriate model type. */ struct mlipcpp_model_impl { - std::unique_ptr model; + std::unique_ptr model; + mlipcpp_model_options_t options; mlipcpp::ModelResult last_result; bool weights_loaded = false; int32_t last_n_atoms = 0; // Track atoms in last result @@ -224,12 +229,9 @@ mlipcpp_model_t mlipcpp_model_create(const mlipcpp_model_options_t *options) { options = &default_opts; } - // Create internal model structure + // Create internal model structure (model creation deferred to load time) auto impl = std::make_unique(); - - // Create PETModel with default hypers (will be overridden by GGUF) - mlipcpp::pet::PETHypers hypers; - impl->model = std::make_unique(hypers); + impl->options = *options; // Update global backend preference if specified in options auto backend_pref = to_backend_preference(options->backend); @@ -237,17 +239,6 @@ mlipcpp_model_t mlipcpp_model_create(const mlipcpp_model_options_t *options) { mlipcpp_set_backend(options->backend); } - // Set backend provider (uses global shared backend) - impl->model->set_backend(get_global_backend()); - - // Set compute precision - impl->model->set_precision(to_compute_precision(options->precision)); - - // Override cutoff if requested - if (options->cutoff_override > 0.0f) { - impl->model->set_cutoff(options->cutoff_override); - } - clear_error(); return impl.release(); } catch (const std::exception &e) { @@ -270,10 +261,46 @@ mlipcpp_error_t mlipcpp_model_load(mlipcpp_model_t model, const char *path) { } try { - bool success = model->model->load_from_gguf(path); - if (!success) { - set_error(std::string("Failed to load model from: ") + path); - return MLIPCPP_ERROR_IO; + // Read architecture from GGUF to determine which model to create + mlipcpp::GGUFLoader loader(path); + std::string arch = loader.get_string("general.architecture", ""); + + if (arch == "pet") { + auto pet_model = std::make_unique( + mlipcpp::pet::PETHypers{}); + + // Set backend provider + pet_model->set_backend(get_global_backend()); + + // Set compute precision + pet_model->set_precision( + to_compute_precision(model->options.precision)); + + // Override cutoff if requested + if (model->options.cutoff_override > 0.0f) { + pet_model->set_cutoff(model->options.cutoff_override); + } + + if (!pet_model->load_from_gguf(path)) { + set_error(std::string("Failed to load PET model from: ") + path); + return MLIPCPP_ERROR_IO; + } + + model->model = std::move(pet_model); + } else if (arch == "pet-graph") { + auto graph_model = std::make_unique(); + graph_model->set_backend_preference( + to_backend_preference(model->options.backend)); + + if (!graph_model->load_from_gguf(path)) { + set_error(std::string("Failed to load graph model from: ") + path); + return MLIPCPP_ERROR_IO; + } + + model->model = std::move(graph_model); + } else { + set_error(std::string("Unsupported model architecture: ") + arch); + return MLIPCPP_ERROR_UNSUPPORTED; } model->weights_loaded = true; @@ -510,15 +537,28 @@ mlipcpp_error_t mlipcpp_predict_with_options(mlipcpp_model_t model, // Run prediction using predict_batch for NC forces support auto *pet_model = dynamic_cast(model->model.get()); if (pet_model) { + const bool compute_grad = + (options->compute_forces || options->compute_stress) && + !options->use_nc_forces; auto results = pet_model->predict_batch( {cpp_system}, - options->compute_forces && !options->use_nc_forces, // gradient-based forces - options->use_nc_forces // NC forces from forward pass + compute_grad, // gradient-based outputs + options->use_nc_forces // NC outputs from forward pass ); model->last_result = std::move(results[0]); } else { // Fallback for non-PET models - model->last_result = model->model->predict(cpp_system, options->compute_forces); + model->last_result = model->model->predict( + cpp_system, options->compute_forces || options->compute_stress); + } + + if (!options->compute_forces) { + model->last_result.forces.clear(); + model->last_result.has_forces = false; + } + if (!options->compute_stress) { + model->last_result.stress.clear(); + model->last_result.has_stress = false; } model->last_n_atoms = system->n_atoms; diff --git a/src/api/cpp/mlipcpp_cpp.cpp b/src/api/cpp/mlipcpp_cpp.cpp index 6d901f4..36f92c4 100644 --- a/src/api/cpp/mlipcpp_cpp.cpp +++ b/src/api/cpp/mlipcpp_cpp.cpp @@ -10,6 +10,7 @@ #include "mlipcpp/model.h" #include "mlipcpp/system.h" #include "models/pet/pet.h" +#include "runtime/graph_model.h" #include namespace mlipcpp { @@ -43,6 +44,8 @@ static BackendPreference to_internal(Backend b) { return BackendPreference::Metal; case Backend::Vulkan: return BackendPreference::Vulkan; + case Backend::WebGPU: + return BackendPreference::WebGPU; case Backend::SYCL: return BackendPreference::SYCL; case Backend::CANN: @@ -108,6 +111,16 @@ struct Predictor::Impl { model_type_str = "PET"; model = std::move(pet_model); + } else if (arch == "pet-graph") { + auto graph_model = std::make_unique(); + graph_model->set_backend_preference(to_internal(options.backend)); + + if (!graph_model->load_from_gguf(path)) { + throw std::runtime_error("Failed to load graph model from: " + path); + } + + model_type_str = "PET-Graph"; + model = std::move(graph_model); } else { throw std::runtime_error("Unsupported model architecture: " + arch); } @@ -131,25 +144,35 @@ struct Predictor::Impl { // Use predict_batch for NC forces support auto *pet_model = dynamic_cast(model.get()); if (pet_model) { + const bool compute_grad = + (options.compute_forces || options.compute_stress) && + !options.use_nc_forces; auto internal_results = pet_model->predict_batch( {system}, - options.compute_forces && !options.use_nc_forces, // gradient-based forces - options.use_nc_forces // NC forces from forward pass + compute_grad, // gradient-based outputs + options.use_nc_forces // NC outputs from forward pass ); auto &internal_result = internal_results[0]; Result result; result.energy = internal_result.energy; - if (internal_result.has_forces) { + if (options.compute_forces && internal_result.has_forces) { result.forces = std::move(internal_result.forces); } - if (internal_result.has_stress) { + if (options.compute_stress && internal_result.has_stress) { result.stress = std::move(internal_result.stress); } return result; } else { // Fallback for non-PET models - return predict_impl(system, options.compute_forces); + auto result = predict_impl(system, options.compute_forces || options.compute_stress); + if (!options.compute_forces) { + result.forces.clear(); + } + if (!options.compute_stress) { + result.stress.clear(); + } + return result; } } }; diff --git a/src/api/python/mlipcpp_bindings.cpp b/src/api/python/mlipcpp_bindings.cpp index fe506d2..b543ff8 100644 --- a/src/api/python/mlipcpp_bindings.cpp +++ b/src/api/python/mlipcpp_bindings.cpp @@ -136,9 +136,13 @@ NB_MODULE(_mlipcpp, m) { pbc_ptr = pbc_arr.data(); } + mlipcpp::PredictOptions options; + options.compute_forces = compute_forces || compute_stress; + options.compute_stress = compute_stress; + return self.predict(static_cast(n_atoms), positions.data(), atomic_numbers.data(), cell_ptr, pbc_ptr, - compute_forces); + options); }, "positions"_a, "atomic_numbers"_a, "cell"_a = nb::none(), "pbc"_a = nb::none(), "compute_forces"_a = true, diff --git a/src/api/wasm/mlipcpp_wasm.cpp b/src/api/wasm/mlipcpp_wasm.cpp index ddd668b..4a3f94b 100644 --- a/src/api/wasm/mlipcpp_wasm.cpp +++ b/src/api/wasm/mlipcpp_wasm.cpp @@ -92,9 +92,9 @@ class AtomicSystemWrapper { bool isPeriodic() const { return periodic_; } val getPositions() const { - val result = val::global("Float64Array").new_(positions_.size()); + val result = val::global("Float32Array").new_(positions_.size()); for (size_t i = 0; i < positions_.size(); ++i) { - result.set(i, static_cast(positions_[i])); + result.set(i, positions_[i]); } return result; } @@ -111,9 +111,9 @@ class AtomicSystemWrapper { if (!periodic_ || cell_.empty()) { return val::null(); } - val result = val::global("Float64Array").new_(9); + val result = val::global("Float32Array").new_(9); for (int i = 0; i < 9; ++i) { - result.set(i, static_cast(cell_[i])); + result.set(i, cell_[i]); } return result; } @@ -139,6 +139,20 @@ class PredictorWrapper { PredictorWrapper() = default; PredictorWrapper(std::shared_ptr p) : predictor_(std::move(p)) {} + // Map user-facing backend name strings to the public Backend enum. + static mlipcpp::Backend parseBackend(const std::string& name) { + if (name.empty() || name == "auto") return mlipcpp::Backend::Auto; + if (name == "cpu") return mlipcpp::Backend::CPU; + if (name == "webgpu" || name == "wgpu") return mlipcpp::Backend::WebGPU; + if (name == "metal" || name == "mtl") return mlipcpp::Backend::Metal; + if (name == "cuda") return mlipcpp::Backend::CUDA; + if (name == "hip" || name == "rocm") return mlipcpp::Backend::HIP; + if (name == "vulkan") return mlipcpp::Backend::Vulkan; + if (name == "sycl") return mlipcpp::Backend::SYCL; + if (name == "cann") return mlipcpp::Backend::CANN; + return mlipcpp::Backend::Auto; + } + // Load model from file path (Emscripten VFS) static PredictorWrapper load(const std::string& path) { return PredictorWrapper(std::make_shared(path)); @@ -146,6 +160,12 @@ class PredictorWrapper { // Load model from ArrayBuffer static PredictorWrapper loadFromBuffer(const val& buffer) { + return loadFromBufferWithBackend(buffer, std::string("auto")); + } + + static PredictorWrapper loadFromBufferWithBackend(const val& buffer, + const std::string& backend) { + mlipcpp::set_backend(parseBackend(backend)); // Get data from ArrayBuffer val uint8Array = val::global("Uint8Array").new_(buffer); const size_t length = uint8Array["length"].as(); @@ -228,18 +248,17 @@ class PredictorWrapper { val output = val::object(); output.set("energy", static_cast(result.energy)); - // Convert forces to Float64Array - val forces = val::global("Float64Array").new_(result.forces.size()); + // Return forces as Float32Array (native precision — no double-widening copy) + val forces = val::global("Float32Array").new_(result.forces.size()); for (size_t i = 0; i < result.forces.size(); ++i) { - forces.set(i, static_cast(result.forces[i])); + forces.set(i, result.forces[i]); } output.set("forces", forces); - // Include stress if available if (result.has_stress()) { - val stress = val::global("Float64Array").new_(6); + val stress = val::global("Float32Array").new_(6); for (int i = 0; i < 6; ++i) { - stress.set(i, static_cast(result.stress[i])); + stress.set(i, result.stress[i]); } output.set("stress", stress); } @@ -258,6 +277,14 @@ std::string getVersion() { return mlipcpp::version(); } +std::string getBackendName() { + return std::string(mlipcpp::get_backend_name()); +} + +void setBackend(const std::string& name) { + mlipcpp::set_backend(PredictorWrapper::parseBackend(name)); +} + // Emscripten bindings EMSCRIPTEN_BINDINGS(mlipcpp) { // AtomicSystem wrapper @@ -276,6 +303,7 @@ EMSCRIPTEN_BINDINGS(mlipcpp) { .constructor<>() .class_function("load", &PredictorWrapper::load) .class_function("loadFromBuffer", &PredictorWrapper::loadFromBuffer) + .class_function("loadFromBufferWithBackend", &PredictorWrapper::loadFromBufferWithBackend) .function("modelType", &PredictorWrapper::modelType) .function("cutoff", &PredictorWrapper::cutoff) .function("predictEnergy", &PredictorWrapper::predictEnergy) @@ -285,4 +313,6 @@ EMSCRIPTEN_BINDINGS(mlipcpp) { // Utility functions function("getVersion", &getVersion); + function("getBackendName", &getBackendName); + function("setBackend", &setBackend); } diff --git a/src/bin/graph_inference.cpp b/src/bin/graph_inference.cpp new file mode 100644 index 0000000..61e0110 --- /dev/null +++ b/src/bin/graph_inference.cpp @@ -0,0 +1,122 @@ +/** + * Graph-based inference on XYZ files using auto-exported PET models. + * + * Usage: + * graph_inference [--forces] [--backend ] + */ + +#include "core/backend.h" +#include "core/gguf_loader.h" +#include "mlipcpp/io.h" +#include "mlipcpp/model.h" +#include "mlipcpp/system.h" +#include "models/pet/pet.h" +#include "runtime/graph_model.h" + +#include +#include +#include +#include +#include + +using namespace mlipcpp; + +namespace { + +void print_usage(const char *prog) { + std::cerr << "Usage: " << prog + << " [--forces] [--backend ]\n\n" + << "Options:\n" + << " --forces Compute forces via backward pass\n" + << " --backend auto|cpu|metal|webgpu|cuda|hip|vulkan " + "(default: auto)\n"; +} + +} // namespace + +int main(int argc, char *argv[]) { + if (argc < 3) { + print_usage(argv[0]); + return 1; + } + + std::string model_path = argv[1]; + std::string xyz_path = argv[2]; + bool compute_forces = false; + std::string backend_name = "auto"; + + for (int i = 3; i < argc; i++) { + std::string arg = argv[i]; + if (arg == "--forces") { + compute_forces = true; + } else if (arg == "--backend" && i + 1 < argc) { + backend_name = argv[++i]; + } else { + std::cerr << "Unknown option: " << arg << "\n"; + print_usage(argv[0]); + return 1; + } + } + + try { + BackendPreference pref = parse_backend_preference(backend_name); + + // Route through load_model() for architecture dispatch, but for graph + // models we want to set the backend preference before loading weights. + GGUFLoader probe(model_path); + std::string arch = probe.get_string("general.architecture", ""); + + std::unique_ptr model; + if (arch == "pet-graph") { + auto gm = std::make_unique(); + gm->set_backend_preference(pref); + if (!gm->load_from_gguf(model_path)) { + throw std::runtime_error("Failed to load graph model"); + } + model = std::move(gm); + } else { + model = load_model(model_path); + if (pref != BackendPreference::Auto && + pref != BackendPreference::CPU) { + std::cerr << "Warning: --backend ignored for architecture '" << arch + << "'\n"; + } + } + + AtomicSystem system = io::read_xyz(xyz_path); + std::cout << "Input: " << xyz_path << " (" << system.num_atoms() + << " atoms)\n"; + std::cout << "Model cutoff: " << model->cutoff() << " A\n"; + + auto t0 = std::chrono::high_resolution_clock::now(); + ModelResult result = model->predict(system, compute_forces); + auto t1 = std::chrono::high_resolution_clock::now(); + double ms = std::chrono::duration(t1 - t0).count(); + + std::cout << std::fixed << std::setprecision(6); + std::cout << "\n=== Results ===\n"; + std::cout << "Total energy: " << result.energy << " eV\n"; + + if (result.has_forces) { + std::cout << "\nForces (eV/A):\n"; + float fsum[3] = {0, 0, 0}; + for (size_t i = 0; i < system.num_atoms(); i++) { + std::cout << " Atom " << i << ": [" << std::setw(12) + << result.forces[i * 3 + 0] << ", " << std::setw(12) + << result.forces[i * 3 + 1] << ", " << std::setw(12) + << result.forces[i * 3 + 2] << "]\n"; + for (int k = 0; k < 3; k++) fsum[k] += result.forces[i * 3 + k]; + } + std::cout << " Force sum: [" << fsum[0] << ", " << fsum[1] << ", " + << fsum[2] << "]\n"; + } + + std::cout << "\nCompute time: " << std::fixed << std::setprecision(1) + << ms << " ms\n"; + return 0; + + } catch (const std::exception &e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} diff --git a/src/core/backend.cpp b/src/core/backend.cpp index 427ffc8..61befb6 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -1,11 +1,44 @@ #include "backend.h" #include "log.h" +#include #include +#include #include #include +#include +#include +#include namespace mlipcpp { +BackendPreference parse_backend_preference(std::string_view name_in) { + std::string name(name_in); + std::transform(name.begin(), name.end(), name.begin(), + [](unsigned char c) { return std::tolower(c); }); + + static const std::unordered_map table = { + {"auto", BackendPreference::Auto}, + {"cpu", BackendPreference::CPU}, + {"cuda", BackendPreference::CUDA}, + {"nvidia", BackendPreference::CUDA}, + {"hip", BackendPreference::HIP}, + {"rocm", BackendPreference::HIP}, + {"metal", BackendPreference::Metal}, + {"mtl", BackendPreference::Metal}, + {"vulkan", BackendPreference::Vulkan}, + {"vk", BackendPreference::Vulkan}, + {"webgpu", BackendPreference::WebGPU}, + {"wgpu", BackendPreference::WebGPU}, + {"sycl", BackendPreference::SYCL}, + {"cann", BackendPreference::CANN}, + }; + auto it = table.find(name); + if (it == table.end()) { + throw std::runtime_error("Unknown backend: " + std::string(name_in)); + } + return it->second; +} + #ifndef __EMSCRIPTEN__ namespace log { @@ -82,9 +115,13 @@ BackendProvider::create(BackendPreference pref) { return name.find("ROCm") != std::string_view::npos || name.find("HIP") != std::string_view::npos; case BackendPreference::Metal: - return name.find("Metal") != std::string_view::npos; + // Upstream renamed the Metal backend to "MTL" (with device suffixes). + return name.find("Metal") != std::string_view::npos || + name.find("MTL") != std::string_view::npos; case BackendPreference::Vulkan: return name.find("Vulkan") != std::string_view::npos; + case BackendPreference::WebGPU: + return name.find("WebGPU") != std::string_view::npos; case BackendPreference::SYCL: return name.find("SYCL") != std::string_view::npos; case BackendPreference::CANN: @@ -109,21 +146,52 @@ BackendProvider::create(BackendPreference pref) { return provider; } - // Try GPU (discrete first, then integrated) - ggml_backend_t gpu = nullptr; - ggml_backend_dev_t gpu_dev = - ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_GPU); - if (gpu_dev) { - gpu = ggml_backend_dev_init(gpu_dev, nullptr); + // Enumerate all GPU devices and try to match the preference. For specific + // preferences (Metal, WebGPU, ...) we scan every GPU and pick one whose + // name matches. For Auto we pick the first GPU, except on Emscripten where + // we prefer WebGPU. + std::vector gpu_devs; + { + size_t n_dev = ggml_backend_dev_count(); + for (size_t i = 0; i < n_dev; ++i) { + ggml_backend_dev_t d = ggml_backend_dev_get(i); + auto t = ggml_backend_dev_type(d); + if (t == GGML_BACKEND_DEVICE_TYPE_GPU || + t == GGML_BACKEND_DEVICE_TYPE_IGPU) { + gpu_devs.push_back(d); + } + } } - if (!gpu) { - gpu_dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_IGPU); - if (gpu_dev) { - gpu = ggml_backend_dev_init(gpu_dev, nullptr); + + ggml_backend_t gpu = nullptr; + if (pref == BackendPreference::Auto) { +#ifdef __EMSCRIPTEN__ + for (auto d : gpu_devs) { + std::string_view n = ggml_backend_dev_name(d); + if (n.find("WebGPU") != std::string_view::npos || + n.find("webgpu") != std::string_view::npos) { + gpu = ggml_backend_dev_init(d, nullptr); + if (gpu) break; + } + } +#endif + if (!gpu && !gpu_devs.empty()) { + gpu = ggml_backend_dev_init(gpu_devs[0], nullptr); + } + } else { + // Specific GPU preference: scan devices until one matches. + for (auto d : gpu_devs) { + ggml_backend_t b = ggml_backend_dev_init(d, nullptr); + if (!b) continue; + if (gpu_matches_preference(b)) { + gpu = b; + break; + } + ggml_backend_free(b); } } - // Check if GPU matches preference + // Check if GPU matches preference (Auto accepts any) if (gpu && gpu_matches_preference(gpu)) { provider->primary_ = gpu; provider->name_ = ggml_backend_name(provider->primary_); diff --git a/src/core/backend.h b/src/core/backend.h index 79ecfa0..04f5e6d 100644 --- a/src/core/backend.h +++ b/src/core/backend.h @@ -16,6 +16,7 @@ enum class BackendPreference { HIP, // AMD HIP/ROCm GPU Metal, // Apple Metal GPU (macOS/iOS) Vulkan, // Vulkan GPU (cross-platform) + WebGPU, // WebGPU (Dawn native or browser) SYCL, // Intel SYCL (oneAPI) CANN, // Huawei Ascend NPU }; @@ -81,30 +82,15 @@ class BackendProvider { // Convenience function to get preference name inline const char *backend_preference_name(BackendPreference pref) { - static constexpr const char *names[] = {"auto", "cpu", "cuda", "hip", - "metal", "vulkan", "sycl", "cann"}; + static constexpr const char *names[] = {"auto", "cpu", "cuda", "hip", + "metal", "vulkan", "webgpu", + "sycl", "cann"}; return names[static_cast(pref)]; } -// Parse backend preference from string -inline BackendPreference parse_backend_preference(std::string_view name) { - if (name == "auto") - return BackendPreference::Auto; - if (name == "cpu") - return BackendPreference::CPU; - if (name == "cuda") - return BackendPreference::CUDA; - if (name == "hip") - return BackendPreference::HIP; - if (name == "metal") - return BackendPreference::Metal; - if (name == "vulkan") - return BackendPreference::Vulkan; - if (name == "sycl") - return BackendPreference::SYCL; - if (name == "cann") - return BackendPreference::CANN; - throw std::runtime_error("Unknown backend: " + std::string(name)); -} +// Parse backend preference from string. Accepts common aliases +// (e.g. "mtl" → Metal, "rocm" → HIP) so the same names work for the CLI, +// Python, and JS entry points. +BackendPreference parse_backend_preference(std::string_view name); } // namespace mlipcpp diff --git a/src/models/model.cpp b/src/models/model.cpp index dbeb13e..aa1bb4d 100644 --- a/src/models/model.cpp +++ b/src/models/model.cpp @@ -1,6 +1,7 @@ #include "mlipcpp/model.h" #include "core/gguf_loader.h" #include "pet/pet.h" +#include "runtime/graph_model.h" #include namespace mlipcpp { @@ -16,6 +17,12 @@ std::unique_ptr load_model(const std::string &path) { throw std::runtime_error("Failed to load PET model"); } return model; + } else if (arch == "pet-graph") { + auto model = std::make_unique(); + if (!model->load_from_gguf(path)) { + throw std::runtime_error("Failed to load graph model"); + } + return model; } throw std::runtime_error("Unsupported model architecture: " + arch); diff --git a/src/runtime/graph_interpreter.cpp b/src/runtime/graph_interpreter.cpp new file mode 100644 index 0000000..f34d79d --- /dev/null +++ b/src/runtime/graph_interpreter.cpp @@ -0,0 +1,1837 @@ +#include "graph_interpreter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mlipcpp::runtime { + +namespace { + +// Parse a reference string like "node:5", "input:name", "weight:name", "const:value" +struct RefParsed { + std::string type; // "node", "input", "weight", "const" + std::string value; +}; + +RefParsed parse_ref(const std::string &ref) { + auto colon_pos = ref.find(':'); + if (colon_pos == std::string::npos) { + throw std::runtime_error("Invalid reference format: " + ref); + } + return {ref.substr(0, colon_pos), ref.substr(colon_pos + 1)}; +} + +// Check if a parameter exists in a node's params map +bool has_param(const GIRNode &node, const std::string &key) { + return node.params.find(key) != node.params.end(); +} + +// Get a parameter from a node's params map +template +T get_param(const GIRNode &node, const std::string &key, T default_val) { + auto it = node.params.find(key); + if (it == node.params.end()) { + return default_val; + } + if constexpr (std::is_same_v) { + if (std::holds_alternative(it->second)) { + return std::get(it->second); + } + if (std::holds_alternative(it->second)) { + return static_cast(std::get(it->second)); + } + } else if constexpr (std::is_same_v) { + if (std::holds_alternative(it->second)) { + return std::get(it->second); + } + if (std::holds_alternative(it->second)) { + return static_cast(std::get(it->second)); + } + } else if constexpr (std::is_same_v) { + if (std::holds_alternative(it->second)) { + return std::get(it->second); + } + } else if constexpr (std::is_same_v) { + if (std::holds_alternative(it->second)) { + return std::get(it->second); + } + } + return default_val; +} + +// Get int array parameter +std::vector get_int_array_param(const GIRNode &node, + const std::string &key) { + auto it = node.params.find(key); + if (it == node.params.end()) { + return {}; + } + if (std::holds_alternative>(it->second)) { + return std::get>(it->second); + } + return {}; +} + +} // namespace + +void GraphInterpreter::load_graph(const std::string &json_str) { + graph_ = parse_gir_json(json_str); + node_outputs_.clear(); + pending_constants_.clear(); + output_ = nullptr; +} + +void GraphInterpreter::load_graph_file(const std::string &path) { + graph_ = load_gir_file(path); + node_outputs_.clear(); + pending_constants_.clear(); + output_ = nullptr; +} + +void GraphInterpreter::set_dimension(const std::string &name, int64_t value) { + if (value <= 0) { + throw std::runtime_error( + "GraphInterpreter: dimension '" + name + "' must be positive, got " + + std::to_string(value)); + } + if (value > 1000000) { + throw std::runtime_error( + "GraphInterpreter: dimension '" + name + "' = " + + std::to_string(value) + " is unreasonably large (>1M)"); + } + dimensions_[name] = value; +} + +std::vector GraphInterpreter::resolve_shape( + const std::vector &shape) const { + std::vector resolved; + resolved.reserve(shape.size()); + + for (int64_t dim : shape) { + if (dim == DIM_N_ATOMS) { + auto it = dimensions_.find("n_atoms"); + if (it == dimensions_.end()) { + throw std::runtime_error( + "Symbolic dimension 'n_atoms' used but not set. " + "Call set_dimension(\"n_atoms\", value) before build()."); + } + resolved.push_back(it->second); + } else if (dim == DIM_MAX_NEIGHBORS) { + auto it = dimensions_.find("max_neighbors"); + if (it == dimensions_.end()) { + throw std::runtime_error( + "Symbolic dimension 'max_neighbors' used but not set. " + "Call set_dimension(\"max_neighbors\", value) before build()."); + } + resolved.push_back(it->second); + } else if (dim == DIM_SEQ_LEN) { + // seq_len = n_atoms * (max_neighbors + 1) + auto it_n = dimensions_.find("n_atoms"); + auto it_m = dimensions_.find("max_neighbors"); + if (it_n == dimensions_.end() || it_m == dimensions_.end()) { + throw std::runtime_error( + "Symbolic dimension 'seq_len' requires both 'n_atoms' and " + "'max_neighbors' to be set."); + } + resolved.push_back(it_n->second * (it_m->second + 1)); + } else if (dim == DIM_N_EDGES) { + // n_edges = n_atoms * max_neighbors + auto it_n = dimensions_.find("n_atoms"); + auto it_m = dimensions_.find("max_neighbors"); + if (it_n == dimensions_.end() || it_m == dimensions_.end()) { + throw std::runtime_error( + "Symbolic dimension 'n_edges' requires both 'n_atoms' and " + "'max_neighbors' to be set."); + } + resolved.push_back(it_n->second * it_m->second); + } else if (dim == DIM_MN_PLUS_ONE) { + // max_neighbors_plus_one = max_neighbors + 1 + auto it_m = dimensions_.find("max_neighbors"); + if (it_m == dimensions_.end()) { + throw std::runtime_error( + "Symbolic dimension 'max_neighbors_plus_one' requires " + "'max_neighbors' to be set."); + } + resolved.push_back(it_m->second + 1); + } else { + // Regular concrete dimension + resolved.push_back(dim); + } + } + + return resolved; +} + +void GraphInterpreter::set_weight(const std::string &name, + ggml_tensor *tensor) { + weights_[name] = tensor; +} + +void GraphInterpreter::set_input(const std::string &name, ggml_tensor *tensor) { + inputs_[name] = tensor; +} + +void GraphInterpreter::init_constants() { + // Set constant values after graph allocation. Use ggml_backend_tensor_set + // so this works for non-CPU backends (Metal, WebGPU, ...) where tensor->data + // is a backend-private handle rather than a CPU-mappable pointer. + std::vector staging; + for (const auto &pc : pending_constants_) { + if (!pc.tensor || !pc.tensor->buffer) { + continue; + } + size_t n_elements = ggml_nelements(pc.tensor); + staging.assign(n_elements, pc.value); + ggml_backend_tensor_set(pc.tensor, staging.data(), 0, + n_elements * sizeof(float)); + } + + // Constants are context-owned; clear pointers after initialization to avoid + // stale writes on subsequent builds with different contexts. + pending_constants_.clear(); +} + +ggml_tensor *GraphInterpreter::resolve_input(ggml_context *ctx, + const std::string &ref) { + auto parsed = parse_ref(ref); + + if (parsed.type == "node") { + int node_id = std::stoi(parsed.value); + auto it = node_outputs_.find(node_id); + if (it == node_outputs_.end()) { + throw std::runtime_error("Node " + parsed.value + " not yet computed"); + } + return it->second; + } else if (parsed.type == "input") { + auto it = inputs_.find(parsed.value); + if (it == inputs_.end()) { + throw std::runtime_error("Input not found: " + parsed.value); + } + return it->second; + } else if (parsed.type == "weight") { + auto it = weights_.find(parsed.value); + if (it == weights_.end()) { + throw std::runtime_error("Weight not found: " + parsed.value); + } + return it->second; + } else if (parsed.type == "const") { + // Constants are typically parameters, not tensors + // For now, create a scalar constant tensor and mark as input + // The value will need to be set after allocation + float value = std::stof(parsed.value); + ggml_tensor *t = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1); + ggml_set_input(t); + // Store for later initialization + pending_constants_.push_back({t, value}); + return t; + } else { + throw std::runtime_error("Unknown reference type: " + parsed.type); + } +} + +ggml_tensor *GraphInterpreter::build(ggml_context *ctx) { + if (graph_.nodes.empty()) { + throw std::runtime_error("No graph loaded"); + } + + output_ = nullptr; + node_outputs_.clear(); + pending_constants_.clear(); + + // Build nodes in order (they should already be topologically sorted) + for (const auto &node : graph_.nodes) { + ggml_tensor *output = nullptr; + try { + output = build_node(ctx, node); + } catch (const std::exception &e) { + throw std::runtime_error("Failed building node " + + std::to_string(node.id) + " (" + node.op + + " \"" + node.name + "\"): " + e.what()); + } + if (output) { + node_outputs_[node.id] = output; + if (!node.name.empty()) { + ggml_set_name(output, node.name.c_str()); + } + } + } + + // Find the output tensor + if (!graph_.outputs.empty()) { + auto parsed = parse_ref(graph_.outputs[0].node_ref); + if (parsed.type == "node") { + int node_id = std::stoi(parsed.value); + auto it = node_outputs_.find(node_id); + if (it != node_outputs_.end()) { + output_ = it->second; + } + } + } + + // If no explicit output, use the last node + if (!output_ && !graph_.nodes.empty()) { + output_ = node_outputs_[graph_.nodes.back().id]; + } + + return output_; +} + +ggml_tensor *GraphInterpreter::build_node(ggml_context *ctx, + const GIRNode &node) { + // Dispatch based on operation type + if (node.op == "ADD") { + return build_add(ctx, node); + } else if (node.op == "SUB") { + return build_sub(ctx, node); + } else if (node.op == "MUL") { + return build_mul(ctx, node); + } else if (node.op == "DIV") { + return build_div(ctx, node); + } else if (node.op == "MUL_MAT") { + return build_mul_mat(ctx, node); + } else if (node.op == "MATMUL") { + return build_matmul(ctx, node); + } else if (node.op == "RESHAPE") { + return build_reshape(ctx, node); + } else if (node.op == "VIEW") { + return build_view(ctx, node); + } else if (node.op == "SELECT") { + return build_select(ctx, node); + } else if (node.op == "PERMUTE") { + return build_permute(ctx, node); + } else if (node.op == "TRANSPOSE") { + return build_transpose(ctx, node); + } else if (node.op == "CONT") { + return build_cont(ctx, node); + } else if (node.op == "SCALE") { + return build_scale(ctx, node); + } else if (node.op == "SQR") { + return build_sqr(ctx, node); + } else if (node.op == "SQRT") { + return build_sqrt(ctx, node); + } else if (node.op == "LOG") { + return build_log(ctx, node); + } else if (node.op == "SUM_ROWS") { + return build_sum_rows(ctx, node); + } else if (node.op == "REPEAT") { + return build_repeat(ctx, node); + } else if (node.op == "CLAMP") { + return build_clamp(ctx, node); + } else if (node.op == "SOFT_MAX") { + return build_softmax(ctx, node); + } else if (node.op == "FLASH_ATTN_EXT") { + return build_flash_attn(ctx, node); + } else if (node.op == "UNARY_SILU") { + return build_unary(ctx, node, GGML_UNARY_OP_SILU); + } else if (node.op == "UNARY_RELU") { + return build_unary(ctx, node, GGML_UNARY_OP_RELU); + } else if (node.op == "UNARY_GELU") { + return build_unary(ctx, node, GGML_UNARY_OP_GELU); + } else if (node.op == "UNARY_TANH") { + return build_unary(ctx, node, GGML_UNARY_OP_TANH); + } else if (node.op == "UNARY_EXP") { + return build_unary(ctx, node, GGML_UNARY_OP_EXP); + } else if (node.op == "UNARY_NEG") { + return build_unary(ctx, node, GGML_UNARY_OP_NEG); + } else if (node.op == "UNARY_SIGMOID") { + return build_sigmoid(ctx, node); + } else if (node.op == "DECOMPOSE") { + return build_decompose(ctx, node); + } else if (node.op == "LAYER_NORM") { + return build_layer_norm(ctx, node); + } else if (node.op == "RMS_NORM") { + return build_rms_norm(ctx, node); + } else if (node.op == "CONCAT") { + return build_concat(ctx, node); + } else if (node.op == "GET_ROWS") { + return build_get_rows(ctx, node); + } else if (node.op == "NEW_ZEROS") { + return build_new_zeros(ctx, node); + } else if (node.op == "NEW_ONES") { + return build_new_ones(ctx, node); + } else if (node.op == "LINEAR") { + return build_linear(ctx, node); + } else if (node.op == "SLICE") { + return build_slice(ctx, node); + } else if (node.op == "SPLIT") { + return build_split(ctx, node); + } else if (node.op == "BITWISE_NOT") { + return build_bitwise_not(ctx, node); + } else if (node.op == "INDEX") { + return build_index(ctx, node); + } else if (node.op == "INDEX_PUT") { + return build_index_put(ctx, node); + } else if (node.op == "WHERE") { + return build_where(ctx, node); + } else if (node.op == "COS") { + return build_cos(ctx, node); + } else if (node.op == "SIN") { + return build_sin(ctx, node); + } else if (node.op == "POW") { + return build_pow(ctx, node); + } else if (node.op == "CHUNK") { + return build_chunk(ctx, node); + } else { + throw std::runtime_error("Unknown operation: " + node.op); + } +} + +// ===================== Binary Operations ===================== + +ggml_tensor *GraphInterpreter::build_add(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("ADD requires at least 1 input at node: " + node.name); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + if (node.inputs.size() == 1) { + // Check for scalar addition (tensor + scalar) + if (has_param(node, "scalar")) { + float scalar = static_cast(get_param(node, "scalar", 0.0)); + ggml_tensor *s = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1); + ggml_set_input(s); + pending_constants_.push_back({s, scalar}); + return ggml_add(ctx, a, s); + } + // Single input ADD is identity (e.g., from torch.zeros() + x optimization) + return a; + } + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + + // ggml_add requires ggml_can_repeat(b, a) - b must broadcast to a's shape. + // If a is smaller than b, swap operands (addition is commutative). + if (ggml_nelements(a) < ggml_nelements(b)) { + std::swap(a, b); + } + return ggml_add(ctx, a, b); +} + +ggml_tensor *GraphInterpreter::build_sub(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SUB requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + if (node.inputs.size() == 1) { + // Scalar subtraction (tensor - scalar) + if (has_param(node, "scalar")) { + float scalar = static_cast(get_param(node, "scalar", 0.0)); + ggml_tensor *s = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1); + ggml_set_input(s); + pending_constants_.push_back({s, scalar}); + return ggml_sub(ctx, a, s); + } + throw std::runtime_error("SUB requires 2 inputs (or 1 input with scalar param)"); + } + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + return ggml_sub(ctx, a, b); +} + +ggml_tensor *GraphInterpreter::build_mul(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("MUL requires at least 1 input"); + } + + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // Check for scalar multiplication (tensor * scalar) + if (node.inputs.size() == 1 && has_param(node, "scalar")) { + float scalar = static_cast(get_param(node, "scalar", 1.0)); + return ggml_scale(ctx, a, scalar); + } + + // Standard element-wise multiplication + if (node.inputs.size() < 2) { + throw std::runtime_error("MUL requires 2 inputs (or 1 input with scalar param)"); + } + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + + // ggml_mul requires ggml_can_repeat(b, a) - b must broadcast to a's shape. + // If a is smaller than b, swap operands (multiplication is commutative). + if (ggml_nelements(a) < ggml_nelements(b)) { + std::swap(a, b); + } + return ggml_mul(ctx, a, b); +} + +ggml_tensor *GraphInterpreter::build_div(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("DIV requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + if (node.inputs.size() == 1) { + // Scalar division (tensor / scalar) -> scale by 1/scalar + if (has_param(node, "scalar")) { + float scalar = static_cast(get_param(node, "scalar", 1.0)); + if (scalar == 0.0f) { + throw std::runtime_error("DIV: division by zero scalar"); + } + return ggml_scale(ctx, a, 1.0f / scalar); + } + throw std::runtime_error("DIV requires 2 inputs (or 1 input with scalar param)"); + } + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + return ggml_div(ctx, a, b); +} + +// ===================== Matrix Operations ===================== + +ggml_tensor *GraphInterpreter::build_mul_mat(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.size() < 2) { + throw std::runtime_error("MUL_MAT requires 2 inputs"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + + // ggml_mul_mat(a, b) requires ne00 == ne10 (inner dim must match). + // This heuristic tries different dimension arrangements to find a match. + // Only used for fx.symbolic_trace path; torch.export uses LINEAR/MATMUL ops instead. + if (a->ne[0] == b->ne[0]) { + // Inner dimensions already match + return ggml_mul_mat(ctx, a, b); + } + + // Try swapping and transposing b to match inner dimensions + if (a->ne[0] == b->ne[1]) { + // B's ne[1] matches A's ne[0]: transpose B and swap order + // ggml_mul_mat requires non-transposed first arg, so use ggml_cont + ggml_tensor *bt = ggml_cont(ctx, ggml_transpose(ctx, b)); + return ggml_mul_mat(ctx, bt, a); + } + + // Try the other way: transpose a + if (a->ne[1] == b->ne[0]) { + ggml_tensor *at = ggml_cont(ctx, ggml_transpose(ctx, a)); + return ggml_mul_mat(ctx, b, at); + } + + // Fallback: try original order (will fail with assertion if shapes don't match) + return ggml_mul_mat(ctx, a, b); +} + +ggml_tensor *GraphInterpreter::build_matmul(ggml_context *ctx, + const GIRNode &node) { + // MATMUL: PyTorch matmul(a, b) semantics + // Contracts last dim of a with second-to-last dim of b: + // a_py: [..., m, k], b_py: [..., k, n] -> result: [..., m, n] + // + // In GGML (reversed dims): + // a_ggml: [k, m, ...], b_ggml: [n, k, ...] + // transpose(b_ggml): [k, n, ...] + // ggml_mul_mat(transpose(b), a) -> result: [n, m, ...] -> PyTorch [..., m, n] + if (node.inputs.size() < 2) { + throw std::runtime_error("MATMUL requires 2 inputs"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + + // Ensure both inputs are contiguous (required for ggml_mul_mat) + if (!ggml_is_contiguous(a)) { + a = ggml_cont(ctx, a); + } + if (!ggml_is_contiguous(b)) { + b = ggml_cont(ctx, b); + } + + // General formula: ggml_mul_mat(transpose(b), a) + // transpose(b): swaps ne[0] and ne[1], making ne[0] = b->ne[1] = k (contraction dim) + // a has ne[0] = k (contraction dim) + // Result: ne[0] = transpose(b)->ne[1] = b->ne[0] = n, ne[1] = a->ne[1] = m + ggml_tensor *bt = ggml_cont(ctx, ggml_transpose(ctx, b)); + return ggml_mul_mat(ctx, bt, a); +} + +// ===================== Shape Operations ===================== + +ggml_tensor *GraphInterpreter::build_reshape(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("RESHAPE requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // GGML reshape requires contiguous input - make contiguous if needed + if (!ggml_is_contiguous(a)) { + a = ggml_cont(ctx, a); + } + + auto shape = get_int_array_param(node, "shape"); + + // Prefer output_shape if it's more complete (FX export may have partial + // params) + if (shape.empty() || (!node.output_shape.empty() && + node.output_shape.size() > shape.size())) { + shape = node.output_shape; + } + + if (shape.empty()) { + throw std::runtime_error("RESHAPE: no shape available"); + } + + // Resolve symbolic dimensions (e.g., DIM_N_ATOMS -> actual n_atoms value) + shape = resolve_shape(shape); + + // Reverse shape (Python→GGML dimension order) + std::reverse(shape.begin(), shape.end()); + + // Verify element count matches + int64_t target_nelements = 1; + for (auto d : shape) { + target_nelements *= d; + } + int64_t actual_nelements = ggml_nelements(a); + if (actual_nelements != target_nelements) { + std::string shape_str = "["; + for (size_t i = 0; i < shape.size(); i++) { + if (i > 0) shape_str += ", "; + shape_str += std::to_string(shape[i]); + } + shape_str += "]"; + throw std::runtime_error( + "RESHAPE: element count mismatch for node '" + node.name + + "': input has " + std::to_string(actual_nelements) + + " elements, target shape " + shape_str + " needs " + + std::to_string(target_nelements) + " elements"); + } + + switch (shape.size()) { + case 1: + return ggml_reshape_1d(ctx, a, shape[0]); + case 2: + return ggml_reshape_2d(ctx, a, shape[0], shape[1]); + case 3: + return ggml_reshape_3d(ctx, a, shape[0], shape[1], shape[2]); + case 4: + return ggml_reshape_4d(ctx, a, shape[0], shape[1], shape[2], shape[3]); + default: + throw std::runtime_error("RESHAPE: unsupported number of dimensions: " + + std::to_string(shape.size())); + } +} + +ggml_tensor *GraphInterpreter::build_view(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("VIEW requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + auto shape = get_int_array_param(node, "shape"); + auto index = get_param(node, "index", -1); + + // Prefer output_shape if it has fully resolved dimensions (no -1) + // But use params.shape if we need to know the original intent + bool has_negative = false; + for (auto d : shape) { + if (d < 0) has_negative = true; + } + + // Use output_shape which has resolved dimensions + if (has_negative || shape.empty()) { + if (!node.output_shape.empty()) { + shape = node.output_shape; + } + } + + // If still empty, this might be an indexing operation - pass through input + if (shape.empty()) { + // VIEW with no shape is used for getitem[index] - just pass through + return a; + } + + // Resolve symbolic dimensions (e.g., DIM_N_ATOMS -> actual n_atoms value) + shape = resolve_shape(shape); + + // Reverse shape (Python→GGML dimension order) + std::reverse(shape.begin(), shape.end()); + + // If this is a chunk extraction (getitem with index), use view with offset + if (index >= 0) { + // Chunk offset: index * chunk_size * element_size + size_t byte_offset = static_cast(index) * shape[0] * ggml_element_size(a); + + ggml_tensor *view = nullptr; + switch (shape.size()) { + case 1: + view = ggml_view_1d(ctx, a, shape[0], byte_offset); + break; + case 2: + view = ggml_view_2d(ctx, a, shape[0], shape[1], a->nb[1], byte_offset); + break; + case 3: + view = ggml_view_3d(ctx, a, shape[0], shape[1], shape[2], + a->nb[1], a->nb[2], byte_offset); + break; + case 4: + view = ggml_view_4d(ctx, a, shape[0], shape[1], shape[2], shape[3], + a->nb[1], a->nb[2], a->nb[3], byte_offset); + break; + default: + throw std::runtime_error("VIEW: unsupported number of dimensions"); + } + // Make contiguous so subsequent reshapes work correctly + return ggml_cont(ctx, view); + } + + // For regular view/reshape (no chunk extraction), use reshape which is safer + // when changing dimensionality. GGML reshape just reinterprets the same memory. + switch (shape.size()) { + case 1: + return ggml_reshape_1d(ctx, a, shape[0]); + case 2: + return ggml_reshape_2d(ctx, a, shape[0], shape[1]); + case 3: + return ggml_reshape_3d(ctx, a, shape[0], shape[1], shape[2]); + case 4: + return ggml_reshape_4d(ctx, a, shape[0], shape[1], shape[2], shape[3]); + default: + throw std::runtime_error("VIEW: unsupported number of dimensions: " + + std::to_string(shape.size())); + } +} + +ggml_tensor *GraphInterpreter::build_select(ggml_context *ctx, + const GIRNode &node) { + // SELECT: extract one slice from a dimension, reducing dimensionality by 1 + // PyTorch: x[:, idx, :] on [N, S, D] -> [N, D] + // PyTorch: x[:, :, idx, :] on [B, S, 3, D] -> [B, S, D] + if (node.inputs.empty()) { + throw std::runtime_error("SELECT requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // Get select dimension (PyTorch convention) and index + int py_dim = static_cast(get_param(node, "dim", 1)); + int64_t idx = get_param(node, "index", 0); + + // Use output_shape to determine expected output dimensionality + // This is more reliable than ggml_n_dims which may compress dimensions + int n_dims_output = static_cast(node.output_shape.size()); + int n_dims_input = n_dims_output + 1; // SELECT removes one dimension + + // Convert PyTorch dim to GGML dim (reversed order) + // Use expected input dims, not GGML's compressed dims + int ggml_dim = n_dims_input - 1 - py_dim; + + if (ggml_dim < 0 || ggml_dim >= n_dims_input) { + throw std::runtime_error("SELECT: invalid dimension: py_dim=" + + std::to_string(py_dim) + " for " + + std::to_string(n_dims_input) + "D input"); + } + + // Calculate byte offset to the selected slice + size_t offset = static_cast(idx) * a->nb[ggml_dim]; + + // Handle each case based on input dimensions and which dim we're selecting + if (n_dims_input == 4) { + // 4D tensor [ne0, ne1, ne2, ne3] in GGML order + // PyTorch shape is [ne3, ne2, ne1, ne0] + if (ggml_dim == 1) { + // PyTorch dim=2: select from ne1, result is [ne0, ne2, ne3] + // View with stride that skips over ne1 + return ggml_view_3d(ctx, a, a->ne[0], a->ne[2], a->ne[3], + a->nb[2], a->nb[3], offset); + } else if (ggml_dim == 2) { + // PyTorch dim=1: select from ne2, result is [ne0, ne1, ne3] + return ggml_view_3d(ctx, a, a->ne[0], a->ne[1], a->ne[3], + a->nb[1], a->nb[3], offset); + } else if (ggml_dim == 3) { + // PyTorch dim=0: select from ne3, result is [ne0, ne1, ne2] + return ggml_view_3d(ctx, a, a->ne[0], a->ne[1], a->ne[2], + a->nb[1], a->nb[2], offset); + } else if (ggml_dim == 0) { + // PyTorch dim=3: select from ne0, result is [ne1, ne2, ne3] + // This is selecting a single element from the innermost dimension + return ggml_view_3d(ctx, a, a->ne[1], a->ne[2], a->ne[3], + a->nb[2], a->nb[3], idx * a->nb[0]); + } + } else if (n_dims_input == 3) { + if (ggml_dim == 1) { + // Selecting from middle dimension of 3D: [ne0, ne1, ne2] -> [ne0, ne2] + return ggml_view_2d(ctx, a, a->ne[0], a->ne[2], a->nb[2], offset); + } else if (ggml_dim == 2) { + // Selecting from last dimension (PyTorch first): [ne0, ne1, ne2] -> [ne0, ne1] + return ggml_view_2d(ctx, a, a->ne[0], a->ne[1], a->nb[1], offset); + } else if (ggml_dim == 0) { + // Selecting from first dimension (PyTorch last): [ne0, ne1, ne2] -> [ne1, ne2] + return ggml_view_2d(ctx, a, a->ne[1], a->ne[2], a->nb[2], idx * a->nb[0]); + } + } else if (n_dims_input == 2) { + if (ggml_dim == 1) { + // 2D selecting from PyTorch dim 0: [ne0, ne1] -> [ne0] + return ggml_view_1d(ctx, a, a->ne[0], offset); + } else if (ggml_dim == 0) { + // 2D selecting from PyTorch dim 1: [ne0, ne1] -> [ne1] + return ggml_view_1d(ctx, a, a->ne[1], idx * a->nb[0]); + } + } + + throw std::runtime_error("SELECT: unsupported dimension configuration: " + + std::to_string(n_dims_input) + "D input, ggml_dim=" + + std::to_string(ggml_dim)); +} + +ggml_tensor *GraphInterpreter::build_permute(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("PERMUTE requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + auto axes = get_int_array_param(node, "axes"); + + if (axes.size() != 4) { + // Pad to 4 dimensions + while (axes.size() < 4) { + axes.push_back(axes.size()); + } + } + + // Convert from Python axis order to GGML + // In Python: [0,1,2,3] means [batch, channel, height, width] + // In GGML: [0,1,2,3] means [width, height, channel, batch] + // So we need to reverse the axis mapping + int n_dims = static_cast(axes.size()); + std::vector ggml_axes(4); + for (int i = 0; i < n_dims; i++) { + ggml_axes[n_dims - 1 - i] = n_dims - 1 - static_cast(axes[i]); + } + + return ggml_permute(ctx, a, ggml_axes[0], ggml_axes[1], + ggml_axes[2], ggml_axes[3]); +} + +ggml_tensor *GraphInterpreter::build_transpose(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("TRANSPOSE requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // Get PyTorch dimensions to transpose (defaults to [0, 1] for simple transpose) + auto dims = get_int_array_param(node, "dims"); + if (dims.empty() || dims.size() != 2) { + // Default to swapping dims 0 and 1 + return ggml_transpose(ctx, a); + } + + int64_t py_dim0 = dims[0]; + int64_t py_dim1 = dims[1]; + int n_dims = ggml_n_dims(a); + + // Normalize negative dimensions (PyTorch convention: -1 = last dim) + if (py_dim0 < 0) + py_dim0 += n_dims; + if (py_dim1 < 0) + py_dim1 += n_dims; + + // Convert PyTorch dims to GGML dims (reversed order) + // PyTorch dim i -> GGML dim (n_dims - 1 - i) + int ggml_dim0 = n_dims - 1 - static_cast(py_dim0); + int ggml_dim1 = n_dims - 1 - static_cast(py_dim1); + + // Build permutation array - start with identity + int perm[4] = {0, 1, 2, 3}; + // Swap the two dimensions + std::swap(perm[ggml_dim0], perm[ggml_dim1]); + + return ggml_permute(ctx, a, perm[0], perm[1], perm[2], perm[3]); +} + +ggml_tensor *GraphInterpreter::build_cont(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("CONT requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_cont(ctx, a); +} + +// ===================== Unary Operations ===================== + +ggml_tensor *GraphInterpreter::build_scale(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SCALE requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + float scale = static_cast(get_param(node, "scale", 1.0)); + return ggml_scale(ctx, a, scale); +} + +ggml_tensor *GraphInterpreter::build_sqr(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SQR requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_sqr(ctx, a); +} + +ggml_tensor *GraphInterpreter::build_sqrt(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SQRT requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_sqrt(ctx, a); +} + +ggml_tensor *GraphInterpreter::build_log(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("LOG requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_log(ctx, a); +} + +ggml_tensor *GraphInterpreter::build_cos(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("COS requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_cos(ctx, a); +} + +ggml_tensor *GraphInterpreter::build_sin(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SIN requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_sin(ctx, a); +} + +ggml_tensor *GraphInterpreter::build_pow(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("POW requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + double exponent = get_param(node, "exponent", 2.0); + + // Optimize common cases + if (exponent == 2.0) { + return ggml_sqr(ctx, a); + } else if (exponent == 0.5) { + return ggml_sqrt(ctx, a); + } + + // General case: x^n = exp(n * log(x)) + ggml_tensor *log_a = ggml_log(ctx, a); + ggml_tensor *scaled = ggml_scale(ctx, log_a, static_cast(exponent)); + return ggml_unary(ctx, scaled, GGML_UNARY_OP_EXP); +} + +ggml_tensor *GraphInterpreter::build_sigmoid(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("UNARY_SIGMOID requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_sigmoid(ctx, a); +} + +ggml_tensor *GraphInterpreter::build_chunk(ggml_context *ctx, + const GIRNode &node) { + // CHUNK splits a tensor into num_chunks pieces along dim + // This is typically followed by getitem ops to extract each piece + // We implement this as a pass-through - the actual slicing happens + // when the downstream getitem/select ops extract pieces + if (node.inputs.empty()) { + throw std::runtime_error("CHUNK requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // Get parameters + int64_t num_chunks = get_param(node, "num_chunks", 2); + int64_t dim = get_param(node, "dim", 0); + + // For now, we just return the input - the chunking is done lazily + // by downstream SELECT/VIEW operations that extract specific pieces. + // This works because torch.export captures the chunk + getitem pattern + // as chunk followed by multiple select/view nodes. + (void)num_chunks; + (void)dim; + + return a; +} + +// ===================== Reduction Operations ===================== + +ggml_tensor *GraphInterpreter::build_sum_rows(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SUM_ROWS requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + ggml_tensor *result = ggml_sum_rows(ctx, a); + + // ggml_sum_rows reduces ne[0] to 1, producing shape [1, ne[1], ...]. + // If the expected output has fewer dimensions (e.g., [n_atoms] instead of + // [1, n_atoms]), reshape to squeeze the leading dimension. + if (!node.output_shape.empty()) { + auto expected = resolve_shape(node.output_shape); + std::reverse(expected.begin(), expected.end()); // PyTorch -> GGML order + + // Check if we need to reshape + bool needs_reshape = (expected.size() < static_cast(ggml_n_dims(result))); + if (!needs_reshape) { + // Also check if shapes differ (e.g., [1, n] vs [n]) + for (size_t i = 0; i < expected.size(); i++) { + if (expected[i] != result->ne[i]) { + needs_reshape = true; + break; + } + } + } + + if (needs_reshape) { + switch (expected.size()) { + case 1: + result = ggml_reshape_1d(ctx, result, expected[0]); + break; + case 2: + result = ggml_reshape_2d(ctx, result, expected[0], expected[1]); + break; + case 3: + result = ggml_reshape_3d(ctx, result, expected[0], expected[1], + expected[2]); + break; + default: + break; + } + } + } + + return result; +} + +// ===================== Other Operations ===================== + +ggml_tensor *GraphInterpreter::build_repeat(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.size() < 2) { + // Need a template tensor for repeat + // If not provided, create one from output_shape + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + auto shape = resolve_shape(node.output_shape); + std::reverse(shape.begin(), shape.end()); + + // Create a dummy tensor with target shape + ggml_tensor *b = nullptr; + switch (shape.size()) { + case 1: + b = ggml_new_tensor_1d(ctx, a->type, shape[0]); + break; + case 2: + b = ggml_new_tensor_2d(ctx, a->type, shape[0], shape[1]); + break; + case 3: + b = ggml_new_tensor_3d(ctx, a->type, shape[0], shape[1], shape[2]); + break; + case 4: + b = ggml_new_tensor_4d(ctx, a->type, shape[0], shape[1], shape[2], + shape[3]); + break; + default: + throw std::runtime_error("REPEAT: unsupported number of dimensions"); + } + return ggml_repeat(ctx, a, b); + } + + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + ggml_tensor *b = resolve_input(ctx, node.inputs[1]); + return ggml_repeat(ctx, a, b); +} + +ggml_tensor *GraphInterpreter::build_clamp(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("CLAMP requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + float min_val = static_cast(get_param(node, "min", -INFINITY)); + float max_val = static_cast(get_param(node, "max", INFINITY)); + return ggml_clamp(ctx, a, min_val, max_val); +} + +ggml_tensor *GraphInterpreter::build_softmax(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.empty()) { + throw std::runtime_error("SOFT_MAX requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // Check for mask input + ggml_tensor *mask = nullptr; + if (node.inputs.size() > 1 && node.inputs[1] != "null") { + mask = resolve_input(ctx, node.inputs[1]); + } + + float scale = static_cast(get_param(node, "scale", 1.0)); + return ggml_soft_max_ext(ctx, a, mask, scale, 0.0f); +} + +ggml_tensor *GraphInterpreter::build_flash_attn(ggml_context *ctx, + const GIRNode &node) { + if (node.inputs.size() < 3) { + throw std::runtime_error("FLASH_ATTN_EXT requires at least 3 inputs (Q, K, V)"); + } + + ggml_tensor *q = resolve_input(ctx, node.inputs[0]); + ggml_tensor *k = resolve_input(ctx, node.inputs[1]); + ggml_tensor *v = resolve_input(ctx, node.inputs[2]); + + // Flash attention requires contiguous Q, K, V tensors + // Add ggml_cont if tensors are not contiguous (e.g., after transpose) + if (!ggml_is_contiguous(q)) { + q = ggml_cont(ctx, q); + } + if (!ggml_is_contiguous(k)) { + k = ggml_cont(ctx, k); + } + if (!ggml_is_contiguous(v)) { + v = ggml_cont(ctx, v); + } + + // Optional mask + ggml_tensor *mask = nullptr; + if (node.inputs.size() > 3 && node.inputs[3] != "null") { + mask = resolve_input(ctx, node.inputs[3]); + + // Ensure mask is contiguous for ggml_add + if (!ggml_is_contiguous(mask)) { + mask = ggml_cont(ctx, mask); + } + } + + // Get scale parameter, or compute from head dimension (GGML Q shape is [head_dim, ...]) + float scale; + if (has_param(node, "scale")) { + scale = static_cast(get_param(node, "scale", 1.0)); + } else { + // PyTorch SDPA default: 1/sqrt(head_dim) + int64_t head_dim = q->ne[0]; // head_dim is first GGML dimension + scale = 1.0f / std::sqrt(static_cast(head_dim)); + } + // Use ggml_flash_attn_ext. + // Q, K, V are all [head_dim, seq, heads, batch] in GGML order. + // + // flash_attn_ext expects mask shape [n_kv, n_batch, ne32, ne33] and F16 dtype. + if (mask) { + GGML_ASSERT(mask->ne[1] == q->ne[1] && "mask n_batch dim must match q seq dim"); + if (mask->type != GGML_TYPE_F16) { + mask = ggml_cast(ctx, mask, GGML_TYPE_F16); + } + } + + ggml_tensor *result = ggml_flash_attn_ext(ctx, q, k, v, mask, + scale, 0.0f, 0.0f); + ggml_flash_attn_ext_set_prec(result, GGML_PREC_F32); + + // flash_attn_ext output is [head_dim, heads, seq, batch] (permuted). + // The graph expects [head_dim, seq, heads, batch], so swap dims 1 and 2. + result = ggml_permute(ctx, result, 0, 2, 1, 3); + + return result; +} + +ggml_tensor *GraphInterpreter::build_unary(ggml_context *ctx, + const GIRNode &node, + ggml_unary_op op) { + if (node.inputs.empty()) { + throw std::runtime_error("Unary operation requires at least 1 input"); + } + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + return ggml_unary(ctx, a, op); +} + +// ===================== Decomposition Operations ===================== + +ggml_tensor *GraphInterpreter::build_decompose(ggml_context *ctx, + const GIRNode &node) { + // Check the node name to determine what decomposition to apply + // For now, we handle layer_norm (norm_attention, norm_mlp) + if (node.name.find("norm") != std::string::npos) { + return build_layer_norm(ctx, node); + } + + // For unknown decompositions, just pass through the first input + if (node.inputs.empty()) { + throw std::runtime_error("DECOMPOSE requires at least 1 input"); + } + return resolve_input(ctx, node.inputs[0]); +} + +ggml_tensor *GraphInterpreter::build_layer_norm(ggml_context *ctx, + const GIRNode &node) { + // Layer norm inputs: + // FX style (3 inputs): [input, weight, bias] + // TS style (4 inputs): [input, normalized_shape, weight, bias] + // params.eps = epsilon + if (node.inputs.size() < 3) { + throw std::runtime_error( + "LAYER_NORM requires at least 3 inputs (input, weight, bias)"); + } + + ggml_tensor *input = resolve_input(ctx, node.inputs[0]); + ggml_tensor *weight = nullptr; + ggml_tensor *bias = nullptr; + + if (node.inputs.size() == 3) { + // FX style: [input, weight, bias] + weight = resolve_input(ctx, node.inputs[1]); + bias = resolve_input(ctx, node.inputs[2]); + } else { + // TS style: [input, shape, weight, bias] + weight = resolve_input(ctx, node.inputs[2]); + bias = resolve_input(ctx, node.inputs[3]); + } + + float eps = static_cast(get_param(node, "eps", 1e-5)); + + // Decomposed layer normalization for backward pass support. + // ggml_norm doesn't have a backward pass, so we decompose into primitives: + // LayerNorm(x) = (x - mean(x)) / sqrt(var(x) + eps) * weight + bias + // All primitives used (SUM_ROWS, SCALE, SUB, SQR, SQRT, DIV, MUL, ADD, + // REPEAT) have backward pass support in GGML. + + const int64_t d = input->ne[0]; // Feature dimension + const float inv_d = 1.0f / static_cast(d); + + // Step 1: mean = sum_rows(x) / d + ggml_tensor *sum_x = ggml_sum_rows(ctx, input); + ggml_tensor *mean = ggml_scale(ctx, sum_x, inv_d); + + // Step 2: x_centered = x - mean (broadcast mean to input shape) + ggml_tensor *mean_broadcast = ggml_repeat(ctx, mean, input); + ggml_tensor *x_centered = ggml_sub(ctx, input, mean_broadcast); + + // Step 3: var = sum_rows(x_centered^2) / d + ggml_tensor *x_centered_sq = ggml_sqr(ctx, x_centered); + ggml_tensor *sum_sq = ggml_sum_rows(ctx, x_centered_sq); + ggml_tensor *var = ggml_scale(ctx, sum_sq, inv_d); + + // Step 4: std = sqrt(var + eps) + ggml_tensor *eps_tensor = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 1); + ggml_set_input(eps_tensor); + pending_constants_.push_back({eps_tensor, eps}); + ggml_tensor *var_stabilized = ggml_add(ctx, var, eps_tensor); + ggml_tensor *std_val = ggml_sqrt(ctx, var_stabilized); + + // Step 5: normalized = x_centered / std (broadcast std to input shape) + ggml_tensor *std_broadcast = ggml_repeat(ctx, std_val, input); + ggml_tensor *normalized = ggml_div(ctx, x_centered, std_broadcast); + + // Step 6: Apply affine transform: normalized * weight + bias + ggml_tensor *scaled = ggml_mul(ctx, normalized, weight); + return ggml_add(ctx, scaled, bias); +} + +ggml_tensor *GraphInterpreter::build_rms_norm(ggml_context *ctx, + const GIRNode &node) { + // RMS norm: x / sqrt(mean(x^2) + eps) * weight + // inputs: [input, weight] + // params.eps = epsilon + if (node.inputs.size() < 2) { + throw std::runtime_error("RMS_NORM requires at least 2 inputs (input, weight)"); + } + + ggml_tensor *input = resolve_input(ctx, node.inputs[0]); + ggml_tensor *weight = resolve_input(ctx, node.inputs[1]); + float eps = static_cast(get_param(node, "eps", 1e-5)); + + // Use GGML's native RMS norm + ggml_tensor *normalized = ggml_rms_norm(ctx, input, eps); + + // Apply scale: normalized * weight + return ggml_mul(ctx, normalized, weight); +} + +ggml_tensor *GraphInterpreter::build_concat(ggml_context *ctx, + const GIRNode &node) { + // CONCAT: concatenate tensors along a dimension + // inputs: [tensor1, tensor2, ...] (at least 2) + // params.dim: dimension to concatenate along (PyTorch convention) + if (node.inputs.size() < 2) { + throw std::runtime_error("CONCAT requires at least 2 inputs"); + } + + // Get PyTorch dimension (defaults to 0) + int py_dim = static_cast(get_param(node, "dim", 0)); + + // Resolve all input tensors + std::vector tensors; + for (const auto &input_ref : node.inputs) { + tensors.push_back(resolve_input(ctx, input_ref)); + } + + // GGML ggml_concat concatenates along a GGML dimension + // Convert PyTorch dim to GGML dim (reversed order) + int n_dims = ggml_n_dims(tensors[0]); + int ggml_dim = n_dims - 1 - py_dim; + + // Handle negative dimension + if (py_dim < 0) { + py_dim = n_dims + py_dim; + ggml_dim = n_dims - 1 - py_dim; + } + + // Concatenate iteratively: result = concat(a, b), then concat(result, c), etc. + ggml_tensor *result = tensors[0]; + for (size_t i = 1; i < tensors.size(); i++) { + result = ggml_concat(ctx, result, tensors[i], ggml_dim); + } + + return result; +} + +ggml_tensor *GraphInterpreter::build_get_rows(ggml_context *ctx, + const GIRNode &node) { + // GET_ROWS: embedding lookup / row selection + // inputs: [weight_table, indices] + // weight_table: [embedding_dim, num_embeddings] in GGML order + // indices: [n_indices] or [n1, n2, ...] integer tensor + // output: [embedding_dim, n_indices] or [embedding_dim, n1, n2, ...] + if (node.inputs.size() < 2) { + throw std::runtime_error("GET_ROWS requires 2 inputs (weight, indices)"); + } + + ggml_tensor *weight_table = resolve_input(ctx, node.inputs[0]); + ggml_tensor *indices = resolve_input(ctx, node.inputs[1]); + + + // Get original indices shape for later reshape + int64_t idx_ne0 = indices->ne[0]; + int64_t idx_ne1 = indices->ne[1]; + int64_t idx_ne2 = indices->ne[2]; + int64_t idx_ne3 = indices->ne[3]; + int64_t n_indices = ggml_nelements(indices); + + // If indices is multi-dimensional, flatten to 1D first + bool need_reshape = (idx_ne1 > 1 || idx_ne2 > 1 || idx_ne3 > 1); + if (need_reshape) { + indices = ggml_cont(ctx, ggml_reshape_1d(ctx, indices, n_indices)); + } + + // Perform the get_rows operation + ggml_tensor *result = ggml_get_rows(ctx, weight_table, indices); + + // If we flattened, reshape output to match original index dimensions + // output shape: [embedding_dim, idx_ne0, idx_ne1, ...] + if (need_reshape) { + int64_t embed_dim = weight_table->ne[0]; + if (idx_ne2 > 1) { + result = ggml_reshape_4d(ctx, result, embed_dim, idx_ne0, idx_ne1, idx_ne2); + } else if (idx_ne1 > 1) { + result = ggml_reshape_3d(ctx, result, embed_dim, idx_ne0, idx_ne1); + } + } + + return result; +} + +ggml_tensor *GraphInterpreter::build_new_zeros(ggml_context *ctx, + const GIRNode &node) { + // NEW_ZEROS: create a tensor filled with zeros + // params.shape: the shape of the tensor to create + auto shape = get_int_array_param(node, "shape"); + + // Use output_shape if params.shape is empty + if (shape.empty()) { + shape = node.output_shape; + } + + if (shape.empty()) { + throw std::runtime_error("NEW_ZEROS: no shape available"); + } + + // Resolve symbolic dimensions (e.g., DIM_N_ATOMS -> actual n_atoms value) + shape = resolve_shape(shape); + + // Reverse shape (Python→GGML dimension order) + std::reverse(shape.begin(), shape.end()); + + // Create zero-initialized tensor + ggml_tensor *result = nullptr; + switch (shape.size()) { + case 1: + result = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, shape[0]); + break; + case 2: + result = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, shape[0], shape[1]); + break; + case 3: + result = ggml_new_tensor_3d(ctx, GGML_TYPE_F32, shape[0], shape[1], shape[2]); + break; + case 4: + result = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, shape[0], shape[1], shape[2], shape[3]); + break; + default: + throw std::runtime_error("NEW_ZEROS: unsupported number of dimensions: " + + std::to_string(shape.size())); + } + + // Mark as input so it will be initialized + ggml_set_input(result); + // Store for later initialization to zero + pending_constants_.push_back({result, 0.0f}); + + return result; +} + +ggml_tensor *GraphInterpreter::build_new_ones(ggml_context *ctx, + const GIRNode &node) { + // NEW_ONES: create a tensor filled with ones + auto shape = get_int_array_param(node, "shape"); + if (shape.empty()) { + shape = node.output_shape; + } + if (shape.empty()) { + throw std::runtime_error("NEW_ONES: no shape available"); + } + + // Resolve symbolic dimensions (e.g., DIM_N_ATOMS -> actual n_atoms value) + shape = resolve_shape(shape); + + // Reverse shape (Python→GGML dimension order) + std::reverse(shape.begin(), shape.end()); + + ggml_tensor *result = nullptr; + switch (shape.size()) { + case 1: + result = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, shape[0]); + break; + case 2: + result = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, shape[0], shape[1]); + break; + case 3: + result = ggml_new_tensor_3d(ctx, GGML_TYPE_F32, shape[0], shape[1], shape[2]); + break; + case 4: + result = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, shape[0], shape[1], shape[2], shape[3]); + break; + default: + throw std::runtime_error("NEW_ONES: unsupported number of dimensions"); + } + + ggml_set_input(result); + pending_constants_.push_back({result, 1.0f}); + return result; +} + +ggml_tensor *GraphInterpreter::build_linear(ggml_context *ctx, + const GIRNode &node) { + // LINEAR: y = x @ W.T + b + // inputs: [input, weight] or [input, weight, bias] + if (node.inputs.size() < 2) { + throw std::runtime_error("LINEAR requires at least 2 inputs (input, weight)"); + } + + ggml_tensor *input = resolve_input(ctx, node.inputs[0]); + ggml_tensor *weight = resolve_input(ctx, node.inputs[1]); + + // GGML mul_mat: (weight @ input.T).T = input @ weight.T + ggml_tensor *result = ggml_mul_mat(ctx, weight, input); + + // Add bias if present + if (node.inputs.size() > 2) { + ggml_tensor *bias = resolve_input(ctx, node.inputs[2]); + result = ggml_add(ctx, result, bias); + } + + return result; +} + +ggml_tensor *GraphInterpreter::build_slice(ggml_context *ctx, + const GIRNode &node) { + // SLICE: extract a slice from a tensor along one dimension. + // Supports: full pass-through (shapes match) and simple prefix slicing from offset 0. + if (node.inputs.empty()) { + throw std::runtime_error("SLICE requires at least 1 input"); + } + + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + auto output_shape = node.output_shape; + if (output_shape.empty()) { + // No output shape info: pass through (full slice) + return a; + } + + // Resolve symbolic dimensions + output_shape = resolve_shape(output_shape); + + // Reverse for GGML + std::reverse(output_shape.begin(), output_shape.end()); + + // Check if shapes match (full pass-through) + bool shapes_match = true; + for (size_t i = 0; i < output_shape.size() && i < 4; i++) { + if (output_shape[i] != static_cast(a->ne[i])) { + shapes_match = false; + break; + } + } + + if (shapes_match) { + return a; + } + + // Only support simple prefix slicing from offset 0 along one dimension. + // Verify that exactly one dimension differs and the output is smaller. + int n_diff = 0; + for (size_t i = 0; i < output_shape.size() && i < 4; i++) { + if (output_shape[i] != static_cast(a->ne[i])) { + if (output_shape[i] > static_cast(a->ne[i])) { + throw std::runtime_error( + "SLICE: output dimension " + std::to_string(i) + " (" + + std::to_string(output_shape[i]) + ") is larger than input (" + + std::to_string(a->ne[i]) + ") at node '" + node.name + "'"); + } + n_diff++; + } + } + + if (n_diff > 1) { + throw std::runtime_error( + "SLICE: multiple dimensions differ between input and output at node '" + + node.name + "'. Only single-dimension slicing is supported."); + } + + // Simple prefix slice from offset 0 + switch (output_shape.size()) { + case 1: + return ggml_view_1d(ctx, a, output_shape[0], 0); + case 2: + return ggml_view_2d(ctx, a, output_shape[0], output_shape[1], a->nb[1], 0); + case 3: + return ggml_view_3d(ctx, a, output_shape[0], output_shape[1], output_shape[2], + a->nb[1], a->nb[2], 0); + case 4: + return ggml_view_4d(ctx, a, output_shape[0], output_shape[1], output_shape[2], + output_shape[3], a->nb[1], a->nb[2], a->nb[3], 0); + default: + throw std::runtime_error( + "SLICE: unsupported number of dimensions: " + + std::to_string(output_shape.size()) + " at node '" + node.name + "'"); + } +} + +ggml_tensor *GraphInterpreter::build_split(ggml_context *ctx, + const GIRNode &node) { + // SPLIT: split a tensor into chunks + // The actual extraction is done by subsequent getitem/VIEW nodes + // Just pass through the input tensor + if (node.inputs.empty()) { + throw std::runtime_error("SPLIT requires at least 1 input"); + } + return resolve_input(ctx, node.inputs[0]); +} + +ggml_tensor *GraphInterpreter::build_bitwise_not(ggml_context *ctx, + const GIRNode &node) { + // BITWISE_NOT: invert boolean tensor + // For float representation of bool: not(x) = 1 - x + if (node.inputs.empty()) { + throw std::runtime_error("BITWISE_NOT requires at least 1 input"); + } + + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + + // Create a ones tensor to subtract from + ggml_tensor *ones = nullptr; + switch (ggml_n_dims(a)) { + case 1: + ones = ggml_new_tensor_1d(ctx, a->type, a->ne[0]); + break; + case 2: + ones = ggml_new_tensor_2d(ctx, a->type, a->ne[0], a->ne[1]); + break; + case 3: + ones = ggml_new_tensor_3d(ctx, a->type, a->ne[0], a->ne[1], a->ne[2]); + break; + case 4: + ones = ggml_new_tensor_4d(ctx, a->type, a->ne[0], a->ne[1], a->ne[2], a->ne[3]); + break; + default: + throw std::runtime_error("BITWISE_NOT: unsupported dimensions"); + } + + ggml_set_input(ones); + pending_constants_.push_back({ones, 1.0f}); + + return ggml_sub(ctx, ones, a); +} + +ggml_tensor *GraphInterpreter::build_index(ggml_context *ctx, + const GIRNode &node) { + // INDEX: advanced indexing with tensor indices + // This is a complex operation - for now, handle simple cases + if (node.inputs.size() < 2) { + throw std::runtime_error("INDEX requires at least 2 inputs"); + } + + ggml_tensor *a = resolve_input(ctx, node.inputs[0]); + ggml_tensor *indices = resolve_input(ctx, node.inputs[1]); + + // Use get_rows for simple 1D index case + return ggml_get_rows(ctx, a, indices); +} + +ggml_tensor *GraphInterpreter::build_index_put(ggml_context *ctx, + const GIRNode &node) { + // INDEX_PUT: scatter values into tensor at indices + // In PET, this is used for masking: tensor[boolean_mask] = scalar_value + // When the value is 0 and mask indicates invalid positions: + // result = source * (1 - mask) (zeros out masked positions) + if (node.inputs.size() < 3) { + throw std::runtime_error("INDEX_PUT requires 3 inputs (source, mask, values)"); + } + + ggml_tensor *source = resolve_input(ctx, node.inputs[0]); + ggml_tensor *mask = resolve_input(ctx, node.inputs[1]); + ggml_tensor *values = resolve_input(ctx, node.inputs[2]); + + // For scalar values (typically 0.0 for masking), we can simplify: + // result = source * (1 - mask) + values * mask + // When values = 0: result = source * (1 - mask) + + // Create a ones tensor for computing (1 - mask) + ggml_tensor *ones = nullptr; + switch (ggml_n_dims(mask)) { + case 1: + ones = ggml_new_tensor_1d(ctx, mask->type, mask->ne[0]); + break; + case 2: + ones = ggml_new_tensor_2d(ctx, mask->type, mask->ne[0], mask->ne[1]); + break; + case 3: + ones = ggml_new_tensor_3d(ctx, mask->type, mask->ne[0], mask->ne[1], mask->ne[2]); + break; + case 4: + ones = ggml_new_tensor_4d(ctx, mask->type, mask->ne[0], mask->ne[1], mask->ne[2], mask->ne[3]); + break; + default: + throw std::runtime_error("INDEX_PUT: unsupported mask dimensions"); + } + ggml_set_input(ones); + pending_constants_.push_back({ones, 1.0f}); + + // (1 - mask): where mask=1 (to replace), this gives 0; where mask=0 (to keep), this gives 1 + ggml_tensor *inv_mask = ggml_sub(ctx, ones, mask); + + // source * inv_mask: keeps only non-masked positions + ggml_tensor *kept = ggml_mul(ctx, source, inv_mask); + + // mask * values: the values to insert at masked positions + ggml_tensor *inserted = ggml_mul(ctx, mask, values); + + // Combine: kept + inserted + return ggml_add(ctx, kept, inserted); +} + +ggml_tensor *GraphInterpreter::build_where(ggml_context *ctx, + const GIRNode &node) { + // WHERE(condition, x, y): returns x where condition is true, y otherwise + // Implemented as: x * condition_f32 + y * (1 - condition_f32) + if (node.inputs.size() < 3) { + throw std::runtime_error("WHERE requires 3 inputs (condition, x, y)"); + } + + ggml_tensor *condition = resolve_input(ctx, node.inputs[0]); + ggml_tensor *x = resolve_input(ctx, node.inputs[1]); + ggml_tensor *y = resolve_input(ctx, node.inputs[2]); + + // condition is a float tensor where 1.0 = true, 0.0 = false + // result = x * condition + y * (1 - condition) + ggml_tensor *x_masked = ggml_mul(ctx, x, condition); + ggml_tensor *ones = nullptr; + switch (ggml_n_dims(condition)) { + case 1: + ones = ggml_new_tensor_1d(ctx, condition->type, condition->ne[0]); + break; + case 2: + ones = ggml_new_tensor_2d(ctx, condition->type, condition->ne[0], + condition->ne[1]); + break; + case 3: + ones = ggml_new_tensor_3d(ctx, condition->type, condition->ne[0], + condition->ne[1], condition->ne[2]); + break; + case 4: + ones = ggml_new_tensor_4d(ctx, condition->type, condition->ne[0], + condition->ne[1], condition->ne[2], + condition->ne[3]); + break; + default: + throw std::runtime_error("WHERE: unsupported condition dimensions"); + } + ggml_set_input(ones); + pending_constants_.push_back({ones, 1.0f}); + + ggml_tensor *inv_condition = ggml_sub(ctx, ones, condition); + ggml_tensor *y_masked = ggml_mul(ctx, y, inv_condition); + + return ggml_add(ctx, x_masked, y_masked); +} + +std::string GraphInterpreter::summary() const { + if (graph_.nodes.empty()) { + return "No graph loaded"; + } + + std::stringstream ss; + ss << "Graph: " << graph_.model_type << " v" << graph_.version << "\n"; + ss << "Inputs: " << graph_.inputs.size() << "\n"; + for (const auto &input : graph_.inputs) { + ss << " - " << input.name << ": ["; + for (size_t i = 0; i < input.shape.size(); i++) { + if (i > 0) + ss << ", "; + ss << input.shape[i]; + } + ss << "]\n"; + } + ss << "Nodes: " << graph_.nodes.size() << "\n"; + + // Count operations + std::map op_counts; + for (const auto &node : graph_.nodes) { + op_counts[node.op]++; + } + ss << "Operations:\n"; + for (const auto &[op, count] : op_counts) { + ss << " " << op << ": " << count << "\n"; + } + + ss << "Outputs: " << graph_.outputs.size() << "\n"; + for (const auto &output : graph_.outputs) { + ss << " - " << output.name << " -> " << output.node_ref << "\n"; + } + + return ss.str(); +} + +void GraphInterpreter::set_debug_output_dir(const std::string &dir) { + debug_dir_ = dir; + debug_mode_ = !dir.empty(); + if (debug_mode_) { + std::filesystem::create_directories(dir); + } +} + +void GraphInterpreter::dump_tensor(ggml_tensor *t, const std::string &name, + int node_id) { + if (!debug_mode_ || !t || !t->data) { + return; + } + + // Format filename: node_XXXX_name.bin + std::stringstream filename; + filename << debug_dir_ << "/node_" << std::setfill('0') << std::setw(4) + << node_id << "_" << name << ".bin"; + + // Get tensor data size + size_t n_elements = ggml_nelements(t); + size_t data_size = n_elements * ggml_element_size(t); + + // Write binary data + std::ofstream file(filename.str(), std::ios::binary); + if (file.is_open()) { + file.write(static_cast(t->data), data_size); + file.close(); + } + + // Write metadata JSON + std::stringstream meta_filename; + meta_filename << debug_dir_ << "/node_" << std::setfill('0') << std::setw(4) + << node_id << "_" << name << ".json"; + + std::ofstream meta_file(meta_filename.str()); + if (meta_file.is_open()) { + meta_file << "{\n"; + meta_file << " \"node_id\": " << node_id << ",\n"; + meta_file << " \"name\": \"" << name << "\",\n"; + meta_file << " \"shape\": [" << t->ne[0] << ", " << t->ne[1] << ", " + << t->ne[2] << ", " << t->ne[3] << "],\n"; + meta_file << " \"n_dims\": " << ggml_n_dims(t) << ",\n"; + meta_file << " \"type\": " << static_cast(t->type) << ",\n"; + meta_file << " \"n_elements\": " << n_elements << ",\n"; + + // Compute basic statistics if F32 + if (t->type == GGML_TYPE_F32) { + const float *data = static_cast(t->data); + float min_val = data[0], max_val = data[0], sum = 0.0f; + for (size_t i = 0; i < n_elements; i++) { + if (data[i] < min_val) + min_val = data[i]; + if (data[i] > max_val) + max_val = data[i]; + sum += data[i]; + } + float mean = sum / static_cast(n_elements); + meta_file << " \"min\": " << min_val << ",\n"; + meta_file << " \"max\": " << max_val << ",\n"; + meta_file << " \"mean\": " << mean << ",\n"; + + // First few values + meta_file << " \"first_values\": ["; + for (size_t i = 0; i < std::min(n_elements, size_t(10)); i++) { + if (i > 0) + meta_file << ", "; + meta_file << data[i]; + } + meta_file << "]\n"; + } else { + meta_file << " \"stats\": \"non-f32 tensor\"\n"; + } + + meta_file << "}\n"; + meta_file.close(); + } +} + +void GraphInterpreter::dump_all_tensors() { + if (!debug_mode_) { + return; + } + + // Dump all node outputs + for (const auto &[node_id, tensor] : node_outputs_) { + // Find the node name + std::string name = "unknown"; + for (const auto &node : graph_.nodes) { + if (node.id == node_id) { + name = node.name.empty() ? node.op : node.name; + break; + } + } + dump_tensor(tensor, name, node_id); + } + + // Dump inputs + int input_id = -1000; + for (const auto &[name, tensor] : inputs_) { + dump_tensor(tensor, "input_" + name, input_id--); + } + + // Dump output + if (output_) { + dump_tensor(output_, "final_output", 9999); + } +} + +} // namespace mlipcpp::runtime diff --git a/src/runtime/graph_interpreter.h b/src/runtime/graph_interpreter.h new file mode 100644 index 0000000..f38e216 --- /dev/null +++ b/src/runtime/graph_interpreter.h @@ -0,0 +1,146 @@ +#pragma once + +#include "../core/ggml_utils.h" +#include "graph_ir.h" + +#include +#include +#include +#include +#include +#include + +namespace mlipcpp::runtime { + +// The graph interpreter builds and executes GGML graphs from GIR +class GraphInterpreter { +public: + GraphInterpreter() = default; + ~GraphInterpreter() = default; + + // Load a graph from JSON + void load_graph(const std::string &json_str); + void load_graph_file(const std::string &path); + + // Set a runtime dimension value (e.g., "n_atoms" -> 3, "max_neighbors" -> 20) + // Must be called before build() for graphs with symbolic dimensions + void set_dimension(const std::string &name, int64_t value); + + // Set a weight tensor (must be called before build) + void set_weight(const std::string &name, ggml_tensor *tensor); + + // Set an input tensor (must be called before compute) + void set_input(const std::string &name, ggml_tensor *tensor); + + // Build the GGML computation graph + // This uses the provided context for allocations + ggml_tensor *build(ggml_context *ctx); + + // Get the output tensor after build + ggml_tensor *get_output() const { return output_; } + + // Initialize pending constants (call after graph allocation) + void init_constants(); + + // Get summary of the loaded graph + std::string summary() const; + + // Check if a graph is loaded + bool has_graph() const { return !graph_.nodes.empty(); } + + // Get the GIR graph for inspection + const GIRGraph &graph() const { return graph_; } + + // Debug mode: set output directory for dumping intermediate tensors + void set_debug_output_dir(const std::string &dir); + + // Callback for tensor inspection during graph building + using TensorCallback = std::function; + void set_tensor_callback(TensorCallback cb) { tensor_cb_ = std::move(cb); } + + // Dump a tensor to the debug directory (after compute) + void dump_tensor(ggml_tensor *t, const std::string &name, int node_id); + + // Dump all node outputs after compute (call after backend_graph_compute) + void dump_all_tensors(); + +private: + // The IR graph + GIRGraph graph_; + + // Runtime dimension values (for symbolic dimensions like "n_atoms") + std::map dimensions_; + + // Tensor references + std::map weights_; + std::map inputs_; + std::map node_outputs_; // node_id -> tensor + + // Output tensor + ggml_tensor *output_ = nullptr; + + // Pending constants to initialize after allocation + struct PendingConstant { + ggml_tensor *tensor; + float value; + }; + std::vector pending_constants_; + + // Debug mode + bool debug_mode_ = false; + std::string debug_dir_; + TensorCallback tensor_cb_ = [](ggml_tensor *, const char *, int) {}; + + // Resolve symbolic dimensions in a shape to actual values + std::vector resolve_shape(const std::vector &shape) const; + + // Build helpers + ggml_tensor *resolve_input(ggml_context *ctx, const std::string &ref); + ggml_tensor *build_node(ggml_context *ctx, const GIRNode &node); + + // Operation builders + ggml_tensor *build_add(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_sub(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_mul(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_div(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_mul_mat(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_matmul(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_reshape(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_view(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_select(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_permute(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_transpose(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_cont(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_scale(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_sqr(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_sqrt(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_log(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_cos(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_sin(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_sum_rows(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_repeat(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_clamp(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_softmax(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_flash_attn(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_unary(ggml_context *ctx, const GIRNode &node, + ggml_unary_op op); + ggml_tensor *build_decompose(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_layer_norm(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_concat(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_get_rows(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_new_zeros(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_new_ones(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_linear(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_slice(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_split(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_bitwise_not(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_index(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_index_put(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_where(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_pow(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_sigmoid(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_chunk(ggml_context *ctx, const GIRNode &node); + ggml_tensor *build_rms_norm(ggml_context *ctx, const GIRNode &node); +}; + +} // namespace mlipcpp::runtime diff --git a/src/runtime/graph_ir.cpp b/src/runtime/graph_ir.cpp new file mode 100644 index 0000000..acd967a --- /dev/null +++ b/src/runtime/graph_ir.cpp @@ -0,0 +1,518 @@ +#include "graph_ir.h" + +#include +#include +#include + +// Simple JSON parsing - we'll use nlohmann/json if available, otherwise basic +// parsing For now, implement a basic parser that handles our specific format + +namespace mlipcpp::runtime { + +namespace { + +// Skip whitespace +void skip_ws(const std::string &s, size_t &pos) { + while (pos < s.size() && std::isspace(s[pos])) { + ++pos; + } +} + +// Parse a JSON string +std::string parse_string(const std::string &s, size_t &pos) { + skip_ws(s, pos); + if (pos >= s.size() || s[pos] != '"') { + throw std::runtime_error("Expected string at position " + + std::to_string(pos)); + } + ++pos; + std::string result; + while (pos < s.size() && s[pos] != '"') { + if (s[pos] == '\\' && pos + 1 < s.size()) { + ++pos; + switch (s[pos]) { + case '"': + result += '"'; + break; + case '\\': + result += '\\'; + break; + case 'n': + result += '\n'; + break; + case 't': + result += '\t'; + break; + default: + result += s[pos]; + break; + } + } else { + result += s[pos]; + } + ++pos; + } + if (pos >= s.size()) { + throw std::runtime_error("Unterminated string"); + } + ++pos; // Skip closing quote + return result; +} + +// Parse a JSON number +double parse_number(const std::string &s, size_t &pos) { + skip_ws(s, pos); + size_t start = pos; + if (pos < s.size() && (s[pos] == '-' || s[pos] == '+')) { + ++pos; + } + while (pos < s.size() && (std::isdigit(s[pos]) || s[pos] == '.' || + s[pos] == 'e' || s[pos] == 'E' || s[pos] == '-')) { + ++pos; + } + return std::stod(s.substr(start, pos - start)); +} + +// Expect a character +void expect_char(const std::string &s, size_t &pos, char c) { + skip_ws(s, pos); + if (pos >= s.size() || s[pos] != c) { + throw std::runtime_error("Expected '" + std::string(1, c) + + "' at position " + std::to_string(pos)); + } + ++pos; +} + +// Forward declarations +GIRParam parse_value(const std::string &s, size_t &pos); + +// Parse an array of values +std::vector parse_array(const std::string &s, size_t &pos) { + expect_char(s, pos, '['); + std::vector result; + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ']') { + ++pos; + return result; + } + + while (true) { + result.push_back(parse_value(s, pos)); + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ']') { + ++pos; + break; + } + expect_char(s, pos, ','); + } + return result; +} + +// Special values for symbolic dimensions +constexpr int64_t DIM_N_ATOMS = -1000001; +constexpr int64_t DIM_MAX_NEIGHBORS = -1000002; +constexpr int64_t DIM_SEQ_LEN = -1000003; // n_atoms * (max_neighbors + 1) +constexpr int64_t DIM_N_EDGES = -1000004; // n_atoms * max_neighbors +constexpr int64_t DIM_MN_PLUS_ONE = -1000005; // max_neighbors + 1 + +// Convert symbolic dimension name to special value +int64_t symbolic_dim_to_value(const std::string &name) { + if (name == "n_atoms") return DIM_N_ATOMS; + if (name == "max_neighbors") return DIM_MAX_NEIGHBORS; + if (name == "seq_len") return DIM_SEQ_LEN; + if (name == "n_edges") return DIM_N_EDGES; + if (name == "max_neighbors_plus_one") return DIM_MN_PLUS_ONE; + // Unknown symbolic name - return -1 + return -1; +} + +// Parse an array that may contain integers or symbolic dimension strings +std::vector parse_int_array(const std::string &s, size_t &pos) { + expect_char(s, pos, '['); + std::vector result; + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ']') { + ++pos; + return result; + } + + while (true) { + skip_ws(s, pos); + if (pos < s.size() && s[pos] == '"') { + // Symbolic dimension name + std::string sym = parse_string(s, pos); + result.push_back(symbolic_dim_to_value(sym)); + } else { + // Numeric value + result.push_back(static_cast(parse_number(s, pos))); + } + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ']') { + ++pos; + break; + } + expect_char(s, pos, ','); + } + return result; +} + +// Parse an array of strings +std::vector parse_string_array(const std::string &s, size_t &pos) { + expect_char(s, pos, '['); + std::vector result; + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ']') { + ++pos; + return result; + } + + while (true) { + result.push_back(parse_string(s, pos)); + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ']') { + ++pos; + break; + } + expect_char(s, pos, ','); + } + return result; +} + +// Parse a generic value +GIRParam parse_value(const std::string &s, size_t &pos) { + skip_ws(s, pos); + if (pos >= s.size()) { + throw std::runtime_error("Unexpected end of input"); + } + + if (s[pos] == '"') { + return parse_string(s, pos); + } else if (s[pos] == '[') { + // Try to determine if it's an int array or mixed + auto arr = parse_array(s, pos); + if (arr.empty()) { + return std::vector{}; + } + // Check if all elements are numbers + bool all_ints = true; + for (const auto &v : arr) { + if (!std::holds_alternative(v) && + !std::holds_alternative(v)) { + all_ints = false; + break; + } + } + if (all_ints) { + std::vector int_arr; + for (const auto &v : arr) { + if (std::holds_alternative(v)) { + int_arr.push_back(static_cast(std::get(v))); + } else { + int_arr.push_back(std::get(v)); + } + } + return int_arr; + } + // Mixed array - just return first element or empty + return std::vector{}; + } else if (s.compare(pos, 4, "true") == 0) { + pos += 4; + return true; + } else if (s.compare(pos, 5, "false") == 0) { + pos += 5; + return false; + } else if (s.compare(pos, 4, "null") == 0) { + pos += 4; + return std::string("null"); + } else if (s[pos] == '-' || s[pos] == '+' || std::isdigit(s[pos])) { + double num = parse_number(s, pos); + // Check if it's an integer + if (num == static_cast(num)) { + return static_cast(num); + } + return num; + } else { + throw std::runtime_error("Unexpected character at position " + + std::to_string(pos)); + } +} + +// Parse GIRDtype from string +GIRDtype parse_dtype(const std::string &s) { + if (s == "f32") + return GIRDtype::F32; + if (s == "f16") + return GIRDtype::F16; + if (s == "i32") + return GIRDtype::I32; + if (s == "i16") + return GIRDtype::I16; + if (s == "i8") + return GIRDtype::I8; + if (s == "bool") + return GIRDtype::BOOL; + throw std::runtime_error("Unknown dtype: " + s); +} + +// Skip a JSON object (for ignored fields) +void skip_object(const std::string &s, size_t &pos) { + expect_char(s, pos, '{'); + int depth = 1; + while (pos < s.size() && depth > 0) { + if (s[pos] == '"') { + parse_string(s, pos); + } else if (s[pos] == '{') { + ++depth; + ++pos; + } else if (s[pos] == '}') { + --depth; + ++pos; + } else if (s[pos] == '[') { + // Skip array + int arr_depth = 1; + ++pos; + while (pos < s.size() && arr_depth > 0) { + if (s[pos] == '"') { + parse_string(s, pos); + } else if (s[pos] == '[') { + ++arr_depth; + ++pos; + } else if (s[pos] == ']') { + --arr_depth; + ++pos; + } else { + ++pos; + } + } + } else { + ++pos; + } + } +} + +// Parse params object +std::map parse_params(const std::string &s, + size_t &pos) { + std::map result; + expect_char(s, pos, '{'); + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == '}') { + ++pos; + return result; + } + + while (true) { + std::string key = parse_string(s, pos); + expect_char(s, pos, ':'); + result[key] = parse_value(s, pos); + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == '}') { + ++pos; + break; + } + expect_char(s, pos, ','); + } + return result; +} + +// Parse an input specification +GIRInput parse_input(const std::string &s, size_t &pos) { + GIRInput input; + expect_char(s, pos, '{'); + + while (true) { + skip_ws(s, pos); + if (pos < s.size() && s[pos] == '}') { + ++pos; + break; + } + + std::string key = parse_string(s, pos); + expect_char(s, pos, ':'); + + if (key == "name") { + input.name = parse_string(s, pos); + } else if (key == "dtype") { + input.dtype = parse_dtype(parse_string(s, pos)); + } else if (key == "shape") { + input.shape = parse_int_array(s, pos); + } else if (key == "dynamic_dims") { + auto dims = parse_int_array(s, pos); + input.dynamic_dims.assign(dims.begin(), dims.end()); + } else { + // Skip unknown field + parse_value(s, pos); + } + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ',') { + ++pos; + } + } + return input; +} + +// Parse an output specification +GIROutput parse_output(const std::string &s, size_t &pos) { + GIROutput output; + expect_char(s, pos, '{'); + + while (true) { + skip_ws(s, pos); + if (pos < s.size() && s[pos] == '}') { + ++pos; + break; + } + + std::string key = parse_string(s, pos); + expect_char(s, pos, ':'); + + if (key == "name") { + output.name = parse_string(s, pos); + } else if (key == "node_ref") { + output.node_ref = parse_string(s, pos); + } else if (key == "dtype") { + output.dtype = parse_dtype(parse_string(s, pos)); + } else if (key == "shape") { + output.shape = parse_int_array(s, pos); + } else { + parse_value(s, pos); + } + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ',') { + ++pos; + } + } + return output; +} + +// Parse a node +GIRNode parse_node(const std::string &s, size_t &pos) { + GIRNode node; + expect_char(s, pos, '{'); + + while (true) { + skip_ws(s, pos); + if (pos < s.size() && s[pos] == '}') { + ++pos; + break; + } + + std::string key = parse_string(s, pos); + expect_char(s, pos, ':'); + + if (key == "id") { + node.id = static_cast(parse_number(s, pos)); + } else if (key == "op") { + node.op = parse_string(s, pos); + } else if (key == "name") { + node.name = parse_string(s, pos); + } else if (key == "inputs") { + node.inputs = parse_string_array(s, pos); + } else if (key == "output_shape") { + node.output_shape = parse_int_array(s, pos); + } else if (key == "output_dtype") { + node.output_dtype = parse_dtype(parse_string(s, pos)); + } else if (key == "params") { + node.params = parse_params(s, pos); + } else { + parse_value(s, pos); + } + + skip_ws(s, pos); + if (pos < s.size() && s[pos] == ',') { + ++pos; + } + } + return node; +} + +} // namespace + +GIRGraph parse_gir_json(const std::string &json_str) { + GIRGraph graph; + size_t pos = 0; + + expect_char(json_str, pos, '{'); + + while (true) { + skip_ws(json_str, pos); + if (pos >= json_str.size() || json_str[pos] == '}') { + break; + } + + std::string key = parse_string(json_str, pos); + expect_char(json_str, pos, ':'); + + if (key == "$schema") { + parse_string(json_str, pos); // Ignore + } else if (key == "version") { + graph.version = parse_string(json_str, pos); + } else if (key == "model_type") { + graph.model_type = parse_string(json_str, pos); + } else if (key == "metadata") { + skip_object(json_str, pos); // Skip for now + } else if (key == "constants") { + skip_object(json_str, pos); // Skip for now + } else if (key == "inputs") { + expect_char(json_str, pos, '['); + skip_ws(json_str, pos); + while (pos < json_str.size() && json_str[pos] != ']') { + graph.inputs.push_back(parse_input(json_str, pos)); + skip_ws(json_str, pos); + if (json_str[pos] == ',') + ++pos; + } + expect_char(json_str, pos, ']'); + } else if (key == "outputs") { + expect_char(json_str, pos, '['); + skip_ws(json_str, pos); + while (pos < json_str.size() && json_str[pos] != ']') { + graph.outputs.push_back(parse_output(json_str, pos)); + skip_ws(json_str, pos); + if (json_str[pos] == ',') + ++pos; + } + expect_char(json_str, pos, ']'); + } else if (key == "nodes") { + expect_char(json_str, pos, '['); + skip_ws(json_str, pos); + while (pos < json_str.size() && json_str[pos] != ']') { + graph.nodes.push_back(parse_node(json_str, pos)); + skip_ws(json_str, pos); + if (json_str[pos] == ',') + ++pos; + } + expect_char(json_str, pos, ']'); + } else { + // Skip unknown field + parse_value(json_str, pos); + } + + skip_ws(json_str, pos); + if (json_str[pos] == ',') { + ++pos; + } + } + + return graph; +} + +GIRGraph load_gir_file(const std::string &path) { + std::ifstream file(path); + if (!file.is_open()) { + throw std::runtime_error("Could not open file: " + path); + } + + std::stringstream buffer; + buffer << file.rdbuf(); + return parse_gir_json(buffer.str()); +} + +} // namespace mlipcpp::runtime diff --git a/src/runtime/graph_ir.h b/src/runtime/graph_ir.h new file mode 100644 index 0000000..bd1fb6f --- /dev/null +++ b/src/runtime/graph_ir.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace mlipcpp::runtime { + +// Special values for symbolic dimensions in shapes +// These are used when a dimension depends on runtime parameters +constexpr int64_t DIM_N_ATOMS = -1000001; +constexpr int64_t DIM_MAX_NEIGHBORS = -1000002; +constexpr int64_t DIM_SEQ_LEN = -1000003; // n_atoms * (max_neighbors + 1) +constexpr int64_t DIM_N_EDGES = -1000004; // n_atoms * max_neighbors +constexpr int64_t DIM_MN_PLUS_ONE = -1000005; // max_neighbors + 1 + +// Data types matching the Python GGMLDtype +enum class GIRDtype { F32, F16, I32, I16, I8, BOOL }; + +// Input specification +struct GIRInput { + std::string name; + GIRDtype dtype; + std::vector shape; // -1 for dynamic dimensions + std::vector dynamic_dims; +}; + +// Output specification +struct GIROutput { + std::string name; + std::string node_ref; // "node:N" + GIRDtype dtype; + std::vector shape; +}; + +// Node parameters - can hold various types +using GIRParam = std::variant, std::vector>; + +// A computation node +struct GIRNode { + int id; + std::string op; + std::string name; + std::vector inputs; // "node:N", "input:name", "weight:name", + // "const:value" + std::vector output_shape; + GIRDtype output_dtype; + std::map params; +}; + +// The complete graph +struct GIRGraph { + std::string version; + std::string model_type; + std::vector inputs; + std::vector outputs; + std::vector nodes; + std::map constants; + std::map metadata; + + // Helper to get a node by id + const GIRNode *get_node(int id) const { + for (const auto &node : nodes) { + if (node.id == id) { + return &node; + } + } + return nullptr; + } + + // Helper to find input by name + const GIRInput *get_input(const std::string &name) const { + for (const auto &input : inputs) { + if (input.name == name) { + return &input; + } + } + return nullptr; + } +}; + +// Parse GIR graph from JSON string +GIRGraph parse_gir_json(const std::string &json_str); + +// Parse GIR graph from file +GIRGraph load_gir_file(const std::string &path); + +} // namespace mlipcpp::runtime diff --git a/src/runtime/graph_model.cpp b/src/runtime/graph_model.cpp new file mode 100644 index 0000000..9d19112 --- /dev/null +++ b/src/runtime/graph_model.cpp @@ -0,0 +1,578 @@ +#include "graph_model.h" +#include "core/gguf_loader.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace mlipcpp::runtime { + +namespace { + +// Bump cutoff function +float cutoff_func_bump(float distance, float cutoff, float width) { + float x = (distance - (cutoff - width)) / width; + if (x <= 0.0f) return 1.0f; + if (x >= 1.0f) return 0.0f; + float tan_val = std::tan(static_cast(M_PI) * x); + return 0.5f * (1.0f + std::tanh(1.0f / tan_val)); +} + +// Cosine cutoff function +float cutoff_func_cosine(float distance, float cutoff, float width) { + float x = (distance - (cutoff - width)) / width; + if (x <= 0.0f) return 1.0f; + if (x >= 1.0f) return 0.0f; + return 0.5f * (1.0f + std::cos(static_cast(M_PI) * x)); +} + +} // namespace + +// Context sizes +static constexpr size_t INPUT_CTX_SIZE = 16 * 1024 * 1024; // 16 MB +static constexpr size_t COMPUTE_CTX_SIZE = 512 * 1024 * 1024; // 512 MB + +GraphModel::GraphModel() + : neighbor_builder_(NeighborListOptions{cutoff_, true, false}) {} + +GraphModel::~GraphModel() { + if (weight_buffer_) { + ggml_backend_buffer_free(weight_buffer_); + } + if (ctx_weights_) { + ggml_free(ctx_weights_); + } + // compute_backend_ is owned by backend_provider_; do not free here. +} + +bool GraphModel::load_from_gguf(const std::string &path) { + constexpr size_t TEMP_CTX_SIZE = 512 * 1024 * 1024; + + // Create temporary context with data allocation + ggml_context *temp_ctx = ggml_init({TEMP_CTX_SIZE, nullptr, false}); + if (!temp_ctx) { + throw std::runtime_error("Failed to create temporary context for loading"); + } + + GGUFLoader loader(path, temp_ctx); + int n_tensors = static_cast(loader.get_tensor_names().size()); + + // Read model hyperparameters + cutoff_ = loader.get_float32("pet.cutoff", 4.5f); + cutoff_width_ = loader.get_float32("pet.cutoff_width", 0.2f); + energy_scale_ = loader.get_float32("pet.energy_scale", 1.0f); + cutoff_function_ = loader.get_string("pet.cutoff_function", "cosine"); + forces_mode_ = (loader.get_int32("pet.forces_mode", 0) != 0); + num_neighbors_adaptive_ = loader.get_float32("pet.num_neighbors_adaptive", 0.0f); + + // Update neighbor list builder with loaded cutoff + neighbor_builder_ = NeighborListBuilder(NeighborListOptions{cutoff_, true, false}); + + // Load graph JSON + std::string graph_json = loader.get_string("graph.json", ""); + if (graph_json.empty()) { + ggml_free(temp_ctx); + throw std::runtime_error("No graph.json found in GGUF file"); + } + interp_.load_graph(graph_json); + + // Load species mapping + auto species_map = loader.get_array_int32("pet.species_map"); + for (size_t i = 0; i + 1 < species_map.size(); i += 2) { + species_to_index_[species_map[i]] = species_map[i + 1]; + } + + // Load composition energies + auto comp_keys = loader.get_array_int32("pet.composition_keys"); + auto comp_vals = loader.get_array_float32("pet.composition_values"); + if (comp_keys.size() != comp_vals.size()) { + ggml_free(temp_ctx); + throw std::runtime_error( + "GraphModel: composition_keys and composition_values mismatch"); + } + for (size_t i = 0; i < comp_keys.size(); i++) { + composition_energies_[comp_keys[i]] = comp_vals[i]; + } + + // Create backend + backend_provider_ = BackendProvider::create(backend_preference_); + + // Load weight shapes from metadata (PyTorch shapes, need reversal for GGML) + std::string shapes_json = loader.get_string("graph.weight_shapes", ""); + json weight_shapes; + if (!shapes_json.empty()) { + weight_shapes = json::parse(shapes_json); + } + + // Create weight context (metadata only, no data allocation) + size_t ctx_size = ggml_tensor_overhead() * static_cast(n_tensors); + ctx_weights_ = ggml_init({ctx_size, nullptr, true}); + if (!ctx_weights_) { + ggml_free(temp_ctx); + throw std::runtime_error("Failed to create weight context"); + } + + // Create weight tensors with correct GGML shapes (reversed PyTorch dims) + for (const auto &name : loader.get_tensor_names()) { + ggml_tensor *temp = loader.get_tensor(name); + if (!temp) continue; + + ggml_tensor *t = nullptr; + if (weight_shapes.contains(name)) { + // Use PyTorch shape from metadata, reversed for GGML convention + auto py_shape = weight_shapes[name].get>(); + std::vector ggml_shape(py_shape.rbegin(), py_shape.rend()); + switch (ggml_shape.size()) { + case 0: + t = ggml_new_tensor_1d(ctx_weights_, GGML_TYPE_F32, 1); + break; + case 1: + t = ggml_new_tensor_1d(ctx_weights_, GGML_TYPE_F32, ggml_shape[0]); + break; + case 2: + t = ggml_new_tensor_2d(ctx_weights_, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1]); + break; + case 3: + t = ggml_new_tensor_3d(ctx_weights_, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1], ggml_shape[2]); + break; + default: + continue; + } + } else { + // Fallback: use GGUF stored shape directly + t = ggml_new_tensor( + ctx_weights_, temp->type, ggml_n_dims(temp), temp->ne); + } + + ggml_set_name(t, name.c_str()); + } + + // Allocate backend buffer for weights + ggml_backend_buffer_type_t buft = backend_provider_->buffer_type(); + weight_buffer_ = ggml_backend_alloc_ctx_tensors_from_buft(ctx_weights_, buft); + if (!weight_buffer_) { + ggml_free(temp_ctx); + throw std::runtime_error("Failed to allocate weight buffer"); + } + ggml_backend_buffer_set_usage(weight_buffer_, GGML_BACKEND_BUFFER_USAGE_WEIGHTS); + + // Copy weight data and register with interpreter + for (const auto &name : loader.get_tensor_names()) { + ggml_tensor *temp = loader.get_tensor(name); + ggml_tensor *weight = ggml_get_tensor(ctx_weights_, name.c_str()); + if (temp && weight) { + ggml_backend_tensor_set(weight, temp->data, 0, ggml_nbytes(weight)); + interp_.set_weight(name, weight); + } + } + + ggml_free(temp_ctx); + + // Use primary backend (may be GPU) for compute; owned by BackendProvider. + compute_backend_ = backend_provider_->primary(); + if (!compute_backend_) { + throw std::runtime_error("Failed to get compute backend"); + } + + return true; +} + +void GraphModel::load_graph_file(const std::string &path) { + interp_.load_graph_file(path); +} + +void GraphModel::set_weight(const std::string &name, ggml_tensor *tensor) { + interp_.set_weight(name, tensor); +} + +ModelResult GraphModel::predict(const AtomicSystem &system) { + return predict_single(system, false); +} + +ModelResult GraphModel::predict(const AtomicSystem &system, + bool compute_forces) { + return predict_single(system, compute_forces); +} + +ModelResult GraphModel::predict_single(const AtomicSystem &system, + bool compute_forces) { + if (compute_forces && !forces_mode_) { + throw std::runtime_error( + "GraphModel: forces requested but model was exported with " + "--no-forces. Re-export without --no-forces to enable forces."); + } + + const int n_atoms = static_cast(system.num_atoms()); + const int32_t *atomic_numbers = system.atomic_numbers(); + + // Build neighbor list + NeighborList nlist = neighbor_builder_.build(system); + + // Count max neighbors + std::vector neighbor_counts(n_atoms, 0); + for (int e = 0; e < nlist.num_pairs(); e++) { + neighbor_counts[nlist.centers[e]]++; + } + int max_neighbors = 0; + for (int i = 0; i < n_atoms; i++) { + max_neighbors = std::max(max_neighbors, neighbor_counts[i]); + } + if (max_neighbors == 0) { + max_neighbors = 1; + } + + // Per-pair cutoff distances (for bump cutoff computation) + std::vector pair_cutoffs(nlist.num_pairs(), cutoff_); + + // Set symbolic dimensions for this system + interp_.set_dimension("n_atoms", n_atoms); + interp_.set_dimension("max_neighbors", max_neighbors); + interp_.set_dimension("n_edges", n_atoms * max_neighbors); + interp_.set_dimension("max_neighbors_plus_one", max_neighbors + 1); + + const int total_slots = n_atoms * max_neighbors; + + // --- Create input context --- + ggml_context *input_ctx = ggml_init({INPUT_CTX_SIZE, nullptr, true}); + if (!input_ctx) { + throw std::runtime_error("Failed to create input context"); + } + + // Create input tensors + ggml_tensor *species_t = ggml_new_tensor_1d(input_ctx, GGML_TYPE_I32, n_atoms); + ggml_set_name(species_t, "species"); + + ggml_tensor *neighbor_species_t = + ggml_new_tensor_2d(input_ctx, GGML_TYPE_I32, max_neighbors, n_atoms); + ggml_set_name(neighbor_species_t, "neighbor_species"); + + ggml_tensor *edge_vectors_t = + ggml_new_tensor_3d(input_ctx, GGML_TYPE_F32, 3, max_neighbors, n_atoms); + ggml_set_name(edge_vectors_t, "edge_vectors"); + + ggml_tensor *padding_mask_t = + ggml_new_tensor_2d(input_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(padding_mask_t, "padding_mask"); + + ggml_tensor *reverse_neighbor_index_t = + ggml_new_tensor_1d(input_ctx, GGML_TYPE_I32, total_slots); + ggml_set_name(reverse_neighbor_index_t, "reverse_neighbor_index"); + + // Mode-specific inputs + ggml_tensor *edge_distances_t = nullptr; + ggml_tensor *cutoff_factors_t = nullptr; + ggml_tensor *cutoff_values_t = nullptr; + + if (!forces_mode_) { + edge_distances_t = + ggml_new_tensor_2d(input_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(edge_distances_t, "edge_distances"); + + cutoff_factors_t = + ggml_new_tensor_2d(input_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(cutoff_factors_t, "cutoff_factors"); + } else { + cutoff_values_t = + ggml_new_tensor_2d(input_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(cutoff_values_t, "cutoff_values"); + } + + // Mark edge_vectors as parameter for gradient computation + if (compute_forces) { + ggml_set_param(edge_vectors_t); + } + + // Allocate input buffer + ggml_backend_buffer_t input_buffer = + ggml_backend_alloc_ctx_tensors(input_ctx, compute_backend_); + if (!input_buffer) { + ggml_free(input_ctx); + throw std::runtime_error("Failed to allocate input buffer"); + } + + // --- Pack neighbor list data --- + std::vector species_data(n_atoms); + for (int i = 0; i < n_atoms; i++) { + auto it = species_to_index_.find(atomic_numbers[i]); + if (it == species_to_index_.end()) { + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + throw std::runtime_error( + "Atomic number " + std::to_string(atomic_numbers[i]) + + " not in species map"); + } + species_data[i] = it->second; + } + + std::vector ns_data(total_slots, 0); + std::vector ev_data(total_slots * 3, 0.0f); + std::vector ed_data(total_slots, 0.0f); + std::vector pm_data(total_slots, 1.0f); // 1.0 = padded + std::vector cf_data(total_slots, 0.0f); + std::vector cv_data(total_slots, cutoff_); + std::vector rni_data(total_slots, 0); + std::vector neighbor_atoms_vec(total_slots, -1); + + // Build edge mapping + using EdgeKey = std::tuple; + std::map edge_to_flat_idx; + std::vector slot_indices(n_atoms, 0); + bool has_cell_shifts = !nlist.cell_shifts.empty(); + + for (int e = 0; e < nlist.num_pairs(); e++) { + int i = nlist.centers[e]; + int j = nlist.neighbors[e]; + int slot = slot_indices[i]++; + if (slot >= max_neighbors) continue; + + int flat_idx = i * max_neighbors + slot; + + int sa = 0, sb = 0, sc = 0; + if (has_cell_shifts) { + sa = nlist.cell_shifts[e][0]; + sb = nlist.cell_shifts[e][1]; + sc = nlist.cell_shifts[e][2]; + } + edge_to_flat_idx[{i, j, sa, sb, sc}] = flat_idx; + + auto it = species_to_index_.find(atomic_numbers[j]); + if (it == species_to_index_.end()) { + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + throw std::runtime_error( + "Atomic number " + std::to_string(atomic_numbers[j]) + + " not in species map"); + } + ns_data[flat_idx] = it->second; + + const auto &ev = nlist.edge_vectors[e]; + int ev_idx = i * (max_neighbors * 3) + slot * 3; + ev_data[ev_idx + 0] = ev[0]; + ev_data[ev_idx + 1] = ev[1]; + ev_data[ev_idx + 2] = ev[2]; + + ed_data[flat_idx] = nlist.distances[e]; + pm_data[flat_idx] = 0.0f; // valid edge + neighbor_atoms_vec[flat_idx] = j; + + float r = nlist.distances[e]; + float pc = pair_cutoffs[e]; + cv_data[flat_idx] = pc; + if (cutoff_function_ == "bump") { + cf_data[flat_idx] = cutoff_func_bump(r, pc, cutoff_width_); + } else { + cf_data[flat_idx] = cutoff_func_cosine(r, pc, cutoff_width_); + } + } + + // Build reverse neighbor index + for (int e = 0; e < nlist.num_pairs(); e++) { + int i = nlist.centers[e]; + int j = nlist.neighbors[e]; + int sa = 0, sb = 0, sc = 0; + if (has_cell_shifts) { + sa = nlist.cell_shifts[e][0]; + sb = nlist.cell_shifts[e][1]; + sc = nlist.cell_shifts[e][2]; + } + auto it_ij = edge_to_flat_idx.find({i, j, sa, sb, sc}); + if (it_ij == edge_to_flat_idx.end()) continue; + auto it_ji = edge_to_flat_idx.find({j, i, -sa, -sb, -sc}); + if (it_ji != edge_to_flat_idx.end()) { + rni_data[it_ij->second] = it_ji->second; + } + } + + // Copy data to tensors + ggml_backend_tensor_set(species_t, species_data.data(), 0, + species_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(neighbor_species_t, ns_data.data(), 0, + ns_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(edge_vectors_t, ev_data.data(), 0, + ev_data.size() * sizeof(float)); + ggml_backend_tensor_set(padding_mask_t, pm_data.data(), 0, + pm_data.size() * sizeof(float)); + ggml_backend_tensor_set(reverse_neighbor_index_t, rni_data.data(), 0, + rni_data.size() * sizeof(int32_t)); + + // Register common inputs + interp_.set_input("species", species_t); + interp_.set_input("neighbor_species", neighbor_species_t); + interp_.set_input("edge_vectors", edge_vectors_t); + interp_.set_input("padding_mask", padding_mask_t); + interp_.set_input("reverse_neighbor_index", reverse_neighbor_index_t); + + // Register mode-specific inputs + if (!forces_mode_) { + ggml_backend_tensor_set(edge_distances_t, ed_data.data(), 0, + ed_data.size() * sizeof(float)); + ggml_backend_tensor_set(cutoff_factors_t, cf_data.data(), 0, + cf_data.size() * sizeof(float)); + interp_.set_input("edge_distances", edge_distances_t); + interp_.set_input("cutoff_factors", cutoff_factors_t); + } else { + ggml_backend_tensor_set(cutoff_values_t, cv_data.data(), 0, + cv_data.size() * sizeof(float)); + interp_.set_input("cutoff_values", cutoff_values_t); + } + + // --- Build and compute --- + ggml_context *compute_ctx = ggml_init({COMPUTE_CTX_SIZE, nullptr, true}); + if (!compute_ctx) { + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + throw std::runtime_error("Failed to create compute context"); + } + + ggml_tensor *output = interp_.build(compute_ctx); + if (!output) { + ggml_free(compute_ctx); + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + throw std::runtime_error("Failed to build computation graph"); + } + ggml_set_output(output); + + ggml_cgraph *cgraph = nullptr; + + if (compute_forces) { + // Build forward + backward graph + ggml_tensor *total_energy = ggml_sum(compute_ctx, output); + ggml_set_loss(total_energy); + ggml_set_output(total_energy); + + cgraph = ggml_new_graph_custom(compute_ctx, 32768, true); + ggml_build_forward_expand(cgraph, output); + ggml_build_forward_expand(cgraph, total_energy); + ggml_build_backward_expand(compute_ctx, cgraph, nullptr); + + ggml_tensor *grad = ggml_graph_get_grad(cgraph, edge_vectors_t); + if (grad) { + ggml_set_output(grad); + } else { + compute_forces = false; + } + } else { + cgraph = ggml_new_graph(compute_ctx); + ggml_build_forward_expand(cgraph, output); + } + + ggml_backend_buffer_t compute_buffer = + ggml_backend_alloc_ctx_tensors(compute_ctx, compute_backend_); + if (!compute_buffer) { + ggml_free(compute_ctx); + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + throw std::runtime_error("Failed to allocate compute buffer"); + } + + interp_.init_constants(); + + if (compute_forces) { + ggml_graph_reset(cgraph); + } + + ggml_status status = ggml_backend_graph_compute(compute_backend_, cgraph); + if (status != GGML_STATUS_SUCCESS) { + ggml_backend_buffer_free(compute_buffer); + ggml_free(compute_ctx); + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + throw std::runtime_error("Graph computation failed"); + } + + // --- Extract results --- + ModelResult result; + + // Get atomic energies + std::vector atomic_energies(n_atoms); + ggml_backend_tensor_get(output, atomic_energies.data(), 0, + n_atoms * sizeof(float)); + + // Sum and scale + float model_energy = 0.0f; + for (int i = 0; i < n_atoms; i++) { + model_energy += atomic_energies[i]; + } + float scaled_energy = model_energy * energy_scale_; + + // Add composition energies + float composition_energy = 0.0f; + for (int i = 0; i < n_atoms; i++) { + auto it = composition_energies_.find(atomic_numbers[i]); + if (it != composition_energies_.end()) { + composition_energy += it->second; + } + } + + result.energy = scaled_energy + composition_energy; + + // Extract forces + if (compute_forces) { + ggml_tensor *grad_tensor = ggml_graph_get_grad(cgraph, edge_vectors_t); + if (grad_tensor && grad_tensor->data) { + std::vector grad_data(ggml_nelements(grad_tensor)); + ggml_backend_tensor_get(grad_tensor, grad_data.data(), 0, + ggml_nbytes(grad_tensor)); + + // Scatter edge gradients to per-atom forces + result.forces.resize(n_atoms * 3, 0.0f); + const int stride_slot = 3; + const int stride_atom = 3 * max_neighbors; + + for (int ca = 0; ca < n_atoms; ca++) { + for (int slot = 0; slot < max_neighbors; slot++) { + int flat_idx = ca * max_neighbors + slot; + if (pm_data[flat_idx] > 0.5f) continue; + + int na = neighbor_atoms_vec[flat_idx]; + if (na < 0) continue; + + int base = slot * stride_slot + ca * stride_atom; + float gx = grad_data[0 + base]; + float gy = grad_data[1 + base]; + float gz = grad_data[2 + base]; + + result.forces[ca * 3 + 0] += gx; + result.forces[ca * 3 + 1] += gy; + result.forces[ca * 3 + 2] += gz; + + result.forces[na * 3 + 0] -= gx; + result.forces[na * 3 + 1] -= gy; + result.forces[na * 3 + 2] -= gz; + } + } + + // Apply energy scale + for (int i = 0; i < n_atoms * 3; i++) { + result.forces[i] *= energy_scale_; + } + + result.has_forces = true; + } + } + + // Cleanup + ggml_backend_buffer_free(compute_buffer); + ggml_free(compute_ctx); + ggml_backend_buffer_free(input_buffer); + ggml_free(input_ctx); + + return result; +} + +} // namespace mlipcpp::runtime diff --git a/src/runtime/graph_model.h b/src/runtime/graph_model.h new file mode 100644 index 0000000..4aa08d8 --- /dev/null +++ b/src/runtime/graph_model.h @@ -0,0 +1,112 @@ +#pragma once + +#include "core/backend.h" +#include "graph_interpreter.h" +#include "mlipcpp/model.h" +#include "mlipcpp/neighbor_list.h" + +#include +#include +#include +#include + +struct ggml_context; +struct ggml_backend; +struct ggml_backend_buffer; + +typedef struct ggml_backend *ggml_backend_t; +typedef struct ggml_backend_buffer *ggml_backend_buffer_t; + +namespace mlipcpp::runtime { + +/** + * Model implementation using auto-exported computation graphs. + * + * Wraps GraphInterpreter to provide the standard Model interface, + * enabling automatic PyTorch -> GGML model conversion. + * + * Supports dynamic system sizes: the graph is exported with symbolic + * dimensions (n_atoms, max_neighbors) that are resolved at runtime. + * + * Usage: + * GraphModel model; + * model.load_from_gguf("model.gguf"); + * ModelResult result = model.predict(system); + * ModelResult result_f = model.predict(system, true); // with forces + */ +class GraphModel : public Model { +public: + GraphModel(); + ~GraphModel() override; + + // Model interface + ModelResult predict(const AtomicSystem &system) override; + ModelResult predict(const AtomicSystem &system, bool compute_forces) override; + std::string model_type() const override { return "graph"; } + float cutoff() const override { return cutoff_; } + + /** + * Load model from GGUF file. + * + * The GGUF file must contain: + * - Weights as tensors + * - Graph JSON in metadata field "graph.json" + * - Model hyperparameters (cutoff, species map, etc.) + */ + bool load_from_gguf(const std::string &path); + + /** + * Load graph from separate JSON file (for testing). + */ + void load_graph_file(const std::string &path); + + /** + * Set a weight tensor manually (for testing). + */ + void set_weight(const std::string &name, ggml_tensor *tensor); + + /** + * Set backend preference. + */ + void set_backend_preference(BackendPreference pref) { + backend_preference_ = pref; + } + + /** + * Get the underlying graph interpreter for inspection. + */ + const GraphInterpreter &interpreter() const { return interp_; } + +private: + GraphInterpreter interp_; + + // Model hyperparameters + float cutoff_ = 4.5f; + float cutoff_width_ = 0.2f; + float energy_scale_ = 1.0f; + std::string cutoff_function_ = "cosine"; + bool forces_mode_ = false; + float num_neighbors_adaptive_ = 0.0f; + + BackendPreference backend_preference_ = BackendPreference::Auto; + + // GGML contexts and backend + ggml_context *ctx_weights_ = nullptr; + std::shared_ptr backend_provider_; + ggml_backend_buffer_t weight_buffer_ = nullptr; + ggml_backend_t compute_backend_ = nullptr; + + // Species mapping (atomic number -> index) + std::map species_to_index_; + + // Composition energies (atomic reference energies) + std::map composition_energies_; + + // Neighbor list builder + NeighborListBuilder neighbor_builder_; + + // Predict a single system (all logic lives here) + ModelResult predict_single(const AtomicSystem &system, bool compute_forces); +}; + +} // namespace mlipcpp::runtime diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 43a3162..f05ab2f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -93,6 +93,45 @@ target_link_libraries(test_system Catch2::Catch2WithMain ) +# Graph interpreter tests +add_executable(test_graph_interpreter + test_graph_interpreter.cpp +) + +target_link_libraries(test_graph_interpreter + PRIVATE + mlipcpp + Catch2::Catch2WithMain + ggml + fmt::fmt +) + +target_include_directories(test_graph_interpreter + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ +) + +# Auto-export vs manual comparison tests +add_executable(test_auto_vs_manual + test_auto_vs_manual.cpp +) + +target_link_libraries(test_auto_vs_manual + PRIVATE + mlipcpp + Catch2::Catch2WithMain + ggml + fmt::fmt +) + +target_include_directories(test_auto_vs_manual + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ + ${CMAKE_CURRENT_SOURCE_DIR}/../src/models/pet +) + # Register with CTest include(CTest) list(APPEND CMAKE_MODULE_PATH ${Catch2_SOURCE_DIR}/extras) @@ -102,3 +141,47 @@ catch_discover_tests(test_pet_gradients) catch_discover_tests(test_io) catch_discover_tests(test_neighbor_list) catch_discover_tests(test_system) +catch_discover_tests(test_graph_interpreter) +catch_discover_tests(test_auto_vs_manual) + +# Full export integration test +add_executable(test_full_export + test_full_export.cpp +) + +target_link_libraries(test_full_export + PRIVATE + mlipcpp + Catch2::Catch2WithMain + ggml + fmt::fmt +) + +target_include_directories(test_full_export + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ +) + +catch_discover_tests(test_full_export) + +# GraphModel tests +add_executable(test_graph_model + test_graph_model.cpp +) + +target_link_libraries(test_graph_model + PRIVATE + mlipcpp + Catch2::Catch2WithMain + ggml + fmt::fmt +) + +target_include_directories(test_graph_model + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ +) + +catch_discover_tests(test_graph_model) diff --git a/tests/test_auto_vs_manual.cpp b/tests/test_auto_vs_manual.cpp new file mode 100644 index 0000000..dce4e31 --- /dev/null +++ b/tests/test_auto_vs_manual.cpp @@ -0,0 +1,373 @@ +/** + * @file test_auto_vs_manual.cpp + * @brief Side-by-side comparison of auto-exported GraphModel vs manual PET + * + * This test verifies that the automatic PyTorch -> GGML export produces + * numerically equivalent results to the hand-coded PET implementation. + * + * Reference values from existing tests: + * - water.xyz (3 atoms: O, H, H): -14.380176 eV + * - si.xyz (2 atoms: Si, Si): -4.538056 eV + */ + +#include +#include + +#include "mlipcpp/io.h" +#include "mlipcpp/model.h" +#include "mlipcpp/system.h" +#include "models/pet/pet.h" +#include "runtime/graph_model.h" + +#include +#include +#include +#include +#include + +using namespace mlipcpp; +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +namespace fs = std::filesystem; + +// Test data paths +static const char *MANUAL_MODEL_PATH = "local/pet-mad.gguf"; +static const char *AUTO_MODEL_PATH = "gguf/pet-auto.gguf"; +static const char *WATER_XYZ = "geometries/water.xyz"; +static const char *SI_XYZ = "geometries/si.xyz"; + +// Reference energies from existing PET tests +static constexpr float WATER_ENERGY_REF = -14.380176f; +static constexpr float SI_ENERGY_REF = -4.538056f; +static constexpr float ENERGY_TOLERANCE = 1e-4f; + +/** + * Helper to check if a file exists + */ +static bool file_exists(const std::string &path) { + return fs::exists(path) && fs::is_regular_file(path); +} + +/** + * Load an XYZ file + */ +static AtomicSystem load_xyz(const std::string &path) { + std::ifstream file(path); + if (!file.is_open()) { + throw std::runtime_error("Cannot open XYZ file: " + path); + } + return io::read_xyz(file); +} + +/** + * Check if a GraphModel's GGUF uses the current full-model format. + */ +static bool has_current_graph_format(const runtime::GraphModel &model) { + const auto &graph = model.interpreter().graph(); + for (const auto &inp : graph.inputs) { + if (inp.name == "species") return true; + } + return false; +} + +// ============================================================================ +// Graph Interpreter Unit Tests (don't require full model) +// ============================================================================ + +TEST_CASE("GraphModel basic construction", "[auto_export][graph_model]") { + runtime::GraphModel model; + REQUIRE(model.model_type() == "graph"); + REQUIRE(model.cutoff() > 0.0f); +} + +TEST_CASE("GraphModel loads simple graph JSON", "[auto_export][graph_model]") { + // Create a simple test graph JSON + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [10]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "SCALE", "name": "scale", "inputs": ["input:x"], + "output_shape": [10], "output_dtype": "f32", "params": {"scale": 2.0}} + ] + })"; + + // Write to temp file + std::string temp_path = "/tmp/test_simple_graph.json"; + { + std::ofstream f(temp_path); + f << json; + } + + runtime::GraphModel model; + REQUIRE_NOTHROW(model.load_graph_file(temp_path)); + + // Check graph was loaded + const auto &graph = model.interpreter().graph(); + REQUIRE(graph.nodes.size() == 1); + REQUIRE(graph.inputs.size() == 1); + REQUIRE(graph.inputs[0].name == "x"); + + // Cleanup + fs::remove(temp_path); +} + +// ============================================================================ +// Manual PET Model Tests (baseline) +// ============================================================================ + +TEST_CASE("Manual PET model loads and predicts", "[auto_export][manual]") { + if (!file_exists(MANUAL_MODEL_PATH)) { + SKIP("Manual PET model not found at " << MANUAL_MODEL_PATH); + } + if (!file_exists(WATER_XYZ)) { + SKIP("Water XYZ file not found at " << WATER_XYZ); + } + + // Load manual model + pet::PETModel model(pet::PETHypers{}); + REQUIRE(model.load_from_gguf(MANUAL_MODEL_PATH)); + REQUIRE(model.model_type() == "pet"); + + // Load test system + AtomicSystem water = load_xyz(WATER_XYZ); + REQUIRE(water.num_atoms() == 3); + + // Predict + ModelResult result = model.predict(water); + + // Check energy is reasonable + INFO("Manual PET energy: " << result.energy << " eV"); + INFO("Reference energy: " << WATER_ENERGY_REF << " eV"); + REQUIRE_THAT(result.energy, WithinAbs(WATER_ENERGY_REF, ENERGY_TOLERANCE)); +} + +TEST_CASE("Manual PET silicon test", "[auto_export][manual]") { + if (!file_exists(MANUAL_MODEL_PATH)) { + SKIP("Manual PET model not found at " << MANUAL_MODEL_PATH); + } + if (!file_exists(SI_XYZ)) { + SKIP("Silicon XYZ file not found at " << SI_XYZ); + } + + pet::PETModel model(pet::PETHypers{}); + REQUIRE(model.load_from_gguf(MANUAL_MODEL_PATH)); + + AtomicSystem si = load_xyz(SI_XYZ); + ModelResult result = model.predict(si); + + INFO("Manual PET silicon energy: " << result.energy << " eV"); + INFO("Reference energy: " << SI_ENERGY_REF << " eV"); + REQUIRE_THAT(result.energy, WithinAbs(SI_ENERGY_REF, ENERGY_TOLERANCE)); +} + +// ============================================================================ +// Auto-Export GraphModel Tests +// ============================================================================ + +TEST_CASE("GraphModel loads auto-exported GGUF", "[auto_export][graphmodel]") { + if (!file_exists(AUTO_MODEL_PATH)) { + SKIP("Auto-exported model not found at " << AUTO_MODEL_PATH + << " - run: uv run scripts/export_pytorch/export_pet_gguf.py"); + } + + runtime::GraphModel model; + REQUIRE_NOTHROW(model.load_from_gguf(AUTO_MODEL_PATH)); + REQUIRE(model.model_type() == "graph"); + + // Check hyperparameters loaded + INFO("GraphModel cutoff: " << model.cutoff()); + REQUIRE(model.cutoff() > 0.0f); +} + +TEST_CASE("GraphModel water prediction", "[auto_export][graphmodel]") { + if (!file_exists(AUTO_MODEL_PATH)) { + SKIP("Auto-exported model not found"); + } + if (!file_exists(WATER_XYZ)) { + SKIP("Water XYZ file not found"); + } + + runtime::GraphModel model; + REQUIRE(model.load_from_gguf(AUTO_MODEL_PATH)); + if (!has_current_graph_format(model)) { + SKIP("GGUF uses old graph format - re-export with export_pet_gguf.py"); + } + + AtomicSystem water = load_xyz(WATER_XYZ); + ModelResult result = model.predict(water); + + INFO("GraphModel water energy: " << result.energy << " eV"); + INFO("Reference energy: " << WATER_ENERGY_REF << " eV"); + + // Note: This may fail initially until the graph export is complete + // The tolerance is relaxed for development + REQUIRE_THAT(result.energy, WithinAbs(WATER_ENERGY_REF, 0.1f)); +} + +// ============================================================================ +// Side-by-Side Comparison Tests +// ============================================================================ + +TEST_CASE("Auto-export matches manual PET - water", + "[auto_export][comparison]") { + if (!file_exists(MANUAL_MODEL_PATH) || !file_exists(AUTO_MODEL_PATH)) { + SKIP("Both models required for comparison test"); + } + if (!file_exists(WATER_XYZ)) { + SKIP("Water XYZ file not found"); + } + + // Load both models + pet::PETModel manual_model(pet::PETHypers{}); + REQUIRE(manual_model.load_from_gguf(MANUAL_MODEL_PATH)); + + runtime::GraphModel auto_model; + REQUIRE(auto_model.load_from_gguf(AUTO_MODEL_PATH)); + if (!has_current_graph_format(auto_model)) { + SKIP("GGUF uses old graph format - re-export with export_pet_gguf.py"); + } + + // Load test system + AtomicSystem water = load_xyz(WATER_XYZ); + + // Predict with both + ModelResult manual_result = manual_model.predict(water); + ModelResult auto_result = auto_model.predict(water); + + // Compare + float diff = std::abs(manual_result.energy - auto_result.energy); + INFO("Manual PET energy: " << manual_result.energy << " eV"); + INFO("Auto-export energy: " << auto_result.energy << " eV"); + INFO("Difference: " << diff << " eV"); + + // They should match within tolerance + REQUIRE_THAT(auto_result.energy, + WithinAbs(manual_result.energy, ENERGY_TOLERANCE)); +} + +TEST_CASE("Auto-export matches manual PET - silicon", + "[auto_export][comparison]") { + if (!file_exists(MANUAL_MODEL_PATH) || !file_exists(AUTO_MODEL_PATH)) { + SKIP("Both models required for comparison test"); + } + if (!file_exists(SI_XYZ)) { + SKIP("Silicon XYZ file not found"); + } + + pet::PETModel manual_model(pet::PETHypers{}); + REQUIRE(manual_model.load_from_gguf(MANUAL_MODEL_PATH)); + + runtime::GraphModel auto_model; + REQUIRE(auto_model.load_from_gguf(AUTO_MODEL_PATH)); + if (!has_current_graph_format(auto_model)) { + SKIP("GGUF uses old graph format - re-export with export_pet_gguf.py"); + } + + AtomicSystem si = load_xyz(SI_XYZ); + + ModelResult manual_result = manual_model.predict(si); + ModelResult auto_result = auto_model.predict(si); + + float diff = std::abs(manual_result.energy - auto_result.energy); + INFO("Manual PET silicon energy: " << manual_result.energy << " eV"); + INFO("Auto-export silicon energy: " << auto_result.energy << " eV"); + INFO("Difference: " << diff << " eV"); + + REQUIRE_THAT(auto_result.energy, + WithinAbs(manual_result.energy, ENERGY_TOLERANCE)); +} + +TEST_CASE("Auto-export sequential prediction", "[auto_export][sequential]") { + if (!file_exists(AUTO_MODEL_PATH)) { + SKIP("Auto-exported model not found"); + } + if (!file_exists(WATER_XYZ) || !file_exists(SI_XYZ)) { + SKIP("Test XYZ files not found"); + } + + runtime::GraphModel model; + REQUIRE(model.load_from_gguf(AUTO_MODEL_PATH)); + if (!has_current_graph_format(model)) { + SKIP("GGUF uses old graph format - re-export with export_pet_gguf.py"); + } + + // Load test systems + AtomicSystem water = load_xyz(WATER_XYZ); + AtomicSystem si = load_xyz(SI_XYZ); + + // Sequential prediction (GraphModel handles dynamic sizes) + ModelResult water_result = model.predict(water); + ModelResult si_result = model.predict(si); + + INFO("Water energy: " << water_result.energy << " eV"); + INFO("Silicon energy: " << si_result.energy << " eV"); + + // Each should be close to reference + REQUIRE_THAT(water_result.energy, WithinAbs(WATER_ENERGY_REF, 0.1f)); + REQUIRE_THAT(si_result.energy, WithinAbs(SI_ENERGY_REF, 0.1f)); +} + +// ============================================================================ +// Performance Comparison (informational, not failing) +// ============================================================================ + +TEST_CASE("Performance comparison manual vs auto", + "[auto_export][performance][!mayfail]") { + if (!file_exists(MANUAL_MODEL_PATH) || !file_exists(AUTO_MODEL_PATH)) { + SKIP("Both models required for performance test"); + } + if (!file_exists(WATER_XYZ)) { + SKIP("Water XYZ file not found"); + } + + pet::PETModel manual_model(pet::PETHypers{}); + REQUIRE(manual_model.load_from_gguf(MANUAL_MODEL_PATH)); + + runtime::GraphModel auto_model; + REQUIRE(auto_model.load_from_gguf(AUTO_MODEL_PATH)); + if (!has_current_graph_format(auto_model)) { + SKIP("GGUF uses old graph format - re-export with export_pet_gguf.py"); + } + + AtomicSystem water = load_xyz(WATER_XYZ); + + // Warmup + for (int i = 0; i < 3; i++) { + manual_model.predict(water); + auto_model.predict(water); + } + + // Time manual + constexpr int N_ITERS = 10; + auto t0 = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < N_ITERS; i++) { + manual_model.predict(water); + } + auto t1 = std::chrono::high_resolution_clock::now(); + float manual_ms = + std::chrono::duration(t1 - t0).count() / N_ITERS; + + // Time auto + t0 = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < N_ITERS; i++) { + auto_model.predict(water); + } + t1 = std::chrono::high_resolution_clock::now(); + float auto_ms = + std::chrono::duration(t1 - t0).count() / N_ITERS; + + INFO("Manual PET: " << manual_ms << " ms/iter"); + INFO("Auto-export: " << auto_ms << " ms/iter"); + INFO("Ratio (auto/manual): " << (auto_ms / manual_ms)); + + // Auto should be within 2x of manual (generous for now) + // This may fail if auto is slower, which is acceptable during development + REQUIRE(auto_ms < manual_ms * 2.0f); +} diff --git a/tests/test_full_export.cpp b/tests/test_full_export.cpp new file mode 100644 index 0000000..613e15e --- /dev/null +++ b/tests/test_full_export.cpp @@ -0,0 +1,689 @@ +/** + * Test the full PET graph export with neighbor list inputs. + * + * This test loads the graph exported by export_pet_full.py and runs it + * with the saved test inputs, comparing the output to PyTorch reference. + */ + +#include +#include + +#include "runtime/graph_interpreter.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mlipcpp::runtime; +using Catch::Matchers::WithinAbs; + +namespace { + +// Load binary file into vector +template +std::vector load_binary(const std::string &path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { + throw std::runtime_error("Failed to open: " + path); + } + size_t size = f.tellg(); + f.seekg(0); + std::vector data(size / sizeof(T)); + f.read(reinterpret_cast(data.data()), size); + return data; +} + +// Simple parser to extract weight shapes from metadata.json +// Format: "weights": {"name": [dim1, dim2, ...], ...} +std::map> +parse_weight_shapes(const std::string &path) { + std::map> shapes; + + std::ifstream f(path); + if (!f) + return shapes; + + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + // Find "weights" section + size_t weights_pos = content.find("\"weights\""); + if (weights_pos == std::string::npos) + return shapes; + + // Find the opening brace of weights object + size_t brace_start = content.find('{', weights_pos); + if (brace_start == std::string::npos) + return shapes; + + // Find matching closing brace + int brace_count = 1; + size_t pos = brace_start + 1; + while (pos < content.size() && brace_count > 0) { + if (content[pos] == '{') + brace_count++; + else if (content[pos] == '}') + brace_count--; + pos++; + } + std::string weights_str = content.substr(brace_start, pos - brace_start); + + // Parse each weight entry: "name": [d1, d2, ...] + size_t search_pos = 0; + while (true) { + // Find next quoted name + size_t quote1 = weights_str.find('"', search_pos); + if (quote1 == std::string::npos) + break; + size_t quote2 = weights_str.find('"', quote1 + 1); + if (quote2 == std::string::npos) + break; + + std::string name = weights_str.substr(quote1 + 1, quote2 - quote1 - 1); + + // Find array start + size_t arr_start = weights_str.find('[', quote2); + if (arr_start == std::string::npos) + break; + size_t arr_end = weights_str.find(']', arr_start); + if (arr_end == std::string::npos) + break; + + // Parse dimensions + std::string arr_str = + weights_str.substr(arr_start + 1, arr_end - arr_start - 1); + std::vector dims; + std::stringstream ss(arr_str); + std::string item; + while (std::getline(ss, item, ',')) { + // Trim whitespace + size_t start = item.find_first_not_of(" \t\n"); + size_t end = item.find_last_not_of(" \t\n"); + if (start != std::string::npos && end != std::string::npos) { + dims.push_back(std::stoll(item.substr(start, end - start + 1))); + } + } + + // Include empty dims (scalar tensors) - don't skip them + shapes[name] = dims; + + search_pos = arr_end + 1; + } + + return shapes; +} + +} // namespace + +TEST_CASE("Execute full PET graph with neighbor list inputs", + "[graph][pet][integration]") { + const std::string test_dir = "/tmp/pet_full_export"; + const std::string graph_path = test_dir + "/pet_full.json"; + + // Skip if test files don't exist + if (!std::filesystem::exists(graph_path)) { + SKIP("Full PET export not found - run export_pet_full.py first"); + } + + // Load the graph + GraphInterpreter interp; + interp.load_graph_file(graph_path); + + INFO("Graph loaded: " << interp.summary()); + // Allow for different graph versions (with/without 5D decomposition) + REQUIRE(interp.graph().nodes.size() >= 137); + REQUIRE(interp.graph().nodes.size() <= 500); // pet-omad-s has ~292 nodes + + // Read configuration from metadata + std::ifstream meta_stream(test_dir + "/metadata.json"); + REQUIRE(meta_stream.good()); + std::string meta_content((std::istreambuf_iterator(meta_stream)), + std::istreambuf_iterator()); + meta_stream.close(); + + // Parse n_atoms and max_neighbors from metadata JSON + int n_atoms = 2; + int max_neighbors = 8; + { + auto find_int = [&](const std::string &key) -> int { + size_t pos = meta_content.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = meta_content.find(':', pos); + if (pos == std::string::npos) return -1; + pos = meta_content.find_first_of("0123456789", pos); + if (pos == std::string::npos) return -1; + return std::stoi(meta_content.substr(pos)); + }; + int na = find_int("n_atoms"); + int mn = find_int("max_neighbors"); + if (na > 0) n_atoms = na; + if (mn > 0) max_neighbors = mn; + } + INFO("Configuration: n_atoms=" << n_atoms << ", max_neighbors=" << max_neighbors); + + // Set symbolic dimensions for graph resolution + interp.set_dimension("n_atoms", n_atoms); + interp.set_dimension("max_neighbors", max_neighbors); + + // Create CPU backend first - all tensors will use this + ggml_backend_t cpu_backend = ggml_backend_cpu_init(); + REQUIRE(cpu_backend != nullptr); + + // Create weight context with no_alloc=true (backend will allocate) + constexpr size_t WEIGHT_CTX_SIZE = 128 * 1024 * 1024; + ggml_context *weight_ctx = ggml_init({WEIGHT_CTX_SIZE, nullptr, true}); + REQUIRE(weight_ctx != nullptr); + + // Load weight shapes from metadata + auto weight_shapes = parse_weight_shapes(test_dir + "/metadata.json"); + INFO("Found " << weight_shapes.size() << " weight shapes in metadata"); + + // Create weight tensors (no data yet) + INFO("Creating weight tensors..."); + std::map>> + weight_data_map; + int loaded_count = 0; + + for (const auto &[name, py_shape] : weight_shapes) { + std::string weight_path = test_dir + "/" + name + ".bin"; + if (!std::filesystem::exists(weight_path)) { + continue; + } + + auto data = load_binary(weight_path); + + // Reverse shape for GGML (PyTorch -> GGML) + std::vector ggml_shape(py_shape.rbegin(), py_shape.rend()); + + ggml_tensor *t = nullptr; + switch (ggml_shape.size()) { + case 0: + // Scalar tensor - create as 1D with 1 element + t = ggml_new_tensor_1d(weight_ctx, GGML_TYPE_F32, 1); + break; + case 1: + t = ggml_new_tensor_1d(weight_ctx, GGML_TYPE_F32, ggml_shape[0]); + break; + case 2: + t = ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1]); + break; + case 3: + t = ggml_new_tensor_3d(weight_ctx, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1], ggml_shape[2]); + break; + default: + continue; + } + + ggml_set_name(t, name.c_str()); + weight_data_map[name] = {t, std::move(data)}; + interp.set_weight(name, t); + loaded_count++; + } + INFO("Created " << loaded_count << " weight tensors"); + REQUIRE(loaded_count > 50); // Should have ~64 weights + + // Create input tensors + INFO("Creating input tensors..."); + + // Species: [n_atoms] int32 + auto species_data = load_binary(test_dir + "/input_species.bin"); + ggml_tensor *species = + ggml_new_tensor_1d(weight_ctx, GGML_TYPE_I32, n_atoms); + ggml_set_name(species, "species"); + + // Neighbor species: [n_atoms, max_neighbors] int32 -> GGML [max_neighbors, + // n_atoms] + auto neighbor_species_data = + load_binary(test_dir + "/input_neighbor_species.bin"); + ggml_tensor *neighbor_species = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_I32, max_neighbors, n_atoms); + ggml_set_name(neighbor_species, "neighbor_species"); + + // Edge vectors: [n_atoms, max_neighbors, 3] -> GGML [3, max_neighbors, + // n_atoms] + auto edge_vectors_data = + load_binary(test_dir + "/input_edge_vectors.bin"); + ggml_tensor *edge_vectors = + ggml_new_tensor_3d(weight_ctx, GGML_TYPE_F32, 3, max_neighbors, n_atoms); + ggml_set_name(edge_vectors, "edge_vectors"); + + // Edge distances: [n_atoms, max_neighbors] -> GGML [max_neighbors, n_atoms] + auto edge_distances_data = + load_binary(test_dir + "/input_edge_distances.bin"); + ggml_tensor *edge_distances = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(edge_distances, "edge_distances"); + + // Padding mask: [n_atoms, max_neighbors] bool -> GGML [max_neighbors, n_atoms] + // Note: exported as bool (1 byte), we load as floats (1.0 for valid, 0.0 for padding) + std::vector padding_mask_data(n_atoms * max_neighbors, 1.0f); + { + std::ifstream f(test_dir + "/input_padding_mask.bin", std::ios::binary); + if (f) { + std::vector bool_data(n_atoms * max_neighbors); + f.read(reinterpret_cast(bool_data.data()), bool_data.size()); + for (size_t i = 0; i < bool_data.size(); i++) { + padding_mask_data[i] = bool_data[i] ? 1.0f : 0.0f; + } + } + } + ggml_tensor *padding_mask = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(padding_mask, "padding_mask"); + + // Reverse neighbor index: [n_atoms * max_neighbors] int32 + auto reverse_neighbor_index_data = + load_binary(test_dir + "/input_reverse_neighbor_index.bin"); + ggml_tensor *reverse_neighbor_index = + ggml_new_tensor_1d(weight_ctx, GGML_TYPE_I32, n_atoms * max_neighbors); + ggml_set_name(reverse_neighbor_index, "reverse_neighbor_index"); + + // Cutoff factors: [n_atoms, max_neighbors] -> GGML [max_neighbors, n_atoms] + auto cutoff_factors_data = + load_binary(test_dir + "/input_cutoff_factors.bin"); + ggml_tensor *cutoff_factors = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(cutoff_factors, "cutoff_factors"); + + // Register inputs + interp.set_input("species", species); + interp.set_input("neighbor_species", neighbor_species); + interp.set_input("edge_vectors", edge_vectors); + interp.set_input("edge_distances", edge_distances); + interp.set_input("padding_mask", padding_mask); + interp.set_input("reverse_neighbor_index", reverse_neighbor_index); + interp.set_input("cutoff_factors", cutoff_factors); + + // Allocate backend buffer for weight context + INFO("Allocating weight buffer..."); + ggml_backend_buffer_t weight_buffer = + ggml_backend_alloc_ctx_tensors(weight_ctx, cpu_backend); + REQUIRE(weight_buffer != nullptr); + + // Copy weight data to tensors + INFO("Loading weight data..."); + for (const auto &[name, pair] : weight_data_map) { + const auto &[tensor, data] = pair; + ggml_backend_tensor_set(tensor, data.data(), 0, + data.size() * sizeof(float)); + } + + // Copy input data to tensors + INFO("Loading input data..."); + ggml_backend_tensor_set(species, species_data.data(), 0, + species_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(neighbor_species, neighbor_species_data.data(), 0, + neighbor_species_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(edge_vectors, edge_vectors_data.data(), 0, + edge_vectors_data.size() * sizeof(float)); + ggml_backend_tensor_set(edge_distances, edge_distances_data.data(), 0, + edge_distances_data.size() * sizeof(float)); + ggml_backend_tensor_set(padding_mask, padding_mask_data.data(), 0, + padding_mask_data.size() * sizeof(float)); + ggml_backend_tensor_set(reverse_neighbor_index, reverse_neighbor_index_data.data(), 0, + reverse_neighbor_index_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(cutoff_factors, cutoff_factors_data.data(), 0, + cutoff_factors_data.size() * sizeof(float)); + + // Build computation graph + INFO("Building computation graph..."); + constexpr size_t COMPUTE_CTX_SIZE = 256 * 1024 * 1024; + ggml_context *compute_ctx = ggml_init({COMPUTE_CTX_SIZE, nullptr, true}); + REQUIRE(compute_ctx != nullptr); + + ggml_tensor *output = interp.build(compute_ctx); + REQUIRE(output != nullptr); + ggml_set_output(output); + + INFO("Output shape: [" << output->ne[0] << ", " << output->ne[1] << ", " + << output->ne[2] << ", " << output->ne[3] << "]"); + + // Create GGML compute graph + ggml_cgraph *cgraph = ggml_new_graph(compute_ctx); + ggml_build_forward_expand(cgraph, output); + + // Allocate compute buffer + INFO("Allocating compute buffer..."); + ggml_backend_buffer_t compute_buffer = + ggml_backend_alloc_ctx_tensors(compute_ctx, cpu_backend); + REQUIRE(compute_buffer != nullptr); + + // Initialize constants (like NEW_ZEROS) + interp.init_constants(); + + // Compute + INFO("Computing..."); + ggml_status status = ggml_backend_graph_compute(cpu_backend, cgraph); + REQUIRE(status == GGML_STATUS_SUCCESS); + + // Get output + std::vector result(n_atoms); + ggml_backend_tensor_get(output, result.data(), 0, n_atoms * sizeof(float)); + + // Load expected output + auto expected = load_binary(test_dir + "/expected_output.bin"); + + // Print results + std::cout << "C++ output: ["; + for (int i = 0; i < n_atoms; i++) { + if (i > 0) std::cout << ", "; + std::cout << result[i]; + } + std::cout << "]" << std::endl; + + std::cout << "PyTorch output: ["; + for (int i = 0; i < n_atoms; i++) { + if (i > 0) std::cout << ", "; + std::cout << expected[i]; + } + std::cout << "]" << std::endl; + + // Compare + float max_diff = 0.0f; + for (int i = 0; i < n_atoms; i++) { + float diff = std::abs(result[i] - expected[i]); + max_diff = std::max(max_diff, diff); + } + std::cout << "Max difference: " << max_diff << std::endl; + + // Should be within numerical tolerance (< 1e-5 relative error) + for (int i = 0; i < n_atoms; i++) { + CHECK_THAT(result[i], WithinAbs(expected[i], 1e-3)); + } + + // Cleanup + ggml_backend_buffer_free(compute_buffer); + ggml_backend_buffer_free(weight_buffer); + ggml_backend_free(cpu_backend); + ggml_free(compute_ctx); + ggml_free(weight_ctx); +} + +TEST_CASE("Symbolized graph works with different dimensions (water)", + "[graph][pet][dynamic]") { + // Use the graph/weights exported at (7,11) but run with water data (3,2) + const std::string graph_dir = "/tmp/pet_full_export"; + const std::string data_dir = "/tmp/pet_water_real"; + const std::string graph_path = graph_dir + "/pet_full.json"; + + if (!std::filesystem::exists(graph_path) || + !std::filesystem::exists(data_dir + "/metadata.json")) { + SKIP("Export files not found - run export_pet_full.py and water test gen"); + } + + // Read water dimensions from metadata + std::ifstream meta_stream(data_dir + "/metadata.json"); + REQUIRE(meta_stream.good()); + std::string meta_content((std::istreambuf_iterator(meta_stream)), + std::istreambuf_iterator()); + meta_stream.close(); + + int n_atoms = 3; + int max_neighbors = 2; + { + auto find_int = [&](const std::string &key) -> int { + size_t pos = meta_content.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = meta_content.find(':', pos); + if (pos == std::string::npos) return -1; + pos = meta_content.find_first_of("0123456789", pos); + if (pos == std::string::npos) return -1; + return std::stoi(meta_content.substr(pos)); + }; + int na = find_int("n_atoms"); + int mn = find_int("max_neighbors"); + if (na > 0) n_atoms = na; + if (mn > 0) max_neighbors = mn; + } + INFO("Water config: n_atoms=" << n_atoms << ", max_neighbors=" << max_neighbors); + + // Load symbolized graph + GraphInterpreter interp; + interp.load_graph_file(graph_path); + + // Set symbolic dimensions for water + interp.set_dimension("n_atoms", n_atoms); + interp.set_dimension("max_neighbors", max_neighbors); + + ggml_backend_t cpu_backend = ggml_backend_cpu_init(); + REQUIRE(cpu_backend != nullptr); + + constexpr size_t WEIGHT_CTX_SIZE = 128 * 1024 * 1024; + ggml_context *weight_ctx = ggml_init({WEIGHT_CTX_SIZE, nullptr, true}); + REQUIRE(weight_ctx != nullptr); + + // Load weights from GRAPH dir (not data dir) + auto weight_shapes = parse_weight_shapes(graph_dir + "/metadata.json"); + std::map>> + weight_data_map; + int loaded_count = 0; + + for (const auto &[name, py_shape] : weight_shapes) { + std::string weight_path = graph_dir + "/" + name + ".bin"; + if (!std::filesystem::exists(weight_path)) + continue; + + auto data = load_binary(weight_path); + std::vector ggml_shape(py_shape.rbegin(), py_shape.rend()); + + ggml_tensor *t = nullptr; + switch (ggml_shape.size()) { + case 0: + t = ggml_new_tensor_1d(weight_ctx, GGML_TYPE_F32, 1); + break; + case 1: + t = ggml_new_tensor_1d(weight_ctx, GGML_TYPE_F32, ggml_shape[0]); + break; + case 2: + t = ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1]); + break; + case 3: + t = ggml_new_tensor_3d(weight_ctx, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1], ggml_shape[2]); + break; + default: + continue; + } + + ggml_set_name(t, name.c_str()); + weight_data_map[name] = {t, std::move(data)}; + interp.set_weight(name, t); + loaded_count++; + } + REQUIRE(loaded_count > 50); + + // Create input tensors with WATER dimensions + auto species_data = load_binary(data_dir + "/input_species.bin"); + ggml_tensor *species = + ggml_new_tensor_1d(weight_ctx, GGML_TYPE_I32, n_atoms); + ggml_set_name(species, "species"); + + auto neighbor_species_data = + load_binary(data_dir + "/input_neighbor_species.bin"); + ggml_tensor *neighbor_species = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_I32, max_neighbors, n_atoms); + ggml_set_name(neighbor_species, "neighbor_species"); + + auto edge_vectors_data = + load_binary(data_dir + "/input_edge_vectors.bin"); + ggml_tensor *edge_vectors = + ggml_new_tensor_3d(weight_ctx, GGML_TYPE_F32, 3, max_neighbors, n_atoms); + ggml_set_name(edge_vectors, "edge_vectors"); + + auto edge_distances_data = + load_binary(data_dir + "/input_edge_distances.bin"); + ggml_tensor *edge_distances = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(edge_distances, "edge_distances"); + + std::vector padding_mask_data(n_atoms * max_neighbors, 1.0f); + { + std::ifstream f(data_dir + "/input_padding_mask.bin", std::ios::binary); + if (f) { + std::vector bool_data(n_atoms * max_neighbors); + f.read(reinterpret_cast(bool_data.data()), bool_data.size()); + for (size_t i = 0; i < bool_data.size(); i++) { + padding_mask_data[i] = bool_data[i] ? 1.0f : 0.0f; + } + } + } + ggml_tensor *padding_mask = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(padding_mask, "padding_mask"); + + auto reverse_neighbor_index_data = + load_binary(data_dir + "/input_reverse_neighbor_index.bin"); + ggml_tensor *reverse_neighbor_index = + ggml_new_tensor_1d(weight_ctx, GGML_TYPE_I32, n_atoms * max_neighbors); + ggml_set_name(reverse_neighbor_index, "reverse_neighbor_index"); + + auto cutoff_factors_data = + load_binary(data_dir + "/input_cutoff_factors.bin"); + ggml_tensor *cutoff_factors = + ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, max_neighbors, n_atoms); + ggml_set_name(cutoff_factors, "cutoff_factors"); + + interp.set_input("species", species); + interp.set_input("neighbor_species", neighbor_species); + interp.set_input("edge_vectors", edge_vectors); + interp.set_input("edge_distances", edge_distances); + interp.set_input("padding_mask", padding_mask); + interp.set_input("reverse_neighbor_index", reverse_neighbor_index); + interp.set_input("cutoff_factors", cutoff_factors); + + // Allocate and fill + ggml_backend_buffer_t weight_buffer = + ggml_backend_alloc_ctx_tensors(weight_ctx, cpu_backend); + REQUIRE(weight_buffer != nullptr); + + for (const auto &[name, pair] : weight_data_map) { + const auto &[tensor, data] = pair; + ggml_backend_tensor_set(tensor, data.data(), 0, + data.size() * sizeof(float)); + } + ggml_backend_tensor_set(species, species_data.data(), 0, + species_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(neighbor_species, neighbor_species_data.data(), 0, + neighbor_species_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(edge_vectors, edge_vectors_data.data(), 0, + edge_vectors_data.size() * sizeof(float)); + ggml_backend_tensor_set(edge_distances, edge_distances_data.data(), 0, + edge_distances_data.size() * sizeof(float)); + ggml_backend_tensor_set(padding_mask, padding_mask_data.data(), 0, + padding_mask_data.size() * sizeof(float)); + ggml_backend_tensor_set(reverse_neighbor_index, reverse_neighbor_index_data.data(), 0, + reverse_neighbor_index_data.size() * sizeof(int32_t)); + ggml_backend_tensor_set(cutoff_factors, cutoff_factors_data.data(), 0, + cutoff_factors_data.size() * sizeof(float)); + + // Build and compute + constexpr size_t COMPUTE_CTX_SIZE = 256 * 1024 * 1024; + ggml_context *compute_ctx = ggml_init({COMPUTE_CTX_SIZE, nullptr, true}); + REQUIRE(compute_ctx != nullptr); + + ggml_tensor *output = interp.build(compute_ctx); + REQUIRE(output != nullptr); + ggml_set_output(output); + + INFO("Output shape: [" << output->ne[0] << ", " << output->ne[1] << "]"); + CHECK(output->ne[0] == n_atoms); + + ggml_cgraph *cgraph = ggml_new_graph(compute_ctx); + ggml_build_forward_expand(cgraph, output); + + ggml_backend_buffer_t compute_buffer = + ggml_backend_alloc_ctx_tensors(compute_ctx, cpu_backend); + REQUIRE(compute_buffer != nullptr); + + interp.init_constants(); + + ggml_status status = ggml_backend_graph_compute(cpu_backend, cgraph); + REQUIRE(status == GGML_STATUS_SUCCESS); + + std::vector result(n_atoms); + ggml_backend_tensor_get(output, result.data(), 0, n_atoms * sizeof(float)); + + auto expected = load_binary(data_dir + "/expected_output.bin"); + + std::cout << "Water C++ output: ["; + for (int i = 0; i < n_atoms; i++) { + if (i > 0) std::cout << ", "; + std::cout << result[i]; + } + std::cout << "]" << std::endl; + + std::cout << "Water PyTorch output: ["; + for (int i = 0; i < n_atoms; i++) { + if (i > 0) std::cout << ", "; + std::cout << expected[i]; + } + std::cout << "]" << std::endl; + + float max_diff = 0.0f; + for (int i = 0; i < n_atoms; i++) { + float diff = std::abs(result[i] - expected[i]); + max_diff = std::max(max_diff, diff); + } + std::cout << "Water max difference: " << max_diff << std::endl; + + for (int i = 0; i < n_atoms; i++) { + CHECK_THAT(result[i], WithinAbs(expected[i], 1e-3)); + } + + // Check against full PyTorch PET reference (with composition energy and scaling) + { + auto find_double = [&](const std::string &key) -> double { + size_t pos = meta_content.find("\"" + key + "\""); + if (pos == std::string::npos) return 0.0; + pos = meta_content.find(':', pos); + if (pos == std::string::npos) return 0.0; + pos = meta_content.find_first_of("-0123456789", pos); + if (pos == std::string::npos) return 0.0; + return std::stod(meta_content.substr(pos)); + }; + + double comp_energy = find_double("composition_energy"); + double pytorch_ref = find_double("pytorch_reference_energy"); + double energy_scale = find_double("energy_scale"); + // Default to 1.0 if energy_scale not found (legacy models) + if (energy_scale == 0.0) energy_scale = 1.0; + + if (pytorch_ref != 0.0) { + float model_sum = 0.0f; + for (int i = 0; i < n_atoms; i++) model_sum += result[i]; + // Apply energy scale factor: total = scaled_model + composition + double scaled_model = model_sum * energy_scale; + double total = scaled_model + comp_energy; + + std::cout << "\n=== Full Energy Comparison ===" << std::endl; + std::cout << "C++ model energy (raw): " << model_sum << " eV" << std::endl; + std::cout << "Energy scale factor: " << energy_scale << std::endl; + std::cout << "C++ model (scaled): " << scaled_model << " eV" << std::endl; + std::cout << "Composition energy: " << comp_energy << " eV" << std::endl; + std::cout << "C++ total: " << total << " eV" << std::endl; + std::cout << "PyTorch reference: " << pytorch_ref << " eV" << std::endl; + std::cout << "Difference: " << std::abs(total - pytorch_ref) << " eV" << std::endl; + + CHECK_THAT(total, WithinAbs(pytorch_ref, 1e-3)); + } + } + + ggml_backend_buffer_free(compute_buffer); + ggml_backend_buffer_free(weight_buffer); + ggml_backend_free(cpu_backend); + ggml_free(compute_ctx); + ggml_free(weight_ctx); +} diff --git a/tests/test_graph_interpreter.cpp b/tests/test_graph_interpreter.cpp new file mode 100644 index 0000000..8350659 --- /dev/null +++ b/tests/test_graph_interpreter.cpp @@ -0,0 +1,754 @@ +#include + +#include "../src/runtime/graph_interpreter.h" + +#include +#include + +#include +#include +#include + +using namespace mlipcpp::runtime; + +TEST_CASE("GIR JSON parsing", "[runtime]") { + // Create a simple test graph JSON + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [10, 20]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:1"} + ], + "nodes": [ + {"id": 0, "op": "MUL_MAT", "name": "matmul0", "inputs": ["weight:W", "input:x"], "output_shape": [30, 20], "output_dtype": "f32"}, + {"id": 1, "op": "UNARY_SILU", "name": "silu0", "inputs": ["node:0"], "output_shape": [30, 20], "output_dtype": "f32"} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + REQUIRE(interp.has_graph()); + + const auto &graph = interp.graph(); + REQUIRE(graph.version == "1.0.0"); + REQUIRE(graph.model_type == "test"); + REQUIRE(graph.inputs.size() == 1); + REQUIRE(graph.inputs[0].name == "x"); + REQUIRE(graph.inputs[0].dtype == GIRDtype::F32); + REQUIRE(graph.inputs[0].shape.size() == 2); + REQUIRE(graph.inputs[0].shape[0] == 10); + REQUIRE(graph.inputs[0].shape[1] == 20); + + REQUIRE(graph.outputs.size() == 1); + REQUIRE(graph.outputs[0].name == "y"); + REQUIRE(graph.outputs[0].node_ref == "node:1"); + + REQUIRE(graph.nodes.size() == 2); + REQUIRE(graph.nodes[0].id == 0); + REQUIRE(graph.nodes[0].op == "MUL_MAT"); + REQUIRE(graph.nodes[0].inputs.size() == 2); + REQUIRE(graph.nodes[0].inputs[0] == "weight:W"); + REQUIRE(graph.nodes[0].inputs[1] == "input:x"); + + REQUIRE(graph.nodes[1].id == 1); + REQUIRE(graph.nodes[1].op == "UNARY_SILU"); + REQUIRE(graph.nodes[1].inputs[0] == "node:0"); +} + +TEST_CASE("Graph summary", "[runtime]") { + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [10]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "UNARY_RELU", "name": "relu", "inputs": ["input:x"], "output_shape": [10], "output_dtype": "f32"} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + std::string summary = interp.summary(); + REQUIRE(summary.find("test") != std::string::npos); + REQUIRE(summary.find("UNARY_RELU") != std::string::npos); + REQUIRE(summary.find("Nodes: 1") != std::string::npos); +} + +TEST_CASE("Execute MATMUL with non-square matrices", "[runtime][matmul][numerical]") { + // Test MUL_MAT: output = W @ x with non-square dimensions + // W: [4, 3] (PyTorch) -> [3, 4] (GGML) — 3 input features, 4 output features + // x: [3] (PyTorch) -> [3] (GGML) — 3 input features + // output: [4] + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [3]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "MUL_MAT", "name": "matmul", "inputs": ["weight:W", "input:x"], "output_shape": [4], "output_dtype": "f32"} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = true, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // GGML W: [3, 4] (ne[0]=3 input_features, ne[1]=4 output_features) + ggml_tensor *W = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 3, 4); + ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 3); + ggml_set_input(x); + + interp.set_weight("W", W); + interp.set_input("x", x); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + REQUIRE(output->ne[0] == 4); + ggml_set_output(output); + + ggml_cgraph *cgraph = ggml_new_graph(ctx); + ggml_build_forward_expand(cgraph, output); + + ggml_backend_t cpu_backend = ggml_backend_cpu_init(); + REQUIRE(cpu_backend != nullptr); + + ggml_backend_buffer_t buf = ggml_backend_alloc_ctx_tensors(ctx, cpu_backend); + REQUIRE(buf != nullptr); + + // W (stored in GGML column-major layout): + // Row 0: [1, 2, 3] -> output[0] = 1*1 + 2*2 + 3*3 = 14 + // Row 1: [4, 5, 6] -> output[1] = 4*1 + 5*2 + 6*3 = 32 + // Row 2: [7, 8, 9] -> output[2] = 7*1 + 8*2 + 9*3 = 50 + // Row 3: [10, 11, 12] -> output[3] = 10*1 + 11*2 + 12*3 = 68 + float W_data[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; + float x_data[] = {1, 2, 3}; + ggml_backend_tensor_set(W, W_data, 0, sizeof(W_data)); + ggml_backend_tensor_set(x, x_data, 0, sizeof(x_data)); + + ggml_status status = ggml_backend_graph_compute(cpu_backend, cgraph); + REQUIRE(status == GGML_STATUS_SUCCESS); + + float out_data[4]; + ggml_backend_tensor_get(output, out_data, 0, sizeof(out_data)); + + REQUIRE(out_data[0] == 14.0f); + REQUIRE(out_data[1] == 32.0f); + REQUIRE(out_data[2] == 50.0f); + REQUIRE(out_data[3] == 68.0f); + + ggml_backend_buffer_free(buf); + ggml_backend_free(cpu_backend); + ggml_free(ctx); +} + +TEST_CASE("Build simple addition graph", "[runtime][graph]") { + // Create a graph that does: output = input + input (element-wise doubling) + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [4]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "ADD", "name": "add", "inputs": ["input:x", "input:x"], "output_shape": [4], "output_dtype": "f32"} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + // Create GGML context for graph building + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // Create input tensor + ggml_tensor *input = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 4); + ggml_set_input(input); + + // Set input and build graph + interp.set_input("x", input); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + REQUIRE(output->ne[0] == 4); + REQUIRE(output->op == GGML_OP_ADD); + + // Verify graph structure - the ADD operation should reference the same tensor twice + REQUIRE(output->src[0] == input); + REQUIRE(output->src[1] == input); + + ggml_free(ctx); +} + +TEST_CASE("Build matrix multiplication graph", "[runtime][graph]") { + // Test MUL_MAT: output = W @ x + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [2]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "MUL_MAT", "name": "matmul", "inputs": ["weight:W", "input:x"], "output_shape": [3], "output_dtype": "f32"} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // Create weight tensor W: [2, 3] - 2 input features, 3 output features + ggml_tensor *W = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 2, 3); + + // Input x = [2] + ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 2); + ggml_set_input(x); + + interp.set_weight("W", W); + interp.set_input("x", x); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + REQUIRE(output->op == GGML_OP_MUL_MAT); + REQUIRE(output->src[0] == W); + REQUIRE(output->src[1] == x); + + ggml_free(ctx); +} + +TEST_CASE("Build unary operations chain", "[runtime][graph]") { + // Test SQR and SQRT: output = sqrt(sqr(x)) + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [3]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:1"} + ], + "nodes": [ + {"id": 0, "op": "SQR", "name": "sqr", "inputs": ["input:x"], "output_shape": [3], "output_dtype": "f32"}, + {"id": 1, "op": "SQRT", "name": "sqrt", "inputs": ["node:0"], "output_shape": [3], "output_dtype": "f32"} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 3); + ggml_set_input(x); + + interp.set_input("x", x); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + REQUIRE(output->op == GGML_OP_SQRT); + + // Check that the chain is correctly built + ggml_tensor *sqr_result = output->src[0]; + REQUIRE(sqr_result != nullptr); + REQUIRE(sqr_result->op == GGML_OP_SQR); + REQUIRE(sqr_result->src[0] == x); + + ggml_free(ctx); +} + +TEST_CASE("Build scale operation", "[runtime][graph]") { + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [4]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "SCALE", "name": "scale", "inputs": ["input:x"], "output_shape": [4], "output_dtype": "f32", "params": {"scale": 2.5}} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 4); + ggml_set_input(x); + + interp.set_input("x", x); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + REQUIRE(output->op == GGML_OP_SCALE); + REQUIRE(output->src[0] == x); + + ggml_free(ctx); +} + + + + +TEST_CASE("Build layer norm graph", "[runtime][graph]") { + // Test layer norm decomposition + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [4, 256]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "DECOMPOSE", "name": "norm", "inputs": ["input:x", "const:0", "weight:w", "weight:b"], "output_shape": [4, 256], "output_dtype": "f32", "params": {"eps": 1e-5}} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // Input shape [4, 256] in PyTorch = [256, 4] in GGML + ggml_tensor *x = ggml_new_tensor_2d(ctx, GGML_TYPE_F32, 256, 4); + ggml_set_input(x); + + // Weight and bias: [256] in PyTorch = [256] in GGML + ggml_tensor *w = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 256); + ggml_tensor *b = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 256); + + interp.set_input("x", x); + interp.set_weight("w", w); + interp.set_weight("b", b); + + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + // Layer norm decomposition produces: add(mul(norm(x), w), b) + REQUIRE(output->op == GGML_OP_ADD); + + ggml_free(ctx); +} + +// ============================================================================ +// Isolated operation tests for debugging PET numerical accuracy +// ============================================================================ + +TEST_CASE("VIEW chunk extraction from 3D tensor", "[runtime][view][chunk]") { + // Test chunk extraction via VIEW: + // PyTorch: qkv = [2, 9, 768], chunk(3, dim=-1) -> 3 x [2, 9, 256] + // GGML: qkv = [768, 9, 2], VIEW with offset -> [256, 9, 2] each + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // Create source tensor [768, 9, 2] in GGML = [2, 9, 768] in PyTorch + ggml_tensor *qkv = ggml_new_tensor_3d(ctx, GGML_TYPE_F32, 768, 9, 2); + + // Fill with test data: value = ne0_idx + 1000*ne1_idx + 100000*ne2_idx + float *data = static_cast(qkv->data); + for (int i2 = 0; i2 < 2; i2++) { + for (int i1 = 0; i1 < 9; i1++) { + for (int i0 = 0; i0 < 768; i0++) { + int idx = i0 + 768 * i1 + 768 * 9 * i2; + data[idx] = static_cast(i0 + 1000 * i1 + 100000 * i2); + } + } + } + + // Create 3 views for Q, K, V chunks + // Each chunk has shape [256, 9, 2] starting at offset 0, 256, 512 in ne[0] + + // Chunk 0 (Q): offset 0, size 256 + ggml_tensor *q = + ggml_view_3d(ctx, qkv, 256, 9, 2, + qkv->nb[1], // row stride (768 * sizeof(float)) + qkv->nb[2], // slice stride + 0); // byte offset + + // Chunk 1 (K): offset 256 + ggml_tensor *k = ggml_view_3d(ctx, qkv, 256, 9, 2, qkv->nb[1], qkv->nb[2], + 256 * sizeof(float)); + + // Chunk 2 (V): offset 512 + ggml_tensor *v = ggml_view_3d(ctx, qkv, 256, 9, 2, qkv->nb[1], qkv->nb[2], + 512 * sizeof(float)); + + // Verify shapes + REQUIRE(q->ne[0] == 256); + REQUIRE(q->ne[1] == 9); + REQUIRE(q->ne[2] == 2); + + REQUIRE(k->ne[0] == 256); + REQUIRE(v->ne[0] == 256); + + // Verify values + // Q should start at index 0: value = 0 + 1000*0 + 100000*0 = 0 + // K should start at index 256: value = 256 + 1000*0 + 100000*0 = 256 + // V should start at index 512: value = 512 + 1000*0 + 100000*0 = 512 + + float *q_data = static_cast(q->data); + float *k_data = static_cast(k->data); + float *v_data = static_cast(v->data); + + // Check first element of each chunk + REQUIRE(q_data[0] == 0.0f); // Index 0 in original + REQUIRE(k_data[0] == 256.0f); // Index 256 in original + REQUIRE(v_data[0] == 512.0f); // Index 512 in original + + // Check element in second row (ne1=1) + // Q[0, 1, 0] should be at original index 768 (next row) + // Value = 0 + 1000*1 + 100000*0 = 1000 + int row_stride = 768; // Elements per row + REQUIRE(q_data[row_stride] == 1000.0f); // Actually this is wrong indexing + + // Correct: need to use strides properly + // For view tensors, data pointer points to start, but strides may differ + // Check using ggml's internal view offset mechanism + // q->data should point to qkv->data + 0 + // k->data should point to qkv->data + 256*4 bytes + // v->data should point to qkv->data + 512*4 bytes + REQUIRE(q->data == qkv->data); + REQUIRE(k->data == static_cast(qkv->data) + 256 * sizeof(float)); + REQUIRE(v->data == static_cast(qkv->data) + 512 * sizeof(float)); + + ggml_free(ctx); +} + +TEST_CASE("TRANSPOSE 3D tensor axis mapping", "[runtime][transpose]") { + // Test transpose in GGML vs PyTorch + // PyTorch: transpose(1, 2) on [2, 9, 4, 64] -> [2, 4, 9, 64] + // GGML: transpose on [64, 4, 9, 2] (reversed) should produce [64, 9, 4, 2] + + // For 3D case: + // PyTorch: [2, 9, 256] with transpose(1, 2) -> not valid (only 3 dims) + // Let's test 4D: + // PyTorch: [2, 9, 4, 64] with transpose(1, 2) -> [2, 4, 9, 64] + // GGML: [64, 4, 9, 2] with permute(0, 2, 1, 3) -> [64, 9, 4, 2] + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // Create 4D tensor [64, 4, 9, 2] in GGML = [2, 9, 4, 64] in PyTorch + ggml_tensor *src = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, 64, 4, 9, 2); + + // Fill with test data: value = ne0 + 100*ne1 + 10000*ne2 + 1000000*ne3 + float *data = static_cast(src->data); + for (int i3 = 0; i3 < 2; i3++) { + for (int i2 = 0; i2 < 9; i2++) { + for (int i1 = 0; i1 < 4; i1++) { + for (int i0 = 0; i0 < 64; i0++) { + int idx = i0 + 64 * (i1 + 4 * (i2 + 9 * i3)); + data[idx] = static_cast(i0 + 100 * i1 + 10000 * i2 + + 1000000 * i3); + } + } + } + } + + // PyTorch transpose(1, 2) swaps dims 1 and 2 (0-indexed from left) + // In PyTorch order: [2, 9, 4, 64] -> [2, 4, 9, 64] + // In GGML order: [64, 4, 9, 2] -> [64, 9, 4, 2] + // This is ggml_permute(src, 0, 2, 1, 3) + + ggml_tensor *dst = ggml_permute(ctx, src, 0, 2, 1, 3); + + REQUIRE(dst->ne[0] == 64); + REQUIRE(dst->ne[1] == 9); // Was 4 + REQUIRE(dst->ne[2] == 4); // Was 9 + REQUIRE(dst->ne[3] == 2); + + // Verify strides changed but data didn't move + // Original strides: [sizeof(float), 64*4, 64*4*4, 64*4*9*4] + // Permuted strides: [sizeof(float), 64*4*4, 64*4, 64*4*9*4] + REQUIRE(dst->nb[0] == src->nb[0]); // Element stride unchanged + REQUIRE(dst->nb[1] == src->nb[2]); // Swapped + REQUIRE(dst->nb[2] == src->nb[1]); // Swapped + REQUIRE(dst->nb[3] == src->nb[3]); // Batch stride unchanged + + ggml_free(ctx); +} + +TEST_CASE("SELECT operation for node feature extraction", + "[runtime][select]") { + // Test SELECT: [:, 0, :] on [2, 9, 256] -> [2, 256] + // This extracts the first position from sequence dimension + + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [2, 9, 256]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "SELECT", "name": "select", "inputs": ["input:x"], "output_shape": [2, 256], "output_dtype": "f32", "params": {"dim": 1, "index": 0}} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // Input [2, 9, 256] in PyTorch = [256, 9, 2] in GGML + ggml_tensor *x = ggml_new_tensor_3d(ctx, GGML_TYPE_F32, 256, 9, 2); + ggml_set_input(x); + + // Fill with identifiable values + float *data = static_cast(x->data); + for (int i2 = 0; i2 < 2; i2++) { + for (int i1 = 0; i1 < 9; i1++) { + for (int i0 = 0; i0 < 256; i0++) { + int idx = i0 + 256 * i1 + 256 * 9 * i2; + // Encode position in value: seq_pos * 1000 + feature_idx + data[idx] = static_cast(i1 * 1000 + i0); + } + } + } + + interp.set_input("x", x); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + + // Output should be [256, 2] in GGML = [2, 256] in PyTorch + REQUIRE(output->ne[0] == 256); + REQUIRE(output->ne[1] == 2); + + // Verify we got seq_pos=0 data (values should be 0*1000 + feature_idx = feature_idx) + // The output is a view, so we need to check the data pointer offset + // For SELECT dim=1 index=0, we should get the slice at ne[1]=0 + + // The output should have data at offset 0 (index 0 of dim 1) + float *out_data = static_cast(output->data); + REQUIRE(out_data[0] == 0.0f); // seq_pos=0, feature=0: 0*1000 + 0 = 0 + REQUIRE(out_data[1] == 1.0f); // seq_pos=0, feature=1: 0*1000 + 1 = 1 + REQUIRE(out_data[255] == 255.0f); // seq_pos=0, feature=255 + + ggml_free(ctx); +} + +TEST_CASE("FLASH_ATTN_EXT basic graph build", "[runtime][flash_attn]") { + // Test flash attention graph building (not execution) + // GGML flash attention shape requirements are complex, + // just verify the graph builds correctly + + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "q", "dtype": "f32", "shape": [2, 4, 9, 64]}, + {"name": "k", "dtype": "f32", "shape": [2, 4, 9, 64]}, + {"name": "v", "dtype": "f32", "shape": [2, 4, 9, 64]} + ], + "outputs": [ + {"name": "attn", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "FLASH_ATTN_EXT", "name": "attn", "inputs": ["input:q", "input:k", "input:v"], "output_shape": [2, 4, 9, 64], "output_dtype": "f32", "params": {"scale": 0.125}} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + struct ggml_init_params params = { + .mem_size = 64 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = false, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + // GGML flash attention expects: + // Q: [head_dim, seq_len, n_heads, batch] + // K: [head_dim, kv_seq_len, n_heads, batch] + // V: [head_dim, kv_seq_len, n_heads, batch] + // PyTorch: [batch, n_heads, seq_len, head_dim] + + // [2, 4, 9, 64] PyTorch = [64, 9, 4, 2] GGML + ggml_tensor *q = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, 64, 9, 4, 2); + ggml_tensor *k = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, 64, 9, 4, 2); + ggml_tensor *v = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, 64, 9, 4, 2); + + ggml_set_input(q); + ggml_set_input(k); + ggml_set_input(v); + + interp.set_input("q", q); + interp.set_input("k", k); + interp.set_input("v", v); + + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + // Flash attention output is permuted to match PyTorch SDPA format + // So the output op is PERMUTE wrapping FLASH_ATTN_EXT + REQUIRE(output->op == GGML_OP_PERMUTE); + REQUIRE(output->src[0]->op == GGML_OP_FLASH_ATTN_EXT); + + // Verify output has expected number of elements + // GGML flash attention output shape depends on internal logic + REQUIRE(ggml_nelements(output) > 0); + REQUIRE(output->ne[0] == 64); // head_dim is preserved + + ggml_free(ctx); +} + +TEST_CASE("Debug tensor dumping", "[runtime][debug]") { + // Test the debug tensor dumping functionality + + std::string json = R"({ + "version": "1.0.0", + "model_type": "test", + "inputs": [ + {"name": "x", "dtype": "f32", "shape": [4]} + ], + "outputs": [ + {"name": "y", "node_ref": "node:0"} + ], + "nodes": [ + {"id": 0, "op": "SCALE", "name": "scale_test", "inputs": ["input:x"], "output_shape": [4], "output_dtype": "f32", "params": {"scale": 2.0}} + ] + })"; + + GraphInterpreter interp; + interp.load_graph(json); + + // Enable debug mode + std::string debug_dir = "/tmp/test_debug_dump"; + interp.set_debug_output_dir(debug_dir); + + // Use no_alloc = true for backend allocation + struct ggml_init_params params = { + .mem_size = 16 * 1024 * 1024, + .mem_buffer = nullptr, + .no_alloc = true, + }; + ggml_context *ctx = ggml_init(params); + REQUIRE(ctx != nullptr); + + ggml_tensor *x = ggml_new_tensor_1d(ctx, GGML_TYPE_F32, 4); + ggml_set_input(x); + + interp.set_input("x", x); + ggml_tensor *output = interp.build(ctx); + + REQUIRE(output != nullptr); + REQUIRE(output->op == GGML_OP_SCALE); + ggml_set_output(output); + + // Compute the graph + ggml_cgraph *cgraph = ggml_new_graph(ctx); + ggml_build_forward_expand(cgraph, output); + + ggml_backend_t cpu_backend = ggml_backend_cpu_init(); + REQUIRE(cpu_backend != nullptr); + + ggml_backend_buffer_t buf = ggml_backend_alloc_ctx_tensors(ctx, cpu_backend); + REQUIRE(buf != nullptr); + + // Set input data after allocation + float input_data[] = {1.0f, 2.0f, 3.0f, 4.0f}; + ggml_backend_tensor_set(x, input_data, 0, 4 * sizeof(float)); + + ggml_status status = ggml_backend_graph_compute(cpu_backend, cgraph); + REQUIRE(status == GGML_STATUS_SUCCESS); + + // Dump all tensors + interp.dump_all_tensors(); + + // Verify output values + float out_data[4]; + ggml_backend_tensor_get(output, out_data, 0, 4 * sizeof(float)); + REQUIRE(out_data[0] == 2.0f); + REQUIRE(out_data[1] == 4.0f); + REQUIRE(out_data[2] == 6.0f); + REQUIRE(out_data[3] == 8.0f); + + ggml_backend_buffer_free(buf); + ggml_backend_free(cpu_backend); + ggml_free(ctx); + + // Note: We don't verify the files exist here to keep the test simple + // In practice, you would check /tmp/test_debug_dump/ for the dumped files +} diff --git a/tests/test_graph_model.cpp b/tests/test_graph_model.cpp new file mode 100644 index 0000000..2fc43d8 --- /dev/null +++ b/tests/test_graph_model.cpp @@ -0,0 +1,514 @@ +/** + * Test GraphModel with direct-format exported graphs. + * + * This tests the GraphModel wrapper that converts AtomicSystem inputs + * to the format expected by auto-exported graphs. + */ + +#include +#include + +#include "runtime/graph_model.h" +#include "core/gguf_loader.h" +#include "mlipcpp/io.h" +#include "mlipcpp/mlipcpp.h" +#include "mlipcpp/mlipcpp.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace mlipcpp; +using namespace mlipcpp::runtime; +using Catch::Matchers::WithinAbs; + +namespace { + +// Load binary file into vector +template +std::vector load_binary(const std::string &path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { + throw std::runtime_error("Failed to open: " + path); + } + size_t size = f.tellg(); + f.seekg(0); + std::vector data(size / sizeof(T)); + f.read(reinterpret_cast(data.data()), size); + return data; +} + +// Simple parser to extract weight shapes from metadata.json +std::map> +parse_weight_shapes(const std::string &path) { + std::map> shapes; + + std::ifstream f(path); + if (!f) + return shapes; + + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + size_t weights_pos = content.find("\"weights\""); + if (weights_pos == std::string::npos) + return shapes; + + size_t brace_start = content.find('{', weights_pos); + if (brace_start == std::string::npos) + return shapes; + + int brace_count = 1; + size_t pos = brace_start + 1; + while (pos < content.size() && brace_count > 0) { + if (content[pos] == '{') + brace_count++; + else if (content[pos] == '}') + brace_count--; + pos++; + } + std::string weights_str = content.substr(brace_start, pos - brace_start); + + size_t search_pos = 0; + while (true) { + size_t quote1 = weights_str.find('"', search_pos); + if (quote1 == std::string::npos) + break; + size_t quote2 = weights_str.find('"', quote1 + 1); + if (quote2 == std::string::npos) + break; + + std::string name = weights_str.substr(quote1 + 1, quote2 - quote1 - 1); + + size_t arr_start = weights_str.find('[', quote2); + if (arr_start == std::string::npos) + break; + size_t arr_end = weights_str.find(']', arr_start); + if (arr_end == std::string::npos) + break; + + std::string arr_str = + weights_str.substr(arr_start + 1, arr_end - arr_start - 1); + std::vector dims; + std::stringstream ss(arr_str); + std::string item; + while (std::getline(ss, item, ',')) { + size_t start = item.find_first_not_of(" \t\n"); + size_t end = item.find_last_not_of(" \t\n"); + if (start != std::string::npos && end != std::string::npos) { + dims.push_back(std::stoll(item.substr(start, end - start + 1))); + } + } + + if (!dims.empty()) { + shapes[name] = dims; + } + + search_pos = arr_end + 1; + } + + return shapes; +} + +// Helper to load graph and weights into a GraphModel +void setup_graph_model(GraphModel &model, const std::string &test_dir, + ggml_context *weight_ctx, ggml_backend_t backend) { + const std::string graph_path = test_dir + "/pet_full.json"; + model.load_graph_file(graph_path); + + auto weight_shapes = parse_weight_shapes(test_dir + "/metadata.json"); + + for (const auto &[name, py_shape] : weight_shapes) { + std::string weight_path = test_dir + "/" + name + ".bin"; + if (!std::filesystem::exists(weight_path)) + continue; + + auto data = load_binary(weight_path); + std::vector ggml_shape(py_shape.rbegin(), py_shape.rend()); + + ggml_tensor *t = nullptr; + switch (ggml_shape.size()) { + case 1: + t = ggml_new_tensor_1d(weight_ctx, GGML_TYPE_F32, ggml_shape[0]); + break; + case 2: + t = ggml_new_tensor_2d(weight_ctx, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1]); + break; + case 3: + t = ggml_new_tensor_3d(weight_ctx, GGML_TYPE_F32, ggml_shape[0], + ggml_shape[1], ggml_shape[2]); + break; + default: + continue; + } + + ggml_set_name(t, name.c_str()); + model.set_weight(name, t); + } + + // Allocate and fill weights + ggml_backend_buffer_t weight_buffer = + ggml_backend_alloc_ctx_tensors(weight_ctx, backend); + REQUIRE(weight_buffer != nullptr); + + for (const auto &[name, py_shape] : weight_shapes) { + std::string weight_path = test_dir + "/" + name + ".bin"; + if (!std::filesystem::exists(weight_path)) + continue; + + auto data = load_binary(weight_path); + ggml_tensor *t = ggml_get_tensor(weight_ctx, name.c_str()); + if (t) { + ggml_backend_tensor_set(t, data.data(), 0, data.size() * sizeof(float)); + } + } +} + +} // namespace + +TEST_CASE("GraphModel loads graph file", "[graph][model]") { + const std::string test_dir = "/tmp/pet_full_export"; + const std::string graph_path = test_dir + "/pet_full.json"; + + if (!std::filesystem::exists(graph_path)) { + SKIP("Full PET export not found - run export_pet_full.py first"); + } + + GraphModel model; + model.load_graph_file(graph_path); + + // Check graph was loaded + const auto &graph = model.interpreter().graph(); + CHECK(graph.nodes.size() > 100); + CHECK(graph.inputs.size() >= 5); +} + +TEST_CASE("GraphModel with direct inputs matches interpreter", + "[graph][model][integration]") { + const std::string test_dir = "/tmp/pet_full_export"; + const std::string graph_path = test_dir + "/pet_full.json"; + + if (!std::filesystem::exists(graph_path)) { + SKIP("Full PET export not found - run export_pet_full.py first"); + } + + // Create backend and context + ggml_backend_t cpu_backend = ggml_backend_cpu_init(); + REQUIRE(cpu_backend != nullptr); + + constexpr size_t WEIGHT_CTX_SIZE = 128 * 1024 * 1024; + ggml_context *weight_ctx = ggml_init({WEIGHT_CTX_SIZE, nullptr, true}); + REQUIRE(weight_ctx != nullptr); + + // Setup model + GraphModel model; + setup_graph_model(model, test_dir, weight_ctx, cpu_backend); + + // Note: species mapping and dimensions are normally from GGUF file + + // Load expected output + auto expected = load_binary(test_dir + "/expected_output.bin"); + INFO("PyTorch output: [" << expected[0] << ", " << expected[1] << "]"); + + // Create a simple test system (2 Si atoms) + // For this test, we would need the exact same inputs as the export + // For now, just verify the model loads correctly + + INFO("GraphModel setup successful"); + INFO("Expected dimensions: n_atoms=2, max_neighbors=8"); + + // Note: Full prediction test requires matching the exact test inputs + // which include specific edge vectors and distances. The test_full_export.cpp + // directly loads those binary files, while GraphModel computes them from + // positions and neighbor lists. + + // Cleanup + ggml_backend_free(cpu_backend); + ggml_free(weight_ctx); +} + +TEST_CASE("GraphModel GGUF energy prediction", "[graph][model][gguf]") { + const std::string model_path = "gguf/pet-auto.gguf"; + const std::string water_xyz = "geometries/water.xyz"; + + if (!std::filesystem::exists(model_path)) { + SKIP("Auto-exported GGUF not found at " << model_path); + } + if (!std::filesystem::exists(water_xyz)) { + SKIP("Water XYZ file not found"); + } + + GraphModel model; + REQUIRE(model.load_from_gguf(model_path)); + + // Verify the GGUF was exported with the current full-model format + const auto &graph = model.interpreter().graph(); + bool has_species_input = false; + for (const auto &inp : graph.inputs) { + if (inp.name == "species") has_species_input = true; + } + if (!has_species_input) { + SKIP("GGUF uses old graph format (no 'species' input) - re-export with " + "export_pet_gguf.py"); + } + + // Read water system + std::ifstream file(water_xyz); + REQUIRE(file.is_open()); + auto water = mlipcpp::io::read_xyz(file); + REQUIRE(water.num_atoms() == 3); + + // Predict energy + ModelResult result = model.predict(water); + + INFO("Water energy: " << result.energy << " eV"); + // Energy should be negative and in a reasonable range for water + CHECK(result.energy < 0.0f); + CHECK(result.energy > -100.0f); +} + +TEST_CASE("GraphModel GGUF forces prediction", "[graph][model][gguf][forces]") { + const std::string model_path = "gguf/pet-auto.gguf"; + const std::string water_xyz = "geometries/water.xyz"; + + if (!std::filesystem::exists(model_path)) { + SKIP("Auto-exported GGUF not found at " << model_path); + } + if (!std::filesystem::exists(water_xyz)) { + SKIP("Water XYZ file not found"); + } + + GraphModel model; + REQUIRE(model.load_from_gguf(model_path)); + + std::ifstream file(water_xyz); + REQUIRE(file.is_open()); + auto water = mlipcpp::io::read_xyz(file); + REQUIRE(water.num_atoms() == 3); + + // Predict energy + forces + ModelResult result = model.predict(water, true); + + INFO("Water energy: " << result.energy << " eV"); + CHECK(result.energy < 0.0f); + CHECK(result.energy > -100.0f); + + // Should have forces for 3 atoms (9 components) + REQUIRE(result.forces.size() == 9); + + // Newton's third law: forces should sum to approximately zero + float fx_sum = result.forces[0] + result.forces[3] + result.forces[6]; + float fy_sum = result.forces[1] + result.forces[4] + result.forces[7]; + float fz_sum = result.forces[2] + result.forces[5] + result.forces[8]; + + INFO("Force sum: [" << fx_sum << ", " << fy_sum << ", " << fz_sum << "]"); + CHECK_THAT(fx_sum, WithinAbs(0.0f, 0.01f)); + CHECK_THAT(fy_sum, WithinAbs(0.0f, 0.01f)); + CHECK_THAT(fz_sum, WithinAbs(0.0f, 0.01f)); + + // Print per-atom forces + for (int i = 0; i < 3; i++) { + INFO("Atom " << i << " forces: [" << result.forces[i * 3] << ", " + << result.forces[i * 3 + 1] << ", " + << result.forces[i * 3 + 2] << "] eV/A"); + } +} + +TEST_CASE("GraphModel dynamic system sizes", "[graph][model][gguf][dynamic]") { + const std::string model_path = "gguf/pet-auto.gguf"; + const std::string water_xyz = "geometries/water.xyz"; + const std::string si_xyz = "geometries/si.xyz"; + + if (!std::filesystem::exists(model_path)) { + SKIP("Auto-exported GGUF not found at " << model_path); + } + if (!std::filesystem::exists(water_xyz) || + !std::filesystem::exists(si_xyz)) { + SKIP("Test XYZ files not found"); + } + + GraphModel model; + REQUIRE(model.load_from_gguf(model_path)); + + // Verify GGUF format compatibility + const auto &graph = model.interpreter().graph(); + bool has_species_input = false; + for (const auto &inp : graph.inputs) { + if (inp.name == "species") has_species_input = true; + } + if (!has_species_input) { + SKIP("GGUF uses old graph format - re-export with export_pet_gguf.py"); + } + + // Predict water (3 atoms) + { + std::ifstream file(water_xyz); + auto water = mlipcpp::io::read_xyz(file); + ModelResult result = model.predict(water); + INFO("Water energy: " << result.energy << " eV"); + CHECK(result.energy < 0.0f); + } + + // Predict silicon (2 atoms) - different system size, same model instance + { + std::ifstream file(si_xyz); + auto si = mlipcpp::io::read_xyz(file); + ModelResult result = model.predict(si); + INFO("Si energy: " << result.energy << " eV"); + CHECK(result.energy < 0.0f); + } +} + +// Helper: check if a GGUF file uses pet-graph architecture +static bool is_pet_graph_gguf(const std::string &path) { + try { + mlipcpp::GGUFLoader loader(path); + return loader.get_string("general.architecture", "") == "pet-graph"; + } catch (...) { + return false; + } +} + +// ============================================================================ +// Predictor API Tests (public C++ API) +// ============================================================================ + +TEST_CASE("GraphModel via Predictor API", "[graph][model][api]") { + const std::string model_path = "gguf/pet-auto.gguf"; + const std::string water_xyz = "geometries/water.xyz"; + + if (!std::filesystem::exists(model_path)) { + SKIP("Auto-exported GGUF not found at " << model_path); + } + if (!is_pet_graph_gguf(model_path)) { + SKIP("GGUF uses old architecture - re-export with export_pet_gguf.py"); + } + if (!std::filesystem::exists(water_xyz)) { + SKIP("Water XYZ file not found"); + } + + // Load via public Predictor API (same path users take) + mlipcpp::Predictor predictor(model_path); + REQUIRE(predictor.model_type() == "PET-Graph"); + + // Read water system + std::ifstream file(water_xyz); + auto water = mlipcpp::io::read_xyz(file); + REQUIRE(water.num_atoms() == 3); + + // Predict via raw pointer API + auto result = predictor.predict( + water.num_atoms(), water.positions(), water.atomic_numbers(), + nullptr, nullptr, false); + + INFO("Predictor API water energy: " << result.energy << " eV"); + CHECK(result.energy < 0.0f); + CHECK(result.energy > -100.0f); +} + +TEST_CASE("GraphModel via Predictor API with forces", + "[graph][model][api][forces]") { + const std::string model_path = "gguf/pet-auto.gguf"; + const std::string water_xyz = "geometries/water.xyz"; + + if (!std::filesystem::exists(model_path)) { + SKIP("Forces GGUF not found at " << model_path); + } + if (!is_pet_graph_gguf(model_path)) { + SKIP("GGUF uses old architecture - re-export"); + } + if (!std::filesystem::exists(water_xyz)) { + SKIP("Water XYZ file not found"); + } + + mlipcpp::Predictor predictor(model_path); + REQUIRE(predictor.model_type() == "PET-Graph"); + + std::ifstream file(water_xyz); + auto water = mlipcpp::io::read_xyz(file); + + auto result = predictor.predict( + water.num_atoms(), water.positions(), water.atomic_numbers(), + nullptr, nullptr, true); + + INFO("Predictor API water energy: " << result.energy << " eV"); + CHECK(result.energy < 0.0f); + CHECK(result.has_forces()); + REQUIRE(result.forces.size() == 9); + + // Newton's third law + float fx_sum = result.forces[0] + result.forces[3] + result.forces[6]; + float fy_sum = result.forces[1] + result.forces[4] + result.forces[7]; + float fz_sum = result.forces[2] + result.forces[5] + result.forces[8]; + CHECK_THAT(fx_sum, WithinAbs(0.0f, 0.01f)); + CHECK_THAT(fy_sum, WithinAbs(0.0f, 0.01f)); + CHECK_THAT(fz_sum, WithinAbs(0.0f, 0.01f)); +} + +// ============================================================================ +// C API Tests +// ============================================================================ + +TEST_CASE("C API loads graph model", "[graph][model][c_api]") { + const std::string model_path = "gguf/pet-auto.gguf"; + const std::string water_xyz = "geometries/water.xyz"; + + if (!std::filesystem::exists(model_path)) { + SKIP("Auto-exported GGUF not found at " << model_path); + } + if (!is_pet_graph_gguf(model_path)) { + SKIP("GGUF uses old architecture - re-export with export_pet_gguf.py"); + } + if (!std::filesystem::exists(water_xyz)) { + SKIP("Water XYZ file not found"); + } + + // Test C API lifecycle + auto model = mlipcpp_model_create(nullptr); + REQUIRE(model != nullptr); + + auto err = mlipcpp_model_load(model, model_path.c_str()); + REQUIRE(err == MLIPCPP_OK); + + // Check cutoff + float cutoff = 0.0f; + err = mlipcpp_model_get_cutoff(model, &cutoff); + REQUIRE(err == MLIPCPP_OK); + CHECK(cutoff > 0.0f); + + // Predict water + std::ifstream file(water_xyz); + auto water = mlipcpp::io::read_xyz(file); + + mlipcpp_system_t system; + system.n_atoms = water.num_atoms(); + system.positions = water.positions(); + system.atomic_numbers = water.atomic_numbers(); + system.cell = nullptr; + system.pbc = nullptr; + + mlipcpp_result_t result = nullptr; + err = mlipcpp_predict(model, &system, false, &result); + REQUIRE(err == MLIPCPP_OK); + REQUIRE(result != nullptr); + + float energy = 0.0f; + err = mlipcpp_result_get_energy(result, &energy); + REQUIRE(err == MLIPCPP_OK); + + INFO("C API water energy: " << energy << " eV"); + CHECK(energy < 0.0f); + CHECK(energy > -100.0f); + + mlipcpp_result_free(result); + mlipcpp_model_free(model); +} diff --git a/tests/test_pet.cpp b/tests/test_pet.cpp index 55df0a9..48c1c53 100644 --- a/tests/test_pet.cpp +++ b/tests/test_pet.cpp @@ -13,6 +13,18 @@ #include "pet.h" #include #include +#include + +// Fixed-PET GGUFs are produced by the legacy scripts/convert_pet_mad.py +// converter. CI now ships graph-format GGUFs instead; skip when the legacy +// file isn't around so these tests don't block migrations. +#define SKIP_IF_NO_FIXED_PET_GGUF(path) \ + do { \ + if (!std::filesystem::exists(path)) { \ + SKIP("Fixed-PET GGUF " << (path) << " not found — regenerate with " \ + "convert_pet_mad.py to run this"); \ + } \ + } while (0) #include #include #include @@ -89,7 +101,7 @@ AtomicSystem create_test_system_isolated() { } TEST_CASE("PET loads weights from GGUF", "[pet][loading]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); PETHypers hypers; PETModel model(hypers); @@ -99,7 +111,7 @@ TEST_CASE("PET loads weights from GGUF", "[pet][loading]") { } TEST_CASE("PET predicts single system correctly", "[pet][accuracy]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); SECTION("Si 2-atom system") { PETHypers hypers; @@ -133,7 +145,7 @@ TEST_CASE("PET predicts single system correctly", "[pet][accuracy]") { } TEST_CASE("PET batch prediction matches individual", "[.][pet][batch]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); PETHypers hypers; PETModel model(hypers); @@ -172,7 +184,7 @@ TEST_CASE("PET batch prediction matches individual", "[.][pet][batch]") { } TEST_CASE("PET matches reference values", "[pet][verification]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); PETHypers hypers; PETModel model(hypers); @@ -212,7 +224,7 @@ TEST_CASE("PET matches reference values", "[pet][verification]") { } TEST_CASE("PET handles edge cases", "[.][pet][edge_cases]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); PETHypers hypers; PETModel model(hypers); @@ -261,7 +273,7 @@ TEST_CASE("PET handles edge cases", "[.][pet][edge_cases]") { } TEST_CASE("PET batch with multiple systems", "[.][pet][batch]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); PETHypers hypers; PETModel model(hypers); @@ -293,7 +305,7 @@ TEST_CASE("PET batch with multiple systems", "[.][pet][batch]") { } TEST_CASE("PET composition energy handling", "[pet][composition]") { - std::string model_path = "pet-mad.gguf"; + std::string model_path = "pet-mad.gguf"; SKIP_IF_NO_FIXED_PET_GGUF(model_path); PETHypers hypers; PETModel model(hypers); diff --git a/tests/test_pet_gradients.cpp b/tests/test_pet_gradients.cpp index 1574576..4e3ee5a 100644 --- a/tests/test_pet_gradients.cpp +++ b/tests/test_pet_gradients.cpp @@ -3,9 +3,21 @@ #include #include #include +#include #include #include +// Fixed-PET GGUFs come from the legacy scripts/convert_pet_mad.py converter. +// Skip cleanly when that file is absent so CI can run graph-model tests +// without requiring the legacy toolchain. +#define SKIP_IF_NO_FIXED_PET_GGUF(path) \ + do { \ + if (!std::filesystem::exists(path)) { \ + SKIP("Fixed-PET GGUF " << (path) << " not found — regenerate with " \ + "convert_pet_mad.py to run this"); \ + } \ + } while (0) + using namespace mlipcpp; using namespace mlipcpp::pet; @@ -43,7 +55,7 @@ TEST_CASE("PET forces match Python reference (water molecule)", PETHypers hypers; PETModel model(hypers); - REQUIRE(model.load_from_gguf("pet-mad.gguf")); + SKIP_IF_NO_FIXED_PET_GGUF("pet-mad.gguf"); REQUIRE(model.load_from_gguf("pet-mad.gguf")); // Run prediction with forces auto result = model.predict(system, true); @@ -100,7 +112,7 @@ TEST_CASE("PET forces match Python reference (Si crystal)", "[pet][gradient]") { PETHypers hypers; PETModel model(hypers); - REQUIRE(model.load_from_gguf("pet-mad.gguf")); + SKIP_IF_NO_FIXED_PET_GGUF("pet-mad.gguf"); REQUIRE(model.load_from_gguf("pet-mad.gguf")); // Get analytical forces auto result = model.predict(system, true); @@ -156,7 +168,7 @@ TEST_CASE("PET stress matches Python reference (Si crystal)", PETHypers hypers; PETModel model(hypers); - REQUIRE(model.load_from_gguf("pet-mad.gguf")); + SKIP_IF_NO_FIXED_PET_GGUF("pet-mad.gguf"); REQUIRE(model.load_from_gguf("pet-mad.gguf")); // Get forces and stress auto result = model.predict(system, true); @@ -198,7 +210,7 @@ TEST_CASE("PET forces sum to zero (momentum conservation)", "[pet][gradient]") { PETHypers hypers; PETModel model(hypers); - REQUIRE(model.load_from_gguf("pet-mad.gguf")); + SKIP_IF_NO_FIXED_PET_GGUF("pet-mad.gguf"); REQUIRE(model.load_from_gguf("pet-mad.gguf")); auto result = model.predict(system, true); REQUIRE(result.has_forces); diff --git a/tests/test_python_api.py b/tests/test_python_api.py new file mode 100644 index 0000000..3ce094b --- /dev/null +++ b/tests/test_python_api.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Integration test: verify graph-exported models work via Python bindings. + +Usage: + uv run pytest tests/test_python_api.py -v +""" + +import os +import pytest +import numpy as np + +# Skip all tests if mlipcpp is not importable +mlipcpp = pytest.importorskip("mlipcpp") + + +def model_path(name: str) -> str: + """Resolve model path relative to project root.""" + return os.path.join(os.path.dirname(__file__), "..", "gguf", name) + + +def geometry_path(name: str) -> str: + """Resolve geometry path relative to project root.""" + return os.path.join(os.path.dirname(__file__), "..", "geometries", name) + + +def read_xyz(path: str): + """Read an XYZ file and return (positions, atomic_numbers) as numpy arrays.""" + SYMBOL_TO_Z = { + "H": 1, "He": 2, "Li": 3, "Be": 4, "B": 5, "C": 6, "N": 7, "O": 8, + "F": 9, "Ne": 10, "Na": 11, "Mg": 12, "Al": 13, "Si": 14, "P": 15, + "S": 16, "Cl": 17, "Ar": 18, "K": 19, "Ca": 20, "Fe": 26, "Cu": 29, + "Zn": 30, "Ga": 31, "Ge": 32, "As": 33, "Se": 34, "Br": 35, + } + with open(path) as f: + n_atoms = int(f.readline().strip()) + f.readline() # comment + positions = [] + atomic_numbers = [] + for _ in range(n_atoms): + parts = f.readline().split() + symbol = parts[0] + z = SYMBOL_TO_Z.get(symbol) + atomic_numbers.append(z if z is not None else int(symbol)) + positions.extend(float(x) for x in parts[1:4]) + return ( + np.array(positions, dtype=np.float32).reshape(-1, 3), + np.array(atomic_numbers, dtype=np.int32), + ) + + +# --- Predictor API tests --- + +class TestPredictorAPI: + """Test the mlipcpp.Predictor API with graph-exported models.""" + + @pytest.fixture + def auto_model(self): + path = model_path("pet-auto.gguf") + if not os.path.exists(path): + pytest.skip(f"Model not found: {path}") + return mlipcpp.Predictor(path) + + def test_model_type(self, auto_model): + assert auto_model.model_type in ("PET", "PET-Graph") + + def test_cutoff_positive(self, auto_model): + assert auto_model.cutoff > 0.0 + + def test_water_energy(self, auto_model): + water_path = geometry_path("water.xyz") + if not os.path.exists(water_path): + pytest.skip("water.xyz not found") + + positions, atomic_numbers = read_xyz(water_path) + result = auto_model.predict(positions, atomic_numbers, compute_forces=False) + + # Reference from manual PET model (test_auto_vs_manual.cpp) + WATER_ENERGY_REF = -14.380176 + np.testing.assert_allclose(result.energy, WATER_ENERGY_REF, atol=0.01, + err_msg=f"Water energy {result.energy} eV doesn't match reference {WATER_ENERGY_REF} eV") + + def test_water_forces(self, auto_model): + water_path = geometry_path("water.xyz") + if not os.path.exists(water_path): + pytest.skip("water.xyz not found") + + positions, atomic_numbers = read_xyz(water_path) + result = auto_model.predict(positions, atomic_numbers, compute_forces=True) + assert result.energy < 0.0 + assert result.has_forces() + + # Newton's third law: forces should sum to ~0 + forces = np.array(result.forces) + force_sum = forces.sum(axis=0) + np.testing.assert_allclose(force_sum, 0.0, atol=0.01) + + def test_sequential_predictions(self, auto_model): + """Test that the same model can predict multiple systems.""" + water_path = geometry_path("water.xyz") + si_path = geometry_path("si.xyz") + if not os.path.exists(water_path) or not os.path.exists(si_path): + pytest.skip("geometry files not found") + + pos_w, z_w = read_xyz(water_path) + pos_s, z_s = read_xyz(si_path) + + r1 = auto_model.predict(pos_w, z_w, compute_forces=False) + r2 = auto_model.predict(pos_s, z_s, compute_forces=False) + + assert r1.energy < 0.0 + assert r2.energy < 0.0 + # Energies should differ (different systems) + assert abs(r1.energy - r2.energy) > 0.1 + + +# --- Named model tests --- + +KNOWN_MODELS = [ + "pet-mad-s", + "pet-oam-l", + "pet-oam-xl", + "pet-omad-xs", + "pet-omad-s", + "pet-omad-l", + "pet-omat-xs", + "pet-omat-s", + "pet-omat-m", + "pet-omat-l", + "pet-omat-xl", + "pet-omatpes-l", + "pet-spice-s", + "pet-spice-l", +] + + +@pytest.mark.parametrize("model_name", KNOWN_MODELS) +def test_named_model_loads(model_name): + """Test that each named model GGUF loads and produces reasonable energy.""" + path = model_path(f"{model_name}.gguf") + if not os.path.exists(path): + pytest.skip(f"{model_name}.gguf not found in gguf/") + + pred = mlipcpp.Predictor(path) + assert pred.cutoff > 0.0 + + water_path = geometry_path("water.xyz") + if not os.path.exists(water_path): + pytest.skip("water.xyz not found") + + positions, atomic_numbers = read_xyz(water_path) + result = pred.predict(positions, atomic_numbers, compute_forces=False) + assert np.isfinite(result.energy), f"Energy is not finite: {result.energy}" + assert result.energy < 0.0, f"Expected negative energy, got {result.energy}" + + +def test_spice_force_matches_finite_difference(): + """Sanity check: reported forces should match -dE/dx for PET-Graph SPICE model.""" + path = model_path("pet-spice-s.gguf") + if not os.path.exists(path): + pytest.skip("pet-spice-s.gguf not found in gguf/") + + urea_path = geometry_path("urea_molecule.xyz") + if not os.path.exists(urea_path): + pytest.skip("urea_molecule.xyz not found") + + pred = mlipcpp.Predictor(path) + positions, atomic_numbers = read_xyz(urea_path) + + result = pred.predict(positions, atomic_numbers, compute_forces=True) + assert result.has_forces() + forces = np.array(result.forces, dtype=np.float32) + + eps = 1e-2 + atom_idx = 0 + coord_idx = 0 + + pos_plus = positions.copy() + pos_minus = positions.copy() + pos_plus[atom_idx, coord_idx] += eps + pos_minus[atom_idx, coord_idx] -= eps + + e_plus = pred.predict(pos_plus, atomic_numbers, compute_forces=False).energy + e_minus = pred.predict(pos_minus, atomic_numbers, compute_forces=False).energy + fd_force = -(e_plus - e_minus) / (2.0 * eps) + + np.testing.assert_allclose( + forces[atom_idx, coord_idx], + fd_force, + atol=0.1, + err_msg="Force/energy gradient mismatch on pet-spice-s (urea)", + ) + + +# --- ASE calculator tests --- + +class TestASECalculator: + """Test ASE integration if available.""" + + @pytest.fixture + def ase_calc(self): + pytest.importorskip("ase") + path = model_path("pet-auto.gguf") + if not os.path.exists(path): + pytest.skip(f"Model not found: {path}") + + try: + from mlipcpp.ase import MLIPCalculator + except ImportError: + pytest.skip("mlipcpp.ase not available") + + return MLIPCalculator(path) + + def test_ase_energy(self, ase_calc): + from ase.io import read + + water_path = geometry_path("water.xyz") + if not os.path.exists(water_path): + pytest.skip("water.xyz not found") + + atoms = read(water_path) + atoms.calc = ase_calc + energy = atoms.get_potential_energy() + + assert energy < 0.0 + assert energy > -100.0 diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..63ea38a --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite + +# GGUF model(s) are fetched at build time from HuggingFace — don't check them in. +public/*.gguf diff --git a/website/bun.lock b/website/bun.lock new file mode 100644 index 0000000..d33f015 --- /dev/null +++ b/website/bun.lock @@ -0,0 +1,766 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "mlip-demo", + "dependencies": { + "@peterspackman/mlip.js": "file:../packages/mlip.js", + "ngl": "^2.3.1", + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.15.0", + "svelte-check": "^4.1.1", + "typescript": "^5.6.0", + "vite": "^6.0.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@peterspackman/mlip.js": ["@peterspackman/mlip.js@file:../packages/mlip.js", { "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0" } }], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="], + + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="], + + "@types/argparse": ["@types/argparse@2.0.17", "", {}, "sha512-fueJssTf+4dW4HODshEGkIZbkLKHzgu1FvCI4cTc/MKum/534Euo3SrN+ilq8xgyHnOjtmg33/hee8iXLRg1XA=="], + + "@types/benchmark": ["@types/benchmark@2.1.5", "", {}, "sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/compression": ["@types/compression@1.8.1", "", { "dependencies": { "@types/express": "*", "@types/node": "*" } }, "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/express": ["@types/express@5.0.5", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^1" } }, "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + + "@types/swagger-ui-dist": ["@types/swagger-ui-dist@3.30.5", "", {}, "sha512-SrXhD9L8qeIxJzN+o1kmf3wXeVf/+Km3jIdRM1+Yq3I5b/dlF5TcGr5WCVM7I/cBYpgf43/gCPIucQ13AhICiw=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array.prototype.reduce": ["array.prototype.reduce@1.0.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-array-method-boxes-properly": "^1.0.0", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "is-string": "^1.1.1" } }, "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chroma-js": ["chroma-js@1.4.1", "", {}, "sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], + + "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], + + "es-array-method-boxes-properly": ["es-array-method-boxes-properly@1.0.0", "", {}, "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fp-ts": ["fp-ts@2.16.11", "", {}, "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "h264-mp4-encoder": ["h264-mp4-encoder@1.0.12", "", {}, "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + + "immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "io-ts": ["io-ts@2.2.22", "", { "peerDependencies": { "fp-ts": "^2.5.0" } }, "sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "molstar": ["molstar@4.18.0", "", { "dependencies": { "@types/argparse": "^2.0.17", "@types/benchmark": "^2.1.5", "@types/compression": "1.8.1", "@types/express": "^5.0.3", "@types/node": "^18.19.111", "@types/node-fetch": "^2.6.12", "@types/swagger-ui-dist": "3.30.5", "argparse": "^2.0.1", "compression": "^1.8.0", "cors": "^2.8.5", "express": "^5.1.0", "h264-mp4-encoder": "^1.0.12", "immer": "^10.1.1", "immutable": "^5.1.2", "io-ts": "^2.2.22", "node-fetch": "^2.7.0", "react-markdown": "^10.1.0", "rxjs": "^7.8.2", "swagger-ui-dist": "^5.24.0", "tslib": "^2.8.1", "util.promisify": "^1.1.3" }, "peerDependencies": { "@google-cloud/storage": "^7.14.0", "canvas": "^2.11.2", "gl": "^6.0.2", "jpeg-js": "^0.4.4", "pngjs": "^6.0.0", "react": ">=16.14.0", "react-dom": ">=16.14.0" }, "optionalPeers": ["@google-cloud/storage", "canvas", "gl", "jpeg-js", "pngjs"], "bin": { "cif2bcif": "lib/commonjs/cli/cif2bcif/index.js", "cifschema": "lib/commonjs/cli/cifschema/index.js", "model-server": "lib/commonjs/servers/model/server.js", "model-server-preprocess": "lib/commonjs/servers/model/preprocess.js", "model-server-query": "lib/commonjs/servers/model/query.js", "mvs-print-schema": "lib/commonjs/cli/mvs/mvs-print-schema.js", "mvs-render": "lib/commonjs/cli/mvs/mvs-render.js", "mvs-validate": "lib/commonjs/cli/mvs/mvs-validate.js", "volume-server": "lib/commonjs/servers/volume/server.js", "volume-server-pack": "lib/commonjs/servers/volume/pack.js", "volume-server-query": "lib/commonjs/servers/volume/query.js" } }, "sha512-mU2da9laqdFtGKGCqOyFywCAxuvRYevOMFjrX/6RwIUd+HB5yOpbLXXRA5ErVadHXLTlEYOutCzNv+AwvmrfmA=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "ngl": ["ngl@2.4.0", "", { "dependencies": { "chroma-js": "^1.3.7", "molstar": "^4.1.0", "signals": "^1.0.0", "sprintf-js": "^1.1.2", "three": "^0.158.0" } }, "sha512-XrPo1om/Q0r++jqKkIYlQvGGRiJvD81zi9o9ltCLDeBYBaSbQuOSbJ0wq7zIdTuIpQL+a+BogV+LI0trNiGzVw=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.getownpropertydescriptors": ["object.getownpropertydescriptors@2.1.8", "", { "dependencies": { "array.prototype.reduce": "^1.0.6", "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0", "gopd": "^1.0.1", "safe-array-concat": "^1.1.2" } }, "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signals": ["signals@1.0.0", "", {}, "sha512-dE3lBiqgrgIvpGHYBy6/kiYKfh0HXRmbg0ocakBKiOefbal6ZeTtNlQlxsu9ADkNzv5OmRwRKu+IaTPSqJdZDg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="], + + "svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="], + + "swagger-ui-dist": ["swagger-ui-dist@5.30.3", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-giQl7/ToPxCqnUAx2wpnSnDNGZtGzw1LyUw6ZitIpTmdrvpxKFY/94v1hihm0zYNpgp1/VY0jTDk//R0BBgnRQ=="], + + "three": ["three@0.158.0", "", {}, "sha512-TALj4EOpdDPF1henk2Q+s17K61uEAAWQ7TJB68nr7FKxqwyDr3msOt5IWdbGm4TaWKjrtWS8DJJWe9JnvsWOhQ=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "util.promisify": ["util.promisify@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "for-each": "^0.3.3", "get-intrinsic": "^1.2.6", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "object.getownpropertydescriptors": "^2.1.8", "safe-array-concat": "^1.1.3" } }, "sha512-GIEaZ6o86fj09Wtf0VfZ5XP7tmd4t3jM5aZCgmBi231D0DB1AEBa3Aa6MP48DMsAIi96WkpWLimIWVwOjbDMOw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + + "accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + } +} diff --git a/website/index.html b/website/index.html index 9a83172..6b42a79 100644 --- a/website/index.html +++ b/website/index.html @@ -7,7 +7,7 @@ mlip.js - ML Interatomic Potentials in the Browser -
- +
+ diff --git a/website/package-lock.json b/website/package-lock.json deleted file mode 100644 index 8135ec0..0000000 --- a/website/package-lock.json +++ /dev/null @@ -1,5445 +0,0 @@ -{ - "name": "mlip-demo", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "mlip-demo", - "version": "0.1.0", - "dependencies": { - "@peterspackman/mlip.js": "file:../packages/mlip.js", - "ngl": "^2.3.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.2.0", - "typescript": "^5.3.0", - "vite": "^5.0.0" - } - }, - "../packages/mlip.js": { - "name": "@peterspackman/mlip.js", - "version": "0.1.0", - "license": "BSD-3-Clause", - "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@peterspackman/mlip.js": { - "resolved": "../packages/mlip.js", - "link": true - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" - }, - "node_modules/@types/argparse": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-2.0.17.tgz", - "integrity": "sha512-fueJssTf+4dW4HODshEGkIZbkLKHzgu1FvCI4cTc/MKum/534Euo3SrN+ilq8xgyHnOjtmg33/hee8iXLRg1XA==", - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/benchmark": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/benchmark/-/benchmark-2.1.5.tgz", - "integrity": "sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w==", - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/swagger-ui-dist": { - "version": "3.30.5", - "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.5.tgz", - "integrity": "sha512-SrXhD9L8qeIxJzN+o1kmf3wXeVf/+Km3jIdRM1+Yq3I5b/dlF5TcGr5WCVM7I/cBYpgf43/gCPIucQ13AhICiw==", - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.reduce": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz", - "integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-array-method-boxes-properly": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "is-string": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chroma-js": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-1.4.1.tgz", - "integrity": "sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.262", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", - "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fp-ts": { - "version": "2.16.11", - "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", - "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", - "license": "MIT", - "peer": true - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/h264-mp4-encoder": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/h264-mp4-encoder/-/h264-mp4-encoder-1.0.12.tgz", - "integrity": "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ==", - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "license": "MIT" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/io-ts": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.22.tgz", - "integrity": "sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==", - "license": "MIT", - "peerDependencies": { - "fp-ts": "^2.5.0" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/molstar": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/molstar/-/molstar-4.18.0.tgz", - "integrity": "sha512-mU2da9laqdFtGKGCqOyFywCAxuvRYevOMFjrX/6RwIUd+HB5yOpbLXXRA5ErVadHXLTlEYOutCzNv+AwvmrfmA==", - "license": "MIT", - "dependencies": { - "@types/argparse": "^2.0.17", - "@types/benchmark": "^2.1.5", - "@types/compression": "1.8.1", - "@types/express": "^5.0.3", - "@types/node": "^18.19.111", - "@types/node-fetch": "^2.6.12", - "@types/swagger-ui-dist": "3.30.5", - "argparse": "^2.0.1", - "compression": "^1.8.0", - "cors": "^2.8.5", - "express": "^5.1.0", - "h264-mp4-encoder": "^1.0.12", - "immer": "^10.1.1", - "immutable": "^5.1.2", - "io-ts": "^2.2.22", - "node-fetch": "^2.7.0", - "react-markdown": "^10.1.0", - "rxjs": "^7.8.2", - "swagger-ui-dist": "^5.24.0", - "tslib": "^2.8.1", - "util.promisify": "^1.1.3" - }, - "bin": { - "cif2bcif": "lib/commonjs/cli/cif2bcif/index.js", - "cifschema": "lib/commonjs/cli/cifschema/index.js", - "model-server": "lib/commonjs/servers/model/server.js", - "model-server-preprocess": "lib/commonjs/servers/model/preprocess.js", - "model-server-query": "lib/commonjs/servers/model/query.js", - "mvs-print-schema": "lib/commonjs/cli/mvs/mvs-print-schema.js", - "mvs-render": "lib/commonjs/cli/mvs/mvs-render.js", - "mvs-validate": "lib/commonjs/cli/mvs/mvs-validate.js", - "volume-server": "lib/commonjs/servers/volume/server.js", - "volume-server-pack": "lib/commonjs/servers/volume/pack.js", - "volume-server-query": "lib/commonjs/servers/volume/query.js" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@google-cloud/storage": "^7.14.0", - "canvas": "^2.11.2", - "gl": "^6.0.2", - "jpeg-js": "^0.4.4", - "pngjs": "^6.0.0", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - }, - "peerDependenciesMeta": { - "@google-cloud/storage": { - "optional": true - }, - "canvas": { - "optional": true - }, - "gl": { - "optional": true - }, - "jpeg-js": { - "optional": true - }, - "pngjs": { - "optional": true - } - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ngl": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ngl/-/ngl-2.4.0.tgz", - "integrity": "sha512-XrPo1om/Q0r++jqKkIYlQvGGRiJvD81zi9o9ltCLDeBYBaSbQuOSbJ0wq7zIdTuIpQL+a+BogV+LI0trNiGzVw==", - "license": "MIT", - "dependencies": { - "chroma-js": "^1.3.7", - "molstar": "^4.1.0", - "signals": "^1.0.0", - "sprintf-js": "^1.1.2", - "three": "^0.158.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.getownpropertydescriptors": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", - "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", - "license": "MIT", - "dependencies": { - "array.prototype.reduce": "^1.0.6", - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "gopd": "^1.0.1", - "safe-array-concat": "^1.1.2" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-markdown": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", - "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signals": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/signals/-/signals-1.0.0.tgz", - "integrity": "sha512-dE3lBiqgrgIvpGHYBy6/kiYKfh0HXRmbg0ocakBKiOefbal6ZeTtNlQlxsu9ADkNzv5OmRwRKu+IaTPSqJdZDg==" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, - "node_modules/swagger-ui-dist": { - "version": "5.30.3", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.3.tgz", - "integrity": "sha512-giQl7/ToPxCqnUAx2wpnSnDNGZtGzw1LyUw6ZitIpTmdrvpxKFY/94v1hihm0zYNpgp1/VY0jTDk//R0BBgnRQ==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "=1.4.0" - } - }, - "node_modules/three": { - "version": "0.158.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.158.0.tgz", - "integrity": "sha512-TALj4EOpdDPF1henk2Q+s17K61uEAAWQ7TJB68nr7FKxqwyDr3msOt5IWdbGm4TaWKjrtWS8DJJWe9JnvsWOhQ==", - "license": "MIT" - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util.promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.1.3.tgz", - "integrity": "sha512-GIEaZ6o86fj09Wtf0VfZ5XP7tmd4t3jM5aZCgmBi231D0DB1AEBa3Aa6MP48DMsAIi96WkpWLimIWVwOjbDMOw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "for-each": "^0.3.3", - "get-intrinsic": "^1.2.6", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "object.getownpropertydescriptors": "^2.1.8", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/website/package.json b/website/package.json index 6ef0c61..7b43b2a 100644 --- a/website/package.json +++ b/website/package.json @@ -1,25 +1,26 @@ { "name": "mlip-demo", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", - "prebuild": "uv run ../scripts/convert_pet_mad.py --version 1.1.0 --output public/pet-mad.gguf", - "build": "tsc && vite build", + "fetch-model": "mkdir -p public && curl -fL --retry 3 --progress-bar -o public/pet-mad-xs.gguf https://huggingface.co/peterspackman/mlip-gguf/resolve/main/pet-mad-xs.gguf", + "prebuild": "test -s public/pet-mad-xs.gguf || bun run fetch-model", + "build": "bun run prebuild && svelte-check --tsconfig ./tsconfig.json && vite build", + "check": "svelte-check --tsconfig ./tsconfig.json", "preview": "vite preview" }, "dependencies": { "@peterspackman/mlip.js": "file:../packages/mlip.js", - "ngl": "^2.3.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "ngl": "^2.3.1" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.2.0", - "typescript": "^5.3.0", - "vite": "^5.0.0" + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.15.0", + "svelte-check": "^4.1.1", + "typescript": "^5.6.0", + "vite": "^6.0.0" } } diff --git a/website/src/App.css b/website/src/App.css deleted file mode 100644 index 7c1ef3b..0000000 --- a/website/src/App.css +++ /dev/null @@ -1,71 +0,0 @@ -.app { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -.header { - padding: 2rem 0; - text-align: center; - border-bottom: 1px solid var(--border); -} - -.header h1 { - font-size: 2.5rem; - font-weight: 700; - margin-bottom: 0.5rem; -} - -.subtitle { - color: var(--text-secondary); - font-size: 1.1rem; -} - -.nav { - background-color: var(--bg-secondary); - border-bottom: 1px solid var(--border); - padding: 0.5rem 0; -} - -.nav .container { - display: flex; - gap: 0.5rem; -} - -.nav-button { - padding: 0.75rem 1.5rem; - border: none; - background: transparent; - color: var(--text-secondary); - font-size: 1rem; - font-weight: 500; - border-radius: 8px; - transition: all 0.2s; -} - -.nav-button:hover { - background-color: var(--bg-primary); - color: var(--text-primary); -} - -.nav-button.active { - background-color: var(--accent); - color: white; -} - -.main { - flex: 1; - padding: 2rem 0; -} - -.footer { - padding: 1.5rem 0; - border-top: 1px solid var(--border); - text-align: center; - color: var(--text-secondary); - font-size: 0.9rem; -} - -.footer a { - color: var(--accent); -} diff --git a/website/src/App.svelte b/website/src/App.svelte new file mode 100644 index 0000000..aa23d81 --- /dev/null +++ b/website/src/App.svelte @@ -0,0 +1,73 @@ + + +
+
+
+

mlip.js

+

Machine Learning Interatomic Potentials in the Browser

+
+
+ +
+
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ + +
diff --git a/website/src/App.tsx b/website/src/App.tsx deleted file mode 100644 index b3a348a..0000000 --- a/website/src/App.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import MolecularDynamics from './components/MolecularDynamics' -import './App.css' - -function App() { - return ( -
-
-
-

mlip.js

-

- Machine Learning Interatomic Potentials in the Browser -

-
-
- -
-
- -
-
- - -
- ) -} - -export default App diff --git a/website/src/components/EnergyPlot.svelte b/website/src/components/EnergyPlot.svelte new file mode 100644 index 0000000..156b2fa --- /dev/null +++ b/website/src/components/EnergyPlot.svelte @@ -0,0 +1,93 @@ + + +
+ + + {store.mode === 'md' ? 'Total energy' : 'Potential energy'} + +
+ + diff --git a/website/src/components/MDParams.svelte b/website/src/components/MDParams.svelte new file mode 100644 index 0000000..d45239c --- /dev/null +++ b/website/src/components/MDParams.svelte @@ -0,0 +1,129 @@ + + +
+

MD Parameters

+ +
+ + +
+ + + + +

+ {#if store.useConservativeForces && store.thermostat === 'none'} + NVE — total energy should be conserved. + {:else if store.useConservativeForces} + Conservative forces with thermostat. + {:else} + Non-conservative forces are faster but energy will drift. + {/if} +

+ + {#if store.step > 0} +

+ Drift: {store.energyDrift.toFixed(4)} eV + ({(store.energyDrift * 1000 / Math.max(store.step, 1)).toFixed(3)} meV/step) +

+ {/if} +
+ + diff --git a/website/src/components/ModelLoader.svelte b/website/src/components/ModelLoader.svelte new file mode 100644 index 0000000..1d9d966 --- /dev/null +++ b/website/src/components/ModelLoader.svelte @@ -0,0 +1,240 @@ + + +
+

Model

+ + + +
{ e.preventDefault(); isDragging = true }} + ondragleave={() => { isDragging = false }} + ondrop={onDrop} + role="button" + tabindex="-1" + > + + + +

+ Drop a .gguf model here +

+ +
+ + + +
+ {#if store.modelStatus === 'loading'} + Loading {store.modelSource}… + {:else if store.modelStatus === 'ready'} + {store.modelType} · {store.activeBackend || 'backend?'} · {store.modelSource} + {:else if store.modelStatus === 'error'} + {store.modelError} + {:else} + No model loaded + {/if} +
+
+ + diff --git a/website/src/components/MolecularDynamics.css b/website/src/components/MolecularDynamics.css deleted file mode 100644 index 1bfa1dd..0000000 --- a/website/src/components/MolecularDynamics.css +++ /dev/null @@ -1,533 +0,0 @@ -.md-simulation { - display: grid; - grid-template-columns: 320px 1fr; - gap: 1rem; - align-items: stretch; - min-height: 780px; -} - -@media (max-width: 900px) { - .md-simulation { - grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; - } -} - -/* Panels (left and right) */ -.panel { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.panel-left { - display: flex; - flex-direction: column; -} - -.panel-left .panel-section:first-child { - flex: 1; - display: flex; - flex-direction: column; -} - -.panel-left .panel-section:first-child .xyz-input { - flex: 1; - min-height: 100px; -} - -.panel-section { - padding: 0.75rem; - background-color: var(--bg-secondary); - border-radius: 8px; - border: 1px solid var(--border); -} - -.panel-section h3 { - font-size: 0.7rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.5rem; -} - -.control-group { - display: flex; - flex-direction: column; - gap: 0.25rem; - margin-bottom: 0.5rem; -} - -.control-group:last-child { - margin-bottom: 0; -} - -.control-group label { - font-size: 0.75rem; - color: var(--text-secondary); -} - -.sample-buttons { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.sample-button { - padding: 0.4rem 0.6rem; - border: 1px solid var(--border); - background-color: var(--bg-primary); - color: var(--text-primary); - border-radius: 4px; - font-size: 0.75rem; - transition: all 0.2s; - text-align: left; -} - -.sample-button:hover:not(:disabled) { - border-color: var(--accent); - color: var(--accent); -} - -.sample-button.active { - background-color: var(--accent); - border-color: var(--accent); - color: white; -} - -.sample-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.xyz-input { - width: 100%; - padding: 0.4rem; - border: 1px solid var(--border); - border-radius: 4px; - background-color: var(--bg-primary); - color: var(--text-primary); - font-family: monospace; - font-size: 0.65rem; - resize: vertical; - min-height: 60px; -} - -.xyz-input:focus { - outline: none; - border-color: var(--accent); -} - -.load-button { - padding: 0.4rem 0.8rem; - border: none; - background-color: var(--accent); - color: white; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; - transition: background-color 0.2s; - margin-top: 0.5rem; - width: 100%; -} - -.load-button:hover:not(:disabled) { - background-color: var(--accent-hover); -} - -.load-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.number-input { - padding: 0.35rem; - border: 1px solid var(--border); - border-radius: 4px; - background-color: var(--bg-primary); - color: var(--text-primary); - font-size: 0.8rem; - width: 100%; -} - -.control-button { - padding: 0.4rem 0.8rem; - border: 1px solid var(--border); - background-color: var(--bg-primary); - color: var(--text-primary); - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; - transition: all 0.2s; - width: 100%; -} - -.control-button:hover:not(:disabled) { - border-color: var(--accent); - color: var(--accent); -} - -.control-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.model-info { - font-size: 0.75rem; - color: var(--success); - padding: 0.4rem; - background-color: rgba(34, 197, 94, 0.1); - border-radius: 4px; - margin-bottom: 0.5rem; -} - -.error-message { - padding: 0.75rem; - background-color: rgba(239, 68, 68, 0.1); - color: var(--error); - border-radius: 6px; - font-size: 0.8rem; -} - -/* Center viewer */ -.viewer-center { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.viewer-center .ngl-container { - flex: 1; -} - -.ngl-container { - width: 100%; - min-height: 700px; - border-radius: 8px; - overflow: hidden; - border: 1px solid var(--border); - background-color: var(--bg-secondary); - position: relative; -} - -.ngl-container canvas { - position: absolute !important; - top: 0 !important; - left: 0 !important; -} - -/* Small loading indicator in corner */ -.loading-indicator { - position: absolute; - top: 10px; - right: 10px; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: rgba(0, 0, 0, 0.7); - color: rgba(255, 255, 255, 0.9); - border-radius: 6px; - font-size: 0.75rem; - z-index: 10; -} - -.spinner-small { - width: 14px; - height: 14px; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -.placeholder { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background-color: rgba(255, 255, 255, 0.9); - color: var(--text-secondary); - font-size: 0.9rem; - gap: 1rem; -} - -@media (prefers-color-scheme: dark) { - .placeholder { - background-color: rgba(26, 26, 26, 0.9); - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* Stats panel */ -.stats-panel { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1rem; - padding: 0.75rem; - background-color: var(--bg-secondary); - border-radius: 8px; - border: 1px solid var(--border); - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; -} - -.stat { - display: flex; - flex-direction: column; - gap: 0.1rem; -} - -.stat-label { - font-size: 0.6rem; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.stat-value { - font-size: 0.85rem; - font-weight: 600; -} - -.play-button { - padding: 0.5rem 1.25rem; - border: none; - border-radius: 4px; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - margin-left: auto; -} - -.play-button.start { - background-color: var(--success); - color: white; -} - -.play-button.start:hover:not(:disabled) { - background-color: #16a34a; -} - -.play-button.stop { - background-color: var(--error); - color: white; -} - -.play-button.stop:hover { - background-color: #dc2626; -} - -.play-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.structure-select { - width: 100%; - padding: 0.4rem; - border: 1px solid var(--border); - border-radius: 4px; - background-color: var(--bg-primary); - color: var(--text-primary); - font-size: 0.8rem; - margin-bottom: 0.5rem; -} - -.structure-select:focus { - outline: none; - border-color: var(--accent); -} - -.nc-note { - font-size: 0.65rem; - color: var(--text-secondary); - margin-top: 0.5rem; - font-style: italic; -} - -.params-row { - display: flex; - gap: 0.75rem; -} - -.params-row .control-group { - flex: 1; -} - -.mode-tabs { - display: flex; - gap: 0.25rem; - margin-bottom: 0.75rem; -} - -.mode-tab { - flex: 1; - padding: 0.4rem 0.5rem; - border: 1px solid var(--border); - background-color: var(--bg-primary); - color: var(--text-secondary); - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.mode-tab:hover:not(.active) { - border-color: var(--accent); - color: var(--text-primary); -} - -.mode-tab.active { - background-color: var(--accent); - border-color: var(--accent); - color: white; -} - -/* Energy plot overlay */ -.energy-plot { - position: absolute; - bottom: 10px; - left: 10px; - right: 10px; - height: 80px; - background: transparent; - border-radius: 6px; - padding: 4px; - pointer-events: none; -} - -.energy-plot svg { - width: 100%; - height: 100%; -} - -.energy-plot .energy-label { - font-size: 8px; - fill: rgba(255, 255, 255, 0.7); - font-family: system-ui, -apple-system, sans-serif; -} - -.energy-plot .energy-value { - font-size: 9px; - fill: rgba(59, 130, 246, 1); - font-family: monospace; - font-weight: 600; -} - -/* Supercell inputs */ -.supercell-label { - font-size: 0.75rem; - color: var(--text-secondary); - display: block; - margin-bottom: 0.25rem; -} - -.supercell-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 0.5rem; -} - -.supercell-cell { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.supercell-cell label { - font-size: 0.7rem; - color: var(--text-secondary); - text-align: center; -} - -.supercell-input { - width: 100%; - padding: 0.35rem; - border: 1px solid var(--border); - border-radius: 4px; - background-color: var(--bg-primary); - color: var(--text-primary); - font-size: 0.8rem; - text-align: center; -} - -.supercell-input:focus { - outline: none; - border-color: var(--accent); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8rem; - color: var(--text-secondary); - margin-top: 0.5rem; - cursor: pointer; -} - -.checkbox-label input[type="checkbox"] { - width: 1rem; - height: 1rem; - cursor: pointer; -} - -.pubchem-search { - display: flex; - gap: 0.5rem; - margin-top: 0.75rem; -} - -.pubchem-input { - flex: 1; - padding: 0.5rem; - border: 1px solid var(--border); - border-radius: 4px; - background-color: var(--bg-primary); - color: var(--text-primary); - font-size: 0.85rem; -} - -.pubchem-input:focus { - outline: none; - border-color: var(--accent); -} - -.pubchem-input::placeholder { - color: var(--text-secondary); - opacity: 0.7; -} - -.pubchem-button { - padding: 0.5rem 0.75rem; - background-color: var(--accent); - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.85rem; - white-space: nowrap; -} - -.pubchem-button:hover:not(:disabled) { - opacity: 0.9; -} - -.pubchem-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} diff --git a/website/src/components/MolecularDynamics.tsx b/website/src/components/MolecularDynamics.tsx deleted file mode 100644 index af01973..0000000 --- a/website/src/components/MolecularDynamics.tsx +++ /dev/null @@ -1,1284 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react' -import * as NGL from 'ngl' -import { getAtomicNumber, getCovalentRadius, getSymbol } from '../data/elements' -import { fetchFromPubChem } from '../utils/pubchem' -import './MolecularDynamics.css' - -// Sample structures for MD - molecules and crystals -// Crystals use extended XYZ format with Lattice= and pbc= in comment line -const SAMPLE_MOLECULES: Record = { - 'Water': `3 -Water -O 0.000000 0.000000 0.117489 -H 0.000000 0.756950 -0.469957 -H 0.000000 -0.756950 -0.469957`, - 'Methane': `5 -Methane -C 0.000000 0.000000 0.000000 -H 0.629118 0.629118 0.629118 -H -0.629118 -0.629118 0.629118 -H -0.629118 0.629118 -0.629118 -H 0.629118 -0.629118 -0.629118`, - 'Ethanol': `9 -Ethanol -C -0.001193 -0.004555 0.009236 -C 1.519736 -0.001568 -0.012413 -O 2.032422 1.326098 -0.087629 -H -0.394952 1.007606 -0.074891 -H -0.376887 -0.547259 -0.861972 -H -0.435219 -0.483282 0.891082 -H 1.894949 -0.539891 0.862637 -H 1.898649 -0.518854 -0.898756 -H 1.685063 1.800579 0.682628`, - 'Dichloroethane': `8 -1,2-Dichloroethane -C 0.000000 0.000000 0.000000 -C 1.524000 0.000000 0.000000 -Cl -0.799000 1.524000 0.000000 -Cl 2.323000 -1.524000 0.000000 -H -0.360000 -0.514000 0.891000 -H -0.360000 -0.514000 -0.891000 -H 1.884000 0.514000 0.891000 -H 1.884000 0.514000 -0.891000`, - 'Ethylene Glycol': `10 -Ethylene glycol -C 0.000000 0.000000 0.000000 -C 1.524000 0.000000 0.000000 -O -0.524000 1.343000 0.000000 -O 2.048000 -1.343000 0.000000 -H -0.360000 -0.514000 0.891000 -H -0.360000 -0.514000 -0.891000 -H 1.884000 0.514000 0.891000 -H 1.884000 0.514000 -0.891000 -H -0.161000 1.861000 0.748000 -H 1.685000 -1.861000 0.748000`, -} - -// Crystal structures in extended XYZ format (unit cells) -const SAMPLE_CRYSTALS: Record = { - 'Silicon': `8 -Lattice="5.43 0.0 0.0 0.0 5.43 0.0 0.0 0.0 5.43" pbc="T T T" -Si 0.00000 0.00000 0.00000 -Si 2.71500 2.71500 0.00000 -Si 2.71500 0.00000 2.71500 -Si 0.00000 2.71500 2.71500 -Si 1.35750 1.35750 1.35750 -Si 4.07250 4.07250 1.35750 -Si 4.07250 1.35750 4.07250 -Si 1.35750 4.07250 4.07250`, - 'MgO': `8 -Lattice="4.212 0.0 0.0 0.0 4.212 0.0 0.0 0.0 4.212" pbc="T T T" -Mg 0.00000 0.00000 0.00000 -Mg 0.00000 2.10600 2.10600 -Mg 2.10600 0.00000 2.10600 -Mg 2.10600 2.10600 0.00000 -O 2.10600 0.00000 0.00000 -O 2.10600 2.10600 2.10600 -O 0.00000 0.00000 2.10600 -O 0.00000 2.10600 0.00000`, - 'Urea': `16 -Lattice="5.582 0.0 0.0 0.0 5.582 0.0 0.0 0.0 4.686" pbc="T T T" -C 0.00000 2.83100 1.55628 -H 1.37587 4.20687 1.32520 -H 0.80400 3.63500 0.13205 -N 0.81136 3.64236 0.87105 -O 0.00000 2.83100 2.82017 -H -1.37587 1.45513 1.32520 -H -0.80400 2.02700 0.13205 -N -0.81136 2.01964 0.87105 -C 2.83100 0.00000 3.15972 -H 1.45513 1.37587 3.39080 -H 2.02700 0.80400 4.58395 -N 2.01964 0.81136 3.84495 -O 2.83100 0.00000 1.89583 -H 4.20687 -1.37587 3.39080 -H 3.63500 -0.80400 4.58395 -N 3.64236 -0.81136 3.84495`, -} - -// Combined for lookup -const SAMPLE_STRUCTURES: Record = { - ...SAMPLE_MOLECULES, - ...SAMPLE_CRYSTALS, -} - -// Detect bonds based on distance and covalent radii -function detectBonds(positions: number[], atomicNumbers: number[]): [number, number][] { - const numAtoms = atomicNumbers.length - const bonds: [number, number][] = [] - const tolerance = 0.4 // Angstroms tolerance - - for (let i = 0; i < numAtoms; i++) { - const ri = getCovalentRadius(atomicNumbers[i]) - const xi = positions[i * 3] - const yi = positions[i * 3 + 1] - const zi = positions[i * 3 + 2] - - for (let j = i + 1; j < numAtoms; j++) { - const rj = getCovalentRadius(atomicNumbers[j]) - const xj = positions[j * 3] - const yj = positions[j * 3 + 1] - const zj = positions[j * 3 + 2] - - const dx = xi - xj - const dy = yi - yj - const dz = zi - zj - const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) - - // Bond if distance < sum of covalent radii + tolerance - if (dist < ri + rj + tolerance) { - bonds.push([i + 1, j + 1]) // 1-indexed for SDF - } - } - } - - return bonds -} - -// Wrap positions back into the unit cell using fractional coordinates -function wrapPositionsToCell( - positions: number[], - lattice: { a: number[], b: number[], c: number[] } -): number[] { - const numAtoms = positions.length / 3 - const wrapped = new Array(positions.length) - - // Build inverse matrix to convert Cartesian to fractional - // lattice matrix: [a, b, c] as columns - const ax = lattice.a[0], ay = lattice.a[1], az = lattice.a[2] - const bx = lattice.b[0], by = lattice.b[1], bz = lattice.b[2] - const cx = lattice.c[0], cy = lattice.c[1], cz = lattice.c[2] - - // Determinant - const det = ax * (by * cz - bz * cy) - bx * (ay * cz - az * cy) + cx * (ay * bz - az * by) - - // Inverse matrix (to convert Cartesian -> fractional) - const inv = [ - [(by * cz - bz * cy) / det, (cx * bz - bx * cz) / det, (bx * cy - cx * by) / det], - [(az * cy - ay * cz) / det, (ax * cz - cx * az) / det, (cx * ay - ax * cy) / det], - [(ay * bz - az * by) / det, (bx * az - ax * bz) / det, (ax * by - bx * ay) / det] - ] - - for (let i = 0; i < numAtoms; i++) { - const x = positions[i * 3] - const y = positions[i * 3 + 1] - const z = positions[i * 3 + 2] - - // Convert to fractional coordinates - let fa = inv[0][0] * x + inv[0][1] * y + inv[0][2] * z - let fb = inv[1][0] * x + inv[1][1] * y + inv[1][2] * z - let fc = inv[2][0] * x + inv[2][1] * y + inv[2][2] * z - - // Wrap to [0, 1) - fa = fa - Math.floor(fa) - fb = fb - Math.floor(fb) - fc = fc - Math.floor(fc) - - // Convert back to Cartesian - wrapped[i * 3] = fa * ax + fb * bx + fc * cx - wrapped[i * 3 + 1] = fa * ay + fb * by + fc * cy - wrapped[i * 3 + 2] = fa * az + fb * bz + fc * cz - } - - return wrapped -} - -// Generate supercell positions for periodic visualization -// supercellSize: [na, nb, nc] - number of cells in each direction (1 = unit cell only) -function generateSupercell( - positions: number[], - atomicNumbers: number[], - lattice: { a: number[], b: number[], c: number[] }, - supercellSize: [number, number, number] = [1, 1, 1] -): { positions: number[], atomicNumbers: number[] } { - const numAtoms = atomicNumbers.length - const supercellPositions: number[] = [] - const supercellAtomicNumbers: number[] = [] - - const [na_max, nb_max, nc_max] = supercellSize - - // Generate supercell (from 0 to n-1 in each direction) - for (let na = 0; na < na_max; na++) { - for (let nb = 0; nb < nb_max; nb++) { - for (let nc = 0; nc < nc_max; nc++) { - // Translation vector for this cell - const tx = na * lattice.a[0] + nb * lattice.b[0] + nc * lattice.c[0] - const ty = na * lattice.a[1] + nb * lattice.b[1] + nc * lattice.c[1] - const tz = na * lattice.a[2] + nb * lattice.b[2] + nc * lattice.c[2] - - // Add translated atoms - for (let i = 0; i < numAtoms; i++) { - supercellPositions.push( - positions[i * 3] + tx, - positions[i * 3 + 1] + ty, - positions[i * 3 + 2] + tz - ) - supercellAtomicNumbers.push(atomicNumbers[i]) - } - } - } - } - - return { positions: supercellPositions, atomicNumbers: supercellAtomicNumbers } -} - -// Convert positions array to SDF/MOL format for NGL (better element support) -function positionsToSdf(positions: number[], atomicNumbers: number[]): string { - const numAtoms = atomicNumbers.length - const bonds = detectBonds(positions, atomicNumbers) - - let sdf = '\n' // molecule name (blank) - sdf += ' RDKit 3D\n' // program/timestamp line - sdf += '\n' // comment line - - // Counts line: aaabbblllfffcccsssxxxrrrpppiiimmmvvvvvv - const atomCount = String(numAtoms).padStart(3) - const bondCount = String(bonds.length).padStart(3) - sdf += `${atomCount}${bondCount} 0 0 0 0 0 0 0 0999 V2000\n` - - // Atom block: x, y, z, symbol, mass diff, charge, etc. - for (let i = 0; i < numAtoms; i++) { - const symbol = getSymbol(atomicNumbers[i]) - const x = positions[i * 3].toFixed(4).padStart(10) - const y = positions[i * 3 + 1].toFixed(4).padStart(10) - const z = positions[i * 3 + 2].toFixed(4).padStart(10) - const sym = symbol.padEnd(3) - sdf += `${x}${y}${z} ${sym} 0 0 0 0 0 0 0 0 0 0 0 0\n` - } - - // Bond block: atom1 atom2 type stereo - for (const [a1, a2] of bonds) { - sdf += `${String(a1).padStart(3)}${String(a2).padStart(3)} 1 0\n` - } - - sdf += 'M END\n' - return sdf -} - -// Parse XYZ to get atomic numbers -function parseXyzAtomicNumbers(xyz: string): number[] { - const lines = xyz.trim().split('\n') - const numAtoms = parseInt(lines[0]) - const atomicNumbers: number[] = [] - - for (let i = 0; i < numAtoms; i++) { - const parts = lines[i + 2].trim().split(/\s+/) - const element = parts[0] - atomicNumbers.push(getAtomicNumber(element)) - } - - return atomicNumbers -} - -// Parse lattice vectors from extended XYZ comment line -// Returns null if no lattice info, or {a, b, c} vectors -function parseLattice(xyz: string): { a: number[], b: number[], c: number[] } | null { - const lines = xyz.trim().split('\n') - if (lines.length < 2) return null - - const commentLine = lines[1] - const latticeMatch = commentLine.match(/Lattice="([^"]+)"/) - if (!latticeMatch) return null - - const values = latticeMatch[1].split(/\s+/).map(v => parseFloat(v)) - if (values.length !== 9) return null - - // Lattice vectors: [a1 a2 a3 b1 b2 b3 c1 c2 c3] - return { - a: [values[0], values[1], values[2]], - b: [values[3], values[4], values[5]], - c: [values[6], values[7], values[8]] - } -} - -interface TimingInfo { - verlet1: number - systemCreate: number - predict: number - verlet2: number - total: number -} - -interface MDState { - isInitialized: boolean - isLoadingModel: boolean - isModelLoaded: boolean - isRunning: boolean - modelType: string - error: string - step: number - energy: number - kineticEnergy: number - temperature: number - maxForce: number - maxStress: number - msPerStep: number - optimizationConverged: boolean - timing: TimingInfo | null -} - -export default function MolecularDynamics() { - const containerRef = useRef(null) - const stageRef = useRef(null) - const componentRef = useRef(null) - const unitCellRef = useRef(null) // NGL shape component for unit cell - const workerRef = useRef(null) - const atomicNumbersRef = useRef([]) - const latticeRef = useRef<{ a: number[], b: number[], c: number[] } | null>(null) - - const [state, setState] = useState({ - isInitialized: false, - isLoadingModel: false, - isModelLoaded: false, - isRunning: false, - modelType: '', - error: '', - step: 0, - energy: 0, - kineticEnergy: 0, - temperature: 0, - maxForce: 0, - maxStress: 0, - msPerStep: 0, - optimizationConverged: false, - timing: null, - }) - - const lastStepTimeRef = useRef(0) - const lastBondsRef = useRef('') // Serialized bonds for comparison - const [energyHistory, setEnergyHistory] = useState([]) - - const [targetTemperature, setTargetTemperature] = useState(300) - const [timestep, setTimestep] = useState(1.0) - const [selectedStructure, setSelectedStructure] = useState('Ethanol') - const [customXyz, setCustomXyz] = useState(SAMPLE_STRUCTURES['Ethanol']) - const [mode, setMode] = useState<'md' | 'optimize'>('md') - const [maxOptSteps, setMaxOptSteps] = useState(100) - const [forceThreshold, setForceThreshold] = useState(0.05) - const [rattleAmount, setRattleAmount] = useState(0.1) // Angstroms - const [supercellSize, setSupercellSize] = useState<[number, number, number]>([2, 2, 2]) - const supercellSizeRef = useRef<[number, number, number]>([2, 2, 2]) - const [viewStyle, setViewStyle] = useState<'ball+stick' | 'spacefill' | 'licorice'>('ball+stick') - const viewStyleRef = useRef('ball+stick') - const [wrapPositions, setWrapPositions] = useState(false) - const wrapPositionsRef = useRef(false) - const [pubchemQuery, setPubchemQuery] = useState('') - const [pubchemLoading, setPubchemLoading] = useState(false) - - // Initialize NGL Stage - useEffect(() => { - if (!containerRef.current) return - - const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches - stageRef.current = new NGL.Stage(containerRef.current, { - backgroundColor: isDark ? '#1a1a1a' : '#ffffff', - quality: 'high', - }) - - const handleResize = () => stageRef.current?.handleResize() - window.addEventListener('resize', handleResize) - - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - const handleTheme = (e: MediaQueryListEvent) => { - stageRef.current?.setParameters({ backgroundColor: e.matches ? '#1a1a1a' : '#ffffff' }) - } - mediaQuery.addEventListener('change', handleTheme) - - return () => { - window.removeEventListener('resize', handleResize) - mediaQuery.removeEventListener('change', handleTheme) - stageRef.current?.dispose() - } - }, []) - - // Initialize worker - useEffect(() => { - workerRef.current = new Worker( - new URL('../workers/mdWorker.ts', import.meta.url), - { type: 'module' } - ) - - workerRef.current.onmessage = (e) => { - const msg = e.data - - switch (msg.type) { - case 'ready': - // Worker is ready, initialize - workerRef.current?.postMessage({ type: 'init' }) - break - - case 'initialized': - setState(s => ({ ...s, isInitialized: true })) - // Auto-load bundled PET-MAD model - loadBundledModel() - break - - case 'modelLoaded': - setState(s => ({ - ...s, - isLoadingModel: false, - isModelLoaded: true, - modelType: msg.modelType, - })) - break - - case 'systemSet': - // System ready for MD - break - - case 'mdStep': - { - const now = performance.now() - const msPerStep = lastStepTimeRef.current > 0 ? now - lastStepTimeRef.current : 0 - lastStepTimeRef.current = now - // For MD, track total energy (potential + kinetic) - const totalEnergy = msg.energy + msg.kineticEnergy - setEnergyHistory(h => [...h.slice(-99), totalEnergy]) // Keep last 100 points - - setState(s => ({ - ...s, - step: s.step + 1, - energy: msg.energy, - kineticEnergy: msg.kineticEnergy, - temperature: msg.temperature, - msPerStep, - timing: msg.timing || null, - })) - // Update visualization - updateVisualization(msg.positions) - } - break - - case 'optStep': - { - const now = performance.now() - const msPerStep = lastStepTimeRef.current > 0 ? now - lastStepTimeRef.current : 0 - lastStepTimeRef.current = now - // For optimization, track potential energy - setEnergyHistory(h => [...h.slice(-99), msg.energy]) // Keep last 100 points - - // Update cell if it changed (for cell optimization) - if (msg.cell && latticeRef.current) { - latticeRef.current = { - a: [msg.cell[0], msg.cell[1], msg.cell[2]], - b: [msg.cell[3], msg.cell[4], msg.cell[5]], - c: [msg.cell[6], msg.cell[7], msg.cell[8]], - } - } - - setState(s => ({ - ...s, - step: msg.step, - energy: msg.energy, - maxForce: msg.maxForce, - maxStress: msg.maxStress ?? 0, - msPerStep, - optimizationConverged: msg.converged, - })) - // Update visualization - updateVisualization(msg.positions) - } - break - - case 'started': - setState(s => ({ ...s, isRunning: true })) - break - - case 'stopped': - setState(s => ({ ...s, isRunning: false })) - break - - case 'rattled': - // Update visualization with rattled positions - updateVisualization(msg.positions) - break - - case 'error': - setState(s => ({ ...s, error: msg.message, isRunning: false })) - break - } - } - - return () => { - workerRef.current?.terminate() - } - }, []) - - // Load bundled PET-MAD model - const loadBundledModel = async () => { - if (!workerRef.current) return - - setState(s => ({ ...s, isLoadingModel: true })) - try { - const response = await fetch(`${import.meta.env.BASE_URL}pet-mad.gguf`) - if (!response.ok) { - throw new Error(`Failed to fetch model: ${response.status}`) - } - const buffer = await response.arrayBuffer() - workerRef.current.postMessage( - { type: 'loadModel', buffer }, - [buffer] - ) - } catch (err: any) { - setState(s => ({ - ...s, - isLoadingModel: false, - error: `Failed to load bundled model: ${err.message}` - })) - } - } - - // Add representation based on style - const addRepresentationForStyle = (component: any, style: string) => { - if (style === 'spacefill') { - component.addRepresentation('spacefill', { - colorScheme: 'element', - radiusScale: 1.0, - }) - } else if (style === 'licorice') { - component.addRepresentation('licorice', { - colorScheme: 'element', - radiusScale: 0.3, - }) - } else { - // ball+stick - component.addRepresentation('ball+stick', { - colorScheme: 'element', - radiusScale: 0.8, - bondScale: 0.3, - }) - } - } - - // Reload structure with updated bonds (for showing reactions) - const reloadStructureWithBonds = useCallback((positions: number[], style: string = 'ball+stick') => { - if (!stageRef.current || atomicNumbersRef.current.length === 0) return - - // For periodic structures, generate supercell for visualization - let displayPositions = positions - let displayAtomicNumbers = atomicNumbersRef.current - if (latticeRef.current) { - const supercell = generateSupercell(positions, atomicNumbersRef.current, latticeRef.current, supercellSizeRef.current) - displayPositions = supercell.positions - displayAtomicNumbers = supercell.atomicNumbers - } - - const sdf = positionsToSdf(displayPositions, displayAtomicNumbers) - const blob = new Blob([sdf], { type: 'text/plain' }) - - // Store current view state - const stage = stageRef.current - - // Remove old molecule component but keep unit cell - if (componentRef.current) { - (stage as any).removeComponent(componentRef.current) - } - - stage.loadFile(blob, { ext: 'sdf', defaultRepresentation: false }) - .then((component: any) => { - componentRef.current = component - addRepresentationForStyle(component, style) - }) - }, []) - - // Update visualization with new positions - const updateVisualization = useCallback((positions: number[]) => { - if (!stageRef.current || !componentRef.current || atomicNumbersRef.current.length === 0) return - - // Optionally wrap positions into the unit cell for periodic systems - let displayPositions = positions - if (wrapPositionsRef.current && latticeRef.current) { - displayPositions = wrapPositionsToCell(positions, latticeRef.current) - } - - // Check if bonds have changed - const currentBonds = detectBonds(displayPositions, atomicNumbersRef.current) - const bondsKey = currentBonds.map(([a, b]) => `${a}-${b}`).join(',') - - if (bondsKey !== lastBondsRef.current) { - lastBondsRef.current = bondsKey - reloadStructureWithBonds(displayPositions, viewStyleRef.current) - return - } - - const structure = componentRef.current.structure - if (!structure || !structure.atomStore) return - - const atomStore = structure.atomStore - const numAtoms = atomicNumbersRef.current.length - - // For periodic structures, we have supercell copies - const isPeriodic = latticeRef.current !== null - const [na_max, nb_max, nc_max] = supercellSizeRef.current - const numCells = na_max * nb_max * nc_max - const expectedAtoms = isPeriodic ? numAtoms * numCells : numAtoms - - // Check if atom count matches - if (atomStore.count !== expectedAtoms) return - - if (isPeriodic && latticeRef.current) { - // Update all copies of each atom in supercell - const { a, b, c } = latticeRef.current - let atomIdx = 0 - for (let na = 0; na < na_max; na++) { - for (let nb = 0; nb < nb_max; nb++) { - for (let nc = 0; nc < nc_max; nc++) { - const tx = na * a[0] + nb * b[0] + nc * c[0] - const ty = na * a[1] + nb * b[1] + nc * c[1] - const tz = na * a[2] + nb * b[2] + nc * c[2] - - for (let i = 0; i < numAtoms; i++) { - atomStore.x[atomIdx] = displayPositions[i * 3] + tx - atomStore.y[atomIdx] = displayPositions[i * 3 + 1] + ty - atomStore.z[atomIdx] = displayPositions[i * 3 + 2] + tz - atomIdx++ - } - } - } - } - } else { - // Non-periodic: direct update - for (let i = 0; i < numAtoms; i++) { - atomStore.x[i] = displayPositions[i * 3] - atomStore.y[i] = displayPositions[i * 3 + 1] - atomStore.z[i] = displayPositions[i * 3 + 2] - } - } - - // Rebuild the structure to reflect new positions - structure.refreshPosition() - componentRef.current.rebuildRepresentations() - }, [reloadStructureWithBonds]) - - // Create unit cell visualization using NGL Shape - // Shows all unit cell boxes for the supercell - const createUnitCellShape = useCallback(( - lattice: { a: number[], b: number[], c: number[] }, - scSize: [number, number, number] - ) => { - if (!stageRef.current) return - - // Remove existing unit cell - if (unitCellRef.current) { - (stageRef.current as any).removeComponent(unitCellRef.current) - unitCellRef.current = null - } - - const { a, b, c } = lattice - - // Create shape with unit cell edges - const shape = new NGL.Shape('unitcell') - - // Define the 12 edges of a unit cube (in fractional coords) - const edges: [number[], number[]][] = [ - [[0, 0, 0], [1, 0, 0]], [[0, 0, 0], [0, 1, 0]], [[0, 0, 0], [0, 0, 1]], // from origin - [[1, 0, 0], [1, 1, 0]], [[1, 0, 0], [1, 0, 1]], // from (1,0,0) - [[0, 1, 0], [1, 1, 0]], [[0, 1, 0], [0, 1, 1]], // from (0,1,0) - [[0, 0, 1], [1, 0, 1]], [[0, 0, 1], [0, 1, 1]], // from (0,0,1) - [[1, 1, 0], [1, 1, 1]], [[1, 0, 1], [1, 1, 1]], [[0, 1, 1], [1, 1, 1]] // to (1,1,1) - ] - - // Convert fractional to Cartesian - const toCartesian = (frac: number[]): [number, number, number] => [ - frac[0] * a[0] + frac[1] * b[0] + frac[2] * c[0], - frac[0] * a[1] + frac[1] * b[1] + frac[2] * c[1], - frac[0] * a[2] + frac[1] * b[2] + frac[2] * c[2] - ] - - // Add edges for each cell in the supercell - for (let na = 0; na < scSize[0]; na++) { - for (let nb = 0; nb < scSize[1]; nb++) { - for (let nc = 0; nc < scSize[2]; nc++) { - const offset = [na, nb, nc] - edges.forEach(([start, end]) => { - const p1 = toCartesian([start[0] + offset[0], start[1] + offset[1], start[2] + offset[2]]) - const p2 = toCartesian([end[0] + offset[0], end[1] + offset[1], end[2] + offset[2]]) - shape.addWideline(p1, p2, [1, 0.5, 0]) // orange - }) - } - } - } - - // Add the shape to the stage - const shapeComp = (stageRef.current as any).addComponentFromObject(shape) - shapeComp.addRepresentation('buffer', { - linewidth: 3, - opacity: 0.8 - }) - unitCellRef.current = shapeComp - }, []) - - // Load a new structure (creates new component) - const loadStructureVisualization = useCallback((positions: number[], atomicNumbers: number[]) => { - if (!stageRef.current) return - - // For periodic structures, generate supercell for visualization - let displayPositions = positions - let displayAtomicNumbers = atomicNumbers - if (latticeRef.current) { - const supercell = generateSupercell(positions, atomicNumbers, latticeRef.current, supercellSizeRef.current) - displayPositions = supercell.positions - displayAtomicNumbers = supercell.atomicNumbers - } - - const sdf = positionsToSdf(displayPositions, displayAtomicNumbers) - const blob = new Blob([sdf], { type: 'text/plain' }) - - stageRef.current.removeAllComponents() - unitCellRef.current = null // Clear unit cell ref since we removed all components - - stageRef.current.loadFile(blob, { ext: 'sdf', defaultRepresentation: false }) - .then((component: any) => { - componentRef.current = component - addRepresentationForStyle(component, viewStyleRef.current) - - // Add unit cell visualization if we have lattice data - if (latticeRef.current) { - createUnitCellShape(latticeRef.current, supercellSizeRef.current) - } - - // Small delay to let DOM settle, then resize and auto-view - setTimeout(() => { - stageRef.current?.handleResize() - stageRef.current?.autoView() - }, 50) - }) - }, [createUnitCellShape]) - - // Set structure - const setStructure = useCallback((xyz: string) => { - if (!workerRef.current || !stageRef.current) return - - // Parse atomic numbers for visualization - atomicNumbersRef.current = parseXyzAtomicNumbers(xyz) - - // Parse lattice for periodic structures - latticeRef.current = parseLattice(xyz) - - // Send to worker - workerRef.current.postMessage({ type: 'setSystem', xyz }) - - // Load initial visualization - const lines = xyz.trim().split('\n') - const numAtoms = parseInt(lines[0]) - const positions: number[] = [] - - for (let i = 0; i < numAtoms; i++) { - const parts = lines[i + 2].trim().split(/\s+/) - positions.push(parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])) - } - - // Load new structure visualization - loadStructureVisualization(positions, atomicNumbersRef.current) - - // Clear energy history and reset state - setEnergyHistory([]) - setState(s => ({ ...s, step: 0, energy: 0, kineticEnergy: 0, temperature: 0, maxForce: 0, maxStress: 0, msPerStep: 0, optimizationConverged: false })) - }, [loadStructureVisualization]) - - // Handle sample structure selection - const handleSampleSelect = (name: string) => { - setSelectedStructure(name) - if (SAMPLE_STRUCTURES[name]) { - setCustomXyz(SAMPLE_STRUCTURES[name]) - } - } - - // Handle loading the current XYZ - const loadCurrentStructure = () => { - if (customXyz.trim()) { - setStructure(customXyz) - } - } - - // Update parameters - useEffect(() => { - workerRef.current?.postMessage({ - type: 'setParameters', - dt: timestep, - temperature: targetTemperature, - mode, - maxOptSteps, - forceThreshold, - }) - }, [timestep, targetTemperature, mode, maxOptSteps, forceThreshold]) - - // Update supercell visualization when size changes - useEffect(() => { - supercellSizeRef.current = supercellSize - // Only reload if we have a structure and lattice - if (latticeRef.current && atomicNumbersRef.current.length > 0 && componentRef.current) { - // Get current positions from worker by requesting them - // For now, just reload the structure from the XYZ (initial positions) - const lines = customXyz.trim().split('\n') - const numAtoms = parseInt(lines[0]) - const positions: number[] = [] - for (let i = 0; i < numAtoms; i++) { - const parts = lines[i + 2].trim().split(/\s+/) - positions.push(parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])) - } - loadStructureVisualization(positions, atomicNumbersRef.current) - } - }, [supercellSize, customXyz, loadStructureVisualization]) - - // Control functions - const startSimulation = () => { - // Reset step counter, energy history, and convergence flag - setEnergyHistory([]) - setState(s => ({ ...s, step: 0, optimizationConverged: false })) - lastStepTimeRef.current = 0 - workerRef.current?.postMessage({ - type: 'start', - stepsPerFrame: 1, - mode, - }) - } - - const stopMD = () => { - workerRef.current?.postMessage({ type: 'stop' }) - } - - const rattleStructure = () => { - workerRef.current?.postMessage({ type: 'rattle', amount: rattleAmount }) - } - - // Load structure from PubChem - const loadFromPubChem = async () => { - if (!pubchemQuery.trim()) return - - setPubchemLoading(true) - setState(s => ({ ...s, error: '' })) - - try { - const xyz = await fetchFromPubChem(pubchemQuery.trim()) - setCustomXyz(xyz) - setSelectedStructure('') - setStructure(xyz) - } catch (err: any) { - setState(s => ({ ...s, error: err.message })) - } finally { - setPubchemLoading(false) - } - } - - return ( -
- {/* Left panel - Structure and parameters */} -
-
-

Structure

- - +
+ ⌘↵ to apply · Esc to cancel +
+ + +
+
+
+
+{/if} + + diff --git a/website/src/index.css b/website/src/index.css deleted file mode 100644 index 8a3c76f..0000000 --- a/website/src/index.css +++ /dev/null @@ -1,65 +0,0 @@ -:root { - --bg-primary: #ffffff; - --bg-secondary: #f5f5f5; - --text-primary: #1a1a1a; - --text-secondary: #666666; - --accent: #3b82f6; - --accent-hover: #2563eb; - --border: #e5e5e5; - --success: #22c55e; - --error: #ef4444; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg-primary: #1a1a1a; - --bg-secondary: #2d2d2d; - --text-primary: #f5f5f5; - --text-secondary: #a0a0a0; - --accent: #60a5fa; - --accent-hover: #3b82f6; - --border: #404040; - } -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, - Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - background-color: var(--bg-primary); - color: var(--text-primary); - line-height: 1.6; -} - -a { - color: var(--accent); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -button { - cursor: pointer; - font-family: inherit; -} - -code { - font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; - background-color: var(--bg-secondary); - padding: 0.2em 0.4em; - border-radius: 4px; - font-size: 0.9em; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 1rem; -} diff --git a/website/src/lib/chem/bonds.ts b/website/src/lib/chem/bonds.ts new file mode 100644 index 0000000..cf75cda --- /dev/null +++ b/website/src/lib/chem/bonds.ts @@ -0,0 +1,32 @@ +import { getCovalentRadius } from './elements' + +export type Bond = [number, number] + +const BOND_TOLERANCE = 0.4 // Angstroms + +export function detectBonds( + positions: ArrayLike, + atomicNumbers: ArrayLike, +): Bond[] { + const n = atomicNumbers.length + const bonds: Bond[] = [] + for (let i = 0; i < n; i++) { + const ri = getCovalentRadius(atomicNumbers[i]) + const xi = positions[i * 3], yi = positions[i * 3 + 1], zi = positions[i * 3 + 2] + for (let j = i + 1; j < n; j++) { + const rj = getCovalentRadius(atomicNumbers[j]) + const dx = xi - positions[j * 3] + const dy = yi - positions[j * 3 + 1] + const dz = zi - positions[j * 3 + 2] + const d = Math.sqrt(dx * dx + dy * dy + dz * dz) + if (d < ri + rj + BOND_TOLERANCE) { + bonds.push([i + 1, j + 1]) // 1-indexed for SDF + } + } + } + return bonds +} + +export function bondsKey(bonds: Bond[]): string { + return bonds.map(([a, b]) => `${a}-${b}`).join(',') +} diff --git a/website/src/lib/chem/cell.ts b/website/src/lib/chem/cell.ts new file mode 100644 index 0000000..b035128 --- /dev/null +++ b/website/src/lib/chem/cell.ts @@ -0,0 +1,54 @@ +export interface Lattice { + a: [number, number, number] + b: [number, number, number] + c: [number, number, number] +} + +export function parseLattice(xyz: string): Lattice | null { + const lines = xyz.trim().split('\n') + if (lines.length < 2) return null + const m = lines[1].match(/Lattice="([^"]+)"/) + if (!m) return null + const v = m[1].split(/\s+/).map(Number) + if (v.length !== 9 || v.some(Number.isNaN)) return null + return { + a: [v[0], v[1], v[2]], + b: [v[3], v[4], v[5]], + c: [v[6], v[7], v[8]], + } +} + +export function volume(lat: Lattice): number { + const [ax, ay, az] = lat.a + const [bx, by, bz] = lat.b + const [cx, cy, cz] = lat.c + return Math.abs(ax * (by * cz - bz * cy) - bx * (ay * cz - az * cy) + cx * (ay * bz - az * by)) +} + +// Wrap positions into the unit cell via fractional coordinates. +export function wrapPositions(positions: ArrayLike, lat: Lattice): number[] { + const [ax, ay, az] = lat.a + const [bx, by, bz] = lat.b + const [cx, cy, cz] = lat.c + const det = ax * (by * cz - bz * cy) - bx * (ay * cz - az * cy) + cx * (ay * bz - az * by) + const inv = [ + [(by * cz - bz * cy) / det, (cx * bz - bx * cz) / det, (bx * cy - cx * by) / det], + [(az * cy - ay * cz) / det, (ax * cz - cx * az) / det, (cx * ay - ax * cy) / det], + [(ay * bz - az * by) / det, (bx * az - ax * bz) / det, (ax * by - bx * ay) / det], + ] + const n = positions.length / 3 + const out = new Array(positions.length) + for (let i = 0; i < n; i++) { + const x = positions[i * 3], y = positions[i * 3 + 1], z = positions[i * 3 + 2] + let fa = inv[0][0] * x + inv[0][1] * y + inv[0][2] * z + let fb = inv[1][0] * x + inv[1][1] * y + inv[1][2] * z + let fc = inv[2][0] * x + inv[2][1] * y + inv[2][2] * z + fa -= Math.floor(fa) + fb -= Math.floor(fb) + fc -= Math.floor(fc) + out[i * 3] = fa * ax + fb * bx + fc * cx + out[i * 3 + 1] = fa * ay + fb * by + fc * cy + out[i * 3 + 2] = fa * az + fb * bz + fc * cz + } + return out +} diff --git a/website/src/data/elements.ts b/website/src/lib/chem/elements.ts similarity index 100% rename from website/src/data/elements.ts rename to website/src/lib/chem/elements.ts diff --git a/website/src/utils/pubchem.ts b/website/src/lib/chem/pubchem.ts similarity index 100% rename from website/src/utils/pubchem.ts rename to website/src/lib/chem/pubchem.ts diff --git a/website/src/lib/chem/sdf.ts b/website/src/lib/chem/sdf.ts new file mode 100644 index 0000000..a39f7d2 --- /dev/null +++ b/website/src/lib/chem/sdf.ts @@ -0,0 +1,28 @@ +import { getSymbol } from './elements' +import { detectBonds } from './bonds' + +// Build a V2000 SDF/MOL record for NGL. SDF handles the full periodic table +// cleanly, which PDB does not. +export function positionsToSdf( + positions: ArrayLike, + atomicNumbers: ArrayLike, +): string { + const n = atomicNumbers.length + const bonds = detectBonds(positions, atomicNumbers) + + let out = '\n RDKit 3D\n\n' + out += `${String(n).padStart(3)}${String(bonds.length).padStart(3)} 0 0 0 0 0 0 0 0999 V2000\n` + + for (let i = 0; i < n; i++) { + const x = positions[i * 3].toFixed(4).padStart(10) + const y = positions[i * 3 + 1].toFixed(4).padStart(10) + const z = positions[i * 3 + 2].toFixed(4).padStart(10) + const sym = getSymbol(atomicNumbers[i]).padEnd(3) + out += `${x}${y}${z} ${sym} 0 0 0 0 0 0 0 0 0 0 0 0\n` + } + for (const [a, b] of bonds) { + out += `${String(a).padStart(3)}${String(b).padStart(3)} 1 0\n` + } + out += 'M END\n' + return out +} diff --git a/website/src/lib/chem/supercell.ts b/website/src/lib/chem/supercell.ts new file mode 100644 index 0000000..06745b8 --- /dev/null +++ b/website/src/lib/chem/supercell.ts @@ -0,0 +1,37 @@ +import type { Lattice } from './cell' + +export interface Supercell { + positions: number[] + atomicNumbers: number[] +} + +export function generateSupercell( + positions: ArrayLike, + atomicNumbers: ArrayLike, + lat: Lattice, + size: [number, number, number] = [1, 1, 1], +): Supercell { + const numAtoms = atomicNumbers.length + const positionsOut: number[] = [] + const atomicNumbersOut: number[] = [] + const [na, nb, nc] = size + + for (let ia = 0; ia < na; ia++) { + for (let ib = 0; ib < nb; ib++) { + for (let ic = 0; ic < nc; ic++) { + const tx = ia * lat.a[0] + ib * lat.b[0] + ic * lat.c[0] + const ty = ia * lat.a[1] + ib * lat.b[1] + ic * lat.c[1] + const tz = ia * lat.a[2] + ib * lat.b[2] + ic * lat.c[2] + for (let i = 0; i < numAtoms; i++) { + positionsOut.push( + positions[i * 3] + tx, + positions[i * 3 + 1] + ty, + positions[i * 3 + 2] + tz, + ) + atomicNumbersOut.push(atomicNumbers[i]) + } + } + } + } + return { positions: positionsOut, atomicNumbers: atomicNumbersOut } +} diff --git a/website/src/lib/chem/xyz.ts b/website/src/lib/chem/xyz.ts new file mode 100644 index 0000000..c3a0e3c --- /dev/null +++ b/website/src/lib/chem/xyz.ts @@ -0,0 +1,23 @@ +import { getAtomicNumber } from './elements' + +export function parseAtomicNumbers(xyz: string): number[] { + const lines = xyz.trim().split('\n') + const n = parseInt(lines[0]) + const out: number[] = [] + for (let i = 0; i < n; i++) { + const parts = lines[i + 2].trim().split(/\s+/) + out.push(getAtomicNumber(parts[0])) + } + return out +} + +export function parsePositions(xyz: string): number[] { + const lines = xyz.trim().split('\n') + const n = parseInt(lines[0]) + const out: number[] = [] + for (let i = 0; i < n; i++) { + const parts = lines[i + 2].trim().split(/\s+/) + out.push(parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])) + } + return out +} diff --git a/website/src/lib/data/samples.ts b/website/src/lib/data/samples.ts new file mode 100644 index 0000000..9987d6f --- /dev/null +++ b/website/src/lib/data/samples.ts @@ -0,0 +1,93 @@ +export const SAMPLE_MOLECULES: Record = { + 'Water': `3 +Water +O 0.000000 0.000000 0.117489 +H 0.000000 0.756950 -0.469957 +H 0.000000 -0.756950 -0.469957`, + 'Methane': `5 +Methane +C 0.000000 0.000000 0.000000 +H 0.629118 0.629118 0.629118 +H -0.629118 -0.629118 0.629118 +H -0.629118 0.629118 -0.629118 +H 0.629118 -0.629118 -0.629118`, + 'Ethanol': `9 +Ethanol +C -0.001193 -0.004555 0.009236 +C 1.519736 -0.001568 -0.012413 +O 2.032422 1.326098 -0.087629 +H -0.394952 1.007606 -0.074891 +H -0.376887 -0.547259 -0.861972 +H -0.435219 -0.483282 0.891082 +H 1.894949 -0.539891 0.862637 +H 1.898649 -0.518854 -0.898756 +H 1.685063 1.800579 0.682628`, + 'Dichloroethane': `8 +1,2-Dichloroethane +C 0.000000 0.000000 0.000000 +C 1.524000 0.000000 0.000000 +Cl -0.799000 1.524000 0.000000 +Cl 2.323000 -1.524000 0.000000 +H -0.360000 -0.514000 0.891000 +H -0.360000 -0.514000 -0.891000 +H 1.884000 0.514000 0.891000 +H 1.884000 0.514000 -0.891000`, + 'Ethylene Glycol': `10 +Ethylene glycol +C 0.000000 0.000000 0.000000 +C 1.524000 0.000000 0.000000 +O -0.524000 1.343000 0.000000 +O 2.048000 -1.343000 0.000000 +H -0.360000 -0.514000 0.891000 +H -0.360000 -0.514000 -0.891000 +H 1.884000 0.514000 0.891000 +H 1.884000 0.514000 -0.891000 +H -0.161000 1.861000 0.748000 +H 1.685000 -1.861000 0.748000`, +} + +export const SAMPLE_CRYSTALS: Record = { + 'Silicon': `8 +Lattice="5.43 0.0 0.0 0.0 5.43 0.0 0.0 0.0 5.43" pbc="T T T" +Si 0.00000 0.00000 0.00000 +Si 2.71500 2.71500 0.00000 +Si 2.71500 0.00000 2.71500 +Si 0.00000 2.71500 2.71500 +Si 1.35750 1.35750 1.35750 +Si 4.07250 4.07250 1.35750 +Si 4.07250 1.35750 4.07250 +Si 1.35750 4.07250 4.07250`, + 'MgO': `8 +Lattice="4.212 0.0 0.0 0.0 4.212 0.0 0.0 0.0 4.212" pbc="T T T" +Mg 0.00000 0.00000 0.00000 +Mg 0.00000 2.10600 2.10600 +Mg 2.10600 0.00000 2.10600 +Mg 2.10600 2.10600 0.00000 +O 2.10600 0.00000 0.00000 +O 2.10600 2.10600 2.10600 +O 0.00000 0.00000 2.10600 +O 0.00000 2.10600 0.00000`, + 'Urea': `16 +Lattice="5.582 0.0 0.0 0.0 5.582 0.0 0.0 0.0 4.686" pbc="T T T" +C 0.00000 2.83100 1.55628 +H 1.37587 4.20687 1.32520 +H 0.80400 3.63500 0.13205 +N 0.81136 3.64236 0.87105 +O 0.00000 2.83100 2.82017 +H -1.37587 1.45513 1.32520 +H -0.80400 2.02700 0.13205 +N -0.81136 2.01964 0.87105 +C 2.83100 0.00000 3.15972 +H 1.45513 1.37587 3.39080 +H 2.02700 0.80400 4.58395 +N 2.01964 0.81136 3.84495 +O 2.83100 0.00000 1.89583 +H 4.20687 -1.37587 3.39080 +H 3.63500 -0.80400 4.58395 +N 3.64236 -0.81136 3.84495`, +} + +export const SAMPLE_STRUCTURES: Record = { + ...SAMPLE_MOLECULES, + ...SAMPLE_CRYSTALS, +} diff --git a/website/src/lib/ngl/viewer.ts b/website/src/lib/ngl/viewer.ts new file mode 100644 index 0000000..8e09f2f --- /dev/null +++ b/website/src/lib/ngl/viewer.ts @@ -0,0 +1,220 @@ +import * as NGL from 'ngl' +import { positionsToSdf } from '../chem/sdf' +import { detectBonds, bondsKey, type Bond } from '../chem/bonds' +import { wrapPositions, type Lattice } from '../chem/cell' +import { generateSupercell } from '../chem/supercell' + +export type ViewStyle = 'ball+stick' | 'licorice' | 'spacefill' | 'cartoon' + +// Imperative NGL wrapper. Kept out of Svelte land so the reactive graph never +// talks to NGL directly — components call setStructure()/updatePositions(). +export class Viewer { + private stage: NGL.Stage | null = null + private component: any = null + private unitCell: any = null + private lastBonds = '' + private atomicNumbers: number[] = [] + private lattice: Lattice | null = null + private supercell: [number, number, number] = [1, 1, 1] + private wrap = false + private style: ViewStyle = 'ball+stick' + private drawing = false + + mount(el: HTMLElement) { + this.stage = new NGL.Stage(el, { + backgroundColor: this.preferredBg(), + // Orthographic camera — no perspective-driven near-plane clipping when + // zooming into a molecule, which is what you want for scientific viz. + cameraType: 'orthographic', + // Pull clip planes wide open so small molecules never slice through + // the near plane; NGL's defaults are tuned for proteins. + clipNear: 0, + clipFar: 100, + clipDist: 0, + fogNear: 50, + fogFar: 100, + }) + window.addEventListener('resize', this.onResize) + } + + dispose() { + window.removeEventListener('resize', this.onResize) + this.stage?.dispose() + this.stage = null + this.component = null + this.unitCell = null + } + + private onResize = () => this.stage?.handleResize() + + private preferredBg(): string { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? '#1a1a1a' : '#ffffff' + } + + setStyle(style: ViewStyle) { + this.style = style + if (this.component) { + this.component.removeAllRepresentations?.() + this.addRepresentation(this.component) + } + } + + setWrap(wrap: boolean) { + this.wrap = wrap + } + + setSupercell(size: [number, number, number]) { + this.supercell = size + } + + private addRepresentation(component: any) { + const style = this.style + if (style === 'spacefill') { + component.addRepresentation('spacefill', { colorScheme: 'element', radiusScale: 1.0 }) + } else if (style === 'licorice') { + component.addRepresentation('licorice', { colorScheme: 'element', radiusScale: 0.5 }) + } else if (style === 'cartoon') { + component.addRepresentation('cartoon', { colorScheme: 'element' }) + } else { + component.addRepresentation('ball+stick', { colorScheme: 'element', radiusScale: 0.5 }) + } + } + + // Initial structure load (different atom list or first draw). + async setStructure( + positions: ArrayLike, + atomicNumbers: number[], + lattice: Lattice | null, + ) { + if (!this.stage) return + this.atomicNumbers = [...atomicNumbers] + this.lattice = lattice + this.lastBonds = '' + await this.drawStructure(positions) + if (lattice) this.drawUnitCell(lattice) + else this.clearUnitCell() + this.stage.autoView(0) + } + + private async drawStructure(positions: ArrayLike) { + if (!this.stage) return + if (this.drawing) return + this.drawing = true + try { + const display = this.prepareDisplay(positions) + const sdf = positionsToSdf(display.positions, display.atomicNumbers) + this.lastBonds = bondsKey(display.bonds) + + const old = this.component + // Drop the reference before the await so a stray updatePositions during + // the load can't touch a half-swapped component. + this.component = null + if (old) { + try { + this.stage.removeComponent(old) + } catch { + /* ignore */ + } + } + const next = await this.stage.loadFile(new Blob([sdf], { type: 'text/plain' }), { + ext: 'sdf', + defaultRepresentation: false, + }) + this.component = next + this.addRepresentation(next) + } finally { + this.drawing = false + } + } + + private prepareDisplay(positions: ArrayLike): { + positions: ArrayLike + atomicNumbers: number[] + bonds: Bond[] + } { + let pos: ArrayLike = positions + if (this.wrap && this.lattice) { + pos = wrapPositions(positions, this.lattice) + } + let atoms = this.atomicNumbers + if (this.lattice) { + const sup = generateSupercell(pos, this.atomicNumbers, this.lattice, this.supercell) + pos = sup.positions + atoms = sup.atomicNumbers + } + return { positions: pos, atomicNumbers: atoms, bonds: detectBonds(pos, atoms) } + } + + // Lightweight per-step update: if bonds haven't changed, just move atoms. + async updatePositions(positions: ArrayLike) { + if (!this.stage || this.atomicNumbers.length === 0) return + // Skip while a structure rebuild is in-flight; the rebuild will use the + // new positions anyway once it finishes. + if (this.drawing) return + + const display = this.prepareDisplay(positions) + const key = bondsKey(display.bonds) + if (key !== this.lastBonds) { + await this.drawStructure(positions) + return + } + + const structure = this.component?.structure + const store = structure?.atomStore + // atomStore is populated asynchronously by NGL — its typed arrays may be + // undefined for a tick after loadFile resolves. Bail rather than crash. + if (!store || !store.x || !store.y || !store.z) return + const n = display.atomicNumbers.length + if (store.count !== n) return + const p = display.positions + for (let i = 0; i < n; i++) { + store.x[i] = p[i * 3] + store.y[i] = p[i * 3 + 1] + store.z[i] = p[i * 3 + 2] + } + this.component.updateRepresentations({ position: true }) + } + + private drawUnitCell(lat: Lattice) { + if (!this.stage) return + this.clearUnitCell() + const shape = new NGL.Shape('unit-cell') + const color: [number, number, number] = [0.5, 0.5, 0.5] + const o: [number, number, number] = [0, 0, 0] + const a = lat.a as [number, number, number] + const b = lat.b as [number, number, number] + const c = lat.c as [number, number, number] + const ab = add(a, b) + const ac = add(a, c) + const bc = add(b, c) + const abc = add(ab, c) + const edges: [typeof o, typeof o][] = [ + [o, a], [o, b], [o, c], + [a, ab], [a, ac], + [b, ab], [b, bc], + [c, ac], [c, bc], + [ab, abc], [ac, abc], [bc, abc], + ] + for (const [start, end] of edges) shape.addWideline(start, end, color) + this.unitCell = this.stage.loadFile(shape as any) + } + + private clearUnitCell() { + if (this.unitCell && this.stage) { + try { + this.stage.removeComponent(this.unitCell) + } catch { + /* ignore */ + } + } + this.unitCell = null + } + + centerView() { + this.stage?.autoView(400) + } +} + +function add(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} diff --git a/website/src/lib/stores/simulation.svelte.ts b/website/src/lib/stores/simulation.svelte.ts new file mode 100644 index 0000000..ba5befc --- /dev/null +++ b/website/src/lib/stores/simulation.svelte.ts @@ -0,0 +1,405 @@ +// Reactive store for the MD demo state. Uses Svelte 5 runes — every `$state` +// field is tracked, so components that read these fields rerender when they +// change. Constructed once in App.svelte and passed down through context. + +import { Simulation, type Backend, type Thermostat, type Optimizer, type MDStep, type OptStep } from '../worker/simulation' +import type { Lattice } from '../chem/cell' +import { parsePositions, parseAtomicNumbers } from '../chem/xyz' +import { getMass } from '../chem/elements' +import { computeVibrations, type VibMode, type VibProgress } from '../vib/modes' + +export type Mode = 'md' | 'optimize' | 'vib' +export type ModelStatus = 'empty' | 'loading' | 'ready' | 'error' + +export class SimulationStore { + readonly sim: Simulation + + // Model + modelStatus = $state('empty') + modelType = $state('') + modelSource = $state('') + activeBackend = $state('') + backendChoice = $state(defaultBackend()) + modelError = $state('') + + // Structure + numAtoms = $state(0) + atomicNumbers = $state([]) + isPeriodic = $state(false) + lattice = $state(null) + positions = $state(null) + cell = $state(null) + currentXyz = $state('') + + // Simulation control + mode = $state('md') + isRunning = $state(false) + step = $state(0) + lastStep = $state(null) + lastOpt = $state(null) + + // MD parameters + temperature = $state(300) + timestep = $state(1.0) + thermostat = $state('none') + useConservativeForces = $state(true) + + // Optimization parameters + optimizer = $state('lbfgs') + activeOptimizer = $state(null) // what the worker actually picked + optimizerForced = $state(false) // true when routing overrode the user's pick + maxOptSteps = $state(100) + forceThreshold = $state(0.05) + rattleAmount = $state(0.1) + optimizationConverged = $state(false) + + // Readouts + energy = $state(0) + kineticEnergy = $state(0) + currentTemperature = $state(0) + maxForce = $state(0) + maxStress = $state(0) + energyDrift = $state(0) + msPerStep = $state(0) + energyHistory = $state([]) + + // Viewer + viewStyle = $state<'ball+stick' | 'licorice' | 'spacefill' | 'cartoon'>('ball+stick') + wrapPositions = $state(true) + supercell = $state<[number, number, number]>([2, 2, 2]) + + // Vibrational analysis + vibComputing = $state(false) + vibProgress = $state(null) + vibModes = $state([]) + vibEquilibrium = $state(null) + vibError = $state('') + activeMode = $state(null) + vibAmplitude = $state(0.3) // max atomic displacement, Å + vibPlaying = $state(false) + vibPeriodMs = $state(1500) // one oscillation = 1.5 s by default + vibOptimizeFirst = $state(true) + vibProjectTrRot = $state(true) + vibShowImaginary = $state(true) + vibNProjected = $state(0) + vibOptStep = $state(0) + vibOptMaxForce = $state(0) + + private lastStepTime = 0 + private animationFrameId: number | null = null + private animationStart = 0 + + constructor() { + this.sim = new Simulation() + this.sim.on((ev) => this.onEvent(ev)) + } + + async initialize() { + await this.sim.ready() + await this.sim.init() + await this.syncParameters() + } + + private onEvent(ev: Parameters[0]>[0]) { + switch (ev.kind) { + case 'mdStep': { + const now = performance.now() + this.msPerStep = this.lastStepTime > 0 ? now - this.lastStepTime : 0 + this.lastStepTime = now + const s = ev.step + this.step++ + this.lastStep = s + this.energy = s.energy + this.kineticEnergy = s.kineticEnergy + this.currentTemperature = s.temperature + this.energyDrift = s.energyDrift + this.positions = s.positions + const total = s.energy + s.kineticEnergy + this.energyHistory = [...this.energyHistory.slice(-99), total] + break + } + case 'optStep': { + const now = performance.now() + this.msPerStep = this.lastStepTime > 0 ? now - this.lastStepTime : 0 + this.lastStepTime = now + const s = ev.step + this.lastOpt = s + this.step = s.step + this.energy = s.energy + this.maxForce = s.maxForce + this.maxStress = s.maxStress ?? 0 + this.positions = s.positions + if (s.cell) this.cell = s.cell + this.optimizationConverged = s.converged + this.energyHistory = [...this.energyHistory.slice(-99), s.energy] + break + } + case 'rattled': + this.positions = ev.positions + break + case 'started': + this.isRunning = true + break + case 'stopped': + this.isRunning = false + break + case 'optimizerStarted': + this.activeOptimizer = ev.optimizer + this.optimizerForced = ev.forced + break + case 'error': + this.modelError = ev.message + this.isRunning = false + // If we were mid-load, surface the error in the model status line. + if (this.modelStatus === 'loading') this.modelStatus = 'error' + break + } + } + + async loadModel(buffer: ArrayBuffer, source: string) { + this.modelStatus = 'loading' + this.modelSource = source + this.modelError = '' + try { + const info = await this.sim.loadModel(buffer, this.backendChoice) + this.modelType = info.modelType + this.activeBackend = info.backend + this.modelStatus = 'ready' + } catch (err: any) { + this.modelStatus = 'error' + this.modelError = err?.message ?? String(err) + } + } + + async setStructure(xyz: string, lattice: Lattice | null) { + // Invalidate any vib analysis we have for the previous structure. + this.clearVibrations() + const info = await this.sim.setSystem(xyz) + // Parse atoms/positions client-side — the worker sets them internally but + // doesn't ship them back over the wire, and the viewer needs them to draw. + this.atomicNumbers = parseAtomicNumbers(xyz) + this.positions = new Float64Array(parsePositions(xyz)) + this.numAtoms = info.numAtoms + this.isPeriodic = info.isPeriodic + this.lattice = lattice + this.currentXyz = xyz + if (lattice) { + this.cell = new Float64Array([ + ...lattice.a, ...lattice.b, ...lattice.c, + ]) + } else { + this.cell = null + } + this.step = 0 + this.lastStepTime = 0 + this.energyHistory = [] + this.energy = 0 + this.kineticEnergy = 0 + this.energyDrift = 0 + this.currentTemperature = 0 + this.optimizationConverged = false + } + + async syncParameters() { + // Vib mode is a main-thread concept — the worker doesn't know about it, so + // we leave the worker's mode field alone and just sync the numeric params. + const workerMode = this.mode === 'vib' ? undefined : this.mode + await this.sim.setParameters({ + dt: this.timestep, + temperature: this.temperature, + mode: workerMode, + maxOptSteps: this.maxOptSteps, + forceThreshold: this.forceThreshold, + thermostat: this.thermostat, + useConservativeForces: this.useConservativeForces, + optimizer: this.optimizer, + }) + } + + start() { + if (this.mode === 'vib') return + this.sim.start(1, this.mode, this.rattleAmount) + } + + stop() { + this.sim.stop() + } + + stepOnce() { + this.sim.step() + } + + rattle() { + this.sim.rattle(this.rattleAmount) + } + + // ---------- Vibrational analysis ---------- + + async computeVibrations(delta: number = 0.01) { + if (this.vibComputing) return + if (!this.positions || this.atomicNumbers.length === 0) { + this.vibError = 'Load a structure first' + return + } + if (this.isRunning) this.stop() + this.stopModeAnimation() + + this.vibComputing = true + this.vibError = '' + this.vibModes = [] + this.activeMode = null + + const masses = new Float64Array(this.atomicNumbers.length) + for (let i = 0; i < this.atomicNumbers.length; i++) { + masses[i] = getMass(this.atomicNumbers[i]) || 12.011 + } + + try { + if (this.vibOptimizeFirst) { + this.vibProgress = { done: 0, total: this.maxOptSteps, phase: 'optimize' } + await this.runOptimizeToConvergence() + } + + this.vibProgress = { + done: 0, + total: 3 * this.atomicNumbers.length * 2, + phase: 'hessian', + } + // `predictAt` doubles the FD work vs total — report in predictions, not DOFs. + const result = await computeVibrations( + this.sim, + this.positions!, + this.atomicNumbers, + masses, + { + delta, + projectTrRot: this.vibProjectTrRot, + isPeriodic: this.isPeriodic, + }, + (p) => { + // The modes pipeline counts DOFs (each DOF is 2 predictions). Scale + // for a smoother progress bar. + this.vibProgress = { + ...p, + done: p.done * 2, + total: p.total * 2, + } + }, + ) + this.vibModes = result.modes + this.vibEquilibrium = result.equilibriumPositions + this.vibNProjected = result.nProjected + } catch (err: any) { + this.vibError = err?.message ?? String(err) + } finally { + this.vibComputing = false + this.vibProgress = null + } + } + + // Kick off a FIRE optimization in the worker and resolve when it converges + // (or hits the max-step cap). The store's normal event handler keeps + // this.positions in sync as optStep events stream in. + private runOptimizeToConvergence(): Promise { + return new Promise((resolve, reject) => { + let settled = false + const unsub = this.sim.on((ev) => { + if (ev.kind === 'optStep') { + this.vibOptStep = ev.step.step + this.vibOptMaxForce = ev.step.maxForce + if (this.vibProgress) { + this.vibProgress = { + ...this.vibProgress, + done: Math.min(ev.step.step, this.vibProgress.total), + } + } + if (ev.step.converged && !settled) { + settled = true + unsub() + resolve() + } + } else if (ev.kind === 'stopped' && !settled) { + // Worker stopped — FIRE hit its max step count without converging. + settled = true + unsub() + resolve() + } else if (ev.kind === 'error' && !settled) { + settled = true + unsub() + reject(new Error(ev.message)) + } + }) + this.sim + .setParameters({ mode: 'optimize', maxOptSteps: this.maxOptSteps, forceThreshold: this.forceThreshold }) + .then(() => { + this.sim.start(1, 'optimize', 0) + }) + .catch((err) => { + if (!settled) { + settled = true + unsub() + reject(err) + } + }) + }) + } + + playMode(index: number) { + if (!this.vibEquilibrium) return + const mode = this.vibModes[index] + if (!mode) return + this.activeMode = index + this.vibPlaying = true + this.animationStart = performance.now() + this.animateStep() + } + + private animateStep = () => { + if (!this.vibPlaying || this.activeMode === null || !this.vibEquilibrium) return + const mode = this.vibModes[this.activeMode] + if (!mode) return + const t = (performance.now() - this.animationStart) / this.vibPeriodMs + const scale = this.vibAmplitude * Math.sin(2 * Math.PI * t) + + const eq = this.vibEquilibrium + const d = mode.displacement + const next = new Float64Array(eq.length) + for (let i = 0; i < eq.length; i++) next[i] = eq[i] + scale * d[i] + this.positions = next + + this.animationFrameId = requestAnimationFrame(this.animateStep) + } + + stopModeAnimation() { + this.vibPlaying = false + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId) + this.animationFrameId = null + } + if (this.vibEquilibrium) { + // Snap back to the equilibrium geometry so the user doesn't see a + // half-way displaced molecule once they stop the animation. + this.positions = new Float64Array(this.vibEquilibrium) + } + } + + clearVibrations() { + this.stopModeAnimation() + this.vibModes = [] + this.vibEquilibrium = null + this.activeMode = null + this.vibError = '' + } + + dispose() { + this.stopModeAnimation() + this.sim.dispose() + } +} + +function defaultBackend(): Backend { + if (typeof navigator !== 'undefined' && /Firefox/i.test(navigator.userAgent)) { + return 'cpu' + } + return 'auto' +} diff --git a/website/src/lib/vib/jacobi.ts b/website/src/lib/vib/jacobi.ts new file mode 100644 index 0000000..92d086d --- /dev/null +++ b/website/src/lib/vib/jacobi.ts @@ -0,0 +1,90 @@ +// Classical Jacobi eigensolver for symmetric real matrices. +// +// Fine for the sizes we deal with here (a 21-atom aspirin gives a 63x63 +// Hessian — well under a millisecond per sweep in JS). +// +// Returns eigenvalues sorted ascending and the corresponding eigenvectors +// as a column-major flat array: eigvec[j * n + i] = i-th component of +// eigenvector j. + +export interface Eigen { + values: Float64Array + vectors: Float64Array // column-major: vectors[j * n + i] +} + +export function jacobiEigen(A: Float64Array, n: number, tol = 1e-10, maxSweeps = 50): Eigen { + // Work on a copy; algorithm destroys its matrix. + const a = new Float64Array(A) + const v = new Float64Array(n * n) + for (let i = 0; i < n; i++) v[i * n + i] = 1 // identity + + const idx = (i: number, j: number) => i * n + j + + for (let sweep = 0; sweep < maxSweeps; sweep++) { + // Off-diagonal L2 norm as convergence proxy. + let off = 0 + for (let p = 0; p < n - 1; p++) { + for (let q = p + 1; q < n; q++) { + off += a[idx(p, q)] * a[idx(p, q)] + } + } + if (off < tol) break + + for (let p = 0; p < n - 1; p++) { + for (let q = p + 1; q < n; q++) { + const apq = a[idx(p, q)] + if (Math.abs(apq) < tol) continue + const app = a[idx(p, p)] + const aqq = a[idx(q, q)] + + // Rotation angle + const theta = (aqq - app) / (2 * apq) + const t = + theta >= 0 + ? 1 / (theta + Math.sqrt(1 + theta * theta)) + : 1 / (theta - Math.sqrt(1 + theta * theta)) + const c = 1 / Math.sqrt(1 + t * t) + const s = t * c + const tau = s / (1 + c) + + a[idx(p, p)] = app - t * apq + a[idx(q, q)] = aqq + t * apq + a[idx(p, q)] = 0 + a[idx(q, p)] = 0 + + for (let r = 0; r < n; r++) { + if (r !== p && r !== q) { + const arp = a[idx(r, p)] + const arq = a[idx(r, q)] + a[idx(r, p)] = arp - s * (arq + tau * arp) + a[idx(p, r)] = a[idx(r, p)] + a[idx(r, q)] = arq + s * (arp - tau * arq) + a[idx(q, r)] = a[idx(r, q)] + } + const vrp = v[idx(r, p)] + const vrq = v[idx(r, q)] + v[idx(r, p)] = vrp - s * (vrq + tau * vrp) + v[idx(r, q)] = vrq + s * (vrp - tau * vrq) + } + } + } + } + + // Extract diag → eigenvalues, then sort ascending. + const values = new Float64Array(n) + for (let i = 0; i < n; i++) values[i] = a[idx(i, i)] + + const order = Array.from({ length: n }, (_, i) => i) + order.sort((i, j) => values[i] - values[j]) + + const sortedVals = new Float64Array(n) + const sortedVecs = new Float64Array(n * n) + for (let j = 0; j < n; j++) { + sortedVals[j] = values[order[j]] + for (let i = 0; i < n; i++) { + sortedVecs[j * n + i] = v[i * n + order[j]] + } + } + + return { values: sortedVals, vectors: sortedVecs } +} diff --git a/website/src/lib/vib/modes.ts b/website/src/lib/vib/modes.ts new file mode 100644 index 0000000..849cfb5 --- /dev/null +++ b/website/src/lib/vib/modes.ts @@ -0,0 +1,194 @@ +// Finite-difference Hessian + diagonalization → normal modes. +// +// Units and conventions: +// - Positions in Å, forces in eV/Å, masses in amu. +// - Hessian H[i,j] = ∂²V/∂x_i ∂x_j, computed as -(∂F_j/∂x_i) via central FD. +// - Mass-weighted: D[i,j] = H[i,j] / sqrt(m_i m_j). +// - Eigenvalues ω² are in (eV/Ų)/amu; convert to cm⁻¹ via CM_FROM_SQRT_EV_AMU_A2. +// - Imaginary modes (ω² < 0) are reported with negative frequency by convention. + +import { jacobiEigen } from './jacobi' +import { buildTrRotBasis, projectOutTrRot } from './projector' +import type { Simulation } from '../worker/simulation' +import { getSymbol } from '../chem/elements' + +// 1/(2π·c) × sqrt(1 eV / (1 amu · Ų)) expressed in cm⁻¹. Derivation: +// ω [1/s] = sqrt(eig · 9.648533e27) +// ν [cm⁻¹] = ω / (2π c) with c = 2.99792458e10 cm/s +// So ν = sqrt(eig) · 521.47 +const CM_FROM_SQRT_EV_AMU_A2 = 521.4709 + +export interface VibMode { + index: number + frequencyCm: number // cm⁻¹, negative for imaginary modes + imaginary: boolean + eigenvalue: number // (eV/Ų)/amu — raw ω² + displacement: Float64Array // length 3N, un-mass-weighted Cartesian displacement +} + +export interface VibResult { + modes: VibMode[] + equilibriumPositions: Float64Array + atomicNumbers: number[] + nProjected: number // number of TR directions removed, 0 if projection disabled +} + +export interface VibProgress { + done: number + total: number + phase: 'optimize' | 'hessian' | 'diagonalize' | 'done' +} + +export interface ComputeVibOptions { + delta?: number // FD step, Å + projectTrRot?: boolean // project translations (+ rotations if molecule) + isPeriodic?: boolean // skips rotation projection if true +} + +export async function computeVibrations( + sim: Simulation, + positions: Float64Array, + atomicNumbers: number[], + masses: Float64Array, + options: ComputeVibOptions = {}, + onProgress?: (p: VibProgress) => void, +): Promise { + const delta = options.delta ?? 0.01 + const doProject = options.projectTrRot ?? true + const isPeriodic = options.isPeriodic ?? false + const n3 = positions.length + const n = n3 / 3 + if (n !== atomicNumbers.length) throw new Error('positions/atomicNumbers mismatch') + if (masses.length !== n) throw new Error('masses length mismatch') + + onProgress?.({ done: 0, total: n3, phase: 'hessian' }) + + // Central differences: for each DOF i, evaluate forces at x ± δ e_i. + // Store F(x+δe_i) in plusF[i * n3 + j], similarly minusF. + const plusF = new Float64Array(n3 * n3) + const minusF = new Float64Array(n3 * n3) + + const scratch = new Float64Array(positions) // reusable work buffer + + for (let i = 0; i < n3; i++) { + scratch.set(positions) + scratch[i] += delta + const fp = await sim.predictAt(scratch) + plusF.set(fp.forces, i * n3) + + scratch[i] -= 2 * delta + const fm = await sim.predictAt(scratch) + minusF.set(fm.forces, i * n3) + + onProgress?.({ done: i + 1, total: n3, phase: 'hessian' }) + } + + // H[i,j] = -(F_j(x + δe_i) - F_j(x - δe_i)) / (2δ) + // with sign convention F = -∇V so H = -∂F_j/∂x_i. + // Symmetrize (H + Hᵀ)/2 to kill FD asymmetry noise. + const H = new Float64Array(n3 * n3) + for (let i = 0; i < n3; i++) { + for (let j = 0; j < n3; j++) { + H[i * n3 + j] = -(plusF[i * n3 + j] - minusF[i * n3 + j]) / (2 * delta) + } + } + for (let i = 0; i < n3; i++) { + for (let j = i + 1; j < n3; j++) { + const avg = 0.5 * (H[i * n3 + j] + H[j * n3 + i]) + H[i * n3 + j] = avg + H[j * n3 + i] = avg + } + } + + // Mass-weight: D[i,j] = H[i,j] / sqrt(m_i m_j) + const invSqrtM = new Float64Array(n3) + for (let a = 0; a < n; a++) { + const s = 1 / Math.sqrt(masses[a]) + invSqrtM[a * 3] = s + invSqrtM[a * 3 + 1] = s + invSqrtM[a * 3 + 2] = s + } + const D = new Float64Array(n3 * n3) + for (let i = 0; i < n3; i++) { + for (let j = 0; j < n3; j++) { + D[i * n3 + j] = H[i * n3 + j] * invSqrtM[i] * invSqrtM[j] + } + } + + // Project out translations (+ rotations if non-periodic) to clean up the + // 6 (or 3 for crystals / 5 for linear molecules) zero-frequency modes. + let nProjected = 0 + if (doProject) { + const basis = buildTrRotBasis(positions, masses, !isPeriodic) + projectOutTrRot(D, n3, basis.vectors) + nProjected = basis.nRemoved + } + + onProgress?.({ done: n3, total: n3, phase: 'diagonalize' }) + // Defer one microtask so the UI can paint the "diagonalizing" state. + await new Promise((r) => setTimeout(r, 0)) + + const { values, vectors } = jacobiEigen(D, n3) + + // Build modes: convert eigenvalues → cm⁻¹, un-mass-weight eigenvectors. + // Projected-out directions have eigenvalues ~0 and sit at the bottom of the + // sorted list — skip them by count so the user only sees real vibrations. + const modes: VibMode[] = [] + for (let k = nProjected; k < n3; k++) { + const ev = values[k] + const imaginary = ev < 0 + const freq = (imaginary ? -1 : 1) * Math.sqrt(Math.abs(ev)) * CM_FROM_SQRT_EV_AMU_A2 + + const displacement = new Float64Array(n3) + for (let i = 0; i < n3; i++) { + // u_i = v_i / sqrt(m_i) — convert mass-weighted back to Cartesian + displacement[i] = vectors[k * n3 + i] * invSqrtM[i] + } + // Normalize displacement so the largest atomic displacement is 1 Å at + // unit amplitude. Nicer for animation than raw mass-weighted vector norm. + let maxLen = 0 + for (let a = 0; a < n; a++) { + const dx = displacement[a * 3] + const dy = displacement[a * 3 + 1] + const dz = displacement[a * 3 + 2] + const len = Math.sqrt(dx * dx + dy * dy + dz * dz) + if (len > maxLen) maxLen = len + } + if (maxLen > 0) { + for (let i = 0; i < n3; i++) displacement[i] /= maxLen + } + + modes.push({ index: k, frequencyCm: freq, imaginary, eigenvalue: ev, displacement }) + } + + onProgress?.({ done: n3, total: n3, phase: 'done' }) + + return { + modes, + equilibriumPositions: new Float64Array(positions), + atomicNumbers: [...atomicNumbers], + nProjected, + } +} + +export function formatFrequency(mode: VibMode): string { + const v = Math.abs(mode.frequencyCm) + const s = v < 10 ? v.toFixed(2) : v.toFixed(1) + return mode.imaginary ? `${s}i cm⁻¹` : `${s} cm⁻¹` +} + +// Human-readable hint about which atom(s) dominate a mode. +export function modeSummary(mode: VibMode, atomicNumbers: number[]): string { + const n = atomicNumbers.length + const weights: { idx: number; w: number }[] = [] + for (let a = 0; a < n; a++) { + const dx = mode.displacement[a * 3] + const dy = mode.displacement[a * 3 + 1] + const dz = mode.displacement[a * 3 + 2] + weights.push({ idx: a, w: Math.sqrt(dx * dx + dy * dy + dz * dz) }) + } + weights.sort((a, b) => b.w - a.w) + const top = weights.slice(0, 2).filter((w) => w.w > 0.15) + if (!top.length) return '' + return top.map((w) => `${getSymbol(atomicNumbers[w.idx])}${w.idx + 1}`).join('+') +} diff --git a/website/src/lib/vib/projector.ts b/website/src/lib/vib/projector.ts new file mode 100644 index 0000000..b2386f9 --- /dev/null +++ b/website/src/lib/vib/projector.ts @@ -0,0 +1,139 @@ +// Translation/rotation projector for molecular Hessians. +// +// In mass-weighted coordinates q_i = x_i * sqrt(m_i), the translation and +// rotation directions span a 6D (or 5D for linear molecules) subspace with +// eigenvalue 0. Finite differences and numerical noise smear those to small +// non-zero eigenvalues that mix with real vibrations. We build the TR basis +// analytically, orthonormalize it, and apply the projector P = I - V V^T to +// the mass-weighted Hessian before diagonalizing. +// +// Reference: the standard approach used in ORCA/psi4/CFOUR/Gaussian and the +// projector in OCC's vibrational analysis. + +export interface TrRotBasis { + vectors: Float64Array[] // each length 3N, mass-weighted, orthonormalized + nRemoved: number // 3, 5, or 6 depending on geometry +} + +// Build and orthonormalize mass-weighted translation + rotation directions. +// `positions` is 3N in Å, `masses` is N in amu. Only used for molecules — for +// periodic cells there are 3 acoustic translations but no rotations, so pass +// includeRotations=false. +export function buildTrRotBasis( + positions: Float64Array, + masses: Float64Array, + includeRotations: boolean = true, + tol: number = 1e-6, +): TrRotBasis { + const n = masses.length + const n3 = 3 * n + + // Shift to center of mass — rotations are defined about the COM. + let cx = 0, cy = 0, cz = 0, mTot = 0 + for (let a = 0; a < n; a++) { + cx += masses[a] * positions[a * 3] + cy += masses[a] * positions[a * 3 + 1] + cz += masses[a] * positions[a * 3 + 2] + mTot += masses[a] + } + cx /= mTot; cy /= mTot; cz /= mTot + const r = new Float64Array(n3) + for (let a = 0; a < n; a++) { + r[a * 3] = positions[a * 3] - cx + r[a * 3 + 1] = positions[a * 3 + 1] - cy + r[a * 3 + 2] = positions[a * 3 + 2] - cz + } + const sqrtM = new Float64Array(n) + for (let a = 0; a < n; a++) sqrtM[a] = Math.sqrt(masses[a]) + + // Raw translation vectors in mass-weighted coords: + // T_α[3a+β] = δ(α,β) * sqrt(m_a) + const raw: Float64Array[] = [] + for (let alpha = 0; alpha < 3; alpha++) { + const v = new Float64Array(n3) + for (let a = 0; a < n; a++) v[a * 3 + alpha] = sqrtM[a] + raw.push(v) + } + + // Raw rotation vectors (about COM), mass-weighted: + // R_α[3a+β] = ε_αβγ r_a[γ] * sqrt(m_a) + // i.e. rotation about axis α acts on atom a as (e_α × r_a) * sqrt(m_a). + if (includeRotations) { + for (let alpha = 0; alpha < 3; alpha++) { + const v = new Float64Array(n3) + for (let a = 0; a < n; a++) { + const rx = r[a * 3], ry = r[a * 3 + 1], rz = r[a * 3 + 2] + if (alpha === 0) { // x axis: (0, -rz, ry) + v[a * 3 + 1] = -rz * sqrtM[a] + v[a * 3 + 2] = ry * sqrtM[a] + } else if (alpha === 1) { // y axis: ( rz, 0, -rx) + v[a * 3 + 0] = rz * sqrtM[a] + v[a * 3 + 2] = -rx * sqrtM[a] + } else { // z axis: (-ry, rx, 0) + v[a * 3 + 0] = -ry * sqrtM[a] + v[a * 3 + 1] = rx * sqrtM[a] + } + } + raw.push(v) + } + } + + // Gram-Schmidt with drop-on-near-zero-norm. For linear molecules one + // rotation vector becomes (nearly) zero after orthogonalization against + // the other two — we drop it. + const ortho: Float64Array[] = [] + for (const u of raw) { + const v = new Float64Array(u) + for (const w of ortho) { + let dot = 0 + for (let i = 0; i < n3; i++) dot += v[i] * w[i] + for (let i = 0; i < n3; i++) v[i] -= dot * w[i] + } + let norm = 0 + for (let i = 0; i < n3; i++) norm += v[i] * v[i] + norm = Math.sqrt(norm) + if (norm > tol) { + for (let i = 0; i < n3; i++) v[i] /= norm + ortho.push(v) + } + } + + return { vectors: ortho, nRemoved: ortho.length } +} + +// Apply P D P in-place where P = I - Σ_k v_k v_k^T and v_k are orthonormal. +// For orthonormal V we can decompose: P D P = D - V V^T D - D V V^T + V V^T D V V^T. +// Implemented as: left-project, then right-project (equivalent since V is ON). +export function projectOutTrRot(D: Float64Array, n: number, V: Float64Array[]): void { + // Left projection: D ← D - v (v^T D) for each v. + for (const v of V) { + // row = v^T D (length n) + const row = new Float64Array(n) + for (let j = 0; j < n; j++) { + let s = 0 + for (let i = 0; i < n; i++) s += v[i] * D[i * n + j] + row[j] = s + } + for (let i = 0; i < n; i++) { + const vi = v[i] + if (vi === 0) continue + for (let j = 0; j < n; j++) D[i * n + j] -= vi * row[j] + } + } + + // Right projection: D ← D - (D v) v^T for each v. + for (const v of V) { + // col = D v (length n) + const col = new Float64Array(n) + for (let i = 0; i < n; i++) { + let s = 0 + for (let j = 0; j < n; j++) s += D[i * n + j] * v[j] + col[i] = s + } + for (let i = 0; i < n; i++) { + const ci = col[i] + if (ci === 0) continue + for (let j = 0; j < n; j++) D[i * n + j] -= ci * v[j] + } + } +} diff --git a/website/src/workers/mdWorker.ts b/website/src/lib/worker/mdWorker.ts similarity index 56% rename from website/src/workers/mdWorker.ts rename to website/src/lib/worker/mdWorker.ts index c20ed89..29de6b1 100644 --- a/website/src/workers/mdWorker.ts +++ b/website/src/lib/worker/mdWorker.ts @@ -1,3 +1,4 @@ +/// // Web Worker for molecular dynamics simulation // Runs mlip.js inference off the main thread // @@ -10,7 +11,8 @@ // - Time: fs (femtoseconds) // - Temperature: K -import createMlipcpp, { MlipcppModule, Model, AtomicSystem } from '@peterspackman/mlip.js' +import createMlipcpp from '@peterspackman/mlip.js' +import type { MlipcppModule, Model, AtomicSystem } from '@peterspackman/mlip.js' interface WorkerState { module: MlipcppModule | null @@ -39,6 +41,17 @@ interface WorkerState { stressThreshold: number // Convergence threshold for stress (eV/A^3) optStep: number optimizeCell: boolean // Whether to optimize cell in FIRE + thermostat: 'csvr' | 'none' + thermostatTau: number // fs + useConservativeForces: boolean // false = NC forces (faster, non-conservative) + initialTotalEnergy: number | null // baseline for NVE drift diagnostic + optimizer: 'lbfgs' | 'fire' + lbfgs: { + history: { s: Float64Array; y: Float64Array; rho: number }[] + currentE: number + currentG: Float64Array | null + step: number + } | null } const state: WorkerState = { @@ -68,20 +81,39 @@ const state: WorkerState = { stressThreshold: 0.01, // eV/A^3 (~1.6 GPa) optStep: 0, optimizeCell: true, // Default to optimizing cell for periodic systems + thermostat: 'none', // NVE by default — honest physics over pretty thermostat + thermostatTau: 100, + useConservativeForces: true, // Conservative forces by default so NVE actually conserves + initialTotalEnergy: null, + optimizer: 'lbfgs', + lbfgs: null, } -// Atomic masses in amu +// Standard atomic weights in amu (IUPAC 2021). Covers rows 1-5 plus the common +// heavier elements seen in MLIP training sets. Unknown Z falls back to carbon +// with a console warning so missing entries are visible. const ATOMIC_MASSES: Record = { - 1: 1.008, // H - 6: 12.011, // C - 7: 14.007, // N - 8: 15.999, // O - 9: 18.998, // F - 12: 24.305, // Mg - 14: 28.085, // Si - 15: 30.974, // P - 16: 32.065, // S - 17: 35.453, // Cl + 1: 1.008, 2: 4.0026, + 3: 6.94, 4: 9.0122, 5: 10.81, 6: 12.011, 7: 14.007, 8: 15.999, 9: 18.998, 10: 20.180, + 11: 22.990, 12: 24.305, 13: 26.982, 14: 28.085, 15: 30.974, 16: 32.06, 17: 35.45, 18: 39.95, + 19: 39.098, 20: 40.078, 21: 44.956, 22: 47.867, 23: 50.942, 24: 51.996, 25: 54.938, 26: 55.845, + 27: 58.933, 28: 58.693, 29: 63.546, 30: 65.38, 31: 69.723, 32: 72.630, 33: 74.922, 34: 78.971, + 35: 79.904, 36: 83.798, + 37: 85.468, 38: 87.62, 39: 88.906, 40: 91.224, 41: 92.906, 42: 95.95, 44: 101.07, 45: 102.91, + 46: 106.42, 47: 107.87, 48: 112.41, 49: 114.82, 50: 118.71, 51: 121.76, 52: 127.60, 53: 126.90, 54: 131.29, + 55: 132.91, 56: 137.33, 72: 178.49, 73: 180.95, 74: 183.84, 75: 186.21, 76: 190.23, 77: 192.22, + 78: 195.08, 79: 196.97, 80: 200.59, 81: 204.38, 82: 207.2, 83: 208.98, +} + +const warnedMassZ = new Set() +function massFor(z: number): number { + const m = ATOMIC_MASSES[z] + if (m !== undefined) return m + if (!warnedMassZ.has(z)) { + warnedMassZ.add(z) + console.warn(`[mdWorker] No atomic mass for Z=${z}; using 12.011 (carbon). Dynamics will be wrong for this element.`) + } + return 12.011 } // Physical constants @@ -162,6 +194,15 @@ function initializeVelocities(numAtoms: number, masses: Float64Array, temperatur velocities[i * 3 + 2] -= vz } + // Maxwell-Boltzmann sampling has ~sqrt(2/dof) relative variance in T, which + // is ~60% for a 3-atom system. Rescale to hit the target T exactly so we + // don't start hundreds of K off target and blame the thermostat. + const { temp: tInit } = calculateKineticEnergy(velocities, masses, numAtoms) + if (tInit > 1e-10) { + const scale = Math.sqrt(temperature / tInit) + for (let i = 0; i < velocities.length; i++) velocities[i] *= scale + } + return velocities } @@ -200,19 +241,75 @@ function calculateKineticEnergy(velocities: Float64Array, masses: Float64Array, } -// Berendsen thermostat velocity scaling -function berendsenThermostat( +// Standard normal sample via Box-Muller. One value per call (the paired +// sample is recomputed next call — cheap enough for thermostat use). +function gaussian(): number { + const u1 = Math.max(Math.random(), 1e-300) + const u2 = Math.random() + return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) +} + +// Sum of n independent chi-squared(1) = sum of n squared N(0,1). Exact via +// Box-Muller for n up to a few hundred (our DOF counts are tiny). +function sumSquaredGaussians(n: number): number { + if (n <= 0) return 0 + let s = 0 + // Consume Gaussians in pairs so Box-Muller isn't wasted. + for (let i = 0; i < n - 1; i += 2) { + const u1 = Math.max(Math.random(), 1e-300) + const u2 = Math.random() + const r = Math.sqrt(-2 * Math.log(u1)) + const g1 = r * Math.cos(2 * Math.PI * u2) + const g2 = r * Math.sin(2 * Math.PI * u2) + s += g1 * g1 + g2 * g2 + } + if (n % 2 === 1) { + const g = gaussian() + s += g * g + } + return s +} + +// Canonical sampling through velocity rescaling (Bussi, Donadio, Parrinello, +// JCP 126, 014101 (2007)). Samples the canonical distribution exactly while +// being as robust and simple as Berendsen. Assumes COM already removed so +// Nf = 3N - 3. +function csvrThermostat( velocities: Float64Array, - currentTemp: number, + masses: Float64Array, + numAtoms: number, targetTemp: number, - tau: number, - dt: number + tau: number, // fs + dt: number // fs ): void { - if (currentTemp < 1e-10) return - const lambda = Math.sqrt(1 + (dt / tau) * (targetTemp / currentTemp - 1)) - for (let i = 0; i < velocities.length; i++) { - velocities[i] *= lambda + const ndeg = Math.max(3 * numAtoms - 3, 1) + + // Current KE in amu·A^2/fs^2 (the native units we work with) + let kk = 0 + for (let i = 0; i < numAtoms; i++) { + const m = masses[i] + const vx = velocities[i * 3], vy = velocities[i * 3 + 1], vz = velocities[i * 3 + 2] + kk += 0.5 * m * (vx * vx + vy * vy + vz * vz) } + if (kk <= 0) return + + // Target KE (sigma in Bussi's notation) = 0.5 * Nf * kB * T + const sigma = 0.5 * ndeg * KB_AMU_A2_FS2 * targetTemp + + // Exponential decay factor per step. tau <= 0 disables coupling. + const factor = tau > 0 ? Math.exp(-dt / tau) : 0 + + const rr = gaussian() + const s2 = sumSquaredGaussians(ndeg - 1) + + const newKk = + kk + + (1 - factor) * (sigma * (s2 + rr * rr) / ndeg - kk) + + 2 * rr * Math.sqrt(kk * sigma / ndeg * (1 - factor) * factor) + + if (newKk <= 0) return // extremely rare numerical edge; skip this step + const alpha = Math.sqrt(newKk / kk) + for (let i = 0; i < velocities.length; i++) velocities[i] *= alpha } // Remove center of mass velocity @@ -249,57 +346,80 @@ async function handleInit(data: { modelBuffer?: ArrayBuffer }): Promise { state.module = await createMlipcpp() if (data.modelBuffer) { - state.model = state.module.Model.loadFromBuffer(data.modelBuffer) + state.model = await state.module.Model.loadFromBuffer(data.modelBuffer) } - self.postMessage({ type: 'initialized', version: state.module.getVersion() }) + self.postMessage({ type: 'initialized', version: await state.module.getVersion() }) } catch (err: any) { self.postMessage({ type: 'error', message: `Initialization failed: ${err.message}` }) } } -async function handleLoadModel(data: { buffer: ArrayBuffer }): Promise { +async function handleLoadModel(data: { buffer: ArrayBuffer, backend?: string }): Promise { if (!state.module) { self.postMessage({ type: 'error', message: 'Module not initialized' }) return } try { - state.model = state.module.Model.loadFromBuffer(data.buffer) + const backend = data.backend || 'auto' + + // Release any previously loaded model + its associated system BEFORE we + // create a new Predictor. Embind smart pointers are reclaimed by JS GC + // lazily, so without this the old Predictor's WebGPU device / tensor + // buffers can still be alive when the new one is initialized — which + // makes the second Predictor's load write into overlapping storage + // buffer ranges and trip `silu_back_f32` aliasing errors inside + // ggml-webgpu. + if (state.model && typeof (state.model as any).delete === 'function') { + try { (state.model as any).delete() } catch { /* ignore */ } + } + state.model = null + if (state.system && typeof (state.system as any).delete === 'function') { + try { (state.system as any).delete() } catch { /* ignore */ } + } + state.system = null + state.forces = null + state.initialTotalEnergy = null + + state.model = await state.module.Model.loadFromBufferWithBackend(data.buffer, backend) self.postMessage({ type: 'modelLoaded', - modelType: state.model.modelType(), - cutoff: state.model.cutoff(), + modelType: await state.model.modelType(), + cutoff: await state.model.cutoff(), + backend: await state.module.getBackendName(), }) } catch (err: any) { self.postMessage({ type: 'error', message: `Failed to load model: ${err.message}` }) } } -function handleSetSystem(data: { xyz: string }): void { +async function handleSetSystem(data: { xyz: string }): Promise { if (!state.module) { self.postMessage({ type: 'error', message: 'Module not initialized' }) return } try { - state.system = state.module.AtomicSystem.fromXyzString(data.xyz) - state.numAtoms = state.system.numAtoms() - state.isPeriodic = state.system.isPeriodic() - state.positions = new Float64Array(state.system.getPositions()) - state.atomicNumbers = new Int32Array(state.system.getAtomicNumbers()) - state.cell = state.system.getCell() ? new Float64Array(state.system.getCell()!) : null + state.system = await state.module.AtomicSystem.fromXyzString(data.xyz) + state.numAtoms = await state.system.numAtoms() + state.isPeriodic = await state.system.isPeriodic() + state.positions = new Float64Array(await state.system.getPositions()) + state.atomicNumbers = new Int32Array(await state.system.getAtomicNumbers()) + const cellArr = await state.system.getCell() + state.cell = cellArr ? new Float64Array(cellArr) : null // Set up masses state.masses = new Float64Array(state.numAtoms) for (let i = 0; i < state.numAtoms; i++) { const z = state.atomicNumbers[i] - state.masses[i] = ATOMIC_MASSES[z] || 12.0 // Default to carbon mass + state.masses[i] = massFor(z) } // Initialize velocities and clear all cached forces/state state.velocities = initializeVelocities(state.numAtoms, state.masses, state.temperature) state.forces = null + state.initialTotalEnergy = null // Clear FIRE optimizer cache fireForces = null @@ -323,7 +443,7 @@ function handleSetSystem(data: { xyz: string }): void { } } -function handlePredict(): void { +async function handlePredict(): Promise { if (!state.module || !state.model || !state.system) { self.postMessage({ type: 'error', message: 'System or model not ready' }) return @@ -331,34 +451,312 @@ function handlePredict(): void { try { // Use NC forces for faster prediction (non-conservative forces from forward pass) - const result = state.model.predictWithOptions(state.system, true) + const result = await state.model.predictWithOptions(state.system, true) + // result.forces is a Float32Array owned by the embind call — copy into a + // Float64Array we can transfer, to keep the main thread in double precision. + const forcesOut = new Float64Array(result.forces) self.postMessage({ type: 'prediction', energy: result.energy, - forces: Array.from(result.forces), - }) + forces: forcesOut, + }, [forcesOut.buffer]) } catch (err: any) { self.postMessage({ type: 'error', message: `Prediction failed: ${err.message}` }) } } +// Predict at arbitrary positions without touching the cached MD state. Reuses +// the loaded species/cell/PBC. Forced to conservative forces because this is +// used for physically meaningful things (Hessian, scans) where NC wouldn't +// give a symmetric/reciprocal-compatible result. +async function handlePredictAt(data: { + positions: Float64Array, + id?: number, +}): Promise { + try { + const result = await predictAtPositions(data.positions) + const forcesOut = new Float64Array(result.forces) + self.postMessage({ + type: 'predictAtResult', + id: data.id, + energy: result.energy, + forces: forcesOut, + }, [forcesOut.buffer]) + } catch (err: any) { + self.postMessage({ + type: 'predictAtResult', + id: data.id, + error: err?.message ?? String(err), + }) + } +} + function handleSetParameters(data: { dt?: number, temperature?: number, mode?: 'md' | 'optimize', maxOptSteps?: number, - forceThreshold?: number + forceThreshold?: number, + thermostat?: 'csvr' | 'none', + thermostatTau?: number, + useConservativeForces?: boolean, + optimizer?: 'lbfgs' | 'fire', }): void { if (data.dt !== undefined) state.dt = data.dt if (data.temperature !== undefined) state.temperature = data.temperature if (data.mode !== undefined) state.mode = data.mode if (data.maxOptSteps !== undefined) state.maxOptSteps = data.maxOptSteps if (data.forceThreshold !== undefined) state.forceThreshold = data.forceThreshold + if (data.thermostat !== undefined) state.thermostat = data.thermostat + if (data.thermostatTau !== undefined) state.thermostatTau = data.thermostatTau + if (data.useConservativeForces !== undefined) { + // Changing force type invalidates any cached forces. + if (state.useConservativeForces !== data.useConservativeForces) { + state.forces = null + state.initialTotalEnergy = null + } + state.useConservativeForces = data.useConservativeForces + } + if (data.optimizer !== undefined) state.optimizer = data.optimizer self.postMessage({ type: 'parametersSet', dt: state.dt, temperature: state.temperature }) } let mdTimeout: ReturnType | null = null +// Shared predict helper — build an AtomicSystem at arbitrary positions and get +// energy + forces via the currently loaded model. Always conservative (forces +// must be gradients of the energy for optimization and FD Hessian to make +// physical sense). +async function predictAtPositions( + positions: Float64Array, +): Promise<{ energy: number; forces: ArrayLike }> { + if (!state.module || !state.model || !state.atomicNumbers) { + throw new Error('Module/model/system not ready') + } + const system = await state.module.AtomicSystem.create( + positions, + state.atomicNumbers, + state.cell, + state.isPeriodic, + ) + const result = await state.model.predictWithOptions(system, false) + return result +} + +// ========== L-BFGS optimizer ========== +// +// Limited-memory BFGS for atom positions (no cell DOFs — cell optimization +// stays on FIRE). Implements Nocedal's two-loop recursion with a scaled +// identity initial Hessian. +// +// Knobs: +// LBFGS_M history depth (number of (s, y) pairs kept) +// LBFGS_MAX_STEP cap on the infinity-norm of the displacement per step (Å) +// LBFGS_LS_MAX max backtracking line-search trials per step +// LBFGS_ARMIJO Armijo sufficient-decrease constant +// +// Line search: start α=1, backtrack α ← α/2 until the energy decreases by +// Armijo · α · g·d. If the budget runs out, take the last trial anyway — +// better than stalling. +const LBFGS_M = 10 +const LBFGS_MAX_STEP = 0.2 // Å +const LBFGS_LS_MAX = 5 +const LBFGS_ARMIJO = 1e-4 + +async function resetLBFGS(): Promise { + state.lbfgs = { + history: [], + currentE: 0, + currentG: null, + step: 0, + } +} + +// L-BFGS two-loop recursion: returns the search direction d = -H_k g. +function lbfgsDirection( + g: Float64Array, + history: { s: Float64Array; y: Float64Array; rho: number }[], +): Float64Array { + const n = g.length + const q = new Float64Array(g) + const alphas = new Array(history.length) + + for (let i = history.length - 1; i >= 0; i--) { + const h = history[i] + let sq = 0 + for (let j = 0; j < n; j++) sq += h.s[j] * q[j] + alphas[i] = h.rho * sq + for (let j = 0; j < n; j++) q[j] -= alphas[i] * h.y[j] + } + + // Scaled identity H_0 = (s·y) / (y·y) · I + let h0 = 1 + if (history.length > 0) { + const last = history[history.length - 1] + let yy = 0, sy = 0 + for (let j = 0; j < n; j++) { + yy += last.y[j] * last.y[j] + sy += last.s[j] * last.y[j] + } + if (yy > 0) h0 = sy / yy + } + + const r = new Float64Array(n) + for (let i = 0; i < n; i++) r[i] = h0 * q[i] + for (let i = 0; i < history.length; i++) { + const h = history[i] + let yr = 0 + for (let j = 0; j < n; j++) yr += h.y[j] * r[j] + const beta = h.rho * yr + for (let j = 0; j < n; j++) r[j] += (alphas[i] - beta) * h.s[j] + } + + // d = -H g + for (let i = 0; i < n; i++) r[i] = -r[i] + return r +} + +function maxInfNorm(v: Float64Array): number { + let m = 0 + for (let i = 0; i < v.length; i++) { + const a = Math.abs(v[i]) + if (a > m) m = a + } + return m +} + +async function runLBFGSStep(): Promise { + if (!state.model || !state.positions || !state.atomicNumbers || !state.lbfgs) return true + + const lb = state.lbfgs + const n3 = state.positions.length + const nAtoms = state.atomicNumbers.length + + // First step: get E, g at the current position. + if (!lb.currentG) { + const result = await predictAtPositions(state.positions) + lb.currentE = result.energy + lb.currentG = new Float64Array(n3) + for (let i = 0; i < n3; i++) lb.currentG[i] = -result.forces[i] + } + + // Convergence check on atomic forces. + const forcesForCheck = new Float64Array(n3) + for (let i = 0; i < n3; i++) forcesForCheck[i] = -lb.currentG[i] + const maxF = calculateMaxForce(forcesForCheck, nAtoms) + if (maxF < state.forceThreshold) { + postOptStep(lb, nAtoms, true) + return true + } + + // Give up after maxOptSteps iterations even if not converged. + if (lb.step >= state.maxOptSteps) { + postOptStep(lb, nAtoms, false) + return true + } + + // Build search direction. + let d = lbfgsDirection(lb.currentG, lb.history) + + // Safety: if not a descent direction, fall back to steepest descent and + // discard the curvature history (it's lying to us). + let dg = 0 + for (let i = 0; i < n3; i++) dg += d[i] * lb.currentG[i] + if (dg >= 0) { + d = new Float64Array(n3) + for (let i = 0; i < n3; i++) d[i] = -lb.currentG[i] + dg = 0 + for (let i = 0; i < n3; i++) dg += d[i] * lb.currentG[i] + lb.history.length = 0 + } + + // Cap infinity-norm step size — prevents giant jumps early on when the + // approximate Hessian is still a scaled identity. + const dMax = maxInfNorm(d) + if (dMax > LBFGS_MAX_STEP) { + const scale = LBFGS_MAX_STEP / dMax + for (let i = 0; i < n3; i++) d[i] *= scale + dg *= scale + } + + // Backtracking line search. + let alpha = 1 + const trial = new Float64Array(n3) + let newE = Infinity + let newForces: ArrayLike | null = null + let accepted = false + for (let ls = 0; ls < LBFGS_LS_MAX; ls++) { + for (let i = 0; i < n3; i++) trial[i] = state.positions[i] + alpha * d[i] + const r = await predictAtPositions(trial) + newE = r.energy + newForces = r.forces + if (newE <= lb.currentE + LBFGS_ARMIJO * alpha * dg) { + accepted = true + break + } + alpha *= 0.5 + } + // If the line search gave up, still accept the last trial — any move beats + // stalling, and L-BFGS recovers well from imperfect steps as long as we + // trash the history when it happens. + if (!accepted) lb.history.length = 0 + + if (!newForces) return true // shouldn't happen; guards the TS narrowing + + const newG = new Float64Array(n3) + for (let i = 0; i < n3; i++) newG[i] = -newForces[i] + + // Update curvature history (skip if s·y is tiny or negative — indicates + // non-convexity in this neighbourhood). + const s = new Float64Array(n3) + const y = new Float64Array(n3) + let sy = 0 + for (let i = 0; i < n3; i++) { + s[i] = trial[i] - state.positions[i] + y[i] = newG[i] - lb.currentG[i] + sy += s[i] * y[i] + } + if (sy > 1e-12 && accepted) { + lb.history.push({ s, y, rho: 1 / sy }) + if (lb.history.length > LBFGS_M) lb.history.shift() + } + + // Commit the move. + state.positions.set(trial) + lb.currentE = newE + lb.currentG = newG + lb.step++ + state.optStep = lb.step + + postOptStep(lb, nAtoms, false) + return false +} + +function postOptStep( + lb: NonNullable, + nAtoms: number, + converged: boolean, +): void { + if (!state.positions) return + const forcesForReport = new Float64Array(lb.currentG!.length) + for (let i = 0; i < lb.currentG!.length; i++) forcesForReport[i] = -lb.currentG![i] + const maxF = calculateMaxForce(forcesForReport, nAtoms) + const posOut = new Float64Array(state.positions) + const cellOut = state.cell ? new Float64Array(state.cell) : null + const transfers: ArrayBuffer[] = [posOut.buffer] + if (cellOut) transfers.push(cellOut.buffer) + self.postMessage({ + type: 'optStep', + positions: posOut, + cell: cellOut, + energy: lb.currentE, + maxForce: maxF, + maxStress: 0, + step: lb.step, + converged, + }, transfers) +} + // FIRE optimizer constants const FIRE_ALPHA_START = 0.1 const FIRE_F_ALPHA = 0.99 @@ -373,7 +771,7 @@ let fireStress: Float64Array | null = null let fireCellForce: Float64Array | null = null // Reset FIRE optimizer state and initialize velocities along force direction -function resetFIRE(): void { +async function resetFIRE(): Promise { state.fireAlpha = FIRE_ALPHA_START state.fireNpos = 0 state.fireDt = 0.1 // Start with small timestep @@ -394,13 +792,13 @@ function resetFIRE(): void { // Initialize velocities along force direction for faster startup if (state.module && state.model && state.positions && state.velocities && state.masses) { // Get initial forces - const system = state.module.AtomicSystem.create( + const system = await state.module.AtomicSystem.create( state.positions, state.atomicNumbers!, state.cell, state.isPeriodic ) - const result = state.model.predictWithOptions(system, true) + const result = await state.model.predictWithOptions(system, true) const forces = new Float64Array(result.forces) // Calculate force magnitude @@ -493,7 +891,7 @@ function calculateVolume(cell: Float64Array): number { // Reference: Bitzek et al., PRL 97, 170201 (2006) // Extended to optimize cell using stress tensor for periodic systems // Uses cached forces for single prediction per step (like MD) -function runFIREStep(): boolean { +async function runFIREStep(): Promise { if (!state.module || !state.model || !state.positions || !state.velocities || !state.masses) { return true // converged = done } @@ -505,13 +903,13 @@ function runFIREStep(): boolean { // If no cached forces, compute initial forces if (!fireForces) { - state.system = state.module.AtomicSystem.create( + state.system = await state.module.AtomicSystem.create( state.positions, state.atomicNumbers!, state.cell, state.isPeriodic ) - const result = state.model.predictWithOptions(state.system, true) + const result = await state.model.predictWithOptions(state.system, true) fireForces = new Float64Array(result.forces) fireStress = result.stress ? new Float64Array(result.stress) : null if (optimizingCell && fireStress && state.cell) { @@ -535,24 +933,28 @@ function runFIREStep(): boolean { if (converged || state.optStep >= state.maxOptSteps) { // Get final energy - state.system = state.module.AtomicSystem.create( + state.system = await state.module.AtomicSystem.create( state.positions, state.atomicNumbers!, state.cell, state.isPeriodic ) - const result = state.model.predictWithOptions(state.system, true) + const result = await state.model.predictWithOptions(state.system, true) + const posOut = new Float64Array(state.positions) + const cellOut = state.cell ? new Float64Array(state.cell) : null + const transfers: ArrayBuffer[] = [posOut.buffer] + if (cellOut) transfers.push(cellOut.buffer) self.postMessage({ type: 'optStep', - positions: Array.from(state.positions), - cell: state.cell ? Array.from(state.cell) : null, + positions: posOut, + cell: cellOut, energy: result.energy, maxForce, maxStress, step: state.optStep, converged, - }) + }, transfers) return true // Done } @@ -640,13 +1042,13 @@ function runFIREStep(): boolean { } // Get new forces (single prediction per step) - state.system = state.module.AtomicSystem.create( + state.system = await state.module.AtomicSystem.create( state.positions, state.atomicNumbers!, state.cell, state.isPeriodic ) - const resultNew = state.model.predictWithOptions(state.system, true) + const resultNew = await state.model.predictWithOptions(state.system, true) const forcesNew = new Float64Array(resultNew.forces) const stressNew = resultNew.stress ? new Float64Array(resultNew.stress) : null @@ -682,17 +1084,20 @@ function runFIREStep(): boolean { const maxForceNew = calculateMaxForce(forcesNew, state.numAtoms) const maxStressNew = (optimizingCell && stressNew) ? calculateMaxStress(stressNew) : 0 - // Send update + const posOut = new Float64Array(state.positions) + const cellOut = state.cell ? new Float64Array(state.cell) : null + const transfers: ArrayBuffer[] = [posOut.buffer] + if (cellOut) transfers.push(cellOut.buffer) self.postMessage({ type: 'optStep', - positions: Array.from(state.positions), - cell: state.cell ? Array.from(state.cell) : null, + positions: posOut, + cell: cellOut, energy: resultNew.energy, maxForce: maxForceNew, maxStress: maxStressNew, step: state.optStep, converged: false, - }) + }, transfers) return false // Not done yet } catch (err: any) { @@ -702,7 +1107,7 @@ function runFIREStep(): boolean { } } -function runMDStep(): void { +async function runMDStep(): Promise { if (!state.module || !state.model || !state.positions || !state.velocities || !state.masses) { return } @@ -710,15 +1115,17 @@ function runMDStep(): void { try { const t0 = performance.now() + const useNCForces = !state.useConservativeForces + // If we don't have cached forces, compute them first if (!state.forces) { - state.system = state.module.AtomicSystem.create( + state.system = await state.module.AtomicSystem.create( state.positions, state.atomicNumbers!, state.cell, state.isPeriodic ) - const result = state.model.predictWithOptions(state.system, true) + const result = await state.model.predictWithOptions(state.system, useNCForces) state.forces = new Float64Array(result.forces) } @@ -739,7 +1146,7 @@ function runMDStep(): void { const t1 = performance.now() // Get forces at new positions (single prediction per step) - state.system = state.module.AtomicSystem.create( + state.system = await state.module.AtomicSystem.create( state.positions, state.atomicNumbers!, state.cell, @@ -747,7 +1154,7 @@ function runMDStep(): void { ) const t2 = performance.now() - const result = state.model.predictWithOptions(state.system, true) + const result = await state.model.predictWithOptions(state.system, useNCForces) const t3 = performance.now() const forcesNew = new Float64Array(result.forces) @@ -771,22 +1178,40 @@ function runMDStep(): void { // Remove center of mass motion to prevent drift removeCOMVelocity(state.velocities, state.masses, state.numAtoms) - // Apply thermostat (tau = 100 fs is a reasonable coupling time) - const { temp } = calculateKineticEnergy(state.velocities, state.masses, state.numAtoms) - berendsenThermostat(state.velocities, temp, state.temperature, 100, state.dt) + // Apply thermostat (skipped in NVE mode) + if (state.thermostat === 'csvr') { + csvrThermostat( + state.velocities, state.masses, state.numAtoms, + state.temperature, state.thermostatTau, state.dt + ) + } - // Calculate updated temperature after thermostat + // Calculate final KE/T after (optional) thermostat const { ke: keNew, temp: tempNew } = calculateKineticEnergy(state.velocities, state.masses, state.numAtoms) + + // NVE drift diagnostic: track total energy relative to first step. + // Only meaningful with conservative forces + no thermostat. We still report + // it in other modes so users can see what it's doing. + const totalE = result.energy + keNew + if (state.initialTotalEnergy === null) { + state.initialTotalEnergy = totalE + } + const energyDrift = totalE - state.initialTotalEnergy + const t4 = performance.now() - // Send update with timing info + // Send update with timing info — transfer typed-array buffers zero-copy. + // state.forces keeps a copy so the next step can reuse cached forces. + const posOut = new Float64Array(state.positions) + const forcesOut = new Float64Array(forcesNew) self.postMessage({ type: 'mdStep', - positions: Array.from(state.positions), + positions: posOut, energy: result.energy, kineticEnergy: keNew, temperature: tempNew, - forces: Array.from(forcesNew), + energyDrift, + forces: forcesOut, timing: { verlet1: t1 - t0, systemCreate: t2 - t1, @@ -794,7 +1219,7 @@ function runMDStep(): void { verlet2: t4 - t3, total: t4 - t0, }, - }) + }, [posOut.buffer, forcesOut.buffer]) } catch (err: any) { handleStop() self.postMessage({ type: 'error', message: `MD step failed: ${err.message}` }) @@ -811,7 +1236,7 @@ function rattlePositions(amount: number): void { } } -function handleStart(data: { stepsPerFrame?: number, mode?: 'md' | 'optimize', rattleAmount?: number }): void { +async function handleStart(data: { stepsPerFrame?: number, mode?: 'md' | 'optimize', rattleAmount?: number }): Promise { if (state.isRunning) return // Update mode if provided @@ -822,8 +1247,22 @@ function handleStart(data: { stepsPerFrame?: number, mode?: 'md' | 'optimize', r state.isRunning = true if (state.mode === 'optimize') { - // Reset FIRE state for new optimization - resetFIRE() + // Cell optimization (periodic + optimizeCell) always uses FIRE — cell + // dynamics are coupled to atoms and easier to reason about with a + // velocity-based scheme. Otherwise respect the user's pick. + const forceFIRE = state.isPeriodic && state.optimizeCell + const useLBFGS = !forceFIRE && state.optimizer === 'lbfgs' + self.postMessage({ + type: 'optimizerStarted', + optimizer: useLBFGS ? 'lbfgs' : 'fire', + forced: forceFIRE, + }) + + if (useLBFGS) { + await resetLBFGS() + } else { + await resetFIRE() + } // Apply rattle if requested if (data.rattleAmount && data.rattleAmount > 0) { @@ -831,9 +1270,9 @@ function handleStart(data: { stepsPerFrame?: number, mode?: 'md' | 'optimize', r } // Run optimization steps as fast as possible - const runOptLoop = () => { + const runOptLoop = async () => { if (!state.isRunning) return - const done = runFIREStep() + const done = useLBFGS ? await runLBFGSStep() : await runFIREStep() if (done) { handleStop() } else { @@ -846,10 +1285,10 @@ function handleStart(data: { stepsPerFrame?: number, mode?: 'md' | 'optimize', r const stepsPerFrame = data.stepsPerFrame || 1 // Run MD steps as fast as possible - const runMDLoop = () => { + const runMDLoop = async () => { if (!state.isRunning) return for (let i = 0; i < stepsPerFrame; i++) { - runMDStep() + await runMDStep() } mdTimeout = setTimeout(runMDLoop, 0) } @@ -868,8 +1307,8 @@ function handleStop(): void { self.postMessage({ type: 'stopped' }) } -function handleStep(): void { - runMDStep() +async function handleStep(): Promise { + await runMDStep() } function handleRattle(data: { amount: number }): void { @@ -881,10 +1320,11 @@ function handleRattle(data: { amount: number }): void { rattlePositions(data.amount) // Send back the new positions so visualization can update + const posOut = new Float64Array(state.positions) self.postMessage({ type: 'rattled', - positions: Array.from(state.positions), - }) + positions: posOut, + }, [posOut.buffer]) } // Message router @@ -899,26 +1339,29 @@ self.onmessage = async (e: MessageEvent) => { await handleLoadModel(data) break case 'setSystem': - handleSetSystem(data) + await handleSetSystem(data) break case 'predict': - handlePredict() + await handlePredict() break case 'setParameters': handleSetParameters(data) break case 'start': - handleStart(data) + await handleStart(data) break case 'stop': handleStop() break case 'step': - handleStep() + await handleStep() break case 'rattle': handleRattle(data) break + case 'predictAt': + await handlePredictAt(data) + break default: self.postMessage({ type: 'error', message: `Unknown message type: ${type}` }) } diff --git a/website/src/lib/worker/simulation.ts b/website/src/lib/worker/simulation.ts new file mode 100644 index 0000000..bd24a62 --- /dev/null +++ b/website/src/lib/worker/simulation.ts @@ -0,0 +1,232 @@ +// Typed RPC wrapper around mdWorker.ts. +// +// The worker speaks a message-based protocol (`{ type, ...payload }` plus +// streamed events like `'mdStep'` and `'modelLoaded'`). This module hides +// that behind method-shaped calls and a small event bus so UI code never +// touches postMessage directly. + +export type Backend = 'auto' | 'cpu' | 'webgpu' +export type Thermostat = 'csvr' | 'none' +export type Optimizer = 'lbfgs' | 'fire' + +export interface MDStep { + positions: Float64Array + forces: Float64Array + energy: number + kineticEnergy: number + temperature: number + energyDrift: number + timing: { + verlet1: number + systemCreate: number + predict: number + verlet2: number + total: number + } +} + +export interface OptStep { + positions: Float64Array + cell: Float64Array | null + energy: number + maxForce: number + maxStress: number + step: number + converged: boolean +} + +export interface ModelInfo { + modelType: string + cutoff: number + backend: string +} + +export interface SystemInfo { + numAtoms: number + isPeriodic: boolean +} + +export interface Prediction { + energy: number + forces: Float64Array +} + +export type SimulationEvent = + | { kind: 'mdStep'; step: MDStep } + | { kind: 'optStep'; step: OptStep } + | { kind: 'rattled'; positions: Float64Array } + | { kind: 'started' } + | { kind: 'stopped' } + | { kind: 'optimizerStarted'; optimizer: Optimizer; forced: boolean } + | { kind: 'error'; message: string } + +type Listener = (ev: SimulationEvent) => void + +// Events that the worker pushes without an RPC request. Everything else is +// a one-shot request/response keyed by message type. +const STREAM_TYPES = new Set(['mdStep', 'optStep', 'rattled', 'started', 'stopped', 'optimizerStarted', 'error']) + +// One-shot response messages keyed by request type → response type. +const RESPONSE_FOR: Record = { + init: 'initialized', + loadModel: 'modelLoaded', + setSystem: 'systemSet', + predict: 'prediction', + setParameters: 'parametersSet', + predictAt: 'predictAtResult', +} + +export class Simulation { + private worker: Worker + private listeners = new Set() + private pending = new Map void; reject: (e: any) => void }>() + private readyPromise: Promise + + constructor() { + this.worker = new Worker(new URL('./mdWorker.ts', import.meta.url), { type: 'module' }) + this.readyPromise = new Promise((resolve) => { + this.pending.set('ready', { resolve: () => resolve(), reject: () => {} }) + }) + this.worker.onmessage = (e: MessageEvent) => this.onMessage(e) + } + + private onMessage(e: MessageEvent) { + const msg = e.data + if (!msg?.type) return + + // Errors reject any in-flight request AND emit a stream event so the UI + // can show the message. Without this, a failed loadModel / setSystem / + // predict hangs forever because the pending promise is keyed to the + // specific response type. + if (msg.type === 'error') { + for (const [key, { reject }] of this.pending) { + if (key === 'ready') continue + this.pending.delete(key) + reject(new Error(msg.message ?? 'Worker error')) + } + this.emit({ kind: 'error', message: msg.message }) + return + } + + if (STREAM_TYPES.has(msg.type)) { + const event = this.toEvent(msg) + if (event) this.emit(event) + return + } + + // One-shot response: find a pending caller that expects this response type. + for (const [key, { resolve }] of this.pending) { + if (RESPONSE_FOR[key] === msg.type || key === msg.type) { + this.pending.delete(key) + resolve(msg) + return + } + } + } + + private toEvent(msg: any): SimulationEvent | null { + switch (msg.type) { + case 'mdStep': + return { kind: 'mdStep', step: msg as MDStep } + case 'optStep': + return { kind: 'optStep', step: msg as OptStep } + case 'rattled': + return { kind: 'rattled', positions: msg.positions } + case 'started': + return { kind: 'started' } + case 'stopped': + return { kind: 'stopped' } + case 'optimizerStarted': + return { kind: 'optimizerStarted', optimizer: msg.optimizer, forced: msg.forced } + case 'error': + return { kind: 'error', message: msg.message } + default: + return null + } + } + + private emit(ev: SimulationEvent) { + for (const l of this.listeners) l(ev) + } + + private request(type: string, payload: any = {}, transfers: Transferable[] = []): Promise { + return new Promise((resolve, reject) => { + if (this.pending.has(type)) { + reject(new Error(`Concurrent ${type} calls are not supported`)) + return + } + this.pending.set(type, { resolve, reject }) + this.worker.postMessage({ type, ...payload }, transfers) + }) + } + + on(listener: Listener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + async ready(): Promise { + return this.readyPromise + } + + async init(): Promise<{ version: string }> { + return this.request('init') + } + + async loadModel(buffer: ArrayBuffer, backend: Backend = 'auto'): Promise { + return this.request('loadModel', { buffer, backend }, [buffer]) + } + + async setSystem(xyz: string): Promise { + return this.request('setSystem', { xyz }) + } + + async predict(): Promise { + return this.request('predict') + } + + async predictAt(positions: Float64Array): Promise { + // Send a copy so callers can keep reusing their buffer across FD steps + // without tripping on the transfer-then-detach semantics. + const copy = new Float64Array(positions) + const res = await this.request('predictAt', { positions: copy }, [copy.buffer]) + if (res.error) throw new Error(res.error) + return { energy: res.energy, forces: res.forces } + } + + async setParameters(params: { + dt?: number + temperature?: number + mode?: 'md' | 'optimize' + maxOptSteps?: number + forceThreshold?: number + thermostat?: Thermostat + thermostatTau?: number + useConservativeForces?: boolean + optimizer?: Optimizer + }): Promise { + await this.request('setParameters', params) + } + + start(stepsPerFrame = 1, mode: 'md' | 'optimize' = 'md', rattleAmount = 0): void { + this.worker.postMessage({ type: 'start', stepsPerFrame, mode, rattleAmount }) + } + + stop(): void { + this.worker.postMessage({ type: 'stop' }) + } + + step(): void { + this.worker.postMessage({ type: 'step' }) + } + + rattle(amount: number): void { + this.worker.postMessage({ type: 'rattle', amount }) + } + + dispose(): void { + this.worker.terminate() + this.listeners.clear() + this.pending.clear() + } +} diff --git a/website/src/main.ts b/website/src/main.ts new file mode 100644 index 0000000..24fcde4 --- /dev/null +++ b/website/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import App from './App.svelte' +import './styles/app.css' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/website/src/main.tsx b/website/src/main.tsx deleted file mode 100644 index 964aeb4..0000000 --- a/website/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) diff --git a/website/src/styles/app.css b/website/src/styles/app.css new file mode 100644 index 0000000..777a989 --- /dev/null +++ b/website/src/styles/app.css @@ -0,0 +1,168 @@ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --text-primary: #1a1a1a; + --text-secondary: #666666; + --accent: #3b82f6; + --accent-hover: #2563eb; + --border: #e5e5e5; + --success: #22c55e; + --error: #ef4444; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --text-primary: #f5f5f5; + --text-secondary: #a0a0a0; + --accent: #60a5fa; + --accent-hover: #3b82f6; + --border: #404040; + } +} + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 14px; + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 0 1rem; + width: 100%; +} + +.header { + padding: 1.5rem 0; + text-align: center; + border-bottom: 1px solid var(--border); +} +.header h1 { + font-size: 2rem; + margin: 0; + font-weight: 700; +} +.subtitle { + color: var(--text-secondary); + margin: 0.25rem 0 0; + font-size: 1rem; +} + +.main { + flex: 1; + padding: 1rem 0; +} + +/* Desktop: viewer is hero; panels flank it, narrow, scroll internally. */ +.md-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr) 240px; + gap: 1rem; + align-items: start; + height: calc(100vh - 170px); + min-height: 560px; +} + +/* Tablet: viewer still hero on top, controls stacked below. */ +@media (max-width: 1100px) { + .md-layout { + grid-template-columns: 1fr; + height: auto; + min-height: 0; + } + .panel-left, .panel-right { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } +} + +/* Phone: single column. */ +@media (max-width: 640px) { + .panel-left, .panel-right { + grid-template-columns: 1fr; + } +} + +.panel { + display: flex; + flex-direction: column; + gap: 0.75rem; + overflow-y: auto; + max-height: 100%; +} + +.center { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + min-width: 0; +} + +/* Column holding viewer + plot. Width is capped so the 4:3 viewer never + exceeds the available vertical space. Both children share this cap, so + the plot strip always aligns with the viewer. */ +.viewer-column { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + max-width: calc((100vh - 300px) * 4 / 3); + margin: 0 auto; + /* Card shrinks to content — no extra grey background filling the column. */ + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--bg-primary); +} +.viewer-frame { + width: 100%; + aspect-ratio: 4 / 3; + display: flex; + min-height: 0; + /* On short viewports this would exceed remaining height — shrink instead. */ + flex-shrink: 1; +} +@media (max-width: 1100px) { + .viewer-column { + max-width: min(800px, 95vw); + } + .viewer-frame { + aspect-ratio: 4 / 3; + } +} + +.footer { + padding: 1rem 0; + text-align: center; + color: var(--text-secondary); + font-size: 0.85rem; + border-top: 1px solid var(--border); +} diff --git a/website/src/vite-env.d.ts b/website/src/vite-env.d.ts index 811e876..58a91ed 100644 --- a/website/src/vite-env.d.ts +++ b/website/src/vite-env.d.ts @@ -1,3 +1,4 @@ +/// /// declare module '*.css' { @@ -10,10 +11,12 @@ declare module 'ngl' { constructor(element: HTMLElement, params?: Record) loadFile(file: string | Blob | File, params?: Record): Promise removeAllComponents(): void + removeComponent(component: any): void autoView(duration?: number): void handleResize(): void setParameters(params: Record): void dispose(): void + viewer: any } export class Shape { constructor(name: string) diff --git a/website/svelte.config.js b/website/svelte.config.js new file mode 100644 index 0000000..8abe436 --- /dev/null +++ b/website/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + preprocess: vitePreprocess(), +} diff --git a/website/tsconfig.json b/website/tsconfig.json index 3934b8f..a5ec6e4 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -1,21 +1,21 @@ { + "extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "skipLibCheck": true, "moduleResolution": "bundler", + "skipLibCheck": true, "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true }, - "include": ["src"], + "include": ["src/**/*.ts", "src/**/*.svelte"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/website/vite.config.ts b/website/vite.config.ts index 591786b..5fc175c 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { svelte } from '@sveltejs/vite-plugin-svelte' export default defineConfig({ - plugins: [react()], + plugins: [svelte()], base: '/mlip.cpp/', build: { outDir: 'dist',