diff --git a/.changeset/parallel-experiment-runs.md b/.changeset/parallel-experiment-runs.md new file mode 100644 index 00000000000..b5878a085f8 --- /dev/null +++ b/.changeset/parallel-experiment-runs.md @@ -0,0 +1,14 @@ +--- +"@hashintel/petrinaut-core": patch +"@hashintel/petrinaut": patch +--- + +Run an experiment's runs in parallel across several Web Workers. + +An experiment used to run every one of its runs in a single worker, using one core however many the machine had. Runs are independent, so they now split across one worker per logical core (minus one, so the editor stays responsive), capped at the run count — measured at ~4x on 8 shards on a 10-core machine. + +Sharding cannot change what an experiment reports. Per-run seeds derive from the run's **global** index rather than its position within a shard, so run *i* gets the same seed whichever worker owns it, and each worker's per-frame statistics recombine through the metric accumulator monoids (`empty`/`merge`) — output is byte-identical at every shard count. A frame is only finalised once every still-running shard has reported it, with finished shards dropped from that watermark rather than blocking it. + +Scalar metric frames now carry their pre-reduction accumulator state, because `frameValue` is already reduced and a mean of means is not a mean. + +Hosts can cap or pin parallelism with `experimentShardCount` on `ExperimentsProvider`, or `shardCount` on `createMonteCarloExperiment`. diff --git a/AGENTS.md b/AGENTS.md index 2575a5db8ff..f29330a7e70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,30 @@ When you change UI or behaviour in the petrinaut packages (`libs/@hashintel/petr If a change ships without doc updates, call that out in your summary so the user can decide whether to follow up. +### Petrinaut architecture docs + +Distinct from the user guide above: the **architecture** docs describe the shape of the code for the people (and agents) working on it. They are generated from annotations in the source by `@local/petrinaut-arch-docs`, and building the bundle fails when they drift. + +The architecture is declared **next to the code it describes** — never in a central mapping file. Two tags, and that is the whole vocabulary: + +- `@layerRoot ` plus `@role ` in a doc comment on a folder's primary file declares a layer. Prefer this — it needs no new file. +- A folder's `README.md` frontmatter (`layer` and `role`) does the same, and the prose below becomes that layer's page. Use it when the folder has real prose to carry, or when no single file is the obvious host. +- Files with no annotation inherit from the nearest declaring ancestor, so 37 declarations cover 412 files. Do not annotate every file. + +Any other tag is ignored, so do not add one expecting it to appear in the docs. In a declaring README's frontmatter, `layer` and `role` are the only keys and anything else fails the build. + +The generated docs are **build output and are not committed** — there is nothing to regenerate before pushing. Only the annotations are versioned. + +When you change structure in `libs/@hashintel/petrinaut-core` or `libs/@hashintel/petrinaut`, you MUST add a declaration if you introduce a folder that is a genuinely new architectural unit — a new boundary or a distinct responsibility, not merely a new directory. + +Verify with `yarn workspace @local/petrinaut-arch-docs lint:arch-docs`, which fails on unannotated files, undeclared ancestors and rule violations. To read the docs, `turbo run doc:architecture --filter @local/petrinaut-arch-docs` writes the bundle to `libs/@local/petrinaut-arch-docs/bundle/` (git-ignored); open `bundle/architecture.md` for the entire model in one file. + +Full reference: `libs/@local/petrinaut-arch-docs/README.md`. Browse the docs with `turbo run dev --filter @apps/petrinaut-docs`, which regenerates the bundle first — run it through Turborepo, since the package script alone skips that. + +Hand-written MDX in `libs/@local/petrinaut-arch-docs/content/` carries the reasoning an import graph cannot express — why a boundary sits where it does, what the alternatives were. It is optional; the system works with that directory absent. + +Give such a page `attachTo: ` in its frontmatter and it nests inside the generated tree beneath that layer, rather than sitting in a separate section — that is how the simulation deep-dives are wired. Link between pages with `[text](layer:core.simulation.engine)` or `[text](doc:simulation/memory-model)`; relative paths break when a page's `attachTo` changes, and unresolved targets fail CI. + ## Contextual Rules CRITICAL: For the files referenced below, use your Read tool to load it on a need-to-know basis, ONLY when relevant to the SPECIFIC task at hand: diff --git a/apps/petrinaut-docs/.gitignore b/apps/petrinaut-docs/.gitignore new file mode 100644 index 00000000000..3f53ae7899a --- /dev/null +++ b/apps/petrinaut-docs/.gitignore @@ -0,0 +1,11 @@ +# Copied from the architecture bundle by scripts/sync-bundle.mjs. The bundle in +# libs/@local/petrinaut-arch-docs/bundle is the committed artefact; this is a +# build input derived from it. +src/content/docs/ +src/content/diagrams/ +src/content/components/ +public/architecture.md +public/architecture.json + +dist/ +.astro/ diff --git a/apps/petrinaut-docs/LICENSE-APACHE.md b/apps/petrinaut-docs/LICENSE-APACHE.md new file mode 100644 index 00000000000..4b43328a923 --- /dev/null +++ b/apps/petrinaut-docs/LICENSE-APACHE.md @@ -0,0 +1,189 @@ +# Apache License + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +- **(a)** You must give any other recipients of the Work or Derivative Works a copy of + this License; and +- **(b)** You must cause any modified files to carry prominent notices stating that You + changed the files; and +- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, + all copyright, patent, trademark, and attribution notices from the Source form + of the Work, excluding those notices that do not pertain to any part of the + Derivative Works; and +- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any + Derivative Works that You distribute must include a readable copy of the + attribution notices contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, in at least one of the + following places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if provided along + with the Derivative Works; or, within a display generated by the Derivative + Works, if and wherever such third-party notices normally appear. The contents of + the NOTICE file are for informational purposes only and do not modify the + License. You may add Your own attribution notices within Derivative Works that + You distribute, alongside or as an addendum to the NOTICE text from the Work, + provided that such additional attribution notices cannot be construed as + modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: Apply the Apache License to a specific file + +To apply the Apache License to an individual file, attach the following notice. +The text should be enclosed in the appropriate comment syntax for the file +format. + + Copyright © 2025–, HASH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/petrinaut-docs/LICENSE-MIT.md b/apps/petrinaut-docs/LICENSE-MIT.md new file mode 100644 index 00000000000..d85585ee20d --- /dev/null +++ b/apps/petrinaut-docs/LICENSE-MIT.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright © 2025–, HASH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/petrinaut-docs/LICENSE.md b/apps/petrinaut-docs/LICENSE.md new file mode 100644 index 00000000000..6dad94c0e5a --- /dev/null +++ b/apps/petrinaut-docs/LICENSE.md @@ -0,0 +1,3 @@ +# License + +Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE.md) or [MIT license](LICENSE-MIT.md) at your option. diff --git a/apps/petrinaut-docs/README.md b/apps/petrinaut-docs/README.md new file mode 100644 index 00000000000..be89710cf02 --- /dev/null +++ b/apps/petrinaut-docs/README.md @@ -0,0 +1,66 @@ +# `@apps/petrinaut-docs` + +Renders the Petrinaut architecture bundle as a browsable [Starlight](https://starlight.astro.build) +site. + +**Run it through Turborepo**, which is what knows the site needs a freshly +generated bundle: + +```sh +turbo run dev --filter @apps/petrinaut-docs # browse at http://localhost:4321 +turbo run build --filter @apps/petrinaut-docs # static output in dist/ +``` + +`yarn workspace @apps/petrinaut-docs dev` runs the package script directly and +skips the task graph, so it renders whatever was last synced. + +## This app owns no content + +Every page comes from the bundle generated by `@local/petrinaut-arch-docs`. +`scripts/sync-bundle.mjs` copies that bundle into `src/content/`; generating it +is the generator's own `doc:architecture` task, which `sync:bundle` declares as a +dependency. The bundle is build output and is not committed. + +`build`, `lint:tsc` and `dev` all depend on one shared `sync:bundle` rather than +each running it — they used to call it themselves, which raced when Turborepo ran +them concurrently on the same directories. + +That separation is deliberate. The bundle has to render in a host that did not +generate it — hash.dev is the other one — so this site is a test of the bundle's +portability as much as a way to read it. Anything that only works here is a bug +in the bundle. + +To change what the docs say, change the annotations in the Petrinaut source or +the authored pages in `libs/@local/petrinaut-arch-docs/content/`. Anything run +through Turborepo picks the change up; to regenerate the bundle on its own: + +```sh +turbo run doc:architecture --filter @local/petrinaut-arch-docs +``` + +## Why the bundle is copied rather than read in place + +Pointing Astro's `glob` loader at the bundle directory works for `astro build`, +but not for `astro dev`: in dev, Astro resolves an MDX page's relative image +paths against the project root, so `../diagrams/core.svg` cannot be found. +Copying sidesteps that, and matches what an embedding host does anyway. + +The copied directories (`src/content/docs`, `src/content/diagrams`) are +gitignored, as is the bundle they come from. Nothing generated is versioned. + +## Notes on configuration + +Authored pages are optional. When `content/` in the generator package is empty +or absent, there is no authored home page, so `/` redirects to the generated +architecture overview instead. + +`trailingSlash: "never"` and `build.format: "file"` are load-bearing, not +stylistic. The bundle's inter-page links are relative and assume a page's slug +maps to a URL with no trailing slash; serving `/architecture/core/` instead would +resolve those links one level too deep. + +`installConfig.hoistingLimits` nests this app's dependencies rather than hoisting +them. Astro's generated prerender entry resolves `cookie` from this app's build +output, which would otherwise reach the root-hoisted `cookie@0.7.2` that +`express` pins and fail on a missing `parseCookie` export. Nesting keeps Astro on +its own `cookie@2.x` without changing hoisting for the rest of the monorepo. diff --git a/apps/petrinaut-docs/astro.config.mjs b/apps/petrinaut-docs/astro.config.mjs new file mode 100644 index 00000000000..b7146795c99 --- /dev/null +++ b/apps/petrinaut-docs/astro.config.mjs @@ -0,0 +1,165 @@ +// @ts-check +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import react from "@astrojs/react"; +import starlight from "@astrojs/starlight"; +import { defineConfig } from "astro/config"; + +/** + * Renders the architecture bundle produced by `@local/petrinaut-arch-docs`. + * + * This site owns no content. That is the point: the bundle has to render in a + * host that did not generate it, and this site is the first of two such hosts + * (hash.dev being the other). Anything that only works here is a bug in the + * bundle's portability, which is why the sidebar below is built from + * `manifest.json` rather than from Starlight-specific frontmatter. + */ + +const manifestPath = fileURLToPath( + new URL( + "../../libs/@local/petrinaut-arch-docs/bundle/manifest.json", + import.meta.url, + ), +); + +/** @type {import("@local/petrinaut-arch-docs").BundleManifest} */ +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + +/** + * @typedef {{ label: string, link: string }} SidebarLink + * @typedef {{ label: string, collapsed?: boolean, items: SidebarItem[] }} SidebarGroup + * @typedef {SidebarLink | SidebarGroup} SidebarItem + */ + +/** + * Builds Starlight's nested sidebar from the manifest. + * + * Nesting follows the *slug*, not whether a page was generated. An authored + * guide that attached itself to a layer has a slug beneath that layer, so it + * nests with the generated reference for the same code — which is the whole + * point of attaching it. Ordering within a level uses the manifest's `order`, + * so guides (low numbers) sort ahead of sub-layers (1000+). + */ +const buildSidebar = () => { + const pages = [...manifest.pages].sort( + (left, right) => + left.order - right.order || left.slug.localeCompare(right.slug), + ); + + /** + * @param {string} slug + * @returns {string} + */ + const parentOf = (slug) => + slug.includes("/") ? slug.slice(0, slug.lastIndexOf("/")) : ""; + + const hasChildren = new Set( + pages.map((page) => parentOf(page.slug)).filter((slug) => slug !== ""), + ); + + /** + * Recursive, so the return type is annotated explicitly — TypeScript cannot + * infer it from a function that references itself. + * + * @param {string} parentSlug + * @returns {SidebarItem[]} + */ + const itemsUnder = (parentSlug) => + pages + .filter((page) => parentOf(page.slug) === parentSlug) + .map((page) => + hasChildren.has(page.slug) + ? { + label: page.title, + collapsed: true, + items: [ + { label: `${page.title} overview`, link: `/${page.slug}` }, + ...itemsUnder(page.slug), + ], + } + : { label: page.title, link: `/${page.slug}` }, + ); + + const architectureRoot = pages.find((page) => page.slug === "architecture"); + const narrative = pages.filter( + (page) => !page.slug.startsWith("architecture") && !page.slug.includes("/"), + ); + + return [ + ...narrative.map((page) => ({ + label: page.title, + link: page.slug === "index" ? "/" : `/${page.slug}`, + })), + ...(architectureRoot + ? [ + { + label: "Architecture", + collapsed: false, + items: [ + { label: "Overview", link: `/${architectureRoot.slug}` }, + ...itemsUnder(architectureRoot.slug), + ], + }, + ] + : []), + ]; +}; + +/** + * Authored pages are optional, so the site must have a root page without them. + * + * An authored `index` page becomes `/`. With no authored content at all, `/` + * redirects to the generated overview instead, which keeps `content/` a genuine + * opt-in rather than something the site quietly depends on. + */ +const hasAuthoredIndex = manifest.pages.some( + (page) => page.kind === "authored" && page.slug === "index", +); + +export default defineConfig({ + site: "https://petrinaut-docs.hash.dev", + + ...(hasAuthoredIndex ? {} : { redirects: { "/": "/architecture" } }), + + // The bundle's inter-page links are relative and assume slugs map to URLs + // without a trailing slash. `format: "file"` writes `views.html` rather than + // `views/index.html`, so there is no trailing-slash form of a URL for a reader + // to land on and resolve those links one level too deep. See the generator's + // README for the contract. + trailingSlash: "never", + build: { format: "file" }, + + integrations: [ + // Authored pages may import diagram components from the bundle; generated + // pages stay plain Markdown and never need this. + react(), + starlight({ + title: "Architecture Docs", + description: + "How the Petrinaut packages fit together — generated from annotations in the source.", + // The helmet carries the Petrinaut identity, so the title beside it names + // only what this site is. `replacesTitle: false` keeps both. + logo: { + src: "./src/assets/petrinaut-helmet.png", + alt: "Petrinaut", + replacesTitle: false, + }, + favicon: "/favicon.ico", + social: [ + { + icon: "github", + label: "GitHub", + href: "https://github.com/hashintel/hash/tree/main/libs/@hashintel/petrinaut", + }, + ], + sidebar: buildSidebar(), + // No llms.txt plugin: the bundle emits its own `architecture.md` and + // `architecture.json`, which `scripts/sync-bundle.mjs` copies into + // `public/`. Serving those keeps the site's machine-readable surface + // identical to what any other host of the bundle would serve, rather than + // a second, site-shaped copy that could disagree with it. + pagination: false, + }), + ], +}); diff --git a/apps/petrinaut-docs/package.json b/apps/petrinaut-docs/package.json new file mode 100644 index 00000000000..e4318ab78a5 --- /dev/null +++ b/apps/petrinaut-docs/package.json @@ -0,0 +1,34 @@ +{ + "name": "@apps/petrinaut-docs", + "version": "0.0.0-private", + "private": true, + "description": "Renders the Petrinaut architecture bundle as a browsable docs site", + "license": "(MIT OR Apache-2.0)", + "type": "module", + "scripts": { + "build": "astro build", + "dev": "astro dev", + "lint:tsc": "astro check --minimumSeverity error", + "preview": "astro preview", + "sync:bundle": "node scripts/sync-bundle.mjs" + }, + "dependencies": { + "@astrojs/react": "6.0.2", + "@astrojs/starlight": "0.41.5", + "astro": "7.1.6", + "react": "19.2.6", + "react-dom": "19.2.6", + "sharp": "0.35.3" + }, + "devDependencies": { + "@astrojs/check": "0.9.10", + "@local/petrinaut-arch-docs": "workspace:*", + "@types/node": "22.18.13", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" + }, + "installConfig": { + "hoistingLimits": "dependencies" + } +} diff --git a/apps/petrinaut-docs/public/favicon.ico b/apps/petrinaut-docs/public/favicon.ico new file mode 100644 index 00000000000..93949fb5761 Binary files /dev/null and b/apps/petrinaut-docs/public/favicon.ico differ diff --git a/apps/petrinaut-docs/scripts/sync-bundle.mjs b/apps/petrinaut-docs/scripts/sync-bundle.mjs new file mode 100644 index 00000000000..4bd3a17f24e --- /dev/null +++ b/apps/petrinaut-docs/scripts/sync-bundle.mjs @@ -0,0 +1,65 @@ +/** + * Copies the architecture bundle into this app's content tree. + * + * Generating the bundle is `@local/petrinaut-arch-docs`'s job, declared in + * `turbo.json` as a dependency of this task. This script used to spawn the + * generator itself, which meant a cross-package build step lived in a shell call + * that Turborepo could not see, order or report on. Now it only copies. + * + * Reading the bundle in place via a `glob` loader with an out-of-root `base` + * works for `astro build` but not for `astro dev`: Astro resolves an MDX page's + * relative image paths against the project root in dev, so `../diagrams/core.svg` + * cannot be found. Copying sidesteps that, and is what a host embedding the + * bundle (hash.dev) does anyway — so this stays an honest test of whether the + * bundle is portable. + */ + +import { access, cp, mkdir, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const bundleRoot = fileURLToPath( + new URL("../../../libs/@local/petrinaut-arch-docs/bundle/", import.meta.url), +); +const contentRoot = fileURLToPath(new URL("../src/content/", import.meta.url)); + +try { + await access(bundleRoot); +} catch { + process.stderr.write( + `No bundle at ${bundleRoot}\n` + + "Run this through Turborepo so the generator runs first:\n" + + " turbo run sync:bundle --filter @apps/petrinaut-docs\n", + ); + process.exit(1); +} + +for (const directory of ["docs", "diagrams", "components"]) { + await rm(new URL(directory, `file://${contentRoot}`), { + recursive: true, + force: true, + }); +} + +await mkdir(contentRoot, { recursive: true }); + +// `pages/` becomes `docs/` — the collection name Starlight expects. +await cp(`${bundleRoot}pages`, `${contentRoot}docs`, { recursive: true }); +await cp(`${bundleRoot}diagrams`, `${contentRoot}diagrams`, { + recursive: true, +}); + +// Diagram components imported by authored pages. Copied as siblings of `docs/` +// because that is the layout the bundle's own relative imports assume. +await cp(`${bundleRoot}components`, `${contentRoot}components`, { + recursive: true, +}); + +// The bundle's machine-readable artefacts are served as-is, so an agent reading +// this site gets byte-identical content to one reading the bundle directly. +const publicRoot = fileURLToPath(new URL("../public/", import.meta.url)); +await mkdir(publicRoot, { recursive: true }); +for (const file of ["architecture.md", "architecture.json"]) { + await cp(`${bundleRoot}${file}`, `${publicRoot}${file}`); +} + +process.stdout.write("Synced architecture bundle into src/content\n"); diff --git a/apps/petrinaut-docs/src/assets/petrinaut-helmet.png b/apps/petrinaut-docs/src/assets/petrinaut-helmet.png new file mode 100644 index 00000000000..44d37070e72 Binary files /dev/null and b/apps/petrinaut-docs/src/assets/petrinaut-helmet.png differ diff --git a/apps/petrinaut-docs/src/content.config.mjs b/apps/petrinaut-docs/src/content.config.mjs new file mode 100644 index 00000000000..be3497ce343 --- /dev/null +++ b/apps/petrinaut-docs/src/content.config.mjs @@ -0,0 +1,18 @@ +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; +import { defineCollection } from "astro:content"; + +/** + * The docs collection is populated by `scripts/sync-bundle.mjs`, which copies the + * generated bundle into `src/content/docs`. This app authors no content of its + * own — see the sync script for why copying rather than loading in place. + * + * The bundle's `sidebar_order` frontmatter is read from `manifest.json` by + * `astro.config.mjs`, not from here, so the schema is Starlight's own. + */ +export const collections = { + docs: defineCollection({ + loader: docsLoader(), + schema: docsSchema(), + }), +}; diff --git a/apps/petrinaut-docs/tsconfig.json b/apps/petrinaut-docs/tsconfig.json new file mode 100644 index 00000000000..942ee7c77ec --- /dev/null +++ b/apps/petrinaut-docs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*", "astro.config.mjs"], + "exclude": ["dist"], + "compilerOptions": { + "checkJs": true + } +} diff --git a/apps/petrinaut-docs/turbo.json b/apps/petrinaut-docs/turbo.json new file mode 100644 index 00000000000..cd13dfbb0f8 --- /dev/null +++ b/apps/petrinaut-docs/turbo.json @@ -0,0 +1,30 @@ +{ + "extends": ["//"], + "tasks": { + // Copies the bundle into `src/content/`. Generating it is the generator's + // job, declared here as a dependency so Turborepo orders the two — the sync + // script used to shell out and run the generator itself, which put a + // cross-package build step outside the task graph. + "sync:bundle": { + "cache": false, + "dependsOn": ["@local/petrinaut-arch-docs#doc:architecture"] + }, + // `build`, `lint:tsc` and `dev` all need the synced content. Depending on one + // shared `sync:bundle` rather than each running it keeps them from racing on + // the same directories when Turborepo runs them concurrently. + "build": { + "cache": false, + "dependsOn": ["sync:bundle"], + "outputs": ["dist/**"] + }, + "lint:tsc": { + "cache": false, + "dependsOn": ["sync:bundle"] + }, + "dev": { + "cache": false, + "persistent": true, + "dependsOn": ["sync:bundle"] + } + } +} diff --git a/libs/@hashintel/petrinaut-core/.oxlintrc.json b/libs/@hashintel/petrinaut-core/.oxlintrc.json index 838081027d2..5a4b9641d42 100644 --- a/libs/@hashintel/petrinaut-core/.oxlintrc.json +++ b/libs/@hashintel/petrinaut-core/.oxlintrc.json @@ -144,6 +144,7 @@ "dist/**", "build/**", "coverage/**", + "benchmarks/**", "*.gen.*", "*.tsbuildinfo", ".turbo/**" diff --git a/libs/@hashintel/petrinaut-core/benchmarks/.gitignore b/libs/@hashintel/petrinaut-core/benchmarks/.gitignore new file mode 100644 index 00000000000..97c133635a6 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/.gitignore @@ -0,0 +1 @@ +.node-worker-bundle.mjs diff --git a/libs/@hashintel/petrinaut-core/benchmarks/README.md b/libs/@hashintel/petrinaut-core/benchmarks/README.md new file mode 100644 index 00000000000..b46e5196e3d --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/README.md @@ -0,0 +1,70 @@ +# Simulation benchmarks + +Measurement harnesses backing +[`../docs/simulation-performance.md`](../docs/simulation-performance.md). These +are investigation tools, not tests — nothing here asserts, and none of it runs +in CI. + +They run against the **built** package, so build first: + +```bash +yarn build +``` + +Then, from this directory: + +```bash +node monte-carlo-throughput.mjs +``` + +## What each one measures + +| Script | Question it answers | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `monte-carlo-throughput.mjs` | Baseline ns/run-frame of the current engine, per-run construction cost, and what metric aggregation adds | +| `flat-stepper-ceiling.mjs` | What the same net costs as hand-written flat typed-array code — i.e. the headroom available without leaving JavaScript | +| `coloured-enumeration.mjs` | How per-frame cost scales with token count when a coloured input arc has weight 2 (the `∏ C(n, w)` blow-up) | +| `shard-main.mjs` | Whether sharding runs across worker threads scales, **and whether it changes results** (simulator level) | +| `sharded-experiment.mjs` | The same question against the production `createMonteCarloExperiment` runtime with real worker threads | + +`flat-stepper-ceiling.mjs` needs no build — it imports nothing from the package. + +## Reading the numbers + +Results are reported per **run-frame** (one run advanced by one frame) rather +than per experiment, because runs finish at different times: a completed run +stops consuming budget, so wall clock alone conflates "faster engine" with +"runs deadlocked earlier". `runFrames` is summed from run summaries. + +Absolute figures are machine-specific. The ratios are the point. + +## The sharding checks + +Two scripts cover sharding at different levels. + +`sharded-experiment.mjs` is the one that matters: it drives the shipped +`createMonteCarloExperiment` over real worker threads at several shard counts. +It bundles the worker for Node first, because the `dist` build wraps the worker +in an inline Blob that only a browser can load. It exits non-zero if any shard +count produces different results. + +`shard-main.mjs` predates the production implementation and spawns +`shard-worker.mjs` directly against the **unmodified** `MonteCarloSimulator`. It +is kept because it isolates the simulator from the experiment runtime, so a +regression can be attributed to one or the other. + +Both check two things: + +1. **Scaling** — wall clock against shard count. +2. **Result preservation** — every frame's merged histogram is fingerprinted and + compared across shard counts. This must print `identical to 1 shard: YES` on + every row. If it ever prints `NO`, the sharding design is wrong, not the + benchmark. + +Two details carry that guarantee, and both must survive into any production +implementation: + +- Seeds derive from the **global** run index, so run _i_ gets the same seed + regardless of which shard owns it. +- Metric state is merged with the accumulator monoid's `merge`, which is + associative and commutative, so shard completion order does not matter. diff --git a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs new file mode 100644 index 00000000000..e5f3c49942b --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs @@ -0,0 +1,104 @@ +/** + * Measures how per-frame cost scales with token count for a transition whose + * coloured input arc has weight 2. + * + * `enumerateWeightedMarkingIndicesGenerator` materialises the full per-place + * combination list up front, so the expectation is O(C(n, 2)) = O(n^2) work and + * allocation per transition evaluation per frame. + */ +import { performance } from "node:perf_hooks"; + +const DIST = "../dist"; + +const { createMonteCarloSimulator } = await import(`${DIST}/index.js`); +const { compileHirArtifacts } = await import(`${DIST}/hir.js`); + +/** Net: one coloured place `pool`, one transition consuming 2 pool tokens. */ +const sdcpn = { + types: [ + { + id: "t-item", + name: "Item", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [{ elementId: "v", name: "v", type: "real" }], + }, + ], + places: [ + { + id: "pool", + name: "Pool", + colorId: "t-item", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "sink", + name: "Sink", + colorId: "t-item", + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "pair", + name: "Pair", + inputArcs: [{ placeId: "pool", weight: 2, type: "standard" }], + outputArcs: [{ placeId: "sink", weight: 1 }], + lambdaType: "predicate", + // Never fires, so token counts stay constant and we measure pure + // enablement/enumeration cost at a fixed marking size. + lambdaCode: "export default Lambda(() => false);", + transitionKernelCode: + "export default TransitionKernel(() => ({ Sink: [{ v: 1 }] }));", + x: 50, + y: 0, + }, + ], + differentialEquations: [], + parameters: [], +}; + +const artifacts = compileHirArtifacts(sdcpn).artifacts; + +process.stdout.write( + "coloured place, input arc weight 2, transition never fires\n" + + "tokens C(n,2) ns/run-frame\n", +); + +for (const tokens of [10, 25, 50, 100, 200, 400]) { + const simulator = createMonteCarloSimulator({ + sdcpn, + initialMarking: { + pool: Array.from({ length: tokens }, (_, index) => ({ v: index })), + sink: [], + }, + parameterValues: {}, + seed: 1, + dt: 0.1, + maxTime: 5, + runCount: 20, + hirArtifacts: artifacts, + metrics: [], + }); + + const start = performance.now(); + simulator.runUntilComplete(); + const ms = performance.now() - start; + + let frames = 0; + for (const summary of simulator.getSummaries()) { + frames += summary.frameNumber; + } + + const combinations = (tokens * (tokens - 1)) / 2; + process.stdout.write( + `${String(tokens).padStart(6)} ${String(combinations).padStart(7)} ` + + `${((ms / frames) * 1e6).toFixed(0).padStart(12)}\n`, + ); +} diff --git a/libs/@hashintel/petrinaut-core/benchmarks/flat-stepper-ceiling.mjs b/libs/@hashintel/petrinaut-core/benchmarks/flat-stepper-ceiling.mjs new file mode 100644 index 00000000000..08d70bb0d6d --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/flat-stepper-ceiling.mjs @@ -0,0 +1,134 @@ +/** + * Measures the achievable ceiling for the SIR Monte Carlo workload in plain JS: + * a flat, allocation-free, structure-of-arrays stepper equivalent to what a + * codegen backend (JS or WASM) would emit for the same net. + * + * Mirrors the semantics of the current engine for this net: + * - 3 uncoloured places (S, I, R), 2 transitions (infection, recovery) + * - per transition per frame: one RNG draw, exp(-lambda * timeSinceLastFiring) + * acceptance test, structural enablement on input arc weights + * - infection: S-1, I+1 (consumes 1 S + 1 I, emits 2 I) + * - recovery: I-1, R+1 + * - deadlock when no transition is structurally enabled + */ +import { performance } from "node:perf_hooks"; + +const RUNS = 4000; +const DT = 0.1; +const MAX_TIME = 60; +const MAX_FRAMES = Math.round(MAX_TIME / DT); +const INFECTION_RATE = 0.4; +const RECOVERY_RATE = 0.1; + +const S0 = 500; +const I0 = 5; + +// ---- Structure of arrays: one lane per run, no per-run objects. ------------- +const s = new Int32Array(RUNS).fill(S0); +const i = new Int32Array(RUNS).fill(I0); +const r = new Int32Array(RUNS); +// Elapsed frames since last firing, per transition per run. +const elapsed0 = new Int32Array(RUNS); +const elapsed1 = new Int32Array(RUNS); +const rng = new Uint32Array(RUNS); +const frameNumber = new Int32Array(RUNS); +const active = new Uint8Array(RUNS).fill(1); + +for (let run = 0; run < RUNS; run++) { + rng[run] = (42 + run * 2654435761) >>> 0; +} + +// mulberry32-style step, matching the shape of the engine's seeded RNG: +// one u32 state, one float out. +const start = performance.now(); + +let advancedTotal = 0; +let activeCount = RUNS; + +for (let frame = 0; frame < MAX_FRAMES && activeCount > 0; frame++) { + for (let run = 0; run < RUNS; run++) { + if (active[run] === 0) { + continue; + } + + const sv = s[run]; + const iv = i[run]; + + // Deadlock check: infection needs S>=1 && I>=1, recovery needs I>=1. + if (iv === 0) { + active[run] = 0; + activeCount--; + continue; + } + + let state = rng[run]; + let fired0 = 0; + let fired1 = 0; + + // --- transition 0: infection (S>=1, I>=1) --- + if (sv >= 1 && iv >= 1) { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const u = ((t ^ (t >>> 14)) >>> 0) / 4294967296; + const lambda = INFECTION_RATE * (elapsed0[run] * DT); + if (Math.exp(-lambda) <= u) { + fired0 = 1; + } + } + + // --- transition 1: recovery (I>=1) --- + if (iv >= 1) { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const u = ((t ^ (t >>> 14)) >>> 0) / 4294967296; + const lambda = RECOVERY_RATE * (elapsed1[run] * DT); + if (Math.exp(-lambda) <= u) { + fired1 = 1; + } + } + + rng[run] = state; + + if (fired0 === 1) { + s[run] = sv - 1; + i[run] = iv + 1; + elapsed0[run] = 0; + } else { + elapsed0[run]++; + } + + if (fired1 === 1) { + i[run] = i[run] - 1; + r[run] = r[run] + 1; + elapsed1[run] = 0; + } else { + elapsed1[run]++; + } + + frameNumber[run]++; + advancedTotal++; + + if (frameNumber[run] >= MAX_FRAMES) { + active[run] = 0; + activeCount--; + } + } +} + +const ms = performance.now() - start; +process.stdout.write( + `flat SoA stepper: ${ms.toFixed(0)} ms, ${advancedTotal} run-frames, ${( + (ms / advancedTotal) * + 1e6 + ).toFixed(0)} ns/run-frame\n`, +); + +let sumR = 0; +for (let run = 0; run < RUNS; run++) { + sumR += r[run]; +} +process.stdout.write(`mean recovered: ${(sumR / RUNS).toFixed(1)}\n`); diff --git a/libs/@hashintel/petrinaut-core/benchmarks/monte-carlo-throughput.mjs b/libs/@hashintel/petrinaut-core/benchmarks/monte-carlo-throughput.mjs new file mode 100644 index 00000000000..f8643674cd9 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/monte-carlo-throughput.mjs @@ -0,0 +1,117 @@ +/** + * Baseline throughput of the current Monte Carlo engine, and the cost that + * metric aggregation adds on top of it. + * + * Reports nanoseconds per *run-frame* (one run advanced by one frame), which is + * the unit that stays comparable as run counts and early-completion rates + * change. + */ +import { performance } from "node:perf_hooks"; + +const { + createMonteCarloSimulator, + createMonteCarloUserDefinedMetric, + createMonteCarloUserDefinedMetricConfigsFromSpecs, +} = await import("../dist/index.js"); +const { compileHirArtifacts } = await import("../dist/hir.js"); +const { sirModel } = await import("../dist/examples/index.js"); + +const RUNS = 4000; +const DT = 0.1; +const MAX_TIME = 60; + +const sdcpn = sirModel.petriNetDefinition; +const artifacts = compileHirArtifacts(sdcpn).artifacts; + +const initialMarking = { + place__susceptible: 500, + place__infected: 5, + place__recovered: 0, +}; + +function build(metricSpecs) { + const metrics = createMonteCarloUserDefinedMetricConfigsFromSpecs( + metricSpecs, + sdcpn, + {}, + ).map((config) => createMonteCarloUserDefinedMetric(config)); + + return createMonteCarloSimulator({ + sdcpn, + initialMarking, + parameterValues: {}, + seed: 42, + dt: DT, + maxTime: MAX_TIME, + runCount: RUNS, + hirArtifacts: artifacts, + metrics, + }); +} + +function measure(label, metricSpecs) { + // Construction is timed separately: buildSimulation() runs once per run, so + // it is a per-run cost rather than a per-frame one. + const constructStart = performance.now(); + const simulator = build(metricSpecs); + const constructMs = performance.now() - constructStart; + + const runStart = performance.now(); + simulator.runUntilComplete(); + const runMs = performance.now() - runStart; + + let runFrames = 0; + for (const summary of simulator.getSummaries()) { + runFrames += summary.frameNumber; + } + + process.stdout.write( + `${label.padEnd(30)} construct ${constructMs.toFixed(0).padStart(5)} ms ` + + `(${((constructMs / RUNS) * 1000).toFixed(0).padStart(4)} µs/run) ` + + `simulate ${runMs.toFixed(0).padStart(6)} ms ` + + `${((runMs / runFrames) * 1e6).toFixed(0).padStart(5)} ns/run-frame\n`, + ); + + return runMs; +} + +const distribution = (id, label, placeId) => ({ + kind: "placeTokenCountMean", + id, + label, + placeId, + runOutput: { type: "distribution", binning: "exact" }, +}); + +process.stdout.write( + `SIR model, ${RUNS} runs, dt ${DT}, maxTime ${MAX_TIME}\n\n`, +); + +const bare = measure("engine only (no metrics)", []); +measure("+ 1 scalar metric", [ + { + kind: "placeTokenCountMean", + id: "m1", + label: "Infected", + placeId: "place__infected", + }, +]); +const hist = measure("+ 1 distribution metric", [ + distribution("m2", "Infected", "place__infected"), +]); +const three = measure("+ 3 metrics (2 distributions)", [ + distribution("a", "S", "place__susceptible"), + distribution("b", "I", "place__infected"), + { + kind: "placeTokenCountMean", + id: "c", + label: "R", + placeId: "place__recovered", + }, +]); + +process.stdout.write( + `\nmetric overhead vs engine only: ` + + `1 distribution ${(((hist - bare) / bare) * 100).toFixed(0)}%, ` + + `3 metrics ${(((three - bare) / bare) * 100).toFixed(0)}%\n`, +); diff --git a/libs/@hashintel/petrinaut-core/benchmarks/shard-main.mjs b/libs/@hashintel/petrinaut-core/benchmarks/shard-main.mjs new file mode 100644 index 00000000000..499966ca6c1 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/shard-main.mjs @@ -0,0 +1,121 @@ +/** + * Prototype: shard one experiment's runs across N worker threads and verify + * (a) wall-clock scaling, and + * (b) that merged per-frame histograms are IDENTICAL regardless of shard + * count — i.e. sharding is result-preserving. + * + * Uses the shipped MonteCarloSimulator unchanged. The only new machinery is + * global-index seed derivation plus a monoid merge of per-frame metric state. + */ +import { availableParallelism } from "node:os"; +import { performance } from "node:perf_hooks"; +import { Worker } from "node:worker_threads"; + +const WORKER = new URL("./shard-worker.mjs", import.meta.url); + +const TOTAL_RUNS = 4000; +const DT = 0.1; +const MAX_TIME = 60; +const BASE_SEED = 42; + +function runShard(runOffset, runCount) { + return new Promise((resolve, reject) => { + const worker = new Worker(WORKER, { + workerData: { + runOffset, + runCount, + baseSeed: BASE_SEED, + dt: DT, + maxTime: MAX_TIME, + }, + }); + worker.once("message", (message) => { + resolve(message); + void worker.terminate(); + }); + worker.once("error", reject); + }); +} + +/** The histogram monoid: merge bin lists by summing frequencies. */ +function mergeShards(results) { + const byFrame = []; + for (const result of results) { + for (const { frameNumber, bins } of result.partialFrames) { + byFrame[frameNumber] ??= new Map(); + const target = byFrame[frameNumber]; + for (const [value, frequency] of bins) { + target.set(value, (target.get(value) ?? 0) + frequency); + } + } + } + return byFrame; +} + +/** Canonical string for comparing merged results across shard counts. */ +function fingerprint(frames) { + return frames + .map((bins, index) => + bins === undefined + ? `${index}:-` + : `${index}:${[...bins.entries()] + .sort(([a], [b]) => a - b) + .map(([value, frequency]) => `${value}x${frequency}`) + .join(",")}`, + ) + .join("|"); +} + +async function measure(shardCount) { + const base = Math.floor(TOTAL_RUNS / shardCount); + const remainder = TOTAL_RUNS % shardCount; + + const shards = []; + let offset = 0; + for (let shard = 0; shard < shardCount; shard++) { + const count = base + (shard < remainder ? 1 : 0); + shards.push({ offset, count }); + offset += count; + } + + const start = performance.now(); + const results = await Promise.all( + shards.map((shard) => runShard(shard.offset, shard.count)), + ); + const ms = performance.now() - start; + + return { + shardCount, + ms, + runFrames: results.reduce((sum, result) => sum + result.runFrames, 0), + fingerprint: fingerprint(mergeShards(results)), + }; +} + +const cores = availableParallelism(); +process.stdout.write( + `availableParallelism = ${cores}\n` + + `${TOTAL_RUNS} runs, dt ${DT}, maxTime ${MAX_TIME}, 1 distribution metric\n` + + `wall-clock includes worker spawn + per-shard HIR compile + per-run buildSimulation\n\n`, +); + +let baselineMs = null; +let baselineFingerprint = null; + +for (const shardCount of [1, 2, 4, 8, 12]) { + // eslint-disable-next-line no-await-in-loop -- sequential so each config gets all cores + const result = await measure(shardCount); + baselineMs ??= result.ms; + baselineFingerprint ??= result.fingerprint; + + process.stdout.write( + `${String(result.shardCount).padStart(2)} shard(s) ${result.ms + .toFixed(0) + .padStart(6)} ms` + + ` speedup ${(baselineMs / result.ms).toFixed(2).padStart(5)}x` + + ` ${result.runFrames} run-frames` + + ` identical to 1 shard: ${ + result.fingerprint === baselineFingerprint ? "YES" : "NO" + }\n`, + ); +} diff --git a/libs/@hashintel/petrinaut-core/benchmarks/shard-worker.mjs b/libs/@hashintel/petrinaut-core/benchmarks/shard-worker.mjs new file mode 100644 index 00000000000..41c23b6090a --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/shard-worker.mjs @@ -0,0 +1,81 @@ +/** + * Shard worker: owns a contiguous slice of an experiment's runs and advances + * them with the *unmodified* MonteCarloSimulator, then ships back partial + * per-frame metric state for the main thread to merge. + */ +import { parentPort, workerData } from "node:worker_threads"; + +import { sirModel } from "../dist/examples/index.js"; +import { compileHirArtifacts } from "../dist/hir.js"; +import { + createMonteCarloSimulator, + createMonteCarloUserDefinedMetric, + createMonteCarloUserDefinedMetricConfigsFromSpecs, +} from "../dist/index.js"; + +const { runOffset, runCount, baseSeed, dt, maxTime } = workerData; + +const sdcpn = sirModel.petriNetDefinition; +const artifacts = compileHirArtifacts(sdcpn).artifacts; + +const metrics = createMonteCarloUserDefinedMetricConfigsFromSpecs( + [ + { + kind: "placeTokenCountMean", + id: "infected", + label: "Infected", + placeId: "place__infected", + runOutput: { type: "distribution", binning: "exact" }, + }, + ], + sdcpn, + {}, +).map((config) => createMonteCarloUserDefinedMetric(config)); + +/** + * Seeds are derived from the GLOBAL run index, not the shard-local one, so + * sharding changes only *who* runs a seed — never *which* seeds run. This is + * what makes shard count invisible in the results. + * + * Mirrors `deriveRunSeed` in monte-carlo/run-state.ts. + */ +function deriveRunSeed(seed, globalRunIndex) { + return ( + Math.abs(Math.trunc(seed + (globalRunIndex + 1) * 2_654_435_761)) % + 2_147_483_648 + ); +} + +const simulator = createMonteCarloSimulator({ + sdcpn, + initialMarking: { + place__susceptible: 500, + place__infected: 5, + place__recovered: 0, + }, + parameterValues: {}, + seed: baseSeed, + dt, + maxTime, + runCount, + hirArtifacts: artifacts, + metrics, + runs: Array.from({ length: runCount }, (_, localIndex) => ({ + seed: deriveRunSeed(baseSeed, runOffset + localIndex), + })), +}); + +simulator.runUntilComplete(); + +let runFrames = 0; +for (const summary of simulator.getSummaries()) { + runFrames += summary.frameNumber; +} + +parentPort.postMessage({ + runFrames, + partialFrames: metrics[0].frames.map((frame) => ({ + frameNumber: frame.frameNumber, + bins: frame.bins ?? [], + })), +}); diff --git a/libs/@hashintel/petrinaut-core/benchmarks/sharded-experiment-worker.mjs b/libs/@hashintel/petrinaut-core/benchmarks/sharded-experiment-worker.mjs new file mode 100644 index 00000000000..1ac61aa6e57 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/sharded-experiment-worker.mjs @@ -0,0 +1,39 @@ +/** + * Node worker entry for `sharded-experiment.mjs`. + * + * The shipped Monte Carlo worker is written against the browser worker global + * scope, so under `node:worker_threads` its `self`-style message plumbing is + * bridged to `parentPort` before importing it. + * + * The `dist` build wraps the worker in an inline Blob for the browser, which + * Node cannot import, so `sharded-experiment.mjs` bundles the worker source for + * Node first and this file loads that bundle. + */ +import { parentPort } from "node:worker_threads"; + +const listeners = new Set(); + +globalThis.self = { + postMessage: (message) => parentPort.postMessage(message), + addEventListener: (type, listener) => { + if (type === "message") { + listeners.add(listener); + } + }, + removeEventListener: (type, listener) => { + if (type === "message") { + listeners.delete(listener); + } + }, +}; +globalThis.postMessage = globalThis.self.postMessage; +globalThis.addEventListener = globalThis.self.addEventListener; +globalThis.removeEventListener = globalThis.self.removeEventListener; + +parentPort.on("message", (data) => { + for (const listener of listeners) { + listener({ data }); + } +}); + +await import("./.node-worker-bundle.mjs"); diff --git a/libs/@hashintel/petrinaut-core/benchmarks/sharded-experiment.mjs b/libs/@hashintel/petrinaut-core/benchmarks/sharded-experiment.mjs new file mode 100644 index 00000000000..f2e0c0581f0 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benchmarks/sharded-experiment.mjs @@ -0,0 +1,151 @@ +/** + * End-to-end check of the production sharded experiment runtime. + * + * Runs the same experiment at several shard counts through + * `createMonteCarloExperiment` with real worker threads, and verifies that the + * merged metric timeline is identical every time. This is the guarantee the + * whole design rests on: shard count changes how fast an experiment finishes, + * never what it reports. + * + * Requires a build (`yarn build`) because the worker is loaded from `dist`. + */ +import { execFileSync } from "node:child_process"; +import { availableParallelism } from "node:os"; +import { performance } from "node:perf_hooks"; +import { Worker } from "node:worker_threads"; + +const { createMonteCarloExperiment } = await import("../dist/index.js"); +const { compileHirArtifacts } = await import("../dist/hir.js"); +const { sirModel } = await import("../dist/examples/index.js"); + +// `dist` wraps the worker in an inline Blob for the browser, which Node cannot +// import, so bundle the worker source for Node before spawning any threads. +execFileSync( + "npx", + [ + "esbuild", + "src/simulation/monte-carlo/worker/monte-carlo.worker.ts", + "--bundle", + "--format=esm", + "--platform=node", + "--outfile=benchmarks/.node-worker-bundle.mjs", + "--log-level=warning", + ], + { cwd: new URL("..", import.meta.url), stdio: "inherit" }, +); + +const TOTAL_RUNS = 2000; +const DT = 0.1; +const MAX_TIME = 60; + +const sdcpn = sirModel.petriNetDefinition; +const artifacts = compileHirArtifacts(sdcpn).artifacts; + +const workerUrl = new URL("./sharded-experiment-worker.mjs", import.meta.url); + +/** + * Adapts a Node `Worker` to the `WorkerLike` shape the transport expects. + * + * In the browser this is just `new Worker(...)`; Node's event API differs + * enough to need a thin shim. + */ +function createWorker() { + const worker = new Worker(workerUrl); + + return { + postMessage: (message) => worker.postMessage(message), + addEventListener: (_type, listener) => { + worker.on("message", (data) => listener({ data })); + }, + terminate: () => void worker.terminate(), + }; +} + +function runExperiment(shardCount) { + return new Promise((resolve, reject) => { + const start = performance.now(); + + createMonteCarloExperiment({ + createWorker, + shardCount, + sdcpn, + initialMarking: { + place__susceptible: 500, + place__infected: 5, + place__recovered: 0, + }, + parameterValues: {}, + seed: 42, + dt: DT, + maxTime: MAX_TIME, + hirArtifacts: artifacts, + runCount: TOTAL_RUNS, + metricSpecs: [ + { + kind: "placeTokenCountMean", + id: "infected", + label: "Infected", + placeId: "place__infected", + runOutput: { type: "distribution", binning: "exact" }, + }, + { + kind: "placeTokenCountMean", + id: "recovered-mean", + label: "Recovered (mean)", + placeId: "place__recovered", + }, + ], + }).then((experiment) => { + experiment.events.subscribe((event) => { + if (event.type === "complete") { + const ms = performance.now() - start; + const { frames } = experiment.metrics.get(); + experiment.dispose(); + resolve({ shardCount, ms, frames }); + } else if (event.type === "error") { + experiment.dispose(); + reject(new Error(event.message)); + } + }); + experiment.start(); + }, reject); + }); +} + +/** Canonical form of the merged timeline, for exact comparison. */ +function fingerprint(frames) { + return frames + .map((frame) => + frame.outputType === "distribution" + ? `${frame.metricId}@${frame.frameNumber}:${frame.bins + .map(([value, frequency]) => `${value}x${frequency}`) + .join(",")}` + : `${frame.metricId}@${frame.frameNumber}:${frame.frameValue}/${frame.runSampleCount}`, + ) + .join("|"); +} + +process.stdout.write( + `availableParallelism = ${availableParallelism()}\n` + + `${TOTAL_RUNS} runs, dt ${DT}, maxTime ${MAX_TIME}, 1 distribution + 1 scalar metric\n\n`, +); + +let baseline = null; + +for (const shardCount of [1, 2, 4, 8]) { + // eslint-disable-next-line no-await-in-loop -- sequential so each run gets all cores + const result = await runExperiment(shardCount); + baseline ??= result; + + const identical = fingerprint(result.frames) === fingerprint(baseline.frames); + process.stdout.write( + `${String(shardCount).padStart(2)} shard(s) ${result.ms.toFixed(0).padStart(6)} ms` + + ` speedup ${(baseline.ms / result.ms).toFixed(2).padStart(5)}x` + + ` ${String(result.frames.length).padStart(5)} merged frames` + + ` identical to 1 shard: ${identical ? "YES" : "NO"}\n`, + ); + + if (!identical) { + process.exitCode = 1; + } +} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/architecture.css b/libs/@hashintel/petrinaut-core/docs/architecture/architecture.css deleted file mode 100644 index c9b6ef590c5..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/architecture.css +++ /dev/null @@ -1,478 +0,0 @@ -/* Shared styles for the simulation architecture docs (architecture*.html). */ - -:root { - /* One colour per module, used consistently across all pages. */ - --engine: #2563eb; /* blue — engine (stepping, frames) */ - --engine-bg: #eff6ff; - --authoring: #9333ea; /* purple — compilation of user code */ - --authoring-bg: #faf5ff; - --worker: #059669; /* green — worker + transport protocol */ - --worker-bg: #ecfdf5; - --mc: #ea580c; /* orange — monte carlo / experiments */ - --mc-bg: #fff7ed; - --memory: #dc2626; /* red — buffers, where bytes live */ - --memory-bg: #fef2f2; - --ui: #0891b2; /* cyan — React / UI consumers */ - --ui-bg: #ecfeff; - --ink: #111111; - --muted: #555555; - --line: #d9d9d9; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0 auto; - padding: 2rem 1.5rem 5rem; - max-width: 1000px; - background: #ffffff; - color: var(--ink); - font: - 15px/1.55 -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Helvetica, - Arial, - sans-serif; -} - -h1 { - font-size: 1.7rem; - margin: 0.5rem 0 0.25rem; -} - -h2 { - font-size: 1.25rem; - margin: 2.5rem 0 0.75rem; - padding-bottom: 0.3rem; - border-bottom: 1px solid var(--line); -} - -h3 { - font-size: 1.02rem; - margin: 1.75rem 0 0.5rem; -} - -p { - max-width: 76ch; -} - -.subtitle { - color: var(--muted); - margin-top: 0; -} - -code, -pre { - font-family: - ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.86em; - background: #f5f5f5; - border-radius: 4px; -} - -code { - padding: 0.1em 0.35em; -} - -pre { - padding: 0.8rem 1rem; - overflow-x: auto; - border: 1px solid #ececec; -} - -pre code { - padding: 0; - background: none; -} - -a { - color: var(--engine); -} - -/* ---- Top navigation ------------------------------------------------- */ - -nav.docs { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - padding: 0.6rem 0; - margin-bottom: 1rem; - border-bottom: 2px solid var(--ink); - font-size: 0.9rem; -} - -nav.docs a { - text-decoration: none; - color: var(--ink); - padding: 0.25rem 0.7rem; - border: 1px solid var(--line); - border-radius: 999px; -} - -nav.docs a.current { - color: #fff; - background: var(--ink); - border-color: var(--ink); -} - -nav.docs a.tag-engine.current { - background: var(--engine); - border-color: var(--engine); -} -nav.docs a.tag-authoring.current { - background: var(--authoring); - border-color: var(--authoring); -} -nav.docs a.tag-worker.current { - background: var(--worker); - border-color: var(--worker); -} -nav.docs a.tag-mc.current { - background: var(--mc); - border-color: var(--mc); -} - -/* ---- Legend chips ---------------------------------------------------- */ - -.legend { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 1rem; - margin: 0.75rem 0 1.25rem; - font-size: 0.82rem; -} - -.legend span::before { - content: ""; - display: inline-block; - width: 0.75em; - height: 0.75em; - margin-right: 0.4em; - border-radius: 3px; - vertical-align: -0.05em; -} - -.legend .l-engine::before { - background: var(--engine); -} -.legend .l-authoring::before { - background: var(--authoring); -} -.legend .l-worker::before { - background: var(--worker); -} -.legend .l-mc::before { - background: var(--mc); -} -.legend .l-memory::before { - background: var(--memory); -} -.legend .l-ui::before { - background: var(--ui); -} - -/* ---- Tables ----------------------------------------------------------- */ - -table { - border-collapse: collapse; - width: 100%; - margin: 0.75rem 0 1.25rem; - font-size: 0.88rem; -} - -th, -td { - border: 1px solid var(--line); - padding: 0.4rem 0.6rem; - text-align: left; - vertical-align: top; -} - -th { - background: #f6f6f6; - font-weight: 600; -} - -/* ---- Generic diagram primitives -------------------------------------- */ - -.box { - border: 1.5px solid var(--ink); - border-radius: 8px; - padding: 0.55rem 0.8rem; - background: #fff; -} - -.box strong { - display: block; - font-size: 0.9rem; -} - -.box small { - color: var(--muted); - font-size: 0.78rem; - line-height: 1.35; - display: block; -} - -.box.b-engine { - border-color: var(--engine); - background: var(--engine-bg); -} -.box.b-engine strong { - color: var(--engine); -} -.box.b-authoring { - border-color: var(--authoring); - background: var(--authoring-bg); -} -.box.b-authoring strong { - color: var(--authoring); -} -.box.b-worker { - border-color: var(--worker); - background: var(--worker-bg); -} -.box.b-worker strong { - color: var(--worker); -} -.box.b-mc { - border-color: var(--mc); - background: var(--mc-bg); -} -.box.b-mc strong { - color: var(--mc); -} -.box.b-memory { - border-color: var(--memory); - background: var(--memory-bg); -} -.box.b-memory strong { - color: var(--memory); -} -.box.b-ui { - border-color: var(--ui); - background: var(--ui-bg); -} -.box.b-ui strong { - color: var(--ui); -} - -/* Vertical pipeline: boxes separated by a downward arrow. */ -.pipeline { - display: flex; - flex-direction: column; - gap: 0; - margin: 1rem 0 1.5rem; - max-width: 620px; -} - -.pipeline > .step { - position: relative; -} - -.pipeline > .step + .step { - margin-top: 1.6rem; -} - -.pipeline > .step + .step::before { - content: "▼"; - position: absolute; - top: -1.45rem; - left: 2rem; - font-size: 0.8rem; - color: var(--muted); -} - -.pipeline > .step + .step[data-arrow]::after { - content: attr(data-arrow); - position: absolute; - top: -1.5rem; - left: 3.4rem; - font-size: 0.75rem; - color: var(--muted); - font-family: ui-monospace, Menlo, monospace; -} - -/* Horizontal flow: boxes with a → between them. */ -.hflow { - display: flex; - flex-wrap: wrap; - align-items: stretch; - gap: 0.35rem; - margin: 1rem 0 1.5rem; -} - -.hflow > .box { - flex: 1 1 0; - min-width: 118px; -} - -.hflow > .arr { - align-self: center; - color: var(--muted); - font-size: 1rem; - padding: 0 0.1rem; -} - -/* Two/three column thread lanes. */ -.lanes { - display: grid; - gap: 1rem; - margin: 1rem 0 1.5rem; -} - -.lanes.two { - grid-template-columns: 1fr 1fr; -} -.lanes.three { - grid-template-columns: 1fr 1fr 1fr; -} - -.lane { - border: 1px dashed #bbb; - border-radius: 10px; - padding: 0.75rem; - background: #fcfcfc; -} - -.lane > h4 { - margin: 0 0 0.6rem; - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--muted); -} - -.lane .box { - margin-bottom: 0.6rem; -} - -.lane .box:last-child { - margin-bottom: 0; -} - -/* ---- Byte-layout bars -------------------------------------------------- */ - -.bytes { - display: flex; - width: 100%; - margin: 0.75rem 0 0.25rem; - border: 1.5px solid var(--ink); - border-radius: 6px; - overflow: hidden; - font-family: ui-monospace, Menlo, monospace; - font-size: 0.72rem; - text-align: center; -} - -.bytes .seg { - padding: 0.45rem 0.2rem; - border-right: 1.5px solid var(--ink); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.bytes .seg:last-child { - border-right: none; -} - -.bytes .seg em { - display: block; - font-style: normal; - color: var(--muted); - font-size: 0.66rem; -} - -.seg.s-header { - background: #ededed; -} -.seg.s-u32 { - background: #d3e5ff; -} -.seg.s-f64 { - background: #fecaca; -} -.seg.s-u8 { - background: #d1fae5; -} -.seg.s-tokens { - background: #fee2b8; -} - -.bytes-caption { - font-size: 0.78rem; - color: var(--muted); - margin: 0.15rem 0 1.25rem; -} - -/* ---- Callouts ----------------------------------------------------------- */ - -.note { - border-left: 4px solid #eab308; - background: #fefce8; - padding: 0.6rem 0.9rem; - margin: 1rem 0; - border-radius: 0 6px 6px 0; - font-size: 0.9rem; -} - -.note.refactor { - border-left-color: var(--memory); - background: var(--memory-bg); -} - -.note strong { - display: inline; -} - -/* ---- At-a-glance card ---------------------------------------------------- */ - -.glance { - display: grid; - grid-template-columns: max-content 1fr; - gap: 0.3rem 1.2rem; - border: 1px solid var(--line); - border-radius: 8px; - padding: 0.85rem 1rem; - margin: 1rem 0 1.5rem; - font-size: 0.88rem; - background: #fafafa; -} - -.glance dt { - font-weight: 600; - color: var(--muted); -} - -.glance dd { - margin: 0; -} - -/* ---- SVG diagrams --------------------------------------------------------- */ - -figure { - margin: 1rem 0 1.5rem; -} - -figure svg { - max-width: 100%; - height: auto; -} - -figcaption { - font-size: 0.78rem; - color: var(--muted); - margin-top: 0.25rem; -} - -@media (max-width: 720px) { - .lanes.two, - .lanes.three { - grid-template-columns: 1fr; - } -} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html b/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html deleted file mode 100644 index ae048fa3b0a..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html +++ /dev/null @@ -1,288 +0,0 @@ - - - - - - Simulation Architecture — Compilation (Authoring) - - - - - -

Compilation (Authoring)

-

- The HIR pipeline turns user-authored TypeScript into checked, - source-spanned buffer programs. Scenario code retains a separate - same-realm compiler hardened against accidents, not attackers. -

- -
-
Inputs
-
- Code strings stored on the SDCPN document (lambda, kernel, dynamics, - metric, scenario) -
-
Outputs
-
- Versioned HIR artifacts for runtime code; InitialMarking - for scenarios -
-
Errors
-
- Thrown at compile time with the offending item’s ID - (SDCPNItemError) → surfaced as protocol - error messages / result objects -
-
Runs on
-
- LSP worker (HIR compilation) · simulation workers (buffer programs) · - main thread (scenarios and timeline metric evaluation) -
-
- -

The compilation pipeline (module-style code)

-

- Dynamics, lambdas, kernels, and metrics all compile through the HIR. The - first three are authored as export default Wrapper(fn) - modules; metrics are authored as function bodies. -

- -
-
- User source - export default Lambda((input, parameters) => …) - (TypeScript allowed) -
- -
- Lower to HIR - Parse the supported TypeScript subset with exact source spans -
- -
- Check + analyze - Validate against places, token layouts, parameters, and output - shapes -
- -
- Emit artifact - Generate buffer-native JS with offsets and strides baked in -
- -
- Instantiate in runtime - Validate v4 fingerprint, then bind parameters, pool, and ABI - views -
-
- -
- The - LSP virtual files - (lsp/generate-virtual-files.ts) are the type-level twin of - this pipeline: they generate the Color_*, - Parameters, Dynamics, - TransitionKernel… declarations that Monaco checks against, so - editor diagnostics and runtime artifacts are produced from the same HIR - checks and emitters. -
- -

What gets compiled, where it runs

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SurfaceAuthored asCompiled byExecuted inSignature at runtime
Transition lambdamodule, Lambda(fn)compileHirArtifacts in the LSP workerWorker, per enablement check - (f64, u64, u8, placeBases, indices) → boolean | rate -
Transition kernelmodule, TransitionKernel(fn)compileHirArtifacts in the LSP workerWorker, per firing - (views, placeBases, indices, outputViews, sink) → void -
Dynamicsmodule, Dynamics(fn) - compileHirArtifacts (colours with a real element) - Worker, per step per place - (placeBytes, tokenCount) → Float64Array -
Metricplain function body (no module)compileHirArtifacts in the LSP workerMain thread (timeline) · MC worker (experiments) - (f64, u64, u8, placeCounts, placeOffsets) → finite number - — NaN/∞ throw -
Scenarioper-place expressions or one code-mode bodyauthoring/scenario/compile-scenario.tsMain thread, before createSimulationInitialMarking (+ scenario parameter values)
Place visualizermodule, Visualization(fn) returning JSX - Host UI package (@hashintel/petrinaut, - ui/lib/compile-visualizer.ts — the one inherently - React-based surface, since visualizers author JSX) - Main thread render({tokens, parameters}) → ReactElement
- -

Scenario compilation → InitialMarking

- -
-
-

per_place mode

-
- Uncoloured place → expression string - Evaluated with parameters and scenario in - scope; result rounded and clamped >= 0 (a token count). -
-
- Coloured place → row arrays - Rows in colour element order; each row coerced through - coerceTokenRecord (typed defaults for missing columns, - extra columns throw). -
-
-
-

code mode

-
- One function body - Returns { PlaceName: count | TokenRecord[] } keyed by - place name (per_place uses place IDs — a known - asymmetry). Unknown names are silently dropped. -
-
- Result: InitialMarking (JSON) - Keyed by place ID. Fed to createSimulation, packed to - binary in buildSimulation. -
-
-
- -

Metric compilation and evaluation

-
-
- compileHirArtifacts - Lower, typecheck, and emit a buffer-native metric program. -
- -
- createHirMetricEvaluator - Bind referenced place ordinals and the per-run string pool. -
- -
- Per-frame evaluation - Quick sim: evaluated on the main thread against stored frames. - Experiments: evaluated inside the MC worker per run per frame - (metrics/specs.ts). -
-
- -

Scenario sandboxing (authoring/sandbox.ts)

-
    -
  • - Shadowed globals: scenario expressions declare - var window, document, globalThis, self, fetch, XMLHttpRequest, - importScripts, Function, setTimeout, setInterval, queueMicrotask - — name lookups resolve to undefined. -
  • -
  • - runSandboxed(action): blocks the - constructor-chain escape (({}).constructor.constructor → - Function) for the duration of the call, since shadowing - only stops identifier lookup. -
  • -
  • - Same realm by design: user code executes with - new Function in the worker/main realm. Treat this as - robustness hardening (typos, accidental API use), not isolation. -
  • -
- -

Distributions (deferred sampling)

-

- HIR-emitted kernels construct small runtime values for - Distribution.Gaussian / Uniform / Lognormal. Kernels defer - sampling through the ABI sink; the engine samples each value once per - token with the seeded RNG (sample-distribution.ts), so - chained .map(fn) transforms share a single draw and runs stay - reproducible. When stochasticity is disabled, the runtime isn’t injected - and plain values are required. -

- -
- Compatibility seam. HIR artifacts contain a version and - fingerprint of their sanitized compilation input. Any schema, code, or - extension change requires recompilation before the engine will run them. -
- - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md b/libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md deleted file mode 100644 index 7fca3d59c08..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md +++ /dev/null @@ -1,26 +0,0 @@ -# Petrinaut dependency diagrams - -These diagrams are generated from Petrinaut's TypeScript imports with -[dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and laid -out with [D2](https://d2lang.com/) using ELK. - -## Project modules - -[Open the project dependency diagram](./petrinaut-dependencies.svg). - -![Dependencies between Petrinaut modules](./petrinaut-dependencies.svg) - -## Compilation and execution path - -[Open the focused compilation dependency diagram](./petrinaut-compilation-dependencies.svg). - -![Dependencies around the LSP, HIR compilation, and simulation runtimes](./petrinaut-compilation-dependencies.svg) - -Regenerate both diagrams from the repository root: - -```sh -yarn workspace @hashintel/petrinaut-core doc:dependency-diagram -``` - -The checked-in `.d2` files are the readable graph sources; the `.svg` files are -generated views. Test and Storybook files are intentionally excluded. diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/engine.html b/libs/@hashintel/petrinaut-core/docs/architecture/engine.html deleted file mode 100644 index d135b93ba2c..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/engine.html +++ /dev/null @@ -1,770 +0,0 @@ - - - - - - Simulation Architecture — Engine - - - - - -

Engine

-

- engine/ + frames/ — builds an SDCPN into a - runnable SimulationInstance and advances it one immutable - binary frame at a time. -

- -
-
Entry points
-
- buildSimulation(input) · - computeNextFrame(simulation) -
-
Inputs
-
- SDCPN snapshot, InitialMarking, parameter values, seed, dt, - maxTime (number | null; null = no limit), extensions -
-
Outputs
-
- New EngineFrame per step + - completionReason: "maxTime" | "deadlock" | null -
-
Events
-
- None — pure return values; the worker turns - them into protocol messages -
-
Runs on
-
- Worker thread (quick sim). The same engine functions are reused by - Monte Carlo with different frame - storage -
-
- -

Lifecycle

- -
-
- buildSimulation(input) - Flattens component instances, compiles user code (authoring): lambdas only where available, kernels only for coloured outputs, - dynamics only for colours with at least one real element. - Packs the initial marking into frame 0 (coercing token values by - element type). -
-
- SimulationInstance - frames: EngineFrame[] (history, frame 0 included) · - frameLayout · compiledTransitions · - differentialEquationFns · parameterValues · - dt · maxTime · currentTime · currentFrameNumber · rngState -
-
- computeNextFrame(simulation) - Returns a new instance value with one more frame appended — - the previous frames are never mutated. -
-
- -

One step, in order

- -
-
- 1 · maxTime? - If maxTime !== null and - currentTime >= maxTime, return - "maxTime" without computing. -
- -
- 2 · Dynamics - Per place with a differential equation: slice its token region, - decode tokens, call the user Dynamics fn, apply Euler - x += dx·dt to real slots (discrete derivatives - are forced to 0). Builds an intermediate frame. -
- -
- 3 · Transitions - executeTransitions: for each transition — enablement - (arc weights, inhibitor/read arcs), token-combination enumeration, - lambda (predicate / stochastic rate vs seeded RNG), kernel for - coloured outputs. Removals compact the buffer; additions - append. -
- -
- 4 · Timers - If nothing fired, advance every transition’s - timeSinceLastFiringMs by dt. -
- -
- 5 · Complete? - maxTime reached, or deadlock (nothing fired and no - transition is enabled). -
-
- -
- Every stage that changes state allocates a new - ArrayBuffer via createEngineFrame() — dynamics, - token removal, and token insertion each rebuild the frame. Immutability - makes history trivially correct at the cost of allocation churn per step. -
- -

EngineFrame — the binary format

-

- One ArrayBuffer, read through section-typed views. The frame - stores no IDs and no time — decoding requires the - EngineFrameLayout (place/transition order, per-place - strideBytes and TokenSlotLayout) derived from - the SDCPN, and time travels as payload metadata. -

-

- Source of truth: - libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts - — createEngineFrame() computes every offset below and is the - only frame constructor; readEngineFrame() recreates the same - views for reading; the header constants live in the - HeaderOffset enum. -

- -
- - - - - - - - - - Memory map — example instance - - - byte offsets on the left · one typed-array view per section on the - right - - - - - - header — 64 B fixed - - magic "PFRM" · version · P · T · - - - section offsets · byteLength - - DataView, little-endian - - - - place token counts - u32 × P = 8 B - Uint32Array(buf, 64, P) - - tokens currently in each place - - - - - place value offsets - u32 × P = 8 B - Uint32Array(buf, 72, P) - - each place’s token run, relative to the token region start - - - - - transition elapsed - f64 × T = 16 B - Float64Array(buf, 80, T) - - ms since each transition last fired - - - - - transition firing counts - u32 × T = 8 B - Uint32Array(buf, 96, T) - - cumulative firings since frame 0 - - - - - - fired flags - - — u8 × T = 2 B - - - Uint8Array(buf, 104, T) - - - - padding — 6 B - - alignTo(…, 8) so f64 views stay aligned - - - - - - tokenBytes / tokenF64 views (b112…) - - - packed token structs — place-major, token-major; - - - this all-real example is pure f64 (booleans = u8) - - - - - - slot 0 · b112 · p1 · tok0 · x - - slot 1 · b120 · p1 · tok0 · y - - slot 2 · b128 · p1 · tok0 · z - - slot 3 · b136 · p1 · tok1 · x - - slot 4 · b144 · p1 · tok1 · y - - slot 5 · b152 · p1 · tok1 · z - - slot 6 · b160 · p2 · tok0 · a - - slot 7 · b168 · p2 · tok0 · b - - - - - p1 — byteOffset 0 - - 2 tokens × stride 24 B (x,y,z — all real) - - - p2 — byteOffset 48 - - 1 token × stride 16 B (a,b) → byte 112 + 48 - - - - - 0 - 64 - 72 - 80 - 96 - 104 - 106 - 112 - 176 - - - - byteLength = 176 (recorded in the header, checked on read) - - -
- Exact offsets for a concrete instance: P = 2 places (p1 - = 2 tokens × 24-byte stride, p2 = 1 token × 16-byte stride — all - dimensions real here, so every field is one f64; a boolean - dimension would appear as a single u8 byte inside its token’s stride), - T = 2 transitions → 176-byte frame. Sections are laid - out by createEngineFrame(); sections and strides are padded - to 8-byte boundaries by alignTo(). Block heights are not to - scale. -
-
- -

Header (offsets in bytes, little-endian, via DataView)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OffsetFieldTypeValue / meaning
0magicu320x5046524d (“PFRM”) — corrupt-frame guard
4versionu162 — the current (only) format; readers assert this on decode
6headerBytesu1664
8 / 12placeCount / transitionCountu32must match the layout (checked on read)
16tokenByteLengthu32token region length in bytes
20–40section offsetsu32 × 6byte offsets of each section above
44byteLengthu32whole-frame length (checked on read)
- -

Token region

-

- Each coloured place owns a contiguous run of - count × strideBytes bytes starting at its byte offset - (uncoloured places have stride 0 and only a count), giving O(1) access to - any place via the layout. Within a token, each element sits at its - layout-computed byte offset: real/integer as - f64, boolean as one u8 byte. Value coercion (integers - rounded, booleans 0/1) lives in engine/token-values.ts; byte - placement in engine/token-layout.ts. -

- -

Who reads and writes the buffer

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathDirectionView usedNotes
createEngineFrame(layout, snapshot)write (allocate) - DataView header + section views, bulk - Uint8Array.set - - Only constructor of frames; recomputes byte offsets from counts × - strides. -
- readEngineFrame(layout, frame) → - EngineFrameView - read - Uint32Array sections + tokenBytes (u8) / - tokenF64 views - - Zero-copy; toSnapshot() copies the token region into a - fresh Uint8Array. -
Dynamics (computePlaceNextState)read + writef64 view over a fresh byte copy - Euler touches only realFieldF64Offsets; discrete bytes - copied through untouched. -
- Transition firing (compute-possible-transition, - execute-transitions, remove-tokens…) - read + write - readTokenRecord / - encodeTokenValuesToBytes + byte-range copies - - Input tokens decoded to TokenRecords for user - lambdas/kernels; kernel outputs packed into per-token - Uint8Array blocks. -
SimulationFrameReader (main thread)readSame section views over the cloned buffer - getPlaceTokens() = decoded records via the place’s - TokenSlotLayout; getTransitionState() = - timers/flags. -
- -

Token attribute typing (discrete types)

-
    -
  • - Storage is schema-driven: real and - integer elements are f64 fields; - boolean elements are single u8 bytes, placed by - computeTokenSlotLayout. -
  • -
  • - Coercion at the write boundary - (coerceTokenRecord, - encodeTokenAttributeValue): integers round, booleans become - 0/1; initial markings, scenario rows, and kernel outputs all pass - through it before writeTokenValue / - encodeTokenToBytes place the bytes. -
  • -
  • - Decode at the read boundary - (readTokenRecord): user code and the UI always see - number | boolean values. -
  • -
  • - Only transition kernels write discrete values. Dynamics - apply to real elements only — Euler integrates - realFieldF64Offsets exclusively, discrete bytes are copied - through untouched, and colours without real elements skip dynamics - compilation entirely. -
  • -
  • - Distributions (Distribution.Gaussian…) are allowed for - real/integer kernel outputs and rejected for boolean. -
  • -
- -

Token memory layout — packed structs

-

- Since FRAME_VERSION = 2, tokens are stored as schema-driven - packed structs (array-of-structs; column layout was - considered and rejected because the workload — kernels, copies, UI reads — - is token-oriented). - engine/token-layout.ts (computeTokenSlotLayout) - is the single source of truth: -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Logical typePhysical typeNotes
realf64 — 8 Bunchanged
integerf64 — 8 B, rounded on read/write - Exact only within ±2^53 (documented in the schema). - i32 rejected (silent wraparound at ±2^31); i64 rejected (bigint is - contagious into user code: fortune * 1.05 would throw). - If >2^53 is ever needed, add a separate opt-in - int64 element type instead of changing - integer. -
booleanu8 — 1 B - Not bit-packed (would break byte-granular copies for marginal - savings). -
uuid (128-bit, FE-1121)u64 × 2 — 16 B - Two little-endian lanes read via the shared - BigUint64Array view, combined to one - bigint at the boundary (~28 ns; lane-compare without - combining for equality, ~4× faster). Never routed through - number (NaN-payload hazard). Kernel outputs are - optional — omitted values auto-generate from the seeded RNG. -
string (FE-769)u64 pool reference — 8 B - The frame stores an ID into an append-only per-run string intern - pool (engine/string-pool.ts); the pool lives on - SimulationInstance, not on the frame, so frames stay - fixed-stride and byte-copyable. Equal strings share one ID; id 0 is - the pre-seeded "", so zeroed buffers decode cleanly. -
-
    -
  • - Layout rule: per colour, fields are ordered by - decreasing alignment (stable) and the stride is rounded up to 8 B — - every f64 field stays 8-aligned so hot paths use plain typed-array views - (no DataView). Places are byte-addressed: - { byteOffset, count, strideBytes }. -
  • -
  • - Access rule: token-layout.ts (readTokenRecord - / writeTokenValue / encodeTokenToBytes) is the - only code that indexes token bytes; all whole-token moves are byte-range - copies (Uint8Array.set). The raw - getPlaceTokenValues reader was removed from the public API - — raw f64 access is meaningless under mixed widths. Because the string - pool never crosses the worker boundary with the frames, each frame - payload ships an append-only newStrings delta that the - main-thread frame store accumulates and hands to the frame reader for - decoding. -
  • -
- -

Determinism

-

- seeded-rng.ts provides a pure - nextRandom(state) → [value, nextState]. The RNG state lives - on SimulationInstance.rngState and is threaded through - stochastic lambda sampling and distribution draws, so a given - (SDCPN, marking, parameters, seed, dt) always reproduces the - same frame sequence. -

- -
- Refactoring seams. - (1) Per-step allocation: each stage rebuilds the whole buffer — a - double-buffer strategy (as Monte Carlo already does) would remove most - churn. (2) 128-bit types (UUID, FE-1121) slot into the layout as - u64×2 fields (align 8, read via BigUint64Array) - — the layout machinery is ready; the value plumbing (bigint - boundary, v5 coercion, seeded generation) is not. (3) The worker keeps the - full frames[] history although only the latest frame is - needed to advance — history retention belongs to the main-thread store. -
- - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/index.html b/libs/@hashintel/petrinaut-core/docs/architecture/index.html deleted file mode 100644 index 0df5a6d6e35..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/index.html +++ /dev/null @@ -1,439 +0,0 @@ - - - - - - Petrinaut Simulation — Architecture Overview - - - - - -

Simulation Architecture — Overview

-

- How Petrinaut runs SDCPN simulations: who owns the memory, what crosses - thread boundaries, and how results are read back. Code lives in - petrinaut-core/src/simulation/ (engine, authoring, worker, - runtime, monte-carlo). These pages describe the headless core only — - consumers appear as "the host application" using the public API; the React - integration is documented separately in - @hashintel/petrinaut/ARCHITECTURE.md. Generated import maps spanning core, React, and UI modules are in the - dependency diagrams. -

- -
- Engine (stepping & frames) - Compilation (user code) - Worker & protocol - Monte Carlo - Memory / buffers - Host application -
- -

The two execution paths

-

- There are two independent ways to execute a net. - Quick Simulation runs one interactive simulation and - keeps every frame so the host can scrub. - Experiments run many Monte Carlo simulations with - bounded memory (two reusable buffers per run) and only ship - metric aggregates to the host — frame buffers never leave the worker. -

- -
-
-

Main thread — host application

-
- Run configuration - owns InitialMarking, parameter values, seed/dt/maxTime - (null = unbounded); calls createSimulation() -
-
- Playback driver - core playback module — picks the viewed frame - (getFrame(i)), drives ack/backpressure per play - mode -
-
- Experiment consumer - one MonteCarloExperiment handle per experiment, - subscribed to its stores -
-
- Rendering & metrics - read via SimulationFrameReader / metric frames -
-
-
-

Main thread — core runtime

-
- createSimulation() - runtime/simulation.ts — sanitizes SDCPN for extensions, flattens - component instances, owns lifecycle stores + events -
-
- SimulationFrameStore - runtime/frame-store.ts — retains every - SimulationFramePayload (ArrayBuffer + time) -
-
- SimulationFrameReader - frames/frame-reader.ts — typed-array views over a stored frame, - zero-copy reads -
-
- createMonteCarloExperiment() - monte-carlo/runtime/experiment.ts — status / progress / metrics - stores -
-
-
-

Worker threads

-
- simulation.worker - init → buildSimulation(); loop → - computeNextFrame(); streams frames under ack - backpressure -
-
- SimulationInstance.frames[] - full EngineFrame history — the compute source of truth -
-
- monte-carlo.worker - MonteCarloSimulator — round-robin - advanceAll(), metrics observed in-worker -
-
- 2 × ArrayBuffer per run - current / next, swapped each step; no history -
-
-
- -

Where the memory actually lives

-

- Everything the simulation computes is stored in raw - ArrayBuffers read through typed-array views. There is - no object graph of tokens — tokens are packed structs - (real/integer as f64 fields, - boolean as one u8 byte, stride rounded to 8 B), decoded to JS - objects only at the read boundary. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MemoryStructureThreadLifetime / role
SDCPN documentPlain JS objects (editor state)Main - Snapshot is structured-cloned into the worker at init; - later edits don’t affect a running simulation. -
Initial marking - JSON (InitialMarking: count or - TokenRecord[] per place) - Main (host state) - Packed into frame 0 by - buildSimulation → packInitialPlaceMarking. -
EngineFrame (quick sim) - ArrayBuffer: 64-byte header + - Uint32/Float64/Uint8 sections - + packed token structs (byte-addressed places) - Worker - Immutable snapshot per step. The worker appends every frame to - SimulationInstance.frames[] — compute source of truth. -
SimulationFramePayload{ time, frame: ArrayBuffer }Worker → Main - Structured-clone copy per frame (no transfer list). - Retained forever by the in-memory frame store for scrubbing. -
SimulationFrameReader - Uint32Array/Float64Array views over the - stored buffer - Main - Zero-copy views. getPlaceTokens() materializes typed - TokenRecord objects via the place’s - TokenSlotLayout. -
Monte Carlo run buffers - 2 × ArrayBuffer per run (current / next), token-value - region has capacity + growth policy - MC worker - Swapped each step. Never sent to the main thread — only progress - counters and metric frames (small JSON) cross the boundary. -
Compiled user codeVersioned HIR buffer programs; scenarios remain plain JSWorkers (HIR programs) · Main (scenarios, timeline metrics) - Produced by authoring at init time. -
- -
- Refactoring seam — triple retention. Each quick-sim frame - currently exists three times: in the worker history - (SimulationInstance.frames[]), as a structured-clone copy in - the main-thread frame store, and transiently in the - postMessage queue. Frames are never transferred - (Transferable) and the worker history is only read by the - stepping loop’s latest entry. Retention policy is deliberately isolated - behind runtime/frame-store.ts so this can change without - touching consumers. -
- -

Quick Simulation — end-to-end data flow

- -
-
- SDCPN snapshot + InitialMarking + parameters + - seed/dt/maxTime - Assembled by the host (main thread). Scenario compilation may have - produced the marking and parameter overrides first. -
-
- simulation.worker — buildSimulation() - Sanitize by extensions → compile lambdas / kernels / dynamics → pack - frame 0. Replies frame(0) + ready. -
-
- computeNextFrame() × batch - Dynamics (Euler) → transitions (enablement, lambda, kernel) → new - immutable EngineFrame appended; time += dt. -
-
- SimulationFrameStore (main) - Appends every payload; publishes {count, latest} through - the frames store. -
-
- SimulationFrameReader - compileSimulationFrameReader(sdcpn) specialises the - layout once; readers are created per frame on demand (latest() - / getFrame(i)). -
-
- Host reads - The playback driver picks frameIndex; the host renders - from getTransitionState(), getPlaceTokens(), - and getPlaceTokenCount(); metric code reads the raw - packed frame through a HIR evaluator. -
-
- -

Protocols at a glance

-

- Both workers speak a small typed message protocol over - postMessage, wrapped in a - SimulationTransport (queues messages until the worker boots). - Full payloads and sequence diagrams are on the - Worker and - Monte Carlo pages. -

- - - - - - - - - - - - - - - - - - - - - - - - -
Host → WorkerWorker → HostFlow control
- Quick sim
worker/messages.ts -
- init · start · pause · stop · setBackpressure · ack - - ready · frame · frames · paused · complete · error - - Ack-based backpressure: worker computes at most - maxFramesAhead past the last acked frame. -
- Monte Carlo
monte-carlo/worker/messages.ts -
init · start · cancel - ready · progress · metricFrames · complete · cancelled · - error - - Fire-and-forget batches (advanceAll() × batchSize per - loop tick); cancellation checked between batches. -
- -

Module map

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AreaPathOwnsDetails
Engineengine/, frames/Build + stepping, EngineFrame binary format, frame readersengine.html
Compilationauthoring/ - User code → JS functions (lambda, kernel, dynamics, metric, - scenario), sandbox hardening - authoring.html
- Worker + runtime - worker/, runtime/ - Transport protocol, backpressure, lifecycle stores, frame retention - worker.html
Monte Carlomonte-carlo/ - Batch runs, bounded buffers, metric pipeline, experiment handle - monte-carlo.html
- -

Design invariants worth keeping

-
    -
  • - Frames are opaque outside the engine. - EngineFrame is “not a public API or stable storage format”; - it can only be decoded with the SDCPN-derived - EngineFrameLayout. All consumers go through - SimulationFrameReader. -
  • -
  • - Simulation time lives outside the frame. The run - controller owns frame number and time; payloads carry time as metadata. -
  • -
  • - Runs are snapshots. A simulation never re-reads the - live document; extension sanitization + component flattening happen once - at init on both sides of the boundary (they must agree on the place - list, or the layout check throws). -
  • -
  • - Determinism. One seeded RNG - (seeded-rng.ts) threaded through lambda sampling and - distribution draws; same seed → same run. -
  • -
- - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/monte-carlo.html b/libs/@hashintel/petrinaut-core/docs/architecture/monte-carlo.html deleted file mode 100644 index 31a10a8eeba..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/monte-carlo.html +++ /dev/null @@ -1,376 +0,0 @@ - - - - - - Simulation Architecture — Monte Carlo (Experiments) - - - - - -

Monte Carlo (Experiments)

-

- monte-carlo/ — runs many independent simulations of the same - net with bounded memory, aggregates metrics inside the worker, - and ships only small JSON metric frames to the UI. Frame buffers never - cross the thread boundary. -

- -
-
Core
-
- createMonteCarloSimulator(config) → - MonteCarloSimulator (synchronous, thread-agnostic) -
-
Worker
-
- monte-carlo.worker.ts + worker/messages.ts -
-
Host handle
-
- createMonteCarloExperiment() → stores: - status · progress · metrics + events; - start/cancel/dispose -
-
Inputs
-
- SDCPN, marking, parameters, seed, dt, - maxTime (required), runCount, metric specs -
-
Outputs
-
- MonteCarloWorkerProgress (run counters) + - MonteCarloUserDefinedMetricFrame[] -
-
- -

Layers

- -
-
- Host application - One experiment record per run of - createMonteCarloExperiment(); subscribes to the handle’s - status/progress/metrics stores and renders the metric frames. -
-
- Experiment handle (main) - runtime/experiment.ts — worker mode - (createWorker/transport) or - local mode (runs the simulator on the calling thread, used by - tests/embedding). -
-
- monte-carlo.worker - Builds the simulator + compiles metric specs, then loops: - advanceAll() × batchSize (default 4), post - progress + pending metricFrames, yield, - repeat. cancel is honoured between batches. -
-
- MonteCarloSimulator - Owns N × MonteCarloRun; deterministic round-robin - advanceAll() advances every active run one frame per - call, so long runs don’t starve short ones. -
-
- Engine functions, reused - Same enablement/lambda/kernel/dynamics code as quick sim (transition-effect.ts - adapts them to the MC buffers). -
-
- -

Run model

- - - - - - - - - - - - - - - - - - - - - - - - - -
Per runMeaning
- seed, parameterValues, - initialMarking - - Defaults derived from the experiment config; overridable per run - (runs[]), e.g. seed = base seed + index. -
status - ready → running → complete | error — errors are per - run, other runs continue. -
- frameNumber · currentTime · rngState · completionReason - - Progress; a run completes on its own deadlock or the shared - maxTime. -
- tokenByteCount · tokenByteCapacity · reallocations - - Buffer telemetry, exposed in MonteCarloRunSummary. -
- -

Memory — two buffers per run, swapped every step

- -
-
-

step k

-
- currentFrame (read) state at frame k -
-
- nextFrame (write) - dynamics + transitions write frame k+1 here -
-
-
-

after the step

-
- swap pointers - current ⇄ next — no allocation, no history. If the - next token count doesn’t fit, the target buffer alone reallocates: - nextCapacityBytes = max(requiredBytes, capacity × 2, 64). -
-
- metrics observe frame k+1 - then the data may be overwritten — readers are only valid during - observeFrame. -
-
-
- -

- The buffer layout (frame-buffer.ts) is a leaner sibling of - the quick-sim EngineFrame: same sections, - no 64-byte header, one extra - transitionElapsedFrames section, and a token region with - spare capacity: -

- -
-
- place countsu32 × P -
-
- place offsetsu32 × P -
-
- transition elapsedf64 × T -
-
- elapsed framesf64 × T -
-
- firing countsu32 × T -
-
- fired flagsu8 × T -
-
- token structsbytes, used ≤ capacity -
-
- sparecapacity -
-
-

- All views - (Uint32Array/Float64Array/Uint8Array) - are created once per buffer over a single ArrayBuffer; IDs - resolve to dense indices through the shared EngineFrameLayout - (layout.ts). -

- -

Metrics pipeline — computed where the data is

- -
-
- Specs - MonteCarloMetricSpec: expression (metric - code body) · placeTokenCountMean · - transitionFiringCount — serializable, sent in - init. -
- -
- Compile before worker start - Expression metrics carry HIR artifacts; built-in specs create a - measure(run) function directly. -
- -
- Sample per frame - observeFrame(ctx) visits every run’s current frame as a - SimulationFrameReader (forEachRunFrame); - sampleRuns filters active/completed/all. -
- -
- Aggregate - Across runs: mean·sum·min·max·last → scalar, or keep the - run axis and bin it → distribution (exact or bin - width). Optionally aggregate over time. -
- -
- Metric frames - MonteCarloUserDefinedMetricFrame — scalar - (value/frameValue/timeValue) or distribution (bins: [value, frequency][]) per frame. Small JSON. -
-
- -

Protocol (monte-carlo/worker/messages.ts)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DirectionTypePayload
Host → Workerinit - sdcpn, extensions?, initialMarking, parameterValues, seed, dt, - maxTime, runCount, batchSize?, metricSpecs? -
start
cancel— (checked between batches)
Worker → Hostready
progress - MonteCarloWorkerProgress = advance counters (advancedRuns, completedRuns, erroredRuns, activeRuns, - allFinished) + frameNumber, time, runCount -
metricFramesPending MonteCarloUserDefinedMetricFrame[] batch
complete / cancelledFinal (or last-known) progress
errormessage, itemId
- -
- Contrast with quick sim: no ack/backpressure — the worker - free-runs to completion in small batches; the only upstream control is - cancel. And the payloads are aggregates, not frames: the UI - never holds Monte Carlo simulation state. -
- -

What the host receives

-
    -
  • - The handle exposes status / progress / - metrics stores (frames + - latestByMetricId); completion disposes the worker while the - accumulated metric frames remain available for display. -
  • -
  • - Scalar metric frames carry value / - frameValue / timeValue — directly plottable as - a per-frame series or a time-aggregated number. -
  • -
  • - Distribution metric frames keep the run axis as - bins: [value, frequency][] — hosts can paint a bins × - frames heatmap and derive run aggregations (mean, median, percentiles) - from the bins, all without ever holding simulation state. -
  • -
  • - How Petrinaut’s React UI renders these is described in - @hashintel/petrinaut/ARCHITECTURE.md. -
  • -
- -
- Refactoring seams. - (1) Runs are round-robin on one worker thread; the run-state model was - designed so runs can shard across multiple workers later. (2) User code - still receives decoded TokenRecord objects per firing — the - README’s planned “IR compilation” would let lambdas/kernels operate - directly on the numeric buffers. (3) Buffer growth is a naive ×2 doubling; - arc-weight static analysis could size buffers up front and eliminate - reallocations. -
- - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 deleted file mode 100644 index 44b4b9019e1..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by scripts/generate-dependency-diagrams.mjs. Do not edit. - -direction: right - -# modules - -"core / HIR artifacts": {class: core; tooltip: "1 source file"} -"core / HIR compiler": {class: core; tooltip: "7 source files"} -"core / HIR compiler API": {class: core; tooltip: "1 source file"} -"core / HIR emitters": {class: core; tooltip: "2 source files"} -"core / HIR runtime API": {class: core; tooltip: "2 source files"} -"core / LSP client & transport": {class: core; tooltip: "4 source files"} -"core / LSP services": {class: core; tooltip: "10 source files"} -"core / LSP worker": {class: core; tooltip: "3 source files"} -"core / Monte Carlo runtime": {class: core; tooltip: "22 source files"} -"core / simulation assembly": {class: core; tooltip: "1 source file"} -"core / simulation controller": {class: core; tooltip: "3 source files"} -"core / simulation frames & metrics": {class: core; tooltip: "4 source files"} -"core / simulation worker": {class: core; tooltip: "5 source files"} -"React / experiments provider": {class: react; tooltip: "2 source files"} -"React / LSP provider": {class: react; tooltip: "3 source files"} -"React / simulation provider": {class: react; tooltip: "4 source files"} -"UI / experiment authoring": {class: ui; tooltip: "6 source files"} -"UI / metric authoring": {class: ui; tooltip: "7 source files"} -"UI / scenario authoring": {class: ui; tooltip: "1 source file"} -"UI / simulation timeline": {class: ui; tooltip: "16 source files"} - -# dependencies - -"core / HIR compiler API" -> "core / HIR artifacts": {tooltip: "1 file-level dependency"} -"core / HIR compiler API" -> "core / HIR compiler": {tooltip: "7 file-level dependencies"} -"core / HIR compiler API" -> "core / HIR emitters": {tooltip: "2 file-level dependencies"} -"core / HIR compiler API" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / HIR compiler" -> "core / HIR artifacts": {tooltip: "1 file-level dependency"} -"core / HIR compiler" -> "core / HIR emitters": {tooltip: "2 file-level dependencies"} -"core / HIR compiler" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / HIR emitters" -> "core / HIR compiler": {tooltip: "5 file-level dependencies"} -"core / HIR runtime API" -> "core / HIR artifacts": {tooltip: "1 file-level dependency"} -"core / LSP client & transport" -> "core / HIR compiler API": {tooltip: "1 file-level dependency"} -"core / LSP client & transport" -> "core / LSP worker": {tooltip: "4 file-level dependencies"} -"core / LSP services" -> "core / HIR compiler API": {tooltip: "4 file-level dependencies"} -"core / LSP worker" -> "core / HIR compiler API": {tooltip: "1 file-level dependency"} -"core / LSP worker" -> "core / LSP services": {tooltip: "7 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / HIR runtime API": {tooltip: "4 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / simulation assembly": {tooltip: "1 file-level dependency"} -"core / Monte Carlo runtime" -> "core / simulation controller": {tooltip: "1 file-level dependency"} -"core / Monte Carlo runtime" -> "core / simulation frames & metrics": {tooltip: "4 file-level dependencies"} -"core / simulation assembly" -> "core / HIR runtime API": {tooltip: "2 file-level dependencies"} -"core / simulation assembly" -> "core / simulation frames & metrics": {tooltip: "1 file-level dependency"} -"core / simulation controller" -> "core / simulation frames & metrics": {tooltip: "1 file-level dependency"} -"core / simulation controller" -> "core / simulation worker": {tooltip: "3 file-level dependencies"} -"core / simulation frames & metrics" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / simulation worker" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / simulation worker" -> "core / simulation assembly": {tooltip: "1 file-level dependency"} -"core / simulation worker" -> "core / simulation frames & metrics": {tooltip: "1 file-level dependency"} -"React / experiments provider" -> "core / Monte Carlo runtime": {tooltip: "1 file-level dependency"} -"React / experiments provider" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"React / LSP provider" -> "core / LSP client & transport": {tooltip: "2 file-level dependencies"} -"React / simulation provider" -> "core / simulation worker": {tooltip: "1 file-level dependency"} -"React / simulation provider" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"UI / experiment authoring" -> "React / experiments provider": {tooltip: "5 file-level dependencies"} -"UI / experiment authoring" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"UI / experiment authoring" -> "UI / metric authoring": {tooltip: "2 file-level dependencies"} -"UI / metric authoring" -> "React / LSP provider": {tooltip: "4 file-level dependencies"} -"UI / simulation timeline" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"UI / simulation timeline" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"UI / simulation timeline" -> "React / simulation provider": {tooltip: "1 file-level dependency"} -"UI / simulation timeline" -> "UI / metric authoring": {tooltip: "2 file-level dependencies"} - -# styling - -classes: { - core: {style.fill: "#dcecff"; style.stroke: "#3676b8"} - react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} - ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} -} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg deleted file mode 100644 index 3773273135c..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg +++ /dev/null @@ -1,358 +0,0 @@ -core / HIR artifacts1 source filecore / HIR compiler7 source filescore / HIR compiler API1 source filecore / HIR emitters2 source filescore / HIR runtime API2 source filescore / LSP client & transport4 source filescore / LSP services10 source filescore / LSP worker3 source filescore / Monte Carlo runtime22 source filescore / simulation assembly1 source filecore / simulation controller3 source filescore / simulation frames & metrics4 source filescore / simulation worker5 source filesReact / experiments provider2 source filesReact / LSP provider3 source filesReact / simulation provider4 source filesUI / experiment authoring6 source filesUI / metric authoring7 source filesUI / scenario authoring1 source fileUI / simulation timeline16 source files 1 source file - - - - - - - - - - - - -7 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -4 source files - - - - - - - - - - - - -10 source files - - - - - - - - - - - - -3 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - -3 source files - - - - - - - - - - - - -4 source files - - - - - - - - - - - - -5 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -3 source files - - - - - - - - - - - - -4 source files - - - - - - - - - - - - -6 source files - - - - - - - - - - - - -7 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - -16 source files - - - - - - - - - - - - - - - - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 deleted file mode 100644 index 117dbf97a8a..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 +++ /dev/null @@ -1,118 +0,0 @@ -# Generated by scripts/generate-dependency-diagrams.mjs. Do not edit. - -direction: right - -# modules - -"core / AI tools": {class: core; tooltip: "1 source file"} -"core / editing & document state": {class: core; tooltip: "15 source files"} -"core / examples": {class: core; tooltip: "6 source files"} -"core / HIR compiler & runtime": {class: core; tooltip: "13 source files"} -"core / LSP": {class: core; tooltip: "17 source files"} -"core / model & persistence": {class: core; tooltip: "22 source files"} -"core / Monte Carlo runtime": {class: core; tooltip: "22 source files"} -"core / shared model API": {class: core; tooltip: "19 source files"} -"core / simulation engine": {class: core; tooltip: "29 source files"} -"core / simulation runtime & workers": {class: core; tooltip: "8 source files"} -"Petrinaut / public API": {class: ui; tooltip: "2 source files"} -"React / editor state": {class: react; tooltip: "40 source files"} -"React / LSP": {class: react; tooltip: "2 source files"} -"React / playback & actual mode": {class: react; tooltip: "5 source files"} -"React / simulation & experiments": {class: react; tooltip: "5 source files"} -"UI / canvas": {class: ui; tooltip: "22 source files"} -"UI / development tools": {class: ui; tooltip: "6 source files"} -"UI / editor": {class: ui; tooltip: "115 source files"} -"UI / shared components & infrastructure": {class: ui; tooltip: "53 source files"} -"UI / shared views": {class: ui; tooltip: "1 source file"} - -# dependencies - -"core / AI tools" -> "core / examples": {tooltip: "1 file-level dependency"} -"core / AI tools" -> "core / shared model API": {tooltip: "6 file-level dependencies"} -"core / editing & document state" -> "core / model & persistence": {tooltip: "1 file-level dependency"} -"core / editing & document state" -> "core / shared model API": {tooltip: "22 file-level dependencies"} -"core / examples" -> "core / shared model API": {tooltip: "8 file-level dependencies"} -"core / HIR compiler & runtime" -> "core / shared model API": {tooltip: "7 file-level dependencies"} -"core / HIR compiler & runtime" -> "core / simulation engine": {tooltip: "2 file-level dependencies"} -"core / LSP" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / LSP" -> "core / HIR compiler & runtime": {tooltip: "6 file-level dependencies"} -"core / LSP" -> "core / shared model API": {tooltip: "18 file-level dependencies"} -"core / LSP" -> "core / simulation engine": {tooltip: "1 file-level dependency"} -"core / model & persistence" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / model & persistence" -> "core / shared model API": {tooltip: "12 file-level dependencies"} -"core / model & persistence" -> "core / simulation engine": {tooltip: "3 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / Monte Carlo runtime" -> "core / HIR compiler & runtime": {tooltip: "4 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / shared model API": {tooltip: "14 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / simulation engine": {tooltip: "24 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / simulation runtime & workers": {tooltip: "1 file-level dependency"} -"core / shared model API" -> "core / AI tools": {tooltip: "2 file-level dependencies"} -"core / shared model API" -> "core / editing & document state": {tooltip: "15 file-level dependencies"} -"core / shared model API" -> "core / HIR compiler & runtime": {tooltip: "1 file-level dependency"} -"core / shared model API" -> "core / LSP": {tooltip: "3 file-level dependencies"} -"core / shared model API" -> "core / model & persistence": {tooltip: "13 file-level dependencies"} -"core / shared model API" -> "core / simulation engine": {tooltip: "12 file-level dependencies"} -"core / simulation engine" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / simulation engine" -> "core / HIR compiler & runtime": {tooltip: "7 file-level dependencies"} -"core / simulation engine" -> "core / Monte Carlo runtime": {tooltip: "2 file-level dependencies"} -"core / simulation engine" -> "core / shared model API": {tooltip: "27 file-level dependencies"} -"core / simulation engine" -> "core / simulation runtime & workers": {tooltip: "2 file-level dependencies"} -"core / simulation runtime & workers" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / simulation runtime & workers" -> "core / HIR compiler & runtime": {tooltip: "1 file-level dependency"} -"core / simulation runtime & workers" -> "core / shared model API": {tooltip: "10 file-level dependencies"} -"core / simulation runtime & workers" -> "core / simulation engine": {tooltip: "10 file-level dependencies"} -"Petrinaut / public API" -> "core / shared model API": {tooltip: "1 file-level dependency"} -"Petrinaut / public API" -> "React / editor state": {tooltip: "2 file-level dependencies"} -"Petrinaut / public API" -> "UI / shared components & infrastructure": {tooltip: "4 file-level dependencies"} -"React / editor state" -> "core / shared model API": {tooltip: "23 file-level dependencies"} -"React / editor state" -> "React / LSP": {tooltip: "2 file-level dependencies"} -"React / editor state" -> "React / playback & actual mode": {tooltip: "6 file-level dependencies"} -"React / editor state" -> "React / simulation & experiments": {tooltip: "6 file-level dependencies"} -"React / LSP" -> "core / LSP": {tooltip: "2 file-level dependencies"} -"React / LSP" -> "core / shared model API": {tooltip: "2 file-level dependencies"} -"React / LSP" -> "React / editor state": {tooltip: "3 file-level dependencies"} -"React / playback & actual mode" -> "core / shared model API": {tooltip: "7 file-level dependencies"} -"React / playback & actual mode" -> "React / editor state": {tooltip: "5 file-level dependencies"} -"React / playback & actual mode" -> "React / simulation & experiments": {tooltip: "3 file-level dependencies"} -"React / simulation & experiments" -> "core / Monte Carlo runtime": {tooltip: "1 file-level dependency"} -"React / simulation & experiments" -> "core / shared model API": {tooltip: "5 file-level dependencies"} -"React / simulation & experiments" -> "core / simulation runtime & workers": {tooltip: "1 file-level dependency"} -"React / simulation & experiments" -> "React / editor state": {tooltip: "11 file-level dependencies"} -"React / simulation & experiments" -> "React / LSP": {tooltip: "2 file-level dependencies"} -"UI / canvas" -> "core / shared model API": {tooltip: "5 file-level dependencies"} -"UI / canvas" -> "React / editor state": {tooltip: "30 file-level dependencies"} -"UI / canvas" -> "React / playback & actual mode": {tooltip: "3 file-level dependencies"} -"UI / canvas" -> "React / simulation & experiments": {tooltip: "3 file-level dependencies"} -"UI / canvas" -> "UI / shared components & infrastructure": {tooltip: "13 file-level dependencies"} -"UI / canvas" -> "UI / shared views": {tooltip: "1 file-level dependency"} -"UI / development tools" -> "core / shared model API": {tooltip: "6 file-level dependencies"} -"UI / development tools" -> "UI / shared components & infrastructure": {tooltip: "3 file-level dependencies"} -"UI / editor" -> "core / examples": {tooltip: "2 file-level dependencies"} -"UI / editor" -> "core / HIR compiler & runtime": {tooltip: "1 file-level dependency"} -"UI / editor" -> "core / shared model API": {tooltip: "63 file-level dependencies"} -"UI / editor" -> "React / editor state": {tooltip: "139 file-level dependencies"} -"UI / editor" -> "React / LSP": {tooltip: "13 file-level dependencies"} -"UI / editor" -> "React / playback & actual mode": {tooltip: "11 file-level dependencies"} -"UI / editor" -> "React / simulation & experiments": {tooltip: "16 file-level dependencies"} -"UI / editor" -> "UI / canvas": {tooltip: "2 file-level dependencies"} -"UI / editor" -> "UI / shared components & infrastructure": {tooltip: "147 file-level dependencies"} -"UI / editor" -> "UI / shared views": {tooltip: "1 file-level dependency"} -"UI / shared components & infrastructure" -> "core / shared model API": {tooltip: "15 file-level dependencies"} -"UI / shared components & infrastructure" -> "React / editor state": {tooltip: "6 file-level dependencies"} -"UI / shared components & infrastructure" -> "React / LSP": {tooltip: "4 file-level dependencies"} -"UI / shared components & infrastructure" -> "UI / canvas": {tooltip: "1 file-level dependency"} -"UI / shared components & infrastructure" -> "UI / editor": {tooltip: "14 file-level dependencies"} -"UI / shared views" -> "core / shared model API": {tooltip: "2 file-level dependencies"} -"UI / shared views" -> "React / editor state": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "React / playback & actual mode": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "React / simulation & experiments": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "UI / editor": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "UI / shared components & infrastructure": {tooltip: "1 file-level dependency"} - -# styling - -classes: { - core: {style.fill: "#dcecff"; style.stroke: "#3676b8"} - react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} - ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} -} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg deleted file mode 100644 index 3b1203b4487..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg +++ /dev/null @@ -1,358 +0,0 @@ -core / AI tools1 source filecore / editing & document state15 source filescore / examples6 source filescore / HIR compiler & runtime13 source filescore / LSP17 source filescore / model & persistence22 source filescore / Monte Carlo runtime22 source filescore / shared model API19 source filescore / simulation engine29 source filescore / simulation runtime & workers8 source filesPetrinaut / public API2 source filesReact / editor state40 source filesReact / LSP2 source filesReact / playback & actual mode5 source filesReact / simulation & experiments5 source filesUI / canvas22 source filesUI / development tools6 source filesUI / editor115 source filesUI / shared components & infrastructure53 source filesUI / shared views1 source file 1 source file - - - - - - - - - - - - -15 source files - - - - - - - - - - - - -6 source files - - - - - - - - - - - - -13 source files - - - - - - - - - - - - -17 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -19 source files - - - - - - - - - - - - -29 source files - - - - - - - - - - - - -8 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -40 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -5 source files - - - - - - - - - - - - -5 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -6 source files - - - - - - - - - - - - -115 source files - - - - - - - - - - - - -53 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - - - - - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/worker.html b/libs/@hashintel/petrinaut-core/docs/architecture/worker.html deleted file mode 100644 index 0a898518bc3..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/worker.html +++ /dev/null @@ -1,752 +0,0 @@ - - - - - - Simulation Architecture — Worker & Protocol - - - - - -

Worker & Protocol

-

- worker/ (protocol + worker entrypoint) and - runtime/ - (main-thread lifecycle, transport, frame retention). Everything between - the host application and the engine. -

- -
-
Protocol
-
- worker/messages.ts — discriminated unions over - postMessage -
-
Transport
-
- runtime/transport.ts — wraps a Worker factory; - queues messages until the worker boots -
-
Host handle
-
- createSimulation()Simulation (stores: - status, frames; events; - run/pause/reset/ack/getFrame/dispose) -
-
Flow control
-
- Ack-based backpressure — the consumer’s pace bounds worker - memory/compute -
-
- -

Message protocol

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Host → Worker (ToWorkerMessage) -
TypePayloadEffect in the worker
init - sdcpn, extensions?, initialMarking, parameterValues, seed, dt, - maxTime (number | null), maxFramesAhead?, batchSize? - - Runs buildSimulation() (compiles user code, packs frame - 0), replies frame(0) then ready. Resets - lastAckedFrame = -1. -
start - Starts the async compute loop (no-op if running; refuses from - complete/error). -
pauseStops the loop, keeps all state, replies paused.
stopDiscards the SimulationInstance entirely.
setBackpressuremaxFramesAhead?, batchSize?Live-updates loop tuning.
ackframeNumber - lastAckedFrame = max(lastAckedFrame, n) — unblocks - computation. -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Worker → Host (ToMainMessage) -
TypePayloadEffect on the main thread
readyinitialFrameCount - Resolves the createSimulation promise; status → - Ready. -
frame / frames - SimulationFramePayload = - { time, frame: ArrayBuffer } (single / batch) - - Appended to the frame store; frames store publishes - {count, latest: reader}. -
pausedframeNumberStatus → Paused.
completereason: "deadlock" | "maxTime", frameNumberStatus → Complete; emitted on the event stream.
errormessage, itemId (offending SDCPN item if known) - Status → Error; rejects init if still initializing. -
- -

Sequence — one quick simulation

- -
- - - - - - - - - - - Host (playback driver) - - - - createSimulation (main) - - - - simulation.worker - - - - - - - - - - createSimulation(config) - - - - init {sdcpn, initialMarking, seed, dt…} - - - buildSimulation() - - - compile + frame 0 - - - - - frame {t:0, ArrayBuffer²} - - ready {initialFrameCount:1} - - Promise resolves · status "Ready" - - - - simulation.run() - - start - - - - - loop blocked: lastAckedFrame = -1 - - - → nothing computes until first ack - - - - - - - loop — while frames remain to compute - - - - - - ack(frameNumber) — playback mode decides when - - - - - - computeNextFrame() - - - × batchSize while - - - n - acked < ahead - - - - - frames [{time, ArrayBuffer²}, …] - - frames store → {count, latest reader} - - - - complete {reason: deadlock | maxTime} - - status "Complete" + event emitted - -
- ² = the frame ArrayBuffer is - structured-clone copied - across the boundary (no transfer list); the worker keeps its own copy in - SimulationInstance.frames[]. -
-
- -

Backpressure — the ack contract

- -
// worker compute loop (simplified from simulation.worker.ts)
-while (isRunning) {
-  if (lastAckedFrame < 0 || currentFrame - lastAckedFrame >= maxFramesAhead) {
-    await delay(10); continue;          // wait for the consumer
-  }
-  batch = compute up to batchSize frames (stop on complete/error)
-  post "frame" | "frames"
-  await delay(0);                        // let pause/stop/ack messages in
-}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Play mode (core playback module)maxFramesAheadbatchSizeAck behaviour
viewOnlyn/a - No core backpressure profile — the React playback provider pauses - the worker and never acks, so nothing new is computed. -
computeBuffer4010 - Acks when playback is within ~0.5 s of the last computed frame — - keeps a small rolling buffer ahead of the playhead. -
computeMax10000500Acks every arrival → compute as fast as possible.
Worker defaults10001000Used when init omits the settings.
- -

Lifecycle

- -
- - - - - - - - - Initializing - - Ready - - Running - - Paused - - Complete - - Error - - - ready msg - - run() - - pause() - - run() - - deadlock / maxTime - - error msg - - - reset(): stop + clear store → Ready (from any state) - - -
- Main-thread SimulationState. The worker mirrors a smaller - internal status (ready / running / complete / error); - complete and error are terminal for the worker — - reset() sends stop and requires a fresh - init to run again. -
-
- -

- Main-thread runtime responsibilities (createSimulation) -

-
    -
  • - Snapshot preparation: sanitizes the SDCPN for the - enabled extensions and flattens component instances so the - main-thread frame layout matches what the worker produces - (place-count mismatch throws on read). -
  • -
  • - Transport: - createWorkerTransport(createWorker) queues outbound - messages until the worker resolves; test code can inject a pre-built - SimulationTransport instead. -
  • -
  • - Retention: - createInMemorySimulationFrameStore(sdcpn) - compiles the reader factory once and appends every payload — it keeps - all frames so playback can scrub. Alternative stores - (latest-only, sliding window, persisted) can be swapped in without - changing consumers. -
  • -
  • - Publication: status and - frames readable stores + events stream; hosts - subscribe with store.subscribe(listener). -
  • -
- -
- Refactoring seams. - (1) Frames could be posted with a transfer list to halve copies — - but not as-is: computeNextFrame() re-reads the latest stored - frame to compute the next one, so transferring (detaching) the posted - buffer first requires stepping from a retained working copy (e.g. - latest-only retention or a double buffer). (2) reset() keeps - the main-thread status Ready but the worker has discarded its - simulation — a subsequent run() sends start to a - worker with nothing to run; a full re-init is what actually happens in the - UI flow. (3) The 10 ms poll while waiting for acks is a busy-wait; a - promise-per-ack would be exact. -
- - diff --git a/libs/@hashintel/petrinaut-core/docs/simulation-performance.md b/libs/@hashintel/petrinaut-core/docs/simulation-performance.md new file mode 100644 index 00000000000..84fba39dbfc --- /dev/null +++ b/libs/@hashintel/petrinaut-core/docs/simulation-performance.md @@ -0,0 +1,686 @@ +# Simulation performance: threads, WASM, and GPU + +Status: §3 (worker sharding) is implemented. §2 and §4 measurements still stand +and are unaddressed. §5–§9 remain proposals. + +Goal: make **Experiments** (Monte Carlo batches) as fast as possible, with +per-seed parallelism across threads/workers, and decide whether WASM (browser), +native (server), and WebGPU are worth their cost. + +Everything labelled _measured_ below comes from harnesses in +[`../benchmarks/`](../benchmarks/README.md), run on a 10-core Apple Silicon +laptop, Node 25.6, against the built `dist` of this package. + +--- + +## TL;DR + +1. **The engine is ~36× slower than equivalent flat JavaScript.** The gap is + architectural (allocation, copying, string-keyed lookup, per-run + recompilation), not "JavaScript is slow". WASM addresses the _wrong_ 3% of + the problem until that is fixed. +2. **There is a quadratic blow-up on coloured input arcs with weight ≥ 2** that + dwarfs everything else: 13.4 ms _per run-frame_ at 400 tokens in a place. + That is ~6.7 hours for a 1000-run × 1800-frame experiment. Fixing it is a + contained change and should happen before anything on this list. +3. **Per-seed worker sharding needs no `SharedArrayBuffer` and is + result-preserving — now implemented** (§3.3): ~4× on 8 shards (10-core + machine), byte-identical output at every shard count. The metric accumulators + were already monoids (`empty`/`merge`), so this was designed for. +4. **Optional per-place token capacity is the keystone** (§5). Beyond the + modelling feature, it converts frames from growable to fixed-size, which is + the precondition for SoA layout, a WASM linear-memory ABI, a computable + state-space bound, and any GPU path. +5. **WASM is worth doing, but as a second codegen backend behind whole-loop + codegen, not as a rewrite.** Expect ~1.5–3× over _good_ JS, not over + today's engine. +6. **WebGPU: discrete transitions are possible but only for a restricted + subset, and WGSL has no `f64`** — so a GPU path is a numerically-different + fork, not an acceleration of the existing one. Offloading _only_ ODEs is + architecturally worse than doing nothing (per-frame round-trip). + +Done: **§3 worker sharding**. +Remaining, in order: **§4 hot-path fixes → §5 capacities → §6 whole-loop +codegen → §7 WASM/native → §8 GPU (spike only)**. + +§4 item 1 (the quadratic enumeration blow-up) is the single highest-value change +left and is independent of everything else. + +--- + +## 1. What runs today + +Two engines share `engine/` compilation but differ in stepping: + +| Path | Used by | Frame storage | +| ------------------------------ | ------------------------------- | -------------------------------------------- | +| `engine/compute-next-frame.ts` | interactive single-run playback | immutable; appends every frame to `frames[]` | +| `monte-carlo/advance-run.ts` | **Experiments** | two reusable buffers, swapped per step | + +Experiments are the target, so everything below concerns the Monte Carlo path. +One experiment now runs several Web Workers (§3.3), each owning a slice of the +runs and running its own `MonteCarloSimulator`, which advances its runs +round-robin one frame at a time (`advanceAll()`) and streams per-frame metric +state to the main thread for merging. The §2 measurements below are per worker +and unaffected by that fan-out. + +User code (dynamics, lambdas, kernels, expression metrics) is compiled by the +HIR pipeline in the LSP worker into buffer-ABI JS programs +(`hir/emit-buffer-js.ts`), instantiated via `new Function`, and reads packed +token bytes directly. That part is already well designed — it is the _engine +around it_ that is expensive. + +--- + +## 2. Where the time actually goes (measured) + +### 2.1 Baseline throughput + +SIR example, 500 S + 5 I, 3 uncoloured places, 2 transitions, `dt` 0.1, +`maxTime` 60, 4000 runs (2.4 M run-frames), single thread: + +| Configuration | ns / run-frame | +| --------------------------------------------- | -------------- | +| Current engine, no metrics | **1 143** | +| Current engine, 1 scalar metric | 1 385 | +| Current engine, 1 distribution metric | 2 223 | +| Current engine, 3 metrics (2 distributions) | 3 798 | +| Hand-written flat SoA stepper, same semantics | **31** | + +The last row is the headline. `benchmarks/flat-stepper-ceiling.mjs` implements +the same net — same RNG shape, same `exp(-λ·elapsed) ≤ u` acceptance test, same +deadlock rule — as typed arrays with no allocation, no string lookup, and no +per-frame copying. It produces the same mean recovered count and runs **~36× +faster**. + +Caveat, stated plainly: that stepper is _specialised to one net_. A generic +engine cannot reach 31 ns. But a **code-generating** engine can get close, and +this codebase already generates code (§6). Treat 36× as the ceiling and +5–15× as the realistic target for a generic flat rewrite. + +### 2.2 Self-time profile + +`--cpu-prof` over 2.4 M run-frames, simulation portion only: + +| Cost centre | Share | Why | +| ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `computeTransitionEffect` (+ closures) | **29%** | allocates an object array per transition per frame (`inputPlaces.map(spread)`), then three more arrays via `.every`/`.filter`×2 | +| `copyMonteCarloFrameBuffer` | **20%** | full frame memcpy every step — _even with zero dynamics_ | +| `advanceRun` + `advanceAll` | 14% | scheduling, `Set`/`Map` churn per step | +| `nextRandom` | 9% | one draw per transition per frame | +| token add/remove/merge | 7% | `Object.entries`, `new Set`, `Record` | +| `enumerateWeightedMarkingIndicesGenerator` | 4% | see §2.4 | +| `getPlaceIndex` | 3% | `Map` lookup **in the innermost loop** | +| GC | 2% | consequence of the above | + +Note what is _absent_: the compiled user code barely registers. The buffer-ABI +programs are fast. The engine wrapping them is not. + +Three of these are pure waste rather than trade-offs: + +- **The frame copy.** `writeFrameAfterDynamics` copies current → next + unconditionally, then applies dynamics. With no dynamics-enabled places + (the SIR case, and many others) the copy is the only thing that happens. +- **String-keyed place lookup.** `frameLayout.placeIndexById.get(placeId)` runs + per place per transition per frame. Indices are known at build time. +- **`Record` for removals/additions.** Every firing allocates + objects keyed by string IDs, immediately iterated with `Object.entries`. + +### 2.3 Per-run construction + +`createRunState` calls `buildSimulation()` **once per run**, and +`buildSimulation` calls `instantiateHirBuffer{Lambda,Kernel,Dynamics}`, each of +which is a `new Function`. + +| Runs | Construction | Per run | +| ----- | ------------ | ------- | +| 100 | 23 ms | 229 µs | +| 1 000 | 95 ms | 95 µs | +| 4 000 | 319 ms | 80 µs | + +For a 1000-run experiment on a 5-transition net that is ~10 000 `new Function` +calls, ~1000 duplicate `frameLayout` maps, and 1000 distinct function identities +at every shared call site in the engine — which also defeats V8 inlining. The +compiled programs are **pure and stateless**; only the scratch buffers +(`placeBases`, `indices`, `kernelStaging`) are per-run, and those are small. +One compiled `SimulationDefinition` should be shared by all runs in a shard, +with per-run state reduced to buffers + RNG + counters. + +This also explains part of why sharding scales sub-linearly (§3): every shard +pays its own construction cost. + +### 2.4 The quadratic blow-up (most important finding) + +`enumerateWeightedMarkingIndicesGenerator` is a generator, but it is only lazy +over the _Cartesian product across arcs_. Per arc it eagerly materialises the +full combination list: + +```ts +const perPlaceCombos = places.map((p) => indexCombinations(p.count, p.weight)); +``` + +`indexCombinations(n, k)` backtracks and pushes **every** k-combination into an +array before a single one is yielded. So a transition with a coloured input arc +of weight `w` over a place holding `n` tokens allocates `C(n, w)` arrays on +_every evaluation, every frame_ — and `computeTransitionEffect` `return`s on the +first accepted combination, so nearly all of it is discarded. + +Measured, one coloured place, one weight-2 input arc, transition never fires: + +| Tokens in place | `C(n,2)` | ns / run-frame | +| --------------- | -------- | -------------- | +| 10 | 45 | 17 892 | +| 25 | 300 | 58 276 | +| 50 | 1 225 | 214 988 | +| 100 | 4 950 | 870 518 | +| 200 | 19 900 | 3 318 793 | +| 400 | 79 800 | **13 406 833** | + +Clean quadratic, ~170 ns per enumerated combination. At 400 tokens that is +**13.4 ms for one run-frame**; a 1000-run × 1800-frame experiment would take +~6.7 hours. Weight 3 makes it cubic. + +The general bound for one transition is `∏ᵢ C(nᵢ, wᵢ)` over its coloured +non-inhibitor input arcs. + +**Fix** (contained, no architecture change): iterate combinations by index +without materialising them — an odometer over per-arc combination ranks, with +`combinationCount = C(n, w)` computed arithmetically and the _k_-th combination +unranked on demand. Cost becomes proportional to combinations actually +_examined_, which for a firing transition is often 1. This is worth doing +independently of everything else in this document. + +### 2.5 Metric aggregation + +`createMonteCarloMetricHistogramAccumulator().add` does `new Map(state)` per +sample — an O(bins) copy per run per frame per metric. With 4000 runs that is +4000 map clones per frame. Measured cost at 4000 runs: **+94%** for one +distribution metric, **+232%** for three metrics. + +Worse, that cost is not constant per unit of work — it **grows with run count**, +because bin count grows with the number of runs sampled: + +| Runs | Engine ns/run-frame | With 1 distribution metric | Metric cost | +| ----- | ------------------- | -------------------------- | ----------- | +| 125 | 1 140 | 1 565 | 425 | +| 500 | 893 | 1 722 | 828 | +| 1 000 | 979 | 1 745 | 766 | +| 4 000 | 1 101 | 2 139 | **1 038** | + +The engine term is flat (~900–1150 ns, within noise); the metric term rises +2.4× across a 32× increase in runs. So doubling an experiment's run count more +than doubles its distribution-metric time — exactly the wrong scaling property +for a tool whose answer to "I need better statistics" is "run more seeds". + +The accumulators are already monoids, which is exactly right for sharding +(§3); they just need mutable-in-place `add` on a private state object, plus a +reused typed-array bin table when binning is fixed-width. Also, +`forEachRunFrame` allocates a fresh `SimulationFrameReader` **per run per frame +per metric** (`createMonteCarloFrameReader`) — that should be one reusable +cursor rebound to each run. + +--- + +## 3. Per-seed parallelism (the headline ask) + +### 3.1 Why this is the easy win + +Runs are fully independent: separate seed, separate RNG state, separate frame +buffers, no shared mutable state. The only cross-run interaction is metric +aggregation, and `MonteCarloMetricMonoid` already exposes `empty`/`merge`. + +So the design is: **shard runs across N workers; each shard runs the existing +simulator over its slice; the main thread merges per-frame metric states.** + +Two invariants make shard count invisible to results: + +1. **Seeds derive from the global run index**, not the shard-local one — so run + _i_ gets the same seed no matter which shard owns it. `deriveRunSeed` already + takes a run index; shards must pass the global one. +2. **Merge is associative and commutative** over the per-frame accumulator + state, so shard completion order does not matter. + +Notably, this needs **no `SharedArrayBuffer`**. The app is not cross-origin +isolated (no COOP/COEP headers), so `SharedArrayBuffer` — and therefore WASM +threads — is unavailable today. Message-passing shards with transferable +`ArrayBuffer`s sidestep that entirely. Worth remembering when §7 tempts you +toward WASM threading. + +### 3.2 Prototype and results + +[`../benchmarks/shard-main.mjs`](../benchmarks/README.md) shards 4000 SIR runs +across Node `worker_threads`, using the **shipped, unmodified** +`MonteCarloSimulator`, with one distribution metric merged on the main thread: + +| Shards | Wall clock | Speedup | Merged result identical to 1 shard | +| ------ | ---------- | ------- | ---------------------------------- | +| 1 | 5 866 ms | 1.00× | — | +| 2 | 2 990 ms | 1.96× | **yes** | +| 4 | 1 785 ms | 3.29× | **yes** | +| 8 | 1 414 ms | 4.15× | **yes** | +| 12 | 1 619 ms | 3.62× | **yes** | + +One representative run; timings vary ±10% between invocations (8 shards +measured between 3.79× and 4.18× across repeats). Result-preservation does not +vary — it is exact, verified by fingerprinting every frame's merged histogram +and comparing across shard counts, identical on every row of every repeat. That +is the property that matters most: sharding must not change what an experiment +reports. + +Scaling is sub-linear and regresses past core count, for understandable +reasons: 4 performance + 6 efficiency cores, per-shard HIR compile, and the +per-run construction cost of §2.3 paid N times. Fixing §2.3 should improve +parallel efficiency as well as single-thread speed. + +### 3.3 What shipped + +Implemented — `createMonteCarloExperiment` now fans out over N transports: + +| Piece | Where | +| ----------------------------------------- | ---------------------------------------------- | +| Shard sizing and contiguous split | `monte-carlo/runtime/shard-plan.ts` | +| Global-index seed derivation | `MonteCarloSimulatorConfig.runIndexOffset` | +| Streaming monoid merge of per-frame state | `monte-carlo/metrics/merge.ts` | +| Fan-out, progress, lifecycle | `monte-carlo/runtime/experiment.ts` | +| Host-level override | `ExperimentsProvider`'s `experimentShardCount` | + +Two design points worth keeping in mind when touching this: + +- **Scalar frames carry `runAggregate`**, the pre-reduction accumulator state, + because `frameValue` cannot be merged — a mean of means is not a mean. Time + aggregation is recomputed on the main thread from merged frame values, while + distribution metrics aggregate per _run_ over time and so are already correct + shard-locally. +- **Frames are released on a watermark**: a frame number finalises only once + every still-running shard has reported it, with finished shards dropped from + the watermark rather than blocking it. That is also why merged output matches + an unsharded run — a finished shard has no active runs left to contribute, + exactly like the completed runs a single simulator skips. + +Measured end to end by `benchmarks/sharded-experiment.mjs`, which drives the real +runtime over real worker threads and fails if any shard count changes results: + +| Shards | Wall clock | Speedup | Identical to 1 shard | +| ------ | ---------- | ------- | -------------------- | +| 1 | 3 156 ms | 1.00× | — | +| 2 | 1 667 ms | 1.89× | yes | +| 4 | 1 058 ms | 2.98× | yes | +| 8 | 769 ms | 4.11× | yes | + +Still open: + +- **Concurrent experiments do not share a pool.** Three 8-way experiments spawn + 24 workers and compete for cores. Memory is roughly unchanged (sharding splits + runs rather than duplicating them), so this degrades gracefully rather than + breaking, and the docs tell users to run experiments one at a time for maximum + speed — but a shared pool would be better. +- **Metric state crosses threads as `[number, number][]`**, not typed arrays, so + it is cloned rather than transferred. +- Scaling is sub-linear past ~4 shards, partly from efficiency cores and partly + because each shard repays the per-run construction cost of §2.3. + +Server side (`petrinaut-cli`), the same sharding applies via +`node:worker_threads` — not yet wired up. Note the existing memory constraint +recorded for the optimisation path — a 1 GB ECS task against multiple Node CLIs +each capped at 768 MB — so shard count there must be bounded by memory, not just +cores. + +--- + +## 4. Hot-path fixes (do these first) + +Ordered by measured value per unit of risk. None require new toolchain, new +ABI, or user-visible change. + +| # | Change | Expected | +| --- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| 1 | Lazy, index-based combination enumeration (§2.4) | up to ~1000× on affected nets; nothing on weight-1 nets | +| 2 | Share one compiled `SimulationDefinition` across a shard's runs (§2.3) | removes 80–230 µs/run and improves inlining | +| 3 | Skip the frame copy when no place has dynamics; otherwise copy only dynamic places | ~20% | +| 4 | Replace `Record` removals/additions with dense per-place-index typed arrays | ~10% + most GC | +| 5 | Resolve place/transition indices at build time; delete `getPlaceIndex` from the hot loop | ~3% | +| 6 | Mutable-in-place metric accumulators + reusable frame cursor (§2.5) | ~30–50% of metric overhead | +| 7 | Single RNG draw per frame reused across transitions where semantics allow | up to 9% — **needs a semantics decision**, changes streams | + +Items 1–6 are behaviour-preserving. Item 7 changes RNG streams, so existing +seeds would produce different (equally valid) trajectories — a product call, +not an engineering one. + +My estimate for 1–6 combined on a typical coloured net: **5–15×**, before any +threading. Combined with §3 on 8 cores: **20–60×**. That is likely enough to +make WASM optional rather than urgent. + +--- + +## 5. Optional per-place token capacity + +You raised this as a prerequisite; it is, and for more reasons than frame +sizing. + +### 5.1 Shape + +Add to `Place` in `types/sdcpn.ts`: + +```ts +/** Optional hard upper bound on tokens in this place. */ +capacity?: number | null; +``` + +Semantics to decide (see §10): when a firing would exceed capacity, is the +transition _not enabled_ (classical capacity / self-loop semantics), or does it +fire and **error**? These differ observably. Classical Petri-net capacity treats +it as an enablement condition, and that is also the cheaper check — it folds +into the existing structural-enablement test alongside inhibitor arcs. + +### 5.2 What it unlocks + +**Fixed frame size.** With every place bounded, the frame's byte length is +static: + +```text +frameBytes = header + + placeCount × (4 + 4) // counts + offsets + + transitionCount × (8 + 4 + 1) // elapsed + firingCount + flag + + Σ_p capacity_p × stride_p // token region, padded +``` + +No `ensureFrameCapacity`, no reallocation, no `reallocations` counter, no +per-step repacking to keep places contiguous — each place gets a fixed slot and +`placeOffsets` becomes a build-time constant. That removes `applyTokenAdditions` +repacking entirely (a measured 3%, worse on colour-heavy nets) and makes frames +trivially poolable and transferable. + +**A computable state-space bound.** For uncoloured places, reachable markings +are at most `∏ (capacity_p + 1)`. Report it in the UI so users see when they +have specified something astronomically large. For coloured places the bound is +over token _values_ too and is generally not enumerable — be careful not to +promise a "combinatorial explosion" number that only holds for uncoloured nets. + +**A static worst-case enumeration bound.** Per transition, +`∏ᵢ C(capacity_i, wᵢ)` (§2.4) is computable at compile time. This is worth +surfacing as a **lint**: "this transition can enumerate up to 79 800 token +combinations per frame". That warning would have caught §2.4 before it was +measured. + +**The GPU/WASM precondition.** Fixed per-run frame size is what lets you +allocate `runs × frameBytes` as one contiguous block — required for a WASM +linear-memory layout and mandatory for GPU (§8). + +### 5.3 Proposed semantics + +A transition is not enabled when firing would take any output place above its +capacity — the supply-side mirror of an input arc that cannot be satisfied. Three +details that were decisions rather than consequences: + +- **Net change, not gross output.** Constraints are the sum of output arc weights + on a place minus the _standard_ input arc weights on the same place, so a + 1-in/1-out self loop is never blocked by its own full place. Read and inhibitor + arcs consume nothing and therefore do not make room. +- **Same-frame pending output counts.** Output tokens are applied once at the end + of a frame, so the frame's counts lag during transition evaluation. Without + folding in what earlier transitions already committed, two producers feeding + one capped place would each individually fit and jointly overflow. Tracked in + `MonteCarloRunState.pendingOutputCounts`, allocated only for nets that declare + a capacity. +- **Deadlock includes capacity.** Both engines' structural-enablement checks + consider capacity, so a net whose remaining transitions are all blocked by full + places reports deadlock instead of stepping to `maxTime` with nothing + happening. + +Constraints are precomputed per transition at build time +(`engine/capacity.ts`) and would be empty for nets without capacities, so the hot path +pays nothing for a feature it does not use. + +An initial marking above a place's capacity is rejected at build time: capacity +blocks transitions, so it cannot repair a starting state that already violates +the bound. + +Shipped alongside: the `Place.capacity` field and zod schema, a place-inspector +control, and `drawing-a-net.md` / `simulation.md` updates. + +Not done: capacity does **not** yet participate in the HIR artifact fingerprint. +It changes enablement but not any compiled program, so artifacts stay valid; if +capacity ever feeds into codegen (§6) that has to change. The fixed-size frame +layout this unlocks (§5.2) is also still unbuilt — the runtime keeps using +growable frames. + +--- + +## 6. Whole-loop codegen (where the real win is) + +The engine currently interprets net structure at runtime: loop over +`transitionIds`, look up compiled transition, build input descriptors, enumerate, +call lambda, call kernel, apply effects generically. The user code is compiled; +the _net_ is not. + +Since the net is known when an experiment starts, emit **one specialised +stepper per net** — the whole frame loop, with structure unrolled and user code +inlined: + +```text +for each run in shard: + # transition 0: infection (S ≥ 1, I ≥ 1) + if (counts[0] >= 1 && counts[1] >= 1) { …inlined lambda…; …inlined kernel… } + # transition 1: recovery (I ≥ 1) + if (counts[1] >= 1) { … } +``` + +No dispatch, no descriptor objects, no string lookup, no generic enumeration +for weight-1 arcs, no `Record` allocation. This _is_ the flat stepper of §2.1, +generated rather than hand-written. + +This is a natural extension of the existing pipeline, not a new concept: +`emit-buffer-js.ts` already emits per-surface programs from HIR. This emits one +program per _net_, splicing those bodies in. The HIR is designed for it — pure, +no recursion, no unbounded loops, statically unrollable `map`, and the module +docblock already names "JavaScript today, WASM/GPU later" as the intended +backend set. + +Sequencing matters: **do §4 and §5 first.** Codegen over a growable frame with +`Record`-based effects would just generate the same expensive shapes. + +Risks: compile time per experiment start (mitigate by caching on the artifact +fingerprint, which already exists); debuggability of generated code (keep the +current interpreted path as a reference implementation and differential-test +against it — the existing test suite becomes the oracle); and code size for +large nets. + +--- + +## 7. WASM (browser) and native (server) + +### 7.1 The honest case for WASM + +WASM buys predictable numeric performance without a JIT warm-up and without +V8's deoptimisation cliffs. Against **well-written** JS numeric code over typed +arrays, expect **~1.5–3×** — not the 10× that "rewrite it in WASM" implies. All +of §2's cost centres are allocation, copying, and dispatch, and WASM does not +fix those by itself; a flat JS rewrite captures most of the same ground. + +So: WASM is a worthwhile _second backend_ once §6 exists, and a poor +_first_ move. + +### 7.2 Design that works + +The only viable shape is **one module per net containing the entire stepping +loop with user code inlined** — the §6 design, emitted as WASM instead of JS. +Frames live in linear memory as `runs × frameBytes` (needs §5). One host call +advances a whole batch of frames for all runs in the shard, so there are +**zero boundary crossings per frame**. + +Designs to reject: + +- _Rust engine in WASM, user code in JS_ — a boundary crossing per transition + per frame. Slower than today. +- _Rust engine + HIR interpreter in WASM_ — interpretation loses to JIT-compiled + JS on hot code. +- _Runtime Cranelift in the browser_ — not available. + +### 7.3 The `libm` gotcha + +WASM has only `sqrt`, `abs`, `min`, `max`, `ceil`, `floor`, `trunc`, `nearest` +for `f64`. It has **no `exp`, `log`, `pow`, `sin`, `cos`, `tan`** — and +`HIR_MATH_FNS` exposes all of them, with `Math.exp` on the firing path of +_every_ transition evaluation. + +Importing them from JS means a host call per invocation, which would erase the +win. So a WASM backend must ship its own `libm` subset in the module. That is a +known, bounded piece of work, but it is real, and it must produce +**bit-identical** results to V8's `Math.*` or seeded runs will diverge between +backends. Getting bit-identical `exp` across V8 and a hand-rolled +implementation is not guaranteed — this may force accepting per-backend +divergence, which is a product decision about what "same seed" promises. + +Two build routes: + +| Route | Notes | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Emit WASM bytes directly from HIR | No runtime toolchain; ~1.5–2.5k lines for the emitter; must hand-roll libm | +| Rust engine template + generated Rust for user code, compiled ahead of time | Only works if nets are known at build time — they are not (users author code at runtime), so this cannot be the general path | + +Since nets and user code are authored at runtime, **direct WASM emission from +HIR is the only route that covers the product**. Precedent exists in-repo: +`libs/@blockprotocol/type-system/rust` already ships `cdylib` + `wasm-bindgen`, +and `wasm-bindgen` is pinned in the workspace — but note that precedent is for +_ahead-of-time_ compiled Rust, which is the case that does not apply here. + +### 7.4 Server: native + +The elegant option: **one HIR→WASM backend, two hosts.** The browser runs it on +the built-in engine; `petrinaut-cli` runs the same module under Wasmtime, which +compiles it natively with good codegen. One emitter, one set of semantics, one +place for bit-exactness to be verified — and CPU-native speed on the server. + +Alternative if the server should not depend on WASM: a Rust engine plus +**Cranelift** JIT of the HIR (Cranelift is a normal crate; runtime codegen is +fine off-browser). Faster ceiling, but a second codegen backend to keep +semantically in sync with the browser's — I would avoid that until there is +evidence it is needed. + +Either way the server also wants §3's sharding across `worker_threads` (or OS +processes, given the recorded 768 MB-per-CLI cap). + +--- + +## 8. GPU / WebGPU + +### 8.1 Is Petri-net simulation possible on a GPU? + +Yes — but the parallelism axis has to be **across runs, not within a run**. One +GPU thread per seed is exactly the embarrassingly-parallel shape GPUs want, and +it is what an experiment already is. Within a single run there is very little to +parallelise: transitions must be evaluated in a defined order because each +firing mutates the marking the next transition observes (`applyTokenRemovals` +runs immediately, inside the loop). + +So the model is: `runs` threads, each stepping its own net for many frames, +with metrics reduced on-GPU via atomics into histogram buffers, reading back +only every N frames. + +### 8.2 The four hard constraints + +**1. No `f64` in WGSL.** WebGPU core has `f32`; `f16` behind the `shader-f16` +feature; `f64` is an open proposal, not a shipped extension +([gpuweb#2805](https://github.com/gpuweb/gpuweb/issues/2805)). The entire token +layout is `f64`, dynamics integrate in `f64`, and `real`/`integer` both map to +`f64` lanes. A GPU path is therefore **`f32`** — different rounding, ~7 +significant digits, and integer exactness only to 2²⁴. Consequences: Euler +integration on stiff dynamics degrades noticeably, and **GPU results will not +match CPU results for the same seed**. For a product whose reproducibility +story is "same seed, same trajectory", that is a fork in semantics, not an +optimisation. + +Also no `u64`: the string-pool ids and 128-bit UUIDs need `u32` pairs, with +manual 64-bit arithmetic if compared or hashed. + +**2. Variable token counts.** GPU buffers are fixed at dispatch. §5 capacities +are therefore **mandatory**, not optional, for any GPU path — and the total +allocation is `runs × frameBytes`, so generous capacities multiply fast. + +**3. Unbounded enumeration.** `∏ᵢ C(nᵢ, wᵢ)` combinations per transition with +data-dependent trip counts is hostile to SIMT: divergence plus per-thread work +imbalance. A realistic GPU subset would restrict to weight-1 coloured arcs (or +first-match semantics), which is a real capability reduction. + +**4. Divergence.** Each run fires different transitions, so a subgroup executes +the union of taken branches. Bounded by transition count — the CPU engine walks +all transitions every frame anyway — but kernel bodies with heavy user code +will diverge badly. + +### 8.3 Why "ODEs on the GPU only" is the wrong split + +This is the specific idea worth pushing back on. A frame is +`dynamics → transitions → timers`, and the discrete part reads exactly what the +continuous part wrote. Running dynamics on the GPU and transitions on the CPU +means, **every frame**, uploading the token region, dispatching, and reading +back — with `mapAsync` readback latency in the hundreds of microseconds to +milliseconds, against a whole-frame CPU cost of ~1 µs per run today. The +round-trip would be 100–1000× the work it replaces. + +Same for "timing of the next firing": that is one `exp` and a compare per +transition. There is nothing there to offload. + +The rule: **GPU pays off only if the entire stepping loop lives on the GPU for +many frames without readback.** Partial offload of any per-frame stage is +strictly worse than not doing it. + +### 8.4 Verdict + +Sequence this **after** §4–§7, and scope it as a spike, not a roadmap item. +Realistic upside for a suitable net — bounded capacities, weight-1 arcs, +`f32`-tolerant, thousands of runs — is large (plausibly 10–100× over a +multi-threaded CPU implementation, dominated by how much divergence the net +causes). But it is a second engine with different numerics, a restricted +feature subset, and a hard dependency on §5, and it only helps at run counts +where a well-optimised threaded CPU engine may already be fast enough. + +Reasonable spike, ~1 week, answering one question: for the SIR net with 10 000 +runs, `f32`, capacities set, what is the end-to-end wall clock versus the +threaded CPU path? If it is not ≥10×, drop it. + +A cheaper GPU idea worth noting: keep simulation on the CPU and use WebGPU only +for **rendering** large token populations and distribution charts, which has no +numerical-fidelity problem at all. + +--- + +## 9. Not covered above: the algorithmic option + +Worth flagging because it may beat every item here. The engine uses fixed-`dt` +time-stepping: cost is `frames × transitions` regardless of activity — +`maxTime` 180 / `dt` 0.1 = 1800 frames per run, whether or not anything +happened. For nets where firings are sparse relative to `dt`, an **event-driven +scheme** (Gillespie SSA / next-reaction method, with ODE integration between +events) does work proportional to _events_, not frames. That can be 10–100× +fewer steps, and it composes with everything above. + +It is a semantics change — different trajectories, different meaning for +`dt`, and hybrid continuous/discrete handling — so it would have to be an +opt-in mode. But "reduce the number of steps" is a bigger lever than "make each +step faster", and it deserves evaluation alongside the engineering work. + +--- + +## 10. Questions for you + +Blocking design decisions: + +1. **Capacity semantics** — when a firing would exceed a place's capacity: is + the transition not enabled (classical, cheap, composes with inhibitor arcs), + or does it error and fail the run? Do capacities affect _existing_ nets at + all, or only where explicitly set? +2. **Reproducibility contract** — must the same seed give the same trajectory + across (a) shard counts, (b) engine versions, (c) CPU vs WASM vs GPU + backends? (a) is preserved by the §3 design. (b) is broken by hot-path item 7. + (c) is essentially impossible for GPU (`f32`) and hard for WASM (`libm`). + Knowing which of these you are willing to give up decides §7 and §8. +3. **Worker budget** — one shared pool across concurrent experiments, or per + experiment? What is the acceptable core count while the user keeps editing? + +Sequencing: + +4. Should I start with §4 items 1–6 (measurable, behaviour-preserving, no new + concepts), or do you want the §3 sharding landed first because it is the + visible feature? +5. Is the §9 event-driven mode in scope at all, or is fixed-`dt` a fixed + product decision? diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json index 5f45d28c7c8..300fd370c0f 100644 --- a/libs/@hashintel/petrinaut-core/package.json +++ b/libs/@hashintel/petrinaut-core/package.json @@ -69,7 +69,6 @@ }, "scripts": { "build": "vite build", - "doc:dependency-diagram": "node scripts/generate-dependency-diagrams.mjs", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", @@ -86,7 +85,6 @@ "devDependencies": { "@types/node": "22.18.13", "@typescript/native-preview": "7.0.0-dev.20260511.1", - "dependency-cruiser": "18.0.0", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", "rolldown": "1.1.2", diff --git a/libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs b/libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs deleted file mode 100644 index da80c52d321..00000000000 --- a/libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs +++ /dev/null @@ -1,356 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmod, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { cruise } from "dependency-cruiser"; -import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config"; - -const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); -const outputDirectory = fileURLToPath( - new URL("../docs/architecture/", import.meta.url), -); -const tsconfigPath = fileURLToPath( - new URL("../dependency-cruiser.tsconfig.json", import.meta.url), -); - -const corePrefix = "libs/@hashintel/petrinaut-core/src/"; -const petrinautPrefix = "libs/@hashintel/petrinaut/src/"; -const coreSource = join(repoRoot, corePrefix); - -const coreAliases = [ - ["@hashintel/petrinaut-core/examples", "examples/index.ts"], - ["@hashintel/petrinaut-core/hir-runtime", "hir-runtime.ts"], - ["@hashintel/petrinaut-core/hir", "hir.ts"], - ["@hashintel/petrinaut-core/workers/lsp", "workers/lsp.ts"], - ["@hashintel/petrinaut-core/workers/monte-carlo", "workers/monte-carlo.ts"], - ["@hashintel/petrinaut-core/workers/simulation", "workers/simulation.ts"], - ["@hashintel/petrinaut-core", "index.ts"], -].map(([name, path]) => ({ - alias: join(coreSource, path), - name, - onlyModule: true, -})); - -const cruiseResult = await cruise( - ["libs/@hashintel/petrinaut-core/src", "libs/@hashintel/petrinaut/src"], - { - baseDir: repoRoot, - exclude: - "(?:[.](?:test|stories)[.][cm]?[jt]sx?$|/(?:__fixtures__|__snapshots__)/)", - includeOnly: "^libs/@hashintel/petrinaut(?:-core)?/src/", - moduleSystems: ["es6"], - tsPreCompilationDeps: true, - }, - { - alias: coreAliases, - conditionNames: ["types", "import", "default"], - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], - }, - { tsConfig: extractTSConfig(tsconfigPath) }, -); - -if (typeof cruiseResult.output === "string") { - throw new TypeError("dependency-cruiser returned formatted output"); -} - -/** @type {import("dependency-cruiser").ICruiseResult["modules"]} */ -const modules = cruiseResult.output.modules; - -/** - * @param {string} source - * @returns {string | null} - */ -function broadModule(source) { - if (source.startsWith(corePrefix)) { - const path = source.slice(corePrefix.length); - const [directory, child] = path.split("/"); - - if (directory === "hir" || directory.startsWith("hir")) { - return "core / HIR compiler & runtime"; - } - if (directory === "lsp" || path === "workers/lsp.ts") { - return "core / LSP"; - } - if (directory === "simulation" || directory === "workers") { - if (child === "monte-carlo" || path === "workers/monte-carlo.ts") { - return "core / Monte Carlo runtime"; - } - if ( - ["runtime", "worker"].includes(child) || - path === "workers/simulation.ts" - ) { - return "core / simulation runtime & workers"; - } - return "core / simulation engine"; - } - if ( - [ - "actions.ts", - "clipboard", - "commands.ts", - "handle", - "layout", - "store", - ].includes(directory) - ) { - return "core / editing & document state"; - } - if ( - [ - "actual-mode", - "file-format", - "playback", - "schemas", - "validation", - ].includes(directory) - ) { - return "core / model & persistence"; - } - if (directory === "examples") { - return "core / examples"; - } - if (directory === "ai.ts") { - return "core / AI tools"; - } - return "core / shared model API"; - } - - if (source.startsWith(petrinautPrefix)) { - const path = source.slice(petrinautPrefix.length); - const [directory, child, grandchild] = path.split("/"); - - if (!child) { - return "Petrinaut / public API"; - } - if (directory === "react") { - if (child === "lsp") { - return "React / LSP"; - } - if (["experiments", "simulation"].includes(child)) { - return "React / simulation & experiments"; - } - if ( - ["actual-mode-context.ts", "execution-frame", "playback"].includes( - child, - ) - ) { - return "React / playback & actual mode"; - } - return "React / editor state"; - } - if (directory === "ui" && child === "views") { - if (grandchild === "Editor") { - return "UI / editor"; - } - if (grandchild === "SDCPN") { - return "UI / canvas"; - } - return "UI / shared views"; - } - if (directory === "ui" && child === "dev") { - return "UI / development tools"; - } - return "UI / shared components & infrastructure"; - } - - return null; -} - -/** - * @param {string} source - * @returns {string | null} - */ -function compilationModule(source) { - if (source.startsWith(corePrefix)) { - const path = source.slice(corePrefix.length); - - if (path.startsWith("lsp/worker/")) { - return "core / LSP worker"; - } - if (path.startsWith("lsp/lib/")) { - return "core / LSP services"; - } - if (path.startsWith("lsp/") || path === "workers/lsp.ts") { - return "core / LSP client & transport"; - } - if (path === "hir.ts") { - return "core / HIR compiler API"; - } - if (path === "hir-runtime.ts" || path === "hir/instantiate.ts") { - return "core / HIR runtime API"; - } - if (path.startsWith("hir/emit-")) { - return "core / HIR emitters"; - } - if (path === "hir/artifact-fingerprint.ts") { - return "core / HIR artifacts"; - } - if (path.startsWith("hir/")) { - return "core / HIR compiler"; - } - if (path === "simulation/engine/build-simulation.ts") { - return "core / simulation assembly"; - } - if (path.startsWith("simulation/frames/")) { - return "core / simulation frames & metrics"; - } - if (path.startsWith("simulation/runtime/")) { - return "core / simulation controller"; - } - if ( - path.startsWith("simulation/worker/") || - path === "workers/simulation.ts" - ) { - return "core / simulation worker"; - } - if ( - path.startsWith("simulation/monte-carlo/") || - path === "workers/monte-carlo.ts" - ) { - return "core / Monte Carlo runtime"; - } - return null; - } - - if (!source.startsWith(petrinautPrefix)) { - return null; - } - - const path = source.slice(petrinautPrefix.length); - if (path.startsWith("react/lsp/") || path === "react/hooks/use-lsp.ts") { - return "React / LSP provider"; - } - if ( - path.startsWith("react/simulation/") || - path === "react/hooks/use-simulation.ts" - ) { - return "React / simulation provider"; - } - if (path.startsWith("react/experiments/")) { - return "React / experiments provider"; - } - if (path.includes("/SimulateView/metrics/")) { - return "UI / metric authoring"; - } - if (path.includes("/SimulateView/experiments/")) { - return "UI / experiment authoring"; - } - if (path.includes("/simulation-timeline/")) { - return "UI / simulation timeline"; - } - if (path.endsWith("/scenario-lsp.ts")) { - return "UI / scenario authoring"; - } - return null; -} - -/** @param {string} name */ -function moduleClass(name) { - if (name.startsWith("core /")) { - return "core"; - } - if (name.startsWith("React /")) { - return "react"; - } - return "ui"; -} - -/** @param {(source: string) => string | null} classify */ -function buildGraph(classify) { - /** @type {Map>} */ - const nodes = new Map(); - /** @type {Map} */ - const edges = new Map(); - - for (const module of modules) { - const from = classify(module.source); - if (from) { - const sources = nodes.get(from) ?? new Set(); - sources.add(module.source); - nodes.set(from, sources); - } - - for (const dependency of module.dependencies) { - const to = classify(dependency.resolved); - if (!from || !to || from === to) { - continue; - } - - const sources = nodes.get(to) ?? new Set(); - sources.add(dependency.resolved); - nodes.set(to, sources); - - const edge = `${from}\u0000${to}`; - edges.set(edge, (edges.get(edge) ?? 0) + 1); - } - } - - const nodeLines = [...nodes.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, sources]) => { - const fileLabel = `${sources.size} source ${sources.size === 1 ? "file" : "files"}`; - return `${JSON.stringify(name)}: {class: ${moduleClass(name)}; tooltip: ${JSON.stringify(fileLabel)}}`; - }); - const edgeLines = [...edges.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([edge, count]) => { - const [from, to] = edge.split("\u0000"); - return `${JSON.stringify(from)} -> ${JSON.stringify(to)}: {tooltip: ${JSON.stringify(`${count} file-level ${count === 1 ? "dependency" : "dependencies"}`)}}`; - }); - - return ( - `# Generated by scripts/generate-dependency-diagrams.mjs. Do not edit.\n\n` + - `direction: right\n\n` + - `# modules\n\n${nodeLines.join("\n")}\n\n` + - `# dependencies\n\n${edgeLines.join("\n")}\n\n` + - `# styling\n\nclasses: {\n` + - ` core: {style.fill: "#dcecff"; style.stroke: "#3676b8"}\n` + - ` react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"}\n` + - ` ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"}\n` + - `}\n` - ); -} - -/** - * @param {string} sourcePath - * @param {string} outputPath - */ -function renderD2(sourcePath, outputPath) { - const result = spawnSync( - "mise", - [ - "exec", - "--env", - "dev", - "--", - "d2", - "--layout", - "elk", - sourcePath, - outputPath, - ], - { cwd: repoRoot, encoding: "utf8" }, - ); - - if (result.status !== 0) { - throw new Error(result.stderr || result.stdout || "D2 rendering failed"); - } -} - -/** @type {Array<[string, (source: string) => string | null]>} */ -const diagrams = [ - ["petrinaut-dependencies", broadModule], - ["petrinaut-compilation-dependencies", compilationModule], -]; - -for (const [name, classify] of diagrams) { - const sourcePath = `${outputDirectory}${name}.d2`; - const outputPath = `${outputDirectory}${name}.svg`; - await writeFile(sourcePath, buildGraph(classify)); - renderD2(sourcePath, outputPath); - await chmod(outputPath, 0o644); - process.stdout.write( - `Generated ${sourcePath.slice(repoRoot.length)} and ${outputPath.slice(repoRoot.length)}\n`, - ); -} diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md index 27b501d83cb..ace369c6efd 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -1,3 +1,8 @@ +--- +layer: core.actual-mode +role: Renders an execution supplied by an external source rather than by simulation +--- + # Actual Mode Core This folder contains the experimental, transport-neutral pieces of Petrinaut diff --git a/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts b/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts index 00c5fd626b3..8b9f52fef41 100644 --- a/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts +++ b/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.clipboard + * @role Serialises a selection and pastes it back, resolving name collisions + */ + import { v4 as generateUuid } from "uuid"; import { getArcEndpointPlaceId } from "../arc-endpoints"; diff --git a/libs/@hashintel/petrinaut-core/src/examples/index.ts b/libs/@hashintel/petrinaut-core/src/examples/index.ts index 9992c4d0e83..4d956abf4e3 100644 --- a/libs/@hashintel/petrinaut-core/src/examples/index.ts +++ b/libs/@hashintel/petrinaut-core/src/examples/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.examples + * @role Ready-made SDCPN documents shipped for onboarding and demos + */ + export { productionMachines } from "./production-with-machine-failure"; export { deploymentPipelineSDCPN } from "./deployment-pipeline"; export { probabilisticSatellitesSDCPN } from "./satellites-launcher"; diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts index d9327873a4b..b147ee6b35d 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.file-format + * @role Reads and writes the on-disk SDCPN document format, plus export converters + */ + import { legacySdcpnFileSchema, SDCPN_FILE_FORMAT_VERSION, diff --git a/libs/@hashintel/petrinaut-core/src/handle/index.ts b/libs/@hashintel/petrinaut-core/src/handle/index.ts index 032e79a1835..50961200efa 100644 --- a/libs/@hashintel/petrinaut-core/src/handle/index.ts +++ b/libs/@hashintel/petrinaut-core/src/handle/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.handle + * @role Stateful handle wrapping a document, emitting change events to subscribers + */ + export { createJsonDocHandle, type CreateJsonDocHandleOptions, diff --git a/libs/@hashintel/petrinaut-core/src/hir/README.md b/libs/@hashintel/petrinaut-core/src/hir/README.md index 6d06a46ed70..0d172ea3a84 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/README.md +++ b/libs/@hashintel/petrinaut-core/src/hir/README.md @@ -1,3 +1,8 @@ +--- +layer: core.hir +role: Lowers user-authored TypeScript to a source-spanned IR, then typechecks, lints and emits it +--- + # Petrinaut HIR The HIR is Petrinaut's source-spanned intermediate representation for diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 3e493eb241f..3fcc2841218 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -1,7 +1,12 @@ -// Public surface for `@hashintel/petrinaut-core` — the headless engine. -// -// No React, no DOM, no Monaco. Stateful handles, streams, and pure logic for -// SDCPN documents, simulation, LSP, and playback. +/** + * Public surface for `@hashintel/petrinaut-core` — the headless engine. + * + * No React, no DOM, no Monaco. Stateful handles, streams, and pure logic for + * SDCPN documents, simulation, LSP, and playback. + * + * @layerRoot core + * @role SDCPN document model, compiler, simulation runtimes and LSP, with no UI framework + */ // --- Document --- export { @@ -196,16 +201,20 @@ export { createMonteCarloExperiment, createMonteCarloMetricHistogramAccumulator, createMonteCarloMetricNumericAccumulator, + createMonteCarloMetricShardMerger, createMonteCarloSimulator, createMonteCarloUserDefinedMetricConfigsFromSpecs, createMonteCarloUserDefinedMetric, createSimulation, createWorkerTransport, + getDefaultMonteCarloShardCount, + planMonteCarloShards, } from "./simulation"; export type { BackpressureConfig, CreateMonteCarloExperimentConfig, CreateSimulationConfig, + MonteCarloShardPlanEntry, Simulation, SimulationCompleteEvent, SimulationConfig, diff --git a/libs/@hashintel/petrinaut-core/src/layout/index.ts b/libs/@hashintel/petrinaut-core/src/layout/index.ts index 630c48d4435..ce337ba2f01 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/index.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.layout + * @role Computes node positions for a net, so auto-layout does not require the canvas + */ + export { calculateGraphLayout, type LayoutDimensions, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/index.ts b/libs/@hashintel/petrinaut-core/src/lsp/index.ts index 415bd3a45c9..0526ac6399e 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/index.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.lsp + * @role Language-server client and transport for editing user code in the net + */ + export { createLanguageClient, type CreateLanguageClientConfig, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts index 4d95f74d5c9..0c352b578fa 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts @@ -7,6 +7,9 @@ * - Server push: `textDocument/publishDiagnostics` * * The LanguageService is created once and reused across SDCPN changes. + * + * @layerRoot core.lsp.worker + * @role Hosts the TypeScript language server off the main thread */ import ts from "typescript"; import { diff --git a/libs/@hashintel/petrinaut-core/src/playback/index.ts b/libs/@hashintel/petrinaut-core/src/playback/index.ts index b382d70a22b..8347b28b50b 100644 --- a/libs/@hashintel/petrinaut-core/src/playback/index.ts +++ b/libs/@hashintel/petrinaut-core/src/playback/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.playback + * @role Picks the viewed frame over time and defines the per-play-mode backpressure profiles + */ + export { createPlayback, formatPlaybackSpeed, diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index fd48c8705db..4179d09c508 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.schemas + * @role Zod schemas for document entities, metrics and scenarios, and the descriptions the AI tools read + */ + import { z } from "zod"; import { getParameterValueError } from "../parameter-values"; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md index d4ab5c66f91..ef14e3b0dbd 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md @@ -1,8 +1,8 @@ # Simulation Architecture -> For the illustrated deep-dive (memory maps, sequence diagrams, protocols), -> open [`../../docs/architecture/index.html`](../../docs/architecture/index.html) -> in a browser — no build step needed. +> For the deep-dive (memory model, frame format, protocols, Monte Carlo), see +> the architecture docs, added by #9205 under +> `libs/@local/petrinaut-arch-docs/content/simulation/`. The simulation module is split into five boundaries: diff --git a/libs/@hashintel/petrinaut-core/src/simulation/README.md b/libs/@hashintel/petrinaut-core/src/simulation/README.md index 3e300cd2718..df532582f7c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/README.md @@ -1,11 +1,16 @@ +--- +layer: core.simulation +role: Executes SDCPN nets — stepping, frames, workers and batch statistics +--- + # Simulation Module Headless SDCPN simulation runtime. -> Illustrated architecture documentation (memory layouts, sequence diagrams, -> protocols) lives in -> [`../../docs/architecture/index.html`](../../docs/architecture/index.html) — -> self-contained HTML, open it in a browser. +> Deep-dive documentation (memory layouts, the frame format, worker protocol, +> Monte Carlo) lives in the architecture docs. They are added by #9205 under +> `libs/@local/petrinaut-arch-docs/content/simulation/`, and #9206 adds a site +> that renders them. ## Overview diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts index 46048c77487..b83654180d5 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts @@ -2,6 +2,9 @@ * Shared hardening helpers for evaluating user-authored JS expressions * (scenario expressions, metric bodies, …). Co-located so the compilers * can't drift on what they consider "safe enough". + * + * @layerRoot core.simulation.authoring + * @role Compiles and sandboxes the code users write inside a net */ /** diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md b/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md index 58551a8f40e..30c16803904 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md @@ -1,3 +1,8 @@ +--- +layer: core.simulation.engine +role: Builds an SDCPN definition into a runnable instance and computes frames +--- + # Simulation Engine Core simulation logic for SDCPN Petri net execution. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts index c5f7bf5cb5d..d8c67f6d6ec 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.simulation.frames + * @role The frame layout and the readers hosts use to inspect one frame + */ + import { readTokenRecord } from "../engine/token-layout"; import { createEngineFrameLayout, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/index.ts b/libs/@hashintel/petrinaut-core/src/simulation/index.ts index 2c936b9e04f..a24e682ccdb 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/index.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/index.ts @@ -23,13 +23,17 @@ export { createMonteCarloExperiment, createMonteCarloMetricHistogramAccumulator, createMonteCarloMetricNumericAccumulator, + createMonteCarloMetricShardMerger, createMonteCarloSimulator, createMonteCarloUserDefinedMetricConfigsFromSpecs, createMonteCarloUserDefinedMetric, + getDefaultMonteCarloShardCount, + planMonteCarloShards, } from "./monte-carlo"; export type { CreateMonteCarloExperimentConfig, MonteCarloAdvanceResult, + MonteCarloShardPlanEntry, MonteCarloActiveRunPlaceCountsVisitor, MonteCarloExperiment, MonteCarloExperimentEvent, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md index 84651a2c99b..782cebe0c6d 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md @@ -1,3 +1,8 @@ +--- +layer: core.simulation.monte-carlo +role: Runs many independent simulations with bounded frame memory, reporting metric aggregates +--- + # Monte Carlo Simulator ## Goal diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/index.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/index.ts index 3e27827423f..623bf7a4682 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/index.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/index.ts @@ -3,10 +3,16 @@ export { addAllMonteCarloMetricValues, createMonteCarloMetricHistogramAccumulator, createMonteCarloMetricNumericAccumulator, + createMonteCarloMetricShardMerger, createMonteCarloUserDefinedMetricConfigsFromSpecs, createMonteCarloUserDefinedMetric, } from "./metrics"; export { createMonteCarloExperiment } from "./runtime/experiment"; +export { + getDefaultMonteCarloShardCount, + planMonteCarloShards, +} from "./runtime/shard-plan"; +export type { MonteCarloShardPlanEntry } from "./runtime/shard-plan"; export type { MonteCarloAdvanceResult, MonteCarloRunConfig, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/index.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/index.ts index adbe17cd8ba..50538bcf7dd 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/index.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/index.ts @@ -3,6 +3,7 @@ export { createMonteCarloMetricHistogramAccumulator, createMonteCarloMetricNumericAccumulator, } from "./accumulators"; +export { createMonteCarloMetricShardMerger } from "./merge"; export { createMonteCarloUserDefinedMetricConfigsFromSpecs } from "./specs"; export { createMonteCarloUserDefinedMetric } from "./user-defined"; export type { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/merge.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/merge.test.ts new file mode 100644 index 00000000000..b0b98aa31a3 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/merge.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; + +import { createMonteCarloMetricShardMerger } from "./merge"; + +import type { + MonteCarloUserDefinedMetricAggregation, + MonteCarloUserDefinedMetricFrame, + MonteCarloUserDefinedMetricTimeAggregation, +} from "./types"; + +/** Builds a scalar shard frame as a worker would emit it for `values`. */ +function scalarFrame(options: { + frameNumber: number; + values: readonly number[]; + aggregateRuns?: MonteCarloUserDefinedMetricAggregation; + aggregateTime?: MonteCarloUserDefinedMetricTimeAggregation; + metricId?: string; +}): MonteCarloUserDefinedMetricFrame { + const { + frameNumber, + values, + aggregateRuns = "mean", + aggregateTime = "none", + metricId = "m", + } = options; + + const count = values.length; + const sum = values.reduce((total, value) => total + value, 0); + const frameValue = + count === 0 + ? null + : aggregateRuns === "mean" + ? sum / count + : aggregateRuns === "sum" + ? sum + : aggregateRuns === "min" + ? Math.min(...values) + : aggregateRuns === "max" + ? Math.max(...values) + : values.at(-1)!; + + return { + metricId, + label: metricId, + outputType: "scalar", + frameNumber, + time: frameNumber * 0.1, + value: frameValue, + frameValue, + timeValue: null, + runSampleCount: count, + timeSampleCount: 0, + runAggregate: { + count, + sum, + min: count === 0 ? null : Math.min(...values), + max: count === 0 ? null : Math.max(...values), + last: count === 0 ? null : values.at(-1)!, + }, + aggregateRuns, + aggregateTime, + }; +} + +function distributionFrame( + frameNumber: number, + bins: readonly [number, number][], +): MonteCarloUserDefinedMetricFrame { + return { + metricId: "d", + label: "d", + outputType: "distribution", + frameNumber, + time: frameNumber * 0.1, + value: null, + frameValue: null, + timeValue: null, + bins, + runSampleCount: bins.reduce((total, [, frequency]) => total + frequency, 0), + timeSampleCount: bins.reduce( + (total, [, frequency]) => total + frequency, + 0, + ), + }; +} + +describe("createMonteCarloMetricShardMerger", () => { + it("withholds a frame until every running shard has reported it", () => { + const merger = createMonteCarloMetricShardMerger(2); + + expect( + merger.accept(0, [scalarFrame({ frameNumber: 0, values: [1] })]), + ).toHaveLength(0); + + const released = merger.accept(1, [ + scalarFrame({ frameNumber: 0, values: [3] }), + ]); + + expect(released).toHaveLength(1); + expect(released[0]!.frameValue).toBe(2); + }); + + it("means across shards by weight, not by averaging means", () => { + const merger = createMonteCarloMetricShardMerger(2); + + // Shard 0 has three runs averaging 10; shard 1 has one run at 100. + // A mean of means would give 55; the correct pooled mean is 32.5. + merger.accept(0, [scalarFrame({ frameNumber: 0, values: [5, 10, 15] })]); + const released = merger.accept(1, [ + scalarFrame({ frameNumber: 0, values: [100] }), + ]); + + expect(released[0]!.frameValue).toBe(32.5); + expect(released[0]!.runSampleCount).toBe(4); + }); + + it.each([ + ["sum", 130], + ["min", 5], + ["max", 100], + ] as const)("merges the %s aggregation across shards", (method, expected) => { + const merger = createMonteCarloMetricShardMerger(2); + + merger.accept(0, [ + scalarFrame({ + frameNumber: 0, + values: [5, 10, 15], + aggregateRuns: method, + }), + ]); + const released = merger.accept(1, [ + scalarFrame({ frameNumber: 0, values: [100], aggregateRuns: method }), + ]); + + expect(released[0]!.frameValue).toBe(expected); + }); + + it("resolves `last` by shard order even when shards reply out of order", () => { + const merger = createMonteCarloMetricShardMerger(2); + + // Shard 1 owns the higher global run indices, so its last value wins — + // regardless of which shard's message arrives first. + merger.accept(1, [ + scalarFrame({ frameNumber: 0, values: [100], aggregateRuns: "last" }), + ]); + const released = merger.accept(0, [ + scalarFrame({ frameNumber: 0, values: [5], aggregateRuns: "last" }), + ]); + + expect(released[0]!.frameValue).toBe(100); + }); + + it("sums distribution bins and keeps them sorted", () => { + const merger = createMonteCarloMetricShardMerger(2); + + merger.accept(0, [ + distributionFrame(0, [ + [5, 2], + [1, 1], + ]), + ]); + const released = merger.accept(1, [ + distributionFrame(0, [ + [5, 3], + [9, 1], + ]), + ]); + + expect(released[0]!.outputType).toBe("distribution"); + expect( + (released[0] as { bins: readonly (readonly number[])[] }).bins, + ).toStrictEqual([ + [1, 1], + [5, 5], + [9, 1], + ]); + expect(released[0]!.runSampleCount).toBe(7); + }); + + it("recomputes time aggregation from merged frame values", () => { + const merger = createMonteCarloMetricShardMerger(2); + const frames: MonteCarloUserDefinedMetricFrame[] = []; + + // Merged frame values are 2 then 20, so the running mean is 2 then 11. + for (const [frameNumber, left, right] of [ + [0, 1, 3], + [1, 10, 30], + ] as const) { + merger.accept(0, [ + scalarFrame({ frameNumber, values: [left], aggregateTime: "mean" }), + ]); + frames.push( + ...merger.accept(1, [ + scalarFrame({ frameNumber, values: [right], aggregateTime: "mean" }), + ]), + ); + } + + expect(frames.map((frame) => frame.frameValue)).toStrictEqual([2, 20]); + expect(frames.map((frame) => frame.timeValue)).toStrictEqual([2, 11]); + // `value` follows the time aggregate when one is configured. + expect(frames.map((frame) => frame.value)).toStrictEqual([2, 11]); + expect(frames.map((frame) => frame.timeSampleCount)).toStrictEqual([1, 2]); + }); + + it("stops waiting on a shard once it finishes", () => { + const merger = createMonteCarloMetricShardMerger(2); + + // Shard 1 completes early; shard 0 keeps producing frames and must not + // stall waiting for a shard that will never report again. + merger.accept(1, [scalarFrame({ frameNumber: 0, values: [3] })]); + merger.accept(0, [scalarFrame({ frameNumber: 0, values: [1] })]); + expect(merger.finishShard(1)).toHaveLength(0); + + const released = merger.accept(0, [ + scalarFrame({ frameNumber: 1, values: [7] }), + ]); + + expect(released).toHaveLength(1); + expect(released[0]!.frameNumber).toBe(1); + // Only shard 0 still has active runs, so only its sample counts. + expect(released[0]!.frameValue).toBe(7); + expect(released[0]!.runSampleCount).toBe(1); + }); + + it("releases frames in ascending frame order across metrics", () => { + const merger = createMonteCarloMetricShardMerger(1); + + const released = merger.accept(0, [ + scalarFrame({ frameNumber: 1, values: [1], metricId: "b" }), + scalarFrame({ frameNumber: 0, values: [1], metricId: "a" }), + scalarFrame({ frameNumber: 1, values: [1], metricId: "a" }), + scalarFrame({ frameNumber: 0, values: [1], metricId: "b" }), + ]); + + expect(released.map((frame) => frame.frameNumber)).toStrictEqual([ + 0, 0, 1, 1, + ]); + }); + + it("flushes buffered frames that no shard will complete", () => { + const merger = createMonteCarloMetricShardMerger(2); + + merger.accept(0, [scalarFrame({ frameNumber: 0, values: [1] })]); + + expect(merger.flush().map((frame) => frame.frameNumber)).toStrictEqual([0]); + expect(merger.flush()).toHaveLength(0); + }); + + it("rejects invalid shard counts and indices", () => { + expect(() => createMonteCarloMetricShardMerger(0)).toThrow( + /positive shard count/, + ); + expect(() => createMonteCarloMetricShardMerger(1).accept(1, [])).toThrow( + /Unknown metric shard index/, + ); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/merge.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/merge.ts new file mode 100644 index 00000000000..981519651b9 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/merge.ts @@ -0,0 +1,287 @@ +/** + * Merging of metric frames produced by the shards of one sharded experiment. + * + * A sharded experiment splits its runs across several workers, each of which + * aggregates only its own runs. Recombining those partial results is what makes + * shard layout invisible in the output, and it is only sound because the + * per-frame aggregates form monoids: + * + * - **distribution** frames carry bins, which merge by summing frequencies; + * - **scalar** frames carry `runAggregate`, which merges through + * `createMonteCarloMetricNumericAccumulator(...).merge` — `frameValue` itself + * cannot be merged, because a mean of means is not a mean. + * + * Time aggregation is handled differently per output type. Distribution metrics + * aggregate each *run* over time, and every run lives in exactly one shard, so + * that work is already correct shard-locally. Scalar metrics aggregate the + * cross-run frame value over time, which depends on the merged value, so it is + * recomputed here as frames finalise. + */ +import { + createMonteCarloMetricNumericAccumulator, + type MonteCarloMetricNumericAccumulatorState, +} from "./accumulators"; + +import type { + MonteCarloUserDefinedMetricDistributionBin, + MonteCarloUserDefinedMetricFrame, +} from "./types"; + +type ShardFrame = { + shardIndex: number; + frame: MonteCarloUserDefinedMetricFrame; +}; + +/** + * Sums bin frequencies across shard frames. + * + * Every shard bins with the same configuration, so equal bin values are the + * same bucket and can be added directly. + */ +function mergeDistributionBins( + shardFrames: readonly ShardFrame[], +): MonteCarloUserDefinedMetricDistributionBin[] { + const totals = new Map(); + + for (const { frame } of shardFrames) { + if (frame.outputType !== "distribution") { + continue; + } + for (const [value, frequency] of frame.bins) { + totals.set(value, (totals.get(value) ?? 0) + frequency); + } + } + + return [...totals.entries()] + .sort(([left], [right]) => left - right) + .map(([value, frequency]) => [value, frequency]); +} + +/** + * Combines one metric's frames for a single frame number across shards. + * + * Shards are folded in index order so the result never depends on which worker + * replied first. That matters for the `last` aggregation, which then resolves to + * the highest-indexed shard that sampled anything — mirroring the + * single-simulator behaviour of taking the highest run index. + */ +function mergeShardFramesForFrameNumber( + unordered: readonly ShardFrame[], +): MonteCarloUserDefinedMetricFrame { + if (unordered.length === 0) { + throw new Error("Cannot merge an empty set of metric shard frames"); + } + + const shardFrames = [...unordered].sort( + (left, right) => left.shardIndex - right.shardIndex, + ); + const first = shardFrames[0]!.frame; + if (shardFrames.length === 1) { + return first; + } + + let runSampleCount = 0; + let timeSampleCount = 0; + for (const { frame } of shardFrames) { + runSampleCount += frame.runSampleCount; + timeSampleCount += frame.timeSampleCount; + } + + if (first.outputType === "distribution") { + return { + ...first, + bins: mergeDistributionBins(shardFrames), + runSampleCount, + timeSampleCount, + }; + } + + const accumulator = createMonteCarloMetricNumericAccumulator( + first.aggregateRuns, + ); + let runAggregate: MonteCarloMetricNumericAccumulatorState = + accumulator.empty(); + for (const { frame } of shardFrames) { + if (frame.outputType !== "scalar") { + continue; + } + runAggregate = accumulator.merge(runAggregate, frame.runAggregate); + } + + const frameValue = accumulator.read(runAggregate); + + return { + ...first, + frameValue, + // Overwritten by the time-aggregation pass when one is configured. + value: frameValue, + timeValue: null, + runAggregate, + runSampleCount, + timeSampleCount, + }; +} + +type TimeAggregationState = { + frameCount: number; + state: MonteCarloMetricNumericAccumulatorState | null; +}; + +/** + * Streaming merger for a sharded experiment's metric frames. + * + * Shards report independently and at different rates, so a frame number can + * only be finalised once every shard that is still running has reported it. + * Shards that finish early are excluded from that watermark rather than + * blocking it — which is also why merged output matches an unsharded run: a + * finished shard has no active runs left to contribute, exactly like the + * completed runs a single simulator skips. + */ +export function createMonteCarloMetricShardMerger(shardCount: number): { + /** Buffers a shard's frames and returns any frames that are now final. */ + accept: ( + shardIndex: number, + frames: readonly MonteCarloUserDefinedMetricFrame[], + ) => MonteCarloUserDefinedMetricFrame[]; + /** Drops a shard from the watermark and releases whatever that unblocks. */ + finishShard: (shardIndex: number) => MonteCarloUserDefinedMetricFrame[]; + /** Releases every buffered frame regardless of the watermark. */ + flush: () => MonteCarloUserDefinedMetricFrame[]; +} { + if (!Number.isInteger(shardCount) || shardCount <= 0) { + throw new Error("Metric shard merger requires a positive shard count"); + } + + /** + * metricId -> frameNumber -> one entry per contributing shard. + * + * Entries keep their shard index because shards report asynchronously but + * merging must be deterministic: the `last` aggregation resolves by shard + * order, not arrival order. + */ + const buffered = new Map>(); + const highestFrameByShard = new Array(shardCount).fill(-1); + const finished = new Array(shardCount).fill(false); + const timeAggregation = new Map(); + + const getWatermark = (): number => { + let watermark = Number.POSITIVE_INFINITY; + for (let shard = 0; shard < shardCount; shard++) { + if (finished[shard]) { + continue; + } + watermark = Math.min(watermark, highestFrameByShard[shard]!); + } + return watermark; + }; + + /** + * Applies the configured time aggregation to a finalised scalar frame. + * + * Called strictly in ascending frame order per metric, so the accumulator + * sees the merged frame values in simulation order. + */ + const applyTimeAggregation = ( + frame: MonteCarloUserDefinedMetricFrame, + ): MonteCarloUserDefinedMetricFrame => { + if (frame.outputType !== "scalar" || frame.aggregateTime === "none") { + return frame; + } + + const accumulator = createMonteCarloMetricNumericAccumulator( + frame.aggregateTime, + ); + let entry = timeAggregation.get(frame.metricId); + if (!entry) { + entry = { frameCount: 0, state: null }; + timeAggregation.set(frame.metricId, entry); + } + + if (frame.frameValue !== null) { + entry.state = accumulator.add( + entry.state ?? accumulator.empty(), + frame.frameValue, + ); + entry.frameCount += 1; + } + + const timeValue = entry.state ? accumulator.read(entry.state) : null; + + return { + ...frame, + timeValue, + value: timeValue ?? frame.frameValue, + timeSampleCount: entry.frameCount, + }; + }; + + const drainUpTo = (watermark: number): MonteCarloUserDefinedMetricFrame[] => { + const released: MonteCarloUserDefinedMetricFrame[] = []; + + for (const [metricId, byFrameNumber] of buffered) { + const ready = [...byFrameNumber.keys()] + .filter((frameNumber) => frameNumber <= watermark) + .sort((left, right) => left - right); + + for (const frameNumber of ready) { + released.push( + applyTimeAggregation( + mergeShardFramesForFrameNumber(byFrameNumber.get(frameNumber)!), + ), + ); + byFrameNumber.delete(frameNumber); + } + + if (byFrameNumber.size === 0) { + buffered.delete(metricId); + } + } + + // Frames are released per metric above; order them globally so consumers + // that append to a flat timeline see monotonic frame numbers. + return released.sort((left, right) => left.frameNumber - right.frameNumber); + }; + + return { + accept(shardIndex, frames) { + if (shardIndex < 0 || shardIndex >= shardCount) { + throw new Error(`Unknown metric shard index ${shardIndex}`); + } + + for (const frame of frames) { + let byFrameNumber = buffered.get(frame.metricId); + if (!byFrameNumber) { + byFrameNumber = new Map(); + buffered.set(frame.metricId, byFrameNumber); + } + + const existing = byFrameNumber.get(frame.frameNumber); + if (existing) { + existing.push({ shardIndex, frame }); + } else { + byFrameNumber.set(frame.frameNumber, [{ shardIndex, frame }]); + } + + highestFrameByShard[shardIndex] = Math.max( + highestFrameByShard[shardIndex]!, + frame.frameNumber, + ); + } + + return drainUpTo(getWatermark()); + }, + + finishShard(shardIndex) { + if (shardIndex < 0 || shardIndex >= shardCount) { + throw new Error(`Unknown metric shard index ${shardIndex}`); + } + + finished[shardIndex] = true; + return drainUpTo(getWatermark()); + }, + + flush() { + return drainUpTo(Number.POSITIVE_INFINITY); + }, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts index b89bf67c520..2ada0ef1cd9 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts @@ -1,5 +1,6 @@ import type { HirMetricArtifact } from "../../../hir-runtime"; import type { SimulationFrameReader } from "../../api"; +import type { MonteCarloMetricNumericAccumulatorState } from "./accumulators"; export type MonteCarloMetricRunStatus = | "ready" @@ -192,6 +193,18 @@ export type MonteCarloUserDefinedScalarMetricFrame = frameValue: number | null; timeValue: number | null; timeSampleCount: number; + /** + * Mergeable across-runs accumulator state behind `frameValue`. + * + * `frameValue` is already reduced and cannot be combined across a sharded + * experiment (a mean of means is not a mean). This state can, via the + * numeric accumulator monoid — see `metrics/merge.ts`. + */ + runAggregate: MonteCarloMetricNumericAccumulatorState; + /** How `runAggregate` reduces to `frameValue`; needed to merge shards. */ + aggregateRuns: MonteCarloUserDefinedMetricAggregation; + /** How frame values reduce over time; re-applied after shards merge. */ + aggregateTime: MonteCarloUserDefinedMetricTimeAggregation; }; export type MonteCarloUserDefinedDistributionMetricFrame = diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/user-defined.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/user-defined.ts index d8444798ee1..8614f0afec0 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/user-defined.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/user-defined.ts @@ -163,9 +163,13 @@ export function createMonteCarloUserDefinedMetric( runOutput.aggregateRuns ?? config.aggregateRuns ?? "mean"; const runAccumulator = createMonteCarloMetricNumericAccumulator(aggregateRuns); - const frameValue = runAccumulator.read( - addAllMonteCarloMetricValues(runAccumulator, runValues), + // Kept on the frame so a sharded experiment can merge across shards + // before reducing — see `metrics/merge.ts`. + const runAggregate = addAllMonteCarloMetricValues( + runAccumulator, + runValues, ); + const frameValue = runAccumulator.read(runAggregate); if (frameValue !== null) { scalarFrameCountState = frameCountAccumulator.add( scalarFrameCountState, @@ -195,6 +199,9 @@ export function createMonteCarloUserDefinedMetric( timeValue, runSampleCount: runValues.length, timeSampleCount: scalarFrameCountState.count, + runAggregate, + aggregateRuns, + aggregateTime, }); }, }; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts index 97624284ae8..141cc64f08e 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts @@ -81,7 +81,11 @@ export function createRunState( runConfig: MonteCarloRunConfig | undefined, index: number, ): MonteCarloRunState { - const seed = runConfig?.seed ?? deriveRunSeed(config.seed ?? 1, index); + // Seeds derive from the run's *global* index so that sharding an experiment + // across workers reassigns runs without changing which seeds run. + const seed = + runConfig?.seed ?? + deriveRunSeed(config.seed ?? 1, (config.runIndexOffset ?? 0) + index); const initialMarking = runConfig?.initialMarking ?? config.initialMarking; const inputParameterValues = { ...config.parameterValues, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts index 525c81e63ba..6a774714f91 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { compileHirArtifacts } from "../../../hir/compile"; import { createMonteCarloExperiment } from "./experiment"; +import type { WorkerLike } from "../../../environment"; import type { HirMetricArtifact } from "../../../hir/instantiate"; import type { SDCPN } from "../../../types/sdcpn"; import type { SimulationTransport } from "../../api"; @@ -70,6 +71,15 @@ function makeMetricFrame( timeValue: null, runSampleCount: 1, timeSampleCount: frameNumber + 1, + runAggregate: { + count: 1, + sum: frameNumber, + min: frameNumber, + max: frameNumber, + last: frameNumber, + }, + aggregateRuns: "mean", + aggregateTime: "none", }; } @@ -119,6 +129,262 @@ function createExperimentWithMockTransport(mock: { }); } +/** + * Drives a sharded experiment through fake workers, one mock transport per + * shard, so shard fan-out and metric merging can be exercised without threads. + */ +function createShardedExperiment(options: { + runCount: number; + shardCount: number; +}) { + const mocks: ReturnType[] = []; + + const promise = createMonteCarloExperiment({ + createWorker: () => { + const mock = makeMockTransport(); + mocks.push(mock); + // The real factory returns a Worker; the transport only needs the + // message plumbing, which the mock transport provides directly. + const worker: WorkerLike = { + postMessage: (message) => { + mock.transport.send(message); + }, + addEventListener: (_type, listener) => { + mock.transport.onMessage((message) => { + listener({ data: message }); + }); + }, + terminate: () => { + mock.transport.terminate(); + }, + }; + return worker; + }, + sdcpn: empty(), + initialMarking: {}, + parameterValues: {}, + seed: 1, + dt: 1, + maxTime: 10, + runCount: options.runCount, + shardCount: options.shardCount, + }); + + return { promise, mocks }; +} + +/** Awaits the worker-transport factory's internal promise resolution. */ +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("createMonteCarloExperiment sharding", () => { + it("splits runs across shards with global run index offsets", async () => { + const { mocks } = createShardedExperiment({ runCount: 10, shardCount: 4 }); + await flushMicrotasks(); + + expect(mocks).toHaveLength(4); + expect( + mocks.map((mock) => { + const init = mock.sent.find((message) => message.type === "init"); + return init?.type === "init" + ? { runIndexOffset: init.runIndexOffset, runCount: init.runCount } + : null; + }), + ).toStrictEqual([ + { runIndexOffset: 0, runCount: 3 }, + { runIndexOffset: 3, runCount: 3 }, + { runIndexOffset: 6, runCount: 2 }, + { runIndexOffset: 8, runCount: 2 }, + ]); + }); + + it("never creates more shards than runs", async () => { + const { mocks } = createShardedExperiment({ runCount: 2, shardCount: 8 }); + await flushMicrotasks(); + + expect(mocks).toHaveLength(2); + }); + + it("resolves only once every shard is ready", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 2, + }); + await flushMicrotasks(); + + let resolved = false; + void promise.then(() => { + resolved = true; + }); + + mocks[0]!.simulate({ type: "ready" }); + await flushMicrotasks(); + expect(resolved).toBe(false); + + mocks[1]!.simulate({ type: "ready" }); + const experiment = await promise; + + expect(experiment.status.get()).toBe("Ready"); + experiment.dispose(); + }); + + it("merges metric frames across shards before publishing them", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 2, + }); + await flushMicrotasks(); + for (const mock of mocks) { + mock.simulate({ type: "ready" }); + } + const experiment = await promise; + + // One shard alone cannot finalise a frame — the other might still report it. + mocks[0]!.simulate({ type: "metricFrames", frames: [makeMetricFrame(0)] }); + expect(experiment.metrics.get().frames).toHaveLength(0); + + mocks[1]!.simulate({ type: "metricFrames", frames: [makeMetricFrame(0)] }); + + const frames = experiment.metrics.get().frames; + expect(frames).toHaveLength(1); + // Both shards contributed one run each at the same value. + expect(frames[0]!.runSampleCount).toBe(2); + + experiment.dispose(); + }); + + it("completes only after every shard completes", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 2, + }); + await flushMicrotasks(); + for (const mock of mocks) { + mock.simulate({ type: "ready" }); + } + const experiment = await promise; + const events = vi.fn(); + experiment.events.subscribe(events); + + mocks[0]!.simulate({ + type: "complete", + progress: makeProgress({ allFinished: true, completedRuns: 2 }), + }); + expect(experiment.status.get()).not.toBe("Complete"); + expect(events).not.toHaveBeenCalled(); + + mocks[1]!.simulate({ + type: "complete", + progress: makeProgress({ allFinished: true, completedRuns: 2 }), + }); + + expect(experiment.status.get()).toBe("Complete"); + expect(events).toHaveBeenCalledTimes(1); + // Run tallies sum across shards. + expect(experiment.progress.get()?.completedRuns).toBe(4); + expect(experiment.progress.get()?.allFinished).toBe(true); + + experiment.dispose(); + }); + + it("reports the slowest shard's position while any shard still runs", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 2, + }); + await flushMicrotasks(); + for (const mock of mocks) { + mock.simulate({ type: "ready" }); + } + const experiment = await promise; + + mocks[0]!.simulate({ + type: "progress", + progress: makeProgress({ frameNumber: 9, time: 9 }), + }); + mocks[1]!.simulate({ + type: "progress", + progress: makeProgress({ frameNumber: 3, time: 3 }), + }); + + // Merged metrics only extend as far as the slowest shard, so progress must + // not run ahead of the data behind it. + expect(experiment.progress.get()?.frameNumber).toBe(3); + + experiment.dispose(); + }); + + it("cancels every shard when one reports cancellation", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 3, + }); + await flushMicrotasks(); + for (const mock of mocks) { + mock.simulate({ type: "ready" }); + } + const experiment = await promise; + + mocks[0]!.simulate({ type: "cancelled", progress: makeProgress() }); + + expect(experiment.status.get()).toBe("Cancelled"); + for (const mock of mocks.slice(1)) { + expect(mock.sent.some((message) => message.type === "cancel")).toBe(true); + } + // Cancelled runs were abandoned, not finished. + expect(experiment.progress.get()?.allFinished).toBe(false); + + experiment.dispose(); + }); + + it("tears down surviving shards when one errors after start", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 2, + }); + await flushMicrotasks(); + for (const mock of mocks) { + mock.simulate({ type: "ready" }); + } + const experiment = await promise; + const events = vi.fn(); + experiment.events.subscribe(events); + + mocks[1]!.simulate({ type: "error", message: "boom", itemId: null }); + + expect(experiment.status.get()).toBe("Error"); + expect(events).toHaveBeenCalledWith({ + type: "error", + message: "boom", + itemId: null, + }); + expect(mocks[0]!.isTerminated()).toBe(true); + }); + + it("forwards start to every shard", async () => { + const { promise, mocks } = createShardedExperiment({ + runCount: 4, + shardCount: 2, + }); + await flushMicrotasks(); + for (const mock of mocks) { + mock.simulate({ type: "ready" }); + } + const experiment = await promise; + + experiment.start(); + + for (const mock of mocks) { + expect(mock.sent.some((message) => message.type === "start")).toBe(true); + } + expect(experiment.status.get()).toBe("Running"); + + experiment.dispose(); + }); +}); + describe("createMonteCarloExperiment", () => { it("sends init and resolves when the worker reports ready", async () => { const mock = makeMockTransport(); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts index 2eb0ff08fd4..7e5cc65f299 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts @@ -2,10 +2,15 @@ import { DEFAULT_PETRINAUT_EXTENSIONS } from "../../../extensions"; import { resolveNetParameterValues } from "../../../parameter-values"; import { createWorkerTransport } from "../../runtime/transport"; import { + createMonteCarloMetricShardMerger, createMonteCarloUserDefinedMetricConfigsFromSpecs, createMonteCarloUserDefinedMetric, } from "../metrics"; import { createMonteCarloSimulator } from "../monte-carlo-simulator"; +import { + getDefaultMonteCarloShardCount, + planMonteCarloShards, +} from "./shard-plan"; import type { AbortSignalLike } from "../../../environment"; import type { PetrinautExtensionSettings } from "../../../extensions"; @@ -29,6 +34,7 @@ import type { MonteCarloToMainMessage, MonteCarloWorkerProgress, } from "../worker/messages"; +import type { MonteCarloShardPlanEntry } from "./shard-plan"; export type MonteCarloExperimentState = | "Initializing" @@ -61,6 +67,16 @@ type CreateMonteCarloExperimentBaseConfig = { hirArtifacts?: HirArtifacts; runCount: number; batchSize?: number; + /** + * How many workers to split the runs across. + * + * Defaults to one per logical core minus one (capped at `runCount`). Runs are + * independent and seeds derive from the global run index, so shard count + * changes only how fast an experiment finishes, never what it reports. Only + * honoured for `createWorker` experiments — a caller-supplied `transport` is a + * single channel and always runs as one shard. + */ + shardCount?: number; signal?: AbortSignalLike; }; @@ -485,11 +501,28 @@ export function createMonteCarloExperiment( }); } - let transport: SimulationTransport; + // A caller-supplied transport is a single channel, so it cannot be sharded. + // `createWorker` can be called once per shard. + let shards: MonteCarloShardPlanEntry[]; + let transports: SimulationTransport[]; if ("transport" in config && config.transport !== undefined) { - transport = config.transport; + shards = [{ runIndexOffset: 0, runCount: config.runCount }]; + transports = [config.transport]; } else if ("createWorker" in config && config.createWorker !== undefined) { - transport = createWorkerTransport(config.createWorker); + const { createWorker } = config; + try { + shards = planMonteCarloShards( + config.runCount, + config.shardCount ?? getDefaultMonteCarloShardCount(config.runCount), + ); + } catch (error) { + return Promise.reject( + error instanceof Error + ? error + : new Error("Failed to plan Monte Carlo experiment shards"), + ); + } + transports = shards.map(() => createWorkerTransport(createWorker)); } else { return Promise.reject( new Error( @@ -497,6 +530,8 @@ export function createMonteCarloExperiment( ), ); } + + const shardCount = shards.length; const status = createReadableStore("Initializing"); const progress = createReadableStore(null); const metrics = createReadableStore( @@ -507,10 +542,98 @@ export function createMonteCarloExperiment( return new Promise((resolve, reject) => { let settled = false; - let off: (() => void) | null = null; + let offListeners: (() => void)[] = []; let abortListener: (() => void) | null = null; - const cleanupTransport = ({ sendCancel }: { sendCancel: boolean }) => { + const merger = createMonteCarloMetricShardMerger(shardCount); + const shardProgress = new Array( + shardCount, + ).fill(null); + const shardReady = new Array(shardCount).fill(false); + const shardSettled = new Array(shardCount).fill(false); + /** Shards that will produce no further metric frames, for any reason. */ + const shardFinished = new Array(shardCount).fill(false); + /** + * Shards whose runs all reached a terminal state. + * + * Distinct from `shardFinished`: a cancelled shard stops reporting but its + * runs were abandoned, not finished, so it must not make the experiment + * claim `allFinished`. + */ + const shardCompleted = new Array(shardCount).fill(false); + + const publishMetricFrames = ( + frames: readonly MonteCarloUserDefinedMetricFrame[], + ) => { + if (frames.length > 0) { + metrics.set(appendMetricFrames(metrics.get(), frames)); + } + }; + + /** + * Combines shard progress into one experiment-level view. + * + * Run tallies sum. Frame position reports the slowest shard still running, + * because that is how far the *merged* metric timeline actually extends — + * reporting the fastest shard would run the progress bar ahead of the data + * behind it. + */ + const publishProgress = () => { + let advancedRuns = 0; + let completedRuns = 0; + let erroredRuns = 0; + let activeRuns = 0; + let slowestFrameNumber = Number.POSITIVE_INFINITY; + let slowestTime = Number.POSITIVE_INFINITY; + let furthestFrameNumber = 0; + let furthestTime = 0; + let reported = false; + + for (let shard = 0; shard < shardCount; shard++) { + const current = shardProgress[shard]; + if (!current) { + continue; + } + + reported = true; + advancedRuns += current.advancedRuns; + completedRuns += current.completedRuns; + erroredRuns += current.erroredRuns; + activeRuns += current.activeRuns; + furthestFrameNumber = Math.max( + furthestFrameNumber, + current.frameNumber, + ); + furthestTime = Math.max(furthestTime, current.time); + + if (!shardFinished[shard]) { + slowestFrameNumber = Math.min( + slowestFrameNumber, + current.frameNumber, + ); + slowestTime = Math.min(slowestTime, current.time); + } + } + + if (!reported) { + return; + } + + const stillReporting = !shardFinished.every(Boolean); + + progress.set({ + advancedRuns, + completedRuns, + erroredRuns, + activeRuns, + allFinished: shardCompleted.every(Boolean), + runCount: config.runCount, + frameNumber: stillReporting ? slowestFrameNumber : furthestFrameNumber, + time: stillReporting ? slowestTime : furthestTime, + }); + }; + + const cleanupTransports = ({ sendCancel }: { sendCancel: boolean }) => { if (disposed) { return; } @@ -520,18 +643,21 @@ export function createMonteCarloExperiment( config.signal?.removeEventListener("abort", abortListener); abortListener = null; } - off?.(); - off = null; - - if (sendCancel) { - try { - transport.send({ type: "cancel" }); - } catch { - // Transport may already be torn down. + for (const off of offListeners) { + off(); + } + offListeners = []; + + for (const transport of transports) { + if (sendCancel) { + try { + transport.send({ type: "cancel" }); + } catch { + // Transport may already be torn down. + } } + transport.terminate(); } - - transport.terminate(); }; const rejectBeforeReady = (error: Error) => { @@ -540,7 +666,7 @@ export function createMonteCarloExperiment( } settled = true; - cleanupTransport({ sendCancel: false }); + cleanupTransports({ sendCancel: false }); reject(error); }; @@ -551,7 +677,7 @@ export function createMonteCarloExperiment( error.name = "AbortError"; reject(error); } - cleanupTransport({ sendCancel: true }); + cleanupTransports({ sendCancel: true }); }; abortListener = onAbort; @@ -566,61 +692,134 @@ export function createMonteCarloExperiment( return; } status.set("Running"); - transport.send({ type: "start" }); + for (const transport of transports) { + transport.send({ type: "start" }); + } }, cancel() { if (disposed) { return; } - transport.send({ type: "cancel" }); + for (const transport of transports) { + transport.send({ type: "cancel" }); + } }, dispose() { - cleanupTransport({ sendCancel: true }); + cleanupTransports({ sendCancel: true }); }, }; - off = transport.onMessage((rawMessage) => { - const message = rawMessage as MonteCarloToMainMessage; - - switch (message.type) { - case "ready": { - status.set("Ready"); - if (!settled) { - settled = true; - resolve(handle); + /** + * Marks a shard as done and, once every shard is, emits the terminal event. + * + * The experiment is only complete when all shards complete; a single + * cancellation or error is terminal for the whole experiment, matching the + * single-worker contract. + */ + const settleShard = ( + shardIndex: number, + outcome: "complete" | "cancelled", + ) => { + if (shardSettled[shardIndex]) { + return; + } + shardSettled[shardIndex] = true; + shardFinished[shardIndex] = true; + shardCompleted[shardIndex] = outcome === "complete"; + publishMetricFrames(merger.finishShard(shardIndex)); + + if (outcome === "cancelled") { + // One shard cancelling cancels the experiment; stop the rest so they + // do not keep burning cores on results nobody will read. + for (const transport of transports) { + try { + transport.send({ type: "cancel" }); + } catch { + // Transport may already be torn down. } - break; - } - case "metricFrames": { - metrics.set(appendMetricFrames(metrics.get(), message.frames)); - break; } - case "progress": - progress.set(message.progress); - break; - case "complete": - progress.set(message.progress); - status.set("Complete"); - events.emit({ type: "complete", progress: message.progress }); - break; - case "cancelled": - progress.set(message.progress); - status.set("Cancelled"); - events.emit({ type: "cancelled", progress: message.progress }); - break; - case "error": - status.set("Error"); - events.emit({ - type: "error", - message: message.message, - itemId: message.itemId, - }); - if (!settled) { - rejectBeforeReady(new Error(message.message)); - } - break; + publishProgress(); + status.set("Cancelled"); + events.emit({ type: "cancelled", progress: progress.get() }); + return; } - }); + + publishProgress(); + + if (shardSettled.every(Boolean)) { + // Nothing should be left buffered, but flush so a shard that stopped + // mid-frame cannot silently strand its last frames. + publishMetricFrames(merger.flush()); + const finalProgress = progress.get(); + status.set("Complete"); + events.emit({ + type: "complete", + progress: finalProgress ?? { + advancedRuns: 0, + completedRuns: 0, + erroredRuns: 0, + activeRuns: 0, + allFinished: true, + runCount: config.runCount, + frameNumber: 0, + time: 0, + }, + }); + } + }; + + offListeners = transports.map((transport, shardIndex) => + transport.onMessage((rawMessage) => { + const message = rawMessage as MonteCarloToMainMessage; + + switch (message.type) { + case "ready": { + shardReady[shardIndex] = true; + if (shardReady.every(Boolean)) { + status.set("Ready"); + if (!settled) { + settled = true; + resolve(handle); + } + } + break; + } + case "metricFrames": { + publishMetricFrames(merger.accept(shardIndex, message.frames)); + break; + } + case "progress": + shardProgress[shardIndex] = message.progress; + publishProgress(); + break; + case "complete": + shardProgress[shardIndex] = message.progress; + settleShard(shardIndex, "complete"); + break; + case "cancelled": + if (message.progress) { + shardProgress[shardIndex] = message.progress; + } + settleShard(shardIndex, "cancelled"); + break; + case "error": + status.set("Error"); + events.emit({ + type: "error", + message: message.message, + itemId: message.itemId, + }); + if (settled) { + // Already handed the caller a handle, so surface the error as an + // event and tear the remaining shards down. + cleanupTransports({ sendCancel: true }); + } else { + rejectBeforeReady(new Error(message.message)); + } + break; + } + }), + ); if (config.signal) { if (config.signal.aborted) { @@ -631,20 +830,24 @@ export function createMonteCarloExperiment( } try { - transport.send({ - type: "init", - sdcpn: config.sdcpn, - extensions: config.extensions, - initialMarking: config.initialMarking, - parameterValues: config.parameterValues, - seed: config.seed, - dt: config.dt, - maxTime: config.maxTime, - hirArtifacts: config.hirArtifacts, - runCount: config.runCount, - batchSize: config.batchSize, - metricSpecs: "metricSpecs" in config ? config.metricSpecs : undefined, - }); + for (const [shardIndex, transport] of transports.entries()) { + const shard = shards[shardIndex]!; + transport.send({ + type: "init", + sdcpn: config.sdcpn, + extensions: config.extensions, + initialMarking: config.initialMarking, + parameterValues: config.parameterValues, + seed: config.seed, + dt: config.dt, + maxTime: config.maxTime, + hirArtifacts: config.hirArtifacts, + runCount: shard.runCount, + runIndexOffset: shard.runIndexOffset, + batchSize: config.batchSize, + metricSpecs: "metricSpecs" in config ? config.metricSpecs : undefined, + }); + } } catch (error) { rejectBeforeReady( error instanceof Error diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/shard-plan.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/shard-plan.test.ts new file mode 100644 index 00000000000..1fe621a3320 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/shard-plan.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + getDefaultMonteCarloShardCount, + planMonteCarloShards, +} from "./shard-plan"; + +describe("planMonteCarloShards", () => { + it("splits runs evenly when they divide exactly", () => { + expect(planMonteCarloShards(1000, 4)).toStrictEqual([ + { runIndexOffset: 0, runCount: 250 }, + { runIndexOffset: 250, runCount: 250 }, + { runIndexOffset: 500, runCount: 250 }, + { runIndexOffset: 750, runCount: 250 }, + ]); + }); + + it("spreads the remainder across leading shards instead of one straggler", () => { + const plan = planMonteCarloShards(1003, 4); + + expect(plan.map((shard) => shard.runCount)).toStrictEqual([ + 251, 251, 251, 250, + ]); + // Slice sizes differ by at most one, so no shard becomes the run everyone + // else waits on. + expect(Math.max(...plan.map((shard) => shard.runCount))).toBe(251); + }); + + it("covers every run exactly once, contiguously", () => { + for (const [runCount, shardCount] of [ + [1000, 7], + [13, 5], + [100, 3], + ] as const) { + const plan = planMonteCarloShards(runCount, shardCount); + + let expectedOffset = 0; + for (const shard of plan) { + expect(shard.runIndexOffset).toBe(expectedOffset); + expect(shard.runCount).toBeGreaterThan(0); + expectedOffset += shard.runCount; + } + expect(expectedOffset).toBe(runCount); + } + }); + + it("never creates an empty shard when runs are scarcer than shards", () => { + const plan = planMonteCarloShards(3, 16); + + expect(plan).toHaveLength(3); + expect(plan.every((shard) => shard.runCount === 1)).toBe(true); + }); + + it("produces a single full slice for one shard", () => { + expect(planMonteCarloShards(500, 1)).toStrictEqual([ + { runIndexOffset: 0, runCount: 500 }, + ]); + }); + + it.each([ + [0, 4], + [-1, 4], + [1.5, 4], + [10, 0], + [10, -2], + ])("rejects runCount %s with shardCount %s", (runCount, shardCount) => { + expect(() => planMonteCarloShards(runCount, shardCount)).toThrow( + /requires a positive/, + ); + }); +}); + +describe("getDefaultMonteCarloShardCount", () => { + // `navigator` is getter-only in Node, so it has to be stubbed rather than + // assigned. + const withHardwareConcurrency = ( + value: number | undefined, + body: () => void, + ) => { + vi.stubGlobal( + "navigator", + value === undefined ? {} : { hardwareConcurrency: value }, + ); + try { + body(); + } finally { + vi.unstubAllGlobals(); + } + }; + + it("leaves one core for the main thread", () => { + withHardwareConcurrency(10, () => { + expect(getDefaultMonteCarloShardCount(1000)).toBe(9); + }); + }); + + it("never exceeds the run count", () => { + withHardwareConcurrency(10, () => { + expect(getDefaultMonteCarloShardCount(3)).toBe(3); + }); + }); + + it("still yields one shard on a single-core host", () => { + withHardwareConcurrency(1, () => { + expect(getDefaultMonteCarloShardCount(1000)).toBe(1); + }); + }); + + it("falls back to one shard when the host reports no core count", () => { + withHardwareConcurrency(undefined, () => { + expect(getDefaultMonteCarloShardCount(1000)).toBe(1); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/shard-plan.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/shard-plan.ts new file mode 100644 index 00000000000..c992da9fbcd --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/shard-plan.ts @@ -0,0 +1,91 @@ +/** + * Splitting an experiment's runs across worker shards. + * + * Runs are independent, so an experiment is parallelised by giving each worker a + * contiguous slice of the run range. Each slice carries its global start index + * so seeds stay tied to the run's position in the experiment rather than its + * position within a shard — which is what keeps results identical no matter how + * many shards are used. + */ + +export type MonteCarloShardPlanEntry = { + /** Index of this shard's first run within the whole experiment. */ + runIndexOffset: number; + /** How many runs this shard owns. Always at least 1. */ + runCount: number; +}; + +/** + * Reads the host's logical core count, where the host exposes one. + * + * Returns `null` in environments without the hint (some workers, older + * runtimes, SSR) so callers can fall back rather than guess. + */ +function detectHardwareConcurrency(): number | null { + const navigatorLike = ( + globalThis as { + navigator?: { hardwareConcurrency?: number }; + } + ).navigator; + const reported = navigatorLike?.hardwareConcurrency; + + return typeof reported === "number" && + Number.isFinite(reported) && + reported > 0 + ? Math.floor(reported) + : null; +} + +/** + * Default shard count for an experiment of `runCount` runs. + * + * One core is left for the main thread so the editor stays responsive while an + * experiment runs, and the count never exceeds the number of runs — an empty + * shard would have nothing to simulate. Falls back to a single shard when the + * host reports no core count, matching the previous single-worker behaviour. + */ +export function getDefaultMonteCarloShardCount(runCount: number): number { + const cores = detectHardwareConcurrency(); + if (cores === null) { + return 1; + } + + return Math.max(1, Math.min(runCount, cores - 1)); +} + +/** + * Splits `runCount` runs into at most `shardCount` contiguous slices. + * + * The remainder is spread one run at a time across the leading shards rather + * than piled onto the last one, so slice sizes differ by at most one and no + * single shard becomes the straggler that the whole experiment waits on. + * + * @throws Error if `runCount` or `shardCount` is not a positive integer. + */ +export function planMonteCarloShards( + runCount: number, + shardCount: number, +): MonteCarloShardPlanEntry[] { + if (!Number.isInteger(runCount) || runCount <= 0) { + throw new Error("Monte Carlo shard planning requires a positive runCount"); + } + if (!Number.isInteger(shardCount) || shardCount <= 0) { + throw new Error( + "Monte Carlo shard planning requires a positive shardCount", + ); + } + + const shards = Math.min(runCount, shardCount); + const baseSize = Math.floor(runCount / shards); + const remainder = runCount % shards; + + const plan: MonteCarloShardPlanEntry[] = []; + let runIndexOffset = 0; + for (let shard = 0; shard < shards; shard++) { + const size = baseSize + (shard < remainder ? 1 : 0); + plan.push({ runIndexOffset, runCount: size }); + runIndexOffset += size; + } + + return plan; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts index 660f6ff6522..79e3ce0b92f 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts @@ -27,6 +27,18 @@ export type MonteCarloSimulatorConfig = { dt: number; maxTime: number; runs?: readonly MonteCarloRunConfig[]; + /** + * Index of this simulator's first run within a larger logical experiment. + * + * Default seeds derive from `runIndexOffset + localIndex`, so an experiment + * split across several simulators (one per worker shard) gives run *i* the + * same seed regardless of which shard owns it. Shard layout therefore never + * changes an experiment's results. + * + * `runs[].seed`, `getRunSummary(index)` and `MonteCarloRunSummary.index` all + * stay shard-local; only seed derivation uses the global index. + */ + runIndexOffset?: number; /** Initial token region capacity per run, in bytes. */ initialTokenByteCapacity?: number; metrics?: readonly MonteCarloFrameMetric[]; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts index 4af3a65a41b..ef3cbdf196f 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts @@ -20,7 +20,15 @@ export type MonteCarloInitMessage = { /** Precompiled HIR artifacts (`compileHirArtifacts`) — required for any * dynamics/lambda/kernel user code in the net. */ hirArtifacts?: HirArtifacts; + /** Number of runs this worker owns — a slice of the experiment when sharded. */ runCount: number; + /** + * Index of this worker's first run within the whole experiment. + * + * Seeds derive from the global index, so an experiment sharded across workers + * runs the same set of seeds as an unsharded one. + */ + runIndexOffset?: number; batchSize?: number; metricSpecs?: readonly MonteCarloMetricSpec[]; }; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts index 2abfad23f08..7fc2cda0ed3 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts @@ -166,6 +166,7 @@ function initialize(message: MonteCarloInitMessage): void { maxTime: message.maxTime, hirArtifacts: message.hirArtifacts, runCount: message.runCount, + runIndexOffset: message.runIndexOffset, metrics: userMetrics, }); batchSize = message.batchSize ?? DEFAULT_BATCH_SIZE; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts index e2de82ea7a3..48ce9aa0716 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.simulation.runtime + * @role Host-side controller for a run — owns the transport, the frame store and the status streams + */ + import { DEFAULT_PETRINAUT_EXTENSIONS, sanitizeSDCPNForExtensions, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md b/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md index 4184d8900f2..45f67f0b638 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md @@ -1,3 +1,8 @@ +--- +layer: core.simulation.worker +role: Computes simulation frames off the main thread under host backpressure +--- + # Simulation Worker Worker runtime for off-main-thread SDCPN simulation computation. diff --git a/libs/@hashintel/petrinaut-core/src/store/index.ts b/libs/@hashintel/petrinaut-core/src/store/index.ts index c319b60aae6..aa4d4444906 100644 --- a/libs/@hashintel/petrinaut-core/src/store/index.ts +++ b/libs/@hashintel/petrinaut-core/src/store/index.ts @@ -1 +1,6 @@ +/** + * @layerRoot core.store + * @role Minimal subscribable store primitive the core exposes instead of a framework dependency + */ + export { createReadableStore, type ReadableStore } from "./readable-store"; diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 99bcbcee03c..78cb73420e6 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.types + * @role The canonical TypeScript types describing an SDCPN document + */ + export type ID = string; export type ColorElementType = diff --git a/libs/@hashintel/petrinaut-core/src/validation/README.md b/libs/@hashintel/petrinaut-core/src/validation/README.md index 1ff45ab267f..f383896fb6c 100644 --- a/libs/@hashintel/petrinaut-core/src/validation/README.md +++ b/libs/@hashintel/petrinaut-core/src/validation/README.md @@ -1,3 +1,8 @@ +--- +layer: core.validation +role: Structural integrity validators for SDCPN entities, enforcing naming conventions +--- + # validation/ Structural integrity validators for SDCPN entities. These enforce naming diff --git a/libs/@hashintel/petrinaut-core/src/workers/README.md b/libs/@hashintel/petrinaut-core/src/workers/README.md new file mode 100644 index 00000000000..9e01e0793f2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/workers/README.md @@ -0,0 +1,20 @@ +--- +layer: core.workers +role: The module entry points hosts instantiate as Web Workers +--- + +# Worker entry points + +One file per worker the host can spawn. Each is a thin entry point wiring a +message port to the runtime that does the work, holding no logic of its own, so +the runtimes stay testable on the main thread. + +| Entry point | Runtime it hosts | +| ---------------- | ----------------------------------- | +| `lsp.ts` | the language server | +| `simulation.ts` | frame computation for a single run | +| `monte-carlo.ts` | batched runs reporting only metrics | + +Separate export subpaths rather than one worker, so a host pays only for the +threads it uses — an editor with no experiments open never loads the Monte +Carlo runtime. diff --git a/libs/@hashintel/petrinaut/ARCHITECTURE.md b/libs/@hashintel/petrinaut/ARCHITECTURE.md index 5d5ce442d82..a3d88ab81d5 100644 --- a/libs/@hashintel/petrinaut/ARCHITECTURE.md +++ b/libs/@hashintel/petrinaut/ARCHITECTURE.md @@ -2,9 +2,9 @@ How `@hashintel/petrinaut`'s React layer consumes the headless simulation runtime from `@hashintel/petrinaut-core`. The core's architecture (engine, -frame format, worker protocol, Monte Carlo) is documented in -[`petrinaut-core/docs/architecture/`](../petrinaut-core/docs/architecture/index.html) -— this page covers only the React side of the boundary. +frame format, worker protocol, Monte Carlo) is documented in the architecture +docs, which #9206 adds a site for, and this page +covers only the React side of the boundary. > This file is internal engineering documentation. It deliberately does NOT > live in `docs/` — that folder is the end-user guide, consumed at runtime by diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 2337d8ed2f5..54a947c8c41 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -32,20 +32,31 @@ Experiments progress through five status labels: | Status | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------- | -| **Initializing** | The experiment has been created and the worker is starting up. | +| **Initializing** | The experiment has been created and its workers are starting up. | | **Running** | Runs are in progress. | | **Complete** | All runs finished without error. | | **Error** | The experiment failed to start or hit an unrecoverable error. The drawer shows the error message. | -| **Cancelled** | You clicked **Cancel**, or the worker was cancelled. | +| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | -Each experiment runs in its own background Web Worker, so simulation playback and editor interactions stay responsive. Multiple experiments can run concurrently. +Experiments run in background Web Workers, so simulation playback and editor interactions stay responsive. Multiple experiments can run concurrently. + +### Parallel runs + +Because every run is independent, an experiment splits its runs across several workers and runs them at the same time -- by default one worker per processor core, leaving one core free so the editor stays responsive. A 1000-run experiment on an 8-core machine runs roughly 4x faster than it would on a single worker. + +This never changes an experiment's results. Each run's seed is derived from its position in the experiment rather than from which worker happens to execute it, and the per-frame statistics from each worker are combined into the same distributions you would get from running everything sequentially. Re-running an experiment with the same configuration and seed gives the same numbers regardless of how many cores the machine has. + +Two consequences worth knowing: + +- Progress reports the slowest worker's position, so the progress bar never runs ahead of the results behind it. +- Several experiments running at once each use the same number of workers, so they compete for cores and all of them slow down. Run them one at a time if you want any single one to finish as fast as possible. ### Actions In the experiment's view drawer (open it by clicking a row in the list, or any experiment in the top-bar **Active experiments** popover): - **Cancel** -- stops the experiment. Only available while it is initializing or running. -- **Remove** -- deletes the record and disposes the worker. Available after completion, cancellation, or error. +- **Remove** -- deletes the record and disposes the experiment's workers. Available after completion, cancellation, or error. - **Close** -- closes the drawer without affecting the experiment. There is no built-in restart action -- to re-run with the same configuration, **Create** a new experiment with the same settings. diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index afd1269f459..05f4ca7d38f 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -1,3 +1,15 @@ +/** + * Public surface for `@hashintel/petrinaut` — the host-facing entry point. + * + * Re-exports the handful of contexts and types a host needs to embed the editor + * and inject its own capabilities (error tracking, optimization, slots). The + * editor itself is reached through `/ui`, and the React bindings through + * `/react`. + * + * @layerRoot petrinaut + * @role The host-facing entry point: the contexts and types an embedder wires up + */ + export type { ErrorTracker } from "./react/error-tracker-context"; export { ErrorTrackerContext } from "./react/error-tracker-context"; export type { PetrinautOptimization } from "./react/optimization-context"; diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx index b44171305fa..4cf11ca1b3f 100644 --- a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.execution-frame + * @role Abstracts where frames come from, so canvas and timeline work for live runs and recordings alike + */ + import { use, useState, type FC, type PropsWithChildren } from "react"; import { diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index 0a3ffb9cde9..653102d60c8 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -77,6 +77,11 @@ function makeMetricFrame(): MonteCarloUserDefinedMetricFrame { timeValue: null, runSampleCount: 2, timeSampleCount: 1, + // Carried on every scalar frame so a sharded experiment can merge across + // shards before reducing. + runAggregate: { count: 2, sum: 2, min: 1, max: 1, last: 1 }, + aggregateRuns: "mean", + aggregateTime: "none", }; } @@ -208,6 +213,9 @@ const TestWrapper = ({ MonteCarloToMainMessage > } + // One shard, so a single fake worker stands in for the whole + // experiment. Sharding itself is covered in petrinaut-core. + experimentShardCount={1} > diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index ed7aff3caa0..dcf2d4c7079 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.experiments + * @role Tracks Monte Carlo experiment handles and their streamed metric results + */ + import { use, useEffect, useRef, useState } from "react"; import { v4 as generateUuid } from "uuid"; @@ -30,6 +35,15 @@ import { type ExperimentsProviderProps = React.PropsWithChildren<{ workerFactory?: WorkerFactory; + /** + * How many workers each experiment splits its runs across. + * + * Defaults to one per logical core minus one. Sharding never changes an + * experiment's results, only how quickly it finishes, so this exists for hosts + * that need to cap CPU use (or pin behaviour in tests) rather than to affect + * output. + */ + experimentShardCount?: number; }>; type ExperimentHandleRegistration = { @@ -158,6 +172,7 @@ function assertExperimentInput(input: CreateExperimentInput): void { export const ExperimentsProvider: React.FC = ({ children, workerFactory, + experimentShardCount, }) => { const { extensions, petriNetDefinition } = use(SDCPNContext); const { requestHirArtifacts } = use(LanguageClientContext); @@ -165,6 +180,7 @@ export const ExperimentsProvider: React.FC = ({ const petriNetDefinitionRef = useLatest(petriNetDefinition); const extensionsRef = useLatest(extensions); const workerFactoryRef = useLatest(workerFactory ?? createMonteCarloWorker); + const shardCountRef = useLatest(experimentShardCount); const registrationsRef = useRef( new Map(), ); @@ -428,6 +444,9 @@ export const ExperimentsProvider: React.FC = ({ const handle = await createMonteCarloExperiment({ ...experimentConfigBase, createWorker: workerFactoryRef.current, + ...(shardCountRef.current === undefined + ? {} + : { shardCount: shardCountRef.current }), metricSpecs, signal: abortController.signal, }); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index 69fd67a3a96..bc7f78ec22e 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot react.hooks + * @role Cross-cutting hooks over the providers — documents, parameters, window lifecycle + */ + // Public hook surface for `@hashintel/petrinaut/react`. // // Each hook reads from an existing React context (SDCPN, Simulation, Playback, diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index 67ebe507cde..c134c7521a2 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -1,7 +1,12 @@ -// Public surface for `@hashintel/petrinaut/react` — React bindings. -// -// Hooks, contexts, and bridge providers that synchronize a Core instance with -// React. No visual widgets — `/ui` builds on top of this. +/** + * Public surface for `@hashintel/petrinaut/react` — React bindings. + * + * Hooks, contexts, and bridge providers that synchronize a Core instance with + * React. No visual widgets — `/ui` builds on top of this. + * + * @layerRoot react + * @role Contexts, hooks and providers that mirror core state into React + */ // --- Instance access + low-level adapters --- export { PetrinautInstanceContext } from "./instance-context"; diff --git a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx index 0cf2e64836c..72d5cd1c03d 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.lsp + * @role Exposes the core language client to the editor as React context + */ + import { use, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { diff --git a/libs/@hashintel/petrinaut/src/react/playback/README.md b/libs/@hashintel/petrinaut/src/react/playback/README.md index b3ad0e91cd7..21d78175571 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/README.md +++ b/libs/@hashintel/petrinaut/src/react/playback/README.md @@ -1,3 +1,8 @@ +--- +layer: react.playback +role: Drives the viewed frame with a requestAnimationFrame loop and applies the per-mode ack policy +--- + # Playback Module React context for viewing simulation frames at controlled speeds. diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index fb3f4abb714..1c52de2a836 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.simulation + * @role Owns the run configuration and mirrors the core simulation handle into React + */ + import { use, useEffect, useRef, useState } from "react"; import { diff --git a/libs/@hashintel/petrinaut/src/react/state/README.md b/libs/@hashintel/petrinaut/src/react/state/README.md new file mode 100644 index 00000000000..043910eb86b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/README.md @@ -0,0 +1,11 @@ +--- +layer: react.state +role: Contexts owning editor-session state — active net, selection, settings, undo/redo, read-only mode +--- + +State belonging to an editing session rather than to a document or a run. + +Read-only is the load-bearing piece: simulate mode locks editing except for an +allow-list, and `use-is-read-only` is the single source of that answer. +Components ask rather than working it out from mode flags, because a component +that guesses will eventually guess differently from its neighbour. diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index 9bbd5a12027..fa27bb932fe 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -1,8 +1,13 @@ -// Public surface for `@hashintel/petrinaut/ui` — the opinionated visual editor. -// -// `` is the single editor entry: it takes a -// `PetrinautDocHandle` and renders the full editor on top of -// `` (`/react`). +/** + * Public surface for `@hashintel/petrinaut/ui` — the opinionated visual editor. + * + * `` is the single editor entry: it takes a + * `PetrinautDocHandle` and renders the full editor on top of + * `` (`/react`). + * + * @layerRoot ui + * @role The visual editor: canvas, panels, dialogs and the Monaco integration + */ export { Petrinaut } from "./petrinaut"; export type { PetrinautAiMessage } from "./views/Editor/panels/ai-assistant-panel"; diff --git a/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx index 3fef0372d82..f654712d86b 100644 --- a/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot ui.monaco + * @role Wires the Monaco editor to the language server for authoring user code + */ + import { CompletionSync } from "./completion-sync"; import { MonacoContext } from "./context"; import { DiagnosticsSync } from "./diagnostics-sync"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index a32c21a68b0..1414f4da523 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot ui.views.editor + * @role Arranges the panels, toolbars and dialogs around the canvas + */ + import { use, useState } from "react"; import { type MenuItem } from "@hashintel/ds-components"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/README.md b/libs/@hashintel/petrinaut/src/ui/views/README.md new file mode 100644 index 00000000000..8293e3d2da6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/README.md @@ -0,0 +1,8 @@ +--- +layer: ui.views +role: The top-level screens the editor composes — the editor shell and the net canvas +--- + +Each subfolder is a screen rather than a widget. The split matters because the +canvas is reused outside the full editor — Actual mode renders a net with no +editing affordances — so it cannot depend on the editor shell around it. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 611d0eef4fe..c618b18fcfc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot ui.views.canvas + * @role Renders the net as an interactive graph, with node and arc interaction + */ + import "@xyflow/react/dist/style.css"; import { Background, ReactFlow, SelectionMode } from "@xyflow/react"; import { diff --git a/libs/@local/petrinaut-arch-docs/.gitignore b/libs/@local/petrinaut-arch-docs/.gitignore new file mode 100644 index 00000000000..5a2f7068984 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/.gitignore @@ -0,0 +1,9 @@ +# Build output. Regenerate with `mise run doc:architecture`. +# +# Not versioned: it is derived entirely from the annotations in the source and +# the authored pages in `content/`, so committing it would mean reviewing the +# same change twice and resolving conflicts in generated files. +bundle/ + +# Scratch copies from mutation-testing runs. Never part of the package. +.mut/ diff --git a/libs/@local/petrinaut-arch-docs/.oxlintrc.json b/libs/@local/petrinaut-arch-docs/.oxlintrc.json new file mode 100644 index 00000000000..979f705f360 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/.oxlintrc.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../node_modules/oxlint/configuration_schema.json", + "plugins": ["import", "unicorn", "typescript"], + "categories": { + "correctness": "error" + }, + "env": { + "builtin": true, + "es2026": true + }, + "rules": { + "default-case-last": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "no-console": "error", + "no-param-reassign": ["error", { "props": true }], + "no-shadow": "error", + "no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_+", + "varsIgnorePattern": "^_+" + } + ], + "no-use-before-define": "error", + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-mutable-exports": "error", + "import/no-self-import": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "unicorn/filename-case": "error" + }, + "ignorePatterns": ["bundle/**", "content/**", ".turbo/**"] +} diff --git a/libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md b/libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md new file mode 100644 index 00000000000..4b43328a923 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md @@ -0,0 +1,189 @@ +# Apache License + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +- **(a)** You must give any other recipients of the Work or Derivative Works a copy of + this License; and +- **(b)** You must cause any modified files to carry prominent notices stating that You + changed the files; and +- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, + all copyright, patent, trademark, and attribution notices from the Source form + of the Work, excluding those notices that do not pertain to any part of the + Derivative Works; and +- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any + Derivative Works that You distribute must include a readable copy of the + attribution notices contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, in at least one of the + following places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if provided along + with the Derivative Works; or, within a display generated by the Derivative + Works, if and wherever such third-party notices normally appear. The contents of + the NOTICE file are for informational purposes only and do not modify the + License. You may add Your own attribution notices within Derivative Works that + You distribute, alongside or as an addendum to the NOTICE text from the Work, + provided that such additional attribution notices cannot be construed as + modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: Apply the Apache License to a specific file + +To apply the Apache License to an individual file, attach the following notice. +The text should be enclosed in the appropriate comment syntax for the file +format. + + Copyright © 2025–, HASH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/libs/@local/petrinaut-arch-docs/LICENSE-MIT.md b/libs/@local/petrinaut-arch-docs/LICENSE-MIT.md new file mode 100644 index 00000000000..d85585ee20d --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/LICENSE-MIT.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright © 2025–, HASH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/@local/petrinaut-arch-docs/LICENSE.md b/libs/@local/petrinaut-arch-docs/LICENSE.md new file mode 100644 index 00000000000..6dad94c0e5a --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/LICENSE.md @@ -0,0 +1,3 @@ +# License + +Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE.md) or [MIT license](LICENSE-MIT.md) at your option. diff --git a/libs/@local/petrinaut-arch-docs/README.md b/libs/@local/petrinaut-arch-docs/README.md new file mode 100644 index 00000000000..34a9f52e476 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/README.md @@ -0,0 +1,285 @@ +# `@local/petrinaut-arch-docs` + +Generates the Petrinaut architecture documentation from annotations in the +source, and bundles it with hand-written MDX into one portable artefact. + +```sh +# Regenerate the bundle after changing annotations or code +yarn workspace @local/petrinaut-arch-docs doc:architecture + +# Check the annotations without writing anything +yarn workspace @local/petrinaut-arch-docs lint:arch-docs +``` + +## Why this exists + +Architecture docs rot because nothing fails when they stop being true. Two +specific failures motivated this package: + +- `petrinaut-core/scripts/generate-dependency-diagrams.mjs` held the architecture + as ~180 lines of `if (path.startsWith("simulation/monte-carlo/"))` mappings, + far from the code they described, with a fallback that silently mis-bucketed + anything renamed. It also hard-coded seven of `petrinaut-core`'s ten entry + points, so imports through `./ai`, `./optimization` and `./compiled-model` + were missing from the diagram entirely. +- `petrinaut-core/docs/architecture/*.html` was 3,100 lines of hand-written HTML + that nothing verified against the code. #9205 moves its content into authored + MDX under `content/simulation/`, where it sits beside the generated pages. + +Here the architecture is declared next to the code it describes, and the build fails +when a declaration stops matching reality. + +## Declaring a layer + +A declaration is two lines — an id and a one-line role: + +```ts +/** + * @layerRoot core.simulation.monte-carlo + * @role Runs many simulations with bounded frame memory + */ +``` + +That is the whole vocabulary. `@layerRoot` names the layer this folder _and its +descendants_ form; `@role` says what it is for. Between them they place a node in +the graph and label it, which is the whole of what these docs assert — anything +more would be a claim the generator cannot check. + +Tags are read from any block comment, only at the start of a line, so mentioning +`@layerRoot` in prose declares nothing. A tag's text wraps across lines until the +next tag or a blank line. Any other tag is ignored — `@param`, `@deprecated` +and the rest are someone else's business — except a miscasing of one of these +two, which is reported as a probable typo. + +### Declaring from a README instead + +A folder `README.md` can declare the same thing in frontmatter, and its prose +becomes the layer's page body — so a folder README that already explains itself +becomes an architecture page for free. Use it when the folder has real prose to +carry, or when no single file is the obvious host. Otherwise prefer the doc +comment: it needs no new file. + +```yaml +--- +layer: core.simulation.monte-carlo +role: Runs many simulations with bounded frame memory +--- +``` + +`layer` and `role` are the only keys, and an unknown one alongside them fails +the build rather than being ignored — a misspelled `role` would otherwise leave +the layer with no responsibility statement and no complaint. + +Use one or the other on a folder, never both. A README with no `layer` key is +left alone as an ordinary document, and is linked from its layer page under +"Further reading". + +Layer ids are dotted and hierarchical, and every ancestor must itself be +declared — `core.simulation.monte-carlo` requires `core.simulation` and `core`. + +### Inheritance is what keeps this small + +A file with no tags belongs to the nearest ancestor folder that declares a layer. +That is why 37 declarations cover 413 files: you declare a layer where the +architecture actually changes, not on every file. + +## The output: a portable bundle + +Written to `bundle/`, which is **git-ignored build output** — it is derived +entirely from the annotations and from `content/`, so committing it would mean +reviewing the same change twice and resolving conflicts in generated files. +Regenerate it whenever you need it; nothing depends on a stored copy. + +The bundle is framework-neutral by design — the Starlight site in +`apps/petrinaut-docs` and hash.dev are both just consumers. + +| File | What it is | +| ------------------- | ---------------------------------------------------------------- | +| `architecture.json` | The model: layers, edges, enforced rules | +| `architecture.md` | The whole architecture as one file — the cheapest read for an AI | +| `manifest.json` | Page tree for building navigation without crawling `pages/` | +| `pages/**.mdx` | Generated layer pages, plus authored pages merged in | +| `components/*.tsx` | React diagram components imported by authored pages | +| `diagrams/**.d2` | Diagram sources (diffable) | +| `diagrams/**.svg` | Rendered diagrams | + +**Generated** MDX is YAML frontmatter plus plain CommonMark — no JSX, no +imports, no framework components — which is what lets it render in Astro, in +hash.dev's Next.js MDX pipeline, and as plain text. + +**Authored** pages may additionally import the diagram components below, which is +where every requirement the bundle places on a host comes from: + +| A host must provide | For | +| ---------------------------- | -------------------------------------------------- | +| A React-capable MDX pipeline | Any authored page that imports a diagram component | + +Nothing else — in particular nothing hydrates, so no client-side runtime is +required. In particular the bundle asks for **no Markdown or Rehype +plugins** — a host renders it with its Markdown pipeline exactly as configured, +which is what keeps "render the bundle" a small job rather than a negotiation. + +### Embedding the bundle elsewhere + +A host reads `manifest.json`, maps each page's `slug` onto its own URL space, and +renders `pages/`. + +One contract to honour: **links between generated pages are relative and assume +slugs map to URLs without a trailing slash.** A host that serves +`/architecture/core/simulation/` rather than `/architecture/core/simulation` +must rewrite them; `manifest.json` gives you every slug to do so. The Starlight +consumer sets `trailingSlash: "never"` for this reason. + +## Diagrams + +Diagrams are D2, rendered to SVG at build time. Three kinds, each bounding its +node count a different way, because a node-link diagram stops being readable +somewhere around twenty boxes: + +| Diagram | Shows | Bounded by | +| ------------- | ----------------------------------------------- | ------------------ | +| `overview` | The top-level layers | Roots | +| `around/` | What a layer depends on, and what depends on it | The layer's degree | +| `within/` | A layer's direct children | Its fan-out | + +Every layer gets an `around/` diagram, leaves included — those are where readers +land, and "what does this touch" is the question they arrive with. Only layers +with sub-layers get a `within/` one. + +A neighbourhood draws only edges _incident to the focus_. Edges among the +neighbours are real but belong to those layers' own pages; drawing them rebuilds +the tangle the overview exists to avoid. + +Aggregation never invents a dependency: an edge appears because imports exist, +and its count sums real `fileDependencies`. Neighbours are capped at twelve, and +the remainder becomes a single dashed "+N further layers" node carrying their +combined count — elided where it would be unreadable, never dropped where it +would read as absent. + +Names are namespaced by directory rather than by prefix: a layer id is unique +only among layer ids, so a flat `around-` would collide with a top-level +layer actually called `around-something`. + +## Hand-written pages (optional) + +`content/` is entirely optional. With no `content/` directory at all, the +generator emits a bundle of generated pages only, and the docs site renders it — +`/` redirects to the generated overview instead of an authored home page. Add +pages when you have something to say that an import graph cannot express; delete +them freely. + +Anything in `content/` is copied into the bundle and merged into the same +manifest as the generated pages. Slugs mirror the directory layout; `title`, +`description` and `sidebar_order` come from frontmatter. + +### Attaching a page to a layer + +By default an authored page sits at the top level, as a standalone narrative +entry. Add `attachTo` and it moves _inside_ the generated tree instead, beneath +the page for the layer it explains: + +```yaml +--- +title: Memory model +description: Where simulation state actually lives. +attachTo: core.simulation # a layer declared in the source +sidebar_order: 10 +--- +``` + +The page's slug becomes `architecture/core/simulation/memory-model`, the layer's +page gains a **Guides** section linking to it, and any host that nests by slug +shows the guide beside the generated reference for the same code. + +`attachTo` is not a layer declaration — it references a layer declared in a +package, and the build fails if that layer does not exist. Declaring layers from +`content/` remains forbidden. + +Generated pages occupy `sidebar_order` 1000 and above, so within a layer the +attached guides (low numbers) sort ahead of its sub-layers. + +### Diagram components + +`content/components/` holds React components that authored pages import: + +```mdx +import { ByteMap } from "@diagrams/byte-map"; + + +``` + +The `@diagrams/` alias is rewritten to a real relative path at emit time, for +the same reason as `layer:` and `doc:` — a page's depth depends on `attachTo`. +The components ship _inside_ the bundle (`components/`), so a host renders them +from the artefact rather than needing its own copy. + +Two rules keep them portable, and both are load-bearing: + +- **Plain React, no dependencies.** No design system, no Astro, no `next/*`. + Styling lives in `components/diagram.css`, which derives its colours from the + host's `currentColor` so it works on light and dark themes it has never seen. +- **String props, never JSX.** JSX written inside MDX is compiled by the _host's_ + MDX renderer, and passing that to a React component fails at render. Props are + strings, and `` `backticks` `` render as ``. + +This is the one place the bundle asks something of its host: rendering these +pages needs a React-capable MDX pipeline. Generated pages remain plain +CommonMark and need nothing, and `architecture.md` — the single-file artefact +for agents — contains no components at all. + +### Linking between pages + +Because `attachTo` decides where a page ends up, an authored page cannot know its +own depth and so cannot write a correct relative link by hand. Name the target +instead, and the generator computes the path: + +| Syntax | Resolves to | +| -------------------------------------- | --------------------------------------------------- | +| `[text](layer:core.simulation.engine)` | that layer's generated page | +| `[text](doc:simulation/memory-model)` | another authored page, by its path under `content/` | + +Fragments are preserved (`layer:core.hir#sub-layers`). A target that does not +resolve is a build error rather than a broken link nobody notices. Ordinary +relative and absolute links are left untouched, so a top-level page that will +never move can still use them. + +Authored pages carry the reasoning no import graph can supply: why a boundary is +where it is, and what was tried before. They may not declare a layer; layer +declarations belong in the packages. + +## What the checks enforce + +These run on every build of the bundle: `doc:architecture` refuses to write while +any of them fails. `lint:arch-docs` runs the same checks and reports without +writing, which is the form to reach for in a pre-commit hook or a CI step. Either +fails on: + +- a source file that no declaration covers +- a source file the import graph reached that no layer claims, which means the + extractor and the graph disagree about what is in scope +- a layer whose dotted id implies an undeclared ancestor +- a duplicate layer id, or two declarations on one folder +- a duplicated singular tag, a tag with no value, or an unknown key in a + declaring README +- a package configured for a language with no extractor +- an `exports` subpath with no resolvable source entry, since imports through it + would be missing from the graph +- a rule naming a layer that does not exist, which would leave it inert +- a dependency violating a rule in `architecture.config.ts` +- an `attachTo` naming a layer that does not exist +- a `layer:` or `doc:` link target that does not resolve +- an `@diagrams/` import naming a component that does not exist + +The last four in the graph group exist because each failure removes coverage +rather than adding a visible error. An `exports` subpath that stops resolving, or +a rule with a typo, leaves a build that passes while checking less than it +claims. + +Warnings (reported, non-fatal): a layer with no files and no sub-layers, an +`exports` subpath with no resolvable source entry. + +## Adding a package + +Add it to `packages` in `architecture.config.ts`, declare a root layer in its +source, and run a build. `sourceDirectory` defaults to `src`, so build +configuration outside it is deliberately not part of any layer. diff --git a/libs/@local/petrinaut-arch-docs/architecture.config.ts b/libs/@local/petrinaut-arch-docs/architecture.config.ts new file mode 100644 index 00000000000..9f7c183b23b --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/architecture.config.ts @@ -0,0 +1,107 @@ +/** + * What the architecture bundle covers, and what it forbids. + * + * This file holds only things that genuinely cannot be read from the code: + * which packages participate, what to skip, and the layer-crossing rules. The + * layer taxonomy itself deliberately lives *in* the packages, as README + * frontmatter and `@layerRoot` annotations — that is the whole point of the + * exercise. Resist the temptation to add a path→layer mapping here. + */ + +import type { ArchitecturePackageInput } from "./src/model"; + +export interface LayerRule { + /** Layer id or ancestor prefix the rule applies to. */ + from: string; + /** Layer id or ancestor prefix that must not be reached. */ + to: string; + reason: string; +} + +export interface ArchitectureConfig { + packages: ArchitecturePackageInput[]; + rules: LayerRule[]; + ignoredDirectories: string[]; + ignoredFilePattern: RegExp; + /** Where the generated bundle is written, repo-relative. */ + outputDirectory: string; + /** Where hand-written MDX is read from, repo-relative. */ + contentDirectory: string; + /** Base URL for source links in generated pages. */ + sourceUrlPrefix: string; +} + +export const config: ArchitectureConfig = { + packages: [ + { + name: "@hashintel/petrinaut-core", + path: "libs/@hashintel/petrinaut-core", + description: + "Headless SDCPN engine: document model, HIR compiler, simulation runtimes, LSP. No React, no DOM.", + language: "typescript", + }, + { + name: "@hashintel/petrinaut", + path: "libs/@hashintel/petrinaut", + description: + "React editor built on the headless core: providers, canvas, panels, Monaco integration.", + language: "typescript", + }, + ], + + /** + * Each rule is a claim the drift check enforces against the real import + * graph, so adding one here without fixing the code fails CI. A rule matches + * an edge when the edge's endpoints are the named layer or a descendant of it. + */ + rules: [ + { + from: "core", + to: "react", + reason: + "the headless core is published without React and must stay usable from Node and workers", + }, + { + from: "core", + to: "ui", + reason: "the headless core must not reach into editor components", + }, + { + from: "core", + to: "petrinaut", + reason: + "the core is the lower package of the pair and cannot depend on its consumer", + }, + { + from: "react", + to: "ui", + reason: + "state providers must not depend on the components that render them, so the React layer stays testable without mounting the editor", + }, + ], + + ignoredDirectories: [ + "node_modules", + "dist", + "__fixtures__", + "__snapshots__", + "docs", + ], + + /** + * Tests, stories and ambient declarations are excluded: they describe the + * architecture's *use*, not its shape, and including them inflates every + * layer's file count with fixtures. + */ + ignoredFilePattern: + /(?:\.(?:test|spec|stories)\.[cm]?[jt]sx?$|\.d\.ts$|\/CHANGELOG\.md$|\/LICENSE[^/]*\.md$)/u, + + /** + * The bundle is the product; the Starlight site in `apps/petrinaut-docs` and + * hash.dev are both just consumers of it. It therefore lives with the + * generator that owns its schema, not inside either renderer. + */ + outputDirectory: "libs/@local/petrinaut-arch-docs/bundle", + contentDirectory: "libs/@local/petrinaut-arch-docs/content", + sourceUrlPrefix: "https://github.com/hashintel/hash/blob/main/", +}; diff --git a/libs/@local/petrinaut-arch-docs/content/components/byte-map.tsx b/libs/@local/petrinaut-arch-docs/content/components/byte-map.tsx new file mode 100644 index 00000000000..b8541da8f06 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/components/byte-map.tsx @@ -0,0 +1,88 @@ +/** + * A byte-addressed memory map: offset gutter on the left, one row per section. + * + * This is the diagram the hand-written HTML pages did best — reading the frame + * format as a table loses the sense of a single contiguous buffer, which is the + * whole point of the format. Offsets are supplied rather than computed, because + * the map illustrates one worked example rather than a live layout. + */ + +import "./diagram.css"; +import { Inline } from "./inline"; + +export interface SegmentSpec { + /** Byte offset where this section starts. */ + offset: number; + name: string; + /** Element type and count, e.g. `u32 × P`. */ + type?: string; + /** The typed-array view used to read it. */ + view?: string; + accent?: "core" | "worker" | "none"; +} + +export interface ByteMapProps { + title?: string; + segments: SegmentSpec[]; + /** Total length, rendered as the closing offset. */ + byteLength?: number; + caption?: string; +} + +const accentVariable = (accent: SegmentSpec["accent"]): string | undefined => + accent === undefined || accent === "none" + ? undefined + : `var(--pnd-${accent})`; + +export const ByteMap = ({ + title, + segments, + byteLength, + caption, +}: ByteMapProps) => ( +
+ {title === undefined ? null : ( +
{title}
+ )} +
+ {segments.map((segment) => ( + + ))} + {byteLength === undefined ? null : ( + <> +
{byteLength}
+
+ + )} +
+ {caption === undefined ? null : ( +
+ +
+ )} +
+); + +const Segment = ({ segment }: { segment: SegmentSpec }) => ( + <> +
{segment.offset}
+
+ + + + {segment.type === undefined ? null : ( + + + + )} + {segment.view === undefined ? null : ( + + + + )} +
+ +); diff --git a/libs/@local/petrinaut-arch-docs/content/components/diagram.css b/libs/@local/petrinaut-arch-docs/content/components/diagram.css new file mode 100644 index 00000000000..2f2eabf60f0 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/components/diagram.css @@ -0,0 +1,257 @@ +/* + * Shared styling for the architecture diagram components. + * + * Deliberately self-contained: plain CSS with no preprocessor, no design-system + * import, and no framework assumptions, so the same file works in the Starlight + * site and in any host that renders the bundle. Colours derive from the host's + * text colour where possible, and everything falls back sensibly if the host + * defines none of the custom properties. + */ + +.pnd { + --pnd-line: color-mix(in srgb, currentColor 25%, transparent); + --pnd-muted: color-mix(in srgb, currentColor 65%, transparent); + --pnd-surface: color-mix(in srgb, currentColor 5%, transparent); + + --pnd-core: #3676b8; + --pnd-react: #7051b5; + --pnd-ui: #3d8055; + --pnd-worker: #b5762f; + + margin: 1.5rem 0; + font-size: 0.85rem; + line-height: 1.45; +} + +.pnd-title { + margin-bottom: 0.5rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--pnd-muted); +} + +.pnd-lanes { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(var(--pnd-columns, 1), minmax(0, 1fr)); +} + +.pnd-lane { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--pnd-line); + border-radius: 0.5rem; + background: var(--pnd-surface); +} + +.pnd-lane-title { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--pnd-muted); +} + +.pnd-box { + padding: 0.5rem 0.6rem; + border: 1px solid var(--pnd-line); + border-left: 3px solid var(--pnd-accent, var(--pnd-line)); + border-radius: 0.35rem; + background: var(--pnd-surface); +} + +.pnd-box-label { + font-weight: 600; +} + +.pnd-box-note { + margin-top: 0.15rem; + color: var(--pnd-muted); +} + +.pnd-box code, +.pnd-lane code, +.pnd-step code { + font-size: 0.9em; +} + +/* Pipeline ------------------------------------------------------------- */ + +.pnd-pipeline { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 0.4rem; +} + +/* + * One flex item per step, arrow included, so a wrap never separates an arrow + * from the box it points at. + */ +.pnd-step-group { + display: flex; + align-items: stretch; + gap: 0.4rem; + flex: 1 1 8rem; + min-width: 8rem; +} + +.pnd-step { + flex: 1 1 auto; + padding: 0.5rem 0.6rem; + border: 1px solid var(--pnd-line); + border-radius: 0.35rem; + background: var(--pnd-surface); +} + +.pnd-step-index { + font-size: 0.7rem; + font-weight: 700; + color: var(--pnd-muted); +} + +.pnd-arrow { + align-self: center; + color: var(--pnd-muted); +} + +/* Visible to assistive technology, not on screen. */ +.pnd-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* Byte map ------------------------------------------------------------- */ + +.pnd-bytes { + display: grid; + grid-template-columns: auto 1fr; + gap: 0 0.6rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pnd-offset { + padding-top: 0.45rem; + text-align: right; + color: var(--pnd-muted); + font-size: 0.75rem; + white-space: nowrap; +} + +.pnd-seg { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.6rem; + align-items: baseline; + padding: 0.4rem 0.6rem; + margin-bottom: 0.25rem; + border: 1px solid var(--pnd-line); + border-left: 3px solid var(--pnd-accent, var(--pnd-line)); + border-radius: 0.3rem; + background: var(--pnd-surface); +} + +.pnd-seg-name { + font-weight: 600; +} + +.pnd-seg-type, +.pnd-seg-view { + color: var(--pnd-muted); + font-size: 0.78rem; +} + +.pnd-seg-view { + margin-left: auto; +} + +/* Sequence ------------------------------------------------------------- */ + +.pnd-sequence { + display: grid; + gap: 0.35rem; +} + +.pnd-msg { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 0.5rem; + align-items: center; +} + +.pnd-msg-label { + padding: 0.35rem 0.55rem; + border: 1px solid var(--pnd-line); + border-radius: 0.3rem; + background: var(--pnd-surface); +} + +.pnd-msg-dir { + color: var(--pnd-muted); + font-size: 1rem; +} + +.pnd-msg-note { + color: var(--pnd-muted); +} + +.pnd-msg-spacer { + min-width: 0; +} + +.pnd-actors { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--pnd-muted); +} + +.pnd-actors span:last-child { + text-align: right; +} + +/* Legend --------------------------------------------------------------- */ + +.pnd-legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.6rem; + font-size: 0.75rem; + color: var(--pnd-muted); +} + +.pnd-legend span::before { + content: ""; + display: inline-block; + width: 0.6rem; + height: 0.6rem; + margin-right: 0.3rem; + border-radius: 0.15rem; + background: var(--pnd-accent, currentColor); +} + +@media (max-width: 640px) { + .pnd-lanes { + grid-template-columns: minmax(0, 1fr); + } + + .pnd-msg, + .pnd-actors { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/libs/@local/petrinaut-arch-docs/content/components/inline.tsx b/libs/@local/petrinaut-arch-docs/content/components/inline.tsx new file mode 100644 index 00000000000..0777ef723e4 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/components/inline.tsx @@ -0,0 +1,27 @@ +/** + * Renders a plain string, turning `backticked` spans into ``. + * + * Diagram components take strings rather than nodes on purpose. JSX written + * inside MDX is compiled by the host's MDX renderer — Astro's, here — and + * handing that to a React component fails at render time with "Objects are not + * valid as a React child". Strings avoid the interop entirely, which also means + * a host embedding the bundle needs no special MDX/JSX configuration. + * + * Backticks are the one piece of formatting these diagrams actually need, so + * they are supported directly rather than by accepting arbitrary markup. + */ + +import type { ReactElement } from "react"; + +export const Inline = ({ text }: { text: string }): ReactElement => ( + <> + {text.split("`").map((part, index) => + // Odd indices are the spans that sat between a pair of backticks. + index % 2 === 1 ? ( + {part} + ) : ( + {part} + ), + )} + +); diff --git a/libs/@local/petrinaut-arch-docs/content/components/lanes.tsx b/libs/@local/petrinaut-arch-docs/content/components/lanes.tsx new file mode 100644 index 00000000000..c447ddb95b0 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/components/lanes.tsx @@ -0,0 +1,92 @@ +/** + * Lane-and-box diagrams: parallel columns of boxes, one column per thread or + * mode. + * + * Data-driven rather than hand-marked-up, so a page describes the content and + * the component owns the presentation. + */ + +import "./diagram.css"; +import { Inline } from "./inline"; + +/** Accent colours, keyed to the layer families the diagrams talk about. */ +export type Accent = "core" | "react" | "ui" | "worker" | "none"; + +const accentVariable = (accent: Accent | undefined): string | undefined => + accent === undefined || accent === "none" + ? undefined + : `var(--pnd-${accent})`; + +export interface BoxSpec { + label: string; + note?: string; + accent?: Accent; +} + +export interface LaneSpec { + title: string; + accent?: Accent; + boxes: BoxSpec[]; +} + +export const Box = ({ label, note, accent }: BoxSpec) => ( +
+
+ +
+ {note === undefined ? null : ( +
+ +
+ )} +
+); + +export interface LanesProps { + title?: string; + lanes: LaneSpec[]; + /** Colour key rendered beneath the lanes. */ + legend?: { label: string; accent: Accent }[]; +} + +export const Lanes = ({ title, lanes, legend }: LanesProps) => ( +
+ {title === undefined ? null : ( +
{title}
+ )} +
+ {lanes.map((lane) => ( +
+
{lane.title}
+ {lane.boxes.map((box, index) => ( + + ))} +
+ ))} +
+ {legend === undefined ? null : ( +
+ {legend.map((entry) => ( + + {entry.label} + + ))} +
+ )} +
+); diff --git a/libs/@local/petrinaut-arch-docs/content/components/pipeline.tsx b/libs/@local/petrinaut-arch-docs/content/components/pipeline.tsx new file mode 100644 index 00000000000..575999809cf --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/components/pipeline.tsx @@ -0,0 +1,67 @@ +/** + * A numbered left-to-right chain of steps, wrapping on narrow screens. + * + * Used where the old HTML pages drew an arrow chain: the compilation pipeline, + * the fixed order of a simulation step, the metrics pipeline. + */ + +import "./diagram.css"; +import { Inline } from "./inline"; + +export interface StepSpec { + label: string; + note?: string; +} + +export interface PipelineProps { + title?: string; + steps: StepSpec[]; + /** Numbers the steps. Off for pipelines where order is not a sequence. */ + numbered?: boolean; +} + +export const Pipeline = ({ title, steps, numbered = false }: PipelineProps) => ( +
+ {title === undefined ? null : ( +
{title}
+ )} +
+ {steps.map((step, index) => ( + // A fragment per step so the separator sits between, not inside, boxes. + + ))} +
+
+); + +const Step = ({ + index, + numbered, + step, +}: { + index: number; + numbered: boolean; + step: StepSpec; +}) => ( + // The arrow is grouped with the step that follows it rather than emitted as a + // sibling. As separate flex items they wrapped independently, which left an + // arrow stranded at the end of a row pointing at a box on the next one. +
+ {index === 0 ? null : ( + + )} +
+ {numbered ?
{index + 1}
: null} +
+ +
+ {step.note === undefined ? null : ( +
+ +
+ )} +
+
+); diff --git a/libs/@local/petrinaut-arch-docs/content/components/sequence.tsx b/libs/@local/petrinaut-arch-docs/content/components/sequence.tsx new file mode 100644 index 00000000000..6d761804877 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/components/sequence.tsx @@ -0,0 +1,94 @@ +/** + * A two-actor message sequence: who sends what, in order, with side notes. + * + * Kept to two actors deliberately. The hand-written HTML had three columns + * (host, main-thread runtime, worker), which read as three lifelines but made + * every message ambiguous about which hop it described. Two actors and an + * explicit direction says the same thing without the ambiguity. + */ + +import "./diagram.css"; +import { Inline } from "./inline"; + +export interface MessageSpec { + /** `right` is left-actor → right-actor. */ + direction: "right" | "left"; + label: string; + note?: string; +} + +/** A phase separator, rendered as a full-width note rather than a message. */ +export interface PhaseSpec { + phase: string; +} + +export type SequenceRow = MessageSpec | PhaseSpec; + +export interface SequenceProps { + title?: string; + actors: [string, string]; + rows: SequenceRow[]; +} + +const isPhase = (row: SequenceRow): row is PhaseSpec => "phase" in row; + +export const Sequence = ({ title, actors, rows }: SequenceProps) => ( +
+ {title === undefined ? null : ( +
{title}
+ )} +
+ {actors[0]} +
+
+ {rows.map((row, index) => + isPhase(row) ? ( +
+ +
+ ) : ( + /* + * The direction arrow is decorative, so a screen reader would + * otherwise get the label with no indication of who sent it. The + * visually-hidden sentence carries that, and is the only place + * direction is stated in text. + */ +
+ + {row.direction === "right" + ? `${actors[0]} to ${actors[1]}: ` + : `${actors[1]} to ${actors[0]}: `} + + {row.direction === "right" ? ( + <> +
+ +
+ +
+ {row.note === undefined ? null : } +
+ + ) : ( + <> +
+ {row.note === undefined ? null : } +
+ +
+ +
+ + )} +
+ ), + )} +
+
+); diff --git a/libs/@local/petrinaut-arch-docs/content/index.mdx b/libs/@local/petrinaut-arch-docs/content/index.mdx new file mode 100644 index 00000000000..490a2666763 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/index.mdx @@ -0,0 +1,78 @@ +--- +title: Petrinaut architecture +description: How the Petrinaut packages fit together, and where to start reading. +sidebar_order: 0 +--- + +Petrinaut is a visual editor and simulation runtime for Stochastic Dynamic +Coloured Petri Nets (SDCPN). This documentation is in two halves that are +maintained differently, and it is worth knowing which you are reading. + +## The diagrams + +Every box is a layer and every arrow is real imports, counted from the +dependency graph rather than drawn by hand. There are three kinds, each showing +a different slice: + +- The [Architecture](architecture) page opens with the **top-level layers** — + the whole system at a glance, with everything below the first level folded + into its parent. +- Every layer page opens with its **neighbourhood**: what that layer depends on, + and what depends on it. This is usually the one you want. +- A layer that has sub-layers also gets a **drill-down** of its direct children. + +Where a layer has more neighbours than fit legibly, the remainder is drawn as a +single "further layers" node rather than omitted, and the full list is always in +the tables on the page. + +**Generated pages** — everything under [Architecture](architecture) — are +extracted from annotations in the source on every build, and CI fails if they +drift. Layer sizes and dependency counts all come from the code. If a generated page disagrees with the code, that is a bug in the +annotations, not in the page. + +**Authored pages** — this one, and its siblings — carry the reasoning that no +import graph can supply: why a boundary sits where it does, what the alternatives +were, which mistakes are easy to make. They are hand-written and can go stale, so +they avoid restating facts the generated pages already own. + +## Where to start + +- [Two execution paths](two-execution-paths) — the single most important thing to + understand before changing anything in the simulation layers. +- [Architecture](architecture) — the generated map: layers, dependencies, + boundaries. +- [Working on the architecture](working-on-the-architecture) — how to declare a + layer, and what CI will hold you to. + +## Simulation internals + +Detail that the generated pages do not carry — binary formats, message +protocols, and the costs behind each design choice. + +- [Memory model](doc:simulation/memory-model) — which buffer lives on which thread, + and the frame format. +- [How a step is computed](doc:simulation/stepping) — the fixed stage order, and + determinism. +- [Compiling user code](doc:simulation/user-code) — the HIR pipeline, scenarios, and + what the sandbox does not protect against. +- [Worker protocol and backpressure](doc:simulation/protocol) — messages, and + the ack contract. +- [Monte Carlo experiments](doc:simulation/experiments) — bounded memory, and why + only aggregates reach the UI. + +## The packages at a glance + +`@hashintel/petrinaut-core` is the headless half: the document model, the HIR +compiler for user-authored code, the simulation runtimes, and the language +server. It has no React, no DOM and no Monaco, which is what lets it run +unchanged in Node and inside workers. + +`@hashintel/petrinaut` is the editor built on top. It splits into a React layer +that owns state and a UI layer that renders it — and the dependency only goes one +way, which is enforced rather than merely intended. + +Three further packages are not yet covered by the generated model: +`@hashintel/petrinaut-cli`, `@apps/petrinaut-website`, and `apps/petrinaut-opt` +(the Python Optuna service). +Adding them is a matter of declaring layers in their source; see +[Working on the architecture](working-on-the-architecture). diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/experiments.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/experiments.mdx new file mode 100644 index 00000000000..deb62aa9622 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/simulation/experiments.mdx @@ -0,0 +1,126 @@ +--- +title: Monte Carlo experiments +description: How many runs execute in bounded memory, and why only aggregates reach the UI. +sidebar_order: 10 +attachTo: core.simulation.monte-carlo +--- + +import { Lanes } from "@diagrams/lanes"; + +An experiment runs many independent simulations of the same net with bounded +memory, aggregates metrics inside the worker, and ships only small JSON metric +frames to the host. **Frame buffers never cross the thread boundary.** + +The engine itself is shared with the interactive path — the same enablement, +lambda, kernel and dynamics code — adapted to different frame storage. + +## The layers + +The host holds one experiment handle per experiment, subscribed to its status, +progress and metric stores. The handle runs either in worker mode or in local +mode, where the simulator runs on the calling thread; local mode is what tests +and embedders use. + +Inside the worker, the loop is: advance every run one frame, post progress and +any pending metric frames, yield, repeat. Cancellation is checked between +batches, and the default batch is 4. + +The simulator owns N runs and advances them **round-robin**, one frame each per +call. That is deliberate: it stops long runs from starving short ones, so +progress reporting stays meaningful and a single pathological run cannot stall +the batch. + +## Per run + +Each run carries its own seed, parameter values and initial marking — defaults +come from the experiment config and are overridable per run, typically as +`base seed + index`. + +Stepping errors are **per run**: once the experiment is running, one run throwing +does not stop the others, which is what makes a large sweep survive a parameter +combination that fails mid-flight. A run completes on its own deadlock or on the +shared max time. + +Construction is not isolated. Every run state is built up front in the simulator +constructor, so an invalid marking or parameter set in any single run throws +before the experiment starts and none of the runs execute. + +Each run also reports buffer telemetry — bytes used, capacity, and reallocation +count — which is the first thing to look at when an experiment is slower than +expected. + +## Two buffers, swapped every step + + + +The consequence to internalise: **frame readers handed to metric code are only +valid during observation.** They are views over a buffer that the next step will +overwrite. Anything a metric needs later must be extracted into the value it +returns. + +If the next token count does not fit, only the target buffer reallocates, taking +the larger of what is required and double the current capacity. + +The Monte Carlo buffer layout is a leaner sibling of the interactive frame: the +same sections, **no 64-byte header**, one extra section for elapsed frames, and a +token region with spare capacity beyond what is used. Views are created once per +buffer rather than per read. + +## Metrics are computed where the data is + +```text +specs → compile before start → sample per frame → aggregate → metric frames +``` + +Metric specs are serializable and sent at init: either an expression (carrying +HIR artifacts) or a built-in such as mean place-token count or transition firing +count. + +Sampling visits every run's current frame through the same reader interface the +interactive path uses. Aggregation happens across runs — mean, sum, min, max, +last for a scalar — or keeps the run axis and bins it into a distribution. + +What reaches the host is therefore small JSON: a scalar frame carries a value +per frame and per time, and a distribution frame carries bins as value/frequency +pairs. A host can paint a bins-by-frames heatmap, or derive medians and +percentiles from the bins, **without ever holding simulation state**. + +## Contrast with the interactive path + +No acks, no backpressure: the worker free-runs to completion in small batches, +and the only upstream control is cancel. That is affordable precisely because the +payloads are aggregates rather than frames — there is no consumer pace to respect +because there is no unbounded data to hold back. + +## Known costs + +Runs are round-robin on one worker thread, though the run-state model was +designed so runs can shard across workers later. Buffer growth is naive doubling; +static analysis of arc weights could size buffers up front and remove +reallocations entirely. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/memory-model.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/memory-model.mdx new file mode 100644 index 00000000000..c236ed3e511 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/simulation/memory-model.mdx @@ -0,0 +1,191 @@ +--- +title: Memory model +description: Where simulation state actually lives, and the binary frame format it lives in. +sidebar_order: 10 +attachTo: core.simulation +--- + +import { ByteMap } from "@diagrams/byte-map"; +import { Lanes } from "@diagrams/lanes"; + +Everything the simulation computes is stored in raw `ArrayBuffer`s read through +typed-array views. There is no object graph of tokens: tokens are packed structs, +decoded to JavaScript objects only at the read boundary. + +Knowing which buffer lives on which thread is the thing that makes the rest of +the simulation layers make sense. + +## Where each piece lives + + + +| Memory | Structure | Thread | Lifetime | +| ------------------- | -------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------ | +| SDCPN document | Plain JS objects (editor state) | Main | Structured-cloned into the worker at init. Later edits do not affect a running simulation. | +| Initial marking | JSON — count or `TokenRecord[]` per place | Main | Packed into frame 0 by `buildSimulation`. | +| `EngineFrame` | `ArrayBuffer` — 64-byte header plus typed sections and packed tokens | Worker | Immutable snapshot per step, appended to the instance's frame history. | +| Frame payload | `{ time, frame: ArrayBuffer }` | Worker → Main | Structured-clone copy per frame. Retained by the frame store for scrubbing. | +| Frame reader | Typed-array views over the stored buffer | Main | Zero-copy. Materialises `TokenRecord`s only when asked. | +| Monte Carlo buffers | 2 × `ArrayBuffer` per run | MC worker | Swapped each step. Never cross to the main thread. | +| Compiled user code | Versioned HIR buffer programs | Workers | Produced once at init. | + +The asymmetry between rows 4 and 6 is the whole difference between the two +execution paths — see [Two execution paths](doc:two-execution-paths). + +## The frame format + +One `ArrayBuffer` per frame, read through section-typed views. The frame stores +**no IDs and no time**: decoding requires the layout derived from the SDCPN +(place and transition order, per-place stride and slot layout), and time travels +alongside as payload metadata. + +`createEngineFrame()` computes every offset and is the only frame constructor; +`readEngineFrame()` recreates the same views for reading. + + + +Header fields worth knowing: `magic` (`0x5046524d`, a corrupt-frame guard), +`version` (currently `2`, asserted on decode), `placeCount`/`transitionCount` +(checked against the layout on read), and `byteLength` (checked on read). + +### Token packing + +Each coloured place owns a contiguous run of `count × strideBytes` starting at +its byte offset, giving O(1) access to any place. Uncoloured places have stride +0 and only a count. + +| Element type | Physical | Notes | +| ------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `real` | `f64` — 8 B | | +| `integer` | `f64` — 8 B, rounded | Exact within ±2^53. `i32` was rejected for silent wraparound; `i64` because bigint is contagious into user code. | +| `boolean` | `u8` — 1 B | Not bit-packed: that would break byte-granular copies for marginal savings. | +| `uuid` | `u64 × 2` — 16 B | Two little-endian lanes via a shared `BigUint64Array`, combined to one `bigint` at the boundary. Never routed through `number`. | +| `string` | `u64` pool reference — 8 B | The frame stores an ID into an append-only per-run intern pool. The pool lives on the instance, not the frame, so frames stay fixed-stride and byte-copyable. | + +Fields are ordered per colour by decreasing alignment, and the stride is rounded +up to 8 bytes. One module owns all token-byte indexing; every whole-token move is +a byte-range copy. + +Because the string pool never crosses the worker boundary with the frames, each +frame payload ships an append-only delta of new strings that the main-thread +store accumulates and hands to the reader for decoding. + +## Known cost: triple retention + +Each quick-simulation frame currently exists three times — in the worker's frame +history, as a structured-clone copy in the main-thread store, and transiently in +the `postMessage` queue. Frames are never transferred, and the worker's history +is only ever read at its latest entry. + +Retention is deliberately isolated behind the frame store, so this can change +without touching consumers. Transferring buffers instead of copying is not a drop-in +fix: computing the next frame re-reads the latest stored frame, so detaching the +posted buffer first requires stepping from a retained working copy. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/protocol.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/protocol.mdx new file mode 100644 index 00000000000..56a342e4ff7 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/simulation/protocol.mdx @@ -0,0 +1,168 @@ +--- +title: Worker protocol and backpressure +description: The messages between host and simulation worker, and the ack contract that bounds memory. +sidebar_order: 10 +attachTo: core.simulation.worker +--- + +import { Sequence } from "@diagrams/sequence"; + +Both simulation workers speak a small typed message protocol over `postMessage`, +wrapped in a transport that queues messages until the worker boots. This page +covers the quick-simulation worker; the experiment worker is on +[Monte Carlo](doc:simulation/experiments). + +## Messages + +**Host → worker** + +| Type | Payload | Effect | +| ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `init` | net, marking, parameters, seed, `dt`, `maxTime`, backpressure settings | Builds the simulation, packs frame 0, replies `frame(0)` then `ready`. Resets the ack cursor. | +| `start` | — | Starts the compute loop. No-op if running; refused after completion or error. | +| `pause` | — | Stops the loop, keeps all state, replies `paused`. | +| `stop` | — | Discards the simulation entirely. | +| `setBackpressure` | `maxFramesAhead`, `batchSize` | Live-updates loop tuning. | +| `ack` | `frameNumber` | Raises the ack cursor, unblocking computation. | + +**Worker → host** + +| Type | Payload | Effect | +| ------------------ | ----------------------------------------- | ---------------------------------------------------------------- | +| `ready` | initial frame count | Resolves the creation promise; status becomes Ready. | +| `frame` / `frames` | `{ time, frame }`, single or batch | Appended to the frame store, which republishes count and latest. | +| `paused` | frame number | Status becomes Paused. | +| `complete` | `"deadlock"` or `"maxTime"`, frame number | Status becomes Complete, emitted on the event stream. | +| `error` | message, offending item id if known | Status becomes Error; rejects creation if still initialising. | + +Errors carry the SDCPN item id where known, which is what lets the editor +highlight the transition whose lambda threw rather than showing a bare message. + +## One run, end to end + + + +## The ack contract + +**Nothing is computed until the consumer asks for it.** The worker's loop blocks +while the ack cursor is behind, so the consumer's pace bounds the worker's memory +and CPU: + +```js +while (isRunning) { + if (lastAckedFrame < 0 || currentFrame - lastAckedFrame >= maxFramesAhead) { + await delay(10); // wait for the consumer + continue; + } + // compute up to batchSize frames, stopping on complete or error + post("frames", batch); + await delay(0); // let pause / stop / ack messages in +} +``` + +Note the first condition: after `init`, the cursor starts below zero, so a fresh +worker computes **nothing** until the first ack. Starting a run is therefore two +steps, `start` then acks, and a host that starts but never acks looks hung while +behaving exactly as designed. + +Two details the sketch above smooths over, both worth knowing before relying on +the ordering: + +- The ahead limit is checked once per batch, not once per frame, so a batch can + run up to `batchSize - 1` frames past `maxFramesAhead`. It bounds memory in + aggregate rather than capping the cursor exactly. +- On the final iteration the worker posts `complete` before the batch holding the + last frames. A host that stores frames on `frames` and tears down on `complete` + will drop that final batch unless it drains after completing. + +The per-mode profiles the core exposes: + +| Play mode | `maxFramesAhead` | `batchSize` | Behaviour | +| --------------- | ---------------- | ----------- | ----------------------------------------------------------------------------------------- | +| view only | — | — | No profile: the React layer pauses the worker and never acks, so nothing new is computed. | +| compute buffer | 40 | 10 | Acks when playback is within ~0.5 s of the last computed frame. | +| compute max | 10000 | 500 | Acks on every arrival — compute as fast as possible. | +| worker defaults | 1000 | 1000 | Used when `init` omits the settings. | + +## Lifecycle + +```text +Initializing ──ready──▶ Ready ──run()──▶ Running ──pause()──▶ Paused + │ │ + └──── run() ◀────────┘ + Running ──deadlock | maxTime──▶ Complete + any ──error msg───────────▶ Error +``` + +That is the **main-thread** state. The worker mirrors a smaller internal status, +in which complete and error are terminal. + +A sharp edge: `reset()` returns the main-thread status to Ready, but the worker +has discarded its simulation, so a subsequent `run()` sends `start` to a worker +with nothing to run. What actually happens in the UI flow is a full re-init. +Treat reset as "throw this away", not "rewind". + +## Main-thread responsibilities + +The host-side runtime does four things worth knowing about: + +- **Snapshot preparation** — sanitizes the net for enabled extensions and + flattens component instances, so the main-thread layout matches what the worker + produces. A place-count mismatch throws on read rather than decoding garbage. +- **Transport** — queues outbound messages until the worker resolves; tests can + inject a pre-built transport instead of a real worker. +- **Retention** — the in-memory store keeps every frame so playback can scrub. + Alternative stores (latest-only, sliding window, persisted) can be swapped in + without changing consumers. +- **Publication** — status and frames as readable stores, plus an event stream. + +## Known costs + +The 10 ms poll while waiting for acks is a busy-wait; a promise per ack would be +exact. And frames could be posted with a transfer list to halve copying, but only +once stepping no longer re-reads the latest stored frame — see +[Memory model](doc:simulation/memory-model). diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/stepping.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/stepping.mdx new file mode 100644 index 00000000000..39debb4ecd7 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/simulation/stepping.mdx @@ -0,0 +1,76 @@ +--- +title: How a step is computed +description: The fixed order of one simulation step, and what makes a run reproducible. +sidebar_order: 10 +attachTo: core.simulation.engine +--- + +import { Pipeline } from "@diagrams/pipeline"; + +`buildSimulation(input)` turns an SDCPN into a runnable instance; +`computeNextFrame(simulation)` advances it by one frame. Each frame it returns is +immutable, and neither function emits events: the worker is what turns their +return values into protocol messages. + +Neither is pure. Stepping mutates the instance it is given, and the transition +path reuses kernel staging buffers between calls, so a `SimulationInstance` must +not be stepped concurrently or reused across runs. + +Building an instance flattens component instances, compiles user code, and packs +the initial marking into frame 0. Compilation is selective: lambdas only where +available, kernels only for coloured outputs, dynamics only for colours with at +least one real element. + +## One step, in order + +The order is fixed, and several surprising behaviours follow from it. + + + +Discrete derivatives are forced to zero in step 2; removals compact the buffer +and additions append in step 3. + +Dynamics run before transitions, so a transition sees token values already +advanced by this step's integration. Timers only advance on a step where nothing +fired, so they measure idle time rather than wall time. + +## Immutability, and what it costs + +Every stage that changes state allocates a new buffer: dynamics, token removal, +and token insertion each rebuild the frame. That makes history trivially correct +— previous frames are never mutated — at the cost of allocation churn per step. + +This is the single clearest optimisation opportunity in the engine. Monte Carlo already +demonstrates the alternative: two buffers swapped per step, no history. Bringing +that to the interactive path means moving history retention wholly onto the main +thread, which the frame store already isolates. + +## Determinism + +One seeded RNG is threaded through stochastic lambda sampling and distribution +draws, as a pure `nextRandom(state) → [value, nextState]` whose state lives on +the instance. + +The guarantee: the same `(SDCPN, marking, parameters, seed, dt)` always +reproduces the same frame sequence. This is what makes experiments comparable +across runs, and it is why the RNG state is threaded rather than global — a +global would couple runs sharing a worker. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/user-code.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/user-code.mdx new file mode 100644 index 00000000000..284bea7fd1e --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/simulation/user-code.mdx @@ -0,0 +1,106 @@ +--- +title: Compiling user code +description: How authored TypeScript becomes buffer programs, and what the sandbox does and does not protect. +sidebar_order: 10 +attachTo: core.simulation.authoring +--- + +import { Pipeline } from "@diagrams/pipeline"; + +Dynamics, lambdas, kernels and metrics all compile through the HIR: user +TypeScript is lowered to a source-spanned IR, checked against the net, and +emitted as buffer-native JavaScript with offsets and strides baked in. Scenario +code takes a separate, simpler path. + + + +The editor's type checking is the same pipeline seen from the other end: the LSP +generates the `Color_*`, `Parameters`, `Dynamics` and kernel declarations Monaco +checks against, so editor diagnostics and runtime artifacts come from the same +HIR checks and emitters. + +## What compiles where + +| Surface | Authored as | Executed in | +| ----------------- | -------------------------------------------- | -------------------------------------------------- | +| Transition lambda | module, `Lambda(fn)` | Worker, per enablement check | +| Transition kernel | module, `TransitionKernel(fn)` | Worker, per firing | +| Dynamics | module, `Dynamics(fn)` | Worker, per step per place | +| Metric | plain function body | Main thread (timeline) and MC worker (experiments) | +| Scenario | per-place expressions, or one code-mode body | Main thread, before the run starts | +| Place visualizer | module, `Visualization(fn)` returning JSX | Main thread, at render | + +The visualizer is the one inherently React-based surface — it authors JSX — which +is why it compiles in the UI package rather than in the headless core. + +Metrics must return a finite number; NaN and infinity throw rather than +propagating into a chart. + +## Scenarios produce a marking, not a run + +Scenario compilation is separate from the HIR pipeline and produces an initial +marking (plus parameter overrides) before the simulation is created. + +Two modes, with one asymmetry worth remembering: **per-place mode is keyed by +place ID, code mode is keyed by place name.** In code mode, unknown names are +silently dropped. + +In per-place mode an uncoloured place takes an expression evaluated with +parameters in scope, rounded and clamped at zero; a coloured place takes row +arrays in colour-element order, each coerced with typed defaults for missing +columns, and extra columns throw. + +## What the sandbox is for + +Scenario expressions run with a list of globals shadowed — `window`, `document`, +`globalThis`, `fetch`, `importScripts`, `Function`, timers and similar — so those +name lookups resolve to `undefined`. Because shadowing only stops identifier +lookup, the sandbox additionally blocks the constructor-chain escape +(`({}).constructor.constructor` reaching `Function`) for the duration of the +call, by swapping the `.constructor` descriptors on the built-in prototypes and +restoring them afterwards. + +**Treat this as robustness hardening, not isolation.** User code executes with +`new Function` in the same realm as the host. It protects against typos and +accidental API use, not against an attacker. + +## Distributions are sampled once + +Kernels can construct distribution values rather than numbers. They defer +sampling through the ABI sink, and the engine samples each value once per token +with the seeded RNG. That is what lets chained `.map(fn)` transforms share a +single draw and keeps runs reproducible. + +When stochasticity is disabled the runtime is not injected at all, and plain +values are required. + +## Compatibility + +HIR artifacts carry a version and a fingerprint of their sanitized compilation +input. Any schema, code, or extension change requires recompilation before the +engine will run them — a stale artifact is refused rather than silently +mismatched against a changed net. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/worker-sharding.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/worker-sharding.mdx new file mode 100644 index 00000000000..35dcce46106 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/simulation/worker-sharding.mdx @@ -0,0 +1,42 @@ +--- +title: Worker sharding +description: How one experiment's runs split across Web Workers without changing what it reports. +sidebar_order: 20 +attachTo: core.simulation.monte-carlo +--- + +Runs are independent — own seed, own RNG state, own frame buffers, no shared +mutable state. An experiment therefore splits them across one worker per logical +core minus one, each running its own `MonteCarloSimulator` over a slice of the +runs. Measured ~4x at 8 shards on a 10-core machine. + +No `SharedArrayBuffer` is involved: the app is not cross-origin isolated, and +message-passing shards over independent runs need no shared memory. + +## Why sharding cannot change results + +Two properties carry it, and both are easy to break: + +**Seeds derive from the run's global index.** `runIndexOffset + localIndex`, so +run *i* gets the same seed whichever worker owns it. Seeding from the position +*within* a shard would silently correlate runs that must be independent. + +**Frames merge through a monoid.** `frameValue` on a scalar metric frame is +already reduced and cannot be combined — a mean of means is not a mean — so +frames carry their pre-reduction accumulator state, and `metrics/merge.ts` +recombines it. Output is byte-identical at every shard count. + +## The frame watermark + +A frame is finalised only once every *still-running* shard has reported it. +Finished shards are dropped from the watermark rather than blocking it, which is +what stops a completed shard stalling the experiment. + +Progress therefore reports the slowest shard's position — the bar never runs +ahead of the results behind it. + +```text +runs 0..k -> Worker 1 ┐ +runs k..2k -> Worker 2 ├─ per-frame accumulator state -> merge -> frame N +runs 2k..N -> Worker 3 ┘ (at slowest shard) +``` diff --git a/libs/@local/petrinaut-arch-docs/content/two-execution-paths.mdx b/libs/@local/petrinaut-arch-docs/content/two-execution-paths.mdx new file mode 100644 index 00000000000..455b491dd71 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/two-execution-paths.mdx @@ -0,0 +1,52 @@ +--- +title: Two execution paths +description: Why Quick Simulation and Experiments share an engine but not a memory model. +sidebar_order: 1 +--- + +There are two independent ways to execute a net, and almost every surprising +thing about the simulation layers follows from the difference between them. + +**Quick Simulation** runs one interactive simulation and keeps _every frame_, so +the user can scrub backwards and forwards through the run. Frames accumulate in +a store on the main thread. + +**Experiments** run many Monte Carlo simulations with _bounded memory_ — two +reusable buffers per run — and ship only metric aggregates back to the host. +Frame buffers never leave the worker. + +## Why not one path + +The obvious simplification is to make experiments reuse the interactive +simulator and throw away frames afterwards. It does not work: an experiment is +hundreds or thousands of runs, and retaining frames even transiently makes peak +memory scale with run length times concurrency. Bounding memory is not an +optimisation on the experiment path, it is the reason that path exists. + +The consequence to keep in mind: **anything you add to the frame format costs +memory on the interactive path and nothing on the experiment path, while +anything you add to metric aggregation costs on both.** A per-frame field is +cheap to add and expensive to keep; an aggregate is the reverse. + +## What this means when changing code + +The engine is shared, so a change to stepping or to the frame layout affects both +paths. The _controllers_ are not shared — the interactive path goes through the +simulation controller with its frame store, the experiment path through the Monte +Carlo runtime — so a change to run lifecycle usually needs doing twice, and +forgetting one is the classic bug here. + +If you are touching either, read the generated pages for +[the engine](layer:core.simulation.engine), +[the simulation controller](layer:core.simulation.runtime) and +[the Monte Carlo runtime](layer:core.simulation.monte-carlo) — their +declared invariants say which properties you must not break. + +## Actual mode is a third source, but not a third path + +Actual mode renders an execution that came from somewhere else entirely — a live +external system rather than a simulation. It does not run the engine at all. It +reaches the same canvas and timeline because both read frames through the +[execution-frame interface](layer:react.execution-frame), which abstracts +over "where frames come from". That indirection is why adding Actual mode did not require +touching the canvas. diff --git a/libs/@local/petrinaut-arch-docs/content/working-on-the-architecture.mdx b/libs/@local/petrinaut-arch-docs/content/working-on-the-architecture.mdx new file mode 100644 index 00000000000..ef295fa9507 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/working-on-the-architecture.mdx @@ -0,0 +1,120 @@ +--- +title: Working on the architecture +description: How to declare a layer, refine one, and what CI holds you to. +sidebar_order: 2 +--- + +The generated pages come from annotations in the Petrinaut source. This page is +the short version of how to change them; the full reference lives in +[`libs/@local/petrinaut-arch-docs/README.md`](https://github.com/hashintel/hash/blob/main/libs/@local/petrinaut-arch-docs/README.md). + +## Declaring a layer + +Add frontmatter to the folder's `README.md`: + +```yaml +--- +layer: core.simulation.monte-carlo +role: Runs many simulations with bounded frame memory +--- +``` + +The prose below the frontmatter becomes the layer's page body, so an existing +folder README needs only the frontmatter added. + +For a folder with a barrel entry file and no README, annotate the entry file +instead: + +```ts +/** + * @layerRoot core.simulation.monte-carlo + * @role Runs many simulations with bounded frame memory + */ +``` + +Use one mechanism or the other, never both on the same folder. `layer` and +`role` are the only frontmatter keys, and `@layerRoot` and `@role` the only +tags; anything else is either an error or ignored. + +## You do not need to annotate every file + +A file with no annotation belongs to the nearest ancestor folder that declares a +layer. Thirty-seven declarations cover four hundred and thirteen files. Declare a layer where +the architecture genuinely changes — a new boundary, a different responsibility — +not on every directory that exists. + +## Moving or renaming a folder + +Nothing to update, provided the folder keeps its declaration with it. That is the +main practical gain over the old path-matching script, where a move silently +re-bucketed files into a fallback group. + +Renaming a _layer id_ is a real change: it moves the layer's page and invalidates +inbound links, and every descendant id has to move with it. + +## Adding a new package + +Add it to `packages` in +[`architecture.config.ts`](https://github.com/hashintel/hash/blob/main/libs/@local/petrinaut-arch-docs/architecture.config.ts), +declare a root layer in its source, and rebuild. Build configuration outside the +package's source directory is deliberately excluded — it configures the build, it +is not part of the design. + +## What CI will hold you to + +Run the check before pushing: + +```bash +yarn workspace @local/petrinaut-arch-docs lint:arch-docs +``` + +It fails when a source file no layer covers appears, when a layer id implies an +ancestor nothing declares, when a dependency violates a declared rule, and when +the import graph reaches a source file no layer claims. + +The bundle itself is build output and is git-ignored, so there is nothing to +regenerate before pushing. Only the annotations are versioned. To read the docs +locally: + +```bash +yarn workspace @local/petrinaut-arch-docs doc:architecture +``` + +## Adding a hand-written guide + +Authored pages live in `libs/@local/petrinaut-arch-docs/content/` and are +optional. To have one sit _inside_ the architecture tree rather than beside it, +name the layer it explains: + +```yaml +--- +title: Memory model +attachTo: core.simulation +sidebar_order: 10 +--- +``` + +The page then appears beneath that layer, and the layer's page links to it under +"Guides". + +Because `attachTo` decides where the page ends up, link to other pages by name +rather than by path — `[text](layer:core.simulation.engine)` for a generated +page, `[text](doc:simulation/memory-model)` for another authored one. A target +that does not resolve fails the check. + +## Declaring a rule + +Rules live in `architecture.config.ts` and are checked against the real import +graph: + +```ts +{ + from: "react", + to: "ui", + reason: "state providers must not depend on the components that render them", +} +``` + +Add one only when the code already satisfies it — a rule is a statement of fact +that CI keeps true, not an aspiration. Check first with a build; if there are +existing violations, either fix them in the same change or leave the rule out. diff --git a/libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json b/libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json similarity index 54% rename from libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json rename to libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json index cbff533fc46..afe5f0c20e9 100644 --- a/libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json +++ b/libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json @@ -1,6 +1,7 @@ { + "//": "Used only by the architecture generator, to give dependency-cruiser's TypeScript resolver the workspace path mappings. Moved here from petrinaut-core when the hand-written diagram script was retired, so the config lives with its only consumer.", "compilerOptions": { - "baseUrl": "../../../..", + "baseUrl": "../../..", "paths": { "@hashintel/petrinaut-core": [ "libs/@hashintel/petrinaut-core/src/index.ts" @@ -14,6 +15,15 @@ "@hashintel/petrinaut-core/hir-runtime": [ "libs/@hashintel/petrinaut-core/src/hir-runtime.ts" ], + "@hashintel/petrinaut-core/ai": [ + "libs/@hashintel/petrinaut-core/src/ai.ts" + ], + "@hashintel/petrinaut-core/compiled-model": [ + "libs/@hashintel/petrinaut-core/src/compiled-model.ts" + ], + "@hashintel/petrinaut-core/optimization": [ + "libs/@hashintel/petrinaut-core/src/optimization.ts" + ], "@hashintel/petrinaut-core/workers/lsp": [ "libs/@hashintel/petrinaut-core/src/workers/lsp.ts" ], @@ -25,5 +35,8 @@ ] } }, - "include": ["src", "../petrinaut/src"] + "include": [ + "../../@hashintel/petrinaut-core/src", + "../../@hashintel/petrinaut/src" + ] } diff --git a/libs/@local/petrinaut-arch-docs/package.json b/libs/@local/petrinaut-arch-docs/package.json new file mode 100644 index 00000000000..a9acd4f5aaf --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@local/petrinaut-arch-docs", + "version": "0.0.0-private", + "private": true, + "description": "Generates the Petrinaut architecture bundle from in-code annotations", + "license": "(MIT OR Apache-2.0)", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "doc:architecture": "tsx src/cli.ts build", + "fix:eslint": "oxlint --fix --report-unused-disable-directives-severity=error .", + "lint:arch-docs": "tsx src/cli.ts check", + "lint:eslint": "oxlint --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsc --noEmit", + "test:unit": "vitest --run src" + }, + "dependencies": { + "dependency-cruiser": "18.0.0", + "js-yaml": "4.3.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@hashintel/petrinaut": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", + "@local/tsconfig": "workspace:*", + "@types/js-yaml": "^4", + "@types/node": "22.18.13", + "oxlint": "1.63.0", + "tsx": "4.20.6", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/libs/@local/petrinaut-arch-docs/src/build.ts b/libs/@local/petrinaut-arch-docs/src/build.ts new file mode 100644 index 00000000000..1fe89f8dda9 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/build.ts @@ -0,0 +1,356 @@ +/** + * Builds the bundle in memory. + * + * Kept separate from the CLI so `build` and `check` run the same pipeline — + * `check` builds and throws the files away, reporting only the diagnostics. + */ + +import { join } from "node:path"; + +import { config, type ArchitectureConfig } from "../architecture.config"; +import { runChecks } from "./check"; +import { + collectAuthoredContent, + type AuthoredComponent, + type AuthoredPage, +} from "./content"; +import { error, type Diagnostic } from "./diagnostics"; +import { + buildManifest, + buildSingleFileArchitecture, + type BundleManifest, +} from "./emit/bundle-outputs"; +import { + buildNeighbourhoodDiagram, + buildOverviewDiagram, + buildSubtreeDiagram, +} from "./emit/d2"; +import { + buildPages, + resolveAuthoredLinks, + resolveComponentImports, + layerSlug, + type GeneratedPage, +} from "./emit/mdx"; +import { extract } from "./extract"; +import { buildGraph } from "./graph"; +import { + architectureModelSchema, + packageSchema, + type ArchitectureModel, +} from "./model"; + +export const GENERATOR_NAME = "@local/petrinaut-arch-docs"; + +export interface BuiltBundle { + model: ArchitectureModel; + manifest: BundleManifest; + generated: GeneratedPage[]; + authored: AuthoredPage[]; + /** Diagram name → D2 source. */ + diagramSources: Map; + /** Importable diagram components shipped with the bundle. */ + components: AuthoredComponent[]; + singleFile: string; + diagnostics: Diagnostic[]; +} + +/** Diagram embedded on the overview page. */ +const OVERVIEW_DIAGRAM = "overview"; + +/** + * Diagram names are namespaced by subdirectory rather than by a name prefix, + * because a layer id is only guaranteed unique among layer ids — a flat + * `around-${id}` scheme would collide with a top-level layer actually called + * `around-something`. + */ +const NEIGHBOURHOOD_PREFIX = "around"; +const SUBTREE_PREFIX = "within"; + +export const buildBundle = async (options: { + repoRoot: string; + overrides?: Partial; + /** + * Whether rendered SVGs will exist. When false, pages omit diagram images + * rather than pointing at files that were never written — a bundle that + * references a missing image fails the consuming site's build. + */ + includeDiagrams?: boolean; +}): Promise => { + const settings: ArchitectureConfig = { ...config, ...options.overrides }; + const { repoRoot } = options; + + // Normalise once up front so every stage sees defaulted fields such as + // `sourceDirectory` rather than each having to reapply them. + const packages = settings.packages.map((pkg) => packageSchema.parse(pkg)); + + const extraction = await extract({ + repoRoot, + packages, + ignoredDirectories: settings.ignoredDirectories, + ignoredFilePattern: settings.ignoredFilePattern, + }); + + const diagnostics: Diagnostic[] = [...extraction.diagnostics]; + + const graph = await buildGraph({ + repoRoot, + packages: packages.filter((pkg) => pkg.language === "typescript"), + tsconfigPath: join( + repoRoot, + "libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json", + ), + ignoredDirectories: settings.ignoredDirectories, + ignoredFilePattern: settings.ignoredFilePattern, + fileLayers: extraction.fileLayers, + layers: extraction.layers, + }); + + diagnostics.push(...graph.diagnostics); + + const model: ArchitectureModel = { + version: 1, + packages, + layers: extraction.layers, + edges: graph.edges, + rules: settings.rules, + }; + + // Validated, not enforced. `extract` already reports each invalid layer + // against the file that declared it, which is the message someone can act on; + // throwing here would replace it with a stack trace. Anything the per-layer + // pass missed still gets reported rather than reaching a consumer, and the + // build refuses to write while any error stands. + const validated = architectureModelSchema.safeParse(model); + if (!validated.success) { + for (const issue of validated.error.issues) { + // Layer issues are already reported against the file that declared them. + // Repeating them here would print the same typo three times. + if (issue.path[0] === "layers") { + continue; + } + + diagnostics.push( + error( + "architecture.config.ts", + `the generated model is not valid at \`${issue.path.join(".")}\`: ${issue.message}`, + ), + ); + } + } + + diagnostics.push(...runChecks({ model, rules: settings.rules })); + + const diagramSources = new Map(); + + diagramSources.set( + OVERVIEW_DIAGRAM, + buildOverviewDiagram(model.layers, model.edges, GENERATOR_NAME), + ); + + // A neighbourhood diagram for *every* layer, including leaves — those are + // where a reader most often lands, and "what does this touch" is the question + // they arrive with. + for (const layer of model.layers) { + diagramSources.set( + `${NEIGHBOURHOOD_PREFIX}/${layer.id}`, + buildNeighbourhoodDiagram( + layer.id, + model.layers, + model.edges, + GENERATOR_NAME, + ), + ); + } + + // A drill-down diagram for every layer that has sub-layers, answering the + // other question: what is inside this one. + const parentLayerIds = model.layers + .filter((layer) => model.layers.some((other) => other.parent === layer.id)) + .map((layer) => layer.id); + + for (const parentId of parentLayerIds) { + diagramSources.set( + `${SUBTREE_PREFIX}/${parentId}`, + buildSubtreeDiagram(parentId, model.layers, model.edges, GENERATOR_NAME), + ); + } + + const authoredResult = await collectAuthoredContent({ + repoRoot, + contentDirectory: settings.contentDirectory, + }); + + for (const failure of authoredResult.errors) { + diagnostics.push(error(failure.file, failure.message)); + } + + const declaredLayerIds = new Set(model.layers.map((layer) => layer.id)); + const layerSlugsById = new Map( + model.layers.map((layer) => [layer.id, layerSlug(layer.id)]), + ); + + /** + * Resolve every authored page's final slug in one pass. + * + * An attached page moves beneath its layer's page so the guide and the + * generated reference for the same code sit together. Its file name becomes + * the last segment, which is why two guides attached to one layer must not + * share a file name. A page whose `attachTo` does not name a real layer is + * reported and stays where it was, so one bad guide does not move the rest. + */ + const authoredSlugs = new Map(); + const guidesByLayer = new Map< + string, + { slug: string; title: string; description: string }[] + >(); + + for (const page of authoredResult.pages) { + const attached = + page.attachTo !== null && declaredLayerIds.has(page.attachTo); + + if (page.attachTo !== null && !attached) { + diagnostics.push( + error( + page.sourceFile, + `attachTo \`${page.attachTo}\` is not a declared layer`, + ), + ); + } + + if (!attached || page.attachTo === null) { + authoredSlugs.set(page.slug, page.slug); + continue; + } + + const leaf = page.slug.slice(page.slug.lastIndexOf("/") + 1); + const slug = `${layerSlug(page.attachTo)}/${leaf}`; + authoredSlugs.set(page.slug, slug); + guidesByLayer.set(page.attachTo, [ + ...(guidesByLayer.get(page.attachTo) ?? []), + { slug, title: page.title, description: page.description }, + ]); + } + + const componentNames = new Set( + authoredResult.components.map((component) => component.name), + ); + + // Authored pages address their targets by name (`layer:`, `doc:`, `@diagrams/`) + // rather than by path, because a page's depth is only known once `attachTo` is + // resolved. + const authored = authoredResult.pages.map((page) => { + const slug = authoredSlugs.get(page.slug) ?? page.slug; + const linked = resolveAuthoredLinks(page.contents, slug, { + layerSlugs: layerSlugsById, + docSlugs: authoredSlugs, + }); + const imported = resolveComponentImports( + linked.contents, + slug, + componentNames, + ); + + for (const target of [...linked.unresolved, ...imported.unresolved]) { + diagnostics.push( + error(page.sourceFile, `link target \`${target}\` does not resolve`), + ); + } + + return { + ...page, + slug, + path: `pages/${slug}.mdx`, + contents: imported.contents, + }; + }); + + const includeDiagrams = options.includeDiagrams ?? true; + + const generated = buildPages(model, { + sourceUrlPrefix: settings.sourceUrlPrefix, + overviewDiagram: includeDiagrams ? OVERVIEW_DIAGRAM : null, + neighbourhoodDiagrams: includeDiagrams + ? new Map( + model.layers.map((layer) => [ + layer.id, + `${NEIGHBOURHOOD_PREFIX}/${layer.id}`, + ]), + ) + : new Map(), + subtreeDiagrams: includeDiagrams + ? new Map(parentLayerIds.map((id) => [id, `${SUBTREE_PREFIX}/${id}`])) + : new Map(), + guidesByLayer, + }); + + /** + * Checked against generated pages and against other authored pages. + * + * An attached page takes its last slug segment from its own file name, so + * `content/a/protocol.mdx` and `content/b/protocol.mdx` attached to the same + * layer resolve to one page. Checking only against generated pages let one + * overwrite the other while the manifest listed both. + */ + const occupiedSlugs = new Set(generated.map((page) => page.slug)); + for (const page of authored) { + if (occupiedSlugs.has(page.slug)) { + diagnostics.push( + error( + page.sourceFile, + `page slug \`${page.slug}\` is already taken; rename this file or attach it to a different layer`, + ), + ); + continue; + } + occupiedSlugs.add(page.slug); + } + + const manifest = buildManifest({ + generator: GENERATOR_NAME, + generated, + authored, + }); + + return { + model, + manifest, + generated, + authored, + diagramSources, + singleFile: buildSingleFileArchitecture(model), + components: authoredResult.components, + diagnostics, + }; +}; + +/** + * The bundle's text files, keyed by path relative to the bundle root. SVGs are + * excluded: they are produced by the external `d2` renderer, so comparing them + * would make drift detection depend on the renderer's exact version. + */ +export const bundleTextFiles = (bundle: BuiltBundle): Map => { + const files = new Map(); + + files.set("architecture.json", `${JSON.stringify(bundle.model, null, 2)}\n`); + files.set("manifest.json", `${JSON.stringify(bundle.manifest, null, 2)}\n`); + files.set("architecture.md", bundle.singleFile); + + for (const [name, source] of bundle.diagramSources) { + files.set(`diagrams/${name}.d2`, source); + } + + for (const page of bundle.generated) { + files.set(page.path, page.contents); + } + + for (const page of bundle.authored) { + files.set(page.path, page.contents); + } + + for (const component of bundle.components) { + files.set(component.path, component.contents); + } + + return files; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/check.test.ts b/libs/@local/petrinaut-arch-docs/src/check.test.ts new file mode 100644 index 00000000000..f7eb59abc79 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/check.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; + +import { + checkAncestorsDeclared, + checkEmptyLayers, + checkRuleTargets, + checkRules, + withinScope, +} from "./check"; + +import type { ArchitectureModel, Edge, Layer } from "./model"; + +/** + * These are the checks a build fails on, so each one is pinned in both directions: + * it fires when the rule is broken, and stays quiet when it is not. A check that + * silently stops firing is worse than no check, because the docs keep claiming to + * be verified. + */ + +const layer = (overrides: Partial & Pick): Layer => ({ + name: overrides.id, + parent: + overrides.id.lastIndexOf(".") === -1 + ? null + : overrides.id.slice(0, overrides.id.lastIndexOf(".")), + package: "@test/pkg", + role: "role", + declaredIn: `src/${overrides.id}/README.md`, + prose: null, + references: [], + files: ["src/a.ts"], + fileCount: 1, + lineCount: 1, + ...overrides, +}); + +const edge = (from: string, to: string, count = 1): Edge => ({ + from, + to, + fileDependencies: count, + examples: [{ from: `src/${from}.ts`, to: `src/${to}.ts` }], + crossesPackage: false, +}); + +const model = (layers: Layer[], edges: Edge[] = []): ArchitectureModel => ({ + version: 1, + packages: [], + layers, + edges, + rules: [], +}); + +describe("withinScope", () => { + it("matches the scope itself and its descendants", () => { + expect(withinScope("core", "core")).toBe(true); + expect(withinScope("core.simulation", "core")).toBe(true); + expect(withinScope("core.simulation.engine", "core")).toBe(true); + }); + + it("does not match a sibling that merely shares a prefix", () => { + expect(withinScope("coreutils", "core")).toBe(false); + expect(withinScope("react", "core")).toBe(false); + }); +}); + +describe("checkAncestorsDeclared", () => { + it("reports an implied ancestor that nobody declared", () => { + const diagnostics = checkAncestorsDeclared( + model([layer({ id: "core" }), layer({ id: "core.a.b" })]), + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`core.a`"); + expect(diagnostics[0]?.severity).toBe("error"); + }); + + it("stays quiet when the whole chain is declared", () => { + const diagnostics = checkAncestorsDeclared( + model([ + layer({ id: "core" }), + layer({ id: "core.a" }), + layer({ id: "core.a.b" }), + ]), + ); + + expect(diagnostics).toEqual([]); + }); +}); + +describe("checkRuleTargets", () => { + const layers = [layer({ id: "core" }), layer({ id: "ui" })]; + + it("reports a rule naming a layer that does not exist", () => { + const diagnostics = checkRuleTargets(model(layers), [ + { from: "reactt", to: "ui", reason: "typo in from" }, + ]); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`reactt`"); + expect(diagnostics[0]?.message).toContain("can never fire"); + expect(diagnostics[0]?.file).toBe("architecture.config.ts"); + }); + + it("reports both sides when both are wrong", () => { + const diagnostics = checkRuleTargets(model(layers), [ + { from: "nope", to: "also-nope", reason: "two typos" }, + ]); + + expect(diagnostics).toHaveLength(2); + }); + + it("stays quiet when both sides name declared layers", () => { + const diagnostics = checkRuleTargets(model(layers), [ + { from: "core", to: "ui", reason: "core stays headless" }, + ]); + + expect(diagnostics).toEqual([]); + }); + + /** + * A rule states its endpoints as layer ids, and `checkRules` widens each to + * cover descendants. An ancestor prefix that is not itself declared would + * therefore match nothing, which is the case this rejects. + */ + it("rejects a prefix that is not itself a declared layer", () => { + const diagnostics = checkRuleTargets( + model([layer({ id: "core" }), layer({ id: "core.engine" })]), + [{ from: "core.eng", to: "core", reason: "partial segment" }], + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`core.eng`"); + }); +}); + +describe("checkRules", () => { + const layers = [ + layer({ id: "core" }), + layer({ id: "core.engine" }), + layer({ id: "ui" }), + layer({ id: "ui.views" }), + ]; + + it("fires when a descendant violates a rule stated at the parent", () => { + const diagnostics = checkRules( + model(layers, [edge("core.engine", "ui.views", 4)]), + [{ from: "core", to: "ui", reason: "core stays headless" }], + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("core stays headless"); + expect(diagnostics[0]?.message).toContain("4 imports"); + // Names a real file so the fix does not begin with a search. + expect(diagnostics[0]?.file).toBe("src/core.engine.ts"); + }); + + it("stays quiet on the permitted direction", () => { + const diagnostics = checkRules( + model(layers, [edge("ui.views", "core.engine")]), + [{ from: "core", to: "ui", reason: "core stays headless" }], + ); + + expect(diagnostics).toEqual([]); + }); + + it("uses singular wording for a single import", () => { + const diagnostics = checkRules(model(layers, [edge("core", "ui", 1)]), [ + { from: "core", to: "ui", reason: "because" }, + ]); + + expect(diagnostics[0]?.message).toContain("1 import does"); + }); +}); + +describe("checkEmptyLayers", () => { + it("warns about a leaf layer with no files", () => { + const diagnostics = checkEmptyLayers( + model([layer({ id: "core", files: [], fileCount: 0 })]), + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.severity).toBe("warning"); + }); + + it("allows a grouping layer to hold no files of its own", () => { + const diagnostics = checkEmptyLayers( + model([ + layer({ id: "core", files: [], fileCount: 0 }), + layer({ id: "core.engine" }), + ]), + ); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/check.ts b/libs/@local/petrinaut-arch-docs/src/check.ts new file mode 100644 index 00000000000..9936756d341 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/check.ts @@ -0,0 +1,137 @@ +/** + * Checks that turn the documentation into something a build can hold to account. + * + * Documentation rots because nothing fails when it stops being true. Each check + * here makes one class of rot into a build error: a layer id implying an + * ancestor nobody declared, a rule the real import graph violates, a + * declaration whose folder has been emptied. + * + * Every check states something about the graph, which is all this version + * asserts. It holds no unverifiable prose to account because it reads none. + * + * Several checks exist because their failure removes coverage instead of + * producing an error. A rule with a typo, or an `exports` subpath that stops + * resolving, leaves a build that passes while verifying less than it claims. + */ + +import { error, warning, type Diagnostic } from "./diagnostics"; +import { ancestorLayerIds } from "./model"; + +import type { LayerRule } from "../architecture.config"; +import type { ArchitectureModel } from "./model"; + +/** True when `layerId` is the scope itself or nested inside it. */ +export const withinScope = (layerId: string, scope: string): boolean => + layerId === scope || layerId.startsWith(`${scope}.`); + +/** + * Every ancestor segment of a dotted layer id must itself be a declared layer. + * + * Without this, `core.simulation.monte-carlo` could exist while + * `core.simulation` does not, leaving the diagram with a container nobody + * described and the docs with a page that 404s from its own breadcrumb. + */ +export const checkAncestorsDeclared = ( + model: ArchitectureModel, +): Diagnostic[] => { + const declared = new Set(model.layers.map((layer) => layer.id)); + + return model.layers.flatMap((layer) => + ancestorLayerIds(layer.id) + .filter((ancestor) => !declared.has(ancestor)) + .map((ancestor) => + error( + layer.declaredIn, + `layer \`${layer.id}\` implies an ancestor \`${ancestor}\` that is not declared anywhere`, + ), + ), + ); +}; + +/** + * Rules are only worth stating if they are enforced, so a violation is an error + * rather than a note on a page. The message names representative files so the + * fix does not start with a search. + */ +export const checkRules = ( + model: ArchitectureModel, + rules: LayerRule[], +): Diagnostic[] => + rules.flatMap((rule) => + model.edges + .filter( + (edge) => + withinScope(edge.from, rule.from) && withinScope(edge.to, rule.to), + ) + .map((edge) => { + const example = edge.examples[0]; + const imports = + edge.fileDependencies === 1 + ? "1 import does" + : `${edge.fileDependencies} imports do`; + + return error( + example?.from ?? edge.from, + `\`${edge.from}\` must not depend on \`${edge.to}\` (${rule.reason}); ${imports}, e.g. ${example ? `${example.from} → ${example.to}` : "unknown"}`, + ); + }), + ); + +/** + * Layers with no files usually mean a declaration whose folder was emptied or + * renamed. Grouping layers legitimately hold no files of their own, so this is a + * warning: it is reported, but does not fail the build. + */ +export const checkEmptyLayers = (model: ArchitectureModel): Diagnostic[] => { + const hasChildren = new Set( + model.layers.flatMap((layer) => + layer.parent === null ? [] : [layer.parent], + ), + ); + + return model.layers + .filter((layer) => layer.fileCount === 0 && !hasChildren.has(layer.id)) + .map((layer) => + warning( + layer.declaredIn, + `layer \`${layer.id}\` has no source files and no sub-layers; was its folder moved or emptied?`, + ), + ); +}; + +/** + * A rule naming a layer that does not exist matches no edge and fires never. + * + * `checkRules` matches by layer id or ancestor prefix, so `reactt` is not a + * pattern that happens to match nothing: it is a rule that has been switched off + * by a typo, while still reading as enforced on the overview page. The rules are + * the only claims here checked against the import graph, so a silently inert one + * is the most expensive kind of mistake this file can allow. + */ +export const checkRuleTargets = ( + model: ArchitectureModel, + rules: LayerRule[], +): Diagnostic[] => { + const declared = new Set(model.layers.map((layer) => layer.id)); + + return rules.flatMap((rule) => + (["from", "to"] as const) + .filter((side) => !declared.has(rule[side])) + .map((side) => + error( + "architecture.config.ts", + `rule \`${rule.from}\` must not depend on \`${rule.to}\` names \`${rule[side]}\` as its \`${side}\`, which is not a declared layer, so the rule can never fire`, + ), + ), + ); +}; + +export const runChecks = (options: { + model: ArchitectureModel; + rules: LayerRule[]; +}): Diagnostic[] => [ + ...checkAncestorsDeclared(options.model), + ...checkRuleTargets(options.model, options.rules), + ...checkRules(options.model, options.rules), + ...checkEmptyLayers(options.model), +]; diff --git a/libs/@local/petrinaut-arch-docs/src/cli.ts b/libs/@local/petrinaut-arch-docs/src/cli.ts new file mode 100644 index 00000000000..f3c38fc8dcb --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/cli.ts @@ -0,0 +1,154 @@ +/** + * `arch-docs build` writes the bundle; `arch-docs check` reports on it. + * + * `check` reports without writing: it builds everything, discards the files, and + * fails on any annotation error — an unannotated file, an undeclared ancestor, a + * violated rule — so the map cannot quietly stop matching the code. + */ + +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { config } from "../architecture.config"; +import { buildBundle, bundleTextFiles, type BuiltBundle } from "./build"; +import { countErrors, type Diagnostic } from "./diagnostics"; +import { canRenderDiagrams, renderD2 } from "./emit/d2"; + +const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); +const bundleRoot = join(repoRoot, config.outputDirectory); + +const supportsColour = process.stdout.isTTY === true; + +const colour = (code: string, text: string): string => + supportsColour ? `\u001B[${code}m${text}\u001B[0m` : text; + +const red = (text: string): string => colour("31", text); +const yellow = (text: string): string => colour("33", text); +const dim = (text: string): string => colour("2", text); + +const reportDiagnostics = (diagnostics: Diagnostic[]): { errors: number } => { + const errors = diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ); + const warnings = diagnostics.filter( + (diagnostic) => diagnostic.severity === "warning", + ); + + for (const diagnostic of [...errors, ...warnings]) { + const location = + diagnostic.line === null + ? diagnostic.file + : `${diagnostic.file}:${diagnostic.line}`; + const label = + diagnostic.severity === "error" ? red("error") : yellow("warning"); + process.stderr.write(`${label} ${location}\n ${diagnostic.message}\n`); + } + + return { errors: countErrors(diagnostics) }; +}; + +const summarise = (bundle: BuiltBundle): void => { + const files = bundle.model.layers.reduce( + (total, layer) => total + layer.fileCount, + 0, + ); + + process.stdout.write( + dim( + `${bundle.model.layers.length} layers \u00b7 ${bundle.model.edges.length} edges \u00b7 ${files} files \u00b7 ${bundle.generated.length} generated pages \u00b7 ${bundle.authored.length} authored pages\n`, + ), + ); +}; + +/** Returns false when a diagram the pages already reference failed to render. */ +const writeBundle = async (bundle: BuiltBundle): Promise => { + // Generated pages, diagrams and components are rewritten wholesale so a + // renamed layer or a deleted component cannot leave an orphan behind, which + // would silently keep serving a description of code that no longer exists. + await rm(join(bundleRoot, "pages"), { recursive: true, force: true }); + await rm(join(bundleRoot, "diagrams"), { recursive: true, force: true }); + await rm(join(bundleRoot, "components"), { recursive: true, force: true }); + await mkdir(bundleRoot, { recursive: true }); + + for (const [path, contents] of bundleTextFiles(bundle)) { + const target = join(bundleRoot, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents, "utf8"); + } + + for (const name of bundle.diagramSources.keys()) { + const source = join(bundleRoot, `diagrams/${name}.d2`); + const output = join(bundleRoot, `diagrams/${name}.svg`); + const result = renderD2(repoRoot, source, output); + + if (!result.ok) { + // The pages were emitted expecting this SVG, because `d2` answered the + // availability probe. A bundle that references an image it does not + // contain fails the consuming site's build, so this stops here rather + // than writing one. + process.stderr.write( + `${red("error")} could not render ${name}.svg, and the pages already reference it\n ${result.error}\n`, + ); + return false; + } + + // d2 writes 0600, which leaves the committed SVG unreadable to anything + // serving the bundle under a different user. + await chmod(output, 0o644); + } + + return true; +}; + +const main = async (): Promise => { + const command = process.argv[2] ?? "build"; + + if (!["build", "check"].includes(command)) { + process.stderr.write( + `unknown command \`${command}\`; expected build or check\n`, + ); + return 2; + } + + // Probed before building so pages only reference diagrams that will exist. + // `check` never writes, so it does not care either way. + const diagramsAvailable = command === "check" || canRenderDiagrams(repoRoot); + + if (command === "build" && !diagramsAvailable) { + process.stderr.write( + `${yellow("warning")} d2 is unavailable, so the bundle is being written without rendered diagrams.\n Install it with \`mise install\` to include them.\n`, + ); + } + + const bundle = await buildBundle({ + repoRoot, + includeDiagrams: diagramsAvailable, + }); + const { errors } = reportDiagnostics(bundle.diagnostics); + summarise(bundle); + + if (command === "check") { + // `check` builds the whole bundle and discards the files. The build is + // what surfaces the diagnostics — an unannotated file, an undeclared + // ancestor, a violated rule — and because the bundle is never stored, there is no + // committed copy that could be out of date with the source. + return errors > 0 ? 1 : 0; + } + + if (errors > 0) { + process.stderr.write( + `\n${red("refusing to write")} the bundle while ${errors} annotation error${errors === 1 ? "" : "s"} remain\n`, + ); + return 1; + } + + if (!(await writeBundle(bundle))) { + return 1; + } + + process.stdout.write(`Wrote ${config.outputDirectory}\n`); + return 0; +}; + +process.exitCode = await main(); diff --git a/libs/@local/petrinaut-arch-docs/src/content.ts b/libs/@local/petrinaut-arch-docs/src/content.ts new file mode 100644 index 00000000000..7d26104c59d --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/content.ts @@ -0,0 +1,228 @@ +/** + * Hand-written MDX that ships in the same bundle as the generated pages. + * + * Authored pages carry the reasoning an import graph cannot supply. They end up + * in the same manifest as the generated pages, so a host renders one coherent + * set of docs. Slugs mirror the directory layout under `content/`. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +import { parseFrontmatterRecord } from "./frontmatter"; +import { toPosix } from "./paths"; + +import type { Dirent } from "node:fs"; + +export interface AuthoredPage { + /** Path within the bundle. */ + path: string; + slug: string; + title: string; + description: string; + contents: string; + order: number; + /** + * Layer id this page explains, from the `attachTo` frontmatter key. + * + * When set, the page is placed beneath that layer's page rather than at the + * top level, so a hand-written guide sits with the generated reference for the + * same code instead of in a separate section. Null means a standalone page. + * + * Note this is *not* a layer declaration — it attaches to a layer declared in + * the source. Declaring layers from `content/` stays forbidden. + */ + attachTo: string | null; + /** Repo-relative source file, for error messages. */ + sourceFile: string; +} + +/** A diagram component shipped in the bundle for authored pages to import. */ +export interface AuthoredComponent { + /** Path within the bundle, e.g. `components/lanes.tsx`. */ + path: string; + /** Import name authors use, e.g. `lanes` for `@diagrams/lanes`. */ + name: string; + contents: string; +} + +export interface AuthoredContentResult { + pages: AuthoredPage[]; + components: AuthoredComponent[]; + errors: { file: string; message: string }[]; +} + +const authoredExtensions = new Set([".md", ".mdx"]); +const componentExtensions = new Set([".tsx", ".ts", ".css"]); + +/** Directory under `content/` holding importable diagram components. */ +const componentDirectory = "components"; + +const extensionOf = (name: string): string => { + const dot = name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot).toLowerCase(); +}; + +/** + * A scalar frontmatter value as a trimmed string. + * + * YAML gives back the type it inferred, so `sidebar_order: 10` is a number and + * `title: 2026` is too. Anything that is not a scalar (a list, a nested + * mapping) has no sensible single-line reading and is treated as absent. + */ +const readString = (value: unknown): string | null => { + if (typeof value === "string") { + return value.trim() === "" ? null : value.trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return null; +}; + +/** + * Reads `title`, `description`, `sidebar_order` and `attachTo` out of a page's + * frontmatter, without requiring the architecture-declaration shape. + */ +const readPageMeta = ( + record: Record | null, +): { + title: string | null; + description: string; + order: number; + attachTo: string | null; +} => { + if (record === null) { + return { title: null, description: "", order: 100, attachTo: null }; + } + + const rawOrder = record.sidebar_order; + const order = + typeof rawOrder === "number" ? rawOrder : Number(readString(rawOrder)); + + return { + title: readString(record.title), + description: readString(record.description) ?? "", + order: Number.isFinite(order) ? order : 100, + attachTo: readString(record.attachTo), + }; +}; + +/** Derives a heading-based title when frontmatter omits one. */ +const firstHeading = (markdown: string): string | null => { + const withoutFrontmatter = markdown.replace( + /^---\r?\n[\s\S]*?\r?\n---\r?\n?/u, + "", + ); + const match = /^#\s+(.+)$/mu.exec(withoutFrontmatter); + return match ? (match[1] ?? "").trim() : null; +}; + +const titleFromSlug = (slug: string): string => { + const last = slug.split("/").pop() ?? slug; + return last + .split("-") + .map((word) => + word === "" ? word : word[0]?.toUpperCase() + word.slice(1), + ) + .join(" "); +}; + +export const collectAuthoredContent = async (options: { + repoRoot: string; + contentDirectory: string; +}): Promise => { + const root = join(options.repoRoot, options.contentDirectory); + const pages: AuthoredPage[] = []; + const components: AuthoredComponent[] = []; + const errors: { file: string; message: string }[] = []; + + let entries: Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true, recursive: true }); + } catch (cause) { + // An absent directory is a normal state, since `content/` is optional. + // Anything else (a permission error, a bad path) would otherwise drop every + // authored page and still report success. + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") { + throw cause; + } + return { pages, components, errors }; + } + + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const componentPath = toPosix( + relative(root, join(entry.parentPath, entry.name)), + ); + + if (componentPath.startsWith(`${componentDirectory}/`)) { + if (componentExtensions.has(extensionOf(entry.name))) { + components.push({ + path: componentPath, + name: componentPath + .slice(componentDirectory.length + 1) + .replace(/\.[^.]+$/u, ""), + contents: await readFile(join(entry.parentPath, entry.name), "utf8"), + }); + } + continue; + } + + if (!authoredExtensions.has(extensionOf(entry.name))) { + continue; + } + + const absolutePath = join(entry.parentPath, entry.name); + const relativePath = toPosix(relative(root, absolutePath)); + const contents = await readFile(absolutePath, "utf8"); + + const { record, errors: frontmatterErrors } = + parseFrontmatterRecord(contents); + + // Checked as a key, not as a successful declaration parse. Going through + // the strict declaration schema meant an unrelated key such as `title` + // failed the parse and let the `layer` key through unreported. + if (record !== null && "layer" in record) { + errors.push({ + file: toPosix(relative(options.repoRoot, absolutePath)), + message: + "authored pages must not declare a layer; layer declarations belong in the source packages", + }); + } + + // `parseFrontmatterRecord` only reports unreadable YAML, which is the one + // failure an authored page cannot legitimately have. + for (const message of frontmatterErrors) { + errors.push({ + file: toPosix(relative(options.repoRoot, absolutePath)), + message, + }); + } + + const slug = relativePath.replace(/\.(?:md|mdx)$/iu, ""); + const meta = readPageMeta(record); + + pages.push({ + path: `pages/${slug}.mdx`, + slug, + title: meta.title ?? firstHeading(contents) ?? titleFromSlug(slug), + description: meta.description, + contents, + order: meta.order, + attachTo: meta.attachTo, + sourceFile: toPosix(relative(options.repoRoot, absolutePath)), + }); + } + + return { + pages: pages.sort((left, right) => left.slug.localeCompare(right.slug)), + components: components.sort((left, right) => + left.path.localeCompare(right.path), + ), + errors, + }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/diagnostics.ts b/libs/@local/petrinaut-arch-docs/src/diagnostics.ts new file mode 100644 index 00000000000..e33e8fe9663 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/diagnostics.ts @@ -0,0 +1,33 @@ +/** + * What every stage reports, and how. + * + * One shape for extraction, graph building and the checks, so the CLI formats + * them identically and no stage has to invent its own reporting. This lived in + * `extract.ts`, which meant the graph builder could only return bare strings and + * the caller had to guess a file to attribute them to. + */ + +export interface Diagnostic { + /** Repo-relative path the reader should open. */ + file: string; + line: number | null; + message: string; + severity: "error" | "warning"; +} + +/** Fails the build. */ +export const error = ( + file: string, + message: string, + line: number | null = null, +): Diagnostic => ({ file, line, message, severity: "error" }); + +/** Reported, but does not fail the build. */ +export const warning = ( + file: string, + message: string, + line: number | null = null, +): Diagnostic => ({ file, line, message, severity: "warning" }); + +export const countErrors = (diagnostics: Diagnostic[]): number => + diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; diff --git a/libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts b/libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts new file mode 100644 index 00000000000..1d0b10dcd43 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts @@ -0,0 +1,139 @@ +/** + * The bundle's index files: `manifest.json` and `architecture.md`. + * + * `manifest.json` is what a host site reads to build navigation without having + * to crawl the pages directory. `architecture.md` is the whole architecture as a + * single file, which is cheaper for a model to read than fetching thirty pages. + */ + +import { ARCHITECTURE_MODEL_VERSION, type ArchitectureModel } from "../model"; + +import type { AuthoredPage } from "../content"; +import type { GeneratedPage } from "./mdx"; + +export const BUNDLE_MANIFEST_VERSION = 1; + +export interface ManifestPage { + /** Path within the bundle. */ + path: string; + slug: string; + title: string; + description: string; + /** `generated` pages are rewritten on every build; `authored` are hand-written. */ + kind: "generated" | "authored"; + order: number; +} + +export interface BundleManifest { + manifestVersion: number; + modelVersion: number; + generator: string; + /** Relative path to the machine-readable model. */ + model: string; + pages: ManifestPage[]; +} + +export const buildManifest = (options: { + generator: string; + generated: GeneratedPage[]; + authored: AuthoredPage[]; +}): BundleManifest => { + const pages: ManifestPage[] = [ + ...options.generated.map((page) => ({ + path: page.path, + slug: page.slug, + title: page.title, + description: page.description, + kind: "generated" as const, + order: page.order, + })), + ...options.authored.map((page) => ({ + path: page.path, + slug: page.slug, + title: page.title, + description: page.description, + kind: "authored" as const, + order: page.order, + })), + ].sort( + (left, right) => + left.order - right.order || left.slug.localeCompare(right.slug), + ); + + return { + manifestVersion: BUNDLE_MANIFEST_VERSION, + modelVersion: ARCHITECTURE_MODEL_VERSION, + generator: options.generator, + model: "architecture.json", + pages, + }; +}; + +/** + * The whole architecture as one Markdown file. + * + * Ordered so a reader (human or model) meets the system top-down: what the + * packages are, what the rules are, then each layer with its role and + * dependencies. Deliberately terse — this is a reference, and + * every token spent on prose here is one an agent pays on every read. + */ +export const buildSingleFileArchitecture = ( + model: ArchitectureModel, +): string => { + const lines: string[] = [ + "# Petrinaut architecture", + "", + "Generated from annotations in the source. Do not edit — change the `@layerRoot`/`@role` annotations or the declaring README frontmatter instead.", + "", + "## Packages", + "", + ]; + + for (const pkg of model.packages) { + lines.push(`- \`${pkg.name}\` (\`${pkg.path}\`) — ${pkg.description}`); + } + + if (model.rules.length > 0) { + lines.push("", "## Enforced rules", ""); + for (const rule of model.rules) { + lines.push( + `- \`${rule.from}\` must not depend on \`${rule.to}\` — ${rule.reason}`, + ); + } + } + + lines.push("", "## Layers", ""); + + for (const layer of model.layers) { + lines.push( + `### ${layer.name} (\`${layer.id}\`)`, + "", + layer.role, + "", + `- Package: \`${layer.package}\``, + `- Declared in: \`${layer.declaredIn}\``, + `- Size: ${layer.fileCount} files, ${layer.lineCount} lines`, + ); + + const outgoing = model.edges.filter((edge) => edge.from === layer.id); + + if (outgoing.length > 0) { + const rendered = outgoing + .slice() + .sort((left, right) => right.fileDependencies - left.fileDependencies) + .map((edge) => `\`${edge.to}\` (${edge.fileDependencies})`) + .join(", "); + lines.push(`- Depends on: ${rendered}`); + } + + if (layer.references.length > 0) { + lines.push( + `- Further reading: ${layer.references.map((reference) => `\`${reference}\``).join(", ")}`, + ); + } + + lines.push(""); + } + + return `${lines.join("\n")}\n`; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts b/libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts new file mode 100644 index 00000000000..87ebbefe3c5 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { buildNeighbourhoodDiagram } from "./d2"; + +import type { Edge, Layer } from "../model"; + +/** + * The neighbourhood diagram is the one a reader opens first, and the only diagram + * that decides what to leave out. These tests pin what it draws and, more + * importantly, what it says about the parts it does not draw: a cap that silently + * dropped neighbours would make a layer look less connected than it is. + */ + +const layer = (id: string, name = id): Layer => ({ + id, + name, + parent: id.includes(".") ? id.slice(0, id.lastIndexOf(".")) : null, + package: "@test/pkg", + role: `role of ${id}`, + declaredIn: `src/${id}/index.ts`, + prose: null, + references: [], + files: [`src/${id}/index.ts`], + fileCount: 1, + lineCount: 1, +}); + +const edge = (from: string, to: string, count = 1): Edge => ({ + from, + to, + fileDependencies: count, + examples: [], + crossesPackage: false, +}); + +const build = (focus: string, layers: Layer[], edges: Edge[]): string => + buildNeighbourhoodDiagram(focus, layers, edges, "test"); + +describe("buildNeighbourhoodDiagram", () => { + const layers = [layer("core"), layer("core.a"), layer("core.b")]; + + it("draws dependencies and dependents in the right directions", () => { + const diagram = build("core.a", layers, [ + edge("core.a", "core.b", 3), + edge("core", "core.a", 2), + ]); + + expect(diagram).toContain("core_a -> core_b"); + expect(diagram).toContain("core -> core_a"); + expect(diagram).toContain("3 file-level dependencies"); + expect(diagram).toContain("2 file-level dependencies"); + }); + + it("marks the focus and labels nodes with their role", () => { + const diagram = build("core.a", layers, [edge("core.a", "core.b")]); + + expect(diagram).toContain("core_a: {class: [core; focus]"); + expect(diagram).toContain("role of core.b"); + }); + + it("flattens dotted ids so D2 does not synthesise container boxes", () => { + const diagram = build("core.a", layers, [edge("core.a", "core.b")]); + + expect(diagram).toContain("core_a"); + expect(diagram).not.toContain("core.a:"); + }); + + it("keeps both directions between a reciprocal pair", () => { + const diagram = build("core.a", layers, [ + edge("core.a", "core.b", 5), + edge("core.b", "core.a", 7), + ]); + + expect(diagram).toContain("core_a -> core_b"); + expect(diagram).toContain("core_b -> core_a"); + expect(diagram).toContain("5 file-level dependencies"); + expect(diagram).toContain("7 file-level dependencies"); + }); + + it("says so when a layer has no dependencies either way", () => { + const diagram = build("core.a", layers, [edge("core", "core.b")]); + + expect(diagram).toContain("no dependencies either way"); + }); + + describe("when a layer has more neighbours than fit", () => { + const many = [ + layer("hub"), + ...Array.from({ length: 15 }, (_, index) => layer(`n${index}`)), + ]; + // Descending weights, so the cap keeps the heaviest twelve. + const heavy = Array.from({ length: 15 }, (_, index) => + edge(`n${index}`, "hub", 15 - index), + ); + + it("draws twelve and collapses the rest into one node", () => { + const diagram = build("hub", many, heavy); + + const drawn = [...diagram.matchAll(/^n\d+: \{/gmu)]; + expect(drawn).toHaveLength(12); + expect(diagram).toContain("+3 further layers"); + }); + + it("reports the elided dependencies rather than dropping them", () => { + const diagram = build("hub", many, heavy); + + // The three lightest edges carry 3, 2 and 1 dependencies. + expect(diagram).toContain("6 file-level dependencies"); + expect(diagram).toContain("elided -> hub"); + }); + + it("keeps the heaviest neighbours and discards the lightest", () => { + const diagram = build("hub", many, heavy); + + expect(diagram).toContain("n0: {"); + expect(diagram).not.toMatch(/^n14: \{/mu); + }); + + it("points the elided edge outward when the focus is the importer", () => { + const outward = Array.from({ length: 15 }, (_, index) => + edge("hub", `n${index}`, 15 - index), + ); + const diagram = build("hub", many, outward); + + expect(diagram).toContain("hub -> elided"); + expect(diagram).not.toContain("elided -> hub"); + }); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/emit/d2.ts b/libs/@local/petrinaut-arch-docs/src/emit/d2.ts new file mode 100644 index 00000000000..d00e962616e --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/d2.ts @@ -0,0 +1,421 @@ +/** + * Diagram generation. + * + * Dotted layer ids map straight onto D2's container syntax, so + * `core.simulation.monte-carlo` nests inside `core.simulation` inside `core` + * with no extra bookkeeping — the taxonomy the annotations declare *is* the + * diagram's grouping. + * + * Three kinds of diagram, each bounding its node count a different way: + * + * - the overview is a level cut at depth 1 — the top-level layers only; + * - a layer with sub-layers gets a drill-down of its direct children; + * - every layer, leaf or not, gets a neighbourhood showing what it depends on + * and what depends on it. + * + * The neighbourhood is the one a reader usually wants: landing on a layer page, + * the question is what this layer touches, not what sits inside it. Leaves have + * nothing inside them and are exactly where readers land. + * + * Both `.d2` source and rendered `.svg` are written. + */ + +import { spawnSync } from "node:child_process"; + +import type { Edge, Layer } from "../model"; + +const quote = (text: string): string => JSON.stringify(text); + +/** Top-level ancestor of a dotted id, used for colour classing. */ +const rootSegment = (id: string): string => id.split(".")[0] ?? id; + +const styleClasses = [ + `classes: {`, + ` core: {style.fill: "#dcecff"; style.stroke: "#3676b8"}`, + ` react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"}`, + ` ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"}`, + ` petrinaut: {style.fill: "#fdeedc"; style.stroke: "#b5762f"}`, + ` other: {style.fill: "#f2f2f2"; style.stroke: "#777777"}`, + ` boundary: {style.stroke-dash: 4}`, + ` focus: {style.stroke-width: 3; style.bold: true}`, + ` elided: {style.fill: "#ffffff"; style.stroke: "#aaaaaa"; style.stroke-dash: 3; style.italic: true}`, + `}`, +].join("\n"); + +const knownRoots = ["core", "react", "ui", "petrinaut"]; + +const classFor = (id: string): string => { + const root = rootSegment(id); + return knownRoots.includes(root) ? root : "other"; +}; + +const pluralise = (count: number, singular: string, plural?: string): string => + `${count} ${count === 1 ? singular : (plural ?? `${singular}s`)}`; + +const header = (generatedBy: string): string => + `# Generated by ${generatedBy}. Edit the annotations in the source, not this file.\n\ndirection: right\n`; + +/** + * D2 reads `.` as container nesting, so a dotted id used as a key would make it + * synthesise ancestor boxes. Flattening to `_` keeps every node a peer, which is + * what a neighbourhood diagram wants — the focus and its neighbours sit + * alongside each other regardless of where they live in the taxonomy. + */ +const flatKey = (id: string): string => id.replace(/\./gu, "_"); + +/** + * How many neighbours a neighbourhood diagram draws before eliding the rest. + * + * Bounded because a widely-depended-on layer (`core.types`) would otherwise + * produce a diagram with every consumer in the system on it, which is accurate + * and unreadable. The elided count is drawn as its own node rather than dropped, + * so the diagram never implies a layer has fewer neighbours than it does. + */ +const maxNeighbours = 12; + +/** + * One diagram per layer, showing what it depends on and what depends on it. + * + * Only edges *incident to the focus* are drawn. Edges among the neighbours + * themselves are real but belong to those layers' own pages; including them + * turns a readable star into the same tangle the overview already avoids. + */ +export const buildNeighbourhoodDiagram = ( + focusId: string, + layers: Layer[], + edges: Edge[], + generatedBy: string, +): string => { + const layersById = new Map(layers.map((layer) => [layer.id, layer])); + const focus = layersById.get(focusId); + + /** Neighbour id → dependencies in each direction. */ + const neighbours = new Map(); + + const record = ( + otherId: string, + direction: "out" | "in", + count: number, + ): void => { + const entry = neighbours.get(otherId) ?? { out: 0, in: 0 }; + entry[direction] += count; + neighbours.set(otherId, entry); + }; + + for (const edge of edges) { + if (edge.from === focusId && edge.to !== focusId) { + record(edge.to, "out", edge.fileDependencies); + } else if (edge.to === focusId && edge.from !== focusId) { + record(edge.from, "in", edge.fileDependencies); + } + } + + const ranked = [...neighbours.entries()].sort( + ([leftId, left], [rightId, right]) => + right.out + right.in - (left.out + left.in) || + leftId.localeCompare(rightId), + ); + + const shown = ranked.slice(0, maxNeighbours); + const elided = ranked.slice(maxNeighbours); + + const nodeLines = [ + `${flatKey(focusId)}: {class: [${classFor(focusId)}; focus]; label: ${quote( + focus?.name ?? focusId, + )}; tooltip: ${quote(focus?.role ?? focusId)}}`, + ...shown.map(([id]) => { + const layer = layersById.get(id); + return `${flatKey(id)}: {class: ${classFor(id)}; label: ${quote( + layer?.name ?? id, + )}; tooltip: ${quote(layer?.role ?? id)}}`; + }), + ]; + + const elidedOut = elided.reduce((sum, [, counts]) => sum + counts.out, 0); + const elidedIn = elided.reduce((sum, [, counts]) => sum + counts.in, 0); + + if (elided.length > 0) { + const total = elidedOut + elidedIn; + nodeLines.push( + `elided: {class: elided; label: ${quote( + `+${pluralise(elided.length, "further layer")}`, + )}; tooltip: ${quote( + `${pluralise(total, "file-level dependency", "file-level dependencies")}, omitted to keep the diagram readable — the full list is in the tables below`, + )}}`, + ); + } + + const edgeLines: string[] = []; + + for (const [id, counts] of shown) { + if (counts.out > 0) { + edgeLines.push( + `${flatKey(focusId)} -> ${flatKey(id)}: {tooltip: ${quote( + pluralise( + counts.out, + "file-level dependency", + "file-level dependencies", + ), + )}}`, + ); + } + if (counts.in > 0) { + edgeLines.push( + `${flatKey(id)} -> ${flatKey(focusId)}: {tooltip: ${quote( + pluralise( + counts.in, + "file-level dependency", + "file-level dependencies", + ), + )}}`, + ); + } + } + + // Drawn dashed and in whichever directions the elided layers actually use, so + // the summary node reads as part of the graph rather than as a stray legend. + if (elidedOut > 0) { + edgeLines.push( + `${flatKey(focusId)} -> elided: {class: boundary; tooltip: ${quote( + pluralise( + elidedOut, + "file-level dependency", + "file-level dependencies", + ), + )}}`, + ); + } + if (elidedIn > 0) { + edgeLines.push( + `elided -> ${flatKey(focusId)}: {class: boundary; tooltip: ${quote( + pluralise(elidedIn, "file-level dependency", "file-level dependencies"), + )}}`, + ); + } + + if (nodeLines.length === 1) { + nodeLines.push( + `isolated: {class: elided; label: ${quote("no dependencies either way")}}`, + ); + } + + return [ + header(generatedBy), + `# what ${focusId} depends on, and what depends on it\n`, + nodeLines.join("\n"), + "\n# dependencies\n", + edgeLines.join("\n"), + "\n# styling\n", + styleClasses, + "", + ].join("\n"); +}; + +/** + * One diagram per layer that has sub-layers, showing only its *direct* children. + * + * Nesting the entire sub-tree produced an unreadable tangle — 23 layers under + * `core` with every inter-layer edge drawn is technically accurate and useless. + * Showing one level at a time, with edges aggregated up to that level, keeps each + * diagram legible and lets a reader drill down through the pages instead. + */ +export const buildSubtreeDiagram = ( + parentId: string, + layers: Layer[], + edges: Edge[], + generatedBy: string, +): string => { + const children = layers.filter((layer) => layer.parent === parentId); + const childIds = children.map((layer) => layer.id); + + /** + * D2 reads `.` as container nesting, so a full dotted id as a key would make + * it synthesise the ancestor boxes this diagram exists to leave out. The leaf + * segment is unique among siblings, which is all a single-level diagram needs. + */ + const key = (layerId: string): string => layerId.slice(parentId.length + 1); + + /** The direct child of `parentId` that `layerId` sits under, if any. */ + const childScopeOf = (layerId: string): string | null => + childIds.find( + (childId) => layerId === childId || layerId.startsWith(`${childId}.`), + ) ?? null; + + /** Total files in a child, including its own descendants. */ + const totalFiles = (childId: string): number => + layers + .filter( + (layer) => layer.id === childId || layer.id.startsWith(`${childId}.`), + ) + .reduce((total, layer) => total + layer.fileCount, 0); + + const nodeLines = children.map((child) => { + const descendants = layers.filter((layer) => + layer.id.startsWith(`${child.id}.`), + ).length; + const detail = + descendants === 0 + ? pluralise(totalFiles(child.id), "source file") + : `${pluralise(totalFiles(child.id), "source file")}, ${pluralise(descendants, "sub-layer")}`; + + return `${key(child.id)}: {class: ${classFor(child.id)}; label: ${quote(child.name)}; tooltip: ${quote(`${detail} · ${child.role}`)}}`; + }); + + const aggregated = new Map(); + for (const edge of edges) { + const from = childScopeOf(edge.from); + const to = childScopeOf(edge.to); + if (from === null || to === null || from === to) { + continue; + } + const pairKey = `${from}\u0000${to}`; + aggregated.set( + pairKey, + (aggregated.get(pairKey) ?? 0) + edge.fileDependencies, + ); + } + + const edgeLines = [...aggregated.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([edgeKey, count]) => { + const [from = "", to = ""] = edgeKey.split("\u0000"); + return `${key(from)} -> ${key(to)}: {tooltip: ${quote( + pluralise(count, "file-level dependency", "file-level dependencies"), + )}}`; + }); + + return [ + header(generatedBy), + `# direct sub-layers of ${parentId}\n`, + nodeLines.join("\n"), + "\n# dependencies\n", + edgeLines.join("\n"), + "\n# styling\n", + styleClasses, + "", + ].join("\n"); +}; + +/** The collapsed graph: top-level layers only, edges aggregated. */ +export const buildOverviewDiagram = ( + layers: Layer[], + edges: Edge[], + generatedBy: string, +): string => { + interface Group { + name: string; + fileCount: number; + roles: string[]; + } + + const groups = new Map(); + + for (const layer of layers) { + const root = rootSegment(layer.id); + const group = groups.get(root) ?? { name: root, fileCount: 0, roles: [] }; + group.fileCount += layer.fileCount; + if (layer.id === root) { + group.name = layer.name; + group.roles.unshift(layer.role); + } + groups.set(root, group); + } + + const aggregated = new Map(); + for (const edge of edges) { + const from = rootSegment(edge.from); + const to = rootSegment(edge.to); + if (from === to) { + continue; + } + const key = `${from} ${to}`; + aggregated.set(key, (aggregated.get(key) ?? 0) + edge.fileDependencies); + } + + const nodeLines = [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([id, group]) => + `${id}: {class: ${classFor(id)}; label: ${quote(group.name)}; tooltip: ${quote( + `${pluralise(group.fileCount, "source file")}${group.roles[0] ? ` · ${group.roles[0]}` : ""}`, + )}}`, + ); + + const edgeLines = [...aggregated.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, count]) => { + const [from = "", to = ""] = key.split(" "); + return `${from} -> ${to}: {tooltip: ${quote( + pluralise(count, "file-level dependency", "file-level dependencies"), + )}}`; + }); + + return [ + header(generatedBy), + "# top-level layers\n", + nodeLines.join("\n"), + "\n# dependencies\n", + edgeLines.join("\n"), + "\n# styling\n", + styleClasses, + "", + ].join("\n"); +}; + +/** + * Whether the `d2` renderer is available. + * + * Checked before pages are emitted so the bundle never references an SVG it + * did not produce. `d2` is a declared repo tool, but environments that install + * tools individually (Vercel) can legitimately lack it. + */ +export const canRenderDiagrams = (repoRoot: string): boolean => { + const result = spawnSync( + "mise", + ["exec", "--env", "dev", "--", "d2", "--version"], + { cwd: repoRoot, encoding: "utf8" }, + ); + return !result.error && result.status === 0; +}; +/** + * Renders a `.d2` file to SVG. + * + * `d2` is provided by mise, matching how the previous script invoked it. When it + * is unavailable the caller is told rather than the build failing outright: the + * `.d2` sources are the diffable artefact, and a missing renderer should not + * block regenerating the model and pages. + */ +export const renderD2 = ( + repoRoot: string, + sourcePath: string, + outputPath: string, +): { ok: true } | { ok: false; error: string } => { + const result = spawnSync( + "mise", + [ + "exec", + "--env", + "dev", + "--", + "d2", + "--layout", + "elk", + sourcePath, + outputPath, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + + if (result.error) { + return { ok: false, error: result.error.message }; + } + + if (result.status !== 0) { + return { + ok: false, + error: result.stderr || result.stdout || "d2 exited non-zero", + }; + } + + return { ok: true }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts b/libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts new file mode 100644 index 00000000000..b88397cc082 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAuthoredLinks, layerSlug } from "./mdx"; + +/** + * An authored page's final slug depends on its `attachTo`, so it cannot write a + * correct relative link by hand. These tests pin the resolution that replaces + * hand-written paths, including the failure case — an unresolved target must be + * reported, never emitted as-is and left to 404. + */ + +const layerSlugs = new Map([ + ["core", layerSlug("core")], + ["core.simulation", layerSlug("core.simulation")], + ["core.simulation.engine", layerSlug("core.simulation.engine")], +]); + +const docSlugs = new Map([ + ["index", "index"], + ["two-execution-paths", "two-execution-paths"], + ["simulation/memory-model", "architecture/core/simulation/memory-model"], +]); + +const resolve = (contents: string, fromSlug: string) => + resolveAuthoredLinks(contents, fromSlug, { layerSlugs, docSlugs }); + +describe("resolveAuthoredLinks", () => { + it("resolves a layer link from a top-level page", () => { + const { contents, unresolved } = resolve( + "See [the engine](layer:core.simulation.engine).", + "two-execution-paths", + ); + + expect(unresolved).toEqual([]); + expect(contents).toBe( + "See [the engine](architecture/core/simulation/engine).", + ); + }); + + it("resolves a layer link from a page attached deep in the tree", () => { + const { contents } = resolve( + "See [the engine](layer:core.simulation.engine).", + "architecture/core/simulation/worker/protocol", + ); + + // Same target, different depth — the path has to differ. + expect(contents).toBe("See [the engine](../engine)."); + }); + + it("resolves a doc link between attached pages", () => { + const { contents, unresolved } = resolve( + "See [memory](doc:simulation/memory-model).", + "architecture/core/simulation/worker/protocol", + ); + + expect(unresolved).toEqual([]); + expect(contents).toBe("See [memory](../memory-model)."); + }); + + it("resolves a doc link that climbs out of the architecture tree", () => { + const { contents } = resolve( + "See [paths](doc:two-execution-paths).", + "architecture/core/simulation/memory-model", + ); + + expect(contents).toBe("See [paths](../../../two-execution-paths)."); + }); + + it("preserves a fragment", () => { + const { contents } = resolve( + "See [engine](layer:core.simulation.engine#invariants).", + "two-execution-paths", + ); + + expect(contents).toBe( + "See [engine](architecture/core/simulation/engine#invariants).", + ); + }); + + it("reports an unknown layer instead of emitting a broken link", () => { + const { contents, unresolved } = resolve( + "See [nope](layer:core.nonexistent).", + "index", + ); + + expect(unresolved).toEqual(["layer:core.nonexistent"]); + // Left untouched so the diagnostic is the only signal, not a silent rewrite. + expect(contents).toBe("See [nope](layer:core.nonexistent)."); + }); + + it("reports an unknown doc", () => { + const { unresolved } = resolve("[gone](doc:missing-page).", "index"); + + expect(unresolved).toEqual(["doc:missing-page"]); + }); + + it("leaves ordinary links alone", () => { + const source = + "[external](https://example.com) [relative](../sibling) [anchor](#section)"; + const { contents, unresolved } = resolve(source, "index"); + + expect(contents).toBe(source); + expect(unresolved).toEqual([]); + }); + + it("does not rewrite a scheme-like string outside a link target", () => { + const source = "Run `yarn doc:architecture` to regenerate."; + const { contents, unresolved } = resolve(source, "index"); + + expect(contents).toBe(source); + expect(unresolved).toEqual([]); + }); + + it("resolves every link in a page, not just the first", () => { + const { contents, unresolved } = resolve( + "[a](layer:core) then [b](layer:core.simulation)", + "index", + ); + + expect(unresolved).toEqual([]); + expect(contents).toBe( + "[a](architecture/core) then [b](architecture/core/simulation)", + ); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/emit/mdx.ts b/libs/@local/petrinaut-arch-docs/src/emit/mdx.ts new file mode 100644 index 00000000000..a549da9a6ec --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/mdx.ts @@ -0,0 +1,523 @@ +/** + * MDX page generation. + * + * Output here is deliberately **framework-neutral**: YAML frontmatter plus plain + * CommonMark. No JSX, no imports, no framework-specific components. That + * constraint is what lets the same bundle render in the Starlight site, in + * hash.dev's Next.js MDX pipeline, and as plain text for an AI agent — a single + * `` component here would break two of those three. + * + * Diagrams are referenced as relative image paths, and every layer page links + * back to the annotation that declared it so a reader can go straight from the + * rendered claim to the source of truth. + */ + +import { posix } from "node:path"; + +import type { ArchitectureModel, Edge, Layer } from "../model"; + +export interface GeneratedPage { + /** Path within the bundle, e.g. `pages/core.simulation.mdx`. */ + path: string; + /** Route-ish identifier a host can map onto its own URL space. */ + slug: string; + title: string; + description: string; + contents: string; + order: number; +} + +/** + * Sidebar order at which generated pages begin. + * + * Authored pages are the narrative entry to the docs and generated pages are + * reference, so the two sets are kept in separate bands rather than interleaved + * by number. An authored page can still sort itself after the reference section + * by choosing a `sidebar_order` above this. + */ +export const GENERATED_ORDER_BASE = 1000; + +/** + * Backslashes are escaped before pipes, not after. + * + * Escaping only the pipe turns a role containing `a\|b` into `a\\|b`, which + * Markdown reads as a literal backslash followed by an unescaped cell + * separator, splitting the row. Roles are prose written by hand, so this is + * reachable by anyone who writes one. + */ +const escapeTableCell = (text: string): string => + text.replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|").replace(/\n/gu, " "); + +/** Serialises frontmatter by hand so the output stays byte-stable. */ +const frontmatter = (fields: Record): string => { + const lines = Object.entries(fields).map( + ([key, value]) => + `${key}: ${typeof value === "number" ? value : JSON.stringify(value)}`, + ); + return ["---", ...lines, "---", ""].join("\n"); +}; + +const sourceLink = (sourceUrlPrefix: string, file: string): string => + `${sourceUrlPrefix}${file}`; + +/** The slug a layer's generated page occupies. */ +export const layerSlug = (id: string): string => + `architecture/${id.replace(/\./gu, "/")}`; + +/** + * Rewrites relative links in embedded README prose to absolute source URLs. + * + * A README's `[engine](./engine/README.md)` is correct where the README lives + * and broken once the prose is embedded in a docs page served from somewhere + * else entirely. Resolving against the README's own directory keeps those links + * working wherever the bundle is mounted. + */ +export const rewriteRelativeLinks = ( + prose: string, + declaredIn: string, + sourceUrlPrefix: string, +): string => { + const baseDirectory = posix.dirname(declaredIn); + + return prose.replace( + /(!?\[[^\]]*\])\(([^)\s]+)(\s+"[^"]*")?\)/gu, + (match, label: string, target: string, title: string | undefined) => { + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/iu.test(target)) { + return match; + } + + const [path = "", fragment] = target.split("#"); + + if (path === "") { + return match; + } + + const resolved = posix.normalize(posix.join(baseDirectory, path)); + + // A link that escapes the repository root cannot be made absolute. + if (resolved.startsWith("..")) { + return match; + } + + return `${label}(${sourceUrlPrefix}${resolved}${fragment === undefined ? "" : `#${fragment}`}${title ?? ""})`; + }, + ); +}; + +/** + * Relative link from one bundle page to another, so the bundle works when + * mounted at any base path. + * + * Resolved against the *slug* — page links are followed in URL space by a + * reader, and assume slugs map to URLs without a trailing slash. + */ +const relativeTo = (fromSlug: string, toSlug: string): string => { + const relative = posix.relative(posix.dirname(fromSlug), toSlug); + return relative === "" ? "." : relative; +}; + +/** + * Relative path from a page's *file* to an asset in the bundle. + * + * Deliberately different from `relativeTo`: MDX toolchains resolve image paths + * against the file on disk at build time, not against the page's URL. Using the + * slug-relative form here produced `diagrams/x.svg` from `pages/architecture.mdx`, + * which points at a `pages/diagrams/` directory that does not exist. + */ +const assetPathFrom = (slug: string, assetPath: string): string => + posix.relative(posix.dirname(`pages/${slug}`), assetPath); + +/** + * Resolves `doc:` and `layer:` link targets in authored pages. + * + * An authored page's final slug depends on its `attachTo`, so it cannot know + * its own depth and therefore cannot write a correct relative link by hand. + * These two schemes let a page name its target and have the path computed: + * + * - `[text](layer:core.simulation.engine)` → that layer's generated page + * - `[text](doc:two-execution-paths)` → another authored page, by its file slug + * + * Unresolvable targets are reported rather than silently emitted, because a + * broken link here is invisible until someone clicks it. + */ +export const resolveAuthoredLinks = ( + contents: string, + fromSlug: string, + options: { + layerSlugs: Map; + docSlugs: Map; + }, +): { contents: string; unresolved: string[] } => { + const unresolved: string[] = []; + + const resolved = contents.replace( + /(\]\()(layer|doc):([^)\s#]+)(#[^)\s]*)?(\))/gu, + ( + match, + open: string, + scheme: string, + target: string, + fragment: string | undefined, + close: string, + ) => { + const slug = + scheme === "layer" + ? options.layerSlugs.get(target) + : options.docSlugs.get(target); + + if (slug === undefined) { + unresolved.push(`${scheme}:${target}`); + return match; + } + + return `${open}${relativeTo(fromSlug, slug)}${fragment ?? ""}${close}`; + }, + ); + + return { contents: resolved, unresolved }; +}; + +/** + * Rewrites `@diagrams/x` import specifiers to a path relative to the page. + * + * Same problem as `layer:`/`doc:` links: a page's depth depends on its + * `attachTo`, so it cannot write a correct relative import by hand. Authors use + * a stable alias and the real path is computed at emit time. + */ +export const resolveComponentImports = ( + contents: string, + fromSlug: string, + available: Set, +): { contents: string; unresolved: string[] } => { + const unresolved: string[] = []; + const fromDirectory = posix.dirname(`pages/${fromSlug}`); + + const resolved = contents.replace( + /(["'])@diagrams\/([^"']+)\1/gu, + (match, quote: string, name: string) => { + if (!available.has(name)) { + unresolved.push(`@diagrams/${name}`); + return match; + } + + const target = posix.relative(fromDirectory, `components/${name}`); + return `${quote}${target.startsWith(".") ? target : `./${target}`}${quote}`; + }, + ); + + return { contents: resolved, unresolved }; +}; + +const describeEdges = ( + layer: Layer, + edges: Edge[], + layersById: Map, + slug: string, +): string[] => { + const outgoing = edges.filter((edge) => edge.from === layer.id); + const incoming = edges.filter((edge) => edge.to === layer.id); + + const sections: string[] = []; + + const table = ( + heading: string, + intro: string, + rows: Edge[], + direction: "to" | "from", + ): string[] => { + if (rows.length === 0) { + return []; + } + + return [ + `## ${heading}`, + "", + intro, + "", + "| Layer | Imports | Package boundary |", + "| --- | --- | --- |", + ...rows + .slice() + .sort((left, right) => right.fileDependencies - left.fileDependencies) + .map((edge) => { + const otherId = direction === "to" ? edge.to : edge.from; + const other = layersById.get(otherId); + const label = other ? other.name : otherId; + const link = `[${escapeTableCell(label)}](${relativeTo(slug, layerSlug(otherId))})`; + const crosses = edge.crossesPackage ? "crossed" : "—"; + return `| ${link} | ${edge.fileDependencies} | ${crosses} |`; + }), + "", + ]; + }; + + sections.push( + ...table( + "Depends on", + "Aggregated from real TypeScript imports.", + outgoing, + "to", + ), + ); + sections.push( + ...table( + "Depended on by", + "Who reaches into this layer.", + incoming, + "from", + ), + ); + + return sections; +}; + +const buildLayerPage = ( + layer: Layer, + model: ArchitectureModel, + layersById: Map, + sourceUrlPrefix: string, + order: number, + neighbourhoodDiagram: string | null, + subtreeDiagram: string | null, + attachedGuides: { slug: string; title: string; description: string }[], +): GeneratedPage => { + const slug = layerSlug(layer.id); + const children = model.layers.filter((other) => other.parent === layer.id); + + const body: string[] = []; + + body.push(`> ${layer.role}`, ""); + + body.push( + [ + `**Package** \`${layer.package}\``, + `**Layer id** \`${layer.id}\``, + `**Files** ${layer.fileCount}`, + `**Lines** ${layer.lineCount.toLocaleString("en-US")}`, + ].join(" · "), + "", + ); + + body.push( + `Declared in [\`${layer.declaredIn}\`](${sourceLink(sourceUrlPrefix, layer.declaredIn)}).`, + "", + ); + + if (neighbourhoodDiagram !== null) { + body.push( + `![What ${layer.name} depends on, and what depends on it](${assetPathFrom( + slug, + `diagrams/${neighbourhoodDiagram}.svg`, + )})`, + "", + ); + } + + if (subtreeDiagram !== null) { + body.push( + `![Layers within ${layer.name}](${assetPathFrom( + slug, + `diagrams/${subtreeDiagram}.svg`, + )})`, + "", + ); + } + + if (children.length > 0) { + body.push( + "## Sub-layers", + "", + ...children.map( + (child) => + `- [${child.name}](${relativeTo(slug, layerSlug(child.id))}) — ${child.role}`, + ), + "", + ); + } + + if (attachedGuides.length > 0) { + body.push( + "## Guides", + "", + "Hand-written explanations of this layer. Unlike the rest of this page, they are not generated and not checked against the code.", + "", + ...attachedGuides.map( + (guide) => + `- [${guide.title}](${relativeTo(slug, guide.slug)})${guide.description === "" ? "" : ` — ${guide.description}`}`, + ), + "", + ); + } + + body.push(...describeEdges(layer, model.edges, layersById, slug)); + + if (layer.prose !== null) { + body.push( + "## Notes", + "", + rewriteRelativeLinks(layer.prose, layer.declaredIn, sourceUrlPrefix), + "", + ); + } + + if (layer.references.length > 0) { + body.push( + "## Further reading", + "", + ...layer.references.map( + (reference) => + `- [\`${reference}\`](${sourceLink(sourceUrlPrefix, reference)})`, + ), + "", + ); + } + + if (layer.files.length > 0) { + // A per-file list would dominate the page (the editor layer alone resolves + // over a hundred files) and every consumer that actually wants the list can + // read `files` from architecture.json instead. + const folder = posix.dirname(layer.declaredIn); + body.push( + "## Source", + "", + `${layer.fileCount} file${layer.fileCount === 1 ? "" : "s"} resolve to this layer, rooted at [\`${folder}\`](${sourceUrlPrefix}${folder}) — files under a sub-layer's folder belong to that sub-layer instead. The full list is in \`architecture.json\`.`, + "", + ); + } + + return { + path: `pages/${slug}.mdx`, + slug, + title: layer.name, + description: layer.role, + order, + contents: + frontmatter({ + title: layer.name, + description: layer.role, + sidebar_order: order, + }) + `\n${body.join("\n")}`, + }; +}; + +const buildOverviewPage = ( + model: ArchitectureModel, + overviewDiagram: string | null, +): GeneratedPage => { + const slug = "architecture"; + const roots = model.layers.filter((layer) => layer.parent === null); + + const body: string[] = [ + "> Generated from annotations in the Petrinaut source. Every layer and edge on this page was read out of the code, not drawn by hand.", + "", + ]; + + if (overviewDiagram !== null) { + body.push( + `![Top-level layers and the dependencies between them](${assetPathFrom( + slug, + `diagrams/${overviewDiagram}.svg`, + )})`, + "", + ); + } + + body.push( + "## Top-level layers", + "", + "| Layer | Responsibility | Files |", + "| --- | --- | --- |", + ...roots.map( + (layer) => + `| [${escapeTableCell(layer.name)}](${relativeTo(slug, layerSlug(layer.id))}) | ${escapeTableCell(layer.role)} | ${model.layers + .filter( + (other) => + other.id === layer.id || other.id.startsWith(`${layer.id}.`), + ) + .reduce((total, other) => total + other.fileCount, 0)} |`, + ), + "", + ); + + body.push( + "## Packages", + "", + "| Package | Path | Description |", + "| --- | --- | --- |", + ...model.packages.map( + (pkg) => + `| \`${pkg.name}\` | \`${pkg.path}\` | ${escapeTableCell(pkg.description)} |`, + ), + "", + ); + + if (model.rules.length > 0) { + body.push( + "## Enforced rules", + "", + "These are checked against the real import graph whenever the bundle is built.", + "", + "| Rule | Reason |", + "| --- | --- |", + ...model.rules.map( + (rule) => + `| \`${rule.from}\` must not depend on \`${rule.to}\` | ${escapeTableCell(rule.reason)} |`, + ), + "", + ); + } + + return { + path: `pages/${slug}.mdx`, + slug, + title: "Architecture", + description: + "Generated map of the Petrinaut packages: layers and the dependencies between them.", + order: GENERATED_ORDER_BASE, + contents: + frontmatter({ + title: "Architecture", + description: + "Generated map of the Petrinaut packages: layers and the dependencies between them.", + sidebar_order: GENERATED_ORDER_BASE, + }) + `\n${body.join("\n")}`, + }; +}; + +export const buildPages = ( + model: ArchitectureModel, + options: { + sourceUrlPrefix: string; + overviewDiagram: string | null; + /** Layer id → diagram name showing its dependencies and dependents. */ + neighbourhoodDiagrams: Map; + /** Layer id → diagram name showing its direct children, for parents only. */ + subtreeDiagrams: Map; + /** Authored guides attached to each layer id. */ + guidesByLayer?: Map< + string, + { slug: string; title: string; description: string }[] + >; + }, +): GeneratedPage[] => { + const layersById = new Map(model.layers.map((layer) => [layer.id, layer])); + + const pages = [buildOverviewPage(model, options.overviewDiagram)]; + + model.layers.forEach((layer, index) => { + pages.push( + buildLayerPage( + layer, + model, + layersById, + options.sourceUrlPrefix, + GENERATED_ORDER_BASE + index + 1, + options.neighbourhoodDiagrams.get(layer.id) ?? null, + options.subtreeDiagrams.get(layer.id) ?? null, + options.guidesByLayer?.get(layer.id) ?? [], + ), + ); + }); + + return pages; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/extract.test.ts b/libs/@local/petrinaut-arch-docs/src/extract.test.ts new file mode 100644 index 00000000000..15397028cfc --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/extract.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { extract } from "./extract"; + +import type { ArchitecturePackage } from "./model"; + +/** + * The extractor walks a real directory tree, so these tests build small trees in + * a temp dir. Inheritance is the part most worth pinning down: it is what makes + * ~40 declarations cover 400 files, and a regression there would silently + * re-bucket files rather than fail loudly. + */ + +let root: string; + +const pkg: ArchitecturePackage = { + name: "@test/pkg", + path: "pkg", + description: "test package", + language: "typescript", + sourceDirectory: "src", +}; + +const write = async (relativePath: string, contents: string): Promise => { + const absolute = join(root, relativePath); + await mkdir(join(absolute, ".."), { recursive: true }); + await writeFile(absolute, contents, "utf8"); +}; + +const run = async () => + extract({ + repoRoot: root, + packages: [pkg], + ignoredDirectories: ["node_modules"], + ignoredFilePattern: /\.test\.ts$/u, + }); + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "arch-docs-extract-")); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("extract", () => { + it("assigns files to the nearest declaring ancestor", async () => { + await write( + "pkg/src/README.md", + "---\nlayer: core\nrole: Root\n---\n\nRoot prose.\n", + ); + await write( + "pkg/src/engine/README.md", + "---\nlayer: core.engine\nrole: Engine\n---\n", + ); + await write("pkg/src/top.ts", "export const a = 1;\n"); + await write("pkg/src/engine/step.ts", "export const b = 2;\n"); + await write("pkg/src/engine/deep/nested.ts", "export const c = 3;\n"); + + const { layers, fileLayers, diagnostics } = await run(); + + expect(diagnostics).toEqual([]); + expect(fileLayers.get("pkg/src/top.ts")).toBe("core"); + expect(fileLayers.get("pkg/src/engine/step.ts")).toBe("core.engine"); + // Inherits through a folder that declares nothing of its own. + expect(fileLayers.get("pkg/src/engine/deep/nested.ts")).toBe("core.engine"); + + const engine = layers.find((layer) => layer.id === "core.engine"); + expect(engine?.fileCount).toBe(2); + expect(layers.find((layer) => layer.id === "core")?.fileCount).toBe(1); + }); + + it("captures README prose as the layer body", async () => { + await write( + "pkg/src/README.md", + "---\nlayer: core\nrole: Root\n---\n\n# Title\n\nBody text.\n", + ); + await write("pkg/src/a.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers[0]?.prose).toBe("# Title\n\nBody text."); + }); + + it("declares a layer from a @layerRoot entry file", async () => { + await write( + "pkg/src/index.ts", + `/**\n * @layerRoot core\n * @role Does the thing\n */\nexport const a = 1;\n`, + ); + + const { layers, diagnostics } = await run(); + + expect(diagnostics).toEqual([]); + expect(layers).toHaveLength(1); + expect(layers[0]?.name).toBe("core"); + expect(layers[0]?.role).toBe("Does the thing"); + expect(layers[0]?.declaredIn).toBe("pkg/src/index.ts"); + }); + + it("requires a role alongside @layerRoot", async () => { + await write( + "pkg/src/index.ts", + `/**\n * @layerRoot core\n */\nexport const a = 1;\n`, + ); + + const { diagnostics } = await run(); + + expect( + diagnostics.some((diagnostic) => diagnostic.message.includes("@role")), + ).toBe(true); + }); + + it("reports a file that no declaration covers", async () => { + await write("pkg/src/orphan.ts", "export const a = 1;\n"); + + const { diagnostics } = await run(); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("no layer resolves"); + }); + + it("rejects two declarations on one folder", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write( + "pkg/src/index.ts", + `/**\n * @layerRoot core.other\n * @role Other\n */\nexport const a = 1;\n`, + ); + + const { diagnostics } = await run(); + + expect( + diagnostics.some((diagnostic) => + diagnostic.message.includes("may declare at most one layer"), + ), + ).toBe(true); + }); + + it("rejects the same layer id declared twice", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/a/README.md", "---\nlayer: core\nrole: Dup\n---\n"); + + const { diagnostics } = await run(); + + expect( + diagnostics.some((diagnostic) => + diagnostic.message.includes("is already declared in"), + ), + ).toBe(true); + }); + + it("lists non-declaring markdown as references", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/BUFFER_ABI.md", "# ABI\n\nDetails.\n"); + await write("pkg/src/a.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers[0]?.references).toEqual(["pkg/src/BUFFER_ABI.md"]); + }); + + it("excludes files matching the ignore pattern", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/a.ts", "export const a = 1;\n"); + await write("pkg/src/a.test.ts", "export const b = 2;\n"); + + const { layers, fileLayers } = await run(); + + expect(layers[0]?.fileCount).toBe(1); + expect(fileLayers.has("pkg/src/a.test.ts")).toBe(false); + }); + + it("counts non-blank lines only", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/a.ts", "const a = 1;\n\n\nconst b = 2;\n"); + + const { layers } = await run(); + + expect(layers[0]?.lineCount).toBe(2); + }); + + it("produces layers sorted by id, so output is stable", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/z/README.md", "---\nlayer: core.z\nrole: Z\n---\n"); + await write("pkg/src/a/README.md", "---\nlayer: core.a\nrole: A\n---\n"); + await write("pkg/src/a/f.ts", "export const a = 1;\n"); + await write("pkg/src/z/f.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers.map((layer) => layer.id)).toEqual([ + "core", + "core.a", + "core.z", + ]); + }); + + it("derives the parent id from the dotted layer id", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write( + "pkg/src/a/b/README.md", + "---\nlayer: core.a.b\nrole: Deep\n---\n", + ); + await write("pkg/src/a/b/f.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers.find((layer) => layer.id === "core")?.parent).toBeNull(); + expect(layers.find((layer) => layer.id === "core.a.b")?.parent).toBe( + "core.a", + ); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/extract.ts b/libs/@local/petrinaut-arch-docs/src/extract.ts new file mode 100644 index 00000000000..dad5cea082e --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/extract.ts @@ -0,0 +1,388 @@ +/** + * Walks the configured packages and resolves every source file to a layer. + * + * This is the module that replaces the hand-maintained path→layer switch that + * used to live in `petrinaut-core/scripts/generate-dependency-diagrams.mjs`. + * There, the mapping was ~180 lines of `if (path.startsWith(...))` sitting far + * from the code it described, with a silent fallback that mis-bucketed anything + * moved or renamed. Here the mapping is declared next to the code and resolved + * by inheritance: declare a layer on a folder, and every descendant file + * belongs to it until a deeper declaration says otherwise. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join, posix, relative } from "node:path"; + +import { error, type Diagnostic } from "./diagnostics"; +import { parseFrontmatter } from "./frontmatter"; +import { layerSchema, parentLayerId } from "./model"; +import { toPosix } from "./paths"; +import { sourceExtensions, sourceRootOf } from "./scope"; +import { scanTags } from "./tags"; + +import type { Layer, ArchitecturePackage } from "./model"; + +/** A layer declaration plus everything resolved onto it during the walk. */ +interface LayerAccumulator { + id: string; + name: string; + package: string; + role: string; + declaredIn: string; + /** Folder the declaration governs; descendants inherit from it. */ + scope: string; + prose: string | null; + references: string[]; + files: string[]; + lineCount: number; +} + +export interface ExtractionResult { + layers: Layer[]; + /** Repo-relative source file → layer id, for the graph stage. */ + fileLayers: Map; + diagnostics: Diagnostic[]; +} + +export interface ExtractOptions { + repoRoot: string; + packages: ArchitecturePackage[]; + /** Directory names skipped entirely during the walk. */ + ignoredDirectories: string[]; + /** Regex matched against repo-relative paths to skip files. */ + ignoredFilePattern: RegExp; +} + +const sourceExtensionSet = new Set(sourceExtensions); + +const extensionOf = (name: string): string => { + const dot = name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot); +}; + +const countNonBlankLines = (text: string): number => + text.split("\n").filter((line) => line.trim() !== "").length; + +interface WalkEntry { + /** Repo-relative, posix-separated. */ + path: string; + absolutePath: string; + /** Repo-relative posix directory containing the file. */ + directory: string; + name: string; +} + +const walkFiles = async ( + root: string, + repoRoot: string, + ignoredDirectories: Set, +): Promise => { + const entries: WalkEntry[] = []; + + const visit = async (absoluteDirectory: string): Promise => { + const contents = await readdir(absoluteDirectory, { withFileTypes: true }); + + for (const item of contents) { + const absolutePath = join(absoluteDirectory, item.name); + + if (item.isDirectory()) { + if (!ignoredDirectories.has(item.name)) { + await visit(absolutePath); + } + continue; + } + + if (!item.isFile()) { + continue; + } + + const relativePath = toPosix(relative(repoRoot, absolutePath)); + entries.push({ + path: relativePath, + absolutePath, + directory: posix.dirname(relativePath), + name: item.name, + }); + } + }; + + await visit(root); + return entries.sort((left, right) => left.path.localeCompare(right.path)); +}; + +/** + * Finds the declaration governing a path by walking up the directory chain and + * taking the first (deepest) match. Ties cannot happen: a folder may hold at + * most one declaration, which `collectDeclarations` enforces. + */ +const resolveScope = ( + directory: string, + scopes: Map, +): string | null => { + let current = directory; + + for (;;) { + const layerId = scopes.get(current); + if (layerId !== undefined) { + return layerId; + } + + const parent = posix.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +}; + +export const extract = async ( + options: ExtractOptions, +): Promise => { + const { repoRoot, packages, ignoredFilePattern } = options; + const ignoredDirectories = new Set(options.ignoredDirectories); + const diagnostics: Diagnostic[] = []; + + const accumulators = new Map(); + /** Folder → layer id it declares. */ + const scopes = new Map(); + /** Layer id → the file that declared it, for duplicate reporting. */ + const declaredBy = new Map(); + + const packageEntries = new Map(); + + for (const pkg of packages) { + // A package whose language has no extractor would otherwise contribute no + // files, no layers and no diagnostics — appearing in the model as covered + // while being entirely undescribed. That is the silent mis-bucketing this + // system exists to remove, so it is an error rather than a skip. + if (pkg.language !== "typescript") { + diagnostics.push( + error( + `${pkg.path}/package.json`, + `package \`${pkg.name}\` is configured as \`${pkg.language}\`, which has no extractor — it would be listed in the model with no layers. Remove it from architecture.config.ts until one exists.`, + ), + ); + continue; + } + + const entries = await walkFiles( + join(repoRoot, sourceRootOf(pkg)), + repoRoot, + ignoredDirectories, + ); + packageEntries.set( + pkg.name, + entries.filter((entry) => !ignoredFilePattern.test(entry.path)), + ); + } + + // Pass 1 — collect declarations, so inheritance can be resolved in pass 2 + // regardless of the order files are visited in. + for (const pkg of packages) { + for (const entry of packageEntries.get(pkg.name) ?? []) { + const isMarkdown = entry.name.toLowerCase().endsWith(".md"); + const isSource = sourceExtensionSet.has(extensionOf(entry.name)); + + if (!isMarkdown && !isSource) { + continue; + } + + const contents = await readFile(entry.absolutePath, "utf8"); + + const register = ( + accumulator: Omit< + LayerAccumulator, + "files" | "lineCount" | "references" + >, + ): void => { + const existingFile = declaredBy.get(accumulator.id); + if (existingFile !== undefined) { + diagnostics.push( + error( + accumulator.declaredIn, + `layer \`${accumulator.id}\` is already declared in ${existingFile}`, + ), + ); + return; + } + + const existingScope = scopes.get(accumulator.scope); + if (existingScope !== undefined) { + diagnostics.push( + error( + accumulator.declaredIn, + `${accumulator.scope} already declares layer \`${existingScope}\`; a folder may declare at most one layer`, + ), + ); + return; + } + + declaredBy.set(accumulator.id, accumulator.declaredIn); + scopes.set(accumulator.scope, accumulator.id); + accumulators.set(accumulator.id, { + ...accumulator, + references: [], + files: [], + lineCount: 0, + }); + }; + + if (isMarkdown) { + const { declaration, body, errors } = parseFrontmatter(contents); + + for (const message of errors) { + diagnostics.push(error(entry.path, message)); + } + + if (declaration) { + const segments = declaration.layer.split("."); + register({ + id: declaration.layer, + name: segments[segments.length - 1] ?? "", + package: pkg.name, + role: declaration.role, + declaredIn: entry.path, + scope: entry.directory, + prose: body === "" ? null : body, + }); + } + + continue; + } + + const { tags, diagnostics: tagDiagnostics } = scanTags(contents); + + for (const diagnostic of tagDiagnostics) { + diagnostics.push( + error(entry.path, diagnostic.message, diagnostic.line), + ); + } + + if (tags.layerRoot) { + const id = tags.layerRoot.value; + const segments = id.split("."); + register({ + id, + name: segments[segments.length - 1] ?? "", + package: pkg.name, + role: tags.role?.value ?? "", + declaredIn: entry.path, + scope: entry.directory, + prose: null, + }); + + if (!tags.role) { + diagnostics.push( + error( + entry.path, + `@layerRoot ${id} also needs an @role describing what the layer is responsible for`, + tags.layerRoot.line, + ), + ); + } + } + } + } + + // Pass 2 — resolve every source file to a layer and fold in file-level tags. + const fileLayers = new Map(); + + for (const pkg of packages) { + for (const entry of packageEntries.get(pkg.name) ?? []) { + const isMarkdown = entry.name.toLowerCase().endsWith(".md"); + const isSource = sourceExtensionSet.has(extensionOf(entry.name)); + + if (!isMarkdown && !isSource) { + continue; + } + + const inheritedLayerId = resolveScope(entry.directory, scopes); + + if (isMarkdown) { + if (inheritedLayerId === null) { + continue; + } + const accumulator = accumulators.get(inheritedLayerId); + if (accumulator && accumulator.declaredIn !== entry.path) { + accumulator.references.push(entry.path); + } + continue; + } + + if (inheritedLayerId === null) { + diagnostics.push( + error( + entry.path, + `no layer resolves for this file — declare one on ${entry.directory} or an ancestor (README frontmatter or @layerRoot)`, + ), + ); + continue; + } + + const accumulator = accumulators.get(inheritedLayerId); + + if (!accumulator) { + continue; + } + + // Only line counts are read here: with no per-file tags left in the + // vocabulary, a file's contents say nothing about the architecture beyond + // which layer's folder it sits in. + const contents = await readFile(entry.absolutePath, "utf8"); + + accumulator.files.push(entry.path); + accumulator.lineCount += countNonBlankLines(contents); + fileLayers.set(entry.path, inheritedLayerId); + } + } + + /** + * Every layer is validated here, so the model can never carry an invalid one. + * + * Without this, a `@layerRoot` written without a `@role` reached + * `architectureModelSchema.parse` in `build.ts` and threw a Zod stack trace, + * which replaced the diagnostic naming the file that needed fixing. A + * malformed layer id took the same route. Reporting the file and dropping the + * layer keeps the failure legible, and the build refuses to write anyway. + */ + const layers: Layer[] = [...accumulators.values()] + .flatMap((accumulator) => { + const candidate = { + id: accumulator.id, + name: accumulator.name, + parent: parentLayerId(accumulator.id), + package: accumulator.package, + role: accumulator.role, + declaredIn: accumulator.declaredIn, + prose: accumulator.prose, + references: accumulator.references.sort((left, right) => + left.localeCompare(right), + ), + files: accumulator.files, + fileCount: accumulator.files.length, + lineCount: accumulator.lineCount, + }; + + const parsed = layerSchema.safeParse(candidate); + + if (!parsed.success) { + for (const issue of parsed.error.issues) { + diagnostics.push( + error( + accumulator.declaredIn, + `layer \`${accumulator.id}\` is not valid: ${issue.path.join(".")} ${issue.message}`, + ), + ); + } + } + + // Kept even when invalid. The diagnostic above names the file to fix, and + // the build refuses to write while any error stands, so the model is never + // published. Dropping the layer instead would orphan its files and bury + // the real message under a list of files that no longer resolve. + return [candidate]; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + + return { layers, fileLayers, diagnostics }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts b/libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts new file mode 100644 index 00000000000..9293bd17264 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { parseFrontmatter, parseFrontmatterRecord } from "./frontmatter"; + +describe("parseFrontmatterRecord", () => { + /** + * Both readers go through this, so the cases below are the ones that used to + * differ between the YAML parse and a line-splitting one. + */ + it("strips a trailing comment from a value", () => { + const { record } = parseFrontmatterRecord(`--- +attachTo: core.simulation # a layer declared in the source +--- +`); + + expect(record?.attachTo).toBe("core.simulation"); + }); + + it("keeps a hash inside a quoted value", () => { + const { record } = parseFrontmatterRecord(`--- +title: "Frame #3" +--- +`); + + expect(record?.title).toBe("Frame #3"); + }); + + it("reports a `layer` key even beside unrelated page keys", () => { + const { record } = parseFrontmatterRecord(`--- +title: A guide +layer: core.sneaky +role: should be rejected +--- +`); + + expect(record).not.toBeNull(); + expect("layer" in (record ?? {})).toBe(true); + }); + + it("preserves the type YAML inferred", () => { + const { record } = parseFrontmatterRecord(`--- +sidebar_order: 10 +--- +`); + + expect(record?.sidebar_order).toBe(10); + }); + + it("treats a non-mapping document as absent", () => { + expect( + parseFrontmatterRecord("---\n- one\n- two\n---\n").record, + ).toBeNull(); + }); + + it("reports unreadable YAML", () => { + const { record, errors } = parseFrontmatterRecord( + "---\nlayer: [unclosed\n---\n", + ); + + expect(record).toBeNull(); + expect(errors[0]).toContain("invalid YAML"); + }); +}); + +describe("parseFrontmatter", () => { + it("reads a layer declaration and keeps the prose body", () => { + const { declaration, body, errors } = parseFrontmatter(`--- +layer: core.simulation.monte-carlo +role: Runs many bounded-memory simulations +--- + +# Monte Carlo + +Runs batches. +`); + + expect(errors).toEqual([]); + expect(declaration).toEqual({ + layer: "core.simulation.monte-carlo", + role: "Runs many bounded-memory simulations", + }); + expect(body).toBe("# Monte Carlo\n\nRuns batches."); + }); + + it("rejects an unknown key on a declaration, catching typos", () => { + const { declaration, errors } = parseFrontmatter(`--- +layer: core.thing +role: Does a thing +rol: Does a thing +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toHaveLength(1); + }); + + it("treats a README with no frontmatter as prose only", () => { + const { declaration, body, errors } = parseFrontmatter( + "# HIR\n\nThe compiler pipeline.\n", + ); + + expect(declaration).toBeNull(); + expect(errors).toEqual([]); + expect(body).toBe("# HIR\n\nThe compiler pipeline."); + }); + + it("ignores frontmatter that is unrelated to architecture", () => { + const { declaration, errors } = parseFrontmatter(`--- +title: Some page +sidebar_position: 3 +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toEqual([]); + }); + + it("flags a half-written declaration missing its layer key", () => { + const { declaration, errors } = parseFrontmatter(`--- +role: Does a thing +boundaries: + - kind: worker + note: something +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("no `layer` key"); + }); + + it("requires a role alongside a layer", () => { + const { declaration, errors } = parseFrontmatter(`--- +layer: core.thing +--- +`); + + expect(declaration).toBeNull(); + expect(errors.join(" ")).toContain("role"); + }); + + it("reports malformed YAML instead of throwing", () => { + const { declaration, errors } = parseFrontmatter(`--- +layer: [unclosed +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("invalid YAML"); + }); + + it("handles CRLF line endings", () => { + const { declaration } = parseFrontmatter( + "---\r\nlayer: core.thing\r\nrole: Does a thing\r\n---\r\nBody\r\n", + ); + + expect(declaration?.layer).toBe("core.thing"); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/frontmatter.ts b/libs/@local/petrinaut-arch-docs/src/frontmatter.ts new file mode 100644 index 00000000000..f96e33eb552 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/frontmatter.ts @@ -0,0 +1,140 @@ +/** + * Layer declarations read from folder `README.md` frontmatter. + * + * A README is the natural home for folder-level architecture metadata: the + * frontmatter declares the layer, and the prose below it becomes the layer's + * page body. Several Petrinaut folders already have READMEs describing exactly + * this, so declaring a layer there costs a few lines of frontmatter rather than + * a new document. + * + * A README *without* a `layer` key is not a declaration — it stays an ordinary + * document and is surfaced as a reference on whichever layer it falls under. + */ + +import { load } from "js-yaml"; +import { z } from "zod"; + +const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u; + +/** + * Strict: a declaration is exactly a layer id and a role. + * + * An unknown key next to a `layer` is a typo or a leftover from a vocabulary + * this version does not have, and either is worth a build error rather than + * silence — a misspelled `role` would otherwise leave the layer with no + * responsibility statement and no complaint. + */ +export const layerDeclarationSchema = z + .object({ + /** Dotted layer id this folder and its descendants belong to. */ + layer: z.string().min(1), + /** One-line responsibility statement. */ + role: z.string().min(1), + }) + .strict(); + +export type LayerDeclaration = z.infer; + +export interface FrontmatterRecord { + /** The mapping as YAML read it, or null when there is no frontmatter. */ + record: Record | null; + /** Markdown body with frontmatter removed. */ + body: string; + errors: string[]; +} + +export interface FrontmatterResult { + /** Present only when the frontmatter carried a `layer` key. */ + declaration: LayerDeclaration | null; + /** Markdown body with frontmatter removed. */ + body: string; + errors: string[]; +} + +/** + * Reads the frontmatter block as YAML, with no schema applied. + * + * Every caller goes through here, so a page's metadata and the check that it + * does not declare a layer see the same mapping. Reading it twice, once as YAML + * and once by splitting lines, made `attachTo: core.simulation # comment` parse + * differently in the two places, and let a `layer` key hide behind an unrelated + * key such as `title`. + */ +export const parseFrontmatterRecord = (markdown: string): FrontmatterRecord => { + const match = frontmatterPattern.exec(markdown); + + if (!match) { + return { record: null, body: markdown.trim(), errors: [] }; + } + + const body = markdown.slice(match[0].length).trim(); + + let parsed: unknown; + try { + parsed = load(match[1] ?? ""); + } catch (cause) { + return { + record: null, + body, + errors: [ + `invalid YAML frontmatter: ${cause instanceof Error ? cause.message : String(cause)}`, + ], + }; + } + + // A scalar or a list is valid YAML but not a mapping, so there are no keys to + // read. Treated as absent rather than as an error, matching a file with no + // frontmatter at all. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { record: null, body, errors: [] }; + } + + return { record: parsed as Record, body, errors: [] }; +}; + +/** + * Keys that only make sense as part of a layer declaration. Seeing one without + * a `layer` key almost always means the declaration is half-written, so it is + * reported rather than ignored. + */ +const declarationOnlyKeys = new Set(["role"]); + +export const parseFrontmatter = (markdown: string): FrontmatterResult => { + const { record, body, errors } = parseFrontmatterRecord(markdown); + + if (record === null) { + return { declaration: null, body, errors }; + } + + if (!("layer" in record)) { + const strayKeys = Object.keys(record).filter((key) => + declarationOnlyKeys.has(key), + ); + + return { + declaration: null, + body, + errors: + strayKeys.length > 0 + ? [ + `frontmatter has ${strayKeys.map((key) => `\`${key}\``).join(", ")} but no \`layer\` key, so it does not declare a layer`, + ] + : [], + }; + } + + const result = layerDeclarationSchema.safeParse(record); + + if (!result.success) { + return { + declaration: null, + body, + errors: result.error.issues.map( + (issue) => + `${issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""}${issue.message}`, + ), + }; + } + + return { declaration: result.data, body, errors: [] }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/graph.ts b/libs/@local/petrinaut-arch-docs/src/graph.ts new file mode 100644 index 00000000000..617bf6844f4 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/graph.ts @@ -0,0 +1,292 @@ +/** + * Turns the real TypeScript import graph into layer-level edges. + * + * dependency-cruiser supplies the file-level truth; the layer assignment comes + * from `extract.ts`. Aggregating one against the other is what makes the + * diagrams trustworthy: an edge appears because imports exist, never because + * someone drew it. + * + * Package subpath aliases are derived from each package's `exports` map rather + * than hand-listed, so a new entry point cannot drop out of the graph unnoticed. + * The previous script hard-coded seven of `petrinaut-core`'s ten entry points, + * which meant imports through `./ai`, `./optimization` and `./compiled-model` + * resolved to nothing and vanished from the diagram. An `exports` subpath that + * no longer resolves is an error here for the same reason: the failure is a loss + * of coverage, and coverage that goes missing without complaint is what this + * package exists to prevent. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { cruise, type ICruiseResult, type IModule } from "dependency-cruiser"; +import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config"; + +import { error, type Diagnostic } from "./diagnostics"; +import { toPosix } from "./paths"; +import { + exclusionPattern, + sourceExtensions, + sourceRootOf, + sourceRootPattern, +} from "./scope"; + +import type { ArchitecturePackage, Edge, Layer } from "./model"; + +/** How many representative file pairs to record per edge. */ +const examplesPerEdge = 3; + +interface Alias { + alias: string; + name: string; + onlyModule: true; +} + +/** + * Maps a package's `exports` targets back to their source entry files. + * + * `./dist/hir.js` → `src/hir.ts`, and `./dist/react.js` → `src/react/index.ts` + * when the flat file does not exist. Both shapes are in use across the + * Petrinaut packages. + */ +const deriveAliases = ( + repoRoot: string, + pkg: ArchitecturePackage, +): { aliases: Alias[]; diagnostics: Diagnostic[] } => { + const packageRoot = join(repoRoot, pkg.path); + const manifestPath = `${pkg.path}/package.json`; + const diagnostics: Diagnostic[] = []; + + const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ) as { exports?: Record }; + + const aliases: Alias[] = []; + + for (const [subpath, target] of Object.entries(manifest.exports ?? {})) { + if (subpath === "./package.json") { + continue; + } + + const distPath = + typeof target === "string" + ? target + : ((target as Record | null)?.import ?? + (target as Record | null)?.default ?? + null); + + if (distPath === null || !distPath.endsWith(".js")) { + // Asset exports such as `./styles.css` have no module counterpart. + continue; + } + + const stem = distPath.replace(/^\.\/dist\//u, "").replace(/\.js$/u, ""); + + const resolved = [ + join(packageRoot, pkg.sourceDirectory, `${stem}.ts`), + join(packageRoot, pkg.sourceDirectory, `${stem}.tsx`), + join(packageRoot, pkg.sourceDirectory, stem, "index.ts"), + join(packageRoot, pkg.sourceDirectory, stem, "index.tsx"), + ].find((candidate) => existsSync(candidate)); + + if (resolved === undefined) { + diagnostics.push( + error( + manifestPath, + `exports \`${subpath}\` but no source entry file resolves for it (looked for ${pkg.sourceDirectory}/${stem}.ts and ${pkg.sourceDirectory}/${stem}/index.ts). Imports through this specifier would be missing from the graph.`, + ), + ); + continue; + } + + aliases.push({ + alias: resolved, + name: subpath === "." ? pkg.name : `${pkg.name}${subpath.slice(1)}`, + onlyModule: true, + }); + } + + // Longest specifier first so `@pkg/workers/lsp` is not shadowed by `@pkg`. + return { + aliases: aliases.sort( + (left, right) => right.name.length - left.name.length, + ), + diagnostics, + }; +}; + +export interface GraphOptions { + repoRoot: string; + /** TypeScript packages only; callers filter before reaching here. */ + packages: ArchitecturePackage[]; + tsconfigPath: string; + ignoredDirectories: string[]; + ignoredFilePattern: RegExp; + /** Repo-relative source file → layer id, from `extract`. */ + fileLayers: Map; + layers: Layer[]; +} + +export interface GraphResult { + edges: Edge[]; + diagnostics: Diagnostic[]; +} + +/** Runs dependency-cruiser over the covered source roots. */ +const cruiseModules = async ( + options: GraphOptions, + aliases: Alias[], +): Promise => { + const result = await cruise( + options.packages.map(sourceRootOf), + { + baseDir: options.repoRoot, + exclude: exclusionPattern(options), + includeOnly: sourceRootPattern(options.packages), + moduleSystems: ["es6"], + tsPreCompilationDeps: true, + }, + { + alias: aliases, + conditionNames: ["types", "import", "default"], + // Mirrors `sourceExtensions`, plus the JavaScript forms a dependency may + // legitimately resolve to inside a covered package. + extensions: [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ], + }, + { tsConfig: extractTSConfig(options.tsconfigPath) }, + ); + + if (typeof result.output === "string") { + throw new TypeError("dependency-cruiser returned formatted output"); + } + + return (result.output as ICruiseResult).modules; +}; + +/** + * Reports source files the graph reached but no layer claims. + * + * Both stages exclude the same paths, so a source file left over is a real + * disagreement about what is in scope, and every edge touching it is missing + * from the model. Two defects hid here until this check existed: the cruise root + * ignored `sourceDirectory`, and `.mts` files were assigned to layers while + * being unresolvable to the graph. Neither produced a single message. + * + * Restricted to source extensions, because the graph legitimately reaches assets + * no layer should claim. `ui/index.css` is imported by TypeScript and belongs to + * no layer, which is correct: the model describes modules, not the stylesheet one + * of them pulls in. + */ +const checkCoverage = ( + modules: IModule[], + fileLayers: Map, +): Diagnostic[] => + modules + .map((module) => toPosix(module.source)) + .filter( + (file) => + sourceExtensions.some((extension) => file.endsWith(extension)) && + !fileLayers.has(file), + ) + .sort((left, right) => left.localeCompare(right)) + .map((file) => + error( + file, + "the import graph reached this source file but no layer claims it, so its imports are missing from the model. Either it sits outside every declaration's folder, or the extractor and the graph disagree about what counts as source.", + ), + ); + +export const buildGraph = async ( + options: GraphOptions, +): Promise => { + const { repoRoot, fileLayers, layers } = options; + + const aliases: Alias[] = []; + const diagnostics: Diagnostic[] = []; + + for (const pkg of options.packages) { + const derived = deriveAliases(repoRoot, pkg); + aliases.push(...derived.aliases); + diagnostics.push(...derived.diagnostics); + } + + const modules = await cruiseModules(options, aliases); + diagnostics.push(...checkCoverage(modules, fileLayers)); + + interface EdgeAccumulator { + fileDependencies: number; + examples: { from: string; to: string }[]; + } + + const accumulated = new Map(); + + for (const module of modules) { + const fromFile = toPosix(module.source); + const fromLayer = fileLayers.get(fromFile); + + if (fromLayer === undefined) { + continue; + } + + for (const dependency of module.dependencies) { + const toFile = toPosix(dependency.resolved); + const toLayer = fileLayers.get(toFile); + + // Imports landing outside any layer: node_modules, uncovered packages. + // A file *inside* the covered roots is reported by `checkCoverage`. + if (toLayer === undefined || toLayer === fromLayer) { + continue; + } + + // `>` cannot occur in a layer id, which is dot-separated kebab-case, so + // the pair round-trips. An earlier version used a NUL byte for this and + // left two raw NULs in the file, which made every text tool treat the + // source as binary. + const key = `${fromLayer}>${toLayer}`; + const edge = accumulated.get(key) ?? { + fileDependencies: 0, + examples: [], + }; + edge.fileDependencies += 1; + if (edge.examples.length < examplesPerEdge) { + edge.examples.push({ from: fromFile, to: toFile }); + } + accumulated.set(key, edge); + } + } + + const packageOf = new Map(layers.map((layer) => [layer.id, layer.package])); + + const edges: Edge[] = [...accumulated.entries()] + .map(([key, edge]) => { + const [from = "", to = ""] = key.split(">"); + const fromPackage = packageOf.get(from); + const toPackage = packageOf.get(to); + + return { + from, + to, + fileDependencies: edge.fileDependencies, + examples: edge.examples, + crossesPackage: + fromPackage !== undefined && + toPackage !== undefined && + fromPackage !== toPackage, + }; + }) + .sort( + (left, right) => + left.from.localeCompare(right.from) || left.to.localeCompare(right.to), + ); + + return { edges, diagnostics }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/index.ts b/libs/@local/petrinaut-arch-docs/src/index.ts new file mode 100644 index 00000000000..0ea2dbaeec2 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/index.ts @@ -0,0 +1,5 @@ +/** + * The one type a host of the bundle needs: the shape of `manifest.json`. + */ + +export type { BundleManifest } from "./emit/bundle-outputs"; diff --git a/libs/@local/petrinaut-arch-docs/src/model.ts b/libs/@local/petrinaut-arch-docs/src/model.ts new file mode 100644 index 00000000000..2b212ac4f78 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/model.ts @@ -0,0 +1,120 @@ +/** + * The architecture model — the schema every consumer reads. + * + * Consumers read `architecture.json` rather than re-parsing the codebase, so + * this is the public contract; bump `ARCHITECTURE_MODEL_VERSION` on a breaking + * shape change so they fail loudly rather than mis-reading fields. + */ + +import { z } from "zod"; + +export const ARCHITECTURE_MODEL_VERSION = 1; + +/** + * A layer: one node in the architecture, and one page in the docs. + * + * `id` is dotted and hierarchical (`core.simulation.monte-carlo`). Every + * ancestor segment must itself be a declared layer — the checks enforce this so + * the taxonomy cannot grow implicit holes. + */ +export const layerSchema = z.object({ + id: z + .string() + .regex( + /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/u, + "layer ids are dot-separated kebab-case segments", + ), + name: z.string().min(1), + parent: z.string().nullable(), + package: z.string().min(1), + /** One-line statement of what this layer is responsible for. */ + role: z.string().min(1), + /** Repo-relative path of the declaring `README.md` or entry file. */ + declaredIn: z.string().min(1), + /** Prose body of the declaring README, if any — becomes the page body. */ + prose: z.string().nullable(), + /** Other markdown under this layer, e.g. `hir/BUFFER_ABI.md`, linked from its page. */ + references: z.array(z.string().min(1)), + files: z.array(z.string().min(1)), + fileCount: z.number().int().nonnegative(), + /** Total non-blank lines across `files`. */ + lineCount: z.number().int().nonnegative(), +}); + +export type Layer = z.infer; + +/** An aggregated import relationship between two layers. */ +export const edgeSchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), + /** How many file-level imports collapse into this edge. */ + fileDependencies: z.number().int().positive(), + /** A few representative imports, so a reader can jump to real code. */ + examples: z.array(z.object({ from: z.string(), to: z.string() })), + /** + * Whether the two layers live in different workspace packages. + * + * The only fact derived about what an edge crosses. A static import graph + * cannot tell which runtime costs an import incurs: importing a module that + * runs in a worker is how the caller obtains that module, and says nothing + * about whether a thread hop occurs at the call. Package membership is a + * property of the two layers, so it holds whenever it is reported. + */ + crossesPackage: z.boolean(), +}); + +export type Edge = z.infer; + +export const packageSchema = z.object({ + name: z.string().min(1), + /** Repo-relative package root. */ + path: z.string().min(1), + description: z.string(), + language: z.enum(["typescript", "python"]), + /** + * Subdirectory holding the code the architecture describes, relative to + * `path`. Build configuration (`vite.config.ts`, `panda.config.ts`, + * `.storybook/`) sits outside it and is deliberately not part of any layer — + * it configures the build, it is not a piece of the system's design. + */ + sourceDirectory: z.string().min(1).default("src"), +}); + +export type ArchitecturePackage = z.infer; + +/** Config-facing shape, where defaulted fields may be omitted. */ +export type ArchitecturePackageInput = z.input; + +export const architectureModelSchema = z.object({ + version: z.literal(ARCHITECTURE_MODEL_VERSION), + packages: z.array(packageSchema), + layers: z.array(layerSchema), + edges: z.array(edgeSchema), + /** Echoed from config so consumers can render the rules without reading it. */ + rules: z.array( + z.object({ + from: z.string().min(1), + to: z.string().min(1), + reason: z.string().min(1), + }), + ), +}); + +export type ArchitectureModel = z.infer; + +/** Derive the parent layer id from a dotted id (`a.b.c` → `a.b`). */ +export const parentLayerId = (id: string): string | null => { + const lastDot = id.lastIndexOf("."); + return lastDot === -1 ? null : id.slice(0, lastDot); +}; + +/** All ancestor ids of a dotted id, nearest first. */ +export const ancestorLayerIds = (id: string): string[] => { + const ancestors: string[] = []; + let current = parentLayerId(id); + while (current !== null) { + ancestors.push(current); + current = parentLayerId(current); + } + return ancestors; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/paths.ts b/libs/@local/petrinaut-arch-docs/src/paths.ts new file mode 100644 index 00000000000..8af5a139881 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/paths.ts @@ -0,0 +1,14 @@ +/** + * Path helpers shared by every stage that reports a file. + * + * Repo-relative, posix-separated paths are the model's currency: they appear in + * `declaredIn`, in diagnostics, in edge examples and in source links. The + * conversion lived in three modules separately, which is three chances for the + * model to disagree with itself about what a path looks like on Windows. + */ + +import { posix, sep } from "node:path"; + +/** Converts a platform path to the posix form the model always uses. */ +export const toPosix = (path: string): string => + path.split(sep).join(posix.sep); diff --git a/libs/@local/petrinaut-arch-docs/src/scope.test.ts b/libs/@local/petrinaut-arch-docs/src/scope.test.ts new file mode 100644 index 00000000000..e421551e433 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/scope.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { + exclusionPattern, + sourceExtensions, + sourceRootOf, + sourceRootPattern, +} from "./scope"; + +import type { ArchitecturePackage } from "./model"; + +/** + * These four functions exist because the extractor and the graph builder used to + * answer the same questions separately and drift apart. The tests pin the answers + * rather than the callers, so a future divergence shows up here first. + */ + +const pkg = ( + overrides: Partial & Pick, +): ArchitecturePackage => ({ + name: "@test/pkg", + description: "", + language: "typescript", + sourceDirectory: "src", + ...overrides, +}); + +describe("sourceRootOf", () => { + it("honours a non-default sourceDirectory", () => { + expect(sourceRootOf(pkg({ path: "libs/a", sourceDirectory: "lib" }))).toBe( + "libs/a/lib", + ); + }); + + it("defaults to src", () => { + expect(sourceRootOf(pkg({ path: "libs/a" }))).toBe("libs/a/src"); + }); +}); + +describe("sourceRootPattern", () => { + it("anchors at the start and requires a path separator", () => { + const pattern = new RegExp( + sourceRootPattern([pkg({ path: "libs/a" }), pkg({ path: "libs/b" })]), + "u", + ); + + expect(pattern.test("libs/a/src/index.ts")).toBe(true); + expect(pattern.test("libs/b/src/index.ts")).toBe(true); + // A sibling whose name merely starts the same must not match. + expect(pattern.test("libs/a/srcs/index.ts")).toBe(false); + expect(pattern.test("other/libs/a/src/index.ts")).toBe(false); + }); + + it("escapes regular-expression characters in a package path", () => { + const pattern = new RegExp( + sourceRootPattern([pkg({ path: "libs/@scope/a.b" })]), + "u", + ); + + expect(pattern.test("libs/@scope/a.b/src/index.ts")).toBe(true); + expect(pattern.test("libs/@scope/aXb/src/index.ts")).toBe(false); + }); +}); + +describe("exclusionPattern", () => { + const pattern = new RegExp( + exclusionPattern({ + ignoredDirectories: ["node_modules", "__fixtures__"], + ignoredFilePattern: /(?:\.test\.ts$|\.d\.ts$)/u, + }), + "u", + ); + + it("excludes an ignored directory at any depth", () => { + expect(pattern.test("libs/a/src/__fixtures__/net.ts")).toBe(true); + expect(pattern.test("libs/a/src/deep/node_modules/x.ts")).toBe(true); + }); + + it("excludes ignored files", () => { + expect(pattern.test("libs/a/src/thing.test.ts")).toBe(true); + expect(pattern.test("libs/a/src/thing.d.ts")).toBe(true); + }); + + it("keeps ordinary source", () => { + expect(pattern.test("libs/a/src/thing.ts")).toBe(false); + // A directory name appearing as a file stem is not an exclusion. + expect(pattern.test("libs/a/src/node_modules.ts")).toBe(false); + }); +}); + +describe("sourceExtensions", () => { + it("covers the module extensions TypeScript emits from", () => { + expect([...sourceExtensions]).toEqual([".ts", ".tsx", ".mts", ".cts"]); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/scope.ts b/libs/@local/petrinaut-arch-docs/src/scope.ts new file mode 100644 index 00000000000..fa0482384e2 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/scope.ts @@ -0,0 +1,56 @@ +/** + * What counts as source, and which files are in scope. + * + * The extractor and the graph builder each walk the packages, and they have to + * agree on the answer. When they disagreed, the graph silently lost edges: the + * cruise root was a hardcoded `src` while the extractor honoured + * `sourceDirectory`, and `.mts` files were assigned to layers by one and left + * unresolvable by the other. Both are read from here now, so a discrepancy has + * to be introduced deliberately rather than by editing one module. + */ + +import { posix } from "node:path"; + +import type { ArchitecturePackage } from "./model"; + +/** + * Extensions treated as architecture source. + * + * `.js` variants are absent on purpose: these packages are TypeScript, and a + * committed `.js` file under `src` would be build output that no layer should + * claim. + */ +export const sourceExtensions = [".ts", ".tsx", ".mts", ".cts"] as const; + +/** Repo-relative, posix source root for a package. */ +export const sourceRootOf = (pkg: ArchitecturePackage): string => + posix.join(pkg.path, pkg.sourceDirectory); + +const escapeForRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + +/** + * Anchored pattern matching every in-scope source root. + * + * Passed to dependency-cruiser as `includeOnly`, so the graph never reaches + * outside the packages the model covers. + */ +export const sourceRootPattern = (packages: ArchitecturePackage[]): string => + `^(?:${packages.map((pkg) => escapeForRegExp(sourceRootOf(pkg))).join("|")})/`; + +/** + * Everything both stages skip, as one pattern. + * + * The extractor skips ignored directories during its walk and ignored files + * afterwards. dependency-cruiser needs the same two facts as a single regex, or + * it reports modules the extractor never considered and every one of them looks + * like a coverage gap. + */ +export const exclusionPattern = (options: { + ignoredDirectories: string[]; + ignoredFilePattern: RegExp; +}): string => { + const directories = options.ignoredDirectories.map(escapeForRegExp).join("|"); + + return `(?:/(?:${directories})/)|(?:${options.ignoredFilePattern.source})`; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/tags.test.ts b/libs/@local/petrinaut-arch-docs/src/tags.test.ts new file mode 100644 index 00000000000..8ff60fe91bf --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/tags.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { scanTags } from "./tags"; + +describe("scanTags", () => { + it("reads a layer declaration from a file header", () => { + const { tags, diagnostics } = scanTags(`/** + * @layerRoot core.simulation.monte-carlo + * @role Runs many bounded-memory simulations + */ +export const run = () => {}; +`); + + expect(diagnostics).toEqual([]); + expect(tags.layerRoot?.value).toBe("core.simulation.monte-carlo"); + expect(tags.layerRoot?.line).toBe(2); + expect(tags.role?.value).toBe("Runs many bounded-memory simulations"); + }); + + it("continues a tag's text across wrapped lines", () => { + const { tags } = scanTags(`/** + * @role Runs many bounded-memory simulations, so a long experiment + * never grows the heap regardless of how many frames it computes + */`); + + expect(tags.role?.value).toBe( + "Runs many bounded-memory simulations, so a long experiment never grows the heap regardless of how many frames it computes", + ); + }); + + it("ends a tag's text at a blank line", () => { + const { tags } = scanTags(`/** + * @role Compiles user code + * + * Extra prose that is not part of the role. + */`); + + expect(tags.role?.value).toBe("Compiles user code"); + }); + + it("reports a duplicated singular tag rather than silently overwriting", () => { + const { tags, diagnostics } = scanTags(`/** + * @role First + * @role Second + */`); + + expect(tags.role?.value).toBe("First"); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("duplicate @role"); + }); + + it("suggests a correction for a miscased tag", () => { + const { diagnostics } = scanTags(`/** + * @LayerRoot core.thing + */`); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toBe( + "unknown tag @LayerRoot; did you mean @layerRoot?", + ); + }); + + it("ignores standard JSDoc tags", () => { + const { tags, diagnostics } = scanTags(`/** + * Does a thing. + * + * @param input the thing + * @returns the other thing + * @see somewhere + */`); + + expect(diagnostics).toEqual([]); + expect(tags.layerRoot).toBeNull(); + expect(tags.role).toBeNull(); + }); + + it("ignores tags outside the vocabulary", () => { + const { tags, diagnostics } = scanTags(`/** + * @layerRoot core.lsp + * @role Language-server client + * @boundary thread — requests reach the server over a worker transport + * @internal + */`); + + expect(diagnostics).toEqual([]); + expect(tags.layerRoot?.value).toBe("core.lsp"); + expect(tags.role?.value).toBe("Language-server client"); + }); + + it("does not treat a tag mentioned mid-sentence as a tag", () => { + const { tags, diagnostics } = scanTags(`/** + * Layers are declared with @layerRoot on a folder entry file. + */`); + + expect(tags.layerRoot).toBeNull(); + expect(diagnostics).toEqual([]); + }); + + it("reports line numbers relative to the whole file", () => { + const { tags } = scanTags(`line one +line two +line three + +/** + * @layerRoot core.thing + */`); + + expect(tags.layerRoot?.line).toBe(6); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/tags.ts b/libs/@local/petrinaut-arch-docs/src/tags.ts new file mode 100644 index 00000000000..4813ec0d199 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/tags.ts @@ -0,0 +1,206 @@ +/** + * The in-code tag vocabulary, and the scanner that reads it. + * + * A layer is declared either here, with `@layerRoot` on a folder's primary + * file, or in a folder `README.md`'s frontmatter (see `frontmatter.ts`), whose + * prose then becomes the layer's page. Files with no tags inherit from the + * nearest declaring ancestor. + * + * The vocabulary is deliberately two tags: an id and a one-line role. Both are + * needed to place a layer in the graph and to label it — anything more is a + * claim the generator cannot check, and this version does not make claims it + * cannot keep. + * + * Tags are recognised only at the start of a line inside a block comment, so a + * tag named in running prose is not picked up. + * + * Block comments are matched by pattern rather than by lexing the file, so a + * string or template literal containing a whole comment block would be read as + * one. Using the TypeScript scanner would remove that case, at the cost of + * parsing every file. Worth revisiting if a package starts embedding annotated + * code samples in string literals. + */ + +/** A tag occurrence, with the line it was found on for error reporting. */ +export interface TagValue { + value: string; + line: number; +} + +export interface ParsedTags { + /** + * `@layerRoot` — declares that this file's folder *and its descendants* form + * the named layer. The alternative to a README declaration, for folders that + * have a barrel entry file but no README. + */ + layerRoot: TagValue | null; + /** `@role` — one-line statement of what the layer is responsible for. */ + role: TagValue | null; +} + +export interface TagDiagnostic { + line: number; + message: string; +} + +export interface TagScanResult { + tags: ParsedTags; + diagnostics: TagDiagnostic[]; +} + +const emptyTags = (): ParsedTags => ({ + layerRoot: null, + role: null, +}); + +/** Tags that may appear at most once per file. */ +const singularTags = ["layerRoot", "role"] as const; + +type SingularTag = (typeof singularTags)[number]; + +const knownTagNames = new Set(singularTags); + +const blockCommentPattern = /\/\*\*[\s\S]*?\*\//gu; + +/** + * Byte offsets of the start of each line, so an offset can be turned into a + * 1-based line number by binary search rather than a rescan per comment. + */ +const lineStartOffsets = (text: string): number[] => { + const starts = [0]; + for (let position = 0; position < text.length; position += 1) { + if (text[position] === "\n") { + starts.push(position + 1); + } + } + return starts; +}; + +const lineNumberAt = (lineStarts: number[], offset: number): number => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ((lineStarts[middle] ?? 0) <= offset) { + low = middle; + } else { + high = middle - 1; + } + } + return low + 1; +}; + +/** + * Strips the comment delimiters and the leading `*` gutter, returning body + * lines paired with their line number in the original file. + */ +const commentBodyLines = ( + comment: string, + startLine: number, +): { text: string; line: number }[] => + comment + .replace(/^\/\*\*/u, "") + .replace(/\*\/$/u, "") + .split("\n") + .map((rawLine, offset) => ({ + text: rawLine.replace(/^\s*\*\s?/u, ""), + line: startLine + offset, + })); + +interface RawTag { + name: string; + text: string; + line: number; +} + +/** + * Collects tags from one comment body. A tag's text continues onto following + * lines until the next tag or the end of the comment, so multi-line notes stay + * readable in source. + */ +const collectRawTags = (lines: { text: string; line: number }[]): RawTag[] => { + const tags: RawTag[] = []; + let current: RawTag | null = null; + + for (const { text, line } of lines) { + const match = /^@([A-Za-z][A-Za-z0-9]*)\s*(.*)$/u.exec(text.trim()); + + if (match) { + if (current) { + tags.push(current); + } + current = { name: match[1] ?? "", text: match[2] ?? "", line }; + continue; + } + + if (current) { + const continuation = text.trim(); + if (continuation === "") { + // A blank line ends the tag's text but not the comment. + tags.push(current); + current = null; + } else { + current.text = `${current.text} ${continuation}`.trim(); + } + } + } + + if (current) { + tags.push(current); + } + + return tags; +}; + +/** Reads every architecture tag out of a source file's block comments. */ +export const scanTags = (sourceText: string): TagScanResult => { + const tags = emptyTags(); + const diagnostics: TagDiagnostic[] = []; + + const assignSingular = (name: SingularTag, { text, line }: RawTag): void => { + if (text === "") { + diagnostics.push({ line, message: `@${name} requires a value` }); + return; + } + if (tags[name] !== null) { + diagnostics.push({ + line, + message: `duplicate @${name} (already set on line ${tags[name]?.line})`, + }); + return; + } + tags[name] = { value: text, line }; + }; + + const lineStarts = lineStartOffsets(sourceText); + + for (const match of sourceText.matchAll(blockCommentPattern)) { + const startLine = lineNumberAt(lineStarts, match.index); + const rawTags = collectRawTags(commentBodyLines(match[0], startLine)); + + for (const rawTag of rawTags) { + const { name, line } = rawTag; + + if (singularTags.includes(name as SingularTag)) { + assignSingular(name as SingularTag, rawTag); + continue; + } + + // Only a miscased version of one of our own tags is reported. Every other + // unknown tag is someone else's — `@param`, `@deprecated`, an eslint + // directive, or one of the annotations this version deliberately does not + // read — and is none of our business. + const suggestion = [...knownTagNames].find( + (known) => known !== name && known.toLowerCase() === name.toLowerCase(), + ); + if (suggestion !== undefined) { + diagnostics.push({ + line, + message: `unknown tag @${name}; did you mean @${suggestion}?`, + }); + } + } + } + + return { tags, diagnostics }; +}; diff --git a/libs/@local/petrinaut-arch-docs/tsconfig.json b/libs/@local/petrinaut-arch-docs/tsconfig.json new file mode 100644 index 00000000000..d25e7719d59 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src", "architecture.config.ts"] +} diff --git a/libs/@local/petrinaut-arch-docs/turbo.json b/libs/@local/petrinaut-arch-docs/turbo.json new file mode 100644 index 00000000000..17273dee65f --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/turbo.json @@ -0,0 +1,20 @@ +{ + "extends": ["//"], + "tasks": { + "doc:architecture": { + // Not cached. Turborepo hashes a task's own package plus its dependencies' + // task *outputs*, and the annotations this reads are plain source comments + // in `@hashintel/petrinaut` and `petrinaut-core` — not any task's output. + // A cached result would therefore survive an annotation change and go + // quietly stale, which is the exact failure this package exists to prevent. + // The run takes a few seconds; correctness is worth more here. + "cache": false, + // Declared so consumers can depend on this task rather than on the + // directory existing, and so `turbo run` prunes it on a clean. + "outputs": ["bundle/**"] + }, + "lint:arch-docs": { + "cache": false + } + } +} diff --git a/yarn.config.cjs b/yarn.config.cjs index 0f8f4f1f8fd..4270b58385e 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -18,6 +18,9 @@ const enforcedDevDependencies = { const allowedUnscriptedDevDependencies = { // Bundled into the LSP worker, not required by published package consumers. "@hashintel/petrinaut-core": new Set(["typescript"]), + // `astro check` type-checks the diagram components, and needs TypeScript + // even though no script invokes `tsc` directly. + "@apps/petrinaut-docs": new Set(["typescript"]), }; const ignoredDependencies = [ diff --git a/yarn.lock b/yarn.lock index 6a68fd61208..a1baef9817c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -838,6 +838,25 @@ __metadata: languageName: unknown linkType: soft +"@apps/petrinaut-docs@workspace:apps/petrinaut-docs": + version: 0.0.0-use.local + resolution: "@apps/petrinaut-docs@workspace:apps/petrinaut-docs" + dependencies: + "@astrojs/check": "npm:0.9.10" + "@astrojs/react": "npm:6.0.2" + "@astrojs/starlight": "npm:0.41.5" + "@local/petrinaut-arch-docs": "workspace:*" + "@types/node": "npm:22.18.13" + "@types/react": "npm:19.2.14" + "@types/react-dom": "npm:19.2.3" + astro: "npm:7.1.6" + react: "npm:19.2.6" + react-dom: "npm:19.2.6" + sharp: "npm:0.35.3" + typescript: "npm:5.9.3" + languageName: unknown + linkType: soft + "@apps/petrinaut-opt@workspace:*, @apps/petrinaut-opt@workspace:apps/petrinaut-opt": version: 0.0.0-use.local resolution: "@apps/petrinaut-opt@workspace:apps/petrinaut-opt" @@ -1050,10 +1069,356 @@ __metadata: languageName: node linkType: hard -"@astrojs/compiler@npm:^2.0.0": - version: 2.10.3 - resolution: "@astrojs/compiler@npm:2.10.3" - checksum: 10c0/35e7a6e9d197924a3203afd3bd7bff39c8d4271516816c30173cca872302312c3748eefc5a5832523f49b98743115628a1b96922d7d96588a8d96e110a106b88 +"@astrojs/check@npm:0.9.10": + version: 0.9.10 + resolution: "@astrojs/check@npm:0.9.10" + dependencies: + "@astrojs/language-server": "npm:^2.16.7" + chokidar: "npm:^4.0.3" + kleur: "npm:^4.1.5" + yargs: "npm:^18.0.0" + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + bin: + astro-check: ./bin/astro-check.js + checksum: 10c0/a5de46d71e2c295bbee3c575af73b015e070c2a418f1bc1196b115fddacf60681b5313d9f6ea0427cec5f138bdf6d3c894d8e1938fd54025624b7bab2f52e7eb + languageName: node + linkType: hard + +"@astrojs/compiler-binding-darwin-arm64@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-darwin-arm64@npm:0.3.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@astrojs/compiler-binding-darwin-x64@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-darwin-x64@npm:0.3.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@astrojs/compiler-binding-linux-arm64-gnu@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-linux-arm64-gnu@npm:0.3.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@astrojs/compiler-binding-linux-arm64-musl@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-linux-arm64-musl@npm:0.3.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@astrojs/compiler-binding-linux-x64-gnu@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-linux-x64-gnu@npm:0.3.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@astrojs/compiler-binding-linux-x64-musl@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-linux-x64-musl@npm:0.3.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@astrojs/compiler-binding-wasm32-wasi@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-wasm32-wasi@npm:0.3.2" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.2.0" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@astrojs/compiler-binding-win32-arm64-msvc@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-win32-arm64-msvc@npm:0.3.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@astrojs/compiler-binding-win32-x64-msvc@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding-win32-x64-msvc@npm:0.3.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@astrojs/compiler-binding@npm:0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-binding@npm:0.3.2" + dependencies: + "@astrojs/compiler-binding-darwin-arm64": "npm:0.3.2" + "@astrojs/compiler-binding-darwin-x64": "npm:0.3.2" + "@astrojs/compiler-binding-linux-arm64-gnu": "npm:0.3.2" + "@astrojs/compiler-binding-linux-arm64-musl": "npm:0.3.2" + "@astrojs/compiler-binding-linux-x64-gnu": "npm:0.3.2" + "@astrojs/compiler-binding-linux-x64-musl": "npm:0.3.2" + "@astrojs/compiler-binding-wasm32-wasi": "npm:0.3.2" + "@astrojs/compiler-binding-win32-arm64-msvc": "npm:0.3.2" + "@astrojs/compiler-binding-win32-x64-msvc": "npm:0.3.2" + dependenciesMeta: + "@astrojs/compiler-binding-darwin-arm64": + optional: true + "@astrojs/compiler-binding-darwin-x64": + optional: true + "@astrojs/compiler-binding-linux-arm64-gnu": + optional: true + "@astrojs/compiler-binding-linux-arm64-musl": + optional: true + "@astrojs/compiler-binding-linux-x64-gnu": + optional: true + "@astrojs/compiler-binding-linux-x64-musl": + optional: true + "@astrojs/compiler-binding-wasm32-wasi": + optional: true + "@astrojs/compiler-binding-win32-arm64-msvc": + optional: true + "@astrojs/compiler-binding-win32-x64-msvc": + optional: true + checksum: 10c0/8cdf2c6a9b64cee904f81b88f8b15aad7b2225b569f7c973c713dd960774f625c5f1857db3d8a2bd1cdc0a6aa865a178c750d7407d8c0c8447f0f272d6825ac2 + languageName: node + linkType: hard + +"@astrojs/compiler-rs@npm:^0.3.2": + version: 0.3.2 + resolution: "@astrojs/compiler-rs@npm:0.3.2" + dependencies: + "@astrojs/compiler-binding": "npm:0.3.2" + checksum: 10c0/b92237f7b93eb6439ed0fb3676abe6c2e0df74c50a5e1b4b2ace5884f9169fb9d49113d4ae8bea360b6f6d7c1c4545e4f600739cb46c9a6b1e691fa0e3ec5d5c + languageName: node + linkType: hard + +"@astrojs/compiler@npm:^2.0.0, @astrojs/compiler@npm:^2.13.1": + version: 2.13.1 + resolution: "@astrojs/compiler@npm:2.13.1" + checksum: 10c0/83d85c30c8b1bd6d2382f1bc3d99126732838dc747e3a9defa55f726ccc2276c710e6af81bfb68d8fb17fde452c69cf0187cb1d2f8eb022764f1ccc4cf3a3db1 + languageName: node + linkType: hard + +"@astrojs/internal-helpers@npm:0.10.2": + version: 0.10.2 + resolution: "@astrojs/internal-helpers@npm:0.10.2" + dependencies: + "@types/hast": "npm:^3.0.4" + "@types/mdast": "npm:^4.0.4" + js-yaml: "npm:^4.3.0" + picomatch: "npm:^4.0.4" + retext-smartypants: "npm:^6.2.0" + shiki: "npm:^4.0.2" + smol-toml: "npm:^1.6.0" + unified: "npm:^11.0.5" + checksum: 10c0/003fd40fc588b70ee50daf49d1d76ff9acf3f946d7d79fcb06c89187d078c5b4044f7a24c6c3b614363dd9bfd7be6cdced98d1422bbbc090f902ea7b3b27d0bb + languageName: node + linkType: hard + +"@astrojs/language-server@npm:^2.16.7": + version: 2.16.13 + resolution: "@astrojs/language-server@npm:2.16.13" + dependencies: + "@astrojs/compiler": "npm:^2.13.1" + "@astrojs/yaml2ts": "npm:^0.2.4" + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + "@volar/kit": "npm:~2.4.28" + "@volar/language-core": "npm:~2.4.28" + "@volar/language-server": "npm:~2.4.28" + "@volar/language-service": "npm:~2.4.28" + muggle-string: "npm:^0.4.1" + tinyglobby: "npm:^0.2.16" + volar-service-css: "npm:0.0.71" + volar-service-emmet: "npm:0.0.71" + volar-service-html: "npm:0.0.71" + volar-service-prettier: "npm:0.0.71" + volar-service-typescript: "npm:0.0.71" + volar-service-typescript-twoslash-queries: "npm:0.0.71" + volar-service-yaml: "npm:0.0.71" + vscode-html-languageservice: "npm:^5.6.2" + vscode-uri: "npm:^3.1.0" + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: ">=0.11.0" + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + bin: + astro-ls: ./bin/nodeServer.js + checksum: 10c0/ea46894ba994d45deb8c86696587dbd2a99013c352eef066f505a1583cd24e050d3fb4af0db9828be89d597a42e895fcbbd0fd2cb33ea9127f9752405a093068 + languageName: node + linkType: hard + +"@astrojs/markdown-remark@npm:7.2.2": + version: 7.2.2 + resolution: "@astrojs/markdown-remark@npm:7.2.2" + dependencies: + "@astrojs/internal-helpers": "npm:0.10.2" + "@astrojs/prism": "npm:4.0.2" + github-slugger: "npm:^2.0.0" + hast-util-from-html: "npm:^2.0.3" + hast-util-to-text: "npm:^4.0.2" + mdast-util-definitions: "npm:^6.0.0" + rehype-raw: "npm:^7.0.0" + rehype-stringify: "npm:^10.0.1" + remark-gfm: "npm:^4.0.1" + remark-parse: "npm:^11.0.0" + remark-rehype: "npm:^11.1.2" + remark-smartypants: "npm:^3.0.2" + unified: "npm:^11.0.5" + unist-util-remove-position: "npm:^5.0.0" + unist-util-visit: "npm:^5.1.0" + unist-util-visit-parents: "npm:^6.0.2" + vfile: "npm:^6.0.3" + checksum: 10c0/acec0876cdb4d9900bffc72ab41ff5dfd9595308581b330896e0c6b942dc095c8ea150273471a0a70ba99091667a0a0d762137bab1a5154addfd32cd725027e2 + languageName: node + linkType: hard + +"@astrojs/markdown-satteri@npm:0.3.5, @astrojs/markdown-satteri@npm:^0.3.2": + version: 0.3.5 + resolution: "@astrojs/markdown-satteri@npm:0.3.5" + dependencies: + "@astrojs/internal-helpers": "npm:0.10.2" + "@astrojs/prism": "npm:4.0.2" + github-slugger: "npm:^2.0.0" + hast-util-from-html: "npm:^2.0.3" + satteri: "npm:^0.9.1" + checksum: 10c0/4d88de2c7b058c8ab0a1343e35cfec14f50f36c2c7a7f8024f679ef4edb5e42c8ca8c8828446aa9124aa1c2f5fb1e0ec837e7bf789cd775e7e61a2405a09bc24 + languageName: node + linkType: hard + +"@astrojs/mdx@npm:^7.0.0": + version: 7.0.5 + resolution: "@astrojs/mdx@npm:7.0.5" + dependencies: + "@astrojs/internal-helpers": "npm:0.10.2" + "@astrojs/markdown-remark": "npm:7.2.2" + "@mdx-js/mdx": "npm:^3.1.1" + acorn: "npm:^8.16.0" + es-module-lexer: "npm:^2.0.0" + estree-util-visit: "npm:^2.0.0" + hast-util-to-html: "npm:^9.0.5" + piccolore: "npm:^0.1.3" + rehype-raw: "npm:^7.0.0" + remark-gfm: "npm:^4.0.1" + remark-smartypants: "npm:^3.0.2" + source-map: "npm:^0.7.6" + unist-util-visit: "npm:^5.1.0" + vfile: "npm:^6.0.3" + peerDependencies: + "@astrojs/markdown-satteri": ^0.3.1 + astro: ^7.0.0 + peerDependenciesMeta: + "@astrojs/markdown-satteri": + optional: true + checksum: 10c0/e917ee98475b107488fa2bc08d3b60fa5acc771ab925725c0b72e8374fc6ed0287f4684013cf40b000771f3f3aa45bd42733c5c9ff00024fab5b245bf1ca958f + languageName: node + linkType: hard + +"@astrojs/prism@npm:4.0.2": + version: 4.0.2 + resolution: "@astrojs/prism@npm:4.0.2" + dependencies: + prismjs: "npm:^1.30.0" + checksum: 10c0/23a5bc096f966c08be6f8e89adfe9287fd6d08eeb9c4ec7de8172280aa638f2684dcefc5f848f55e211ff63369abc4f0661698daa5e2de6078c8f269215a7101 + languageName: node + linkType: hard + +"@astrojs/react@npm:6.0.2": + version: 6.0.2 + resolution: "@astrojs/react@npm:6.0.2" + dependencies: + "@astrojs/internal-helpers": "npm:0.10.2" + "@vitejs/plugin-react": "npm:^5.2.0" + devalue: "npm:^5.8.1" + ultrahtml: "npm:^1.6.0" + vite: "npm:^8.0.13" + peerDependencies: + "@types/react": ^17.0.50 || ^18.0.21 || ^19.0.0 + "@types/react-dom": ^17.0.17 || ^18.0.6 || ^19.0.0 + react: ^17.0.2 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 + checksum: 10c0/47e546a4a75e42738abe5cf3df0971297cd700fbd91649d3e9149a335f29e9be9dbc08ab04490d81bc57f56aa0c6f70d4dfa3241f1b00ece52d80f74b7858dd7 + languageName: node + linkType: hard + +"@astrojs/sitemap@npm:^3.7.2": + version: 3.7.3 + resolution: "@astrojs/sitemap@npm:3.7.3" + dependencies: + sitemap: "npm:^9.0.0" + stream-replace-string: "npm:^2.0.0" + zod: "npm:^4.3.6" + checksum: 10c0/99c0240586ea2ab1868903341a302ff4bd909b53c49c1c8be476921593fbee838623207c4fa88522264d79291f074ea536aef614d6b1fb286f29a1a5d244ec21 + languageName: node + linkType: hard + +"@astrojs/starlight@npm:0.41.5": + version: 0.41.5 + resolution: "@astrojs/starlight@npm:0.41.5" + dependencies: + "@astrojs/markdown-satteri": "npm:^0.3.2" + "@astrojs/mdx": "npm:^7.0.0" + "@astrojs/sitemap": "npm:^3.7.2" + "@pagefind/default-ui": "npm:^1.3.0" + "@types/hast": "npm:^3.0.4" + "@types/js-yaml": "npm:^4.0.9" + "@types/mdast": "npm:^4.0.4" + astro-expressive-code: "npm:^0.44.0" + bcp-47: "npm:^2.1.0" + hast-util-from-html: "npm:^2.0.3" + hast-util-select: "npm:^6.0.4" + hast-util-to-string: "npm:^3.0.1" + hastscript: "npm:^9.0.1" + i18next: "npm:^26.0.7" + js-yaml: "npm:^4.1.1" + klona: "npm:^2.0.6" + magic-string: "npm:^0.30.21" + mdast-util-directive: "npm:^3.1.0" + mdast-util-to-markdown: "npm:^2.1.2" + mdast-util-to-string: "npm:^4.0.0" + pagefind: "npm:^1.5.2" + rehype: "npm:^13.0.2" + rehype-format: "npm:^5.0.1" + remark-directive: "npm:^4.0.0" + satteri: "npm:^0.9.1" + ultrahtml: "npm:^1.6.0" + unified: "npm:^11.0.5" + unist-util-visit: "npm:^5.1.0" + vfile: "npm:^6.0.3" + peerDependencies: + "@astrojs/markdown-remark": ^7.2.0 + astro: ^7.0.2 + peerDependenciesMeta: + "@astrojs/markdown-remark": + optional: true + checksum: 10c0/cfb3d03c9ad05a2aad3b6180efc2bf093a73e36ae5ce4a4bdc0ae051a941d40a3e559f4958c31d1f47a8bcf2bb476ab087a3cc27e204a778c861680da63fd67d + languageName: node + linkType: hard + +"@astrojs/telemetry@npm:3.3.3": + version: 3.3.3 + resolution: "@astrojs/telemetry@npm:3.3.3" + dependencies: + ci-info: "npm:^4.4.0" + dset: "npm:^3.1.4" + is-docker: "npm:^4.0.0" + package-manager-detector: "npm:^1.6.0" + checksum: 10c0/d5373e0aa8cbfd684143027dce4e31f7fd45e90df799ce4d255fb750109173a3dd2fc3ef26532b298c715fe063d6e955984d238e323d96e1d8f6c0a03fbbbc73 + languageName: node + linkType: hard + +"@astrojs/yaml2ts@npm:^0.2.4": + version: 0.2.4 + resolution: "@astrojs/yaml2ts@npm:0.2.4" + dependencies: + yaml: "npm:^2.8.3" + checksum: 10c0/bb0a25ae730ea2b8ba20da4626c9d023ba27898396c2ad71384e0aae10c4479bb5d6e9f4f27aad1f486f1d287200c4753ff949ab16af367788f085b078d4b4e6 languageName: node linkType: hard @@ -4099,6 +4464,73 @@ __metadata: languageName: node linkType: hard +"@bruits/satteri-darwin-arm64@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-darwin-arm64@npm:0.9.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@bruits/satteri-darwin-x64@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-darwin-x64@npm:0.9.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@bruits/satteri-linux-arm64-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-linux-arm64-gnu@npm:0.9.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@bruits/satteri-linux-arm64-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-linux-arm64-musl@npm:0.9.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@bruits/satteri-linux-x64-gnu@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-linux-x64-gnu@npm:0.9.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@bruits/satteri-linux-x64-musl@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-linux-x64-musl@npm:0.9.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@bruits/satteri-wasm32-wasi@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-wasm32-wasi@npm:0.9.5" + dependencies: + "@emnapi/core": "npm:1.11.1" + "@emnapi/runtime": "npm:1.11.1" + "@napi-rs/wasm-runtime": "npm:^1.1.6" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@bruits/satteri-win32-arm64-msvc@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-win32-arm64-msvc@npm:0.9.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@bruits/satteri-win32-x64-msvc@npm:0.9.5": + version: 0.9.5 + resolution: "@bruits/satteri-win32-x64-msvc@npm:0.9.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@bufbuild/protobuf@npm:^2.6.2": version: 2.12.1 resolution: "@bufbuild/protobuf@npm:2.12.1" @@ -4106,6 +4538,15 @@ __metadata: languageName: node linkType: hard +"@capsizecss/unpack@npm:^4.0.0": + version: 4.0.1 + resolution: "@capsizecss/unpack@npm:4.0.1" + dependencies: + fontkitten: "npm:^1.0.3" + checksum: 10c0/72ce03871e137bae1c4925f9dc916a6250cf9b9630a8bb28d2ef7575d20fd7ef0e8966b361aaa3a54c0e8d5ff40b133490bd97a9beb6dc209529102947d392b1 + languageName: node + linkType: hard + "@chainsafe/as-chacha20poly1305@npm:^0.1.0": version: 0.1.0 resolution: "@chainsafe/as-chacha20poly1305@npm:0.1.0" @@ -4483,6 +4924,16 @@ __metadata: languageName: node linkType: hard +"@clack/core@npm:1.4.3": + version: 1.4.3 + resolution: "@clack/core@npm:1.4.3" + dependencies: + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/16cda430974bc02844cfa6e3f0e6e19695d97e915a580ab74603d34a1f00dcbdd8dc856adc3f89405e3555b5cf42568ae8687e748cb92c3434fe4d9fc7ebe664 + languageName: node + linkType: hard + "@clack/prompts@npm:0.11.0": version: 0.11.0 resolution: "@clack/prompts@npm:0.11.0" @@ -4494,6 +4945,18 @@ __metadata: languageName: node linkType: hard +"@clack/prompts@npm:^1.1.0": + version: 1.7.0 + resolution: "@clack/prompts@npm:1.7.0" + dependencies: + "@clack/core": "npm:1.4.3" + fast-string-width: "npm:^3.0.2" + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/a11e4f8d4a03cfe2d9eb21c3263f0252648573977b96e495a7d4f159f7efa929fe775a6ac3fe7ae30d8acb72514d94418a2c4335855d05b575a41aa885c9ff26 + languageName: node + linkType: hard + "@cloudamqp/amqp-client@npm:^2.1.1": version: 2.1.1 resolution: "@cloudamqp/amqp-client@npm:2.1.1" @@ -4675,6 +5138,13 @@ __metadata: languageName: node linkType: hard +"@ctrl/tinycolor@npm:^4.0.4": + version: 4.2.0 + resolution: "@ctrl/tinycolor@npm:4.2.0" + checksum: 10c0/374034581953f6debd950e4b07da833d1d8f7af5aead5117c91545fbe1583c91b2407bc285e1625529839135d6465c2e7549e7a8ad70979cab4c207486798064 + languageName: node + linkType: hard + "@dabh/diagnostics@npm:^2.0.8": version: 2.0.8 resolution: "@dabh/diagnostics@npm:2.0.8" @@ -4890,6 +5360,64 @@ __metadata: languageName: node linkType: hard +"@emmetio/abbreviation@npm:^2.3.3": + version: 2.3.3 + resolution: "@emmetio/abbreviation@npm:2.3.3" + dependencies: + "@emmetio/scanner": "npm:^1.0.4" + checksum: 10c0/835b460706d5920a6f9a569a44b7d98e88d5530e3983af3678b44fa38b4cbdf68b5df933476d72e340779b16e7e7962ffa63142db8d2f59b1175a11c30c14635 + languageName: node + linkType: hard + +"@emmetio/css-abbreviation@npm:^2.1.8": + version: 2.1.8 + resolution: "@emmetio/css-abbreviation@npm:2.1.8" + dependencies: + "@emmetio/scanner": "npm:^1.0.4" + checksum: 10c0/b5b3b39e773185d848b634e48e1b520e6ebffd28bfd0ba34fbcf877ca77e0edb8c7bbf58230cb0621f80f579bd7fd0265f00ab5e09ac482a835897cbdb6182a6 + languageName: node + linkType: hard + +"@emmetio/css-parser@npm:^0.4.1": + version: 0.4.1 + resolution: "@emmetio/css-parser@npm:0.4.1" + dependencies: + "@emmetio/stream-reader": "npm:^2.2.0" + "@emmetio/stream-reader-utils": "npm:^0.1.0" + checksum: 10c0/d34bf6c8d431eaa2b8b9cdff14360755ca7e235392a23666fe89f2e2b14ac54657712c196bbb6cb016b5bbf3e19faf7f413f7de815d35a26fce82a507191950e + languageName: node + linkType: hard + +"@emmetio/html-matcher@npm:^1.3.0": + version: 1.3.0 + resolution: "@emmetio/html-matcher@npm:1.3.0" + dependencies: + "@emmetio/scanner": "npm:^1.0.0" + checksum: 10c0/354f6d4d52ffb124883cc350d6a09942f2ec352b03dfb928ba8c3078b0f570041b6095367367094a307388c158e6b053565c09a9c1db699517801435a0eadb5c + languageName: node + linkType: hard + +"@emmetio/scanner@npm:^1.0.0, @emmetio/scanner@npm:^1.0.4": + version: 1.0.4 + resolution: "@emmetio/scanner@npm:1.0.4" + checksum: 10c0/ae6244e563caaff0f88d7afefc33fd6cfb7cc767ce914b54d35b46002637948cfc65951dba6d6941328afa54c721c225836fafce2de40fb7643660ba09fe7372 + languageName: node + linkType: hard + +"@emmetio/stream-reader-utils@npm:^0.1.0": + version: 0.1.0 + resolution: "@emmetio/stream-reader-utils@npm:0.1.0" + checksum: 10c0/8630a066200976aa07ce25708484e5c2458182961280dc4637a7a6e98e1f3bb59fb7bf3bf26380f74c6ff1fd40d3dbf1143f21c721833199e45f669614a33b87 + languageName: node + linkType: hard + +"@emmetio/stream-reader@npm:^2.2.0": + version: 2.2.0 + resolution: "@emmetio/stream-reader@npm:2.2.0" + checksum: 10c0/d1a5c83b1ef062ce89b48e1fc170cd5f21b0dfa6132dc41421ef7eda455563227c047977cd4c7b70f072fa73b5af553b04cafe381c600df9bd17c58ca8238e8b + languageName: node + linkType: hard + "@emnapi/core@npm:1.11.1, @emnapi/core@npm:^1.4.3": version: 1.11.1 resolution: "@emnapi/core@npm:1.11.1" @@ -5147,6 +5675,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm64@npm:0.25.12" @@ -5161,6 +5696,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm@npm:0.25.12" @@ -5175,6 +5717,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-x64@npm:0.25.12" @@ -5189,6 +5738,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-arm64@npm:0.25.12" @@ -5203,6 +5759,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-x64@npm:0.25.12" @@ -5217,6 +5780,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-arm64@npm:0.25.12" @@ -5231,6 +5801,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-x64@npm:0.25.12" @@ -5245,6 +5822,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm64@npm:0.25.12" @@ -5259,6 +5843,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm@npm:0.25.12" @@ -5273,6 +5864,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ia32@npm:0.25.12" @@ -5287,6 +5885,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-loong64@npm:0.25.12" @@ -5301,6 +5906,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-mips64el@npm:0.25.12" @@ -5315,6 +5927,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ppc64@npm:0.25.12" @@ -5329,6 +5948,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-riscv64@npm:0.25.12" @@ -5343,6 +5969,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-s390x@npm:0.25.12" @@ -5357,6 +5990,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-x64@npm:0.25.12" @@ -5371,6 +6011,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-arm64@npm:0.25.12" @@ -5385,6 +6032,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-x64@npm:0.25.12" @@ -5399,6 +6053,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-arm64@npm:0.25.12" @@ -5413,6 +6074,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-x64@npm:0.25.12" @@ -5427,6 +6095,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openharmony-arm64@npm:0.25.12" @@ -5441,6 +6116,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/sunos-x64@npm:0.25.12" @@ -5455,6 +6137,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-arm64@npm:0.25.12" @@ -5469,6 +6158,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-ia32@npm:0.25.12" @@ -5483,6 +6179,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-x64@npm:0.25.12" @@ -5497,6 +6200,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -5768,6 +6478,51 @@ __metadata: languageName: node linkType: hard +"@expressive-code/core@npm:^0.44.1": + version: 0.44.1 + resolution: "@expressive-code/core@npm:0.44.1" + dependencies: + "@ctrl/tinycolor": "npm:^4.0.4" + hast-util-select: "npm:^6.0.2" + hast-util-to-html: "npm:^9.0.1" + hast-util-to-text: "npm:^4.0.1" + hastscript: "npm:^9.0.0" + postcss: "npm:^8.4.38" + postcss-nested: "npm:^6.0.1" + unist-util-visit: "npm:^5.0.0" + unist-util-visit-parents: "npm:^6.0.1" + checksum: 10c0/0e2bc031ce475b290e107ae61b4f915daebf2c7672d293d5f3c12dcc0c88f8c9b82d8649a0a9f5da7fdb8bc2a620c8f08d84f536a59c8ea0fc1777758db735a1 + languageName: node + linkType: hard + +"@expressive-code/plugin-frames@npm:^0.44.1": + version: 0.44.1 + resolution: "@expressive-code/plugin-frames@npm:0.44.1" + dependencies: + "@expressive-code/core": "npm:^0.44.1" + checksum: 10c0/d32c6c4ae72439b4a5ac25688180d037df27ce3bc1a361e89732f487109afe8960b1b2a573c3d704bda2feb2e2a1e72828385abc6971d4c823ba3b06153bcd28 + languageName: node + linkType: hard + +"@expressive-code/plugin-shiki@npm:^0.44.1": + version: 0.44.1 + resolution: "@expressive-code/plugin-shiki@npm:0.44.1" + dependencies: + "@expressive-code/core": "npm:^0.44.1" + shiki: "npm:^4.0.2" + checksum: 10c0/3e9fac7ddc3ebf8cf24579bae9a6767b92f5c87d8de3eb47c0dac1ed138070ef2a4148732b3cd63d178c8fe9bc25f46933228a5b2b44615e5ab453eb7e064afb + languageName: node + linkType: hard + +"@expressive-code/plugin-text-markers@npm:^0.44.1": + version: 0.44.1 + resolution: "@expressive-code/plugin-text-markers@npm:0.44.1" + dependencies: + "@expressive-code/core": "npm:^0.44.1" + checksum: 10c0/78b8d2a98b931d78156830d3deeadbfd16fa1e221fe08a7ed8aca00c20663623f20496d1079529d70cea2660bf27c77783c4626797d77b8ce6932d02729096bd + languageName: node + linkType: hard + "@faker-js/faker@npm:10.4.0": version: 10.4.0 resolution: "@faker-js/faker@npm:10.4.0" @@ -6916,7 +7671,6 @@ __metadata: dependencies: "@types/node": "npm:22.18.13" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" - dependency-cruiser: "npm:18.0.0" elkjs: "npm:0.11.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" @@ -8681,6 +9435,25 @@ __metadata: languageName: unknown linkType: soft +"@local/petrinaut-arch-docs@workspace:*, @local/petrinaut-arch-docs@workspace:libs/@local/petrinaut-arch-docs": + version: 0.0.0-use.local + resolution: "@local/petrinaut-arch-docs@workspace:libs/@local/petrinaut-arch-docs" + dependencies: + "@hashintel/petrinaut": "workspace:*" + "@hashintel/petrinaut-core": "workspace:*" + "@local/tsconfig": "workspace:*" + "@types/js-yaml": "npm:^4" + "@types/node": "npm:22.18.13" + dependency-cruiser: "npm:18.0.0" + js-yaml: "npm:4.3.1" + oxlint: "npm:1.63.0" + tsx: "npm:4.20.6" + typescript: "npm:5.9.3" + vitest: "npm:4.1.10" + zod: "npm:4.4.3" + languageName: unknown + linkType: soft + "@local/petrinaut-optimizer-client@workspace:*, @local/petrinaut-optimizer-client@workspace:libs/@local/petrinaut-optimizer-client": version: 0.0.0-use.local resolution: "@local/petrinaut-optimizer-client@workspace:libs/@local/petrinaut-optimizer-client" @@ -8828,7 +9601,7 @@ __metadata: languageName: node linkType: hard -"@mdx-js/mdx@npm:^3.1.0": +"@mdx-js/mdx@npm:^3.1.0, @mdx-js/mdx@npm:^3.1.1": version: 3.1.1 resolution: "@mdx-js/mdx@npm:3.1.1" dependencies: @@ -9367,15 +10140,15 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.5, @napi-rs/wasm-runtime@npm:^1.1.6": - version: 1.1.6 - resolution: "@napi-rs/wasm-runtime@npm:1.1.6" +"@napi-rs/wasm-runtime@npm:^1.1.5, @napi-rs/wasm-runtime@npm:^1.1.6, @napi-rs/wasm-runtime@npm:^1.2.0": + version: 1.2.2 + resolution: "@napi-rs/wasm-runtime@npm:1.2.2" dependencies: "@tybys/wasm-util": "npm:^0.10.3" peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - checksum: 10c0/344518bf3ef65051dda4c00969f293aa4a21ab7dc7822b3f48519b17cd5eaa3f0bc34898d115d50ba59b1817a0cb905d46f7a7223c8249239cd14c28db388e10 + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.3 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.3 + checksum: 10c0/670ff8359761660f58d95a29fe22ac959d2c295675144fe9bc35118b0e723d1e0ac192df9896ba428eb13930cf0893b632606b70ccaea259fd6eca1836c2eb09 languageName: node linkType: hard @@ -10063,6 +10836,13 @@ __metadata: languageName: node linkType: hard +"@oslojs/encoding@npm:^1.1.0": + version: 1.1.0 + resolution: "@oslojs/encoding@npm:1.1.0" + checksum: 10c0/5553a0974dca60e1a8b247b7b97abcb141cc7ee4e22444f424a07921d6a5f76a43c316f3ee669222787fdef6549f8749cc6d68ff5a631e2542521c56fe36417f + languageName: node + linkType: hard + "@oxc-project/types@npm:=0.137.0": version: 0.137.0 resolution: "@oxc-project/types@npm:0.137.0" @@ -10077,6 +10857,13 @@ __metadata: languageName: node linkType: hard +"@oxc-project/types@npm:=0.143.0": + version: 0.143.0 + resolution: "@oxc-project/types@npm:0.143.0" + checksum: 10c0/f450bdc6ebd69b09b5d77c1ca45369f9e1a2b69d21f9dfcdaaa2379e8d5228bfdd014e77bef9bc21ca5fd118fb9e2213d30d8eb70299f03b1aac5ee1ab2802ba + languageName: node + linkType: hard + "@oxfmt/binding-android-arm-eabi@npm:0.50.0": version: 0.50.0 resolution: "@oxfmt/binding-android-arm-eabi@npm:0.50.0" @@ -10385,6 +11172,62 @@ __metadata: languageName: node linkType: hard +"@pagefind/darwin-arm64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/darwin-arm64@npm:1.5.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@pagefind/darwin-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/darwin-x64@npm:1.5.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@pagefind/default-ui@npm:^1.3.0": + version: 1.5.2 + resolution: "@pagefind/default-ui@npm:1.5.2" + checksum: 10c0/2e4add8f315b6ceb2355b635bf1bbc1fc84e4d3d1dadafc38696184aff1d459720a8efcb843d58ae798a30512859620960a671311ff8e197d0a67b43c47fdeef + languageName: node + linkType: hard + +"@pagefind/freebsd-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/freebsd-x64@npm:1.5.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@pagefind/linux-arm64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/linux-arm64@npm:1.5.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@pagefind/linux-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/linux-x64@npm:1.5.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@pagefind/windows-arm64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/windows-arm64@npm:1.5.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@pagefind/windows-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/windows-x64@npm:1.5.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@pandacss/config@npm:1.11.1, @pandacss/config@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/config@npm:1.11.1" @@ -12483,6 +13326,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-android-arm64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-android-arm64@npm:1.2.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-darwin-arm64@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-darwin-arm64@npm:1.1.2" @@ -12497,6 +13347,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-darwin-arm64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-darwin-x64@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-darwin-x64@npm:1.1.2" @@ -12511,6 +13368,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-darwin-x64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-freebsd-x64@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-freebsd-x64@npm:1.1.2" @@ -12525,6 +13389,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-freebsd-x64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.2" @@ -12539,6 +13410,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@rolldown/binding-linux-arm64-gnu@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.1.2" @@ -12553,6 +13431,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-arm64-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-arm64-musl@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-arm64-musl@npm:1.1.2" @@ -12567,6 +13452,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-arm64-musl@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rolldown/binding-linux-ppc64-gnu@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.1.2" @@ -12581,6 +13473,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-s390x-gnu@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.1.2" @@ -12595,6 +13494,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-s390x-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-x64-gnu@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-x64-gnu@npm:1.1.2" @@ -12609,6 +13515,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-x64-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rolldown/binding-linux-x64-musl@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-linux-x64-musl@npm:1.1.2" @@ -12623,6 +13536,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-linux-x64-musl@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rolldown/binding-openharmony-arm64@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-openharmony-arm64@npm:1.1.2" @@ -12637,6 +13557,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-openharmony-arm64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-wasm32-wasi@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-wasm32-wasi@npm:1.1.2" @@ -12673,6 +13600,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-win32-arm64-msvc@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rolldown/binding-win32-x64-msvc@npm:1.1.2": version: 1.1.2 resolution: "@rolldown/binding-win32-x64-msvc@npm:1.1.2" @@ -12687,6 +13621,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-win32-x64-msvc@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rolldown/plugin-babel@npm:0.2.1": version: 0.2.1 resolution: "@rolldown/plugin-babel@npm:0.2.1" @@ -12723,6 +13664,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-rc.3": + version: 1.0.0-rc.3 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.3" + checksum: 10c0/3928b6282a30f307d1b075d2f217180ae173ea9e00638ce46ab65f089bd5f7a0b2c488ae1ce530f509387793c656a2910337c4cd68fa9d37d7e439365989e699 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-rc.7": version: 1.0.0-rc.7 resolution: "@rolldown/pluginutils@npm:1.0.0-rc.7" @@ -14158,6 +15106,86 @@ __metadata: languageName: node linkType: hard +"@shikijs/core@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/core@npm:4.4.2" + dependencies: + "@shikijs/primitive": "npm:4.4.2" + "@shikijs/types": "npm:4.4.2" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.5" + hast-util-to-html: "npm:^9.0.5" + checksum: 10c0/35058e528dcaea04d3bbf2c3ab6defc352b1d1cc90ac1283645af45f1f208218717ce10e24b27dd03fabdc2b039bedfd0bf69712de1d7ab8f0c67d8c343c6a7e + languageName: node + linkType: hard + +"@shikijs/engine-javascript@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/engine-javascript@npm:4.4.2" + dependencies: + "@shikijs/types": "npm:4.4.2" + "@shikijs/vscode-textmate": "npm:^10.0.2" + oniguruma-to-es: "npm:^4.3.6" + checksum: 10c0/2e8cf60249567da010ce73d08acba9a42ef86c29e6ac8876d196558ba633d666498c98a47f1e3ba59308b78e8b74d7e5793a9061ab1b78919fa28528c3de531a + languageName: node + linkType: hard + +"@shikijs/engine-oniguruma@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/engine-oniguruma@npm:4.4.2" + dependencies: + "@shikijs/types": "npm:4.4.2" + "@shikijs/vscode-textmate": "npm:^10.0.2" + checksum: 10c0/35ce68c3d1cfdf96626521db8f5256770f6327dcd083f8144bc8fcc43a37ae5d83c4472223784fa70b292c3bed67fa02a5130cfc6c54324d08730c760e5ee1a4 + languageName: node + linkType: hard + +"@shikijs/langs@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/langs@npm:4.4.2" + dependencies: + "@shikijs/types": "npm:4.4.2" + checksum: 10c0/041026bd22f2a4b0a073304bf507adc0c175994edf89238ef8e94abc9eeda707e8eed0ee71c0ed2e4c662fb981b7e48d5f5165fd64ffb15596c1273e429da18c + languageName: node + linkType: hard + +"@shikijs/primitive@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/primitive@npm:4.4.2" + dependencies: + "@shikijs/types": "npm:4.4.2" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.5" + checksum: 10c0/6b60cd2ef0f385435283266a9c99e9274dc1a56af8f7bd31189a1b4133c31a27678cb58c5ef96772b71a031c3aa0364e1378a8d962f865a04cf6ad8dae1eba5c + languageName: node + linkType: hard + +"@shikijs/themes@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/themes@npm:4.4.2" + dependencies: + "@shikijs/types": "npm:4.4.2" + checksum: 10c0/36247ea8db358e8ccf1070d5b113d685abeb53af681f7ab0c07e025c71d53097d50fe5715ec46fb70b137bc821300cb368401effd1c076d80df6c319ae4a9d37 + languageName: node + linkType: hard + +"@shikijs/types@npm:4.4.2": + version: 4.4.2 + resolution: "@shikijs/types@npm:4.4.2" + dependencies: + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.5" + checksum: 10c0/219e6ec76378a3740545327d184771fd9b697e4c5a0a409cf3dbdb5cef5eb6e046433aa2509a9bdef7adcd0b22f70d6ca18da3a035f45679da639249caa53a6a + languageName: node + linkType: hard + +"@shikijs/vscode-textmate@npm:^10.0.2": + version: 10.0.2 + resolution: "@shikijs/vscode-textmate@npm:10.0.2" + checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462 + languageName: node + linkType: hard + "@sigma/edge-curve@npm:3.0.0-beta.16": version: 3.0.0-beta.16 resolution: "@sigma/edge-curve@npm:3.0.0-beta.16" @@ -16764,7 +17792,7 @@ __metadata: languageName: node linkType: hard -"@types/estree-jsx@npm:^1.0.0": +"@types/estree-jsx@npm:^1.0.0, @types/estree-jsx@npm:^1.0.5": version: 1.0.5 resolution: "@types/estree-jsx@npm:1.0.5" dependencies: @@ -16907,12 +17935,12 @@ __metadata: languageName: node linkType: hard -"@types/hast@npm:^3.0.0": - version: 3.0.4 - resolution: "@types/hast@npm:3.0.4" +"@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4, @types/hast@npm:^3.0.5": + version: 3.0.5 + resolution: "@types/hast@npm:3.0.5" dependencies: "@types/unist": "npm:*" - checksum: 10c0/3249781a511b38f1d330fd1e3344eed3c4e7ea8eff82e835d35da78e637480d36fad37a78be5a7aed8465d237ad0446abc1150859d0fde395354ea634decf9f7 + checksum: 10c0/f3af8594a6903a507ed191eda944af18099198d6708c29102ae17118c3e20779f6929e91ed37e033541fd28d58055d8a5910a4366dbdf976cf81b13a464741fb languageName: node linkType: hard @@ -16970,7 +17998,7 @@ __metadata: languageName: node linkType: hard -"@types/js-yaml@npm:^4": +"@types/js-yaml@npm:^4, @types/js-yaml@npm:^4.0.9": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211 @@ -17114,7 +18142,7 @@ __metadata: languageName: node linkType: hard -"@types/mdast@npm:^4.0.0": +"@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.4": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" dependencies: @@ -17167,6 +18195,15 @@ __metadata: languageName: node linkType: hard +"@types/nlcst@npm:^2.0.0": + version: 2.0.3 + resolution: "@types/nlcst@npm:2.0.3" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/d83549aaee59681ae8fa2a78d8a1b968a41eb7c0422773dff12acbf3661e4b2b2859740c3effdad9d0cd12ea14a0ec33ca302da12106476b627e09d2a029d3c1 + languageName: node + linkType: hard + "@types/node-fetch@npm:2.6.13": version: 2.6.13 resolution: "@types/node-fetch@npm:2.6.13" @@ -17177,12 +18214,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^24.0.13": - version: 24.10.13 - resolution: "@types/node@npm:24.10.13" +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^24.0.13, @types/node@npm:^24.9.2": + version: 24.13.3 + resolution: "@types/node@npm:24.13.3" dependencies: - undici-types: "npm:~7.16.0" - checksum: 10c0/4ff0b9b060b5477c0fec5b11a176f294be588104ab546295db65b17a92ba0a6077b52ad92dd3c0d2154198c7f9d0021e6c1d42b00c9ac7ebfd85632afbcc48a4 + undici-types: "npm:~7.18.0" + checksum: 10c0/a5bc08f49b9581dcdca90e02cd77197a3c799840807664942e5cd5161a553d4d2ae8064f7e3294410d748d7d098b5c1848be7fa50c06f29c4193ab16be4e59eb languageName: node linkType: hard @@ -17458,6 +18495,15 @@ __metadata: languageName: node linkType: hard +"@types/sax@npm:^1.2.1": + version: 1.2.7 + resolution: "@types/sax@npm:1.2.7" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/d077a761a0753b079bf8279b3993948030ca86ed9125437b9b29c1de40db9b2deb7fddc369f014b58861d450e8b8cc75f163aa29dc8cea81952efbfd859168cf + languageName: node + linkType: hard + "@types/semver@npm:7.7.1": version: 7.7.1 resolution: "@types/semver@npm:7.7.1" @@ -17555,7 +18601,7 @@ __metadata: languageName: node linkType: hard -"@types/unist@npm:*, @types/unist@npm:^3.0.0": +"@types/unist@npm:*, @types/unist@npm:^3.0.0, @types/unist@npm:^3.0.3": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" checksum: 10c0/2b1e4adcab78388e088fcc3c0ae8700f76619dbcb4741d7d201f87e2cb346bfc29a89003cfea2d76c996e1061452e14fcd737e8b25aacf949c1f2d6b2bc3dd60 @@ -18404,6 +19450,22 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react@npm:^5.2.0": + version: 5.2.0 + resolution: "@vitejs/plugin-react@npm:5.2.0" + dependencies: + "@babel/core": "npm:^7.29.0" + "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" + "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" + "@rolldown/pluginutils": "npm:1.0.0-rc.3" + "@types/babel__core": "npm:^7.20.5" + react-refresh: "npm:^0.18.0" + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/bac0a409e71eee954a05bc41580411c369bd5f9ef0586a1f9743fba76ad6603c437d93d407d230780015361f93d1592c55e53314813cded6369c36d3c1e8edbf + languageName: node + linkType: hard + "@vitest/browser-playwright@npm:4.1.10": version: 4.1.10 resolution: "@vitest/browser-playwright@npm:4.1.10" @@ -18619,30 +19681,94 @@ __metadata: languageName: node linkType: hard -"@volar/language-core@npm:2.4.23, @volar/language-core@npm:~2.4.11": - version: 2.4.23 - resolution: "@volar/language-core@npm:2.4.23" +"@volar/kit@npm:~2.4.28": + version: 2.4.28 + resolution: "@volar/kit@npm:2.4.28" + dependencies: + "@volar/language-service": "npm:2.4.28" + "@volar/typescript": "npm:2.4.28" + typesafe-path: "npm:^0.2.2" + vscode-languageserver-textdocument: "npm:^1.0.11" + vscode-uri: "npm:^3.0.8" + peerDependencies: + typescript: "*" + checksum: 10c0/c097b930eeb94d432ba7d21e0286ec787931d9fbf1c07e47e0df2d01dfa5a8d9413bac5c2fb4e342edfe57bec29b916427e62d40792ba39b614a2ff1fe3907a1 + languageName: node + linkType: hard + +"@volar/language-core@npm:2.4.28, @volar/language-core@npm:~2.4.11, @volar/language-core@npm:~2.4.28": + version: 2.4.28 + resolution: "@volar/language-core@npm:2.4.28" + dependencies: + "@volar/source-map": "npm:2.4.28" + checksum: 10c0/d41f7327fed7fa5301fbf2d8f96753d645a976b21dbbeb869794a780aa6523d1e6bf258242bc3d8ccd37f8e8b98a04fea9574e6f63badc585a8a3c2e068c4a86 + languageName: node + linkType: hard + +"@volar/language-server@npm:~2.4.28": + version: 2.4.28 + resolution: "@volar/language-server@npm:2.4.28" + dependencies: + "@volar/language-core": "npm:2.4.28" + "@volar/language-service": "npm:2.4.28" + "@volar/typescript": "npm:2.4.28" + path-browserify: "npm:^1.0.1" + request-light: "npm:^0.7.0" + vscode-languageserver: "npm:^9.0.1" + vscode-languageserver-protocol: "npm:^3.17.5" + vscode-languageserver-textdocument: "npm:^1.0.11" + vscode-uri: "npm:^3.0.8" + checksum: 10c0/8266fdab264dbe449501e7d05e584069e6eb6cc5a06bf02968107c3fa32f8eb49b363d57d7abd76e5f637265469be8e13ebce1dd4b3e6ebdfae030691879d283 + languageName: node + linkType: hard + +"@volar/language-service@npm:2.4.28, @volar/language-service@npm:~2.4.28": + version: 2.4.28 + resolution: "@volar/language-service@npm:2.4.28" dependencies: - "@volar/source-map": "npm:2.4.23" - checksum: 10c0/1b8d60c7c0faa29ef5ec46dd2b673227592d0697753767e4df088f7c2d93843828116fe59472bb9d604ba653400be32a538e985730844b1af4f42a7075e62049 + "@volar/language-core": "npm:2.4.28" + vscode-languageserver-protocol: "npm:^3.17.5" + vscode-languageserver-textdocument: "npm:^1.0.11" + vscode-uri: "npm:^3.0.8" + checksum: 10c0/a150d34f0c1c92641ff978eb6045ac6f0717cf16a51cfd3d0798267ccdc91aca21e6746403bf0254c747fa510ea92e3eb064acfa42320c0ebae959fd4a88add8 languageName: node linkType: hard -"@volar/source-map@npm:2.4.23": - version: 2.4.23 - resolution: "@volar/source-map@npm:2.4.23" - checksum: 10c0/08af690093b811d0a37bdd8d306755b4e7f1535b67625c26f6fa6eb9ae081e24c55dabc8231ce8856aa1b731a5ac137b3f0449b34c093923c3545afdbe462c7a +"@volar/source-map@npm:2.4.28": + version: 2.4.28 + resolution: "@volar/source-map@npm:2.4.28" + checksum: 10c0/24b0b02c7f66febe47f0bfda4a5ed4beaf949041eddc6325c7478b900faeb071795b696d97a4f326dde47217d06e40b67129300bc544f054772c5cb84c2f254e languageName: node linkType: hard -"@volar/typescript@npm:^2.4.11": - version: 2.4.23 - resolution: "@volar/typescript@npm:2.4.23" +"@volar/typescript@npm:2.4.28, @volar/typescript@npm:^2.4.11": + version: 2.4.28 + resolution: "@volar/typescript@npm:2.4.28" dependencies: - "@volar/language-core": "npm:2.4.23" + "@volar/language-core": "npm:2.4.28" path-browserify: "npm:^1.0.1" vscode-uri: "npm:^3.0.8" - checksum: 10c0/dbb449b66e627a75f8f6df98b3210c32edff62747a12d1e6237a6dc2a75f26432833d4d3646d6fbd60ed21fa52d7e342437377973b80cf4bbeacee1980ffd0cb + checksum: 10c0/075c890b9ec1cb17f17e38aaed035f8ee7d507439e87270d8e3c394356fc9387fd0bda9ec1069b36ea4c378d9375a08f5bc64c063a83427010ddd86d472124fc + languageName: node + linkType: hard + +"@vscode/emmet-helper@npm:^2.9.3": + version: 2.11.0 + resolution: "@vscode/emmet-helper@npm:2.11.0" + dependencies: + emmet: "npm:^2.4.3" + jsonc-parser: "npm:^2.3.0" + vscode-languageserver-textdocument: "npm:^1.0.1" + vscode-languageserver-types: "npm:^3.15.1" + vscode-uri: "npm:^3.0.8" + checksum: 10c0/41c743258f958ff143f5152a7c8e8e1c6f96336f4147d537ace7c81337d5f9d7f93efd6df16c80e6d9506c51df9c8deb6440765a1deae219e31cdbead3cfd841 + languageName: node + linkType: hard + +"@vscode/l10n@npm:^0.0.18": + version: 0.0.18 + resolution: "@vscode/l10n@npm:0.0.18" + checksum: 10c0/d1fc797001f7d508ab3fa91175f7a50ea98516c4e47830ff2be79163cde9279279514a167a3bad15b7ab7fc243e7808d8f32d3eb41f4a7d6721d9dfdbb38d89e languageName: node linkType: hard @@ -20217,7 +21343,7 @@ __metadata: languageName: node linkType: hard -"ajv-draft-04@npm:~1.0.0": +"ajv-draft-04@npm:^1.0.0, ajv-draft-04@npm:~1.0.0": version: 1.0.0 resolution: "ajv-draft-04@npm:1.0.0" peerDependencies: @@ -20257,6 +21383,15 @@ __metadata: languageName: node linkType: hard +"ajv-i18n@npm:^4.2.0": + version: 4.2.0 + resolution: "ajv-i18n@npm:4.2.0" + peerDependencies: + ajv: ^8.0.0-beta.0 + checksum: 10c0/8c27882f2d46147600538aa8f48a633c72cd65baa37e0fae7159e832122b4ee504f4076f81ea3f78c15d7236a2d499d42a34d1a27b202066edc620f23963f6db + languageName: node + linkType: hard + "ajv-keywords@npm:^3.5.2": version: 3.5.2 resolution: "ajv-keywords@npm:3.5.2" @@ -20308,6 +21443,17 @@ __metadata: languageName: node linkType: hard +"am-i-vibing@npm:^0.4.0": + version: 0.4.0 + resolution: "am-i-vibing@npm:0.4.0" + dependencies: + process-ancestry: "npm:^0.1.0" + bin: + am-i-vibing: dist/cli.mjs + checksum: 10c0/af511ee621c78957e7e9e86b15498d64b9d73c54689e571a03531fe123002d3a5f7bd2d7fadda9cc07511c8f7cfdcc888d897bebeb12d2bc8b725161e64fd3f5 + languageName: node + linkType: hard + "anser@npm:^2.1.1": version: 2.3.2 resolution: "anser@npm:2.3.2" @@ -20432,7 +21578,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:~3.1.2": +"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -20465,6 +21611,13 @@ __metadata: languageName: node linkType: hard +"arg@npm:^5.0.0": + version: 5.0.2 + resolution: "arg@npm:5.0.2" + checksum: 10c0/ccaf86f4e05d342af6666c569f844bec426595c567d32a8289715087825c2ca7edd8a3d204e4d2fb2aa4602e09a57d0c13ea8c9eea75aac3dbb4af5514e6800e + languageName: node + linkType: hard + "argparse@npm:^1.0.7, argparse@npm:~1.0.3, argparse@npm:~1.0.9": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -20557,6 +21710,13 @@ __metadata: languageName: node linkType: hard +"array-iterate@npm:^2.0.0": + version: 2.0.1 + resolution: "array-iterate@npm:2.0.1" + checksum: 10c0/756c08334f95e290f03ab2141b034514af1311ef7b62f15b0f5ea6f8f3033ee9cc6a8f1c3e9ff4803d4d723cf992aa61460acf5fce884936972db966b1da287d + languageName: node + linkType: hard + "array-union@npm:^1.0.1": version: 1.0.2 resolution: "array-union@npm:1.0.2" @@ -20759,6 +21919,90 @@ __metadata: languageName: node linkType: hard +"astro-expressive-code@npm:^0.44.0": + version: 0.44.1 + resolution: "astro-expressive-code@npm:0.44.1" + dependencies: + rehype-expressive-code: "npm:^0.44.1" + url-extras: "npm:^0.1.0" + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + checksum: 10c0/52d57b7d0b8b0001a51e55f37eb82ade6dea93c53ca1455930c21b89f6e245a3e2660da75fbe924ea019f403cc2648675772ac0352f3a92f5486564e79a54a41 + languageName: node + linkType: hard + +"astro@npm:7.1.6": + version: 7.1.6 + resolution: "astro@npm:7.1.6" + dependencies: + "@astrojs/compiler-rs": "npm:^0.3.2" + "@astrojs/internal-helpers": "npm:0.10.2" + "@astrojs/markdown-satteri": "npm:0.3.5" + "@astrojs/telemetry": "npm:3.3.3" + "@capsizecss/unpack": "npm:^4.0.0" + "@clack/prompts": "npm:^1.1.0" + "@oslojs/encoding": "npm:^1.1.0" + "@rollup/pluginutils": "npm:^5.3.0" + am-i-vibing: "npm:^0.4.0" + aria-query: "npm:^5.3.2" + axobject-query: "npm:^4.1.0" + ci-info: "npm:^4.4.0" + clsx: "npm:^2.1.1" + common-ancestor-path: "npm:^2.0.0" + cookie: "npm:^2.0.1" + devalue: "npm:^5.8.1" + diff: "npm:^8.0.3" + dset: "npm:^3.1.4" + es-module-lexer: "npm:^2.0.0" + esbuild: "npm:^0.28.0" + flattie: "npm:^1.1.1" + fontace: "npm:~0.4.1" + get-tsconfig: "npm:5.0.0-beta.4" + github-slugger: "npm:^2.0.0" + html-escaper: "npm:3.0.3" + http-cache-semantics: "npm:^4.2.0" + js-yaml: "npm:^4.3.0" + jsonc-parser: "npm:^3.3.1" + magic-string: "npm:^1.0.0" + magicast: "npm:^0.5.2" + mrmime: "npm:^2.0.1" + neotraverse: "npm:^1.0.1" + obug: "npm:^2.1.1" + p-limit: "npm:^7.3.0" + p-queue: "npm:^9.1.0" + package-manager-detector: "npm:^1.6.0" + piccolore: "npm:^0.1.3" + picomatch: "npm:^4.0.4" + semver: "npm:^7.7.4" + sharp: "npm:^0.34.0 || ^0.35.0" + shiki: "npm:^4.0.2" + smol-toml: "npm:^1.6.0" + svgo: "npm:^4.0.1" + tinyclip: "npm:^0.1.12" + tinyexec: "npm:^1.0.4" + tinyglobby: "npm:^0.2.15" + ultrahtml: "npm:^1.6.0" + unifont: "npm:~0.7.4" + unstorage: "npm:^1.17.5" + vite: "npm:^8.0.13" + vitefu: "npm:^1.1.2" + xxhash-wasm: "npm:^1.1.0" + yargs-parser: "npm:^22.0.0" + zod: "npm:^4.3.6" + peerDependencies: + "@astrojs/markdown-remark": 7.2.2 + dependenciesMeta: + sharp: + optional: true + peerDependenciesMeta: + "@astrojs/markdown-remark": + optional: true + bin: + astro: ./bin/astro.mjs + checksum: 10c0/cdf83d5b19fa5fbb533bbcb7d8a9cef10f9e350ffe1585a5e0e79ead10915e73527270c3eb140ee8641754907e79df7e915a4547663833ab79cbe7d8bef4f590 + languageName: node + linkType: hard + "astrojs-compiler-sync@npm:^1.0.0": version: 1.0.1 resolution: "astrojs-compiler-sync@npm:1.0.1" @@ -21149,6 +22393,17 @@ __metadata: languageName: node linkType: hard +"bcp-47@npm:^2.1.0": + version: 2.1.1 + resolution: "bcp-47@npm:2.1.1" + dependencies: + is-alphabetical: "npm:^2.0.0" + is-alphanumerical: "npm:^2.0.0" + is-decimal: "npm:^2.0.0" + checksum: 10c0/086cb208c8bb0a2dad6ffcfc626ed9f81116037b92cecb63525410ccc943c1dfb78cb1b9ad41a8bcd239d98500933d1c4c44ae6903d5af428f738f2d743f8dec + languageName: node + linkType: hard + "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -21918,6 +23173,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^5.0.0": + version: 5.0.0 + resolution: "chokidar@npm:5.0.0" + dependencies: + readdirp: "npm:^5.0.0" + checksum: 10c0/42fc907cb2a7ff5c9e220f84dae75380a77997f851c2a5e7865a2cf9ae45dd407a23557208cdcdbf3ac8c93341135a1748e4c48c31855f3bfa095e5159b6bdec + languageName: node + linkType: hard + "chownr@npm:^1.1.1": version: 1.1.4 resolution: "chownr@npm:1.1.4" @@ -21952,7 +23216,7 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.0.0, ci-info@npm:^4.3.1": +"ci-info@npm:^4.0.0, ci-info@npm:^4.3.1, ci-info@npm:^4.4.0": version: 4.4.0 resolution: "ci-info@npm:4.4.0" checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a @@ -22393,6 +23657,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^11.1.0": + version: 11.1.0 + resolution: "commander@npm:11.1.0" + checksum: 10c0/13cc6ac875e48780250f723fb81c1c1178d35c5decb1abb1b628b3177af08a8554e76b2c0f29de72d69eef7c864d12613272a71fabef8047922bc622ab75a179 + languageName: node + linkType: hard + "commander@npm:^12.0.0, commander@npm:^12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" @@ -22442,6 +23713,13 @@ __metadata: languageName: node linkType: hard +"common-ancestor-path@npm:^2.0.0": + version: 2.0.0 + resolution: "common-ancestor-path@npm:2.0.0" + checksum: 10c0/fa0872dc8d5ffb2c0bb006d1f9e7ba4586773df4f0cf3dfa4b4c95710cedb8a78246fbbcc1392c71c882bd5428a2d003851bdd9033f549a445ac2c5deacb45ca + languageName: node + linkType: hard + "common-tags@npm:1.8.2": version: 1.8.2 resolution: "common-tags@npm:1.8.2" @@ -22657,6 +23935,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^1.2.3": + version: 1.2.3 + resolution: "cookie-es@npm:1.2.3" + checksum: 10c0/429eae6f5130a7380ea024d787d7e1ecc644ca84f9c43dfb70f18761a831d2ba591d28f837ce350892cfff0857d711f3a4ad93a082637bceb478823c339c1a97 + languageName: node + linkType: hard + "cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -22685,6 +23970,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^2.0.1": + version: 2.0.1 + resolution: "cookie@npm:2.0.1" + checksum: 10c0/dcf3bd6ec9d0f4f32be7575e23242866cb53be55fc3d4cf36215638596661cec1327a3b4ca286ba15eba8f2abd443e422b8c5f708af4ea9f75b4d3dedc67ee4c + languageName: node + linkType: hard + "cookie@npm:~1.0.1": version: 1.0.2 resolution: "cookie@npm:1.0.2" @@ -22869,6 +24161,15 @@ __metadata: languageName: node linkType: hard +"crossws@npm:^0.3.5": + version: 0.3.5 + resolution: "crossws@npm:0.3.5" + dependencies: + uncrypto: "npm:^0.1.3" + checksum: 10c0/9e873546f0806606c4f775219f6811768fc3b3b0765ca8230722e849058ad098318af006e1faa39a8008c03009c37c519f6bccad41b0d78586237585c75fb38b + languageName: node + linkType: hard + "crypt@npm:0.0.2": version: 0.0.2 resolution: "crypt@npm:0.0.2" @@ -22952,6 +24253,16 @@ __metadata: languageName: node linkType: hard +"css-tree@npm:^3.0.1, css-tree@npm:^3.1.0": + version: 3.2.1 + resolution: "css-tree@npm:3.2.1" + dependencies: + mdn-data: "npm:2.27.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/1f65e9ccaa56112a4706d6f003dd43d777f0dbcf848e66fd320f823192533581f8dd58daa906cb80622658332d50284d6be13b87a6ab4556cbbfe9ef535bbf7e + languageName: node + linkType: hard + "css-tree@npm:~2.2.0": version: 2.2.1 resolution: "css-tree@npm:2.2.1" @@ -23516,6 +24827,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.6": + version: 6.1.7 + resolution: "defu@npm:6.1.7" + checksum: 10c0/e6635388103c8be3c574ac31302f6930e5e6eeedba32cb1b30cf993c7d9fb571aec2485446dfa23bfa63e55e66156fe109027a9695db82a50f931e91e8d4bedb + languageName: node + linkType: hard + "degenerator@npm:7.0.1": version: 7.0.1 resolution: "degenerator@npm:7.0.1" @@ -23651,6 +24969,13 @@ __metadata: languageName: node linkType: hard +"destr@npm:^2.0.5": + version: 2.0.5 + resolution: "destr@npm:2.0.5" + checksum: 10c0/efabffe7312a45ad90d79975376be958c50069f1156b94c181199763a7f971e113bd92227c26b94a169c71ca7dbc13583b7e96e5164743969fc79e1ff153e646 + languageName: node + linkType: hard + "destroy@npm:1.2.0, destroy@npm:^1.0.4, destroy@npm:~1.2.0": version: 1.2.0 resolution: "destroy@npm:1.2.0" @@ -23695,6 +25020,13 @@ __metadata: languageName: node linkType: hard +"devalue@npm:^5.8.1": + version: 5.9.0 + resolution: "devalue@npm:5.9.0" + checksum: 10c0/0ad4cc8e041747fdda6900bccf8648b68ec035fdaf72dd8d30b69295108b704e3078e515921758745139ce64dbd5bdc80e3c9491e4a3205926adcc95a2c22907 + languageName: node + linkType: hard + "devlop@npm:^1.0.0, devlop@npm:^1.1.0": version: 1.1.0 resolution: "devlop@npm:1.1.0" @@ -23711,10 +25043,10 @@ __metadata: languageName: node linkType: hard -"diff@npm:~8.0.2": - version: 8.0.3 - resolution: "diff@npm:8.0.3" - checksum: 10c0/d29321c70d3545fdcb56c5fdd76028c3f04c012462779e062303d4c3c531af80d2c360c26b871e6e2b9a971d2422d47e1779a859106c4cac4b5d2d143df70e20 +"diff@npm:^8.0.3, diff@npm:~8.0.2": + version: 8.0.4 + resolution: "diff@npm:8.0.4" + checksum: 10c0/7ee5d03926db4039be7252ac3b0abaae1bd122a2ca971e5ca7270e444e36ff83dd906fad1a719740ca347e97ed5dc8f458a76a8391dbcd7aff363bdafb348a00 languageName: node linkType: hard @@ -23941,6 +25273,13 @@ __metadata: languageName: node linkType: hard +"dset@npm:^3.1.4": + version: 3.1.4 + resolution: "dset@npm:3.1.4" + checksum: 10c0/b67bbd28dd8a539e90c15ffb61100eb64ef995c5270a124d4f99bbb53f4d82f55a051b731ba81f3215dd9dce2b4c8d69927dc20b3be1c5fc88bab159467aa438 + languageName: node + linkType: hard + "dts-resolver@npm:^3.0.0": version: 3.0.0 resolution: "dts-resolver@npm:3.0.0" @@ -24091,6 +25430,16 @@ __metadata: languageName: node linkType: hard +"emmet@npm:^2.4.3": + version: 2.4.11 + resolution: "emmet@npm:2.4.11" + dependencies: + "@emmetio/abbreviation": "npm:^2.3.3" + "@emmetio/css-abbreviation": "npm:^2.1.8" + checksum: 10c0/4099d9d0d5dee766603c4ea03e1b87296bd397a0e8c6d8d5d6dcfdaad3e4581df5d48939a00eb4437dc08c83e857e231222ee037cb34ad63b1f2cce4041c6fc2 + languageName: node + linkType: hard + "emoji-mart@npm:5.6.0": version: 5.6.0 resolution: "emoji-mart@npm:5.6.0" @@ -24722,6 +26071,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.28.0": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb + languageName: node + linkType: hard + "escalade@npm:3.2.0, escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -25836,7 +27274,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^5.0.1": +"eventemitter3@npm:^5.0.1, eventemitter3@npm:^5.0.4": version: 5.0.4 resolution: "eventemitter3@npm:5.0.4" checksum: 10c0/575b8cac8d709e1473da46f8f15ef311b57ff7609445a7c71af5cd42598583eee6f098fa7a593e30f27e94b8865642baa0689e8fa97c016f742abdb3b1bf6d9a @@ -26088,6 +27526,18 @@ __metadata: languageName: node linkType: hard +"expressive-code@npm:^0.44.1": + version: 0.44.1 + resolution: "expressive-code@npm:0.44.1" + dependencies: + "@expressive-code/core": "npm:^0.44.1" + "@expressive-code/plugin-frames": "npm:^0.44.1" + "@expressive-code/plugin-shiki": "npm:^0.44.1" + "@expressive-code/plugin-text-markers": "npm:^0.44.1" + checksum: 10c0/ca09029718c687bfa494f095a0cc78b5981adc1e19d89a2740b2b7e07b326b205371018d894bfe8abe477cf842b015ef78367ecd575cbb8f2defd6d4910a7967 + languageName: node + linkType: hard + "exsolve@npm:^1.0.7": version: 1.0.8 resolution: "exsolve@npm:1.0.8" @@ -26221,6 +27671,22 @@ __metadata: languageName: node linkType: hard +"fast-string-truncated-width@npm:^3.0.2": + version: 3.0.3 + resolution: "fast-string-truncated-width@npm:3.0.3" + checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981 + languageName: node + linkType: hard + +"fast-string-width@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-string-width@npm:3.0.2" + dependencies: + fast-string-truncated-width: "npm:^3.0.2" + checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7 + languageName: node + linkType: hard + "fast-unique-numbers@npm:^8.0.13": version: 8.0.13 resolution: "fast-unique-numbers@npm:8.0.13" @@ -26238,6 +27704,15 @@ __metadata: languageName: node linkType: hard +"fast-wrap-ansi@npm:^0.2.0": + version: 0.2.2 + resolution: "fast-wrap-ansi@npm:0.2.2" + dependencies: + fast-string-width: "npm:^3.0.2" + checksum: 10c0/1aa7be4f7cb86f4bdb14691cb6bcc0b8df8b3b89df142ade3ae1602332dcf6f990cd750a923cd581ca0847808cb4ec1aa5afaafa7a72f849e87a2a62c98fa370 + languageName: node + linkType: hard + "fast-xml-builder@npm:^1.1.5": version: 1.2.0 resolution: "fast-xml-builder@npm:1.2.0" @@ -26570,6 +28045,13 @@ __metadata: languageName: node linkType: hard +"flattie@npm:^1.1.1": + version: 1.1.1 + resolution: "flattie@npm:1.1.1" + checksum: 10c0/a8f8242c7af126cb2f1aa4a067af338fce609fc4c4df183c626fcc70a46c1878ce4aa88cd0dc8ef8f583ad4e7088a3b11ebeb6a62c9c97d75c0b1b0f08182ee3 + languageName: node + linkType: hard + "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -26587,6 +28069,15 @@ __metadata: languageName: node linkType: hard +"fontace@npm:~0.4.1": + version: 0.4.1 + resolution: "fontace@npm:0.4.1" + dependencies: + fontkitten: "npm:^1.0.2" + checksum: 10c0/2b3b8bc60192b0e9d87c369f1d72bf5da6c5b0f6055d5eeb0c06693455c55395f9592844273035399289f410202306c1d7f7329312e6e42a2694912d39c70709 + languageName: node + linkType: hard + "fontfaceobserver@npm:2.1.0": version: 2.1.0 resolution: "fontfaceobserver@npm:2.1.0" @@ -26594,6 +28085,15 @@ __metadata: languageName: node linkType: hard +"fontkitten@npm:^1.0.2, fontkitten@npm:^1.0.3": + version: 1.0.3 + resolution: "fontkitten@npm:1.0.3" + dependencies: + tiny-inflate: "npm:^1.0.3" + checksum: 10c0/edee7bde2f824a778cf401a2019ada63376fc8bcc5303d09a11e8a9245f35bd945934abea04aef1ca52e8dbb6ce3a17fe9ecdfa52cc0c92f56b25709fb865a76 + languageName: node + linkType: hard + "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -27077,6 +28577,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:5.0.0-beta.4": + version: 5.0.0-beta.4 + resolution: "get-tsconfig@npm:5.0.0-beta.4" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/6b908fc5d2abc937fca6938f5e9218238f49b11cd07362831383aa5815b25ce6f5fff36d4bcd70caa17778a54922b654745ce6c0b1bb19be8eac87da4e3fc087 + languageName: node + linkType: hard + "get-tsconfig@npm:5.0.0-beta.5": version: 5.0.0-beta.5 resolution: "get-tsconfig@npm:5.0.0-beta.5" @@ -27125,6 +28634,13 @@ __metadata: languageName: node linkType: hard +"github-slugger@npm:^2.0.0": + version: 2.0.0 + resolution: "github-slugger@npm:2.0.0" + checksum: 10c0/21b912b6b1e48f1e5a50b2292b48df0ff6abeeb0691b161b3d93d84f4ae6b1acd6ae23702e914af7ea5d441c096453cf0f621b72d57893946618d21dd1a1c486 + languageName: node + linkType: hard + "glob-parent@npm:6.0.2, glob-parent@npm:^6.0.1, glob-parent@npm:^6.0.2": version: 6.0.2 resolution: "glob-parent@npm:6.0.2" @@ -27712,6 +29228,23 @@ __metadata: languageName: node linkType: hard +"h3@npm:^1.15.10": + version: 1.15.11 + resolution: "h3@npm:1.15.11" + dependencies: + cookie-es: "npm:^1.2.3" + crossws: "npm:^0.3.5" + defu: "npm:^6.1.6" + destr: "npm:^2.0.5" + iron-webcrypto: "npm:^1.2.1" + node-mock-http: "npm:^1.0.4" + radix3: "npm:^1.1.2" + ufo: "npm:^1.6.3" + uncrypto: "npm:^0.1.3" + checksum: 10c0/6ccb421b9f92e02e6330c2b6697b18ef18e9550e4a1708f224ca517c40ecd201cd00967d0feb26e718595e86e985edec1755933cf8792d34fb8504f1c7cc261d + languageName: node + linkType: hard + "handle-thing@npm:^2.0.0": version: 2.0.1 resolution: "handle-thing@npm:2.0.1" @@ -27832,6 +29365,45 @@ __metadata: languageName: node linkType: hard +"hast-util-embedded@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-embedded@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + checksum: 10c0/054c3d3b96fcd5c1d1c6f8d38ce1f7f33022ba6362129a022673d0b539f876acdcababbb9df29812fb927294f98ef7a2f44519a80d637fe3eea1819c9e69eeac + languageName: node + linkType: hard + +"hast-util-format@npm:^1.0.0": + version: 1.1.0 + resolution: "hast-util-format@npm:1.1.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-embedded: "npm:^3.0.0" + hast-util-minify-whitespace: "npm:^1.0.0" + hast-util-phrasing: "npm:^3.0.0" + hast-util-whitespace: "npm:^3.0.0" + html-whitespace-sensitive-tag-names: "npm:^3.0.0" + unist-util-visit-parents: "npm:^6.0.0" + checksum: 10c0/6ab223cffe8a524ef4f2564d0385cab174a52551513b318496b3776e6882594eab4810b0f8d90f20e8291fa4e87fa068e03cba316d83c0836dab12dd140e98df + languageName: node + linkType: hard + +"hast-util-from-html@npm:^2.0.0, hast-util-from-html@npm:^2.0.3": + version: 2.0.3 + resolution: "hast-util-from-html@npm:2.0.3" + dependencies: + "@types/hast": "npm:^3.0.0" + devlop: "npm:^1.1.0" + hast-util-from-parse5: "npm:^8.0.0" + parse5: "npm:^7.0.0" + vfile: "npm:^6.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/993ef707c1a12474c8d4094fc9706a72826c660a7e308ea54c50ad893353d32e139b7cbc67510c2e82feac572b320e3b05aeb13d0f9c6302d61261f337b46764 + languageName: node + linkType: hard + "hast-util-from-parse5@npm:^8.0.0": version: 8.0.3 resolution: "hast-util-from-parse5@npm:8.0.3" @@ -27857,6 +29429,37 @@ __metadata: languageName: node linkType: hard +"hast-util-is-body-ok-link@npm:^3.0.0": + version: 3.0.1 + resolution: "hast-util-is-body-ok-link@npm:3.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/c320cbd9a9a834b007a6f2f8c271e98b8331c0193adf06e0a7c5ea0acae664e97ce28eb4436e0658bc5cdb8f47390ec1c6cba7c4fe1ded10951fcdd1432f60bf + languageName: node + linkType: hard + +"hast-util-is-element@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-is-element@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/f5361e4c9859c587ca8eb0d8343492f3077ccaa0f58a44cd09f35d5038f94d65152288dcd0c19336ef2c9491ec4d4e45fde2176b05293437021570aa0bc3613b + languageName: node + linkType: hard + +"hast-util-minify-whitespace@npm:^1.0.0": + version: 1.0.1 + resolution: "hast-util-minify-whitespace@npm:1.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-embedded: "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + hast-util-whitespace: "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/20a7d64947e080463084f444ad09c7f28c40e7648ca2d9c6c036e42a67f8e945d352560ff599304c988257c1e477abcf6a1f508c0900211fa58ec1ba21b36533 + languageName: node + linkType: hard + "hast-util-parse-selector@npm:^4.0.0": version: 4.0.0 resolution: "hast-util-parse-selector@npm:4.0.0" @@ -27866,6 +29469,19 @@ __metadata: languageName: node linkType: hard +"hast-util-phrasing@npm:^3.0.0": + version: 3.0.1 + resolution: "hast-util-phrasing@npm:3.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-embedded: "npm:^3.0.0" + hast-util-has-property: "npm:^3.0.0" + hast-util-is-body-ok-link: "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + checksum: 10c0/d77e186ea3d7d62f6db9c4a55c3e6d9f1f6affd5f40250e8de9d73f167ae19fcc02fafe1601dfbe36e90f76ed5013ac004f0b6b398aee3a04a7a81de12788600 + languageName: node + linkType: hard + "hast-util-raw@npm:^9.0.0": version: 9.1.0 resolution: "hast-util-raw@npm:9.1.0" @@ -27887,7 +29503,7 @@ __metadata: languageName: node linkType: hard -"hast-util-select@npm:^6.0.0": +"hast-util-select@npm:^6.0.0, hast-util-select@npm:^6.0.2, hast-util-select@npm:^6.0.4": version: 6.0.4 resolution: "hast-util-select@npm:6.0.4" dependencies: @@ -27934,6 +29550,25 @@ __metadata: languageName: node linkType: hard +"hast-util-to-html@npm:^9.0.0, hast-util-to-html@npm:^9.0.1, hast-util-to-html@npm:^9.0.5": + version: 9.0.5 + resolution: "hast-util-to-html@npm:9.0.5" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + ccount: "npm:^2.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-whitespace: "npm:^3.0.0" + html-void-elements: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + property-information: "npm:^7.0.0" + space-separated-tokens: "npm:^2.0.0" + stringify-entities: "npm:^4.0.0" + zwitch: "npm:^2.0.4" + checksum: 10c0/b7a08c30bab4371fc9b4a620965c40b270e5ae7a8e94cf885f43b21705179e28c8e43b39c72885d1647965fb3738654e6962eb8b58b0c2a84271655b4d748836 + languageName: node + linkType: hard + "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.6 resolution: "hast-util-to-jsx-runtime@npm:2.3.6" @@ -27972,7 +29607,7 @@ __metadata: languageName: node linkType: hard -"hast-util-to-string@npm:^3.0.0": +"hast-util-to-string@npm:^3.0.0, hast-util-to-string@npm:^3.0.1": version: 3.0.1 resolution: "hast-util-to-string@npm:3.0.1" dependencies: @@ -27981,6 +29616,18 @@ __metadata: languageName: node linkType: hard +"hast-util-to-text@npm:^4.0.1, hast-util-to-text@npm:^4.0.2": + version: 4.0.2 + resolution: "hast-util-to-text@npm:4.0.2" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + unist-util-find-after: "npm:^5.0.0" + checksum: 10c0/93ecc10e68fe5391c6e634140eb330942e71dea2724c8e0c647c73ed74a8ec930a4b77043b5081284808c96f73f2bee64ee416038ece75a63a467e8d14f09946 + languageName: node + linkType: hard + "hast-util-whitespace@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-whitespace@npm:3.0.0" @@ -27990,7 +29637,7 @@ __metadata: languageName: node linkType: hard -"hastscript@npm:^9.0.0": +"hastscript@npm:^9.0.0, hastscript@npm:^9.0.1": version: 9.0.1 resolution: "hastscript@npm:9.0.1" dependencies: @@ -28187,6 +29834,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:3.0.3": + version: 3.0.3 + resolution: "html-escaper@npm:3.0.3" + checksum: 10c0/a042fa4139127ff7546513e90ea39cc9161a1938ce90122dbc4260d4b7252c9aa8452f4509c0c2889901b8ae9a8699179150f1f99d3f80bcf7317573c5f08f4e + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0, html-escaper@npm:^2.0.2": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -28319,6 +29973,13 @@ __metadata: languageName: node linkType: hard +"html-whitespace-sensitive-tag-names@npm:^3.0.0": + version: 3.0.1 + resolution: "html-whitespace-sensitive-tag-names@npm:3.0.1" + checksum: 10c0/da06cad111f6a432edd85c6cd09f6b5abbb385872fba79f23f939bdd4626920ac2e62507f604ef94eb8449902033bda292774624e3283b4dea4ed1620a2be3b2 + languageName: node + linkType: hard + "html@npm:^1.0.0": version: 1.0.0 resolution: "html@npm:1.0.0" @@ -28376,10 +30037,10 @@ __metadata: languageName: node linkType: hard -"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1": - version: 4.1.1 - resolution: "http-cache-semantics@npm:4.1.1" - checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc +"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1, http-cache-semantics@npm:^4.2.0": + version: 4.2.0 + resolution: "http-cache-semantics@npm:4.2.0" + checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37 languageName: node linkType: hard @@ -28640,6 +30301,18 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^26.0.7": + version: 26.3.6 + resolution: "i18next@npm:26.3.6" + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/5920ac8fb6b647a2bdd439d121de04e5297246e52c4d95d13f5faae0824fd45b2faeb66038bcd61fc62362daa815f9c32fb992d6faa3787607d1dfe768e9a8b1 + languageName: node + linkType: hard + "iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -29001,6 +30674,13 @@ __metadata: languageName: node linkType: hard +"iron-webcrypto@npm:^1.2.1": + version: 1.2.1 + resolution: "iron-webcrypto@npm:1.2.1" + checksum: 10c0/5cf27c6e2bd3ef3b4970e486235fd82491ab8229e2ed0ac23307c28d6c80d721772a86ed4e9fe2a5cabadd710c2f024b706843b40561fb83f15afee58f809f66 + languageName: node + linkType: hard + "is-absolute@npm:^1.0.0": version: 1.0.0 resolution: "is-absolute@npm:1.0.0" @@ -29195,6 +30875,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^4.0.0": + version: 4.0.0 + resolution: "is-docker@npm:4.0.0" + bin: + is-docker: cli.js + checksum: 10c0/4ee05c305b545422b172cab17f42900b940a5fa77820380d3244784fde69279d6881305f249b3b7fa2e261c2294804100548f5638880842e5b43df72cec29087 + languageName: node + linkType: hard + "is-electron@npm:^2.2.0": version: 2.2.2 resolution: "is-electron@npm:2.2.2" @@ -30176,7 +31865,7 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:4.3.1, js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1": +"js-yaml@npm:4.3.1, js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1, js-yaml@npm:^4.3.0": version: 4.3.1 resolution: "js-yaml@npm:4.3.1" dependencies: @@ -30441,6 +32130,20 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:^2.3.0": + version: 2.3.1 + resolution: "jsonc-parser@npm:2.3.1" + checksum: 10c0/b5e823612f6518a4d35e65d3c642e87b994c52a71b6d83d306d59f9b57003a1f6c64659808f0f1c3448991c28916d56faca45222f31ddb1a32effecdef0f0485 + languageName: node + linkType: hard + +"jsonc-parser@npm:^3.0.0, jsonc-parser@npm:^3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + "jsondiffpatch@npm:0.7.2": version: 0.7.2 resolution: "jsondiffpatch@npm:0.7.2" @@ -30595,7 +32298,7 @@ __metadata: languageName: node linkType: hard -"kleur@npm:4.1.5": +"kleur@npm:4.1.5, kleur@npm:^4.1.5": version: 4.1.5 resolution: "kleur@npm:4.1.5" checksum: 10c0/e9de6cb49657b6fa70ba2d1448fd3d691a5c4370d8f7bbf1c2f64c24d461270f2117e1b0afe8cb3114f13bbd8e51de158c2a224953960331904e636a5e4c0f2a @@ -30609,6 +32312,13 @@ __metadata: languageName: node linkType: hard +"klona@npm:^2.0.6": + version: 2.0.6 + resolution: "klona@npm:2.0.6" + checksum: 10c0/94eed2c6c2ce99f409df9186a96340558897b3e62a85afdc1ee39103954d2ebe1c1c4e9fe2b0952771771fa96d70055ede8b27962a7021406374fdb695fd4d01 + languageName: node + linkType: hard + "koa-compose@npm:^4.1.0": version: 4.1.0 resolution: "koa-compose@npm:4.1.0" @@ -30894,9 +32604,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-android-arm64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-android-arm64@npm:1.32.0" +"lightningcss-android-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-android-arm64@npm:1.33.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -30908,9 +32618,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-arm64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-darwin-arm64@npm:1.32.0" +"lightningcss-darwin-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-arm64@npm:1.33.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -30922,9 +32632,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-x64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-darwin-x64@npm:1.32.0" +"lightningcss-darwin-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-x64@npm:1.33.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -30936,9 +32646,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-freebsd-x64@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-freebsd-x64@npm:1.32.0" +"lightningcss-freebsd-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-freebsd-x64@npm:1.33.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -30950,9 +32660,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-arm-gnueabihf@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" +"lightningcss-linux-arm-gnueabihf@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -30964,9 +32674,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-arm64-gnu@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" +"lightningcss-linux-arm64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -30978,9 +32688,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-arm64-musl@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" +"lightningcss-linux-arm64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.33.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -30992,9 +32702,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-x64-gnu@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" +"lightningcss-linux-x64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.33.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -31006,9 +32716,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-x64-musl@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-linux-x64-musl@npm:1.32.0" +"lightningcss-linux-x64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-musl@npm:1.33.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -31020,9 +32730,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-win32-arm64-msvc@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" +"lightningcss-win32-arm64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -31034,9 +32744,9 @@ __metadata: languageName: node linkType: hard -"lightningcss-win32-x64-msvc@npm:1.32.0": - version: 1.32.0 - resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" +"lightningcss-win32-x64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.33.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -31084,22 +32794,22 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:^1.32.0": - version: 1.32.0 - resolution: "lightningcss@npm:1.32.0" +"lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0": + version: 1.33.0 + resolution: "lightningcss@npm:1.33.0" dependencies: detect-libc: "npm:^2.0.3" - lightningcss-android-arm64: "npm:1.32.0" - lightningcss-darwin-arm64: "npm:1.32.0" - lightningcss-darwin-x64: "npm:1.32.0" - lightningcss-freebsd-x64: "npm:1.32.0" - lightningcss-linux-arm-gnueabihf: "npm:1.32.0" - lightningcss-linux-arm64-gnu: "npm:1.32.0" - lightningcss-linux-arm64-musl: "npm:1.32.0" - lightningcss-linux-x64-gnu: "npm:1.32.0" - lightningcss-linux-x64-musl: "npm:1.32.0" - lightningcss-win32-arm64-msvc: "npm:1.32.0" - lightningcss-win32-x64-msvc: "npm:1.32.0" + lightningcss-android-arm64: "npm:1.33.0" + lightningcss-darwin-arm64: "npm:1.33.0" + lightningcss-darwin-x64: "npm:1.33.0" + lightningcss-freebsd-x64: "npm:1.33.0" + lightningcss-linux-arm-gnueabihf: "npm:1.33.0" + lightningcss-linux-arm64-gnu: "npm:1.33.0" + lightningcss-linux-arm64-musl: "npm:1.33.0" + lightningcss-linux-x64-gnu: "npm:1.33.0" + lightningcss-linux-x64-musl: "npm:1.33.0" + lightningcss-win32-arm64-msvc: "npm:1.33.0" + lightningcss-win32-x64-msvc: "npm:1.33.0" dependenciesMeta: lightningcss-android-arm64: optional: true @@ -31123,7 +32833,7 @@ __metadata: optional: true lightningcss-win32-x64-msvc: optional: true - checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3 languageName: node linkType: hard @@ -31593,10 +33303,10 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": - version: 11.2.6 - resolution: "lru-cache@npm:11.2.6" - checksum: 10c0/73bbffb298760e71b2bfe8ebc16a311c6a60ceddbba919cfedfd8635c2d125fbfb5a39b71818200e67973b11f8d59c5a9e31d6f90722e340e90393663a66e5cd +"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.2.7": + version: 11.5.2 + resolution: "lru-cache@npm:11.5.2" + checksum: 10c0/ece1ad731f5b655e85d67047d04bfc13823dc77aa61c5454924a9869ba600a0104e39cc33d726021feef812bc347ca34a5608a5eb1972a5dd0870b7ecd42c3f2 languageName: node linkType: hard @@ -31675,6 +33385,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^1.0.0": + version: 1.1.0 + resolution: "magic-string@npm:1.1.0" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/f904dee2fd190ee5450c754234d8a831228677e9092ed2e545e42746beecf18d617156eb911af55a14496ebe1e60179678c4080c919f74eca7738eba3f38f13a + languageName: node + linkType: hard + "magicast@npm:^0.5.2": version: 0.5.3 resolution: "magicast@npm:0.5.3" @@ -31835,6 +33554,34 @@ __metadata: languageName: node linkType: hard +"mdast-util-definitions@npm:^6.0.0": + version: 6.0.0 + resolution: "mdast-util-definitions@npm:6.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/a2e0e51122a3eff4f35379de2c50ee3d8a89bea58488a390b1b40ada95727eb769f87d4bc885e5935d61820d19e0567bc047876db302a2139f3a29668b612b80 + languageName: node + linkType: hard + +"mdast-util-directive@npm:^3.0.0, mdast-util-directive@npm:^3.1.0": + version: 3.1.0 + resolution: "mdast-util-directive@npm:3.1.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + ccount: "npm:^2.0.0" + devlop: "npm:^1.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + parse-entities: "npm:^4.0.0" + stringify-entities: "npm:^4.0.0" + unist-util-visit-parents: "npm:^6.0.0" + checksum: 10c0/596b093b940197cf43af4d0de12e82a1d2b1eb5add73dd16077aa80e0d0e1f208ea642c420726e59ccd352c193d6ecd5c106d6fab769f252617c75333f91a314 + languageName: node + linkType: hard + "mdast-util-find-and-replace@npm:^3.0.0": version: 3.0.1 resolution: "mdast-util-find-and-replace@npm:3.0.1" @@ -32032,7 +33779,7 @@ __metadata: languageName: node linkType: hard -"mdast-util-to-markdown@npm:^2.0.0": +"mdast-util-to-markdown@npm:^2.0.0, mdast-util-to-markdown@npm:^2.1.2": version: 2.1.2 resolution: "mdast-util-to-markdown@npm:2.1.2" dependencies: @@ -32072,6 +33819,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.27.1": + version: 2.27.1 + resolution: "mdn-data@npm:2.27.1" + checksum: 10c0/eb8abf5d22e4d1e090346f5e81b67d23cef14c83940e445da5c44541ad874dc8fb9f6ca236e8258c3a489d9fb5884188a4d7d58773adb9089ac2c0b966796393 + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -32250,6 +34004,21 @@ __metadata: languageName: node linkType: hard +"micromark-extension-directive@npm:^4.0.0": + version: 4.0.0 + resolution: "micromark-extension-directive@npm:4.0.0" + dependencies: + devlop: "npm:^1.0.0" + micromark-factory-space: "npm:^2.0.0" + micromark-factory-whitespace: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + parse-entities: "npm:^4.0.0" + checksum: 10c0/b4aef0f44339543466ae186130a4514985837b6b12d0c155bd1162e740f631e58f0883a39d0c723206fa0ff53a9b579965c79116f902236f6f123c3340b5fefb + languageName: node + linkType: hard + "micromark-extension-gfm-autolink-literal@npm:^2.0.0": version: 2.1.0 resolution: "micromark-extension-gfm-autolink-literal@npm:2.1.0" @@ -33132,10 +34901,10 @@ __metadata: languageName: node linkType: hard -"mrmime@npm:^2.0.0": - version: 2.0.0 - resolution: "mrmime@npm:2.0.0" - checksum: 10c0/312b35ed288986aec90955410b21ed7427fd1e4ee318cb5fc18765c8d029eeded9444faa46589e5b1ed6b35fb2054a802ac8dcb917ddf6b3e189cb3bf11a965c +"mrmime@npm:^2.0.0, mrmime@npm:^2.0.1": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 languageName: node linkType: hard @@ -33444,6 +35213,13 @@ __metadata: languageName: node linkType: hard +"neotraverse@npm:^1.0.1": + version: 1.0.1 + resolution: "neotraverse@npm:1.0.1" + checksum: 10c0/98e5d2d1974f91d527851a9dcfceb62701a1d31970b324b9f35fbdd244d173edc7a379ea1773f64771a9dc282486e3d3b693b24e14b1e969291612b2ff6b5054 + languageName: node + linkType: hard + "netmask@npm:^2.0.2": version: 2.0.2 resolution: "netmask@npm:2.0.2" @@ -33556,6 +35332,15 @@ __metadata: languageName: node linkType: hard +"nlcst-to-string@npm:^4.0.0": + version: 4.0.0 + resolution: "nlcst-to-string@npm:4.0.0" + dependencies: + "@types/nlcst": "npm:^2.0.0" + checksum: 10c0/a192c8b3365a7c076812004e72ae5b4a1734e582be2a6f3c062f3beecf18868a9fe2d1bad870bfead320fb39830f2c4f3752e5ae6574c4e59157126fd1ddba70 + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -33600,6 +35385,13 @@ __metadata: languageName: node linkType: hard +"node-fetch-native@npm:^1.6.7": + version: 1.6.7 + resolution: "node-fetch-native@npm:1.6.7" + checksum: 10c0/8b748300fb053d21ca4d3db9c3ff52593d5e8f8a2d9fe90cbfad159676e324b954fdaefab46aeca007b5b9edab3d150021c4846444e4e8ab1f4e44cd3807be87 + languageName: node + linkType: hard + "node-fetch@npm:2.7.0, node-fetch@npm:^2.5.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -33658,6 +35450,13 @@ __metadata: languageName: node linkType: hard +"node-mock-http@npm:^1.0.4": + version: 1.0.5 + resolution: "node-mock-http@npm:1.0.5" + checksum: 10c0/14a36b28423953af95e4659aa1e07208b5a38daaa89c1aac28a193f5420744a2511eb288503019b2fc8de582c70612d2e475d0df1d1a0e44fe3dfd7fc8f5399c + languageName: node + linkType: hard + "node-readable-to-web-readable-stream@npm:^0.4.2": version: 0.4.2 resolution: "node-readable-to-web-readable-stream@npm:0.4.2" @@ -34002,6 +35801,17 @@ __metadata: languageName: node linkType: hard +"ofetch@npm:^1.5.1": + version: 1.5.1 + resolution: "ofetch@npm:1.5.1" + dependencies: + destr: "npm:^2.0.5" + node-fetch-native: "npm:^1.6.7" + ufo: "npm:^1.6.1" + checksum: 10c0/97ebc600512ea0ab401e97c73313218cc53c9b530b32ec8c995c347b0c68887129993168d1753f527761a64c6f93a5d823ce1378ccec95fc65a606f323a79a6c + languageName: node + linkType: hard + "officeparser@npm:6.0.7": version: 6.0.7 resolution: "officeparser@npm:6.0.7" @@ -34018,6 +35828,13 @@ __metadata: languageName: node linkType: hard +"ohash@npm:^2.0.11": + version: 2.0.11 + resolution: "ohash@npm:2.0.11" + checksum: 10c0/d07c8d79cc26da082c1a7c8d5b56c399dd4ed3b2bd069fcae6bae78c99a9bcc3ad813b1e1f49ca2f335292846d689c6141a762cf078727d2302a33d414e69c79 + languageName: node + linkType: hard + "on-finished@npm:^2.3.0, on-finished@npm:^2.4.1, on-finished@npm:~2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -34070,6 +35887,24 @@ __metadata: languageName: node linkType: hard +"oniguruma-parser@npm:^0.12.2": + version: 0.12.2 + resolution: "oniguruma-parser@npm:0.12.2" + checksum: 10c0/fe5255d2cd5a6b845d5a0abe1725898ef40cea5522290dba6ccc08cc388891e9e8007af4baa5059942b786ad44b2b677ef25948039c9ba3f057bd35d2c52076a + languageName: node + linkType: hard + +"oniguruma-to-es@npm:^4.3.6": + version: 4.3.6 + resolution: "oniguruma-to-es@npm:4.3.6" + dependencies: + oniguruma-parser: "npm:^0.12.2" + regex: "npm:^6.1.0" + regex-recursion: "npm:^6.0.2" + checksum: 10c0/044e08b98e706987c2882ccf228de2a671de4aff33e3bd370da137ba599c0d520549625ad26ecc8898502445c991e19f106f584e94382262ba2af02c908ca3cf + languageName: node + linkType: hard + "only@npm:~0.0.2": version: 0.0.2 resolution: "only@npm:0.0.2" @@ -34533,6 +36368,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^7.3.0": + version: 7.3.1 + resolution: "p-limit@npm:7.3.1" + dependencies: + yocto-queue: "npm:^1.2.1" + checksum: 10c0/49b7d7fcc244d4659fa3b260c7b4e632bc72333c60e76e4cd24c7befdeb9b8e22da644e5064e83df9e2ded75245e3e3b9df0e9cf8500b08db8ebf58ac18c6764 + languageName: node + linkType: hard + "p-locate@npm:^3.0.0": version: 3.0.0 resolution: "p-locate@npm:3.0.0" @@ -34574,13 +36418,13 @@ __metadata: languageName: node linkType: hard -"p-queue@npm:^9.0.0": - version: 9.0.0 - resolution: "p-queue@npm:9.0.0" +"p-queue@npm:^9.0.0, p-queue@npm:^9.1.0": + version: 9.3.3 + resolution: "p-queue@npm:9.3.3" dependencies: - eventemitter3: "npm:^5.0.1" + eventemitter3: "npm:^5.0.4" p-timeout: "npm:^7.0.0" - checksum: 10c0/0f27fcbec9e4e02f34cc3660f14b5746798c51a2425dc3d3c5238924c34726b1580606d5d1432fc05304e2350c00b112ded03eb43c1b49de7791a7150fbfcb4e + checksum: 10c0/0d88a35cb3301bee2aac654c0a7018a2e40775fa597f3a06da66f33b0311d106bebbe9732c8e5968ae1e0e0b49f79c874333d2a7b0a57fad469189f236c066d7 languageName: node linkType: hard @@ -34730,6 +36574,45 @@ __metadata: languageName: node linkType: hard +"package-manager-detector@npm:^1.6.0": + version: 1.8.0 + resolution: "package-manager-detector@npm:1.8.0" + checksum: 10c0/4c8e2c47fdc874465d3b3b11934401db9d73470ccbdabeb092e46cb94abbc491d5befc8c271b5ea1ae9c6a881f44c2997e011873365bde9fd6c3dc001221ff7a + languageName: node + linkType: hard + +"pagefind@npm:^1.5.2": + version: 1.5.2 + resolution: "pagefind@npm:1.5.2" + dependencies: + "@pagefind/darwin-arm64": "npm:1.5.2" + "@pagefind/darwin-x64": "npm:1.5.2" + "@pagefind/freebsd-x64": "npm:1.5.2" + "@pagefind/linux-arm64": "npm:1.5.2" + "@pagefind/linux-x64": "npm:1.5.2" + "@pagefind/windows-arm64": "npm:1.5.2" + "@pagefind/windows-x64": "npm:1.5.2" + dependenciesMeta: + "@pagefind/darwin-arm64": + optional: true + "@pagefind/darwin-x64": + optional: true + "@pagefind/freebsd-x64": + optional: true + "@pagefind/linux-arm64": + optional: true + "@pagefind/linux-x64": + optional: true + "@pagefind/windows-arm64": + optional: true + "@pagefind/windows-x64": + optional: true + bin: + pagefind: lib/runner/bin.cjs + checksum: 10c0/bcc4f34f473bfeb09dc4cb464a3547bf958e11ee4dee4144ec7a364bd74b9f7c21f85be71e388b66d30923a9a453dcaed2f6b425f3e67631cadc9650ba741aab + languageName: node + linkType: hard + "pako@npm:^0.2.5": version: 0.2.9 resolution: "pako@npm:0.2.9" @@ -34849,6 +36732,20 @@ __metadata: languageName: node linkType: hard +"parse-latin@npm:^7.0.0": + version: 7.0.0 + resolution: "parse-latin@npm:7.0.0" + dependencies: + "@types/nlcst": "npm:^2.0.0" + "@types/unist": "npm:^3.0.0" + nlcst-to-string: "npm:^4.0.0" + unist-util-modify-children: "npm:^4.0.0" + unist-util-visit-children: "npm:^3.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/4232a464f98c41c6680575c54bc2c9b21ac4b82a1f796a871bfef5efa6eddaab9bccf734b08cde6b0a5504ef46a0a14041ddd0bc5d9cc70f73a507f93f610596 + languageName: node + linkType: hard + "parse-ms@npm:^4.0.0": version: 4.0.0 resolution: "parse-ms@npm:4.0.0" @@ -35249,6 +37146,13 @@ __metadata: languageName: node linkType: hard +"piccolore@npm:^0.1.3": + version: 0.1.3 + resolution: "piccolore@npm:0.1.3" + checksum: 10c0/999666bb32eccc96a26b0cf3b8afe72f9d4cd4ca0eab5802b404cc84c601d81db4485b5feee5c50dbc8436ba6e1bc6a2310f99c18cee6687e430cc0eb4a36470 + languageName: node + linkType: hard + "picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -35558,6 +37462,17 @@ __metadata: languageName: node linkType: hard +"postcss-nested@npm:^6.0.1": + version: 6.2.0 + resolution: "postcss-nested@npm:6.2.0" + dependencies: + postcss-selector-parser: "npm:^6.1.1" + peerDependencies: + postcss: ^8.2.14 + checksum: 10c0/7f9c3f2d764191a39364cbdcec350f26a312431a569c9ef17408021424726b0d67995ff5288405e3724bb7152a4c92f73c027e580ec91e798800ed3c52e2bc6e + languageName: node + linkType: hard + "postcss-normalize-whitespace@npm:7.0.1": version: 7.0.1 resolution: "postcss-normalize-whitespace@npm:7.0.1" @@ -35579,6 +37494,16 @@ __metadata: languageName: node linkType: hard +"postcss-selector-parser@npm:^6.1.1": + version: 6.1.4 + resolution: "postcss-selector-parser@npm:6.1.4" + dependencies: + cssesc: "npm:^3.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 10c0/996f3290dee08ecb073d5f396d1134e619494cfd83140aec07a29618ee1e76d370769a8959d4c0cfefb24aa96dcffa4e7c4937dd881e03b735d50cba959ceb19 + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -35586,7 +37511,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.5.26, postcss@npm:^8.3.11, postcss@npm:^8.4.14, postcss@npm:^8.4.33, postcss@npm:^8.5.15, postcss@npm:^8.5.16, postcss@npm:^8.5.3, postcss@npm:^8.5.6": +"postcss@npm:8.5.26, postcss@npm:^8.3.11, postcss@npm:^8.4.14, postcss@npm:^8.4.33, postcss@npm:^8.4.38, postcss@npm:^8.5.15, postcss@npm:^8.5.23, postcss@npm:^8.5.3, postcss@npm:^8.5.6": version: 8.5.26 resolution: "postcss@npm:8.5.26" dependencies: @@ -35674,12 +37599,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.2.5": - version: 3.7.4 - resolution: "prettier@npm:3.7.4" +"prettier@npm:^3.2.5, prettier@npm:^3.8.1": + version: 3.9.6 + resolution: "prettier@npm:3.9.6" bin: prettier: bin/prettier.cjs - checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389 + checksum: 10c0/9f7ddae234035868f3daab3dc34e6de7e54622185afb017378029f0c09a9c023248ccf6f34def0b17a0538e18c47aa1b3188bab72965d1bf735919baa0747e99 languageName: node linkType: hard @@ -35739,6 +37664,13 @@ __metadata: languageName: node linkType: hard +"process-ancestry@npm:^0.1.0": + version: 0.1.0 + resolution: "process-ancestry@npm:0.1.0" + checksum: 10c0/3b20263db7c2eeb567efcef21bf4b3f3363c4bf7041796943fd8a467f888d94eb093a022b8fcc49c8d843f3fc81d15047255d4c2b4990f2d5a4f851f3c5c9cd4 + languageName: node + linkType: hard + "process-nextick-args@npm:^2.0.1, process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -36262,6 +38194,13 @@ __metadata: languageName: node linkType: hard +"radix3@npm:^1.1.2": + version: 1.1.2 + resolution: "radix3@npm:1.1.2" + checksum: 10c0/d4a295547f71af079868d2c2ed3814a9296ee026c5488212d58c106e6b4797c6eaec1259b46c9728913622f2240c9a944bfc8e2b3b5f6e4a5045338b1609f1e4 + languageName: node + linkType: hard + "raf-schd@npm:^4.0.2": version: 4.0.3 resolution: "raf-schd@npm:4.0.3" @@ -36678,7 +38617,7 @@ __metadata: languageName: node linkType: hard -"react-refresh@npm:0.18.0": +"react-refresh@npm:0.18.0, react-refresh@npm:^0.18.0": version: 0.18.0 resolution: "react-refresh@npm:0.18.0" checksum: 10c0/34a262f7fd803433a534f50deb27a148112a81adcae440c7d1cbae7ef14d21ea8f2b3d783e858cb7698968183b77755a38b4d4b5b1d79b4f4689c2f6d358fff2 @@ -37015,6 +38954,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^5.0.0": + version: 5.0.0 + resolution: "readdirp@npm:5.0.0" + checksum: 10c0/faf1ec57cff2020f473128da3f8d2a57813cc3a08a36c38cae1c9af32c1579906cc50ba75578043b35bade77e945c098233665797cf9730ba3613a62d6e79219 + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -37264,6 +39210,31 @@ __metadata: languageName: node linkType: hard +"regex-recursion@npm:^6.0.2": + version: 6.0.2 + resolution: "regex-recursion@npm:6.0.2" + dependencies: + regex-utilities: "npm:^2.3.0" + checksum: 10c0/68e8b6889680e904b75d7f26edaf70a1a4dc1087406bff53face4c2929d918fd77c72223843fe816ac8ed9964f96b4160650e8d5909e26a998c6e9de324dadb1 + languageName: node + linkType: hard + +"regex-utilities@npm:^2.3.0": + version: 2.3.0 + resolution: "regex-utilities@npm:2.3.0" + checksum: 10c0/78c550a80a0af75223244fff006743922591bd8f61d91fef7c86b9b56cf9bbf8ee5d7adb6d8991b5e304c57c90103fc4818cf1e357b11c6c669b782839bd7893 + languageName: node + linkType: hard + +"regex@npm:^6.1.0": + version: 6.1.0 + resolution: "regex@npm:6.1.0" + dependencies: + regex-utilities: "npm:^2.3.0" + checksum: 10c0/6e0ee2a1c17d5a66dc1120dfc51899dedf6677857e83a0df4d5a822ebb8645a54a079772efc1ade382b67aad35e4e22b5bd2d33c05ed28b0e000f8f57eb0aec1 + languageName: node + linkType: hard + "regexp-ast-analysis@npm:^0.7.0, regexp-ast-analysis@npm:^0.7.1": version: 0.7.1 resolution: "regexp-ast-analysis@npm:0.7.1" @@ -37378,6 +39349,36 @@ __metadata: languageName: node linkType: hard +"rehype-expressive-code@npm:^0.44.1": + version: 0.44.1 + resolution: "rehype-expressive-code@npm:0.44.1" + dependencies: + expressive-code: "npm:^0.44.1" + checksum: 10c0/7139dcb93dfc479225612ac51d1d7ad9b16e295eadbae4fecd697ab5f6f497dfb2d03317dd7b52a4a71691a8a88b7f23a7d0e216195ca6065b761a4958ae33c9 + languageName: node + linkType: hard + +"rehype-format@npm:^5.0.1": + version: 5.0.1 + resolution: "rehype-format@npm:5.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-format: "npm:^1.0.0" + checksum: 10c0/e87aac3e318ef96688785e108315b23762681f1a834ae52ac449b9787ff63e8435aceb70354fb629d2d733daca4f65889ddcdf0cd44b684ea4f481e8fac750e3 + languageName: node + linkType: hard + +"rehype-parse@npm:^9.0.0": + version: 9.0.1 + resolution: "rehype-parse@npm:9.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-from-html: "npm:^2.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/efa9ca17673fe70e2d322a1d262796bbed5f6a89382f8f8393352bbd6f6bbf1d4d1d050984b86ff9cb6c0fa2535175ab0829e53c94b1e38fc3c158e6c0ad90bc + languageName: node + linkType: hard + "rehype-raw@npm:^7.0.0": version: 7.0.0 resolution: "rehype-raw@npm:7.0.0" @@ -37400,6 +39401,29 @@ __metadata: languageName: node linkType: hard +"rehype-stringify@npm:^10.0.0, rehype-stringify@npm:^10.0.1": + version: 10.0.1 + resolution: "rehype-stringify@npm:10.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-to-html: "npm:^9.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/c643ae3a4862465033e0f1e9f664433767279b4ee9296570746970a79940417ec1fb1997a513659aab97063cf971c5d97e0af8129f590719f01628c8aa480765 + languageName: node + linkType: hard + +"rehype@npm:^13.0.2": + version: 13.0.2 + resolution: "rehype@npm:13.0.2" + dependencies: + "@types/hast": "npm:^3.0.0" + rehype-parse: "npm:^9.0.0" + rehype-stringify: "npm:^10.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/13d82086b673b3ce1fddb54cc8d30be16bde83fb62f1507f0af06070c94b85d07c3780fa994357bad2c9d51b84e4108ff661677b71d187e4f2167cab22d84363 + languageName: node + linkType: hard + "reinterval@npm:^1.1.0": version: 1.1.0 resolution: "reinterval@npm:1.1.0" @@ -37414,7 +39438,19 @@ __metadata: languageName: node linkType: hard -"remark-gfm@npm:4.0.1, remark-gfm@npm:^4.0.0": +"remark-directive@npm:^4.0.0": + version: 4.0.0 + resolution: "remark-directive@npm:4.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-directive: "npm:^3.0.0" + micromark-extension-directive: "npm:^4.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/ab1f71ae220913f62833464d483f29de7b4af0dcf1ff626d33cdb5dd81eda1c553a11f779de9fd9b641802586c5f070175824d70f14d53e451a484850705d53a + languageName: node + linkType: hard + +"remark-gfm@npm:4.0.1, remark-gfm@npm:^4.0.0, remark-gfm@npm:^4.0.1": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" dependencies: @@ -37450,16 +39486,28 @@ __metadata: languageName: node linkType: hard -"remark-rehype@npm:^11.0.0": - version: 11.1.1 - resolution: "remark-rehype@npm:11.1.1" +"remark-rehype@npm:^11.0.0, remark-rehype@npm:^11.1.2": + version: 11.1.2 + resolution: "remark-rehype@npm:11.1.2" dependencies: "@types/hast": "npm:^3.0.0" "@types/mdast": "npm:^4.0.0" mdast-util-to-hast: "npm:^13.0.0" unified: "npm:^11.0.0" vfile: "npm:^6.0.0" - checksum: 10c0/68f986e8ee758d415e93babda2a0d89477c15b7c200edc23b8b1d914dd6e963c5fc151a11cbbbcfa7dd237367ff3ef86e302be90f31f37a17b0748668bd8c65b + checksum: 10c0/f9eccacfb596d9605581dc05bfad28635d6ded5dd0a18e88af5fd4df0d3fcf9612e1501d4513bc2164d833cfe9636dab20400080b09e53f155c6e1442a1231fb + languageName: node + linkType: hard + +"remark-smartypants@npm:^3.0.2": + version: 3.0.3 + resolution: "remark-smartypants@npm:3.0.3" + dependencies: + retext: "npm:^9.0.0" + retext-smartypants: "npm:^6.0.0" + unified: "npm:^11.0.4" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/24f210e64d44b13559aa6c7423b29a0a3b7fb1b603c3b997c70ad923e1bf1cebff9f234db801c689c02ff5bebdfd5256481c6690928a2465c09cd54a03076514 languageName: node linkType: hard @@ -37515,6 +39563,20 @@ __metadata: languageName: node linkType: hard +"request-light@npm:^0.5.7": + version: 0.5.8 + resolution: "request-light@npm:0.5.8" + checksum: 10c0/65d47f79cbe13c46c10eecb9264ad06ea5f76ea64855766448937c0fe888a70b9c854bf9dc4c2dc1949ea5400030b55bea7d4ba0807fae0202004cee796ea819 + languageName: node + linkType: hard + +"request-light@npm:^0.7.0": + version: 0.7.0 + resolution: "request-light@npm:0.7.0" + checksum: 10c0/1c98f0d74b8a28a6fecb96d4b30c255a27b402f5e10b2696b67ede7257389a7533fba9e8ea122ccec9a7c6f06db36eab5d0b563bd5ff27b15aaa9d3910e31894 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -37696,6 +39758,51 @@ __metadata: languageName: node linkType: hard +"retext-latin@npm:^4.0.0": + version: 4.0.0 + resolution: "retext-latin@npm:4.0.0" + dependencies: + "@types/nlcst": "npm:^2.0.0" + parse-latin: "npm:^7.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/51530be66db9ef6ab8e9cda5dd0598377ff4321481d6a941bf70dac16fa6e9123ff7d8ff093a05c30a3e00e282e37094b845b6130a8005a3cb7186a961ab99cb + languageName: node + linkType: hard + +"retext-smartypants@npm:^6.0.0, retext-smartypants@npm:^6.2.0": + version: 6.2.0 + resolution: "retext-smartypants@npm:6.2.0" + dependencies: + "@types/nlcst": "npm:^2.0.0" + nlcst-to-string: "npm:^4.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/36f925353dd7f31df642bca2493524a8daee15f9b0e0dfe7fb8982462d23ccb12a99864989db22f0bacb6d7fea1f696ba96e031d3fbac4f013e1c95ef3fed881 + languageName: node + linkType: hard + +"retext-stringify@npm:^4.0.0": + version: 4.0.0 + resolution: "retext-stringify@npm:4.0.0" + dependencies: + "@types/nlcst": "npm:^2.0.0" + nlcst-to-string: "npm:^4.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/eb2930356c85999a8978092a5d6ba3695fea859c71f221dcdc485704552922641bc17e50fea2ae0599d665192eaad002e98bb4236ecac94a570b73581b99004d + languageName: node + linkType: hard + +"retext@npm:^9.0.0": + version: 9.0.0 + resolution: "retext@npm:9.0.0" + dependencies: + "@types/nlcst": "npm:^2.0.0" + retext-latin: "npm:^4.0.0" + retext-stringify: "npm:^4.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/eee9f66ff6fae5670a5eeccc0b5e2639112f868475273ce307d3079cfe7deb9d1b0f2b8fa28b4ab30abaf8538345185a44908f461a27bbf43c4f94feda90ecac + languageName: node + linkType: hard + "retry-request@npm:^7.0.0": version: 7.0.2 resolution: "retry-request@npm:7.0.2" @@ -37865,7 +39972,7 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:~1.1.2, rolldown@npm:~1.1.4": +"rolldown@npm:~1.1.2": version: 1.1.5 resolution: "rolldown@npm:1.1.5" dependencies: @@ -37923,6 +40030,61 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:~1.2.0": + version: 1.2.3 + resolution: "rolldown@npm:1.2.3" + dependencies: + "@oxc-project/types": "npm:=0.143.0" + "@rolldown/binding-android-arm64": "npm:1.2.3" + "@rolldown/binding-darwin-arm64": "npm:1.2.3" + "@rolldown/binding-darwin-x64": "npm:1.2.3" + "@rolldown/binding-freebsd-x64": "npm:1.2.3" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.3" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.3" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.3" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.3" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.3" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.3" + "@rolldown/binding-linux-x64-musl": "npm:1.2.3" + "@rolldown/binding-openharmony-arm64": "npm:1.2.3" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.3" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.3" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/4dbeabc826e59877c7520b2ae1f48d0111e1d21865f5931ff3d184d33f02683e943e65eb24932128fc03701bbcde1b341f313b3fa7f8007368bd1b5e993265f0 + languageName: node + linkType: hard + "rollup@npm:4.59.0": version: 4.59.0 resolution: "rollup@npm:4.59.0" @@ -38297,10 +40459,50 @@ __metadata: languageName: node linkType: hard -"sax@npm:^1.5.0": - version: 1.5.0 - resolution: "sax@npm:1.5.0" - checksum: 10c0/bc3b60a7bfecd40b18256596e96b32df2488339ae1e00a77f842b568f0831228a16c3bd357ec500241ec0b9dc7a475a1286427795c4a8c50bb8e8878f3435dd8 +"satteri@npm:^0.9.1": + version: 0.9.5 + resolution: "satteri@npm:0.9.5" + dependencies: + "@bruits/satteri-darwin-arm64": "npm:0.9.5" + "@bruits/satteri-darwin-x64": "npm:0.9.5" + "@bruits/satteri-linux-arm64-gnu": "npm:0.9.5" + "@bruits/satteri-linux-arm64-musl": "npm:0.9.5" + "@bruits/satteri-linux-x64-gnu": "npm:0.9.5" + "@bruits/satteri-linux-x64-musl": "npm:0.9.5" + "@bruits/satteri-wasm32-wasi": "npm:0.9.5" + "@bruits/satteri-win32-arm64-msvc": "npm:0.9.5" + "@bruits/satteri-win32-x64-msvc": "npm:0.9.5" + "@types/estree-jsx": "npm:^1.0.5" + "@types/hast": "npm:^3.0.4" + "@types/mdast": "npm:^4.0.4" + "@types/unist": "npm:^3.0.3" + dependenciesMeta: + "@bruits/satteri-darwin-arm64": + optional: true + "@bruits/satteri-darwin-x64": + optional: true + "@bruits/satteri-linux-arm64-gnu": + optional: true + "@bruits/satteri-linux-arm64-musl": + optional: true + "@bruits/satteri-linux-x64-gnu": + optional: true + "@bruits/satteri-linux-x64-musl": + optional: true + "@bruits/satteri-wasm32-wasi": + optional: true + "@bruits/satteri-win32-arm64-msvc": + optional: true + "@bruits/satteri-win32-x64-msvc": + optional: true + checksum: 10c0/4a204a57b7b84750cb0fc9a1ab153c0c99dd3c57cac20b785705f6b723c1a465263951ad624eb47808a9501418eb81fa526bbc71f57f8fd07472fdf114e73a9c + languageName: node + linkType: hard + +"sax@npm:^1.4.1, sax@npm:^1.5.0": + version: 1.6.1 + resolution: "sax@npm:1.6.1" + checksum: 10c0/c91a71043d60da50fdf1e2cee936fc7ad4c9376afb8d1022aef5655f279433640859ec06b3b49abfcbe578fc7da2828cc1661e45aaedf835f5393496193437fa languageName: node linkType: hard @@ -38428,7 +40630,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.8.5, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.1, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.3, semver@npm:^7.8.5": +"semver@npm:7.8.5, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.1, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.3, semver@npm:^7.7.4, semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -38657,7 +40859,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:0.35.3": +"sharp@npm:0.35.3, sharp@npm:^0.34.0 || ^0.35.0": version: 0.35.3 resolution: "sharp@npm:0.35.3" dependencies: @@ -38786,6 +40988,22 @@ __metadata: languageName: node linkType: hard +"shiki@npm:^4.0.2": + version: 4.4.2 + resolution: "shiki@npm:4.4.2" + dependencies: + "@shikijs/core": "npm:4.4.2" + "@shikijs/engine-javascript": "npm:4.4.2" + "@shikijs/engine-oniguruma": "npm:4.4.2" + "@shikijs/langs": "npm:4.4.2" + "@shikijs/themes": "npm:4.4.2" + "@shikijs/types": "npm:4.4.2" + "@shikijs/vscode-textmate": "npm:^10.0.2" + "@types/hast": "npm:^3.0.5" + checksum: 10c0/2bae4e358c561373ef5b5a7c3456466d91ae413250abad83a6281bc37b178fd5a6b9d5d68c9b2071d3c4f97ae11262faf04d453ecb9a5fd06af5bb765b6ef2dd + languageName: node + linkType: hard + "short-unique-id@npm:^5.2.0": version: 5.2.0 resolution: "short-unique-id@npm:5.2.0" @@ -38956,6 +41174,20 @@ __metadata: languageName: node linkType: hard +"sitemap@npm:^9.0.0": + version: 9.0.1 + resolution: "sitemap@npm:9.0.1" + dependencies: + "@types/node": "npm:^24.9.2" + "@types/sax": "npm:^1.2.1" + arg: "npm:^5.0.0" + sax: "npm:^1.4.1" + bin: + sitemap: dist/esm/cli.js + checksum: 10c0/dddd3c9f30fd09655fa2c1bd9bb182ed860717b3ba681fdc1ac66ad25b5f399dafdd8b87d5ba4ee914ea14e7b11841aadc3af61e4a45242a14f28194efe12416 + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -39015,6 +41247,13 @@ __metadata: languageName: node linkType: hard +"smol-toml@npm:^1.6.0": + version: 1.7.1 + resolution: "smol-toml@npm:1.7.1" + checksum: 10c0/c2494e19e748578cf9f81f918dd66a548dfe5a0e581ab9f980d5420f2319c882c661a62d01d216a43f2db490faaccd2185bcdb6f2ba0658eb303eda9db871d76 + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4" @@ -39482,6 +41721,13 @@ __metadata: languageName: node linkType: hard +"stream-replace-string@npm:^2.0.0": + version: 2.0.0 + resolution: "stream-replace-string@npm:2.0.0" + checksum: 10c0/6cdf6108c57a869c1282dece0728bd7a8e314855bee71992436460192cdf46b3c976451e1e114716af209b2bfefa0e7e4581ca0eebc330d9dfcde341a72d50af + languageName: node + linkType: hard + "stream-shift@npm:^1.0.2": version: 1.0.3 resolution: "stream-shift@npm:1.0.3" @@ -39581,7 +41827,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^8.2.0": +"string-width@npm:^8.2.0, string-width@npm:^8.2.1": version: 8.2.2 resolution: "string-width@npm:8.2.2" dependencies: @@ -40001,6 +42247,23 @@ __metadata: languageName: node linkType: hard +"svgo@npm:^4.0.1": + version: 4.0.2 + resolution: "svgo@npm:4.0.2" + dependencies: + commander: "npm:^11.1.0" + css-select: "npm:^5.1.0" + css-tree: "npm:^3.0.1" + css-what: "npm:^6.1.0" + csso: "npm:^5.0.5" + picocolors: "npm:^1.1.1" + sax: "npm:^1.5.0" + bin: + svgo: ./bin/svgo.js + checksum: 10c0/d58c9446d2701dd57ef62a29b4299b157cfc32e2282f82fc68dd7cdca2e1efdf0d8bb0fb30ba0499f322d20b6304be277ffbc60a6b6a75489fa30c5300694b9a + languageName: node + linkType: hard + "swap-case@npm:^2.0.2": version: 2.0.2 resolution: "swap-case@npm:2.0.2" @@ -40415,7 +42678,7 @@ __metadata: languageName: node linkType: hard -"tiny-inflate@npm:^1.0.0": +"tiny-inflate@npm:^1.0.0, tiny-inflate@npm:^1.0.3": version: 1.0.3 resolution: "tiny-inflate@npm:1.0.3" checksum: 10c0/fab687537254f6ec44c9a2e880048fe70da3542aba28f73cda3e74c95cabf342a339372f2a6c032e322324f01accc03ca26c04ba2bad9b3eb8cf3ee99bba7f9b @@ -40436,6 +42699,13 @@ __metadata: languageName: node linkType: hard +"tinyclip@npm:^0.1.12": + version: 0.1.15 + resolution: "tinyclip@npm:0.1.15" + checksum: 10c0/8ebdcc60e0e9ba54edeae28158a7a96e94657ac9387cc03cd5ef6c51c36b92be2ebe10cf5e34c9382124ef766cf82784cca2c5db57cd7ab447b7d04880ee7579 + languageName: node + linkType: hard + "tinyexec@npm:^0.3.2": version: 0.3.2 resolution: "tinyexec@npm:0.3.2" @@ -40443,14 +42713,14 @@ __metadata: languageName: node linkType: hard -"tinyexec@npm:^1.0.2": - version: 1.0.4 - resolution: "tinyexec@npm:1.0.4" - checksum: 10c0/d4a5bbcf6bdb23527a4b74c4aa566f41432167112fe76f420ec7e3a90a3ecfd3a7d944383e2719fc3987b69400f7b928daf08700d145fb527c2e80ec01e198bd +"tinyexec@npm:^1.0.2, tinyexec@npm:^1.0.4": + version: 1.3.0 + resolution: "tinyexec@npm:1.3.0" + checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958 languageName: node linkType: hard -"tinyglobby@npm:^0.2.10, tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17, tinyglobby@npm:^0.2.9": +"tinyglobby@npm:^0.2.10, tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.16, tinyglobby@npm:^0.2.17, tinyglobby@npm:^0.2.9": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -41138,6 +43408,22 @@ __metadata: languageName: node linkType: hard +"typesafe-path@npm:^0.2.2": + version: 0.2.2 + resolution: "typesafe-path@npm:0.2.2" + checksum: 10c0/05027ea6a52a1e879da39c53fae7f5059e50cf578d586cc6c7741c3eca0b37ee2da4ef16d35ecf9f60cd0e8ca5aca98ea47902672907a19470f2260e8769b933 + languageName: node + linkType: hard + +"typescript-auto-import-cache@npm:^0.3.5": + version: 0.3.6 + resolution: "typescript-auto-import-cache@npm:0.3.6" + dependencies: + semver: "npm:^7.3.8" + checksum: 10c0/aad48d6aa9985f9a056c5c89ab91b6781c5430c0b80652586c2a997f2cc394f3ed70ec18c0c818a8423991a1cfe7e64d010cc2fded9ea192067a3188476c2b51 + languageName: node + linkType: hard + "typescript-eslint@npm:^8.46.4": version: 8.50.0 resolution: "typescript-eslint@npm:8.50.0" @@ -41280,10 +43566,10 @@ __metadata: languageName: node linkType: hard -"ufo@npm:^1.6.1": - version: 1.6.1 - resolution: "ufo@npm:1.6.1" - checksum: 10c0/5a9f041e5945fba7c189d5410508cbcbefef80b253ed29aa2e1f8a2b86f4bd51af44ee18d4485e6d3468c92be9bf4a42e3a2b72dcaf27ce39ce947ec994f1e6b +"ufo@npm:^1.6.1, ufo@npm:^1.6.3": + version: 1.6.4 + resolution: "ufo@npm:1.6.4" + checksum: 10c0/3a2b29e7e3d772fbf6893d7d23bf442981457adb2fe122828abdbda89bedcb81aafd0dcc080e41b45f9a877db00cb42cbfee9639753a19d9b9bd39b5627039cf languageName: node linkType: hard @@ -41340,6 +43626,13 @@ __metadata: languageName: node linkType: hard +"ultrahtml@npm:^1.6.0": + version: 1.7.0 + resolution: "ultrahtml@npm:1.7.0" + checksum: 10c0/a56907b3ea4b0c16d30c37cb814138f8ae47044892a9d1c5ecca0731e24c2c4cd1c9cfa53d57bf508b21bc3b993e3c02bf3f9c3bfc156d18ed7e12c62a140f75 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" @@ -41369,6 +43662,13 @@ __metadata: languageName: node linkType: hard +"uncrypto@npm:^0.1.3": + version: 0.1.3 + resolution: "uncrypto@npm:0.1.3" + checksum: 10c0/74a29afefd76d5b77bedc983559ceb33f5bbc8dada84ff33755d1e3355da55a4e03a10e7ce717918c436b4dfafde1782e799ebaf2aadd775612b49f7b5b2998e + languageName: node + linkType: hard + "underscore@npm:1.13.8": version: 1.13.8 resolution: "underscore@npm:1.13.8" @@ -41383,10 +43683,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.16.0": - version: 7.16.0 - resolution: "undici-types@npm:7.16.0" - checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d languageName: node linkType: hard @@ -41469,7 +43769,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0, unified@npm:^11.0.4": +"unified@npm:^11.0.0, unified@npm:^11.0.4, unified@npm:^11.0.5": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: @@ -41484,6 +43784,17 @@ __metadata: languageName: node linkType: hard +"unifont@npm:~0.7.4": + version: 0.7.4 + resolution: "unifont@npm:0.7.4" + dependencies: + css-tree: "npm:^3.1.0" + ofetch: "npm:^1.5.1" + ohash: "npm:^2.0.11" + checksum: 10c0/2b1c23e13083ce52382ac3e430b24c7f7a9a8657480f82847e254d5e3aa8a639c1178fda9ad157c21d2ceff5f7a037021c547501856dca7a93bc75bef6fe2f7d + languageName: node + linkType: hard + "unionfs@npm:^4.5.1": version: 4.5.4 resolution: "unionfs@npm:4.5.4" @@ -41511,6 +43822,16 @@ __metadata: languageName: node linkType: hard +"unist-util-find-after@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-find-after@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/a7cea473c4384df8de867c456b797ff1221b20f822e1af673ff5812ed505358b36f47f3b084ac14c3622cb879ed833b71b288e8aa71025352a2aab4c2925a6eb + languageName: node + linkType: hard + "unist-util-is@npm:^6.0.0": version: 6.0.0 resolution: "unist-util-is@npm:6.0.0" @@ -41520,6 +43841,16 @@ __metadata: languageName: node linkType: hard +"unist-util-modify-children@npm:^4.0.0": + version: 4.0.0 + resolution: "unist-util-modify-children@npm:4.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + array-iterate: "npm:^2.0.0" + checksum: 10c0/63d44b09a2e4c674c72816d4328d668972e68cc965ea719fef1c642b66a3ebe3b102e284a3213b4920ebccff05e0f689b4eaae8a0e5c3dafcad117d1577496da + languageName: node + linkType: hard + "unist-util-position-from-estree@npm:^2.0.0": version: 2.0.0 resolution: "unist-util-position-from-estree@npm:2.0.0" @@ -41538,6 +43869,16 @@ __metadata: languageName: node linkType: hard +"unist-util-remove-position@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-remove-position@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/e8c76da4399446b3da2d1c84a97c607b37d03d1d92561e14838cbe4fdcb485bfc06c06cfadbb808ccb72105a80643976d0660d1fe222ca372203075be9d71105 + languageName: node + linkType: hard + "unist-util-stringify-position@npm:^4.0.0": version: 4.0.0 resolution: "unist-util-stringify-position@npm:4.0.0" @@ -41547,24 +43888,33 @@ __metadata: languageName: node linkType: hard -"unist-util-visit-parents@npm:^6.0.0": - version: 6.0.1 - resolution: "unist-util-visit-parents@npm:6.0.1" +"unist-util-visit-children@npm:^3.0.0": + version: 3.0.0 + resolution: "unist-util-visit-children@npm:3.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/51e95f54fbf11d414952c011c761c3960864948ad3fd2abe3989eb18b18d96b8f48e7ea5ab6f23264d1a3f4f5a1ff76312dd8f2196c78b762098403505c3abb9 + languageName: node + linkType: hard + +"unist-util-visit-parents@npm:^6.0.0, unist-util-visit-parents@npm:^6.0.1, unist-util-visit-parents@npm:^6.0.2": + version: 6.0.2 + resolution: "unist-util-visit-parents@npm:6.0.2" dependencies: "@types/unist": "npm:^3.0.0" unist-util-is: "npm:^6.0.0" - checksum: 10c0/51b1a5b0aa23c97d3e03e7288f0cdf136974df2217d0999d3de573c05001ef04cccd246f51d2ebdfb9e8b0ed2704451ad90ba85ae3f3177cf9772cef67f56206 + checksum: 10c0/f1e4019dbd930301825895e3737b1ee0cd682f7622ddd915062135cbb39f8c090aaece3a3b5eae1f2ea52ec33f0931abb8f8a8b5c48a511a4203e3d360a8cd49 languageName: node linkType: hard -"unist-util-visit@npm:^5.0.0": - version: 5.0.0 - resolution: "unist-util-visit@npm:5.0.0" +"unist-util-visit@npm:^5.0.0, unist-util-visit@npm:^5.1.0": + version: 5.1.0 + resolution: "unist-util-visit@npm:5.1.0" dependencies: "@types/unist": "npm:^3.0.0" unist-util-is: "npm:^6.0.0" unist-util-visit-parents: "npm:^6.0.0" - checksum: 10c0/51434a1d80252c1540cce6271a90fd1a106dbe624997c09ed8879279667fb0b2d3a685e02e92bf66598dcbe6cdffa7a5f5fb363af8fdf90dda6c855449ae39a5 + checksum: 10c0/a56e1bbbf63fcb55abe379e660b9a3367787e8be1e2473bdb7e86cfa6f32b6c1fa0092432d7040b8a30b2fc674bbbe024ffe6d03c3d6bf4839b064f584463a4e languageName: node linkType: hard @@ -41707,6 +44057,81 @@ __metadata: languageName: node linkType: hard +"unstorage@npm:^1.17.5": + version: 1.17.5 + resolution: "unstorage@npm:1.17.5" + dependencies: + anymatch: "npm:^3.1.3" + chokidar: "npm:^5.0.0" + destr: "npm:^2.0.5" + h3: "npm:^1.15.10" + lru-cache: "npm:^11.2.7" + node-fetch-native: "npm:^1.6.7" + ofetch: "npm:^1.5.1" + ufo: "npm:^1.6.3" + peerDependencies: + "@azure/app-configuration": ^1.8.0 + "@azure/cosmos": ^4.2.0 + "@azure/data-tables": ^13.3.0 + "@azure/identity": ^4.6.0 + "@azure/keyvault-secrets": ^4.9.0 + "@azure/storage-blob": ^12.26.0 + "@capacitor/preferences": ^6 || ^7 || ^8 + "@deno/kv": ">=0.9.0" + "@netlify/blobs": ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + "@planetscale/database": ^1.19.0 + "@upstash/redis": ^1.34.3 + "@vercel/blob": ">=0.27.1" + "@vercel/functions": ^2.2.12 || ^3.0.0 + "@vercel/kv": ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: ">=0.2.1" + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + "@azure/app-configuration": + optional: true + "@azure/cosmos": + optional: true + "@azure/data-tables": + optional: true + "@azure/identity": + optional: true + "@azure/keyvault-secrets": + optional: true + "@azure/storage-blob": + optional: true + "@capacitor/preferences": + optional: true + "@deno/kv": + optional: true + "@netlify/blobs": + optional: true + "@planetscale/database": + optional: true + "@upstash/redis": + optional: true + "@vercel/blob": + optional: true + "@vercel/functions": + optional: true + "@vercel/kv": + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + checksum: 10c0/52bc07d0951129f493cc2d2a5204f49df90c85e7ab3faac0019e3a31f22f507f7a8263fca7ca4d019950c14cbfed4d43e15c9afe4b5dcb307249ebb60d2e538e + languageName: node + linkType: hard + "until-async@npm:^3.0.2": version: 3.0.2 resolution: "until-async@npm:3.0.2" @@ -41783,6 +44208,13 @@ __metadata: languageName: node linkType: hard +"url-extras@npm:^0.1.0": + version: 0.1.0 + resolution: "url-extras@npm:0.1.0" + checksum: 10c0/353ca8190921d793bedaf765b55f94c1d719d1fd696c9a5dafeaf1b4532c1de936649925a0d699aa9613555b8dac82078e51d0a65cb1e48b92817ed68a527b60 + languageName: node + linkType: hard + "url-parse@npm:^1.5.3": version: 1.5.10 resolution: "url-parse@npm:1.5.10" @@ -42206,19 +44638,19 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": - version: 8.1.4 - resolution: "vite@npm:8.1.4" +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.13": + version: 8.2.0 + resolution: "vite@npm:8.2.0" dependencies: fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.32.0" + lightningcss: "npm:^1.33.0" picomatch: "npm:^4.0.5" - postcss: "npm:^8.5.16" - rolldown: "npm:~1.1.4" + postcss: "npm:^8.5.23" + rolldown: "npm:~1.2.0" tinyglobby: "npm:^0.2.17" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.3.0 + "@vitejs/devtools": ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: ">=1.21.0" less: ^4.0.0 @@ -42259,7 +44691,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/4262cdd0dd00ca99b00a8aef91fc00e58b66e80b54adbef49353bfbbae7342c2d6ddf1180351956086ff53ad186bec03784f57b96e36ab4dad1246547236d79b + checksum: 10c0/bd6a5e7b28973bac06f0e8e54833800e16d1bf38a8aef2acbfea5bbaed6d8fc715fd7cc9b0ec75ef1adbde149bb9167b085c17fe7edcd3a190b4eda16d474e5c languageName: node linkType: hard @@ -42318,6 +44750,18 @@ __metadata: languageName: node linkType: hard +"vitefu@npm:^1.1.2": + version: 1.1.3 + resolution: "vitefu@npm:1.1.3" + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/6cfcd52a17339f63f71ef08787a68850a17a9d9dd7a688e29e41067f963eb2cd5192bb65d38df87fa3a733a21e0229aaa6382201655c1599699e5d6669e3e094 + languageName: node + linkType: hard + "vitest-browser-react@npm:^2.0.2": version: 2.2.0 resolution: "vitest-browser-react@npm:2.2.0" @@ -42404,7 +44848,192 @@ __metadata: languageName: node linkType: hard -"vscode-languageserver-textdocument@npm:^1.0.8": +"volar-service-css@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-css@npm:0.0.71" + dependencies: + vscode-css-languageservice: "npm:^6.3.0" + vscode-languageserver-textdocument: "npm:^1.0.11" + vscode-uri: "npm:^3.0.8" + peerDependencies: + "@volar/language-service": ~2.4.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + checksum: 10c0/fd07e55786a18db2f671eea087e2d43116d8c0f504aac699a3b47fc9d6f53d66c0b3652062ca6ae08d8e59905564f72c63af9727d956165061849ab488462e2e + languageName: node + linkType: hard + +"volar-service-emmet@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-emmet@npm:0.0.71" + dependencies: + "@emmetio/css-parser": "npm:^0.4.1" + "@emmetio/html-matcher": "npm:^1.3.0" + "@vscode/emmet-helper": "npm:^2.9.3" + vscode-uri: "npm:^3.0.8" + peerDependencies: + "@volar/language-service": ~2.4.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + checksum: 10c0/a3c95991859d0e7e06a23d1201fcbdddbb7be0c525a553881f14ab941efe331adbb27431caf67962ec43a077e424f7e30bada6a52254272572b93b01f55bcf73 + languageName: node + linkType: hard + +"volar-service-html@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-html@npm:0.0.71" + dependencies: + vscode-html-languageservice: "npm:^5.3.0" + vscode-languageserver-textdocument: "npm:^1.0.11" + vscode-uri: "npm:^3.0.8" + peerDependencies: + "@volar/language-service": ~2.4.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + checksum: 10c0/b1ff293413945cf7357aadc9484080863e7a855a919ccc4f379123ebabe16f9905f5bd19db60e08028a7282c2926762a006daddabd0e373d5040d32afe2ab364 + languageName: node + linkType: hard + +"volar-service-prettier@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-prettier@npm:0.0.71" + dependencies: + vscode-uri: "npm:^3.0.8" + peerDependencies: + "@volar/language-service": ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + prettier: + optional: true + checksum: 10c0/f3498260e51a5ee6d762fac0f332e745a5705db9ccd9aca9d154985cff733d22e8be6a41239bdce4cd5dcdaa05f8bd6e1baf27c5585f9b968e93155eb74e2118 + languageName: node + linkType: hard + +"volar-service-typescript-twoslash-queries@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-typescript-twoslash-queries@npm:0.0.71" + dependencies: + vscode-uri: "npm:^3.0.8" + peerDependencies: + "@volar/language-service": ~2.4.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + checksum: 10c0/deaea07b08c6dc54763c98217b1b206c885e4434a07018f058e22602f8445ee29ceac70217b1271b6182c979e8ccfe2fbdaa464e327ab8ae8a0eb87517d0adba + languageName: node + linkType: hard + +"volar-service-typescript@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-typescript@npm:0.0.71" + dependencies: + path-browserify: "npm:^1.0.1" + semver: "npm:^7.6.2" + typescript-auto-import-cache: "npm:^0.3.5" + vscode-languageserver-textdocument: "npm:^1.0.11" + vscode-nls: "npm:^5.2.0" + vscode-uri: "npm:^3.0.8" + peerDependencies: + "@volar/language-service": ~2.4.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + checksum: 10c0/c197666fca9810e72e9db81d15c527b21dbebd493966199b5e8d690204ba17cbc6cfaeed6be7d72b285bb795ff7fbb45a939463db0cb804360c4966e5811bbc0 + languageName: node + linkType: hard + +"volar-service-yaml@npm:0.0.71": + version: 0.0.71 + resolution: "volar-service-yaml@npm:0.0.71" + dependencies: + vscode-uri: "npm:^3.0.8" + yaml-language-server: "npm:~1.23.0" + peerDependencies: + "@volar/language-service": ~2.4.0 + peerDependenciesMeta: + "@volar/language-service": + optional: true + checksum: 10c0/6d56a6c77a100208e1ae0c5cbc80f547a066012a21574f476b37b59dfb212129efaf1a98fdc4cde8bcc71c61e9d26e0ac326d6c8ea300dffd681bf2c55b50f35 + languageName: node + linkType: hard + +"vscode-css-languageservice@npm:^6.3.0": + version: 6.3.10 + resolution: "vscode-css-languageservice@npm:6.3.10" + dependencies: + "@vscode/l10n": "npm:^0.0.18" + vscode-languageserver-textdocument: "npm:^1.0.12" + vscode-languageserver-types: "npm:3.17.5" + vscode-uri: "npm:^3.1.0" + checksum: 10c0/3f6228d85579c0306343c734716c17128757f3a6e44625deac06622fe248f05fe15da1901581aa50cb8d9539137a49d19f05dca6d13feb2e6bc9f3d9784236bd + languageName: node + linkType: hard + +"vscode-html-languageservice@npm:^5.3.0, vscode-html-languageservice@npm:^5.6.2": + version: 5.6.2 + resolution: "vscode-html-languageservice@npm:5.6.2" + dependencies: + "@vscode/l10n": "npm:^0.0.18" + vscode-languageserver-textdocument: "npm:^1.0.12" + vscode-languageserver-types: "npm:^3.17.5" + vscode-uri: "npm:^3.1.0" + checksum: 10c0/5ef5f3cb959435a3f29d9b5844fb223140b8b5b616c3f54d0c627427853e8c8806d5d93f3a4e3354a3c9d2e9acdf6cb8043479837d20d9dd30ad6d07bf22286d + languageName: node + linkType: hard + +"vscode-json-languageservice@npm:4.1.8": + version: 4.1.8 + resolution: "vscode-json-languageservice@npm:4.1.8" + dependencies: + jsonc-parser: "npm:^3.0.0" + vscode-languageserver-textdocument: "npm:^1.0.1" + vscode-languageserver-types: "npm:^3.16.0" + vscode-nls: "npm:^5.0.0" + vscode-uri: "npm:^3.0.2" + checksum: 10c0/9165703884e5eef52d4bf52294051df6623461cc7a3d458ef1f4fa3f98ac93c3a79efc8c4a15fda6ddf6e43d155c207c3c13534dcac3fe7982ddf1926dbf22a7 + languageName: node + linkType: hard + +"vscode-jsonrpc@npm:8.2.0": + version: 8.2.0 + resolution: "vscode-jsonrpc@npm:8.2.0" + checksum: 10c0/0789c227057a844f5ead55c84679206227a639b9fb76e881185053abc4e9848aa487245966cc2393fcb342c4541241b015a1a2559fddd20ac1e68945c95344e6 + languageName: node + linkType: hard + +"vscode-jsonrpc@npm:9.0.1": + version: 9.0.1 + resolution: "vscode-jsonrpc@npm:9.0.1" + checksum: 10c0/6ca63393dd78675ed33b5537c147e673865557ff93915cd5189ceae039ca7fe01808ffd9a14016732d4789f2102a037d68d4216f557c3380d1c7f3ea50fd85b4 + languageName: node + linkType: hard + +"vscode-languageserver-protocol@npm:3.17.5": + version: 3.17.5 + resolution: "vscode-languageserver-protocol@npm:3.17.5" + dependencies: + vscode-jsonrpc: "npm:8.2.0" + vscode-languageserver-types: "npm:3.17.5" + checksum: 10c0/5f38fd80da9868d706eaa4a025f4aff9c3faad34646bcde1426f915cbd8d7e8b6c3755ce3fef6eebd256ba3145426af1085305f8a76e34276d2e95aaf339a90b + languageName: node + linkType: hard + +"vscode-languageserver-protocol@npm:^3.17.5": + version: 3.18.2 + resolution: "vscode-languageserver-protocol@npm:3.18.2" + dependencies: + vscode-jsonrpc: "npm:9.0.1" + vscode-languageserver-types: "npm:3.18.0" + checksum: 10c0/c77862958063b2d6edcdbab7da1abece58b251051338083296c468d5caafc2c249aba63f1a15011d8b8354e145937252520fe6a9632c146606a833bdeae9784c + languageName: node + linkType: hard + +"vscode-languageserver-textdocument@npm:^1.0.1, vscode-languageserver-textdocument@npm:^1.0.11, vscode-languageserver-textdocument@npm:^1.0.12, vscode-languageserver-textdocument@npm:^1.0.8": version: 1.0.13 resolution: "vscode-languageserver-textdocument@npm:1.0.13" checksum: 10c0/1de174f1de3bfa9e1660a4b3c1b1c711497196d761e33f4d00785fdbfc556ae5a1f440db04771cbc0f6bd66c2a6f303062bc17d8dc46e76820e39fa2d5626564 @@ -42418,14 +45047,32 @@ __metadata: languageName: node linkType: hard -"vscode-languageserver-types@npm:^3.17.3": +"vscode-languageserver-types@npm:3.18.0, vscode-languageserver-types@npm:^3.15.1, vscode-languageserver-types@npm:^3.16.0, vscode-languageserver-types@npm:^3.17.3, vscode-languageserver-types@npm:^3.17.5": version: 3.18.0 resolution: "vscode-languageserver-types@npm:3.18.0" checksum: 10c0/d8c51cd286cc55b4c1f333e87fa3cefc66d2aae8c65f6209e7ecf88032d3326308b333d62b05243c194be8a3304760445b87630b298116c6c612607f011b4de0 languageName: node linkType: hard -"vscode-uri@npm:^3.0.8": +"vscode-languageserver@npm:^9.0.0, vscode-languageserver@npm:^9.0.1": + version: 9.0.1 + resolution: "vscode-languageserver@npm:9.0.1" + dependencies: + vscode-languageserver-protocol: "npm:3.17.5" + bin: + installServerIntoExtension: bin/installServerIntoExtension + checksum: 10c0/8a0838d77c98a211c76e54bd3a6249fc877e4e1a73322673fb0e921168d8e91de4f170f1d4ff7e8b6289d0698207afc6aba6662d4c1cd8e4bd7cae96afd6b0c2 + languageName: node + linkType: hard + +"vscode-nls@npm:^5.0.0, vscode-nls@npm:^5.2.0": + version: 5.2.0 + resolution: "vscode-nls@npm:5.2.0" + checksum: 10c0/dc9e48f58ebbc807f435d351008813a2ea0c9432d51e778bcac9163c0642f929ddb518411ad654e775ce31e24d6acfa8fb7db8893c05b42c2019894e08b050f9 + languageName: node + linkType: hard + +"vscode-uri@npm:^3.0.2, vscode-uri@npm:^3.0.8, vscode-uri@npm:^3.1.0": version: 3.1.0 resolution: "vscode-uri@npm:3.1.0" checksum: 10c0/5f6c9c10fd9b1664d71fab4e9fbbae6be93c7f75bb3a1d9d74399a88ab8649e99691223fd7cef4644376cac6e94fa2c086d802521b9a8e31c5af3e60f0f35624 @@ -43244,6 +45891,13 @@ __metadata: languageName: node linkType: hard +"xxhash-wasm@npm:^1.1.0": + version: 1.1.0 + resolution: "xxhash-wasm@npm:1.1.0" + checksum: 10c0/35aa152fc7d775ae13364fe4fb20ebd89c6ac1f56cdb6060a6d2f1ed68d15180694467e63a4adb3d11936a4798ccd75a540979070e70d9b911e9981bbdd9cea6 + languageName: node + linkType: hard + "y18n@npm:^3.2.1 || ^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3" @@ -43286,6 +45940,37 @@ __metadata: languageName: node linkType: hard +"yaml-language-server@npm:~1.23.0": + version: 1.23.0 + resolution: "yaml-language-server@npm:1.23.0" + dependencies: + "@vscode/l10n": "npm:^0.0.18" + ajv: "npm:^8.17.1" + ajv-draft-04: "npm:^1.0.0" + ajv-i18n: "npm:^4.2.0" + prettier: "npm:^3.8.1" + request-light: "npm:^0.5.7" + vscode-json-languageservice: "npm:4.1.8" + vscode-languageserver: "npm:^9.0.0" + vscode-languageserver-textdocument: "npm:^1.0.1" + vscode-languageserver-types: "npm:^3.16.0" + vscode-uri: "npm:^3.0.2" + yaml: "npm:2.8.3" + bin: + yaml-language-server: bin/yaml-language-server + checksum: 10c0/d5ed44bb542ad9b9a38171983257f47c391120fb703e043395f3490c22d99f0807d90a0cdb7b4b7955f39557162936f41bd0d6f121c95b473bc1deac597c41a1 + languageName: node + linkType: hard + +"yaml@npm:2.8.3": + version: 2.8.3 + resolution: "yaml@npm:2.8.3" + bin: + yaml: bin.mjs + checksum: 10c0/ddff0e11c1b467728d7eb4633db61c5f5de3d8e9373cf84d08fb0cdee03e1f58f02b9f1c51a4a8a865751695addbd465a77f73f1079be91fe5493b29c305fd77 + languageName: node + linkType: hard + "yaml@npm:^1.10.0, yaml@npm:^1.5.0": version: 1.10.3 resolution: "yaml@npm:1.10.3" @@ -43293,7 +45978,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.3.1": +"yaml@npm:^2.3.1, yaml@npm:^2.8.3": version: 2.9.0 resolution: "yaml@npm:2.9.0" bin: @@ -43375,6 +46060,20 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^18.0.0": + version: 18.1.0 + resolution: "yargs@npm:18.1.0" + dependencies: + cliui: "npm:^9.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + string-width: "npm:^8.2.1" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^22.0.0" + checksum: 10c0/86052bbed90a9aaaaf1eabe004f47c7420e1b7f8d314170cfb0ad4721f518ec2fb7ba5a6daa20708b1595aba257f1554b2d5b3f5606356f86aa9dae18de0bec5 + languageName: node + linkType: hard + "yauzl@npm:^2.10.0": version: 2.10.0 resolution: "yauzl@npm:2.10.0" @@ -43418,7 +46117,7 @@ __metadata: languageName: node linkType: hard -"yocto-queue@npm:^1.0.0": +"yocto-queue@npm:^1.0.0, yocto-queue@npm:^1.2.1": version: 1.2.2 resolution: "yocto-queue@npm:1.2.2" checksum: 10c0/36d4793e9cf7060f9da543baf67c55e354f4862c8d3d34de1a1b1d7c382d44171315cc54abf84d8900b8113d742b830108a1434f4898fb244f9b7e8426d4b8f5 @@ -43508,7 +46207,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5": +"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5, zod@npm:^4.3.6": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 @@ -43544,7 +46243,7 @@ __metadata: languageName: node linkType: hard -"zwitch@npm:^2.0.0": +"zwitch@npm:^2.0.0, zwitch@npm:^2.0.4": version: 2.0.4 resolution: "zwitch@npm:2.0.4" checksum: 10c0/3c7830cdd3378667e058ffdb4cf2bb78ac5711214e2725900873accb23f3dfe5f9e7e5a06dcdc5f29605da976fc45c26d9a13ca334d6eea2245a15e77b8fc06e