diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e6ee96e..498754e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,6 +9,8 @@ on: - "mkdocs.yml" - "pyproject.toml" - "scripts/check_docs_i18n.py" + - "scripts/check_rendered_code_docs.py" + - "src/blueprinting/**" - "uv.lock" pull_request: branches: [main] @@ -18,6 +20,8 @@ on: - "mkdocs.yml" - "pyproject.toml" - "scripts/check_docs_i18n.py" + - "scripts/check_rendered_code_docs.py" + - "src/blueprinting/**" - "uv.lock" workflow_dispatch: @@ -61,6 +65,9 @@ jobs: - name: Build documentation run: uv run --no-dev --extra docs mkdocs build --strict + - name: Check rendered code documentation + run: uv run --no-dev --extra docs python scripts/check_rendered_code_docs.py + - name: Upload documentation preview artifact if: github.event_name == 'pull_request' || github.ref != 'refs/heads/main' uses: actions/upload-artifact@v7 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 278ede5..20f3b7e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -39,8 +39,12 @@ jobs: run: uv run pytest -m baseline_regression tests/regression test-suite: - name: Full test suite + name: Full test suite (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] steps: - name: Check out repository uses: actions/checkout@v6 @@ -53,7 +57,7 @@ jobs: enable-cache: true - name: Install Python - run: uv python install 3.12 + run: uv python install ${{ matrix.python-version }} - name: Install locked dependencies run: uv sync --locked @@ -61,27 +65,48 @@ jobs: - name: Lint supported architecture boundary run: >- uv run ruff check - src/blueprinting/__init__.py - src/blueprinting/__main__.py - src/blueprinting/cli - src/blueprinting/schema - src/blueprinting/workload - src/blueprinting/mapping - src/blueprinting/system - src/blueprinting/synthesizer - src/blueprinting/analysis - src/blueprinting/validation - src/blueprinting/application - src/blueprinting/workbench - tests/synthesizer - tests/analysis - tests/application - tests/regression - tests/workbench + src/blueprinting + tests + examples/calculon_calibration.py + scripts + + - name: Check source formatting + run: >- + uv run ruff format --check + src/blueprinting + tests + examples/calculon_calibration.py + scripts + + - name: Compile runtime type contracts (without mypy) + run: uv run python scripts/check_type_contracts.py - name: Run tests run: uv run pytest + static-typing: + name: Optional static typing layer + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install 3.12 + + - name: Install optional static-typing dependencies + run: uv sync --locked --group typing + + - name: Type-check Blueprinting source + run: uv run mypy src/blueprinting + package-contract: name: Base wheel contract runs-on: ubuntu-latest @@ -104,3 +129,26 @@ jobs: - name: Verify wheel contents and size run: uv run python scripts/check_wheel_contract.py dist/*.whl + + performance-data-contract: + name: Optional performance-data contract + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + + - name: Install Python + run: uv python install 3.12 + + - name: Install locked dependencies with performance-data support + run: uv sync --locked --extra performance-data + + - name: Run performance-data provider tests + run: uv run pytest tests/analysis/test_cost_model_providers.py diff --git a/AGENTS.md b/AGENTS.md index a6d21d6..05b2966 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,9 +173,13 @@ interconnect 与 system profile 位于 `src/blueprinting/system/`;canonical `src/blueprinting/synthesizer/`;cost/evidence analysis 进入 `src/blueprinting/analysis/`。不得新建平行表示栈。 - `SystemProfile` 是当前有限的 compute/memory/network evidence-bearing adapter,不得被描述成已经实现的完整 `ArchitectureBlueprint`;`src/blueprinting/types/system/` 只服务 legacy calculator,新代码不得依赖它。 -- `blueprinting.compiler` Python path 已硬切删除;历史 `compiler.*` canonical codec tag 作为 wire identity 保留, - 未经迁移 ADR 不得改写。 +- Canonical codec tag 必须位于与领域 ownership 一致的 `blueprinting.*` namespace;不得新增历史 package-derived + tag 或兼容 alias。Pre-graduation 阶段 nested type identity 不携带独立版本号,五层 IR root 统一使用 `0.0.0`; + 首次 schema increment 必须有 ADR、真实 migration、产物再生成方案和 conformance gate。 - IR 对象默认 frozen;语义字段使用 typed dataclass/enum/ID,不使用自由字典代替 contract。 +- 普通应用代码不得依赖 authoring decorator;canonical schema/dialect 作者只从 `blueprinting.schema.authoring` + 使用 `record/adt/variant`,pass/target extension 作者只从 `blueprinting.synthesizer.passes.authoring` + 使用 `derivation/relation/claim`。Codec registry、manifest 与 pass registry 是内部实现,不得从 package root 转发。 - 所有公共 derivation/transformation 和 verifier 必须有 positive、negative、round-trip 与 lineage 测试。 - Python 最低版本为 3.10;不得使用只在更高版本解析的语法,除非先更新 packaging contract。 - 修改后至少运行相关 pytest 与 Ruff;文档修改运行双语一致性检查和 `mkdocs build --strict`。 diff --git a/README.md b/README.md index 1de694c..3a5beac 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,15 @@ For development and documentation tooling: pip install -e ".[dev,docs]" ``` +Static source analysis is an independent optional layer: `pip install -e ".[typing]"`. Runtime type-contract checks do not require mypy. + ## Build the current Transformer workload blueprint ```python -from blueprinting.synthesizer.lowering import ( - DistributeTransformerTrainingPass, - PlanTransformerTrainingPass, -) from blueprinting.synthesizer.frontend import build_transformer_model_ir, synthesis_session_for from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerTrainingPass +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerTrainingPass from blueprinting.mapping import TransformerTrainingMappingSpec from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec @@ -101,11 +101,11 @@ result = PassManager().run( ), source, session=synthesis_session_for(model, workload, mapping), -) +).or_raise() portable_plan = result.ir for checkpoint in result.checkpoints: - print(checkpoint.pass_name, checkpoint.ir.digest) + print(checkpoint.record.pass_name, checkpoint.ir.digest) ``` The adapter reads the retained model/execution JSON presets in `data/`, then separates workload facts from the @@ -139,14 +139,8 @@ TP/PP/DP strategy exploration, and a read-only performance-evidence lab. The evi Vidur Phi-2/A100 records and compares exact GEMM samples with the analytical roofline on identical workload facts. Analysis runs outside the UI event loop, and failed candidates remain visible as structured diagnostics. -The existing Calculon Streamlit tools remain isolated as an optional legacy interface. Floating-point analysis is available in the primary NiceGUI workbench: - -```bash -uv sync --extra legacy-ui -uv run streamlit run streamlit_app.py -``` - Calculon remains an adjacent calibration utility and does not participate in the Blueprinting product analysis path. +Floating-point analysis is available directly in the primary NiceGUI workbench. ## Repository layout @@ -159,7 +153,7 @@ src/blueprinting/synthesizer/ # canonical IR, exact-work dialects, and verified src/blueprinting/analysis/ # evidence protocols, cost resolution, and projections src/blueprinting/application/ # framework-neutral analysis services and reports src/blueprinting/validation/ # external baselines and strict regression gates -src/blueprinting/workbench/ # NiceGUI workbench and legacy presentation adapters +src/blueprinting/workbench/ # NiceGUI workbench and presentation adapters data/evidence/ # optional external evidence, excluded from the base package tests/ # domain, derivation, application, and regression contracts @@ -176,8 +170,13 @@ explicit system, deployment mapping, and evidence snapshot. External oracles rem pytest ruff check src/ tests/ examples/calculon_calibration.py ruff format --check src/ tests/ examples/calculon_calibration.py +uv run python scripts/check_type_contracts.py +# Optional static layer: +uv sync --locked --group typing +uv run mypy src/blueprinting uv run python scripts/check_docs_i18n.py uv run mkdocs build --strict +uv run python scripts/check_rendered_code_docs.py ``` ## License diff --git a/data/examples/3072_t4_p64_d12_mbs4_full.json b/data/examples/3072_t4_p64_d12_mbs4_full.json index c22dea5..b250afc 100755 --- a/data/examples/3072_t4_p64_d12_mbs4_full.json +++ b/data/examples/3072_t4_p64_d12_mbs4_full.json @@ -1,7 +1,7 @@ { "num_procs": 3072, "tensor_par": 4, - "pipeline_par": 64, + "pipeline_par": 48, "data_par": 12, "sequence_par": true, "tensor_par_net": 0, @@ -24,4 +24,4 @@ "optimizer_offload": false, "training": true, "zero": 1 -} \ No newline at end of file +} diff --git a/data/examples/3072_t4_p64_d12_mbs4_full.toml b/data/examples/3072_t4_p64_d12_mbs4_full.toml index 3ff14d6..8862b68 100755 --- a/data/examples/3072_t4_p64_d12_mbs4_full.toml +++ b/data/examples/3072_t4_p64_d12_mbs4_full.toml @@ -1,6 +1,6 @@ num_procs = 3072 tensor_par = 4 -pipeline_par = 64 +pipeline_par = 48 data_par = 12 sequence_par = true tensor_par_net = 0 diff --git a/data/validation/baseline_regression_contract.json b/data/validation/baseline_regression_contract.json index a87e101..8ab1e42 100644 --- a/data/validation/baseline_regression_contract.json +++ b/data/validation/baseline_regression_contract.json @@ -1,13 +1,41 @@ { - "schema": "blueprinting.baseline-regression-contract.v1", + "schema": "blueprinting.baseline-regression-contract.v0", "training": { "baseline": "Calculon / SeqSel Table 5", + "report_schema": "blueprinting.calculon-calibration-experiment.v0", + "oracle": { + "name": "Calculon", + "package_version": "0.1.0", + "source_digest": "c72cf8a0a0fc9f1fb9813a2248747d6242bbc664b665abe4b5fc6b6b18f5927b" + }, + "inputs": { + "models": { + "megatron-22B.json": "fd62296fa7370d0e84291003fb1057f4131553c973f65954afaecf0dea1c3b25", + "gpt3-175B.json": "fabfc66b4a57d3c357d410232a9805b004bbee8f23abbc505c0d23594c208c11", + "turing-530B.json": "2e855371e0abe718b346ad3f876436d0f61953ba6e2c828e68be664596b83667", + "megatron-1T.json": "5d5c2c6678b044897b1ea70fbaf2ab4ded83c643c3998b7e9e26ce57ae2967da" + }, + "executions": { + "megatron-22B_full.json": "375e22afecdb4b24cf354b89ccce080c59e097ab8c85b5e73ecc142a120ef005", + "megatron-22B_seqsel.json": "94fc3a6b946fbcd19601410789690e68b36417032473f4cf65bd97e14d97fb74", + "gpt3-175B_full.json": "25ccadd0e39ba81f544ea3b0415189ea42102543d550cf725d8ae15ae99dbd81", + "gpt3-175B_seqsel.json": "99bd010ac34b52578a461aa877d208c65e777932e300b191d7c9e29d38cea6a8", + "turing-530B_full.json": "157ccecce654eb875b6a1682fca6bb2a10309961bc50382db56cfff54bd23bfe", + "turing-530B_seqsel.json": "2d6516a1ce9664951608239838572db5a0129ef794f90d4c46f5924a3221891a", + "megatron-1T_full.json": "24f738a12c5b1fc64b0c0c36bdfc70237032388bc0d383e0aac0d66ebb5b14e6", + "megatron-1T_seqsel.json": "7ba4278645e3ed2dbf26c4dddc007364a56fe495200061183a641de9c0dec9e4" + }, + "systems": { + "a100_80g.json": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "evidence_revision": "eb1eb9fcc4a6e414e85b0252c23ea9ad2730aae2", "case_count": 8, "budgets": { "workload_max_absolute_error_percent": 1e-9, "calculon_mean_absolute_error_percent": 1e-9, "calculon_max_absolute_error_percent": 1e-9, + "breakdown_max_absolute_error_percent": 1e-9, "paper_mean_absolute_error_percent": 3.7, "paper_max_absolute_error_percent": 8.9, "memory_max_absolute_error_bytes": 1.0 @@ -18,14 +46,14 @@ "paper_mean_absolute_error_percent": 3.654361437637248, "paper_max_absolute_error_percent": 8.874452316395821, "portable_digests": { - "seqsel-tab5/megatron-22B/full": "00517ca9fb4df346eebe6d4aa63543ca666a7af4", - "seqsel-tab5/megatron-22B/seqsel": "8eb389c417a9b33124997c21afd5c18597a39404", - "seqsel-tab5/gpt3-175B/full": "2ce66153c5c81ee97c6fa1e2bc14b9e2c0d844ff", - "seqsel-tab5/gpt3-175B/seqsel": "5c888d51fd8d5ffb5bb515ff797a9776a12cea6c", - "seqsel-tab5/turing-530B/full": "99c4b79b949fa4f58ffbd1cc98661d3b2101f5d6", - "seqsel-tab5/turing-530B/seqsel": "4a33b75f901df8c5d4948a8f44d501e8f695674e", - "seqsel-tab5/megatron-1T/full": "0e203dc354d91a45d8860c15aee999d57dbb5bbf", - "seqsel-tab5/megatron-1T/seqsel": "e7c5e86b0f7e9f9f8f3f47124fc643cf4a4d8e09" + "seqsel-tab5/megatron-22B/full": "5a96a71f806f1aede2be8280be8b8436bea3c51d", + "seqsel-tab5/megatron-22B/seqsel": "02857f01085962db40c46d18288d9a61680389b0", + "seqsel-tab5/gpt3-175B/full": "5d8df3e92f712d42df1c8c1c97fffbbb3b5cc9eb", + "seqsel-tab5/gpt3-175B/seqsel": "362754f4f37894aa7bd50eb8e8b7d0319a748b61", + "seqsel-tab5/turing-530B/full": "b3fa239a4ef8a29609d5afcdb58b66ac479efd60", + "seqsel-tab5/turing-530B/seqsel": "126da397b345f17b828170a70e811be98398421f", + "seqsel-tab5/megatron-1T/full": "db115adc9fbe30965631fc3122b1976c751c9b9a", + "seqsel-tab5/megatron-1T/seqsel": "a27fd7b19ea9a120427cbfe6b522d13e4e3a6fc9" } } }, @@ -33,13 +61,13 @@ "baseline": "Vidur Phi-2 / A100 / TP1 raw component-profile alignment", "validation_claim": "drift-detection-not-accuracy-validation", "fixture": "data/validation/vidur/phi2_a100_tp1", - "fixture_manifest_sha256": "24511af4979e32fdc0d1c610960f93e92ac816a2a5b30f30cf6015d2492b2c33", + "fixture_manifest_sha256": "89d507931b931c3bb1e84277a7bac637028ba3f8e80c842d1c2b554714c689a6", "source_repository": "https://github.com/microsoft/vidur", "source_revision": "8383d2935bc62723a212090baa9f98ada206fc14", "license_file": "LICENSE.vidur", "license_sha256": "7df20dcdf9197e9945c14858d41c60f11b52b93e5b69e2b63416b874d598d322", - "baseline_revision": "6a583f1c90969bd82ef82b8cf6dd26bcef914909", - "model_digest": "d67e201b24cc428ac0d3330d7fd597cfe90dcdf6", + "baseline_revision": "49f6d006968ffd30b465b3a67741a58053eb9e2b", + "model_digest": "5d48c3b4d4e8bae69e16b02675a0969f9bb81aca", "case_count": 3, "budgets": { "minimum_semantic_component_coverage": 0.75, @@ -54,8 +82,8 @@ "system_evidence_component_max_absolute_error_percent": 99.50036630036631, "cases": { "phi2-a100-tp1/decode/b1-c33": { - "distributed_digest": "3aaaddf8d05e49da9d675acc2b1dc0e252c4f1fc", - "portable_digest": "d96d85895fa4f19655b2dc0592b5dc402e1bfb0d", + "distributed_digest": "5427664e6e99885931f5f85f584e024287f37a88", + "portable_digest": "a5f111748cca9799c5878b1414fc63b4b749e53c", "component_coverage": 0.75, "baseline_comparable_block_seconds": 0.000147, "system_evidence_comparable_block_seconds": 0.00010824697435897437, @@ -64,8 +92,8 @@ "system_evidence_component_max_absolute_error_percent": 99.50036630036631 }, "phi2-a100-tp1/decode/b1-c129": { - "distributed_digest": "727acb5e4a18f54570180de6dfe7c6f28b634b92", - "portable_digest": "ce6e6d1fd1233134300445391f4cd33a4ae7dff3", + "distributed_digest": "c1fb781cb833250bbd1ed95c88f85b949a35a61a", + "portable_digest": "020a2870ec38c774feb4d33ef0fb250f6a22dff3", "component_coverage": 0.75, "baseline_comparable_block_seconds": 0.00015000000000000001, "system_evidence_comparable_block_seconds": 0.00010879564102564103, @@ -74,8 +102,8 @@ "system_evidence_component_max_absolute_error_percent": 99.50036630036631 }, "phi2-a100-tp1/prefill/b1-c128": { - "distributed_digest": "56e34687555489beae3d40b66deea30e529bde10", - "portable_digest": "71d41b98eb84dfc57ee3ec0e4614ce1a52622aef", + "distributed_digest": "7fd7bde9661c03f66806246744a4c268b2e69b61", + "portable_digest": "e87aaed848653b5e13bbe5048c828169b344499b", "component_coverage": 0.75, "baseline_comparable_block_seconds": 0.0002015, "system_evidence_comparable_block_seconds": 0.00023554310256410256, diff --git a/data/validation/vidur/phi2_a100_tp1/manifest.json b/data/validation/vidur/phi2_a100_tp1/manifest.json index c9967dc..109a761 100644 --- a/data/validation/vidur/phi2_a100_tp1/manifest.json +++ b/data/validation/vidur/phi2_a100_tp1/manifest.json @@ -1,5 +1,5 @@ { - "schema": "blueprinting.vidur-validation-slice.v1", + "schema": "blueprinting.vidur-validation-slice.v0", "source": { "repository": "https://github.com/microsoft/vidur", "revision": "8383d2935bc62723a212090baa9f98ada206fc14", diff --git a/docs/assets/architecture/implemented-derivation-path.svg b/docs/assets/architecture/implemented-derivation-path.svg index c67d466..b13aae7 100644 --- a/docs/assets/architecture/implemented-derivation-path.svg +++ b/docs/assets/architecture/implemented-derivation-path.svg @@ -64,7 +64,7 @@ - transformer-distribute-v2 + transformer-distribute ModelIR → primitive phases + local TP mesh + explicit collectives + structural recomputation DistributedTaskIR @@ -73,7 +73,7 @@ PassCheckpoint #1 - transformer-plan-work-v2 + transformer-plan-work DistributedTaskIR → exact workload facts + abstract resources/buffers + capability alternatives PortablePlanIR diff --git a/docs/assets/stylesheets/site.css b/docs/assets/stylesheets/site.css index f41abc2..1c32c54 100644 --- a/docs/assets/stylesheets/site.css +++ b/docs/assets/stylesheets/site.css @@ -1090,3 +1090,14 @@ body:has(.bp-home) .md-content { transition: none !important; } } + +/* Code-reference equations must stay readable on narrow review surfaces. */ +.md-typeset div.arithmatex { + overflow-x: auto; + overflow-y: hidden; + padding: 0.4rem 0.1rem; +} + +.md-typeset .doc-object { + scroll-margin-top: 4rem; +} diff --git a/docs/contributing/documentation.en.md b/docs/contributing/documentation.en.md index b219b59..6e17b66 100644 --- a/docs/contributing/documentation.en.md +++ b/docs/contributing/documentation.en.md @@ -16,8 +16,10 @@ docs/ │ ├── passes/ transformation contracts │ └── performance/ evidence and simulation contracts ├── experiments/ reproducible validation reports +├── reference/ source-generated IR/pass API and equations ├── project/ status, roadmap, decisions ├── contributing/ maintenance guides +├── javascripts/mathjax.js deterministic math rendering setup ├── overrides/home.html bilingual product landing page └── assets/ ├── architecture/ shared technical diagrams @@ -91,6 +93,21 @@ The English and Chinese pages are peers in structure and technical meaning. A ch `scripts/check_docs_i18n.py` enforces complete pairs, one H1, matching heading shapes, and canonical link usage. This gate is also what makes shared-asset fallback safe for prose. It cannot prove semantic equivalence, so reviewers still compare claims and status manually. +## Code API and pass theory + +Code reference pages use `mkdocstrings` directives and resolve objects directly from `src/`; do not paste generated signatures or source listings into Markdown. Canonical IR pages document the owning `stages//ir.py` module. Public pass pages document the concrete class from `stages//passes.py`, never a compatibility facade. + +Every exported canonical `DerivationPass` class must include in its class docstring: + +- the represented source/target boundary and explicit scope; +- symbols and rendered `$$...$$` core equations; +- enough intermediate reasoning to map each term to produced work, memory, communication, placement, or scheduling facts; +- primary research references when the implementation derives from published work; +- an explicit “internal contract; no paper claim” statement for mechanical/reference passes where inventing a citation would be misleading; +- the semantic facts preserved by the executable rule verifier and the behavior deliberately deferred to later stages. + +`tests/docs/test_code_documentation.py` discovers every class exported by the five stage `passes.py` modules. It fails if a pass lacks equations, provenance, API-page inclusion, or a bilingual page. Arithmatex converts formula blocks and MathJax performs browser rendering; after a strict build, inspect generated `reference/passes/index.html` for `arithmatex` containers and source anchors. + ## Status and decision updates A document that describes future architecture marks it **Planned** or **Contract Only** near the affected section. Only repository code plus proportionate tests may justify **Implemented**. The [implementation status](../project/status.md) is updated in the same change that connects or removes a capability. @@ -105,9 +122,10 @@ Install and validate with: uv sync --locked --no-dev --extra docs uv run --no-dev --extra docs python scripts/check_docs_i18n.py uv run --no-dev --extra docs mkdocs build --strict +uv run --no-dev --extra docs python scripts/check_rendered_code_docs.py ``` -Contributors who also run the formal-analysis test suite may omit `--no-dev`; the default development dependency group includes the test and legacy-workbench dependencies. +Contributors who also run the formal-analysis test suite may omit `--no-dev`; the default development group contains test and lint tools. Static analysis is deliberately optional and is installed with `uv sync --locked --group typing`. For focused local preview: @@ -131,7 +149,8 @@ Before review, inspect both locale routes, page-to-page language switching, navi - Figures have meaningful alt text and remain legible in light/dark contexts. - Reproducible results identify commands, inputs, revisions, and claim boundaries. - Design changes map to source/tests or explicitly state that no implementation exists. -- `check_docs_i18n.py` and `mkdocs build --strict` pass. +- Every public canonical pass has rendered equations, derivation reasoning, provenance, and generated API source. +- `check_docs_i18n.py`, `mkdocs build --strict`, and `check_rendered_code_docs.py` pass. - The landing page and ordinary documentation pages remain usable in both color schemes and at narrow widths. Documentation debt is handled like engineering debt: make the ownership boundary explicit, add a gate that detects regression, and remove the superseded source instead of maintaining ambiguous duplicates. diff --git a/docs/contributing/documentation.zh.md b/docs/contributing/documentation.zh.md index 4ade35b..90e76e3 100644 --- a/docs/contributing/documentation.zh.md +++ b/docs/contributing/documentation.zh.md @@ -16,8 +16,10 @@ docs/ │ ├── passes/ transformation contracts │ └── performance/ evidence and simulation contracts ├── experiments/ reproducible validation reports +├── reference/ 源码生成的 IR/Pass API 与公式 ├── project/ status, roadmap, decisions ├── contributing/ maintenance guides +├── javascripts/mathjax.js 确定性数学渲染配置 ├── overrides/home.html 双语产品首页 └── assets/ ├── architecture/ 共享技术图 @@ -91,6 +93,21 @@ Compiler 不得被描述为系统组件或顶层产品定义。产品方法是 `scripts/check_docs_i18n.py` 会强制完整 pair、唯一 H1、一致 heading shape 与 canonical link usage;正是这个 gate 使 shared-asset fallback 不会污染正文。它不能证明语义等价,因此 reviewer 仍需人工比较 claim 和 status。 +## 代码 API 与 Pass 理论文档 + +代码参考页使用 `mkdocstrings` directive,直接从 `src/` 解析对象;不得把生成后的 signature 或 source listing 复制到 Markdown。Canonical IR 页面记录所属 `stages//ir.py` module;公开 Pass 页面直接记录 `stages//passes.py` 中的具体 class,不通过 compatibility facade。 + +每个导出的 canonical `DerivationPass` class 必须在 class docstring 中包含: + +- 表示的 source/target boundary 与明确 scope; +- symbol 定义和可渲染的 `$$...$$` 核心公式; +- 足够的中间推导,使每一项能映射到产出的 work、memory、communication、placement 或 scheduling fact; +- 实现来自公开研究时引用 primary paper; +- 对 mechanical/reference Pass 明确写出“internal contract;不声明论文算法”,不得为了填引用而伪造来源; +- executable rule verifier 保持的 semantic fact,以及明确推迟到后续 stage 的行为。 + +`tests/docs/test_code_documentation.py` 会发现五层 `passes.py` 导出的全部 class;缺失公式、来源、API 页面收录或双语页面都会失败。Arithmatex 转换公式 block,MathJax 在浏览器端完成渲染;strict build 后应检查生成的 `reference/passes/index.html` 是否包含 `arithmatex` container 与源码 anchor。 + ## 状态与决策更新 描述未来架构的文档要在相应章节附近标记 **Planned** 或 **Contract Only**。只有仓库代码和相称 test 才能证明 **Implemented**。连接或移除 capability 的同一个 change 必须更新[实现状态](../project/status.md)。 @@ -105,9 +122,10 @@ Compiler 不得被描述为系统组件或顶层产品定义。产品方法是 uv sync --locked --no-dev --extra docs uv run --no-dev --extra docs python scripts/check_docs_i18n.py uv run --no-dev --extra docs mkdocs build --strict +uv run --no-dev --extra docs python scripts/check_rendered_code_docs.py ``` -同时运行形式化分析 test suite 的 contributor 可以去掉 `--no-dev`;default development dependency group 包含测试与 legacy workbench 依赖。 +同时运行形式化分析 test suite 的 contributor 可以去掉 `--no-dev`;default development group 包含测试与 lint 工具。Static analysis 被刻意设为可选层,通过 `uv sync --locked --group typing` 安装。 只预览单一语言时: @@ -131,7 +149,8 @@ BUILD_ONLY_LOCALE=zh uv run mkdocs serve - Figure 有有意义的 alt text,在 light/dark context 中均清晰。 - 可复现结果标明 command、input、revision 与 claim boundary。 - Design change 映射到 source/test,或明确声明尚无实现。 -- `check_docs_i18n.py` 与 `mkdocs build --strict` 通过。 +- 每个公开 canonical Pass 都有渲染公式、推导过程、来源与生成的 API source。 +- `check_docs_i18n.py`、`mkdocs build --strict` 与 `check_rendered_code_docs.py` 通过。 - 首页与普通文档页在两种 color scheme 和窄屏下均保持可用。 文档债务应像工程债务一样处理:明确 ownership boundary,增加能发现 regression 的 gate,并删除被取代 source,而不是维护有歧义的 duplicate。 diff --git a/docs/design/derivation-debugging.en.md b/docs/design/derivation-debugging.en.md new file mode 100644 index 0000000..3f2d5dd --- /dev/null +++ b/docs/design/derivation-debugging.en.md @@ -0,0 +1,58 @@ +# IR Derivation Visualization and Replay Audit + +IR Explorer turns one completed, verified formal derivation into an interactive, exportable, rebuildable debugging view. It consumes canonical snapshots, pass contracts and records, and typed lineage. It never mutates a representation or writes UI layout, predicted time, or cost back into an IR. + +## Unified five-stage view + +The workbench exposes five stage slots in canonical order: + +```text +ModelIR -> DistributedTaskIR -> PortablePlanIR -> ConcretePlanIR -> MachineIR +``` + +The current training production path produces the first three. `ConcretePlanIR` and `MachineIR` have no production producers, so ordinary runs show an explicit unavailable state. A valid five-stage debug bundle can be verified and opened by the same viewer. This consumer capability does not imply that target binding, scheduling, or emission exists. + +Each adapter projects only structure owned by its layer: Model operations, values, and SSA dataflow; Distributed tasks, values, mesh/ranks, sharding, and collectives; Portable tasks, buffers, exact workload, resource requirements, and dependencies; Concrete commands, devices/queues, buffer allocation, and synchronization; and Machine sections, instructions, entry points, and dependencies. + +Every layer also has two read-only textual forms. The short form is typed pseudo-syntax for following a derivation: it retains the schema, interface, key bindings, and layer-defining entities while compressing repeated tasks. The detailed form enumerates canonical typed fields such as stable IDs, dependencies, workload facts, resource and implementation requirements, placement, ABI, and lineage. Both are rebuilt from the immutable IR. Neither is a new wire format nor a replacement for canonical JSON and its digest. + +Each layer first explains the question it answers, the current snapshot result, and the semantics it does not own. The structure view does not use a semantically arbitrary force layout; it uses a stable two-dimensional semantic matrix. Columns are derivation phases ordered from left to right, rows are fixed `subsystem × entity kind` lanes, and missing combinations remain empty. Distributed and portable Transformer views can therefore compare attention, MLP, compute, and collective work across phases on consistent horizontal lanes. Structural dependencies are subdued by default and emphasized only for the hovered adjacency. Semantic-group and search controls expose a local expansion, with at most 1000 entities available for inspection. + +Adjacent boundaries default to a three-column `Source | Pass | Target` lowering table. Source and Pass span one rule instance while Target lists each expanded semantic group. One outer rounded `span` carries each complete expression and wraps with its text; continuous nested spans segment the name, parameters, and punctuation. Structure, type, topology, exact workload, and mapping parameters use distinct semantic colors, for example `[MLP.forward([ranks=2,][ops=4304896])]`. Both levels use inline wrapping and `box-decoration-break: clone`, so each visual line's highlight follows its text instead of creating a large cell-wide border. Entity type controls the name and outer base color but is not rendered as a separate head. + +Short expressions do not display long hashes from content-addressed IDs directly. For unnamed `value:*`, `node:*`, and `buffer:*` entities, the derived view assigns snapshot-local aliases such as `value#1`, `node#1`, and `buffer#1` in canonical graph order. These aliases never enter canonical IR, lineage, or digests; full stable IDs remain available to search, the entity inspector, mapping audit, and debug bundles. + +Source and Target contain only parameters owned by that stage: Model shape/dtype, Distributed logical ranks/sharding/collective, Portable exact operations/bytes, Concrete placement/queue/implementation, and Machine opcode/operands. The middle Pass expression renders `.(relation=..., cardinality=N → M)` with its typed signature and rewrite summary as supporting text. Rule identity and signature come from the typed pass contract; observed cardinality comes from canonical lineage evidence. Raw canonical-ID mappings and the complete pass contract live in an expandable audit section. All grouping is presentation only and creates no canonical entity. + +## Adjacent-stage correspondence + +Cross-stage relations are accepted by `PassManager` only after the commit-gate `TransitionVerifier` resolves target `Lineage.sources`, checks `Lineage.kind` and `Lineage.transform` against the declared pass rule, and runs that rule's executable predicate. `source_value`, `source_buffer`, and `source_command` are consistency checks rather than replacement truth. Display names, container positions, and generation order are never mapping evidence. + +Every adjacent boundary likewise has short and detailed lowering forms. The short form leads with the pass input/output schemas, required bindings and analyses, produced and preserved analyses, mutation model, verification, determinism and seed policy, and rewrite rules. The detailed form adds source and target digests, the transaction commit gate, analysis invalidation, and each transform's `signature / rewrite / preserves / introduces / forbids` semantics plus lineage evidence. These rules are declared by the synthesizer's typed `PassRule` contract and travel in the debug bundle; the frontend does not infer them from names. An undeclared transform must be rendered as lineage-only evidence. The correspondence table is grouped evidence for these rules; it does not define lowering semantics. + +Each adjacent boundary also provides a relation table and: + +- source and target coverage; +- 1:1, 1:N, N:1, and N:M cardinality; +- generated, missing, dangling, and explicit-source mismatch counts; +- pass schemas, binding and analysis requirements, verification policy, and host-side duration. + +In addition to generic checks, current rules execute named claims for bound tensor types, roles, exact local buffer sizes, producer/consumer links, operation identity, logical ranks, exact workload facts, dependency topology, buffer uses, and selected implementations. Each accepted relation carries the claim evidence that was actually executed; relation discovery alone is not counted as verification. + +Declared-rule failures are blocking transaction diagnostics: analysis products, observers, and checkpoints are not published. The `TransitionReport` distinguishes `structural_only`, `canonical_conformant`, and `relation_verified`, and stores canonical-normalizer conformance separately from per-relation evidence. IR Explorer exposes both facts instead of presenting reconstruction equality as a semantic proof. Every cross-stage pass must register independent executable relation invariants and a complete normalizer. An unchanged same-stage pass may be `structural_only`; any changed transition without complete executable evidence fails before commit. There is no successful unverified transition. + +## Derived overlays + +Canonical topology is always the base graph. Portable task cost is an opt-in overlay tagged with provider and revision. Future `TimingProjection`, simulation traces, or observations may implement the same overlay protocol, but must be addressed by IR digest and stable entity ID. An overlay cannot add dependencies, change workload facts, or affect a canonical digest. + +## Trace and debug bundle + +`DerivationTrace` is an application-level derived view containing stage instances, branches, canonical snapshots, pass metadata, boundary mappings, and optional overlays. Training produces one chain; inference can record prefill and representative final-decode branches under a shared ModelIR. + +`blueprinting.derivation-debug-bundle.v0` is deterministic JSON. Import re-runs canonical decoding, digest verification, structural verification, stage/schema checks, parent-digest checks, and adjacency checks. Bundles are limited to 50 MiB. This is a single-run replay format, not a canonical representation, `TimelineBundle`, or cross-run diff format. + +## Current boundary + +The first version does not pause or single-step `PassManager`, continue lowering from a UI-edited snapshot, or compare two runs. Breakpoints, runtime-profiler correlation, Concrete producers, Machine emitters, and simulation traces still require separate contracts and implementations. + +See [ADR-0003](../project/adr/0003-derivation-debug-trace.md) for the accepted decision. diff --git a/docs/design/derivation-debugging.zh.md b/docs/design/derivation-debugging.zh.md new file mode 100644 index 0000000..61b3bbf --- /dev/null +++ b/docs/design/derivation-debugging.zh.md @@ -0,0 +1,58 @@ +# IR 推导可视化与回放审计 + +IR Explorer 把一次已经完成验证的形式化推导转换为可交互、可导出且可重新构建的调试视图。它消费 canonical snapshot、pass contract/record 和 typed lineage;不会修改 representation,也不会把界面布局、预测时间或 cost 写回 IR。 + +## 五层统一视图 + +工作台按 canonical 顺序提供五个 stage slot: + +```text +ModelIR -> DistributedTaskIR -> PortablePlanIR -> ConcretePlanIR -> MachineIR +``` + +当前 training production path 产生前三层。`ConcretePlanIR` 与 `MachineIR` 没有 production producer,因此正常运行时显示明确的未生成状态;合法的五层调试包可以验证后导入同一浏览框架。这个 consumer 能力不表示 target binding、scheduler 或 emitter 已实现。 + +每层 adapter 只投影本层已经拥有的结构:Model 的 operation/value 和 SSA dataflow;Distributed 的 task/value、mesh/rank、sharding 与 collective;Portable 的 task/buffer、精确 workload、resource requirement 与 dependency;Concrete 的 command、device/queue、buffer allocation 与 synchronization;Machine 的 section、instruction、entry point 与 dependency。 + +每层同时提供两种只读文本表达。Short form 是面向推导阅读的 typed pseudo-syntax:保留 schema、interface、关键 binding 和本层核心实体,但压缩重复 task。Detailed form 枚举本层 canonical typed 字段:stable ID、dependency、workload facts、resource/implementation requirement、placement、ABI 和 lineage 等。它们都由 immutable IR 重建,不是新的 wire format,也不能替代 canonical JSON/digest。 + +单层视图默认先解释该层回答的问题、当前 snapshot 结果和不拥有的语义。结构图不使用无语义的 force layout,而是采用稳定的二维语义矩阵:列从左到右对应 derivation phase,行固定对应 `subsystem × entity kind`,缺失组合保留为空白。distributed/portable Transformer 因此能在同一水平泳道上比较不同阶段的 attention、MLP、compute 与 collective;结构依赖默认淡化,仅在悬停时强调邻接关系。用户可以通过 semantic group 或搜索展开局部;单次渲染最多检查 1000 个实体。 + +相邻 boundary 默认使用 `Source | Pass | Target` 三列 lowering 对应表。Source 与 Pass 按规则实例纵向合并,Target 逐行显示展开后的语义组。每个完整表达式由一个随文字换行的外层圆角 `span` 承载,内部 name、参数和括号使用连续的嵌套 `span` 分块;结构、类型、拓扑、精确 workload 与映射参数使用不同语义色,例如 `[MLP.forward([ranks=2,][ops=4304896])]`。内外 span 都使用 inline 换行和 `box-decoration-break: clone`,因此每个视觉行的高亮仅跟随文字,不会生成覆盖整个单元格的大边框。Entity type 决定 name 和外层基色,但不显示为独立的 head。 + +Short expression 不直接展示内容寻址 ID 的长 hash。对于没有语义名称的 `value:*`、`node:*` 和 `buffer:*`,派生视图按照 snapshot 中的 canonical 顺序分配 `value#1`、`node#1`、`buffer#1` 形式的本地别名。别名不进入 canonical IR、lineage 或 digest;完整 stable ID 仍用于搜索、entity inspector、mapping audit 和调试包。 + +Source/Target 只将该层拥有的主要参数编码进表达:Model 的 shape/dtype、Distributed 的 logical ranks/sharding/collective、Portable 的 exact operations/bytes、Concrete 的 placement/queue/implementation,以及 Machine 的 opcode/operands。中间 Pass 表达用 `.(relation=..., cardinality=N → M)`描述变换,typed signature 和 rewrite 摘要作为辅助信息。规则身份和 signature 来自 typed pass contract,运行基数来自 canonical lineage evidence。原始 canonical ID 映射和完整 pass contract 收纳在可展开审计区。所有聚合只改变 presentation,不创建 canonical entity。 + +## 相邻层映射 + +跨层关系只有在 commit gate 的 `TransitionVerifier` 解析目标实体的 `Lineage.sources`、根据 pass rule 检查 `Lineage.kind`/`Lineage.transform` 并执行规则 predicate 后才会被 `PassManager` 接受。`source_value`、`source_buffer` 和 `source_command` 用于交叉检查,不替代 lineage。Display name、容器位置和生成顺序都不是映射依据。 + +每个相邻 boundary 同样提供 Short / Detailed lowering 表达。Short form 首先显示 pass 的 input/output schema、required binding/analysis、produced/preserved analysis、mutation model、verification、determinism/seed 和 rewrite rule。Detailed form 进一步显示 source/target digest、transaction commit gate、analysis invalidation,以及每条 transform 的 `signature / rewrite / preserves / introduces / forbids` 语义和 lineage evidence。这些规则由 synthesizer 的 typed `PassRule` contract 声明并随调试包传递,不由前端根据名字猜测;没有声明的 transform 必须显示为 lineage-only evidence。对应表只是规则的分组证据,不定义 lowering 语义。 + +每个相邻 boundary 还提供关系表和以下统计: + +- source/target coverage; +- 1:1、1:N、N:1 与 N:M 基数; +- generated、missing、dangling 与 explicit-source mismatch; +- pass schema、binding/analysis requirement、verification policy 与 host-side duration。 + +通用检查之外,当前规则会执行具名 claim,覆盖绑定后的 tensor type、role、精确 local buffer size、producer/consumer、operation identity、logical rank、精确 workload fact、dependency topology、buffer use 和 selected implementation。每条通过的 relation 都携带实际执行过的 claim evidence;仅发现 lineage relation 不计为 verified。 + +已声明规则的失败是阻断式 transaction diagnostic:analysis product、observer 和 checkpoint 都不会发布。`TransitionReport` 区分 `structural_only`、`canonical_conformant` 与 `relation_verified`,并将 canonical normalizer conformance 与逐 relation evidence 分开保存。IR Explorer 会同时展示两者,不把 reconstruction equality 表述成 semantic proof。所有跨层 pass 都必须注册独立 executable relation invariant 与完整 normalizer;未改变内容的同层 pass 可以是 `structural_only`,任何发生变化但没有完整 executable evidence 的 transition 都会在 commit 前失败。系统不存在成功的 unverified transition。 + +## Derived overlay + +Canonical topology 始终是底图。Portable task cost 通过默认关闭的 overlay 显示,并携带 provider 与 revision。未来 `TimingProjection`、simulation trace 或 observation 可以实现同一 overlay protocol,但必须以 IR digest 和 stable entity ID 寻址。Overlay 不能增加 dependency、改变 workload fact 或影响 canonical digest。 + +## Trace 与调试包 + +`DerivationTrace` 是 application 层派生视图。它保存 stage instance、branch、canonical snapshot、pass metadata、boundary mapping 和可选 overlay。训练产生一条 chain;推理可以在共享 ModelIR 下分别记录 prefill 与 representative final-decode branch。 + +`blueprinting.derivation-debug-bundle.v0` 是确定性 JSON 调试包。导入会重新执行 canonical decode、digest、verifier、stage/schema、父 digest 和相邻 boundary 检查。包大小限制为 50 MiB。它用于一次运行的离线回放,不是新的 canonical representation、`TimelineBundle` 或跨运行 diff 格式。 + +## 当前边界 + +第一版不暂停或单步执行 `PassManager`,不允许在 UI 中修改 snapshot 后继续 lowering,也不比较两次运行。真正的 breakpoint、runtime profiler correlation、Concrete producer、Machine emitter 和 simulation trace 仍需独立 contract 与实现。 + +该设计决策见 [ADR-0003](../project/adr/0003-derivation-debug-trace.md)。 diff --git a/docs/design/index.en.md b/docs/design/index.en.md index 189b1f2..512b586 100644 --- a/docs/design/index.en.md +++ b/docs/design/index.en.md @@ -74,7 +74,7 @@ ConcretePlanIR + target plugin Simulation is the primary exploration path. Program emission is optional and can arrive after hardware/ABI maturity. When both exist, they share command IDs, dependencies, queues, synchronization, and buffers, so measurements can validate the plan that was actually simulated. -The current v1 schema implements only a generic device/queue/buffer/command scaffold. It has no production producer, route or resource-occupancy semantics, or typed target extension. It is therefore an **experimental contract**, not a frozen cross-target ABI. +The current v1 schema includes the common device/queue/buffer/command envelope, typed queue-order and slot/dataflow extensions, target verifiers, and two deterministic virtual reference binders. It still has no production target plugin, resource scheduler, occupancy model, or hardware-legality proof. It is therefore an **experimental contract**, not a frozen cross-target ABI. ## Timeline as a staged product diff --git a/docs/design/index.zh.md b/docs/design/index.zh.md index 246c565..b9ba8cd 100644 --- a/docs/design/index.zh.md +++ b/docs/design/index.zh.md @@ -74,7 +74,7 @@ ConcretePlanIR + target plugin Simulation 是主要 exploration path;program emission 是可选能力,可以在 hardware/ABI 成熟后再出现。当二者都存在时,共享 command ID、dependency、queue、synchronization 与 buffer,使 measurement 能验证实际被 simulation 的 plan。 -当前 v1 schema 只实现通用 device/queue/buffer/command 骨架,尚无 production producer、route/resource-occupancy semantic 或 typed target extension。因此它是 **experimental contract**,不是已经冻结的跨 target ABI。 +当前 v1 schema 已包含通用 device/queue/buffer/command envelope、typed queue-order/slot-dataflow extension、target verifier 和两个 deterministic virtual reference binder;但仍无 production target plugin、resource scheduler、occupancy model 或硬件 legality proof。因此它是 **experimental contract**,不是已经冻结的跨 target ABI。 ## Timeline 是阶段性产品 diff --git a/docs/design/ir/index.en.md b/docs/design/ir/index.en.md index 25d006e..198e649 100644 --- a/docs/design/ir/index.en.md +++ b/docs/design/ir/index.en.md @@ -33,6 +33,10 @@ payload Entities use stable typed IDs. Decomposition records one-to-many lineage; fusion records many-to-one lineage. Snapshots are immutable or transactionally isolated. Types, effects, dependencies, memory semantics, and compatibility-relevant extensions use typed fields rather than free-form dictionaries. +## Python definition sites + +The five layers are defined in `src/blueprinting/synthesizer/stages//ir.py`; transformations producing each layer are defined directly in the adjacent `passes.py`. The old `synthesizer.ir` and `synthesizer.lowering` compatibility paths are gone, including from internal baseline adapters. See [Python Algebraic IR Authoring](python-algebra.md). + ## Ownership summary | Layer | Owns | Must not own | @@ -63,9 +67,12 @@ Every mature canonical contract must verify schema identity, ID uniqueness, refe ## Schema evolution -A backward-compatible addition increments the minor schema version and is guarded by a feature. A breaking change increments the major version or supplies an explicit upgrader. Serialized package paths are implementation details and are not schema identities. Internal `1.0.0` identifies a serialization schema only; public stability also requires producer, independent consumer, negative-test, migration, and cross-target conformance gates. +The codec exposes duplicate-safe raw parsing, and `SchemaMigrationRegistry` can register deterministic, acyclic, uniquely resolved version steps. Explicit migrated loads verify the source digest, every intermediate snapshot, the final digest, and the ordered migration IDs. A synthetic test schema exercises chaining, ambiguity rejection, no-op loading, and tamper rejection. + +All five IR roots are currently at `0.0.0`. Canonical record and ADT identities are semantic, versionless names; they do not maintain independent component counters. The production migration registry is empty until a schema is graduated and a real compatibility boundary exists. Snapshots missing required features are still rejected. ## Reference pages - [Model and Distributed IR](model-distributed.md) defines target-neutral program and logical-distribution semantics. - [Planning and Execution IR](planning-execution.md) defines portable planning, the target-binding gate, concrete commands, MachineIR, and derived products. +- [Python Algebraic IR Authoring](python-algebra.md) defines source layout, record/ADT deriving, and the explicit semantic boundary. diff --git a/docs/design/ir/index.zh.md b/docs/design/ir/index.zh.md index 5eca4cb..f709440 100644 --- a/docs/design/ir/index.zh.md +++ b/docs/design/ir/index.zh.md @@ -33,6 +33,10 @@ payload Entity 使用稳定 typed ID。Decomposition 记录 one-to-many lineage;fusion 记录 many-to-one lineage。Snapshot 必须 immutable 或 transactionally isolated。Type、effect、dependency、memory semantic 和影响兼容性的 extension 使用 typed field,而不是 free-form dictionary。 +## Python 定义位置 + +五层的真实定义分别位于 `src/blueprinting/synthesizer/stages//ir.py`,产生该层的变换直接定义在相邻的 `passes.py`。旧的 `synthesizer.ir` 与 `synthesizer.lowering` compatibility path 已完全删除,内部 baseline adapter 也直接导入所属 stage。详见 [Python 代数化 IR 编写约定](python-algebra.md)。 + ## 所有权摘要 | 层 | 拥有 | 不得拥有 | @@ -63,9 +67,12 @@ Entity 使用稳定 typed ID。Decomposition 记录 one-to-many lineage;fusion ## Schema 演进 -Backward-compatible addition 提升 minor schema version 并由 feature 保护。Breaking change 提升 major version 或提供显式 upgrader。Serialized package path 是实现细节,不是 schema identity。内部 `1.0.0` 只标识 serialization schema;public stability 还需要 producer、独立 consumer、negative test、migration 与 cross-target conformance Gate。 +Codec 提供 duplicate-safe raw parsing;`SchemaMigrationRegistry` 可以注册 deterministic、无环且路径唯一的版本步骤。显式 migrated load 会验证 source digest、每个中间 snapshot、最终 digest 与有序 migration ID。Synthetic test schema 覆盖链式迁移、歧义拒绝、no-op load 与篡改拒绝。 + +五层 IR root 当前统一为 `0.0.0`。Canonical record 与 ADT identity 使用无版本的语义名,不各自维护 component counter。Production migration registry 在 schema graduation 并出现真实 compatibility boundary 前保持为空。缺少 required feature 的 snapshot 仍会被拒绝。 ## 参考页面 - [模型与分布式 IR](model-distributed.md)定义 target-neutral program 和 logical distribution semantic。 - [规划与执行 IR](planning-execution.md)定义 portable planning、target-binding gate、concrete command、MachineIR 和 derived product。 +- [Python 代数化 IR 编写约定](python-algebra.md)定义源码布局、record/ADT deriving 和显式语义边界。 diff --git a/docs/design/ir/model-distributed.en.md b/docs/design/ir/model-distributed.en.md index 9328e09..4738212 100644 --- a/docs/design/ir/model-distributed.en.md +++ b/docs/design/ir/model-distributed.en.md @@ -46,6 +46,7 @@ DistributedTaskIR ├── mesh: LogicalMesh ├── values: DistributedValue[] ├── tasks: DistributedTask[] +│ └── body: LocalCompute | Collective | PointToPoint | Reshard | Control ├── inputs / outputs ├── attributes └── lineage to ModelIR @@ -53,6 +54,8 @@ DistributedTaskIR Communication operations retain logical semantics: participants, collective kind, reduction, tensor/value relation, and exact logical message bytes where derivable. +`DistributedTask` is the shared graph-node envelope, while mutually exclusive semantics live in its `body` ADT. It no longer uses `kind + Optional collective/peer_transfer`, so constructor choice establishes communication-metadata presence instead of deferring invalid combinations to the verifier. + ### Forbidden information This IR cannot name CUDA, ROCm, LPU, NCCL, physical routes, physical device IDs, queue assignments, absolute timestamps, or target-specific latency. diff --git a/docs/design/ir/model-distributed.zh.md b/docs/design/ir/model-distributed.zh.md index 8f84c64..6bcd3c8 100644 --- a/docs/design/ir/model-distributed.zh.md +++ b/docs/design/ir/model-distributed.zh.md @@ -46,6 +46,7 @@ DistributedTaskIR ├── mesh: LogicalMesh ├── values: DistributedValue[] ├── tasks: DistributedTask[] +│ └── body: LocalCompute | Collective | PointToPoint | Reshard | Control ├── inputs / outputs ├── attributes └── lineage to ModelIR @@ -53,6 +54,8 @@ DistributedTaskIR Communication operation 保留 logical semantic:participant、collective kind、reduction、tensor/value relation,以及可推导时的 exact logical message bytes。 +`DistributedTask` 是统一图节点 envelope;互斥语义位于 `body` ADT,不再使用 `kind + Optional collective/peer_transfer`。因此 communication metadata 的存在性由 constructor 保证,而不是等到 verifier 才发现非法组合。 + ### 禁止的信息 这一层不能命名 CUDA、ROCm、LPU、NCCL、physical route、physical device ID、queue assignment、absolute timestamp 或 target-specific latency。 diff --git a/docs/design/ir/parallel-strategy.en.md b/docs/design/ir/parallel-strategy.en.md new file mode 100644 index 0000000..226e46c --- /dev/null +++ b/docs/design/ir/parallel-strategy.en.md @@ -0,0 +1,116 @@ +# Typed Transformer Parallel Strategies + +This page defines how Blueprinting maps Megatron-style tensor, pipeline, and data parallelism into formal-analysis values. These strategies are target-neutral logical choices, not physical GPU placements or NCCL algorithm selections. + +## Algebraic data model + +`TransformerTrainingMappingSpec` stores `parallelism` as its canonical pattern-matchable structure. `from_mapping` is the boundary adapter for flat external configuration keys; degree-named properties are derived projections and are not a second serialized contract: + +```text +TransformerTrainingParallelism +├── tensor: TensorParallel(degree, communication) +├── pipeline: PipelineParallel(degree, schedule) +├── data: DataParallel(degree, optimizer_sharding) +└── recompute: RecomputePolicy + +PipelineSchedule = + SingleStage + | OneForwardOneBackward + | InterleavedOneForwardOneBackward(virtual_stages) + | ForwardOnly +``` + +Training and inference use different top-level records. Inference uses `ReplicaParallel` so independent serving replicas are not mislabeled as training data parallelism with gradient synchronization. + +Passes destructure the strategy with structural pattern matching: + +```python +match mapping.parallelism: + case TransformerTrainingParallelism( + tensor=TensorParallel(degree=tp, communication=communication), + pipeline=PipelineParallel(degree=pp, schedule=schedule), + data=DataParallel(degree=dp), + recompute=recompute, + ): + ... +``` + +Adding a schedule constructor makes omitted interpreters and lowerings visible to the mypy `exhaustive-match` gate. + +## Composition and divisibility laws + +Let the TP, PP, and DP degrees be `t`, `p`, and `d`: + +```text +world_size = t × p × d +local_batch = global_batch / d +microbatch_count = global_batch / (d × microbatch_size) +blocks_per_pipeline_stage = transformer_blocks / p +blocks_per_virtual_chunk = transformer_blocks / (p × v) +``` + +The current static planner requires every division above to be integral and requires `v` to divide the block count of each physical pipeline stage. It does not silently repair an invalid strategy with padding or uneven stages. + +## Tensor-parallel mapping + +For `Y[M,K] = X[M,N] W[N,K]`, partitioning the output or contraction dimension with degree `t` gives ideal local GEMM work: + +```text +F_local = 2 M N K / t +``` + +Column- and row-parallel linear layers alternate inside the block so selected intermediates remain sharded. Reconstructing a semantic boundary produces an explicit all-reduce, or reduce-scatter plus all-gather for sequence-parallel boundaries. RS+AG requires: + +```text +sequence_length mod t = 0 +local_sequence = sequence_length / t +``` + +`DistributedTaskIR` currently materializes one local TP block, so its `LogicalMesh` contains only the `tp` axis. Typed program semantics retain PP/DP choices and iteration analysis composes them later. A full-model cross-stage P2P task graph is not implemented and cannot be inferred from the local graph. + +## Pipeline-schedule mapping + +| Constructor | Meaning | Current consumer | +|---|---|---| +| `SingleStage` | `p = 1`; no pipeline | training / inference | +| `OneForwardOneBackward` | synchronous non-interleaved 1F1B | training iteration analysis | +| `InterleavedOneForwardOneBackward(v)` | `v` virtual chunks per physical stage | training iteration analysis | +| `ForwardOnly` | static inference forward pipeline | inference mapping contract | + +The current 1F1B composition uses forward and backward critical paths derived from the portable block plan: + +```text +T_chunk = T_forward_chunk + T_backward_chunk +n_bubble = (p - 1) + extra_interleaving_bubbles +T_bubble = n_bubble × T_chunk - T_imbalance_correction +``` + +When `microbatch_count mod p != 0`: + +```text +extra_interleaving_bubbles = (v - 1) × (p - microbatch_count mod p) +``` + +This is an explicit analytical schedule contract, not an execution fact in `PortablePlanIR`. A future concrete scheduler must re-establish the schedule with a command DAG, P2P dependencies, and resource conflicts. + +## Primary references and scope + +- Shoeybi et al., [Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism](https://arxiv.org/abs/1909.08053): column/row intra-layer Transformer tensor parallelism. +- Narayanan et al., [Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM](https://arxiv.org/abs/2104.04473): TP × PP × DP composition, 1F1B, and interleaved pipeline scheduling. +- Huang et al., [GPipe](https://arxiv.org/abs/1811.06965): the microbatch pipeline and basic partition/bubble model. +- Korthikanti et al., [Reducing Activation Recomputation in Large Transformer Models](https://arxiv.org/abs/2205.05198): sequence parallelism and selective activation recomputation. + +The papers motivate algorithms and formulas; they do not prove this implementation correct. Repository tests independently constrain divisibility, work conservation, lineage, the Calculon oracle, and regression digests. + +## Source and tests + +| Concern | Location | +|---|---| +| typed strategy and schedule ADT | `src/blueprinting/mapping/transformer.py` | +| TP block work algebra | `src/blueprinting/synthesizer/dialects/transformer/training.py` | +| pattern-matching derivation | `src/blueprinting/synthesizer/dialects/transformer/training_derivation.py` | +| DistributedTask pass contract | `src/blueprinting/synthesizer/stages/distributed/passes.py` | +| PortablePlan pass contract | `src/blueprinting/synthesizer/stages/portable_plan/passes.py` | +| mapping/ADT tests | `tests/analysis/test_domain_contracts.py` | +| work and lineage tests | `tests/synthesizer/test_transformer_training.py` | +| Calculon alignment gate | `tests/validation/test_calculon.py`, `tests/regression/` | diff --git a/docs/design/ir/parallel-strategy.zh.md b/docs/design/ir/parallel-strategy.zh.md new file mode 100644 index 0000000..9cb4ab5 --- /dev/null +++ b/docs/design/ir/parallel-strategy.zh.md @@ -0,0 +1,118 @@ +# Transformer 并行策略的类型化表示 + +本页定义 Blueprinting 如何把 Megatron 风格的 tensor、pipeline、data parallelism 映射到形式化分析对象。这里的策略是 target-neutral 的逻辑选择,不是物理 GPU placement,也不是 NCCL algorithm 选择。 + +## 代数数据结构 + +`TransformerTrainingMappingSpec` 直接把 `parallelism` 保存为 canonical、可模式匹配的结构。`from_mapping` 是扁平外部配置 key 的 boundary adapter;degree 命名的 property 只是 derived projection,不是第二套 serialized contract: + +```text +TransformerTrainingParallelism +├── tensor: TensorParallel(degree, communication) +├── pipeline: PipelineParallel(degree, schedule) +├── data: DataParallel(degree, optimizer_sharding) +└── recompute: RecomputePolicy + +PipelineSchedule = + SingleStage + | OneForwardOneBackward + | InterleavedOneForwardOneBackward(virtual_stages) + | ForwardOnly +``` + +训练与推理使用不同的顶层 record。推理使用 `ReplicaParallel`,避免把相互独立的 serving replica 错叫成带 gradient synchronization 的 training data parallelism。 + +Pass 通过结构化模式匹配解构策略: + +```python +match mapping.parallelism: + case TransformerTrainingParallelism( + tensor=TensorParallel(degree=tp, communication=communication), + pipeline=PipelineParallel(degree=pp, schedule=schedule), + data=DataParallel(degree=dp), + recompute=recompute, + ): + ... +``` + +增加新的 schedule constructor 后,mypy 的 `exhaustive-match` gate 会迫使 interpreter、lowering 和文档处理新分支。 + +## 组合与整除约束 + +设 TP、PP、DP degree 分别为 `t`、`p`、`d`: + +```text +world_size = t × p × d +local_batch = global_batch / d +microbatch_count = global_batch / (d × microbatch_size) +blocks_per_pipeline_stage = transformer_blocks / p +blocks_per_virtual_chunk = transformer_blocks / (p × v) +``` + +当前静态 planner 要求上述除法均为整数,并要求 `v` 整除每个 physical pipeline stage 的 block 数。它不会通过 padding 或不均匀 stage 隐式修复非法配置。 + +## Tensor Parallel 映射 + +对于矩阵乘 `Y[M,K] = X[M,N] W[N,K]`,若被切分的输出或收缩维 degree 为 `t`,每个 TP rank 的理想局部 GEMM work 为: + +```text +F_local = 2 M N K / t +``` + +column-parallel 与 row-parallel linear 在 block 内交替,使部分中间结果保持 shard;需要重建语义边界时显式产生 all-reduce,或使用 reduce-scatter + all-gather 表达 sequence-parallel 边界。RS+AG 模式下 sequence dimension 必须满足: + +```text +sequence_length mod t = 0 +local_sequence = sequence_length / t +``` + +`DistributedTaskIR` 当前物化一个 local TP block,因此其 `LogicalMesh` 只有 `tp` axis。PP/DP 选择仍以 typed program semantic 保留,并在 iteration analysis 中组合。把完整模型展开成 cross-stage P2P task graph 是尚未实现的下一层能力,不能从当前 local block graph 推断出来。 + +## Pipeline Schedule 映射 + +当前 schedule ADT 区分: + +| Constructor | 含义 | 当前消费者 | +|---|---|---| +| `SingleStage` | `p = 1`,无 pipeline | training / inference | +| `OneForwardOneBackward` | synchronous non-interleaved 1F1B | training iteration analysis | +| `InterleavedOneForwardOneBackward(v)` | 每个 physical stage 有 `v` 个 virtual chunk | training iteration analysis | +| `ForwardOnly` | 静态 inference forward pipeline | inference mapping contract | + +Blueprinting 当前的 1F1B composition 使用从 portable block plan 得到的 forward/backward critical path: + +```text +T_chunk = T_forward_chunk + T_backward_chunk +n_bubble = (p - 1) + extra_interleaving_bubbles +T_bubble = n_bubble × T_chunk - T_imbalance_correction +``` + +当 `microbatch_count mod p != 0` 时: + +```text +extra_interleaving_bubbles = (v - 1) × (p - microbatch_count mod p) +``` + +这是一条明确的 analytical schedule contract,不是 `PortablePlanIR` 的 execution fact。未来 concrete scheduler 必须以 command DAG、P2P dependency 和 resource conflict 重新证明 schedule。 + +## 论文依据与适用边界 + +- Shoeybi et al., [Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism](https://arxiv.org/abs/1909.08053):Transformer intra-layer tensor parallel 的 column/row partition。 +- Narayanan et al., [Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM](https://arxiv.org/abs/2104.04473):TP × PP × DP 组合、1F1B 与 interleaved pipeline schedule。 +- Huang et al., [GPipe](https://arxiv.org/abs/1811.06965):microbatch pipeline 与 bubble/partition 的基本模型。 +- Korthikanti et al., [Reducing Activation Recomputation in Large Transformer Models](https://arxiv.org/abs/2205.05198):sequence parallelism 与 selective activation recomputation。 + +论文提供算法与建模依据,不自动证明本实现正确。对应的整除、work conservation、lineage、Calculon oracle 和 regression digest 均由仓库测试独立约束。 + +## 源码与测试 + +| 内容 | 位置 | +|---|---| +| typed strategy 与 schedule ADT | `src/blueprinting/mapping/transformer.py` | +| TP block work algebra | `src/blueprinting/synthesizer/dialects/transformer/training.py` | +| pattern-matching derivation | `src/blueprinting/synthesizer/dialects/transformer/training_derivation.py` | +| DistributedTask pass contract | `src/blueprinting/synthesizer/stages/distributed/passes.py` | +| PortablePlan pass contract | `src/blueprinting/synthesizer/stages/portable_plan/passes.py` | +| mapping/ADT tests | `tests/analysis/test_domain_contracts.py` | +| work与lineage tests | `tests/synthesizer/test_transformer_training.py` | +| Calculon alignment gate | `tests/validation/test_calculon.py`, `tests/regression/` | diff --git a/docs/design/ir/planning-execution.en.md b/docs/design/ir/planning-execution.en.md index 6e9a9ef..c4d69f4 100644 --- a/docs/design/ir/planning-execution.en.md +++ b/docs/design/ir/planning-execution.en.md @@ -76,7 +76,7 @@ ConcretePlanIR └── lineage to portable tasks ``` -Initial commands are `Launch`, `Collective`, `Transfer`, `Barrier`, `Signal`, `Wait`, and `HostCall`. Planned allocations may be static buffer bindings and do not require runtime allocation commands. +Initial command-body constructors are `Launch`, `CollectiveCommand`, `Transfer`, `Barrier`, `Signal`, `Wait`, and `HostCall`. The graph envelope contains one body rather than a `CommandKind` plus optional payloads. Executable bodies require an `ImplementationRef`; queue ownership is present only on queue-capable bodies. An orthogonal synchronization ADT represents no synchronization, wait, signal, or wait-and-signal clauses without two unrelated optional token tuples. Planned allocations may be static buffer bindings and do not require runtime allocation commands. ### Operational meaning @@ -86,7 +86,7 @@ Commands execute when dependency, ordering, synchronization, resource, and buffe The verifier for a complete producer must check DAG acyclicity, dependency-token production and consumption, ordering legality, cross-resource synchronization, implementation coverage, placement, buffer lifetime and overlap, address bounds, resource capacity, target fingerprints, typed extensions, and lineage. -The current repository implements only a generic device/queue/buffer/command schema and a set of structural verifiers. It has no production portable-to-concrete construction pass, route or resource-occupancy semantics, typed target extension, or end-to-end target conformance. This v1 is an experimental serialization contract, not a frozen public ABI. +The current repository implements the common device/queue/buffer/command schema, typed queue-order and slot/dataflow extensions, extension verifiers, and deterministic virtual portable-to-concrete reference binders. `ConcretePlanIR` remains in the initial `0.0.0` epoch with no production migration history. It has no production target plugin, general route/resource-occupancy model, scheduler, or end-to-end hardware conformance. This remains an experimental serialization contract, not a frozen public ABI. ## MachineIR diff --git a/docs/design/ir/planning-execution.zh.md b/docs/design/ir/planning-execution.zh.md index a34c9e6..f67a832 100644 --- a/docs/design/ir/planning-execution.zh.md +++ b/docs/design/ir/planning-execution.zh.md @@ -76,7 +76,7 @@ ConcretePlanIR └── lineage to portable tasks ``` -初始 command 包括 `Launch`、`Collective`、`Transfer`、`Barrier`、`Signal`、`Wait` 和 `HostCall`。Planned allocation 可以是 static buffer binding,不要求 runtime allocation command。 +初始 command-body constructor 包括 `Launch`、`CollectiveCommand`、`Transfer`、`Barrier`、`Signal`、`Wait` 和 `HostCall`。图 envelope 只包含一个 body,不再使用 `CommandKind` 加 optional payload;可执行 body 必须携带 `ImplementationRef`,queue 只存在于支持 queue 的 body。正交 synchronization ADT 表达无同步、wait、signal 与 wait-and-signal,替代两组互不约束的 optional token tuple。Planned allocation 可以是 static buffer binding,不要求 runtime allocation command。 ### Operational Meaning @@ -86,7 +86,7 @@ Command 在 dependency、ordering、synchronization、resource 和 buffer contra 完整 producer 的 verifier 必须检查 DAG acyclicity、dependency-token production/consumption、ordering legality、cross-resource synchronization、implementation coverage、placement、buffer lifetime/overlap、address bound、resource capacity、target fingerprint、typed extension 和 lineage。 -当前仓库只实现通用 device/queue/buffer/command schema 与一组 structural verifier;尚无 production portable-to-concrete construction pass、route/resource-occupancy semantic、typed target extension 或 end-to-end target conformance。这个 v1 是 experimental serialization contract,不是 frozen public ABI。 +当前仓库已实现通用 device/queue/buffer/command schema、typed queue-order/slot-dataflow extension、extension verifier 与 deterministic virtual portable-to-concrete reference binder。`ConcretePlanIR` 保持在初始 `0.0.0` epoch,不存在 production migration history;仓库尚无 production target plugin、通用 route/resource-occupancy model、scheduler 或端到端硬件 conformance。该 contract 仍是 experimental serialization contract,不是 frozen public ABI。 ## MachineIR diff --git a/docs/design/ir/python-algebra.en.md b/docs/design/ir/python-algebra.en.md new file mode 100644 index 0000000..7ee7ff0 --- /dev/null +++ b/docs/design/ir/python-algebra.en.md @@ -0,0 +1,139 @@ +# Python Algebraic IR Authoring + +Blueprinting uses Python annotations, frozen/slotted dataclasses, structural pattern matching, and a small deriving layer to express canonical IR. The goal is not to imitate Haskell syntax. It is to make constructors, data relations, and invariants dominate the source while shared infrastructure derives mechanical codec, immutability, and registration behavior. + +## Source layout + +The five canonical representations use one consistent layout: + +```text +src/blueprinting/synthesizer/stages/ +├── model/ +│ ├── ir.py +│ └── passes.py +├── distributed/ +│ ├── ir.py +│ └── passes.py +├── portable_plan/ +│ ├── ir.py +│ └── passes.py +├── concrete_plan/ +│ ├── ir.py +│ └── passes.py +└── machine/ + ├── ir.py + └── passes.py +``` + +`ir.py` is the only real definition site for the stage's canonical types. `passes.py` directly defines the public passes producing that stage; it may not merely forward to another `lowering` module. Transformer-specific pure derivation algebra lives in `synthesizer/dialects/transformer/*_derivation.py`. + +The removed `blueprinting.synthesizer.ir` and `blueprinting.synthesizer.lowering` paths are not compatibility surfaces. Internal adapters, examples, and tests import the owning stage directly, so there is only one discoverable definition and transformation hierarchy. + +## Records and ADTs + +An ordinary canonical product type uses `@record`: + +```python +@record("blueprinting.example.axis") +class Axis: + name: str + size: int +``` + +The decorator derives a frozen/slotted dataclass, canonical codec registration, and annotation-driven structural checks. Cross-field semantic invariants remain explicit functions or verifier rules; decorators must not hide them. + +A closed sum type declares its full semantic wire namespace once on the family and retains only a short stable tag on each constructor: + +```python +@adt(wire="blueprinting.ir.distributed-task.task") +class TaskBody: + pass + + +@variant("local-compute") +class LocalCompute(TaskBody): + pass + + +@variant("collective") +class Collective(TaskBody): + spec: CollectiveSpecVariant +``` + +`collective` expands to `blueprinting.ir.distributed-task.task.collective`. The short tag remains explicit because renaming a Python class cannot implicitly change wire identity or canonical digests. Component tags do not carry independent version counters; the owning IR root controls schema compatibility. `seal_adt(TaskBody, TaskBodyVariant)` freezes the exact registered constructor set: the family root is not constructible, late variants are rejected, and runtime field checks reject unregistered subclasses. `adt_manifest(TaskBody)` returns a deterministic variant manifest for schema documentation, checkers, and tooling. + +## Envelope plus sum payload + +Graph-node identity, inputs, outputs, dependencies, and lineage are defined once; mutually exclusive semantics live in the ADT body: + +```python +@record("blueprinting.ir.distributed-task.task-envelope") +class DistributedTask: + id: NodeId + body: LocalCompute | Collective | PointToPoint | Reshard | Control + operation: OperationName + ranks: tuple[int, ...] + inputs: tuple[ValueId, ...] + outputs: tuple[ValueId, ...] + dependencies: tuple[NodeId, ...] + lineage: Lineage +``` + +This removes invalid `kind + Optional payload` combinations. `CollectiveSpec` follows the same rule: `AllReduce` and `ReduceScatter` own a required reduction, `Broadcast` owns a required root, while `AllGather` and `AllToAll` own neither. Interpreters, verifiers, and derived views match on explicit union aliases. The deriving layer enforces exact runtime closure; optional mypy analysis checks match exhaustiveness when the subject is an explicit closed union and `assert_never` closes the function. + +## Derivable versus explicit information + +Derivable information includes immutability, slots, constructors, equality/hash, codec registration, ADT membership, basic field types, match arguments, and variant manifests. + +Wire-family identity, constructor local tags, schema migrations, lineage, pass/rule identity, cross-field invariants, preservation laws, target legality, and evidence validity remain explicit. + +All five roots currently belong to the initial `0.0.0` schema epoch. The default migration registry is intentionally empty: no unpublished intermediate representation is treated as compatibility history. The migration mechanism remains available for the first graduated schema boundary. + +## Passes, relations, and canonical construction + +The pass generic already declares input/output types. An ordinary cross-stage derivation declares its entity relations, an independent invariant for each relation, and one pure `normalize(source, session) -> target` function: + +```python +def verify_decompose( + source: ModelOperation, + target: DistributedTask, + context: RelationCheckContext, +) -> None: + if source.id not in target.lineage.sources: + raise ValueError("task does not retain its semantic source") + if target.ranks != tuple(range(context.target_ir.mesh.size)): + raise ValueError("task does not cover the derived logical mesh") + + +decompose = relation( + "transformer-decompose", + "Expand one semantic operation into distributed tasks", + source=ModelOperation, + target=DistributedTask, + verifier=verify_decompose, + introduces=("logical ranks", "collective tasks"), +) + + +def normalize(source: ModelIR, session: SynthesisSession) -> DistributedTaskIR: + return DistributedTaskIR(...) + + +@derivation( + "transformer-distribute", + revision="1", + bindings=(BindingAxis.WORKLOAD, BindingAxis.STRATEGY), + rules=(decompose,), + normalizer=normalize, +) +class DistributeTransformerTrainingPass( + DerivationPass[ModelIR, DistributedTaskIR] +): + ... +``` + +The commit gate resolves the complete lineage graph, evaluates the normalizer again, and requires exact equality with the candidate snapshot. This is canonical implementation conformance: it detects omissions and deviations across the whole schema shape. It is recorded separately from relation evidence. Each relation verifier then checks an independently stated semantic invariant such as workload conservation, legal role mapping, dependency correspondence, or target ABI compatibility. A normalizer cannot serve as its own semantic proof. `relation(..., preserves=(claim(...),))` declares both relations and named sub-obligations as ordinary immutable values; there is no parallel `@rule` decorator syntax. + +`@derivation` derives `ModelIR -> DistributedTaskIR` and exact schema versions from the generic base. The decorators only build static contracts: they do not wrap `run()`, inspect the call stack, or change execution semantics. + +Passes use `match` for closed ADTs and typed strategies. Registries or `singledispatch` are reserved for open target-plugin and obligation interpreters; canonical constructors are not extended through runtime monkey patching. diff --git a/docs/design/ir/python-algebra.zh.md b/docs/design/ir/python-algebra.zh.md new file mode 100644 index 0000000..f15e625 --- /dev/null +++ b/docs/design/ir/python-algebra.zh.md @@ -0,0 +1,139 @@ +# Python 代数化 IR 编写约定 + +Blueprinting 使用 Python 的类型注解、frozen/slotted dataclass、结构化模式匹配和少量 deriving decorator 表达 canonical IR。目标不是模拟 Haskell 语法,而是让源码主要呈现 constructor、数据关系和 invariant,机械的 codec/immutability/registration 由统一基础设施推导。 + +## 源码布局 + +五层 canonical representation 使用一致目录: + +```text +src/blueprinting/synthesizer/stages/ +├── model/ +│ ├── ir.py +│ └── passes.py +├── distributed/ +│ ├── ir.py +│ └── passes.py +├── portable_plan/ +│ ├── ir.py +│ └── passes.py +├── concrete_plan/ +│ ├── ir.py +│ └── passes.py +└── machine/ + ├── ir.py + └── passes.py +``` + +`ir.py` 是该层 canonical 类型的唯一真实定义位置。`passes.py` 直接定义产生该层 snapshot 的 public pass,不允许只转发到另一个 `lowering` 模块。Transformer-specific 纯推导代数位于 `synthesizer/dialects/transformer/*_derivation.py`。 + +已删除的 `blueprinting.synthesizer.ir` 与 `blueprinting.synthesizer.lowering` 路径不构成 compatibility surface。内部 adapter、example 与 test 都直接导入所属 stage,因此仓库只有一套可发现的定义与变换层级。 + +## Record 与 ADT + +普通 canonical product type 使用 `@record`: + +```python +@record("blueprinting.example.axis") +class Axis: + name: str + size: int +``` + +Decorator 推导 frozen/slotted dataclass、canonical codec 注册和 annotation 驱动的基础结构检查。跨字段 semantic invariant 仍需显式函数或 verifier,不能隐藏在 decorator 中。 + +封闭 sum type 将完整 semantic wire namespace 声明在 family,只在 constructor 上保留短 stable tag: + +```python +@adt(wire="blueprinting.ir.distributed-task.task") +class TaskBody: + pass + + +@variant("local-compute") +class LocalCompute(TaskBody): + pass + + +@variant("collective") +class Collective(TaskBody): + spec: CollectiveSpecVariant +``` + +`collective` 自动展开为 `blueprinting.ir.distributed-task.task.collective`。短 tag 必须显式,因为 Python class 重命名不能隐式改变 wire identity 或 canonical digest。组件 tag 不维护独立版本号;schema compatibility 由所属 IR root 统一控制。`seal_adt(TaskBody, TaskBodyVariant)` 会冻结精确的 registered constructor set:family root 不可构造、late variant 会被拒绝,runtime field check 也会拒绝未注册 subclass。`adt_manifest(TaskBody)` 返回 deterministic variant manifest,供 schema 文档、检查器和工具读取。 + +## Envelope 加 sum payload + +图节点的 ID、输入输出、依赖、lineage 等共同结构只定义一次;互斥语义进入 ADT body: + +```python +@record("blueprinting.ir.distributed-task.task-envelope") +class DistributedTask: + id: NodeId + body: LocalCompute | Collective | PointToPoint | Reshard | Control + operation: OperationName + ranks: tuple[int, ...] + inputs: tuple[ValueId, ...] + outputs: tuple[ValueId, ...] + dependencies: tuple[NodeId, ...] + lineage: Lineage +``` + +这消除了 `kind + Optional payload` 的非法组合。`CollectiveSpec` 采用同一原则:`AllReduce`/`ReduceScatter` 拥有必需的 reduction,`Broadcast` 拥有必需的 root,而 `AllGather`/`AllToAll` 不拥有这两个字段。Interpreter、verifier 和 derived view 对显式 union alias 使用 `match`。Deriving layer 在 runtime 强制精确 closure;安装可选 mypy 后,当 subject 是显式 closed union 且函数由 `assert_never` 封闭时,还会检查 match 穷尽性。 + +## 可以推导与必须显式的边界 + +可以推导:immutability、slots、constructor、equality/hash、codec registration、ADT membership、基础字段类型、match args 和 variant manifest。 + +必须显式:wire family identity、constructor local tag、schema migration、lineage、pass/rule identity、cross-field invariant、preservation law、target legality 和 evidence validity。 + +五层 IR 当前都属于初始 `0.0.0` schema epoch。默认 migration registry 有意保持为空:未发布过的中间表示不构成 compatibility history。Migration 机制仍然保留,等第一个完成 graduation 的 schema boundary 再注册真实迁移。 + +## Pass、relation 与 canonical construction + +Pass generic 已经声明输入输出类型。普通跨层推导需要声明 entity relation、每条 relation 的独立 invariant,以及一个纯 `normalize(source, session) -> target` 函数: + +```python +def verify_decompose( + source: ModelOperation, + target: DistributedTask, + context: RelationCheckContext, +) -> None: + if source.id not in target.lineage.sources: + raise ValueError("task does not retain its semantic source") + if target.ranks != tuple(range(context.target_ir.mesh.size)): + raise ValueError("task does not cover the derived logical mesh") + + +decompose = relation( + "transformer-decompose", + "Expand one semantic operation into distributed tasks", + source=ModelOperation, + target=DistributedTask, + verifier=verify_decompose, + introduces=("logical ranks", "collective tasks"), +) + + +def normalize(source: ModelIR, session: SynthesisSession) -> DistributedTaskIR: + return DistributedTaskIR(...) + + +@derivation( + "transformer-distribute", + revision="1", + bindings=(BindingAxis.WORKLOAD, BindingAxis.STRATEGY), + rules=(decompose,), + normalizer=normalize, +) +class DistributeTransformerTrainingPass( + DerivationPass[ModelIR, DistributedTaskIR] +): + ... +``` + +Commit gate 先解析完整 lineage graph,再重新执行 normalizer,并要求结果与待提交 snapshot 完全相等。这属于 canonical implementation conformance:它能在完整 schema shape 上发现遗漏与偏差,并与 relation evidence 分开记录。随后每条 relation verifier 独立检查 workload conservation、合法 role mapping、dependency correspondence 或 target ABI compatibility 等 semantic invariant。Normalizer 不能充当自身的语义证明。`relation(..., preserves=(claim(...),))` 把 relation 与具名子 obligation 都声明为普通 immutable value,不再提供平行的 `@rule` decorator 语法。 + +`@derivation` 从 generic base 推导 `ModelIR -> DistributedTaskIR` 及精确 schema version。Decorator 只生成静态 contract,不包装 `run()`、不读取调用栈,也不改变执行语义。 + +Pass 对封闭 ADT 和 typed strategy 使用 `match`。开放式 target plugin/obligation interpreter 才使用 registry 或 `singledispatch`;canonical IR 不用 runtime monkey patch 扩展 constructor。 diff --git a/docs/design/modules.en.md b/docs/design/modules.en.md index 3b9c894..7c03589 100644 --- a/docs/design/modules.en.md +++ b/docs/design/modules.en.md @@ -57,7 +57,7 @@ It has no dependency on Transformer-specific derivation, target plugins, perform ## Analysis and transformation infrastructure -`PassManager` executes declarative `PassContract` objects. Each contract declares input/output schemas, required bindings and analyses, preserved and produced analyses, mutation model, verification policy, and determinism. +`PassManager` executes declarative `PassContract` objects. Each contract declares input/output schemas, required bindings and analyses, preserved and produced analyses, mutation model, verification policy, determinism, and typed lineage rules with executable predicates. Cross-boundary verification is part of the commit gate; deterministic replay is enabled in CI and optionally at runtime. `AnalysisStore` is content-addressed by representation digest, analysis key, and session fingerprint. Checkpoint observers inspect verified immutable outputs before analyses are atomically published. See [analysis and transformation infrastructure](passes/index.md). @@ -94,7 +94,7 @@ The scheduler consumes target-legal tasks, deployment resources, and cost views. The memory planner reasons over lifetimes under legal overlap, not only an aggregate peak-memory formula. The output must satisfy DAG, queue, synchronization, buffer, capacity, and target-legality verifiers before becoming `ConcretePlanIR`. -Only a queue-oriented experimental schema and structural verifiers exist; typed target extensions, production target binding, and scheduling are planned. +The experimental contract now includes mutually exclusive queue-order and slot/dataflow typed extensions, target verifiers, and deterministic virtual reference binders. They exercise the common envelope against queue-centric and queue-free semantics; production target plugins, resource scheduling, occupancy, and hardware legality remain planned. ## Products, simulation, and emission @@ -143,16 +143,16 @@ The dependency direction is explicit: workload contracts do not depend on mappin | Logical strategies and explicit deployment mapping | `mapping/` | Implemented Transformer/network slice | | Chip, memory, interconnect, and aggregate system profile | `system/` | Implemented limited profile adapter | | IDs, expressions, lineage | `synthesizer/{ids,expr}.py` | Implemented | -| Canonical formal representations (`*IR`) | `synthesizer/ir/` | Implemented contracts | +| Canonical formal representations (`*IR`) | `synthesizer/stages/*/ir.py` | Implemented contracts | | Bindings and sessions | `synthesizer/{bindings,session}.py` | Implemented | | Analysis/transformation transactions | `synthesizer/passes/base.py` | Implemented | | Workload-to-IR/session frontends | `synthesizer/frontend/` | Implemented Transformer slice | | Transformer exact-work dialect | `synthesizer/dialects/transformer/` | Implemented training/inference slice | -| Transformer derivation passes | `synthesizer/lowering/` | Implemented through portable plan | +| Stage-owned derivation passes | `synthesizer/stages/*/passes.py` | Implemented through portable plan | | Current system cost adapters | `analysis/cost_model.py`, `analysis/cost/` | Implemented slice | | Framework-neutral orchestration and reports | `application/` | Implemented static analysis slice | | Calculon/Vidur comparisons and regression gates | `validation/` | Implemented offline gates | | Optional external performance bundles | `data/evidence/` | Explicitly loaded; excluded from base package | | Architecture model/search, evidence service, simulation, emission | Accepted boundaries | Planned | -`validation/legacy/` contains retained Calculon-only reproductions of historical SeqSel figures. They are compatibility checks, not evidence that the canonical Blueprinting derivation path is correct; the strict gates are `validation/calculon.py`, `validation/vidur.py`, and `validation/regression.py`. +`validation/calculon.py` and `validation/vidur.py` keep external reference implementations behind post-derivation comparison boundaries; `validation/regression.py` freezes their strict drift gates. They are comparison checks, not evidence that the canonical derivation path is correct by construction. diff --git a/docs/design/modules.zh.md b/docs/design/modules.zh.md index 40fe92e..53ac9ba 100644 --- a/docs/design/modules.zh.md +++ b/docs/design/modules.zh.md @@ -57,7 +57,7 @@ Frontend 不读取 peak throughput、kernel catalog、physical topology 或 runt ## Analysis 与 Transformation 基础设施 -`PassManager` 执行 declarative `PassContract`。每个 contract 声明 input/output schema、required binding/analysis、preserved/produced analysis、mutation model、verification policy 和 determinism。 +`PassManager` 执行 declarative `PassContract`。每个 contract 声明 input/output schema、required binding/analysis、preserved/produced analysis、mutation model、verification policy、determinism,以及带可执行 predicate 的 typed lineage rule。跨 boundary 验证属于 commit gate;deterministic replay 在 CI 启用,也可以在 runtime 显式开启。 `AnalysisStore` 通过 representation digest、analysis key 和 session fingerprint 进行 content addressing。Checkpoint observer 在 analysis 原子发布前检查 verified immutable output。详见[分析与变换基础设施](passes/index.md)。 @@ -94,7 +94,7 @@ Scheduler 消费 target-legal task、deployment resource 和 cost view,联合 Memory planner 必须分析合法 overlap 下的 lifetime,而不只是 aggregate peak-memory 公式。输出必须通过 DAG、queue、sync、buffer、capacity 和 target-legality verifier,才能成为 `ConcretePlanIR`。 -当前只有 queue-oriented experimental schema 与 structural verifier;typed target extension、production target binding 和 scheduling 尚未实现。 +Experimental contract 现在包含互斥的 queue-order 与 slot/dataflow typed extension、target verifier 和 deterministic virtual reference binder。它们用于验证 common envelope 能同时承载 queue-centric 与 queue-free semantic;production target plugin、resource scheduling、occupancy 和硬件 legality 仍未实现。 ## Product、Simulation 与 Emission @@ -143,16 +143,16 @@ schema ──► workload ──► mapping | Logical strategy 与显式 deployment mapping | `mapping/` | Implemented Transformer/network slice | | Chip、memory、interconnect 与聚合 system profile | `system/` | Implemented limited profile adapter | | ID、expression、lineage | `synthesizer/{ids,expr}.py` | Implemented | -| Canonical 形式化表示(`*IR`) | `synthesizer/ir/` | Implemented contracts | +| Canonical 形式化表示(`*IR`) | `synthesizer/stages/*/ir.py` | Implemented contracts | | Binding 与 session | `synthesizer/{bindings,session}.py` | Implemented | | Analysis/transformation transaction | `synthesizer/passes/base.py` | Implemented | | Workload-to-IR/session frontend | `synthesizer/frontend/` | Implemented Transformer slice | | Transformer exact-work dialect | `synthesizer/dialects/transformer/` | Implemented training/inference slice | -| Transformer derivation pass | `synthesizer/lowering/` | Implemented through portable plan | +| Stage-owned derivation pass | `synthesizer/stages/*/passes.py` | Implemented through portable plan | | 当前 system cost adapter | `analysis/cost_model.py`、`analysis/cost/` | Implemented slice | | Framework-neutral orchestration 与 report | `application/` | Implemented static analysis slice | | Calculon/Vidur comparison 与 regression gate | `validation/` | Implemented offline gate | | Optional external performance bundle | `data/evidence/` | 显式加载;从 base package 排除 | | Architecture model/search、evidence service、simulation、emission | Accepted boundary | Planned | -`validation/legacy/` 保留 Calculon-only 的历史 SeqSel 图表复现。它们是 compatibility check,不构成 canonical Blueprinting derivation 正确性的证据;严格 gate 位于 `validation/calculon.py`、`validation/vidur.py` 与 `validation/regression.py`。 +`validation/calculon.py` 与 `validation/vidur.py` 把外部 reference implementation 隔离在推导后的 comparison boundary;`validation/regression.py` 冻结其严格 drift gate。它们属于 comparison check,不能证明 canonical derivation 天然正确。 diff --git a/docs/design/passes/index.en.md b/docs/design/passes/index.en.md index 0c6ae04..072dd92 100644 --- a/docs/design/passes/index.en.md +++ b/docs/design/passes/index.en.md @@ -9,7 +9,7 @@ A Blueprinting `Pass` is the current implementation unit for a verified transact Every pass declares: ```text -pass_id and revision +pass identity and contract digest input IR type and accepted schema range output IR type and produced schema version required bindings @@ -19,10 +19,26 @@ produced analyses mutation model verification policy determinism and seed usage +typed lineage relations with independent semantic invariants +an executable canonical normal form ``` Pipeline composition follows these contracts rather than `isinstance` checks against concrete pass classes. +Production passes use one low-noise authoring syntax. `relation()` declares a typed entity mapping, its independent executable semantic invariant, and optional named claims. `@derivation` derives IR types and exact schemas from `DerivationPass[SourceIR, TargetIR]` and binds a module-level pure normalizer. The normalizer establishes that the implementation produced the declared canonical construction; relation invariants establish semantic facts such as conservation, legality, and dependency correspondence without treating that implementation as its own proof. Bindings, analysis effects, and relation identities remain explicit. The sole pass decorator constructs metadata only; it neither wraps nor alters `run()`. + +Each pass is defined in its output stage: + +```text +ModelIR producer -> stages/model/passes.py +DistributedTaskIR producer -> stages/distributed/passes.py +PortablePlanIR producer -> stages/portable_plan/passes.py +ConcretePlanIR producer -> stages/concrete_plan/passes.py +MachineIR producer -> stages/machine/passes.py +``` + +A dialect module may provide pure derivation functions, but it may not own a second public pass class or hide a contract behind import forwarding. + ## Transaction sequence `PassManager` performs: @@ -34,6 +50,9 @@ check input type/schema -> execute immutable or isolated mutation -> check output type/schema and input immutability -> verify output and parent lineage + -> resolve every cross-boundary lineage relation + -> re-evaluate the canonical normal form and require exact snapshot equality + -> run every relation's independent semantic invariant -> create PassRecord and PassCheckpoint -> invoke synchronous observers -> atomically preserve/publish analyses @@ -66,7 +85,7 @@ Observers are read-only. They cannot rewrite a representation or publish analyse ## Determinism -A pass declares whether it is deterministic and how it uses a seed. A deterministic pass over the same input digest, session fingerprint, pass revision, and required analyses must emit the same output digest or diagnostic. +A pass declares whether it is deterministic and how it uses a seed. CI and opt-in `PassManager` verification execute deterministic passes twice against isolated analysis-store snapshots, then compare output digests and analysis-product digests. Normal production execution keeps replay disabled. A same-seed replay mismatch aborts before publication. Search passes may be seeded and budgeted. Candidate order, pruning, and rejection reasons remain provenance so a search result can be replayed. @@ -86,4 +105,4 @@ Every production analysis or transformation design must include: ## Current implementation -The repository implements `SchemaRange`, `PassContract`, `PassPipeline`, `PassManager`, content-addressed `AnalysisStore`, pass records, checkpoints, and observers in `src/blueprinting/synthesizer/passes/base.py`. Contract and failure behavior are covered by `tests/synthesizer/test_pass_manager.py`. +The repository implements the transaction runner in `src/blueprinting/synthesizer/passes/base.py`, keeps the registry implementation in `passes/deriving.py`, and exposes only `@derivation`, `relation`, and `claim` to extension authors through `passes/authoring.py`. Public stage passes live in `stages/*/passes.py`. `tests/synthesizer/test_pass_manager.py` covers contract inference, failure behavior, and determinism. diff --git a/docs/design/passes/index.zh.md b/docs/design/passes/index.zh.md index 3a68f4c..37f0bef 100644 --- a/docs/design/passes/index.zh.md +++ b/docs/design/passes/index.zh.md @@ -9,7 +9,7 @@ Blueprinting 的 `Pass` 是当前实现中对 immutable derivation state 执行 每个 Pass 声明: ```text -pass_id and revision +pass identity 与 contract digest input IR type and accepted schema range output IR type and produced schema version required bindings @@ -19,10 +19,26 @@ produced analyses mutation model verification policy determinism and seed usage +带独立 semantic invariant 的 typed lineage relation +可执行 canonical normal form ``` Pipeline 根据这些 contract 组合,而不是对具体 Pass class 进行 `isinstance` 判断。 +Production pass 使用单一低噪声 authoring syntax:`relation()` 同时声明 typed entity mapping、独立 executable semantic invariant 与可选具名 `claim`;`@derivation` 从 `DerivationPass[SourceIR, TargetIR]` 推导 IR 类型与精确 schema,并绑定模块级纯 normalizer。Normalizer 证明 implementation 产出了声明的 canonical construction;relation invariant 独立检查守恒、合法性与 dependency correspondence 等语义事实,不能把 implementation 本身当作自己的证明。Binding、analysis effect 与 relation identity 仍显式声明;唯一的 pass decorator 只构造 metadata,不包装或改变 `run()`。 + +每个 pass 定义在其输出层: + +```text +ModelIR producer -> stages/model/passes.py +DistributedTaskIR producer -> stages/distributed/passes.py +PortablePlanIR producer -> stages/portable_plan/passes.py +ConcretePlanIR producer -> stages/concrete_plan/passes.py +MachineIR producer -> stages/machine/passes.py +``` + +方言模块可以提供纯推导函数,但不能拥有第二个 public pass class,也不能通过 import forwarding 隐藏 contract。 + ## Transaction 顺序 `PassManager` 执行: @@ -34,6 +50,9 @@ check input type/schema -> execute immutable or isolated mutation -> check output type/schema and input immutability -> verify output and parent lineage + -> 解析全部跨 boundary lineage relation + -> 重新求值 canonical normal form 并要求 snapshot 完全相等 + -> 执行每条 relation 的独立 semantic invariant -> create PassRecord and PassCheckpoint -> invoke synchronous observers -> atomically preserve/publish analyses @@ -66,7 +85,7 @@ Observer 是只读的,不能重写 representation 或直接 publish analysis ## 确定性 -Pass 声明自己是否 deterministic,以及如何使用 seed。Deterministic pass 在相同 input digest、session fingerprint、pass revision 和 required analysis 上必须产生相同 output digest 或 diagnostic。 +Pass 声明自己是否 deterministic,以及如何使用 seed。CI 与可选的 `PassManager` verification 会在相互隔离的 analysis-store snapshot 上执行 deterministic pass 两次,并比较 output digest 与 analysis-product digest;正常 production execution 默认关闭 replay。同 seed 结果不一致会在发布前终止 transaction。 Search pass 可以具有 seed 和 budget。Candidate order、pruning 和 rejection reason 都保留 provenance,使 search result 可以 replay。 @@ -86,4 +105,4 @@ Search pass 可以具有 seed 和 budget。Candidate order、pruning 和 rejecti ## 当前实现 -仓库在 `src/blueprinting/synthesizer/passes/base.py` 中实现了 `SchemaRange`、`PassContract`、`PassPipeline`、`PassManager`、content-addressed `AnalysisStore`、pass record、checkpoint 和 observer。Contract 与 failure behavior 由 `tests/synthesizer/test_pass_manager.py` 覆盖。 +仓库在 `src/blueprinting/synthesizer/passes/base.py` 中实现 transaction runner,在 `passes/deriving.py` 中实现内部 registry,并通过 `passes/authoring.py` 只向扩展作者暴露 `@derivation`、`relation` 与 `claim`。各层 public pass 位于 `stages/*/passes.py`。Contract inference、failure behavior 与 determinism 由 `tests/synthesizer/test_pass_manager.py` 覆盖。 diff --git a/docs/design/passes/target.en.md b/docs/design/passes/target.en.md index 4ba8401..d434f0f 100644 --- a/docs/design/passes/target.en.md +++ b/docs/design/passes/target.en.md @@ -3,7 +3,7 @@ Architecture binding is the formal bridge from a portable workload mapping to an architecture-bound simulation plan. It checks a verified `PortablePlanIR` against a candidate hardware blueprint, deployment, and evidence policy, then constructs one authoritative `ConcretePlanIR` envelope from which timing, simulation, and optional target programs are derived. !!! warning "Design status" - The late-binding boundary and experimental schemas exist; typed target extensions, a production producer, and the downstream analysis/transformation chain are **Planned**. + The late-binding boundary, experimental schemas, and deterministic queue/slot reference binders exist. The target-plugin registry, a production producer, and the downstream analysis/transformation chain remain **Planned**. ## Why this is one vertical slice @@ -68,6 +68,25 @@ Predicted timestamps are annotations, not readiness semantics. Removing them mus If a target makes a cycle or slot a correctness constraint, it enters a typed target extension after binding rather than `TimingProjection`. The projection and trace are published with concrete, evidence, and policy digests in a `TimelineBundle`; see the [timeline staging path](../timeline-path.md) for the complete semantics. +## Implemented reference-binder passes + +`stages/concrete_plan/passes.py` directly defines two deterministic contract-validation passes: + +- `BindReferenceQueueTargetPass` produces a queue-centric `QueueScheduleExtension`; +- `BindReferenceSlotTargetPass` produces a non-queue `SlotDataflowExtension`. + +Both preserve a 1:1 `PlanTask -> ConcreteCommand` identity, dependency topology, and buffer uses. Neither claims to be a production scheduler. Buffers use stable-order aligned linear allocation: + +```text +offset_0 = 0 +bound_i = align_up(offset_i, alignment_i) +offset_(i+1) = bound_i + size_i +``` + +Every command implementation and placement comes from explicit target/deployment bindings. The commit gate rebuilds the complete concrete plan with the same pure normalizer and requires equality of source-buffer identity, exact size, task lineage, command mapping, and the typed extension. Separately, relation invariants check portable-to-concrete buffer identity/capacity/alignment, dependency correspondence, buffer access, operation identity, and target ABI. The two passes map the same portable input into different typed extensions, proving that the common envelope does not assume queue-only targets. + +This is a contract reference implementation, not a paper-derived performance heuristic. Source is `src/blueprinting/synthesizer/stages/concrete_plan/passes.py`; positive/negative, lineage, and deterministic replay tests are in `tests/synthesizer/test_reference_targets.py`. + ## Machine lowering and artifact emission A target plugin lowers verified concrete commands into its own `MachineIR` dialect. A virtual target should emit a deterministic replay package first; CUDA, LPU, and other hardware plugins can then add ABI-specific instructions and executable artifacts incrementally. diff --git a/docs/design/passes/target.zh.md b/docs/design/passes/target.zh.md index 2c49a72..6254516 100644 --- a/docs/design/passes/target.zh.md +++ b/docs/design/passes/target.zh.md @@ -3,7 +3,7 @@ Architecture binding 是从 portable workload mapping 到 architecture-bound simulation plan 的形式化桥梁。它针对 candidate hardware blueprint、deployment 与 evidence policy 检查已验证的 `PortablePlanIR`,构造唯一权威的 `ConcretePlanIR` envelope,再从中派生 timing、simulation 与 optional target program。 !!! warning "设计状态" - 迟绑定边界和 experimental schema 已经存在;typed target extension、production producer 和后续 analysis/transformation chain 均为 **Planned**。 + 迟绑定边界、experimental schema、queue/slot 两个 deterministic reference binder 已存在;target plugin registry、production producer 和后续 analysis/transformation chain 仍为 **Planned**。 ## 为什么必须做成一条纵向切片 @@ -68,6 +68,25 @@ Command DAG 验证完成后,timing projection 计算预测 interval、contenti 如果某个 target 把 cycle/slot 作为 correctness constraint,它在 binding 后进入 typed target extension,而不是 `TimingProjection`。Projection 与 trace 连同 concrete/evidence/policy digest 发布为 `TimelineBundle`;详细语义见 [Timeline 阶段路径](../timeline-path.md)。 +## 已实现的 Reference Binder Pass + +`stages/concrete_plan/passes.py` 直接定义两个用于验证 contract 的 deterministic pass: + +- `BindReferenceQueueTargetPass`:产生 queue-centric `QueueScheduleExtension`; +- `BindReferenceSlotTargetPass`:产生 non-queue `SlotDataflowExtension`。 + +两者都保持 `PlanTask -> ConcreteCommand` 的 1:1 identity、dependency topology 和 buffer use,不声称是 production scheduler。Buffer 使用稳定顺序与 alignment 做线性分配: + +```text +offset_0 = 0 +bound_i = align_up(offset_i, alignment_i) +offset_(i+1) = bound_i + size_i +``` + +每个 command 的 implementation 与 placement 都来自显式 target/deployment binding;commit gate 使用同一个纯 normalizer 重建完整 concrete plan,并要求 source buffer identity、exact size、task lineage、command mapping 与 typed extension 全部相等。独立 relation invariant 另外检查 portable-to-concrete buffer identity/capacity/alignment、dependency correspondence、buffer access、operation identity 与 target ABI。两个 pass 使用相同 portable input 产生不同 typed extension,用于证明 common envelope 不依赖 queue-only 假设。 + +这是 contract reference implementation,不引用性能论文,也不以 heuristic quality 为设计声明。源码在 `src/blueprinting/synthesizer/stages/concrete_plan/passes.py`,positive/negative、lineage 与 deterministic replay 测试在 `tests/synthesizer/test_reference_targets.py`。 + ## Machine Lowering 与 Artifact Emission Target plugin 把 verified concrete command lowering 到自己的 `MachineIR` dialect。Virtual target 应先输出 deterministic replay package;CUDA、LPU 与其他硬件 plugin 再逐步增加 ABI-specific instruction 和 executable artifact。 diff --git a/docs/design/passes/transformer.en.md b/docs/design/passes/transformer.en.md index 61beadd..d657b41 100644 --- a/docs/design/passes/transformer.en.md +++ b/docs/design/passes/transformer.en.md @@ -23,23 +23,39 @@ This page covers decoder-only training at block scope; the repository separately ## Typed semantic import -`TransformerModelSpec` owns dimensions and model semantics. `TransformerTrainingWorkloadSpec` owns global/micro batch size and datatype. `TransformerTrainingMappingSpec` owns TP/PP/DP, recomputation, pipeline interleaving, optimizer sharding, and tensor-parallel communication mode. `synthesis_session_for()` converts these independent contracts into explicit workload and strategy bindings. +`TransformerModelSpec` owns dimensions and model semantics. `TransformerTrainingWorkloadSpec` owns global/micro batch size and datatype. `TransformerTrainingMappingSpec.parallelism` projects configuration into `TensorParallel × PipelineParallel × DataParallel × RecomputePolicy`, with a closed pipeline-schedule ADT. `synthesis_session_for()` converts these independent contracts into explicit workload and strategy bindings. Physical network-tier selection is deliberately absent. `NetworkTierBinding` is supplied only when a portable plan is evaluated against a `SystemProfile`; changing it cannot change the model, distributed, or portable-plan digest. The importer rejects invalid dimensions, TP divisibility failures, sequence dimensions that cannot be evenly partitioned under RS+AG, invalid parallel topology, and inconsistent workload or strategy facts before a pass runs. At TP=1, AR and RS+AG have identical local work and memory semantics. `build_transformer_model_ir()` then creates a coarse, target-neutral `transformer.decoder_training` operation. No target name, peak rate, kernel ID, or latency enters this snapshot. +Composition obeys: + +```text +world_size = TP × PP × DP +local_batch = global_batch / DP +microbatch_count = global_batch / (DP × microbatch_size) +blocks_per_virtual_chunk = block_count / (PP × virtual_stages) +``` + +Every division must be integral. See [Typed Transformer Parallel Strategies](../ir/parallel-strategy.md) for the complete types, schedule constructors, and Megatron mapping. + ## Static workload derivation `derive_transformer_block()` decomposes a block into typed `PrimitiveInvocation` records. Each invocation has a phase, engine class, exact operations, exact read/write bytes, and—when applicable—collective kind and logical message bytes. The analysis follows data dependencies rather than fitted ratios. For a linear layer `Y[M,K] = X[M,N] x W[N,K]`, forward, activation-gradient, and weight-gradient work are three explicit matrix multiplications. Attention, normalization, activation, dropout, residual, and optimizer work are represented separately. +```text +F_forward = F_dgrad = F_wgrad = 2 M N K +F_local_tensor_parallel = 2 M N K / TP +``` + Recomputation is also structural. Full recomputation clones the required forward invocations; selective recomputation clones only the selected attention path. Sequence-parallel recommunication is a distinct collective invocation. Consequently every added operation and byte remains attributable to a semantic cause. ## Distribution derivation -`DistributeTransformerTrainingPass` consumes `ModelIR` plus workload and strategy bindings and introduces: +`DistributeTransformerTrainingPass` is defined in the target stage's `stages/distributed/passes.py`. It consumes `ModelIR` plus workload and strategy bindings, uses `match` to destructure typed TP/PP/DP strategies, local/collective invocations, and the `TaskBody` ADT, and introduces: - a logical TP mesh and logical ranks; - forward, recompute, backward, optimizer, and recommunication tasks; @@ -59,7 +75,7 @@ The pass must preserve workload semantics and satisfy these checks: ## Portable-plan derivation -`PlanTransformerTrainingPass` converts each distributed task into a `PlanTask`. It retains `WorkloadFacts`, declares abstract resource demand, creates capability-based implementation requirements, assigns logical concurrency groups, and introduces boundary buffers and objectives. +`PlanTransformerTrainingPass` is defined in `stages/portable_plan/passes.py` and converts each distributed task into a `PlanTask`. It retains `WorkloadFacts`, declares abstract resource demand, creates capability-based implementation requirements, assigns logical concurrency groups, and introduces boundary buffers and objectives. The pass may say that a task needs `matrix-multiply`, `vector-elementwise`, or a collective capability. It may not select a CUDA kernel, LPU opcode, physical device, memory bank, queue, or duration. Those are decisions of the portable-to-concrete gate. @@ -84,6 +100,15 @@ Typed inconsistency is reported at the earliest boundary: missing strategy bindi The derivation does not compensate for a discrepancy by reading a reference latency or attaching a case-specific coefficient. A disagreement is localized to semantic import, work derivation, distribution, cost evidence, or schedule composition and fixed at that boundary. +## Papers and formula provenance + +- [Megatron-LM 2019](https://arxiv.org/abs/1909.08053): Transformer column/row tensor-parallel partitioning and collective boundaries. +- [Megatron-LM 2021](https://arxiv.org/abs/2104.04473): TP × PP × DP composition, 1F1B, and interleaved pipelines. +- [GPipe](https://arxiv.org/abs/1811.06965): microbatch pipelines and the basic bubble model. +- [Selective recomputation and sequence parallelism](https://arxiv.org/abs/2205.05198): selective recomputation and RS/AG sequence-parallel semantics. + +Citations explain the design provenance; they do not replace verifiers. Work conservation, lineage, round trips, deterministic replay, and the Calculon regression gate check the implemented formulas. + ## Implementation map | Concern | Source | Tests | @@ -92,7 +117,10 @@ The derivation does not compensate for a discrepancy by reading a reference late | Logical mapping contract | `src/blueprinting/mapping/transformer.py` | boundary and validation tests | | Workload-to-IR frontend | `src/blueprinting/synthesizer/frontend/transformer.py` | canonical representation and calibration tests | | Workload algebra | `src/blueprinting/synthesizer/dialects/transformer/training.py` | `tests/validation/test_calculon.py` | -| Two derivation passes | `src/blueprinting/synthesizer/lowering/transformer.py` | `tests/synthesizer/test_transformer_training.py` and calibration tests | +| typed TP/PP/DP strategy | `src/blueprinting/mapping/transformer.py` | `tests/analysis/test_domain_contracts.py` | +| distributed pass definition | `src/blueprinting/synthesizer/stages/distributed/passes.py` | `tests/synthesizer/test_transformer_training.py` | +| portable pass definition | `src/blueprinting/synthesizer/stages/portable_plan/passes.py` | same | +| pure derivation and pattern matching | `src/blueprinting/synthesizer/dialects/transformer/training_derivation.py` | same and calibration tests | | Transaction/checkpoints | `src/blueprinting/synthesizer/passes/base.py` | `tests/synthesizer/test_pass_manager.py` | | Evidence-derived estimates | `src/blueprinting/analysis/cost_model.py` | validation tests | | Calculon/SeqSel oracle gate | `src/blueprinting/validation/calculon.py` | `tests/validation/test_calculon.py` | diff --git a/docs/design/passes/transformer.zh.md b/docs/design/passes/transformer.zh.md index c94bbb7..db9de55 100644 --- a/docs/design/passes/transformer.zh.md +++ b/docs/design/passes/transformer.zh.md @@ -23,23 +23,39 @@ TransformerModelSpec + TransformerTrainingWorkloadSpec + TransformerTrainingMapp ## 强类型语义导入 -`TransformerModelSpec` 拥有模型维度与语义,`TransformerTrainingWorkloadSpec` 拥有 global/micro batch size 与 datatype,`TransformerTrainingMappingSpec` 拥有 TP/PP/DP、重计算、pipeline interleaving、optimizer sharding 与 tensor-parallel 通信模式。`synthesis_session_for()` 把这些彼此独立的 contract 转换成显式 workload 与 strategy binding。 +`TransformerModelSpec` 拥有模型维度与语义,`TransformerTrainingWorkloadSpec` 拥有 global/micro batch size 与 datatype。`TransformerTrainingMappingSpec.parallelism` 把配置投影为 `TensorParallel × PipelineParallel × DataParallel × RecomputePolicy`,其中 pipeline schedule 是封闭 ADT。`synthesis_session_for()` 把这些彼此独立的 contract 转换成显式 workload 与 strategy binding。 Physical network tier 的选择被刻意排除。只有在用 `SystemProfile` 评估 portable plan 时才会提供 `NetworkTierBinding`;改变它不能改变 model、distributed 或 portable-plan digest。 Importer 会在 pass 运行前拒绝非法维度、TP 不可整除、RS+AG 下不可等分的 sequence dimension、错误并行拓扑以及互相矛盾的 workload/strategy facts。TP=1 时 AR 与 RS+AG 具有相同的本地 work/memory 语义。随后 `build_transformer_model_ir()` 创建一个粗粒度、target-neutral 的 `transformer.decoder_training` operation。这个 snapshot 中不存在 target 名称、峰值性能、kernel ID 或 latency。 +组合满足: + +```text +world_size = TP × PP × DP +local_batch = global_batch / DP +microbatch_count = global_batch / (DP × microbatch_size) +blocks_per_virtual_chunk = block_count / (PP × virtual_stages) +``` + +所有除法都必须为整数。完整类型、schedule constructor 与 Megatron 映射见 [Transformer 并行策略的类型化表示](../ir/parallel-strategy.md)。 + ## 静态工作量推导 `derive_transformer_block()` 把一个 block 分解为强类型 `PrimitiveInvocation`。每个 invocation 都带有 phase、engine class、精确 operations、精确 read/write bytes;如果它是 collective,还会带有 collective kind 和逻辑 message bytes。 分析遵循数据依赖,而不是拟合比例。对于线性层 `Y[M,K] = X[M,N] x W[N,K]`,forward、activation-gradient 和 weight-gradient 是三个显式矩阵乘。Attention、normalization、activation、dropout、residual 与 optimizer work 也分别表示。 +```text +F_forward = F_dgrad = F_wgrad = 2 M N K +F_local_tensor_parallel = 2 M N K / TP +``` + 重计算同样是结构语义。Full recomputation 会克隆所需的 forward invocation;selective recomputation 只克隆被选择的 attention path。Sequence-parallel recommunication 是独立的 collective invocation。因此,每一项新增 operation 与 byte 都能追溯到明确的语义原因。 ## 分布式推导 -`DistributeTransformerTrainingPass` 消费 `ModelIR`、workload binding 与 strategy binding,并引入: +`DistributeTransformerTrainingPass` 定义在目标层的 `stages/distributed/passes.py`。它消费 `ModelIR`、workload binding 与 strategy binding,并通过 `match` 解构 typed TP/PP/DP strategy、local/collective invocation 和 `TaskBody` ADT,然后引入: - 逻辑 TP mesh 与逻辑 rank; - forward、recompute、backward、optimizer 和 recommunication task; @@ -59,7 +75,7 @@ Pass 必须保持工作负载语义,并满足以下检查: ## 可移植计划推导 -`PlanTransformerTrainingPass` 把每个 distributed task 转换成 `PlanTask`。它保留 `WorkloadFacts`,声明抽象 resource demand,创建基于 capability 的 implementation requirement,分配逻辑 concurrency group,并引入 boundary buffer 与 objective。 +`PlanTransformerTrainingPass` 定义在 `stages/portable_plan/passes.py`,把每个 distributed task 转换成 `PlanTask`。它保留 `WorkloadFacts`,声明抽象 resource demand,创建基于 capability 的 implementation requirement,分配逻辑 concurrency group,并引入 boundary buffer 与 objective。 Pass 可以声明 task 需要 `matrix-multiply`、`vector-elementwise` 或某种 collective capability,但不能选择 CUDA kernel、LPU opcode、physical device、memory bank、queue 或 duration。这些决策属于 portable-to-concrete gate。 @@ -84,6 +100,15 @@ Observer 可以把这些 facts 与 framework trace 或 reference model 对比并 推导不会通过读取参考 latency 或增加 case-specific coefficient 来掩盖差异。任何不一致都必须定位到 semantic import、work derivation、distribution、cost evidence 或 schedule composition,并在对应边界修复。 +## 论文与公式来源 + +- [Megatron-LM 2019](https://arxiv.org/abs/1909.08053):Transformer column/row tensor-parallel partition 与 collective boundary。 +- [Megatron-LM 2021](https://arxiv.org/abs/2104.04473):TP × PP × DP 组合、1F1B 和 interleaved pipeline。 +- [GPipe](https://arxiv.org/abs/1811.06965):microbatch pipeline 和 bubble 基础。 +- [Selective recomputation and sequence parallelism](https://arxiv.org/abs/2205.05198):selective recomputation 与 RS/AG sequence-parallel 语义。 + +引用说明设计来源,不替代 verifier。公式的实际实现由 workload conservation、lineage、round-trip、deterministic replay 与 Calculon regression gate 检查。 + ## 实现映射 | 关注点 | 源码 | 测试 | @@ -92,7 +117,10 @@ Observer 可以把这些 facts 与 framework trace 或 reference model 对比并 | 逻辑 mapping contract | `src/blueprinting/mapping/transformer.py` | boundary 与 validation tests | | Workload-to-IR frontend | `src/blueprinting/synthesizer/frontend/transformer.py` | canonical representation 与 calibration tests | | 工作量代数 | `src/blueprinting/synthesizer/dialects/transformer/training.py` | `tests/validation/test_calculon.py` | -| 两个 derivation pass | `src/blueprinting/synthesizer/lowering/transformer.py` | `tests/synthesizer/test_transformer_training.py` 与 calibration tests | +| typed TP/PP/DP strategy | `src/blueprinting/mapping/transformer.py` | `tests/analysis/test_domain_contracts.py` | +| 分布式 pass 定义 | `src/blueprinting/synthesizer/stages/distributed/passes.py` | `tests/synthesizer/test_transformer_training.py` | +| Portable pass 定义 | `src/blueprinting/synthesizer/stages/portable_plan/passes.py` | 同上 | +| 纯推导与 pattern matching | `src/blueprinting/synthesizer/dialects/transformer/training_derivation.py` | 同上与 calibration tests | | 事务与 checkpoint | `src/blueprinting/synthesizer/passes/base.py` | `tests/synthesizer/test_pass_manager.py` | | Evidence-derived estimate | `src/blueprinting/analysis/cost_model.py` | validation tests | | Calculon/SeqSel oracle gate | `src/blueprinting/validation/calculon.py` | `tests/validation/test_calculon.py` | diff --git a/docs/design/performance/providers.en.md b/docs/design/performance/providers.en.md index 644ea6d..275dd12 100644 --- a/docs/design/performance/providers.en.md +++ b/docs/design/performance/providers.en.md @@ -140,7 +140,7 @@ AIConfigurator's final `best_config_topn.csv` and Pareto outputs describe servin ## Using the resolver in inference costing -Static inference can use the new resolver while the legacy `InferenceCostProvider` seam remains compatible: +Static inference uses the same resolver as every other production cost path: ```python resolver = CostResolver(( @@ -168,7 +168,7 @@ estimate = estimate_inference_phase( ) ``` -The application boundary also accepts `cost_resolver` and `cost_context`, so evidence promoted into the database no longer needs to bypass `InferenceAnalysisService`. The legacy `InferenceCostProvider` remains as a mutually exclusive compatibility seam. Provider/source revisions, raw record IDs, method, match, assumptions, and per-task uncertainty returned by the resolver reach application task reports; without a correlation model, the service does not invent phase-level variance. +The application boundary also accepts `cost_resolver` and `cost_context`, so evidence promoted into the database does not bypass `InferenceAnalysisService`. The former inference-only provider seam has been removed: `CostResolver` is the single production evidence path, while `InferenceBaseline.lookup()` remains comparison-only. Provider/source revisions, raw record IDs, method, match, assumptions, and per-task uncertainty returned by the resolver reach application task reports; without a correlation model, the service does not invent phase-level variance. Inference task queries are derived from canonical `PlanTask.workload`; GEMM dimensions and local attention-head dimensions are derived from model and TP facts. Tensor-parallel collectives and pipeline P2P use the same resolver. All tasks must be covered by an installed provider—normally an exact database followed by roofline—so an unknown task never becomes zero. diff --git a/docs/design/performance/providers.zh.md b/docs/design/performance/providers.zh.md index 1096027..480917f 100644 --- a/docs/design/performance/providers.zh.md +++ b/docs/design/performance/providers.zh.md @@ -140,7 +140,7 @@ AIConfigurator 的最终 `best_config_topn.csv` 与 Pareto output 描述的是 s ## 在 Inference Costing 中使用 Resolver -Static inference 已可使用新 resolver,同时保留 legacy `InferenceCostProvider` seam: +Static inference 与其他 production cost path 使用同一个 resolver: ```python resolver = CostResolver(( @@ -168,7 +168,7 @@ estimate = estimate_inference_phase( ) ``` -Application 入口同样接受 `cost_resolver` 与 `cost_context`,因此导入 database 的证据不需要绕过 `InferenceAnalysisService`。Legacy `InferenceCostProvider` 仍作为互斥的兼容 seam 保留。Resolver 返回的 provider/source revision、raw record ID、method、match、assumption 与逐 task uncertainty 会进入 application task report;在没有相关性模型时,service 不会擅自合成 phase-level variance。 +Application 入口同样接受 `cost_resolver` 与 `cost_context`,因此导入 database 的证据不需要绕过 `InferenceAnalysisService`。原有 inference-only provider seam 已删除:`CostResolver` 是唯一 production evidence path,`InferenceBaseline.lookup()` 只用于 comparison。Resolver 返回的 provider/source revision、raw record ID、method、match、assumption 与逐 task uncertainty 会进入 application task report;在没有相关性模型时,service 不会擅自合成 phase-level variance。 Inference task query 直接从 canonical `PlanTask.workload` 推导;GEMM dimension 与 local attention-head dimension 来自 model 和 TP facts。Tensor-parallel collective 与 pipeline P2P 使用同一个 resolver。所有 task 都必须被已安装的 provider 覆盖——通常是 exact database 后接 roofline——unknown task 不会被静默变成零。 diff --git a/docs/design/timeline-path.en.md b/docs/design/timeline-path.en.md index 9db578c..55457db 100644 --- a/docs/design/timeline-path.en.md +++ b/docs/design/timeline-path.en.md @@ -66,7 +66,7 @@ Although a timeline is not canonical execution truth, it still creates the first 5. it validates the planning contract with a virtual target and replay before a real target ABI exists; and 6. it leaves an entry point for continued lowering from the same concrete plan into an LPU backend. -It is a staged hardware-exploration product, not a redefinition of the project as a timeline compiler. +It is a staged hardware-exploration product, not a redefinition of the project as a timeline blueprinting. ## Stages and acceptance gates @@ -97,7 +97,7 @@ The first two levels do not need to wait for LPU hardware. Conversely, interest The common layer should express only a coordination kernel that is stable across targets: identity, dependencies, resource claims, buffer references, synchronization, and lineage. Spatial dataflow, routes, issue slots, collective micro-protocols, and special memory movement belong in a namespaced **typed target extension** verified by the target plugin. -The current `ConcretePlanIR` v1 has only a generic device/queue/buffer/command schema. It has neither this typed extension nor a production producer. It is an implementation scaffold, not a frozen cross-target ABI. Before introducing the first non-queue-centric virtual target, the common core must be tested against the risk of forcing every architecture into a GPU-stream model. +The current `ConcretePlanIR` v1 now has typed queue-order and slot/dataflow extensions with deterministic virtual reference binders. This closes the schema-level queue-centric versus queue-free test, but not production scheduling, resource occupancy, target-plugin legality, simulation, or emission. It remains an implementation scaffold rather than a frozen cross-target ABI. ## How each stage connects to observation diff --git a/docs/design/timeline-path.zh.md b/docs/design/timeline-path.zh.md index 9ffaa25..37c785c 100644 --- a/docs/design/timeline-path.zh.md +++ b/docs/design/timeline-path.zh.md @@ -97,7 +97,7 @@ LPU 在 Blueprinting 中首先是 architecture candidate,其次才是 executab 共同层只应该表达跨 target 稳定的 coordination kernel:identity、dependency、resource claim、buffer reference、synchronization 与 lineage。Spatial dataflow、route、issue slot、collective micro-protocol 或特殊 memory movement 必须进入 namespaced **typed target extension**,并由 target plugin 验证。 -当前 `ConcretePlanIR` v1 只有通用 device/queue/buffer/command schema,还没有这种 typed extension,也没有 production producer。它是实现骨架,不是已经冻结的跨 target ABI。引入第一个 non-queue-centric virtual target 前,必须先验证公共 core 不会把所有架构强行拟合成 GPU stream 模型。 +当前 `ConcretePlanIR` v1 已有 typed queue-order/slot-dataflow extension 与 deterministic virtual reference binder,因此完成了 schema 层对 queue-centric 和 queue-free target 的对照验证;但 production scheduling、resource occupancy、target-plugin legality、simulation 与 emission 仍未完成。它依然是实现骨架,不是已经冻结的跨 target ABI。 ## 每层如何与观测联动 diff --git a/docs/design/typed-python-contracts.en.md b/docs/design/typed-python-contracts.en.md new file mode 100644 index 0000000..4cc1e72 --- /dev/null +++ b/docs/design/typed-python-contracts.en.md @@ -0,0 +1,120 @@ +# Progressive Typed Python Contracts + +Blueprinting uses a typed, algebraic subset of Python as its implementation language for formal modeling and verified derivation. This is not a second IR hierarchy and it is not an attempt to turn Python into Haskell. The design keeps ordinary Python values and tooling while making legal constructors, explicit effects, closed alternatives, and checked failure paths visible in source. + +The implementation has two independent gates and one shared declaration source: + +```text +Python annotations + frozen records + ADT declarations + pass contracts + ├── runtime ContractCompiler (always available) + └── standard mypy analysis (optional dependency) +``` + +Runtime declarations are authoritative. Installing mypy adds function-body, flow-sensitive, and exhaustiveness analysis; it does not change runtime semantics or introduce a second type model. Blueprinting does not require a custom mypy plugin. + +## Layer 1: typed runtime values + +`@record` derives frozen/slotted canonical product types and performs annotation-driven structural checks. `@enum` registers a closed canonical enumeration without exposing the codec registry. `@adt` and `@variant` define a semantic constructor family; an explicit union alias plus `seal_adt` closes the family. `@record` and `@adt` own dataclass derivation, so stacking either with `@dataclass` is rejected rather than silently bypassing structural checks: + +```python +@adt(wire="blueprinting.binding.workload-mode") +class WorkloadMode: + pass + + +@variant("training") +class TrainingWorkload(WorkloadMode): + pass + + +@variant("inference") +class InferenceWorkload(WorkloadMode): + phase: InferencePhase + + +WorkloadModeVariant = TrainingWorkload | InferenceWorkload +seal_adt(WorkloadMode, WorkloadModeVariant) +``` + +This construction makes `InferenceWorkload` without a phase unrepresentable. The same pattern now owns portable task bodies and cost-provider support results. Derived properties such as `PlanTask.kind` are read-only projections; they are not serialized discriminators or parallel canonical schemas. + +Open-world semantics remain open. Target plugins and evidence providers continue to use `Protocol` and explicit registries because their constructor set cannot be sealed by the core package. + +## Layer 2: checked effects and contract compilation + +Expected failure uses `Result[T, E]`; Blueprinting's common specialization is `Checked[T] = Result[T, DiagnosticSet]`. `Ok(value, warnings)` and `Err(diagnostics)` make control flow and diagnostic accumulation explicit. Exceptions remain appropriate for programmer errors and at named adapters such as `require_from_json`, `require_run`, and `CostResolver.require`. + +Canonical boundaries use the checked form directly: + +```python +decoded: Checked[ModelIR] = ModelIR.from_json(payload) +verified: Checked[ModelIR] = model.verify() +derived: Checked[PipelineResult[PortablePlanIR]] = manager.run( + pipeline, + model, + session=session, +) +``` + +`ContractCompiler` imports the built-in declaration owners and compiles canonical codec entries, complete record field shapes and defaults, enum members, sealed ADT closures, and pass contracts into one immutable manifest and digest. It rejects unsealed/incomplete families, duplicate pass identities or loaded revisions, cross-stage passes without lineage rules, independent relation invariants, or a complete normalizer, and passes that can commit without output verification. A field annotation, order, constructor mode, default, enum member, or ADT constructor change therefore changes the contract digest. + +Every changed successful cross-stage pass transition has executable semantic evidence. Canonical construction equality is reported separately from relation verification: `canonical_conformant` means a same-stage result matches its declared normalizer, while `relation_verified` means every materialized relation passed an independent invariant or preservation claim. A same-stage no-op may be `structural_only`; a changed transition without a law fails and cannot publish analyses, observers, or checkpoints. There is no successful `unverified` status. + +Run the runtime gate with only normal project dependencies: + +```bash +uv sync --locked +uv run python scripts/check_type_contracts.py +``` + +This gate checks loaded declarations and runtime contracts. It does not parse arbitrary Python modules or prove branch reachability inside function bodies. + +## Layer 3: optional static analysis + +The optional `typing` dependency group installs standard mypy: + +```bash +uv sync --locked --group typing +uv run mypy src/blueprinting +``` + +Mypy checks annotations across modules, generic `Result` composition, union narrowing, and closed-match exhaustiveness. Strict mode is enabled first for the schema/contract foundation and selected canonical modules, then expanded progressively. The base test job deliberately runs without the `typing` group; a separate CI job runs mypy. The wheel contains `py.typed`, so downstream type checkers can consume Blueprinting's inline annotations without making mypy a runtime dependency. + +No custom plugin is currently justified. A plugin would couple correctness to one checker API and duplicate runtime declaration logic. It should be reconsidered only if standard annotations cannot express a concrete, measured invariant after the runtime contract has already been defined. + +## Derivation kernel and domain migrations + +Transformation semantics remain pure functions of a source snapshot and explicit `SynthesisSession`. `@derivation` records the exact source/output schemas, revision, required bindings and analyses, rules, determinism policy, and canonical normalizer. The pass manager re-evaluates that normalizer and requires complete snapshot equality before commit, then runs independently declared relation invariants. Normal-form equality establishes implementation conformance; it is not counted as relation evidence. + +The first migration slice removes three invalid-state patterns: + +| Previous representation | Canonical algebraic representation | +|---|---| +| workload enum plus optional inference phase | `TrainingWorkload | InferenceWorkload(phase)` | +| flat TP/PP/DP strategy fields | canonical `Transformer*Parallelism` product containing typed axes and schedule ADT | +| portable task kind field | `ComputeTask | CollectiveTask | TransferTask | BarrierTask | HostTask` body | +| support status plus conditionally meaningful fields | `CostAvailable | CostUnavailable(missing_fields) | InvalidCostSupport` | +| collective kind plus optional reduction/root | `AllReduce(reduction) | ReduceScatter(reduction) | AllGather | AllToAll | Broadcast(root)` | + +The existing five IR roots remain in schema epoch `0.0.0`. These changes define the unpublished initial epoch; they do not invent migration edges or compatibility history. + +## Authoring rules + +When adding or changing a formal type: + +1. Use a frozen typed product when all fields coexist; use a sealed ADT when alternatives own different data. +2. Keep semantic tags explicit and versionless beneath the owning root schema. +3. Match closed consumers directly on an explicit union alias and close them with `typing_extensions.assert_never`; runtime does not register consumer metadata that cannot prove function-body exhaustiveness. +4. Return `Checked` for expected validation, decoding, resolution, or derivation failure; unwrap only at a named process/application boundary. +5. Keep target/provider extension points open through `Protocol` or registries rather than pretending their world is closed. +6. Run runtime contract compilation, focused positive/negative/round-trip tests, Ruff, and the optional static gate. + +Metaprogramming is intentionally narrow: decorators derive mechanical structure and manifests, but do not wrap execution, inspect stack frames, synthesize hidden business rules, or replace explicit normalizers and verifiers. + +Decorator surfaces are separated by audience. Plain `blueprinting.schema` and `blueprinting.synthesizer.passes` provide values/codecs and the transaction runner without exporting authoring decorators. Core schema and trusted dialect authors import `record/enum/adt/variant` from `blueprinting.schema.authoring`; pass and target-extension authors import `derivation/relation/claim` from `blueprinting.synthesizer.passes.authoring`. Codec registries, manifest compilation, and pass registries remain implementation details. + +## Current limits + +This layer improves Python's ability to express compiler-like invariants, but it is not a proof assistant. Runtime closure checks confirm exact registered family membership; mypy checks closed matches it actually analyzes when they use an explicit union and `assert_never`. Neither proves a scientific formula correct. Semantic confidence still comes from independent executable invariants, canonical construction checks, negative mutation tests, lineage, evidence provenance, and reproducible experiments. + +The runtime compiler currently covers built-in declarations loaded by Blueprinting. Future third-party target plugins need an explicit registration/conformance contract before they can contribute to the compiled manifest. No such general target-plugin registry is claimed as implemented today. diff --git a/docs/design/typed-python-contracts.zh.md b/docs/design/typed-python-contracts.zh.md new file mode 100644 index 0000000..bb98ebf --- /dev/null +++ b/docs/design/typed-python-contracts.zh.md @@ -0,0 +1,120 @@ +# 渐进式 Typed Python Contract + +Blueprinting 使用 Python 的 typed/algebraic 子集实现形式化建模与 verified derivation。这不是第二套 IR hierarchy,也不是试图把 Python 变成 Haskell;目标是在保留普通 Python value 与工具链的同时,让合法 constructor、显式 effect、封闭 alternative 与 checked failure path 直接出现在源码中。 + +实现包含两个彼此独立的 Gate,并共享同一份声明来源: + +```text +Python annotation + frozen record + ADT declaration + pass contract + ├── runtime ContractCompiler(始终可用) + └── standard mypy analysis(可选依赖) +``` + +Runtime declaration 是事实源。安装 mypy 后会增加 function body、flow-sensitive 与 exhaustiveness analysis,但不会改变 runtime semantic,也不会建立第二套类型模型。Blueprinting 不依赖自定义 mypy plugin。 + +## 第一层:Typed Runtime Value + +`@record` 派生 frozen/slotted canonical product type,并执行 annotation-driven structural check;`@enum` 注册 closed canonical enumeration,同时不暴露 codec registry。`@adt` 与 `@variant` 定义 semantic constructor family;显式 union alias 与 `seal_adt` 共同封闭该 family。`@record` 与 `@adt` 自己负责 dataclass derivation,因此若再叠加 `@dataclass` 会直接拒绝,避免悄然绕过 structural check: + +```python +@adt(wire="blueprinting.binding.workload-mode") +class WorkloadMode: + pass + + +@variant("training") +class TrainingWorkload(WorkloadMode): + pass + + +@variant("inference") +class InferenceWorkload(WorkloadMode): + phase: InferencePhase + + +WorkloadModeVariant = TrainingWorkload | InferenceWorkload +seal_adt(WorkloadMode, WorkloadModeVariant) +``` + +因此“不带 phase 的 `InferenceWorkload`”无法被表示。同一模式现在也用于 portable task body 和 cost-provider support result。`PlanTask.kind` 等 derived property 只是只读 projection,不是 serialized discriminator,也不是平行 canonical schema。 + +Open-world semantic 保持开放。Target plugin 与 evidence provider 继续使用 `Protocol` 和显式 registry,因为 core package 无法封闭它们的 constructor 集合。 + +## 第二层:Checked Effect 与 Contract Compilation + +预期失败使用 `Result[T, E]`;Blueprinting 的常用特化是 `Checked[T] = Result[T, DiagnosticSet]`。`Ok(value, warnings)` 与 `Err(diagnostics)` 使控制流和 diagnostic accumulation 显式化。Programmer error 仍使用异常;`require_from_json`、`require_run` 与 `CostResolver.require` 等具名 adapter 是明确的异常边界。 + +Canonical boundary 直接采用 checked form: + +```python +decoded: Checked[ModelIR] = ModelIR.from_json(payload) +verified: Checked[ModelIR] = model.verify() +derived: Checked[PipelineResult[PortablePlanIR]] = manager.run( + pipeline, + model, + session=session, +) +``` + +`ContractCompiler` 显式导入 built-in declaration owner,把 canonical codec entry、完整 record field shape/default、enum member、sealed ADT closure 与 pass contract 编译成一份 immutable manifest/digest。它会拒绝未封闭或不完整 family、重复 pass identity 或同时加载的 revision、缺少 lineage rule/独立 relation invariant/完整 normalizer 的跨层 pass,以及可能在不验证 output 的情况下 commit 的 pass。因此 field annotation、顺序、constructor mode、default、enum member 或 ADT constructor 的改变都会改变 contract digest。 + +每个发生变化且成功的跨层 pass transition 都有 executable semantic evidence。Canonical construction equality 与 relation verification 分开报告:`canonical_conformant` 表示同层结果符合声明的 normalizer;`relation_verified` 表示每个已产生 relation 都通过了独立 invariant 或 preservation claim。同层 no-op 可以是 `structural_only`;发生变化但没有 law 的 transition 直接失败,不能发布 analysis、observer 或 checkpoint。系统不存在成功的 `unverified` 状态。 + +只安装普通项目依赖即可运行 runtime Gate: + +```bash +uv sync --locked +uv run python scripts/check_type_contracts.py +``` + +该 Gate 检查已经加载的 declaration 与 runtime contract;它不会解析任意 Python module,也不会证明 function body 内部的 branch reachability。 + +## 第三层:可选 Static Analysis + +可选 `typing` dependency group 安装标准 mypy: + +```bash +uv sync --locked --group typing +uv run mypy src/blueprinting +``` + +Mypy 检查跨 module annotation、泛型 `Result` composition、union narrowing 与 closed-match exhaustiveness。Strict mode 先覆盖 schema/contract foundation 和选定 canonical module,再渐进扩大。Base test job 明确不安装 `typing` group;独立 CI job 运行 mypy。Wheel 包含 `py.typed`,因此 downstream type checker 可以读取 Blueprinting 的 inline annotation,而 mypy 不会成为 runtime dependency。 + +当前没有理由引入自定义 plugin。Plugin 会把 correctness 耦合到单一 checker API,并重复 runtime declaration logic。只有在 runtime contract 已经明确、且标准 annotation 仍无法表达某个经过实测的具体 invariant 时,才应重新评估。 + +## Derivation Kernel 与领域迁移 + +Transformation semantic 继续保持为 source snapshot 与显式 `SynthesisSession` 的纯函数。`@derivation` 记录精确 source/output schema、revision、required binding/analysis、rule、determinism policy 与 canonical normalizer。Pass manager 会重新求值 normalizer,在 commit 前要求完整 snapshot equality,然后执行独立声明的 relation invariant。Normal-form equality 证明 implementation conformance,不计作 relation evidence。 + +第一批迁移消除了三类非法状态: + +| 旧表示 | Canonical algebraic 表示 | +|---|---| +| workload enum + optional inference phase | `TrainingWorkload | InferenceWorkload(phase)` | +| 扁平 TP/PP/DP strategy field | 包含 typed axis 与 schedule ADT 的 canonical `Transformer*Parallelism` product | +| portable task kind field | `ComputeTask | CollectiveTask | TransferTask | BarrierTask | HostTask` body | +| support status + 条件性字段 | `CostAvailable | CostUnavailable(missing_fields) | InvalidCostSupport` | +| collective kind + optional reduction/root | `AllReduce(reduction) | ReduceScatter(reduction) | AllGather | AllToAll | Broadcast(root)` | + +现有五个 IR root 继续处于 `0.0.0` schema epoch。这些修改定义尚未发布的初始 epoch,不会虚构 migration edge 或 compatibility history。 + +## 编写规则 + +新增或修改形式化类型时: + +1. 所有字段同时存在时使用 frozen typed product;不同 alternative 拥有不同数据时使用 sealed ADT。 +2. Semantic tag 保持显式;nested type 不独立版本化,由所属 root schema 控制版本。 +3. Closed consumer 直接对显式 union alias 做 pattern match,并以 `typing_extensions.assert_never` 封闭;runtime 不注册无法证明函数体穷尽性的 consumer metadata。 +4. Validation、decode、resolution 或 derivation 的预期失败返回 `Checked`;只在具名 process/application boundary unwrap。 +5. Target/provider extension point 使用 `Protocol` 或 registry 保持开放,不伪装成 closed world。 +6. 运行 runtime contract compilation、相关 positive/negative/round-trip test、Ruff 与可选 static Gate。 + +元编程保持克制:decorator 只派生机械结构与 manifest,不包装执行、不检查 stack frame、不生成隐藏业务规则,也不替代显式 normalizer/verifier。 + +Decorator surface 按受众分层:普通 `blueprinting.schema` 与 `blueprinting.synthesizer.passes` 分别提供值/codec 和 transaction runner,不导出 authoring decorator;core schema 或 trusted dialect author 从 `blueprinting.schema.authoring` 导入 `record/enum/adt/variant`,pass/target extension author 从 `blueprinting.synthesizer.passes.authoring` 导入 `derivation/relation/claim`。Codec registry、manifest compiler 与 pass registry 属于内部实现。 + +## 当前限制 + +这套机制增强了 Python 表达 compiler-like invariant 的能力,但不是 proof assistant。Runtime closure check 确认精确的 registered family membership;mypy 对显式 union 与 `assert_never` 检查它实际分析到的 closed match。二者都不能证明科学公式正确。Semantic confidence 仍来自独立 executable invariant、canonical construction check、negative mutation test、lineage、evidence provenance 与可复现实验。 + +Runtime compiler 当前覆盖 Blueprinting 加载的 built-in declaration。未来第三方 target plugin 需要明确 registration/conformance contract 后才能贡献 compiled manifest;仓库目前不声称已经实现通用 target-plugin registry。 diff --git a/docs/experiments/calculon-calibration.en.md b/docs/experiments/calculon-calibration.en.md index ef5a64d..9ede87f 100644 --- a/docs/experiments/calculon-calibration.en.md +++ b/docs/experiments/calculon-calibration.en.md @@ -12,6 +12,36 @@ Model names, Calculon durations, paper measurements, and per-case corrections ar Across the eight SeqSel Table 5 cases, the current system-evidence path is numerically equivalent to Calculon at floating-point precision. This means Blueprinting's workload and analytical mapping path independently reproduces the reference work, system curves, and schedule semantics; it is not evidence of zero error on real hardware or proof of broad architecture-exploration coverage. Against the paper's reported measurements, MAPE is 3.65% and maximum absolute error is 8.87%. +## Experiment identity and metric definitions + +### Frozen provenance + +The machine-readable report schema is `blueprinting.calculon-calibration-experiment.v0`. The current oracle is vendored Calculon `0.1.0`, whose local Python source-tree SHA-256 is `c72cf8a0a0fc9f1fb9813a2248747d6242bbc664b665abe4b5fc6b6b18f5927b`; the hardware evidence revision is `eb1eb9fcc4a6e414e85b0252c23ea9ad2730aae2`. The JSON artifact also records SHA-256 values for every model, execution, and system input, plus the ModelIR, DistributedTaskIR, PortablePlanIR, and two PassCheckpoint digests for each case. + +The oracle is [Calculon](https://github.com/calculon-ai/calculon), and the paper holdout is Table 5 of Korthikanti et al., [Reducing Activation Recomputation in Large Transformer Models](https://arxiv.org/abs/2205.05198). Both Blueprinting estimates finish before Calculon runs; `test_oracle_runs_only_after_both_blueprinting_estimates` enforces this call order. + +### Error definitions + +For Blueprinting result $x_i$ and reference $r_i$, signed relative error and cross-case MAPE are + +$$ +e_i=100\frac{x_i-r_i}{r_i},\qquad +\operatorname{MAPE}=\frac{1}{N}\sum_{i=1}^{N}|e_i|. +$$ + +When both the reference and estimate for a component are zero, its error is defined as zero. A non-zero estimate against a zero reference is infinite and fails the gate. Memory additionally reports absolute byte error so a large capacity cannot hide a small byte mismatch in a percentage. + +Alignment is not total-only: every case checks 19 operation/memory/message/capacity workload metrics, total memory, nine timing components, iteration total, and the paper holdout. This yields 152 workload comparisons. + +### Coverage matrix + +| Model | TP | PP | DP | Global batch | Microbatch | Interleave | Two modes | +|---|---:|---:|---:|---:|---:|---:|---| +| Megatron-22B | 8 | 1 | 1 | 4 | 4 | 1 | full / seqsel | +| GPT-175B | 8 | 8 | 1 | 64 | 1 | 3 | full / seqsel | +| Turing-530B | 8 | 35 | 1 | 280 | 1 | 3 | full / seqsel | +| Megatron-1T | 8 | 64 | 1 | 512 | 1 | 1 | full / seqsel | + ## Experiment path ```text @@ -19,13 +49,13 @@ model.json | semantic import v ModelIR: transformer.decoder_training - | transformer-distribute-v2 + | transformer-distribute | - decompose Transformer primitives | - insert explicit TP collectives | - clone selective/full recomputation primitives v DistributedTaskIR: local TP block task DAG - | transformer-plan-work-v2 + | transformer-plan-work | - derive operations/read/write/message bytes | - do not bind GPU/LPU or write duration v @@ -131,6 +161,22 @@ Summary: - system-evidence versus paper MAPE: **3.65%**; - system-evidence versus paper maximum absolute error: **8.87%**. +### Component-level parity + +| Timing component | MAPE | Maximum absolute error | Maximum absolute time error | +|---|---:|---:|---:| +| forward | 0 | 0 | 0 s | +| backward | 1.53e-14% | 2.80e-14% | 7.11e-15 s | +| optimizer | 0 | 0 | 0 s | +| recompute | 0 | 0 | 0 s | +| tensor parallel | 6.49e-15% | 1.98e-14% | 8.88e-16 s | +| pipeline parallel | 0 | 0 | 0 s | +| data parallel | 0 | 0 | 0 s | +| recommunication | 0 | 0 | 0 s | +| pipeline bubble | 2.25e-15% | 1.80e-14% | 1.78e-15 s | + +All workload metrics and all eight memory totals match exactly; maximum absolute memory error is **0 bytes**. The non-zero timing differences are at the scale expected from floating-point reassociation rather than an observable model discrepancy. + The two largest seqsel cases err in the same direction. The next investigation should prioritize sequence-parallel collectives, large-scale topology, or differences in the paper's environment rather than adding model-specific coefficients. ## Reproduction and artifacts @@ -144,14 +190,18 @@ uv run python examples/calculon_calibration.py \ uv run pytest -m baseline_regression tests/regression ``` -The original eight parametrized training regressions remain in `tests/synthesizer/test_calculon_calibration.py`. The repository-level gate additionally runs all eight cases as one experiment and evaluates `data/validation/baseline_regression_contract.json`: workload and Calculon equivalence, memory, paper-error budgets, evidence revision, case identity, aggregate goldens, and every `PortablePlanIR` digest are frozen together. Updating a golden is a reviewed contract change; the gate has no automatic accept-current-output mode. +The eight parametrized training regressions live in `tests/validation/test_calculon.py`. The repository-level gate additionally runs all eight cases as one experiment and evaluates `data/validation/baseline_regression_contract.json`: report schema, oracle source digest, input provenance, oracle-isolation policy, workload/component/total Calculon equivalence, memory, paper-error budgets, evidence revision, case identity, aggregate goldens, and every `PortablePlanIR` digest are frozen together. Updating a golden is a reviewed contract change; the gate has no automatic accept-current-output mode. + +CI budgets cap workload, component timing, and Calculon total error at `1e-9%`, and absolute memory error at `1 byte`; current results are substantially tighter. The full machine-readable report is `examples/calculon_calibration_result.json`. Implementation map: - `workload/transformer.py`: typed workload and execution facts; - `synthesizer/frontend/transformer.py`: canonical import and binding adapter; - `synthesizer/dialects/transformer/training.py`: static operation/byte derivation; -- `synthesizer/lowering/transformer.py`: the two canonical derivation passes; +- `synthesizer/stages/distributed/passes.py`: the ModelIR-to-DistributedTaskIR pass contract; +- `synthesizer/stages/portable_plan/passes.py`: the DistributedTaskIR-to-PortablePlanIR pass contract; +- `synthesizer/dialects/transformer/training_derivation.py`: pure derivation and pattern matching; - `analysis/cost_model.py`: peak-only and evidence-backed views; - `validation/calculon.py`: oracle adapter, audit, and report. - `validation/regression.py`: strict cross-domain baseline gate and diagnostics. diff --git a/docs/experiments/calculon-calibration.zh.md b/docs/experiments/calculon-calibration.zh.md index ed5abdc..a379a28 100644 --- a/docs/experiments/calculon-calibration.zh.md +++ b/docs/experiments/calculon-calibration.zh.md @@ -12,6 +12,36 @@ 在 8 个 SeqSel Table 5 case 上,当前 system-evidence path 与 Calculon 在 floating-point 精度内数值等价。这表示 Blueprinting 的 workload/analytical mapping path 独立复现了 reference work、system curve 与 schedule semantic;它既不是“真实硬件误差为零”的证据,也不能证明已具备广泛 architecture-exploration coverage。相对于论文报告的实测值,MAPE 为 3.65%,最大绝对误差为 8.87%。 +## 实验身份与指标定义 + +### 冻结的 provenance + +本报告的 machine-readable schema 是 `blueprinting.calculon-calibration-experiment.v0`。当前 oracle 是 vendored Calculon `0.1.0`,本地 Python source-tree SHA-256 为 `c72cf8a0a0fc9f1fb9813a2248747d6242bbc664b665abe4b5fc6b6b18f5927b`;硬件 evidence revision 为 `eb1eb9fcc4a6e414e85b0252c23ea9ad2730aae2`。JSON 产物还逐 case 保存 model、execution 与 system 输入文件的 SHA-256,以及 ModelIR、DistributedTaskIR、PortablePlanIR 和两个 PassCheckpoint digest。 + +Oracle 为 [Calculon](https://github.com/calculon-ai/calculon),论文 holdout 来自 Korthikanti et al. 的 [Reducing Activation Recomputation in Large Transformer Models](https://arxiv.org/abs/2205.05198) Table 5。Blueprinting 的两个 estimate 会先完成,之后才运行 Calculon;`test_oracle_runs_only_after_both_blueprinting_estimates` 对调用顺序执行负担明确的测试。 + +### 误差定义 + +对 Blueprinting 结果 $x_i$ 和 reference $r_i$,逐 case signed relative error 与跨 case MAPE 为: + +$$ +e_i=100\frac{x_i-r_i}{r_i},\qquad +\operatorname{MAPE}=\frac{1}{N}\sum_{i=1}^{N}|e_i|. +$$ + +当一个 component 的 reference 与 estimate 均为零时,其误差定义为零;若 reference 为零而 estimate 非零,则记为无穷大并使 gate 失败。Memory 另报告绝对 byte error,避免大容量使微小 byte 差异在百分比中消失。 + +对齐不是只看 total:每个 case 检查 19 个 operation/memory/message/capacity workload metric、总 memory、9 个 timing component、iteration total 和论文 holdout。共计 152 个 workload comparison。 + +### 覆盖矩阵 + +| Model | TP | PP | DP | Global batch | Microbatch | Interleave | 两种模式 | +|---|---:|---:|---:|---:|---:|---:|---| +| Megatron-22B | 8 | 1 | 1 | 4 | 4 | 1 | full / seqsel | +| GPT-175B | 8 | 8 | 1 | 64 | 1 | 3 | full / seqsel | +| Turing-530B | 8 | 35 | 1 | 280 | 1 | 3 | full / seqsel | +| Megatron-1T | 8 | 64 | 1 | 512 | 1 | 1 | full / seqsel | + ## 实验路径 ```text @@ -19,13 +49,13 @@ model.json | semantic import v ModelIR: transformer.decoder_training - | transformer-distribute-v2 + | transformer-distribute | - decompose Transformer primitives | - insert explicit TP collectives | - clone selective/full recomputation primitives v DistributedTaskIR: local TP block task DAG - | transformer-plan-work-v2 + | transformer-plan-work | - derive operations/read/write/message bytes | - do not bind GPU/LPU or write duration v @@ -131,6 +161,22 @@ System profile:`a100_80g`;8 个 case 共用一个 evidence revision。 - system-evidence vs 论文 MAPE:**3.65%**; - system-evidence vs 论文最大绝对误差:**8.87%**。 +### Component-level parity + +| Timing component | MAPE | 最大绝对误差 | 最大绝对时间误差 | +|---|---:|---:|---:| +| forward | 0 | 0 | 0 s | +| backward | 1.53e-14% | 2.80e-14% | 7.11e-15 s | +| optimizer | 0 | 0 | 0 s | +| recompute | 0 | 0 | 0 s | +| tensor parallel | 6.49e-15% | 1.98e-14% | 8.88e-16 s | +| pipeline parallel | 0 | 0 | 0 s | +| data parallel | 0 | 0 | 0 s | +| recommunication | 0 | 0 | 0 s | +| pipeline bubble | 2.25e-15% | 1.80e-14% | 1.78e-15 s | + +所有 workload metric 和 8 个 memory total 都是 exact match;memory 最大绝对误差为 **0 byte**。非零 timing 差异的量级符合不同浮点运算结合顺序造成的 roundoff,而不是可观察的模型误差。 + 两个最大 seqsel case 的误差方向一致。下一轮应优先检查 sequence-parallel collective、大规模 topology 或论文环境差异,而不是增加 model-specific coefficient。 ## 复现方式与产物 @@ -144,14 +190,18 @@ uv run python examples/calculon_calibration.py \ uv run pytest -m baseline_regression tests/regression ``` -原有的 8 组参数化训练回归仍保留在 `tests/synthesizer/test_calculon_calibration.py`。仓库级 gate 还会把 8 个 case 作为一个完整实验运行,并检查 `data/validation/baseline_regression_contract.json`:workload/Calculon 等价性、memory、论文误差预算、evidence revision、case identity、aggregate golden 与每个 `PortablePlanIR` digest 被一起冻结。更新 golden 是必须经过 review 的 contract 变更;gate 不提供自动“接受当前输出”的模式。 +8 组参数化训练回归位于 `tests/validation/test_calculon.py`。仓库级 gate 还会把 8 个 case 作为一个完整实验运行,并检查 `data/validation/baseline_regression_contract.json`:report schema、oracle source digest、输入 provenance、oracle 隔离策略、workload/component/total Calculon 等价性、memory、论文误差预算、evidence revision、case identity、aggregate golden 与每个 `PortablePlanIR` digest 被一起冻结。更新 golden 是必须经过 review 的 contract 变更;gate 不提供自动“接受当前输出”的模式。 + +CI 精度预算为 workload、component timing、Calculon total 均不超过 `1e-9%`,memory 最大绝对误差不超过 `1 byte`;当前结果显著严于这些阈值。完整机器报告位于 `examples/calculon_calibration_result.json`。 实现映射: - `workload/transformer.py`:typed workload 与 execution facts; - `synthesizer/frontend/transformer.py`:canonical import 与 binding adapter; - `synthesizer/dialects/transformer/training.py`:静态 operation/byte derivation; -- `synthesizer/lowering/transformer.py`:两个 canonical derivation pass; +- `synthesizer/stages/distributed/passes.py`:ModelIR 到 DistributedTaskIR 的 pass contract; +- `synthesizer/stages/portable_plan/passes.py`:DistributedTaskIR 到 PortablePlanIR 的 pass contract; +- `synthesizer/dialects/transformer/training_derivation.py`:纯推导与 pattern matching; - `analysis/cost_model.py`:peak-only 与 evidence-backed view; - `validation/calculon.py`:oracle adapter、audit 与 report。 - `validation/regression.py`:严格的跨域 baseline gate 与诊断。 diff --git a/docs/experiments/vidur-baseline.en.md b/docs/experiments/vidur-baseline.en.md index 308282d..e081a04 100644 --- a/docs/experiments/vidur-baseline.en.md +++ b/docs/experiments/vidur-baseline.en.md @@ -17,7 +17,7 @@ Transformer semantics + mapping + phase context -> coverage and error report ``` -`InferenceCostProvider.resolve()` is the extension point for an admissible Blueprinting performance database or hardware simulator. `InferenceBaseline.lookup()` is the external-oracle interface. `VidurProfileBaseline` implements only `lookup()`, so it cannot be supplied to `estimate_inference_phase()` by accident. +`CostResolver.resolve()` is the single extension point for an admissible Blueprinting performance database or hardware simulator. `InferenceBaseline.lookup()` is the external-oracle interface. `VidurProfileBaseline` implements only `lookup()`, so it cannot be supplied to `estimate_inference_phase()` by accident. The separate `VidurProfileImporter` can explicitly convert user-supplied profile rows into a `PerformanceDatabase`. That is a different workflow and policy decision: the resulting database affects costing only when its provider is deliberately installed in a `CostResolver`. This experiment continues to use `VidurProfileBaseline` only, so its oracle isolation is unchanged. diff --git a/docs/experiments/vidur-baseline.zh.md b/docs/experiments/vidur-baseline.zh.md index 2a0049d..3fc0ac2 100644 --- a/docs/experiments/vidur-baseline.zh.md +++ b/docs/experiments/vidur-baseline.zh.md @@ -17,7 +17,7 @@ Transformer semantics + mapping + phase context -> coverage and error report ``` -`InferenceCostProvider.resolve()` 是 Blueprinting 自有性能数据库或硬件仿真器的扩展点;`InferenceBaseline.lookup()` 是外部 oracle 接口。`VidurProfileBaseline` 只实现 `lookup()`,因此不能被意外传入 `estimate_inference_phase()`。 +`CostResolver.resolve()` 是 Blueprinting 自有性能数据库或硬件仿真器的唯一扩展点;`InferenceBaseline.lookup()` 是外部 oracle 接口。`VidurProfileBaseline` 只实现 `lookup()`,因此不能被意外传入 `estimate_inference_phase()`。 独立的 `VidurProfileImporter` 可以显式把用户提供的 profile row 转换成 `PerformanceDatabase`。这是另一条 workflow,也是一项明确 policy decision:只有用户刻意把该 database provider 安装进 `CostResolver`,它才会影响 costing。本实验仍只使用 `VidurProfileBaseline`,因此 oracle isolation 不变。 diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 0000000..5209b3c --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,19 @@ +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"]], + processEscapes: true, + processEnvironments: true, + }, + options: { + ignoreHtmlClass: ".*|", + processHtmlClass: "arithmatex", + }, +}; + +document$.subscribe(() => { + MathJax.startup.output.clearCache(); + MathJax.typesetClear(); + MathJax.texReset(); + MathJax.typesetPromise(); +}); diff --git a/docs/modeling/inference.en.md b/docs/modeling/inference.en.md index 213bf16..76a4812 100644 --- a/docs/modeling/inference.en.md +++ b/docs/modeling/inference.en.md @@ -102,7 +102,7 @@ case = VidurExperimentCase( report = run_vidur_experiment((case,), baseline) ``` -The API boundary is intentional: an admissible internal `InferenceCostProvider` exposes `resolve()`, while an external `InferenceBaseline` exposes `lookup()`. `run_vidur_experiment()` completes lowering and both Blueprinting cost modes before calling `lookup()`. Vidur therefore cannot alter operations, bytes, dependencies, the plan digest, or the estimated latency. +The API boundary is intentional: production evidence enters through `CostResolver.resolve()`, while an external `InferenceBaseline` exposes only `lookup()`. `run_vidur_experiment()` completes lowering and both Blueprinting cost modes before calling `lookup()`. Vidur therefore cannot alter operations, bytes, dependencies, the plan digest, or the estimated latency. Comparison is over an explicit semantic intersection. The report contains matched component count, coverage, Blueprinting's comparable subtotal, Vidur's comparable subtotal, excluded Blueprinting work, signed comparable-subtotal error, and non-cancelling component MAPE/max error. Missing records remain `not-covered`; they are never converted to zero. This matters because Vidur's public block aggregation has one `add_time`, whereas Blueprinting deliberately keeps both residual additions explicit, and the current CSV adapter does not yet ingest collective profiles. diff --git a/docs/modeling/inference.zh.md b/docs/modeling/inference.zh.md index 7ba423f..00cef07 100644 --- a/docs/modeling/inference.zh.md +++ b/docs/modeling/inference.zh.md @@ -102,7 +102,7 @@ case = VidurExperimentCase( report = run_vidur_experiment((case,), baseline) ``` -这个 API 边界是刻意设计的:Blueprinting 内部可接受的 `InferenceCostProvider` 暴露 `resolve()`,外部 `InferenceBaseline` 只暴露 `lookup()`。`run_vidur_experiment()` 会先完成 lowering 和两种 Blueprinting cost mode,再调用 `lookup()`;因此 Vidur 无法改变 operations、bytes、dependency、plan digest 或 estimated latency。 +这个 API 边界是刻意设计的:production evidence 通过 `CostResolver.resolve()` 进入,外部 `InferenceBaseline` 只暴露 `lookup()`。`run_vidur_experiment()` 会先完成 lowering 和两种 Blueprinting cost mode,再调用 `lookup()`;因此 Vidur 无法改变 operations、bytes、dependency、plan digest 或 estimated latency。 Comparison 只发生在显式 semantic intersection 上。Report 给出 matched component count、coverage、Blueprinting comparable subtotal、Vidur comparable subtotal、被排除的 Blueprinting work、signed comparable-subtotal error,以及不可相互抵消的 component MAPE/max error。缺失 record 保持 `not-covered`,绝不会被当作零。这个区别很重要:Vidur 公开的 block aggregation 只有一个 `add_time`,而 Blueprinting 刻意保留两个 residual addition;当前 CSV adapter 也尚未读取 collective profile。 diff --git a/docs/project/adr/0001-synthesizer-package.en.md b/docs/project/adr/0001-synthesizer-package.en.md index d92d111..ef5bae6 100644 --- a/docs/project/adr/0001-synthesizer-package.en.md +++ b/docs/project/adr/0001-synthesizer-package.en.md @@ -1,7 +1,7 @@ # ADR-0001: Name the Formal Derivation Package Synthesizer - Date: 2026-08-09 -- Status: Accepted +- Status: Accepted; codec-identity clauses superseded by ADR-0004 - Scope: Python package identity, public symbols, report vocabulary, and one serialized field name ## Context @@ -48,9 +48,9 @@ Use synthesis vocabulary for public implementation symbols: Keep `Pass`, `PassManager`, `lowering`, and `IR` where they describe precise borrowed mechanisms. External APIs such as Calculon's `model.compile()` and Python's `compile()` retain their names. -Preserve all existing `compiler.*` canonical codec tags and the `compilation-session` digest domain as stable wire identities. They are historical opaque identifiers, not current Python package names. Renaming those tags would invalidate otherwise unchanged snapshots and digests without adding semantic value. +The original package migration preserved the then-current codec tags and session digest domain. ADR-0004 supersedes that compatibility choice: current artifacts use domain-owned semantic identities and a `synthesis-session` digest domain. -Rename `TargetProfile.compiler_abi` to `target_abi` because the ABI belongs to a bound target, not to a Blueprinting Compiler component. The decoder accepts the legacy field as an alias, rejects payloads containing both spellings, and the encoder emits only `target_abi`. This intentional field-level schema change can alter digests of values containing a `TargetProfile`; target-neutral representation digests must remain unchanged. +Rename the former target ABI field to `target_abi` because the ABI belongs to a bound target. The initial migration accepted the previous spelling as a decoder alias; ADR-0004 removes that runtime alias, so current payloads use only `target_abi`. Report vocabulary distinguishes facts from predictions: exact work uses `derived_*`, timing and memory predictions use `estimated_*`, and the Blueprinting side of comparisons uses `blueprinting`. Calculon and Vidur report schemas advance to v2 because their emitted field names change. @@ -58,8 +58,8 @@ Report vocabulary distinguishes facts from predictions: exact work uses `derived - Imports from `blueprinting.compiler` fail immediately and downstream Python callers must migrate atomically. - Source navigation now exposes the intended split: `synthesizer` owns canonical derivation; `analysis` owns rebuildable evaluation and evidence resolution. -- Canonical snapshots using historical `compiler.*` tags remain readable. -- Legacy canonical JSON containing `compiler_abi` remains readable, but newly serialized target profiles and target-bound session fingerprints change. +- The initial package migration kept its contemporary snapshots readable; ADR-0004 later establishes an explicit wire-format boundary. +- Current target-profile payloads use only `target_abi`; obsolete field spellings are not accepted. - Pickle/module-path compatibility is not provided. Canonical JSON is the supported persistence boundary. - Existing v1 experiment report consumers must migrate to the v2 field names. @@ -67,20 +67,20 @@ Report vocabulary distinguishes facts from predictions: exact work uses `derived 1. Replace Python imports from `blueprinting.compiler` with `blueprinting.synthesizer`. 2. Replace the public symbols using the table above. -3. Replace `compiler_abi=` constructor arguments and attribute reads with `target_abi=` and `.target_abi`. +3. Use `target_abi=` constructor arguments and `.target_abi` attribute reads. 4. Update Calculon consumers from `compiled` to `blueprinting`, `compiled_breakdown_seconds` to `estimated_breakdown_seconds`, and `compiled_explicit_operations` to `derived_explicit_operations`. 5. Update Vidur consumers from `compiled_*` to the corresponding `estimated_*` fields. -6. Regenerate v2 experiment artifacts; do not rewrite old canonical input snapshots merely to replace their opaque codec tags. +6. Regenerate v2 experiment artifacts. ## Validation - A package-boundary test requires `blueprinting.synthesizer` to exist and `blueprinting.compiler` to be absent. - Public API tests require the new symbols and reject legacy re-exports. -- Canonical round-trip tests decode the legacy `compiler_abi` payload and verify that new output contains only `target_abi`. -- Golden target-neutral IR snapshots and baseline regression digests guard the preserved wire tags and digest domain. +- Canonical round-trip tests verify that target profiles encode only `target_abi`. +- Golden target-neutral IR snapshots and baseline regression digests guard the active semantic wire identities. - Calculon and Vidur tests guard the v2 report vocabulary and numerical equivalence. - Ruff, the full pytest suite, bilingual documentation parity, and strict MkDocs builds are release gates. ## Status -Accepted on 2026-08-09. This ADR governs the hard-cut migration delivered with the package rename. A future change to the package boundary, preserved codec tags, or alias policy requires a superseding ADR. +Accepted on 2026-08-09. ADR-0004 supersedes this ADR's codec-tag and field-alias compatibility clauses while retaining the `synthesizer` package decision. diff --git a/docs/project/adr/0001-synthesizer-package.zh.md b/docs/project/adr/0001-synthesizer-package.zh.md index b6164f9..630271d 100644 --- a/docs/project/adr/0001-synthesizer-package.zh.md +++ b/docs/project/adr/0001-synthesizer-package.zh.md @@ -1,7 +1,7 @@ # ADR-0001:将形式化推导包命名为 Synthesizer - 日期:2026-08-09 -- 状态:Accepted +- 状态:Accepted;codec identity 条款由 ADR-0004 取代 - 范围:Python package identity、public symbol、report vocabulary 与一个 serialized field name ## 背景 @@ -48,9 +48,9 @@ Public implementation symbol 使用 synthesis vocabulary: 当 `Pass`、`PassManager`、`lowering` 与 `IR` 精确描述借用机制时继续保留。Calculon 的 `model.compile()`、Python 的 `compile()` 等外部 API 也保持原名。 -保留所有既有 `compiler.*` canonical codec tag 与 `compilation-session` digest domain,把它们视为稳定 wire identity。它们是历史 opaque identifier,不是当前 Python package name。改写这些 tag 会在语义未变化时破坏 snapshot/digest,没有额外价值。 +最初的 package 迁移保留了当时已有的 codec tag 与 session digest domain。ADR-0004 取代这一兼容决策:当前产物使用按领域归属的语义化 identity 与 `synthesis-session` digest domain。 -将 `TargetProfile.compiler_abi` 重命名为 `target_abi`,因为 ABI 属于绑定后的 target,而不属于 Blueprinting Compiler 组件。Decoder 将旧 field 作为 alias 接受,遇到两种拼写同时出现时拒绝 payload;encoder 只输出 `target_abi`。这个有意的 field-level schema change 会改变包含 `TargetProfile` 的 value digest;target-neutral representation digest 必须保持不变。 +将原 target ABI field 重命名为 `target_abi`,因为 ABI 属于绑定后的 target。最初迁移曾让 decoder 接受旧拼写;ADR-0004 删除该 runtime alias,当前 payload 只使用 `target_abi`。 Report vocabulary 区分事实与预测:exact work 使用 `derived_*`,timing/memory prediction 使用 `estimated_*`,comparison 中 Blueprinting 一侧使用 `blueprinting`。Calculon 与 Vidur report 因输出 field name 改变升级为 v2。 @@ -58,8 +58,8 @@ Report vocabulary 区分事实与预测:exact work 使用 `derived_*`,timing - `blueprinting.compiler` import 立即失败,下游 Python caller 必须原子迁移。 - 源码结构明确表达目标边界:`synthesizer` 负责 canonical derivation,`analysis` 负责 rebuildable evaluation 与 evidence resolution。 -- 使用历史 `compiler.*` tag 的 canonical snapshot 仍可读取。 -- 包含 `compiler_abi` 的旧 canonical JSON 仍可读取,但新序列化 target profile 与 target-bound session fingerprint 会改变。 +- 最初的 package 迁移保持了当时 snapshot 的可读性;ADR-0004 后续建立明确的 wire-format 边界。 +- 当前 target-profile payload 只使用 `target_abi`,不再接受废弃 field 拼写。 - 不提供 pickle/module-path compatibility;canonical JSON 是受支持的 persistence boundary。 - 既有 v1 experiment report consumer 必须迁移到 v2 field name。 @@ -67,20 +67,20 @@ Report vocabulary 区分事实与预测:exact work 使用 `derived_*`,timing 1. 将 Python import 从 `blueprinting.compiler` 替换为 `blueprinting.synthesizer`。 2. 按上表替换 public symbol。 -3. 将 `compiler_abi=` constructor argument 与 attribute read 改为 `target_abi=` 和 `.target_abi`。 +3. Constructor argument 与 attribute read 使用 `target_abi=` 和 `.target_abi`。 4. Calculon consumer 将 `compiled` 改为 `blueprinting`、`compiled_breakdown_seconds` 改为 `estimated_breakdown_seconds`、`compiled_explicit_operations` 改为 `derived_explicit_operations`。 5. Vidur consumer 将 `compiled_*` 改为对应的 `estimated_*` field。 -6. 重新生成 v2 experiment artifact;不要仅为了替换 opaque codec tag 而改写旧 canonical input snapshot。 +6. 重新生成 v2 experiment artifact。 ## 验证 - Package-boundary test 要求 `blueprinting.synthesizer` 存在且 `blueprinting.compiler` 不存在。 - Public API test 要求新 symbol 存在,并拒绝 legacy re-export。 -- Canonical round-trip test 解码旧 `compiler_abi` payload,并验证新输出只包含 `target_abi`。 -- Golden target-neutral IR snapshot 与 baseline regression digest 守护被保留的 wire tag 与 digest domain。 +- Canonical round-trip test 验证 target profile 只编码 `target_abi`。 +- Golden target-neutral IR snapshot 与 baseline regression digest 守护当前语义化 wire identity。 - Calculon/Vidur test 守护 v2 report vocabulary 与数值等价性。 - Ruff、完整 pytest、双语文档一致性与 strict MkDocs build 是 release gate。 ## 状态 -本 ADR 于 2026-08-09 被接受,约束随 package rename 一起交付的 hard-cut migration。未来若修改 package boundary、保留的 codec tag 或 alias policy,必须创建 superseding ADR。 +本 ADR 于 2026-08-09 被接受。ADR-0004 取代本 ADR 的 codec-tag 与 field-alias 兼容条款;`synthesizer` package 决策继续有效。 diff --git a/docs/project/adr/0002-workload-system-domains.en.md b/docs/project/adr/0002-workload-system-domains.en.md index dce5539..15438a6 100644 --- a/docs/project/adr/0002-workload-system-domains.en.md +++ b/docs/project/adr/0002-workload-system-domains.en.md @@ -40,9 +40,9 @@ Move workload-to-canonical-state adapters into `blueprinting.synthesizer.fronten Rename `HardwareProfile` to `SystemProfile` and remove its re-export from `blueprinting.analysis`. Analysis selects whether to apply profile efficiency evidence through an explicit policy argument; the system package does not import or choose `CalibrationMode`. -Do not provide `blueprinting.synthesizer.models` or `blueprinting.analysis.SystemProfile` compatibility facades. The legacy `blueprinting.types.system` package remains only for the retained calculator path and is not an admissible dependency for new formal-analysis code. +Do not provide `blueprinting.synthesizer.models` or `blueprinting.analysis.SystemProfile` compatibility facades. The former `blueprinting.types.system` package was removed with the calculator path; supported code imports `blueprinting.system` directly. -Preserve the existing `compiler.transformer.*` and `compiler.analysis.*` codec tags, including `compiler.analysis.hardware_profile.v1`. They are opaque wire identities. The workload and system record fields remain unchanged, so canonical JSON and target-neutral plan digests remain stable. +The initial package split preserved its contemporary codec tags. ADR-0004 supersedes that choice: workload and system records now use `blueprinting.workload.*` and `blueprinting.system.*` semantic identities. `SystemProfile` is explicitly an evidence-bearing compute/memory/network adapter. It is not the future hierarchical `ArchitectureBlueprint`, a deployment description, or a target binding. @@ -52,7 +52,7 @@ Preserve the existing `compiler.transformer.*` and `compiler.analysis.*` codec t - Framework/model importers can grow under `workload` without becoming derivation passes. - Chip and interconnect contracts can evolve under `system` without being tied to roofline or database providers. - Existing Python callers must replace old package paths and the `HardwareProfile` class name. -- Existing canonical JSON remains readable because codec tags and fields are preserved; pickle/module-path compatibility is not supported. +- ADR-0004 defines the later wire-format boundary; pickle/module-path compatibility remains unsupported. - The current logical execution specs still combine workload scenario and mapping intent. Further separation into workload scenario and mapping strategy requires a later ADR if it changes serialized contracts. ## Migration diff --git a/docs/project/adr/0002-workload-system-domains.zh.md b/docs/project/adr/0002-workload-system-domains.zh.md index ecd1f2c..f7c9583 100644 --- a/docs/project/adr/0002-workload-system-domains.zh.md +++ b/docs/project/adr/0002-workload-system-domains.zh.md @@ -40,9 +40,9 @@ Blueprinting 在两个独立 domain input 之间推导 mapping:workload 与 ca 将 `HardwareProfile` 重命名为 `SystemProfile`,并移除 `blueprinting.analysis` 中的 re-export。Analysis 通过显式 policy argument 决定是否应用 profile efficiency evidence;system package 不导入也不选择 `CalibrationMode`。 -不提供 `blueprinting.synthesizer.models` 或 `blueprinting.analysis.SystemProfile` compatibility facade。Legacy `blueprinting.types.system` 只保留给旧 calculator path,新 formal-analysis code 不得依赖它。 +不提供 `blueprinting.synthesizer.models` 或 `blueprinting.analysis.SystemProfile` compatibility facade。原 `blueprinting.types.system` package 已随 calculator path 删除;受支持代码直接导入 `blueprinting.system`。 -保留现有 `compiler.transformer.*` 和 `compiler.analysis.*` codec tag,包括 `compiler.analysis.hardware_profile.v1`。它们是 opaque wire identity。Workload/system record field 不变,因此 canonical JSON 与 target-neutral plan digest 保持稳定。 +最初的 package split 保留了当时已有的 codec tag。ADR-0004 取代这一选择:workload 与 system record 现在分别使用 `blueprinting.workload.*` 和 `blueprinting.system.*` 语义 identity。 `SystemProfile` 被明确限定为 evidence-bearing compute/memory/network adapter;它不是未来 hierarchical `ArchitectureBlueprint`、deployment description 或 target binding。 @@ -52,7 +52,7 @@ Blueprinting 在两个独立 domain input 之间推导 mapping:workload 与 ca - Framework/model importer 可以在 `workload` 下扩展,而不会变成 derivation pass。 - Chip/interconnect contract 可以在 `system` 下演进,而不依赖 roofline/database provider。 - 现有 Python caller 必须迁移旧 package path 与 `HardwareProfile` class name。 -- Codec tag/field 被保留,因此旧 canonical JSON 仍可读取;不支持 pickle/module-path compatibility。 +- ADR-0004 定义后续 wire-format 边界;仍不支持 pickle/module-path compatibility。 - 当前 logical execution spec 仍混合 workload scenario 与 mapping intent。若后续拆分会改变 serialized contract,需要新的 ADR。 ## 迁移 diff --git a/docs/project/adr/0003-derivation-debug-trace.en.md b/docs/project/adr/0003-derivation-debug-trace.en.md new file mode 100644 index 0000000..c4b9d1f --- /dev/null +++ b/docs/project/adr/0003-derivation-debug-trace.en.md @@ -0,0 +1,28 @@ +# ADR-0003: Represent IR Debugging as a Derived Derivation Trace + +- Date: 2026-08-10 +- Status: Accepted +- Scope: IR visualization, lineage mapping, debug bundles, and derived overlays + +## Context + +Existing checkpoints preserve immutable IRs, digests, pass records, and lineage, but the workbench exposed only stage summaries and JSON. Inferring correspondence from names in the UI, or adding layout and cost to canonical IRs, would create a second representation semantics and weaken late binding. + +## Decision + +Introduce an application-owned, invalidatable, rebuildable `DerivationTrace`. Five adapters register by canonical schema. Layer graphs project typed fields only; adjacent-stage correspondence consumes target lineage only. Audit rules produce non-blocking diagnostics, while verifiers and observers retain transaction-acceptance authority. + +Cost, timing, and observations attach through detachable overlays carrying provider revisions. `blueprinting.derivation-debug-bundle.v0` stores one run's canonical snapshots and derived metadata and revalidates them on import. It is neither a sixth IR nor a replacement for `TimelineBundle`. + +Current production runs produce only the first three stages. Adapters and valid-import support for the final two must not be described as completed Concrete or Machine producers. + +## Rejected alternatives + +- Add UI coordinates, colors, collapse state, or cost fields to the five IRs; this contaminates canonical semantics and digests. +- Infer correspondence from display names, array positions, or operation names; this cannot represent decomposition, fusion, or generated entities. +- Make visualization audits blocking by default; the rules are not stable proof obligations for every dialect. +- Deliver breakpoints, snapshot mutation, and cross-run diff in the first version; these require separate execution-state, matching, and safety contracts. + +## Consequences and validation + +Application reports gain traces and analysis-report schemas advance to v2; legacy `stages` remain as a compatibility presentation. The debug bundle has an independent version and a 50 MiB limit. Tests cover all five adapters, lineage cardinality and mismatch, deterministic round trips, tamper rejection, large-graph grouping, and NiceGUI interactions. Canonical golden digests must remain unchanged. diff --git a/docs/project/adr/0003-derivation-debug-trace.zh.md b/docs/project/adr/0003-derivation-debug-trace.zh.md new file mode 100644 index 0000000..402d6bc --- /dev/null +++ b/docs/project/adr/0003-derivation-debug-trace.zh.md @@ -0,0 +1,28 @@ +# ADR-0003:将 IR 调试表示为派生 Derivation Trace + +- 日期:2026-08-10 +- 状态:Accepted +- 范围:IR visualization、lineage mapping、debug bundle 与 derived overlay + +## 背景 + +现有 checkpoint 保存了 immutable IR、digest、pass record 和 lineage,但工作台只能展示阶段摘要与 JSON。若界面自行按名称猜测映射,或把布局/cost 写入 canonical IR,就会创建第二套表示语义并破坏 late binding。 + +## 决策 + +建立 application-owned、可失效可重建的 `DerivationTrace`。五层 adapter 按 canonical schema 注册,层内图只投影 typed fields;相邻层映射只消费 target lineage。审计规则产生非阻断诊断,transaction acceptance 仍由 verifier/observer 决定。 + +Cost、timing 和 observation 通过带 provider revision 的 detachable overlay 接入。`blueprinting.derivation-debug-bundle.v0` 保存一次运行的 canonical snapshots 与派生 metadata,导入时重新验证。它不是第六层 IR,也不替代 `TimelineBundle`。 + +当前 production run 只产生前三层;后两层 adapter 与合法导入支持不得被描述为 Concrete/Machine producer 已完成。 + +## 被拒绝方案 + +- 在五层 IR 中增加 UI 坐标、颜色、折叠状态或 cost 字段;这会污染 canonical semantic 和 digest。 +- 按 display name、数组位置或 operation 名称推测跨层映射;这无法处理 decomposition、fusion 和 generated entity。 +- 让可视化审计默认阻断 pass;当前规则尚未成为所有 dialect 的稳定 proof obligation。 +- 首版同时实现 breakpoint、snapshot mutation 和双运行 diff;这需要独立执行状态、匹配与安全 contract。 + +## 影响与验证 + +Application report 增加 trace,分析报告 schema 升级到 v2;旧 `stages` 继续作为兼容展示。Debug bundle 有独立版本和 50 MiB 限制。五层 adapter、lineage cardinality、mismatch、deterministic round trip、tamper rejection、大图分组和 NiceGUI 交互由测试覆盖;canonical golden digest 不得改变。 diff --git a/docs/project/adr/0004-semantic-wire-identities.en.md b/docs/project/adr/0004-semantic-wire-identities.en.md new file mode 100644 index 0000000..74590f6 --- /dev/null +++ b/docs/project/adr/0004-semantic-wire-identities.en.md @@ -0,0 +1,63 @@ +# ADR-0004: Use Domain-Owned Semantic Wire Identities + +- Date: 2026-08-11 +- Status: Accepted +- Scope: canonical record/enum tags, digest domains, compatibility policy +- Supersedes: the codec-tag preservation clauses in ADR-0001 and ADR-0002 + +## Context + +Canonical tags still reflected an obsolete implementation package. That name +appeared throughout IR declarations, workload and system contracts, analysis +records, tests, and generated JSON even though it no longer described a +Blueprinting domain. Treating the prefix as forever opaque made the source +harder to read and preserved accidental architecture in every new artifact. + +## Decision + +Canonical identities now state semantic ownership directly: + +| Namespace | Owner | +|---|---| +| `blueprinting.ir.*` | Shared primitives and the five canonical IR stages | +| `blueprinting.binding.*` | Explicit derivation bindings | +| `blueprinting.workload.*` | Target-neutral workload contracts | +| `blueprinting.mapping.*` | Logical strategies and deployment mapping | +| `blueprinting.system.*` | Chip, memory, interconnect, and system profiles | +| `blueprinting.analysis.*` | Evidence and rebuildable analysis records | +| `blueprinting.synthesis.*` | Derivation-session state | +| `blueprinting.expression.*` | Typed scalar expressions | + +Names use kebab-case components without independent version counters. ADT +constructors keep short local tags; the family expands them under its semantic +namespace. Compatibility is governed by the owning IR root schema, not by a +second version embedded in every nested type name. + +This is an intentional hard wire-format cut. The runtime codec does not +register obsolete tag aliases or field aliases. Existing persisted artifacts +must be regenerated from their authoritative workload, mapping, system, and +evidence inputs. Canonical digests change by design; numerical facts and +derivation semantics do not. + +Schema migrations remain available for future graduated root-schema changes. +The current production registry is empty because no pre-graduation intermediate +state is treated as released compatibility history. + +## Consequences + +- IR declarations explain domain ownership without historical context. +- Newly encoded values contain only semantic `blueprinting.*` identities. +- Old serialized snapshots fail closed instead of silently entering current + derivations through aliases. +- Golden digests and generated experiment artifacts must be regenerated in the + same change. +- External consumers must treat this release as a wire-format boundary. + +## Verification + +- A source gate rejects canonical decorators whose tag does not begin with + `blueprinting.` and rejects reintroduction of the abandoned prefix. +- Canonical round-trip, schema migration, determinism, and baseline tests run + against regenerated semantic-namespace artifacts. +- Ruff, mypy, full pytest, bilingual documentation, and wheel-contract checks + remain release gates. diff --git a/docs/project/adr/0004-semantic-wire-identities.zh.md b/docs/project/adr/0004-semantic-wire-identities.zh.md new file mode 100644 index 0000000..7b0007b --- /dev/null +++ b/docs/project/adr/0004-semantic-wire-identities.zh.md @@ -0,0 +1,55 @@ +# ADR-0004:使用领域归属明确的语义化 Wire Identity + +- 日期:2026-08-11 +- 状态:Accepted +- 范围:canonical record/enum tag、digest domain 与兼容策略 +- 取代:ADR-0001、ADR-0002 中保留 codec tag 的条款 + +## 背景 + +Canonical tag 仍然反映一个已经废弃的实现 package。虽然该名称已不再对应 +Blueprinting 的任何领域,它仍散落在 IR 定义、workload/system contract、 +analysis record、测试与生成 JSON 中。把该前缀永久视作 opaque identity,会让 +源码更难阅读,并让偶然形成的历史架构持续进入每一个新产物。 + +## 决策 + +Canonical identity 直接表达语义归属: + +| Namespace | 所有者 | +|---|---| +| `blueprinting.ir.*` | 公共 primitive 与五层 canonical IR | +| `blueprinting.binding.*` | 显式 derivation binding | +| `blueprinting.workload.*` | Target-neutral workload contract | +| `blueprinting.mapping.*` | Logical strategy 与 deployment mapping | +| `blueprinting.system.*` | Chip、memory、interconnect 与 system profile | +| `blueprinting.analysis.*` | Evidence 与可重建 analysis record | +| `blueprinting.synthesis.*` | Derivation session state | +| `blueprinting.expression.*` | Typed scalar expression | + +名称使用 kebab-case component,不维护独立版本号。ADT constructor 仍只写短 local +tag,由 family 在语义 namespace 下展开。Compatibility 由所属 IR root schema 管理, +不在每个嵌套类型名称中重复维护第二套版本。 + +这是一次有意的 wire-format 硬切。Runtime codec 不注册废弃 tag alias,也不保留 +field alias。已有持久化产物必须从权威 workload、mapping、system 与 evidence +输入重新生成。Canonical digest 会按设计发生变化;数值事实与推导语义不变。 + +Schema migration 机制保留给未来完成 graduation 的 root-schema 变更。当前 production +registry 为空,因为 pre-graduation 中间状态不构成已发布的 compatibility history。 + +## 影响 + +- IR 定义无需历史背景就能说明领域归属。 +- 新编码值只包含语义化的 `blueprinting.*` identity。 +- 旧序列化快照会 fail closed,不会通过 alias 静默进入当前推导。 +- Golden digest 与生成的实验产物必须在同一变更中重新生成。 +- 外部 consumer 必须把本次发布视作 wire-format 边界。 + +## 验证 + +- Source gate 拒绝 tag 不以 `blueprinting.` 开头的 canonical decorator,并阻止 + 废弃前缀重新进入源码。 +- Canonical round-trip、schema migration、determinism 与 baseline test 使用重新 + 生成的语义 namespace 产物。 +- Ruff、mypy、完整 pytest、双语文档与 wheel contract 继续作为 release gate。 diff --git a/docs/project/adr/0005-algebraic-expression-command-schemas.en.md b/docs/project/adr/0005-algebraic-expression-command-schemas.en.md new file mode 100644 index 0000000..0974436 --- /dev/null +++ b/docs/project/adr/0005-algebraic-expression-command-schemas.en.md @@ -0,0 +1,30 @@ +# ADR-0005: Algebraic Expression and Command Schemas + +Status: Accepted and implemented + +## Context + +`ScalarExpr(op, args)` admitted invalid arities and forced every interpreter to rediscover an enum-dependent product invariant. `ConcreteCommand(kind, queue?, implementation?, wait_tokens?, signal_tokens?)` similarly admitted combinations that were meaningful only for some command kinds. Large verifiers rejected these states after construction, so annotations did not describe the legal canonical value space. + +At the same time, pass contracts listed preservation properties as strings. A discovered lineage relation was counted as verified even when its callback checked only a subset of the declared properties, and callbacks could not inspect the complete source-to-target mapping needed to prove dependency topology. + +## Decision + +- `ScalarExpr` is a closed ADT with `Add`, `Subtract`, `Multiply`, `Divide`, `CeilDivide`, `Maximum`, and `Minimum` constructors. Constructor fields encode arity. +- `ConcreteCommand` is a graph envelope containing one command-body ADT. Executable bodies own required implementations; queue-capable bodies own their optional queue. A separate synchronization ADT represents none, wait, signal, or wait-and-signal clauses. +- A cross-stage pass declares typed lineage relations, an independent executable invariant for each relation, and one pure canonical normalizer. Transition verification resolves the complete lineage graph, re-evaluates the normalizer for canonical implementation conformance, and then evaluates relation invariants as separate semantic evidence. +- Cross-stage passes without relations or an executable normal form fail the commit gate. Any changed transition without complete executable evidence fails; only an unchanged same-stage transition may succeed as `structural_only`. A successful `unverified` state is not representable. + +## Schema epoch + +These constructors define the initial canonical epoch rather than a migration from unpublished prototypes. All five IR roots therefore remain at `0.0.0`; nested record and ADT identities are semantic names without independent version suffixes. The production migration registry contains no historical edges. No decoder aliases or legacy constructor classes are retained. + +## Consequences + +Canonical constructors now describe a substantially smaller legal state space, and closed matches can use `assert_never` for static exhaustiveness. Generic consumers may use derived `kind`, `queue`, implementation, and token properties, but those properties are projections rather than serialized discriminators. + +Future graduated compatibility changes must advance the owning root schema and register an explicit migration. Component-local counters must not be introduced as a substitute for that boundary. + +## Validation + +Positive and round-trip tests cover every active derivation chain. Negative mutation tests change specialized shape, semantic payload, generated-buffer capacity, dependency topology, and implementation identity while retaining otherwise valid structure. Normal-form checks reject canonical construction deviations; an additional counterexample deliberately lets a normalizer accept an invalid implementation and confirms that the independent relation invariant still rejects it. Generic migration tests cover deterministic chaining, ambiguity, no-op loading, and tamper detection without claiming a production history. diff --git a/docs/project/adr/0005-algebraic-expression-command-schemas.zh.md b/docs/project/adr/0005-algebraic-expression-command-schemas.zh.md new file mode 100644 index 0000000..1578f78 --- /dev/null +++ b/docs/project/adr/0005-algebraic-expression-command-schemas.zh.md @@ -0,0 +1,30 @@ +# ADR-0005:代数化表达式与 Command Schema + +状态:已接受并实现 + +## 背景 + +`ScalarExpr(op, args)` 可以表示错误 arity,迫使每个 interpreter 重复恢复由 enum 决定的 product invariant。`ConcreteCommand(kind, queue?, implementation?, wait_tokens?, signal_tokens?)` 同样允许只对部分 command kind 有意义的组合。大型 verifier 只能在构造后拒绝这些状态,因此 annotation 并没有描述 canonical value 的合法空间。 + +与此同时,pass contract 以字符串列举 preservation property。即使 callback 只检查了声明的一部分,发现 lineage relation 也会被计为 verified;callback 还看不到证明 dependency topology 所需的完整 source-to-target mapping。 + +## 决策 + +- `ScalarExpr` 改为封闭 ADT,constructor 为 `Add`、`Subtract`、`Multiply`、`Divide`、`CeilDivide`、`Maximum` 与 `Minimum`;constructor field 直接编码 arity。 +- `ConcreteCommand` 是包含一个 command-body ADT 的图 envelope。可执行 body 自己拥有必需的 implementation;支持 queue 的 body 自己拥有 optional queue。独立 synchronization ADT 表达 none、wait、signal 与 wait-and-signal。 +- 跨层 pass 声明 typed lineage relation、每条 relation 的独立 executable invariant,以及一个纯 canonical normalizer。Transition verifier 先解析完整 lineage graph,再重新求值 normalizer 以检查 canonical implementation conformance,随后把 relation invariant 作为独立 semantic evidence 执行。 +- 没有 relation 或 executable normal form 的跨层 pass 在 commit gate 失败。任何发生变化但缺少完整 executable evidence 的 transition 都会失败;只有内容未变化的同层 transition 可以以 `structural_only` 成功。成功的 `unverified` 状态不可表示。 + +## Schema epoch + +这些 constructor 定义初始 canonical epoch,不是从未发布 prototype 迁移而来。因此五层 IR root 统一保持 `0.0.0`;嵌套 record 与 ADT identity 使用无独立版本后缀的语义名。Production migration registry 不包含历史 edge,也不保留 decoder alias 或 legacy constructor class。 + +## 影响 + +Canonical constructor 描述的合法状态空间显著缩小,closed match 可以使用 `assert_never` 获得静态穷尽检查。通用 consumer 仍可读取派生的 `kind`、`queue`、implementation 与 token property,但这些 property 是 projection,不是 serialized discriminator。 + +未来完成 graduation 后的 compatibility 变更必须提升所属 root schema,并注册显式 migration;不得用 component-local counter 替代这一边界。 + +## 验证 + +Positive 与 round-trip test 覆盖全部活动 derivation chain。Negative mutation test 在保持其余结构合法的情况下修改 specialized shape、semantic payload、generated buffer capacity、dependency topology 与 implementation identity。Normal-form check 拒绝 canonical construction 偏差;另一个反例会故意让 normalizer 接受非法 implementation,并确认独立 relation invariant 仍能拒绝它。Generic migration test 覆盖 deterministic chaining、ambiguity、no-op load 与 tamper detection,不声称存在 production history。 diff --git a/docs/project/adr/0006-progressive-typed-python-contracts.en.md b/docs/project/adr/0006-progressive-typed-python-contracts.en.md new file mode 100644 index 0000000..42e824a --- /dev/null +++ b/docs/project/adr/0006-progressive-typed-python-contracts.en.md @@ -0,0 +1,42 @@ +# ADR-0006: Progressive Typed Python Contracts + +Status: Accepted and implemented foundation + +## Context + +Python annotations documented many local types but did not by themselves remove enum-plus-optional invalid states, make expected failure explicit, guarantee closed-match exhaustiveness, or prevent an unverified pass result from being committed. Requiring mypy at runtime would make the formal model depend on a development tool, while a custom plugin would duplicate runtime semantics behind a checker-specific API. + +## Decision drivers + +- One declaration source must serve runtime verification and optional static analysis. +- Base installations must check formal contracts without mypy. +- Expected validation, decoding, resolution, and derivation failures must be values rather than hidden exception flow. +- Closed core semantics and open plugin/provider semantics must use different extension mechanisms. +- Every changed committed derivation must carry executable evidence. + +## Decision + +- Introduce domain-free `Result`, `Checked`, and immutable diagnostics in `blueprinting.schema`. +- Make canonical `verify`/`from_json`, pass-manager `run`, and cost resolver `resolve` checked APIs; retain only explicitly named `require_*` exception adapters. +- Close core ADTs with an explicit union alias and `seal_adt`; make roots abstract and reject late constructors and unregistered subclasses. Consumers use explicit-union pattern matches plus `assert_never`; no decorator metadata claims to prove function-body coverage. +- Compile complete record/enum schema shapes plus loaded codec, ADT, and pass declarations with a mypy-independent `ContractCompiler` and CI script. +- Separate authoring APIs from ordinary consumption: schema decorators are exposed only from `blueprinting.schema.authoring`, pass decorators only from `blueprinting.synthesizer.passes.authoring`, and ordinary package roots do not forward decorators. +- Remove successful `UNVERIFIED` pass transitions. Cross-stage contracts require typed relations with independent invariants, a separately reported complete normalizer, output verification, and a stable revision. +- Keep target and provider extension points open through `Protocol`/registries. +- Publish `py.typed` and run standard mypy in a separate optional `typing` dependency group and CI job. Do not introduce a custom mypy plugin. + +## Domain/schema migration + +The initial migration replaces workload mode plus optional phase, duplicate flat Transformer binding facts, portable task kind, cost-support status records, and collective kind plus conditional reduction/root fields with constructor-specific ADTs and structured products. The owning canonical roots remain in unpublished schema epoch `0.0.0`; no production migration edge or decoder alias is created. + +`PlanTask.kind` remains a derived presentation projection of `PlanTask.body`, not serialized truth. Open target/provider sets are not sealed by the core package. + +## Consequences + +Code can use structural pattern matching and monadic `map`/`and_then` composition while staying ordinary Python. Runtime checks work in the base environment; installing mypy adds source-level flow and exhaustiveness analysis without changing execution. + +The contract compiler validates declarations, not scientific truth or arbitrary function bodies. Formula correctness still requires independent executable invariants, canonical construction checks, negative tests, evidence provenance, and experiments. Third-party target declarations cannot yet contribute to the compiled manifest because the general target-plugin registry is not implemented. + +## Validation + +Runtime tests cover result composition, diagnostic accumulation, deterministic manifest compilation, ADT closure/exact membership, authoring-surface boundaries, canonical checked verification/decoding, checked pass failures, cost support matching, and rejection of changed transitions without evidence. CI runs the runtime compiler in the base test job, mypy in an independent optional-typing job, full pytest/Ruff gates, bilingual strict docs, and a wheel check requiring `py.typed`. diff --git a/docs/project/adr/0006-progressive-typed-python-contracts.zh.md b/docs/project/adr/0006-progressive-typed-python-contracts.zh.md new file mode 100644 index 0000000..57a52b0 --- /dev/null +++ b/docs/project/adr/0006-progressive-typed-python-contracts.zh.md @@ -0,0 +1,42 @@ +# ADR-0006:渐进式 Typed Python Contract + +状态:已接受,基础实现已完成 + +## 背景 + +Python annotation 可以记录许多局部类型,但本身不能消除 enum-plus-optional 非法状态、显式表达预期失败、保证 closed match 穷尽,或阻止 unverified pass result commit。Runtime 强依赖 mypy 会让形式模型依赖开发工具;自定义 plugin 则会把 runtime semantic 重复实现到 checker-specific API 后面。 + +## 决策驱动因素 + +- 同一份 declaration source 必须同时服务 runtime verification 与可选 static analysis。 +- Base installation 必须能在没有 mypy 时检查形式 contract。 +- Validation、decode、resolution 与 derivation 的预期失败必须是 value,而不是隐藏异常控制流。 +- 封闭 core semantic 与开放 plugin/provider semantic 必须使用不同 extension mechanism。 +- 每个发生变化且 commit 的 derivation 都必须携带 executable evidence。 + +## 决策 + +- 在 `blueprinting.schema` 中引入 domain-free `Result`、`Checked` 与 immutable diagnostic。 +- Canonical `verify`/`from_json`、pass-manager `run` 与 cost resolver `resolve` 使用 checked API;只保留具名 `require_*` exception adapter。 +- Core ADT 使用显式 union alias 与 `seal_adt` 封闭;root 不可直接构造,late constructor 与未注册 subclass 会被拒绝;consumer 使用显式 union pattern match 与 `assert_never`,不注册无法证明函数体覆盖率的 decorator metadata。 +- 使用不依赖 mypy 的 `ContractCompiler` 与 CI script 编译完整 record/enum schema shape,以及已加载 codec、ADT 与 pass declaration。 +- 将 authoring API 与普通消费接口分开:schema decorator 只由 `blueprinting.schema.authoring` 暴露,pass decorator 只由 `blueprinting.synthesizer.passes.authoring` 暴露;普通 package root 不转发 decorator。 +- 删除成功的 `UNVERIFIED` pass transition。跨层 contract 必须具备带独立 invariant 的 typed relation、分开报告的完整 normalizer、output verification 与稳定 revision。 +- Target/provider extension point 继续通过 `Protocol`/registry 保持开放。 +- 发布 `py.typed`,在独立可选 `typing` dependency group 与 CI job 中运行标准 mypy;不引入自定义 mypy plugin。 + +## 领域与 Schema 迁移 + +初始迁移用 constructor-specific ADT 与 structured product 替换 workload mode + optional phase、binding 中重复的扁平 Transformer fact、portable task kind、cost-support status record,以及 collective kind + 条件性 reduction/root field。所属 canonical root 继续处于未发布的 `0.0.0` schema epoch;不创建 production migration edge 或 decoder alias。 + +`PlanTask.kind` 继续作为 `PlanTask.body` 的 derived presentation projection,不是 serialized truth。Open target/provider 集合不由 core package 封闭。 + +## 影响 + +代码可以使用 structural pattern matching 与 monadic `map`/`and_then` composition,同时仍是普通 Python。Runtime check 在 base environment 工作;安装 mypy 后增加 source-level flow/exhaustiveness analysis,但不改变执行。 + +Contract compiler 验证 declaration,不证明 scientific truth 或任意 function body。Formula correctness 仍需要独立 executable invariant、canonical construction check、negative test、evidence provenance 与 experiment。通用 target-plugin registry 尚未实现,因此第三方 target declaration 当前不能贡献 compiled manifest。 + +## 验证 + +Runtime test 覆盖 result composition、diagnostic accumulation、deterministic manifest compilation、ADT closure/exact membership、authoring surface boundary、canonical checked verification/decoding、checked pass failure、cost support matching,以及对缺少 evidence 的 changed transition 的拒绝。CI 在 base test job 中运行 runtime compiler,在独立 optional-typing job 中运行 mypy,并执行完整 pytest/Ruff Gate、双语 strict docs 与要求 `py.typed` 的 wheel check。 diff --git a/docs/project/decisions.en.md b/docs/project/decisions.en.md index f75569d..7d0623a 100644 --- a/docs/project/decisions.en.md +++ b/docs/project/decisions.en.md @@ -19,8 +19,12 @@ This page is the compact index of architecture commitments, rejected alternative | Runtime | Honor a verified plan while retaining bounded mechanism decisions allowed by the contract | Runtime does not repeat unbounded global search or pretend backpressure and failure do not exist | | Schema maturity | Internal schema versions are not automatically public compatibility promises | A contract graduates only after producer, independent consumer, migration, and conformance gates pass | | Documentation | Colocated suffix-based bilingual sources | Navigation and language switching remain page-aligned | -| Formal derivation package | Hard-cut Python rename to `blueprinting.synthesizer`; preserve historical codec tags | Source ownership matches formal plan synthesis without invalidating unchanged canonical snapshots; see [ADR-0001](adr/0001-synthesizer-package.md) | +| Formal derivation package | Hard-cut Python rename to `blueprinting.synthesizer` | Source ownership matches formal plan synthesis; see [ADR-0001](adr/0001-synthesizer-package.md) | | Domain packages | `blueprinting.workload` owns target-neutral workload contracts; `blueprinting.system` owns chip/interconnect/system profiles | Synthesis and analysis consume explicit domain inputs without owning them; see [ADR-0002](adr/0002-workload-system-domains.md) | +| Derivation debugging | Five-stage graphs, adjacent mappings, and debug bundles are derived traces rebuilt from checkpoints and lineage | UI and overlays never enter canonical IR; see [ADR-0003](adr/0003-derivation-debug-trace.md) | +| Canonical wire identity | Domain-owned `blueprinting.*` namespaces with no obsolete aliases | Serialized identities describe current semantics and old artifacts are regenerated at the hard boundary; see [ADR-0004](adr/0004-semantic-wire-identities.md) | +| Algebraic canonical constructors | Scalar operations and concrete command semantics use constructor-specific ADTs; preservation claims are executable evidence | Invalid arity/payload combinations are removed and schema changes use explicit migrations; see [ADR-0005](adr/0005-algebraic-expression-command-schemas.md) | +| Progressive typed Python | Runtime `Checked` contracts and sealed core ADTs share declarations with optional standard mypy analysis; no custom plugin | Base installs retain contract checking, expected failure is explicit, and static analysis adds coverage without becoming runtime truth; see [ADR-0006](adr/0006-progressive-typed-python-contracts.md) | ## Rejected alternatives diff --git a/docs/project/decisions.zh.md b/docs/project/decisions.zh.md index a82f4a5..8cf3835 100644 --- a/docs/project/decisions.zh.md +++ b/docs/project/decisions.zh.md @@ -19,8 +19,12 @@ | Runtime | 遵守 verified plan,并保留 contract 允许的 bounded mechanism decision | Runtime 不重复无界 global search,也不假装 backpressure/failure 不存在 | | Schema maturity | Internal schema version 不自动构成 public compatibility promise | Producer、独立 consumer、migration 与 conformance Gate 通过后才毕业为 stable contract | | Documentation | 同目录 suffix-based 双语 source | Navigation 与 language switching 始终按页面对齐 | -| 形式化推导 package | Python path 硬切为 `blueprinting.synthesizer`;保留历史 codec tag | Source ownership 对齐 formal plan synthesis,同时不破坏未变化的 canonical snapshot;见 [ADR-0001](adr/0001-synthesizer-package.md) | +| 形式化推导 package | Python path 硬切为 `blueprinting.synthesizer` | Source ownership 对齐 formal plan synthesis;见 [ADR-0001](adr/0001-synthesizer-package.md) | | Domain package | `blueprinting.workload` 拥有 target-neutral workload contract;`blueprinting.system` 拥有 chip/interconnect/system profile | Synthesis/analysis 消费显式 domain input,但不拥有它们;见 [ADR-0002](adr/0002-workload-system-domains.md) | +| Derivation 调试 | 五层图、相邻映射与调试包是从 checkpoint/lineage 重建的 derived trace | UI/overlay 不进入 canonical IR;见 [ADR-0003](adr/0003-derivation-debug-trace.md) | +| Canonical wire identity | 按领域归属的 `blueprinting.*` namespace,不保留废弃 alias | 序列化 identity 直接表达当前语义,旧产物在硬切边界重新生成;见 [ADR-0004](adr/0004-semantic-wire-identities.md) | +| 代数化 canonical constructor | Scalar operation 与 concrete command semantic 使用 constructor-specific ADT;preservation claim 具有可执行 evidence | 消除非法 arity/payload 组合,schema 变更通过显式 migration 完成;见 [ADR-0005](adr/0005-algebraic-expression-command-schemas.md) | +| 渐进式 typed Python | Runtime `Checked` contract 与 sealed core ADT 和可选标准 mypy analysis 共享 declaration;不使用自定义 plugin | Base installation 保留 contract check,预期失败显式化,static analysis 增加覆盖但不成为 runtime truth;见 [ADR-0006](adr/0006-progressive-typed-python-contracts.md) | ## 被拒绝方案 diff --git a/docs/project/risks.en.md b/docs/project/risks.en.md index 32c24db..47904d8 100644 --- a/docs/project/risks.en.md +++ b/docs/project/risks.en.md @@ -19,7 +19,7 @@ This page records systemic risks that can break hardware-candidate comparability | R-03 | P0 | Documentation presents a schema/verifier scaffold as a resource-complete plan | There is no portable-to-concrete producer; route, occupancy, and target-specific schedule semantics are incomplete | Downgrade status to experimental contract; upgrade only after producer, consumers, and end-to-end verifiers pass | Wording corrected; capability not implemented | | R-04 | P1 | Planning-evidence and evaluation-evidence identities are conflated | `ConcretePlanIR.evidence_revision` records construction input while derived cost views also use evidence | Separate construction provenance from re-evaluation revision in the contract; verify that re-costing cannot silently mutate a plan | Open | | R-05 | P0 | Simulator/runtime “equivalence” is read as equal temporal behavior | No real backend or conformance evidence exists | Promise only command/event correspondence; define strict, bounded-divergence, and partial-observation levels | Documentation corrected; tests pending | -| R-06 | P1 | Schema version `1.0.0` is mistaken for public stability | Five IR schemas are versioned, but target producers and consumers are not connected | State that internal serialization version is not a compatibility promise; establish a graduation checklist | Documentation corrected; policy pending | +| R-06 | P1 | Pre-graduation schema is mistaken for public stability | Five IR roots remain at `0.0.0`; target producers and consumers are not connected | Keep component identities versionless and establish a graduation checklist before the first schema increment | Zero epoch enforced; graduation policy pending | | R-07 | P0 | “Zero-decision runtime” ignores backpressure, failure, and dynamic duration | No runtime contract exists | Forbid unbounded global replanning while retaining bounded safety and mechanism decisions; declare policy in artifacts | Documentation corrected; runtime pending | | R-08 | P0 | LPU features leak prematurely into portable semantics | No LPU ABI, capability, or resource contract exists yet | Stage Architecture/Simulation/Replay/Executable maturity; introduce physical detail only after the target gate | Controlled | | R-09 | P1 | Simulator and emitter each fill in a missing schedule | Neither production path exists and no cross-consumer conformance test exists | Both consume the same concrete digest plus typed target extension; emitter decision delta must be empty | Open | diff --git a/docs/project/risks.zh.md b/docs/project/risks.zh.md index db7380f..7587c07 100644 --- a/docs/project/risks.zh.md +++ b/docs/project/risks.zh.md @@ -19,7 +19,7 @@ | R-03 | P0 | 文档把 schema/verifier 骨架写成 resource-complete plan | 当前没有 portable-to-concrete producer;route、occupancy 和 target-specific schedule semantic 不完整 | 状态页明确降级为 experimental contract;producer、consumer 与 end-to-end verifier 通过后再升级 | 已降级措辞,能力未实现 | | R-04 | P1 | Planning evidence 与 evaluation evidence identity 混淆 | `ConcretePlanIR.evidence_revision` 记录构造输入,derived cost view 也有独立 evidence | Contract 中区分 construction provenance 与 re-evaluation revision;验证 re-cost 不会静默改 plan | 开放 | | R-05 | P0 | Simulator/runtime “等价”被误读为时间行为相同 | 没有真实 backend 或 conformance evidence | 只承诺 command/event correspondence;定义 strict、bounded-divergence 和 partial-observation 等级 | 文档已纠正,测试待实现 | -| R-06 | P1 | `1.0.0` schema version 被误认为 public stability | 五个 IR schema 有版本,但 target producer/consumer 未贯通 | 明确 internal serialization version 不等于 compatibility promise;建立 graduation checklist | 文档已纠正,policy 待实现 | +| R-06 | P1 | Pre-graduation schema 被误认为 public stability | 五层 IR root 保持 `0.0.0`,target producer/consumer 尚未贯通 | Component identity 保持无版本,并在首次 schema increment 前建立 graduation checklist | Zero epoch 已强制;graduation policy 待实现 | | R-07 | P0 | “Runtime 零决策”忽略 backpressure、failure 与动态 duration | 当前没有 runtime contract | Runtime 禁止无界 global replanning,但保留 bounded safety/mechanism decisions;在 artifact 中声明 policy | 文档已纠正,runtime 待实现 | | R-08 | P0 | LPU 特性提前污染 portable semantic | LPU ABI、capability 和 resource contract 尚未存在 | 按 Architecture/Simulation/Replay/Executable maturity 分层;physical detail 只在 target gate 后出现 | 受控 | | R-09 | P1 | Simulator 与 emitter 各自补全缺失 schedule | 两条 production path 均未实现,缺少 cross-consumer conformance test | 两者消费同一 concrete digest + typed target extension;emitter decision delta 必须为空 | 开放 | diff --git a/docs/project/status.en.md b/docs/project/status.en.md index b3d6f0f..d884309 100644 --- a/docs/project/status.en.md +++ b/docs/project/status.en.md @@ -2,7 +2,7 @@ This page separates Blueprinting's hardware-exploration product goals from the engineering foundation already connected in the repository. A schema or design contract is useful groundwork, but it is not a working exploration capability until a candidate can be constructed, evaluated, and consumed end to end. -**Status date:** 2026-08-09 +**Status date:** 2026-08-11 ## Status vocabulary @@ -22,6 +22,7 @@ This page separates Blueprinting's hardware-exploration product goals from the e | Static Transformer inference phase planning | **Implemented slice** | independently verified prefill/decode plans, KV capacity, and decoder-block phase composition | | Target-neutral workload/mapping plan | **Implemented slice** | Transformer path reaches `PortablePlanIR` | | Portable dependency-projection Chrome Trace export | **Implemented presentation adapter** | the workbench can open it in Perfetto through a PING/PONG bridge; metadata explicitly says `executable=false`, and the export is not a `TimelineBundle` | +| Five-stage IR Explorer and lowering replay audit | **Implemented presentation adapter** | current runs capture the first three stages; five graph adapters, lineage boundaries, debug bundles, and a derived cost overlay exist, while the last two stages still have no production producers | | Versioned compute/memory/network efficiency profile | **Implemented adapter** | `SystemProfile` and two analytical estimate modes | | Normalized task-cost resolution and performance-data ingestion | **Implemented slice** | immutable query/result/store, ordered resolver, roofline fallback, generic simulator tables, Vidur profiles, and four AIConfigurator table families | | Vidur raw component-profile alignment | **Implemented experiment** | exact-key CSV lookup after independent lowering/costing, with component coverage and non-cancelling error attribution | @@ -30,7 +31,7 @@ This page separates Blueprinting's hardware-exploration product goals from the e | Hardware design variables and constraint-aware candidate generation | **Planned** | no design-space generator or search session | | Workload suite and scenario weighting | **Planned** | current path evaluates explicit individual configurations | | Architecture capability/legalization model | **Planned** | target/deployment bindings exist; general plugin path does not | -| Architecture-bound placement, schedule, and memory plan | **Experimental Contract / Planned** | `ConcretePlanIR` has only a generic queue-oriented schema and structural verifier; producer, route/occupancy semantics, and typed target extensions do not exist | +| Architecture-bound placement, schedule, and memory plan | **Experimental Contract / Reference Slice** | `ConcretePlanIR` has typed queue-order and slot/dataflow extensions plus deterministic virtual binders; production target plugins, resource scheduling, occupancy, and hardware legality do not exist | | Discrete-event compute/memory/resource simulation | **Planned** | current result is analytical composition, not event simulation | | Timeline analysis/replay bundle | **Planned** | `TimingProjection`, `SimulationTraceIR`, and `TimelineBundle` are design contracts only | | General network/hardware simulator adapters | **Implemented slice / Planned** | explicit tabular ingestion and a general resolver exist; simulator execution, calibrated interpolation, contention validity, and environment manifests remain planned | @@ -44,7 +45,7 @@ This matrix is authoritative for user-facing claims. The existence of five IR cl ## Schema maturity is not capability maturity -The five current IR classes use `1.0.0` as an internal canonical serialization version. The number does not mean a public API or ABI is frozen, nor that every layer has a production producer and consumer. `ConcretePlanIR` and `MachineIR` in particular remain experimental scaffolds; they must pass the compatibility gate in the [risk register](risks.md) before graduating to stable contracts. +The five current IR classes use `0.0.0` as their pre-graduation canonical schema epoch and require the `typed-semantics` feature. Nested canonical type identities and internal pass/planner identities are semantic names without independent version counters. The migration mechanism is exercised with a synthetic test schema, while the production registry remains empty. `ConcretePlanIR` and `MachineIR` remain experimental and must pass the [risk-register](risks.md) graduation gates. ## Connected analysis path @@ -82,14 +83,15 @@ It cannot yet claim serving-system SLO accuracy: arrivals, queueing, continuous | Foundation | Status | Source of truth | |---|---|---| | Immutable values, stable IDs, lineage, codec, digests | **Implemented** | `src/blueprinting/schema/`, `src/blueprinting/synthesizer/ids.py` | -| Five progressive formal-representation schemas (`*IR`) and verifiers | **Experimental Contract** | `src/blueprinting/synthesizer/ir/`; only the first three have a production derivation slice | +| Progressive typed-Python contracts | **Implemented foundation** | runtime `Result`/diagnostics, sealed ADTs and consumers, deterministic `ContractCompiler`; standard mypy is an optional independent CI layer | +| Five progressive formal-representation schemas (`*IR`) and verifiers | **Experimental Contract** | `src/blueprinting/synthesizer/stages/*/ir.py`; only the first three have a production derivation slice | | Typed workload/strategy/target/deployment bindings | **Implemented** | `bindings.py`, `session.py` | | Chip, memory, interconnect, and aggregate system profile | **Implemented adapter** | `src/blueprinting/system/`; evidence-bearing profile, not the planned `ArchitectureBlueprint` | | Transactional analyses/transformations, checkpoints, observers | **Implemented** | `passes/base.py` | | Transformer workload/mapping contracts, frontend, and workload algebra | **Implemented slice** | `workload/transformer.py`, `mapping/transformer.py`, `synthesizer/frontend/transformer.py`, `synthesizer/dialects/transformer/` | -| Distributed and portable mapping derivations | **Implemented slice** | `lowering/transformer.py` | +| Distributed and portable mapping derivations | **Implemented slice** | `stages/{distributed,portable_plan}/passes.py`, `dialects/transformer/*_derivation.py` | | Cost protocol, resolver, roofline, database, and external importers | **Implemented slice** | `analysis/cost/`, `analysis/vidur.py`; exact task latency only, not plan simulation | -| Static inference frontend, lowering, cost, and request composition | **Implemented slice** | `workload/transformer_inference.py`, `mapping/transformer.py`, `synthesizer/{frontend,lowering}/transformer_inference.py`, `synthesizer/dialects/transformer/inference.py`, `analysis/inference_cost.py`, `application/inference.py` | +| Static inference frontend, lowering, cost, and request composition | **Implemented slice** | `workload/transformer_inference.py`, `mapping/transformer.py`, `synthesizer/frontend/transformer_inference.py`, `synthesizer/stages/{distributed,portable_plan}/passes.py`, `synthesizer/dialects/transformer/{inference,inference_derivation}.py`, `analysis/inference_cost.py`, `application/inference.py` | | Vidur raw component-profile alignment | **Implemented experiment** | `analysis/vidur.py` + `validation/vidur.py`; a minimal licensed CI slice is pinned locally and the full upstream corpus remains external | | Calculon experiment | **Implemented experiment** | `validation/calculon.py` | | External-baseline regression gate | **Implemented** | frozen contract and licensed offline fixtures under `data/validation/`; `validation/regression.py`; `.github/workflows/quality.yml` | @@ -98,7 +100,7 @@ These typed representations, verifiers, derivation transactions, and analyses ar ## Verification baseline -The current test suite covers binding consistency, canonical serialization, verifier rejection, pass transaction rollback, checkpoint observers, workload conservation, Calculon calibration, prefill/decode scaling, KV capacity, static request composition, baseline-only Vidur comparison, roofline components, exact/ambiguous database resolution, simulator unit normalization, AIConfigurator CSV/Parquet schemas, explicit Vidur ingestion, and inference resolver fallback. A dedicated CI job runs the eight-case Calculon/SeqSel and three-case pinned Vidur gates on every main-branch pull request and push. It freezes provenance, semantic policy, coverage, comparable-subtotal drift budgets, non-cancelling component errors, aggregate results, and IR digests; it cannot silently regenerate goldens. The Vidur gate is drift detection, not an accuracy certification. Documentation checks enforce complete bilingual page pairs and strict site builds. +The current test suite covers runtime type-contract compilation, ADT closure, checked failure paths, binding consistency, canonical serialization, verifier rejection, pass transaction rollback, checkpoint observers, workload conservation, Calculon calibration, prefill/decode scaling, KV capacity, static request composition, baseline-only Vidur comparison, roofline components, exact/ambiguous database resolution, simulator unit normalization, AIConfigurator CSV/Parquet schemas, explicit Vidur ingestion, and inference resolver fallback. The base CI job compiles runtime contracts without installing mypy; a separate optional-typing job analyzes source with standard mypy. A dedicated regression job runs the eight-case Calculon/SeqSel and three-case pinned Vidur gates on every main-branch pull request and push. It freezes provenance, semantic policy, coverage, comparable-subtotal drift budgets, non-cancelling component errors, aggregate results, and IR digests; it cannot silently regenerate goldens. The Vidur gate is drift detection, not an accuracy certification. Documentation checks enforce complete bilingual page pairs and strict site builds. Status promotion requires an end-to-end product test. For example, introducing `ArchitectureBlueprint` as a dataclass is Contract Only; constructing two different candidates, mapping the same workload, producing comparable results, and preserving provenance is the minimum product-level evidence. diff --git a/docs/project/status.zh.md b/docs/project/status.zh.md index f0a9d7b..5a24992 100644 --- a/docs/project/status.zh.md +++ b/docs/project/status.zh.md @@ -2,7 +2,7 @@ 本页区分 Blueprinting 的硬件探索产品目标与仓库中已经贯通的工程基础。Schema 或 design contract 是有价值的基础,但只有 candidate 能够被端到端构造、评估与消费时,才算真正的 exploration capability。 -**状态日期:** 2026-08-09 +**状态日期:** 2026-08-11 ## 状态词汇 @@ -22,6 +22,7 @@ | Static Transformer inference phase planning | **Implemented slice** | 独立验证的 prefill/decode plan、KV 容量以及 decoder-block phase composition | | Target-neutral workload/mapping plan | **Implemented slice** | Transformer path 到达 `PortablePlanIR` | | Portable dependency projection Chrome Trace export | **Implemented presentation adapter** | Workbench 可通过 PING/PONG bridge 在 Perfetto 打开;metadata 明确 `executable=false`,不是 `TimelineBundle` | +| 五层 IR Explorer 与 lowering replay audit | **Implemented presentation adapter** | 当前运行捕获前三层;五层 graph adapter、lineage boundary、调试包与 derived cost overlay 已实现,后两层仍无 production producer | | 版本化 compute/memory/network efficiency profile | **Implemented adapter** | `SystemProfile` 与两种 analytical estimate mode | | Normalized task-cost resolution 与性能数据导入 | **Implemented slice** | immutable query/result/store、ordered resolver、roofline fallback、通用 simulator 表、Vidur profile 与四类 AIConfigurator 表 | | Vidur raw component-profile 对齐 | **Implemented experiment** | 独立 lowering/costing 后进行 exact-key CSV lookup,并报告 component coverage 与不可抵消的误差归因 | @@ -30,7 +31,7 @@ | Hardware design variable 与 constraint-aware candidate generation | **Planned** | 无 design-space generator 或 search session | | Workload suite 与 scenario weighting | **Planned** | 当前路径评估显式 individual configuration | | Architecture capability/legalization model | **Planned** | target/deployment binding 已有;通用 plugin path 尚无 | -| Architecture-bound placement、schedule 与 memory plan | **Experimental Contract / Planned** | `ConcretePlanIR` 只有通用 queue-oriented schema 与 structural verifier;producer、route/occupancy semantic 和 typed target extension 尚无 | +| Architecture-bound placement、schedule 与 memory plan | **Experimental Contract / Reference Slice** | `ConcretePlanIR` 已有 typed queue-order/slot-dataflow extension 与 deterministic virtual binder;production target plugin、resource scheduling、occupancy 和硬件 legality 尚无 | | Discrete-event compute/memory/resource simulation | **Planned** | 当前结果是 analytical composition,不是 event simulation | | Timeline analysis/replay bundle | **Planned** | `TimingProjection`、`SimulationTraceIR`、`TimelineBundle` 只有 design contract | | 通用 network/hardware simulator adapter | **Implemented slice / Planned** | 显式 tabular ingestion 与通用 resolver 已存在;simulator execution、calibrated interpolation、contention validity 与 environment manifest 仍未实现 | @@ -44,7 +45,7 @@ ## Schema 成熟度不是能力成熟度 -当前五个 IR class 使用 `1.0.0` 作为内部 canonical serialization version。这个数字不表示 public API/ABI 已冻结,也不表示每层都有 production producer 和 consumer。特别是 `ConcretePlanIR` 与 `MachineIR` 仍是 experimental scaffold;它们必须通过[风险登记表](risks.md)中的 compatibility Gate 才能升级为 stable contract。 +当前五个 IR class 统一使用 `0.0.0` 作为 pre-graduation canonical schema epoch,并强制要求 `typed-semantics` feature。嵌套 canonical type identity 与内部 pass/planner identity 使用无独立版本号的语义名。Migration 机制通过 synthetic test schema 验证,production registry 保持为空。`ConcretePlanIR` 与 `MachineIR` 仍是 experimental contract,必须通过[风险登记表](risks.md)中的 graduation Gate。 ## 已贯通分析路径 @@ -82,14 +83,15 @@ TransformerModelSpec + inference request cohort + inference mapping | 基础 | 状态 | Source of truth | |---|---|---| | Immutable value、stable ID、lineage、codec、digest | **Implemented** | `src/blueprinting/schema/`、`src/blueprinting/synthesizer/ids.py` | -| 五层 progressive formal-representation schema(`*IR`)与 verifier | **Experimental Contract** | `src/blueprinting/synthesizer/ir/`;只有前三层存在 production derivation slice | +| 渐进式 typed-Python contract | **Implemented foundation** | runtime `Result`/diagnostic、sealed ADT/consumer 与 deterministic `ContractCompiler`;标准 mypy 是独立的可选 CI 层 | +| 五层 progressive formal-representation schema(`*IR`)与 verifier | **Experimental Contract** | `src/blueprinting/synthesizer/stages/*/ir.py`;只有前三层存在 production derivation slice | | Typed workload/strategy/target/deployment binding | **Implemented** | `bindings.py`、`session.py` | | Chip、memory、interconnect 与聚合 system profile | **Implemented adapter** | `src/blueprinting/system/`;是 evidence-bearing profile,不是计划中的 `ArchitectureBlueprint` | | Transactional analysis/transformation、checkpoint、observer | **Implemented** | `passes/base.py` | | Transformer workload/mapping contract、frontend 与 workload algebra | **Implemented slice** | `workload/transformer.py`、`mapping/transformer.py`、`synthesizer/frontend/transformer.py`、`synthesizer/dialects/transformer/` | -| Distributed/portable mapping derivation | **Implemented slice** | `lowering/transformer.py` | +| Distributed/portable mapping derivation | **Implemented slice** | `stages/{distributed,portable_plan}/passes.py`、`dialects/transformer/*_derivation.py` | | Cost protocol、resolver、roofline、database 与外部 importer | **Implemented slice** | `analysis/cost/`、`analysis/vidur.py`;仅覆盖 exact task latency,不是 plan simulation | -| Static inference frontend、lowering、cost 与 request composition | **Implemented slice** | `workload/transformer_inference.py`、`mapping/transformer.py`、`synthesizer/{frontend,lowering}/transformer_inference.py`、`synthesizer/dialects/transformer/inference.py`、`analysis/inference_cost.py`、`application/inference.py` | +| Static inference frontend、lowering、cost 与 request composition | **Implemented slice** | `workload/transformer_inference.py`、`mapping/transformer.py`、`synthesizer/frontend/transformer_inference.py`、`synthesizer/stages/{distributed,portable_plan}/passes.py`、`synthesizer/dialects/transformer/{inference,inference_derivation}.py`、`analysis/inference_cost.py`、`application/inference.py` | | Vidur raw component-profile 对齐 | **Implemented experiment** | `analysis/vidur.py` + `validation/vidur.py`;最小带许可证 CI slice 固定在本地,完整 upstream corpus 仍保持外部依赖 | | Calculon experiment | **Implemented experiment** | `validation/calculon.py` | | 外部 baseline 回归门禁 | **Implemented** | `data/validation/` 下的冻结 contract 与带许可证离线 fixture、`validation/regression.py`、`.github/workflows/quality.yml` | @@ -98,7 +100,7 @@ TransformerModelSpec + inference request cohort + inference mapping ## 验证基线 -当前 test suite 覆盖 binding consistency、canonical serialization、verifier rejection、pass transaction rollback、checkpoint observer、workload conservation、Calculon calibration、prefill/decode scaling、KV 容量、static request composition、baseline-only Vidur comparison、roofline component、exact/ambiguous database resolution、simulator unit normalization、AIConfigurator CSV/Parquet schema、显式 Vidur ingestion 与 inference resolver fallback。独立 CI job 会在每次面向 main 的 PR 和 push 上执行 8-case Calculon/SeqSel 与 3-case 固定 Vidur gate,同时冻结 provenance、semantic policy、coverage、comparable-subtotal drift budget、不可抵消的 component error、aggregate result 与 IR digest,且不能静默重生成 golden。Vidur gate 只用于 drift detection,不是 accuracy certification。文档检查强制完整双语 page pair 与 strict site build。 +当前 test suite 覆盖 runtime type-contract compilation、ADT closure、checked failure path、binding consistency、canonical serialization、verifier rejection、pass transaction rollback、checkpoint observer、workload conservation、Calculon calibration、prefill/decode scaling、KV 容量、static request composition、baseline-only Vidur comparison、roofline component、exact/ambiguous database resolution、simulator unit normalization、AIConfigurator CSV/Parquet schema、显式 Vidur ingestion 与 inference resolver fallback。Base CI job 在不安装 mypy 的情况下编译 runtime contract;独立 optional-typing job 使用标准 mypy 分析源码。另一个 regression job 会在每次面向 main 的 PR 和 push 上执行 8-case Calculon/SeqSel 与 3-case 固定 Vidur gate,同时冻结 provenance、semantic policy、coverage、comparable-subtotal drift budget、不可抵消的 component error、aggregate result 与 IR digest,且不能静默重生成 golden。Vidur gate 只用于 drift detection,不是 accuracy certification。文档检查强制完整双语 page pair 与 strict site build。 能力升级需要端到端 product test。例如只增加 `ArchitectureBlueprint` dataclass 仍是 Contract Only;至少要构造两个不同 candidate、映射同一 workload、产生可比较 result 并保持 provenance,才能形成产品级证据。 diff --git a/docs/reference/concrete-plan-ir.en.md b/docs/reference/concrete-plan-ir.en.md new file mode 100644 index 0000000..b263237 --- /dev/null +++ b/docs/reference/concrete-plan-ir.en.md @@ -0,0 +1,12 @@ +# ConcretePlanIR API + +`ConcretePlanIR` is the target-bound authoritative command envelope. Common +coordination semantics stay in the envelope; target-only correctness semantics +live in a registered typed extension. + +::: blueprinting.synthesizer.stages.concrete_plan.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/concrete-plan-ir.zh.md b/docs/reference/concrete-plan-ir.zh.md new file mode 100644 index 0000000..8faf4c5 --- /dev/null +++ b/docs/reference/concrete-plan-ir.zh.md @@ -0,0 +1,10 @@ +# ConcretePlanIR API + +`ConcretePlanIR` 是 target-bound 的权威 command envelope。通用 coordination semantic 位于 envelope;target-only correctness semantic 位于注册过的 typed extension。 + +::: blueprinting.synthesizer.stages.concrete_plan.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/derivation-infrastructure.en.md b/docs/reference/derivation-infrastructure.en.md new file mode 100644 index 0000000..841536f --- /dev/null +++ b/docs/reference/derivation-infrastructure.en.md @@ -0,0 +1,50 @@ +# Derivation infrastructure API + +The infrastructure separates three concerns: + +- schema authoring creates immutable, codec-visible records and closed ADTs; +- pass authoring extracts static contracts from annotations without wrapping execution; +- the transaction runner verifies inputs, outputs, lineage rules, deterministic replay, analyses, and checkpoints before commit. + +## Schema authoring + +::: blueprinting.schema.authoring + options: + members: + - record + - adt + - variant + - adt_manifest + show_root_heading: false + show_root_toc_entry: false + +## Pass authoring + +::: blueprinting.synthesizer.passes.authoring + options: + members: + - derivation + - relation + - claim + show_root_heading: false + show_root_toc_entry: false + +## Transaction and verification types + +::: blueprinting.synthesizer.passes.base + options: + members: + - PassRule + - TransitionRelation + - TransitionReport + - TransitionVerifier + - PassContract + - PassContext + - PassResult + - DerivationPass + - PassPipeline + - PassRecord + - PassCheckpoint + - PassManager + show_root_heading: false + show_root_toc_entry: false diff --git a/docs/reference/derivation-infrastructure.zh.md b/docs/reference/derivation-infrastructure.zh.md new file mode 100644 index 0000000..82c56c3 --- /dev/null +++ b/docs/reference/derivation-infrastructure.zh.md @@ -0,0 +1,50 @@ +# 推导基础设施 API + +基础设施分离三个关注点: + +- schema authoring 创建 immutable、codec-visible 的 record 与封闭 ADT; +- pass authoring 从 annotation 提取静态 contract,不包装执行语义; +- transaction runner 在 commit 前验证输入、输出、lineage rule、deterministic replay、analysis 与 checkpoint。 + +## Schema authoring + +::: blueprinting.schema.authoring + options: + members: + - record + - adt + - variant + - adt_manifest + show_root_heading: false + show_root_toc_entry: false + +## Pass authoring + +::: blueprinting.synthesizer.passes.authoring + options: + members: + - derivation + - relation + - claim + show_root_heading: false + show_root_toc_entry: false + +## 事务与验证类型 + +::: blueprinting.synthesizer.passes.base + options: + members: + - PassRule + - TransitionRelation + - TransitionReport + - TransitionVerifier + - PassContract + - PassContext + - PassResult + - DerivationPass + - PassPipeline + - PassRecord + - PassCheckpoint + - PassManager + show_root_heading: false + show_root_toc_entry: false diff --git a/docs/reference/distributed-task-ir.en.md b/docs/reference/distributed-task-ir.en.md new file mode 100644 index 0000000..fa85fb2 --- /dev/null +++ b/docs/reference/distributed-task-ir.en.md @@ -0,0 +1,12 @@ +# DistributedTaskIR API + +`DistributedTaskIR` owns logical meshes, typed sharding, collective/P2P/local +task variants, distributed dependencies, and source lineage. Physical devices +and target implementation choices are forbidden here. + +::: blueprinting.synthesizer.stages.distributed.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/distributed-task-ir.zh.md b/docs/reference/distributed-task-ir.zh.md new file mode 100644 index 0000000..df25fc0 --- /dev/null +++ b/docs/reference/distributed-task-ir.zh.md @@ -0,0 +1,10 @@ +# DistributedTaskIR API + +`DistributedTaskIR` 表达 logical mesh、typed sharding、collective/P2P/local task variant、分布式依赖和 source lineage;物理设备与 target implementation 选择不得进入本层。 + +::: blueprinting.synthesizer.stages.distributed.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/index.en.md b/docs/reference/index.en.md new file mode 100644 index 0000000..0dd0cb3 --- /dev/null +++ b/docs/reference/index.en.md @@ -0,0 +1,29 @@ +# Code documentation + +This section is generated from the canonical Python source with +`mkdocstrings`. It is the discoverable API companion to the semantic design +documents, not a second definition of IR meaning. + +## Reading order + +1. Choose the owning representation stage under **Canonical IR API**. +2. Read **Pass Formulae and API** for every committed cross-stage derivation. +3. Use **Derivation Infrastructure API** for the decorators, transaction + runner, lineage gate, and deterministic replay contract. + +Every public canonical pass is documented from its class docstring. The same +docstring carries its equations, derivation assumptions, research references, +and explicit non-claims, so source review and rendered documentation cannot +silently describe different algorithms. + +## Authority boundary + +- `ir.py` is authoritative for immutable schema and structural invariants. +- `passes.py` is authoritative for public pass contracts. +- dialect `*_derivation.py` modules own pure domain derivations. +- rendered pages explain those sources and link to them; they do not create a + parallel schema or estimator. + +Math is rendered by Arithmatex and MathJax. API objects and source listings are +collected by the Python handler directly from `src/` during `mkdocs build`. + diff --git a/docs/reference/index.zh.md b/docs/reference/index.zh.md new file mode 100644 index 0000000..aca29d3 --- /dev/null +++ b/docs/reference/index.zh.md @@ -0,0 +1,21 @@ +# 代码文档 + +本节使用 `mkdocstrings` 直接从 canonical Python 源码生成,是语义设计文档的 API 伴随视图,不定义第二套 IR 含义。 + +## 阅读顺序 + +1. 在 **Canonical IR API** 中选择表示层,查看该层真实 schema 定义。 +2. 阅读 **Pass 公式与 API**,理解每个已提交跨层推导的公式、假设与论文来源。 +3. 在 **推导基础设施 API** 中查看 decorator、事务 runner、lineage gate 和 deterministic replay contract。 + +每个公开 canonical Pass 都从类 docstring 生成文档。公式、推导假设、研究来源和明确的非声明与代码放在一起,避免源码 review 和站点文档悄悄描述不同算法。 + +## 权威边界 + +- `ir.py` 是 immutable schema 与结构 invariant 的事实源。 +- `passes.py` 是公开 Pass contract 的事实源。 +- dialect 下的 `*_derivation.py` 保存纯领域推导。 +- 渲染页面负责解释和链接,不建立平行 schema 或 estimator。 + +数学公式由 Arithmatex 与 MathJax 渲染;API 对象和源码清单在 `mkdocs build` 期间由 Python handler 直接从 `src/` 提取。 + diff --git a/docs/reference/machine-ir.en.md b/docs/reference/machine-ir.en.md new file mode 100644 index 0000000..5083b05 --- /dev/null +++ b/docs/reference/machine-ir.en.md @@ -0,0 +1,11 @@ +# MachineIR API + +`MachineIR` belongs to one target plugin and records its instruction dialect, +sections, entry points, ABI identity, command lineage, and program format. + +::: blueprinting.synthesizer.stages.machine.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/machine-ir.zh.md b/docs/reference/machine-ir.zh.md new file mode 100644 index 0000000..56f004e --- /dev/null +++ b/docs/reference/machine-ir.zh.md @@ -0,0 +1,10 @@ +# MachineIR API + +`MachineIR` 归一个 target plugin 所有,表达其 instruction dialect、section、entry point、ABI identity、command lineage 和 program format。 + +::: blueprinting.synthesizer.stages.machine.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/model-ir.en.md b/docs/reference/model-ir.en.md new file mode 100644 index 0000000..667656d --- /dev/null +++ b/docs/reference/model-ir.en.md @@ -0,0 +1,12 @@ +# ModelIR API + +`ModelIR` records model values, tensor types, explicit dataflow, operations, +effects, and dialect-owned semantic payloads. It excludes parallel placement, +hardware throughput, and predicted time. + +::: blueprinting.synthesizer.stages.model.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/model-ir.zh.md b/docs/reference/model-ir.zh.md new file mode 100644 index 0000000..6e4e6d7 --- /dev/null +++ b/docs/reference/model-ir.zh.md @@ -0,0 +1,10 @@ +# ModelIR API + +`ModelIR` 表达模型 value、tensor type、显式数据流、operation、effect 和 dialect-owned semantic payload;不包含并行 placement、硬件吞吐或预测时间。 + +::: blueprinting.synthesizer.stages.model.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/passes.en.md b/docs/reference/passes.en.md new file mode 100644 index 0000000..732d428 --- /dev/null +++ b/docs/reference/passes.en.md @@ -0,0 +1,91 @@ +# Pass formulae and API + +This page renders the source documentation for every public canonical pass. +The equations are part of the class docstrings, so changing an implemented +derivation and changing its code reference happen in the same review surface. + +## Coverage and provenance + +| Pass | Boundary | Formula provenance | Commit evidence | +|---|---|---|---| +| `DistributeTransformerTrainingPass` | Model → distributed task | Megatron-LM; selective recomputation | executable lineage rules + deterministic replay | +| `DistributeTransformerInferencePass` | Model → distributed task | Transformer; Megatron-LM; FlashAttention boundary | executable lineage rules + exact work tests | +| `PlanTransformerTrainingPass` | Distributed task → portable plan | conservation of the derived Transformer work vector | executable work-equality rules | +| `PlanTransformerInferencePass` | Distributed task → portable plan | work conservation; conservative unfused workspace | executable work-equality rules | +| `BindReferenceQueueTargetPass` | Portable → concrete | internal deterministic contract; no paper claim | structural verifier + cross-boundary predicates | +| `BindReferenceSlotTargetPass` | Portable → concrete | internal deterministic contract; no paper claim | extension verifier + dependency-order proof | + +!!! note "Formula versus performance evidence" + + FLOPs, bytes, payloads, shapes, and capacities below are canonical workload + facts. Latency, efficiency, overlap, and uncertainty belong to evidence/cost + views and are deliberately absent from these pass equations. + +## Training distribution + +::: blueprinting.synthesizer.stages.distributed.passes.DistributeTransformerTrainingPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Inference distribution + +::: blueprinting.synthesizer.stages.distributed.passes.DistributeTransformerInferencePass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Training portable planning + +::: blueprinting.synthesizer.stages.portable_plan.passes.PlanTransformerTrainingPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Inference portable planning + +::: blueprinting.synthesizer.stages.portable_plan.passes.PlanTransformerInferencePass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Reference queue binding + +::: blueprinting.synthesizer.stages.concrete_plan.passes.BindReferenceQueueTargetPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Reference slot/dataflow binding + +::: blueprinting.synthesizer.stages.concrete_plan.passes.BindReferenceSlotTargetPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Research sources + +- Shoeybi et al., [Megatron-LM](https://arxiv.org/abs/1909.08053). +- Narayanan et al., [Efficient Large-Scale Language Model Training Using + Megatron-LM](https://arxiv.org/abs/2104.04473). +- Vaswani et al., [Attention Is All You Need](https://arxiv.org/abs/1706.03762). +- Korthikanti et al., [Reducing Activation Recomputation in Large Transformer + Models](https://arxiv.org/abs/2205.05198). +- Dao et al., [FlashAttention](https://arxiv.org/abs/2205.14135). +- Rajbhandari et al., [ZeRO](https://arxiv.org/abs/1910.02054). + +These papers establish algorithmic provenance; repository verifiers and tests, +not citation alone, establish what this implementation actually preserves. + diff --git a/docs/reference/passes.zh.md b/docs/reference/passes.zh.md new file mode 100644 index 0000000..5a7aedf --- /dev/null +++ b/docs/reference/passes.zh.md @@ -0,0 +1,96 @@ +# Pass 公式与 API + +本页渲染全部公开 canonical Pass 的源码文档。核心公式直接写在 class docstring 中,因此实现推导发生变化时,代码参考也必须在同一个 review 面中变化。 + +## 覆盖与来源 + +| Pass | 边界 | 公式来源 | Commit 证据 | +|---|---|---|---| +| `DistributeTransformerTrainingPass` | Model → distributed task | Megatron-LM、selective recomputation | executable lineage rule + deterministic replay | +| `DistributeTransformerInferencePass` | Model → distributed task | Transformer、Megatron-LM、FlashAttention implementation boundary | executable lineage rule + exact-work test | +| `PlanTransformerTrainingPass` | Distributed task → portable plan | 已推导 Transformer work vector 的守恒映射 | executable work-equality rule | +| `PlanTransformerInferencePass` | Distributed task → portable plan | work conservation、保守 unfused workspace | executable work-equality rule | +| `BindReferenceQueueTargetPass` | Portable → concrete | 内部 deterministic contract,不声明论文算法 | structural verifier + cross-boundary predicate | +| `BindReferenceSlotTargetPass` | Portable → concrete | 内部 deterministic contract,不声明论文算法 | extension verifier + dependency-order proof | + +!!! note "公式与性能证据的边界" + + 下列 FLOPs、bytes、payload、shape 和 capacity 是 canonical workload fact。Latency、efficiency、overlap 与 uncertainty 属于 evidence/cost view,不能混入这些 Pass 公式。 + +## 训练分布推导 + +该 Pass 从一个语义 Transformer block 推导 local TP compute、collective、recompute、反向与 optimizer invocation。PP/DP 保留在 typed strategy 中,不伪造尚未物化的跨 stage DAG。 + +::: blueprinting.synthesizer.stages.distributed.passes.DistributeTransformerTrainingPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## 推理分布推导 + +该 Pass 分别处理 prefill 的 `q=c` 与 decode 的 `q=1`,显式推导 attention、projection、MLP、KV-cache 和 collective work。 + +::: blueprinting.synthesizer.stages.distributed.passes.DistributeTransformerInferencePass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## 训练 PortablePlan 推导 + +该 Pass 不重新估算 work,而是证明 distributed invocation 的 operations/read/write/message 向量被无损写入 `WorkloadFacts`。 + +::: blueprinting.synthesizer.stages.portable_plan.passes.PlanTransformerTrainingPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## 推理 PortablePlan 推导 + +该 Pass 同时形成 persistent weight、KV state、boundary 和 conservative workspace obligation;target binding 之后才能用已选实现收紧 workspace。 + +::: blueprinting.synthesizer.stages.portable_plan.passes.PlanTransformerInferencePass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Reference queue binding + +这是用于验证 ConcretePlan contract 的确定性构造,不是性能最优 scheduler。公式给出 buffer alignment recurrence 与 stable queue subsequence。 + +::: blueprinting.synthesizer.stages.concrete_plan.passes.BindReferenceQueueTargetPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## Reference slot/dataflow binding + +这是用于验证 typed target extension 的确定性 cycle/slot 构造,并通过 PortablePlan 的拓扑顺序证明 dependency legality。 + +::: blueprinting.synthesizer.stages.concrete_plan.passes.BindReferenceSlotTargetPass + options: + members: + - run + show_root_heading: false + show_root_toc_entry: false + +## 研究来源 + +- Shoeybi et al., [Megatron-LM](https://arxiv.org/abs/1909.08053)。 +- Narayanan et al., [Efficient Large-Scale Language Model Training Using Megatron-LM](https://arxiv.org/abs/2104.04473)。 +- Vaswani et al., [Attention Is All You Need](https://arxiv.org/abs/1706.03762)。 +- Korthikanti et al., [Reducing Activation Recomputation in Large Transformer Models](https://arxiv.org/abs/2205.05198)。 +- Dao et al., [FlashAttention](https://arxiv.org/abs/2205.14135)。 +- Rajbhandari et al., [ZeRO](https://arxiv.org/abs/1910.02054)。 + +论文用于说明算法来源,不自动证明实现正确;实际 preservation 由 verifier、negative test、deterministic replay 和 baseline gate 证明。 + diff --git a/docs/reference/portable-plan-ir.en.md b/docs/reference/portable-plan-ir.en.md new file mode 100644 index 0000000..a20368b --- /dev/null +++ b/docs/reference/portable-plan-ir.en.md @@ -0,0 +1,12 @@ +# PortablePlanIR API + +`PortablePlanIR` is the target-neutral selected-strategy plan: exact work, +abstract buffers, capability requirements, objectives, and task DAG. Its +quantities are workload facts rather than latency estimates. + +::: blueprinting.synthesizer.stages.portable_plan.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/docs/reference/portable-plan-ir.zh.md b/docs/reference/portable-plan-ir.zh.md new file mode 100644 index 0000000..38122e1 --- /dev/null +++ b/docs/reference/portable-plan-ir.zh.md @@ -0,0 +1,10 @@ +# PortablePlanIR API + +`PortablePlanIR` 是 target-neutral 的已选策略计划,包含精确 work、abstract buffer、capability requirement、objective 和 task DAG;其中数量是 workload fact,不是 latency estimate。 + +::: blueprinting.synthesizer.stages.portable_plan.ir + options: + members: true + show_root_heading: false + show_root_toc_entry: false + diff --git a/examples/calculon_calibration.py b/examples/calculon_calibration.py index f4f189d..8996b49 100644 --- a/examples/calculon_calibration.py +++ b/examples/calculon_calibration.py @@ -33,12 +33,26 @@ def main() -> int: arguments.output.write_text(payload, encoding="utf-8") print("Blueprinting synthesis ↔ Calculon calibration") + print(f"report schema: {report.schema}") + print(f"oracle: {report.oracle['name']} {report.oracle['package_version']} / {report.oracle['source_digest']}") print(f"hardware evidence: {report.hardware_name} / {report.evidence_revision}") print( "mean absolute error: " f"peak-only={report.peak_mean_absolute_error_percent:.3f}% " f"system-evidence={report.calibrated_mean_absolute_error_percent:.6f}%" ) + print( + "alignment audit: " + f"workload-max={report.workload_max_absolute_error_percent:.6g}% " + f"component-max={max(item['max_absolute_error_percent'] for item in report.breakdown_error.values()):.6g}% " + f"memory-max={report.memory_max_absolute_error_bytes:.0f} B" + ) + if report.paper_mean_absolute_error_percent is not None and report.paper_max_absolute_error_percent is not None: + print( + "paper holdout: " + f"MAPE={report.paper_mean_absolute_error_percent:.3f}% " + f"max={report.paper_max_absolute_error_percent:.3f}%" + ) print() print(f"{'case':42} {'peak':>10} {'calibrated':>12} {'Calculon':>10} {'error':>9}") for case in report.cases: @@ -49,6 +63,8 @@ def main() -> int: f"{case.calculon_total_seconds:10.4f} " f"{case.calibrated_error_percent:+8.4f}%" ) + if arguments.output is not None: + print(f"\nreport: {arguments.output}") return 0 diff --git a/examples/calculon_calibration_result.json b/examples/calculon_calibration_result.json index d8cef44..b3ad1d8 100644 --- a/examples/calculon_calibration_result.json +++ b/examples/calculon_calibration_result.json @@ -14,6 +14,8 @@ "per-case correction factor", "agrad/wgrad reference ratio" ], + "oracle_read_during_costing": false, + "oracle_read_during_lowering": false, "scope": "target-and-datatype", "shared_across_cases": true }, @@ -42,22 +44,36 @@ "recompute": 0.2861413155860729, "tensor_parallel": 0.19742618860307692 }, + "inputs": { + "execution": { + "file": "megatron-22B_full.json", + "sha256": "375e22afecdb4b24cf354b89ccce080c59e097ab8c85b5e73ecc142a120ef005" + }, + "model": { + "file": "megatron-22B.json", + "sha256": "fd62296fa7370d0e84291003fb1057f4131553c973f65954afaecf0dea1c3b25" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "1230592973066408f9b10ce90a054bc9bf3b86c6", - "model_digest": "6ca20f14d69e2a0eea633ac6339f498a0a50a563", + "distributed_digest": "d3fd44a9439d26a8ba498a06ec3a98b731be19b3", + "model_digest": "0a86c52efd6c33d9161717c16b6fc6b638d99993", "pass_checkpoints": [ { - "digest": "1230592973066408f9b10ce90a054bc9bf3b86c6", - "pass": "transformer-distribute-v2", + "digest": "d3fd44a9439d26a8ba498a06ec3a98b731be19b3", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "a702e06f09e0e7ed49b1d06371fdc5f01df40ead", - "pass": "transformer-plan-work-v2", + "digest": "5a96a71f806f1aede2be8280be8b8436bea3c51d", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "a702e06f09e0e7ed49b1d06371fdc5f01df40ead" + "portable_digest": "5a96a71f806f1aede2be8280be8b8436bea3c51d" }, "memory_bytes": { "calculon": 51705331712.0, @@ -202,22 +218,36 @@ "recompute": 0.048463972824615384, "tensor_parallel": 0.3070430097723077 }, + "inputs": { + "execution": { + "file": "megatron-22B_seqsel.json", + "sha256": "94fc3a6b946fbcd19601410789690e68b36417032473f4cf65bd97e14d97fb74" + }, + "model": { + "file": "megatron-22B.json", + "sha256": "fd62296fa7370d0e84291003fb1057f4131553c973f65954afaecf0dea1c3b25" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "5623e9ff5660faaf3d66f3c3791b19f61abc5877", - "model_digest": "6ca20f14d69e2a0eea633ac6339f498a0a50a563", + "distributed_digest": "eef0af11e6938fe3f11aff52cf63d9607872a447", + "model_digest": "0a86c52efd6c33d9161717c16b6fc6b638d99993", "pass_checkpoints": [ { - "digest": "5623e9ff5660faaf3d66f3c3791b19f61abc5877", - "pass": "transformer-distribute-v2", + "digest": "eef0af11e6938fe3f11aff52cf63d9607872a447", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "d8c1a76517458d5bd92af95c125225a00c1b6a61", - "pass": "transformer-plan-work-v2", + "digest": "02857f01085962db40c46d18288d9a61680389b0", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "d8c1a76517458d5bd92af95c125225a00c1b6a61" + "portable_digest": "02857f01085962db40c46d18288d9a61680389b0" }, "memory_bytes": { "calculon": 55920607232.0, @@ -362,22 +392,36 @@ "recompute": 3.648568878349474, "tensor_parallel": 1.5947695088246152 }, + "inputs": { + "execution": { + "file": "gpt3-175B_full.json", + "sha256": "25ccadd0e39ba81f544ea3b0415189ea42102543d550cf725d8ae15ae99dbd81" + }, + "model": { + "file": "gpt3-175B.json", + "sha256": "fabfc66b4a57d3c357d410232a9805b004bbee8f23abbc505c0d23594c208c11" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "878896018e2080321a2998174a5a91c269247bce", - "model_digest": "e2a097e24aa28d6bc3fbfc731880d6a766ab9db9", + "distributed_digest": "0b572a6def05b2097affcd80e33868beb2e120e3", + "model_digest": "db567ca7a65374b0edddb502d447ee62a0af69ee", "pass_checkpoints": [ { - "digest": "878896018e2080321a2998174a5a91c269247bce", - "pass": "transformer-distribute-v2", + "digest": "0b572a6def05b2097affcd80e33868beb2e120e3", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "b9ed7a9c81a7cbd02066e000478d3488e31c3321", - "pass": "transformer-plan-work-v2", + "digest": "5d8df3e92f712d42df1c8c1c97fffbbb3b5cc9eb", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "b9ed7a9c81a7cbd02066e000478d3488e31c3321" + "portable_digest": "5d8df3e92f712d42df1c8c1c97fffbbb3b5cc9eb" }, "memory_bytes": { "calculon": 51649806336.0, @@ -522,22 +566,36 @@ "recompute": 0.31852512492307694, "tensor_parallel": 2.487064078178462 }, + "inputs": { + "execution": { + "file": "gpt3-175B_seqsel.json", + "sha256": "99bd010ac34b52578a461aa877d208c65e777932e300b191d7c9e29d38cea6a8" + }, + "model": { + "file": "gpt3-175B.json", + "sha256": "fabfc66b4a57d3c357d410232a9805b004bbee8f23abbc505c0d23594c208c11" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "1f2ac9b45bd791e4a5032c0a969270633cbd5926", - "model_digest": "e2a097e24aa28d6bc3fbfc731880d6a766ab9db9", + "distributed_digest": "1e2da5234306897e0f109759584ba1c8870d1db8", + "model_digest": "db567ca7a65374b0edddb502d447ee62a0af69ee", "pass_checkpoints": [ { - "digest": "1f2ac9b45bd791e4a5032c0a969270633cbd5926", - "pass": "transformer-distribute-v2", + "digest": "1e2da5234306897e0f109759584ba1c8870d1db8", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "bf7be9b50d482232aaee649c506f4afca5c9dfcb", - "pass": "transformer-plan-work-v2", + "digest": "362754f4f37894aa7bd50eb8e8b7d0319a748b61", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "bf7be9b50d482232aaee649c506f4afca5c9dfcb" + "portable_digest": "362754f4f37894aa7bd50eb8e8b7d0319a748b61" }, "memory_bytes": { "calculon": 58060800000.0, @@ -682,22 +740,36 @@ "recompute": 9.488717946599008, "tensor_parallel": 2.8847319171282053 }, + "inputs": { + "execution": { + "file": "turing-530B_full.json", + "sha256": "157ccecce654eb875b6a1682fca6bb2a10309961bc50382db56cfff54bd23bfe" + }, + "model": { + "file": "turing-530B.json", + "sha256": "2e855371e0abe718b346ad3f876436d0f61953ba6e2c828e68be664596b83667" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "bdc95102dbe14479526bf537c29e5a37dcd99e36", - "model_digest": "941505070aec9373c78d390e55facfadc3966960", + "distributed_digest": "3ce405d27e936dc75adecd9f741c785d4ff7828e", + "model_digest": "697aa38a61200b95b9adeefcac544528837d67a5", "pass_checkpoints": [ { - "digest": "bdc95102dbe14479526bf537c29e5a37dcd99e36", - "pass": "transformer-distribute-v2", + "digest": "3ce405d27e936dc75adecd9f741c785d4ff7828e", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "3988fe962f1ccecca2cee3a08b98da5e177f8c65", - "pass": "transformer-plan-work-v2", + "digest": "b3fa239a4ef8a29609d5afcdb58b66ac479efd60", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "3988fe962f1ccecca2cee3a08b98da5e177f8c65" + "portable_digest": "b3fa239a4ef8a29609d5afcdb58b66ac479efd60" }, "memory_bytes": { "calculon": 45386465280.0, @@ -842,22 +914,36 @@ "recompute": 0.45357908108034195, "tensor_parallel": 4.4889105591794864 }, + "inputs": { + "execution": { + "file": "turing-530B_seqsel.json", + "sha256": "2d6516a1ce9664951608239838572db5a0129ef794f90d4c46f5924a3221891a" + }, + "model": { + "file": "turing-530B.json", + "sha256": "2e855371e0abe718b346ad3f876436d0f61953ba6e2c828e68be664596b83667" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "37a93bf5ccd120dfcd7288b2e928d9160db2cfb5", - "model_digest": "941505070aec9373c78d390e55facfadc3966960", + "distributed_digest": "26b7a85626700e2e1541503b515f09ffc1f0c4dd", + "model_digest": "697aa38a61200b95b9adeefcac544528837d67a5", "pass_checkpoints": [ { - "digest": "37a93bf5ccd120dfcd7288b2e928d9160db2cfb5", - "pass": "transformer-distribute-v2", + "digest": "26b7a85626700e2e1541503b515f09ffc1f0c4dd", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "2e9047b02f855c3527c07cfae116bbc41631eaea", - "pass": "transformer-plan-work-v2", + "digest": "126da397b345f17b828170a70e811be98398421f", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "2e9047b02f855c3527c07cfae116bbc41631eaea" + "portable_digest": "126da397b345f17b828170a70e811be98398421f" }, "memory_bytes": { "calculon": 57487032320.0, @@ -1002,22 +1088,36 @@ "recompute": 17.447966314699297, "tensor_parallel": 4.3855419689572654 }, + "inputs": { + "execution": { + "file": "megatron-1T_full.json", + "sha256": "24f738a12c5b1fc64b0c0c36bdfc70237032388bc0d383e0aac0d66ebb5b14e6" + }, + "model": { + "file": "megatron-1T.json", + "sha256": "5d5c2c6678b044897b1ea70fbaf2ab4ded83c643c3998b7e9e26ce57ae2967da" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "b1a289cda7c0b71b92c7ff3328a6a0193aa396e6", - "model_digest": "f293e3af1366c93c861a1107ac21c88e394fecc2", + "distributed_digest": "8a007b00c8be10ea81bcfa8b84e2b8f50969f214", + "model_digest": "e4edf8022b0bff660948ff3e33bc9acdfb60b55f", "pass_checkpoints": [ { - "digest": "b1a289cda7c0b71b92c7ff3328a6a0193aa396e6", - "pass": "transformer-distribute-v2", + "digest": "8a007b00c8be10ea81bcfa8b84e2b8f50969f214", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "d4a11ba89f39f36f002ee9c0179bbc02dc62a542", - "pass": "transformer-plan-work-v2", + "digest": "db115adc9fbe30965631fc3122b1976c751c9b9a", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "d4a11ba89f39f36f002ee9c0179bbc02dc62a542" + "portable_digest": "db115adc9fbe30965631fc3122b1976c751c9b9a" }, "memory_bytes": { "calculon": 49679769600.0, @@ -1162,22 +1262,36 @@ "recompute": 0.6911681235509971, "tensor_parallel": 6.819764661606838 }, + "inputs": { + "execution": { + "file": "megatron-1T_seqsel.json", + "sha256": "7ba4278645e3ed2dbf26c4dddc007364a56fe495200061183a641de9c0dec9e4" + }, + "model": { + "file": "megatron-1T.json", + "sha256": "5d5c2c6678b044897b1ea70fbaf2ab4ded83c643c3998b7e9e26ce57ae2967da" + }, + "system": { + "file": "a100_80g.json", + "sha256": "afdd153ff952babe2102c23c6eba390f67e624eddbd4159f61015a1486a556bc" + } + }, "ir": { - "distributed_digest": "d6abe8eac1b0974539e127ccc0b0b148e9645ccc", - "model_digest": "f293e3af1366c93c861a1107ac21c88e394fecc2", + "distributed_digest": "9a3f1b3500db1287531ac2bc77cada0d312cfbda", + "model_digest": "e4edf8022b0bff660948ff3e33bc9acdfb60b55f", "pass_checkpoints": [ { - "digest": "d6abe8eac1b0974539e127ccc0b0b148e9645ccc", - "pass": "transformer-distribute-v2", + "digest": "9a3f1b3500db1287531ac2bc77cada0d312cfbda", + "pass": "transformer-distribute", "schema": "blueprinting.distributed-task" }, { - "digest": "7549a08665566ef4187e9a37ca50d860308c8800", - "pass": "transformer-plan-work-v2", + "digest": "a27fd7b19ea9a120427cbfe6b522d13e4e3a6fc9", + "pass": "transformer-plan-work", "schema": "blueprinting.portable-plan" } ], - "portable_digest": "7549a08665566ef4187e9a37ca50d860308c8800" + "portable_digest": "a27fd7b19ea9a120427cbfe6b522d13e4e3a6fc9" }, "memory_bytes": { "calculon": 63507865600.0, @@ -1303,9 +1417,68 @@ "evidence_revision": "eb1eb9fcc4a6e414e85b0252c23ea9ad2730aae2", "name": "a100_80g" }, - "schema": "blueprinting.calculon-calibration-experiment.v2", + "oracle": { + "name": "Calculon", + "package_version": "0.1.0", + "source_digest": "c72cf8a0a0fc9f1fb9813a2248747d6242bbc664b665abe4b5fc6b6b18f5927b", + "source_repository": "https://github.com/calculon-ai/calculon" + }, + "paper_baseline": { + "name": "SeqSel Table 5", + "paper": "Reducing Activation Recomputation in Large Transformer Models", + "source": "https://arxiv.org/abs/2205.05198" + }, + "schema": "blueprinting.calculon-calibration-experiment.v0", "summary": { + "breakdown_error": { + "backward": { + "max_absolute_error_percent": 2.7984239203661165e-14, + "max_absolute_error_seconds": 7.105427357601002e-15, + "mean_absolute_error_percent": 1.5317396770292495e-14 + }, + "data_parallel": { + "max_absolute_error_percent": 0.0, + "max_absolute_error_seconds": 0.0, + "mean_absolute_error_percent": 0.0 + }, + "forward": { + "max_absolute_error_percent": 0.0, + "max_absolute_error_seconds": 0.0, + "mean_absolute_error_percent": 0.0 + }, + "optimizer": { + "max_absolute_error_percent": 0.0, + "max_absolute_error_seconds": 0.0, + "mean_absolute_error_percent": 0.0 + }, + "pipeline_bubble": { + "max_absolute_error_percent": 1.8000830927035763e-14, + "max_absolute_error_seconds": 1.7763568394002505e-15, + "mean_absolute_error_percent": 2.2501038658794704e-15 + }, + "pipeline_parallel": { + "max_absolute_error_percent": 0.0, + "max_absolute_error_seconds": 0.0, + "mean_absolute_error_percent": 0.0 + }, + "recommunication": { + "max_absolute_error_percent": 0.0, + "max_absolute_error_seconds": 0.0, + "mean_absolute_error_percent": 0.0 + }, + "recompute": { + "max_absolute_error_percent": 0.0, + "max_absolute_error_seconds": 0.0, + "mean_absolute_error_percent": 0.0 + }, + "tensor_parallel": { + "max_absolute_error_percent": 1.978605739612848e-14, + "max_absolute_error_seconds": 8.881784197001252e-16, + "mean_absolute_error_percent": 6.49050536409944e-15 + } + }, "case_count": 8, + "memory_max_absolute_error_bytes": 0.0, "peak_only_mean_absolute_error_percent": 12.993831306582473, "system_evidence_max_absolute_error_percent": 2.0611695169375357e-14, "system_evidence_mean_absolute_error_percent": 6.17654222892984e-15, diff --git a/mkdocs.yml b/mkdocs.yml index 5b8af1b..7e078cc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,25 +68,60 @@ nav: - Golden Derivation Walkthrough: design/walkthrough.md - Analysis Module Architecture: design/modules.md - Floating-Point Numerical Analysis: design/numerical-analysis.md + - IR Derivation Debugging: design/derivation-debugging.md + - Progressive Typed Python Contracts: design/typed-python-contracts.md - Formal Representations: - Representation Stack: design/ir/index.md + - Python Algebraic IR Authoring: design/ir/python-algebra.md + - Typed Transformer Parallel Strategies: design/ir/parallel-strategy.md - Workload and Distribution Models: design/ir/model-distributed.md - Planning and Realization Models: design/ir/planning-execution.md - Analysis and Transformation: - Transaction Infrastructure: design/passes/index.md - Transformer Workload Derivation: design/passes/transformer.md - Architecture Binding and Plan Construction: design/passes/target.md + - Code Reference: + - Code Documentation: reference/index.md + - Canonical IR API: + - ModelIR API: reference/model-ir.md + - DistributedTaskIR API: reference/distributed-task-ir.md + - PortablePlanIR API: reference/portable-plan-ir.md + - ConcretePlanIR API: reference/concrete-plan-ir.md + - MachineIR API: reference/machine-ir.md + - Pass Formulae and API: reference/passes.md + - Derivation Infrastructure API: reference/derivation-infrastructure.md - Project: - Implementation Status: project/status.md - Roadmap: project/roadmap.md - Decisions and Terminology: project/decisions.md - ADR-0001 — Synthesizer Package: project/adr/0001-synthesizer-package.md - ADR-0002 — Workload and System Domains: project/adr/0002-workload-system-domains.md + - ADR-0003 — Derivation Debug Trace: project/adr/0003-derivation-debug-trace.md + - ADR-0004 — Semantic Wire Identities: project/adr/0004-semantic-wire-identities.md + - ADR-0005 — Algebraic Expression and Command Schemas: project/adr/0005-algebraic-expression-command-schemas.md + - ADR-0006 — Progressive Typed Python Contracts: project/adr/0006-progressive-typed-python-contracts.md - Architecture Risk Register: project/risks.md - Documentation Guide: contributing/documentation.md plugins: - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: + - src + options: + docstring_section_style: list + heading_level: 2 + members_order: source + merge_init_into_class: true + separate_signature: true + skip_local_inventory: true + show_root_full_path: true + show_root_heading: true + show_signature_annotations: true + show_source: true - i18n: docs_structure: suffix # Shared, language-neutral assets are inherited from the default locale. @@ -130,20 +165,37 @@ plugins: Golden Derivation Walkthrough: 完整推导示例 Analysis Module Architecture: 分析模块架构 Floating-Point Numerical Analysis: 浮点数数值分析 + IR Derivation Debugging: IR 推导调试 + Progressive Typed Python Contracts: 渐进式 Typed Python Contract Formal Representations: 形式化表示 Representation Stack: 表示体系 + Typed Transformer Parallel Strategies: Transformer 并行策略的类型化表示 Workload and Distribution Models: 工作负载与分布模型 Planning and Realization Models: 规划与实现模型 Analysis and Transformation: 分析与变换 Transaction Infrastructure: 事务基础设施 Transformer Workload Derivation: Transformer 工作负载推导 Architecture Binding and Plan Construction: 架构绑定与计划构造 + Code Reference: 代码参考 + Code Documentation: 代码文档 + Canonical IR API: Canonical IR API + ModelIR API: ModelIR API + DistributedTaskIR API: DistributedTaskIR API + PortablePlanIR API: PortablePlanIR API + ConcretePlanIR API: ConcretePlanIR API + MachineIR API: MachineIR API + Pass Formulae and API: Pass 公式与 API + Derivation Infrastructure API: 推导基础设施 API Project: 项目 Implementation Status: 实现状态 Roadmap: 路线图 Decisions and Terminology: 设计决策与术语 ADR-0001 — Synthesizer Package: ADR-0001 — Synthesizer 包命名 ADR-0002 — Workload and System Domains: ADR-0002 — Workload 与 System 领域 + ADR-0003 — Derivation Debug Trace: ADR-0003 — 推导调试 Trace + ADR-0004 — Semantic Wire Identities: ADR-0004 — 语义化 Wire Identity + ADR-0005 — Algebraic Expression and Command Schemas: ADR-0005 — 代数化表达式与 Command Schema + ADR-0006 — Progressive Typed Python Contracts: ADR-0006 — 渐进式 Typed Python Contract Architecture Risk Register: 架构风险登记表 Documentation Guide: 文档维护指南 @@ -156,6 +208,10 @@ extra: extra_css: - assets/stylesheets/site.css +extra_javascript: + - javascripts/mathjax.js + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js + markdown_extensions: - admonition - attr_list @@ -164,6 +220,8 @@ markdown_extensions: - toc: permalink: true - pymdownx.details + - pymdownx.arithmatex: + generic: true - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite diff --git a/pyproject.toml b/pyproject.toml index c598641..d30cd78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,9 +42,10 @@ classifiers = [ dependencies = [ "numpy>=1.20.0", - "pandas>=1.3.0", - "rich>=12.0.0", "nicegui>=3.15,<4", + "typing-extensions>=4.4.0", + # Runtime requirements of the retained vendored Calculon comparison oracle. + "pandas>=1.3.0", "psutil>=5.9.0", ] @@ -52,15 +53,15 @@ dependencies = [ performance-data = [ "pyarrow>=12.0.0", ] -full = [ - "plotly>=5.0.0", -] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.24.0", ] +typing = [ + "mypy>=1.17,<2", +] docs = [ "mkdocs>=1.5.0", "mkdocs-material>=9.0.0", @@ -68,10 +69,6 @@ docs = [ "mkdocstrings[python]>=0.24.0", "pymdown-extensions>=10.21.3,<11.0.0", ] -all = [ - "blueprinting[full,dev,docs,performance-data]", -] - [project.urls] Homepage = "https://github.com/DeepLink-org/Blueprinting" Documentation = "https://deeplink-org.github.io/Blueprinting/" @@ -118,7 +115,7 @@ packages = ["src/blueprinting", "src/calculon"] "data/validation/vidur/phi2_a100_tp1" = "blueprinting/presets/evidence/vidur/phi2_a100_tp1" [tool.hatch.envs.default] -features = ["dev", "full"] +features = ["dev"] [tool.hatch.envs.default.scripts] test = "pytest {args:tests}" @@ -132,7 +129,7 @@ features = ["docs"] [tool.hatch.envs.docs.scripts] check = "python scripts/check_docs_i18n.py" -build = "python scripts/check_docs_i18n.py && mkdocs build --strict" +build = "python scripts/check_docs_i18n.py && mkdocs build --strict && python scripts/check_rendered_code_docs.py" serve = "mkdocs serve" # ============================================================================ @@ -145,7 +142,6 @@ target-version = "py310" src = ["src", "tests"] exclude = [ "src/calculon/*", - "scripts/*", ".git", "__pycache__", "*.egg-info", @@ -179,7 +175,6 @@ known-first-party = ["blueprinting", "calculon"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["B", "SIM"] -"__init__.py" = ["F401"] # ============================================================================ # Pytest Configuration @@ -202,8 +197,8 @@ markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", ] filterwarnings = [ - "ignore::DeprecationWarning", - "ignore::PendingDeprecationWarning", + "error::DeprecationWarning", + "error::PendingDeprecationWarning", ] # ============================================================================ @@ -243,11 +238,34 @@ python_version = "3.10" warn_return_any = true warn_unused_configs = true ignore_missing_imports = true +enable_error_code = ["exhaustive-match"] exclude = [ "src/calculon/", "tests/", ] +[[tool.mypy.overrides]] +module = [ + "blueprinting.contracts", + "blueprinting.schema.authoring", + "blueprinting.schema.contracts", + "blueprinting.schema.diagnostics", + "blueprinting.schema.deriving", + "blueprinting.schema.result", + "blueprinting.mapping.transformer", + "blueprinting.workload.transformer", + "blueprinting.workload.transformer_inference", + "blueprinting.synthesizer.bindings", + "blueprinting.synthesizer.passes.base", + "blueprinting.synthesizer.passes.authoring", + "blueprinting.synthesizer.passes.deriving", + "blueprinting.synthesizer.stages.common", + "blueprinting.synthesizer.stages.distributed.ir", + "blueprinting.synthesizer.stages.portable_plan.ir", + "blueprinting.analysis.cost.protocol", +] +strict = true + [dependency-groups] dev = [ "pytest>=8.3.5", @@ -255,3 +273,6 @@ dev = [ "pytest-cov>=4.0.0", "ruff>=0.1.0", ] +typing = [ + "mypy>=1.17,<2", +] diff --git a/scripts/check_rendered_code_docs.py b/scripts/check_rendered_code_docs.py new file mode 100644 index 0000000..d25dfa9 --- /dev/null +++ b/scripts/check_rendered_code_docs.py @@ -0,0 +1,69 @@ +"""Verify that generated API pages contain rendered pass theory and source.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SITE = ROOT / "site" +PASSES = ( + "DistributeTransformerTrainingPass", + "DistributeTransformerInferencePass", + "PlanTransformerTrainingPass", + "PlanTransformerInferencePass", + "BindReferenceQueueTargetPass", + "BindReferenceSlotTargetPass", +) +IR_PAGES = ( + "model-ir", + "distributed-task-ir", + "portable-plan-ir", + "concrete-plan-ir", + "machine-ir", +) + + +def _read(path: Path, failures: list[str]) -> str: + if not path.is_file(): + failures.append(f"missing generated page: {path.relative_to(ROOT)}") + return "" + return path.read_text() + + +def main() -> int: + failures: list[str] = [] + for prefix in (Path(), Path("zh")): + pass_page = SITE / prefix / "reference/passes/index.html" + html = _read(pass_page, failures) + if not html: + continue + for pass_name in PASSES: + if pass_name not in html: + failures.append(f"{pass_page.relative_to(ROOT)} omits {pass_name}") + if html.count('class="arithmatex"') < len(PASSES): + failures.append(f"{pass_page.relative_to(ROOT)} does not render one or more pass equations") + if html.count("Source code in") < len(PASSES): + failures.append(f"{pass_page.relative_to(ROOT)} does not render source for every pass") + if "javascripts/mathjax.js" not in html: + failures.append(f"{pass_page.relative_to(ROOT)} does not load the MathJax configuration") + + for page_name in IR_PAGES: + ir_page = SITE / prefix / f"reference/{page_name}/index.html" + ir_html = _read(ir_page, failures) + if ir_html and 'class="doc doc-object' not in ir_html: + failures.append(f"{ir_page.relative_to(ROOT)} contains no generated API object") + + generated_mathjax = _read(SITE / "javascripts/mathjax.js", failures) + if generated_mathjax and "MathJax.typesetPromise()" not in generated_mathjax: + failures.append("generated MathJax configuration does not typeset page content") + + if failures: + for failure in failures: + print(f"code-docs: {failure}") + return 1 + print(f"code-docs: rendered {len(PASSES)} pass derivations and {len(IR_PAGES)} IR APIs in 2 locales") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_type_contracts.py b/scripts/check_type_contracts.py new file mode 100644 index 0000000..754729f --- /dev/null +++ b/scripts/check_type_contracts.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Compile runtime type contracts without importing mypy.""" + +from __future__ import annotations + +from blueprinting.contracts import compile_runtime_contracts +from blueprinting.schema import Err + + +def main() -> int: + result = compile_runtime_contracts() + if isinstance(result, Err): + for diagnostic in result.error: + print(diagnostic.render()) + return 1 + manifest = result.value + print( + "runtime type contracts: " + f"{len(manifest.types.canonical_types)} records/enums, " + f"{len(manifest.types.algebraic_families)} ADTs, " + f"{len(manifest.derivations)} passes; digest={manifest.digest}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_wheel_contract.py b/scripts/check_wheel_contract.py index 3ca95cb..4a75e4c 100644 --- a/scripts/check_wheel_contract.py +++ b/scripts/check_wheel_contract.py @@ -16,6 +16,8 @@ def main(argv: list[str]) -> int: with zipfile.ZipFile(wheel) as archive: members = archive.infolist() names = tuple(item.filename for item in members) + if "blueprinting/py.typed" not in names: + raise SystemExit("wheel is missing the PEP 561 blueprinting/py.typed marker") required_prefixes = ( "blueprinting/presets/models/", "blueprinting/presets/systems/", @@ -38,7 +40,15 @@ def main(argv: list[str]) -> int: ) if unexpected_evidence: raise SystemExit(f"wheel contains unapproved evidence: {unexpected_evidence[:3]!r}") - forbidden_prefixes = ("blueprinting/systems/", "data/evidence/") + forbidden_prefixes = ( + "blueprinting/compiler/", + "blueprinting/fp/", + "blueprinting/synthesizer/ir/", + "blueprinting/synthesizer/lowering/", + "blueprinting/synthesizer/stages/distributed_task/", + "blueprinting/systems/", + "data/evidence/", + ) leaked = tuple(name for name in names if name.startswith(forbidden_prefixes)) if leaked: raise SystemExit(f"wheel contains optional evidence: {leaked[:3]!r}") diff --git a/src/blueprinting/__init__.py b/src/blueprinting/__init__.py index 76b49c5..b5c4914 100644 --- a/src/blueprinting/__init__.py +++ b/src/blueprinting/__init__.py @@ -3,8 +3,8 @@ Domain contracts live in explicit packages such as :mod:`blueprinting.workload`, :mod:`blueprinting.mapping`, :mod:`blueprinting.system`, and :mod:`blueprinting.synthesizer`. The package root intentionally avoids broad -re-exports so importing Blueprinting does not initialize a legacy simulation -stack or hide domain ownership. +re-exports so importing Blueprinting does not initialize application services +or hide domain ownership. """ from .__about__ import __version__ diff --git a/src/blueprinting/analysis/__init__.py b/src/blueprinting/analysis/__init__.py index 1ca6106..b4dbf63 100644 --- a/src/blueprinting/analysis/__init__.py +++ b/src/blueprinting/analysis/__init__.py @@ -49,7 +49,6 @@ ) from .inference_evidence import ( InferenceBaseline, - InferenceCostProvider, InferenceEvidenceQuery, InferenceEvidenceResult, ) @@ -77,7 +76,6 @@ "EvidenceCoverage", "EvidenceDatabaseSummary", "InferenceBaseline", - "InferenceCostProvider", "InferenceEvidenceQuery", "InferenceEvidenceResult", "InferencePhaseEstimate", diff --git a/src/blueprinting/analysis/cost/__init__.py b/src/blueprinting/analysis/cost/__init__.py index da0c08e..a910410 100644 --- a/src/blueprinting/analysis/cost/__init__.py +++ b/src/blueprinting/analysis/cost/__init__.py @@ -18,6 +18,7 @@ TabularPerformanceImporter, ) from .protocol import ( + CostAvailable, CostEstimate, CostModelError, CostNotAvailableError, @@ -28,12 +29,14 @@ CostResolver, CostSubject, CostSupport, + CostSupportVariant, + CostUnavailable, EstimateMatch, EstimateMethod, EstimateUncertainty, InvalidCostEvidenceError, + InvalidCostSupport, ProviderAttempt, - SupportStatus, ) from .roofline import RooflineCostProvider @@ -41,6 +44,7 @@ "AIConfiguratorPerformanceImporter", "AIConfiguratorTable", "CostEstimate", + "CostAvailable", "CostModelError", "CostNotAvailableError", "CostProvider", @@ -52,6 +56,8 @@ "CostResolver", "CostSubject", "CostSupport", + "CostSupportVariant", + "CostUnavailable", "EstimateMatch", "EstimateMethod", "EstimateUncertainty", @@ -59,6 +65,7 @@ "EvidenceCoverage", "EvidenceDatabaseSummary", "InvalidCostEvidenceError", + "InvalidCostSupport", "LatencyUnit", "PerformanceDatabase", "PerformanceDatabaseProvider", @@ -66,7 +73,6 @@ "ProviderAttempt", "RooflineCostProvider", "SimulatorPerformanceImporter", - "SupportStatus", "TabularImportSpec", "TabularPerformanceImporter", "build_gemm_comparison_curve", diff --git a/src/blueprinting/analysis/cost/aiconfigurator.py b/src/blueprinting/analysis/cost/aiconfigurator.py index 6dacb94..89349e9 100644 --- a/src/blueprinting/analysis/cost/aiconfigurator.py +++ b/src/blueprinting/analysis/cost/aiconfigurator.py @@ -99,7 +99,7 @@ def _runtime_selector(row: dict[str, Any], row_number: int) -> dict[str, Any]: class AIConfiguratorPerformanceImporter: - IMPORTER_REVISION = "blueprinting-aiconfigurator-perf-v1" + IMPORTER_REVISION = "blueprinting-aiconfigurator-perf-v0" @classmethod def from_file( diff --git a/src/blueprinting/analysis/cost/database.py b/src/blueprinting/analysis/cost/database.py index e64f23c..aed3345 100644 --- a/src/blueprinting/analysis/cost/database.py +++ b/src/blueprinting/analysis/cost/database.py @@ -2,13 +2,19 @@ from __future__ import annotations -import math import statistics from collections import defaultdict -from dataclasses import dataclass, field -from functools import cached_property - -from blueprinting.schema.codec import canonical_dumps, canonical_loads, content_digest, record_type +from collections.abc import Iterable +from dataclasses import field +from typing import Annotated, TypeAlias + +from blueprinting.schema.authoring import ( + NonEmptyText, + NonNegativeFiniteNumber, + ValueConstraint, + record, +) +from blueprinting.schema.codec import canonical_dumps, canonical_loads, content_digest from blueprinting.schema.frozen import FrozenDict from .protocol import ( @@ -17,6 +23,7 @@ CostQuery, CostSubject, CostSupport, + CostSupportVariant, EstimateMatch, EstimateMethod, EstimateUncertainty, @@ -25,82 +32,59 @@ # Keep the legacy codec namespace as a stable serialized identity. +_PERFORMANCE_RECORD_IDENTITY_FIELDS = frozenset({"subject", "operation", "hardware", "datatype"}) + -@record_type("compiler.analysis.cost.provenance.v1") -@dataclass(frozen=True) +def performance_record_identity_collisions(keys: Iterable[str]) -> frozenset[str]: + return _PERFORMANCE_RECORD_IDENTITY_FIELDS.intersection(keys) + + +@record("blueprinting.analysis.cost.provenance") class EvidenceProvenance: - source: str - source_revision: str - importer: str - data_digest: str + source: NonEmptyText + source_revision: NonEmptyText + importer: NonEmptyText + data_digest: NonEmptyText method: EstimateMethod metadata: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - for name in ("source", "source_revision", "importer", "data_digest"): - value = getattr(self, name) - if not isinstance(value, str) or not value: - raise ValueError(f"{name} must be a non-empty string") - if not isinstance(self.method, EstimateMethod): - raise TypeError("method must be EstimateMethod") - object.__setattr__(self, "metadata", FrozenDict(self.metadata)) - -@record_type("compiler.analysis.cost.performance_record.v1") -@dataclass(frozen=True) +@record("blueprinting.analysis.cost.performance-record") class PerformanceRecord: - record_id: str + record_id: NonEmptyText subject: CostSubject - operation: str - hardware: str - datatype: str - seconds: float + operation: NonEmptyText + hardware: NonEmptyText + datatype: NonEmptyText + seconds: NonNegativeFiniteNumber selector: FrozenDict provenance: EvidenceProvenance metadata: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - for name in ("record_id", "operation", "hardware", "datatype"): - value = getattr(self, name) - if not isinstance(value, str) or not value: - raise ValueError(f"{name} must be a non-empty string") - if not isinstance(self.subject, CostSubject): - raise TypeError("subject must be CostSubject") - if ( - isinstance(self.seconds, bool) - or not isinstance(self.seconds, (int, float)) - or not math.isfinite(self.seconds) - or self.seconds < 0 - ): - raise ValueError("record seconds must be finite and non-negative") - if not isinstance(self.provenance, EvidenceProvenance): - raise TypeError("provenance must be EvidenceProvenance") - object.__setattr__(self, "selector", FrozenDict(self.selector)) - object.__setattr__(self, "metadata", FrozenDict(self.metadata)) - duplicate_identity = {"subject", "operation", "hardware", "datatype"}.intersection(self.selector) + duplicate_identity = performance_record_identity_collisions(self.selector) if duplicate_identity: raise ValueError(f"record selector duplicates core identity: {', '.join(sorted(duplicate_identity))}") -@record_type("compiler.analysis.cost.performance_database.v1") -@dataclass(frozen=True) +PerformanceRecords: TypeAlias = Annotated[ + tuple[PerformanceRecord, ...], + ValueConstraint.NON_EMPTY, +] + + +@record("blueprinting.analysis.cost.performance-database") class PerformanceDatabase: - name: str - records: tuple[PerformanceRecord, ...] + name: NonEmptyText + records: PerformanceRecords metadata: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - if not isinstance(self.name, str) or not self.name: - raise ValueError("database name must be a non-empty string") - object.__setattr__(self, "records", tuple(self.records)) - object.__setattr__(self, "metadata", FrozenDict(self.metadata)) - if not self.records or any(not isinstance(record, PerformanceRecord) for record in self.records): - raise ValueError("performance database requires typed records") record_ids = tuple(record.record_id for record in self.records) if len(set(record_ids)) != len(record_ids): raise ValueError("performance database record IDs must be unique") - @cached_property + @property def revision(self) -> str: return content_digest(self, "performance-database") @@ -140,12 +124,25 @@ def __init__(self, database: PerformanceDatabase) -> None: self._database = database self._name = f"performance-db:{database.name}" self._revision = database.revision - index = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) - for record in database.records: - core = (record.subject, record.operation, record.hardware, record.datatype) - selector_keys = tuple(record.selector) - typed_values = tuple((type(record.selector[key]), record.selector[key]) for key in selector_keys) - index[core][selector_keys][typed_values].append(record) + index: defaultdict[ + tuple[CostSubject, str, str, str], + defaultdict[ + tuple[str, ...], + defaultdict[tuple[tuple[type[object], object], ...], list[PerformanceRecord]], + ], + ] = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + for evidence_record in database.records: + core = ( + evidence_record.subject, + evidence_record.operation, + evidence_record.hardware, + evidence_record.datatype, + ) + selector_keys = tuple(evidence_record.selector) + typed_values = tuple( + (type(evidence_record.selector[key]), evidence_record.selector[key]) for key in selector_keys + ) + index[core][selector_keys][typed_values].append(evidence_record) self._index = { core: { selector_keys: {values: tuple(records) for values, records in value_index.items()} @@ -186,17 +183,17 @@ def _selected_records(self, query: CostQuery) -> tuple[PerformanceRecord, ...] | specificity = max(len(record.selector) for record in candidates) candidates = tuple(record for record in candidates if len(record.selector) == specificity) groups: dict[tuple[object, ...], list[PerformanceRecord]] = defaultdict(list) - for record in candidates: - provenance = record.provenance + for evidence_record in candidates: + provenance = evidence_record.provenance key = ( - record.selector, + evidence_record.selector, provenance.source, provenance.source_revision, provenance.importer, provenance.method, provenance.data_digest, ) - groups[key].append(record) + groups[key].append(evidence_record) if len(groups) > 1: descriptions = sorted( f"{items[0].provenance.source}@{items[0].provenance.source_revision}:{dict(items[0].selector)}" @@ -207,7 +204,7 @@ def _selected_records(self, query: CostQuery) -> tuple[PerformanceRecord, ...] | ) return tuple(next(iter(groups.values()))) - def supports(self, query: CostQuery) -> CostSupport: + def supports(self, query: CostQuery) -> CostSupportVariant: try: records = self._selected_records(query) except InvalidCostEvidenceError as error: @@ -226,7 +223,7 @@ def estimate(self, query: CostQuery) -> CostEstimate: provenance = records[0].provenance selector = records[0].selector uncovered = tuple(sorted(set(query.match_context) - set(selector))) - assumptions = () + assumptions: tuple[str, ...] = () if uncovered: assumptions = ( "evidence matches an exact declared selector; dimensions not declared by the source are not " diff --git a/src/blueprinting/analysis/cost/explorer.py b/src/blueprinting/analysis/cost/explorer.py index 89a17bb..a35fab4 100644 --- a/src/blueprinting/analysis/cost/explorer.py +++ b/src/blueprinting/analysis/cost/explorer.py @@ -7,6 +7,7 @@ from blueprinting.schema.frozen import FrozenDict from blueprinting.system import SystemProfile +from blueprinting.workload import require_transformer_data_type, transformer_element_bytes from ..cost_model import CalibrationMode from .database import PerformanceDatabase, PerformanceDatabaseProvider, PerformanceRecord @@ -268,10 +269,9 @@ def _gemm_shape(selector: FrozenDict, semantic_operation: str) -> tuple[int, int def _datatype_bytes(datatype: str) -> int: - sizes = {"float8": 1, "float16": 2, "bfloat16": 2, "float32": 4} try: - return sizes[datatype] - except KeyError as error: + return transformer_element_bytes(require_transformer_data_type(datatype)) + except ValueError as error: raise ValueError(f"unsupported datatype size: {datatype!r}") from error diff --git a/src/blueprinting/analysis/cost/importers.py b/src/blueprinting/analysis/cost/importers.py index fea1f7d..b4e2540 100644 --- a/src/blueprinting/analysis/cost/importers.py +++ b/src/blueprinting/analysis/cost/importers.py @@ -14,7 +14,12 @@ from blueprinting.schema.codec import content_digest from blueprinting.schema.frozen import FrozenDict -from .database import EvidenceProvenance, PerformanceDatabase, PerformanceRecord +from .database import ( + EvidenceProvenance, + PerformanceDatabase, + PerformanceRecord, + performance_record_identity_collisions, +) from .protocol import CostSubject, EstimateMethod @@ -110,7 +115,7 @@ def __post_init__(self) -> None: collisions = set(self.selector_columns).intersection(self.constant_selectors) if collisions: raise ValueError(f"selector columns collide with constants: {', '.join(sorted(collisions))}") - duplicate_identity = {"subject", "operation", "hardware", "datatype"}.intersection( + duplicate_identity = performance_record_identity_collisions( set(self.selector_columns).union(self.constant_selectors) ) if duplicate_identity: @@ -177,14 +182,13 @@ def _required(row: dict[str, Any], column: str, row_number: int) -> Any: def _parse_scalar(value: Any, type_name: str, *, column: str, row_number: int) -> str | int | float | bool: value = _python_scalar(value) if type_name == "string": - if not isinstance(value, str): - value = str(value) - if not value: + text = str(value) + if not text: raise ValueError(f"row {row_number} has an empty string in {column!r}") - return value + return text if type_name == "bool": if isinstance(value, bool): - return value + return bool(value) normalized = str(value).strip().lower() if normalized in {"true", "1"}: return True @@ -224,7 +228,7 @@ def _identity( class TabularPerformanceImporter: """Import simulator/profiler tables using an explicit, revisioned schema.""" - IMPORTER_REVISION = "blueprinting-tabular-performance-v1" + IMPORTER_REVISION = "blueprinting-tabular-performance-v0" @classmethod def from_file(cls, path: str | Path, spec: TabularImportSpec) -> PerformanceDatabase: @@ -244,11 +248,13 @@ def from_file(cls, path: str | Path, spec: TabularImportSpec) -> PerformanceData ) records = [] for row_number, row in enumerate(rows, start=1): - latency = _parse_scalar( - _required(row, spec.latency_column, row_number), - "float", - column=spec.latency_column, - row_number=row_number, + latency = float( + _parse_scalar( + _required(row, spec.latency_column, row_number), + "float", + column=spec.latency_column, + row_number=row_number, + ) ) if latency < 0: raise ValueError(f"row {row_number} has negative latency") diff --git a/src/blueprinting/analysis/cost/protocol.py b/src/blueprinting/analysis/cost/protocol.py index 2ee5ff6..7e9f0f4 100644 --- a/src/blueprinting/analysis/cost/protocol.py +++ b/src/blueprinting/analysis/cost/protocol.py @@ -7,14 +7,30 @@ from __future__ import annotations -import math from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum -from typing import Protocol, runtime_checkable - -from blueprinting.schema.codec import content_digest, enum_type, record_type +from typing import Any, Protocol, runtime_checkable + +from blueprinting.schema.authoring import ( + CanonicalLowerText, + NonBlankText, + NonEmptyText, + NonNegativeFiniteNumber, + NonNegativeInt, + PositiveInt, + UnitIntervalNumber, + adt, + enum, + is_adt_variant, + record, + seal_adt, + variant, +) +from blueprinting.schema.codec import content_digest +from blueprinting.schema.diagnostics import Diagnostic, DiagnosticSet from blueprinting.schema.frozen import FrozenDict +from blueprinting.schema.result import Checked, Err, Ok # Keep the legacy codec namespace as a stable serialized identity. @@ -31,13 +47,13 @@ class InvalidCostEvidenceError(CostModelError): """Raised when available evidence is ambiguous or internally invalid.""" -@enum_type("compiler.analysis.cost.subject.v1") +@enum("blueprinting.analysis.cost.subject") class CostSubject(Enum): OPERATOR = "operator" COMMUNICATION = "communication" -@enum_type("compiler.analysis.cost.method.v1") +@enum("blueprinting.analysis.cost.method") class EstimateMethod(Enum): MEASURED = "measured" SIMULATED = "simulated" @@ -46,7 +62,7 @@ class EstimateMethod(Enum): VENDOR_MODEL = "vendor_model" -@enum_type("compiler.analysis.cost.match.v1") +@enum("blueprinting.analysis.cost.match") class EstimateMatch(Enum): EXACT_SELECTOR = "exact_selector" INTERPOLATED = "interpolated" @@ -55,13 +71,6 @@ class EstimateMatch(Enum): FALLBACK = "fallback" -@enum_type("compiler.analysis.cost.support_status.v1") -class SupportStatus(Enum): - AVAILABLE = "available" - UNAVAILABLE = "unavailable" - INVALID = "invalid" - - _RESERVED_DIMENSIONS = frozenset( { "subject", @@ -86,23 +95,7 @@ class SupportStatus(Enum): ) -def _required_text(value: str, name: str) -> None: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{name} must be a non-empty string") - - -def _optional_text(value: str, name: str) -> None: - if not isinstance(value, str): - raise TypeError(f"{name} must be a string") - - -def _non_negative_integer(value: int, name: str) -> None: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError(f"{name} must be a non-negative integer") - - -@record_type("compiler.analysis.cost.query.v1") -@dataclass(frozen=True) +@record("blueprinting.analysis.cost.query") class CostQuery: """One fully identified task-cost question. @@ -113,15 +106,15 @@ class CostQuery: """ subject: CostSubject - operation: str - hardware: str - datatype: str - operations: int = 0 - read_bytes: int = 0 - write_bytes: int = 0 - message_bytes: int = 0 - participants: int = 1 - network_tier: int = 0 + operation: NonBlankText + hardware: NonBlankText + datatype: NonBlankText + operations: NonNegativeInt = 0 + read_bytes: NonNegativeInt = 0 + write_bytes: NonNegativeInt = 0 + message_bytes: NonNegativeInt = 0 + participants: PositiveInt = 1 + network_tier: NonNegativeInt = 0 engine: str = "" hardware_revision: str = "" implementation: str = "" @@ -133,26 +126,6 @@ class CostQuery: dimensions: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - if not isinstance(self.subject, CostSubject): - raise TypeError("subject must be CostSubject") - for name in ("operation", "hardware", "datatype"): - _required_text(getattr(self, name), name) - for name in ( - "engine", - "hardware_revision", - "implementation", - "implementation_revision", - "runtime", - "runtime_revision", - "topology", - "power_mode", - ): - _optional_text(getattr(self, name), name) - for name in ("operations", "read_bytes", "write_bytes", "message_bytes", "network_tier"): - _non_negative_integer(getattr(self, name), name) - if isinstance(self.participants, bool) or not isinstance(self.participants, int) or self.participants <= 0: - raise ValueError("participants must be a positive integer") - object.__setattr__(self, "dimensions", FrozenDict(self.dimensions)) collisions = _RESERVED_DIMENSIONS.intersection(self.dimensions) if collisions: raise ValueError(f"dimensions use reserved names: {', '.join(sorted(collisions))}") @@ -192,8 +165,7 @@ def match_context(self) -> FrozenDict: return FrozenDict(context) -@record_type("compiler.analysis.cost.query_context.v1") -@dataclass(frozen=True) +@record("blueprinting.analysis.cost.query-context") class CostQueryContext: """Deployment/implementation facts supplied after portable planning. @@ -201,40 +173,29 @@ class CostQueryContext: example ``gemm``) is used as a fallback when no semantic key is present. """ - runtime: str = "" + runtime: CanonicalLowerText = "" runtime_revision: str = "" topology: str = "" power_mode: str = "" - implementations: FrozenDict = field(default_factory=FrozenDict) - implementation_revisions: FrozenDict = field(default_factory=FrozenDict) + implementations: FrozenDict[NonEmptyText] = field(default_factory=FrozenDict) + implementation_revisions: FrozenDict[NonEmptyText] = field(default_factory=FrozenDict) dimensions: FrozenDict = field(default_factory=FrozenDict) - operation_dimensions: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - for name in ("runtime", "runtime_revision", "topology", "power_mode"): - _optional_text(getattr(self, name), name) - if self.runtime: - object.__setattr__(self, "runtime", self.runtime.strip().lower()) - for name in ("implementations", "implementation_revisions", "dimensions", "operation_dimensions"): - object.__setattr__(self, name, FrozenDict(getattr(self, name))) - for name, values in ( - ("implementations", self.implementations), - ("implementation_revisions", self.implementation_revisions), - ): - if any(not isinstance(value, str) or not value for value in values.values()): - raise ValueError(f"{name} values must be non-empty strings") - for operation, dimensions in self.operation_dimensions.items(): - if not isinstance(dimensions, Mapping): - raise TypeError(f"operation dimensions for {operation!r} must be a mapping") + operation_dimensions: FrozenDict[Mapping[str, Any]] = field(default_factory=FrozenDict) def implementation_for(self, semantic_operation: str, operation: str) -> str: - return self.implementations.get(semantic_operation, self.implementations.get(operation, "")) + value = self.implementations.get(semantic_operation, self.implementations.get(operation, "")) + if not isinstance(value, str): + raise TypeError("implementation identity must be a string") + return value def implementation_revision_for(self, semantic_operation: str, operation: str) -> str: - return self.implementation_revisions.get( + value = self.implementation_revisions.get( semantic_operation, self.implementation_revisions.get(operation, ""), ) + if not isinstance(value, str): + raise TypeError("implementation revision must be a string") + return value def dimensions_for(self, semantic_operation: str, operation: str) -> FrozenDict: result = self.dimensions.to_dict() @@ -247,30 +208,15 @@ def dimensions_for(self, semantic_operation: str, operation: str) -> FrozenDict: return FrozenDict(result) -@record_type("compiler.analysis.cost.uncertainty.v1") -@dataclass(frozen=True) +@record("blueprinting.analysis.cost.uncertainty") class EstimateUncertainty: - sample_count: int = 0 - standard_deviation_seconds: float | None = None - lower_bound_seconds: float | None = None - upper_bound_seconds: float | None = None - confidence: float | None = None + sample_count: NonNegativeInt = 0 + standard_deviation_seconds: NonNegativeFiniteNumber | None = None + lower_bound_seconds: NonNegativeFiniteNumber | None = None + upper_bound_seconds: NonNegativeFiniteNumber | None = None + confidence: UnitIntervalNumber | None = None def __post_init__(self) -> None: - _non_negative_integer(self.sample_count, "sample_count") - for name in ("standard_deviation_seconds", "lower_bound_seconds", "upper_bound_seconds"): - value = getattr(self, name) - if value is not None and ( - isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0 - ): - raise ValueError(f"{name} must be finite and non-negative when present") - if self.confidence is not None and ( - isinstance(self.confidence, bool) - or not isinstance(self.confidence, (int, float)) - or not math.isfinite(self.confidence) - or not 0 <= self.confidence <= 1 - ): - raise ValueError("confidence must be in [0, 1] when present") if ( self.lower_bound_seconds is not None and self.upper_bound_seconds is not None @@ -279,79 +225,63 @@ def __post_init__(self) -> None: raise ValueError("uncertainty lower bound cannot exceed upper bound") -@record_type("compiler.analysis.cost.estimate.v1") -@dataclass(frozen=True) +@record("blueprinting.analysis.cost.estimate") class CostEstimate: - seconds: float - provider: str - provider_revision: str - source_revision: str + seconds: NonNegativeFiniteNumber + provider: NonBlankText + provider_revision: NonBlankText + source_revision: NonBlankText method: EstimateMethod match: EstimateMatch uncertainty: EstimateUncertainty = field(default_factory=EstimateUncertainty) - raw_record_ids: tuple[str, ...] = () + raw_record_ids: tuple[NonEmptyText, ...] = () validity_domain: FrozenDict = field(default_factory=FrozenDict) components: FrozenDict = field(default_factory=FrozenDict) - assumptions: tuple[str, ...] = () - - def __post_init__(self) -> None: - if ( - isinstance(self.seconds, bool) - or not isinstance(self.seconds, (int, float)) - or not math.isfinite(self.seconds) - or self.seconds < 0 - ): - raise ValueError("estimate seconds must be finite and non-negative") - for name in ("provider", "provider_revision", "source_revision"): - _required_text(getattr(self, name), name) - if not isinstance(self.method, EstimateMethod): - raise TypeError("method must be EstimateMethod") - if not isinstance(self.match, EstimateMatch): - raise TypeError("match must be EstimateMatch") - if not isinstance(self.uncertainty, EstimateUncertainty): - raise TypeError("uncertainty must be EstimateUncertainty") - object.__setattr__(self, "raw_record_ids", tuple(self.raw_record_ids)) - object.__setattr__(self, "validity_domain", FrozenDict(self.validity_domain)) - object.__setattr__(self, "components", FrozenDict(self.components)) - object.__setattr__(self, "assumptions", tuple(self.assumptions)) - if any(not isinstance(item, str) or not item for item in self.raw_record_ids): - raise ValueError("raw record IDs must be non-empty strings") - if any(not isinstance(item, str) or not item for item in self.assumptions): - raise ValueError("assumptions must be non-empty strings") + assumptions: tuple[NonEmptyText, ...] = () -@dataclass(frozen=True) +@adt(wire="blueprinting.analysis.cost.support") class CostSupport: - status: SupportStatus - reason: str - missing_fields: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.status, SupportStatus): - raise TypeError("status must be SupportStatus") - _required_text(self.reason, "support reason") - object.__setattr__(self, "missing_fields", tuple(self.missing_fields)) - if any(not isinstance(item, str) or not item for item in self.missing_fields): - raise ValueError("missing fields must be non-empty strings") + """Closed provider coverage result with no status/payload mismatch.""" @classmethod - def available(cls, reason: str = "query is covered") -> CostSupport: - return cls(SupportStatus.AVAILABLE, reason) + def available(cls, reason: str = "query is covered") -> CostSupportVariant: + return CostAvailable(reason) @classmethod - def unavailable(cls, reason: str, *missing_fields: str) -> CostSupport: - return cls(SupportStatus.UNAVAILABLE, reason, tuple(missing_fields)) + def unavailable(cls, reason: str, *missing_fields: str) -> CostSupportVariant: + return CostUnavailable(reason, tuple(missing_fields)) @classmethod - def invalid(cls, reason: str) -> CostSupport: - return cls(SupportStatus.INVALID, reason) + def invalid(cls, reason: str) -> CostSupportVariant: + return InvalidCostSupport(reason) + + +@variant("available") +class CostAvailable(CostSupport): + reason: NonBlankText + + +@variant("unavailable") +class CostUnavailable(CostSupport): + reason: NonBlankText + missing_fields: tuple[NonEmptyText, ...] = () + + +@variant("invalid") +class InvalidCostSupport(CostSupport): + reason: NonBlankText + + +CostSupportVariant = CostAvailable | CostUnavailable | InvalidCostSupport +seal_adt(CostSupport, CostSupportVariant) @dataclass(frozen=True) class ProviderAttempt: provider: str revision: str - support: CostSupport + support: CostSupportVariant @dataclass(frozen=True) @@ -370,7 +300,7 @@ def name(self) -> str: ... @property def revision(self) -> str: ... - def supports(self, query: CostQuery) -> CostSupport: ... + def supports(self, query: CostQuery) -> CostSupportVariant: ... def estimate(self, query: CostQuery) -> CostEstimate: ... @@ -378,9 +308,10 @@ def estimate(self, query: CostQuery) -> CostEstimate: ... class CostResolver: """Ordered, deterministic provider selection without anonymous blending.""" - def __init__(self, providers: tuple[CostProvider, ...], *, policy_name: str = "ordered-first-supported-v1") -> None: + def __init__(self, providers: tuple[CostProvider, ...], *, policy_name: str = "ordered-first-supported-v0") -> None: self._providers = tuple(providers) - _required_text(policy_name, "policy_name") + if not isinstance(policy_name, str) or not policy_name.strip(): + raise ValueError("policy_name must be a non-empty string") identities = tuple((provider.name, provider.revision) for provider in self._providers) if len(set(identities)) != len(identities): raise ValueError("resolver providers must have unique name/revision identities") @@ -398,32 +329,78 @@ def providers(self) -> tuple[CostProvider, ...]: def revision(self) -> str: return self._revision - def try_resolve(self, query: CostQuery) -> CostResolution | None: + def resolve(self, query: CostQuery) -> Checked[CostResolution]: + """Resolve expected availability and evidence failures as diagnostics.""" + if not isinstance(query, CostQuery): raise TypeError("query must be CostQuery") attempts: list[ProviderAttempt] = [] for provider in self._providers: support = provider.supports(query) - if not isinstance(support, CostSupport): + if not is_adt_variant(support, CostSupport): raise TypeError(f"provider {provider.name!r} returned an invalid support result") attempts.append(ProviderAttempt(provider.name, provider.revision, support)) - if support.status is SupportStatus.INVALID: - raise InvalidCostEvidenceError( - f"provider {provider.name!r} found invalid evidence for {query.digest}: {support.reason}" + match support: + case InvalidCostSupport(reason): + return Err( + DiagnosticSet.of( + Diagnostic( + "cost.invalid_evidence", + f"provider {provider.name!r} found invalid evidence: {reason}", + ("provider", provider.name), + ) + ) + ) + case CostUnavailable(): + continue + case CostAvailable(): + pass + try: + estimate = provider.estimate(query) + except InvalidCostEvidenceError as error: + return Err( + DiagnosticSet.of( + Diagnostic( + "cost.invalid_evidence", + f"provider {provider.name!r} rejected its selected evidence: {error}", + ("provider", provider.name), + ) + ) ) - if support.status is SupportStatus.UNAVAILABLE: - continue - estimate = provider.estimate(query) if not isinstance(estimate, CostEstimate): raise TypeError(f"provider {provider.name!r} returned an invalid estimate") if estimate.provider != provider.name or estimate.provider_revision != provider.revision: - raise InvalidCostEvidenceError(f"provider {provider.name!r} returned inconsistent provenance identity") - return CostResolution(query.digest, estimate, tuple(attempts), self.revision) - return None - - def resolve(self, query: CostQuery) -> CostResolution: - resolution = self.try_resolve(query) - if resolution is None: - attempted = ", ".join(provider.name for provider in self._providers) or "none" - raise CostNotAvailableError(f"no cost provider covers query {query.digest}; attempted: {attempted}") - return resolution + return Err( + DiagnosticSet.of( + Diagnostic( + "cost.inconsistent_provenance", + f"provider {provider.name!r} returned inconsistent provenance identity", + ("provider", provider.name), + ) + ) + ) + return Ok(CostResolution(query.digest, estimate, tuple(attempts), self.revision)) + attempted = ", ".join(provider.name for provider in self._providers) or "none" + return Err( + DiagnosticSet.of( + Diagnostic( + "cost.unavailable", + f"no cost provider covers query {query.digest}; attempted: {attempted}", + ("query", query.digest), + ) + ) + ) + + def require(self, query: CostQuery) -> CostResolution: + """Explicit exception adapter for application boundaries.""" + + def exception(diagnostics: DiagnosticSet) -> CostModelError: + rendered = "; ".join(item.render() for item in diagnostics.errors) + if any( + item.code.startswith("cost.invalid") or item.code == "cost.inconsistent_provenance" + for item in diagnostics.errors + ): + return InvalidCostEvidenceError(rendered) + return CostNotAvailableError(rendered) + + return self.resolve(query).or_raise(exception) diff --git a/src/blueprinting/analysis/cost/roofline.py b/src/blueprinting/analysis/cost/roofline.py index 3643473..c5d7657 100644 --- a/src/blueprinting/analysis/cost/roofline.py +++ b/src/blueprinting/analysis/cost/roofline.py @@ -8,16 +8,19 @@ from ...system import SystemProfile from ..cost_model import CalibrationMode from .protocol import ( + CostAvailable, CostEstimate, CostProvider, CostQuery, CostSubject, CostSupport, + CostSupportVariant, + CostUnavailable, EstimateMatch, EstimateMethod, EstimateUncertainty, InvalidCostEvidenceError, - SupportStatus, + InvalidCostSupport, ) @@ -51,7 +54,7 @@ def __init__( self._revision = content_digest( FrozenDict( { - "provider": "blueprinting-roofline-v1", + "provider": "blueprinting-roofline-v0", "hardware_revision": hardware.evidence_revision, "calibration_mode": mode.value, "processing_mode": processing_mode, @@ -72,7 +75,7 @@ def revision(self) -> str: def hardware(self) -> SystemProfile: return self._hardware - def supports(self, query: CostQuery) -> CostSupport: + def supports(self, query: CostQuery) -> CostSupportVariant: if query.hardware != self._hardware.name: return CostSupport.unavailable("query targets a different system profile") if query.datatype != self._hardware.datatype: @@ -94,8 +97,11 @@ def supports(self, query: CostQuery) -> CostSupport: def estimate(self, query: CostQuery) -> CostEstimate: support = self.supports(query) - if support.status is not SupportStatus.AVAILABLE: - raise InvalidCostEvidenceError(f"roofline provider cannot estimate query: {support.reason}") + match support: + case CostAvailable(): + pass + case CostUnavailable(reason) | InvalidCostSupport(reason): + raise InvalidCostEvidenceError(f"roofline provider cannot estimate query: {reason}") compute_seconds = 0.0 memory_seconds = 0.0 diff --git a/src/blueprinting/analysis/cost_model.py b/src/blueprinting/analysis/cost_model.py index 184f317..0d3b88c 100644 --- a/src/blueprinting/analysis/cost_model.py +++ b/src/blueprinting/analysis/cost_model.py @@ -24,24 +24,27 @@ TensorParallelCommunication, TransformerTrainingMappingSpec, ) -from blueprinting.schema.codec import enum_type +from blueprinting.schema.authoring import enum from blueprinting.synthesizer.dialects.transformer import ( BlockMemoryFacts, EngineKind, PhaseWork, PrimitiveInvocation, TrainingPhase, + TransformerTrainingPlanSemantic, + TransformerTrainingPlanTaskSemantic, ) from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec -from ..synthesizer.ir import CollectiveKind, PlanTask, PortablePlanIR +from ..synthesizer.stages.distributed.ir import CollectiveKind +from ..synthesizer.stages.portable_plan.ir import PlanTask, PortablePlanIR, require_concrete_quantity from ..system import SystemProfile # Codec tags are stable wire identities; the legacy namespace survives the # Python package move so existing snapshots and performance evidence still load. -@enum_type("compiler.analysis.calibration_mode") +@enum("blueprinting.analysis.cost.calibration-mode") class CalibrationMode(Enum): PEAK_ONLY = "peak_only" SYSTEM_EVIDENCE = "system_evidence" @@ -155,40 +158,22 @@ def _task_estimate( def _invocation_from_plan_task(task: PlanTask) -> PrimitiveInvocation: """Reconstruct a cost view from canonical portable workload facts.""" - attributes = task.workload.attributes - try: - phase = TrainingPhase(attributes["phase"]) - engine = EngineKind(attributes["engine"]) - name = attributes["name"] - primitive = attributes["primitive"] - source_layer = attributes["source_layer"] - except (KeyError, ValueError) as error: - raise ValueError(f"portable training task {task.id} has invalid semantic workload metadata") from error - for field_name, value in (("name", name), ("primitive", primitive), ("source_layer", source_layer)): - if not isinstance(value, str) or not value: - raise ValueError(f"portable training task {task.id} has invalid {field_name}") - collective_value = attributes.get("collective", "") - collective = None - if engine is EngineKind.COLLECTIVE: - try: - collective = CollectiveKind(collective_value) - except ValueError as error: - raise ValueError(f"portable training task {task.id} has invalid collective metadata") from error - elif collective_value != "": - raise ValueError(f"local portable training task {task.id} carries collective metadata") + semantic = task.semantic + if not isinstance(semantic, TransformerTrainingPlanTaskSemantic): + raise ValueError(f"portable training task {task.id} is missing typed Transformer semantics") return PrimitiveInvocation( - name=name, - source_layer=source_layer, - primitive=primitive, - phase=phase, - engine=engine, + name=semantic.name, + source_layer=semantic.source_layer, + primitive=semantic.primitive, + phase=semantic.phase, + engine=semantic.engine, work=PhaseWork( - operations=task.workload.operations, - read_bytes=task.workload.read_bytes, - write_bytes=task.workload.write_bytes, - message_bytes=task.workload.message_bytes, + operations=require_concrete_quantity(task.workload.operations, f"task {task.id} operations"), + read_bytes=require_concrete_quantity(task.workload.read_bytes, f"task {task.id} read_bytes"), + write_bytes=require_concrete_quantity(task.workload.write_bytes, f"task {task.id} write_bytes"), + message_bytes=require_concrete_quantity(task.workload.message_bytes, f"task {task.id} message_bytes"), ), - collective=collective, + collective=semantic.collective, ) @@ -199,9 +184,10 @@ def estimate_block( *, network_binding: NetworkTierBinding, ) -> BlockEstimate: - mapping = plan.attributes.get("mapping_spec") - if not isinstance(mapping, TransformerTrainingMappingSpec): + semantic = plan.semantic + if not isinstance(semantic, TransformerTrainingPlanSemantic): raise TypeError("portable plan is missing TransformerTrainingMappingSpec") + mapping = semantic.mapping if not isinstance(network_binding, NetworkTierBinding): raise TypeError("network_binding must be NetworkTierBinding") tasks = [] @@ -292,18 +278,13 @@ def estimate_iteration( ) -> IterationEstimate: """Apply an explicit 1F1B/interleaved schedule to a derived block plan.""" - model = plan.attributes.get("model_spec") - workload = plan.attributes.get("workload_spec") - mapping = plan.attributes.get("mapping_spec") - block_memory = plan.attributes.get("block_memory") - if not isinstance(model, TransformerModelSpec): - raise TypeError("portable plan is missing TransformerModelSpec") - if not isinstance(workload, TransformerTrainingWorkloadSpec): - raise TypeError("portable plan is missing TransformerTrainingWorkloadSpec") - if not isinstance(mapping, TransformerTrainingMappingSpec): - raise TypeError("portable plan is missing TransformerTrainingMappingSpec") - if not isinstance(block_memory, BlockMemoryFacts): - raise TypeError("portable plan is missing BlockMemoryFacts") + semantic = plan.semantic + if not isinstance(semantic, TransformerTrainingPlanSemantic): + raise TypeError("portable plan is missing typed Transformer training semantics") + model = semantic.model + workload = semantic.workload + mapping = semantic.mapping + block_memory = semantic.block_memory if hardware.datatype != workload.datatype: raise ValueError("system profile datatype does not match workload datatype") if not isinstance(network_binding, NetworkTierBinding): diff --git a/src/blueprinting/analysis/inference_cost.py b/src/blueprinting/analysis/inference_cost.py index 64d2340..f9eb072 100644 --- a/src/blueprinting/analysis/inference_cost.py +++ b/src/blueprinting/analysis/inference_cost.py @@ -6,15 +6,27 @@ from blueprinting.mapping import NetworkTierBinding, TransformerInferenceMappingSpec from blueprinting.schema.frozen import FrozenDict -from blueprinting.synthesizer.dialects.transformer import EngineKind, InferenceInvocation, PhaseWork -from blueprinting.workload import TransformerModelSpec +from blueprinting.synthesizer.dialects.transformer import ( + EngineKind, + InferenceInvocation, + PhaseWork, + TransformerBufferSemantic, + TransformerInferencePlanSemantic, + TransformerInferencePlanTaskSemantic, +) +from blueprinting.workload import TransformerDataType, TransformerModelSpec from ..synthesizer.bindings import InferencePhase -from ..synthesizer.ir import CollectiveKind, PlanBuffer, PlanTask, PortablePlanIR +from ..synthesizer.stages.portable_plan.ir import ( + PlanBuffer, + PlanTask, + PortablePlanIR, + require_concrete_quantity, +) from ..system import SystemProfile from .cost import CostQuery, CostQueryContext, CostResolver, CostSubject, EstimateUncertainty from .cost_model import CalibrationMode -from .inference_evidence import InferenceCostProvider, InferenceEvidenceQuery +from .inference_evidence import InferenceEvidenceQuery, inference_cost_operation @dataclass(frozen=True) @@ -68,7 +80,7 @@ def inference_evidence_query_for( *, hardware: SystemProfile, mapping: TransformerInferenceMappingSpec, - datatype: str, + datatype: TransformerDataType, model: TransformerModelSpec, batch_size: int, query_tokens: int, @@ -94,16 +106,6 @@ def inference_evidence_query_for( ) -_GEMM_PRIMITIVES = frozenset( - { - "attention_pre_projection", - "attention_post_projection", - "mlp_up_projection", - "mlp_down_projection", - } -) - - def _merge_dimensions(base: dict[str, object], extra: FrozenDict) -> FrozenDict: overlap = set(base).intersection(extra) conflicts = tuple(key for key in overlap if base[key] != extra[key]) @@ -119,7 +121,7 @@ def cost_query_for_inference_task( hardware: SystemProfile, mapping: TransformerInferenceMappingSpec, network_binding: NetworkTierBinding, - datatype: str, + datatype: TransformerDataType, model: TransformerModelSpec, batch_size: int, query_tokens: int, @@ -132,7 +134,7 @@ def cost_query_for_inference_task( raise TypeError("context must be CostQueryContext") invocation = _invocation_from_plan_task(task) primitive = invocation.primitive - operation = "gemm" if primitive in _GEMM_PRIMITIVES else primitive + operation = inference_cost_operation(primitive) subject = CostSubject.COMMUNICATION if invocation.engine is EngineKind.COLLECTIVE else CostSubject.OPERATOR tensor_parallel = mapping.tensor_parallel dimensions: dict[str, object] = { @@ -158,7 +160,7 @@ def cost_query_for_inference_task( "window_size": 0, "kv_cache_datatype": datatype, } - if primitive in _GEMM_PRIMITIVES: + if operation == "gemm": tokens = batch_size * query_tokens local_hidden = model.hidden_size // tensor_parallel local_feedforward = model.feedforward_size // tensor_parallel @@ -172,16 +174,16 @@ def cost_query_for_inference_task( else: n, k = model.hidden_size, local_feedforward dimensions.update({"m": m, "n": n, "k": k}) - dimensions = _merge_dimensions(dimensions, context.dimensions_for(primitive, operation)).to_dict() + query_dimensions = _merge_dimensions(dimensions, context.dimensions_for(primitive, operation)) return CostQuery( subject=subject, operation=operation, hardware=hardware.name, datatype=datatype, - operations=task.workload.operations, - read_bytes=task.workload.read_bytes, - write_bytes=task.workload.write_bytes, - message_bytes=task.workload.message_bytes, + operations=require_concrete_quantity(task.workload.operations, f"task {task.id} operations"), + read_bytes=require_concrete_quantity(task.workload.read_bytes, f"task {task.id} read_bytes"), + write_bytes=require_concrete_quantity(task.workload.write_bytes, f"task {task.id} write_bytes"), + message_bytes=require_concrete_quantity(task.workload.message_bytes, f"task {task.id} message_bytes"), participants=tensor_parallel if subject is CostSubject.COMMUNICATION else 1, network_tier=network_binding.tensor_parallel, engine=invocation.engine.value, @@ -192,7 +194,7 @@ def cost_query_for_inference_task( runtime_revision=context.runtime_revision, topology=context.topology, power_mode=context.power_mode, - dimensions=FrozenDict(dimensions), + dimensions=query_dimensions, ) @@ -202,13 +204,12 @@ def _task_estimate( hardware: SystemProfile, mapping: TransformerInferenceMappingSpec, network_binding: NetworkTierBinding, - datatype: str, + datatype: TransformerDataType, model: TransformerModelSpec, batch_size: int, query_tokens: int, context_tokens: int, mode: CalibrationMode, - cost_provider: InferenceCostProvider | None, cost_resolver: CostResolver | None, cost_context: CostQueryContext, ) -> InferenceTaskEstimate: @@ -251,7 +252,7 @@ def _task_estimate( assumptions: tuple[str, ...] = () total_seconds = analytical_seconds if cost_resolver is not None: - resolution = cost_resolver.resolve( + resolution = cost_resolver.require( cost_query_for_inference_task( task, model=model, @@ -275,26 +276,6 @@ def _task_estimate( method = evidence.method.value uncertainty = evidence.uncertainty assumptions = evidence.assumptions - elif cost_provider is not None: - evidence = cost_provider.resolve( - inference_evidence_query_for( - invocation, - model=model, - mapping=mapping, - datatype=datatype, - hardware=hardware, - batch_size=batch_size, - query_tokens=query_tokens, - context_tokens=context_tokens, - ) - ) - if evidence is not None: - total_seconds = evidence.seconds - provider_name = evidence.provider - revision = evidence.revision - source_revision = evidence.revision - match = evidence.match - method = "external" return InferenceTaskEstimate( invocation=invocation, compute_seconds=compute_seconds, @@ -321,42 +302,23 @@ def _invocation_from_plan_task(task: PlanTask) -> InferenceInvocation: embedded in ``PortablePlanIR`` as a second source of workload truth. """ - attributes = task.workload.attributes - try: - phase = InferencePhase(attributes["phase"]) - engine = EngineKind(attributes["engine"]) - name = attributes["name"] - primitive = attributes["primitive"] - source_layer = attributes["source_layer"] - except (KeyError, ValueError) as error: - raise ValueError(f"portable inference task {task.id} has invalid semantic workload metadata") from error - for field_name, value in (("name", name), ("primitive", primitive), ("source_layer", source_layer)): - if not isinstance(value, str) or not value: - raise ValueError(f"portable inference task {task.id} has invalid {field_name}") - - collective_value = attributes.get("collective", "") - collective = None - if engine is EngineKind.COLLECTIVE: - try: - collective = CollectiveKind(collective_value) - except ValueError as error: - raise ValueError(f"portable inference task {task.id} has invalid collective metadata") from error - elif collective_value != "": - raise ValueError(f"local portable inference task {task.id} carries collective metadata") + semantic = task.semantic + if not isinstance(semantic, TransformerInferencePlanTaskSemantic): + raise ValueError(f"portable inference task {task.id} is missing typed Transformer semantics") return InferenceInvocation( - name=name, - source_layer=source_layer, - primitive=primitive, - phase=phase, - engine=engine, + name=semantic.name, + source_layer=semantic.source_layer, + primitive=semantic.primitive, + phase=semantic.phase, + engine=semantic.engine, work=PhaseWork( - operations=task.workload.operations, - read_bytes=task.workload.read_bytes, - write_bytes=task.workload.write_bytes, - message_bytes=task.workload.message_bytes, + operations=require_concrete_quantity(task.workload.operations, f"task {task.id} operations"), + read_bytes=require_concrete_quantity(task.workload.read_bytes, f"task {task.id} read_bytes"), + write_bytes=require_concrete_quantity(task.workload.write_bytes, f"task {task.id} write_bytes"), + message_bytes=require_concrete_quantity(task.workload.message_bytes, f"task {task.id} message_bytes"), ), - collective=collective, + collective=semantic.collective, ) @@ -368,7 +330,11 @@ def _concrete_buffer_size(buffer: PlanBuffer) -> int: def _semantic_buffer_size(plan: PortablePlanIR, semantic: str) -> int: - buffers = tuple(buffer for buffer in plan.buffers if buffer.attributes.get("semantic") == semantic) + buffers = tuple( + buffer + for buffer in plan.buffers + if isinstance(buffer.semantic, TransformerBufferSemantic) and buffer.semantic.role == semantic + ) if len(buffers) != 1: raise ValueError(f"portable inference plan must contain exactly one {semantic!r} buffer") return _concrete_buffer_size(buffers[0]) @@ -380,37 +346,27 @@ def estimate_inference_phase( mode: CalibrationMode = CalibrationMode.SYSTEM_EVIDENCE, *, network_binding: NetworkTierBinding, - cost_provider: InferenceCostProvider | None = None, cost_resolver: CostResolver | None = None, cost_context: CostQueryContext = CostQueryContext(), ) -> InferencePhaseEstimate: """Cost one prefill or decode phase point without queueing assumptions.""" - model = plan.attributes.get("model_spec") - mapping = plan.attributes.get("inference_mapping_spec") - phase = plan.attributes.get("inference_phase") - datatype = plan.attributes.get("datatype") - if not isinstance(model, TransformerModelSpec): - raise TypeError("portable inference plan is missing TransformerModelSpec") - if not isinstance(mapping, TransformerInferenceMappingSpec): - raise TypeError("portable inference plan is missing TransformerInferenceMappingSpec") - if not isinstance(phase, InferencePhase): - raise TypeError("portable inference plan is missing InferencePhase") - if not isinstance(datatype, str): - raise TypeError("portable inference plan is missing its datatype") + semantic = plan.semantic + if not isinstance(semantic, TransformerInferencePlanSemantic): + raise TypeError("portable inference plan is missing typed Transformer inference semantics") + model = semantic.model + mapping = semantic.mapping + phase = semantic.phase + datatype = semantic.datatype if hardware.datatype != datatype: raise ValueError("system profile datatype does not match inference workload datatype") if not isinstance(network_binding, NetworkTierBinding): raise TypeError("network_binding must be NetworkTierBinding") - if cost_provider is not None and cost_resolver is not None: - raise ValueError("cost_provider and cost_resolver are mutually exclusive") if not isinstance(cost_context, CostQueryContext): raise TypeError("cost_context must be CostQueryContext") - batch_size = plan.attributes.get("batch_size") - query_tokens = plan.attributes.get("query_tokens") - context_tokens = plan.attributes.get("context_tokens") - if any(not isinstance(value, int) for value in (batch_size, query_tokens, context_tokens)): - raise TypeError("portable inference plan has non-concrete workload facts") + batch_size = semantic.batch_size + query_tokens = semantic.query_tokens + context_tokens = semantic.context_tokens task_estimates = [] for task in plan.tasks: @@ -426,7 +382,6 @@ def estimate_inference_phase( query_tokens=query_tokens, context_tokens=context_tokens, mode=mode, - cost_provider=cost_provider, cost_resolver=cost_resolver, cost_context=cost_context, ) @@ -450,7 +405,7 @@ def estimate_inference_phase( apply_efficiency=mode is CalibrationMode.SYSTEM_EVIDENCE, ) else: - pipeline_dimensions = { + pipeline_dimensions: dict[str, object] = { "semantic_operation": "p2p", "phase": phase.value, "model_name": model.name, @@ -459,11 +414,11 @@ def estimate_inference_phase( "context_tokens": context_tokens, "pipeline_parallel": mapping.pipeline_parallel, } - pipeline_dimensions = _merge_dimensions( + resolved_pipeline_dimensions = _merge_dimensions( pipeline_dimensions, cost_context.dimensions_for("p2p", "p2p"), ) - pipeline_estimate = cost_resolver.resolve( + pipeline_estimate = cost_resolver.require( CostQuery( subject=CostSubject.COMMUNICATION, operation="p2p", @@ -480,7 +435,7 @@ def estimate_inference_phase( runtime_revision=cost_context.runtime_revision, topology=cost_context.topology, power_mode=cost_context.power_mode, - dimensions=pipeline_dimensions, + dimensions=resolved_pipeline_dimensions, ) ).estimate one_hop_seconds = pipeline_estimate.seconds diff --git a/src/blueprinting/analysis/inference_evidence.py b/src/blueprinting/analysis/inference_evidence.py index e32b902..5fb08ef 100644 --- a/src/blueprinting/analysis/inference_evidence.py +++ b/src/blueprinting/analysis/inference_evidence.py @@ -1,8 +1,8 @@ -"""Normalized latency evidence contracts with an explicit oracle boundary. +"""Normalized latency evidence contracts for read-only comparison oracles. -Cost providers are admissible inputs to Blueprinting's estimator. Baselines -are read-only comparison oracles and therefore expose a different method name; -they cannot be passed accidentally as cost providers. +Production costing uses the shared :class:`~blueprinting.analysis.cost.CostResolver` +protocol. Baselines expose only ``lookup`` and therefore cannot be passed to a +costing path accidentally. """ from __future__ import annotations @@ -11,8 +11,25 @@ from dataclasses import dataclass from typing import Protocol, runtime_checkable +from blueprinting.workload import TransformerDataType, require_transformer_data_type + from ..synthesizer.bindings import InferencePhase +_GEMM_PRIMITIVES = frozenset( + { + "attention_pre_projection", + "attention_post_projection", + "mlp_up_projection", + "mlp_down_projection", + } +) + + +def inference_cost_operation(primitive: str) -> str: + """Map one Transformer inference primitive to the shared evidence operation taxonomy.""" + + return "gemm" if primitive in _GEMM_PRIMITIVES else primitive + @dataclass(frozen=True) class InferenceEvidenceQuery: @@ -31,15 +48,16 @@ class InferenceEvidenceQuery: query_tokens: int context_tokens: int tensor_parallel: int - datatype: str + datatype: TransformerDataType def __post_init__(self) -> None: if not isinstance(self.phase, InferencePhase): raise TypeError("phase must be InferencePhase") - for field_name in ("primitive", "source_layer", "model_name", "hardware_name", "datatype"): + for field_name in ("primitive", "source_layer", "model_name", "hardware_name"): value = getattr(self, field_name) if not isinstance(value, str) or not value: raise ValueError(f"{field_name} must not be empty") + require_transformer_data_type(self.datatype) for field_name in ( "model_sequence_length", "hidden_size", @@ -84,16 +102,6 @@ def __post_init__(self) -> None: raise ValueError(f"{field_name} must not be empty") -@runtime_checkable -class InferenceCostProvider(Protocol): - """Admissible Blueprinting cost source, such as its performance database.""" - - @property - def revision(self) -> str: ... - - def resolve(self, query: InferenceEvidenceQuery) -> InferenceEvidenceResult | None: ... - - @runtime_checkable class InferenceBaseline(Protocol): """External comparison oracle that must never participate in lowering or costing.""" diff --git a/src/blueprinting/analysis/vidur.py b/src/blueprinting/analysis/vidur.py index bd5a0c4..3e87ff4 100644 --- a/src/blueprinting/analysis/vidur.py +++ b/src/blueprinting/analysis/vidur.py @@ -19,7 +19,7 @@ from ..synthesizer.bindings import InferencePhase from .cost.database import EvidenceProvenance, PerformanceDatabase, PerformanceRecord from .cost.protocol import CostSubject, EstimateMethod -from .inference_evidence import InferenceEvidenceQuery, InferenceEvidenceResult +from .inference_evidence import InferenceEvidenceQuery, InferenceEvidenceResult, inference_cost_operation _COMPUTE_COLUMNS = { "input_layernorm": "time_stats.input_layernorm.median", @@ -45,15 +45,6 @@ "residual_add": "mlp.residual", } -_GEMM_PRIMITIVES = frozenset( - { - "attention_pre_projection", - "attention_post_projection", - "mlp_up_projection", - "mlp_down_projection", - } -) - def _read_rows(path: Path, *, required: frozenset[str], timing_columns: frozenset[str]) -> tuple[dict[str, str], ...]: with path.open(newline="", encoding="utf-8") as stream: @@ -138,7 +129,7 @@ def __init__( self._revision = content_digest( FrozenDict( { - "adapter": "blueprinting-vidur-baseline-v1", + "adapter": "blueprinting-vidur-baseline-v0", "upstream_revision": source_revision, "data_digest": data_digest, "model_name": model_name, @@ -329,7 +320,7 @@ class VidurProfileImporter: admissible to a ``CostResolver``; baseline lookup remains post-hoc only. """ - IMPORTER_REVISION = "blueprinting-vidur-profile-v1" + IMPORTER_REVISION = "blueprinting-vidur-profile-v0" @classmethod def from_csv( @@ -553,7 +544,7 @@ def _compute_records( milliseconds = _milliseconds(row, metric) if milliseconds is None: continue - operation = "gemm" if primitive in _GEMM_PRIMITIVES else primitive + operation = inference_cost_operation(primitive) selector = { **shared_selector, "semantic_operation": primitive, diff --git a/src/blueprinting/application/__init__.py b/src/blueprinting/application/__init__.py index 15dba74..887bb8f 100644 --- a/src/blueprinting/application/__init__.py +++ b/src/blueprinting/application/__init__.py @@ -9,6 +9,29 @@ SweepReport, SweepRequest, ) +from .derivation import ( + CANONICAL_STAGE_ORDER, + CanonicalIRStage, + DerivationDebugBundleCodec, + DerivationStage, + DerivationTrace, + DerivationTransition, + DerivedOverlay, + EntityOverlay, + EntityRef, + IRBoundaryView, + IRGraphEdge, + IRGraphNode, + IRGraphView, + LineageRelation, + MappingSummary, + PassContractView, + PassRuleView, + TraceDiagnostic, + boundary_view, + build_derivation_trace, + graph_view, +) from .inference import ( DecodeStepReport, InferenceAnalysisDraft, @@ -24,6 +47,12 @@ "AnalysisOutcome", "AnalysisReport", "BlueprintingService", + "CANONICAL_STAGE_ORDER", + "CanonicalIRStage", + "DerivationDebugBundleCodec", + "DerivationStage", + "DerivationTrace", + "DerivationTransition", "DiagnosticLevel", "DecodeStepReport", "InferenceAnalysisDraft", @@ -31,8 +60,23 @@ "InferenceAnalysisReport", "InferenceAnalysisService", "IRStageReport", + "DerivedOverlay", + "EntityOverlay", + "EntityRef", + "IRBoundaryView", + "IRGraphEdge", + "IRGraphNode", + "IRGraphView", + "LineageRelation", + "MappingSummary", + "PassContractView", + "PassRuleView", "SweepCase", "SweepReport", "SweepRequest", "TaskReport", + "TraceDiagnostic", + "boundary_view", + "build_derivation_trace", + "graph_view", ] diff --git a/src/blueprinting/application/analysis.py b/src/blueprinting/application/analysis.py index ed0c657..5fe5555 100644 --- a/src/blueprinting/application/analysis.py +++ b/src/blueprinting/application/analysis.py @@ -19,25 +19,31 @@ from blueprinting.mapping import NetworkTierBinding, TransformerTrainingMappingSpec from blueprinting.schema.codec import content_digest from blueprinting.schema.frozen import FrozenDict, freeze, thaw +from blueprinting.synthesizer.dialects.transformer import TransformerTrainingPlanTaskSemantic from blueprinting.synthesizer.errors import ( IRVerificationError, PassExecutionError, SynthesisError, ) from blueprinting.synthesizer.frontend import build_transformer_model_ir, synthesis_session_for -from blueprinting.synthesizer.ir import PortablePlanIR -from blueprinting.synthesizer.lowering import DistributeTransformerTrainingPass, PlanTransformerTrainingPass from blueprinting.synthesizer.passes import AnalysisStore, PassManager, PassPipeline +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerTrainingPass +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR, require_concrete_quantity +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerTrainingPass from blueprinting.system import SystemProfile from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec +from .derivation import ( + DerivationTrace, + build_derivation_trace, + portable_cost_overlay, + with_overlays, +) from .reporting import AnalysisDiagnostic, DiagnosticLevel, IRStageReport, TaskReport, stage_report LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from blueprinting.analysis import InferenceCostProvider - from .inference import InferenceAnalysisDraft, InferenceAnalysisOutcome, InferenceAnalysisService @@ -107,13 +113,13 @@ def fingerprint(self) -> str: "seed": self.seed, } ), - "blueprinting-analysis-request-v1", + "blueprinting-analysis-request-v0", ) def normalized_execution(self) -> dict[str, Any]: """Make world size a derived fact instead of a second source of truth.""" - data = thaw(self.execution_data) + data = self.execution_data.to_dict() data.pop("num_procs", None) mapping = TransformerTrainingMappingSpec.from_mapping(data) data["num_procs"] = mapping.world_size @@ -163,6 +169,7 @@ class AnalysisReport: workload: FrozenDict evidence: FrozenDict configuration: FrozenDict + derivation_trace: DerivationTrace stages: tuple[IRStageReport, ...] tasks: tuple[TaskReport, ...] limitations: tuple[str, ...] @@ -219,7 +226,7 @@ def fingerprint(self) -> str: "data_parallel": self.data_parallel, } ), - "blueprinting-sweep-request-v1", + "blueprinting-sweep-request-v0", ) @@ -262,7 +269,6 @@ def __init__( self, analyses: AnalysisStore | None = None, *, - inference_cost_provider: InferenceCostProvider | None = None, inference_cost_resolver: CostResolver | None = None, inference_cost_context: CostQueryContext = CostQueryContext(), ) -> None: @@ -275,7 +281,6 @@ def __init__( ) self._inference: InferenceAnalysisService = InferenceAnalysisService( analyses, - cost_provider=inference_cost_provider, cost_resolver=inference_cost_resolver, cost_context=inference_cost_context, ) @@ -358,7 +363,7 @@ def _analyze(self, draft: AnalysisDraft) -> AnalysisOutcome: source = build_transformer_model_ir(model, datatype=workload_spec.datatype) frontend_duration = time.perf_counter_ns() - frontend_started session = replace(synthesis_session_for(model, workload_spec, mapping), seed=draft.seed) - pipeline = self._manager.run(self._pipeline, source, session=session) + pipeline = self._manager.require_run(self._pipeline, source, session=session) plan = pipeline.ir if not isinstance(plan, PortablePlanIR): raise TypeError(f"analysis pipeline returned {type(plan).__name__}, expected PortablePlanIR") @@ -415,15 +420,25 @@ def _analyze(self, draft: AnalysisDraft) -> AnalysisOutcome: task_id=str(task.id), operation=str(task.operation), kind=task.kind.value, - phase=str(task.workload.attributes.get("phase", "unknown")), - engine=str(task.workload.attributes.get("engine", "unknown")), - source_layer=str(task.workload.attributes.get("source_layer", "")), + phase=( + task.semantic.phase.value + if isinstance(task.semantic, TransformerTrainingPlanTaskSemantic) + else "unknown" + ), + engine=( + task.semantic.engine.value + if isinstance(task.semantic, TransformerTrainingPlanTaskSemantic) + else "unknown" + ), + source_layer=( + task.semantic.source_layer if isinstance(task.semantic, TransformerTrainingPlanTaskSemantic) else "" + ), dependencies=tuple(str(item) for item in task.dependencies), concurrency_group=task.concurrency_group or "", - operations=task.workload.operations, - read_bytes=task.workload.read_bytes, - write_bytes=task.workload.write_bytes, - message_bytes=task.workload.message_bytes, + operations=require_concrete_quantity(task.workload.operations, f"task {task.id} operations"), + read_bytes=require_concrete_quantity(task.workload.read_bytes, f"task {task.id} read_bytes"), + write_bytes=require_concrete_quantity(task.workload.write_bytes, f"task {task.id} write_bytes"), + message_bytes=require_concrete_quantity(task.workload.message_bytes, f"task {task.id} message_bytes"), compute_seconds=task_estimate.compute_seconds, memory_seconds=task_estimate.memory_seconds, network_seconds=task_estimate.network_seconds, @@ -431,6 +446,23 @@ def _analyze(self, draft: AnalysisDraft) -> AnalysisOutcome: ) for task, task_estimate in zip(plan.tasks, estimate.block.tasks) ) + derivation_trace = build_derivation_trace( + source, + self._pipeline, + pipeline, + request_digest=draft.fingerprint, + session_fingerprint=session.fingerprint, + source_duration_ns=frontend_duration, + ) + derivation_trace = with_overlays( + derivation_trace, + portable_cost_overlay( + plan.digest, + tasks, + provider=f"cost-model:{draft.calibration_mode.value}", + revision=hardware.evidence_revision, + ), + ) workload = FrozenDict( { "task_count": len(tasks), @@ -483,7 +515,7 @@ def _analyze(self, draft: AnalysisDraft) -> AnalysisOutcome: } ) report = AnalysisReport( - schema="blueprinting.analysis-report.v1", + schema="blueprinting.analysis-report.v0", request_digest=draft.fingerprint, session_fingerprint=session.fingerprint, plan_digest=plan.digest, @@ -503,6 +535,7 @@ def _analyze(self, draft: AnalysisDraft) -> AnalysisOutcome: workload=workload, evidence=evidence, configuration=configuration, + derivation_trace=derivation_trace, stages=tuple(stages), tasks=tasks, limitations=( @@ -560,20 +593,29 @@ def sweep( on_progress(index, len(candidates)) feasible = tuple(case for case in cases if case.status == "success" and case.feasible) - pareto_digests = { - case.request_digest - for case in feasible - if not any( - other.request_digest != case.request_digest - and other.total_seconds <= case.total_seconds + + def dominates(other: SweepCase, case: SweepCase) -> bool: + if ( + other.total_seconds is None + or other.memory_bytes is None + or case.total_seconds is None + or case.memory_bytes is None + ): + return False + return ( + other.total_seconds <= case.total_seconds and other.memory_bytes <= case.memory_bytes and (other.total_seconds < case.total_seconds or other.memory_bytes < case.memory_bytes) - for other in feasible ) + + pareto_digests = { + case.request_digest + for case in feasible + if not any(other.request_digest != case.request_digest and dominates(other, case) for other in feasible) } cases = [replace(case, pareto=case.request_digest in pareto_digests) for case in cases] return SweepReport( - schema="blueprinting.strategy-sweep.v1", + schema="blueprinting.strategy-sweep.v0", request_digest=request.fingerprint, cases=tuple(cases), ) diff --git a/src/blueprinting/application/derivation.py b/src/blueprinting/application/derivation.py new file mode 100644 index 0000000..b47928b --- /dev/null +++ b/src/blueprinting/application/derivation.py @@ -0,0 +1,1416 @@ +"""Presentation-neutral derivation traces and canonical IR graph projections. + +The objects in this module are derived, rebuildable views. They never become a +sixth canonical IR and never write presentation or estimate fields back into a +canonical snapshot. Cross-stage correspondence is read exclusively from typed +entity lineage. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, replace +from enum import Enum +from typing import Any, Protocol, cast + +from typing_extensions import assert_never + +from blueprinting.schema.codec import canonical_dumps +from blueprinting.schema.frozen import FrozenDict +from blueprinting.synthesizer.ids import Lineage, StableId +from blueprinting.synthesizer.passes import PassPipeline, PipelineResult, TransitionReport +from blueprinting.synthesizer.stages.common import CanonicalIRMixin +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.distributed.ir import ( + AllGather, + AllReduce, + AllToAll, + Broadcast, + Collective, + CollectiveKind, + CollectiveSpecVariant, + DistributedTaskIR, + ReduceScatter, + ReductionKind, + collective_kind, +) +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR + + +class CanonicalIRStage(Enum): + MODEL = "model" + DISTRIBUTED = "distributed" + PORTABLE = "portable" + CONCRETE = "concrete" + MACHINE = "machine" + + +CANONICAL_STAGE_ORDER = ( + CanonicalIRStage.MODEL, + CanonicalIRStage.DISTRIBUTED, + CanonicalIRStage.PORTABLE, + CanonicalIRStage.CONCRETE, + CanonicalIRStage.MACHINE, +) + +_STAGE_LABELS = { + CanonicalIRStage.MODEL: "模型语义", + CanonicalIRStage.DISTRIBUTED: "分布式任务", + CanonicalIRStage.PORTABLE: "可移植计划", + CanonicalIRStage.CONCRETE: "具体执行计划", + CanonicalIRStage.MACHINE: "目标机器程序", +} + + +def _collective_projection( + spec: CollectiveSpecVariant, +) -> tuple[CollectiveKind, ReductionKind | None, int | None]: + kind = collective_kind(spec) + match spec: + case AllReduce(reduction=reduction) | ReduceScatter(reduction=reduction): + return kind, reduction, None + case Broadcast(root=root): + return kind, None, root + case AllGather() | AllToAll(): + return kind, None, None + assert_never(spec) + + +@dataclass(frozen=True, order=True) +class EntityRef: + stage: CanonicalIRStage + snapshot_digest: str + kind: str + entity_id: str + + @property + def key(self) -> str: + return f"{self.snapshot_digest}:{self.kind}:{self.entity_id}" + + +@dataclass(frozen=True) +class IRGraphNode: + ref: EntityRef + label: str + group: str + properties: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True) +class IRGraphEdge: + source: EntityRef + target: EntityRef + kind: str + label: str = "" + count: int = 1 + + +@dataclass(frozen=True) +class IRGraphView: + stage: CanonicalIRStage + snapshot_digest: str + nodes: tuple[IRGraphNode, ...] + edges: tuple[IRGraphEdge, ...] + + def node(self, entity_id: str) -> IRGraphNode | None: + return next((item for item in self.nodes if item.ref.entity_id == entity_id), None) + + +@dataclass(frozen=True) +class TraceDiagnostic: + code: str + message: str + level: str = "warning" + entity_id: str = "" + + +@dataclass(frozen=True) +class LineageRelation: + target: EntityRef + sources: tuple[EntityRef, ...] + unresolved_sources: tuple[str, ...] + lineage_kind: str + transform: str + explicit_source_mismatch: bool = False + verified_claims: tuple[str, ...] = () + + +@dataclass(frozen=True) +class MappingSummary: + source_entities: int + mapped_source_entities: int + target_entities: int + mapped_target_entities: int + generated_targets: int + dangling_sources: int + one_to_one: int + one_to_many: int + many_to_one: int + many_to_many: int + + +@dataclass(frozen=True) +class IRBoundaryView: + source_stage: CanonicalIRStage + target_stage: CanonicalIRStage + source_digest: str + target_digest: str + pass_name: str + relations: tuple[LineageRelation, ...] + summary: MappingSummary + diagnostics: tuple[TraceDiagnostic, ...] = () + + +@dataclass(frozen=True) +class PassRuleView: + transform: str + source_entity: str + target_entity: str + rewrite: str + preserves: tuple[str, ...] + introduces: tuple[str, ...] + forbids: tuple[str, ...] + semantic_invariant: str | None = None + + +@dataclass(frozen=True) +class PassContractView: + name: str + input_schema: str + output_schema: str + required_bindings: tuple[str, ...] + required_analyses: tuple[str, ...] + produced_analyses: tuple[str, ...] + mutation_model: str + verification: str + deterministic: bool + revision: str = "1" + preserved_analyses: tuple[str, ...] = () + uses_session_seed: bool = False + contract_digest: str = "" + normal_form: str | None = None + rules: tuple[PassRuleView, ...] = () + + +@dataclass(frozen=True) +class DerivationStage: + stage: CanonicalIRStage + branch: str + label: str + pass_name: str + duration_ns: int + ir: CanonicalIRMixin + diagnostics: tuple[TraceDiagnostic, ...] = () + + @property + def digest(self) -> str: + return self.ir.digest + + @property + def schema(self) -> str: + return f"{self.ir.header.schema_name}@{self.ir.header.schema_version}" + + @property + def snapshot_json(self) -> str: + return self.ir.to_json() + + +@dataclass(frozen=True) +class DerivationTransition: + source_digest: str + target_digest: str + contract: PassContractView + duration_ns: int + boundary: IRBoundaryView + verification_status: str = "structural_only" + verified_relations: int = 0 + verified_claims: int = 0 + canonical_conformance: str | None = None + + +@dataclass(frozen=True) +class EntityOverlay: + entity_id: str + metrics: tuple[tuple[str, float], ...] + + +@dataclass(frozen=True) +class DerivedOverlay: + name: str + stage_digest: str + provider: str + revision: str + entities: tuple[EntityOverlay, ...] + + +@dataclass(frozen=True) +class DerivationTrace: + request_digest: str + session_fingerprint: str + stages: tuple[DerivationStage, ...] + transitions: tuple[DerivationTransition, ...] + overlays: tuple[DerivedOverlay, ...] = () + schema: str = "blueprinting.derivation-trace.v0" + + def __post_init__(self) -> None: + object.__setattr__(self, "stages", tuple(self.stages)) + object.__setattr__(self, "transitions", tuple(self.transitions)) + object.__setattr__(self, "overlays", tuple(self.overlays)) + + def stage_for(self, stage: CanonicalIRStage, branch: str = "training") -> DerivationStage | None: + return next((item for item in self.stages if item.stage is stage and item.branch == branch), None) + + @property + def branches(self) -> tuple[str, ...]: + return tuple(dict.fromkeys(item.branch for item in self.stages)) + + def graph(self, stage: CanonicalIRStage, branch: str = "training") -> IRGraphView | None: + item = self.stage_for(stage, branch) + return graph_view(item.ir) if item is not None else None + + +@dataclass(frozen=True) +class _LineageEntity: + ref: EntityRef + lineage: Lineage + explicit_sources: tuple[str, ...] = () + + +class IRVisualizationAdapter(Protocol): + ir_type: type[CanonicalIRMixin] + stage: CanonicalIRStage + + def graph(self, ir: CanonicalIRMixin) -> IRGraphView: ... + + def lineage_entities(self, ir: CanonicalIRMixin) -> tuple[_LineageEntity, ...]: ... + + +def _text(value: Any) -> str: + if isinstance(value, Enum): + return str(value.value) + if isinstance(value, StableId): + return str(value) + if isinstance(value, (FrozenDict, dict, tuple, list, frozenset)): + try: + return canonical_dumps(value) + except (TypeError, ValueError): + return repr(value) + return str(value) + + +def _properties(**values: Any) -> tuple[tuple[str, str], ...]: + return tuple((key, _text(value)) for key, value in values.items() if value is not None and value != "") + + +def _dimension_text(value: Any) -> str: + name = getattr(value, "name", None) + if isinstance(name, str) and name: + return name + operation = getattr(getattr(value, "op", None), "value", None) + arguments = getattr(value, "args", ()) + if isinstance(operation, str) and arguments: + return f"{operation}({', '.join(_dimension_text(item) for item in arguments)})" + return str(value) + + +def _shape_text(value: Any) -> str: + return "[" + ", ".join(_dimension_text(item) for item in value.shape) + "]" + + +def _ref(stage: CanonicalIRStage, digest: str, kind: str, identifier: Any) -> EntityRef: + return EntityRef(stage, digest, kind, str(identifier)) + + +class _ModelAdapter: + ir_type = ModelIR + stage = CanonicalIRStage.MODEL + + def graph(self, ir: CanonicalIRMixin) -> IRGraphView: + assert isinstance(ir, ModelIR) + digest = ir.digest + nodes = [] + edges: list[IRGraphEdge] = [] + values = {item.id: item for item in ir.values} + value_refs = {} + operation_refs = {} + for value in ir.values: + ref = _ref(self.stage, digest, "value", value.id) + value_refs[value.id] = ref + nodes.append( + IRGraphNode( + ref, + value.name or str(value.id), + f"value/{value.role.value}", + _properties( + role=value.role, + shape=_shape_text(value.type), + dtype=value.type.dtype, + layout=value.type.layout, + lineage=value.lineage.kind, + ), + ) + ) + for operation in ir.operations: + ref = _ref(self.stage, digest, "operation", operation.id) + operation_refs[operation.id] = ref + nodes.append( + IRGraphNode( + ref, + str(operation.operation), + f"operation/{operation.operation.dialect}", + _properties( + operation=operation.operation, + shape=_shape_text(values[operation.inputs[0]].type) if operation.inputs else None, + dtype=values[operation.inputs[0]].type.dtype if operation.inputs else None, + inputs=len(operation.inputs), + outputs=len(operation.outputs), + lineage=operation.lineage.kind, + ), + ) + ) + edges.extend( + IRGraphEdge(value_refs[item], ref, "dataflow", "input") + for item in operation.inputs + if item in value_refs + ) + edges.extend( + IRGraphEdge(ref, value_refs[item], "dataflow", "output") + for item in operation.outputs + if item in value_refs + ) + for operation in ir.operations: + for dependency in operation.control_dependencies: + if dependency in operation_refs: + edges.append(IRGraphEdge(operation_refs[dependency], operation_refs[operation.id], "control")) + return IRGraphView(self.stage, digest, tuple(nodes), tuple(edges)) + + def lineage_entities(self, ir: CanonicalIRMixin) -> tuple[_LineageEntity, ...]: + assert isinstance(ir, ModelIR) + digest = ir.digest + return tuple( + [_LineageEntity(_ref(self.stage, digest, "operation", item.id), item.lineage) for item in ir.operations] + + [_LineageEntity(_ref(self.stage, digest, "value", item.id), item.lineage) for item in ir.values] + ) + + +class _DistributedAdapter: + ir_type = DistributedTaskIR + stage = CanonicalIRStage.DISTRIBUTED + + def graph(self, ir: CanonicalIRMixin) -> IRGraphView: + assert isinstance(ir, DistributedTaskIR) + digest = ir.digest + nodes = [] + edges: list[IRGraphEdge] = [] + value_refs = {} + task_refs = {} + for value in ir.values: + ref = _ref(self.stage, digest, "value", value.id) + value_refs[value.id] = ref + nodes.append( + IRGraphNode( + ref, + str(value.id), + f"value/{value.role.value}", + _properties( + role=value.role, + shape=_shape_text(value.type), + dtype=value.type.dtype, + owners=value.owners, + owner_count=len(value.owners), + sharding="[" + + ",".join("+".join(axes) if axes else "-" for axes in value.sharding.dimension_axes) + + "]", + dimension_axes=value.sharding.dimension_axes, + replicated_axes=value.sharding.replicated_axes, + ), + ) + ) + for task in ir.tasks: + invocation = getattr(task.semantic, "invocation", None) + phase = getattr(getattr(invocation, "phase", None), "value", "") + source_layer = getattr(invocation, "source_layer", "") + group = f"{phase or task.body_tag}/{source_layer or 'unscoped'}" + collective: CollectiveSpecVariant | None = None + kind: CollectiveKind | None = None + reduction: ReductionKind | None = None + root: int | None = None + match task.body: + case Collective(spec=spec): + collective = spec + kind, reduction, root = _collective_projection(collective) + case _: + pass + ref = _ref(self.stage, digest, "task", task.id) + task_refs[task.id] = ref + nodes.append( + IRGraphNode( + ref, + str(task.operation), + group, + _properties( + kind=task.body_tag, + ranks=task.ranks, + rank_count=len(task.ranks), + collective_kind=kind, + participants=len(collective.participants) if collective is not None else None, + message_bytes=collective.message_bytes if collective is not None else None, + reduction=reduction, + root=root, + phase=phase, + source_layer=source_layer, + ), + ) + ) + edges.extend( + IRGraphEdge(value_refs[item], ref, "dataflow", "input") for item in task.inputs if item in value_refs + ) + edges.extend( + IRGraphEdge(ref, value_refs[item], "dataflow", "output") for item in task.outputs if item in value_refs + ) + for task in ir.tasks: + for dependency in task.dependencies: + if dependency in task_refs: + edges.append(IRGraphEdge(task_refs[dependency], task_refs[task.id], "dependency")) + return IRGraphView(self.stage, digest, tuple(nodes), tuple(edges)) + + def lineage_entities(self, ir: CanonicalIRMixin) -> tuple[_LineageEntity, ...]: + assert isinstance(ir, DistributedTaskIR) + digest = ir.digest + task_items = [_LineageEntity(_ref(self.stage, digest, "task", item.id), item.lineage) for item in ir.tasks] + value_items = [ + _LineageEntity( + _ref(self.stage, digest, "value", item.id), + item.lineage, + (str(item.source_value),) if item.source_value is not None else (), + ) + for item in ir.values + ] + return tuple(task_items + value_items) + + +class _PortableAdapter: + ir_type = PortablePlanIR + stage = CanonicalIRStage.PORTABLE + + def graph(self, ir: CanonicalIRMixin) -> IRGraphView: + assert isinstance(ir, PortablePlanIR) + digest = ir.digest + nodes = [] + edges: list[IRGraphEdge] = [] + buffer_refs = {} + task_refs = {} + for buffer in ir.buffers: + ref = _ref(self.stage, digest, "buffer", buffer.id) + buffer_refs[buffer.id] = ref + nodes.append( + IRGraphNode( + ref, + str(buffer.id), + f"buffer/{buffer.role.value}", + _properties( + role=buffer.role, + storage=buffer.storage_class, + size_bytes=buffer.size_bytes, + alignment_bytes=buffer.alignment_bytes, + ), + ) + ) + for task in ir.tasks: + phase = getattr(getattr(task.semantic, "phase", None), "value", task.kind.value) + source_layer = getattr(task.semantic, "source_layer", "unscoped") + ref = _ref(self.stage, digest, "task", task.id) + task_refs[task.id] = ref + nodes.append( + IRGraphNode( + ref, + str(task.operation), + f"{phase}/{source_layer}", + _properties( + kind=task.kind, + ranks=task.logical_ranks, + rank_count=len(task.logical_ranks), + operations=task.workload.operations, + read_bytes=task.workload.read_bytes, + write_bytes=task.workload.write_bytes, + message_bytes=task.workload.message_bytes, + concurrency=task.concurrency_group, + phase=phase, + source_layer=source_layer, + ), + ) + ) + edges.extend( + IRGraphEdge(buffer_refs[item], ref, "buffer", "read") for item in task.inputs if item in buffer_refs + ) + edges.extend( + IRGraphEdge(ref, buffer_refs[item], "buffer", "write") for item in task.outputs if item in buffer_refs + ) + for task in ir.tasks: + for dependency in task.dependencies: + if dependency in task_refs: + edges.append(IRGraphEdge(task_refs[dependency], task_refs[task.id], "dependency")) + return IRGraphView(self.stage, digest, tuple(nodes), tuple(edges)) + + def lineage_entities(self, ir: CanonicalIRMixin) -> tuple[_LineageEntity, ...]: + assert isinstance(ir, PortablePlanIR) + digest = ir.digest + return tuple( + [_LineageEntity(_ref(self.stage, digest, "task", item.id), item.lineage) for item in ir.tasks] + + [_LineageEntity(_ref(self.stage, digest, "buffer", item.id), item.lineage) for item in ir.buffers] + ) + + +class _ConcreteAdapter: + ir_type = ConcretePlanIR + stage = CanonicalIRStage.CONCRETE + + def graph(self, ir: CanonicalIRMixin) -> IRGraphView: + assert isinstance(ir, ConcretePlanIR) + digest = ir.digest + nodes = [] + edges = [] + refs: dict[Any, EntityRef] = {} + for device in ir.devices: + ref = _ref(self.stage, digest, "device", device.id) + refs[device.id] = ref + nodes.append( + IRGraphNode( + ref, device.target_device, f"device/{device.target_device}", _properties(rank=device.logical_rank) + ) + ) + for queue in ir.queues: + ref = _ref(self.stage, digest, "queue", queue.id) + refs[queue.id] = ref + nodes.append( + IRGraphNode( + ref, queue.engine, f"device/{queue.device}", _properties(kind=queue.kind, ordered=queue.ordered) + ) + ) + if queue.device in refs: + edges.append(IRGraphEdge(refs[queue.device], ref, "placement", "queue")) + for region in ir.memory_regions: + ref = _ref(self.stage, digest, "memory_region", region.id) + refs[region.id] = ref + nodes.append( + IRGraphNode( + ref, + region.memory_space, + f"device/{region.device}", + _properties(capacity_bytes=region.capacity_bytes, alignment_bytes=region.alignment_bytes), + ) + ) + if region.device in refs: + edges.append(IRGraphEdge(refs[region.device], ref, "placement", "memory")) + for buffer in ir.buffers: + ref = _ref(self.stage, digest, "buffer", buffer.id) + refs[buffer.id] = ref + nodes.append( + IRGraphNode( + ref, + str(buffer.id), + f"memory/{buffer.memory_region}", + _properties( + offset=buffer.offset_bytes, size_bytes=buffer.size_bytes, alignment=buffer.alignment_bytes + ), + ) + ) + if buffer.memory_region in refs: + edges.append(IRGraphEdge(refs[buffer.memory_region], ref, "allocation")) + token_refs: dict[Any, EntityRef] = {} + for command in ir.commands: + queue_group = str(command.queue) if command.queue is not None else "host" + ref = _ref(self.stage, digest, "command", command.id) + refs[command.id] = ref + nodes.append( + IRGraphNode( + ref, + command.implementation.key if command.implementation is not None else command.kind.value, + f"queue/{queue_group}", + _properties(kind=command.kind, queue=command.queue, implementation=command.implementation), + ) + ) + if command.queue in refs: + edges.append(IRGraphEdge(refs[command.queue], ref, "placement", "command")) + for command in ir.commands: + command_ref = refs[command.id] + for dependency in command.dependencies: + if dependency in refs: + edges.append(IRGraphEdge(refs[dependency], command_ref, "dependency")) + for use in command.buffers: + if use.buffer not in refs: + continue + if use.access.value == "read": + edges.append(IRGraphEdge(refs[use.buffer], command_ref, "buffer", "read")) + else: + edges.append(IRGraphEdge(command_ref, refs[use.buffer], "buffer", use.access.value)) + for token in command.signal_tokens + command.wait_tokens: + if token not in token_refs: + token_ref = _ref(self.stage, digest, "sync_token", token) + token_refs[token] = token_ref + nodes.append(IRGraphNode(token_ref, str(token), "synchronization")) + edges.extend( + IRGraphEdge(command_ref, token_refs[item], "synchronization", "signal") + for item in command.signal_tokens + ) + edges.extend( + IRGraphEdge(token_refs[item], command_ref, "synchronization", "wait") for item in command.wait_tokens + ) + return IRGraphView(self.stage, digest, tuple(nodes), tuple(edges)) + + def lineage_entities(self, ir: CanonicalIRMixin) -> tuple[_LineageEntity, ...]: + assert isinstance(ir, ConcretePlanIR) + digest = ir.digest + return tuple( + [_LineageEntity(_ref(self.stage, digest, "command", item.id), item.lineage) for item in ir.commands] + + [ + _LineageEntity( + _ref(self.stage, digest, "buffer", item.id), + item.lineage, + (str(item.source_buffer),) if item.source_buffer is not None else (), + ) + for item in ir.buffers + ] + ) + + +class _MachineAdapter: + ir_type = MachineIR + stage = CanonicalIRStage.MACHINE + + def graph(self, ir: CanonicalIRMixin) -> IRGraphView: + assert isinstance(ir, MachineIR) + digest = ir.digest + nodes = [] + edges = [] + instruction_refs = {} + section_refs = {} + for section in ir.sections: + ref = _ref(self.stage, digest, "section", f"section:{section.name}") + section_refs[section.name] = ref + nodes.append( + IRGraphNode( + ref, + section.name, + f"section/{section.kind.value}", + _properties(kind=section.kind, alignment=section.alignment_bytes, data_bytes=len(section.data)), + ) + ) + for instruction in section.instructions: + instruction_ref = _ref(self.stage, digest, "instruction", instruction.id) + instruction_refs[instruction.id] = instruction_ref + nodes.append( + IRGraphNode( + instruction_ref, + str(instruction.opcode), + f"section/{section.name}", + _properties(opcode=instruction.opcode, operands=instruction.operands), + ) + ) + edges.append(IRGraphEdge(ref, instruction_ref, "contains")) + for instruction in ir.instructions: + for dependency in instruction.dependencies: + if dependency in instruction_refs: + edges.append( + IRGraphEdge(instruction_refs[dependency], instruction_refs[instruction.id], "dependency") + ) + for entry in ir.entry_points: + ref = _ref(self.stage, digest, "entry_point", f"entry:{entry.name}") + nodes.append(IRGraphNode(ref, entry.name, "entry_points")) + if entry.instruction in instruction_refs: + edges.append(IRGraphEdge(ref, instruction_refs[entry.instruction], "entry")) + return IRGraphView(self.stage, digest, tuple(nodes), tuple(edges)) + + def lineage_entities(self, ir: CanonicalIRMixin) -> tuple[_LineageEntity, ...]: + assert isinstance(ir, MachineIR) + digest = ir.digest + return tuple( + _LineageEntity( + _ref(self.stage, digest, "instruction", item.id), + item.lineage, + (str(item.source_command),) if item.source_command is not None else (), + ) + for item in ir.instructions + ) + + +_ADAPTERS = cast( + tuple[IRVisualizationAdapter, ...], + ( + _ModelAdapter(), + _DistributedAdapter(), + _PortableAdapter(), + _ConcreteAdapter(), + _MachineAdapter(), + ), +) + + +def adapter_for(ir: CanonicalIRMixin) -> IRVisualizationAdapter: + for adapter in _ADAPTERS: + if type(ir) is adapter.ir_type: + return adapter + raise TypeError(f"no IR visualization adapter for {type(ir).__name__}") + + +def graph_view(ir: CanonicalIRMixin) -> IRGraphView: + return adapter_for(ir).graph(ir) + + +def _stage_diagnostics(ir: CanonicalIRMixin) -> tuple[TraceDiagnostic, ...]: + return tuple( + TraceDiagnostic(item.code, item.message, item.severity.value, "/".join(item.path)) + for item in ir.diagnostics().diagnostics + ) + + +def _mapping_summary( + source_graph: IRGraphView, + target_entities: Sequence[_LineageEntity], + relations: Sequence[LineageRelation], +) -> MappingSummary: + target_by_source: dict[str, set[str]] = defaultdict(set) + source_counts_by_target = {} + generated = 0 + dangling = 0 + mapped_targets = 0 + for relation in relations: + if not relation.sources and not relation.unresolved_sources: + generated += 1 + if relation.sources: + mapped_targets += 1 + dangling += len(relation.unresolved_sources) + source_counts_by_target[relation.target.key] = len(relation.sources) + for source in relation.sources: + target_by_source[source.key].add(relation.target.key) + one_to_one = one_to_many = many_to_one = many_to_many = 0 + for relation in relations: + source_count = source_counts_by_target[relation.target.key] + max_targets = max((len(target_by_source[item.key]) for item in relation.sources), default=0) + if source_count == 1 and max_targets == 1: + one_to_one += 1 + elif source_count == 1 and max_targets > 1: + one_to_many += 1 + elif source_count > 1 and max_targets <= 1: + many_to_one += 1 + elif source_count > 1 and max_targets > 1: + many_to_many += 1 + return MappingSummary( + source_entities=len(source_graph.nodes), + mapped_source_entities=len(target_by_source), + target_entities=len(target_entities), + mapped_target_entities=mapped_targets, + generated_targets=generated, + dangling_sources=dangling, + one_to_one=one_to_one, + one_to_many=one_to_many, + many_to_one=many_to_one, + many_to_many=many_to_many, + ) + + +def boundary_view( + source: CanonicalIRMixin, + target: CanonicalIRMixin, + pass_name: str, + transition_report: TransitionReport | None = None, +) -> IRBoundaryView: + source_adapter = adapter_for(source) + target_adapter = adapter_for(target) + source_graph = source_adapter.graph(source) + source_by_id: dict[str, list[EntityRef]] = defaultdict(list) + for node in source_graph.nodes: + source_by_id[node.ref.entity_id].append(node.ref) + target_entities = target_adapter.lineage_entities(target) + if transition_report is not None and transition_report.relations: + if transition_report.source_digest != source.digest or transition_report.target_digest != target.digest: + raise ValueError("transition report digests do not match the requested boundary") + target_by_id = {item.ref.entity_id: item for item in target_entities} + relations = [] + for verified in transition_report.relations: + target_entity = target_by_id.get(verified.target_id) + if target_entity is None: + raise ValueError(f"transition report references unknown target {verified.target_id}") + resolved = [] + for source_id in verified.source_ids: + candidates = source_by_id.get(source_id, ()) + if len(candidates) != 1: + raise ValueError(f"verified transition source {source_id} is unavailable in the source graph") + resolved.append(candidates[0]) + relations.append( + LineageRelation( + target=target_entity.ref, + sources=tuple(resolved), + unresolved_sources=(), + lineage_kind=verified.lineage_kind.value, + transform=verified.transform, + explicit_source_mismatch=False, + verified_claims=tuple(item.name for item in verified.evidence), + ) + ) + summary = _mapping_summary(source_graph, target_entities, relations) + return IRBoundaryView( + source_adapter.stage, + target_adapter.stage, + source.digest, + target.digest, + pass_name, + tuple(relations), + summary, + (), + ) + relations = [] + diagnostics = [] + for item in target_entities: + resolved = [] + unresolved = [] + lineage_sources = tuple(str(source_id) for source_id in item.lineage.sources) + for source_id in lineage_sources: + candidates = source_by_id.get(source_id, ()) + if len(candidates) == 1: + resolved.append(candidates[0]) + else: + unresolved.append(source_id) + diagnostics.append( + TraceDiagnostic( + "lineage.source_unresolved", + f"{item.ref.entity_id} references unavailable source {source_id}", + "warning", + item.ref.entity_id, + ) + ) + mismatch = bool(item.explicit_sources and set(item.explicit_sources) != set(lineage_sources)) + if mismatch: + diagnostics.append( + TraceDiagnostic( + "lineage.explicit_source_mismatch", + f"{item.ref.entity_id} explicit source disagrees with Lineage.sources", + "warning", + item.ref.entity_id, + ) + ) + if not lineage_sources and item.lineage.kind.value != "generated": + diagnostics.append( + TraceDiagnostic( + "lineage.source_missing", + f"{item.ref.entity_id} has no upstream source", + "info", + item.ref.entity_id, + ) + ) + relations.append( + LineageRelation( + target=item.ref, + sources=tuple(resolved), + unresolved_sources=tuple(unresolved), + lineage_kind=item.lineage.kind.value, + transform=item.lineage.transform, + explicit_source_mismatch=mismatch, + ) + ) + diagnostics.extend(_transition_rules(source, target, relations)) + summary = _mapping_summary(source_graph, target_entities, relations) + return IRBoundaryView( + source_adapter.stage, + target_adapter.stage, + source.digest, + target.digest, + pass_name, + tuple(relations), + summary, + tuple(diagnostics), + ) + + +def _transition_rules( + source: CanonicalIRMixin, + target: CanonicalIRMixin, + relations: Sequence[LineageRelation], +) -> tuple[TraceDiagnostic, ...]: + """Run narrow, schema-aware audit rules without changing pass acceptance.""" + + diagnostics = [] + if isinstance(source, ModelIR) and isinstance(target, DistributedTaskIR): + source_operations = {str(item.id) for item in source.operations} + mapped_operations = { + item.entity_id + for relation in relations + if relation.target.kind == "task" + for item in relation.sources + if item.kind == "operation" + } + for identifier in sorted(source_operations - mapped_operations): + diagnostics.append( + TraceDiagnostic( + "transformer.model_operation_unmapped", + f"model operation {identifier} has no distributed task lineage", + "warning", + identifier, + ) + ) + if isinstance(source, DistributedTaskIR) and isinstance(target, PortablePlanIR): + source_tasks = {str(item.id): item for item in source.tasks} + target_tasks = {str(item.id): item for item in target.tasks} + relation_by_target = {item.target.entity_id: item for item in relations} + for target_id, task in target_tasks.items(): + relation = relation_by_target.get(target_id) + if relation is None or len(relation.sources) != 1: + continue + source_task = source_tasks.get(relation.sources[0].entity_id) + if source_task is None: + continue + if source_task.operation != task.operation: + diagnostics.append( + TraceDiagnostic( + "portable.operation_changed", + f"portable task {target_id} changed semantic operation", + "warning", + target_id, + ) + ) + if source_task.ranks != task.logical_ranks: + diagnostics.append( + TraceDiagnostic( + "portable.rank_mapping_changed", + f"portable task {target_id} changed logical ranks", + "warning", + target_id, + ) + ) + invocation = getattr(source_task.semantic, "invocation", None) + work = getattr(invocation, "work", None) + if work is not None: + expected = ( + getattr(work, "operations", None), + getattr(work, "read_bytes", None), + getattr(work, "write_bytes", None), + getattr(work, "message_bytes", None), + ) + actual = ( + task.workload.operations, + task.workload.read_bytes, + task.workload.write_bytes, + task.workload.message_bytes, + ) + if expected != actual: + diagnostics.append( + TraceDiagnostic( + "portable.workload_not_conserved", + f"portable task {target_id} does not preserve typed workload facts", + "warning", + target_id, + ) + ) + return tuple(diagnostics) + + +def _contract_view(derivation_pass: Any) -> PassContractView: + contract = derivation_pass.contract + return PassContractView( + name=contract.name, + input_schema=( + f"{contract.input_type.SCHEMA_NAME}@{contract.input_schema.minimum}..{contract.input_schema.maximum}" + ), + output_schema=f"{contract.output_type.SCHEMA_NAME}@{contract.output_schema}", + required_bindings=tuple(sorted(item.value for item in contract.required_bindings)), + required_analyses=tuple(sorted(str(item) for item in contract.required_analyses)), + produced_analyses=tuple(sorted(str(item) for item in contract.produced_analyses)), + mutation_model=contract.mutation_model.value, + verification=contract.verification.value, + deterministic=contract.deterministic, + revision=contract.revision, + preserved_analyses=tuple(sorted(str(item) for item in contract.preserved_analyses)), + uses_session_seed=contract.uses_session_seed, + contract_digest=contract.digest, + normal_form=contract.normalizer_identity, + rules=tuple( + PassRuleView( + item.transform, + item.source_entity, + item.target_entity, + item.rewrite, + item.preservation_names, + item.introduces, + item.forbids, + contract._callable_identity(item.verifier) or None, + ) + for item in contract.rules + ), + ) + + +def build_derivation_trace( + source: CanonicalIRMixin, + pipeline: PassPipeline, + result: PipelineResult[Any], + *, + request_digest: str, + session_fingerprint: str, + source_duration_ns: int, + branch: str = "training", +) -> DerivationTrace: + if len(pipeline.passes) != len(result.checkpoints): + raise ValueError("pipeline and checkpoint counts differ") + source_adapter = adapter_for(source) + stages = [ + DerivationStage( + source_adapter.stage, + branch, + _STAGE_LABELS[source_adapter.stage], + "frontend-import", + source_duration_ns, + source, + _stage_diagnostics(source), + ) + ] + transitions = [] + previous = source + for derivation_pass, checkpoint in zip(pipeline.passes, result.checkpoints): + output = checkpoint.ir + output_adapter = adapter_for(output) + stages.append( + DerivationStage( + output_adapter.stage, + branch, + _STAGE_LABELS[output_adapter.stage], + checkpoint.record.pass_name, + checkpoint.record.duration_ns, + output, + _stage_diagnostics(output), + ) + ) + boundary = boundary_view( + previous, + output, + checkpoint.record.pass_name, + checkpoint.record.transition_report, + ) + transitions.append( + DerivationTransition( + previous.digest, + output.digest, + _contract_view(derivation_pass), + checkpoint.record.duration_ns, + boundary, + checkpoint.record.transition_report.status.value, + checkpoint.record.transition_report.verified_relations, + checkpoint.record.transition_report.verified_claims, + ( + checkpoint.record.transition_report.canonical_conformance.verifier + if checkpoint.record.transition_report.canonical_conformance is not None + else None + ), + ) + ) + previous = output + return DerivationTrace( + request_digest=request_digest, + session_fingerprint=session_fingerprint, + stages=tuple(stages), + transitions=tuple(transitions), + ) + + +def portable_cost_overlay( + plan_digest: str, + tasks: Iterable[Any], + *, + provider: str, + revision: str, +) -> DerivedOverlay: + return DerivedOverlay( + name="portable-task-cost", + stage_digest=plan_digest, + provider=provider, + revision=revision, + entities=tuple( + EntityOverlay( + entity_id=item.task_id, + metrics=( + ("total_seconds", float(item.total_seconds)), + ("compute_seconds", float(item.compute_seconds)), + ("memory_seconds", float(item.memory_seconds)), + ("network_seconds", float(item.network_seconds)), + ), + ) + for item in tasks + ), + ) + + +class DerivationDebugBundleCodec: + SCHEMA = "blueprinting.derivation-debug-bundle.v0" + MAX_BYTES = 50 * 1024 * 1024 + + @classmethod + def dumps(cls, trace: DerivationTrace) -> str: + payload = { + "schema": cls.SCHEMA, + "request_digest": trace.request_digest, + "session_fingerprint": trace.session_fingerprint, + "stages": [ + { + "stage": item.stage.value, + "branch": item.branch, + "label": item.label, + "pass_name": item.pass_name, + "duration_ns": item.duration_ns, + "snapshot_json": item.snapshot_json, + } + for item in trace.stages + ], + "transitions": [ + { + "source_digest": item.source_digest, + "target_digest": item.target_digest, + "duration_ns": item.duration_ns, + "verification_status": item.verification_status, + "verified_relations": item.verified_relations, + "verified_claims": item.verified_claims, + "canonical_conformance": item.canonical_conformance, + "contract": { + "name": item.contract.name, + "revision": item.contract.revision, + "input_schema": item.contract.input_schema, + "output_schema": item.contract.output_schema, + "required_bindings": item.contract.required_bindings, + "required_analyses": item.contract.required_analyses, + "produced_analyses": item.contract.produced_analyses, + "preserved_analyses": item.contract.preserved_analyses, + "mutation_model": item.contract.mutation_model, + "verification": item.contract.verification, + "deterministic": item.contract.deterministic, + "uses_session_seed": item.contract.uses_session_seed, + "contract_digest": item.contract.contract_digest, + "normal_form": item.contract.normal_form, + "rules": [ + { + "transform": rule.transform, + "source_entity": rule.source_entity, + "target_entity": rule.target_entity, + "rewrite": rule.rewrite, + "preserves": rule.preserves, + "introduces": rule.introduces, + "forbids": rule.forbids, + "semantic_invariant": rule.semantic_invariant, + } + for rule in item.contract.rules + ], + }, + } + for item in trace.transitions + ], + "overlays": [ + { + "name": item.name, + "stage_digest": item.stage_digest, + "provider": item.provider, + "revision": item.revision, + "entities": [ + {"entity_id": entity.entity_id, "metrics": dict(entity.metrics)} for entity in item.entities + ], + } + for item in trace.overlays + ], + } + rendered = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + if len(rendered.encode("utf-8")) > cls.MAX_BYTES: + raise ValueError("derivation debug bundle exceeds 50 MiB") + return rendered + + @classmethod + def loads(cls, payload: str) -> DerivationTrace: + if len(payload.encode("utf-8")) > cls.MAX_BYTES: + raise ValueError("derivation debug bundle exceeds 50 MiB") + value = json.loads(payload) + if not isinstance(value, dict) or value.get("schema") != cls.SCHEMA: + raise ValueError("unsupported derivation debug bundle schema") + stage_rows = value.get("stages") + if not isinstance(stage_rows, list) or not stage_rows: + raise ValueError("derivation debug bundle requires stages") + stage_types: Mapping[CanonicalIRStage, type[CanonicalIRMixin]] = { + CanonicalIRStage.MODEL: ModelIR, + CanonicalIRStage.DISTRIBUTED: DistributedTaskIR, + CanonicalIRStage.PORTABLE: PortablePlanIR, + CanonicalIRStage.CONCRETE: ConcretePlanIR, + CanonicalIRStage.MACHINE: MachineIR, + } + stages = [] + for row in stage_rows: + if not isinstance(row, dict): + raise ValueError("bundle stage must be an object") + stage = CanonicalIRStage(str(row["stage"])) + snapshot_json = row.get("snapshot_json") + if not isinstance(snapshot_json, str): + raise ValueError("bundle stage snapshot_json must be a string") + ir = stage_types[stage].require_from_json(snapshot_json) + if adapter_for(ir).stage is not stage: + raise ValueError("bundle stage does not match snapshot schema") + stages.append( + DerivationStage( + stage, + str(row.get("branch", "training")), + str(row.get("label", _STAGE_LABELS[stage])), + str(row.get("pass_name", "imported")), + int(row.get("duration_ns", 0)), + ir, + _stage_diagnostics(ir), + ) + ) + if len({(item.branch, item.stage) for item in stages}) != len(stages): + raise ValueError("bundle repeats a canonical stage within one branch") + expected_pairs: set[tuple[str, str]] = set() + for branch in dict.fromkeys(item.branch for item in stages): + branch_stages = [item for item in stages if item.branch == branch] + indices = [CANONICAL_STAGE_ORDER.index(item.stage) for item in branch_stages] + if indices != list(range(len(indices))): + raise ValueError("bundle branches must start at ModelIR and contain contiguous canonical stages") + expected_pairs.update( + (source.digest, target.digest) for source, target in zip(branch_stages, branch_stages[1:]) + ) + by_digest = {item.digest: item for item in stages} + transition_rows = value.get("transitions", []) + if not isinstance(transition_rows, list): + raise ValueError("bundle transitions must be a list") + actual_pairs = { + (str(row.get("source_digest")), str(row.get("target_digest"))) + for row in transition_rows + if isinstance(row, dict) + } + if actual_pairs != expected_pairs or len(actual_pairs) != len(transition_rows): + raise ValueError("bundle transitions must cover every adjacent stage exactly once") + transitions = [] + for row in transition_rows: + source = by_digest.get(str(row.get("source_digest"))) + target = by_digest.get(str(row.get("target_digest"))) + if source is None or target is None: + raise ValueError("bundle transition references unavailable stage") + source_index = CANONICAL_STAGE_ORDER.index(source.stage) + target_index = CANONICAL_STAGE_ORDER.index(target.stage) + if target_index != source_index + 1: + raise ValueError("bundle transition must connect adjacent canonical stages") + if source.digest not in target.ir.header.parent_digests: + raise ValueError("bundle transition target does not retain source digest") + contract_row = row.get("contract", {}) + rule_rows = contract_row.get("rules", ()) + if not isinstance(rule_rows, (list, tuple)): + raise ValueError("bundle pass contract rules must be a list") + contract = PassContractView( + name=str(contract_row.get("name", target.pass_name)), + input_schema=str(contract_row.get("input_schema", source.schema)), + output_schema=str(contract_row.get("output_schema", target.schema)), + required_bindings=tuple(str(item) for item in contract_row.get("required_bindings", ())), + required_analyses=tuple(str(item) for item in contract_row.get("required_analyses", ())), + produced_analyses=tuple(str(item) for item in contract_row.get("produced_analyses", ())), + mutation_model=str(contract_row.get("mutation_model", "immutable")), + verification=str(contract_row.get("verification", "both")), + deterministic=bool(contract_row.get("deterministic", True)), + revision=str(contract_row.get("revision", "1")), + preserved_analyses=tuple(str(item) for item in contract_row.get("preserved_analyses", ())), + uses_session_seed=bool(contract_row.get("uses_session_seed", False)), + contract_digest=str(contract_row.get("contract_digest", "")), + normal_form=(str(contract_row["normal_form"]) if contract_row.get("normal_form") is not None else None), + rules=tuple( + PassRuleView( + transform=str(rule.get("transform", "")), + source_entity=str(rule.get("source_entity", "")), + target_entity=str(rule.get("target_entity", "")), + rewrite=str(rule.get("rewrite", "")), + preserves=tuple(str(item) for item in rule.get("preserves", ())), + introduces=tuple(str(item) for item in rule.get("introduces", ())), + forbids=tuple(str(item) for item in rule.get("forbids", ())), + semantic_invariant=( + str(rule["semantic_invariant"]) if rule.get("semantic_invariant") is not None else None + ), + ) + for rule in rule_rows + if isinstance(rule, dict) + ), + ) + boundary = boundary_view(source.ir, target.ir, contract.name) + transitions.append( + DerivationTransition( + source.digest, + target.digest, + contract, + int(row.get("duration_ns", target.duration_ns)), + boundary, + str(row.get("verification_status", "structural_only")), + int(row.get("verified_relations", 0)), + int(row.get("verified_claims", 0)), + (str(row["canonical_conformance"]) if row.get("canonical_conformance") is not None else None), + ) + ) + overlays = [] + overlay_rows = value.get("overlays", []) + if not isinstance(overlay_rows, list): + raise ValueError("bundle overlays must be a list") + for row in overlay_rows: + if row.get("stage_digest") not in by_digest: + raise ValueError("bundle overlay references unavailable stage") + overlays.append( + DerivedOverlay( + name=str(row["name"]), + stage_digest=str(row["stage_digest"]), + provider=str(row["provider"]), + revision=str(row["revision"]), + entities=tuple( + EntityOverlay( + str(entity["entity_id"]), + tuple((str(key), float(metric)) for key, metric in entity.get("metrics", {}).items()), + ) + for entity in row.get("entities", ()) + ), + ) + ) + trace = DerivationTrace( + request_digest=str(value.get("request_digest", "imported")), + session_fingerprint=str(value.get("session_fingerprint", "imported")), + stages=tuple(stages), + transitions=tuple(transitions), + overlays=tuple(overlays), + ) + cls.dumps(trace) + return trace + + +def with_overlays(trace: DerivationTrace, *overlays: DerivedOverlay) -> DerivationTrace: + return replace(trace, overlays=trace.overlays + tuple(overlays)) + + +def stage_label(stage: CanonicalIRStage) -> str: + return _STAGE_LABELS[stage] + + +__all__ = [ + "CANONICAL_STAGE_ORDER", + "CanonicalIRStage", + "DerivationDebugBundleCodec", + "DerivationStage", + "DerivationTrace", + "DerivationTransition", + "DerivedOverlay", + "EntityOverlay", + "EntityRef", + "IRBoundaryView", + "IRGraphEdge", + "IRGraphNode", + "IRGraphView", + "IRVisualizationAdapter", + "LineageRelation", + "MappingSummary", + "PassContractView", + "PassRuleView", + "TraceDiagnostic", + "adapter_for", + "boundary_view", + "build_derivation_trace", + "graph_view", + "portable_cost_overlay", + "stage_label", + "with_overlays", +] diff --git a/src/blueprinting/application/inference.py b/src/blueprinting/application/inference.py index 49d3789..b75d83f 100644 --- a/src/blueprinting/application/inference.py +++ b/src/blueprinting/application/inference.py @@ -19,7 +19,6 @@ CostQueryContext, CostResolver, EstimateUncertainty, - InferenceCostProvider, InferencePhaseEstimate, estimate_inference_phase, ) @@ -27,20 +26,36 @@ from blueprinting.schema.codec import content_digest from blueprinting.schema.frozen import FrozenDict, freeze, thaw from blueprinting.synthesizer.bindings import InferencePhase +from blueprinting.synthesizer.dialects.transformer import TransformerInferencePlanTaskSemantic from blueprinting.synthesizer.errors import IRVerificationError, PassExecutionError, SynthesisError from blueprinting.synthesizer.frontend import ( build_transformer_inference_model_ir, inference_synthesis_session_for, ) -from blueprinting.synthesizer.ir import ModelIR, PortablePlanIR -from blueprinting.synthesizer.lowering import DistributeTransformerInferencePass, PlanTransformerInferencePass -from blueprinting.synthesizer.passes import AnalysisStore, PassCheckpoint, PassManager, PassPipeline +from blueprinting.synthesizer.passes import ( + AnalysisStore, + PassCheckpoint, + PassManager, + PassPipeline, + PipelineResult, +) +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerInferencePass +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR, require_concrete_quantity +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerInferencePass from blueprinting.system import SystemProfile from blueprinting.workload import ( + TransformerDataType, TransformerInferenceRequestSpec, TransformerModelSpec, ) +from .derivation import ( + DerivationTrace, + build_derivation_trace, + portable_cost_overlay, + with_overlays, +) from .reporting import AnalysisDiagnostic, DiagnosticLevel, IRStageReport, TaskReport, stage_report LOGGER = logging.getLogger(__name__) @@ -116,11 +131,11 @@ def fingerprint(self) -> str: "seed": self.seed, } ), - "blueprinting-inference-analysis-request-v1", + "blueprinting-inference-analysis-request-v0", ) def normalized_execution(self) -> dict[str, Any]: - data = thaw(self.execution_data) + data = self.execution_data.to_dict() data.pop("num_procs", None) mapping = TransformerInferenceMappingSpec.from_mapping(data) data["replicas"] = mapping.replicas @@ -161,6 +176,7 @@ class InferenceAnalysisReport: workload: FrozenDict evidence: FrozenDict configuration: FrozenDict + derivation_traces: tuple[DerivationTrace, ...] stages: tuple[IRStageReport, ...] tasks: tuple[TaskReport, ...] decode_steps: tuple[DecodeStepReport, ...] @@ -203,15 +219,25 @@ def uncertainty_report(uncertainty: EstimateUncertainty) -> FrozenDict: task_id=str(task.id), operation=str(task.operation), kind=task.kind.value, - phase=str(task.workload.attributes.get("phase", "unknown")), - engine=str(task.workload.attributes.get("engine", "unknown")), - source_layer=str(task.workload.attributes.get("source_layer", "")), + phase=( + task.semantic.phase.value + if isinstance(task.semantic, TransformerInferencePlanTaskSemantic) + else "unknown" + ), + engine=( + task.semantic.engine.value + if isinstance(task.semantic, TransformerInferencePlanTaskSemantic) + else "unknown" + ), + source_layer=( + task.semantic.source_layer if isinstance(task.semantic, TransformerInferencePlanTaskSemantic) else "" + ), dependencies=tuple(str(item) for item in task.dependencies), concurrency_group=task.concurrency_group or "", - operations=task.workload.operations, - read_bytes=task.workload.read_bytes, - write_bytes=task.workload.write_bytes, - message_bytes=task.workload.message_bytes, + operations=require_concrete_quantity(task.workload.operations, f"task {task.id} operations"), + read_bytes=require_concrete_quantity(task.workload.read_bytes, f"task {task.id} read_bytes"), + write_bytes=require_concrete_quantity(task.workload.write_bytes, f"task {task.id} write_bytes"), + message_bytes=require_concrete_quantity(task.workload.message_bytes, f"task {task.id} message_bytes"), compute_seconds=task_estimate.compute_seconds, memory_seconds=task_estimate.memory_seconds, network_seconds=task_estimate.network_seconds, @@ -237,12 +263,9 @@ def __init__( self, analyses: AnalysisStore | None = None, *, - cost_provider: InferenceCostProvider | None = None, cost_resolver: CostResolver | None = None, cost_context: CostQueryContext = CostQueryContext(), ) -> None: - if cost_provider is not None and cost_resolver is not None: - raise ValueError("cost_provider and cost_resolver are mutually exclusive") if not isinstance(cost_context, CostQueryContext): raise TypeError("cost_context must be CostQueryContext") self._manager = PassManager(analyses=analyses) @@ -250,7 +273,6 @@ def __init__( DistributeTransformerInferencePass(), PlanTransformerInferencePass(), ) - self._cost_provider = cost_provider self._cost_resolver = cost_resolver self._cost_context = cost_context @@ -331,7 +353,7 @@ def _derive_phase( model: TransformerModelSpec, mapping: TransformerInferenceMappingSpec, network_binding: NetworkTierBinding, - datatype: str, + datatype: TransformerDataType, hardware: SystemProfile, draft: InferenceAnalysisDraft, *, @@ -350,7 +372,7 @@ def _derive_phase( ), seed=draft.seed, ) - pipeline = self._manager.run(self._pipeline, source, session=session) + pipeline = self._manager.require_run(self._pipeline, source, session=session) plan = pipeline.ir if not isinstance(plan, PortablePlanIR): raise TypeError(f"inference pipeline returned {type(plan).__name__}, expected PortablePlanIR") @@ -359,7 +381,6 @@ def _derive_phase( hardware, draft.calibration_mode, network_binding=network_binding, - cost_provider=self._cost_provider, cost_resolver=self._cost_resolver, cost_context=self._cost_context, ) @@ -463,9 +484,48 @@ def _analyze(self, draft: InferenceAnalysisDraft) -> InferenceAnalysisOutcome: ) ) - tasks = _task_reports(prefill.plan, prefill.estimate) + prefill_tasks = _task_reports(prefill.plan, prefill.estimate) + tasks = prefill_tasks + representative_tasks: tuple[TaskReport, ...] = () if representative is not None: - tasks += _task_reports(representative.plan, representative.estimate) + representative_tasks = _task_reports(representative.plan, representative.estimate) + tasks += representative_tasks + phase_traces = [] + for branch, derived, phase_tasks in ( + ("prefill", prefill, prefill_tasks), + ( + f"decode.context-{representative.estimate.context_tokens}" if representative is not None else "", + representative, + representative_tasks, + ), + ): + if derived is None: + continue + pipeline_result = PipelineResult( + ir=derived.plan, + records=tuple(item.record for item in derived.checkpoints), + checkpoints=derived.checkpoints, + ) + trace = build_derivation_trace( + source, + self._pipeline, + pipeline_result, + request_digest=draft.fingerprint, + session_fingerprint=derived.session_fingerprint, + source_duration_ns=frontend_duration, + branch=branch, + ) + phase_traces.append( + with_overlays( + trace, + portable_cost_overlay( + derived.plan.digest, + phase_tasks, + provider=f"inference-cost:{draft.calibration_mode.value}", + revision=hardware.evidence_revision, + ), + ) + ) decode_steps = tuple( DecodeStepReport( context_tokens=item.estimate.context_tokens, @@ -535,7 +595,6 @@ def _analyze(self, draft: InferenceAnalysisDraft) -> InferenceAnalysisOutcome: "hardware_name": hardware.name, "hardware_revision": hardware.evidence_revision, "mode": draft.calibration_mode.value, - "cost_provider_revision": self._cost_provider.revision if self._cost_provider is not None else "none", "cost_resolver_revision": self._cost_resolver.revision if self._cost_resolver is not None else "none", "revisions": FrozenDict(evidence_revisions), } @@ -553,7 +612,7 @@ def _analyze(self, draft: InferenceAnalysisDraft) -> InferenceAnalysisOutcome: } ) report = InferenceAnalysisReport( - schema="blueprinting.inference-analysis-report.v1", + schema="blueprinting.inference-analysis-report.v0", request_digest=draft.fingerprint, model_name=draft.model_name, execution_name=draft.execution_name, @@ -576,6 +635,7 @@ def _analyze(self, draft: InferenceAnalysisDraft) -> InferenceAnalysisOutcome: workload=workload, evidence=evidence, configuration=configuration, + derivation_traces=tuple(phase_traces), stages=tuple(stages), tasks=tasks, decode_steps=decode_steps, @@ -585,7 +645,7 @@ def _analyze(self, draft: InferenceAnalysisDraft) -> InferenceAnalysisOutcome: "replicas 只参与映射合法性与 world-size 记账;当前报告是单 replica cohort latency,不估算跨 replica serving capacity。", "当前 workload dialect 支持 dense multi-head attention 与非 gated MLP;embedding、LM head 和 sampler 尚未建模。", "PortablePlanIR 尚未绑定 attention implementation;working memory 使用未融合 score materialization 的保守上界。", - "除非提供 Blueprinting cost resolver 或 legacy provider,组件耗时使用共享 system profile 的解析 roofline 证据;comparison baseline 不参与该选择。", + "除非提供 Blueprinting cost resolver,组件耗时使用共享 system profile 的解析 roofline 证据;comparison baseline 不参与该选择。", ), ) return InferenceAnalysisOutcome(draft.fingerprint, diagnostics, report) diff --git a/src/blueprinting/application/reporting.py b/src/blueprinting/application/reporting.py index 0fb616a..490e391 100644 --- a/src/blueprinting/application/reporting.py +++ b/src/blueprinting/application/reporting.py @@ -7,7 +7,10 @@ from typing import Any from blueprinting.schema.frozen import FrozenDict -from blueprinting.synthesizer.ir import DistributedTaskIR, ModelIR, PortablePlanIR +from blueprinting.synthesizer.stages.common import CanonicalIRMixin +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR class DiagnosticLevel(Enum): @@ -101,7 +104,7 @@ def _diagnostics_from_verification( path=item.path, hint=item.hint, ) - for item in ir.verify().diagnostics + for item in ir.diagnostics().diagnostics ) @@ -109,7 +112,7 @@ def stage_report( stage: str, label: str, pass_name: str, - ir: ModelIR | DistributedTaskIR | PortablePlanIR, + ir: CanonicalIRMixin, duration_ns: int, ) -> IRStageReport: """Build an inspectable report for one verified derivation boundary.""" @@ -118,8 +121,10 @@ def stage_report( node_count, value_count = len(ir.operations), len(ir.values) elif isinstance(ir, DistributedTaskIR): node_count, value_count = len(ir.tasks), len(ir.values) - else: + elif isinstance(ir, PortablePlanIR): node_count, value_count = len(ir.tasks), len(ir.buffers) + else: + raise TypeError(f"application stage reports do not support {type(ir).__name__}") return IRStageReport( stage=stage, label=label, diff --git a/src/blueprinting/contracts.py b/src/blueprinting/contracts.py new file mode 100644 index 0000000..71bb3e4 --- /dev/null +++ b/src/blueprinting/contracts.py @@ -0,0 +1,204 @@ +"""Runtime contract compiler for Blueprinting's typed Python declarations. + +This module deliberately has no dependency on mypy. It compiles the same +canonical record, closed ADT, and derivation declarations used by ordinary +runtime execution into one deterministic manifest. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .schema.contracts import TypeUniverse, compile_type_universe +from .schema.diagnostics import DiagnosticBag +from .schema.result import Checked, Err, Ok, checked +from .synthesizer.passes.deriving import pass_contract_manifest + + +@dataclass(frozen=True, order=True) +class DerivationContractDeclaration: + """Stable projection of one loaded pass contract.""" + + name: str + revision: str + python_type: str + input_schema: str + output_schema: str + normalizer: str | None + rules: tuple[str, ...] + digest: str + + +@dataclass(frozen=True) +class RuntimeContractManifest: + """Complete declaration universe checked without a static analyzer.""" + + types: TypeUniverse + derivations: tuple[DerivationContractDeclaration, ...] + + @property + def digest(self) -> str: + from .schema.codec import content_digest + + return content_digest( + ( + self.types.digest, + tuple( + ( + item.name, + item.revision, + item.python_type, + item.input_schema, + item.output_schema, + item.normalizer, + item.rules, + item.digest, + ) + for item in self.derivations + ), + ), + "runtime-contract-manifest", + ) + + +def _qualified_name(value: type[object]) -> str: + return f"{value.__module__}.{value.__qualname__}" + + +def _load_builtin_declarations() -> None: + """Import declaration owners explicitly; imports are registration only.""" + + from . import mapping as _mapping + from .analysis.cost import protocol as _cost_protocol + from .synthesizer import expr as _expr + from .synthesizer.stages.concrete_plan import ir as _concrete_ir + from .synthesizer.stages.concrete_plan import passes as _concrete_passes + from .synthesizer.stages.distributed import ir as _distributed_ir + from .synthesizer.stages.distributed import passes as _distributed_passes + from .synthesizer.stages.machine import ir as _machine_ir + from .synthesizer.stages.model import ir as _model_ir + from .synthesizer.stages.portable_plan import ir as _portable_ir + from .synthesizer.stages.portable_plan import passes as _portable_passes + + _ = ( + _mapping, + _cost_protocol, + _expr, + _concrete_ir, + _concrete_passes, + _distributed_ir, + _distributed_passes, + _machine_ir, + _model_ir, + _portable_ir, + _portable_passes, + ) + + +class ContractCompiler: + """Compile and validate loaded runtime type and derivation declarations.""" + + def __init__(self, *, load_builtins: bool = True) -> None: + self.load_builtins = load_builtins + + def compile(self) -> Checked[RuntimeContractManifest]: + if self.load_builtins: + _load_builtin_declarations() + + bag = DiagnosticBag() + declarations: list[DerivationContractDeclaration] = [] + identities: dict[tuple[str, str], str] = {} + names: dict[str, str] = {} + for pass_type, contract in pass_contract_manifest(): + python_type = _qualified_name(pass_type) + identity = (contract.name, contract.revision) + previous = identities.get(identity) + if previous is not None and previous != python_type: + bag.error( + "contract.pass.duplicate_identity", + f"{contract.name}@{contract.revision} is declared by both {previous} and {python_type}", + "pass", + contract.name, + ) + identities[identity] = python_type + previous_revision = names.get(contract.name) + if previous_revision is not None and previous_revision != contract.revision: + bag.error( + "contract.pass.multiple_revisions", + f"runtime loaded revisions {previous_revision!r} and {contract.revision!r}", + "pass", + contract.name, + ) + names[contract.name] = contract.revision + if not contract.verification.verifies_output: + bag.error( + "contract.pass.output_not_verified", + "a committed derivation must structurally verify its output", + "pass", + contract.name, + ) + if contract.input_type is not contract.output_type: + if not contract.rules: + bag.error( + "contract.pass.missing_relations", + "a cross-stage derivation must declare typed lineage relations", + "pass", + contract.name, + ) + incomplete = tuple( + rule.transform for rule in contract.rules if rule.verifier is None and not rule.preserves + ) + if incomplete: + bag.error( + "contract.pass.missing_invariants", + f"cross-stage relations lack independent executable invariants: {', '.join(incomplete)}", + "pass", + contract.name, + ) + if contract.normalizer is None: + bag.error( + "contract.pass.missing_normal_form", + "a cross-stage derivation must declare a complete executable normal form", + "pass", + contract.name, + ) + declarations.append( + DerivationContractDeclaration( + contract.name, + contract.revision, + python_type, + f"{contract.input_type.SCHEMA_NAME}@{contract.input_schema.minimum}", + f"{contract.output_type.SCHEMA_NAME}@{contract.output_schema}", + contract.normalizer_identity, + tuple(rule.transform for rule in contract.rules), + contract.digest, + ) + ) + + type_result = compile_type_universe(additional=bag.report()) + if isinstance(type_result, Err): + return type_result + assert isinstance(type_result, Ok) + manifest = RuntimeContractManifest(type_result.value, tuple(declarations)) + return checked(manifest, type_result.diagnostics) + + +def compile_runtime_contracts() -> Checked[RuntimeContractManifest]: + """Compile the built-in declarations using the mypy-independent layer.""" + + return ContractCompiler().compile() + + +def require_runtime_contracts() -> RuntimeContractManifest: + """Exception adapter intended only for CLI and CI process boundaries.""" + + return compile_runtime_contracts().or_raise() + + +__all__ = [ + "ContractCompiler", + "DerivationContractDeclaration", + "RuntimeContractManifest", + "compile_runtime_contracts", + "require_runtime_contracts", +] diff --git a/src/blueprinting/fp/__init__.py b/src/blueprinting/fp/__init__.py deleted file mode 100755 index 4fedc30..0000000 --- a/src/blueprinting/fp/__init__.py +++ /dev/null @@ -1,91 +0,0 @@ -import copy - - -def float_point_values_table(sign_bit=True, exponent_bits=5, mantissa_bits=2, draw=False): - assert exponent_bits > 0, ( - "Exponent bit amount cannot be zero or negative. A float must have at least 1 exponent bit, or else it's just an integer, loses Inf/NaN, etc." - ) - assert mantissa_bits >= 0, "Mantissa bit amount cannot be negative. However, mantissa is allowed to have zero bits." - mantissa_base = _generate_mantissa_base(mantissa_bits) - table_cell_rows = _generate_table_data_cells(sign_bit, mantissa_base, exponent_bits) - if draw: - from IPython.display import Markdown, display - - md = _format_pretty_table(table_cell_rows, sign_bit, exponent_bits, mantissa_bits) - display(Markdown(md)) - float_point_values = [float(x) for x in sum(table_cell_rows, [])] - float_point_values = [x for x in float_point_values if x == x and x not in (float("inf"), float("-inf"))] - float_point_values.sort() - return float_point_values - - -def _format_pretty_table(table_cell_rows, sign_bit, exponent_bits, mantissa_bits) -> str: - nrow = len(table_cell_rows) - ncol = len(table_cell_rows[0]) - output_text = "| |" - output_text += "|".join(["… " + _int_to_bits(i, mantissa_bits) for i in range(ncol)]) - output_text += "|\n" - output_text += "|---|{}|\n".format("|".join(["---"] * ncol)) - for i in range(nrow): - output_text += "|" - if sign_bit: - output_text += "0 " if i < nrow // 2 else "1 " - output_text += _int_to_bits(i, exponent_bits) + " …|" - row = table_cell_rows[i] - output_text += "|".join(row) - output_text += "|\n" - return output_text - - -def _generate_table_data_cells(sign_bit, mantissa_base, exponent_bits): - rows: list[list] = [] - exponent_bias = int(pow(2, exponent_bits - 1)) - 1 - exponent_range: int = int(pow(2, exponent_bits)) - 1 - # Generate the main rows. - for i in range(exponent_range): - row = [] - rows.append(row) - exponent = i - exponent_bias - exponent += 1 if i == 0 else 0 - multiplier = pow(2, exponent) - for j in range(len(mantissa_base)): - mantissa_number = mantissa_base[j] - mantissa_number -= 1.0 if i == 0 else 0.0 - stringified_number = str(mantissa_number * multiplier) - row.append(stringified_number) - # Add the infinity/NaN row. - inf_nan_row: list = ["Inf"] - for _i in range(len(mantissa_base) - 1): - inf_nan_row.append("NaN") - rows.append(inf_nan_row) - # If there is a sign bit, append a duplicate of every row, with a minus sign. - if sign_bit: - for row in copy.deepcopy(rows): - for cell_index in range(len(row)): - row[cell_index] = "-" + row[cell_index] - rows.append(row) - return rows - - -def _generate_mantissa_base(bits_amount: int): - base = [] - step = pow(2, -bits_amount) - value = 1.0 - while True: - base.append(value) - value += step - if value >= 2.0: - break - return base - - -def _int_to_bits(number: int, bits_amount: int) -> str: - ret: str = "" - digit_value: int = 1 - for _i in range(bits_amount): - if number & digit_value == 0: - ret = "0" + ret - else: - ret = "1" + ret - digit_value *= 2 - return ret diff --git a/src/blueprinting/mapping/__init__.py b/src/blueprinting/mapping/__init__.py index 51e7919..303b6aa 100644 --- a/src/blueprinting/mapping/__init__.py +++ b/src/blueprinting/mapping/__init__.py @@ -2,16 +2,40 @@ from .network import NetworkTierBinding from .transformer import ( + DataParallel, + ForwardOnly, + InterleavedOneForwardOneBackward, + OneForwardOneBackward, + PipelineParallel, + PipelineSchedule, + PipelineScheduleVariant, RecomputePolicy, + ReplicaParallel, + SingleStage, + TensorParallel, TensorParallelCommunication, TransformerInferenceMappingSpec, + TransformerInferenceParallelism, TransformerTrainingMappingSpec, + TransformerTrainingParallelism, ) __all__ = [ + "DataParallel", + "ForwardOnly", + "InterleavedOneForwardOneBackward", "NetworkTierBinding", + "OneForwardOneBackward", + "PipelineParallel", + "PipelineSchedule", + "PipelineScheduleVariant", "RecomputePolicy", + "ReplicaParallel", + "SingleStage", + "TensorParallel", "TensorParallelCommunication", "TransformerInferenceMappingSpec", + "TransformerInferenceParallelism", "TransformerTrainingMappingSpec", + "TransformerTrainingParallelism", ] diff --git a/src/blueprinting/mapping/network.py b/src/blueprinting/mapping/network.py index 417ba52..1e36c1a 100644 --- a/src/blueprinting/mapping/network.py +++ b/src/blueprinting/mapping/network.py @@ -3,26 +3,24 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass from typing import Any -from blueprinting.schema.codec import record_type +from blueprinting.schema.authoring import NonNegativeInt, record def _tier(value: Any, name: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ValueError(f"{name} must be a non-negative integer") - return value + return int(value) def _aliased_tier(data: Mapping[str, Any], canonical: str, legacy: str) -> int: if canonical in data and legacy in data and data[canonical] != data[legacy]: raise ValueError(f"{canonical} conflicts with legacy alias {legacy}") - return data.get(canonical, data.get(legacy, 0)) + return _tier(data.get(canonical, data.get(legacy, 0)), canonical) -@record_type("blueprinting.mapping.network-tier-binding.v1") -@dataclass(frozen=True) +@record("blueprinting.mapping.network-tier-binding") class NetworkTierBinding: """Map logical parallel domains to ordered tiers of one bound system. @@ -31,13 +29,9 @@ class NetworkTierBinding: :class:`~blueprinting.system.SystemProfile`. """ - tensor_parallel: int = 0 - pipeline_parallel: int = 0 - data_parallel: int = 0 - - def __post_init__(self) -> None: - for name in ("tensor_parallel", "pipeline_parallel", "data_parallel"): - _tier(getattr(self, name), name) + tensor_parallel: NonNegativeInt = 0 + pipeline_parallel: NonNegativeInt = 0 + data_parallel: NonNegativeInt = 0 @classmethod def from_mapping(cls, data: Mapping[str, Any]) -> NetworkTierBinding: diff --git a/src/blueprinting/mapping/transformer.py b/src/blueprinting/mapping/transformer.py index 7dfc21a..8f53761 100644 --- a/src/blueprinting/mapping/transformer.py +++ b/src/blueprinting/mapping/transformer.py @@ -3,19 +3,21 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass from enum import Enum from typing import Any -from blueprinting.schema.codec import enum_type, record_type -from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec - - -def _positive_integer(value: Any, name: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ValueError(f"{name} must be a positive integer") - return value +from typing_extensions import assert_never +from blueprinting.schema.authoring import ( + AtLeastTwoInt, + PositiveInt, + adt, + enum, + record, + seal_adt, + variant, +) +from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec _MISSING = object() @@ -32,48 +34,169 @@ def _field(data: Mapping[str, Any], canonical: str, legacy: str, default: Any = return default -@enum_type("compiler.transformer.recompute_policy") +@enum("blueprinting.mapping.recompute-policy") class RecomputePolicy(Enum): NONE = "none" ATTENTION = "attn_only" FULL = "full" -@enum_type("compiler.transformer.tp_communication") +@enum("blueprinting.mapping.tensor-parallel-communication") class TensorParallelCommunication(Enum): ALL_REDUCE = "ar" REDUCE_SCATTER_ALL_GATHER = "rs_ag" -@record_type("blueprinting.mapping.transformer-training.v1") -@dataclass(frozen=True) +@adt(wire="blueprinting.mapping.pipeline-schedule") +class PipelineSchedule: + """Closed family of pipeline execution schedules.""" + + +@variant("single-stage") +class SingleStage(PipelineSchedule): + """No inter-stage pipeline because the pipeline degree is one.""" + + +@variant("one-f-one-b") +class OneForwardOneBackward(PipelineSchedule): + """Non-interleaved 1F1B training schedule.""" + + +@variant("interleaved-one-f-one-b") +class InterleavedOneForwardOneBackward(PipelineSchedule): + """Megatron-style interleaved 1F1B with virtual pipeline stages.""" + + virtual_stages: AtLeastTwoInt + + +@variant("forward-only") +class ForwardOnly(PipelineSchedule): + """Static inference pipeline with no backward wave.""" + + +PipelineScheduleVariant = SingleStage | OneForwardOneBackward | InterleavedOneForwardOneBackward | ForwardOnly +seal_adt(PipelineSchedule, PipelineScheduleVariant) + + +@record("blueprinting.mapping.tensor-parallel") +class TensorParallel: + degree: PositiveInt + communication: TensorParallelCommunication + + @property + def sequence_parallel(self) -> bool: + return self.communication is TensorParallelCommunication.REDUCE_SCATTER_ALL_GATHER + + +@record("blueprinting.mapping.pipeline-parallel") +class PipelineParallel: + degree: PositiveInt + schedule: PipelineScheduleVariant + + def __post_init__(self) -> None: + if self.degree == 1 and not isinstance(self.schedule, SingleStage): + raise ValueError("pipeline degree one requires SingleStage") + if self.degree > 1 and isinstance(self.schedule, SingleStage): + raise ValueError("pipeline degree greater than one requires a pipeline schedule") + + +@record("blueprinting.mapping.data-parallel") +class DataParallel: + degree: PositiveInt + optimizer_sharding: bool = False + + def __post_init__(self) -> None: + if self.optimizer_sharding and self.degree == 1: + raise ValueError("optimizer sharding requires data parallel degree greater than one") + + +@record("blueprinting.mapping.replica-parallel") +class ReplicaParallel: + degree: PositiveInt + + +@record("blueprinting.mapping.transformer-training-strategy") +class TransformerTrainingParallelism: + tensor: TensorParallel + pipeline: PipelineParallel + data: DataParallel + recompute: RecomputePolicy + + def __post_init__(self) -> None: + if isinstance(self.pipeline.schedule, ForwardOnly): + raise ValueError("forward-only schedule is invalid for training") + + +@record("blueprinting.mapping.transformer-inference-strategy") +class TransformerInferenceParallelism: + tensor: TensorParallel + pipeline: PipelineParallel + replicas: ReplicaParallel + + def __post_init__(self) -> None: + if not isinstance(self.pipeline.schedule, (SingleStage, ForwardOnly)): + raise ValueError("inference requires a single-stage or forward-only schedule") + + +def _pipeline_interleaving(schedule: PipelineScheduleVariant) -> int: + match schedule: + case InterleavedOneForwardOneBackward(virtual_stages): + return virtual_stages + case SingleStage() | OneForwardOneBackward() | ForwardOnly(): + return 1 + assert_never(schedule) + + +def _training_schedule(pipeline_degree: int, virtual_stages: int) -> PipelineScheduleVariant: + if pipeline_degree == 1: + if virtual_stages != 1: + raise ValueError("pipeline interleaving requires pipeline_parallel > 1") + return SingleStage() + if virtual_stages == 1: + return OneForwardOneBackward() + return InterleavedOneForwardOneBackward(virtual_stages) + + +@record("blueprinting.mapping.transformer-training") class TransformerTrainingMappingSpec: """Target-neutral parallel and recomputation strategy for training.""" - tensor_parallel: int - pipeline_parallel: int - data_parallel: int - recompute: RecomputePolicy - pipeline_interleaving: int - optimizer_sharding: bool - tensor_parallel_communication: TensorParallelCommunication + parallelism: TransformerTrainingParallelism fused_activation: bool = False sequence_parallel_all_gather_redo: bool = False - def __post_init__(self) -> None: - for name in ("tensor_parallel", "pipeline_parallel", "data_parallel", "pipeline_interleaving"): - _positive_integer(getattr(self, name), name) - if not isinstance(self.recompute, RecomputePolicy): - raise TypeError("recompute must be a RecomputePolicy") - if not isinstance(self.tensor_parallel_communication, TensorParallelCommunication): - raise TypeError("tensor_parallel_communication must be TensorParallelCommunication") - if self.optimizer_sharding and self.data_parallel == 1: - raise ValueError("optimizer sharding requires data_parallel > 1") - @property def world_size(self) -> int: return self.tensor_parallel * self.pipeline_parallel * self.data_parallel + @property + def tensor_parallel(self) -> int: + return self.parallelism.tensor.degree + + @property + def pipeline_parallel(self) -> int: + return self.parallelism.pipeline.degree + + @property + def data_parallel(self) -> int: + return self.parallelism.data.degree + + @property + def recompute(self) -> RecomputePolicy: + return self.parallelism.recompute + + @property + def pipeline_interleaving(self) -> int: + return _pipeline_interleaving(self.parallelism.pipeline.schedule) + + @property + def optimizer_sharding(self) -> bool: + return self.parallelism.data.optimizer_sharding + + @property + def tensor_parallel_communication(self) -> TensorParallelCommunication: + return self.parallelism.tensor.communication + def validate_workload(self, workload: TransformerTrainingWorkloadSpec) -> None: if not isinstance(workload, TransformerTrainingWorkloadSpec): raise TypeError("workload must be TransformerTrainingWorkloadSpec") @@ -95,6 +218,15 @@ def validate_model(self, model: TransformerModelSpec) -> None: for name, value in divisibility.items(): if value % self.tensor_parallel: raise ValueError(f"{name} must be divisible by tensor_parallel") + if self.pipeline_parallel > model.block_count: + raise ValueError("pipeline_parallel cannot exceed block_count") + if model.block_count % self.pipeline_parallel: + raise ValueError("pipeline_parallel must divide block_count for static planning") + blocks_per_stage = model.block_count // self.pipeline_parallel + if self.pipeline_interleaving > blocks_per_stage: + raise ValueError("pipeline_interleaving cannot exceed blocks per pipeline stage") + if blocks_per_stage % self.pipeline_interleaving: + raise ValueError("pipeline_interleaving must divide blocks per pipeline stage") def local_batch_size(self, workload: TransformerTrainingWorkloadSpec) -> int: return workload.global_batch_size // self.data_parallel @@ -119,38 +251,47 @@ def from_mapping(cls, data: Mapping[str, Any]) -> TransformerTrainingMappingSpec raise ValueError("overlap requires concrete scheduling and is outside this mapping contract") if any(data.get(name, False) for name in ("weight_offload", "activations_offload", "optimizer_offload")): raise ValueError("offload strategies are outside this mapping contract") - return cls( - tensor_parallel=tensor_parallel, - pipeline_parallel=pipeline_parallel, - data_parallel=data_parallel, - recompute=RecomputePolicy(_field(data, "recompute", "activation_recompute")), - pipeline_interleaving=data["pipeline_interleaving"], - optimizer_sharding=data["optimizer_sharding"], - tensor_parallel_communication=TensorParallelCommunication( - _field(data, "tensor_parallel_communication", "tensor_par_comm_type") + parallelism = TransformerTrainingParallelism( + TensorParallel( + tensor_parallel, + TensorParallelCommunication(_field(data, "tensor_parallel_communication", "tensor_par_comm_type")), ), + PipelineParallel( + pipeline_parallel, + _training_schedule(pipeline_parallel, data["pipeline_interleaving"]), + ), + DataParallel(data_parallel, data["optimizer_sharding"]), + RecomputePolicy(_field(data, "recompute", "activation_recompute")), + ) + return cls( + parallelism=parallelism, fused_activation=data.get("fused_activation", False), sequence_parallel_all_gather_redo=data.get("seq_par_ag_redo", False), ) -@record_type("blueprinting.mapping.transformer-inference.v1") -@dataclass(frozen=True) +@record("blueprinting.mapping.transformer-inference") class TransformerInferenceMappingSpec: """Target-neutral logical mapping for an inference replica.""" - tensor_parallel: int - pipeline_parallel: int - replicas: int - - def __post_init__(self) -> None: - for name in ("tensor_parallel", "pipeline_parallel", "replicas"): - _positive_integer(getattr(self, name), name) + parallelism: TransformerInferenceParallelism @property def world_size(self) -> int: return self.tensor_parallel * self.pipeline_parallel * self.replicas + @property + def tensor_parallel(self) -> int: + return self.parallelism.tensor.degree + + @property + def pipeline_parallel(self) -> int: + return self.parallelism.pipeline.degree + + @property + def replicas(self) -> int: + return self.parallelism.replicas.degree + def validate_model(self, model: TransformerModelSpec) -> None: divisibility = { "hidden_size": model.hidden_size, @@ -173,8 +314,11 @@ def from_mapping(cls, data: Mapping[str, Any]) -> TransformerInferenceMappingSpe expected_world_size = tensor_parallel * pipeline_parallel * replicas if data.get("num_procs", expected_world_size) != expected_world_size: raise ValueError("num_procs must equal tensor_parallel * pipeline_parallel * replicas") + schedule: PipelineScheduleVariant = SingleStage() if pipeline_parallel == 1 else ForwardOnly() return cls( - tensor_parallel=tensor_parallel, - pipeline_parallel=pipeline_parallel, - replicas=replicas, + TransformerInferenceParallelism( + TensorParallel(tensor_parallel, TensorParallelCommunication.ALL_REDUCE), + PipelineParallel(pipeline_parallel, schedule), + ReplicaParallel(replicas), + ) ) diff --git a/src/blueprinting/py.typed b/src/blueprinting/py.typed new file mode 100644 index 0000000..e7f1357 --- /dev/null +++ b/src/blueprinting/py.typed @@ -0,0 +1 @@ +# PEP 561 marker: Blueprinting publishes inline type information. diff --git a/src/blueprinting/schema/__init__.py b/src/blueprinting/schema/__init__.py index 2f409af..ea7c871 100644 --- a/src/blueprinting/schema/__init__.py +++ b/src/blueprinting/schema/__init__.py @@ -4,21 +4,55 @@ analysis contracts may depend on it; it must not import any of those packages. """ -from blueprinting.schema.codec import canonical_dumps, canonical_loads, content_digest, enum_type, record_type +from blueprinting.schema.codec import ( + canonical_decode, + canonical_dump_raw, + canonical_dumps, + canonical_loads, + canonical_parse, + content_digest, + raw_content_digest, +) from blueprinting.schema.frozen import EMPTY_MAP, FrozenDict, freeze, thaw +from .contracts import TypeUniverse, compile_type_universe +from .diagnostics import ( + EMPTY_DIAGNOSTICS, + Diagnostic, + DiagnosticBag, + DiagnosticError, + DiagnosticSet, + Severity, +) from .errors import SchemaError, SerializationError +from .result import Checked, Err, Ok, Result, checked, collect_results __all__ = [ "EMPTY_MAP", "FrozenDict", + "Checked", + "Diagnostic", + "DiagnosticBag", + "DiagnosticError", + "DiagnosticSet", + "EMPTY_DIAGNOSTICS", + "Err", + "Ok", + "Result", "SchemaError", "SerializationError", + "Severity", + "TypeUniverse", "canonical_dumps", + "canonical_decode", + "canonical_dump_raw", "canonical_loads", + "canonical_parse", "content_digest", - "enum_type", + "checked", + "collect_results", + "compile_type_universe", "freeze", - "record_type", + "raw_content_digest", "thaw", ] diff --git a/src/blueprinting/schema/authoring.py b/src/blueprinting/schema/authoring.py new file mode 100644 index 0000000..24d6189 --- /dev/null +++ b/src/blueprinting/schema/authoring.py @@ -0,0 +1,68 @@ +"""Expert authoring surface for canonical records and closed algebraic data. + +Application users do not need these helpers. They are intentionally grouped +here for core schema and trusted dialect authors; registry and manifest +implementation details remain in :mod:`blueprinting.schema.deriving`. +""" + +from .deriving import ( + ADTSpec, + ValueConstraint, + VariantSpec, + adt, + adt_manifest, + enum, + is_adt_variant, + record, + require_adt_variant, + seal_adt, + variant, +) +from .refinements import ( + AtLeastTwoInt, + CanonicalLowerText, + ContentDigest, + FiniteFloat, + NonBlankText, + NonEmptyText, + NonNegativeFiniteFloat, + NonNegativeFiniteNumber, + NonNegativeInt, + PositiveFiniteFloat, + PositiveFiniteNumber, + PositiveInt, + PositiveUnitIntervalNumber, + StableName, + SymbolName, + UnitIntervalNumber, +) + +__all__ = [ + "ADTSpec", + "AtLeastTwoInt", + "CanonicalLowerText", + "ContentDigest", + "FiniteFloat", + "NonBlankText", + "NonEmptyText", + "NonNegativeFiniteFloat", + "NonNegativeFiniteNumber", + "NonNegativeInt", + "PositiveFiniteFloat", + "PositiveFiniteNumber", + "PositiveInt", + "PositiveUnitIntervalNumber", + "StableName", + "SymbolName", + "UnitIntervalNumber", + "ValueConstraint", + "VariantSpec", + "adt", + "adt_manifest", + "enum", + "is_adt_variant", + "record", + "require_adt_variant", + "seal_adt", + "variant", +] diff --git a/src/blueprinting/schema/codec.py b/src/blueprinting/schema/codec.py index b524107..b58e8a2 100644 --- a/src/blueprinting/schema/codec.py +++ b/src/blueprinting/schema/codec.py @@ -18,33 +18,21 @@ from typing import Any, TypeVar from .errors import SerializationError +from .frozen import FrozenDict T = TypeVar("T") _RECORD_TYPES: dict[str, type[Any]] = {} _RECORD_TAGS: dict[type[Any], str] = {} -_RECORD_FIELD_ALIASES: dict[str, dict[str, str]] = {} _ENUM_TYPES: dict[str, type[Enum]] = {} _ENUM_TAGS: dict[type[Enum], str] = {} -def record_type( - tag: str, - *, - field_aliases: Mapping[str, str] | None = None, -) -> Callable[[type[T]], type[T]]: - """Register a frozen dataclass and optional legacy field aliases.""" +def record_type(tag: str) -> Callable[[type[T]], type[T]]: + """Register a frozen dataclass under one canonical semantic identity.""" if not isinstance(tag, str) or not tag: raise TypeError("canonical record tag must be a non-empty string") - aliases = dict(field_aliases or {}) - if any( - not isinstance(legacy, str) or not legacy or not isinstance(current, str) or not current or legacy == current - for legacy, current in aliases.items() - ): - raise TypeError("canonical field aliases must map distinct non-empty strings") - if len(set(aliases.values())) != len(aliases): - raise TypeError("canonical field aliases must have unique destinations") def decorate(cls: type[T]) -> type[T]: if not is_dataclass(cls): @@ -52,24 +40,11 @@ def decorate(cls: type[T]) -> type[T]: parameters = getattr(cls, "__dataclass_params__", None) if parameters is None or not parameters.frozen: raise TypeError(f"canonical record {cls.__name__} must be frozen") - init_fields = {item.name for item in fields(cls) if item.init} - unknown_destinations = set(aliases.values()) - init_fields - if unknown_destinations: - rendered = ", ".join(sorted(unknown_destinations)) - raise TypeError(f"canonical field aliases target unknown fields: {rendered}") - conflicting_sources = set(aliases) & init_fields - if conflicting_sources: - rendered = ", ".join(sorted(conflicting_sources)) - raise TypeError(f"canonical field aliases shadow current fields: {rendered}") previous = _RECORD_TYPES.get(tag) if previous is not None and previous is not cls: raise RuntimeError(f"canonical record tag {tag!r} is already registered") - previous_aliases = _RECORD_FIELD_ALIASES.get(tag) - if previous_aliases is not None and previous_aliases != aliases: - raise RuntimeError(f"canonical record tag {tag!r} has conflicting field aliases") _RECORD_TYPES[tag] = cls _RECORD_TAGS[cls] = tag - _RECORD_FIELD_ALIASES[tag] = aliases return cls return decorate @@ -87,8 +62,8 @@ def decorate(cls: type[T]) -> type[T]: previous = _ENUM_TYPES.get(tag) if previous is not None and previous is not cls: raise RuntimeError(f"canonical enum tag {tag!r} is already registered") - _ENUM_TYPES[tag] = cls # type: ignore[assignment] - _ENUM_TAGS[cls] = tag # type: ignore[index] + _ENUM_TYPES[tag] = cls + _ENUM_TAGS[cls] = tag return cls return decorate @@ -178,14 +153,16 @@ def _decode(value: Any) -> Any: items = value["$map"] if not isinstance(items, list): raise SerializationError("canonical mapping payload must be an array") - result = {} + result = [] + keys = set() for pair in items: if not isinstance(pair, list) or len(pair) != 2 or not isinstance(pair[0], str): raise SerializationError("invalid canonical mapping entry") - if pair[0] in result: + if pair[0] in keys: raise SerializationError(f"duplicate canonical mapping key: {pair[0]!r}") - result[pair[0]] = _decode(pair[1]) - return result + keys.add(pair[0]) + result.append((pair[0], _decode(pair[1]))) + return FrozenDict(items=result) if set(value) == {"$enum", "value"}: tag = value["$enum"] @@ -210,15 +187,11 @@ def _decode(value: Any) -> Any: if not isinstance(payload, dict): raise SerializationError(f"fields for canonical record {tag!r} must be an object") try: - aliases = _RECORD_FIELD_ALIASES.get(tag, {}) - decoded = {} + decoded: dict[str, Any] = {} for name, item in payload.items(): - current_name = aliases.get(name, name) - if current_name in decoded: - raise SerializationError( - f"canonical record {tag!r} supplies both a current field and its legacy alias: {current_name!r}" - ) - decoded[current_name] = _decode(item) + if not isinstance(name, str): + raise SerializationError(f"field names for canonical record {tag!r} must be strings") + decoded[name] = _decode(item) return record_cls(**decoded) except SerializationError: raise @@ -237,6 +210,12 @@ def canonical_dumps(value: Any) -> str: def canonical_loads(payload: str) -> Any: """Deserialize canonical JSON using the closed-world type registry.""" + return canonical_decode(canonical_parse(payload)) + + +def canonical_parse(payload: str) -> Any: + """Parse canonical JSON without constructing registered Python records.""" + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result = {} for key, value in pairs: @@ -258,9 +237,21 @@ def reject_nonfinite_constant(value: str) -> Any: raise except (TypeError, ValueError) as error: raise SerializationError("invalid canonical JSON") from error + return raw + + +def canonical_decode(raw: Any) -> Any: + """Decode an already duplicate-checked canonical JSON tree.""" + return _decode(raw) +def canonical_dump_raw(raw: Any) -> str: + """Serialize a raw canonical JSON tree deterministically.""" + + return json.dumps(raw, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + def content_digest(value: Any, domain: str = "blueprinting") -> str: """Return a domain-separated BLAKE2 digest of a canonical value.""" @@ -269,3 +260,21 @@ def content_digest(value: Any, domain: str = "blueprinting") -> str: hasher.update(b"\x00") hasher.update(canonical_dumps(value).encode("utf-8")) return hasher.hexdigest() + + +def canonical_type_manifest() -> tuple[tuple[str, str, type[Any]], ...]: + """Return deterministic record/enum registrations for contract compilation.""" + + records = tuple(("record", tag, value) for tag, value in _RECORD_TYPES.items()) + enums = tuple(("enum", tag, value) for tag, value in _ENUM_TYPES.items()) + return tuple(sorted(records + enums, key=lambda item: (item[0], item[1]))) + + +def raw_content_digest(raw: Any, domain: str = "blueprinting") -> str: + """Digest an already parsed canonical node without constructing its Python record.""" + + hasher = hashlib.blake2b(digest_size=20) + hasher.update(domain.encode("utf-8")) + hasher.update(b"\x00") + hasher.update(canonical_dump_raw(raw).encode("utf-8")) + return hasher.hexdigest() diff --git a/src/blueprinting/schema/contracts.py b/src/blueprinting/schema/contracts.py new file mode 100644 index 0000000..b8626ec --- /dev/null +++ b/src/blueprinting/schema/contracts.py @@ -0,0 +1,310 @@ +"""Runtime compilation of canonical records and algebraic type declarations.""" + +from __future__ import annotations + +import types +from dataclasses import MISSING, Field, dataclass, fields, is_dataclass +from enum import Enum +from typing import Annotated, Any, ClassVar, Literal, TypeVar, Union, get_args, get_origin, get_type_hints + +from .codec import canonical_dumps, canonical_type_manifest, content_digest +from .deriving import ( + ADTSpec, + adt_closure, + adt_family_manifest, + adt_manifest, +) +from .diagnostics import Diagnostic, DiagnosticBag, DiagnosticSet +from .result import Checked, checked + + +@dataclass(frozen=True, order=True) +class CanonicalFieldContract: + name: str + annotation: str + init: bool + keyword_only: bool + default_kind: str + default_identity: str | None + default_value: str | None + + +@dataclass(frozen=True, order=True) +class CanonicalEnumMemberContract: + name: str + canonical_name: str + value: str + + +@dataclass(frozen=True, order=True) +class CanonicalTypeContract: + kind: str + wire: str + python_type: str + fields: tuple[CanonicalFieldContract, ...] = () + enum_members: tuple[CanonicalEnumMemberContract, ...] = () + + +@dataclass(frozen=True, order=True) +class AlgebraicFamilyContract: + wire: str + python_type: str + variants: tuple[str, ...] + sealed_variants: tuple[str, ...] + + +@dataclass(frozen=True) +class TypeUniverse: + """Immutable runtime view of the currently loaded typed algebraic declarations.""" + + canonical_types: tuple[CanonicalTypeContract, ...] + algebraic_families: tuple[AlgebraicFamilyContract, ...] + + @property + def digest(self) -> str: + return content_digest( + ( + tuple( + ( + item.kind, + item.wire, + item.python_type, + tuple( + ( + field.name, + field.annotation, + field.init, + field.keyword_only, + field.default_kind, + field.default_identity, + field.default_value, + ) + for field in item.fields + ), + tuple((member.name, member.canonical_name, member.value) for member in item.enum_members), + ) + for item in self.canonical_types + ), + tuple( + (item.wire, item.python_type, item.variants, item.sealed_variants) + for item in self.algebraic_families + ), + ), + "type-universe", + ) + + +def _type_name(value: type[Any]) -> str: + return f"{value.__module__}.{value.__qualname__}" + + +def _callable_name(value: Any) -> str: + module = getattr(value, "__module__", type(value).__module__) + qualname = getattr(value, "__qualname__", type(value).__qualname__) + return f"{module}.{qualname}" + + +def _annotation_shape(annotation: Any) -> str: + """Return a cross-version structural identity for one resolved annotation.""" + + if annotation is Any: + return "typing.Any" + if annotation is None or annotation is type(None): + return "builtins.None" + if annotation is Ellipsis: + return "builtins.Ellipsis" + if isinstance(annotation, TypeVar): + constraints = tuple(_annotation_shape(item) for item in annotation.__constraints__) + bound = _annotation_shape(annotation.__bound__) if annotation.__bound__ is not None else "" + return f"typevar[{annotation.__name__};{','.join(constraints)};{bound}]" + origin = get_origin(annotation) + arguments = get_args(annotation) + if origin in {types.UnionType, Union}: + return f"union[{','.join(sorted(_annotation_shape(item) for item in arguments))}]" + if origin is Annotated: + base, *metadata = arguments + rendered_metadata = ",".join(canonical_dumps(item) for item in metadata) + return f"annotated[{_annotation_shape(base)};{rendered_metadata}]" + if origin is Literal: + return f"literal[{','.join(canonical_dumps(item) for item in arguments)}]" + if origin is ClassVar: + return f"classvar[{_annotation_shape(arguments[0])}]" + if origin is not None: + origin_name = _type_name(origin) if isinstance(origin, type) else str(origin) + return f"{origin_name}[{','.join(_annotation_shape(item) for item in arguments)}]" + if isinstance(annotation, type): + return _type_name(annotation) + return str(annotation) + + +def _adt_roots_in(annotation: Any) -> tuple[type[Any], ...]: + roots = {item.root for item in adt_family_manifest()} + found: set[type[Any]] = set() + + def visit(item: Any) -> None: + if isinstance(item, type) and item in roots: + found.add(item) + return + origin = get_origin(item) + if origin is Annotated: + arguments = get_args(item) + if arguments: + visit(arguments[0]) + return + for argument in get_args(item): + visit(argument) + + visit(annotation) + return tuple(sorted(found, key=_type_name)) + + +def _field_default(field: Field[Any], bag: DiagnosticBag, wire: str) -> tuple[str, str | None, str | None]: + if field.default is not MISSING: + try: + return "value", None, canonical_dumps(field.default) + except Exception as error: + bag.error( + "contract.schema.invalid_default", + f"field {field.name!r} has a non-canonical default: {error}", + "type", + wire, + field.name, + ) + return "value", None, f"" + if field.default_factory is not MISSING: + factory = field.default_factory + identity = _callable_name(factory) + try: + first = canonical_dumps(factory()) + second = canonical_dumps(factory()) + if first != second: + raise ValueError("successive calls produced different canonical defaults") + return "factory", identity, first + except Exception as error: + bag.error( + "contract.schema.invalid_default_factory", + f"field {field.name!r} default factory {identity} is not deterministic/canonical: {error}", + "type", + wire, + field.name, + ) + return "factory", identity, "" + return "required", None, None + + +def _canonical_type_contract( + kind: str, + wire: str, + python_type: type[Any], + bag: DiagnosticBag, +) -> CanonicalTypeContract: + if kind == "record": + if not is_dataclass(python_type): + bag.error("contract.schema.not_dataclass", "registered record is not a dataclass", "type", wire) + return CanonicalTypeContract(kind, wire, _type_name(python_type)) + annotations = get_type_hints(python_type, include_extras=True) + field_contracts = [] + for field in fields(python_type): + if not field.init: + continue + annotation = annotations.get(field.name, Any) + roots = _adt_roots_in(annotation) + if roots: + bag.error( + "contract.schema.adt_root_field", + f"field {field.name!r} refers to abstract ADT root(s): " + f"{', '.join(_type_name(item) for item in roots)}; use the exact sealed union alias", + "type", + wire, + field.name, + ) + default_kind, default_identity, default_value = _field_default(field, bag, wire) + field_contracts.append( + CanonicalFieldContract( + field.name, + _annotation_shape(annotation), + field.init, + field.kw_only is True, + default_kind, + default_identity, + default_value, + ) + ) + return CanonicalTypeContract(kind, wire, _type_name(python_type), tuple(field_contracts)) + if kind == "enum": + if not issubclass(python_type, Enum): + bag.error("contract.schema.not_enum", "registered enum does not derive from Enum", "type", wire) + return CanonicalTypeContract(kind, wire, _type_name(python_type)) + members = tuple( + CanonicalEnumMemberContract(name, member.name, canonical_dumps(member.value)) + for name, member in python_type.__members__.items() + ) + return CanonicalTypeContract(kind, wire, _type_name(python_type), enum_members=members) + bag.error("contract.schema.unknown_kind", f"unknown canonical registration kind {kind!r}", "type", wire) + return CanonicalTypeContract(kind, wire, _type_name(python_type)) + + +def _family_contract(spec: ADTSpec, bag: DiagnosticBag) -> AlgebraicFamilyContract: + manifest = adt_manifest(spec.root) + variants = tuple(item.constructor for item in manifest) + closure = adt_closure(spec.root) + path = ("adt", spec.wire) + if closure is None: + bag.error( + "contract.adt.unsealed", + f"ADT family {_type_name(spec.root)} has no explicit Union closure", + *path, + hint="declare VariantAlias = A | B and call seal_adt(Family, VariantAlias)", + ) + closure = () + else: + missing = tuple(item for item in variants if item not in closure) + unknown = tuple(item for item in closure if item not in variants) + if missing: + bag.error( + "contract.adt.missing_variant", + f"sealed closure omits: {', '.join(_type_name(item) for item in missing)}", + *path, + ) + if unknown: + bag.error( + "contract.adt.unknown_variant", + f"sealed closure contains unregistered types: {', '.join(_type_name(item) for item in unknown)}", + *path, + ) + return AlgebraicFamilyContract( + spec.wire, + _type_name(spec.root), + tuple(f"{item.local_tag}:{item.wire_tag}:{_type_name(item.constructor)}" for item in manifest), + tuple(_type_name(item) for item in closure), + ) + + +def compile_type_universe(*, additional: DiagnosticSet = DiagnosticSet()) -> Checked[TypeUniverse]: + """Compile loaded runtime declarations without importing any static checker.""" + + bag = DiagnosticBag() + bag.extend(additional.diagnostics) + canonical_types = tuple( + _canonical_type_contract(kind, wire, python_type, bag) for kind, wire, python_type in canonical_type_manifest() + ) + families = tuple(_family_contract(item, bag) for item in adt_family_manifest()) + universe = TypeUniverse(canonical_types, families) + return checked(universe, bag.report()) + + +def contract_error(code: str, message: str, *path: str) -> Diagnostic: + """Small helper for domain-specific contract compiler contributors.""" + + return Diagnostic(code, message, tuple(path)) + + +__all__ = [ + "AlgebraicFamilyContract", + "CanonicalEnumMemberContract", + "CanonicalFieldContract", + "CanonicalTypeContract", + "TypeUniverse", + "compile_type_universe", + "contract_error", +] diff --git a/src/blueprinting/schema/deriving.py b/src/blueprinting/schema/deriving.py new file mode 100644 index 0000000..d420bd1 --- /dev/null +++ b/src/blueprinting/schema/deriving.py @@ -0,0 +1,394 @@ +"""Small, type-checker-visible deriving helpers for canonical algebraic data.""" + +from __future__ import annotations + +import math +import re +import types +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +from typing import Annotated, Any, ClassVar, Literal, TypeGuard, TypeVar, Union, get_args, get_origin, get_type_hints + +from typing_extensions import dataclass_transform + +from .codec import enum_type, record_type +from .frozen import FrozenDict + +T = TypeVar("T") + +_WIRE_RE = re.compile(r"[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*") +_LOCAL_TAG_RE = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*") + + +@enum_type("blueprinting.schema.value-constraint") +class ValueConstraint(Enum): + """Small closed vocabulary of reusable structural refinements.""" + + NON_EMPTY = "non_empty" + NON_BLANK = "non_blank" + AT_LEAST_TWO_ITEMS = "at_least_two_items" + UNIQUE_ITEMS = "unique_items" + NON_EMPTY_ITEMS = "non_empty_items" + NON_NEGATIVE = "non_negative" + NON_NEGATIVE_ITEMS = "non_negative_items" + POSITIVE = "positive" + AT_LEAST_TWO = "at_least_two" + AT_MOST_ONE = "at_most_one" + FINITE = "finite" + CANONICAL_LOWER_TEXT = "canonical_lower_text" + STABLE_NAME = "stable_name" + CONTENT_DIGEST = "content_digest" + SYMBOL_NAME = "symbol_name" + + +@dataclass(frozen=True) +class ADTSpec: + """One closed canonical sum-type family.""" + + wire: str + root: type[Any] + + @property + def prefix(self) -> str: + return self.wire + + def tag(self, local_tag: str) -> str: + return f"{self.prefix}.{local_tag}" + + +@dataclass(frozen=True) +class VariantSpec: + """Manifest entry for one explicitly named ADT constructor.""" + + family: ADTSpec + local_tag: str + wire_tag: str + constructor: type[Any] + + +_ADT_SPECS: dict[type[Any], ADTSpec] = {} +_VARIANT_SPECS: dict[type[Any], VariantSpec] = {} +_ADT_CLOSURES: dict[type[Any], tuple[type[Any], ...]] = {} +_ANNOTATION_CACHE: dict[type[Any], dict[str, Any]] = {} + + +def _structural_annotations(cls: type[Any]) -> dict[str, Any]: + """Resolve one record's annotations once, after its decorator has returned.""" + + annotations = _ANNOTATION_CACHE.get(cls) + if annotations is None: + annotations = get_type_hints(cls, include_extras=True) + _ANNOTATION_CACHE[cls] = annotations + return annotations + + +def _matches(value: Any, annotation: Any) -> bool: + if annotation is Any: + return True + if annotation in _VARIANT_SPECS: + return type(value) is annotation + origin = get_origin(annotation) + if origin is ClassVar: + return True + if origin is Annotated: + base, *metadata = get_args(annotation) + return _matches(value, base) and all( + not isinstance(constraint, ValueConstraint) or _matches_constraint(value, constraint) + for constraint in metadata + ) + if origin is Literal: + return any(type(value) is type(expected) and value == expected for expected in get_args(annotation)) + if origin in {types.UnionType, Union}: + return any(_matches(value, item) for item in get_args(annotation)) + if origin is tuple: + arguments = get_args(annotation) + if not isinstance(value, tuple): + return False + if len(arguments) == 2 and arguments[1] is Ellipsis: + return all(_matches(item, arguments[0]) for item in value) + return len(value) == len(arguments) and all( + _matches(item, expected) for item, expected in zip(value, arguments) + ) + if origin is frozenset: + arguments = get_args(annotation) + return isinstance(value, frozenset) and (not arguments or all(_matches(item, arguments[0]) for item in value)) + if origin is FrozenDict: + arguments = get_args(annotation) + return isinstance(value, FrozenDict) and ( + not arguments or all(_matches(item, arguments[0]) for item in value.values()) + ) + if origin in {dict, Mapping}: + arguments = get_args(annotation) + if not isinstance(value, Mapping): + return False + if len(arguments) != 2: + return True + key_type, value_type = arguments + return all(_matches(key, key_type) and _matches(item, value_type) for key, item in value.items()) + if origin in {list, Sequence}: + arguments = get_args(annotation) + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + return False + return not arguments or all(_matches(item, arguments[0]) for item in value) + if origin is not None: + try: + return isinstance(value, origin) + except TypeError: + return True + if annotation is int and isinstance(value, bool): + return False + try: + return isinstance(value, annotation) + except TypeError: + return True + + +def _matches_constraint(value: Any, constraint: ValueConstraint) -> bool: + try: + if constraint is ValueConstraint.NON_EMPTY: + return len(value) > 0 + if constraint is ValueConstraint.NON_BLANK: + return isinstance(value, str) and bool(value.strip()) + if constraint is ValueConstraint.AT_LEAST_TWO_ITEMS: + return len(value) >= 2 + if constraint is ValueConstraint.UNIQUE_ITEMS: + return len(set(value)) == len(value) + if constraint is ValueConstraint.NON_EMPTY_ITEMS: + return all(bool(item) for item in value) + if constraint is ValueConstraint.NON_NEGATIVE: + return bool(value >= 0) + if constraint is ValueConstraint.NON_NEGATIVE_ITEMS: + return all(item >= 0 for item in value) + if constraint is ValueConstraint.POSITIVE: + return bool(value > 0) + if constraint is ValueConstraint.AT_LEAST_TWO: + return bool(value >= 2) + if constraint is ValueConstraint.AT_MOST_ONE: + return bool(value <= 1) + if constraint is ValueConstraint.FINITE: + return not isinstance(value, float) or math.isfinite(value) + if constraint is ValueConstraint.CANONICAL_LOWER_TEXT: + return isinstance(value, str) and value == value.strip().lower() + if constraint is ValueConstraint.STABLE_NAME: + return isinstance(value, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_.-]*", value) is not None + if constraint is ValueConstraint.CONTENT_DIGEST: + return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{40}", value) is not None + if constraint is ValueConstraint.SYMBOL_NAME: + return isinstance(value, str) and bool(value) and value.replace("_", "a").isalnum() + except (TypeError, ValueError): + return False + return False + + +def _install_structural_post_init(cls: type[T]) -> None: + local_invariant = cls.__dict__.get("__post_init__") + invariants: list[Callable[[Any], None]] = [] + for base in cls.__bases__: + inherited = getattr(base, "__record_invariants__", None) + if inherited is not None: + invariants.extend(inherited) + continue + inherited_post_init = getattr(base, "__post_init__", None) + if inherited_post_init is not None: + invariants.append(inherited_post_init) + if local_invariant is not None: + invariants.append(local_invariant) + + unique_invariants = tuple(dict.fromkeys(invariants)) + setattr(cls, "__record_invariants__", unique_invariants) # noqa: B010 + + def structural_post_init(self: Any) -> None: + annotations = _structural_annotations(type(self)) + for name, annotation in annotations.items(): + if get_origin(annotation) is ClassVar: + continue + value = getattr(self, name) + if not _matches(value, annotation): + raise TypeError(f"{type(self).__name__}.{name} must match {annotation!r}, got {type(value).__name__}") + for invariant in unique_invariants: + invariant(self) + + setattr(cls, "__post_init__", structural_post_init) # noqa: B010 + + +@dataclass_transform(frozen_default=True) +def record( + tag: str, + *, + order: bool = False, +) -> Callable[[type[T]], type[T]]: + """Derive an immutable canonical record from an annotated class declaration.""" + + if not isinstance(tag, str) or _WIRE_RE.fullmatch(tag) is None: + raise TypeError("canonical record tag must be a dotted lowercase wire identity") + + def decorate(cls: type[T]) -> type[T]: + if "__dataclass_fields__" in cls.__dict__: + raise TypeError("@record derives its own frozen dataclass; do not combine it with @dataclass") + _install_structural_post_init(cls) + cls = dataclass(frozen=True, slots=True, order=order)(cls) + return record_type(tag)(cls) + + return decorate + + +def enum(tag: str) -> Callable[[type[T]], type[T]]: + """Register one closed enumeration through the public authoring surface.""" + + if not isinstance(tag, str) or _WIRE_RE.fullmatch(tag) is None: + raise TypeError("canonical enum tag must be a dotted lowercase wire identity") + return enum_type(tag) + + +@dataclass_transform(frozen_default=True) +def adt(*, wire: str) -> Callable[[type[T]], type[T]]: + """Declare the shared semantic wire namespace for a closed sum type.""" + + if not isinstance(wire, str) or _WIRE_RE.fullmatch(wire) is None: + raise TypeError("ADT wire namespace must be a dotted lowercase identity") + + def decorate(cls: type[T]) -> type[T]: + if "__dataclass_fields__" in cls.__dict__: + raise TypeError("@adt derives its own frozen dataclass; do not combine it with @dataclass") + cls = dataclass(frozen=True, slots=True)(cls) + if cls in _ADT_SPECS: + raise RuntimeError(f"ADT family {cls.__name__} is already registered") + if any(item.wire == wire for item in _ADT_SPECS.values()): + raise RuntimeError(f"ADT wire namespace {wire!r} is already registered") + spec = ADTSpec(wire, cls) + _ADT_SPECS[cls] = spec + setattr(cls, "__adt_spec__", spec) # noqa: B010 + + root = cls + + def adt_new(constructor: type[Any], *_args: Any, **_kwargs: Any) -> Any: + if constructor is root: + raise TypeError(f"ADT family {root.__name__} is abstract; instantiate one of its sealed variants") + return object.__new__(constructor) + + setattr(cls, "__new__", staticmethod(adt_new)) # noqa: B010 + return cls + + return decorate + + +def _family_of(cls: type[Any]) -> ADTSpec: + families = tuple(_ADT_SPECS[base] for base in cls.__bases__ if base in _ADT_SPECS) + if len(families) != 1: + raise TypeError("canonical variant must directly inherit from exactly one declared ADT family") + return families[0] + + +@dataclass_transform(frozen_default=True) +def variant(local_tag: str) -> Callable[[type[T]], type[T]]: + """Derive and register one explicitly named constructor of an ADT family.""" + + if not isinstance(local_tag, str) or _LOCAL_TAG_RE.fullmatch(local_tag) is None: + raise TypeError("variant tag must be a short lowercase kebab-case identity") + + def decorate(cls: type[T]) -> type[T]: + family = _family_of(cls) + if family.root in _ADT_CLOSURES: + raise RuntimeError(f"ADT family {family.root.__name__} is sealed and cannot accept late variants") + if any(item.family == family and item.local_tag == local_tag for item in _VARIANT_SPECS.values()): + raise RuntimeError(f"ADT family {family.root.__name__} already declares variant {local_tag!r}") + constructor = record(family.tag(local_tag))(cls) + spec = VariantSpec(family, local_tag, family.tag(local_tag), constructor) + _VARIANT_SPECS[constructor] = spec + setattr(constructor, "__variant_spec__", spec) # noqa: B010 + return constructor + + return decorate + + +def adt_manifest(family: type[Any]) -> tuple[VariantSpec, ...]: + """Return a deterministic documentation/schema manifest for one ADT family.""" + + spec = _ADT_SPECS.get(family) + if spec is None: + raise TypeError(f"{family.__name__} is not a declared ADT family") + return tuple( + sorted((item for item in _VARIANT_SPECS.values() if item.family == spec), key=lambda item: item.local_tag) + ) + + +def _union_members(variants: Any) -> tuple[type[Any], ...]: + origin = get_origin(variants) + members = get_args(variants) if origin in {types.UnionType, Union} else (variants,) + if not members or any(not isinstance(item, type) for item in members): + raise TypeError("an ADT closure must contain concrete constructor types") + return tuple(members) + + +def seal_adt(family: type[Any], variants: Any) -> Any: + """Declare the explicit runtime closure corresponding to a static Union alias.""" + + if family not in _ADT_SPECS: + raise TypeError(f"{getattr(family, '__name__', family)!r} is not a declared ADT family") + members = _union_members(variants) + if any(_VARIANT_SPECS.get(item) is None for item in members): + raise TypeError("an ADT closure may contain only registered variants") + if any(_VARIANT_SPECS[item].family.root is not family for item in members): + raise TypeError("an ADT closure cannot mix constructors from different families") + if len(set(members)) != len(members): + raise ValueError("an ADT closure cannot repeat a constructor") + registered = tuple(item.constructor for item in adt_manifest(family)) + if set(members) != set(registered): + missing = tuple(item.__name__ for item in registered if item not in members) + unknown = tuple(item.__name__ for item in members if item not in registered) + detail = [] + if missing: + detail.append(f"missing {', '.join(missing)}") + if unknown: + detail.append(f"unknown {', '.join(unknown)}") + raise ValueError(f"ADT closure must contain exactly its registered variants: {'; '.join(detail)}") + previous = _ADT_CLOSURES.get(family) + if previous is not None and previous != registered: + raise RuntimeError(f"ADT family {family.__name__} is already sealed with another closure") + _ADT_CLOSURES[family] = registered + return variants + + +def adt_closure(family: type[Any]) -> tuple[type[Any], ...] | None: + """Return the declared constructor closure, if the family has been sealed.""" + + return _ADT_CLOSURES.get(family) + + +def is_adt_variant(value: object, family: type[T]) -> TypeGuard[T]: + """Return whether ``value`` is an exact constructor in a sealed family.""" + + if family not in _ADT_SPECS: + raise TypeError(f"{getattr(family, '__name__', family)!r} is not a declared ADT family") + closure = _ADT_CLOSURES.get(family) + if closure is None: + raise TypeError(f"ADT family {family.__name__} is not sealed") + return type(value) in closure + + +def require_adt_variant(value: object, family: type[Any], subject: str = "value") -> None: + """Reject family roots, subclasses, and constructors outside the exact closure.""" + + if not is_adt_variant(value, family): + actual = f"{type(value).__module__}.{type(value).__qualname__}" + raise TypeError(f"{subject} must be an exact sealed variant of {family.__name__}, got {actual}") + + +def adt_family_manifest() -> tuple[ADTSpec, ...]: + return tuple(sorted(_ADT_SPECS.values(), key=lambda item: item.wire)) + + +__all__ = [ + "ADTSpec", + "VariantSpec", + "adt", + "adt_closure", + "adt_family_manifest", + "adt_manifest", + "is_adt_variant", + "record", + "require_adt_variant", + "seal_adt", + "variant", +] diff --git a/src/blueprinting/schema/diagnostics.py b/src/blueprinting/schema/diagnostics.py new file mode 100644 index 0000000..0119af7 --- /dev/null +++ b/src/blueprinting/schema/diagnostics.py @@ -0,0 +1,142 @@ +"""Domain-free diagnostics shared by schema, derivation, and applications.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from enum import Enum + + +class Severity(Enum): + """Stable diagnostic severity used across Blueprinting contracts.""" + + ERROR = "error" + WARNING = "warning" + + +@dataclass(frozen=True, order=True) +class Diagnostic: + """One stable, machine-readable contract diagnostic.""" + + code: str + message: str + path: tuple[str, ...] = () + severity: Severity = Severity.ERROR + hint: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.code, str) or not self.code: + raise ValueError("diagnostic code must be a non-empty string") + if not isinstance(self.message, str) or not self.message: + raise ValueError("diagnostic message must be a non-empty string") + if any(not isinstance(item, str) or not item for item in self.path): + raise ValueError("diagnostic paths must contain non-empty strings") + if not isinstance(self.severity, Severity): + raise TypeError("diagnostic severity must be Severity") + if self.hint is not None and (not isinstance(self.hint, str) or not self.hint): + raise ValueError("diagnostic hint must be non-empty when present") + + def prefixed(self, *path: str) -> Diagnostic: + """Return the same diagnostic below an additional stable path.""" + + return Diagnostic(self.code, self.message, tuple(path) + self.path, self.severity, self.hint) + + def render(self) -> str: + location = ".".join(self.path) if self.path else "" + suffix = f" Hint: {self.hint}" if self.hint else "" + return f"[{self.code}] {location}: {self.message}{suffix}" + + +@dataclass(frozen=True) +class DiagnosticSet: + """Immutable, ordered diagnostics produced by one contract boundary.""" + + diagnostics: tuple[Diagnostic, ...] = () + + def __post_init__(self) -> None: + values = tuple(self.diagnostics) + if any(not isinstance(item, Diagnostic) for item in values): + raise TypeError("diagnostic sets may contain only Diagnostic values") + object.__setattr__(self, "diagnostics", values) + + def __iter__(self) -> Iterator[Diagnostic]: + return iter(self.diagnostics) + + def __len__(self) -> int: + return len(self.diagnostics) + + @property + def errors(self) -> tuple[Diagnostic, ...]: + return tuple(item for item in self.diagnostics if item.severity is Severity.ERROR) + + @property + def warnings(self) -> tuple[Diagnostic, ...]: + return tuple(item for item in self.diagnostics if item.severity is Severity.WARNING) + + @property + def ok(self) -> bool: + return not self.errors + + def extend(self, other: DiagnosticSet) -> DiagnosticSet: + if not isinstance(other, DiagnosticSet): + raise TypeError("diagnostics may only be extended with another DiagnosticSet") + return DiagnosticSet(self.diagnostics + other.diagnostics) + + def prefixed(self, *path: str) -> DiagnosticSet: + return DiagnosticSet(tuple(item.prefixed(*path) for item in self.diagnostics)) + + @classmethod + def of(cls, *diagnostics: Diagnostic) -> DiagnosticSet: + return cls(tuple(diagnostics)) + + +EMPTY_DIAGNOSTICS = DiagnosticSet() + + +class DiagnosticError(ValueError): + """Explicit exception adapter for Checked values at application boundaries.""" + + def __init__(self, diagnostics: DiagnosticSet, subject: str = "Blueprinting contract") -> None: + if not isinstance(diagnostics, DiagnosticSet): + raise TypeError("DiagnosticError requires a DiagnosticSet") + self.subject = subject + self.diagnostics = diagnostics + rendered = "\n".join(f" - {item.render()}" for item in diagnostics.errors or diagnostics.diagnostics) + super().__init__(f"{subject} failed:\n{rendered}") + + +class DiagnosticBag: + """Local mutable builder that publishes only immutable diagnostics.""" + + __slots__ = ("_items",) + + def __init__(self) -> None: + self._items: list[Diagnostic] = [] + + def error(self, code: str, message: str, *path: str, hint: str | None = None) -> None: + self._items.append(Diagnostic(code, message, tuple(path), Severity.ERROR, hint)) + + def warning(self, code: str, message: str, *path: str, hint: str | None = None) -> None: + self._items.append(Diagnostic(code, message, tuple(path), Severity.WARNING, hint)) + + def add(self, diagnostic: Diagnostic) -> None: + if not isinstance(diagnostic, Diagnostic): + raise TypeError("diagnostic bag entries must be Diagnostic values") + self._items.append(diagnostic) + + def extend(self, diagnostics: Iterable[Diagnostic]) -> None: + for diagnostic in diagnostics: + self.add(diagnostic) + + def report(self) -> DiagnosticSet: + return DiagnosticSet(tuple(self._items)) + + +__all__ = [ + "Diagnostic", + "DiagnosticBag", + "DiagnosticError", + "DiagnosticSet", + "EMPTY_DIAGNOSTICS", + "Severity", +] diff --git a/src/blueprinting/schema/frozen.py b/src/blueprinting/schema/frozen.py index 8bf1e9a..4dcecdb 100644 --- a/src/blueprinting/schema/frozen.py +++ b/src/blueprinting/schema/frozen.py @@ -3,7 +3,11 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from typing import Any +from typing import Any, Generic, cast + +from typing_extensions import TypeVar + +V = TypeVar("V", default=Any) def freeze(value: Any) -> Any: @@ -38,31 +42,31 @@ def thaw(value: Any) -> Any: return value -class FrozenDict(Mapping): +class FrozenDict(Mapping[str, V], Generic[V]): """A compact, hashable mapping with recursively frozen values.""" __slots__ = ("_hash", "_items") def __init__( self, - source: Mapping[str, Any] | None = None, + source: Mapping[str, V] | None = None, *, - items: Iterable[tuple[str, Any]] | None = None, + items: Iterable[tuple[str, V]] | None = None, ) -> None: if source is not None and items is not None: raise TypeError("provide either source or items, not both") raw_items = source.items() if source is not None else (items or ()) - copied: dict[str, Any] = {} + copied: dict[str, V] = {} for key, value in raw_items: if not isinstance(key, str): raise TypeError("FrozenDict keys must be strings") if key in copied: raise ValueError(f"duplicate FrozenDict key: {key!r}") - copied[key] = freeze(value) - self._items = tuple(sorted(copied.items(), key=lambda pair: pair[0])) - self._hash = None + copied[key] = cast(V, freeze(value)) + self._items: tuple[tuple[str, V], ...] = tuple(sorted(copied.items(), key=lambda pair: pair[0])) + self._hash: int | None = None - def __getitem__(self, key: str) -> Any: + def __getitem__(self, key: str) -> V: for item_key, value in self._items: if item_key == key: return value @@ -83,12 +87,12 @@ def __repr__(self) -> str: body = ", ".join(f"{key!r}: {value!r}" for key, value in self._items) return f"FrozenDict({{{body}}})" - def __reduce__(self): + def __reduce__(self) -> tuple[type[FrozenDict[V]], tuple[dict[str, V]]]: """Use the public constructor for process and UI cache round-trips.""" return FrozenDict, (dict(self._items),) - def evolve(self, **changes: Any) -> FrozenDict: + def evolve(self, **changes: V) -> FrozenDict[V]: updated = dict(self._items) updated.update(changes) return FrozenDict(updated) diff --git a/src/blueprinting/schema/refinements.py b/src/blueprinting/schema/refinements.py new file mode 100644 index 0000000..cada2c8 --- /dev/null +++ b/src/blueprinting/schema/refinements.py @@ -0,0 +1,70 @@ +"""Reusable, domain-free refined value types for immutable contracts.""" + +from __future__ import annotations + +from typing import Annotated, TypeAlias + +from .deriving import ValueConstraint + +NonEmptyText: TypeAlias = Annotated[str, ValueConstraint.NON_EMPTY] +NonBlankText: TypeAlias = Annotated[str, ValueConstraint.NON_BLANK] +StableName: TypeAlias = Annotated[str, ValueConstraint.STABLE_NAME] +ContentDigest: TypeAlias = Annotated[str, ValueConstraint.CONTENT_DIGEST] +SymbolName: TypeAlias = Annotated[str, ValueConstraint.SYMBOL_NAME] +CanonicalLowerText: TypeAlias = Annotated[str, ValueConstraint.CANONICAL_LOWER_TEXT] + +NonNegativeInt: TypeAlias = Annotated[int, ValueConstraint.NON_NEGATIVE] +PositiveInt: TypeAlias = Annotated[int, ValueConstraint.POSITIVE] +AtLeastTwoInt: TypeAlias = Annotated[int, ValueConstraint.AT_LEAST_TWO] +FiniteFloat: TypeAlias = Annotated[float, ValueConstraint.FINITE] +NonNegativeFiniteFloat: TypeAlias = Annotated[ + float, + ValueConstraint.FINITE, + ValueConstraint.NON_NEGATIVE, +] +PositiveFiniteFloat: TypeAlias = Annotated[ + float, + ValueConstraint.FINITE, + ValueConstraint.POSITIVE, +] +NonNegativeFiniteNumber: TypeAlias = Annotated[ + int | float, + ValueConstraint.FINITE, + ValueConstraint.NON_NEGATIVE, +] +PositiveFiniteNumber: TypeAlias = Annotated[ + int | float, + ValueConstraint.FINITE, + ValueConstraint.POSITIVE, +] +UnitIntervalNumber: TypeAlias = Annotated[ + int | float, + ValueConstraint.FINITE, + ValueConstraint.NON_NEGATIVE, + ValueConstraint.AT_MOST_ONE, +] +PositiveUnitIntervalNumber: TypeAlias = Annotated[ + int | float, + ValueConstraint.FINITE, + ValueConstraint.POSITIVE, + ValueConstraint.AT_MOST_ONE, +] + +__all__ = [ + "AtLeastTwoInt", + "CanonicalLowerText", + "ContentDigest", + "FiniteFloat", + "NonBlankText", + "NonEmptyText", + "NonNegativeFiniteFloat", + "NonNegativeFiniteNumber", + "NonNegativeInt", + "PositiveFiniteFloat", + "PositiveFiniteNumber", + "PositiveInt", + "PositiveUnitIntervalNumber", + "StableName", + "SymbolName", + "UnitIntervalNumber", +] diff --git a/src/blueprinting/schema/result.py b/src/blueprinting/schema/result.py new file mode 100644 index 0000000..46699b0 --- /dev/null +++ b/src/blueprinting/schema/result.py @@ -0,0 +1,121 @@ +"""Small algebraic result type with ordered diagnostic accumulation.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from typing import Generic, TypeAlias, TypeVar, cast + +from typing_extensions import Never + +from .diagnostics import EMPTY_DIAGNOSTICS, DiagnosticError, DiagnosticSet + +T = TypeVar("T") +U = TypeVar("U") +E = TypeVar("E") +F = TypeVar("F") +T_co = TypeVar("T_co", covariant=True) +E_co = TypeVar("E_co", covariant=True) + + +class Result(Generic[T_co, E_co]): + """Success or expected failure without hidden exception control flow.""" + + @property + def is_ok(self) -> bool: + return isinstance(self, Ok) + + @property + def is_err(self) -> bool: + return isinstance(self, Err) + + def map(self: Result[T, E], function: Callable[[T], U]) -> Result[U, E]: + if isinstance(self, Ok): + success = cast(Ok[T], self) + return Ok(function(success.value), success.diagnostics) + return cast(Err[E], self) + + def map_error(self: Result[T, E], function: Callable[[E], F]) -> Result[T, F]: + if isinstance(self, Err): + failure = cast(Err[E], self) + return Err(function(failure.error)) + return cast(Ok[T], self) + + def and_then(self: Result[T, E], function: Callable[[T], Result[U, E]]) -> Result[U, E]: + if isinstance(self, Err): + return cast(Err[E], self) + success = cast(Ok[T], self) + following = function(success.value) + if isinstance(following, Ok): + next_success = cast(Ok[U], following) + return Ok(next_success.value, success.diagnostics.extend(next_success.diagnostics)) + failure = cast(Err[E], following) + if isinstance(failure.error, DiagnosticSet) and success.diagnostics.diagnostics: + return Err(cast(E, success.diagnostics.extend(failure.error))) + return failure + + def or_raise( + self: Result[T, E], + factory: Callable[[E], BaseException] | None = None, + ) -> T: + """Unwrap only at an explicit exception boundary.""" + + if isinstance(self, Ok): + return cast(Ok[T], self).value + failure = cast(Err[E], self) + if factory is not None: + raise factory(failure.error) + if isinstance(failure.error, DiagnosticSet): + raise DiagnosticError(failure.error) + raise RuntimeError(f"unhandled Result error: {failure.error!r}") + + +@dataclass(frozen=True) +class Ok(Result[T_co, Never], Generic[T_co]): + value: T_co + diagnostics: DiagnosticSet = field(default_factory=DiagnosticSet) + + def __post_init__(self) -> None: + if not isinstance(self.diagnostics, DiagnosticSet): + raise TypeError("Ok diagnostics must be a DiagnosticSet") + if self.diagnostics.errors: + raise ValueError("Ok diagnostics may contain warnings but not errors") + + +@dataclass(frozen=True) +class Err(Result[Never, E_co], Generic[E_co]): + error: E_co + + def __post_init__(self) -> None: + if isinstance(self.error, DiagnosticSet) and not self.error.errors: + raise ValueError("Err diagnostics must contain at least one error") + + +Checked: TypeAlias = Result[T, DiagnosticSet] + + +def collect_results(results: Iterable[Checked[T]]) -> Checked[tuple[T, ...]]: + """Accumulate independent Checked values without losing diagnostic order.""" + + values: list[T] = [] + warnings = EMPTY_DIAGNOSTICS + failures = EMPTY_DIAGNOSTICS + for result in results: + if isinstance(result, Ok): + success = cast(Ok[T], result) + values.append(success.value) + warnings = warnings.extend(success.diagnostics) + else: + failures = failures.extend(cast(Err[DiagnosticSet], result).error) + if failures.errors: + return Err(warnings.extend(failures)) + return Ok(tuple(values), warnings) + + +def checked(value: T, diagnostics: DiagnosticSet = EMPTY_DIAGNOSTICS) -> Checked[T]: + """Create Ok or Err according to the supplied immutable diagnostics.""" + + return Err(diagnostics) if diagnostics.errors else Ok(value, diagnostics) + + +__all__ = ["Checked", "Err", "Ok", "Result", "checked", "collect_results"] diff --git a/src/blueprinting/synthesizer/__init__.py b/src/blueprinting/synthesizer/__init__.py index 1d3097b..2693826 100644 --- a/src/blueprinting/synthesizer/__init__.py +++ b/src/blueprinting/synthesizer/__init__.py @@ -6,11 +6,14 @@ CalibrationBinding, DeploymentProfile, InferencePhase, + InferenceWorkload, StrategyBinding, TargetProfile, TargetRequirements, + TrainingWorkload, WorkloadBinding, WorkloadMode, + WorkloadModeVariant, ) from .errors import ( BindingError, @@ -22,7 +25,23 @@ SynthesisError, VerificationReport, ) -from .expr import ExprOp, ScalarExpr, Symbol, ceil_div, free_symbols, maximum, minimum, substitute +from .expr import ( + Add, + CeilDivide, + Divide, + ExprOp, + Maximum, + Minimum, + Multiply, + ScalarExpr, + Subtract, + Symbol, + ceil_div, + free_symbols, + maximum, + minimum, + substitute, +) from .ids import ( BufferId, CommandId, @@ -36,23 +55,40 @@ TokenId, ValueId, ) +from .schema_migration import ( + DEFAULT_SCHEMA_MIGRATIONS, + MigrationResult, + SchemaMigration, + SchemaMigrationRegistry, +) from .session import SynthesisSession __all__ = [ "BindingAxis", + "Add", "BindingError", "BindingSet", "BufferId", "CalibrationBinding", "CommandId", + "CeilDivide", + "Divide", "SynthesisSession", + "DEFAULT_SCHEMA_MIGRATIONS", + "MigrationResult", + "SchemaMigration", + "SchemaMigrationRegistry", "SynthesisError", "DeploymentProfile", "DeviceId", "Diagnostic", "ExprOp", + "Maximum", + "Minimum", + "Multiply", "IRVerificationError", "InferencePhase", + "InferenceWorkload", "InstructionId", "Lineage", "LineageKind", @@ -65,6 +101,7 @@ "TokenId", "ScalarExpr", "StrategyBinding", + "Subtract", "Symbol", "TargetProfile", "TargetRequirements", @@ -72,6 +109,8 @@ "VerificationReport", "WorkloadBinding", "WorkloadMode", + "WorkloadModeVariant", + "TrainingWorkload", "ceil_div", "free_symbols", "maximum", diff --git a/src/blueprinting/synthesizer/axes.py b/src/blueprinting/synthesizer/axes.py index f92673d..c5396b5 100644 --- a/src/blueprinting/synthesizer/axes.py +++ b/src/blueprinting/synthesizer/axes.py @@ -2,10 +2,10 @@ from enum import Enum -from blueprinting.schema.codec import enum_type +from blueprinting.schema.authoring import enum -@enum_type("compiler.binding_axis") +@enum("blueprinting.binding.axis") class BindingAxis(Enum): WORKLOAD = "workload" STRATEGY = "strategy" diff --git a/src/blueprinting/synthesizer/bindings.py b/src/blueprinting/synthesizer/bindings.py index 00167e3..6ae6525 100644 --- a/src/blueprinting/synthesizer/bindings.py +++ b/src/blueprinting/synthesizer/bindings.py @@ -2,199 +2,186 @@ from __future__ import annotations -import math -from dataclasses import dataclass, field, replace +from dataclasses import field, replace from enum import Enum from typing import Any -from blueprinting.schema.codec import content_digest, enum_type, record_type -from blueprinting.schema.frozen import FrozenDict, freeze +from typing_extensions import assert_never + +from blueprinting.schema.authoring import ( + NonEmptyText, + NonNegativeInt, + PositiveInt, + adt, + enum, + record, + seal_adt, + variant, +) +from blueprinting.schema.codec import content_digest +from blueprinting.schema.frozen import FrozenDict from .axes import BindingAxis from .errors import BindingError, MissingBindingError -from .expr import Scalar, ScalarExpr, Symbol, free_symbols +from .semantics import EMPTY_SEMANTIC, BindingSemantic + + +def _reject_reserved_attributes(attributes: FrozenDict, reserved: frozenset[str], field_name: str) -> None: + def walk(value: Any, path: tuple[str, ...]) -> None: + if isinstance(value, FrozenDict): + for key, item in value.items(): + location = path + (key,) + if key.lower() in reserved: + raise BindingError( + f"{field_name}.{'.'.join(location)} is a typed semantic field and cannot be an attribute" + ) + walk(item, location) + elif isinstance(value, (tuple, frozenset)): + for index, item in enumerate(value): + walk(item, path + (str(index),)) + + walk(attributes, ()) + + +_WORKLOAD_RESERVED_ATTRIBUTES = frozenset( + { + "workload_spec", + "phase", + "inference_phase", + "batch_size", + "global_batch_size", + "microbatch_size", + "micro_batches", + "sequence_length", + "prompt_tokens", + "generated_tokens", + "query_tokens", + "context_tokens", + "datatype", + } +) +_STRATEGY_RESERVED_ATTRIBUTES = frozenset( + { + "mapping_spec", + "inference_mapping_spec", + "parallelism", + "tensor_parallel", + "tensor_par", + "pipeline_parallel", + "pipeline_par", + "data_parallel", + "data_par", + "replicas", + "recompute", + "recompute_policy", + "activation_recompute", + "pipeline_schedule", + "pipeline_interleaving", + "optimizer_sharding", + "tensor_parallel_communication", + "tensor_par_comm_type", + "world_size", + "num_procs", + "fused_activation", + "sequence_parallel_all_gather_redo", + } +) + + +@enum("blueprinting.binding.inference-phase") +class InferencePhase(Enum): + PREFILL = "prefill" + DECODE = "decode" -def _frozen_map(value: Any) -> FrozenDict: - frozen = freeze(value) - if not isinstance(frozen, FrozenDict): - raise TypeError("expected a mapping") - return frozen +@adt(wire="blueprinting.binding.workload-mode") +class WorkloadMode: + """Closed workload specialization; inference always carries its phase.""" -def _positive(value: Scalar, field_name: str) -> None: - if not isinstance(value, (int, float, Symbol, ScalarExpr)): - raise BindingError(f"{field_name} must be numeric or symbolic") - if isinstance(value, bool) or ( - isinstance(value, (int, float)) and ((isinstance(value, float) and not math.isfinite(value)) or value <= 0) - ): - raise BindingError(f"{field_name} must be finite and greater than zero") - if any(symbol.axis is not BindingAxis.WORKLOAD for symbol in free_symbols(value)): - raise BindingError(f"{field_name} may only reference workload symbols") +@variant("training") +class TrainingWorkload(WorkloadMode): + pass -def _string_set(value: Any, field_name: str) -> frozenset[str]: - if isinstance(value, (str, bytes)): - raise BindingError(f"{field_name} must be an iterable of strings, not one string") - try: - result = frozenset(value) - except TypeError as error: - raise BindingError(f"{field_name} must be an iterable of strings") from error - if any(not isinstance(item, str) or not item for item in result): - raise BindingError(f"{field_name} must contain non-empty strings") - return result +@variant("inference") +class InferenceWorkload(WorkloadMode): + phase: InferencePhase -@enum_type("compiler.workload_mode") -class WorkloadMode(Enum): - TRAINING = "training" - INFERENCE = "inference" +WorkloadModeVariant = TrainingWorkload | InferenceWorkload +seal_adt(WorkloadMode, WorkloadModeVariant) -@enum_type("compiler.inference_phase") -class InferencePhase(Enum): - PREFILL = "prefill" - DECODE = "decode" - - -@record_type("compiler.binding.workload") -@dataclass(frozen=True) +@record("blueprinting.binding.workload") class WorkloadBinding: - mode: WorkloadMode - batch_size: Scalar = 1 - sequence_length: Scalar = 1 - micro_batches: Scalar = 1 - inference_phase: InferencePhase | None = None + mode: WorkloadModeVariant + semantic: BindingSemantic = EMPTY_SEMANTIC attributes: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - if not isinstance(self.mode, WorkloadMode): - raise BindingError("mode must be a WorkloadMode") - _positive(self.batch_size, "batch_size") - _positive(self.sequence_length, "sequence_length") - _positive(self.micro_batches, "micro_batches") - if self.mode is WorkloadMode.TRAINING and self.inference_phase is not None: - raise BindingError("training workload cannot define an inference phase") - object.__setattr__(self, "attributes", _frozen_map(self.attributes)) + _reject_reserved_attributes(self.attributes, _WORKLOAD_RESERVED_ATTRIBUTES, "workload.attributes") + + @property + def inference_phase(self) -> InferencePhase | None: + match self.mode: + case TrainingWorkload(): + return None + case InferenceWorkload(phase): + return phase + assert_never(self.mode) @property def fingerprint(self) -> str: return content_digest(self, "workload-binding") -@record_type("compiler.binding.strategy") -@dataclass(frozen=True) +@record("blueprinting.binding.strategy") class StrategyBinding: - tensor_parallel: int = 1 - pipeline_parallel: int = 1 - data_parallel: int = 1 - recompute_policy: str = "none" - pipeline_policy: str = "none" + semantic: BindingSemantic = EMPTY_SEMANTIC attributes: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - for name in ("tensor_parallel", "pipeline_parallel", "data_parallel"): - value = getattr(self, name) - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise BindingError(f"{name} must be a positive integer") - if not isinstance(self.recompute_policy, str) or not isinstance(self.pipeline_policy, str): - raise BindingError("strategy policies must be strings") - if not self.recompute_policy or not self.pipeline_policy: - raise BindingError("strategy policies must not be empty") - object.__setattr__(self, "attributes", _frozen_map(self.attributes)) - - @property - def world_size(self) -> int: - return self.tensor_parallel * self.pipeline_parallel * self.data_parallel + _reject_reserved_attributes(self.attributes, _STRATEGY_RESERVED_ATTRIBUTES, "strategy.attributes") @property def fingerprint(self) -> str: return content_digest(self, "strategy-binding") -@record_type("compiler.target_requirements") -@dataclass(frozen=True) +@record("blueprinting.binding.target-requirements") class TargetRequirements: - device_count_range: tuple[int, int] = (1, 2**31 - 1) - minimum_memory_bytes: int = 0 - required_dtypes: frozenset[str] = frozenset() - required_collectives: frozenset[str] = frozenset() - required_capabilities: frozenset[str] = frozenset() + device_count_range: tuple[PositiveInt, PositiveInt] = (1, 2**31 - 1) + minimum_memory_bytes: NonNegativeInt = 0 + required_dtypes: frozenset[NonEmptyText] = frozenset() + required_collectives: frozenset[NonEmptyText] = frozenset() + required_capabilities: frozenset[NonEmptyText] = frozenset() attributes: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - device_range = tuple(self.device_count_range) - if len(device_range) != 2: - raise BindingError("device_count_range must contain exactly two bounds") - lower, upper = device_range - if any(isinstance(value, bool) or not isinstance(value, int) for value in device_range): - raise BindingError("device_count_range bounds must be integers") - if lower <= 0 or upper < lower: - raise BindingError("device_count_range must be positive and ordered") - if ( - isinstance(self.minimum_memory_bytes, bool) - or not isinstance(self.minimum_memory_bytes, int) - or self.minimum_memory_bytes < 0 - ): - raise BindingError("minimum_memory_bytes must not be negative") - object.__setattr__(self, "device_count_range", device_range) - object.__setattr__(self, "required_dtypes", _string_set(self.required_dtypes, "required_dtypes")) - object.__setattr__( - self, - "required_collectives", - _string_set(self.required_collectives, "required_collectives"), - ) - object.__setattr__( - self, - "required_capabilities", - _string_set(self.required_capabilities, "required_capabilities"), - ) - object.__setattr__(self, "attributes", _frozen_map(self.attributes)) + lower, upper = self.device_count_range + if upper < lower: + raise BindingError("device_count_range bounds must be ordered") -@record_type("compiler.binding.target", field_aliases={"compiler_abi": "target_abi"}) -@dataclass(frozen=True) +@record("blueprinting.binding.target") class TargetProfile: - name: str - architecture: str - architecture_revision: str - runtime_stack: str - runtime_revision: str - target_abi: str - supported_dtypes: frozenset[str] = frozenset() - supported_operations: frozenset[str] = frozenset() - memory_spaces: frozenset[str] = frozenset() - execution_engines: frozenset[str] = frozenset() - supported_collectives: frozenset[str] = frozenset() - capabilities: frozenset[str] = frozenset() - kernel_library: str = "none" - collective_library: str = "none" + name: NonEmptyText + architecture: NonEmptyText + architecture_revision: NonEmptyText + runtime_stack: NonEmptyText + runtime_revision: NonEmptyText + target_abi: NonEmptyText + supported_dtypes: frozenset[NonEmptyText] = frozenset() + supported_operations: frozenset[NonEmptyText] = frozenset() + memory_spaces: frozenset[NonEmptyText] = frozenset() + execution_engines: frozenset[NonEmptyText] = frozenset() + supported_collectives: frozenset[NonEmptyText] = frozenset() + capabilities: frozenset[NonEmptyText] = frozenset() + kernel_library: NonEmptyText = "none" + collective_library: NonEmptyText = "none" attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - required = ( - "name", - "architecture", - "architecture_revision", - "runtime_stack", - "runtime_revision", - "target_abi", - ) - if any(not isinstance(getattr(self, name), str) or not getattr(self, name) for name in required): - raise BindingError("target identity fields must not be empty") - for name in ( - "supported_dtypes", - "supported_operations", - "memory_spaces", - "execution_engines", - "supported_collectives", - "capabilities", - ): - object.__setattr__(self, name, _string_set(getattr(self, name), name)) - if any(not isinstance(value, str) or not value for value in (self.kernel_library, self.collective_library)): - raise BindingError("target library identities must be non-empty strings") - object.__setattr__(self, "attributes", _frozen_map(self.attributes)) - @property def fingerprint(self) -> str: return content_digest(self, "target-profile") @@ -207,34 +194,18 @@ def satisfies(self, requirements: TargetRequirements) -> bool: ) -@record_type("compiler.binding.deployment") -@dataclass(frozen=True) +@record("blueprinting.binding.deployment") class DeploymentProfile: - name: str - device_count: int + name: NonEmptyText + device_count: PositiveInt topology: FrozenDict - environment_revision: str - available_memory_bytes: tuple[int, ...] = () + environment_revision: NonEmptyText + available_memory_bytes: tuple[NonNegativeInt, ...] = () reserved_resources: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - if ( - not isinstance(self.name, str) - or not isinstance(self.environment_revision, str) - or not self.name - or not self.environment_revision - ): - raise BindingError("deployment identity fields must not be empty") - if isinstance(self.device_count, bool) or not isinstance(self.device_count, int) or self.device_count <= 0: - raise BindingError("device_count must be a positive integer") - memory = tuple(self.available_memory_bytes) - if len(memory) not in (0, 1, self.device_count): + if len(self.available_memory_bytes) not in (0, 1, self.device_count): raise BindingError("available_memory_bytes must be empty, scalar-like, or per-device") - if any(isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in memory): - raise BindingError("available memory must not be negative") - object.__setattr__(self, "available_memory_bytes", memory) - object.__setattr__(self, "topology", _frozen_map(self.topology)) - object.__setattr__(self, "reserved_resources", _frozen_map(self.reserved_resources)) @property def fingerprint(self) -> str: @@ -252,15 +223,10 @@ def satisfies(self, requirements: TargetRequirements) -> bool: return all(item >= requirements.minimum_memory_bytes for item in memory) -@record_type("compiler.binding.calibration") -@dataclass(frozen=True) +@record("blueprinting.binding.calibration") class CalibrationBinding: - evidence_revision: str - cost_model_revision: str - - def __post_init__(self) -> None: - if any(not isinstance(value, str) or not value for value in (self.evidence_revision, self.cost_model_revision)): - raise BindingError("calibration revisions must not be empty") + evidence_revision: NonEmptyText + cost_model_revision: NonEmptyText @property def fingerprint(self) -> str: @@ -270,8 +236,7 @@ def fingerprint(self) -> str: BindingValue = WorkloadBinding | StrategyBinding | TargetProfile | DeploymentProfile | CalibrationBinding -@record_type("compiler.binding_set") -@dataclass(frozen=True) +@record("blueprinting.binding.set") class BindingSet: workload: WorkloadBinding | None = None strategy: StrategyBinding | None = None @@ -279,21 +244,18 @@ class BindingSet: deployment: DeploymentProfile | None = None calibration: CalibrationBinding | None = None - def __post_init__(self) -> None: - expected = { - "workload": WorkloadBinding, - "strategy": StrategyBinding, - "target": TargetProfile, - "deployment": DeploymentProfile, - "calibration": CalibrationBinding, - } - for name, expected_type in expected.items(): - value = getattr(self, name) - if value is not None and not isinstance(value, expected_type): - raise BindingError(f"{name} binding must be {expected_type.__name__}") - def get(self, axis: BindingAxis) -> BindingValue | None: - return getattr(self, axis.value) + if axis is BindingAxis.WORKLOAD: + return self.workload + if axis is BindingAxis.STRATEGY: + return self.strategy + if axis is BindingAxis.TARGET: + return self.target + if axis is BindingAxis.DEPLOYMENT: + return self.deployment + if axis is BindingAxis.CALIBRATION: + return self.calibration + raise TypeError(f"unsupported binding axis: {axis!r}") def has(self, axis: BindingAxis) -> bool: return self.get(axis) is not None diff --git a/src/blueprinting/synthesizer/dialects/transformer/__init__.py b/src/blueprinting/synthesizer/dialects/transformer/__init__.py index 6d81015..868eb0b 100644 --- a/src/blueprinting/synthesizer/dialects/transformer/__init__.py +++ b/src/blueprinting/synthesizer/dialects/transformer/__init__.py @@ -2,6 +2,24 @@ from .common import EngineKind, PhaseWork from .inference import InferenceBlockMemoryFacts, InferenceInvocation, derive_transformer_inference_block +from .semantics import ( + TransformerBufferSemantic, + TransformerInferenceDistributedTaskSemantic, + TransformerInferencePlanSemantic, + TransformerInferencePlanTaskSemantic, + TransformerInferenceProgramSemantic, + TransformerInferenceStrategySemantic, + TransformerInferenceWorkloadSemantic, + TransformerModelOperationSemantic, + TransformerTrainingDistributedTaskSemantic, + TransformerTrainingPlanSemantic, + TransformerTrainingPlanTaskSemantic, + TransformerTrainingProgramSemantic, + TransformerTrainingStrategySemantic, + TransformerTrainingWorkloadSemantic, + inference_task_semantic, + training_task_semantic, +) from .training import ( BlockMemoryFacts, PrimitiveInvocation, @@ -17,6 +35,22 @@ "PhaseWork", "PrimitiveInvocation", "TrainingPhase", + "TransformerBufferSemantic", + "TransformerInferenceDistributedTaskSemantic", + "TransformerInferenceProgramSemantic", + "TransformerInferencePlanSemantic", + "TransformerInferencePlanTaskSemantic", + "TransformerInferenceStrategySemantic", + "TransformerInferenceWorkloadSemantic", + "TransformerModelOperationSemantic", + "TransformerTrainingDistributedTaskSemantic", + "TransformerTrainingProgramSemantic", + "TransformerTrainingPlanSemantic", + "TransformerTrainingPlanTaskSemantic", + "TransformerTrainingStrategySemantic", + "TransformerTrainingWorkloadSemantic", "derive_transformer_block", "derive_transformer_inference_block", + "inference_task_semantic", + "training_task_semantic", ] diff --git a/src/blueprinting/synthesizer/dialects/transformer/common.py b/src/blueprinting/synthesizer/dialects/transformer/common.py index fee22e6..50c9865 100644 --- a/src/blueprinting/synthesizer/dialects/transformer/common.py +++ b/src/blueprinting/synthesizer/dialects/transformer/common.py @@ -2,36 +2,28 @@ from __future__ import annotations -from dataclasses import dataclass from enum import Enum -from blueprinting.schema.codec import enum_type, record_type +from blueprinting.schema.authoring import NonNegativeInt, enum, record # Codec tags retain their legacy namespace as stable serialized identities. -@enum_type("compiler.analysis.engine_kind") +@enum("blueprinting.analysis.transformer.engine-kind") class EngineKind(Enum): MATRIX = "matrix" VECTOR = "vector" COLLECTIVE = "collective" -@record_type("compiler.analysis.phase_work.v1") -@dataclass(frozen=True) +@record("blueprinting.analysis.transformer.phase-work") class PhaseWork: """Exact work for one invocation, before target binding.""" - operations: int = 0 - read_bytes: int = 0 - write_bytes: int = 0 - message_bytes: int = 0 - - def __post_init__(self) -> None: - for field_name in ("operations", "read_bytes", "write_bytes", "message_bytes"): - value = getattr(self, field_name) - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError(f"{field_name} must be a non-negative integer") + operations: NonNegativeInt = 0 + read_bytes: NonNegativeInt = 0 + write_bytes: NonNegativeInt = 0 + message_bytes: NonNegativeInt = 0 @property def memory_bytes(self) -> int: diff --git a/src/blueprinting/synthesizer/dialects/transformer/inference.py b/src/blueprinting/synthesizer/dialects/transformer/inference.py index 474dfd3..627483d 100644 --- a/src/blueprinting/synthesizer/dialects/transformer/inference.py +++ b/src/blueprinting/synthesizer/dialects/transformer/inference.py @@ -8,43 +8,30 @@ from __future__ import annotations -from dataclasses import dataclass - from blueprinting.mapping import TransformerInferenceMappingSpec -from blueprinting.schema.codec import record_type -from blueprinting.workload import TransformerModelSpec +from blueprinting.schema.authoring import NonEmptyText, NonNegativeInt, record +from blueprinting.workload import TransformerDataType, TransformerModelSpec, transformer_element_bytes from ...bindings import InferencePhase -from ...ir import CollectiveKind +from ...stages.distributed.ir import CollectiveKind from .common import EngineKind, PhaseWork # Keep the legacy codec namespace as a stable serialized identity. -@record_type("blueprinting.transformer.inference-invocation.v2") -@dataclass(frozen=True) +@record("blueprinting.ir.semantic.transformer.inference-invocation") class InferenceInvocation: """One target-neutral component invocation for a single decoder block.""" - name: str - source_layer: str - primitive: str + name: NonEmptyText + source_layer: NonEmptyText + primitive: NonEmptyText phase: InferencePhase engine: EngineKind work: PhaseWork collective: CollectiveKind | None = None def __post_init__(self) -> None: - for field_name in ("name", "source_layer", "primitive"): - value = getattr(self, field_name) - if not isinstance(value, str) or not value: - raise ValueError(f"{field_name} must not be empty") - if not isinstance(self.phase, InferencePhase): - raise TypeError("phase must be InferencePhase") - if not isinstance(self.engine, EngineKind): - raise TypeError("engine must be EngineKind") - if not isinstance(self.work, PhaseWork): - raise TypeError("work must be PhaseWork") if self.engine is EngineKind.COLLECTIVE: if self.collective is None: raise ValueError("collective invocations require a collective kind") @@ -52,21 +39,14 @@ def __post_init__(self) -> None: raise ValueError("local invocations cannot carry collective metadata") -@record_type("compiler.analysis.inference_block_memory_facts.v1") -@dataclass(frozen=True) +@record("blueprinting.ir.semantic.transformer.inference-block-memory") class InferenceBlockMemoryFacts: """Per-rank storage for one tensor-parallel block shard and phase.""" - weights: int - kv_cache: int - working_upper_bound: int - boundary: int - - def __post_init__(self) -> None: - for field_name in self.__dataclass_fields__: - value = getattr(self, field_name) - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError(f"{field_name} must be a non-negative integer") + weights: NonNegativeInt + kv_cache: NonNegativeInt + working_upper_bound: NonNegativeInt + boundary: NonNegativeInt def _work(*, operations: int = 0, read: int = 0, write: int = 0, message: int = 0) -> PhaseWork: @@ -80,7 +60,7 @@ def derive_transformer_inference_block( phase: InferencePhase, batch_size: int, context_tokens: int, - datatype: str, + datatype: TransformerDataType, ) -> tuple[tuple[InferenceInvocation, ...], InferenceBlockMemoryFacts]: """Derive exact work for one local block at one inference phase point. @@ -95,10 +75,7 @@ def derive_transformer_inference_block( for name, value in (("batch_size", batch_size), ("context_tokens", context_tokens)): if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise ValueError(f"{name} must be a positive integer") - try: - element_bytes = {"float8": 1, "float16": 2, "bfloat16": 2, "float32": 4}[datatype] - except KeyError as error: - raise ValueError(f"unsupported datatype: {datatype!r}") from error + element_bytes = transformer_element_bytes(datatype) b = batch_size q = context_tokens if phase is InferencePhase.PREFILL else 1 diff --git a/src/blueprinting/synthesizer/dialects/transformer/inference_derivation.py b/src/blueprinting/synthesizer/dialects/transformer/inference_derivation.py new file mode 100644 index 0000000..feb3aa8 --- /dev/null +++ b/src/blueprinting/synthesizer/dialects/transformer/inference_derivation.py @@ -0,0 +1,664 @@ +"""Inference lowerings from a semantic decoder to a phase-local work plan.""" + +from __future__ import annotations + +from typing import cast + +from blueprinting.mapping import ( + ForwardOnly, + PipelineParallel, + ReplicaParallel, + SingleStage, + TensorParallel, + TransformerInferenceMappingSpec, + TransformerInferenceParallelism, +) +from blueprinting.workload import TransformerDataType, TransformerModelSpec + +from ...bindings import InferencePhase, InferenceWorkload +from ...ids import BufferId, Lineage, NodeId, ValueId +from ...passes.authoring import RelationCheckContext, relation +from ...session import SynthesisSession +from ...stages.common import Effect, EffectKind, OperationName, TensorType, make_header +from ...stages.distributed.ir import ( + Collective, + CollectiveKind, + DistributedTask, + DistributedTaskIR, + DistributedValue, + LocalCompute, + LogicalMesh, + MeshAxis, + ReductionKind, + ShardingSpec, + collective_kind, + make_collective_spec, +) +from ...stages.model.ir import ModelIR, ModelOperation, ModelValue, ValueRole +from ...stages.portable_plan.ir import ( + AbstractStorageClass, + CollectiveTask, + ComputeTask, + ImplementationRequirement, + ObjectiveDirection, + ObjectiveKind, + PlanBuffer, + PlanBufferRole, + PlanObjective, + PlanTask, + PortablePlanIR, + ResourceKind, + ResourceRequirement, + ResourceScope, + WorkloadFacts, +) +from .common import EngineKind +from .inference import InferenceInvocation, derive_transformer_inference_block +from .semantics import ( + TransformerBufferSemantic, + TransformerInferenceDistributedTaskSemantic, + TransformerInferencePlanSemantic, + TransformerInferenceProgramSemantic, + TransformerInferenceStrategySemantic, + TransformerInferenceWorkloadSemantic, + TransformerModelOperationSemantic, + inference_task_semantic, +) + + +def _parallelism(mapping: TransformerInferenceMappingSpec) -> tuple[int, int, int]: + """Destructure the TP × PP × replica inference strategy.""" + + match mapping.parallelism: + case TransformerInferenceParallelism( + tensor=TensorParallel(degree=tp), + pipeline=PipelineParallel(degree=pp, schedule=SingleStage() | ForwardOnly()), + replicas=ReplicaParallel(degree=replicas), + ): + return tp, pp, replicas + case _: + raise TypeError("unsupported Transformer inference parallel strategy") + + +def _semantic_specs( + ir: ModelIR, + session: SynthesisSession, +) -> tuple[TransformerModelSpec, TransformerInferenceMappingSpec, InferencePhase, int, int, int, TransformerDataType]: + if len(ir.operations) != 1 or ir.operations[0].operation != OperationName("transformer", "decoder_inference"): + raise ValueError("Transformer inference distribution expects one transformer.decoder_inference operation") + operation_semantic = ir.operations[0].semantic + if not isinstance(operation_semantic, TransformerModelOperationSemantic): + raise TypeError("model operation is missing a typed TransformerModelSpec") + model = operation_semantic.model + workload = session.bindings.workload + strategy = session.bindings.strategy + if workload is None or strategy is None: + raise ValueError("Transformer inference distribution requires workload and strategy bindings") + if not isinstance(workload.mode, InferenceWorkload): + raise ValueError("Transformer inference requires an inference workload binding") + inference_phase = workload.mode.phase + strategy_semantic = strategy.semantic + workload_semantic = workload.semantic + if not isinstance(strategy_semantic, TransformerInferenceStrategySemantic): + raise TypeError("strategy binding is missing a typed TransformerInferenceMappingSpec") + if not isinstance(workload_semantic, TransformerInferenceWorkloadSemantic): + raise TypeError("workload binding is missing typed Transformer inference facts") + mapping = strategy_semantic.mapping + batch_size = workload_semantic.batch_size + context_tokens = workload_semantic.context_tokens + query_tokens = context_tokens if inference_phase is InferencePhase.PREFILL else 1 + datatype = workload_semantic.datatype + mapping.validate_model(model) + return model, mapping, inference_phase, batch_size, query_tokens, context_tokens, datatype + + +def _verify_inference_distributed_value( + source: ModelValue, + target: DistributedValue, + context: RelationCheckContext, +) -> None: + target_ir = context.target_ir + if not isinstance(target_ir, DistributedTaskIR): + raise TypeError("inference value invariant requires DistributedTaskIR") + if target.source_value != source.id or target.role is not source.role: + raise ValueError("distributed inference value must retain its source identity and role") + if target.type.dtype != source.type.dtype: + raise ValueError("distributed inference value must preserve datatype") + if target.owners != tuple(range(target_ir.mesh.size)): + raise ValueError("distributed inference value owners must cover the logical mesh") + + +def _verify_inference_distributed_task( + _source: ModelOperation, + target: DistributedTask, + context: RelationCheckContext, +) -> None: + target_ir = context.target_ir + if not isinstance(target_ir, DistributedTaskIR): + raise TypeError("inference task invariant requires DistributedTaskIR") + if target.ranks != tuple(range(target_ir.mesh.size)): + raise ValueError("distributed inference task ranks must cover the logical mesh") + semantic = target.semantic + if not isinstance(semantic, TransformerInferenceDistributedTaskSemantic): + raise TypeError("distributed inference task must retain its typed invocation") + invocation = semantic.invocation + if invocation.engine is EngineKind.COLLECTIVE: + if not isinstance(target.body, Collective) or invocation.collective is None: + raise ValueError("collective invocation must lower to a collective body") + if collective_kind(target.body.spec) is not invocation.collective: + raise ValueError("collective body kind differs from its inference invocation") + if target.body.spec.message_bytes != invocation.work.message_bytes: + raise ValueError("collective body must conserve exact message bytes") + elif not isinstance(target.body, LocalCompute): + raise ValueError("non-collective inference invocation must lower to local compute") + + +def _verify_inference_boundary_buffer( + source: DistributedValue, + target: PlanBuffer, + _context: RelationCheckContext, +) -> None: + expected_role = { + ValueRole.INPUT: PlanBufferRole.INPUT, + ValueRole.OUTPUT: PlanBufferRole.OUTPUT, + }.get(source.role) + if expected_role is None or target.role is not expected_role: + raise ValueError("inference boundary buffer must preserve its distributed value role") + if target.storage_class is not AbstractStorageClass.DEVICE_LOCAL: + raise ValueError("inference boundary buffer must remain abstract device-local storage") + + +def _verify_inference_cache_buffer( + source: DistributedValue, + target: PlanBuffer, + context: RelationCheckContext, +) -> None: + source_ir = context.source_ir + if not isinstance(source_ir, DistributedTaskIR) or not isinstance( + source_ir.semantic, TransformerInferenceProgramSemantic + ): + raise TypeError("cache invariant requires typed inference program semantics") + if source.role is not ValueRole.KV_CACHE: + raise ValueError("cache buffer must originate from a distributed KV-cache value") + if target.role is not PlanBufferRole.STATE or target.storage_class is not AbstractStorageClass.PERSISTENT: + raise ValueError("KV cache must remain persistent state") + if target.size_bytes != source_ir.semantic.block_memory.kv_cache: + raise ValueError("KV-cache capacity differs from the derived block-memory fact") + if target.semantic != TransformerBufferSemantic("kv_cache", phase=source_ir.semantic.phase): + raise ValueError("KV-cache semantic projection is inconsistent") + + +def _verify_inference_plan_task( + source: DistributedTask, + target: PlanTask, + context: RelationCheckContext, +) -> None: + source_ir = context.source_ir + semantic = source.semantic + if not isinstance(source_ir, DistributedTaskIR) or not isinstance( + source_ir.semantic, TransformerInferenceProgramSemantic + ): + raise TypeError("plan task invariant requires typed inference program semantics") + if not isinstance(semantic, TransformerInferenceDistributedTaskSemantic): + raise TypeError("plan task source must retain its typed inference invocation") + invocation = semantic.invocation + expected_work = WorkloadFacts( + operations=invocation.work.operations, + read_bytes=invocation.work.read_bytes, + write_bytes=invocation.work.write_bytes, + message_bytes=invocation.work.message_bytes, + ) + if target.workload != expected_work: + raise ValueError("portable inference task must conserve exact workload facts") + if target.operation != source.operation or target.logical_ranks != source.ranks or target.effects != source.effects: + raise ValueError("portable inference task must preserve operation, logical ranks, and effects") + expected_semantic = inference_task_semantic( + invocation, + query_tokens=source_ir.semantic.query_tokens, + context_tokens=source_ir.semantic.context_tokens, + ) + if target.semantic != expected_semantic: + raise ValueError("portable inference task semantic projection is inconsistent") + if invocation.engine is EngineKind.COLLECTIVE: + if not isinstance(target.body, CollectiveTask): + raise ValueError("collective invocation must produce CollectiveTask") + elif not isinstance(target.body, ComputeTask): + raise ValueError("compute invocation must produce ComputeTask") + + +def _verify_inference_weight_buffer( + _sources: DistributedTask | tuple[DistributedTask, ...], + target: PlanBuffer, + context: RelationCheckContext, +) -> None: + source_ir = context.source_ir + if not isinstance(source_ir, DistributedTaskIR) or not isinstance( + source_ir.semantic, TransformerInferenceProgramSemantic + ): + raise TypeError("weight invariant requires typed inference program semantics") + if target.role is not PlanBufferRole.CONSTANT or target.storage_class is not AbstractStorageClass.PERSISTENT: + raise ValueError("inference weights must remain persistent constants") + if target.size_bytes != source_ir.semantic.block_memory.weights: + raise ValueError("weight capacity differs from the derived block-memory fact") + + +def _verify_inference_workspace_buffer( + _sources: DistributedTask | tuple[DistributedTask, ...], + target: PlanBuffer, + context: RelationCheckContext, +) -> None: + source_ir = context.source_ir + if not isinstance(source_ir, DistributedTaskIR) or not isinstance( + source_ir.semantic, TransformerInferenceProgramSemantic + ): + raise TypeError("workspace invariant requires typed inference program semantics") + if target.role is not PlanBufferRole.WORKSPACE or target.storage_class is not AbstractStorageClass.TRANSIENT: + raise ValueError("inference workspace must remain transient workspace storage") + if target.size_bytes != source_ir.semantic.block_memory.working_upper_bound: + raise ValueError("workspace capacity differs from the derived upper bound") + + +INFERENCE_DISTRIBUTION_RULES = ( + relation( + "transformer-inference-distribute", + "Map inference inputs, outputs, and KV state onto the logical tensor-parallel mesh", + source=ModelValue, + target=DistributedValue, + verifier=_verify_inference_distributed_value, + introduces=("owners", "logical sharding", "mesh scope"), + forbids=("physical device", "target implementation", "predicted time"), + ), + relation( + "transformer-inference-decompose", + "Expand one inference phase into observable local and collective tasks", + source=ModelOperation, + target=DistributedTask, + verifier=_verify_inference_distributed_task, + introduces=("logical ranks", "task kind", "collective spec", "distributed dependencies"), + forbids=("physical device", "queue", "kernel", "predicted time"), + ), +) + +INFERENCE_PLANNING_RULES = ( + relation( + "transformer-inference-plan-buffer", + "Materialize inference boundary values as abstract plan buffers", + source=DistributedValue, + target=PlanBuffer, + verifier=_verify_inference_boundary_buffer, + introduces=("exact size", "storage class", "alignment", "producer/consumers"), + forbids=("memory address", "physical memory region", "predicted time"), + ), + relation( + "transformer-inference-plan-cache", + "Materialize persistent KV-cache state for the inference phase", + source=DistributedValue, + target=PlanBuffer, + verifier=_verify_inference_cache_buffer, + introduces=("persistent state buffer", "cache consumers", "phase tag"), + forbids=("physical memory region", "memory address"), + ), + relation( + "transformer-inference-plan-work", + "Materialize target-neutral inference work and capability requirements", + source=DistributedTask, + target=PlanTask, + verifier=_verify_inference_plan_task, + introduces=("exact WorkloadFacts", "resources", "implementation capabilities", "concurrency"), + forbids=("kernel ID", "physical queue", "empirical duration"), + ), + relation( + "transformer-inference-plan-weights", + "Materialize persistent block-weight capacity required by the task set", + source="DistributedTask set", + target=PlanBuffer, + verifier=_verify_inference_weight_buffer, + introduces=("persistent constant buffer", "weight consumers", "alignment"), + forbids=("physical memory region", "memory address"), + ), + relation( + "transformer-inference-plan-workspace", + "Materialize an abstract upper bound for transient phase workspace", + source="DistributedTask set", + target=PlanBuffer, + verifier=_verify_inference_workspace_buffer, + introduces=("transient workspace bound", "workspace consumers", "alignment"), + forbids=("physical memory region", "memory address", "predicted allocation time"), + ), +) + + +def normalize_inference_distribution(ir: ModelIR, session: SynthesisSession) -> DistributedTaskIR: + """Purely derive logical distributed tasks for Transformer inference.""" + model, mapping, phase, batch_size, query_tokens, context_tokens, datatype = _semantic_specs(ir, session) + invocations, block_memory = derive_transformer_inference_block( + model, + mapping, + phase=phase, + batch_size=batch_size, + context_tokens=context_tokens, + datatype=datatype, + ) + tp, _pp, _replicas = _parallelism(mapping) + ranks = tuple(range(tp)) + mesh = LogicalMesh("local-tensor-parallel-group", (MeshAxis("tp", tp),)) + source_input = ir.inputs[0] + source_output = ir.outputs[0] + source_cache = next(value.id for value in ir.values if value.role is ValueRole.KV_CACHE) + input_id = ValueId.derive(ir.digest, phase.value, "transformer-distributed", "input") + cache_id = ValueId.derive(ir.digest, phase.value, "transformer-distributed", "kv-cache") + output_id = ValueId.derive(ir.digest, phase.value, "transformer-distributed", "output") + boundary_type = TensorType((batch_size, query_tokens, model.hidden_size), datatype) + cache_type = TensorType((2, batch_size, context_tokens, model.hidden_size), datatype) + boundary_sharding = ShardingSpec.replicated(boundary_type.rank, ("tp",)) + cache_sharding = ShardingSpec(((), (), (), ("tp",))) + + task_ids = tuple( + NodeId.derive(ir.digest, phase.value, "transformer-distributed", index, invocation.name) + for index, invocation in enumerate(invocations) + ) + tasks = [] + cache_primitives = frozenset({"attention_kv_cache_save", "attention_core"}) + for index, (task_id, invocation) in enumerate(zip(task_ids, invocations)): + body: Collective | LocalCompute + match invocation: + case InferenceInvocation(engine=EngineKind.COLLECTIVE, collective=kind) if kind is not None: + reduction = ( + ReductionKind.SUM if kind in {CollectiveKind.ALL_REDUCE, CollectiveKind.REDUCE_SCATTER} else None + ) + body = Collective(make_collective_spec(kind, ranks, invocation.work.message_bytes, reduction=reduction)) + operation = OperationName("collective", kind.value) + case InferenceInvocation(engine=EngineKind.MATRIX | EngineKind.VECTOR, collective=None): + body = LocalCompute() + operation = OperationName("transformer", f"{invocation.primitive}_{phase.value}") + case _: + raise TypeError("unsupported Transformer inference invocation") + inputs = [] + if index == 0: + inputs.append(input_id) + if invocation.primitive in cache_primitives: + inputs.append(cache_id) + effects: tuple[Effect, ...] + match invocation.primitive: + case "attention_kv_cache_save": + effects = (Effect(EffectKind.WRITE, "kv_cache"),) + case "attention_core": + effects = (Effect(EffectKind.READ, "kv_cache"),) + case _: + effects = () + tasks.append( + DistributedTask( + id=task_id, + body=body, + operation=operation, + ranks=ranks, + inputs=tuple(inputs), + outputs=(output_id,) if index == len(invocations) - 1 else (), + dependencies=(task_ids[index - 1],) if index else (), + lineage=Lineage.lowered("transformer-inference-decompose", (ir.operations[0].id,)), + effects=effects, + semantic=TransformerInferenceDistributedTaskSemantic(invocation), + ) + ) + + distributed = DistributedTaskIR( + name=f"{model.name}-{phase.value}-local-tp-block", + source_model_digest=ir.digest, + mesh=mesh, + values=( + DistributedValue( + input_id, + boundary_type, + ValueRole.INPUT, + boundary_sharding, + ranks, + Lineage.lowered("transformer-inference-distribute", (source_input,)), + source_value=source_input, + ), + DistributedValue( + cache_id, + cache_type, + ValueRole.KV_CACHE, + cache_sharding, + ranks, + Lineage.lowered("transformer-inference-distribute", (source_cache,)), + source_value=source_cache, + ), + DistributedValue( + output_id, + boundary_type, + ValueRole.OUTPUT, + boundary_sharding, + ranks, + Lineage.lowered("transformer-inference-distribute", (source_output,)), + source_value=source_output, + ), + ), + tasks=tuple(tasks), + inputs=(input_id, cache_id), + outputs=(output_id,), + semantic=TransformerInferenceProgramSemantic( + model, + mapping, + phase, + batch_size, + query_tokens, + context_tokens, + datatype, + "one-local-tensor-parallel-block-phase", + block_memory, + ), + header=make_header( + DistributedTaskIR.SCHEMA_NAME, + DistributedTaskIR.SCHEMA_VERSION, + parent_digests=(ir.digest,), + ), + ) + return distributed + + +def _plan_resources(invocation: InferenceInvocation) -> tuple[ResourceRequirement, ...]: + resources = [] + if invocation.work.operations: + resources.append( + ResourceRequirement( + ResourceKind.COMPUTE, + invocation.work.operations, + ResourceScope.PER_RANK, + ) + ) + if invocation.work.memory_bytes: + resources.append( + ResourceRequirement(ResourceKind.MEMORY_BANDWIDTH, invocation.work.memory_bytes, ResourceScope.PER_RANK) + ) + if invocation.work.message_bytes: + resources.append( + ResourceRequirement( + ResourceKind.NETWORK, + invocation.work.message_bytes, + ResourceScope.PER_RANK, + ) + ) + return tuple(resources) + + +def _implementation(invocation: InferenceInvocation) -> ImplementationRequirement: + match invocation: + case InferenceInvocation(engine=EngineKind.COLLECTIVE, collective=kind) if kind is not None: + return ImplementationRequirement(kind.value, alternatives=("collective-library", "network-engine")) + case InferenceInvocation(engine=EngineKind.COLLECTIVE): + raise TypeError("collective inference invocation is missing its collective kind") + case _: + pass + alternatives = { + "attention_core": ("flash-attention", "paged-attention", "dense-attention"), + "attention_kv_cache_save": ("fused-kv-write", "vector-engine"), + "attention_pre_projection": ("tensor-core", "matrix-engine"), + "attention_post_projection": ("tensor-core", "matrix-engine"), + "mlp_up_projection": ("tensor-core", "matrix-engine"), + "mlp_down_projection": ("tensor-core", "matrix-engine"), + }.get(invocation.primitive) + if alternatives is not None: + return ImplementationRequirement(invocation.primitive, alternatives=alternatives) + return ImplementationRequirement(invocation.primitive, alternatives=("vector-engine",)) + + +def normalize_inference_plan(ir: DistributedTaskIR, session: SynthesisSession) -> PortablePlanIR: + """Purely derive target-neutral exact work for Transformer inference.""" + program = ir.semantic + if not isinstance(program, TransformerInferenceProgramSemantic): + raise TypeError("distributed inference IR is missing InferenceBlockMemoryFacts") + model = program.model + mapping = program.mapping + phase = program.phase + block_memory = program.block_memory + strategy = session.bindings.strategy + if strategy is None: + raise ValueError("portable inference planning requires a strategy binding") + if not isinstance(strategy.semantic, TransformerInferenceStrategySemantic): + raise TypeError("strategy binding is missing typed Transformer inference semantics") + if strategy.semantic.mapping != mapping: + raise ValueError("strategy binding does not match the distributed program semantics") + + input_id = BufferId.derive(ir.digest, "transformer-inference-portable", "input") + output_id = BufferId.derive(ir.digest, "transformer-inference-portable", "output") + weight_id = BufferId.derive(ir.digest, "transformer-inference-portable", "weights") + cache_id = BufferId.derive(ir.digest, "transformer-inference-portable", "kv-cache") + workspace_id = BufferId.derive(ir.digest, "transformer-inference-portable", "workspace-upper-bound") + task_ids = tuple( + NodeId.derive(ir.digest, "transformer-inference-portable", index, task.id) + for index, task in enumerate(ir.tasks) + ) + task_semantics = tuple(task.semantic for task in ir.tasks) + if any(not isinstance(item, TransformerInferenceDistributedTaskSemantic) for item in task_semantics): + raise TypeError("distributed inference task is missing InferenceInvocation") + typed_semantics = cast(tuple[TransformerInferenceDistributedTaskSemantic, ...], task_semantics) + invocations = tuple(item.invocation for item in typed_semantics) + weight_consumers = tuple( + task_id for task_id, invocation in zip(task_ids, invocations) if invocation.engine is EngineKind.MATRIX + ) + cache_consumers = tuple( + task_id + for task_id, invocation in zip(task_ids, invocations) + if invocation.primitive in {"attention_kv_cache_save", "attention_core"} + ) + + tasks = [] + for index, (source_task, task_id, invocation) in enumerate(zip(ir.tasks, task_ids, invocations)): + inputs = [workspace_id] + if index == 0: + inputs.append(input_id) + if invocation.engine is EngineKind.MATRIX: + inputs.append(weight_id) + if invocation.primitive in {"attention_kv_cache_save", "attention_core"}: + inputs.append(cache_id) + tasks.append( + PlanTask( + id=task_id, + body=CollectiveTask() if invocation.engine is EngineKind.COLLECTIVE else ComputeTask(), + operation=source_task.operation, + dependencies=(task_ids[index - 1],) if index else (), + inputs=tuple(inputs), + outputs=(output_id,) if index == len(ir.tasks) - 1 else (), + logical_ranks=source_task.ranks, + workload=WorkloadFacts( + operations=invocation.work.operations, + read_bytes=invocation.work.read_bytes, + write_bytes=invocation.work.write_bytes, + message_bytes=invocation.work.message_bytes, + ), + lineage=Lineage.lowered("transformer-inference-plan-work", (source_task.id,)), + resources=_plan_resources(invocation), + implementations=(_implementation(invocation),), + concurrency_group=("network" if invocation.engine is EngineKind.COLLECTIVE else "compute"), + effects=source_task.effects, + semantic=inference_task_semantic( + invocation, + query_tokens=program.query_tokens, + context_tokens=program.context_tokens, + ), + ) + ) + + return PortablePlanIR( + name=f"{model.name}-{phase.value}-local-tp-block-plan", + source_distributed_digest=ir.digest, + strategy_fingerprint=strategy.fingerprint, + planner_revision="transformer-inference-work-analysis", + tasks=tuple(tasks), + buffers=( + PlanBuffer( + input_id, + block_memory.boundary, + PlanBufferRole.INPUT, + AbstractStorageClass.DEVICE_LOCAL, + Lineage.lowered("transformer-inference-plan-buffer", (ir.inputs[0],)), + consumers=(task_ids[0],), + alignment_bytes=16, + ), + PlanBuffer( + output_id, + block_memory.boundary, + PlanBufferRole.OUTPUT, + AbstractStorageClass.DEVICE_LOCAL, + Lineage.lowered("transformer-inference-plan-buffer", (ir.outputs[0],)), + producer=task_ids[-1], + alignment_bytes=16, + ), + PlanBuffer( + weight_id, + block_memory.weights, + PlanBufferRole.CONSTANT, + AbstractStorageClass.PERSISTENT, + Lineage.lowered("transformer-inference-plan-weights", tuple(task.id for task in ir.tasks)), + consumers=weight_consumers, + alignment_bytes=16, + semantic=TransformerBufferSemantic("block_weights"), + ), + PlanBuffer( + cache_id, + block_memory.kv_cache, + PlanBufferRole.STATE, + AbstractStorageClass.PERSISTENT, + Lineage.lowered("transformer-inference-plan-cache", (ir.inputs[1],)), + consumers=cache_consumers, + alignment_bytes=16, + semantic=TransformerBufferSemantic("kv_cache", phase=phase), + ), + PlanBuffer( + workspace_id, + block_memory.working_upper_bound, + PlanBufferRole.WORKSPACE, + AbstractStorageClass.TRANSIENT, + Lineage.lowered("transformer-inference-plan-workspace", tuple(task.id for task in ir.tasks)), + consumers=task_ids, + alignment_bytes=16, + semantic=TransformerBufferSemantic( + "block_working_upper_bound", + bound="unfused-score-materialization", + ), + ), + ), + inputs=(input_id,), + outputs=(output_id,), + objectives=( + PlanObjective(ObjectiveKind.LATENCY, ObjectiveDirection.MINIMIZE), + PlanObjective(ObjectiveKind.PEAK_MEMORY, ObjectiveDirection.MINIMIZE), + ), + semantic=TransformerInferencePlanSemantic( + model, + mapping, + phase, + program.batch_size, + program.query_tokens, + program.context_tokens, + program.datatype, + program.scope, + block_memory, + ), + header=make_header( + PortablePlanIR.SCHEMA_NAME, + PortablePlanIR.SCHEMA_VERSION, + parent_digests=(ir.digest,), + ), + ) diff --git a/src/blueprinting/synthesizer/dialects/transformer/semantics.py b/src/blueprinting/synthesizer/dialects/transformer/semantics.py new file mode 100644 index 0000000..78b7050 --- /dev/null +++ b/src/blueprinting/synthesizer/dialects/transformer/semantics.py @@ -0,0 +1,148 @@ +"""Registered typed semantic payloads for the Transformer dialect.""" + +from __future__ import annotations + +from blueprinting.mapping import TransformerInferenceMappingSpec, TransformerTrainingMappingSpec +from blueprinting.schema.authoring import NonEmptyText, PositiveInt, record +from blueprinting.workload import TransformerDataType, TransformerModelSpec, TransformerTrainingWorkloadSpec + +from ...bindings import InferencePhase +from ...semantics import ( + BindingSemantic, + BufferSemantic, + DistributedTaskSemantic, + ModelOperationSemantic, + PlanTaskSemantic, + ProgramSemantic, +) +from ...stages.distributed.ir import CollectiveKind +from .common import EngineKind +from .inference import InferenceBlockMemoryFacts, InferenceInvocation +from .training import BlockMemoryFacts, PrimitiveInvocation, TrainingPhase + + +@record("blueprinting.ir.semantic.transformer.model-operation-semantic") +class TransformerModelOperationSemantic(ModelOperationSemantic): + model: TransformerModelSpec + + +@record("blueprinting.ir.semantic.transformer.training-workload-binding-semantic") +class TransformerTrainingWorkloadSemantic(BindingSemantic): + workload: TransformerTrainingWorkloadSpec + + +@record("blueprinting.ir.semantic.transformer.training-strategy-binding-semantic") +class TransformerTrainingStrategySemantic(BindingSemantic): + mapping: TransformerTrainingMappingSpec + + +@record("blueprinting.ir.semantic.transformer.inference-workload-binding-semantic") +class TransformerInferenceWorkloadSemantic(BindingSemantic): + batch_size: PositiveInt + context_tokens: PositiveInt + datatype: TransformerDataType + + +@record("blueprinting.ir.semantic.transformer.inference-strategy-binding-semantic") +class TransformerInferenceStrategySemantic(BindingSemantic): + mapping: TransformerInferenceMappingSpec + + +@record("blueprinting.ir.semantic.transformer.training-distributed-task-semantic") +class TransformerTrainingDistributedTaskSemantic(DistributedTaskSemantic): + invocation: PrimitiveInvocation + + +@record("blueprinting.ir.semantic.transformer.inference-distributed-task-semantic") +class TransformerInferenceDistributedTaskSemantic(DistributedTaskSemantic): + invocation: InferenceInvocation + + +@record("blueprinting.ir.semantic.transformer.training-plan-task-semantic") +class TransformerTrainingPlanTaskSemantic(PlanTaskSemantic): + name: str + source_layer: str + primitive: str + phase: TrainingPhase + engine: EngineKind + collective: CollectiveKind | None = None + + +@record("blueprinting.ir.semantic.transformer.inference-plan-task-semantic") +class TransformerInferencePlanTaskSemantic(PlanTaskSemantic): + name: str + source_layer: str + primitive: str + phase: InferencePhase + engine: EngineKind + query_tokens: int + context_tokens: int + collective: CollectiveKind | None = None + + +@record("blueprinting.ir.semantic.transformer.training-program-semantic") +class TransformerTrainingProgramSemantic(ProgramSemantic): + model: TransformerModelSpec + workload: TransformerTrainingWorkloadSpec + mapping: TransformerTrainingMappingSpec + scope: str + block_memory: BlockMemoryFacts + + +@record("blueprinting.ir.semantic.transformer.training-plan-semantic") +class TransformerTrainingPlanSemantic(TransformerTrainingProgramSemantic): + pass + + +@record("blueprinting.ir.semantic.transformer.inference-program-semantic") +class TransformerInferenceProgramSemantic(ProgramSemantic): + model: TransformerModelSpec + mapping: TransformerInferenceMappingSpec + phase: InferencePhase + batch_size: int + query_tokens: int + context_tokens: int + datatype: TransformerDataType + scope: str + block_memory: InferenceBlockMemoryFacts + + +@record("blueprinting.ir.semantic.transformer.inference-plan-semantic") +class TransformerInferencePlanSemantic(TransformerInferenceProgramSemantic): + pass + + +@record("blueprinting.ir.semantic.transformer.buffer-semantic") +class TransformerBufferSemantic(BufferSemantic): + role: NonEmptyText + phase: InferencePhase | None = None + bound: NonEmptyText | None = None + + +def training_task_semantic(invocation: PrimitiveInvocation) -> TransformerTrainingPlanTaskSemantic: + return TransformerTrainingPlanTaskSemantic( + invocation.name, + invocation.source_layer, + invocation.primitive, + invocation.phase, + invocation.engine, + invocation.collective, + ) + + +def inference_task_semantic( + invocation: InferenceInvocation, + *, + query_tokens: int, + context_tokens: int, +) -> TransformerInferencePlanTaskSemantic: + return TransformerInferencePlanTaskSemantic( + invocation.name, + invocation.source_layer, + invocation.primitive, + invocation.phase, + invocation.engine, + query_tokens, + context_tokens, + invocation.collective, + ) diff --git a/src/blueprinting/synthesizer/dialects/transformer/training.py b/src/blueprinting/synthesizer/dialects/transformer/training.py index 99fc48e..81dc668 100644 --- a/src/blueprinting/synthesizer/dialects/transformer/training.py +++ b/src/blueprinting/synthesizer/dialects/transformer/training.py @@ -12,16 +12,16 @@ from enum import Enum from blueprinting.mapping import RecomputePolicy, TensorParallelCommunication, TransformerTrainingMappingSpec -from blueprinting.schema.codec import enum_type, record_type +from blueprinting.schema.authoring import NonEmptyText, NonNegativeInt, enum, record from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec -from ...ir import CollectiveKind +from ...stages.distributed.ir import CollectiveKind from .common import EngineKind, PhaseWork # Keep the legacy codec namespace as a stable serialized identity. -@enum_type("compiler.analysis.training_phase") +@enum("blueprinting.analysis.transformer.training-phase") class TrainingPhase(Enum): FORWARD = "forward" RECOMPUTE = "recompute" @@ -31,29 +31,19 @@ class TrainingPhase(Enum): RECOMMUNICATION = "recommunication" -@record_type("blueprinting.transformer.primitive-invocation.v2") -@dataclass(frozen=True) +@record("blueprinting.ir.semantic.transformer.primitive-invocation") class PrimitiveInvocation: """One structurally selected operation in a local block program.""" - name: str - source_layer: str - primitive: str + name: NonEmptyText + source_layer: NonEmptyText + primitive: NonEmptyText phase: TrainingPhase engine: EngineKind work: PhaseWork collective: CollectiveKind | None = None def __post_init__(self) -> None: - for field_name in ("name", "source_layer", "primitive"): - if not isinstance(getattr(self, field_name), str) or not getattr(self, field_name): - raise ValueError(f"{field_name} must not be empty") - if not isinstance(self.phase, TrainingPhase): - raise TypeError("phase must be TrainingPhase") - if not isinstance(self.engine, EngineKind): - raise TypeError("engine must be EngineKind") - if not isinstance(self.work, PhaseWork): - raise TypeError("work must be PhaseWork") if self.engine is EngineKind.COLLECTIVE: if self.collective is None: raise ValueError("collective invocations require a collective kind") @@ -61,25 +51,18 @@ def __post_init__(self) -> None: raise ValueError("local invocations cannot carry collective metadata") -@record_type("compiler.analysis.block_memory_facts.v1") -@dataclass(frozen=True) +@record("blueprinting.ir.semantic.transformer.training-block-memory") class BlockMemoryFacts: """Storage quantities for one local tensor-parallel block shard.""" - weights: int - activation_working: int - activation_storage: int - activation_checkpoint: int - weight_gradients: int - weight_gradients_unsharded: int - activation_gradients: int - optimizer: int - - def __post_init__(self) -> None: - for field_name in self.__dataclass_fields__: - value = getattr(self, field_name) - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError(f"{field_name} must be a non-negative integer") + weights: NonNegativeInt + activation_working: NonNegativeInt + activation_storage: NonNegativeInt + activation_checkpoint: NonNegativeInt + weight_gradients: NonNegativeInt + weight_gradients_unsharded: NonNegativeInt + activation_gradients: NonNegativeInt + optimizer: NonNegativeInt @dataclass(frozen=True) diff --git a/src/blueprinting/synthesizer/dialects/transformer/training_derivation.py b/src/blueprinting/synthesizer/dialects/transformer/training_derivation.py new file mode 100644 index 0000000..83c5abc --- /dev/null +++ b/src/blueprinting/synthesizer/dialects/transformer/training_derivation.py @@ -0,0 +1,518 @@ +"""Transformer training lowerings from semantic graph to portable work DAG.""" + +from __future__ import annotations + +from typing import cast + +from blueprinting.mapping import ( + DataParallel, + ForwardOnly, + InterleavedOneForwardOneBackward, + OneForwardOneBackward, + PipelineParallel, + RecomputePolicy, + SingleStage, + TensorParallel, + TensorParallelCommunication, + TransformerTrainingMappingSpec, + TransformerTrainingParallelism, +) +from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec + +from ...bindings import TrainingWorkload +from ...ids import BufferId, Lineage, NodeId, ValueId +from ...passes.authoring import RelationCheckContext, relation +from ...session import SynthesisSession +from ...stages.common import OperationName, TensorType, make_header +from ...stages.distributed.ir import ( + Collective, + CollectiveKind, + DistributedTask, + DistributedTaskIR, + DistributedValue, + LocalCompute, + LogicalMesh, + MeshAxis, + ReductionKind, + ShardingSpec, + collective_kind, + make_collective_spec, +) +from ...stages.model.ir import ModelIR, ModelOperation, ModelValue, ValueRole +from ...stages.portable_plan.ir import ( + AbstractStorageClass, + CollectiveTask, + ComputeTask, + ImplementationRequirement, + ObjectiveDirection, + ObjectiveKind, + PlanBuffer, + PlanBufferRole, + PlanObjective, + PlanTask, + PortablePlanIR, + ResourceKind, + ResourceRequirement, + ResourceScope, + WorkloadFacts, +) +from .common import EngineKind +from .semantics import ( + TransformerModelOperationSemantic, + TransformerTrainingDistributedTaskSemantic, + TransformerTrainingPlanSemantic, + TransformerTrainingProgramSemantic, + TransformerTrainingStrategySemantic, + TransformerTrainingWorkloadSemantic, + training_task_semantic, +) +from .training import PrimitiveInvocation, TrainingPhase, derive_transformer_block + + +def _parallelism( + mapping: TransformerTrainingMappingSpec, +) -> tuple[int, int, int, int, TensorParallelCommunication, RecomputePolicy]: + """Destructure the Megatron-style TP × PP × DP strategy.""" + + match mapping.parallelism: + case TransformerTrainingParallelism( + tensor=TensorParallel(degree=tp, communication=communication), + pipeline=PipelineParallel(degree=pp, schedule=schedule), + data=DataParallel(degree=dp), + recompute=recompute, + ): + match schedule: + case SingleStage() | OneForwardOneBackward(): + virtual_stages = 1 + case InterleavedOneForwardOneBackward(virtual_stages=count): + virtual_stages = count + case ForwardOnly(): + raise TypeError("forward-only schedule is invalid for training") + return tp, pp, dp, virtual_stages, communication, recompute + case _: + raise TypeError("unsupported Transformer training parallel strategy") + + +def _semantic_specs( + ir: ModelIR, + session: SynthesisSession, +) -> tuple[TransformerModelSpec, TransformerTrainingWorkloadSpec, TransformerTrainingMappingSpec]: + if len(ir.operations) != 1 or ir.operations[0].operation != OperationName("transformer", "decoder_training"): + raise ValueError("Transformer distribution expects one transformer.decoder_training operation") + operation_semantic = ir.operations[0].semantic + if not isinstance(operation_semantic, TransformerModelOperationSemantic): + raise TypeError("model operation is missing a typed TransformerModelSpec") + model = operation_semantic.model + workload = session.bindings.workload + strategy = session.bindings.strategy + if workload is None or strategy is None: + raise ValueError("Transformer distribution requires workload and strategy bindings") + if not isinstance(workload.mode, TrainingWorkload): + raise ValueError("Transformer training requires a training workload binding") + workload_semantic = workload.semantic + strategy_semantic = strategy.semantic + if not isinstance(workload_semantic, TransformerTrainingWorkloadSemantic): + raise TypeError("workload binding is missing a typed TransformerTrainingWorkloadSpec") + if not isinstance(strategy_semantic, TransformerTrainingStrategySemantic): + raise TypeError("strategy binding is missing a typed TransformerTrainingMappingSpec") + workload_spec = workload_semantic.workload + mapping = strategy_semantic.mapping + mapping.validate_model(model) + mapping.validate_workload(workload_spec) + return model, workload_spec, mapping + + +def _verify_training_distributed_value( + source: ModelValue, + target: DistributedValue, + context: RelationCheckContext, +) -> None: + target_ir = context.target_ir + if not isinstance(target_ir, DistributedTaskIR): + raise TypeError("training value invariant requires DistributedTaskIR") + if target.source_value != source.id or target.role is not source.role: + raise ValueError("distributed value must retain its source identity and role") + if target.type.dtype != source.type.dtype: + raise ValueError("distributed value must preserve boundary datatype") + if target.owners != tuple(range(target_ir.mesh.size)): + raise ValueError("distributed value owners must cover the target logical mesh") + + +def _verify_training_distributed_task( + _source: ModelOperation, + target: DistributedTask, + context: RelationCheckContext, +) -> None: + target_ir = context.target_ir + if not isinstance(target_ir, DistributedTaskIR): + raise TypeError("training task invariant requires DistributedTaskIR") + if target.ranks != tuple(range(target_ir.mesh.size)): + raise ValueError("distributed task ranks must cover the logical mesh") + semantic = target.semantic + if not isinstance(semantic, TransformerTrainingDistributedTaskSemantic): + raise TypeError("distributed task must retain its typed PrimitiveInvocation") + invocation = semantic.invocation + if invocation.engine is EngineKind.COLLECTIVE: + if not isinstance(target.body, Collective) or invocation.collective is None: + raise ValueError("collective invocation must lower to a collective body") + if collective_kind(target.body.spec) is not invocation.collective: + raise ValueError("collective body kind differs from its invocation") + if target.body.spec.message_bytes != invocation.work.message_bytes: + raise ValueError("collective body must conserve exact message bytes") + elif not isinstance(target.body, LocalCompute): + raise ValueError("non-collective invocation must lower to local compute") + + +def _verify_training_plan_buffer( + source: DistributedValue, + target: PlanBuffer, + _context: RelationCheckContext, +) -> None: + expected_role = { + ValueRole.INPUT: PlanBufferRole.INPUT, + ValueRole.OUTPUT: PlanBufferRole.OUTPUT, + }.get(source.role) + if expected_role is None or target.role is not expected_role: + raise ValueError("training boundary buffer must preserve its distributed value role") + if target.storage_class is not AbstractStorageClass.DEVICE_LOCAL: + raise ValueError("training boundary buffers must remain abstract device-local storage") + + +def _verify_training_plan_task( + source: DistributedTask, + target: PlanTask, + _context: RelationCheckContext, +) -> None: + semantic = source.semantic + if not isinstance(semantic, TransformerTrainingDistributedTaskSemantic): + raise TypeError("plan relation source must retain a typed PrimitiveInvocation") + invocation = semantic.invocation + expected_work = WorkloadFacts( + operations=invocation.work.operations, + read_bytes=invocation.work.read_bytes, + write_bytes=invocation.work.write_bytes, + message_bytes=invocation.work.message_bytes, + ) + if target.workload != expected_work: + raise ValueError("portable task must conserve exact distributed workload facts") + if target.operation != source.operation or target.logical_ranks != source.ranks or target.effects != source.effects: + raise ValueError("portable task must preserve operation, logical ranks, and effects") + if target.semantic != training_task_semantic(invocation): + raise ValueError("portable task semantic projection differs from its invocation") + if invocation.engine is EngineKind.COLLECTIVE: + if not isinstance(target.body, CollectiveTask): + raise ValueError("collective invocation must produce CollectiveTask") + elif not isinstance(target.body, ComputeTask): + raise ValueError("compute invocation must produce ComputeTask") + + +TRAINING_DISTRIBUTION_RULES = ( + relation( + "transformer-distribute", + "Map a model boundary value onto the logical tensor-parallel mesh", + source=ModelValue, + target=DistributedValue, + verifier=_verify_training_distributed_value, + introduces=("owners", "logical sharding", "mesh scope"), + forbids=("physical device", "target implementation", "predicted time"), + ), + relation( + "transformer-decompose", + "Expand one semantic decoder-training operation into an ordered local/collective task DAG", + source=ModelOperation, + target=DistributedTask, + verifier=_verify_training_distributed_task, + introduces=("logical ranks", "task kind", "collective spec", "distributed dependencies"), + forbids=("physical device", "queue", "kernel", "predicted time"), + ), +) + +TRAINING_PLANNING_RULES = ( + relation( + "transformer-plan-buffer", + "Materialize a distributed boundary value as an abstract plan buffer", + source=DistributedValue, + target=PlanBuffer, + verifier=_verify_training_plan_buffer, + introduces=("exact size", "storage class", "alignment", "producer/consumers"), + forbids=("memory address", "physical memory region", "predicted time"), + ), + relation( + "transformer-plan-work", + "Materialize target-neutral exact work and capability requirements", + source=DistributedTask, + target=PlanTask, + verifier=_verify_training_plan_task, + introduces=("exact WorkloadFacts", "resource requirements", "implementation capabilities", "concurrency group"), + forbids=("kernel ID", "physical queue", "empirical duration", "wall-clock timestamp"), + ), +) + + +_TRAINING_STAGE = { + TrainingPhase.FORWARD: 0, + TrainingPhase.RECOMPUTE: 1, + TrainingPhase.RECOMMUNICATION: 1, + TrainingPhase.ACTIVATION_GRADIENT: 2, + TrainingPhase.WEIGHT_GRADIENT: 2, + TrainingPhase.OPTIMIZER: 3, +} + + +def _training_dependencies( + invocations: tuple[PrimitiveInvocation, ...], + task_ids: tuple[NodeId, ...], +) -> tuple[tuple[tuple[NodeId, ...], ...], int]: + """Build a conservative phase-ordered DAG and identify the block output producer.""" + + stages = tuple(_TRAINING_STAGE[item.phase] for item in invocations) + if not stages or stages[0] != 0 or any(current < previous for previous, current in zip(stages, stages[1:])): + raise ValueError("training invocations must be ordered by forward/recompute/backward/optimizer stage") + forward_indices = tuple(index for index, item in enumerate(invocations) if item.phase is TrainingPhase.FORWARD) + if not forward_indices: + raise ValueError("training lowering requires at least one forward invocation") + dependencies = tuple((task_ids[index - 1],) if index else () for index in range(len(task_ids))) + return dependencies, forward_indices[-1] + + +def normalize_training_distribution(ir: ModelIR, session: SynthesisSession) -> DistributedTaskIR: + """Purely derive logical distributed tasks for Transformer training.""" + model, workload, mapping = _semantic_specs(ir, session) + invocations, block_memory = derive_transformer_block(model, workload, mapping) + tp, _pp, _dp, _virtual_stages, communication, _recompute = _parallelism(mapping) + ranks = tuple(range(tp)) + mesh = LogicalMesh("local-tensor-parallel-group", (MeshAxis("tp", tp),)) + source_input = ir.inputs[0] + source_output = ir.outputs[0] + input_id = ValueId.derive(ir.digest, "transformer-distributed", "input") + output_id = ValueId.derive(ir.digest, "transformer-distributed", "output") + tensor_type = TensorType( + (workload.microbatch_size, model.sequence_length, model.hidden_size), + workload.datatype, + ) + match communication: + case TensorParallelCommunication.REDUCE_SCATTER_ALL_GATHER: + sharding = ShardingSpec(((), ("tp",), ())) + case TensorParallelCommunication.ALL_REDUCE: + sharding = ShardingSpec.replicated(tensor_type.rank, ("tp",)) + + task_ids = tuple( + NodeId.derive(ir.digest, "transformer-distributed", index, invocation.name) + for index, invocation in enumerate(invocations) + ) + dependencies, output_producer_index = _training_dependencies(invocations, task_ids) + tasks = [] + for index, (task_id, invocation) in enumerate(zip(task_ids, invocations)): + body: Collective | LocalCompute + match invocation: + case PrimitiveInvocation(engine=EngineKind.COLLECTIVE, collective=kind) if kind is not None: + reduction = ( + ReductionKind.SUM if kind in {CollectiveKind.ALL_REDUCE, CollectiveKind.REDUCE_SCATTER} else None + ) + body = Collective(make_collective_spec(kind, ranks, invocation.work.message_bytes, reduction=reduction)) + operation = OperationName("collective", kind.value) + case PrimitiveInvocation(engine=EngineKind.MATRIX | EngineKind.VECTOR, collective=None): + body = LocalCompute() + operation = OperationName("transformer", f"{invocation.primitive}_{invocation.phase.value}") + case _: + raise TypeError("unsupported Transformer training invocation") + tasks.append( + DistributedTask( + id=task_id, + body=body, + operation=operation, + ranks=ranks, + inputs=(input_id,) if index == 0 else (), + outputs=(output_id,) if index == output_producer_index else (), + dependencies=dependencies[index], + lineage=Lineage.lowered("transformer-decompose", (ir.operations[0].id,)), + semantic=TransformerTrainingDistributedTaskSemantic(invocation), + ) + ) + + distributed = DistributedTaskIR( + name=f"{model.name}-local-tp-block", + source_model_digest=ir.digest, + mesh=mesh, + values=( + DistributedValue( + input_id, + tensor_type, + ValueRole.INPUT, + sharding, + ranks, + Lineage.lowered("transformer-distribute", (source_input,)), + source_value=source_input, + ), + DistributedValue( + output_id, + tensor_type, + ValueRole.OUTPUT, + sharding, + ranks, + Lineage.lowered("transformer-distribute", (source_output,)), + source_value=source_output, + ), + ), + tasks=tuple(tasks), + inputs=(input_id,), + outputs=(output_id,), + semantic=TransformerTrainingProgramSemantic( + model, + workload, + mapping, + "one-local-tensor-parallel-block", + block_memory, + ), + header=make_header( + DistributedTaskIR.SCHEMA_NAME, + DistributedTaskIR.SCHEMA_VERSION, + parent_digests=(ir.digest,), + ), + ) + return distributed + + +def _plan_resources(invocation: PrimitiveInvocation) -> tuple[ResourceRequirement, ...]: + resources = [] + if invocation.work.operations: + resources.append( + ResourceRequirement( + ResourceKind.COMPUTE, + invocation.work.operations, + ResourceScope.PER_RANK, + ) + ) + if invocation.work.memory_bytes: + resources.append( + ResourceRequirement( + ResourceKind.MEMORY_BANDWIDTH, + invocation.work.memory_bytes, + ResourceScope.PER_RANK, + ) + ) + if invocation.work.message_bytes: + resources.append( + ResourceRequirement( + ResourceKind.NETWORK, + invocation.work.message_bytes, + ResourceScope.PER_RANK, + ) + ) + return tuple(resources) + + +def normalize_training_plan(ir: DistributedTaskIR, session: SynthesisSession) -> PortablePlanIR: + """Purely derive target-neutral exact work for Transformer training.""" + program = ir.semantic + if not isinstance(program, TransformerTrainingProgramSemantic): + raise TypeError("distributed Transformer IR is missing typed semantic facts") + model = program.model + workload = program.workload + mapping = program.mapping + tp, _pp, _dp, _virtual_stages, communication, _recompute = _parallelism(mapping) + block_memory = program.block_memory + strategy = session.bindings.strategy + if strategy is None: + raise ValueError("portable planning requires a strategy binding") + if not isinstance(strategy.semantic, TransformerTrainingStrategySemantic): + raise TypeError("strategy binding is missing typed Transformer training semantics") + if strategy.semantic.mapping != mapping: + raise ValueError("strategy binding does not match the distributed program semantics") + + input_id = BufferId.derive(ir.digest, "transformer-portable", "input") + output_id = BufferId.derive(ir.digest, "transformer-portable", "output") + boundary_elements = workload.microbatch_size * model.sequence_length * model.hidden_size + if communication is TensorParallelCommunication.REDUCE_SCATTER_ALL_GATHER: + boundary_elements //= tp + boundary_bytes = boundary_elements * workload.bytes_per_element + + task_ids = tuple( + NodeId.derive(ir.digest, "transformer-portable", index, task.id) for index, task in enumerate(ir.tasks) + ) + task_semantics = tuple(task.semantic for task in ir.tasks) + if any(not isinstance(item, TransformerTrainingDistributedTaskSemantic) for item in task_semantics): + raise TypeError("distributed task is missing a typed PrimitiveInvocation") + typed_semantics = cast(tuple[TransformerTrainingDistributedTaskSemantic, ...], task_semantics) + typed_invocations = tuple(item.invocation for item in typed_semantics) + dependencies, output_producer_index = _training_dependencies(typed_invocations, task_ids) + tasks = [] + for index, (source_task, task_id, invocation) in enumerate(zip(ir.tasks, task_ids, typed_invocations)): + alternatives: tuple[str, ...] + match invocation: + case PrimitiveInvocation(engine=EngineKind.MATRIX): + capability, alternatives = "matrix-multiply", ("tensor-core", "matrix-engine") + case PrimitiveInvocation(engine=EngineKind.VECTOR): + capability, alternatives = "vector-elementwise", ("vector-engine",) + case PrimitiveInvocation(engine=EngineKind.COLLECTIVE, collective=kind) if kind is not None: + capability, alternatives = kind.value, ("collective-library", "network-engine") + case _: + raise TypeError("unsupported Transformer training invocation") + tasks.append( + PlanTask( + id=task_id, + body=CollectiveTask() if invocation.engine is EngineKind.COLLECTIVE else ComputeTask(), + operation=source_task.operation, + dependencies=dependencies[index], + inputs=(input_id,) if index == 0 else (), + outputs=(output_id,) if index == output_producer_index else (), + logical_ranks=source_task.ranks, + workload=WorkloadFacts( + operations=invocation.work.operations, + read_bytes=invocation.work.read_bytes, + write_bytes=invocation.work.write_bytes, + message_bytes=invocation.work.message_bytes, + ), + lineage=Lineage.lowered("transformer-plan-work", (source_task.id,)), + resources=_plan_resources(invocation), + implementations=(ImplementationRequirement(capability, alternatives=alternatives),), + concurrency_group=("network" if invocation.engine is EngineKind.COLLECTIVE else "compute"), + semantic=training_task_semantic(invocation), + ) + ) + + return PortablePlanIR( + name=f"{model.name}-local-tp-block-plan", + source_distributed_digest=ir.digest, + strategy_fingerprint=strategy.fingerprint, + planner_revision="transformer-work-analysis", + tasks=tuple(tasks), + buffers=( + PlanBuffer( + input_id, + boundary_bytes, + PlanBufferRole.INPUT, + AbstractStorageClass.DEVICE_LOCAL, + Lineage.lowered("transformer-plan-buffer", (ir.inputs[0],)), + consumers=(task_ids[0],), + alignment_bytes=16, + ), + PlanBuffer( + output_id, + boundary_bytes, + PlanBufferRole.OUTPUT, + AbstractStorageClass.DEVICE_LOCAL, + Lineage.lowered("transformer-plan-buffer", (ir.outputs[0],)), + producer=task_ids[output_producer_index], + alignment_bytes=16, + ), + ), + inputs=(input_id,), + outputs=(output_id,), + objectives=( + PlanObjective(ObjectiveKind.LATENCY, ObjectiveDirection.MINIMIZE), + PlanObjective(ObjectiveKind.PEAK_MEMORY, ObjectiveDirection.MINIMIZE), + ), + semantic=TransformerTrainingPlanSemantic( + model, + workload, + mapping, + program.scope, + block_memory, + ), + header=make_header( + PortablePlanIR.SCHEMA_NAME, + PortablePlanIR.SCHEMA_VERSION, + parent_digests=(ir.digest,), + ), + ) diff --git a/src/blueprinting/synthesizer/errors.py b/src/blueprinting/synthesizer/errors.py index 6ce9c05..083c46b 100644 --- a/src/blueprinting/synthesizer/errors.py +++ b/src/blueprinting/synthesizer/errors.py @@ -8,8 +8,19 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass -from enum import Enum + +from blueprinting.schema.diagnostics import ( + Diagnostic as Diagnostic, +) +from blueprinting.schema.diagnostics import ( + DiagnosticBag as _DiagnosticBag, +) +from blueprinting.schema.diagnostics import ( + DiagnosticSet, +) +from blueprinting.schema.diagnostics import ( + Severity as Severity, +) class SynthesisError(Exception): @@ -45,49 +56,8 @@ def __init__(self, pass_name: str, cause: BaseException): super().__init__(f"pass {pass_name!r} failed: {cause}") -class Severity(Enum): - """Diagnostic severity.""" - - ERROR = "error" - WARNING = "warning" - - -@dataclass(frozen=True) -class Diagnostic: - """One stable, machine-readable verification diagnostic.""" - - code: str - message: str - path: tuple[str, ...] = () - severity: Severity = Severity.ERROR - hint: str | None = None - - def render(self) -> str: - location = ".".join(self.path) if self.path else "" - suffix = f" Hint: {self.hint}" if self.hint else "" - return f"[{self.code}] {location}: {self.message}{suffix}" - - -@dataclass(frozen=True) -class VerificationReport: - """Immutable result of verifying one IR snapshot.""" - - diagnostics: tuple[Diagnostic, ...] = () - - @property - def errors(self) -> tuple[Diagnostic, ...]: - return tuple(item for item in self.diagnostics if item.severity is Severity.ERROR) - - @property - def warnings(self) -> tuple[Diagnostic, ...]: - return tuple(item for item in self.diagnostics if item.severity is Severity.WARNING) - - @property - def ok(self) -> bool: - return not self.errors - - def extend(self, other: VerificationReport) -> VerificationReport: - return VerificationReport(self.diagnostics + other.diagnostics) +class VerificationReport(DiagnosticSet): + """Backward-compatible name for the domain-free immutable diagnostics.""" def require_ok(self, subject: str = "IR") -> None: if not self.ok: @@ -104,39 +74,8 @@ def __init__(self, subject: str, diagnostics: Iterable[Diagnostic]): super().__init__(f"{subject} verification failed:\n{rendered}") -class DiagnosticBag: - """Mutable diagnostic accumulator scoped to one verifier invocation.""" - - __slots__ = ("_items",) - - def __init__(self) -> None: - self._items = [] - - def error( - self, - code: str, - message: str, - *path: str, - hint: str | None = None, - ) -> None: - self._items.append(Diagnostic(code=code, message=message, path=tuple(path), hint=hint)) - - def warning( - self, - code: str, - message: str, - *path: str, - hint: str | None = None, - ) -> None: - self._items.append( - Diagnostic( - code=code, - message=message, - path=tuple(path), - severity=Severity.WARNING, - hint=hint, - ) - ) +class DiagnosticBag(_DiagnosticBag): + """Compatibility builder that publishes VerificationReport.""" def report(self) -> VerificationReport: - return VerificationReport(tuple(self._items)) + return VerificationReport(super().report().diagnostics) diff --git a/src/blueprinting/synthesizer/expr.py b/src/blueprinting/synthesizer/expr.py index 1a40fcb..8fb245b 100644 --- a/src/blueprinting/synthesizer/expr.py +++ b/src/blueprinting/synthesizer/expr.py @@ -1,30 +1,41 @@ -"""Minimal immutable expression AST for exact workload quantities. +"""Immutable algebraic expressions for exact workload quantities. -This AST deliberately models exact quantities, not target performance. It is -closed, serializable, and safe to partially bind without evaluating arbitrary -Python or SymPy input. +Each operation is a distinct constructor, so invalid arity is not representable. +The small ``ExprOp`` enum remains only as a convenient smart-constructor input; +it is not part of the canonical expression representation. """ from __future__ import annotations import math from collections.abc import Mapping -from dataclasses import dataclass from enum import Enum from numbers import Real -from typing import Any, Union - -from blueprinting.schema.codec import enum_type, record_type +from typing import Annotated, Any, ClassVar, TypeAlias, cast + +from typing_extensions import assert_never + +from blueprinting.schema.authoring import ( + FiniteFloat, + SymbolName, + ValueConstraint, + VariantSpec, + adt, + is_adt_variant, + record, + seal_adt, + variant, +) from .axes import BindingAxis from .errors import BindingError Number = int | float -Scalar = Union[int, float, "Symbol", "ScalarExpr"] -@enum_type("compiler.expr_op") class ExprOp(Enum): + """Surface syntax accepted by :func:`expression`; not a wire discriminator.""" + ADD = "add" SUB = "sub" MUL = "mul" @@ -36,106 +47,172 @@ class ExprOp(Enum): class _ExpressionOperators: def __add__(self, other: Scalar) -> Scalar: - return expression(ExprOp.ADD, self, other) + return expression(ExprOp.ADD, cast(Scalar, self), other) def __radd__(self, other: Scalar) -> Scalar: - return expression(ExprOp.ADD, other, self) + return expression(ExprOp.ADD, other, cast(Scalar, self)) def __sub__(self, other: Scalar) -> Scalar: - return expression(ExprOp.SUB, self, other) + return expression(ExprOp.SUB, cast(Scalar, self), other) def __rsub__(self, other: Scalar) -> Scalar: - return expression(ExprOp.SUB, other, self) + return expression(ExprOp.SUB, other, cast(Scalar, self)) def __mul__(self, other: Scalar) -> Scalar: - return expression(ExprOp.MUL, self, other) + return expression(ExprOp.MUL, cast(Scalar, self), other) def __rmul__(self, other: Scalar) -> Scalar: - return expression(ExprOp.MUL, other, self) + return expression(ExprOp.MUL, other, cast(Scalar, self)) def __truediv__(self, other: Scalar) -> Scalar: - return expression(ExprOp.DIV, self, other) + return expression(ExprOp.DIV, cast(Scalar, self), other) def __rtruediv__(self, other: Scalar) -> Scalar: - return expression(ExprOp.DIV, other, self) + return expression(ExprOp.DIV, other, cast(Scalar, self)) -@record_type("compiler.symbol") -@dataclass(frozen=True) +@record("blueprinting.expression.symbol") class Symbol(_ExpressionOperators): - name: str + name: SymbolName axis: BindingAxis integer: bool = True positive: bool = False - def __post_init__(self) -> None: - if not self.name or not self.name.replace("_", "a").isalnum(): - raise ValueError(f"invalid symbol name: {self.name!r}") - if not isinstance(self.axis, BindingAxis): - raise TypeError("symbol axis must be a BindingAxis") - -@record_type("compiler.scalar_expr") -@dataclass(frozen=True) +@adt(wire="blueprinting.expression.scalar") class ScalarExpr(_ExpressionOperators): - op: ExprOp - args: tuple[Scalar, ...] - - def __post_init__(self) -> None: - if not isinstance(self.op, ExprOp): - raise TypeError("scalar expression operation must be ExprOp") - args = tuple(_coerce(item) for item in self.args) - object.__setattr__(self, "args", args) - if self.op in (ExprOp.SUB, ExprOp.DIV, ExprOp.CEIL_DIV) and len(args) != 2: - raise ValueError(f"{self.op.value} expects exactly two arguments") - if self.op in (ExprOp.ADD, ExprOp.MUL, ExprOp.MAX, ExprOp.MIN) and len(args) < 2: - raise ValueError(f"{self.op.value} expects at least two arguments") + """Closed family of exact scalar expression constructors.""" + + __variant_spec__: ClassVar[VariantSpec] @property def free_symbols(self) -> tuple[Symbol, ...]: - found = set() - for item in self.args: + found: set[Symbol] = set() + for item in operands(cast(ScalarExprVariant, self)): found.update(free_symbols(item)) return tuple(sorted(found, key=lambda symbol: (symbol.axis.value, symbol.name))) def subs(self, bindings: Mapping[Any, Number]) -> Scalar: - return substitute(self, bindings) + return substitute(cast(ScalarExprVariant, self), bindings) def evaluate(self, bindings: Mapping[Any, Number]) -> Number: - result = substitute(self, bindings) - if isinstance(result, (Symbol, ScalarExpr)): - missing = ", ".join(f"{item.axis.value}.{item.name}" for item in free_symbols(result)) + result = substitute(cast(ScalarExprVariant, self), bindings) + if isinstance(result, Symbol) or is_adt_variant(result, ScalarExpr): + missing = ", ".join(f"{item.axis.value}.{item.name}" for item in free_symbols(cast(Scalar, result))) raise BindingError(f"expression still has unbound symbols: {missing}") - return result + return cast(Number, result) + + +@variant("add") +class Add(ScalarExpr): + terms: ScalarOperands + + +@variant("subtract") +class Subtract(ScalarExpr): + left: Scalar + right: Scalar + + +@variant("multiply") +class Multiply(ScalarExpr): + factors: ScalarOperands + + +@variant("divide") +class Divide(ScalarExpr): + numerator: Scalar + denominator: Scalar + + +@variant("ceil-divide") +class CeilDivide(ScalarExpr): + numerator: Scalar + denominator: Scalar + + +@variant("maximum") +class Maximum(ScalarExpr): + values: ScalarOperands + + +@variant("minimum") +class Minimum(ScalarExpr): + values: ScalarOperands + + +ScalarExprVariant: TypeAlias = Add | Subtract | Multiply | Divide | CeilDivide | Maximum | Minimum +seal_adt(ScalarExpr, ScalarExprVariant) +Scalar: TypeAlias = int | FiniteFloat | Symbol | ScalarExprVariant +ScalarOperands: TypeAlias = Annotated[ + tuple[Scalar, ...], + ValueConstraint.AT_LEAST_TWO_ITEMS, +] def _coerce(value: Scalar) -> Scalar: - if isinstance(value, bool) or not isinstance(value, (Real, Symbol, ScalarExpr)): + if isinstance(value, bool) or not (isinstance(value, (Real, Symbol)) or is_adt_variant(value, ScalarExpr)): raise TypeError(f"unsupported scalar expression value: {value!r}") if isinstance(value, float) and not math.isfinite(value): raise ValueError("scalar expressions do not permit NaN or infinity") - return value + return cast(Scalar, value) + + +def operands(value: ScalarExprVariant) -> tuple[Scalar, ...]: + """Return constructor operands for generic visitors without exposing arity tags.""" + + match value: + case Add(terms=terms): + return terms + case Subtract(left=left, right=right): + return (left, right) + case Multiply(factors=factors): + return factors + case Divide(numerator=left, denominator=right) | CeilDivide(numerator=left, denominator=right): + return (left, right) + case Maximum(values=values) | Minimum(values=values): + return values + assert_never(value) def expression(op: ExprOp, *args: Scalar) -> Scalar: - """Create a lightly folded expression while preserving operand order.""" + """Create a lightly folded algebraic expression while preserving operand order.""" values = tuple(_coerce(item) for item in args) if all(isinstance(item, Real) and not isinstance(item, bool) for item in values): - return _evaluate_numeric(op, values) # type: ignore[arg-type] - if op is ExprOp.ADD: - values = tuple(item for item in values if item != 0) - if len(values) == 1: - return values[0] - elif op is ExprOp.MUL: - if any(item == 0 for item in values): - return 0 - values = tuple(item for item in values if item != 1) - if len(values) == 1: - return values[0] - elif op is ExprOp.SUB and values[1] == 0: - return values[0] - return ScalarExpr(op=op, args=values) + return _evaluate_numeric(op, cast(tuple[Number, ...], values)) + match op: + case ExprOp.ADD: + values = tuple(item for item in values if item != 0) + if len(values) == 1: + return values[0] + return Add(values) + case ExprOp.SUB: + if len(values) != 2: + raise ValueError("subtract expects exactly two arguments") + if values[1] == 0: + return values[0] + return Subtract(values[0], values[1]) + case ExprOp.MUL: + if any(item == 0 for item in values): + return 0 + values = tuple(item for item in values if item != 1) + if len(values) == 1: + return values[0] + return Multiply(values) + case ExprOp.DIV: + if len(values) != 2: + raise ValueError("divide expects exactly two arguments") + return Divide(values[0], values[1]) + case ExprOp.CEIL_DIV: + if len(values) != 2: + raise ValueError("ceil-divide expects exactly two arguments") + return CeilDivide(values[0], values[1]) + case ExprOp.MAX: + return Maximum(values) + case ExprOp.MIN: + return Minimum(values) + assert_never(op) def ceil_div(left: Scalar, right: Scalar) -> Scalar: @@ -153,7 +230,7 @@ def minimum(*values: Scalar) -> Scalar: def free_symbols(value: Scalar) -> tuple[Symbol, ...]: if isinstance(value, Symbol): return (value,) - if isinstance(value, ScalarExpr): + if is_adt_variant(value, ScalarExpr): return value.free_symbols return () @@ -168,9 +245,24 @@ def substitute(value: Scalar, bindings: Mapping[Any, Number]) -> Scalar: if value.name in bindings: return _validate_bound_value(value, bindings[value.name]) return value - if isinstance(value, ScalarExpr): - return expression(value.op, *(substitute(item, bindings) for item in value.args)) - return value + match value: + case Add(terms=terms): + return expression(ExprOp.ADD, *(substitute(item, bindings) for item in terms)) + case Subtract(left=left, right=right): + return expression(ExprOp.SUB, substitute(left, bindings), substitute(right, bindings)) + case Multiply(factors=factors): + return expression(ExprOp.MUL, *(substitute(item, bindings) for item in factors)) + case Divide(numerator=left, denominator=right): + return expression(ExprOp.DIV, substitute(left, bindings), substitute(right, bindings)) + case CeilDivide(numerator=left, denominator=right): + return expression(ExprOp.CEIL_DIV, substitute(left, bindings), substitute(right, bindings)) + case Maximum(values=values): + return expression(ExprOp.MAX, *(substitute(item, bindings) for item in values)) + case Minimum(values=values): + return expression(ExprOp.MIN, *(substitute(item, bindings) for item in values)) + case int() | float(): + return value + assert_never(value) def _validate_bound_value(symbol: Symbol, value: Number) -> Number: @@ -184,25 +276,63 @@ def _validate_bound_value(symbol: Symbol, value: Number) -> Number: def _evaluate_numeric(op: ExprOp, values: tuple[Number, ...]) -> Number: - if op is ExprOp.ADD: - return sum(values) - if op is ExprOp.SUB: - return values[0] - values[1] - if op is ExprOp.MUL: - result: Number = 1 - for item in values: - result *= item - return result - if op is ExprOp.DIV: - if values[1] == 0: - raise ZeroDivisionError("division by zero in scalar expression") - return values[0] / values[1] - if op is ExprOp.CEIL_DIV: - if values[1] == 0: - raise ZeroDivisionError("division by zero in scalar expression") - return math.ceil(values[0] / values[1]) - if op is ExprOp.MAX: - return max(values) - if op is ExprOp.MIN: - return min(values) - raise AssertionError(f"unhandled expression operation: {op}") + match op: + case ExprOp.ADD: + if len(values) < 2: + raise ValueError("add expects at least two arguments") + return sum(values) + case ExprOp.SUB: + if len(values) != 2: + raise ValueError("subtract expects exactly two arguments") + return values[0] - values[1] + case ExprOp.MUL: + if len(values) < 2: + raise ValueError("multiply expects at least two arguments") + result: Number = 1 + for item in values: + result *= item + return result + case ExprOp.DIV: + if len(values) != 2: + raise ValueError("divide expects exactly two arguments") + if values[1] == 0: + raise ZeroDivisionError("division by zero in scalar expression") + return values[0] / values[1] + case ExprOp.CEIL_DIV: + if len(values) != 2: + raise ValueError("ceil-divide expects exactly two arguments") + if values[1] == 0: + raise ZeroDivisionError("division by zero in scalar expression") + return math.ceil(values[0] / values[1]) + case ExprOp.MAX: + if len(values) < 2: + raise ValueError("maximum expects at least two arguments") + return max(values) + case ExprOp.MIN: + if len(values) < 2: + raise ValueError("minimum expects at least two arguments") + return min(values) + assert_never(op) + + +__all__ = [ + "Add", + "CeilDivide", + "Divide", + "ExprOp", + "Maximum", + "Minimum", + "Multiply", + "Scalar", + "ScalarExpr", + "ScalarExprVariant", + "Subtract", + "Symbol", + "ceil_div", + "expression", + "free_symbols", + "maximum", + "minimum", + "operands", + "substitute", +] diff --git a/src/blueprinting/synthesizer/frontend/transformer.py b/src/blueprinting/synthesizer/frontend/transformer.py index 41999d7..75493ec 100644 --- a/src/blueprinting/synthesizer/frontend/transformer.py +++ b/src/blueprinting/synthesizer/frontend/transformer.py @@ -8,23 +8,31 @@ from blueprinting.mapping import TransformerTrainingMappingSpec from blueprinting.schema.frozen import FrozenDict -from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec +from blueprinting.workload import ( + TransformerDataType, + TransformerModelSpec, + TransformerTrainingWorkloadSpec, + require_transformer_data_type, +) from ..axes import BindingAxis -from ..bindings import BindingSet, StrategyBinding, WorkloadBinding, WorkloadMode +from ..bindings import BindingSet, StrategyBinding, TrainingWorkload, WorkloadBinding +from ..dialects.transformer import ( + TransformerModelOperationSemantic, + TransformerTrainingStrategySemantic, + TransformerTrainingWorkloadSemantic, +) from ..expr import Symbol from ..ids import Lineage, NodeId, ValueId -from ..ir import ModelIR, ModelOperation, ModelValue, OperationName, TensorType, ValueRole from ..session import SynthesisSession +from ..stages.common import OperationName, TensorType +from ..stages.model.ir import ModelIR, ModelOperation, ModelValue, ValueRole -_SUPPORTED_DATATYPES = frozenset({"float8", "float16", "bfloat16", "float32"}) - -def build_transformer_model_ir(model: TransformerModelSpec, *, datatype: str = "float16") -> ModelIR: +def build_transformer_model_ir(model: TransformerModelSpec, *, datatype: TransformerDataType = "float16") -> ModelIR: """Import a model as one semantic operation before structural lowering.""" - if datatype not in _SUPPORTED_DATATYPES: - raise ValueError(f"unsupported datatype: {datatype!r}") + datatype = require_transformer_data_type(datatype) batch = Symbol("microbatch_size", BindingAxis.WORKLOAD, positive=True) sequence = Symbol("sequence_length", BindingAxis.WORKLOAD, positive=True) tensor_type = TensorType((batch, sequence, model.hidden_size), datatype) @@ -50,7 +58,7 @@ def build_transformer_model_ir(model: TransformerModelSpec, *, datatype: str = " (input_id,), (output_id,), Lineage.root("transformer-import"), - attributes=FrozenDict({"model_spec": model}), + semantic=TransformerModelOperationSemantic(model), ), ), inputs=(input_id,), @@ -69,21 +77,13 @@ def synthesis_session_for( mapping.validate_model(model) mapping.validate_workload(workload_spec) workload = WorkloadBinding( - WorkloadMode.TRAINING, - batch_size=workload_spec.microbatch_size, - sequence_length=model.sequence_length, - micro_batches=mapping.microbatch_count(workload_spec), - attributes=FrozenDict({"workload_spec": workload_spec}), + TrainingWorkload(), + semantic=TransformerTrainingWorkloadSemantic(workload_spec), ) strategy = StrategyBinding( - tensor_parallel=mapping.tensor_parallel, - pipeline_parallel=mapping.pipeline_parallel, - data_parallel=mapping.data_parallel, - recompute_policy=mapping.recompute.value, - pipeline_policy=f"1f1b-interleaved-{mapping.pipeline_interleaving}", - attributes=FrozenDict({"mapping_spec": mapping}), + semantic=TransformerTrainingStrategySemantic(mapping), ) return SynthesisSession( bindings=BindingSet(workload=workload, strategy=strategy), - features=frozenset({"transformer-training-analysis-v2"}), + features=frozenset({"transformer-training-analysis"}), ) diff --git a/src/blueprinting/synthesizer/frontend/transformer_inference.py b/src/blueprinting/synthesizer/frontend/transformer_inference.py index 30e09a5..8989615 100644 --- a/src/blueprinting/synthesizer/frontend/transformer_inference.py +++ b/src/blueprinting/synthesizer/frontend/transformer_inference.py @@ -6,28 +6,32 @@ from blueprinting.mapping import TransformerInferenceMappingSpec from blueprinting.schema.frozen import FrozenDict -from blueprinting.workload import TransformerModelSpec +from blueprinting.workload import TransformerDataType, TransformerModelSpec, require_transformer_data_type from ..axes import BindingAxis -from ..bindings import BindingSet, InferencePhase, StrategyBinding, WorkloadBinding, WorkloadMode +from ..bindings import BindingSet, InferencePhase, InferenceWorkload, StrategyBinding, WorkloadBinding +from ..dialects.transformer import ( + TransformerInferenceStrategySemantic, + TransformerInferenceWorkloadSemantic, + TransformerModelOperationSemantic, +) from ..expr import Symbol from ..ids import Lineage, NodeId, ValueId -from ..ir import Effect, EffectKind, ModelIR, ModelOperation, ModelValue, OperationName, TensorType, ValueRole from ..session import SynthesisSession - -_SUPPORTED_DATATYPES = frozenset({"float8", "float16", "bfloat16", "float32"}) +from ..stages.common import Effect, EffectKind, OperationName, TensorType +from ..stages.model.ir import ModelIR, ModelOperation, ModelValue, ValueRole def _positive_integer(value: Any, name: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise ValueError(f"{name} must be a positive integer") - return value + return int(value) def build_transformer_inference_model_ir( model: TransformerModelSpec, *, - datatype: str = "float16", + datatype: TransformerDataType = "float16", ) -> ModelIR: """Import a phase-neutral decoder inference operation. @@ -35,8 +39,7 @@ def build_transformer_inference_model_ir( extent is supplied by a phase workload binding, not embedded in the model. """ - if datatype not in _SUPPORTED_DATATYPES: - raise ValueError(f"unsupported datatype: {datatype!r}") + datatype = require_transformer_data_type(datatype) batch = Symbol("batch_size", BindingAxis.WORKLOAD, positive=True) context = Symbol("sequence_length", BindingAxis.WORKLOAD, positive=True) query = Symbol("query_tokens", BindingAxis.WORKLOAD, positive=True) @@ -71,7 +74,7 @@ def build_transformer_inference_model_ir( (output_id,), Lineage.root("transformer-inference-import"), effects=(Effect(EffectKind.STATE, "kv_cache"),), - attributes=FrozenDict({"model_spec": model}), + semantic=TransformerModelOperationSemantic(model), ), ), inputs=(input_id,), @@ -79,7 +82,7 @@ def build_transformer_inference_model_ir( attributes=FrozenDict( { "model_family": "decoder-only-transformer", - "workload_mode": WorkloadMode.INFERENCE.value, + "workload_mode": "inference", } ), ) @@ -92,40 +95,24 @@ def inference_synthesis_session_for( phase: InferencePhase, batch_size: int, context_tokens: int, - datatype: str = "float16", + datatype: TransformerDataType = "float16", ) -> SynthesisSession: """Create an explicit phase binding for static inference specialization.""" mapping.validate_model(model) _positive_integer(batch_size, "batch_size") _positive_integer(context_tokens, "context_tokens") - if datatype not in _SUPPORTED_DATATYPES: - raise ValueError(f"unsupported datatype: {datatype!r}") + datatype = require_transformer_data_type(datatype) if not isinstance(phase, InferencePhase): raise TypeError("phase must be InferencePhase") - query_tokens = context_tokens if phase is InferencePhase.PREFILL else 1 workload = WorkloadBinding( - WorkloadMode.INFERENCE, - batch_size=batch_size, - sequence_length=context_tokens, - inference_phase=phase, - attributes=FrozenDict( - { - "query_tokens": query_tokens, - "context_tokens": context_tokens, - "datatype": datatype, - } - ), + InferenceWorkload(phase), + semantic=TransformerInferenceWorkloadSemantic(batch_size, context_tokens, datatype), ) strategy = StrategyBinding( - tensor_parallel=mapping.tensor_parallel, - pipeline_parallel=mapping.pipeline_parallel, - data_parallel=mapping.replicas, - recompute_policy="none", - pipeline_policy="static-inference", - attributes=FrozenDict({"inference_mapping_spec": mapping}), + semantic=TransformerInferenceStrategySemantic(mapping), ) return SynthesisSession( bindings=BindingSet(workload=workload, strategy=strategy), - features=frozenset({"transformer-inference-analysis-v2", f"inference-{phase.value}"}), + features=frozenset({"transformer-inference-analysis"}), ) diff --git a/src/blueprinting/synthesizer/ids.py b/src/blueprinting/synthesizer/ids.py index 6cd1c76..74719d2 100644 --- a/src/blueprinting/synthesizer/ids.py +++ b/src/blueprinting/synthesizer/ids.py @@ -8,9 +8,10 @@ from collections.abc import Iterable from dataclasses import dataclass from enum import Enum -from typing import Any, ClassVar, TypeVar +from typing import Annotated, Any, ClassVar, TypeAlias, TypeVar -from blueprinting.schema.codec import canonical_dumps, enum_type, record_type +from blueprinting.schema.authoring import NonEmptyText, ValueConstraint, enum, record +from blueprinting.schema.codec import canonical_dumps from .errors import InvalidIdError @@ -52,61 +53,52 @@ def __str__(self) -> str: return f"{self.PREFIX}:{self.value}" -@record_type("compiler.id.node") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.node", order=True) class NodeId(StableId): PREFIX: ClassVar[str] = "node" -@record_type("compiler.id.value") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.value", order=True) class ValueId(StableId): PREFIX: ClassVar[str] = "value" -@record_type("compiler.id.buffer") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.buffer", order=True) class BufferId(StableId): PREFIX: ClassVar[str] = "buffer" -@record_type("compiler.id.command") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.command", order=True) class CommandId(StableId): PREFIX: ClassVar[str] = "command" -@record_type("compiler.id.instruction") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.instruction", order=True) class InstructionId(StableId): PREFIX: ClassVar[str] = "instruction" -@record_type("compiler.id.device") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.device", order=True) class DeviceId(StableId): PREFIX: ClassVar[str] = "device" -@record_type("compiler.id.queue") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.queue", order=True) class QueueId(StableId): PREFIX: ClassVar[str] = "queue" -@record_type("compiler.id.memory_region") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.memory-region", order=True) class MemoryRegionId(StableId): PREFIX: ClassVar[str] = "memory-region" -@record_type("compiler.id.token") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.id.token", order=True) class TokenId(StableId): PREFIX: ClassVar[str] = "token" -@enum_type("compiler.lineage_kind") +@enum("blueprinting.ir.lineage-kind") class LineageKind(Enum): ROOT = "root" PRESERVED = "preserved" @@ -117,25 +109,21 @@ class LineageKind(Enum): GENERATED = "generated" -@record_type("compiler.lineage") -@dataclass(frozen=True) +LineageSources: TypeAlias = Annotated[ + tuple[StableId, ...], + ValueConstraint.UNIQUE_ITEMS, +] + + +@record("blueprinting.ir.lineage") class Lineage: """Typed provenance from source entities to one lowering product.""" kind: LineageKind - transform: str - sources: tuple[StableId, ...] = () + transform: NonEmptyText + sources: LineageSources = () def __post_init__(self) -> None: - object.__setattr__(self, "sources", tuple(self.sources)) - if not isinstance(self.kind, LineageKind): - raise TypeError("lineage kind must be LineageKind") - if any(not isinstance(source, StableId) for source in self.sources): - raise TypeError("lineage sources must be stable synthesis IDs") - if not self.transform: - raise ValueError("lineage transform must not be empty") - if len(set(self.sources)) != len(self.sources): - raise ValueError("lineage sources must be unique") if self.kind is LineageKind.ROOT and self.sources: raise ValueError("root lineage cannot have source IDs") diff --git a/src/blueprinting/synthesizer/ir/__init__.py b/src/blueprinting/synthesizer/ir/__init__.py deleted file mode 100644 index 98acbbd..0000000 --- a/src/blueprinting/synthesizer/ir/__init__.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Canonical Blueprinting IR contracts.""" - -from .common import Effect, EffectKind, IRHeader, OperationName, SchemaVersion, TensorType -from .concrete_plan import ( - AccessMode, - BufferBinding, - BufferUse, - CommandKind, - ConcreteCommand, - ConcretePlanIR, - DevicePlacement, - ImplementationRef, - MemoryRegion, - QueueKind, - QueueSpec, -) -from .distributed import ( - CollectiveKind, - CollectiveSpec, - DistributedTask, - DistributedTaskIR, - DistributedTaskKind, - DistributedValue, - LogicalMesh, - MeshAxis, - PeerTransfer, - ReductionKind, - ShardingSpec, -) -from .machine import ( - MachineEntryPoint, - MachineInstruction, - MachineIR, - MachineOpcode, - MachineSection, - MachineSectionKind, -) -from .model import ModelIR, ModelOperation, ModelValue, ValueRole -from .portable_plan import ( - AbstractStorageClass, - ImplementationRequirement, - ObjectiveDirection, - ObjectiveKind, - PlanBuffer, - PlanBufferRole, - PlanObjective, - PlanTask, - PlanTaskKind, - PortablePlanIR, - ResourceKind, - ResourceRequirement, - ResourceScope, - WorkloadFacts, -) - -__all__ = [ - "Effect", - "EffectKind", - "AbstractStorageClass", - "AccessMode", - "BufferBinding", - "BufferUse", - "CommandKind", - "ConcreteCommand", - "ConcretePlanIR", - "DevicePlacement", - "IRHeader", - "ImplementationRequirement", - "ImplementationRef", - "CollectiveKind", - "CollectiveSpec", - "DistributedTask", - "DistributedTaskIR", - "DistributedTaskKind", - "DistributedValue", - "LogicalMesh", - "MeshAxis", - "MemoryRegion", - "MachineEntryPoint", - "MachineIR", - "MachineInstruction", - "MachineOpcode", - "MachineSection", - "MachineSectionKind", - "ModelIR", - "ModelOperation", - "ModelValue", - "OperationName", - "ObjectiveDirection", - "ObjectiveKind", - "PeerTransfer", - "ReductionKind", - "QueueKind", - "QueueSpec", - "SchemaVersion", - "ShardingSpec", - "PlanBuffer", - "PlanBufferRole", - "PlanObjective", - "PlanTask", - "PlanTaskKind", - "PortablePlanIR", - "ResourceKind", - "ResourceRequirement", - "ResourceScope", - "TensorType", - "ValueRole", - "WorkloadFacts", -] diff --git a/src/blueprinting/synthesizer/ir/concrete_plan.py b/src/blueprinting/synthesizer/ir/concrete_plan.py deleted file mode 100644 index ff7a381..0000000 --- a/src/blueprinting/synthesizer/ir/concrete_plan.py +++ /dev/null @@ -1,465 +0,0 @@ -"""Target- and deployment-bound authoritative command plan.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import ClassVar - -from blueprinting.schema.codec import enum_type, record_type -from blueprinting.schema.frozen import FrozenDict - -from ..errors import DiagnosticBag, VerificationReport -from ..ids import ( - BufferId, - CommandId, - DeviceId, - Lineage, - MemoryRegionId, - QueueId, - TokenId, -) -from .common import ( - CanonicalIRMixin, - IRHeader, - SchemaVersion, - frozen_map, - is_content_digest, - make_header, - reject_reserved_attributes, - require_instance, - typed_tuple, - verify_known_references, - verify_ordered_dag, - verify_unique_ids, -) - - -@record_type("compiler.concrete.device") -@dataclass(frozen=True) -class DevicePlacement: - id: DeviceId - logical_rank: int - target_device: str - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, DeviceId, "device placement ID") - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if isinstance(self.logical_rank, bool) or not isinstance(self.logical_rank, int) or self.logical_rank < 0: - raise ValueError("logical rank must be a non-negative integer") - if not isinstance(self.target_device, str) or not self.target_device: - raise ValueError("target device identity must not be empty") - - -@enum_type("compiler.concrete.queue_kind") -class QueueKind(Enum): - COMPUTE = "compute" - COLLECTIVE = "collective" - TRANSFER = "transfer" - HOST = "host" - - -@record_type("compiler.concrete.queue") -@dataclass(frozen=True) -class QueueSpec: - id: QueueId - device: DeviceId - kind: QueueKind - engine: str - ordered: bool = True - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, QueueId, "queue ID") - require_instance(self.device, DeviceId, "queue device") - require_instance(self.kind, QueueKind, "queue kind") - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if not isinstance(self.engine, str) or not self.engine: - raise ValueError("queue engine must not be empty") - if not isinstance(self.ordered, bool): - raise TypeError("queue ordered flag must be boolean") - - -@record_type("compiler.concrete.memory_region") -@dataclass(frozen=True) -class MemoryRegion: - id: MemoryRegionId - device: DeviceId - memory_space: str - capacity_bytes: int - alignment_bytes: int = 1 - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, MemoryRegionId, "memory region ID") - require_instance(self.device, DeviceId, "memory region device") - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if not isinstance(self.memory_space, str) or not self.memory_space: - raise ValueError("memory space must not be empty") - for name in ("capacity_bytes", "alignment_bytes"): - value = getattr(self, name) - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ValueError(f"{name} must be a positive integer") - - -@record_type("compiler.concrete.buffer_binding") -@dataclass(frozen=True) -class BufferBinding: - id: BufferId - memory_region: MemoryRegionId - offset_bytes: int - size_bytes: int - alignment_bytes: int - lineage: Lineage - source_buffer: BufferId | None = None - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, BufferId, "buffer binding ID") - require_instance(self.memory_region, MemoryRegionId, "buffer memory region") - require_instance(self.lineage, Lineage, "buffer binding lineage") - if self.source_buffer is not None: - require_instance(self.source_buffer, BufferId, "source buffer") - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - for name in ("offset_bytes", "size_bytes", "alignment_bytes"): - value = getattr(self, name) - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError(f"{name} must be an integer") - if self.offset_bytes < 0: - raise ValueError("buffer offset must not be negative") - if self.size_bytes <= 0 or self.alignment_bytes <= 0: - raise ValueError("buffer size and alignment must be positive") - - -@record_type("compiler.concrete.implementation_ref") -@dataclass(frozen=True) -class ImplementationRef: - namespace: str - name: str - version: str - abi: str - variant: str = "default" - - def __post_init__(self) -> None: - for field_name in ("namespace", "name", "version", "abi", "variant"): - if not isinstance(getattr(self, field_name), str): - raise TypeError("implementation identity fields must be strings") - if any(not getattr(self, item) for item in ("namespace", "name", "version", "abi", "variant")): - raise ValueError("implementation identity fields must not be empty") - - @property - def key(self) -> str: - return f"{self.namespace}:{self.name}:{self.version}:{self.variant}@{self.abi}" - - -@enum_type("compiler.concrete.access_mode") -class AccessMode(Enum): - READ = "read" - WRITE = "write" - READ_WRITE = "read_write" - - -@record_type("compiler.concrete.buffer_use") -@dataclass(frozen=True) -class BufferUse: - buffer: BufferId - access: AccessMode - - def __post_init__(self) -> None: - require_instance(self.buffer, BufferId, "buffer use ID") - require_instance(self.access, AccessMode, "buffer access mode") - - -@enum_type("compiler.concrete.command_kind") -class CommandKind(Enum): - LAUNCH = "launch" - COLLECTIVE = "collective" - TRANSFER = "transfer" - BARRIER = "barrier" - SIGNAL = "signal" - WAIT = "wait" - HOST_CALL = "host_call" - - -@record_type("compiler.concrete.command") -@dataclass(frozen=True) -class ConcreteCommand: - id: CommandId - kind: CommandKind - dependencies: tuple[CommandId, ...] - queue: QueueId | None - implementation: ImplementationRef | None - buffers: tuple[BufferUse, ...] - lineage: Lineage - wait_tokens: tuple[TokenId, ...] = () - signal_tokens: tuple[TokenId, ...] = () - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, CommandId, "command ID") - require_instance(self.kind, CommandKind, "command kind") - require_instance(self.lineage, Lineage, "command lineage") - if self.queue is not None: - require_instance(self.queue, QueueId, "command queue") - if self.implementation is not None: - require_instance(self.implementation, ImplementationRef, "command implementation") - object.__setattr__( - self, - "dependencies", - typed_tuple(self.dependencies, CommandId, "command dependencies"), - ) - object.__setattr__(self, "buffers", typed_tuple(self.buffers, BufferUse, "command buffers")) - object.__setattr__(self, "wait_tokens", typed_tuple(self.wait_tokens, TokenId, "command wait tokens")) - object.__setattr__( - self, - "signal_tokens", - typed_tuple(self.signal_tokens, TokenId, "command signal tokens"), - ) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - - -_CONCRETE_RESERVED = frozenset( - { - "start", - "start_time", - "predicted_start", - "end", - "end_time", - "predicted_end", - "duration", - "latency", - "estimated_time", - "predicted_duration", - } -) - - -@record_type("compiler.ir.concrete_plan.v1") -@dataclass(frozen=True) -class ConcretePlanIR(CanonicalIRMixin): - """Dependency-driven plan consumed by both simulation and emission.""" - - SCHEMA_NAME: ClassVar[str] = "blueprinting.concrete-plan" - SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(1, 0, 0) - - name: str - source_portable_digest: str - target_fingerprint: str - deployment_fingerprint: str - abi_revision: str - evidence_revision: str - planner_revision: str - devices: tuple[DevicePlacement, ...] - queues: tuple[QueueSpec, ...] - memory_regions: tuple[MemoryRegion, ...] - buffers: tuple[BufferBinding, ...] - commands: tuple[ConcreteCommand, ...] - attributes: FrozenDict = field(default_factory=FrozenDict) - header: IRHeader = field( - default_factory=lambda: make_header(ConcretePlanIR.SCHEMA_NAME, ConcretePlanIR.SCHEMA_VERSION) - ) - - def __post_init__(self) -> None: - require_instance(self.header, IRHeader, "concrete header") - identity_fields = ( - "name", - "source_portable_digest", - "target_fingerprint", - "deployment_fingerprint", - "abi_revision", - "evidence_revision", - "planner_revision", - ) - if any(not isinstance(getattr(self, field_name), str) for field_name in identity_fields): - raise TypeError("concrete plan identity fields must be strings") - object.__setattr__(self, "devices", typed_tuple(self.devices, DevicePlacement, "concrete devices")) - object.__setattr__(self, "queues", typed_tuple(self.queues, QueueSpec, "concrete queues")) - object.__setattr__( - self, - "memory_regions", - typed_tuple(self.memory_regions, MemoryRegion, "concrete memory regions"), - ) - object.__setattr__(self, "buffers", typed_tuple(self.buffers, BufferBinding, "concrete buffers")) - object.__setattr__(self, "commands", typed_tuple(self.commands, ConcreteCommand, "concrete commands")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if ( - not self.header.parent_digests - and self.header.schema_name == self.SCHEMA_NAME - and self.header.schema_version == self.SCHEMA_VERSION - and is_content_digest(self.source_portable_digest) - ): - object.__setattr__(self, "header", self.header.with_parents(self.source_portable_digest)) - - def verify(self) -> VerificationReport: - bag = DiagnosticBag() - self._verify_common(bag) - if not self.name: - bag.error("concrete.name", "concrete plan name must not be empty", "name") - identity_fields = ( - "source_portable_digest", - "target_fingerprint", - "deployment_fingerprint", - "abi_revision", - "evidence_revision", - "planner_revision", - ) - for field_name in identity_fields: - if not getattr(self, field_name): - bag.error("concrete.identity", f"{field_name} must not be empty", field_name) - if not is_content_digest(self.source_portable_digest): - bag.error( - "concrete.source_digest", "source_portable_digest must be a canonical digest", "source_portable_digest" - ) - elif self.source_portable_digest not in self.header.parent_digests: - bag.error("concrete.parent_digest", "source digest must be retained in header", "header", "parent_digests") - for field_name in ("target_fingerprint", "deployment_fingerprint"): - if not is_content_digest(getattr(self, field_name)): - bag.error( - "concrete.binding_fingerprint", - f"{field_name} must be a canonical binding digest", - field_name, - ) - if not self.devices or not self.commands: - bag.error("concrete.empty", "concrete plan requires devices and commands", "commands") - - verify_unique_ids(bag, self.devices, lambda item: item.id, "devices") - verify_unique_ids(bag, self.queues, lambda item: item.id, "queues") - verify_unique_ids(bag, self.memory_regions, lambda item: item.id, "memory_regions") - verify_unique_ids(bag, self.buffers, lambda item: item.id, "buffers") - verify_unique_ids(bag, self.commands, lambda item: item.id, "commands") - verify_ordered_dag(bag, self.commands, lambda item: item.id, lambda item: item.dependencies, "commands") - - device_ids = {item.id for item in self.devices} - queue_ids = {item.id for item in self.queues} - buffer_ids = {item.id for item in self.buffers} - if len({item.logical_rank for item in self.devices}) != len(self.devices): - bag.error("device.duplicate_rank", "logical ranks must map to one physical device each", "devices") - if len({item.target_device for item in self.devices}) != len(self.devices): - bag.error("device.duplicate_target", "target device identities must be unique", "devices") - for index, device in enumerate(self.devices): - reject_reserved_attributes( - bag, - device.attributes, - _CONCRETE_RESERVED, - "devices", - str(index), - "attributes", - ) - - queue_by_id = {item.id: item for item in self.queues} - for index, queue in enumerate(self.queues): - if queue.device not in device_ids: - bag.error("reference.unknown", f"unknown queue device {queue.device}", "queues", str(index), "device") - reject_reserved_attributes(bag, queue.attributes, _CONCRETE_RESERVED, "queues", str(index), "attributes") - - region_by_id = {item.id: item for item in self.memory_regions} - for index, region in enumerate(self.memory_regions): - if region.device not in device_ids: - bag.error( - "reference.unknown", - f"unknown memory-region device {region.device}", - "memory_regions", - str(index), - "device", - ) - reject_reserved_attributes( - bag, - region.attributes, - _CONCRETE_RESERVED, - "memory_regions", - str(index), - "attributes", - ) - - buffer_by_id = {item.id: item for item in self.buffers} - for index, buffer in enumerate(self.buffers): - path = ("buffers", str(index)) - region = region_by_id.get(buffer.memory_region) - if region is None: - bag.error("reference.unknown", f"unknown memory region {buffer.memory_region}", *path, "memory_region") - else: - if buffer.offset_bytes % max(buffer.alignment_bytes, region.alignment_bytes) != 0: - bag.error( - "buffer.alignment", "buffer offset violates buffer or region alignment", *path, "offset_bytes" - ) - if buffer.offset_bytes + buffer.size_bytes > region.capacity_bytes: - bag.error("buffer.out_of_bounds", "buffer allocation exceeds memory region capacity", *path) - reject_reserved_attributes(bag, buffer.attributes, _CONCRETE_RESERVED, *path, "attributes") - - signaled = {} - implementation_kinds = {CommandKind.LAUNCH, CommandKind.COLLECTIVE, CommandKind.HOST_CALL} - queue_kinds = {CommandKind.LAUNCH, CommandKind.COLLECTIVE, CommandKind.TRANSFER, CommandKind.HOST_CALL} - expected_queue_kind = { - CommandKind.LAUNCH: QueueKind.COMPUTE, - CommandKind.COLLECTIVE: QueueKind.COLLECTIVE, - CommandKind.TRANSFER: QueueKind.TRANSFER, - CommandKind.HOST_CALL: QueueKind.HOST, - } - for index, command in enumerate(self.commands): - path = ("commands", str(index)) - if command.kind in queue_kinds and command.queue is None: - bag.error("command.missing_queue", f"{command.kind.value} command requires a queue", *path, "queue") - if command.queue is not None and command.queue not in queue_ids: - bag.error("reference.unknown", f"unknown queue {command.queue}", *path, "queue") - elif command.queue is not None and command.kind in expected_queue_kind: - actual_kind = queue_by_id[command.queue].kind - if actual_kind is not expected_queue_kind[command.kind]: - bag.error( - "command.queue_kind", - f"{command.kind.value} requires a {expected_queue_kind[command.kind].value} queue", - *path, - "queue", - ) - if command.kind in implementation_kinds and command.implementation is None: - bag.error( - "command.missing_implementation", - f"{command.kind.value} command requires a selected implementation", - *path, - "implementation", - ) - if command.kind not in implementation_kinds and command.implementation is not None: - bag.error( - "command.unexpected_implementation", - f"{command.kind.value} command cannot select an implementation", - *path, - "implementation", - ) - used_buffers = tuple(item.buffer for item in command.buffers) - verify_known_references(bag, used_buffers, buffer_ids, *path, "buffers") - if len(set(used_buffers)) != len(used_buffers): - bag.error("command.duplicate_buffer", "one command may list each buffer only once", *path, "buffers") - if command.kind is CommandKind.LAUNCH and command.queue in queue_by_id: - device = queue_by_id[command.queue].device - for buffer_id in used_buffers: - binding = buffer_by_id.get(buffer_id) - region = region_by_id.get(binding.memory_region) if binding is not None else None - if region is not None and region.device != device: - bag.error( - "command.device_mismatch", - f"launch buffer {buffer_id} is not resident on queue device", - *path, - "buffers", - ) - - if len(set(command.wait_tokens)) != len(command.wait_tokens): - bag.error("token.duplicate_wait", "wait tokens must be unique", *path, "wait_tokens") - if len(set(command.signal_tokens)) != len(command.signal_tokens): - bag.error("token.duplicate_signal", "signal tokens must be unique", *path, "signal_tokens") - for token in command.wait_tokens: - if token not in signaled: - bag.error("token.wait_before_signal", f"token {token} has not been signaled", *path, "wait_tokens") - for token in command.signal_tokens: - if token in signaled: - bag.error("token.multiple_signal", f"token {token} has multiple signalers", *path, "signal_tokens") - signaled[token] = command.id - if command.kind is CommandKind.SIGNAL and not command.signal_tokens: - bag.error( - "token.empty_signal", "signal command must produce at least one token", *path, "signal_tokens" - ) - if command.kind is CommandKind.WAIT and not command.wait_tokens: - bag.error("token.empty_wait", "wait command must consume at least one token", *path, "wait_tokens") - reject_reserved_attributes(bag, command.attributes, _CONCRETE_RESERVED, *path, "attributes") - - reject_reserved_attributes(bag, self.attributes, _CONCRETE_RESERVED, "attributes") - return bag.report() diff --git a/src/blueprinting/synthesizer/ir/distributed.py b/src/blueprinting/synthesizer/ir/distributed.py deleted file mode 100644 index 961e7cf..0000000 --- a/src/blueprinting/synthesizer/ir/distributed.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Logical distributed program over a virtual device mesh.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import ClassVar - -from blueprinting.schema.codec import enum_type, record_type -from blueprinting.schema.frozen import FrozenDict - -from ..errors import DiagnosticBag, VerificationReport -from ..expr import Scalar -from ..ids import Lineage, NodeId, ValueId -from .common import ( - CanonicalIRMixin, - Effect, - IRHeader, - OperationName, - SchemaVersion, - TensorType, - frozen_map, - is_content_digest, - make_header, - reject_reserved_attributes, - require_instance, - typed_tuple, - verify_known_references, - verify_nonnegative_scalar, - verify_ordered_dag, - verify_unique_ids, -) -from .model import ValueRole - - -@record_type("compiler.distributed.mesh_axis") -@dataclass(frozen=True) -class MeshAxis: - name: str - size: int - - def __post_init__(self) -> None: - if not isinstance(self.name, str) or not self.name: - raise ValueError("mesh axis name must not be empty") - if isinstance(self.size, bool) or not isinstance(self.size, int) or self.size <= 0: - raise ValueError("mesh axis size must be a positive integer") - - -@record_type("compiler.distributed.logical_mesh") -@dataclass(frozen=True) -class LogicalMesh: - name: str - axes: tuple[MeshAxis, ...] - - def __post_init__(self) -> None: - object.__setattr__(self, "axes", typed_tuple(self.axes, MeshAxis, "logical mesh axes")) - if not isinstance(self.name, str) or not self.name: - raise ValueError("logical mesh name must not be empty") - names = tuple(axis.name for axis in self.axes) - if not names or len(set(names)) != len(names): - raise ValueError("logical mesh axes must be non-empty and uniquely named") - - @property - def size(self) -> int: - result = 1 - for axis in self.axes: - result *= axis.size - return result - - @property - def axis_names(self) -> tuple[str, ...]: - return tuple(axis.name for axis in self.axes) - - -@record_type("compiler.distributed.sharding") -@dataclass(frozen=True) -class ShardingSpec: - """Mapping from tensor dimensions to logical mesh axes.""" - - dimension_axes: tuple[tuple[str, ...], ...] - replicated_axes: tuple[str, ...] = () - - def __post_init__(self) -> None: - object.__setattr__(self, "dimension_axes", tuple(tuple(item) for item in self.dimension_axes)) - object.__setattr__(self, "replicated_axes", tuple(self.replicated_axes)) - axes = tuple(axis for dimensions in self.dimension_axes for axis in dimensions) + self.replicated_axes - if any(not isinstance(axis, str) or not axis for axis in axes): - raise ValueError("sharding axes must be non-empty strings") - - @classmethod - def replicated(cls, rank: int, axes: tuple[str, ...]) -> ShardingSpec: - return cls(dimension_axes=tuple(() for _ in range(rank)), replicated_axes=axes) - - -@enum_type("compiler.distributed.collective_kind") -class CollectiveKind(Enum): - ALL_REDUCE = "all_reduce" - ALL_GATHER = "all_gather" - REDUCE_SCATTER = "reduce_scatter" - ALL_TO_ALL = "all_to_all" - BROADCAST = "broadcast" - - -@enum_type("compiler.distributed.reduction_kind") -class ReductionKind(Enum): - SUM = "sum" - MAX = "max" - MIN = "min" - PRODUCT = "product" - - -@record_type("compiler.distributed.collective") -@dataclass(frozen=True) -class CollectiveSpec: - kind: CollectiveKind - participants: tuple[int, ...] - message_bytes: Scalar - reduction: ReductionKind | None = None - root: int | None = None - - def __post_init__(self) -> None: - require_instance(self.kind, CollectiveKind, "collective kind") - if self.reduction is not None: - require_instance(self.reduction, ReductionKind, "collective reduction") - object.__setattr__(self, "participants", tuple(self.participants)) - if any(isinstance(rank, bool) or not isinstance(rank, int) or rank < 0 for rank in self.participants): - raise ValueError("collective participants must be non-negative integer ranks") - if not self.participants or len(set(self.participants)) != len(self.participants): - raise ValueError("collective participants must be non-empty and unique") - reduction_kinds = {CollectiveKind.ALL_REDUCE, CollectiveKind.REDUCE_SCATTER} - if self.kind in reduction_kinds and self.reduction is None: - raise ValueError(f"{self.kind.value} requires a reduction operation") - if self.kind not in reduction_kinds and self.reduction is not None: - raise ValueError(f"{self.kind.value} does not accept a reduction operation") - if self.kind is CollectiveKind.BROADCAST and self.root is None: - raise ValueError("broadcast requires a root rank") - if self.root is not None and (isinstance(self.root, bool) or not isinstance(self.root, int) or self.root < 0): - raise ValueError("collective root must be a non-negative integer rank") - if self.kind is not CollectiveKind.BROADCAST and self.root is not None: - raise ValueError(f"{self.kind.value} does not accept a root rank") - - -@record_type("compiler.distributed.peer_transfer") -@dataclass(frozen=True) -class PeerTransfer: - source_rank: int - destination_rank: int - message_bytes: Scalar - channel: str = "default" - - def __post_init__(self) -> None: - if not isinstance(self.channel, str): - raise TypeError("peer transfer channel must be a string") - for endpoint in (self.source_rank, self.destination_rank): - if isinstance(endpoint, bool) or not isinstance(endpoint, int) or endpoint < 0: - raise ValueError("peer transfer endpoints must be non-negative integer ranks") - if self.source_rank == self.destination_rank: - raise ValueError("peer transfer endpoints must differ") - if not self.channel: - raise ValueError("peer transfer channel must not be empty") - - -@enum_type("compiler.distributed.task_kind") -class DistributedTaskKind(Enum): - LOCAL_COMPUTE = "local_compute" - COLLECTIVE = "collective" - POINT_TO_POINT = "point_to_point" - RESHARD = "reshard" - CONTROL = "control" - - -@record_type("compiler.distributed.value") -@dataclass(frozen=True) -class DistributedValue: - id: ValueId - type: TensorType - role: ValueRole - sharding: ShardingSpec - owners: tuple[int, ...] - lineage: Lineage - source_value: ValueId | None = None - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, ValueId, "distributed value ID") - require_instance(self.type, TensorType, "distributed value type") - require_instance(self.role, ValueRole, "distributed value role") - require_instance(self.sharding, ShardingSpec, "distributed value sharding") - require_instance(self.lineage, Lineage, "distributed value lineage") - if self.source_value is not None: - require_instance(self.source_value, ValueId, "distributed source value") - object.__setattr__(self, "owners", tuple(self.owners)) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if any(isinstance(rank, bool) or not isinstance(rank, int) or rank < 0 for rank in self.owners): - raise ValueError("distributed value owners must be non-negative integer ranks") - - -@record_type("compiler.distributed.task") -@dataclass(frozen=True) -class DistributedTask: - id: NodeId - kind: DistributedTaskKind - operation: OperationName - ranks: tuple[int, ...] - inputs: tuple[ValueId, ...] - outputs: tuple[ValueId, ...] - dependencies: tuple[NodeId, ...] - lineage: Lineage - collective: CollectiveSpec | None = None - peer_transfer: PeerTransfer | None = None - effects: tuple[Effect, ...] = () - attributes: FrozenDict = field(default_factory=FrozenDict) - - def __post_init__(self) -> None: - require_instance(self.id, NodeId, "distributed task ID") - require_instance(self.kind, DistributedTaskKind, "distributed task kind") - require_instance(self.operation, OperationName, "distributed task operation") - require_instance(self.lineage, Lineage, "distributed task lineage") - if self.collective is not None: - require_instance(self.collective, CollectiveSpec, "distributed collective") - if self.peer_transfer is not None: - require_instance(self.peer_transfer, PeerTransfer, "distributed peer transfer") - object.__setattr__(self, "ranks", tuple(self.ranks)) - object.__setattr__(self, "inputs", typed_tuple(self.inputs, ValueId, "distributed task inputs")) - object.__setattr__(self, "outputs", typed_tuple(self.outputs, ValueId, "distributed task outputs")) - object.__setattr__( - self, - "dependencies", - typed_tuple(self.dependencies, NodeId, "distributed task dependencies"), - ) - object.__setattr__(self, "effects", typed_tuple(self.effects, Effect, "distributed task effects")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if any(isinstance(rank, bool) or not isinstance(rank, int) or rank < 0 for rank in self.ranks): - raise ValueError("distributed task ranks must be non-negative integers") - - -_DISTRIBUTED_RESERVED = frozenset( - { - "physical_device", - "device_id", - "route", - "queue", - "stream", - "kernel", - "implementation_id", - "start", - "start_time", - "end", - "end_time", - "duration", - "latency", - "bandwidth", - } -) - - -@record_type("compiler.ir.distributed_task.v1") -@dataclass(frozen=True) -class DistributedTaskIR(CanonicalIRMixin): - """Logical task graph whose ranks are virtual, never physical devices.""" - - SCHEMA_NAME: ClassVar[str] = "blueprinting.distributed-task" - SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(1, 0, 0) - - name: str - source_model_digest: str - mesh: LogicalMesh - values: tuple[DistributedValue, ...] - tasks: tuple[DistributedTask, ...] - inputs: tuple[ValueId, ...] - outputs: tuple[ValueId, ...] - attributes: FrozenDict = field(default_factory=FrozenDict) - header: IRHeader = field( - default_factory=lambda: make_header(DistributedTaskIR.SCHEMA_NAME, DistributedTaskIR.SCHEMA_VERSION) - ) - - def __post_init__(self) -> None: - require_instance(self.header, IRHeader, "distributed header") - require_instance(self.mesh, LogicalMesh, "distributed logical mesh") - if not isinstance(self.name, str): - raise TypeError("distributed program name must be a string") - object.__setattr__(self, "values", typed_tuple(self.values, DistributedValue, "distributed values")) - object.__setattr__(self, "tasks", typed_tuple(self.tasks, DistributedTask, "distributed tasks")) - object.__setattr__(self, "inputs", typed_tuple(self.inputs, ValueId, "distributed inputs")) - object.__setattr__(self, "outputs", typed_tuple(self.outputs, ValueId, "distributed outputs")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if ( - not self.header.parent_digests - and self.header.schema_name == self.SCHEMA_NAME - and self.header.schema_version == self.SCHEMA_VERSION - and is_content_digest(self.source_model_digest) - ): - object.__setattr__(self, "header", self.header.with_parents(self.source_model_digest)) - - def verify(self) -> VerificationReport: - bag = DiagnosticBag() - self._verify_common(bag) - if not self.name: - bag.error("distributed.name", "distributed program name must not be empty", "name") - if not self.tasks or not self.values: - bag.error("distributed.empty", "distributed program requires tasks and values", "tasks") - if not is_content_digest(self.source_model_digest): - bag.error( - "distributed.source_digest", "source_model_digest must be a canonical digest", "source_model_digest" - ) - elif self.source_model_digest not in self.header.parent_digests: - bag.error( - "distributed.parent_digest", - "source model digest must be retained in header.parent_digests", - "header", - "parent_digests", - ) - - verify_unique_ids(bag, self.values, lambda item: item.id, "values") - verify_unique_ids(bag, self.tasks, lambda item: item.id, "tasks") - verify_ordered_dag(bag, self.tasks, lambda item: item.id, lambda item: item.dependencies, "tasks") - value_ids = {item.id for item in self.values} - verify_known_references(bag, self.inputs, value_ids, "inputs") - verify_known_references(bag, self.outputs, value_ids, "outputs") - if len(set(self.inputs)) != len(self.inputs): - bag.error("distributed.duplicate_input", "distributed inputs must be unique", "inputs") - if len(set(self.outputs)) != len(self.outputs): - bag.error("distributed.duplicate_output", "distributed outputs must be unique", "outputs") - mesh_axes = set(self.mesh.axis_names) - valid_ranks = set(range(self.mesh.size)) - - for index, value in enumerate(self.values): - path = ("values", str(index)) - if len(value.sharding.dimension_axes) != value.type.rank: - bag.error( - "sharding.rank", - "sharding dimension count must equal tensor rank", - *path, - "sharding", - "dimension_axes", - ) - used_axes = tuple(axis for axes in value.sharding.dimension_axes for axis in axes) - used_axes += value.sharding.replicated_axes - if len(set(used_axes)) != len(used_axes): - bag.error("sharding.axis_reuse", "a mesh axis may appear only once", *path, "sharding") - for axis in used_axes: - if axis not in mesh_axes: - bag.error("sharding.unknown_axis", f"unknown mesh axis {axis!r}", *path, "sharding") - if not value.owners or len(set(value.owners)) != len(value.owners): - bag.error("ownership.invalid", "owners must be non-empty and unique", *path, "owners") - for rank in value.owners: - if rank not in valid_ranks: - bag.error("rank.unknown", f"owner rank {rank} is outside the logical mesh", *path, "owners") - reject_reserved_attributes(bag, value.attributes, _DISTRIBUTED_RESERVED, *path, "attributes") - reject_reserved_attributes( - bag, - value.type.attributes, - _DISTRIBUTED_RESERVED, - *path, - "type", - "attributes", - ) - - defined = set(self.inputs) - for index, task in enumerate(self.tasks): - path = ("tasks", str(index)) - if not task.ranks or len(set(task.ranks)) != len(task.ranks): - bag.error("rank.invalid", "task ranks must be non-empty and unique", *path, "ranks") - for rank in task.ranks: - if rank not in valid_ranks: - bag.error("rank.unknown", f"task rank {rank} is outside the logical mesh", *path, "ranks") - if task.operation.dialect.lower() in {"cuda", "nccl", "rocm", "rccl", "lpu"}: - bag.error( - "distributed.target_dialect", - f"target dialect {task.operation.dialect!r} is illegal in DistributedTaskIR", - *path, - "operation", - ) - verify_known_references(bag, task.inputs, value_ids, *path, "inputs") - verify_known_references(bag, task.outputs, value_ids, *path, "outputs") - if len(set(task.inputs)) != len(task.inputs): - bag.error("task.duplicate_input", "task inputs must be unique", *path, "inputs") - if len(set(task.outputs)) != len(task.outputs): - bag.error("task.duplicate_output", "task outputs must be unique", *path, "outputs") - for input_id in task.inputs: - if input_id in value_ids and input_id not in defined: - bag.error("dataflow.use_before_definition", f"value {input_id} is not yet defined", *path, "inputs") - for output_id in task.outputs: - if output_id in defined: - bag.error( - "dataflow.multiple_definition", f"value {output_id} has multiple definitions", *path, "outputs" - ) - defined.add(output_id) - - if task.kind is DistributedTaskKind.COLLECTIVE: - if task.collective is None or task.peer_transfer is not None: - bag.error("task.collective_contract", "collective task requires only collective metadata", *path) - elif set(task.collective.participants) != set(task.ranks): - bag.error( - "collective.participants", "collective participants must equal task ranks", *path, "collective" - ) - elif task.kind is DistributedTaskKind.POINT_TO_POINT: - if task.peer_transfer is None or task.collective is not None: - bag.error("task.peer_contract", "point-to-point task requires only peer transfer metadata", *path) - elif {task.peer_transfer.source_rank, task.peer_transfer.destination_rank} != set(task.ranks): - bag.error("peer.endpoints", "peer transfer endpoints must equal task ranks", *path, "peer_transfer") - elif task.collective is not None or task.peer_transfer is not None: - bag.error("task.communication_contract", "non-communication task has communication metadata", *path) - - if task.collective is not None: - verify_nonnegative_scalar(bag, task.collective.message_bytes, *path, "collective", "message_bytes") - if task.collective.root is not None and task.collective.root not in task.collective.participants: - bag.error("collective.root", "broadcast root must be a participant", *path, "collective", "root") - if task.peer_transfer is not None: - verify_nonnegative_scalar( - bag, task.peer_transfer.message_bytes, *path, "peer_transfer", "message_bytes" - ) - reject_reserved_attributes(bag, task.attributes, _DISTRIBUTED_RESERVED, *path, "attributes") - - for output_id in self.outputs: - if output_id in value_ids and output_id not in defined: - bag.error("dataflow.undefined_output", f"distributed output {output_id} is not defined", "outputs") - reject_reserved_attributes(bag, self.attributes, _DISTRIBUTED_RESERVED, "attributes") - return bag.report() diff --git a/src/blueprinting/synthesizer/lowering/__init__.py b/src/blueprinting/synthesizer/lowering/__init__.py deleted file mode 100644 index dcc0de8..0000000 --- a/src/blueprinting/synthesizer/lowering/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Production lowering passes for canonical IR dialects.""" - -from .transformer import DistributeTransformerTrainingPass, PlanTransformerTrainingPass -from .transformer_inference import DistributeTransformerInferencePass, PlanTransformerInferencePass - -__all__ = [ - "DistributeTransformerInferencePass", - "DistributeTransformerTrainingPass", - "PlanTransformerInferencePass", - "PlanTransformerTrainingPass", -] diff --git a/src/blueprinting/synthesizer/lowering/transformer.py b/src/blueprinting/synthesizer/lowering/transformer.py deleted file mode 100644 index b62e84e..0000000 --- a/src/blueprinting/synthesizer/lowering/transformer.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Transformer training lowerings from semantic graph to portable work DAG.""" - -from __future__ import annotations - -from blueprinting.mapping import TensorParallelCommunication, TransformerTrainingMappingSpec -from blueprinting.schema.frozen import FrozenDict -from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec - -from ..axes import BindingAxis -from ..dialects.transformer import EngineKind, PrimitiveInvocation, TrainingPhase, derive_transformer_block -from ..ids import BufferId, Lineage, NodeId, ValueId -from ..ir import ( - AbstractStorageClass, - CollectiveKind, - CollectiveSpec, - DistributedTask, - DistributedTaskIR, - DistributedTaskKind, - DistributedValue, - ImplementationRequirement, - LogicalMesh, - MeshAxis, - ModelIR, - ObjectiveDirection, - ObjectiveKind, - OperationName, - PlanBuffer, - PlanBufferRole, - PlanObjective, - PlanTask, - PlanTaskKind, - PortablePlanIR, - ReductionKind, - ResourceKind, - ResourceRequirement, - ResourceScope, - ShardingSpec, - TensorType, - ValueRole, - WorkloadFacts, -) -from ..passes import DerivationPass, PassContext, PassContract - - -def _semantic_specs( - ir: ModelIR, - context: PassContext, -) -> tuple[TransformerModelSpec, TransformerTrainingWorkloadSpec, TransformerTrainingMappingSpec]: - if len(ir.operations) != 1 or ir.operations[0].operation != OperationName("transformer", "decoder_training"): - raise ValueError("Transformer distribution expects one transformer.decoder_training operation") - model = ir.operations[0].attributes.get("model_spec") - if not isinstance(model, TransformerModelSpec): - raise TypeError("model operation is missing a typed TransformerModelSpec") - workload = context.session.bindings.workload - strategy = context.session.bindings.strategy - if workload is None or strategy is None: - raise ValueError("Transformer distribution requires workload and strategy bindings") - workload_spec = workload.attributes.get("workload_spec") - mapping = strategy.attributes.get("mapping_spec") - if not isinstance(workload_spec, TransformerTrainingWorkloadSpec): - raise TypeError("workload binding is missing a typed TransformerTrainingWorkloadSpec") - if not isinstance(mapping, TransformerTrainingMappingSpec): - raise TypeError("strategy binding is missing a typed TransformerTrainingMappingSpec") - mapping.validate_model(model) - mapping.validate_workload(workload_spec) - expected = ( - workload_spec.microbatch_size, - model.sequence_length, - mapping.microbatch_count(workload_spec), - ) - actual = (workload.batch_size, workload.sequence_length, workload.micro_batches) - if actual != expected: - raise ValueError(f"workload binding {actual!r} is inconsistent with workload facts {expected!r}") - if ( - strategy.tensor_parallel != mapping.tensor_parallel - or strategy.pipeline_parallel != mapping.pipeline_parallel - or strategy.data_parallel != mapping.data_parallel - or strategy.recompute_policy != mapping.recompute.value - or strategy.pipeline_policy != f"1f1b-interleaved-{mapping.pipeline_interleaving}" - ): - raise ValueError("strategy binding is inconsistent with Transformer mapping facts") - return model, workload_spec, mapping - - -_TRAINING_STAGE = { - TrainingPhase.FORWARD: 0, - TrainingPhase.RECOMPUTE: 1, - TrainingPhase.RECOMMUNICATION: 1, - TrainingPhase.ACTIVATION_GRADIENT: 2, - TrainingPhase.WEIGHT_GRADIENT: 2, - TrainingPhase.OPTIMIZER: 3, -} - - -def _training_dependencies( - invocations: tuple[PrimitiveInvocation, ...], - task_ids: tuple[NodeId, ...], -) -> tuple[tuple[tuple[NodeId, ...], ...], int]: - """Build a conservative phase-ordered DAG and identify the block output producer.""" - - stages = tuple(_TRAINING_STAGE[item.phase] for item in invocations) - if not stages or stages[0] != 0 or any(current < previous for previous, current in zip(stages, stages[1:])): - raise ValueError("training invocations must be ordered by forward/recompute/backward/optimizer stage") - forward_indices = tuple(index for index, item in enumerate(invocations) if item.phase is TrainingPhase.FORWARD) - if not forward_indices: - raise ValueError("training lowering requires at least one forward invocation") - dependencies = tuple((task_ids[index - 1],) if index else () for index in range(len(task_ids))) - return dependencies, forward_indices[-1] - - -class DistributeTransformerTrainingPass(DerivationPass[ModelIR, DistributedTaskIR]): - """Expand one semantic block into explicit local and collective tasks.""" - - contract = PassContract.create( - "transformer-distribute-v2", - ModelIR, - DistributedTaskIR, - required_bindings=frozenset({BindingAxis.WORKLOAD, BindingAxis.STRATEGY}), - ) - - def run(self, ir: ModelIR, context: PassContext) -> DistributedTaskIR: - model, workload, mapping = _semantic_specs(ir, context) - invocations, block_memory = derive_transformer_block(model, workload, mapping) - ranks = tuple(range(mapping.tensor_parallel)) - mesh = LogicalMesh("local-tensor-parallel-group", (MeshAxis("tp", mapping.tensor_parallel),)) - source_input = ir.inputs[0] - source_output = ir.outputs[0] - input_id = ValueId.derive(ir.digest, "transformer-distributed", "input") - output_id = ValueId.derive(ir.digest, "transformer-distributed", "output") - tensor_type = TensorType( - (workload.microbatch_size, model.sequence_length, model.hidden_size), - workload.datatype, - ) - if mapping.tensor_parallel_communication is TensorParallelCommunication.REDUCE_SCATTER_ALL_GATHER: - sharding = ShardingSpec(((), ("tp",), ())) - else: - sharding = ShardingSpec.replicated(tensor_type.rank, ("tp",)) - - task_ids = tuple( - NodeId.derive(ir.digest, "transformer-distributed", index, invocation.name) - for index, invocation in enumerate(invocations) - ) - dependencies, output_producer_index = _training_dependencies(invocations, task_ids) - tasks = [] - for index, (task_id, invocation) in enumerate(zip(task_ids, invocations)): - collective = None - kind = DistributedTaskKind.LOCAL_COMPUTE - if invocation.engine is EngineKind.COLLECTIVE: - kind = DistributedTaskKind.COLLECTIVE - reduction = ( - ReductionKind.SUM - if invocation.collective in {CollectiveKind.ALL_REDUCE, CollectiveKind.REDUCE_SCATTER} - else None - ) - collective = CollectiveSpec( - kind=invocation.collective, - participants=ranks, - message_bytes=invocation.work.message_bytes, - reduction=reduction, - ) - operation = ( - OperationName("collective", invocation.collective.value) - if invocation.collective is not None - else OperationName("transformer", f"{invocation.primitive}_{invocation.phase.value}") - ) - tasks.append( - DistributedTask( - id=task_id, - kind=kind, - operation=operation, - ranks=ranks, - inputs=(input_id,) if index == 0 else (), - outputs=(output_id,) if index == output_producer_index else (), - dependencies=dependencies[index], - lineage=Lineage.lowered("transformer-decompose", (ir.operations[0].id,)), - collective=collective, - attributes=FrozenDict({"invocation": invocation}), - ) - ) - - return DistributedTaskIR( - name=f"{model.name}-local-tp-block", - source_model_digest=ir.digest, - mesh=mesh, - values=( - DistributedValue( - input_id, - tensor_type, - ValueRole.INPUT, - sharding, - ranks, - Lineage.lowered("transformer-distribute", (source_input,)), - source_value=source_input, - ), - DistributedValue( - output_id, - tensor_type, - ValueRole.OUTPUT, - sharding, - ranks, - Lineage.lowered("transformer-distribute", (source_output,)), - source_value=source_output, - ), - ), - tasks=tuple(tasks), - inputs=(input_id,), - outputs=(output_id,), - attributes=FrozenDict( - { - "model_spec": model, - "workload_spec": workload, - "mapping_spec": mapping, - "block_memory": block_memory, - "scope": "one-local-tensor-parallel-block", - } - ), - ) - - -def _plan_resources(invocation: PrimitiveInvocation) -> tuple[ResourceRequirement, ...]: - resources = [] - if invocation.work.operations: - resources.append( - ResourceRequirement( - ResourceKind.COMPUTE, - invocation.work.operations, - ResourceScope.PER_RANK, - FrozenDict({"engine": invocation.engine.value}), - ) - ) - if invocation.work.memory_bytes: - resources.append( - ResourceRequirement( - ResourceKind.MEMORY_BANDWIDTH, - invocation.work.memory_bytes, - ResourceScope.PER_RANK, - ) - ) - if invocation.work.message_bytes: - resources.append( - ResourceRequirement( - ResourceKind.NETWORK, - invocation.work.message_bytes, - ResourceScope.PER_RANK, - ) - ) - return tuple(resources) - - -class PlanTransformerTrainingPass(DerivationPass[DistributedTaskIR, PortablePlanIR]): - """Materialize exact WorkloadFacts without choosing a hardware target.""" - - contract = PassContract.create( - "transformer-plan-work-v2", - DistributedTaskIR, - PortablePlanIR, - required_bindings=frozenset({BindingAxis.STRATEGY}), - ) - - def run(self, ir: DistributedTaskIR, context: PassContext) -> PortablePlanIR: - model = ir.attributes.get("model_spec") - workload = ir.attributes.get("workload_spec") - mapping = ir.attributes.get("mapping_spec") - if not isinstance(model, TransformerModelSpec): - raise TypeError("distributed Transformer IR is missing TransformerModelSpec") - if not isinstance(workload, TransformerTrainingWorkloadSpec): - raise TypeError("distributed Transformer IR is missing TransformerTrainingWorkloadSpec") - if not isinstance(mapping, TransformerTrainingMappingSpec): - raise TypeError("distributed Transformer IR is missing typed semantic facts") - strategy = context.session.bindings.strategy - if strategy is None: - raise ValueError("portable planning requires a strategy binding") - - input_id = BufferId.derive(ir.digest, "transformer-portable", "input") - output_id = BufferId.derive(ir.digest, "transformer-portable", "output") - boundary_elements = workload.microbatch_size * model.sequence_length * model.hidden_size - if mapping.tensor_parallel_communication is TensorParallelCommunication.REDUCE_SCATTER_ALL_GATHER: - boundary_elements //= mapping.tensor_parallel - boundary_bytes = boundary_elements * workload.bytes_per_element - - task_ids = tuple( - NodeId.derive(ir.digest, "transformer-portable", index, task.id) for index, task in enumerate(ir.tasks) - ) - invocations = tuple(task.attributes.get("invocation") for task in ir.tasks) - if any(not isinstance(item, PrimitiveInvocation) for item in invocations): - raise TypeError("distributed task is missing a typed PrimitiveInvocation") - typed_invocations = tuple(item for item in invocations if isinstance(item, PrimitiveInvocation)) - dependencies, output_producer_index = _training_dependencies(typed_invocations, task_ids) - tasks = [] - for index, (source_task, task_id, invocation) in enumerate(zip(ir.tasks, task_ids, typed_invocations)): - if invocation.engine is EngineKind.MATRIX: - capability = "matrix-multiply" - alternatives = ("tensor-core", "matrix-engine") - elif invocation.engine is EngineKind.VECTOR: - capability = "vector-elementwise" - alternatives = ("vector-engine",) - else: - capability = invocation.collective.value - alternatives = ("collective-library", "network-engine") - tasks.append( - PlanTask( - id=task_id, - kind=( - PlanTaskKind.COLLECTIVE if invocation.engine is EngineKind.COLLECTIVE else PlanTaskKind.COMPUTE - ), - operation=source_task.operation, - dependencies=dependencies[index], - inputs=(input_id,) if index == 0 else (), - outputs=(output_id,) if index == output_producer_index else (), - logical_ranks=source_task.ranks, - workload=WorkloadFacts( - operations=invocation.work.operations, - read_bytes=invocation.work.read_bytes, - write_bytes=invocation.work.write_bytes, - message_bytes=invocation.work.message_bytes, - attributes=FrozenDict( - { - "name": invocation.name, - "engine": invocation.engine.value, - "phase": invocation.phase.value, - "primitive": invocation.primitive, - "source_layer": invocation.source_layer, - "collective": ( - invocation.collective.value if invocation.collective is not None else "" - ), - } - ), - ), - lineage=Lineage.lowered("transformer-plan-work", (source_task.id,)), - resources=_plan_resources(invocation), - implementations=(ImplementationRequirement(capability, alternatives=alternatives),), - concurrency_group=("network" if invocation.engine is EngineKind.COLLECTIVE else "compute"), - ) - ) - - return PortablePlanIR( - name=f"{model.name}-local-tp-block-plan", - source_distributed_digest=ir.digest, - strategy_fingerprint=strategy.fingerprint, - planner_revision="transformer-work-analysis-v2", - tasks=tuple(tasks), - buffers=( - PlanBuffer( - input_id, - boundary_bytes, - PlanBufferRole.INPUT, - AbstractStorageClass.DEVICE_LOCAL, - Lineage.lowered("transformer-plan-buffer", (ir.inputs[0],)), - consumers=(task_ids[0],), - alignment_bytes=16, - ), - PlanBuffer( - output_id, - boundary_bytes, - PlanBufferRole.OUTPUT, - AbstractStorageClass.DEVICE_LOCAL, - Lineage.lowered("transformer-plan-buffer", (ir.outputs[0],)), - producer=task_ids[output_producer_index], - alignment_bytes=16, - ), - ), - inputs=(input_id,), - outputs=(output_id,), - objectives=( - PlanObjective(ObjectiveKind.LATENCY, ObjectiveDirection.MINIMIZE), - PlanObjective(ObjectiveKind.PEAK_MEMORY, ObjectiveDirection.MINIMIZE), - ), - attributes=FrozenDict( - { - "model_spec": model, - "workload_spec": workload, - "mapping_spec": mapping, - "block_memory": ir.attributes["block_memory"], - "scope": ir.attributes["scope"], - } - ), - ) diff --git a/src/blueprinting/synthesizer/lowering/transformer_inference.py b/src/blueprinting/synthesizer/lowering/transformer_inference.py deleted file mode 100644 index 2b550e3..0000000 --- a/src/blueprinting/synthesizer/lowering/transformer_inference.py +++ /dev/null @@ -1,452 +0,0 @@ -"""Inference lowerings from a semantic decoder to a phase-local work plan.""" - -from __future__ import annotations - -from blueprinting.mapping import TransformerInferenceMappingSpec -from blueprinting.schema.frozen import FrozenDict -from blueprinting.workload import TransformerModelSpec - -from ..axes import BindingAxis -from ..bindings import InferencePhase, WorkloadMode -from ..dialects.transformer import ( - EngineKind, - InferenceBlockMemoryFacts, - InferenceInvocation, - derive_transformer_inference_block, -) -from ..ids import BufferId, Lineage, NodeId, ValueId -from ..ir import ( - AbstractStorageClass, - CollectiveKind, - CollectiveSpec, - DistributedTask, - DistributedTaskIR, - DistributedTaskKind, - DistributedValue, - Effect, - EffectKind, - ImplementationRequirement, - LogicalMesh, - MeshAxis, - ModelIR, - ObjectiveDirection, - ObjectiveKind, - OperationName, - PlanBuffer, - PlanBufferRole, - PlanObjective, - PlanTask, - PlanTaskKind, - PortablePlanIR, - ReductionKind, - ResourceKind, - ResourceRequirement, - ResourceScope, - ShardingSpec, - TensorType, - ValueRole, - WorkloadFacts, -) -from ..passes import DerivationPass, PassContext, PassContract - - -def _semantic_specs( - ir: ModelIR, - context: PassContext, -) -> tuple[TransformerModelSpec, TransformerInferenceMappingSpec, InferencePhase, int, int, int, str]: - if len(ir.operations) != 1 or ir.operations[0].operation != OperationName("transformer", "decoder_inference"): - raise ValueError("Transformer inference distribution expects one transformer.decoder_inference operation") - model = ir.operations[0].attributes.get("model_spec") - if not isinstance(model, TransformerModelSpec): - raise TypeError("model operation is missing a typed TransformerModelSpec") - workload = context.session.bindings.workload - strategy = context.session.bindings.strategy - if workload is None or strategy is None: - raise ValueError("Transformer inference distribution requires workload and strategy bindings") - if workload.mode is not WorkloadMode.INFERENCE or workload.inference_phase is None: - raise ValueError("Transformer inference requires an explicit inference phase") - mapping = strategy.attributes.get("inference_mapping_spec") - if not isinstance(mapping, TransformerInferenceMappingSpec): - raise TypeError("strategy binding is missing a typed TransformerInferenceMappingSpec") - if ( - strategy.tensor_parallel != mapping.tensor_parallel - or strategy.pipeline_parallel != mapping.pipeline_parallel - or strategy.data_parallel != mapping.replicas - ): - raise ValueError("strategy binding is inconsistent with inference execution facts") - if any( - isinstance(value, bool) or not isinstance(value, int) - for value in (workload.batch_size, workload.sequence_length) - ): - raise TypeError("static Transformer inference requires concrete integer workload bindings") - batch_size = workload.batch_size - context_tokens = workload.sequence_length - query_tokens = context_tokens if workload.inference_phase is InferencePhase.PREFILL else 1 - if workload.attributes.get("query_tokens") != query_tokens: - raise ValueError("workload query_tokens attribute is inconsistent with the inference phase") - if workload.attributes.get("context_tokens") != context_tokens: - raise ValueError("workload context_tokens attribute is inconsistent with sequence_length") - datatype = workload.attributes.get("datatype") - if not isinstance(datatype, str): - raise TypeError("inference workload binding is missing a concrete datatype") - mapping.validate_model(model) - return model, mapping, workload.inference_phase, batch_size, query_tokens, context_tokens, datatype - - -class DistributeTransformerInferencePass(DerivationPass[ModelIR, DistributedTaskIR]): - """Expand one phase into observable local and collective components.""" - - contract = PassContract.create( - "transformer-inference-distribute-v2", - ModelIR, - DistributedTaskIR, - required_bindings=frozenset({BindingAxis.WORKLOAD, BindingAxis.STRATEGY}), - ) - - def run(self, ir: ModelIR, context: PassContext) -> DistributedTaskIR: - model, mapping, phase, batch_size, query_tokens, context_tokens, datatype = _semantic_specs(ir, context) - invocations, block_memory = derive_transformer_inference_block( - model, - mapping, - phase=phase, - batch_size=batch_size, - context_tokens=context_tokens, - datatype=datatype, - ) - ranks = tuple(range(mapping.tensor_parallel)) - mesh = LogicalMesh("local-tensor-parallel-group", (MeshAxis("tp", mapping.tensor_parallel),)) - source_input = ir.inputs[0] - source_output = ir.outputs[0] - source_cache = next(value.id for value in ir.values if value.role is ValueRole.KV_CACHE) - input_id = ValueId.derive(ir.digest, phase.value, "transformer-distributed", "input") - cache_id = ValueId.derive(ir.digest, phase.value, "transformer-distributed", "kv-cache") - output_id = ValueId.derive(ir.digest, phase.value, "transformer-distributed", "output") - boundary_type = TensorType((batch_size, query_tokens, model.hidden_size), datatype) - cache_type = TensorType((2, batch_size, context_tokens, model.hidden_size), datatype) - boundary_sharding = ShardingSpec.replicated(boundary_type.rank, ("tp",)) - cache_sharding = ShardingSpec(((), (), (), ("tp",))) - - task_ids = tuple( - NodeId.derive(ir.digest, phase.value, "transformer-distributed", index, invocation.name) - for index, invocation in enumerate(invocations) - ) - tasks = [] - cache_primitives = frozenset({"attention_kv_cache_save", "attention_core"}) - for index, (task_id, invocation) in enumerate(zip(task_ids, invocations)): - collective = None - kind = DistributedTaskKind.LOCAL_COMPUTE - if invocation.engine is EngineKind.COLLECTIVE: - kind = DistributedTaskKind.COLLECTIVE - collective = CollectiveSpec( - kind=invocation.collective, - participants=ranks, - message_bytes=invocation.work.message_bytes, - reduction=( - ReductionKind.SUM - if invocation.collective in {CollectiveKind.ALL_REDUCE, CollectiveKind.REDUCE_SCATTER} - else None - ), - ) - operation = ( - OperationName("collective", invocation.collective.value) - if invocation.collective is not None - else OperationName("transformer", f"{invocation.primitive}_{phase.value}") - ) - inputs = [] - if index == 0: - inputs.append(input_id) - if invocation.primitive in cache_primitives: - inputs.append(cache_id) - effects = () - if invocation.primitive == "attention_kv_cache_save": - effects = (Effect(EffectKind.WRITE, "kv_cache"),) - elif invocation.primitive == "attention_core": - effects = (Effect(EffectKind.READ, "kv_cache"),) - tasks.append( - DistributedTask( - id=task_id, - kind=kind, - operation=operation, - ranks=ranks, - inputs=tuple(inputs), - outputs=(output_id,) if index == len(invocations) - 1 else (), - dependencies=(task_ids[index - 1],) if index else (), - lineage=Lineage.lowered("transformer-inference-decompose", (ir.operations[0].id,)), - collective=collective, - effects=effects, - attributes=FrozenDict({"invocation": invocation}), - ) - ) - - return DistributedTaskIR( - name=f"{model.name}-{phase.value}-local-tp-block", - source_model_digest=ir.digest, - mesh=mesh, - values=( - DistributedValue( - input_id, - boundary_type, - ValueRole.INPUT, - boundary_sharding, - ranks, - Lineage.lowered("transformer-inference-distribute", (source_input,)), - source_value=source_input, - ), - DistributedValue( - cache_id, - cache_type, - ValueRole.KV_CACHE, - cache_sharding, - ranks, - Lineage.lowered("transformer-inference-distribute", (source_cache,)), - source_value=source_cache, - ), - DistributedValue( - output_id, - boundary_type, - ValueRole.OUTPUT, - boundary_sharding, - ranks, - Lineage.lowered("transformer-inference-distribute", (source_output,)), - source_value=source_output, - ), - ), - tasks=tuple(tasks), - inputs=(input_id, cache_id), - outputs=(output_id,), - attributes=FrozenDict( - { - "model_spec": model, - "inference_mapping_spec": mapping, - "inference_phase": phase, - "batch_size": batch_size, - "query_tokens": query_tokens, - "context_tokens": context_tokens, - "datatype": datatype, - "block_memory": block_memory, - "scope": "one-local-tensor-parallel-block-phase", - } - ), - ) - - -def _plan_resources(invocation: InferenceInvocation) -> tuple[ResourceRequirement, ...]: - resources = [] - if invocation.work.operations: - resources.append( - ResourceRequirement( - ResourceKind.COMPUTE, - invocation.work.operations, - ResourceScope.PER_RANK, - FrozenDict({"engine": invocation.engine.value}), - ) - ) - if invocation.work.memory_bytes: - resources.append( - ResourceRequirement(ResourceKind.MEMORY_BANDWIDTH, invocation.work.memory_bytes, ResourceScope.PER_RANK) - ) - if invocation.work.message_bytes: - resources.append( - ResourceRequirement( - ResourceKind.NETWORK, - invocation.work.message_bytes, - ResourceScope.PER_RANK, - ) - ) - return tuple(resources) - - -def _implementation(invocation: InferenceInvocation) -> ImplementationRequirement: - if invocation.engine is EngineKind.COLLECTIVE: - return ImplementationRequirement( - invocation.collective.value, alternatives=("collective-library", "network-engine") - ) - alternatives = { - "attention_core": ("flash-attention", "paged-attention", "dense-attention"), - "attention_kv_cache_save": ("fused-kv-write", "vector-engine"), - "attention_pre_projection": ("tensor-core", "matrix-engine"), - "attention_post_projection": ("tensor-core", "matrix-engine"), - "mlp_up_projection": ("tensor-core", "matrix-engine"), - "mlp_down_projection": ("tensor-core", "matrix-engine"), - }.get(invocation.primitive) - if alternatives is not None: - return ImplementationRequirement(invocation.primitive, alternatives=alternatives) - return ImplementationRequirement(invocation.primitive, alternatives=("vector-engine",)) - - -class PlanTransformerInferencePass(DerivationPass[DistributedTaskIR, PortablePlanIR]): - """Materialize a phase plan without target placement or measured time.""" - - contract = PassContract.create( - "transformer-inference-plan-work-v2", - DistributedTaskIR, - PortablePlanIR, - required_bindings=frozenset({BindingAxis.WORKLOAD, BindingAxis.STRATEGY}), - ) - - def run(self, ir: DistributedTaskIR, context: PassContext) -> PortablePlanIR: - model = ir.attributes.get("model_spec") - mapping = ir.attributes.get("inference_mapping_spec") - phase = ir.attributes.get("inference_phase") - block_memory = ir.attributes.get("block_memory") - if not isinstance(model, TransformerModelSpec): - raise TypeError("distributed inference IR is missing TransformerModelSpec") - if not isinstance(mapping, TransformerInferenceMappingSpec): - raise TypeError("distributed inference IR is missing TransformerInferenceMappingSpec") - if not isinstance(phase, InferencePhase): - raise TypeError("distributed inference IR is missing InferencePhase") - if not isinstance(block_memory, InferenceBlockMemoryFacts): - raise TypeError("distributed inference IR is missing InferenceBlockMemoryFacts") - strategy = context.session.bindings.strategy - if strategy is None: - raise ValueError("portable inference planning requires a strategy binding") - - input_id = BufferId.derive(ir.digest, "transformer-inference-portable", "input") - output_id = BufferId.derive(ir.digest, "transformer-inference-portable", "output") - weight_id = BufferId.derive(ir.digest, "transformer-inference-portable", "weights") - cache_id = BufferId.derive(ir.digest, "transformer-inference-portable", "kv-cache") - workspace_id = BufferId.derive(ir.digest, "transformer-inference-portable", "workspace-upper-bound") - task_ids = tuple( - NodeId.derive(ir.digest, "transformer-inference-portable", index, task.id) - for index, task in enumerate(ir.tasks) - ) - invocations = tuple(task.attributes.get("invocation") for task in ir.tasks) - if any(not isinstance(item, InferenceInvocation) for item in invocations): - raise TypeError("distributed inference task is missing InferenceInvocation") - weight_consumers = tuple( - task_id for task_id, invocation in zip(task_ids, invocations) if invocation.engine is EngineKind.MATRIX - ) - cache_consumers = tuple( - task_id - for task_id, invocation in zip(task_ids, invocations) - if invocation.primitive in {"attention_kv_cache_save", "attention_core"} - ) - - tasks = [] - for index, (source_task, task_id, invocation) in enumerate(zip(ir.tasks, task_ids, invocations)): - inputs = [workspace_id] - if index == 0: - inputs.append(input_id) - if invocation.engine is EngineKind.MATRIX: - inputs.append(weight_id) - if invocation.primitive in {"attention_kv_cache_save", "attention_core"}: - inputs.append(cache_id) - tasks.append( - PlanTask( - id=task_id, - kind=( - PlanTaskKind.COLLECTIVE if invocation.engine is EngineKind.COLLECTIVE else PlanTaskKind.COMPUTE - ), - operation=source_task.operation, - dependencies=(task_ids[index - 1],) if index else (), - inputs=tuple(inputs), - outputs=(output_id,) if index == len(ir.tasks) - 1 else (), - logical_ranks=source_task.ranks, - workload=WorkloadFacts( - operations=invocation.work.operations, - read_bytes=invocation.work.read_bytes, - write_bytes=invocation.work.write_bytes, - message_bytes=invocation.work.message_bytes, - attributes=FrozenDict( - { - "name": invocation.name, - "engine": invocation.engine.value, - "phase": invocation.phase.value, - "primitive": invocation.primitive, - "source_layer": invocation.source_layer, - "query_tokens": ir.attributes["query_tokens"], - "context_tokens": ir.attributes["context_tokens"], - "collective": ( - invocation.collective.value if invocation.collective is not None else "" - ), - } - ), - ), - lineage=Lineage.lowered("transformer-inference-plan-work", (source_task.id,)), - resources=_plan_resources(invocation), - implementations=(_implementation(invocation),), - concurrency_group=("network" if invocation.engine is EngineKind.COLLECTIVE else "compute"), - effects=source_task.effects, - ) - ) - - return PortablePlanIR( - name=f"{model.name}-{phase.value}-local-tp-block-plan", - source_distributed_digest=ir.digest, - strategy_fingerprint=strategy.fingerprint, - planner_revision="transformer-inference-work-analysis-v2", - tasks=tuple(tasks), - buffers=( - PlanBuffer( - input_id, - block_memory.boundary, - PlanBufferRole.INPUT, - AbstractStorageClass.DEVICE_LOCAL, - Lineage.lowered("transformer-inference-plan-buffer", (ir.inputs[0],)), - consumers=(task_ids[0],), - alignment_bytes=16, - ), - PlanBuffer( - output_id, - block_memory.boundary, - PlanBufferRole.OUTPUT, - AbstractStorageClass.DEVICE_LOCAL, - Lineage.lowered("transformer-inference-plan-buffer", (ir.outputs[0],)), - producer=task_ids[-1], - alignment_bytes=16, - ), - PlanBuffer( - weight_id, - block_memory.weights, - PlanBufferRole.CONSTANT, - AbstractStorageClass.PERSISTENT, - Lineage.lowered("transformer-inference-plan-weights", tuple(task.id for task in ir.tasks)), - consumers=weight_consumers, - alignment_bytes=16, - attributes=FrozenDict({"semantic": "block_weights"}), - ), - PlanBuffer( - cache_id, - block_memory.kv_cache, - PlanBufferRole.STATE, - AbstractStorageClass.PERSISTENT, - Lineage.lowered("transformer-inference-plan-cache", (ir.inputs[1],)), - consumers=cache_consumers, - alignment_bytes=16, - attributes=FrozenDict({"semantic": "kv_cache", "phase": phase.value}), - ), - PlanBuffer( - workspace_id, - block_memory.working_upper_bound, - PlanBufferRole.WORKSPACE, - AbstractStorageClass.TRANSIENT, - Lineage.lowered("transformer-inference-plan-workspace", tuple(task.id for task in ir.tasks)), - consumers=task_ids, - alignment_bytes=16, - attributes=FrozenDict( - { - "semantic": "block_working_upper_bound", - "bound": "unfused-score-materialization", - } - ), - ), - ), - inputs=(input_id,), - outputs=(output_id,), - objectives=( - PlanObjective(ObjectiveKind.LATENCY, ObjectiveDirection.MINIMIZE), - PlanObjective(ObjectiveKind.PEAK_MEMORY, ObjectiveDirection.MINIMIZE), - ), - attributes=FrozenDict( - { - "model_spec": model, - "inference_mapping_spec": mapping, - "inference_phase": phase, - "batch_size": ir.attributes["batch_size"], - "query_tokens": ir.attributes["query_tokens"], - "context_tokens": ir.attributes["context_tokens"], - "datatype": ir.attributes["datatype"], - "scope": ir.attributes["scope"], - } - ), - ) diff --git a/src/blueprinting/synthesizer/passes/__init__.py b/src/blueprinting/synthesizer/passes/__init__.py index ba59cab..dd8f29f 100644 --- a/src/blueprinting/synthesizer/passes/__init__.py +++ b/src/blueprinting/synthesizer/passes/__init__.py @@ -6,19 +6,29 @@ AnalysisKey, AnalysisProduct, AnalysisStore, + ClaimEvidence, DerivationPass, + DeterminismPolicy, FunctionPass, MutationModel, PassCheckpoint, PassContext, PassContract, PassManager, + PassNormalizer, PassObserver, PassPipeline, PassRecord, PassResult, + PassRule, PipelineResult, + RelationCheckContext, + RuleClaim, SchemaRange, + TransitionRelation, + TransitionReport, + TransitionVerificationStatus, + TransitionVerifier, VerificationPolicy, ) @@ -28,18 +38,28 @@ "AnalysisKey", "AnalysisProduct", "AnalysisStore", + "ClaimEvidence", "DerivationPass", + "DeterminismPolicy", "FunctionPass", "MutationModel", "PassContext", "PassCheckpoint", "PassContract", "PassManager", + "PassNormalizer", "PassObserver", "PassPipeline", "PassRecord", "PassResult", + "PassRule", + "RelationCheckContext", + "RuleClaim", "PipelineResult", "SchemaRange", + "TransitionRelation", + "TransitionReport", + "TransitionVerificationStatus", + "TransitionVerifier", "VerificationPolicy", ] diff --git a/src/blueprinting/synthesizer/passes/authoring.py b/src/blueprinting/synthesizer/passes/authoring.py new file mode 100644 index 0000000..58de9ca --- /dev/null +++ b/src/blueprinting/synthesizer/passes/authoring.py @@ -0,0 +1,37 @@ +"""Stable authoring surface for verified derivation passes. + +Application code should execute passes through :class:`PassManager`. This +module is for trusted pass and target-extension authors and deliberately omits +transaction-runner and registry implementation details. +""" + +from .base import ( + AnalysisKey, + AnalysisProduct, + DerivationPass, + MutationModel, + PassContext, + PassResult, + PassRule, + RelationCheckContext, + RuleClaim, + VerificationPolicy, +) +from .deriving import claim, derivation, equal_claim, relation + +__all__ = [ + "AnalysisKey", + "AnalysisProduct", + "DerivationPass", + "MutationModel", + "PassContext", + "PassResult", + "PassRule", + "RelationCheckContext", + "RuleClaim", + "VerificationPolicy", + "claim", + "derivation", + "equal_claim", + "relation", +] diff --git a/src/blueprinting/synthesizer/passes/base.py b/src/blueprinting/synthesizer/passes/base.py index a9b41dd..29823fd 100644 --- a/src/blueprinting/synthesizer/passes/base.py +++ b/src/blueprinting/synthesizer/passes/base.py @@ -12,29 +12,96 @@ import threading import time from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import dataclass, field from enum import Enum -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Protocol, TypeVar from blueprinting.schema.codec import content_digest +from blueprinting.schema.diagnostics import Diagnostic, DiagnosticSet from blueprinting.schema.errors import SerializationError from blueprinting.schema.frozen import freeze +from blueprinting.schema.result import Checked, Err, Ok from ..axes import BindingAxis from ..errors import ( + BindingError, + IRVerificationError, MissingAnalysisError, + MissingBindingError, PassContractError, PassExecutionError, SynthesisError, ) -from ..ir.common import CanonicalIRMixin, SchemaVersion +from ..ids import Lineage, LineageKind, StableId from ..session import SynthesisSession +from ..stages.common import CanonicalIRMixin, SchemaVersion InputIR = TypeVar("InputIR", bound=CanonicalIRMixin) OutputIR = TypeVar("OutputIR", bound=CanonicalIRMixin) +def _diagnostic_code(error: SynthesisError) -> str: + if isinstance(error, MissingBindingError): + return "pass.missing_binding" + if isinstance(error, BindingError): + return "binding.invalid" + if isinstance(error, MissingAnalysisError): + return "pass.missing_analysis" + if isinstance(error, PassContractError): + return "pass.contract" + if isinstance(error, IRVerificationError): + return "ir.verification" + return "synthesis.failure" + + +class RuleVerifier(Protocol): + """Executable semantic predicate for one declared lineage rule.""" + + def __call__(self, source: Any, target: Any, context: RelationCheckContext) -> None: ... + + +PassNormalizer = Callable[[Any, SynthesisSession], CanonicalIRMixin] + + +@dataclass(frozen=True) +class RelationCheckContext: + """Read-only whole-boundary context available to executable rule claims.""" + + source_ir: CanonicalIRMixin + target_ir: CanonicalIRMixin + source_to_targets: Mapping[StableId, tuple[StableId, ...]] + target_to_sources: Mapping[StableId, tuple[StableId, ...]] + target_entity_kinds: Mapping[StableId, str] + session: SynthesisSession + + def targets_for(self, source_id: StableId) -> tuple[StableId, ...]: + return self.source_to_targets.get(source_id, ()) + + def only_target_for(self, source_id: StableId, target_entity: str | None = None) -> StableId: + targets = self.targets_for(source_id) + if target_entity is not None: + targets = tuple(item for item in targets if self.target_entity_kinds.get(item) == target_entity) + if len(targets) != 1: + suffix = f" of kind {target_entity}" if target_entity is not None else "" + raise ValueError(f"source {source_id} maps to {len(targets)} targets{suffix}, expected exactly one") + return targets[0] + + +@dataclass(frozen=True) +class RuleClaim: + """One named preservation property backed by an executable predicate.""" + + name: str + verifier: RuleVerifier = field(compare=False, repr=False) + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name: + raise ValueError("rule claim name must be a non-empty string") + if not callable(self.verifier): + raise TypeError("rule claim verifier must be callable") + + @dataclass(frozen=True, order=True) class SchemaRange: """Inclusive schema compatibility interval.""" @@ -56,20 +123,17 @@ def accepts(self, version: SchemaVersion) -> bool: @dataclass(frozen=True, order=True) class AnalysisKey: - """Versioned identity of one derived analysis kind.""" + """Identity of one derived analysis kind in the current schema epoch.""" namespace: str name: str - version: int = 1 def __post_init__(self) -> None: if not self.namespace or not self.name: raise ValueError("analysis namespace and name must not be empty") - if isinstance(self.version, bool) or not isinstance(self.version, int) or self.version <= 0: - raise ValueError("analysis version must be a positive integer") def __str__(self) -> str: - return f"{self.namespace}.{self.name}@{self.version}" + return f"{self.namespace}.{self.name}" @dataclass(frozen=True, order=True) @@ -195,6 +259,14 @@ def entries_for(self, ir_digest: str, context_fingerprint: str) -> tuple[Analysi ) return tuple(sorted(entries, key=lambda item: item.address.key)) + def clone(self) -> AnalysisStore: + """Return an isolated snapshot for deterministic replay.""" + + clone = AnalysisStore() + with self._lock: + clone._entries = dict(self._entries) + return clone + class MutationModel(Enum): IMMUTABLE = "immutable" @@ -216,11 +288,304 @@ def verifies_output(self) -> bool: return self in {VerificationPolicy.BOTH, VerificationPolicy.OUTPUT_ONLY} +class DeterminismPolicy(Enum): + OFF = "off" + VERIFY = "verify" + + +@dataclass(frozen=True, order=True) +class PassRule: + """Declarative semantic contract for one named lineage transform.""" + + transform: str + source_entity: str + target_entity: str + rewrite: str + preserves: tuple[RuleClaim, ...] = () + introduces: tuple[str, ...] = () + forbids: tuple[str, ...] = () + verifier: RuleVerifier | None = field(default=None, compare=False, repr=False) + + def __post_init__(self) -> None: + identity = (self.transform, self.source_entity, self.target_entity, self.rewrite) + if any(not isinstance(item, str) or not item for item in identity): + raise ValueError("pass rule identity and rewrite fields must be non-empty strings") + claims = tuple(self.preserves) + if any(not isinstance(item, RuleClaim) for item in claims): + raise TypeError("pass rule preserves must contain executable RuleClaim values") + if len({item.name for item in claims}) != len(claims): + raise ValueError("pass rule preservation claim names must be unique") + object.__setattr__(self, "preserves", claims) + for field_name in ("introduces", "forbids"): + values = tuple(getattr(self, field_name)) + if any(not isinstance(item, str) or not item for item in values): + raise ValueError(f"pass rule {field_name} must contain non-empty strings") + object.__setattr__(self, field_name, values) + if self.verifier is not None and not callable(self.verifier): + raise TypeError("pass rule verifier must be callable") + + @property + def preservation_names(self) -> tuple[str, ...]: + return tuple(item.name for item in self.preserves) + + +@dataclass(frozen=True, order=True) +class ClaimEvidence: + name: str + verifier: str + + +@dataclass(frozen=True, order=True) +class TransitionRelation: + rule_id: str + transform: str + source_entity: str + target_entity: str + source_ids: tuple[str, ...] + target_id: str + lineage_kind: LineageKind + evidence: tuple[ClaimEvidence, ...] = () + + +class TransitionVerificationStatus(Enum): + STRUCTURAL_ONLY = "structural_only" + CANONICAL_CONFORMANT = "canonical_conformant" + RELATION_VERIFIED = "relation_verified" + + +@dataclass(frozen=True) +class TransitionReport: + source_digest: str + target_digest: str + relations: tuple[TransitionRelation, ...] = () + status: TransitionVerificationStatus = TransitionVerificationStatus.STRUCTURAL_ONLY + canonical_conformance: ClaimEvidence | None = None + + @property + def verified_relations(self) -> int: + return sum(bool(item.evidence) for item in self.relations) + + @property + def verified_claims(self) -> int: + return sum(len(item.evidence) for item in self.relations) + + @property + def canonical_conformant(self) -> bool: + return self.canonical_conformance is not None + + +@dataclass(frozen=True) +class _EntityDescriptor: + kind: str + identifier: StableId + lineage: Lineage + value: Any + + +def _lineage_entities(ir: CanonicalIRMixin) -> tuple[_EntityDescriptor, ...]: + """Expose canonical entity identity without importing application views.""" + + from ..stages.concrete_plan.ir import ConcretePlanIR + from ..stages.distributed.ir import DistributedTaskIR + from ..stages.machine.ir import MachineIR + from ..stages.model.ir import ModelIR + from ..stages.portable_plan.ir import PortablePlanIR + + entities: tuple[Any, ...] + if isinstance(ir, ModelIR): + entities = tuple(ir.values) + tuple(ir.operations) + elif isinstance(ir, DistributedTaskIR): + entities = tuple(ir.values) + tuple(ir.tasks) + elif isinstance(ir, PortablePlanIR): + entities = tuple(ir.buffers) + tuple(ir.tasks) + elif isinstance(ir, ConcretePlanIR): + entities = tuple(ir.buffers) + tuple(ir.commands) + elif isinstance(ir, MachineIR): + entities = ir.instructions + else: + return () + return tuple(_EntityDescriptor(type(item).__name__, item.id, item.lineage, item) for item in entities) + + +class TransitionVerifier: + """Verify typed entity lineage and executable derivation laws.""" + + @staticmethod + def verify( + source: CanonicalIRMixin, + target: CanonicalIRMixin, + contract: PassContract, + session: SynthesisSession | None = None, + ) -> TransitionReport: + if contract.normalizer is not None and session is None: + raise PassContractError( + f"pass {contract.name!r} requires its synthesis session to evaluate the declared normal form" + ) + relation_session = session if session is not None else SynthesisSession() + + def verify_normal_form() -> ClaimEvidence | None: + if contract.normalizer is None: + return None + try: + expected = contract.normalizer(source, relation_session) + except Exception as error: + raise PassContractError( + f"pass {contract.name!r} could not evaluate its declared normal form: {error}" + ) from error + if expected != target: + raise PassContractError( + f"pass {contract.name!r} output differs from its declared canonical normal form" + ) + return ClaimEvidence( + "canonical normal form", + contract._callable_identity(contract.normalizer), + ) + + source_entities = {item.identifier: item for item in _lineage_entities(source)} + target_entities = _lineage_entities(target) + if not contract.rules: + if type(source) is not type(target): + raise PassContractError(f"cross-stage pass {contract.name!r} must declare executable lineage rules") + normal_form_evidence = verify_normal_form() + if source.digest == target.digest: + status = ( + TransitionVerificationStatus.CANONICAL_CONFORMANT + if normal_form_evidence is not None + else TransitionVerificationStatus.STRUCTURAL_ONLY + ) + elif normal_form_evidence is not None: + status = TransitionVerificationStatus.CANONICAL_CONFORMANT + else: + raise PassContractError( + f"same-stage pass {contract.name!r} changed canonical IR without a declared normal form or rules" + ) + return TransitionReport( + source.digest, + target.digest, + status=status, + canonical_conformance=normal_form_evidence, + ) + rules = {item.transform: item for item in contract.rules} + pending: list[tuple[_EntityDescriptor, tuple[_EntityDescriptor, ...], PassRule, str]] = [] + for target_entity in target_entities: + lineage = target_entity.lineage + if lineage.kind is LineageKind.ROOT: + raise PassContractError( + f"pass {contract.name!r} produced root lineage for {target_entity.kind} {target_entity.identifier}" + ) + rule = rules.get(lineage.transform) + if rule is None: + raise PassContractError( + f"pass {contract.name!r} produced undeclared lineage transform {lineage.transform!r}" + ) + resolved = [] + for source_id in lineage.sources: + entity = source_entities.get(source_id) + if entity is None: + raise PassContractError( + f"pass {contract.name!r} lineage source {source_id} does not exist in its input snapshot" + ) + resolved.append(entity) + if lineage.kind is LineageKind.GENERATED: + if resolved or rule.source_entity not in {"none", "generated"}: + raise PassContractError("generated lineage requires zero sources and an explicit generated rule") + actual_source_kind = "none" + else: + if not resolved: + raise PassContractError(f"{lineage.kind.value} lineage requires at least one source") + if lineage.kind in {LineageKind.PRESERVED, LineageKind.CLONED} and len(resolved) != 1: + raise PassContractError(f"{lineage.kind.value} lineage requires exactly one source") + if lineage.kind is LineageKind.FUSED and len(resolved) < 2: + raise PassContractError("fused lineage requires at least two sources") + source_kinds = {item.kind for item in resolved} + if len(source_kinds) != 1: + raise PassContractError("one lineage relation cannot mix source entity kinds") + actual_source_kind = next(iter(source_kinds)) + if len(resolved) > 1: + actual_source_kind += " set" + if actual_source_kind != rule.source_entity or target_entity.kind != rule.target_entity: + raise PassContractError( + f"pass {contract.name!r} transform {rule.transform!r} expected " + f"{rule.source_entity} -> {rule.target_entity}, got " + f"{actual_source_kind} -> {target_entity.kind}" + ) + pending.append((target_entity, tuple(resolved), rule, actual_source_kind)) + + source_to_targets: dict[StableId, list[StableId]] = {} + target_to_sources: dict[StableId, tuple[StableId, ...]] = {} + for target_entity, resolved_entities, _rule, _kind in pending: + target_to_sources[target_entity.identifier] = tuple(item.identifier for item in resolved_entities) + for item in resolved_entities: + source_to_targets.setdefault(item.identifier, []).append(target_entity.identifier) + context = RelationCheckContext( + source, + target, + {key: tuple(value) for key, value in source_to_targets.items()}, + target_to_sources, + {item.identifier: item.kind for item in target_entities}, + relation_session, + ) + normal_form_evidence = verify_normal_form() + relations = [] + for target_entity, resolved_entities, rule, actual_source_kind in pending: + source_value: Any = tuple(item.value for item in resolved_entities) + if len(source_value) == 1: + source_value = source_value[0] + try: + evidence = [] + if rule.verifier is not None: + rule.verifier(source_value, target_entity.value, context) + evidence.append(ClaimEvidence("relation invariant", contract._callable_identity(rule.verifier))) + for claim in rule.preserves: + claim.verifier(source_value, target_entity.value, context) + evidence.append(ClaimEvidence(claim.name, contract._callable_identity(claim.verifier))) + except PassContractError: + raise + except Exception as error: + raise PassContractError( + f"pass {contract.name!r} rule {rule.transform!r} rejected its relation: {error}" + ) from error + relations.append( + TransitionRelation( + rule_id=f"{contract.name}.{rule.transform}", + transform=rule.transform, + source_entity=actual_source_kind, + target_entity=target_entity.kind, + source_ids=tuple(str(item.identifier) for item in resolved_entities), + target_id=str(target_entity.identifier), + lineage_kind=target_entity.lineage.kind, + evidence=tuple(evidence), + ) + ) + has_complete_evidence = bool(relations) and all(item.evidence for item in relations) + if not has_complete_evidence and source.digest != target.digest: + raise PassContractError( + f"pass {contract.name!r} changed canonical IR without complete executable relation evidence" + ) + status = ( + TransitionVerificationStatus.RELATION_VERIFIED + if has_complete_evidence + else TransitionVerificationStatus.STRUCTURAL_ONLY + ) + if type(source) is not type(target) and status is not TransitionVerificationStatus.RELATION_VERIFIED: + raise PassContractError( + f"cross-stage pass {contract.name!r} did not produce executable claim evidence for every relation" + ) + return TransitionReport( + source.digest, + target.digest, + tuple(relations), + status, + normal_form_evidence, + ) + + @dataclass(frozen=True) class PassContract: """Complete static contract for one canonical IR transition.""" name: str + revision: str input_type: type[CanonicalIRMixin] input_schema: SchemaRange output_type: type[CanonicalIRMixin] @@ -233,10 +598,14 @@ class PassContract: verification: VerificationPolicy = VerificationPolicy.BOTH deterministic: bool = True uses_session_seed: bool = False + rules: tuple[PassRule, ...] = () + normalizer: PassNormalizer | None = field(default=None, compare=False, repr=False) def __post_init__(self) -> None: if not self.name: raise ValueError("pass name must not be empty") + if not isinstance(self.revision, str) or not self.revision: + raise ValueError("pass revision must be a non-empty string") if not issubclass(self.input_type, CanonicalIRMixin) or not issubclass(self.output_type, CanonicalIRMixin): raise TypeError("pass input and output types must be canonical IR roots") if not self.input_schema.accepts(self.input_type.SCHEMA_VERSION): @@ -247,23 +616,89 @@ def __post_init__(self) -> None: object.__setattr__(self, "required_analyses", frozenset(self.required_analyses)) object.__setattr__(self, "preserved_analyses", frozenset(self.preserved_analyses)) object.__setattr__(self, "produced_analyses", frozenset(self.produced_analyses)) + rules = tuple(self.rules) + if any(not isinstance(item, PassRule) for item in rules): + raise TypeError("pass rules must contain only PassRule values") + object.__setattr__(self, "rules", rules) + if len({item.transform for item in self.rules}) != len(self.rules): + raise ValueError("pass rule transforms must be unique") + if self.uses_session_seed and not self.deterministic: + raise ValueError("uses_session_seed requires a deterministic pass contract") + if self.normalizer is not None and not callable(self.normalizer): + raise TypeError("pass normalizer must be callable") overlap = self.preserved_analyses & self.produced_analyses if overlap: rendered = ", ".join(str(item) for item in sorted(overlap)) raise ValueError(f"analyses cannot be both preserved and produced: {rendered}") + @staticmethod + def _callable_identity(value: Callable[..., Any] | None) -> str: + if value is None: + return "" + return f"{value.__module__}.{value.__qualname__}" + + @property + def normalizer_identity(self) -> str | None: + """Stable diagnostic identity of the canonical derivation law.""" + + identity = self._callable_identity(self.normalizer) + return identity or None + + @property + def digest(self) -> str: + """Content identity of the contract and its declared callable identities.""" + + rules = tuple( + ( + rule.transform, + rule.source_entity, + rule.target_entity, + rule.rewrite, + tuple((claim.name, self._callable_identity(claim.verifier)) for claim in rule.preserves), + rule.introduces, + rule.forbids, + self._callable_identity(rule.verifier), + ) + for rule in self.rules + ) + return content_digest( + ( + self.name, + self.revision, + self.input_type.SCHEMA_NAME, + str(self.input_schema.minimum), + str(self.input_schema.maximum), + self.output_type.SCHEMA_NAME, + str(self.output_schema), + tuple(sorted(axis.value for axis in self.required_bindings)), + tuple(sorted(str(key) for key in self.required_analyses)), + tuple(sorted(str(key) for key in self.preserved_analyses)), + tuple(sorted(str(key) for key in self.produced_analyses)), + self.mutation_model.value, + self.verification.value, + self.deterministic, + self.uses_session_seed, + rules, + self._callable_identity(self.normalizer), + ), + "pass-contract", + ) + @classmethod def create( cls, name: str, input_type: type[CanonicalIRMixin], output_type: type[CanonicalIRMixin], + *, + revision: str = "1", **options: Any, ) -> PassContract: """Build the common exact-schema contract without hiding its resolved values.""" return cls( name=name, + revision=revision, input_type=input_type, input_schema=SchemaRange.exact(input_type.SCHEMA_VERSION), output_type=output_type, @@ -354,12 +789,15 @@ def __len__(self) -> int: @dataclass(frozen=True) class PassRecord: pass_name: str + contract_revision: str + contract_digest: str input_digest: str output_digest: str session_fingerprint: str duration_ns: int mutation_model: MutationModel produced_analyses: tuple[AnalysisKey, ...] + transition_report: TransitionReport @dataclass(frozen=True) @@ -394,9 +832,24 @@ def __init__( analyses: AnalysisStore | None = None, *, observers: Iterable[PassObserver] = (), + determinism: DeterminismPolicy = DeterminismPolicy.OFF, ) -> None: self.analyses = analyses if analyses is not None else AnalysisStore() self.observers = tuple(observers) + if not isinstance(determinism, DeterminismPolicy): + raise TypeError("determinism must be a DeterminismPolicy") + self.determinism = determinism + + @staticmethod + def _result_signature(result: PassResult[Any]) -> tuple[str, tuple[tuple[str, str], ...]]: + products = tuple( + ( + str(product.key), + content_digest(freeze(product.value), f"analysis:{product.key}"), + ) + for product in result.analyses + ) + return result.ir.digest, products def run( self, @@ -404,11 +857,45 @@ def run( ir: InputIR, *, session: SynthesisSession, + ) -> Checked[PipelineResult[Any]]: + """Execute a pipeline and return expected contract failures as diagnostics.""" + + try: + return Ok(self._run(pipeline, ir, session=session)) + except PassExecutionError: + raise + except SynthesisError as error: + return Err( + DiagnosticSet.of( + Diagnostic( + _diagnostic_code(error), + str(error), + ("pipeline",), + ) + ) + ) + + def require_run( + self, + pipeline: PassPipeline, + ir: InputIR, + *, + session: SynthesisSession, + ) -> PipelineResult[Any]: + """Explicit exception adapter for application and legacy boundaries.""" + + return self._run(pipeline, ir, session=session) + + def _run( + self, + pipeline: PassPipeline, + ir: InputIR, + *, + session: SynthesisSession, ) -> PipelineResult[Any]: current: CanonicalIRMixin = ir records = [] checkpoints = [] - context = PassContext(session=session, analyses=self.analyses) for derivation_pass in pipeline: contract = derivation_pass.contract @@ -434,20 +921,38 @@ def run( rendered = ", ".join(str(item) for item in missing) raise MissingAnalysisError(f"pass {contract.name!r} requires missing analyses: {rendered}") - if contract.mutation_model is MutationModel.TRANSACTIONAL: - working = type(current).from_json(current.to_json()) - else: - working = current + def invoke( + store: AnalysisStore, + *, + isolate_input: bool, + snapshot: CanonicalIRMixin = current, + current_contract: PassContract = contract, + current_pass: DerivationPass[Any, Any] = derivation_pass, + ) -> PassResult[Any]: + working = ( + type(snapshot).require_from_json(snapshot.to_json()) + if isolate_input or current_contract.mutation_model is MutationModel.TRANSACTIONAL + else snapshot + ) + context = PassContext(session=session, analyses=store) + try: + raw_result = current_pass.run(working, context) + except SynthesisError: + raise + except Exception as error: + raise PassExecutionError(current_contract.name, error) from error + return raw_result if isinstance(raw_result, PassResult) else PassResult(raw_result) started = time.perf_counter_ns() - try: - raw_result = derivation_pass.run(working, context) - except SynthesisError: - raise - except Exception as error: - raise PassExecutionError(contract.name, error) from error + if self.determinism is DeterminismPolicy.VERIFY and contract.deterministic: + result = invoke(self.analyses.clone(), isolate_input=True) + else: + result = invoke(self.analyses, isolate_input=False) duration_ns = time.perf_counter_ns() - started - result = raw_result if isinstance(raw_result, PassResult) else PassResult(raw_result) + if self.determinism is DeterminismPolicy.VERIFY and contract.deterministic: + replay = invoke(self.analyses.clone(), isolate_input=True) + if self._result_signature(result) != self._result_signature(replay): + raise PassContractError(f"pass {contract.name!r} failed deterministic replay") if contract.mutation_model is MutationModel.IMMUTABLE: try: @@ -475,6 +980,7 @@ def run( raise PassContractError( f"pass {contract.name!r} changed the IR without retaining its input digest in lineage" ) + transition_report = TransitionVerifier.verify(current, output, contract, session) product_keys = tuple(product.key for product in result.analyses) if len(set(product_keys)) != len(product_keys): @@ -488,12 +994,15 @@ def run( ) record = PassRecord( pass_name=contract.name, + contract_revision=contract.revision, + contract_digest=contract.digest, input_digest=input_digest, output_digest=output_digest, session_fingerprint=session.fingerprint, duration_ns=duration_ns, mutation_model=contract.mutation_model, produced_analyses=tuple(sorted(product_keys)), + transition_report=transition_report, ) checkpoint = PassCheckpoint( record=record, diff --git a/src/blueprinting/synthesizer/passes/deriving.py b/src/blueprinting/synthesizer/passes/deriving.py new file mode 100644 index 0000000..2a9aaa6 --- /dev/null +++ b/src/blueprinting/synthesizer/passes/deriving.py @@ -0,0 +1,151 @@ +"""Low-noise deriving syntax for typed pass and lineage-rule contracts. + +The decorators only derive metadata already present in Python annotations and +generic bases. They never wrap execution or hide pass semantics. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any, TypeVar, cast, get_args, get_origin + +from ..axes import BindingAxis +from ..stages.common import CanonicalIRMixin +from .base import ( + AnalysisKey, + DerivationPass, + MutationModel, + PassContract, + PassNormalizer, + PassRule, + RelationCheckContext, + RuleClaim, + RuleVerifier, + VerificationPolicy, +) + +PassType = TypeVar("PassType", bound=type[DerivationPass[Any, Any]]) + +_PASS_CONTRACTS: dict[type[DerivationPass[Any, Any]], PassContract] = {} + + +def claim(name: str, verifier: Callable[[Any, Any, RelationCheckContext], None]) -> RuleClaim: + """Declare one preservation property and the predicate that proves it.""" + + return RuleClaim(name, cast(RuleVerifier, verifier)) + + +def equal_claim( + name: str, + source: Callable[[Any], Any], + target: Callable[[Any], Any], +) -> RuleClaim: + """Declare equality between source and target semantic projections.""" + + def verify(source_value: Any, target_value: Any, _context: RelationCheckContext) -> None: + expected = source(source_value) + actual = target(target_value) + if actual != expected: + raise ValueError(f"claim {name!r} failed: {actual!r} != {expected!r}") + + return RuleClaim(name, cast(RuleVerifier, verify)) + + +def relation( + transform: str, + rewrite: str, + *, + source: type[Any] | str, + target: type[Any] | str, + verifier: Callable[[Any, Any, RelationCheckContext], None], + preserves: Iterable[RuleClaim] = (), + introduces: Iterable[str] = (), + forbids: Iterable[str] = (), +) -> PassRule: + """Declare a lineage shape plus an independent executable relation invariant.""" + + source_name = source if isinstance(source, str) else source.__name__ + target_name = target if isinstance(target, str) else target.__name__ + return PassRule( + transform, + source_name, + target_name, + rewrite, + preserves=tuple(preserves), + introduces=tuple(introduces), + forbids=tuple(forbids), + verifier=cast(RuleVerifier, verifier), + ) + + +def _pass_types(pass_type: type[DerivationPass[Any, Any]]) -> tuple[type[CanonicalIRMixin], type[CanonicalIRMixin]]: + for base in getattr(pass_type, "__orig_bases__", ()): + if get_origin(base) is not DerivationPass: + continue + source, target = get_args(base) + if ( + isinstance(source, type) + and isinstance(target, type) + and issubclass(source, CanonicalIRMixin) + and issubclass(target, CanonicalIRMixin) + ): + return source, target + raise TypeError(f"{pass_type.__name__} must directly specialize DerivationPass[SourceIR, TargetIR]") + + +def derivation( + name: str, + *, + revision: str, + bindings: Iterable[BindingAxis] = (), + requires: Iterable[AnalysisKey] = (), + preserves: Iterable[AnalysisKey] = (), + produces: Iterable[AnalysisKey] = (), + rules: Iterable[PassRule] = (), + mutation: MutationModel = MutationModel.IMMUTABLE, + verification: VerificationPolicy = VerificationPolicy.BOTH, + deterministic: bool = True, + uses_session_seed: bool = False, + normalizer: PassNormalizer | None = None, +) -> Callable[[PassType], PassType]: + """Attach a complete exact-schema contract to a typed pass class.""" + + def decorate(pass_type: PassType) -> PassType: + source, target = _pass_types(pass_type) + pass_type.contract = PassContract.create( + name, + source, + target, + revision=revision, + required_bindings=frozenset(bindings), + required_analyses=frozenset(requires), + preserved_analyses=frozenset(preserves), + produced_analyses=frozenset(produces), + mutation_model=mutation, + verification=verification, + deterministic=deterministic, + uses_session_seed=uses_session_seed, + rules=tuple(rules), + normalizer=normalizer, + ) + previous = _PASS_CONTRACTS.get(pass_type) + if previous is not None and previous != pass_type.contract: + raise RuntimeError(f"pass class {pass_type.__name__} is already registered with another contract") + _PASS_CONTRACTS[pass_type] = pass_type.contract + return pass_type + + return decorate + + +def pass_contract_manifest() -> tuple[tuple[type[DerivationPass[Any, Any]], PassContract], ...]: + """Return all decorator-declared pass contracts in deterministic order.""" + + return tuple( + sorted( + _PASS_CONTRACTS.items(), + key=lambda item: (item[1].name, item[1].revision, item[0].__module__, item[0].__qualname__), + ) + ) + + +__all__ = ["claim", "derivation", "equal_claim", "pass_contract_manifest", "relation"] diff --git a/src/blueprinting/synthesizer/schema_migration.py b/src/blueprinting/synthesizer/schema_migration.py new file mode 100644 index 0000000..fb4dc02 --- /dev/null +++ b/src/blueprinting/synthesizer/schema_migration.py @@ -0,0 +1,210 @@ +"""Explicit, deterministic migration registry for canonical IR snapshots.""" + +from __future__ import annotations + +import copy +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from blueprinting.schema.codec import ( + canonical_decode, + canonical_dump_raw, + canonical_parse, + raw_content_digest, +) +from blueprinting.schema.errors import SerializationError + +from .stages.common import SchemaVersion + +RawMigration = Callable[[Any], Any] + + +@dataclass(frozen=True) +class SchemaMigration: + schema_name: str + from_version: SchemaVersion + to_version: SchemaVersion + migration_id: str + transform: RawMigration + + def __post_init__(self) -> None: + if not self.schema_name or not self.migration_id: + raise ValueError("migration schema name and ID must not be empty") + if self.to_version <= self.from_version: + raise ValueError("schema migrations must advance the version") + if not callable(self.transform): + raise TypeError("schema migration transform must be callable") + + +@dataclass(frozen=True) +class MigrationResult: + payload: str + schema_name: str + source_version: SchemaVersion + target_version: SchemaVersion + source_digest: str + target_digest: str + migration_ids: tuple[str, ...] + + +@dataclass(frozen=True) +class _RawSnapshot: + schema_name: str + schema_version: SchemaVersion + content_digest: str + payload: Any + + +def _raw_snapshot(raw: Any) -> _RawSnapshot: + if not isinstance(raw, dict) or raw.get("$type") != "blueprinting.ir.snapshot" or set(raw) != {"$type", "fields"}: + raise SerializationError("migration payload must be a canonical IR snapshot") + fields = raw.get("fields") + if not isinstance(fields, dict): + raise SerializationError("migration snapshot fields must be an object") + required = {"schema_name", "schema_version", "content_digest", "payload"} + if not required.issubset(fields): + raise SerializationError("migration snapshot is missing envelope fields") + schema_name = fields["schema_name"] + digest = fields["content_digest"] + version = canonical_decode(fields["schema_version"]) + if not isinstance(schema_name, str) or not isinstance(digest, str) or not isinstance(version, SchemaVersion): + raise SerializationError("migration snapshot has invalid envelope metadata") + return _RawSnapshot(schema_name, version, digest, fields["payload"]) + + +class SchemaMigrationRegistry: + """Acyclic registry with unique-path migration resolution.""" + + def __init__(self) -> None: + self._steps: dict[tuple[str, SchemaVersion, SchemaVersion], SchemaMigration] = {} + + def register(self, migration: SchemaMigration) -> None: + if not isinstance(migration, SchemaMigration): + raise TypeError("migration must be SchemaMigration") + key = (migration.schema_name, migration.from_version, migration.to_version) + if key in self._steps: + raise ValueError(f"duplicate schema migration edge: {key!r}") + if any( + item.schema_name == migration.schema_name and item.migration_id == migration.migration_id + for item in self._steps.values() + ): + raise ValueError(f"duplicate schema migration ID: {migration.migration_id!r}") + if self._reachable(migration.schema_name, migration.to_version, migration.from_version): + raise ValueError("schema migration would introduce a cycle") + self._steps[key] = migration + + def _outgoing(self, schema_name: str, version: SchemaVersion) -> tuple[SchemaMigration, ...]: + return tuple( + sorted( + ( + item + for item in self._steps.values() + if item.schema_name == schema_name and item.from_version == version + ), + key=lambda item: (item.to_version, item.migration_id), + ) + ) + + def _reachable(self, schema_name: str, source: SchemaVersion, target: SchemaVersion) -> bool: + pending = [source] + seen = set() + while pending: + current = pending.pop() + if current == target: + return True + if current in seen: + continue + seen.add(current) + pending.extend(item.to_version for item in self._outgoing(schema_name, current)) + return False + + def path( + self, + schema_name: str, + source: SchemaVersion, + target: SchemaVersion, + ) -> tuple[SchemaMigration, ...]: + if source == target: + return () + paths: list[tuple[SchemaMigration, ...]] = [] + + def visit(version: SchemaVersion, prefix: tuple[SchemaMigration, ...], seen: frozenset[SchemaVersion]) -> None: + if len(paths) > 1: + return + for step in self._outgoing(schema_name, version): + if step.to_version in seen: + continue + candidate = prefix + (step,) + if step.to_version == target: + paths.append(candidate) + elif step.to_version < target: + visit(step.to_version, candidate, seen | {step.to_version}) + + visit(source, (), frozenset({source})) + if not paths: + raise SerializationError(f"no migration path for {schema_name}@{source} -> {target}") + if len(paths) != 1: + raise SerializationError(f"ambiguous migration path for {schema_name}@{source} -> {target}") + return paths[0] + + @staticmethod + def _validated_snapshot(raw: Any, *, expected_schema: str, expected_version: SchemaVersion) -> _RawSnapshot: + decoded = _raw_snapshot(raw) + if decoded.schema_name != expected_schema or decoded.schema_version != expected_version: + raise SerializationError( + f"migration produced {decoded.schema_name}@{decoded.schema_version}, " + f"expected {expected_schema}@{expected_version}" + ) + expected_digest = raw_content_digest(decoded.payload, f"ir:{expected_schema}") + if decoded.content_digest != expected_digest: + raise SerializationError("canonical IR snapshot digest mismatch during migration") + return decoded + + def migrate_json( + self, + payload: str, + *, + schema_name: str, + target_version: SchemaVersion, + ) -> MigrationResult: + raw = canonical_parse(payload) + decoded = _raw_snapshot(raw) + if decoded.schema_name != schema_name: + raise SerializationError(f"snapshot schema {decoded.schema_name!r} is not {schema_name!r}") + source_version = decoded.schema_version + source = self._validated_snapshot(raw, expected_schema=schema_name, expected_version=source_version) + steps = self.path(schema_name, source_version, target_version) + current_raw = raw + current = source + for step in steps: + first = step.transform(copy.deepcopy(current_raw)) + second = step.transform(copy.deepcopy(current_raw)) + if canonical_dump_raw(first) != canonical_dump_raw(second): + raise SerializationError(f"schema migration {step.migration_id!r} is nondeterministic") + current_raw = first + current = self._validated_snapshot( + current_raw, + expected_schema=schema_name, + expected_version=step.to_version, + ) + return MigrationResult( + payload=canonical_dump_raw(current_raw), + schema_name=schema_name, + source_version=source_version, + target_version=target_version, + source_digest=source.content_digest, + target_digest=current.content_digest, + migration_ids=tuple(item.migration_id for item in steps), + ) + + +DEFAULT_SCHEMA_MIGRATIONS = SchemaMigrationRegistry() + + +__all__ = [ + "DEFAULT_SCHEMA_MIGRATIONS", + "MigrationResult", + "SchemaMigration", + "SchemaMigrationRegistry", +] diff --git a/src/blueprinting/synthesizer/semantics.py b/src/blueprinting/synthesizer/semantics.py new file mode 100644 index 0000000..e11166a --- /dev/null +++ b/src/blueprinting/synthesizer/semantics.py @@ -0,0 +1,63 @@ +"""Marker contracts for typed, dialect-owned synthesis semantics.""" + +from __future__ import annotations + +from blueprinting.schema.authoring import NonEmptyText, record + + +class SemanticPayload: + """Base marker for registered immutable semantic payloads.""" + + +class ModelOperationSemantic(SemanticPayload): + """Dialect semantics attached to a ModelIR operation.""" + + +class DistributedTaskSemantic(SemanticPayload): + """Dialect semantics attached to a DistributedTaskIR task.""" + + +class PlanTaskSemantic(SemanticPayload): + """Dialect semantics attached to a PortablePlanIR task.""" + + +class ProgramSemantic(SemanticPayload): + """Dialect semantics shared by one canonical program snapshot.""" + + +class BindingSemantic(SemanticPayload): + """Typed specialization attached to a synthesis binding.""" + + +class BufferSemantic(SemanticPayload): + """Typed semantic role attached to a portable buffer.""" + + +@record("blueprinting.ir.semantic.empty") +class EmptySemantic( + ModelOperationSemantic, + DistributedTaskSemantic, + PlanTaskSemantic, + ProgramSemantic, + BindingSemantic, + BufferSemantic, +): + """Explicit absence of dialect-specific semantics for generic fixtures.""" + + namespace: NonEmptyText = "generic" + + +EMPTY_SEMANTIC = EmptySemantic() + + +__all__ = [ + "BindingSemantic", + "BufferSemantic", + "DistributedTaskSemantic", + "EMPTY_SEMANTIC", + "EmptySemantic", + "ModelOperationSemantic", + "PlanTaskSemantic", + "ProgramSemantic", + "SemanticPayload", +] diff --git a/src/blueprinting/synthesizer/session.py b/src/blueprinting/synthesizer/session.py index 228dc42..05115d4 100644 --- a/src/blueprinting/synthesizer/session.py +++ b/src/blueprinting/synthesizer/session.py @@ -2,45 +2,27 @@ from __future__ import annotations -from dataclasses import dataclass, field, replace +from dataclasses import field, replace from typing import Any -from blueprinting.schema.codec import content_digest, record_type -from blueprinting.schema.frozen import FrozenDict, freeze +from blueprinting.schema.authoring import NonEmptyText, NonNegativeInt, record +from blueprinting.schema.codec import content_digest +from blueprinting.schema.frozen import FrozenDict from .axes import BindingAxis from .bindings import BindingSet, BindingValue, TargetRequirements from .errors import BindingError -@record_type("compiler.session") -@dataclass(frozen=True) +@record("blueprinting.synthesis.session") class SynthesisSession: bindings: BindingSet = field(default_factory=BindingSet) target_requirements: TargetRequirements = field(default_factory=TargetRequirements) - evidence_snapshot: str = "none" - seed: int = 0 - features: frozenset[str] = frozenset() + evidence_snapshot: NonEmptyText = "none" + seed: NonNegativeInt = 0 + features: frozenset[NonEmptyText] = frozenset() options: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - if not isinstance(self.bindings, BindingSet): - raise TypeError("bindings must be a BindingSet") - if not isinstance(self.target_requirements, TargetRequirements): - raise TypeError("target_requirements must be TargetRequirements") - if isinstance(self.seed, bool) or not isinstance(self.seed, int) or self.seed < 0: - raise ValueError("synthesis seed must be a non-negative integer") - if not isinstance(self.evidence_snapshot, str) or not self.evidence_snapshot: - raise ValueError("evidence_snapshot must not be empty") - features = frozenset(self.features) - if any(not isinstance(feature, str) or not feature for feature in features): - raise ValueError("synthesis features must be non-empty strings") - object.__setattr__(self, "features", features) - options = freeze(self.options) - if not isinstance(options, FrozenDict): - raise TypeError("session options must be a mapping") - object.__setattr__(self, "options", options) - def with_binding(self, binding: BindingValue) -> SynthesisSession: return replace(self, bindings=self.bindings.with_binding(binding)) @@ -64,4 +46,4 @@ def require(self, *axes: BindingAxis) -> None: def fingerprint(self) -> str: # The digest domain is a stable wire identity retained across the # Python package and public class rename. - return content_digest(self, "compilation-session") + return content_digest(self, "synthesis-session") diff --git a/src/blueprinting/synthesizer/stages/__init__.py b/src/blueprinting/synthesizer/stages/__init__.py new file mode 100644 index 0000000..35bfabe --- /dev/null +++ b/src/blueprinting/synthesizer/stages/__init__.py @@ -0,0 +1,6 @@ +"""Discoverable homes for the five canonical formal-representation stages. + +Each stage owns its canonical declarations in ``ir.py`` and exposes the +transformations that produce it in ``passes.py``. The package intentionally +does not import every stage eagerly. +""" diff --git a/src/blueprinting/synthesizer/ir/common.py b/src/blueprinting/synthesizer/stages/common.py similarity index 63% rename from src/blueprinting/synthesizer/ir/common.py rename to src/blueprinting/synthesizer/stages/common.py index 73b09e7..c2a2746 100644 --- a/src/blueprinting/synthesizer/ir/common.py +++ b/src/blueprinting/synthesizer/stages/common.py @@ -11,63 +11,51 @@ import math import re from collections.abc import Callable, Iterable, Sequence -from dataclasses import dataclass, field, replace +from dataclasses import field, replace from enum import Enum -from typing import Any, ClassVar, TypeVar - -from blueprinting.schema.codec import canonical_dumps, canonical_loads, content_digest, enum_type, record_type +from typing import Any, ClassVar, TypeAlias, TypeVar + +from blueprinting.schema.authoring import ( + ContentDigest, + NonEmptyText, + NonNegativeInt, + PositiveFiniteFloat, + PositiveInt, + StableName, + enum, + is_adt_variant, + record, +) +from blueprinting.schema.codec import canonical_dumps, canonical_loads, content_digest +from blueprinting.schema.diagnostics import Diagnostic, DiagnosticSet from blueprinting.schema.errors import SerializationError -from blueprinting.schema.frozen import FrozenDict, freeze +from blueprinting.schema.frozen import FrozenDict +from blueprinting.schema.result import Checked, Err, Ok, checked -from ..errors import DiagnosticBag, VerificationReport -from ..expr import Scalar, ScalarExpr, Symbol +from ..errors import DiagnosticBag, IRVerificationError, VerificationReport +from ..expr import Scalar, ScalarExpr, ScalarExprVariant, Symbol from ..ids import StableId -_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$") _DIGEST_RE = re.compile(r"^[0-9a-f]{40}$") +_KNOWN_TARGET_DIALECTS = frozenset({"cuda", "lpu", "nccl", "rccl", "rocm"}) +TYPED_SEMANTICS_FEATURE = "typed-semantics" +REQUIRED_IR_FEATURES = frozenset({TYPED_SEMANTICS_FEATURE}) IR = TypeVar("IR", bound="CanonicalIRMixin") Entity = TypeVar("Entity") +TensorDimension: TypeAlias = PositiveInt | PositiveFiniteFloat | Symbol | ScalarExprVariant def is_content_digest(value: str) -> bool: return isinstance(value, str) and _DIGEST_RE.fullmatch(value) is not None -def frozen_map(value: Any) -> FrozenDict: - """Copy an extension mapping into the synthesizer's immutable value domain.""" - - result = freeze(value) - if not isinstance(result, FrozenDict): - raise TypeError("expected a mapping") - return result - - -def require_instance(value: Any, expected: type[Any], field_name: str) -> None: - if not isinstance(value, expected): - raise TypeError(f"{field_name} must be {expected.__name__}") - - -def typed_tuple(value: Iterable[Any], expected: type[Any], field_name: str) -> tuple[Any, ...]: - result = tuple(value) - if any(not isinstance(item, expected) for item in result): - raise TypeError(f"{field_name} must contain only {expected.__name__} values") - return result - - -@record_type("compiler.schema_version") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.schema-version", order=True) class SchemaVersion: """Semantic version of one serialized IR schema.""" - major: int - minor: int = 0 - patch: int = 0 - - def __post_init__(self) -> None: - for name in ("major", "minor", "patch"): - value = getattr(self, name) - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError(f"schema {name} must be a non-negative integer") + major: NonNegativeInt + minor: NonNegativeInt = 0 + patch: NonNegativeInt = 0 @classmethod def parse(cls, value: str) -> SchemaVersion: @@ -80,33 +68,15 @@ def __str__(self) -> str: return f"{self.major}.{self.minor}.{self.patch}" -@record_type("compiler.ir_header") -@dataclass(frozen=True) +@record("blueprinting.ir.header") class IRHeader: """Version and provenance header embedded in every canonical IR.""" - schema_name: str + schema_name: StableName schema_version: SchemaVersion - producer_version: str = "0.1.0" - feature_set: frozenset[str] = frozenset() - parent_digests: tuple[str, ...] = () - - def __post_init__(self) -> None: - if isinstance(self.feature_set, (str, bytes)): - raise TypeError("feature_set must be an iterable of feature names") - object.__setattr__(self, "feature_set", frozenset(self.feature_set)) - object.__setattr__(self, "parent_digests", tuple(self.parent_digests)) - require_instance(self.schema_version, SchemaVersion, "schema_version") - if not isinstance(self.schema_name, str): - raise TypeError("schema_name must be a string") - if _NAME_RE.fullmatch(self.schema_name) is None: - raise ValueError(f"invalid schema name: {self.schema_name!r}") - if not isinstance(self.producer_version, str) or not self.producer_version: - raise ValueError("producer_version must not be empty") - if any(not isinstance(feature, str) or _NAME_RE.fullmatch(feature) is None for feature in self.feature_set): - raise ValueError("IR feature names must be stable identifiers") - if any(not is_content_digest(item) for item in self.parent_digests): - raise ValueError("parent_digests must contain canonical 160-bit hex digests") + producer_version: NonEmptyText = "0.0.0" + feature_set: frozenset[StableName] = frozenset() + parent_digests: tuple[ContentDigest, ...] = () def with_parents(self, *digests: str) -> IRHeader: return replace(self, parent_digests=tuple(digests)) @@ -118,41 +88,30 @@ def make_header( *, parent_digests: Iterable[str] = (), features: Iterable[str] = (), - producer_version: str = "0.1.0", + producer_version: str = "0.0.0", ) -> IRHeader: return IRHeader( schema_name=schema_name, schema_version=schema_version, producer_version=producer_version, - feature_set=frozenset(features), + feature_set=REQUIRED_IR_FEATURES | frozenset(features), parent_digests=tuple(parent_digests), ) -@record_type("compiler.ir_snapshot") -@dataclass(frozen=True) +@record("blueprinting.ir.snapshot") class IRSnapshot: """Self-checking persistence envelope for a canonical IR value.""" - schema_name: str + schema_name: StableName schema_version: SchemaVersion - producer_version: str - feature_set: frozenset[str] - content_digest: str + producer_version: NonEmptyText + feature_set: frozenset[StableName] + content_digest: ContentDigest payload: Any - def __post_init__(self) -> None: - if isinstance(self.feature_set, (str, bytes)): - raise TypeError("snapshot feature_set must be an iterable of feature names") - object.__setattr__(self, "feature_set", frozenset(self.feature_set)) - require_instance(self.schema_version, SchemaVersion, "snapshot schema_version") - if not isinstance(self.schema_name, str) or not isinstance(self.producer_version, str): - raise TypeError("snapshot schema and producer names must be strings") - if not is_content_digest(self.content_digest): - raise ValueError("snapshot content_digest must be a canonical 160-bit hex digest") - -@enum_type("compiler.effect_kind") +@enum("blueprinting.ir.effect-kind") class EffectKind(Enum): READ = "read" WRITE = "write" @@ -161,31 +120,18 @@ class EffectKind(Enum): IO = "io" -@record_type("compiler.effect") -@dataclass(frozen=True) +@record("blueprinting.ir.effect") class Effect: kind: EffectKind - resource: str - - def __post_init__(self) -> None: - require_instance(self.kind, EffectKind, "effect kind") - if not self.resource: - raise ValueError("effect resource must not be empty") + resource: NonEmptyText -@record_type("compiler.operation_name") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.operation-name", order=True) class OperationName: """Structured operation identity; dialect is never inferred from a string.""" - dialect: str - name: str - - def __post_init__(self) -> None: - if not isinstance(self.dialect, str) or not isinstance(self.name, str): - raise TypeError("operation dialect and name must be strings") - if _NAME_RE.fullmatch(self.dialect) is None or _NAME_RE.fullmatch(self.name) is None: - raise ValueError(f"invalid operation name: {self.dialect}.{self.name}") + dialect: StableName + name: StableName @classmethod def parse(cls, value: str) -> OperationName: @@ -198,32 +144,22 @@ def __str__(self) -> str: return f"{self.dialect}.{self.name}" -@record_type("compiler.tensor_type") -@dataclass(frozen=True) +def is_known_target_dialect(operation: OperationName) -> bool: + """Recognize built-in target dialects forbidden before target binding.""" + + return operation.dialect.lower() in _KNOWN_TARGET_DIALECTS + + +@record("blueprinting.ir.tensor-type") class TensorType: """Target-neutral logical tensor type.""" - shape: tuple[Scalar, ...] - dtype: str + shape: tuple[TensorDimension, ...] + dtype: StableName layout: tuple[int, ...] | None = None attributes: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - object.__setattr__(self, "shape", tuple(self.shape)) - if self.layout is not None: - object.__setattr__(self, "layout", tuple(self.layout)) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if not isinstance(self.dtype, str): - raise TypeError("tensor dtype must be a string") - if _NAME_RE.fullmatch(self.dtype) is None: - raise ValueError(f"invalid dtype name: {self.dtype!r}") - for dimension in self.shape: - if isinstance(dimension, bool) or not isinstance(dimension, (int, float, Symbol, ScalarExpr)): - raise TypeError(f"invalid tensor dimension: {dimension!r}") - if isinstance(dimension, float) and not math.isfinite(dimension): - raise ValueError("concrete tensor dimensions must be finite") - if isinstance(dimension, (int, float)) and dimension <= 0: - raise ValueError("concrete tensor dimensions must be greater than zero") if self.layout is not None and tuple(sorted(self.layout)) != tuple(range(len(self.shape))): raise ValueError("tensor layout must be a permutation of shape dimensions") @@ -258,7 +194,7 @@ def to_json(self) -> str: return canonical_dumps(snapshot) @classmethod - def from_json(cls: type[IR], payload: str) -> IR: + def _decode_json(cls: type[IR], payload: str) -> IR: decoded = canonical_loads(payload) if not isinstance(decoded, IRSnapshot): raise SerializationError("canonical IR JSON must contain an IR snapshot envelope") @@ -267,6 +203,10 @@ def from_json(cls: type[IR], payload: str) -> IR: f"snapshot schema {decoded.schema_name}@{decoded.schema_version} is not " f"{cls.SCHEMA_NAME}@{cls.SCHEMA_VERSION}" ) + missing_features = REQUIRED_IR_FEATURES - decoded.feature_set + if missing_features: + rendered = ", ".join(sorted(missing_features)) + raise SerializationError(f"snapshot is missing required feature epoch(s): {rendered}") if type(decoded.payload) is not cls: raise SerializationError(f"snapshot payload is {type(decoded.payload).__name__}, expected {cls.__name__}") payload_header = decoded.payload.header @@ -290,11 +230,57 @@ def from_json(cls: type[IR], payload: str) -> IR: decoded.payload.require_valid() return decoded.payload - def verify(self) -> VerificationReport: + @classmethod + def from_json(cls: type[IR], payload: str) -> Checked[IR]: + """Decode an exact-schema snapshot without exception control flow.""" + + try: + return Ok(cls._decode_json(payload)) + except IRVerificationError as error: + return Err(DiagnosticSet(tuple(error.diagnostics))) + except SerializationError as error: + return Err( + DiagnosticSet.of( + Diagnostic("serialization.snapshot", str(error), ("snapshot",)), + ) + ) + + @classmethod + def require_from_json(cls: type[IR], payload: str) -> IR: + """Explicit exception adapter for trusted internal/replay boundaries.""" + + return cls.from_json(payload).or_raise( + lambda diagnostics: SerializationError("; ".join(item.render() for item in diagnostics.errors)) + ) + + @classmethod + def load_migrated(cls: type[IR], payload: str, *, registry: Any = None) -> Checked[IR]: + """Load through an explicit registered migration path.""" + + if registry is None: + from ..schema_migration import DEFAULT_SCHEMA_MIGRATIONS + + registry = DEFAULT_SCHEMA_MIGRATIONS + try: + result = registry.migrate_json( + payload, + schema_name=cls.SCHEMA_NAME, + target_version=cls.SCHEMA_VERSION, + ) + except SerializationError as error: + return Err(DiagnosticSet.of(Diagnostic("serialization.migration", str(error), ("snapshot",)))) + return cls.from_json(result.payload) + + def diagnostics(self) -> VerificationReport: raise NotImplementedError + def verify(self: IR) -> Checked[IR]: + """Return the immutable snapshot or all expected verifier failures.""" + + return checked(self, self.diagnostics()) + def require_valid(self) -> None: - self.verify().require_ok(type(self).__name__) + self.diagnostics().require_ok(type(self).__name__) def _verify_common(self, bag: DiagnosticBag) -> None: if self.header.schema_name != self.SCHEMA_NAME: @@ -311,6 +297,14 @@ def _verify_common(self, bag: DiagnosticBag) -> None: "header", "schema_version", ) + missing_features = REQUIRED_IR_FEATURES - self.header.feature_set + if missing_features: + bag.error( + "schema.feature_epoch", + f"missing required feature epoch(s): {', '.join(sorted(missing_features))}", + "header", + "feature_set", + ) try: canonical_dumps(self) except SerializationError as error: @@ -385,7 +379,7 @@ def verify_known_references( def verify_nonnegative_scalar(bag: DiagnosticBag, value: Scalar, *path: str) -> None: if isinstance(value, bool): bag.error("scalar.invalid", "boolean is not a scalar quantity", *path) - elif not isinstance(value, (int, float, Symbol, ScalarExpr)): + elif not (isinstance(value, (int, float, Symbol)) or is_adt_variant(value, ScalarExpr)): bag.error("scalar.invalid", f"unsupported scalar quantity {value!r}", *path) elif isinstance(value, float) and not math.isfinite(value): bag.error("scalar.nonfinite", "quantity must be finite", *path) diff --git a/src/blueprinting/synthesizer/stages/concrete_plan/__init__.py b/src/blueprinting/synthesizer/stages/concrete_plan/__init__.py new file mode 100644 index 0000000..81c89a5 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/concrete_plan/__init__.py @@ -0,0 +1,71 @@ +"""ConcretePlanIR stage: target-bound command envelope and typed extensions.""" + +from .ir import ( + AccessMode, + Barrier, + BufferBinding, + BufferUse, + CollectiveCommand, + CommandBody, + CommandBodyVariant, + CommandKind, + CommandSynchronization, + CommandSynchronizationVariant, + ConcreteCommand, + ConcretePlanIR, + DevicePlacement, + HostCall, + ImplementationRef, + IssueSlot, + Launch, + MemoryRegion, + QueueIssueOrder, + QueueKind, + QueueScheduleExtension, + QueueSpec, + RouteConstraint, + Signal, + SignalAfter, + SlotDataflowExtension, + TargetScheduleExtension, + Transfer, + Unsynchronized, + Wait, + WaitAndSignal, + WaitFor, +) + +__all__ = [ + "AccessMode", + "Barrier", + "BufferBinding", + "BufferUse", + "CommandKind", + "CommandBody", + "CommandBodyVariant", + "CommandSynchronization", + "CommandSynchronizationVariant", + "CollectiveCommand", + "ConcreteCommand", + "ConcretePlanIR", + "DevicePlacement", + "ImplementationRef", + "HostCall", + "IssueSlot", + "MemoryRegion", + "Launch", + "QueueIssueOrder", + "QueueKind", + "QueueScheduleExtension", + "QueueSpec", + "RouteConstraint", + "SlotDataflowExtension", + "Signal", + "SignalAfter", + "TargetScheduleExtension", + "Transfer", + "Unsynchronized", + "Wait", + "WaitAndSignal", + "WaitFor", +] diff --git a/src/blueprinting/synthesizer/stages/concrete_plan/ir.py b/src/blueprinting/synthesizer/stages/concrete_plan/ir.py new file mode 100644 index 0000000..122c397 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/concrete_plan/ir.py @@ -0,0 +1,670 @@ +"""Target- and deployment-bound authoritative command plan.""" + +from __future__ import annotations + +from dataclasses import field +from enum import Enum +from typing import Annotated, ClassVar, TypeAlias + +from blueprinting.schema.authoring import ( + NonEmptyText, + NonNegativeInt, + PositiveInt, + ValueConstraint, + VariantSpec, + adt, + enum, + record, + seal_adt, + variant, +) +from blueprinting.schema.frozen import FrozenDict + +from ...errors import DiagnosticBag, VerificationReport +from ...ids import ( + BufferId, + CommandId, + DeviceId, + Lineage, + MemoryRegionId, + QueueId, + TokenId, +) +from ..common import ( + CanonicalIRMixin, + IRHeader, + SchemaVersion, + is_content_digest, + make_header, + reject_reserved_attributes, + verify_known_references, + verify_ordered_dag, + verify_unique_ids, +) + + +@record("blueprinting.ir.concrete-plan.device") +class DevicePlacement: + """Binding from a logical rank to a physical target device.""" + + id: DeviceId + logical_rank: NonNegativeInt + target_device: NonEmptyText + attributes: FrozenDict = field(default_factory=FrozenDict) + + +@enum("blueprinting.ir.concrete-plan.queue-kind") +class QueueKind(Enum): + """Target execution-engine category represented by a command queue.""" + + COMPUTE = "compute" + COLLECTIVE = "collective" + TRANSFER = "transfer" + HOST = "host" + + +@record("blueprinting.ir.concrete-plan.queue") +class QueueSpec: + """A target-owned command queue attached to one physical device.""" + + id: QueueId + device: DeviceId + kind: QueueKind + engine: NonEmptyText + ordered: bool = True + attributes: FrozenDict = field(default_factory=FrozenDict) + + +@record("blueprinting.ir.concrete-plan.memory-region") +class MemoryRegion: + """A finite physical memory region available to concrete buffers.""" + + id: MemoryRegionId + device: DeviceId + memory_space: NonEmptyText + capacity_bytes: PositiveInt + alignment_bytes: PositiveInt = 1 + attributes: FrozenDict = field(default_factory=FrozenDict) + + +@record("blueprinting.ir.concrete-plan.buffer-binding") +class BufferBinding: + """Physical placement of one portable buffer with preserved lineage.""" + + id: BufferId + memory_region: MemoryRegionId + offset_bytes: NonNegativeInt + size_bytes: PositiveInt + alignment_bytes: PositiveInt + lineage: Lineage + source_buffer: BufferId | None = None + attributes: FrozenDict = field(default_factory=FrozenDict) + + +@record("blueprinting.ir.concrete-plan.implementation-ref") +class ImplementationRef: + """Versioned target implementation and ABI selected for a command.""" + + namespace: NonEmptyText + name: NonEmptyText + version: NonEmptyText + abi: NonEmptyText + variant: NonEmptyText = "default" + + @property + def key(self) -> str: + return f"{self.namespace}:{self.name}:{self.version}:{self.variant}@{self.abi}" + + +@enum("blueprinting.ir.concrete-plan.access-mode") +class AccessMode(Enum): + """Concrete command access performed on a bound buffer.""" + + READ = "read" + WRITE = "write" + READ_WRITE = "read_write" + + +@record("blueprinting.ir.concrete-plan.buffer-use") +class BufferUse: + """Typed buffer access declared by one concrete command.""" + + buffer: BufferId + access: AccessMode + + +@enum("blueprinting.ir.concrete-plan.command-kind") +class CommandKind(Enum): + """Derived command category used by generic consumers and diagnostics.""" + + LAUNCH = "launch" + COLLECTIVE = "collective" + TRANSFER = "transfer" + BARRIER = "barrier" + SIGNAL = "signal" + WAIT = "wait" + HOST_CALL = "host_call" + + +@adt(wire="blueprinting.ir.concrete-plan.command-body") +class CommandBody: + """Closed family of mutually exclusive target command semantics.""" + + __variant_spec__: ClassVar[VariantSpec] + + +SynchronizationTokens: TypeAlias = Annotated[ + tuple[TokenId, ...], + ValueConstraint.NON_EMPTY, + ValueConstraint.UNIQUE_ITEMS, +] + + +@variant("launch") +class Launch(CommandBody): + implementation: ImplementationRef + queue: QueueId | None = None + + +@variant("collective") +class CollectiveCommand(CommandBody): + implementation: ImplementationRef + queue: QueueId | None = None + + +@variant("transfer") +class Transfer(CommandBody): + queue: QueueId | None = None + + +@variant("barrier") +class Barrier(CommandBody): + pass + + +@variant("signal") +class Signal(CommandBody): + tokens: SynchronizationTokens + + +@variant("wait") +class Wait(CommandBody): + tokens: SynchronizationTokens + + +@variant("host-call") +class HostCall(CommandBody): + implementation: ImplementationRef + queue: QueueId | None = None + + +CommandBodyVariant: TypeAlias = Launch | CollectiveCommand | Transfer | Barrier | Signal | Wait | HostCall +seal_adt(CommandBody, CommandBodyVariant) + + +@adt(wire="blueprinting.ir.concrete-plan.synchronization") +class CommandSynchronization: + """Closed synchronization clause orthogonal to executable command semantics.""" + + __variant_spec__: ClassVar[VariantSpec] + + +@variant("none") +class Unsynchronized(CommandSynchronization): + pass + + +@variant("wait") +class WaitFor(CommandSynchronization): + tokens: SynchronizationTokens + + +@variant("signal") +class SignalAfter(CommandSynchronization): + tokens: SynchronizationTokens + + +@variant("wait-and-signal") +class WaitAndSignal(CommandSynchronization): + wait: SynchronizationTokens + signal: SynchronizationTokens + + +CommandSynchronizationVariant: TypeAlias = Unsynchronized | WaitFor | SignalAfter | WaitAndSignal +seal_adt(CommandSynchronization, CommandSynchronizationVariant) + + +@record("blueprinting.ir.concrete-plan.command") +class ConcreteCommand: + """Graph envelope around one typed implementation-bound command body.""" + + id: CommandId + body: CommandBodyVariant + dependencies: tuple[CommandId, ...] + buffers: tuple[BufferUse, ...] + lineage: Lineage + synchronization: CommandSynchronizationVariant = field(default_factory=Unsynchronized) + attributes: FrozenDict = field(default_factory=FrozenDict) + + def __post_init__(self) -> None: + if isinstance(self.body, (Signal, Wait)) and not isinstance(self.synchronization, Unsynchronized): + raise ValueError("standalone signal/wait bodies cannot carry an additional synchronization clause") + + @property + def kind(self) -> CommandKind: + return { + Launch: CommandKind.LAUNCH, + CollectiveCommand: CommandKind.COLLECTIVE, + Transfer: CommandKind.TRANSFER, + Barrier: CommandKind.BARRIER, + Signal: CommandKind.SIGNAL, + Wait: CommandKind.WAIT, + HostCall: CommandKind.HOST_CALL, + }[type(self.body)] + + @property + def queue(self) -> QueueId | None: + if isinstance(self.body, (Launch, CollectiveCommand, Transfer, HostCall)): + return self.body.queue + return None + + @property + def implementation(self) -> ImplementationRef | None: + if isinstance(self.body, (Launch, CollectiveCommand, HostCall)): + return self.body.implementation + return None + + @property + def wait_tokens(self) -> tuple[TokenId, ...]: + if isinstance(self.body, Wait): + return self.body.tokens + if isinstance(self.synchronization, WaitFor): + return self.synchronization.tokens + if isinstance(self.synchronization, WaitAndSignal): + return self.synchronization.wait + return () + + @property + def signal_tokens(self) -> tuple[TokenId, ...]: + if isinstance(self.body, Signal): + return self.body.tokens + if isinstance(self.synchronization, SignalAfter): + return self.synchronization.tokens + if isinstance(self.synchronization, WaitAndSignal): + return self.synchronization.signal + return () + + +class TargetScheduleExtension: + """Marker for registered target-owned scheduling correctness semantics.""" + + +@record("blueprinting.ir.concrete-plan.queue-issue-order") +class QueueIssueOrder: + """Correctness-significant issue order for one target queue.""" + + queue: QueueId + commands: Annotated[tuple[CommandId, ...], ValueConstraint.UNIQUE_ITEMS] + + +@record("blueprinting.ir.concrete-plan.queue-schedule-extension") +class QueueScheduleExtension(TargetScheduleExtension): + """Typed target extension for queue-ordered execution semantics.""" + + orders: tuple[QueueIssueOrder, ...] + + def __post_init__(self) -> None: + if len({item.queue for item in self.orders}) != len(self.orders): + raise ValueError("queue schedule must define each queue at most once") + + +@record("blueprinting.ir.concrete-plan.issue-slot") +class IssueSlot: + """Exact cycle and slot assigned to a command by a slot target.""" + + cycle: NonNegativeInt + slot: NonNegativeInt + command: CommandId + + +@record("blueprinting.ir.concrete-plan.route-constraint") +class RouteConstraint: + """Correctness-significant physical route required by a transfer command.""" + + command: CommandId + source_device: DeviceId + destination_device: DeviceId + channel: NonEmptyText + + def __post_init__(self) -> None: + if self.source_device == self.destination_device: + raise ValueError("route endpoints must differ") + + +@record("blueprinting.ir.concrete-plan.slot-dataflow-extension") +class SlotDataflowExtension(TargetScheduleExtension): + """Typed target extension for slot issue and routed dataflow semantics.""" + + issue_slots: tuple[IssueSlot, ...] + routes: tuple[RouteConstraint, ...] = () + + def __post_init__(self) -> None: + positions = tuple((item.cycle, item.slot) for item in self.issue_slots) + if len(set(positions)) != len(positions): + raise ValueError("issue cycle/slot pairs must be unique") + if len({item.command for item in self.issue_slots}) != len(self.issue_slots): + raise ValueError("each command may occupy only one issue slot") + if len({item.command for item in self.routes}) != len(self.routes): + raise ValueError("each routed command may have only one route constraint") + + +_CONCRETE_RESERVED = frozenset( + { + "start", + "start_time", + "predicted_start", + "end", + "end_time", + "predicted_end", + "duration", + "latency", + "estimated_time", + "predicted_duration", + } +) + + +@record("blueprinting.ir.concrete-plan") +class ConcretePlanIR(CanonicalIRMixin): + """Dependency-driven plan consumed by both simulation and emission.""" + + SCHEMA_NAME: ClassVar[str] = "blueprinting.concrete-plan" + SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(0, 0, 0) + + name: str + source_portable_digest: str + target_fingerprint: str + deployment_fingerprint: str + abi_revision: str + evidence_revision: str + planner_revision: str + devices: tuple[DevicePlacement, ...] + queues: tuple[QueueSpec, ...] + memory_regions: tuple[MemoryRegion, ...] + buffers: tuple[BufferBinding, ...] + commands: tuple[ConcreteCommand, ...] + target_extension: TargetScheduleExtension + attributes: FrozenDict = field(default_factory=FrozenDict) + header: IRHeader = field( + default_factory=lambda: make_header(ConcretePlanIR.SCHEMA_NAME, ConcretePlanIR.SCHEMA_VERSION) + ) + + def diagnostics(self) -> VerificationReport: + bag = DiagnosticBag() + self._verify_common(bag) + if not self.name: + bag.error("concrete.name", "concrete plan name must not be empty", "name") + identity_fields = ( + "source_portable_digest", + "target_fingerprint", + "deployment_fingerprint", + "abi_revision", + "evidence_revision", + "planner_revision", + ) + for field_name in identity_fields: + if not getattr(self, field_name): + bag.error("concrete.identity", f"{field_name} must not be empty", field_name) + if not is_content_digest(self.source_portable_digest): + bag.error( + "concrete.source_digest", "source_portable_digest must be a canonical digest", "source_portable_digest" + ) + elif self.source_portable_digest not in self.header.parent_digests: + bag.error("concrete.parent_digest", "source digest must be retained in header", "header", "parent_digests") + for field_name in ("target_fingerprint", "deployment_fingerprint"): + if not is_content_digest(getattr(self, field_name)): + bag.error( + "concrete.binding_fingerprint", + f"{field_name} must be a canonical binding digest", + field_name, + ) + if not self.devices or not self.commands: + bag.error("concrete.empty", "concrete plan requires devices and commands", "commands") + + verify_unique_ids(bag, self.devices, lambda item: item.id, "devices") + verify_unique_ids(bag, self.queues, lambda item: item.id, "queues") + verify_unique_ids(bag, self.memory_regions, lambda item: item.id, "memory_regions") + verify_unique_ids(bag, self.buffers, lambda item: item.id, "buffers") + verify_unique_ids(bag, self.commands, lambda item: item.id, "commands") + verify_ordered_dag(bag, self.commands, lambda item: item.id, lambda item: item.dependencies, "commands") + + device_ids = {item.id for item in self.devices} + queue_ids = {item.id for item in self.queues} + buffer_ids = {item.id for item in self.buffers} + if len({item.logical_rank for item in self.devices}) != len(self.devices): + bag.error("device.duplicate_rank", "logical ranks must map to one physical device each", "devices") + if len({item.target_device for item in self.devices}) != len(self.devices): + bag.error("device.duplicate_target", "target device identities must be unique", "devices") + for index, device in enumerate(self.devices): + reject_reserved_attributes( + bag, + device.attributes, + _CONCRETE_RESERVED, + "devices", + str(index), + "attributes", + ) + + queue_by_id = {item.id: item for item in self.queues} + for index, queue in enumerate(self.queues): + if queue.device not in device_ids: + bag.error("reference.unknown", f"unknown queue device {queue.device}", "queues", str(index), "device") + reject_reserved_attributes(bag, queue.attributes, _CONCRETE_RESERVED, "queues", str(index), "attributes") + + region_by_id = {item.id: item for item in self.memory_regions} + for index, region in enumerate(self.memory_regions): + if region.device not in device_ids: + bag.error( + "reference.unknown", + f"unknown memory-region device {region.device}", + "memory_regions", + str(index), + "device", + ) + reject_reserved_attributes( + bag, + region.attributes, + _CONCRETE_RESERVED, + "memory_regions", + str(index), + "attributes", + ) + + buffer_by_id = {item.id: item for item in self.buffers} + for index, buffer in enumerate(self.buffers): + path = ("buffers", str(index)) + buffer_region = region_by_id.get(buffer.memory_region) + if buffer_region is None: + bag.error("reference.unknown", f"unknown memory region {buffer.memory_region}", *path, "memory_region") + else: + if buffer.offset_bytes % max(buffer.alignment_bytes, buffer_region.alignment_bytes) != 0: + bag.error( + "buffer.alignment", "buffer offset violates buffer or region alignment", *path, "offset_bytes" + ) + if buffer.offset_bytes + buffer.size_bytes > buffer_region.capacity_bytes: + bag.error("buffer.out_of_bounds", "buffer allocation exceeds memory region capacity", *path) + reject_reserved_attributes(bag, buffer.attributes, _CONCRETE_RESERVED, *path, "attributes") + + signaled = {} + implementation_kinds = {CommandKind.LAUNCH, CommandKind.COLLECTIVE, CommandKind.HOST_CALL} + queue_kinds = {CommandKind.LAUNCH, CommandKind.COLLECTIVE, CommandKind.TRANSFER, CommandKind.HOST_CALL} + expected_queue_kind = { + CommandKind.LAUNCH: QueueKind.COMPUTE, + CommandKind.COLLECTIVE: QueueKind.COLLECTIVE, + CommandKind.TRANSFER: QueueKind.TRANSFER, + CommandKind.HOST_CALL: QueueKind.HOST, + } + for index, command in enumerate(self.commands): + path = ("commands", str(index)) + if ( + isinstance(self.target_extension, QueueScheduleExtension) + and command.kind in queue_kinds + and command.queue is None + ): + bag.error("command.missing_queue", f"{command.kind.value} command requires a queue", *path, "queue") + if command.queue is not None and command.queue not in queue_ids: + bag.error("reference.unknown", f"unknown queue {command.queue}", *path, "queue") + elif command.queue is not None and command.kind in expected_queue_kind: + actual_kind = queue_by_id[command.queue].kind + if actual_kind is not expected_queue_kind[command.kind]: + bag.error( + "command.queue_kind", + f"{command.kind.value} requires a {expected_queue_kind[command.kind].value} queue", + *path, + "queue", + ) + if command.kind in implementation_kinds and command.implementation is None: + bag.error( + "command.missing_implementation", + f"{command.kind.value} command requires a selected implementation", + *path, + "implementation", + ) + if command.kind not in implementation_kinds and command.implementation is not None: + bag.error( + "command.unexpected_implementation", + f"{command.kind.value} command cannot select an implementation", + *path, + "implementation", + ) + used_buffers = tuple(item.buffer for item in command.buffers) + verify_known_references(bag, used_buffers, buffer_ids, *path, "buffers") + if len(set(used_buffers)) != len(used_buffers): + bag.error("command.duplicate_buffer", "one command may list each buffer only once", *path, "buffers") + if command.kind is CommandKind.LAUNCH and command.queue in queue_by_id: + queue_device = queue_by_id[command.queue].device + for buffer_id in used_buffers: + binding = buffer_by_id.get(buffer_id) + binding_region = region_by_id.get(binding.memory_region) if binding is not None else None + if binding_region is not None and binding_region.device != queue_device: + bag.error( + "command.device_mismatch", + f"launch buffer {buffer_id} is not resident on queue device", + *path, + "buffers", + ) + + if len(set(command.wait_tokens)) != len(command.wait_tokens): + bag.error("token.duplicate_wait", "wait tokens must be unique", *path, "wait_tokens") + if len(set(command.signal_tokens)) != len(command.signal_tokens): + bag.error("token.duplicate_signal", "signal tokens must be unique", *path, "signal_tokens") + for token in command.wait_tokens: + if token not in signaled: + bag.error("token.wait_before_signal", f"token {token} has not been signaled", *path, "wait_tokens") + for token in command.signal_tokens: + if token in signaled: + bag.error("token.multiple_signal", f"token {token} has multiple signalers", *path, "signal_tokens") + signaled[token] = command.id + if command.kind is CommandKind.SIGNAL and not command.signal_tokens: + bag.error( + "token.empty_signal", "signal command must produce at least one token", *path, "signal_tokens" + ) + if command.kind is CommandKind.WAIT and not command.wait_tokens: + bag.error("token.empty_wait", "wait command must consume at least one token", *path, "wait_tokens") + reject_reserved_attributes(bag, command.attributes, _CONCRETE_RESERVED, *path, "attributes") + + reject_reserved_attributes(bag, self.attributes, _CONCRETE_RESERVED, "attributes") + _verify_target_extension(self, bag) + return bag.report() + + +def _verify_target_extension(plan: ConcretePlanIR, bag: DiagnosticBag) -> None: + command_by_id = {item.id: item for item in plan.commands} + if isinstance(plan.target_extension, QueueScheduleExtension): + queue_ids = {item.id for item in plan.queues} + ordered_commands = tuple(command for order in plan.target_extension.orders for command in order.commands) + expected = tuple(item.id for item in plan.commands if item.queue is not None) + if len(ordered_commands) != len(set(ordered_commands)): + bag.error("target.queue.duplicate_command", "queued commands must appear exactly once", "target_extension") + if set(ordered_commands) != set(expected): + bag.error("target.queue.coverage", "queue orders must cover every queued command", "target_extension") + for order_index, order in enumerate(plan.target_extension.orders): + if order.queue not in queue_ids: + bag.error( + "reference.unknown", f"unknown extension queue {order.queue}", "target_extension", str(order_index) + ) + continue + position = {command: index for index, command in enumerate(order.commands)} + for command_id in order.commands: + command = command_by_id.get(command_id) + if command is None: + bag.error("reference.unknown", f"unknown extension command {command_id}", "target_extension") + continue + if command.queue != order.queue: + bag.error("target.queue.mismatch", "command is listed under a different queue", "target_extension") + for dependency in command.dependencies: + if dependency in position and position[dependency] >= position[command_id]: + bag.error("target.queue.order", "queue order violates a command dependency", "target_extension") + return + + if isinstance(plan.target_extension, SlotDataflowExtension): + if plan.queues or any(item.queue is not None for item in plan.commands): + bag.error("target.slot.queue", "slot/dataflow plans cannot carry queue semantics", "target_extension") + slot_by_command = {item.command: item for item in plan.target_extension.issue_slots} + if set(slot_by_command) != set(command_by_id): + bag.error("target.slot.coverage", "issue slots must cover every command exactly once", "target_extension") + for command in plan.commands: + current = slot_by_command.get(command.id) + if current is None: + continue + for dependency in command.dependencies: + previous = slot_by_command.get(dependency) + if previous is not None and (previous.cycle, previous.slot) >= (current.cycle, current.slot): + bag.error("target.slot.order", "issue slots violate a command dependency", "target_extension") + device_ids = {item.id for item in plan.devices} + routes = {item.command: item for item in plan.target_extension.routes} + transfers = {item.id for item in plan.commands if item.kind is CommandKind.TRANSFER} + if set(routes) != transfers: + bag.error("target.route.coverage", "every transfer command requires exactly one route", "target_extension") + for route in plan.target_extension.routes: + if route.source_device not in device_ids or route.destination_device not in device_ids: + bag.error("reference.unknown", "route endpoint is not a concrete device", "target_extension") + return + + bag.error("target.extension.unknown", "unsupported typed target schedule extension", "target_extension") + + +__all__ = [ + "AccessMode", + "Barrier", + "BufferBinding", + "BufferUse", + "CommandKind", + "CommandBody", + "CommandBodyVariant", + "CommandSynchronization", + "CommandSynchronizationVariant", + "CollectiveCommand", + "ConcreteCommand", + "ConcretePlanIR", + "DevicePlacement", + "ImplementationRef", + "HostCall", + "IssueSlot", + "MemoryRegion", + "Launch", + "QueueIssueOrder", + "QueueKind", + "QueueScheduleExtension", + "QueueSpec", + "RouteConstraint", + "SlotDataflowExtension", + "Signal", + "SignalAfter", + "TargetScheduleExtension", + "Transfer", + "Unsynchronized", + "Wait", + "WaitAndSignal", + "WaitFor", +] diff --git a/src/blueprinting/synthesizer/stages/concrete_plan/passes.py b/src/blueprinting/synthesizer/stages/concrete_plan/passes.py new file mode 100644 index 0000000..26cd13c --- /dev/null +++ b/src/blueprinting/synthesizer/stages/concrete_plan/passes.py @@ -0,0 +1,370 @@ +"""Deterministic reference binders for exercising ConcretePlanIR contracts.""" + +from __future__ import annotations + +from blueprinting.schema.codec import content_digest + +from ...axes import BindingAxis +from ...ids import CommandId, DeviceId, Lineage, MemoryRegionId, QueueId +from ...passes.authoring import DerivationPass, PassContext, PassRule, RelationCheckContext, derivation, relation +from ...session import SynthesisSession +from ..common import make_header +from ..portable_plan.ir import PlanBuffer, PlanTask, PlanTaskKind, PortablePlanIR +from .ir import ( + AccessMode, + Barrier, + BufferBinding, + BufferUse, + CollectiveCommand, + CommandBodyVariant, + CommandKind, + ConcreteCommand, + ConcretePlanIR, + DevicePlacement, + HostCall, + ImplementationRef, + IssueSlot, + Launch, + MemoryRegion, + QueueIssueOrder, + QueueKind, + QueueScheduleExtension, + QueueSpec, + RouteConstraint, + SlotDataflowExtension, + TargetScheduleExtension, + Transfer, +) + +__all__ = ["BindReferenceQueueTargetPass", "BindReferenceSlotTargetPass"] + +_COMMAND_KIND = { + PlanTaskKind.COMPUTE: CommandKind.LAUNCH, + PlanTaskKind.COLLECTIVE: CommandKind.COLLECTIVE, + PlanTaskKind.TRANSFER: CommandKind.TRANSFER, + PlanTaskKind.BARRIER: CommandKind.BARRIER, + PlanTaskKind.HOST: CommandKind.HOST_CALL, +} + +_QUEUE_KIND = { + PlanTaskKind.COMPUTE: QueueKind.COMPUTE, + PlanTaskKind.COLLECTIVE: QueueKind.COLLECTIVE, + PlanTaskKind.TRANSFER: QueueKind.TRANSFER, + PlanTaskKind.HOST: QueueKind.HOST, +} + + +def _buffer_size(buffer: PlanBuffer) -> int: + if isinstance(buffer.size_bytes, bool) or not isinstance(buffer.size_bytes, int) or buffer.size_bytes <= 0: + raise TypeError("reference binders require concrete positive buffer sizes") + return buffer.size_bytes + + +def _command_body( + kind: PlanTaskKind, + implementation: ImplementationRef | None, + queue: QueueId | None, +) -> CommandBodyVariant: + match kind: + case PlanTaskKind.COMPUTE: + if implementation is None: + raise ValueError("compute task requires a selected implementation") + return Launch(implementation, queue) + case PlanTaskKind.COLLECTIVE: + if implementation is None: + raise ValueError("collective task requires a selected implementation") + return CollectiveCommand(implementation, queue) + case PlanTaskKind.TRANSFER: + return Transfer(queue) + case PlanTaskKind.BARRIER: + return Barrier() + case PlanTaskKind.HOST: + if implementation is None: + raise ValueError("host task requires a selected implementation") + return HostCall(implementation, queue) + + +def _verify_reference_buffer( + source: PlanBuffer, + target: BufferBinding, + _context: RelationCheckContext, +) -> None: + expected_size = _buffer_size(source) + if target.id != source.id or target.source_buffer != source.id: + raise ValueError("concrete buffer must retain its portable buffer identity") + if target.size_bytes != expected_size: + raise ValueError("concrete buffer size differs from its portable capacity obligation") + if target.alignment_bytes < source.alignment_bytes or target.offset_bytes % target.alignment_bytes: + raise ValueError("concrete buffer does not satisfy portable alignment") + + +def _verify_reference_command( + source: PlanTask, + target: ConcreteCommand, + context: RelationCheckContext, +) -> None: + expected_kind = _COMMAND_KIND[source.kind] + if target.kind is not expected_kind: + raise ValueError("concrete command kind differs from its portable task body") + expected_dependencies = tuple(context.only_target_for(item, "ConcreteCommand") for item in source.dependencies) + if target.dependencies != expected_dependencies: + raise ValueError("concrete command dependencies do not preserve the portable task DAG") + expected_buffers = tuple(BufferUse(item, AccessMode.READ) for item in source.inputs) + tuple( + BufferUse(item, AccessMode.WRITE) for item in source.outputs + ) + if target.buffers != expected_buffers: + raise ValueError("concrete command buffer accesses differ from its portable task") + if expected_kind in {CommandKind.LAUNCH, CommandKind.COLLECTIVE, CommandKind.HOST_CALL}: + implementation = target.implementation + target_profile = context.session.bindings.target + if implementation is None or target_profile is None: + raise ValueError("implementation-bound command is missing target identity") + if implementation.name != str(source.operation) or implementation.abi != target_profile.target_abi: + raise ValueError("selected implementation does not match portable operation and target ABI") + elif target.implementation is not None: + raise ValueError("non-implementation command unexpectedly selected an implementation") + + +def _rules(prefix: str) -> tuple[PassRule, PassRule]: + return ( + relation( + f"{prefix}-buffer", + "Bind an abstract portable buffer to a reference memory region", + source=PlanBuffer, + target=BufferBinding, + verifier=_verify_reference_buffer, + introduces=("memory region", "offset"), + ), + relation( + f"{prefix}-command", + "Select a reference implementation and materialize command dependencies", + source=PlanTask, + target=ConcreteCommand, + verifier=_verify_reference_command, + introduces=("implementation", "target schedule identity"), + ), + ) + + +_QUEUE_RULES = _rules("reference-queue") +_SLOT_RULES = _rules("reference-slot") + + +def _common( + ir: PortablePlanIR, + session: SynthesisSession, + *, + planner: str, + buffer_transform: str, + command_transform: str, + queued: bool, +) -> ConcretePlanIR: + target = session.bindings.target + deployment = session.bindings.deployment + if target is None or deployment is None: + raise ValueError("reference target binding requires target and deployment profiles") + devices = tuple( + DevicePlacement( + DeviceId.derive(ir.digest, planner, "device", rank), + rank, + f"{target.name}:{rank}", + ) + for rank in range(deployment.device_count) + ) + region_id = MemoryRegionId.derive(ir.digest, planner, "memory", 0) + offsets = [] + offset = 0 + for buffer in ir.buffers: + alignment = max(16, buffer.alignment_bytes) + offset = ((offset + alignment - 1) // alignment) * alignment + offsets.append(offset) + offset += _buffer_size(buffer) + declared_capacity = deployment.available_memory_bytes[0] if deployment.available_memory_bytes else offset + if declared_capacity < offset: + raise ValueError("reference deployment does not have enough memory for portable buffers") + region = MemoryRegion(region_id, devices[0].id, "reference-local", max(declared_capacity, 1), 16) + buffers = tuple( + BufferBinding( + buffer.id, + region_id, + buffer_offset, + _buffer_size(buffer), + max(16, buffer.alignment_bytes), + Lineage.lowered(buffer_transform, (buffer.id,)), + source_buffer=buffer.id, + ) + for buffer, buffer_offset in zip(ir.buffers, offsets) + ) + command_ids = { + task.id: CommandId.derive(ir.digest, planner, "command", index, task.id) for index, task in enumerate(ir.tasks) + } + queue_by_kind = {} + queues = [] + extension: TargetScheduleExtension + if queued: + for kind in tuple(dict.fromkeys(task.kind for task in ir.tasks if task.kind in _QUEUE_KIND)): + queue_id = QueueId.derive(ir.digest, planner, "queue", kind.value) + queue_by_kind[kind] = queue_id + queues.append(QueueSpec(queue_id, devices[0].id, _QUEUE_KIND[kind], f"reference:{kind.value}")) + commands = [] + for task in ir.tasks: + command_kind = _COMMAND_KIND[task.kind] + implementation = None + if command_kind in {CommandKind.LAUNCH, CommandKind.COLLECTIVE, CommandKind.HOST_CALL}: + implementation = ImplementationRef( + "reference", + str(task.operation), + "0", + target.target_abi, + ) + uses = tuple(BufferUse(item, AccessMode.READ) for item in task.inputs) + tuple( + BufferUse(item, AccessMode.WRITE) for item in task.outputs + ) + commands.append( + ConcreteCommand( + command_ids[task.id], + _command_body(task.kind, implementation, queue_by_kind.get(task.kind) if queued else None), + tuple(command_ids[item] for item in task.dependencies), + uses, + Lineage.lowered(command_transform, (task.id,)), + ) + ) + if queued: + orders = tuple( + QueueIssueOrder( + queue.id, + tuple(command.id for command in commands if command.queue == queue.id), + ) + for queue in queues + ) + extension = QueueScheduleExtension(orders) + else: + issue_slots = tuple(IssueSlot(index, 0, command.id) for index, command in enumerate(commands)) + transfer_commands = tuple(item for item in commands if item.kind is CommandKind.TRANSFER) + if transfer_commands and len(devices) < 2: + raise ValueError("slot/dataflow transfer routes require at least two deployment devices") + routes = tuple( + RouteConstraint(command.id, devices[0].id, devices[1].id, "reference-link") for command in transfer_commands + ) + extension = SlotDataflowExtension(issue_slots, routes) + return ConcretePlanIR( + name=f"{ir.name}-{planner}", + source_portable_digest=ir.digest, + target_fingerprint=target.fingerprint, + deployment_fingerprint=deployment.fingerprint, + abi_revision=target.target_abi, + evidence_revision=session.evidence_snapshot, + planner_revision=planner, + devices=devices, + queues=tuple(queues), + memory_regions=(region,), + buffers=buffers, + commands=tuple(commands), + target_extension=extension, + header=make_header( + ConcretePlanIR.SCHEMA_NAME, + ConcretePlanIR.SCHEMA_VERSION, + parent_digests=(ir.digest,), + ), + ) + + +def _queue_normal_form(ir: PortablePlanIR, session: SynthesisSession) -> ConcretePlanIR: + return _common( + ir, + session, + planner="reference-queue", + buffer_transform="reference-queue-buffer", + command_transform="reference-queue-command", + queued=True, + ) + + +def _slot_normal_form(ir: PortablePlanIR, session: SynthesisSession) -> ConcretePlanIR: + return _common( + ir, + session, + planner="reference-slot", + buffer_transform="reference-slot-buffer", + command_transform="reference-slot-command", + queued=False, + ) + + +@derivation( + "reference-queue-bind", + revision="1", + bindings=(BindingAxis.TARGET, BindingAxis.DEPLOYMENT), + rules=_QUEUE_RULES, + normalizer=_queue_normal_form, +) +class BindReferenceQueueTargetPass(DerivationPass[PortablePlanIR, ConcretePlanIR]): + r"""Bind a portable plan to the deterministic queue-reference contract. + + This is a contract reference implementation, not a paper-derived scheduler. + Buffers are laid out in canonical order. For buffer ``i`` with size ``s_i`` + and required alignment ``r_i``, define ``a_i=max(16,r_i)`` and + + $$ + o_0=0,\qquad + o_i=\left\lceil\frac{o_{i-1}+s_{i-1}}{a_i}\right\rceil a_i. + $$ + + Hence ``o_i mod a_i = 0`` and ``o_i ≥ o_{i-1}+s_{i-1}``. Tasks retain + topological order and are partitioned into compute, collective, transfer, + and host queues; issue order is their stable subsequence in each queue. + The commit gate re-evaluates this canonical construction and requires exact + equality, so size, alignment, access mode, implementation, task category, + dependencies, and target extension are checked as one derivation law. + + References: + - Internal deterministic reference-target contract; no paper-derived + performance or scheduling algorithm is claimed. + """ + + def run(self, ir: PortablePlanIR, context: PassContext) -> ConcretePlanIR: + return _queue_normal_form(ir, context.session) + + +@derivation( + "reference-slot-bind", + revision="1", + bindings=(BindingAxis.TARGET, BindingAxis.DEPLOYMENT), + rules=_SLOT_RULES, + normalizer=_slot_normal_form, +) +class BindReferenceSlotTargetPass(DerivationPass[PortablePlanIR, ConcretePlanIR]): + r"""Bind a portable plan to the deterministic slot/dataflow reference contract. + + This pass shares the aligned buffer recurrence of + ``BindReferenceQueueTargetPass``. Given the portable topological task order + ``T=(t_0,...,t_{n-1})``, its reference issue assignment is + + $$ + \operatorname{issue}(t_i)=(\operatorname{cycle}=i, + \operatorname{slot}=0). + $$ + + Therefore every dependency ``t_j → t_i`` already proved by PortablePlanIR + satisfies ``j ConcretePlanIR: + return _slot_normal_form(ir, context.session) + + +REFERENCE_QUEUE_TARGET_REVISION = content_digest( + (BindReferenceQueueTargetPass.contract.name, BindReferenceQueueTargetPass.contract.revision), + "reference-target", +) +REFERENCE_SLOT_TARGET_REVISION = content_digest( + (BindReferenceSlotTargetPass.contract.name, BindReferenceSlotTargetPass.contract.revision), + "reference-target", +) diff --git a/src/blueprinting/synthesizer/stages/distributed/__init__.py b/src/blueprinting/synthesizer/stages/distributed/__init__.py new file mode 100644 index 0000000..fa10c90 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/distributed/__init__.py @@ -0,0 +1,57 @@ +"""DistributedTaskIR stage: logical mesh, sharding, and distributed task ADT.""" + +from .ir import ( + AllGather, + AllReduce, + AllToAll, + Broadcast, + Collective, + CollectiveKind, + CollectiveSpec, + CollectiveSpecVariant, + Control, + DistributedTask, + DistributedTaskIR, + DistributedValue, + LocalCompute, + LogicalMesh, + MeshAxis, + PeerTransfer, + PointToPoint, + ReduceScatter, + ReductionKind, + Reshard, + ShardingSpec, + TaskBody, + TaskBodyVariant, + collective_kind, + make_collective_spec, +) + +__all__ = [ + "AllGather", + "AllReduce", + "AllToAll", + "Broadcast", + "Collective", + "CollectiveKind", + "CollectiveSpec", + "CollectiveSpecVariant", + "Control", + "DistributedTask", + "DistributedTaskIR", + "DistributedValue", + "LocalCompute", + "LogicalMesh", + "MeshAxis", + "PeerTransfer", + "PointToPoint", + "ReductionKind", + "ReduceScatter", + "Reshard", + "ShardingSpec", + "TaskBody", + "TaskBodyVariant", + "collective_kind", + "make_collective_spec", +] diff --git a/src/blueprinting/synthesizer/stages/distributed/ir.py b/src/blueprinting/synthesizer/stages/distributed/ir.py new file mode 100644 index 0000000..4ca739c --- /dev/null +++ b/src/blueprinting/synthesizer/stages/distributed/ir.py @@ -0,0 +1,516 @@ +"""Logical distributed program over a virtual device mesh.""" + +from __future__ import annotations + +from dataclasses import field +from enum import Enum +from typing import Annotated, ClassVar, TypeAlias + +from typing_extensions import assert_never + +from blueprinting.schema.authoring import ( + NonEmptyText, + NonNegativeInt, + PositiveInt, + ValueConstraint, + VariantSpec, + adt, + enum, + record, + seal_adt, + variant, +) +from blueprinting.schema.frozen import FrozenDict + +from ...errors import DiagnosticBag, VerificationReport + +# Scalar's forward references must remain visible to annotation deriving. +from ...expr import Scalar, ScalarExpr, Symbol # noqa: F401 +from ...ids import Lineage, NodeId, ValueId +from ...semantics import EMPTY_SEMANTIC, DistributedTaskSemantic, ProgramSemantic +from ..common import ( + CanonicalIRMixin, + Effect, + IRHeader, + OperationName, + SchemaVersion, + TensorType, + is_content_digest, + is_known_target_dialect, + make_header, + reject_reserved_attributes, + verify_known_references, + verify_nonnegative_scalar, + verify_ordered_dag, + verify_unique_ids, +) +from ..model.ir import ValueRole + + +@record("blueprinting.ir.distributed-task.mesh-axis") +class MeshAxis: + """One named dimension of the logical, target-neutral device mesh.""" + + name: NonEmptyText + size: PositiveInt + + +MeshAxes: TypeAlias = Annotated[tuple[MeshAxis, ...], ValueConstraint.NON_EMPTY] + + +@record("blueprinting.ir.distributed-task.logical-mesh") +class LogicalMesh: + """Cartesian logical-rank space used by sharding and collectives.""" + + name: NonEmptyText + axes: MeshAxes + + def __post_init__(self) -> None: + names = tuple(axis.name for axis in self.axes) + if len(set(names)) != len(names): + raise ValueError("logical mesh axes must be uniquely named") + + @property + def size(self) -> int: + result = 1 + for axis in self.axes: + result *= axis.size + return result + + @property + def axis_names(self) -> tuple[str, ...]: + return tuple(axis.name for axis in self.axes) + + +@record("blueprinting.ir.distributed-task.sharding") +class ShardingSpec: + """Mapping from tensor dimensions to logical mesh axes.""" + + dimension_axes: tuple[tuple[NonEmptyText, ...], ...] + replicated_axes: tuple[NonEmptyText, ...] = () + + @classmethod + def replicated(cls, rank: int, axes: tuple[str, ...]) -> ShardingSpec: + return cls(dimension_axes=tuple(() for _ in range(rank)), replicated_axes=axes) + + +@enum("blueprinting.ir.distributed-task.collective-kind") +class CollectiveKind(Enum): + """Logical collective semantics independent of a communication library.""" + + ALL_REDUCE = "all_reduce" + ALL_GATHER = "all_gather" + REDUCE_SCATTER = "reduce_scatter" + ALL_TO_ALL = "all_to_all" + BROADCAST = "broadcast" + + +@enum("blueprinting.ir.distributed-task.reduction-kind") +class ReductionKind(Enum): + """Associative reduction operation required by a logical collective.""" + + SUM = "sum" + MAX = "max" + MIN = "min" + PRODUCT = "product" + + +@adt(wire="blueprinting.ir.distributed-task.collective") +class CollectiveSpec: + """Closed collective semantics without conditional reduction/root fields.""" + + +CollectiveParticipants: TypeAlias = Annotated[ + tuple[int, ...], + ValueConstraint.NON_EMPTY, + ValueConstraint.UNIQUE_ITEMS, + ValueConstraint.NON_NEGATIVE_ITEMS, +] + + +@variant("all-reduce") +class AllReduce(CollectiveSpec): + participants: CollectiveParticipants + message_bytes: Scalar + reduction: ReductionKind + + +@variant("reduce-scatter") +class ReduceScatter(CollectiveSpec): + participants: CollectiveParticipants + message_bytes: Scalar + reduction: ReductionKind + + +@variant("all-gather") +class AllGather(CollectiveSpec): + participants: CollectiveParticipants + message_bytes: Scalar + + +@variant("all-to-all") +class AllToAll(CollectiveSpec): + participants: CollectiveParticipants + message_bytes: Scalar + + +@variant("broadcast") +class Broadcast(CollectiveSpec): + participants: CollectiveParticipants + message_bytes: Scalar + root: NonNegativeInt + + def __post_init__(self) -> None: + if self.root not in self.participants: + raise ValueError("broadcast root must be one of its participants") + + +CollectiveSpecVariant: TypeAlias = AllReduce | ReduceScatter | AllGather | AllToAll | Broadcast +seal_adt(CollectiveSpec, CollectiveSpecVariant) + + +def collective_kind(spec: CollectiveSpecVariant) -> CollectiveKind: + match spec: + case AllReduce(): + return CollectiveKind.ALL_REDUCE + case ReduceScatter(): + return CollectiveKind.REDUCE_SCATTER + case AllGather(): + return CollectiveKind.ALL_GATHER + case AllToAll(): + return CollectiveKind.ALL_TO_ALL + case Broadcast(): + return CollectiveKind.BROADCAST + assert_never(spec) + + +def make_collective_spec( + kind: CollectiveKind, + participants: tuple[int, ...], + message_bytes: Scalar, + *, + reduction: ReductionKind | None = None, + root: int | None = None, +) -> CollectiveSpecVariant: + """Boundary adapter from enum-oriented inputs into the canonical ADT.""" + + match kind: + case CollectiveKind.ALL_REDUCE: + if reduction is None or root is not None: + raise ValueError("all-reduce requires reduction and does not accept root") + return AllReduce(participants, message_bytes, reduction) + case CollectiveKind.REDUCE_SCATTER: + if reduction is None or root is not None: + raise ValueError("reduce-scatter requires reduction and does not accept root") + return ReduceScatter(participants, message_bytes, reduction) + case CollectiveKind.ALL_GATHER: + if reduction is not None or root is not None: + raise ValueError("all-gather does not accept reduction or root") + return AllGather(participants, message_bytes) + case CollectiveKind.ALL_TO_ALL: + if reduction is not None or root is not None: + raise ValueError("all-to-all does not accept reduction or root") + return AllToAll(participants, message_bytes) + case CollectiveKind.BROADCAST: + if reduction is not None or root is None: + raise ValueError("broadcast requires root and does not accept reduction") + return Broadcast(participants, message_bytes, root) + assert_never(kind) + + +@record("blueprinting.ir.distributed-task.peer-transfer") +class PeerTransfer: + """Logical point-to-point transfer between two virtual ranks.""" + + source_rank: NonNegativeInt + destination_rank: NonNegativeInt + message_bytes: Scalar + channel: NonEmptyText = "default" + + def __post_init__(self) -> None: + if self.source_rank == self.destination_rank: + raise ValueError("peer transfer endpoints must differ") + + +@adt(wire="blueprinting.ir.distributed-task.task") +class TaskBody: + """Closed family of mutually exclusive distributed task semantics.""" + + __variant_spec__: ClassVar[VariantSpec] + + +@variant("local-compute") +class LocalCompute(TaskBody): + """Execute a target-neutral operation independently on the logical ranks.""" + + +@variant("collective") +class Collective(TaskBody): + """Task body carrying a well-formed logical collective specification.""" + + spec: CollectiveSpecVariant + + +@variant("point-to-point") +class PointToPoint(TaskBody): + """Task body carrying one logical peer transfer.""" + + spec: PeerTransfer + + +@variant("reshard") +class Reshard(TaskBody): + """Change logical ownership/sharding through explicit dataflow values.""" + + +@variant("control") +class Control(TaskBody): + """Represent a dependency-only logical coordination task.""" + + +TaskBodyVariant: TypeAlias = LocalCompute | Collective | PointToPoint | Reshard | Control +seal_adt(TaskBody, TaskBodyVariant) + + +@record("blueprinting.ir.distributed-task.value") +class DistributedValue: + """Model value specialized with logical ownership and sharding.""" + + id: ValueId + type: TensorType + role: ValueRole + sharding: ShardingSpec + owners: tuple[NonNegativeInt, ...] + lineage: Lineage + source_value: ValueId | None = None + attributes: FrozenDict = field(default_factory=FrozenDict) + + +@record("blueprinting.ir.distributed-task.task-envelope") +class DistributedTask: + """Common graph envelope around one typed distributed task body.""" + + id: NodeId + body: TaskBodyVariant + operation: OperationName + ranks: tuple[NonNegativeInt, ...] + inputs: tuple[ValueId, ...] + outputs: tuple[ValueId, ...] + dependencies: tuple[NodeId, ...] + lineage: Lineage + effects: tuple[Effect, ...] = () + semantic: DistributedTaskSemantic = EMPTY_SEMANTIC + attributes: FrozenDict = field(default_factory=FrozenDict) + + @property + def body_tag(self) -> str: + """Stable short constructor identity derived from the ADT manifest.""" + + return self.body.__variant_spec__.local_tag + + +_DISTRIBUTED_RESERVED = frozenset( + { + "model_spec", + "workload_spec", + "mapping_spec", + "inference_mapping_spec", + "inference_phase", + "batch_size", + "query_tokens", + "context_tokens", + "datatype", + "invocation", + "block_memory", + "scope", + "physical_device", + "device_id", + "route", + "queue", + "stream", + "kernel", + "implementation_id", + "start", + "start_time", + "end", + "end_time", + "duration", + "latency", + "bandwidth", + } +) + + +@record("blueprinting.ir.distributed-task") +class DistributedTaskIR(CanonicalIRMixin): + """Logical task graph whose ranks are virtual, never physical devices.""" + + SCHEMA_NAME: ClassVar[str] = "blueprinting.distributed-task" + SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(0, 0, 0) + + name: str + source_model_digest: str + mesh: LogicalMesh + values: tuple[DistributedValue, ...] + tasks: tuple[DistributedTask, ...] + inputs: tuple[ValueId, ...] + outputs: tuple[ValueId, ...] + semantic: ProgramSemantic = EMPTY_SEMANTIC + attributes: FrozenDict = field(default_factory=FrozenDict) + header: IRHeader = field( + default_factory=lambda: make_header(DistributedTaskIR.SCHEMA_NAME, DistributedTaskIR.SCHEMA_VERSION) + ) + + def diagnostics(self) -> VerificationReport: + bag = DiagnosticBag() + self._verify_common(bag) + if not self.name: + bag.error("distributed.name", "distributed program name must not be empty", "name") + if not self.tasks or not self.values: + bag.error("distributed.empty", "distributed program requires tasks and values", "tasks") + if not is_content_digest(self.source_model_digest): + bag.error( + "distributed.source_digest", "source_model_digest must be a canonical digest", "source_model_digest" + ) + elif self.source_model_digest not in self.header.parent_digests: + bag.error( + "distributed.parent_digest", + "source model digest must be retained in header.parent_digests", + "header", + "parent_digests", + ) + + verify_unique_ids(bag, self.values, lambda item: item.id, "values") + verify_unique_ids(bag, self.tasks, lambda item: item.id, "tasks") + verify_ordered_dag(bag, self.tasks, lambda item: item.id, lambda item: item.dependencies, "tasks") + value_ids = {item.id for item in self.values} + verify_known_references(bag, self.inputs, value_ids, "inputs") + verify_known_references(bag, self.outputs, value_ids, "outputs") + if len(set(self.inputs)) != len(self.inputs): + bag.error("distributed.duplicate_input", "distributed inputs must be unique", "inputs") + if len(set(self.outputs)) != len(self.outputs): + bag.error("distributed.duplicate_output", "distributed outputs must be unique", "outputs") + mesh_axes = set(self.mesh.axis_names) + valid_ranks = set(range(self.mesh.size)) + + for index, value in enumerate(self.values): + path = ("values", str(index)) + if len(value.sharding.dimension_axes) != value.type.rank: + bag.error( + "sharding.rank", + "sharding dimension count must equal tensor rank", + *path, + "sharding", + "dimension_axes", + ) + used_axes = tuple(axis for axes in value.sharding.dimension_axes for axis in axes) + used_axes += value.sharding.replicated_axes + if len(set(used_axes)) != len(used_axes): + bag.error("sharding.axis_reuse", "a mesh axis may appear only once", *path, "sharding") + for axis in used_axes: + if axis not in mesh_axes: + bag.error("sharding.unknown_axis", f"unknown mesh axis {axis!r}", *path, "sharding") + if not value.owners or len(set(value.owners)) != len(value.owners): + bag.error("ownership.invalid", "owners must be non-empty and unique", *path, "owners") + for rank in value.owners: + if rank not in valid_ranks: + bag.error("rank.unknown", f"owner rank {rank} is outside the logical mesh", *path, "owners") + reject_reserved_attributes(bag, value.attributes, _DISTRIBUTED_RESERVED, *path, "attributes") + reject_reserved_attributes( + bag, + value.type.attributes, + _DISTRIBUTED_RESERVED, + *path, + "type", + "attributes", + ) + + defined = set(self.inputs) + for index, task in enumerate(self.tasks): + path = ("tasks", str(index)) + if not task.ranks or len(set(task.ranks)) != len(task.ranks): + bag.error("rank.invalid", "task ranks must be non-empty and unique", *path, "ranks") + for rank in task.ranks: + if rank not in valid_ranks: + bag.error("rank.unknown", f"task rank {rank} is outside the logical mesh", *path, "ranks") + if is_known_target_dialect(task.operation): + bag.error( + "distributed.target_dialect", + f"target dialect {task.operation.dialect!r} is illegal in DistributedTaskIR", + *path, + "operation", + ) + verify_known_references(bag, task.inputs, value_ids, *path, "inputs") + verify_known_references(bag, task.outputs, value_ids, *path, "outputs") + if len(set(task.inputs)) != len(task.inputs): + bag.error("task.duplicate_input", "task inputs must be unique", *path, "inputs") + if len(set(task.outputs)) != len(task.outputs): + bag.error("task.duplicate_output", "task outputs must be unique", *path, "outputs") + for input_id in task.inputs: + if input_id in value_ids and input_id not in defined: + bag.error("dataflow.use_before_definition", f"value {input_id} is not yet defined", *path, "inputs") + for output_id in task.outputs: + if output_id in defined: + bag.error( + "dataflow.multiple_definition", f"value {output_id} has multiple definitions", *path, "outputs" + ) + defined.add(output_id) + + match task.body: + case Collective(spec=collective): + if set(collective.participants) != set(task.ranks): + bag.error( + "collective.participants", "collective participants must equal task ranks", *path, "body" + ) + verify_nonnegative_scalar(bag, collective.message_bytes, *path, "body", "message_bytes") + if isinstance(collective, Broadcast) and collective.root not in collective.participants: + bag.error("collective.root", "broadcast root must be a participant", *path, "body", "root") + case PointToPoint(spec=transfer): + if {transfer.source_rank, transfer.destination_rank} != set(task.ranks): + bag.error("peer.endpoints", "peer transfer endpoints must equal task ranks", *path, "body") + verify_nonnegative_scalar(bag, transfer.message_bytes, *path, "body", "message_bytes") + case LocalCompute() | Reshard() | Control(): + pass + case _: + bag.error( + "task.body.unknown", + f"unsupported distributed task body {type(task.body).__name__}", + *path, + "body", + ) + reject_reserved_attributes(bag, task.attributes, _DISTRIBUTED_RESERVED, *path, "attributes") + + for output_id in self.outputs: + if output_id in value_ids and output_id not in defined: + bag.error("dataflow.undefined_output", f"distributed output {output_id} is not defined", "outputs") + reject_reserved_attributes(bag, self.attributes, _DISTRIBUTED_RESERVED, "attributes") + return bag.report() + + +__all__ = [ + "AllGather", + "AllReduce", + "AllToAll", + "Broadcast", + "Collective", + "CollectiveKind", + "CollectiveSpec", + "CollectiveSpecVariant", + "Control", + "DistributedTask", + "DistributedTaskIR", + "DistributedValue", + "LocalCompute", + "LogicalMesh", + "MeshAxis", + "PeerTransfer", + "PointToPoint", + "ReductionKind", + "ReduceScatter", + "Reshard", + "ShardingSpec", + "TaskBody", + "TaskBodyVariant", + "collective_kind", + "make_collective_spec", +] diff --git a/src/blueprinting/synthesizer/stages/distributed/passes.py b/src/blueprinting/synthesizer/stages/distributed/passes.py new file mode 100644 index 0000000..e774739 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/distributed/passes.py @@ -0,0 +1,129 @@ +"""Verified derivations whose committed result is :class:`DistributedTaskIR`. + +The pass contracts live beside the output IR. Transformer-specific algebra is +kept in its dialect module and is called as a pure derivation. +""" + +from __future__ import annotations + +from ...axes import BindingAxis +from ...dialects.transformer.inference_derivation import ( + INFERENCE_DISTRIBUTION_RULES, + normalize_inference_distribution, +) +from ...dialects.transformer.training_derivation import ( + TRAINING_DISTRIBUTION_RULES, + normalize_training_distribution, +) +from ...passes.authoring import DerivationPass, PassContext, derivation +from ..model.ir import ModelIR +from .ir import DistributedTaskIR + + +@derivation( + "transformer-distribute", + revision="1", + bindings=(BindingAxis.WORKLOAD, BindingAxis.STRATEGY), + rules=TRAINING_DISTRIBUTION_RULES, + normalizer=normalize_training_distribution, +) +class DistributeTransformerTrainingPass(DerivationPass[ModelIR, DistributedTaskIR]): + r"""Expand one training block into logical TP-local and collective tasks. + + The pass interprets a typed ``TP × PP × DP`` strategy, but this snapshot + materializes one local tensor-parallel block only. Let ``B`` be the + microbatch, ``S`` the sequence length, ``H`` the hidden width, ``F`` the + feed-forward width, ``t`` the TP degree, and ``e`` bytes per element. A + matrix product with shapes ``[m,n] × [n,k]`` contributes + + $$ + W_{\mathrm{gemm}} = 2mnk. + $$ + + Therefore the local attention projections contribute + + $$ + W_{\mathrm{QKV}}=\frac{6BSH^2}{t},\qquad + W_{\mathrm{attn\,matmul}}=\frac{4BS^2H}{t}, + $$ + + and the two MLP projections contribute + + $$ + W_{\mathrm{MLP}}=\frac{4BSHF}{t}. + $$ + + At a TP semantic boundary, the logical payload and local reduction work are + + $$ + M_{\mathrm{TP}}=BSH\,e,\qquad + W_{\mathrm{reduce}}=BSH\frac{t-1}{t}. + $$ + + The derivation emits explicit forward, recompute, activation-gradient, + weight-gradient, optimizer, and collective invocations. It does not attach + latency or choose a physical collective algorithm. + + References: + - Shoeybi et al., [Megatron-LM](https://arxiv.org/abs/1909.08053). + - Narayanan et al., [Efficient Large-Scale Language Model + Training](https://arxiv.org/abs/2104.04473). + - Korthikanti et al., [Reducing Activation + Recomputation](https://arxiv.org/abs/2205.05198). + """ + + def run(self, ir: ModelIR, context: PassContext) -> DistributedTaskIR: + return normalize_training_distribution(ir, context.session) + + +@derivation( + "transformer-inference-distribute", + revision="1", + bindings=(BindingAxis.WORKLOAD, BindingAxis.STRATEGY), + rules=INFERENCE_DISTRIBUTION_RULES, + normalizer=normalize_inference_distribution, +) +class DistributeTransformerInferencePass(DerivationPass[ModelIR, DistributedTaskIR]): + r"""Expand one inference phase into logical TP-local and collective tasks. + + Let ``b`` be batch size, ``q`` the query-token count, ``c`` the visible KV + context, ``H`` hidden width, ``F`` feed-forward width, ``h`` attention-head + count, ``t`` TP degree, and ``e`` bytes per element. Prefill uses ``q=c``; + decode uses ``q=1``. The exact local attention-core work is + + $$ + W_{\mathrm{attention}} + = \frac{4bqcH}{t} + + 5b\frac{h}{t}qc, + $$ + + where the first term is ``QKᵀ`` plus ``PV`` and the second is the explicit + softmax model. Projection and MLP work are + + $$ + W_{\mathrm{QKV}}=\frac{6bqH^2}{t},\quad + W_{\mathrm{out}}=\frac{2bqH^2}{t},\quad + W_{\mathrm{MLP}}=\frac{4bqHF}{t}. + $$ + + The per-rank KV state retained by the derived analysis is + + $$ + C_{\mathrm{KV}}=2bc\frac{H}{t}e. + $$ + + The pass marks KV reads/writes as effects and leaves fused/paged attention + as target-neutral implementation alternatives rather than assuming them. + + References: + - Vaswani et al., [Attention Is All You + Need](https://arxiv.org/abs/1706.03762). + - Shoeybi et al., [Megatron-LM](https://arxiv.org/abs/1909.08053). + - Dao et al., [FlashAttention](https://arxiv.org/abs/2205.14135). + """ + + def run(self, ir: ModelIR, context: PassContext) -> DistributedTaskIR: + return normalize_inference_distribution(ir, context.session) + + +__all__ = ["DistributeTransformerInferencePass", "DistributeTransformerTrainingPass"] diff --git a/src/blueprinting/synthesizer/stages/machine/__init__.py b/src/blueprinting/synthesizer/stages/machine/__init__.py new file mode 100644 index 0000000..e1c1f9d --- /dev/null +++ b/src/blueprinting/synthesizer/stages/machine/__init__.py @@ -0,0 +1,19 @@ +"""MachineIR stage: one target plugin's instructions, sections, entry points, and ABI.""" + +from .ir import ( + MachineEntryPoint, + MachineInstruction, + MachineIR, + MachineOpcode, + MachineSection, + MachineSectionKind, +) + +__all__ = [ + "MachineEntryPoint", + "MachineInstruction", + "MachineIR", + "MachineOpcode", + "MachineSection", + "MachineSectionKind", +] diff --git a/src/blueprinting/synthesizer/ir/machine.py b/src/blueprinting/synthesizer/stages/machine/ir.py similarity index 59% rename from src/blueprinting/synthesizer/ir/machine.py rename to src/blueprinting/synthesizer/stages/machine/ir.py index c1ca4be..4b9c9bc 100644 --- a/src/blueprinting/synthesizer/ir/machine.py +++ b/src/blueprinting/synthesizer/stages/machine/ir.py @@ -2,49 +2,42 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import field from enum import Enum from typing import ClassVar -from blueprinting.schema.codec import enum_type, record_type +from blueprinting.schema.authoring import NonEmptyText, PositiveInt, enum, record from blueprinting.schema.frozen import FrozenDict -from ..errors import DiagnosticBag, VerificationReport -from ..ids import CommandId, InstructionId, Lineage -from .common import ( +from ...errors import DiagnosticBag, VerificationReport +from ...ids import CommandId, InstructionId, Lineage +from ..common import ( CanonicalIRMixin, IRHeader, SchemaVersion, - frozen_map, is_content_digest, make_header, reject_reserved_attributes, - require_instance, - typed_tuple, verify_ordered_dag, verify_unique_ids, ) -@record_type("compiler.machine.opcode") -@dataclass(frozen=True, order=True) +@record("blueprinting.ir.machine.opcode", order=True) class MachineOpcode: - dialect: str - name: str + """Dialect-qualified opcode owned by one target plugin.""" - def __post_init__(self) -> None: - if not isinstance(self.dialect, str) or not isinstance(self.name, str): - raise TypeError("machine opcode dialect and name must be strings") - if not self.dialect or not self.name: - raise ValueError("machine opcode dialect and name must not be empty") + dialect: NonEmptyText + name: NonEmptyText def __str__(self) -> str: return f"{self.dialect}.{self.name}" -@record_type("compiler.machine.instruction") -@dataclass(frozen=True) +@record("blueprinting.ir.machine.instruction") class MachineInstruction: + """Target instruction with explicit dependencies, operands, and lineage.""" + id: InstructionId opcode: MachineOpcode dependencies: tuple[InstructionId, ...] @@ -53,73 +46,41 @@ class MachineInstruction: source_command: CommandId | None = None attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - require_instance(self.id, InstructionId, "machine instruction ID") - require_instance(self.opcode, MachineOpcode, "machine opcode") - require_instance(self.lineage, Lineage, "machine instruction lineage") - if self.source_command is not None: - require_instance(self.source_command, CommandId, "machine source command") - object.__setattr__( - self, - "dependencies", - typed_tuple(self.dependencies, InstructionId, "machine instruction dependencies"), - ) - object.__setattr__(self, "operands", frozen_map(self.operands)) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - -@enum_type("compiler.machine.section_kind") +@enum("blueprinting.ir.machine.section-kind") class MachineSectionKind(Enum): + """Container role of a machine-program section.""" + CODE = "code" DATA = "data" DESCRIPTOR = "descriptor" METADATA = "metadata" -@record_type("compiler.machine.section") -@dataclass(frozen=True) +@record("blueprinting.ir.machine.section") class MachineSection: - name: str + """Aligned code or data section in a target machine program.""" + + name: NonEmptyText kind: MachineSectionKind instructions: tuple[MachineInstruction, ...] = () data: bytes = b"" - alignment_bytes: int = 1 + alignment_bytes: PositiveInt = 1 attributes: FrozenDict = field(default_factory=FrozenDict) def __post_init__(self) -> None: - require_instance(self.kind, MachineSectionKind, "machine section kind") - object.__setattr__( - self, - "instructions", - typed_tuple(self.instructions, MachineInstruction, "machine section instructions"), - ) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if not isinstance(self.name, str) or not self.name: - raise ValueError("machine section name must not be empty") - if not isinstance(self.data, bytes): - raise TypeError("machine section data must be bytes") - if ( - isinstance(self.alignment_bytes, bool) - or not isinstance(self.alignment_bytes, int) - or self.alignment_bytes <= 0 - ): - raise ValueError("machine section alignment must be a positive integer") if self.kind is MachineSectionKind.CODE and self.data: raise ValueError("code section cannot contain opaque data") if self.kind is not MachineSectionKind.CODE and self.instructions: raise ValueError("only code sections may contain machine instructions") -@record_type("compiler.machine.entry_point") -@dataclass(frozen=True) +@record("blueprinting.ir.machine.entry-point") class MachineEntryPoint: - name: str - instruction: InstructionId + """Named externally addressable instruction in a machine program.""" - def __post_init__(self) -> None: - require_instance(self.instruction, InstructionId, "entry-point instruction") - if not isinstance(self.name, str) or not self.name: - raise ValueError("machine entry-point name must not be empty") + name: NonEmptyText + instruction: InstructionId _MACHINE_RESERVED = frozenset( @@ -133,13 +94,12 @@ def __post_init__(self) -> None: ) -@record_type("compiler.ir.machine.v1") -@dataclass(frozen=True) +@record("blueprinting.ir.machine") class MachineIR(CanonicalIRMixin): """Target-owned instruction dialect before final binary/container emission.""" SCHEMA_NAME: ClassVar[str] = "blueprinting.machine" - SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(1, 0, 0) + SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(0, 0, 0) name: str source_concrete_digest: str @@ -153,39 +113,11 @@ class MachineIR(CanonicalIRMixin): attributes: FrozenDict = field(default_factory=FrozenDict) header: IRHeader = field(default_factory=lambda: make_header(MachineIR.SCHEMA_NAME, MachineIR.SCHEMA_VERSION)) - def __post_init__(self) -> None: - require_instance(self.header, IRHeader, "machine header") - identity_fields = ( - "name", - "source_concrete_digest", - "target_fingerprint", - "target_plugin", - "target_abi", - "emitter_revision", - "program_format", - ) - if any(not isinstance(getattr(self, field_name), str) for field_name in identity_fields): - raise TypeError("machine program identity fields must be strings") - object.__setattr__(self, "sections", typed_tuple(self.sections, MachineSection, "machine sections")) - object.__setattr__( - self, - "entry_points", - typed_tuple(self.entry_points, MachineEntryPoint, "machine entry points"), - ) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if ( - not self.header.parent_digests - and self.header.schema_name == self.SCHEMA_NAME - and self.header.schema_version == self.SCHEMA_VERSION - and is_content_digest(self.source_concrete_digest) - ): - object.__setattr__(self, "header", self.header.with_parents(self.source_concrete_digest)) - @property def instructions(self) -> tuple[MachineInstruction, ...]: return tuple(instruction for section in self.sections for instruction in section.instructions) - def verify(self) -> VerificationReport: + def diagnostics(self) -> VerificationReport: bag = DiagnosticBag() self._verify_common(bag) identity_fields = ( @@ -267,3 +199,13 @@ def verify(self) -> VerificationReport: ) reject_reserved_attributes(bag, self.attributes, _MACHINE_RESERVED, "attributes") return bag.report() + + +__all__ = [ + "MachineEntryPoint", + "MachineInstruction", + "MachineIR", + "MachineOpcode", + "MachineSection", + "MachineSectionKind", +] diff --git a/src/blueprinting/synthesizer/stages/machine/passes.py b/src/blueprinting/synthesizer/stages/machine/passes.py new file mode 100644 index 0000000..b3a7b3f --- /dev/null +++ b/src/blueprinting/synthesizer/stages/machine/passes.py @@ -0,0 +1,7 @@ +"""Passes producing target MachineIR. + +No production machine emitter exists yet; target plugins will publish their +emitters through this stage boundary after their hardware contracts graduate. +""" + +__all__: tuple[str, ...] = () diff --git a/src/blueprinting/synthesizer/stages/model/__init__.py b/src/blueprinting/synthesizer/stages/model/__init__.py new file mode 100644 index 0000000..f5ef881 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/model/__init__.py @@ -0,0 +1,5 @@ +"""ModelIR stage: semantic values, operations, dataflow, and effects.""" + +from .ir import ModelIR, ModelOperation, ModelValue, ValueRole + +__all__ = ["ModelIR", "ModelOperation", "ModelValue", "ValueRole"] diff --git a/src/blueprinting/synthesizer/ir/model.py b/src/blueprinting/synthesizer/stages/model/ir.py similarity index 67% rename from src/blueprinting/synthesizer/ir/model.py rename to src/blueprinting/synthesizer/stages/model/ir.py index 3ba8a11..dffa7a5 100644 --- a/src/blueprinting/synthesizer/ir/model.py +++ b/src/blueprinting/synthesizer/stages/model/ir.py @@ -2,35 +2,36 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import field from enum import Enum from typing import ClassVar -from blueprinting.schema.codec import enum_type, record_type +from blueprinting.schema.authoring import enum, record from blueprinting.schema.frozen import FrozenDict -from ..errors import DiagnosticBag, VerificationReport -from ..ids import Lineage, NodeId, ValueId -from .common import ( +from ...errors import DiagnosticBag, VerificationReport +from ...ids import Lineage, NodeId, ValueId +from ...semantics import EMPTY_SEMANTIC, ModelOperationSemantic +from ..common import ( CanonicalIRMixin, Effect, IRHeader, OperationName, SchemaVersion, TensorType, - frozen_map, + is_known_target_dialect, make_header, reject_reserved_attributes, - require_instance, - typed_tuple, verify_known_references, verify_ordered_dag, verify_unique_ids, ) -@enum_type("compiler.model.value_role") +@enum("blueprinting.ir.model.value-role") class ValueRole(Enum): + """Semantic ownership role of a model-level SSA value.""" + INPUT = "input" PARAMETER = "parameter" CONSTANT = "constant" @@ -41,9 +42,10 @@ class ValueRole(Enum): OPTIMIZER_STATE = "optimizer_state" -@record_type("compiler.model.value") -@dataclass(frozen=True) +@record("blueprinting.ir.model.value") class ModelValue: + """One typed model-level SSA value with stable provenance.""" + id: ValueId type: TensorType role: ValueRole @@ -51,19 +53,11 @@ class ModelValue: name: str = "" attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - require_instance(self.id, ValueId, "model value ID") - require_instance(self.type, TensorType, "model value type") - require_instance(self.role, ValueRole, "model value role") - require_instance(self.lineage, Lineage, "model value lineage") - if not isinstance(self.name, str): - raise TypeError("model value name must be a string") - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - -@record_type("compiler.model.operation") -@dataclass(frozen=True) +@record("blueprinting.ir.model.operation") class ModelOperation: + """One target-neutral operation with explicit dataflow and effects.""" + id: NodeId operation: OperationName inputs: tuple[ValueId, ...] @@ -71,25 +65,13 @@ class ModelOperation: lineage: Lineage control_dependencies: tuple[NodeId, ...] = () effects: tuple[Effect, ...] = () + semantic: ModelOperationSemantic = EMPTY_SEMANTIC attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - require_instance(self.id, NodeId, "model operation ID") - require_instance(self.operation, OperationName, "model operation name") - require_instance(self.lineage, Lineage, "model operation lineage") - object.__setattr__(self, "inputs", typed_tuple(self.inputs, ValueId, "model operation inputs")) - object.__setattr__(self, "outputs", typed_tuple(self.outputs, ValueId, "model operation outputs")) - object.__setattr__( - self, - "control_dependencies", - typed_tuple(self.control_dependencies, NodeId, "model control dependencies"), - ) - object.__setattr__(self, "effects", typed_tuple(self.effects, Effect, "model operation effects")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - _MODEL_RESERVED = frozenset( { + "model_spec", "tp", "pp", "dp", @@ -111,13 +93,12 @@ def __post_init__(self) -> None: ) -@record_type("compiler.ir.model.v1") -@dataclass(frozen=True) +@record("blueprinting.ir.model") class ModelIR(CanonicalIRMixin): """Explicit tensor SSA graph before distribution decisions.""" SCHEMA_NAME: ClassVar[str] = "blueprinting.model" - SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(1, 0, 0) + SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(0, 0, 0) name: str values: tuple[ModelValue, ...] @@ -127,17 +108,7 @@ class ModelIR(CanonicalIRMixin): attributes: FrozenDict = field(default_factory=FrozenDict) header: IRHeader = field(default_factory=lambda: make_header(ModelIR.SCHEMA_NAME, ModelIR.SCHEMA_VERSION)) - def __post_init__(self) -> None: - require_instance(self.header, IRHeader, "model header") - if not isinstance(self.name, str): - raise TypeError("model name must be a string") - object.__setattr__(self, "values", typed_tuple(self.values, ModelValue, "model values")) - object.__setattr__(self, "operations", typed_tuple(self.operations, ModelOperation, "model operations")) - object.__setattr__(self, "inputs", typed_tuple(self.inputs, ValueId, "model inputs")) - object.__setattr__(self, "outputs", typed_tuple(self.outputs, ValueId, "model outputs")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - - def verify(self) -> VerificationReport: + def diagnostics(self) -> VerificationReport: bag = DiagnosticBag() self._verify_common(bag) if not self.name: @@ -173,7 +144,7 @@ def verify(self) -> VerificationReport: producers = {} for index, operation in enumerate(self.operations): path = ("operations", str(index)) - if operation.operation.dialect.lower() in {"cuda", "nccl", "rocm", "rccl", "lpu"}: + if is_known_target_dialect(operation.operation): bag.error( "model.target_dialect", f"target dialect {operation.operation.dialect!r} is illegal in ModelIR", @@ -214,3 +185,6 @@ def verify(self) -> VerificationReport: bag.error("ssa.undefined_output", f"model output {output_id} is not defined", "outputs") reject_reserved_attributes(bag, self.attributes, _MODEL_RESERVED, "attributes") return bag.report() + + +__all__ = ["ModelIR", "ModelOperation", "ModelValue", "ValueRole"] diff --git a/src/blueprinting/synthesizer/stages/model/passes.py b/src/blueprinting/synthesizer/stages/model/passes.py new file mode 100644 index 0000000..b10e6d1 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/model/passes.py @@ -0,0 +1,7 @@ +"""Passes producing ModelIR. + +Model frontends currently construct ModelIR directly; no canonical pass is +published for this stage yet. +""" + +__all__: tuple[str, ...] = () diff --git a/src/blueprinting/synthesizer/stages/portable_plan/__init__.py b/src/blueprinting/synthesizer/stages/portable_plan/__init__.py new file mode 100644 index 0000000..0f8e1ff --- /dev/null +++ b/src/blueprinting/synthesizer/stages/portable_plan/__init__.py @@ -0,0 +1,51 @@ +"""PortablePlanIR stage: target-neutral exact work and resource requirements.""" + +from .ir import ( + AbstractStorageClass, + BarrierTask, + CollectiveTask, + ComputeTask, + HostTask, + ImplementationRequirement, + ObjectiveDirection, + ObjectiveKind, + PlanBuffer, + PlanBufferRole, + PlanObjective, + PlanTask, + PlanTaskBody, + PlanTaskBodyVariant, + PlanTaskKind, + PortablePlanIR, + ResourceKind, + ResourceRequirement, + ResourceScope, + TransferTask, + WorkloadFacts, + require_concrete_quantity, +) + +__all__ = [ + "AbstractStorageClass", + "BarrierTask", + "CollectiveTask", + "ComputeTask", + "HostTask", + "ImplementationRequirement", + "ObjectiveDirection", + "ObjectiveKind", + "PlanBuffer", + "PlanBufferRole", + "PlanObjective", + "PlanTask", + "PlanTaskBody", + "PlanTaskBodyVariant", + "PlanTaskKind", + "PortablePlanIR", + "ResourceKind", + "ResourceRequirement", + "ResourceScope", + "TransferTask", + "WorkloadFacts", + "require_concrete_quantity", +] diff --git a/src/blueprinting/synthesizer/ir/portable_plan.py b/src/blueprinting/synthesizer/stages/portable_plan/ir.py similarity index 63% rename from src/blueprinting/synthesizer/ir/portable_plan.py rename to src/blueprinting/synthesizer/stages/portable_plan/ir.py index dd97bdc..d694b0f 100644 --- a/src/blueprinting/synthesizer/ir/portable_plan.py +++ b/src/blueprinting/synthesizer/stages/portable_plan/ir.py @@ -6,30 +6,42 @@ from __future__ import annotations -import math -from dataclasses import dataclass, field +from dataclasses import field from enum import Enum -from numbers import Real -from typing import ClassVar - -from blueprinting.schema.codec import enum_type, record_type +from typing import Annotated, ClassVar, TypeAlias + +from typing_extensions import assert_never + +from blueprinting.schema.authoring import ( + NonEmptyText, + NonNegativeInt, + PositiveFiniteFloat, + PositiveInt, + ValueConstraint, + adt, + enum, + record, + seal_adt, + variant, +) from blueprinting.schema.frozen import FrozenDict -from ..errors import DiagnosticBag, VerificationReport -from ..expr import Scalar -from ..ids import BufferId, Lineage, NodeId -from .common import ( +from ...errors import DiagnosticBag, VerificationReport + +# Scalar's forward references must remain visible to annotation deriving. +from ...expr import Scalar, ScalarExpr, Symbol # noqa: F401 +from ...ids import BufferId, Lineage, NodeId +from ...semantics import EMPTY_SEMANTIC, BufferSemantic, PlanTaskSemantic, ProgramSemantic +from ..common import ( CanonicalIRMixin, Effect, IRHeader, OperationName, SchemaVersion, - frozen_map, is_content_digest, + is_known_target_dialect, make_header, reject_reserved_attributes, - require_instance, - typed_tuple, verify_known_references, verify_nonnegative_scalar, verify_ordered_dag, @@ -37,8 +49,10 @@ ) -@enum_type("compiler.portable.resource_kind") +@enum("blueprinting.ir.portable-plan.resource-kind") class ResourceKind(Enum): + """Target-neutral class of a resource demand.""" + COMPUTE = "compute" MEMORY_CAPACITY = "memory_capacity" MEMORY_BANDWIDTH = "memory_bandwidth" @@ -48,53 +62,41 @@ class ResourceKind(Enum): SYNCHRONIZATION = "synchronization" -@enum_type("compiler.portable.resource_scope") +@enum("blueprinting.ir.portable-plan.resource-scope") class ResourceScope(Enum): + """Replication scope used when accounting a resource demand.""" + PER_TASK = "per_task" PER_RANK = "per_rank" SHARED = "shared" -@record_type("compiler.portable.resource_requirement") -@dataclass(frozen=True) +@record("blueprinting.ir.portable-plan.resource-requirement") class ResourceRequirement: + """A typed, target-neutral quantity and its required capabilities.""" + kind: ResourceKind quantity: Scalar scope: ResourceScope = ResourceScope.PER_TASK capabilities: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - require_instance(self.kind, ResourceKind, "resource kind") - require_instance(self.scope, ResourceScope, "resource scope") - object.__setattr__(self, "capabilities", frozen_map(self.capabilities)) + +ImplementationAlternatives: TypeAlias = Annotated[ + tuple[NonEmptyText, ...], + ValueConstraint.UNIQUE_ITEMS, +] -@record_type("compiler.portable.implementation_requirement") -@dataclass(frozen=True) +@record("blueprinting.ir.portable-plan.implementation-requirement") class ImplementationRequirement: """Target-neutral capability request with semantic alternatives.""" - capability: str - alternatives: tuple[str, ...] = () + capability: NonEmptyText + alternatives: ImplementationAlternatives = () constraints: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - object.__setattr__( - self, - "alternatives", - typed_tuple(self.alternatives, str, "implementation alternatives"), - ) - object.__setattr__(self, "constraints", frozen_map(self.constraints)) - if not isinstance(self.capability, str) or not self.capability: - raise ValueError("implementation capability must not be empty") - if len(set(self.alternatives)) != len(self.alternatives): - raise ValueError("implementation alternatives must be unique") - if any(not item for item in self.alternatives): - raise ValueError("implementation alternatives must not be empty") - - -@record_type("compiler.portable.workload_facts") -@dataclass(frozen=True) + +@record("blueprinting.ir.portable-plan.workload-facts") class WorkloadFacts: """Exact or symbolic work quantities, never performance estimates.""" @@ -106,12 +108,27 @@ class WorkloadFacts: persistent_bytes: Scalar = 0 attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - object.__setattr__(self, "attributes", frozen_map(self.attributes)) +def require_concrete_quantity(value: Scalar, subject: str) -> int: + """Return a bound non-negative integer workload quantity. + + Canonical portable plans may carry symbolic quantities before all workload + bindings are available. Consumers that execute or cost a plan must cross + this explicit gate instead of relying on truthiness or implicit numeric + coercion. + """ + + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{subject} must be a concrete integer, got {type(value).__name__}") + if value < 0: + raise ValueError(f"{subject} must be non-negative") + return value -@enum_type("compiler.portable.buffer_role") + +@enum("blueprinting.ir.portable-plan.buffer-role") class PlanBufferRole(Enum): + """Semantic lifetime role of a portable buffer.""" + INPUT = "input" OUTPUT = "output" VALUE = "value" @@ -121,8 +138,10 @@ class PlanBufferRole(Enum): COMMUNICATION = "communication" -@enum_type("compiler.portable.storage_class") +@enum("blueprinting.ir.portable-plan.storage-class") class AbstractStorageClass(Enum): + """Storage capability required without selecting a physical memory.""" + TRANSIENT = "transient" PERSISTENT = "persistent" HOST_VISIBLE = "host_visible" @@ -130,9 +149,10 @@ class AbstractStorageClass(Enum): COMMUNICATION = "communication" -@record_type("compiler.portable.buffer") -@dataclass(frozen=True) +@record("blueprinting.ir.portable-plan.buffer") class PlanBuffer: + """A target-neutral buffer with exact size, lifetime links, and lineage.""" + id: BufferId size_bytes: Scalar role: PlanBufferRole @@ -140,28 +160,15 @@ class PlanBuffer: lineage: Lineage producer: NodeId | None = None consumers: tuple[NodeId, ...] = () - alignment_bytes: int = 1 + alignment_bytes: PositiveInt = 1 + semantic: BufferSemantic = EMPTY_SEMANTIC attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - require_instance(self.id, BufferId, "plan buffer ID") - require_instance(self.role, PlanBufferRole, "plan buffer role") - require_instance(self.storage_class, AbstractStorageClass, "plan buffer storage class") - require_instance(self.lineage, Lineage, "plan buffer lineage") - if self.producer is not None: - require_instance(self.producer, NodeId, "plan buffer producer") - object.__setattr__(self, "consumers", typed_tuple(self.consumers, NodeId, "plan buffer consumers")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if ( - isinstance(self.alignment_bytes, bool) - or not isinstance(self.alignment_bytes, int) - or self.alignment_bytes <= 0 - ): - raise ValueError("buffer alignment must be a positive integer") - - -@enum_type("compiler.portable.task_kind") + +@enum("blueprinting.ir.portable-plan.task-kind") class PlanTaskKind(Enum): + """Execution-domain category of a portable task.""" + COMPUTE = "compute" COLLECTIVE = "collective" TRANSFER = "transfer" @@ -169,90 +176,127 @@ class PlanTaskKind(Enum): HOST = "host" -@record_type("compiler.portable.task") -@dataclass(frozen=True) +@adt(wire="blueprinting.ir.portable-plan.task-body") +class PlanTaskBody: + """Closed execution-domain semantics for one portable task.""" + + +@variant("compute") +class ComputeTask(PlanTaskBody): + pass + + +@variant("collective") +class CollectiveTask(PlanTaskBody): + pass + + +@variant("transfer") +class TransferTask(PlanTaskBody): + pass + + +@variant("barrier") +class BarrierTask(PlanTaskBody): + pass + + +@variant("host") +class HostTask(PlanTaskBody): + pass + + +PlanTaskBodyVariant = ComputeTask | CollectiveTask | TransferTask | BarrierTask | HostTask +seal_adt(PlanTaskBody, PlanTaskBodyVariant) + + +@record("blueprinting.ir.portable-plan.task") class PlanTask: + """A target-neutral unit of exact work in the selected strategy DAG.""" + id: NodeId - kind: PlanTaskKind + body: PlanTaskBodyVariant operation: OperationName dependencies: tuple[NodeId, ...] inputs: tuple[BufferId, ...] outputs: tuple[BufferId, ...] - logical_ranks: tuple[int, ...] + logical_ranks: tuple[NonNegativeInt, ...] workload: WorkloadFacts lineage: Lineage resources: tuple[ResourceRequirement, ...] = () implementations: tuple[ImplementationRequirement, ...] = () - concurrency_group: str | None = None + concurrency_group: NonEmptyText | None = None effects: tuple[Effect, ...] = () + semantic: PlanTaskSemantic = EMPTY_SEMANTIC attributes: FrozenDict = field(default_factory=FrozenDict) - def __post_init__(self) -> None: - require_instance(self.id, NodeId, "plan task ID") - require_instance(self.kind, PlanTaskKind, "plan task kind") - require_instance(self.operation, OperationName, "plan task operation") - require_instance(self.workload, WorkloadFacts, "plan task workload") - require_instance(self.lineage, Lineage, "plan task lineage") - object.__setattr__(self, "dependencies", typed_tuple(self.dependencies, NodeId, "plan task dependencies")) - object.__setattr__(self, "inputs", typed_tuple(self.inputs, BufferId, "plan task inputs")) - object.__setattr__(self, "outputs", typed_tuple(self.outputs, BufferId, "plan task outputs")) - object.__setattr__(self, "logical_ranks", tuple(self.logical_ranks)) - object.__setattr__( - self, - "resources", - typed_tuple(self.resources, ResourceRequirement, "plan task resources"), - ) - object.__setattr__( - self, - "implementations", - typed_tuple(self.implementations, ImplementationRequirement, "plan task implementations"), - ) - object.__setattr__(self, "effects", typed_tuple(self.effects, Effect, "plan task effects")) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if any(isinstance(rank, bool) or not isinstance(rank, int) or rank < 0 for rank in self.logical_ranks): - raise ValueError("logical ranks must be non-negative integers") - if self.concurrency_group is not None: - if not isinstance(self.concurrency_group, str): - raise TypeError("concurrency_group must be a string") - if not self.concurrency_group: - raise ValueError("concurrency_group must be non-empty when provided") - - -@enum_type("compiler.portable.objective_kind") + @property + def kind(self) -> PlanTaskKind: + """Compatibility/presentation view derived from the canonical body.""" + + match self.body: + case ComputeTask(): + return PlanTaskKind.COMPUTE + case CollectiveTask(): + return PlanTaskKind.COLLECTIVE + case TransferTask(): + return PlanTaskKind.TRANSFER + case BarrierTask(): + return PlanTaskKind.BARRIER + case HostTask(): + return PlanTaskKind.HOST + assert_never(self.body) + + +@enum("blueprinting.ir.portable-plan.objective-kind") class ObjectiveKind(Enum): + """Quantity optimized while exploring portable plans.""" + LATENCY = "latency" THROUGHPUT = "throughput" PEAK_MEMORY = "peak_memory" ENERGY = "energy" -@enum_type("compiler.portable.objective_direction") +@enum("blueprinting.ir.portable-plan.objective-direction") class ObjectiveDirection(Enum): + """Optimization direction for a portable-plan objective.""" + MINIMIZE = "minimize" MAXIMIZE = "maximize" -@record_type("compiler.portable.objective") -@dataclass(frozen=True) +@record("blueprinting.ir.portable-plan.objective") class PlanObjective: + """A weighted objective retained as search intent, not measured evidence.""" + kind: ObjectiveKind direction: ObjectiveDirection - weight: float = 1.0 - - def __post_init__(self) -> None: - require_instance(self.kind, ObjectiveKind, "objective kind") - require_instance(self.direction, ObjectiveDirection, "objective direction") - if ( - isinstance(self.weight, bool) - or not isinstance(self.weight, Real) - or not math.isfinite(self.weight) - or self.weight <= 0 - ): - raise ValueError("objective weight must be greater than zero") + weight: PositiveFiniteFloat = 1.0 _PORTABLE_RESERVED = frozenset( { + "model_spec", + "workload_spec", + "mapping_spec", + "inference_mapping_spec", + "inference_phase", + "batch_size", + "query_tokens", + "context_tokens", + "datatype", + "invocation", + "block_memory", + "scope", + "name", + "engine", + "phase", + "primitive", + "source_layer", + "collective", + "semantic", + "bound", "target", "target_id", "physical_device", @@ -279,13 +323,12 @@ def __post_init__(self) -> None: ) -@record_type("compiler.ir.portable_plan.v1") -@dataclass(frozen=True) +@record("blueprinting.ir.portable-plan") class PortablePlanIR(CanonicalIRMixin): """One target-neutral plan candidate with explicit dependency and buffer DAGs.""" SCHEMA_NAME: ClassVar[str] = "blueprinting.portable-plan" - SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(1, 0, 0) + SCHEMA_VERSION: ClassVar[SchemaVersion] = SchemaVersion(0, 0, 0) name: str source_distributed_digest: str @@ -296,35 +339,13 @@ class PortablePlanIR(CanonicalIRMixin): inputs: tuple[BufferId, ...] outputs: tuple[BufferId, ...] objectives: tuple[PlanObjective, ...] + semantic: ProgramSemantic = EMPTY_SEMANTIC attributes: FrozenDict = field(default_factory=FrozenDict) header: IRHeader = field( default_factory=lambda: make_header(PortablePlanIR.SCHEMA_NAME, PortablePlanIR.SCHEMA_VERSION) ) - def __post_init__(self) -> None: - require_instance(self.header, IRHeader, "portable header") - for field_name in ("name", "source_distributed_digest", "strategy_fingerprint", "planner_revision"): - if not isinstance(getattr(self, field_name), str): - raise TypeError(f"{field_name} must be a string") - object.__setattr__(self, "tasks", typed_tuple(self.tasks, PlanTask, "portable tasks")) - object.__setattr__(self, "buffers", typed_tuple(self.buffers, PlanBuffer, "portable buffers")) - object.__setattr__(self, "inputs", typed_tuple(self.inputs, BufferId, "portable inputs")) - object.__setattr__(self, "outputs", typed_tuple(self.outputs, BufferId, "portable outputs")) - object.__setattr__( - self, - "objectives", - typed_tuple(self.objectives, PlanObjective, "portable objectives"), - ) - object.__setattr__(self, "attributes", frozen_map(self.attributes)) - if ( - not self.header.parent_digests - and self.header.schema_name == self.SCHEMA_NAME - and self.header.schema_version == self.SCHEMA_VERSION - and is_content_digest(self.source_distributed_digest) - ): - object.__setattr__(self, "header", self.header.with_parents(self.source_distributed_digest)) - - def verify(self) -> VerificationReport: + def diagnostics(self) -> VerificationReport: bag = DiagnosticBag() self._verify_common(bag) if not self.name: @@ -367,7 +388,7 @@ def verify(self) -> VerificationReport: task_by_id = {item.id: item for item in self.tasks} buffer_by_id = {item.id: item for item in self.buffers} - ancestors = {} + ancestors: dict[NodeId, set[NodeId]] = {} for task in self.tasks: inherited = set(task.dependencies) for dependency in task.dependencies: @@ -431,7 +452,7 @@ def verify(self) -> VerificationReport: *path, "logical_ranks", ) - if task.operation.dialect.lower() in {"cuda", "nccl", "rocm", "rccl", "lpu"}: + if is_known_target_dialect(task.operation): bag.error( "portable.target_dialect", f"target dialect {task.operation.dialect!r} is illegal in PortablePlanIR", @@ -439,21 +460,21 @@ def verify(self) -> VerificationReport: "operation", ) for buffer_id in task.inputs: - buffer = buffer_by_id.get(buffer_id) - if buffer is None: + input_buffer = buffer_by_id.get(buffer_id) + if input_buffer is None: continue - if task.id not in buffer.consumers: + if task.id not in input_buffer.consumers: bag.error("buffer.consumer_mismatch", f"buffer {buffer_id} omits this task", *path, "inputs") - if buffer.producer is not None and buffer.producer not in ancestors.get(task.id, set()): + if input_buffer.producer is not None and input_buffer.producer not in ancestors.get(task.id, set()): bag.error( "task.missing_data_dependency", - f"producer {buffer.producer} of {buffer_id} is not a dependency ancestor", + f"producer {input_buffer.producer} of {buffer_id} is not a dependency ancestor", *path, "dependencies", ) for buffer_id in task.outputs: - buffer = buffer_by_id.get(buffer_id) - if buffer is not None and buffer.producer != task.id: + output_buffer = buffer_by_id.get(buffer_id) + if output_buffer is not None and output_buffer.producer != task.id: bag.error( "buffer.producer_mismatch", f"buffer {buffer_id} names another producer", *path, "outputs" ) @@ -500,3 +521,28 @@ def verify(self) -> VerificationReport: reject_reserved_attributes(bag, self.attributes, _PORTABLE_RESERVED, "attributes") return bag.report() + + +__all__ = [ + "AbstractStorageClass", + "ImplementationRequirement", + "ObjectiveDirection", + "ObjectiveKind", + "PlanBuffer", + "PlanBufferRole", + "PlanObjective", + "PlanTask", + "PlanTaskBody", + "PlanTaskBodyVariant", + "ComputeTask", + "CollectiveTask", + "TransferTask", + "BarrierTask", + "HostTask", + "PlanTaskKind", + "PortablePlanIR", + "ResourceKind", + "ResourceRequirement", + "ResourceScope", + "WorkloadFacts", +] diff --git a/src/blueprinting/synthesizer/stages/portable_plan/passes.py b/src/blueprinting/synthesizer/stages/portable_plan/passes.py new file mode 100644 index 0000000..e6dac44 --- /dev/null +++ b/src/blueprinting/synthesizer/stages/portable_plan/passes.py @@ -0,0 +1,116 @@ +"""Verified derivations whose committed result is :class:`PortablePlanIR`. + +These passes materialize exact target-neutral work. They cannot select kernels, +physical devices, queues, empirical durations, or wall-clock timestamps. +""" + +from __future__ import annotations + +from ...axes import BindingAxis +from ...dialects.transformer.inference_derivation import ( + INFERENCE_PLANNING_RULES, + normalize_inference_plan, +) +from ...dialects.transformer.training_derivation import ( + TRAINING_PLANNING_RULES, + normalize_training_plan, +) +from ...passes.authoring import DerivationPass, PassContext, derivation +from ..distributed.ir import DistributedTaskIR +from .ir import PortablePlanIR + + +@derivation( + "transformer-plan-work", + revision="1", + bindings=(BindingAxis.STRATEGY,), + rules=TRAINING_PLANNING_RULES, + normalizer=normalize_training_plan, +) +class PlanTransformerTrainingPass(DerivationPass[DistributedTaskIR, PortablePlanIR]): + r"""Materialize exact training work without choosing a hardware target. + + This pass is a semantics-preserving reification, not a second estimator. + For every distributed invocation ``i`` it copies the exact work vector + + $$ + \mathbf{w}_i=(F_i,R_i,W_i,M_i) + $$ + + into ``WorkloadFacts(operations, read_bytes, write_bytes, message_bytes)``. + Resource requirements are non-lossy projections of that vector: + + $$ + Q_{\mathrm{compute}}=F_i,\qquad + Q_{\mathrm{memory}}=R_i+W_i,\qquad + Q_{\mathrm{network}}=M_i. + $$ + + Exact block memory is reduced from the structurally derived layer facts; + for example, stored activations are + + $$ + C_{\mathrm{act}}=\sum_{\ell} + \left(A_\ell-O_\ell\,[\neg\mathrm{storeOutput}_\ell] + -A_\ell\,[\neg\mathrm{storeActivation}_\ell]\right). + $$ + + The executable pass rules verify work conservation and lineage before the + snapshot is committed. The scientific provenance is inherited from the + Transformer decomposition rather than introducing a new performance model. + + References: + - Shoeybi et al., [Megatron-LM](https://arxiv.org/abs/1909.08053). + - Rajbhandari et al., [ZeRO](https://arxiv.org/abs/1910.02054). + - Korthikanti et al., [Selective activation + recomputation](https://arxiv.org/abs/2205.05198). + """ + + def run(self, ir: DistributedTaskIR, context: PassContext) -> PortablePlanIR: + return normalize_training_plan(ir, context.session) + + +@derivation( + "transformer-inference-plan-work", + revision="1", + bindings=(BindingAxis.STRATEGY,), + rules=INFERENCE_PLANNING_RULES, + normalizer=normalize_inference_plan, +) +class PlanTransformerInferencePass(DerivationPass[DistributedTaskIR, PortablePlanIR]): + r"""Materialize exact inference work without target placement or timing. + + Each inference invocation is mapped homomorphically into a portable task: + + $$ + (F_i,R_i,W_i,M_i)_{\mathrm{distributed}} + =(F_i,R_i,W_i,M_i)_{\mathrm{portable}}. + $$ + + The conservative workspace bound intentionally assumes an unfused score + materialization until target binding selects an implementation. With + boundary ``D=bqHe`` and local intermediate element counts + ``3bqH/t``, ``b(h/t)qc``, and ``bqF/t``, it is + + $$ + C_{\mathrm{workspace}} + =D+e\max\left(3bq\frac{H}{t}, + b\frac{h}{t}qc, + bq\frac{F}{t}\right). + $$ + + This bound is a portable capacity obligation. A target implementation such + as tiled exact attention may replace its workspace only during verified + target binding; it may not rewrite the canonical operation count. + + References: + - Vaswani et al., [Attention Is All You + Need](https://arxiv.org/abs/1706.03762). + - Dao et al., [FlashAttention](https://arxiv.org/abs/2205.14135). + """ + + def run(self, ir: DistributedTaskIR, context: PassContext) -> PortablePlanIR: + return normalize_inference_plan(ir, context.session) + + +__all__ = ["PlanTransformerInferencePass", "PlanTransformerTrainingPass"] diff --git a/src/blueprinting/system/chip.py b/src/blueprinting/system/chip.py index 012ed19..a765b16 100644 --- a/src/blueprinting/system/chip.py +++ b/src/blueprinting/system/chip.py @@ -2,15 +2,16 @@ from __future__ import annotations -import math -from dataclasses import dataclass +from typing import Annotated, TypeAlias -from blueprinting.schema.codec import record_type - - -def _positive_rate(value: float, name: str) -> None: - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: - raise ValueError(f"{name} must be a finite positive number") +from blueprinting.schema.authoring import ( + NonNegativeInt, + PositiveFiniteNumber, + PositiveInt, + PositiveUnitIntervalNumber, + ValueConstraint, + record, +) def _non_negative_integer(value: int, name: str) -> None: @@ -18,37 +19,27 @@ def _non_negative_integer(value: int, name: str) -> None: raise ValueError(f"{name} must be a non-negative integer") -@record_type("compiler.analysis.efficiency_point.v1") -@dataclass(frozen=True) +@record("blueprinting.system.efficiency-point") class EfficiencyPoint: """Measured or simulated efficiency above one work-size threshold.""" - threshold: int - efficiency: float + threshold: NonNegativeInt + efficiency: PositiveUnitIntervalNumber - def __post_init__(self) -> None: - if isinstance(self.threshold, bool) or not isinstance(self.threshold, int) or self.threshold < 0: - raise ValueError("efficiency threshold must be a non-negative integer") - if ( - isinstance(self.efficiency, bool) - or not isinstance(self.efficiency, (int, float)) - or not math.isfinite(self.efficiency) - or not 0 < self.efficiency <= 1 - ): - raise ValueError("efficiency must be finite and in (0, 1]") - - -@record_type("compiler.analysis.efficiency_curve.v1") -@dataclass(frozen=True) + +EfficiencyPoints: TypeAlias = Annotated[ + tuple[EfficiencyPoint, ...], + ValueConstraint.NON_EMPTY, +] + + +@record("blueprinting.system.efficiency-curve") class EfficiencyCurve: """Piecewise-constant utilization evidence indexed by exact work size.""" - points: tuple[EfficiencyPoint, ...] + points: EfficiencyPoints def __post_init__(self) -> None: - object.__setattr__(self, "points", tuple(self.points)) - if not self.points or any(not isinstance(point, EfficiencyPoint) for point in self.points): - raise ValueError("an efficiency curve requires typed points") thresholds = tuple(point.threshold for point in self.points) if thresholds != tuple(sorted(thresholds, reverse=True)) or len(set(thresholds)) != len(thresholds): raise ValueError("efficiency thresholds must be unique and descending") @@ -64,19 +55,13 @@ def lookup(self, work: int) -> float: raise AssertionError("zero-threshold curve failed to cover work") -@record_type("compiler.analysis.processor_profile.v1") -@dataclass(frozen=True) +@record("blueprinting.system.processor-profile") class ProcessorProfile: """One chip compute engine and its size-dependent utilization evidence.""" - peak_operations_per_second: float + peak_operations_per_second: PositiveFiniteNumber efficiency: EfficiencyCurve - def __post_init__(self) -> None: - _positive_rate(self.peak_operations_per_second, "peak_operations_per_second") - if not isinstance(self.efficiency, EfficiencyCurve): - raise TypeError("efficiency must be EfficiencyCurve") - def throughput(self, operations: int, *, apply_efficiency: bool = True) -> float: _non_negative_integer(operations, "operations") if not isinstance(apply_efficiency, bool): @@ -85,26 +70,14 @@ def throughput(self, operations: int, *, apply_efficiency: bool = True) -> float return self.peak_operations_per_second * efficiency -@record_type("compiler.analysis.memory_profile.v1") -@dataclass(frozen=True) +@record("blueprinting.system.memory-profile") class MemoryProfile: """One chip-visible memory tier and its transfer-efficiency evidence.""" - capacity_bytes: int - peak_bytes_per_second: float + capacity_bytes: PositiveInt + peak_bytes_per_second: PositiveFiniteNumber efficiency: EfficiencyCurve - def __post_init__(self) -> None: - if ( - isinstance(self.capacity_bytes, bool) - or not isinstance(self.capacity_bytes, int) - or self.capacity_bytes <= 0 - ): - raise ValueError("capacity_bytes must be a positive integer") - _positive_rate(self.peak_bytes_per_second, "peak_bytes_per_second") - if not isinstance(self.efficiency, EfficiencyCurve): - raise TypeError("efficiency must be EfficiencyCurve") - def throughput(self, transferred_bytes: int, *, apply_efficiency: bool = True) -> float: _non_negative_integer(transferred_bytes, "transferred_bytes") if not isinstance(apply_efficiency, bool): diff --git a/src/blueprinting/system/interconnect.py b/src/blueprinting/system/interconnect.py index c6854da..2b4237d 100644 --- a/src/blueprinting/system/interconnect.py +++ b/src/blueprinting/system/interconnect.py @@ -2,76 +2,33 @@ from __future__ import annotations -import math -from dataclasses import dataclass - -from blueprinting.schema.codec import record_type +from blueprinting.schema.authoring import ( + NonNegativeFiniteNumber, + PositiveFiniteNumber, + PositiveInt, + PositiveUnitIntervalNumber, + record, +) from blueprinting.schema.frozen import FrozenDict -@record_type("compiler.analysis.network_operation.v1") -@dataclass(frozen=True) +@record("blueprinting.system.network-operation") class NetworkOperationProfile: """Explicit byte-volume rule for one point-to-point or collective operation.""" - volume_multiplier: float + volume_multiplier: PositiveFiniteNumber participant_offset: int - def __post_init__(self) -> None: - if ( - isinstance(self.volume_multiplier, bool) - or not isinstance(self.volume_multiplier, (int, float)) - or not math.isfinite(self.volume_multiplier) - or self.volume_multiplier <= 0 - ): - raise ValueError("volume_multiplier must be a finite positive number") - if isinstance(self.participant_offset, bool) or not isinstance(self.participant_offset, int): - raise TypeError("participant_offset must be an integer") - -@record_type("compiler.analysis.network_profile.v1") -@dataclass(frozen=True) +@record("blueprinting.system.network-profile") class NetworkProfile: """One interconnect tier with bandwidth, latency, capacity, and volume rules.""" - peak_bytes_per_second: float - efficiency: float - latency_seconds: float - participant_capacity: int - operations: FrozenDict - - def __post_init__(self) -> None: - if ( - isinstance(self.peak_bytes_per_second, bool) - or not isinstance(self.peak_bytes_per_second, (int, float)) - or not math.isfinite(self.peak_bytes_per_second) - or self.peak_bytes_per_second <= 0 - ): - raise ValueError("peak_bytes_per_second must be a finite positive number") - if ( - isinstance(self.efficiency, bool) - or not isinstance(self.efficiency, (int, float)) - or not math.isfinite(self.efficiency) - or not 0 < self.efficiency <= 1 - ): - raise ValueError("efficiency must be finite and in (0, 1]") - if ( - isinstance(self.latency_seconds, bool) - or not isinstance(self.latency_seconds, (int, float)) - or not math.isfinite(self.latency_seconds) - or self.latency_seconds < 0 - ): - raise ValueError("latency_seconds must be a finite non-negative number") - if ( - isinstance(self.participant_capacity, bool) - or not isinstance(self.participant_capacity, int) - or self.participant_capacity <= 0 - ): - raise ValueError("participant_capacity must be a positive integer") - operations = FrozenDict(self.operations) - if any(not isinstance(item, NetworkOperationProfile) for item in operations.values()): - raise TypeError("operations must contain NetworkOperationProfile values") - object.__setattr__(self, "operations", operations) + peak_bytes_per_second: PositiveFiniteNumber + efficiency: PositiveUnitIntervalNumber + latency_seconds: NonNegativeFiniteNumber + participant_capacity: PositiveInt + operations: FrozenDict[NetworkOperationProfile] def transferred_bytes(self, operation: str, message_bytes: int, participants: int) -> float: profile = self.operations.get(operation) diff --git a/src/blueprinting/system/profile.py b/src/blueprinting/system/profile.py index cda5eb9..c3dec73 100644 --- a/src/blueprinting/system/profile.py +++ b/src/blueprinting/system/profile.py @@ -3,18 +3,17 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any +from typing import Any, Literal -from blueprinting.schema.codec import content_digest, record_type +from blueprinting.schema.authoring import NonEmptyText, record +from blueprinting.schema.codec import content_digest from blueprinting.schema.frozen import FrozenDict from .chip import EfficiencyCurve, EfficiencyPoint, MemoryProfile, ProcessorProfile from .interconnect import NetworkOperationProfile, NetworkProfile -@record_type("compiler.analysis.hardware_profile.v1") -@dataclass(frozen=True) +@record("blueprinting.system.profile") class SystemProfile: """One accelerator system used for analytical evaluation. @@ -23,32 +22,14 @@ class SystemProfile: exact imported system evidence snapshot. """ - name: str - datatype: str + name: NonEmptyText + datatype: NonEmptyText matrix: ProcessorProfile vector: ProcessorProfile memory: MemoryProfile - processing_mode: str + processing_mode: Literal["roofline", "no_overlap"] networks: tuple[NetworkProfile, ...] - evidence_revision: str - - def __post_init__(self) -> None: - if not isinstance(self.name, str) or not self.name: - raise ValueError("system profile name must not be empty") - if not isinstance(self.datatype, str) or not self.datatype: - raise ValueError("system profile datatype must not be empty") - if not isinstance(self.matrix, ProcessorProfile) or not isinstance(self.vector, ProcessorProfile): - raise TypeError("matrix and vector must be ProcessorProfile") - if not isinstance(self.memory, MemoryProfile): - raise TypeError("memory must be MemoryProfile") - if self.processing_mode not in {"roofline", "no_overlap"}: - raise ValueError("processing_mode must be roofline or no_overlap") - networks = tuple(self.networks) - if any(not isinstance(network, NetworkProfile) for network in networks): - raise TypeError("networks must contain NetworkProfile values") - object.__setattr__(self, "networks", networks) - if not isinstance(self.evidence_revision, str) or not self.evidence_revision: - raise ValueError("evidence_revision must not be empty") + evidence_revision: NonEmptyText @classmethod def from_mapping( diff --git a/src/blueprinting/validation/calculon.py b/src/blueprinting/validation/calculon.py index 1ea2370..8975f78 100644 --- a/src/blueprinting/validation/calculon.py +++ b/src/blueprinting/validation/calculon.py @@ -12,23 +12,31 @@ from __future__ import annotations +import hashlib import json import logging from dataclasses import dataclass from pathlib import Path from typing import Any +import calculon from blueprinting.analysis.cost_model import ( CalibrationMode, IterationEstimate, estimate_iteration, ) from blueprinting.mapping import NetworkTierBinding, TransformerTrainingMappingSpec -from blueprinting.synthesizer.dialects.transformer import EngineKind, TrainingPhase +from blueprinting.synthesizer.dialects.transformer import ( + EngineKind, + TrainingPhase, + TransformerTrainingPlanSemantic, + TransformerTrainingPlanTaskSemantic, +) from blueprinting.synthesizer.frontend import build_transformer_model_ir, synthesis_session_for -from blueprinting.synthesizer.ir import PortablePlanIR -from blueprinting.synthesizer.lowering import DistributeTransformerTrainingPass, PlanTransformerTrainingPass from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerTrainingPass +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR, require_concrete_quantity +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerTrainingPass from blueprinting.system import SystemProfile from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec from calculon.llm import Llm @@ -73,6 +81,7 @@ def to_dict(self) -> dict[str, float]: @dataclass(frozen=True) class CalculonCaseReport: case: str + input_manifest: dict[str, dict[str, str]] model_digest: str distributed_digest: str portable_digest: str @@ -85,7 +94,7 @@ class CalculonCaseReport: @property def calculon_total_seconds(self) -> float: - return self.calculon_stats["total_time"] + return float(self.calculon_stats["total_time"]) @property def peak_error_percent(self) -> float: @@ -101,9 +110,38 @@ def paper_error_percent(self) -> float | None: return None return (self.calibrated.total - self.paper_seconds) / self.paper_seconds * 100 + @property + def estimated_breakdown_seconds(self) -> dict[str, float]: + return { + "forward": self.calibrated.forward, + "backward": self.calibrated.backward, + "optimizer": self.calibrated.optimizer, + "recompute": self.calibrated.recompute, + "tensor_parallel": self.calibrated.tensor_parallel, + "pipeline_parallel": self.calibrated.pipeline_parallel, + "data_parallel": self.calibrated.data_parallel, + "recommunication": self.calibrated.recommunication, + "pipeline_bubble": self.calibrated.pipeline_bubble, + } + + @property + def calculon_breakdown_seconds(self) -> dict[str, float]: + return { + "forward": self.calculon_stats["fw_time"], + "backward": self.calculon_stats["bw_time"], + "optimizer": self.calculon_stats["optim_step_time"], + "recompute": self.calculon_stats["recompute_time"], + "tensor_parallel": self.calculon_stats["tp_comm_exposed_time"], + "pipeline_parallel": self.calculon_stats["pp_comm_exposed_time"], + "data_parallel": self.calculon_stats["dp_comm_exposed_time"], + "recommunication": self.calculon_stats["recomm_exposed_time"], + "pipeline_bubble": self.calculon_stats["bubble_time"], + } + def to_dict(self) -> dict[str, Any]: return { "case": self.case, + "inputs": self.input_manifest, "ir": { "model_digest": self.model_digest, "distributed_digest": self.distributed_digest, @@ -122,28 +160,8 @@ def to_dict(self) -> dict[str, Any]: "system_evidence_vs_calculon": self.calibrated_error_percent, "system_evidence_vs_paper": self.paper_error_percent, }, - "estimated_breakdown_seconds": { - "forward": self.calibrated.forward, - "backward": self.calibrated.backward, - "optimizer": self.calibrated.optimizer, - "recompute": self.calibrated.recompute, - "tensor_parallel": self.calibrated.tensor_parallel, - "pipeline_parallel": self.calibrated.pipeline_parallel, - "data_parallel": self.calibrated.data_parallel, - "recommunication": self.calibrated.recommunication, - "pipeline_bubble": self.calibrated.pipeline_bubble, - }, - "calculon_breakdown_seconds": { - "forward": self.calculon_stats["fw_time"], - "backward": self.calculon_stats["bw_time"], - "optimizer": self.calculon_stats["optim_step_time"], - "recompute": self.calculon_stats["recompute_time"], - "tensor_parallel": self.calculon_stats["tp_comm_exposed_time"], - "pipeline_parallel": self.calculon_stats["pp_comm_exposed_time"], - "data_parallel": self.calculon_stats["dp_comm_exposed_time"], - "recommunication": self.calculon_stats["recomm_exposed_time"], - "pipeline_bubble": self.calculon_stats["bubble_time"], - }, + "estimated_breakdown_seconds": self.estimated_breakdown_seconds, + "calculon_breakdown_seconds": self.calculon_breakdown_seconds, "memory_bytes": { "estimated": self.calibrated.memory.total, "calculon": self.calculon_stats["proc_mem_tier1_cap_req"], @@ -169,6 +187,8 @@ def to_dict(self) -> dict[str, Any]: @dataclass(frozen=True) class CalculonExperimentReport: schema: str + oracle: dict[str, str] + paper_baseline: dict[str, str] hardware_name: str evidence_revision: str calibration_policy: dict[str, Any] @@ -190,6 +210,35 @@ def calibrated_max_absolute_error_percent(self) -> float: def workload_max_absolute_error_percent(self) -> float: return max(abs(metric.relative_error_percent) for case in self.cases for metric in case.workload.values()) + @property + def memory_max_absolute_error_bytes(self) -> float: + return max( + abs(case.calibrated.memory.total - float(case.calculon_stats["proc_mem_tier1_cap_req"])) + for case in self.cases + ) + + @property + def breakdown_error(self) -> dict[str, dict[str, float]]: + components = self.cases[0].estimated_breakdown_seconds + result = {} + for component in components: + absolute_seconds = [] + absolute_percent = [] + for case in self.cases: + estimated = case.estimated_breakdown_seconds[component] + reference = case.calculon_breakdown_seconds[component] + absolute_seconds.append(abs(estimated - reference)) + if reference == 0: + absolute_percent.append(0.0 if estimated == 0 else float("inf")) + else: + absolute_percent.append(abs((estimated - reference) / reference * 100)) + result[component] = { + "mean_absolute_error_percent": sum(absolute_percent) / len(absolute_percent), + "max_absolute_error_percent": max(absolute_percent), + "max_absolute_error_seconds": max(absolute_seconds), + } + return result + @property def paper_mean_absolute_error_percent(self) -> float | None: errors = tuple(abs(case.paper_error_percent) for case in self.cases if case.paper_error_percent is not None) @@ -203,6 +252,8 @@ def paper_max_absolute_error_percent(self) -> float | None: def to_dict(self) -> dict[str, Any]: return { "schema": self.schema, + "oracle": self.oracle, + "paper_baseline": self.paper_baseline, "hardware": { "name": self.hardware_name, "evidence_revision": self.evidence_revision, @@ -214,6 +265,8 @@ def to_dict(self) -> dict[str, Any]: "system_evidence_mean_absolute_error_percent": self.calibrated_mean_absolute_error_percent, "system_evidence_max_absolute_error_percent": self.calibrated_max_absolute_error_percent, "workload_max_absolute_error_percent": self.workload_max_absolute_error_percent, + "memory_max_absolute_error_bytes": self.memory_max_absolute_error_bytes, + "breakdown_error": self.breakdown_error, "system_evidence_vs_paper_mean_absolute_error_percent": self.paper_mean_absolute_error_percent, "system_evidence_vs_paper_max_absolute_error_percent": self.paper_max_absolute_error_percent, }, @@ -226,7 +279,38 @@ def to_json(self) -> str: def _read_json(path: Path) -> dict[str, Any]: with path.open(encoding="utf-8") as stream: - return json.load(stream) + value = json.load(stream) + if not isinstance(value, dict): + raise TypeError(f"Calculon fixture {path} must contain a JSON object") + return value + + +def _sha256(path: Path) -> str: + digester = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digester.update(chunk) + return digester.hexdigest() + + +def _source_tree_digest(root: Path) -> str: + digester = hashlib.sha256() + for path in sorted(root.rglob("*.py")): + relative = path.relative_to(root).as_posix().encode() + payload = path.read_bytes() + digester.update(len(relative).to_bytes(8, "big")) + digester.update(relative) + digester.update(len(payload).to_bytes(8, "big")) + digester.update(payload) + return digester.hexdigest() + + +def _input_manifest(case: CalculonCase) -> dict[str, dict[str, str]]: + return { + "model": {"file": case.model_path.name, "sha256": _sha256(case.model_path)}, + "execution": {"file": case.execution_path.name, "sha256": _sha256(case.execution_path)}, + "system": {"file": case.system_path.name, "sha256": _sha256(case.system_path)}, + } def discover_seqsel_tab5_cases(data_root: Path) -> tuple[CalculonCase, ...]: @@ -251,14 +335,17 @@ def _run_calculon( system_data: dict[str, Any], ) -> dict[str, Any]: logger = logging.getLogger("blueprinting.validation.calculon") - application = Llm.Application(model_data) - execution_fields = {field: execution_data[field] for field in Llm.Execution.fields()} - execution = Llm.Execution.from_json(execution_fields) - system = System(system_data) - model = Llm(application, logger) - model.compile(system, execution) - model.run(system) - return model.get_stats_json(False) + application = Llm.Application(model_data) # type: ignore[no-untyped-call] + execution_fields = {field: execution_data[field] for field in Llm.Execution.fields()} # type: ignore[no-untyped-call] + execution = Llm.Execution.from_json(execution_fields) # type: ignore[no-untyped-call] + system = System(system_data) # type: ignore[no-untyped-call] + model = Llm(application, logger) # type: ignore[no-untyped-call] + model.compile(system, execution) # type: ignore[no-untyped-call] + model.run(system) # type: ignore[no-untyped-call] + stats = model.get_stats_json(False) # type: ignore[no-untyped-call] + if not isinstance(stats, dict): + raise TypeError("Calculon oracle returned a non-object statistics payload") + return stats def _derive_plan( @@ -268,7 +355,7 @@ def _derive_plan( ) -> tuple[PortablePlanIR, tuple[dict[str, Any], ...], str, str]: source = build_transformer_model_ir(model, datatype=workload.datatype) session = synthesis_session_for(model, workload, mapping) - result = PassManager().run( + result = PassManager().require_run( PassPipeline.of(DistributeTransformerTrainingPass(), PlanTransformerTrainingPass()), source, session=session, @@ -303,11 +390,13 @@ def _phase_work(plan: PortablePlanIR, phase: TrainingPhase) -> tuple[int, int, i memory_bytes = 0 message_bytes = 0 for task in plan.tasks: - if task.workload.attributes.get("phase") != phase.value: + semantic = task.semantic + if not isinstance(semantic, TransformerTrainingPlanTaskSemantic) or semantic.phase is not phase: continue - operations += task.workload.operations - memory_bytes += task.workload.read_bytes + task.workload.write_bytes - message_bytes += task.workload.message_bytes + operations += require_concrete_quantity(task.workload.operations, f"task {task.id} operations") + memory_bytes += require_concrete_quantity(task.workload.read_bytes, f"task {task.id} read_bytes") + memory_bytes += require_concrete_quantity(task.workload.write_bytes, f"task {task.id} write_bytes") + message_bytes += require_concrete_quantity(task.workload.message_bytes, f"task {task.id} message_bytes") return operations, memory_bytes, message_bytes @@ -317,7 +406,10 @@ def _workload_audit(plan: PortablePlanIR, calculon: dict[str, Any]) -> dict[str, wgrad_ops, wgrad_memory, _ = _phase_work(plan, TrainingPhase.WEIGHT_GRADIENT) optimizer_ops, optimizer_memory, _ = _phase_work(plan, TrainingPhase.OPTIMIZER) _, _, recomm_messages = _phase_work(plan, TrainingPhase.RECOMMUNICATION) - memory = plan.attributes["block_memory"] + semantic = plan.semantic + if not isinstance(semantic, TransformerTrainingPlanSemantic): + raise TypeError("portable training plan is missing typed Transformer semantics") + memory = semantic.block_memory return { "block_forward_operations": MetricComparison(forward_ops, calculon["block_fw_flops"]), "block_forward_memory_bytes": MetricComparison(forward_memory, calculon["block_fw_mem_accessed"]), @@ -371,33 +463,49 @@ def run_calculon_experiment(cases: tuple[CalculonCase, ...]) -> CalculonExperime evidence_revision = hardware.evidence_revision elif evidence_revision != hardware.evidence_revision: raise ValueError("one experiment report must use one hardware evidence revision") + peak_only = estimate_iteration( + plan, + hardware, + CalibrationMode.PEAK_ONLY, + network_binding=network_binding, + ) + calibrated = estimate_iteration( + plan, + hardware, + CalibrationMode.SYSTEM_EVIDENCE, + network_binding=network_binding, + ) + # Oracle execution is deliberately last: neither lowering nor either + # estimate can observe Calculon outputs or the paper measurement. calculon_stats = _run_calculon(model_data, execution_data, system_data) reports.append( CalculonCaseReport( case=case.name, + input_manifest=_input_manifest(case), model_digest=model_digest, distributed_digest=distributed_digest, portable_digest=plan.digest, pass_checkpoints=checkpoints, workload=_workload_audit(plan, calculon_stats), - peak_only=estimate_iteration( - plan, - hardware, - CalibrationMode.PEAK_ONLY, - network_binding=network_binding, - ), - calibrated=estimate_iteration( - plan, - hardware, - CalibrationMode.SYSTEM_EVIDENCE, - network_binding=network_binding, - ), + peak_only=peak_only, + calibrated=calibrated, calculon_stats=calculon_stats, paper_seconds=case.paper_seconds, ) ) return CalculonExperimentReport( - schema="blueprinting.calculon-calibration-experiment.v2", + schema="blueprinting.calculon-calibration-experiment.v0", + oracle={ + "name": "Calculon", + "package_version": calculon.__version__, + "source_digest": _source_tree_digest(Path(calculon.__file__).resolve().parent), + "source_repository": "https://github.com/calculon-ai/calculon", + }, + paper_baseline={ + "name": "SeqSel Table 5", + "paper": "Reducing Activation Recomputation in Large Transformer Models", + "source": "https://arxiv.org/abs/2205.05198", + }, hardware_name=hardware_name, evidence_revision=evidence_revision, calibration_policy={ @@ -410,6 +518,8 @@ def run_calculon_experiment(cases: tuple[CalculonCase, ...]) -> CalculonExperime ], "shared_across_cases": True, "fit_against_case_outputs": False, + "oracle_read_during_lowering": False, + "oracle_read_during_costing": False, "forbidden_inputs": [ "model name", "Calculon duration", diff --git a/src/blueprinting/validation/regression.py b/src/blueprinting/validation/regression.py index ba1c0ae..a9c9adf 100644 --- a/src/blueprinting/validation/regression.py +++ b/src/blueprinting/validation/regression.py @@ -102,7 +102,7 @@ def _read_json(path: Path) -> dict[str, Any]: def _contract(repository_root: Path) -> dict[str, Any]: contract = _read_json(repository_root / _CONTRACT_PATH) - if contract.get("schema") != "blueprinting.baseline-regression-contract.v1": + if contract.get("schema") != "blueprinting.baseline-regression-contract.v0": raise ValueError("unsupported baseline regression contract schema") return contract @@ -125,8 +125,28 @@ def _fixture_path(fixture_root: Path, relative_path: str) -> Path: def _training_checks(report: CalculonExperimentReport, contract: dict[str, Any]) -> tuple[RegressionCheck, ...]: budgets = contract["budgets"] golden = contract["golden"] + input_manifest: dict[str, dict[str, str]] = {"models": {}, "executions": {}, "systems": {}} + for case in report.cases: + input_manifest["models"][case.input_manifest["model"]["file"]] = case.input_manifest["model"]["sha256"] + input_manifest["executions"][case.input_manifest["execution"]["file"]] = case.input_manifest["execution"][ + "sha256" + ] + input_manifest["systems"][case.input_manifest["system"]["file"]] = case.input_manifest["system"]["sha256"] checks = [ + _exact("training.report_schema", report.schema, contract["report_schema"]), + _exact("training.oracle.name", report.oracle["name"], contract["oracle"]["name"]), + _exact( + "training.oracle.package_version", + report.oracle["package_version"], + contract["oracle"]["package_version"], + ), + _exact( + "training.oracle.source_digest", + report.oracle["source_digest"], + contract["oracle"]["source_digest"], + ), _exact("training.case_count", len(report.cases), contract["case_count"]), + _exact("training.input_manifest", input_manifest, contract["inputs"]), _exact("training.evidence_revision", report.evidence_revision, contract["evidence_revision"]), _at_most( "training.workload_max_absolute_error_percent", @@ -143,6 +163,11 @@ def _training_checks(report: CalculonExperimentReport, contract: dict[str, Any]) report.calibrated_max_absolute_error_percent, budgets["calculon_max_absolute_error_percent"], ), + _at_most( + "training.breakdown_max_absolute_error_percent", + max(item["max_absolute_error_percent"] for item in report.breakdown_error.values()), + budgets["breakdown_max_absolute_error_percent"], + ), _at_most( "training.paper_mean_absolute_error_percent", report.paper_mean_absolute_error_percent, @@ -180,14 +205,21 @@ def _training_checks(report: CalculonExperimentReport, contract: dict[str, Any]) _exact( "training.policy.fit_against_case_outputs", report.calibration_policy["fit_against_case_outputs"], False ), + _exact( + "training.policy.oracle_read_during_lowering", + report.calibration_policy["oracle_read_during_lowering"], + False, + ), + _exact( + "training.policy.oracle_read_during_costing", + report.calibration_policy["oracle_read_during_costing"], + False, + ), ] - memory_error = max( - abs(case.calibrated.memory.total - case.calculon_stats["proc_mem_tier1_cap_req"]) for case in report.cases - ) checks.append( _at_most( "training.memory_max_absolute_error_bytes", - memory_error, + report.memory_max_absolute_error_bytes, budgets["memory_max_absolute_error_bytes"], ) ) @@ -200,11 +232,11 @@ def _training_checks(report: CalculonExperimentReport, contract: dict[str, Any]) ) ) for case_name, expected_digest in golden["portable_digests"].items(): - case = reports_by_name.get(case_name) + matched_case = reports_by_name.get(case_name) checks.append( _exact( f"training.{case_name}.portable_digest", - case.portable_digest if case is not None else None, + matched_case.portable_digest if matched_case is not None else None, expected_digest, ) ) @@ -218,7 +250,7 @@ def run_training_baseline_regression(repository_root: str | Path) -> BaselineReg contract = _contract(root)["training"] report = run_calculon_experiment(discover_seqsel_tab5_cases(root / "data")) return BaselineRegressionGate( - schema="blueprinting.baseline-regression-gate.v1", + schema="blueprinting.baseline-regression-gate.v0", domain="training/calculon", checks=_training_checks(report, contract), ) @@ -240,7 +272,7 @@ def _load_vidur_report( _sha256(manifest_path), contract["fixture_manifest_sha256"], ), - _exact("inference.fixture.schema", manifest.get("schema"), "blueprinting.vidur-validation-slice.v1"), + _exact("inference.fixture.schema", manifest.get("schema"), "blueprinting.vidur-validation-slice.v0"), _exact( "inference.fixture.source_repository", manifest["source"].get("repository"), @@ -470,7 +502,7 @@ def run_inference_baseline_regression(repository_root: str | Path) -> BaselineRe contract = _contract(root)["inference"] report, _, fixture_checks = _load_vidur_report(root, contract) return BaselineRegressionGate( - schema="blueprinting.baseline-regression-gate.v1", + schema="blueprinting.baseline-regression-gate.v0", domain="inference/vidur", checks=_inference_checks(report, contract, fixture_checks), ) diff --git a/src/blueprinting/validation/vidur.py b/src/blueprinting/validation/vidur.py index 9d0b5d3..03db19b 100644 --- a/src/blueprinting/validation/vidur.py +++ b/src/blueprinting/validation/vidur.py @@ -21,12 +21,17 @@ ) from blueprinting.mapping import NetworkTierBinding, TransformerInferenceMappingSpec from blueprinting.synthesizer.bindings import InferencePhase +from blueprinting.synthesizer.dialects.transformer import ( + TransformerInferencePlanSemantic, + TransformerInferencePlanTaskSemantic, +) from blueprinting.synthesizer.frontend import build_transformer_inference_model_ir, inference_synthesis_session_for -from blueprinting.synthesizer.ir import PortablePlanIR -from blueprinting.synthesizer.lowering import DistributeTransformerInferencePass, PlanTransformerInferencePass from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerInferencePass +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerInferencePass from blueprinting.system import SystemProfile -from blueprinting.workload import TransformerModelSpec +from blueprinting.workload import TransformerDataType, TransformerModelSpec, require_transformer_data_type @dataclass(frozen=True) @@ -164,7 +169,7 @@ class VidurExperimentCase: model: TransformerModelSpec mapping: TransformerInferenceMappingSpec network_binding: NetworkTierBinding - datatype: str + datatype: TransformerDataType hardware: SystemProfile phase: InferencePhase batch_size: int @@ -182,8 +187,7 @@ def __post_init__(self) -> None: self.mapping.validate_model(self.model) if self.context_tokens > self.model.sequence_length: raise ValueError("context_tokens cannot exceed model sequence_length") - if self.datatype not in {"float8", "float16", "bfloat16", "float32"}: - raise ValueError(f"unsupported datatype: {self.datatype!r}") + require_transformer_data_type(self.datatype) if self.hardware.datatype != self.datatype: raise ValueError("hardware and workload datatype must match") @@ -300,22 +304,22 @@ def compare_inference_phase_to_vidur( ) -> VidurPhaseComparison: """Compare an already-lowered and already-costed phase with Vidur.""" - model = plan.attributes.get("model_spec") - mapping = plan.attributes.get("inference_mapping_spec") - datatype = plan.attributes.get("datatype") - if not isinstance(model, TransformerModelSpec): - raise TypeError("portable inference plan is missing TransformerModelSpec") - if not isinstance(mapping, TransformerInferenceMappingSpec): - raise TypeError("portable inference plan is missing TransformerInferenceMappingSpec") - if not isinstance(datatype, str): - raise TypeError("portable inference plan is missing its datatype") + semantic = plan.semantic + if not isinstance(semantic, TransformerInferencePlanSemantic): + raise TypeError("portable inference plan is missing typed Transformer semantics") + model = semantic.model + mapping = semantic.mapping + datatype = semantic.datatype if len(plan.tasks) != len(estimate.tasks): raise ValueError("plan and estimate task counts differ") components = [] for plan_task, task_estimate in zip(plan.tasks, estimate.tasks): invocation = task_estimate.invocation - if plan_task.workload.attributes.get("name") != invocation.name: + task_semantic = plan_task.semantic + if not isinstance(task_semantic, TransformerInferencePlanTaskSemantic): + raise TypeError("portable inference task is missing typed Transformer semantics") + if task_semantic.name != invocation.name: raise ValueError("plan and estimate task order differs") reference = baseline.lookup( inference_evidence_query_for( @@ -366,7 +370,7 @@ def run_vidur_experiment( manager = PassManager() for case in cases: source = build_transformer_inference_model_ir(case.model, datatype=case.datatype) - result = manager.run( + result = manager.require_run( pipeline, source, session=inference_synthesis_session_for( @@ -413,7 +417,7 @@ def run_vidur_experiment( ) ) return VidurExperimentReport( - schema="blueprinting.vidur-baseline-experiment.v2", + schema="blueprinting.vidur-baseline-experiment.v0", baseline_revision=baseline.revision, policy={ "baseline_role": "post-hoc-comparison-only", diff --git a/src/blueprinting/workbench/chrome_trace.py b/src/blueprinting/workbench/chrome_trace.py index 143cfac..659435a 100644 --- a/src/blueprinting/workbench/chrome_trace.py +++ b/src/blueprinting/workbench/chrome_trace.py @@ -16,7 +16,7 @@ from .presentation import task_dependency_projection -TRACE_SCHEMA = "blueprinting.chrome-trace.portable-projection.v1" +TRACE_SCHEMA = "blueprinting.chrome-trace.portable-projection.v0" TRACE_KIND = "portable_dependency_projection" diff --git a/src/blueprinting/workbench/evidence_lab.py b/src/blueprinting/workbench/evidence_lab.py index 9104602..31edba8 100644 --- a/src/blueprinting/workbench/evidence_lab.py +++ b/src/blueprinting/workbench/evidence_lab.py @@ -288,9 +288,7 @@ def build(self) -> None: value=self.semantic_operation, label="对比 Primitive", on_change=self._semantic_changed, - ).props("outlined dense").classes("bp-evidence-selector").mark( - "evidence-semantic-operation" - ) + ).props("outlined dense").classes("bp-evidence-selector").mark("evidence-semantic-operation") ui.label("EXACT · NO INTERPOLATION").classes("bp-fidelity-tag bp-mono") if self.error is not None or self.data is None: with ( @@ -362,11 +360,15 @@ def _render_content(self) -> None: with ui.element("section").classes("bp-evidence-surface"): with ui.element("div").classes("bp-evidence-section bp-evidence-compact-head"): ui.label("Measured vs analytical").classes("bp-card-title") - ui.label("散点是 Vidur exact records;连线只帮助阅读,不表示中间点已有证据。 ").classes("bp-card-copy") + ui.label("散点是 Vidur exact records;连线只帮助阅读,不表示中间点已有证据。 ").classes( + "bp-card-copy" + ) with ui.element("div").classes("bp-evidence-chart-grid"): with ui.element("section").classes("bp-evidence-chart"): ui.label("Latency · µs").classes("bp-section-title") - ui.echart(latency_curve_options(report), renderer="svg").classes("w-full bp-evidence-chart-canvas") + ui.echart(latency_curve_options(report), renderer="svg").classes( + "w-full bp-evidence-chart-canvas" + ) with ui.element("section").classes("bp-evidence-chart"): ui.label("Effective throughput · TOPS").classes("bp-section-title") ui.echart(throughput_curve_options(report), renderer="svg").classes( diff --git a/src/blueprinting/workbench/float_analysis.py b/src/blueprinting/workbench/float_analysis.py index 0a270d4..b2bd94f 100644 --- a/src/blueprinting/workbench/float_analysis.py +++ b/src/blueprinting/workbench/float_analysis.py @@ -3,14 +3,12 @@ from __future__ import annotations from dataclasses import dataclass -from math import inf, isnan +from math import inf, isfinite, isnan from typing import Any import numpy as np from nicegui import ui -from blueprinting.fp import float_point_values_table - DEFAULT_FLOAT_FORMATS = { "fp32": (1, 8, 23), "tf32": (1, 8, 10), @@ -42,7 +40,7 @@ def total_bits(self) -> int: @property def bias(self) -> int: - return 2 ** (self.exponent_bits - 1) - 1 + return (1 << (self.exponent_bits - 1)) - 1 @property def min_normal(self) -> float: @@ -129,11 +127,22 @@ def representable_values(spec: FloatFormatSpec) -> list[float]: if spec.exponent_bits + spec.mantissa_bits > 12: raise ValueError("interactive value enumeration is limited to exponent_bits + mantissa_bits <= 12") - return float_point_values_table( - sign_bit=spec.sign_bit, - exponent_bits=spec.exponent_bits, - mantissa_bits=spec.mantissa_bits, - ) + values = [] + signs = (False, True) if spec.sign_bit else (False,) + for negative in signs: + for raw_exponent in range(2**spec.exponent_bits): + exponent = _int_to_bits(raw_exponent, spec.exponent_bits) + for raw_mantissa in range(2**spec.mantissa_bits): + decoded = decode_float_bits( + spec, + negative=negative, + exponent=exponent, + mantissa=_int_to_bits(raw_mantissa, spec.mantissa_bits), + ) + if isfinite(decoded.value): + values.append(decoded.value) + values.sort() + return values def distribution_chart_options(values: list[float], spec: FloatFormatSpec, limit: float) -> dict[str, Any]: @@ -272,9 +281,11 @@ def build(self) -> None: ) with ui.column().classes("bp-numeric-control bp-numeric-control--sign gap-1"): ui.label("符号位 S").classes("bp-summary-label") - self.sign_control = ui.switch( - "启用", value=self.spec.sign_bit, on_change=self._format_changed - ).props("dense").mark("float-sign-bit") + self.sign_control = ( + ui.switch("启用", value=self.spec.sign_bit, on_change=self._format_changed) + .props("dense") + .mark("float-sign-bit") + ) with ui.column().classes("bp-numeric-control bp-numeric-control--range gap-1"): ui.label("观察范围").classes("bp-summary-label") self.range_control = ( @@ -336,9 +347,7 @@ def _render_content(self) -> None: with ui.element("section").classes("bp-evidence-surface bp-numeric-overview"): with ui.element("div").classes("bp-evidence-section bp-numeric-chart-panel"): ui.label("格式位宽对比").classes("bp-card-title") - ui.echart(format_layout_chart_options(self.spec), renderer="svg").classes( - "w-full bp-format-chart" - ) + ui.echart(format_layout_chart_options(self.spec), renderer="svg").classes("w-full bp-format-chart") with ui.element("aside").classes("bp-numeric-spec-panel"): ui.label(self.spec.name).classes("bp-card-title") ui.label("当前格式摘要").classes("bp-card-copy") @@ -478,6 +487,10 @@ def _bits_to_int(bits: tuple[bool, ...]) -> int: return sum(int(bit) << (len(bits) - index - 1) for index, bit in enumerate(bits)) +def _int_to_bits(value: int, width: int) -> tuple[bool, ...]: + return tuple(bool(value & (1 << shift)) for shift in range(width - 1, -1, -1)) + + def _downsample(values: list[float], maximum: int) -> list[float]: if len(values) <= maximum: return values diff --git a/src/blueprinting/workbench/ir_expressions.py b/src/blueprinting/workbench/ir_expressions.py new file mode 100644 index 0000000..bee740f --- /dev/null +++ b/src/blueprinting/workbench/ir_expressions.py @@ -0,0 +1,437 @@ +"""Typed short and detailed text projections for canonical IR and lowering. + +These expressions are rebuildable presentation views. They expose canonical +semantics and pass mechanics without becoming another serialization format. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from typing import Any + +from blueprinting.application import DerivationTransition +from blueprinting.synthesizer.expr import ( + Add, + CeilDivide, + Divide, + Maximum, + Minimum, + Multiply, + Subtract, + Symbol, +) +from blueprinting.synthesizer.stages.common import TensorType +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.distributed.ir import Collective, DistributedTaskIR +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR + + +@dataclass(frozen=True) +class CanonicalIRExpression: + short: str + detailed: str + + +@dataclass(frozen=True) +class LoweringExpression: + short: str + detailed: str + + +def _refs(values: tuple[Any, ...]) -> str: + return "[" + ", ".join(str(item) for item in values) + "]" + + +def _scalar(value: Any) -> str: + if isinstance(value, Symbol): + return f"${value.axis.value}.{value.name}" + match value: + case Add(terms=values): + return "(" + " + ".join(_scalar(item) for item in values) + ")" + case Subtract(left=left, right=right): + return f"({_scalar(left)} - {_scalar(right)})" + case Multiply(factors=values): + return "(" + " * ".join(_scalar(item) for item in values) + ")" + case Divide(numerator=left, denominator=right): + return f"({_scalar(left)} / {_scalar(right)})" + case CeilDivide(numerator=left, denominator=right): + return f"ceil_div({_scalar(left)}, {_scalar(right)})" + case Maximum(values=values): + return f"max({', '.join(_scalar(item) for item in values)})" + case Minimum(values=values): + return f"min({', '.join(_scalar(item) for item in values)})" + case _: + return str(value) + + +def _tensor(value: TensorType) -> str: + shape = "x".join(_scalar(item) for item in value.shape) + layout = "" if value.layout is None else f", layout={value.layout}" + return f"tensor<{shape}x{value.dtype}{layout}>" + + +def _lineage(value: Any) -> str: + sources = _refs(value.sources) + return f"{value.kind.value} via @{value.transform} from {sources}" + + +def _header(ir: Any) -> str: + parents = ", ".join(ir.header.parent_digests) or "none" + return ( + f"schema {ir.header.schema_name}@{ir.header.schema_version}\n" + f"digest {ir.digest}\n" + f"producer {ir.header.producer_version}\n" + f"parents [{parents}]" + ) + + +def _model_expression(ir: ModelIR) -> CanonicalIRExpression: + values = {item.id: item for item in ir.values} + short = [f"model @{ir.name} : {ir.header.schema_name}@{ir.header.schema_version} {{"] + for identifier in ir.inputs: + value = values[identifier] + short.append(f" input %{value.name or value.id} : {_tensor(value.type)}") + for operation in ir.operations: + inputs = ", ".join(f"%{values[item].name or item}" for item in operation.inputs) + outputs = ", ".join(f"%{values[item].name or item}" for item in operation.outputs) + short.append(f" {outputs} = {operation.operation}({inputs})") + for identifier in ir.outputs: + value = values[identifier] + short.append(f" return %{value.name or value.id} : {_tensor(value.type)}") + short.append("}") + + detailed = [_header(ir), "", "values {"] + for value in ir.values: + detailed.append( + f" {value.id} name={value.name!r} role={value.role.value} type={_tensor(value.type)} " + f"lineage=({_lineage(value.lineage)})" + ) + detailed.append("}") + detailed.append("operations {") + for operation in ir.operations: + detailed.append( + f" {operation.id} op={operation.operation} inputs={_refs(operation.inputs)} " + f"outputs={_refs(operation.outputs)} control_deps={_refs(operation.control_dependencies)} " + f"effects={len(operation.effects)} lineage=({_lineage(operation.lineage)})" + ) + detailed.append("}") + detailed.append(f"interface inputs={_refs(ir.inputs)} outputs={_refs(ir.outputs)}") + return CanonicalIRExpression("\n".join(short), "\n".join(detailed)) + + +def _distributed_expression(ir: DistributedTaskIR) -> CanonicalIRExpression: + axes = ", ".join(f"{item.name}={item.size}" for item in ir.mesh.axes) + phase_kinds: Counter[tuple[str, str]] = Counter() + for task in ir.tasks: + invocation = getattr(task.semantic, "invocation", None) + phase = getattr(getattr(invocation, "phase", None), "value", "unscoped") + phase_kinds[(phase, task.body_tag)] += 1 + short = [ + f"distributed @{ir.name} : {ir.header.schema_name}@{ir.header.schema_version} {{", + f" mesh @{ir.mesh.name}<{axes}> size={ir.mesh.size}", + ] + for (phase, kind), count in sorted(phase_kinds.items()): + short.append(f" phase @{phase} {{ {kind} x {count} }}") + short.append(f" interface values={len(ir.values)} inputs={_refs(ir.inputs)} outputs={_refs(ir.outputs)}") + short.append("}") + + detailed = [_header(ir), "", f"mesh @{ir.mesh.name}<{axes}> size={ir.mesh.size}", "values {"] + for value in ir.values: + detailed.append( + f" {value.id} role={value.role.value} type={_tensor(value.type)} owners={_refs(value.owners)} " + f"sharding={value.sharding} lineage=({_lineage(value.lineage)})" + ) + detailed.append("}") + detailed.append("tasks {") + for task in ir.tasks: + match task.body: + case Collective(spec=spec): + body = f"Collective({spec})" + case _: + body = type(task.body).__name__ + detailed.append( + f" {task.id} body={body} op={task.operation} ranks={_refs(task.ranks)} " + f"inputs={_refs(task.inputs)} outputs={_refs(task.outputs)} deps={_refs(task.dependencies)} " + f"lineage=({_lineage(task.lineage)})" + ) + detailed.append("}") + return CanonicalIRExpression("\n".join(short), "\n".join(detailed)) + + +def _portable_expression(ir: PortablePlanIR) -> CanonicalIRExpression: + phase_kinds: Counter[tuple[str, str]] = Counter() + for task in ir.tasks: + phase = getattr(getattr(task.semantic, "phase", None), "value", "unscoped") + phase_kinds[(phase, task.kind.value)] += 1 + objectives = ", ".join(f"{item.kind.value}:{item.direction.value}" for item in ir.objectives) + short = [ + f"portable_plan @{ir.name} : {ir.header.schema_name}@{ir.header.schema_version} {{", + f" strategy {ir.strategy_fingerprint}", + f" planner {ir.planner_revision}", + ] + for (phase, kind), count in sorted(phase_kinds.items()): + short.append(f" phase @{phase} {{ {kind} x {count} }}") + short.append(f" buffers {len(ir.buffers)}; objectives [{objectives}]") + short.append(" target_binding none") + short.append("}") + + detailed = [_header(ir), "", f"strategy {ir.strategy_fingerprint}", f"planner {ir.planner_revision}", "buffers {"] + for buffer in ir.buffers: + detailed.append( + f" {buffer.id} role={buffer.role.value} storage={buffer.storage_class.value} " + f"size_bytes={_scalar(buffer.size_bytes)} alignment={buffer.alignment_bytes} producer={buffer.producer} " + f"consumers={_refs(buffer.consumers)} lineage=({_lineage(buffer.lineage)})" + ) + detailed.append("}") + detailed.append("tasks {") + for task in ir.tasks: + workload = task.workload + resources = ", ".join( + f"{item.kind.value}:{_scalar(item.quantity)}/{item.scope.value}" for item in task.resources + ) + implementations = ", ".join( + f"{item.capability}[{', '.join(item.alternatives)}]" for item in task.implementations + ) + detailed.append( + f" {task.id} kind={task.kind.value} op={task.operation} ranks={_refs(task.logical_ranks)} " + f"deps={_refs(task.dependencies)} io={_refs(task.inputs)}->{_refs(task.outputs)} " + f"workload=(ops={_scalar(workload.operations)}, read={_scalar(workload.read_bytes)}, " + f"write={_scalar(workload.write_bytes)}, message={_scalar(workload.message_bytes)}, " + f"temp={_scalar(workload.temporary_bytes)}, persistent={_scalar(workload.persistent_bytes)}) " + f"resources=[{resources}] implementations=[{implementations}] concurrency={task.concurrency_group} " + f"lineage=({_lineage(task.lineage)})" + ) + detailed.append("}") + detailed.append(f"objectives [{objectives}]") + return CanonicalIRExpression("\n".join(short), "\n".join(detailed)) + + +def _concrete_expression(ir: ConcretePlanIR) -> CanonicalIRExpression: + command_kinds = Counter(item.kind.value for item in ir.commands) + short = [ + f"concrete_plan @{ir.name} : {ir.header.schema_name}@{ir.header.schema_version} {{", + f" target {ir.target_fingerprint}; deployment {ir.deployment_fingerprint}", + f" devices {len(ir.devices)}; queues {len(ir.queues)}; regions {len(ir.memory_regions)}", + " commands " + ", ".join(f"{kind} x {count}" for kind, count in sorted(command_kinds.items())), + " predicted_time not-canonical", + "}", + ] + detailed = [ + _header(ir), + "", + f"target={ir.target_fingerprint} deployment={ir.deployment_fingerprint} abi={ir.abi_revision}", + "devices {", + ] + detailed.extend(f" {item.id} rank={item.logical_rank} target={item.target_device}" for item in ir.devices) + detailed.append("}") + detailed.append("queues {") + detailed.extend( + f" {item.id} device={item.device} kind={item.kind.value} engine={item.engine} ordered={item.ordered}" + for item in ir.queues + ) + detailed.append("}") + detailed.append("commands {") + for item in ir.commands: + detailed.append( + f" {item.id} kind={item.kind.value} queue={item.queue} implementation={item.implementation} " + f"deps={_refs(item.dependencies)} buffers={item.buffers} wait={_refs(item.wait_tokens)} " + f"signal={_refs(item.signal_tokens)} lineage=({_lineage(item.lineage)})" + ) + detailed.append("}") + return CanonicalIRExpression("\n".join(short), "\n".join(detailed)) + + +def _machine_expression(ir: MachineIR) -> CanonicalIRExpression: + short = [ + f"machine_program @{ir.name} : {ir.header.schema_name}@{ir.header.schema_version} {{", + f" target_plugin {ir.target_plugin}; abi {ir.target_abi}; format {ir.program_format}", + ] + short.extend( + f" section @{section.name} kind={section.kind.value} instructions={len(section.instructions)} " + f"data_bytes={len(section.data)}" + for section in ir.sections + ) + short.append(" entry_points [" + ", ".join(item.name for item in ir.entry_points) + "]") + short.append("}") + detailed = [ + _header(ir), + "", + f"plugin={ir.target_plugin} abi={ir.target_abi} emitter={ir.emitter_revision} format={ir.program_format}", + ] + for section in ir.sections: + detailed.append( + f"section @{section.name} kind={section.kind.value} alignment={section.alignment_bytes} " + f"data_bytes={len(section.data)} {{" + ) + for item in section.instructions: + detailed.append( + f" {item.id} opcode={item.opcode} deps={_refs(item.dependencies)} operands={dict(item.operands)} " + f"source_command={item.source_command} lineage=({_lineage(item.lineage)})" + ) + detailed.append("}") + detailed.append( + "entry_points { " + ", ".join(f"@{item.name} -> {item.instruction}" for item in ir.entry_points) + " }" + ) + return CanonicalIRExpression("\n".join(short), "\n".join(detailed)) + + +def canonical_ir_expression(ir: Any) -> CanonicalIRExpression: + if isinstance(ir, ModelIR): + return _model_expression(ir) + if isinstance(ir, DistributedTaskIR): + return _distributed_expression(ir) + if isinstance(ir, PortablePlanIR): + return _portable_expression(ir) + if isinstance(ir, ConcretePlanIR): + return _concrete_expression(ir) + if isinstance(ir, MachineIR): + return _machine_expression(ir) + raise TypeError(f"unsupported canonical IR type: {type(ir).__name__}") + + +def lowering_expression(transition: DerivationTransition, rows: list[dict[str, Any]]) -> LoweringExpression: + contract = transition.contract + rule_by_transform = {item.transform: item for item in contract.rules} + transforms = [item.transform for item in contract.rules] + for row in rows: + if row["transform"] not in transforms: + transforms.append(row["transform"]) + short = [ + f"pass @{contract.name} {{", + f" input {contract.input_schema}", + f" output {contract.output_schema}", + f" requires bindings [{', '.join(contract.required_bindings) or 'none'}]", + f" requires analyses [{', '.join(contract.required_analyses) or 'none'}]", + f" produces analyses [{', '.join(contract.produced_analyses) or 'none'}]", + f" preserves analyses [{', '.join(contract.preserved_analyses) or 'none'}]", + f" transaction {contract.mutation_model}; verify={contract.verification}; " + f"deterministic={str(contract.deterministic).lower()}; seed={str(contract.uses_session_seed).lower()}", + f" transition {transition.verification_status}; relations={transition.verified_relations}; " + f"claims={transition.verified_claims}; canonical={str(transition.canonical_conformance is not None).lower()}", + f" normal_form @{contract.normal_form or 'none'}", + " rules {", + ] + for transform in transforms: + rule = rule_by_transform.get(transform) + if rule is None: + short.append(f" @{transform}: ") + else: + short.append( + f" @{transform}: {rule.source_entity} -> {rule.target_entity}; {rule.rewrite}; " + f"invariant=@{rule.semantic_invariant or 'none'}" + ) + short.extend((" }", "}")) + + detailed = [ + f"pass @{contract.name}", + f"input_schema {contract.input_schema}", + f"output_schema {contract.output_schema}", + f"source_digest {transition.source_digest}", + f"target_digest {transition.target_digest}", + f"contract_digest {contract.contract_digest or 'unavailable'}", + f"normal_form {contract.normal_form or 'none'}", + f"required_bindings [{', '.join(contract.required_bindings) or 'none'}]", + f"required_analyses [{', '.join(contract.required_analyses) or 'none'}]", + f"produced_analyses [{', '.join(contract.produced_analyses) or 'none'}]", + f"preserved_analyses [{', '.join(contract.preserved_analyses) or 'none'}]", + f"mutation_model {contract.mutation_model}", + f"verification {contract.verification}", + f"deterministic {str(contract.deterministic).lower()}", + f"uses_session_seed {str(contract.uses_session_seed).lower()}", + f"transition_status {transition.verification_status}", + f"canonical_conformance {transition.canonical_conformance or 'none'}", + f"verified_relations {transition.verified_relations}", + f"verified_claims {transition.verified_claims}", + "commit_gate verify(input) -> run immutable transformation -> verify(output) -> verify(lineage) -> " + "re-evaluate(normal_form) -> verify(relation invariants) -> canonical digest -> " + "checkpoint observers -> analysis commit", + "analysis_invalidation all non-preserved analyses", + "", + "rules {", + ] + for transform in transforms: + transform_rows = [item for item in rows if item["transform"] == transform] + rule = rule_by_transform.get(transform) + detailed.append(f" @{transform} {{") + if rule is not None: + detailed.extend( + ( + f" signature {rule.source_entity} -> {rule.target_entity}", + f" rewrite {rule.rewrite}", + f" invariant @{rule.semantic_invariant or 'none'}", + f" preserves [{', '.join(rule.preserves) or 'none'}]", + f" introduces [{', '.join(rule.introduces) or 'none'}]", + f" forbids [{', '.join(rule.forbids) or 'none'}]", + ) + ) + else: + detailed.append(" contract undeclared; the rows below are lineage evidence, not a semantic pass rule") + detailed.append( + f" evidence groups={len(transform_rows)} canonical_relations=" + f"{sum(item['relation_count'] for item in transform_rows)}" + ) + for item in transform_rows: + detailed.append( + f" {item['source']} [{item['source_kind']}] -> {item['target']} " + f"[{item['target_kind']}] targets={item['target_count']} lineage={item['lineage_kind']}" + ) + detailed.append(" }") + detailed.append("}") + return LoweringExpression("\n".join(short), "\n".join(detailed)) + + +def lowering_correspondence_rows( + transition: DerivationTransition, + rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Attach pass-owned rule identity and short semantics to lineage evidence rows.""" + + contract = transition.contract + rule_by_transform = {item.transform: item for item in contract.rules} + result = [] + for row in rows: + rule = rule_by_transform.get(row["transform"]) + qualified_rule = f"{contract.name}.{row['transform']}" + if rule is None: + signature = "" + rewrite = "No PassRule declaration; correspondence is evidence only" + else: + signature = f"{rule.source_entity} ⇒ {rule.target_entity}" + rewrite = rule.rewrite + expression = "\n".join( + ( + f"{qualified_rule}(relation={row['mapping_kind']}, cardinality={row['mapping']})", + signature, + rewrite, + ) + ) + result.append( + { + **row, + "qualified_rule": qualified_rule, + "pass_type": "Pass", + "pass_tone": "slate", + "pass_expression_name": qualified_rule, + "pass_expression_parameters": ( + {"name": "relation", "value": row["mapping_kind"], "category": "mapping"}, + {"name": "cardinality", "value": row["mapping"], "category": "mapping"}, + ), + "rule_signature": signature, + "rule_rewrite": rewrite, + "transform_expression": expression, + "rule_declared": rule is not None, + } + ) + return result + + +__all__ = [ + "CanonicalIRExpression", + "LoweringExpression", + "canonical_ir_expression", + "lowering_correspondence_rows", + "lowering_expression", +] diff --git a/src/blueprinting/workbench/nicegui_app.py b/src/blueprinting/workbench/nicegui_app.py index 337a6aa..38b2cbb 100644 --- a/src/blueprinting/workbench/nicegui_app.py +++ b/src/blueprinting/workbench/nicegui_app.py @@ -27,7 +27,7 @@ def run_workbench( show: bool = True, reload: bool = False, ) -> None: - """Run the primary Blueprinting UI; the legacy Streamlit app stays separate.""" + """Run the Blueprinting NiceGUI workbench.""" ui.run( workbench_root, diff --git a/src/blueprinting/workbench/nicegui_theme.py b/src/blueprinting/workbench/nicegui_theme.py index bdfc1a4..ea75893 100644 --- a/src/blueprinting/workbench/nicegui_theme.py +++ b/src/blueprinting/workbench/nicegui_theme.py @@ -341,13 +341,6 @@ background: #22c55e; } -.bp-sidebar-legacy { - min-height: 34px; - padding: 0 6px !important; - color: var(--bp-sidebar-muted) !important; - font-size: 11px; -} - .bp-main { width: 100%; max-width: 1600px; @@ -1418,6 +1411,129 @@ --ag-font-size: 12px; } +.bp-lowering-table .bp-entity-cell { + --entity-color: #64748b; + --entity-tint: #f8fafc; + align-items: center; + background: transparent; +} + +.bp-lowering-table .bp-entity-cell--blue { + --entity-color: #2563eb; + --entity-tint: #eff6ff; +} + +.bp-lowering-table .bp-entity-cell--green { + --entity-color: #15803d; + --entity-tint: #f0fdf4; +} + +.bp-lowering-table .bp-entity-cell--violet { + --entity-color: #7c3aed; + --entity-tint: #f5f3ff; +} + +.bp-lowering-table .bp-entity-cell--amber { + --entity-color: #b45309; + --entity-tint: #fffbeb; +} + +.bp-lowering-table .bp-entity-cell--cyan { + --entity-color: #0e7490; + --entity-tint: #ecfeff; +} + +.bp-lowering-table .bp-pass-cell { + --entity-color: #4f46e5; + --entity-tint: #eef2ff; +} + +.bp-semantic-expression { + display: block; + width: 100%; + padding: 5px 0; + line-height: 1.45; +} + +.bp-expression-stack { + display: block; + width: 100%; + min-width: 0; +} + +.bp-expression { + display: inline; + max-width: 100%; + padding: 3px 4px; + color: var(--entity-color); + border-radius: 6px; + background: color-mix(in srgb, var(--entity-color) 7%, white); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; + font: 650 11px/2.15 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.bp-expression-component { + display: inline; + padding: 2px 3px; + border-radius: 4px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.bp-expression-component--name { + color: var(--entity-color); + background: color-mix(in srgb, var(--entity-color) 18%, white); + font-weight: 750; +} + +.bp-expression-component--structure { + color: #6d28d9; + background: #ede9fe; +} + +.bp-expression-component--type { + color: #1d4ed8; + background: #dbeafe; +} + +.bp-expression-component--topology { + color: #0e7490; + background: #cffafe; +} + +.bp-expression-component--workload { + color: #047857; + background: #d1fae5; +} + +.bp-expression-component--mapping { + color: #a16207; + background: #fef3c7; +} + +.bp-expression-component--property { + color: #475569; + background: #e2e8f0; +} + +.bp-expression-component--punctuation { + color: var(--entity-color); + background: color-mix(in srgb, var(--entity-color) 10%, white); +} + +.bp-expression-detail { + max-width: 100%; + margin-top: 4px; + color: #64748b; + overflow-wrap: anywhere; + font-size: 10px; + line-height: 1.4; +} + .bp-loading-panel { min-height: 400px; display: grid; diff --git a/src/blueprinting/workbench/nicegui_ui.py b/src/blueprinting/workbench/nicegui_ui.py index ad64a76..28d46d2 100644 --- a/src/blueprinting/workbench/nicegui_ui.py +++ b/src/blueprinting/workbench/nicegui_ui.py @@ -17,28 +17,40 @@ from blueprinting.analysis import CalibrationMode from blueprinting.application import ( + CANONICAL_STAGE_ORDER, AnalysisDiagnostic, AnalysisDraft, AnalysisOutcome, BlueprintingService, + CanonicalIRStage, + DerivationDebugBundleCodec, + DerivationTrace, DiagnosticLevel, + IRGraphView, SweepReport, SweepRequest, ) +from blueprinting.workload import TRANSFORMER_DATA_TYPES from .catalog import ConfigCatalog, default_catalog from .chrome_trace import perfetto_open_javascript, portable_projection_trace_json from .evidence_lab import EvidenceLabPanel from .float_analysis import FloatAnalysisPanel +from .ir_expressions import canonical_ir_expression, lowering_correspondence_rows, lowering_expression from .nicegui_theme import METRIC_COLORS, WORKBENCH_CSS from .presentation import ( analysis_metrics, + boundary_rows, dependency_timeline_chart_options, format_bytes, format_count, format_seconds, + ir_graph_chart_options, + ir_stage_narrative, latency_chart_options, + lowering_narrative, memory_chart_options, + semantic_boundary_rows, sweep_chart_options, sweep_distribution_chart_options, sweep_rows, @@ -48,7 +60,6 @@ timeline_summary, ) -_COMPILER_DTYPES = ("float16", "bfloat16", "float32", "float8") _PARALLEL_OPTIONS = (1, 2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128) _CALIBRATION_LABELS = { "系统证据曲线": CalibrationMode.SYSTEM_EVIDENCE, @@ -91,6 +102,42 @@ def _strip_json_suffix(name: str) -> str: return name.removesuffix(".json") +def _segmented_expression_renderer( + name_field: str, + parameters_field: str, + *detail_fields: str, +) -> str: + detail_values = ", ".join(f"params.data.{field}" for field in detail_fields) + detail_script = ( + f"const details = [{detail_values}].filter(Boolean);" + "if (details.length) { const detail = document.createElement('div');" + "detail.className = 'bp-expression-detail'; detail.textContent = details.join(' · ');" + "stack.appendChild(detail); }" + if detail_fields + else "" + ) + return ( + "(params) => {" + "const root = document.createElement('div'); root.className = 'bp-semantic-expression';" + "const stack = document.createElement('div'); stack.className = 'bp-expression-stack';" + "const expression = document.createElement('span'); expression.className = 'bp-expression';" + f"const tokens = params.data.{parameters_field} || [];" + "const appendComponent = (text, category) => {" + "const component = document.createElement('span');" + "component.className = 'bp-expression-component bp-expression-component--' + category;" + "component.textContent = text; expression.appendChild(component); };" + f"appendComponent(params.data.{name_field} + (tokens.length ? '(' : ''), 'name');" + "tokens.forEach((token, index) => appendComponent(" + "token.name + '=' + token.value + (index + 1 < tokens.length ? ',' : '')," + "token.category || 'property'));" + "if (tokens.length) appendComponent(')', 'punctuation');" + "stack.appendChild(expression);" + f"{detail_script}" + "root.appendChild(stack); return root;" + "}" + ) + + class ConfigurationPanel: """One movable configuration surface shared by both workbench modes.""" @@ -308,7 +355,7 @@ def _supported_datatypes(self, hardware_name: str) -> tuple[str, ...]: hardware = self.catalog.load("systems", hardware_name) matrix = set(hardware.get("matrix", {})) vector = set(hardware.get("vector", {})) - supported = tuple(item for item in _COMPILER_DTYPES if item in matrix and item in vector) + supported = tuple(item for item in TRANSFORMER_DATA_TYPES if item in matrix and item in vector) if not supported: raise ValueError(f"硬件预设 {hardware_name} 没有同时定义 matrix/vector datatype") return supported @@ -551,14 +598,17 @@ def draft(self) -> AnalysisDraft: ) def sweep_request(self) -> SweepRequest: - candidates = { - "tensor_parallel": tuple(int(value) for value in (self.tp_candidates.value or ())), - "pipeline_parallel": tuple(int(value) for value in (self.pp_candidates.value or ())), - "data_parallel": tuple(int(value) for value in (self.dp_candidates.value or ())), - } - if any(not values for values in candidates.values()): + tensor_parallel = tuple(int(value) for value in (self.tp_candidates.value or ())) + pipeline_parallel = tuple(int(value) for value in (self.pp_candidates.value or ())) + data_parallel = tuple(int(value) for value in (self.dp_candidates.value or ())) + if not tensor_parallel or not pipeline_parallel or not data_parallel: raise ValueError("TP、PP、DP 候选集合不能为空") - return SweepRequest(base=self.draft(), **candidates) + return SweepRequest( + base=self.draft(), + tensor_parallel=tensor_parallel, + pipeline_parallel=pipeline_parallel, + data_parallel=data_parallel, + ) def set_busy(self, busy: bool) -> None: for control in self._controls: @@ -612,6 +662,15 @@ def __init__( self.batch_grid: Any | None = None self.evidence_panel: EvidenceLabPanel | None = None self.float_panel: FloatAnalysisPanel | None = None + self.imported_derivation_trace: DerivationTrace | None = None + self.ir_selected_stage = CanonicalIRStage.MODEL + self.ir_selected_branch = "training" + self.ir_selected_entity = "" + self.ir_group_filter = "all" + self.ir_search = "" + self.ir_overlay_enabled = False + self.ir_explorer_host: Any | None = None + self.ir_entity_host: Any | None = None def build(self) -> None: ui.add_css(WORKBENCH_CSS) @@ -749,6 +808,8 @@ def _ensure_form(self, host: Any) -> None: self.form = ConfigurationPanel(self.catalog, on_change=self._configuration_changed) self.form.build() else: + if self.form.root is None: + raise RuntimeError("configuration form was not built") self.form.root.move(host) self.form.set_mode(self.mode) self._render_sidebar_controls() @@ -917,6 +978,14 @@ def _quick_calibration_changed(self, event: Any) -> None: def _sync_sidebar_controls(self) -> None: if self.form is None or self.quick_model is None: return + if ( + self.quick_hardware is None + or self.quick_tp is None + or self.quick_pp is None + or self.quick_dp is None + or self.quick_calibration is None + ): + raise RuntimeError("sidebar controls are incomplete") self._syncing_quick_controls = True try: self.quick_model.set_value(str(self.form.model_preset.value)) @@ -1202,6 +1271,8 @@ def _open_configuration(self) -> None: else: self.dialog_title.set_text("CaseSet 定义") self.dialog_copy.set_text("编辑共享基线、候选范围与批量评估证据。") + if self.form.root is None: + raise RuntimeError("configuration form was not built") self.form.root.move(self.drawer_form_host) self.form.set_mode(self.mode) self._render_drawer_footer() @@ -1267,6 +1338,12 @@ async def run_analysis(self) -> None: if outcome is None: raise RuntimeError("分析服务没有返回结果") self.analysis_outcome = outcome + self.imported_derivation_trace = None + self.ir_selected_stage = CanonicalIRStage.MODEL + self.ir_selected_branch = "training" + self.ir_selected_entity = "" + self.ir_group_filter = "all" + self.ir_search = "" self.analysis_stale = False except Exception as error: # pragma: no cover - NiceGUI safety boundary self.local_error = f"未预期的界面错误:{error}" @@ -1424,7 +1501,7 @@ def _render_analysis_result(self) -> None: ): conclusion_tab = ui.tab("conclusion", "时间剖析").mark("tab-conclusion") workload_tab = ui.tab("workload", "任务与工作量").mark("tab-workload") - derivation_tab = ui.tab("derivation", "技术审计").mark("tab-derivation") + derivation_tab = ui.tab("derivation", "IR Explorer").mark("tab-derivation") with ui.tab_panels(tabs, value=conclusion_tab, animated=False, keep_alive=True).classes( "bp-result-panels w-full" ): @@ -1681,58 +1758,520 @@ def _render_workload(self, outcome: AnalysisOutcome) -> None: def _render_derivation(self, outcome: AnalysisOutcome) -> None: report = outcome.report assert report is not None - evidence_surface = ui.element("section").classes("bp-evidence-surface") - with evidence_surface, ui.element("div").classes("bp-evidence-section"): - ui.label("Canonical derivation checkpoints").classes("bp-kicker") - ui.label("每一层结果在提交前经过 verifier,并保留父 digest 与不可变快照。 ").classes("bp-card-copy") - with ui.element("div").classes("bp-stage-flow mt-3"): - for index, stage in enumerate(report.stages): - with ui.element("div").classes("bp-stage"): - with ui.row().classes("w-full items-center justify-between"): - ui.label(f"0{index + 1}").classes("bp-kicker bp-mono") - ui.icon( - "verified" if stage.valid else "error", - color="positive" if stage.valid else "negative", - size="18px", - ) - ui.label(stage.label).classes("bp-card-title mt-2") - ui.label(stage.schema).classes("bp-card-copy bp-mono") - ui.label(f"{stage.node_count:,} nodes · {stage.value_count:,} values/buffers").classes( - "bp-card-copy mt-2" - ) - ui.label(format_seconds(stage.duration_ns / 1e9)).classes("bp-card-copy bp-mono text-secondary") + self.ir_explorer_host = ui.column().classes("w-full") + self._render_ir_explorer() - with evidence_surface, ui.element("div").classes("bp-evidence-section bp-derivation-details"): - for stage in report.stages: - with ( - ui.expansion( - stage.label, - caption=f"{stage.pass_name} · {stage.digest[:16]}", - icon="verified" if stage.valid else "error", - value=False, - ).classes("bp-card w-full"), - ui.column().classes("w-full gap-3 pt-2"), - ): - with ui.row().classes("gap-2"): - for value in ( - stage.schema, - f"{stage.node_count} nodes", - f"{stage.value_count} values", - format_seconds(stage.duration_ns / 1e9), - ): - ui.label(value).classes("bp-data-chip bp-mono") - if stage.parent_digests: - ui.label(f"Parents · {' · '.join(stage.parent_digests)}").classes( - "bp-card-copy bp-mono break-all" - ) - self._render_diagnostics(stage.diagnostics) - pretty_snapshot = json.dumps(json.loads(stage.snapshot_json), ensure_ascii=False, indent=2) - ui.code(pretty_snapshot, language="json").classes("bp-code") + def _active_derivation_trace(self) -> DerivationTrace | None: + if self.imported_derivation_trace is not None: + return self.imported_derivation_trace + outcome = self.analysis_outcome + return outcome.report.derivation_trace if outcome is not None and outcome.report is not None else None + + def _render_ir_explorer(self) -> None: + if self.ir_explorer_host is None: + return + self.ir_explorer_host.clear() + trace = self._active_derivation_trace() + if trace is None: + return + if self.ir_selected_branch not in trace.branches: + self.ir_selected_branch = trace.branches[0] + if trace.stage_for(self.ir_selected_stage, self.ir_selected_branch) is None: + available = next( + (item.stage for item in trace.stages if item.branch == self.ir_selected_branch), + CanonicalIRStage.MODEL, + ) + self.ir_selected_stage = available + with self.ir_explorer_host, ui.element("section").classes("bp-evidence-surface"): + with ui.element("div").classes("bp-evidence-section"): + with ui.row().classes("w-full items-start gap-3"): + with ui.column().classes("gap-0"): + ui.label("Canonical derivation checkpoints").classes("bp-kicker") + ui.label("IR Explorer").classes("bp-result-title") + ui.label( + "逐层检查 canonical 结构,并沿 typed lineage 回放相邻 lowering;审计诊断不会改变 pass 成败。" + ).classes("bp-card-copy") + ui.space() + if self.imported_derivation_trace is not None: + ui.label("IMPORTED TRACE").classes("bp-fidelity-tag bp-mono") + if len(trace.branches) > 1: + ui.select( + list(trace.branches), + label="Derivation branch", + value=self.ir_selected_branch, + on_change=self._set_ir_branch, + ).props("outlined dense options-dense").classes("min-w-52").mark("ir-branch-select") ui.button( - "下载 canonical snapshot", + "导出调试包", icon="download", - on_click=partial(self._download_snapshot, stage.stage, stage.digest, pretty_snapshot), - ).props("outline dense no-caps").classes("bp-secondary-action") + on_click=partial(self._download_derivation_bundle, trace), + ).props("outline dense no-caps").classes("bp-secondary-action").mark("download-derivation-bundle") + ui.upload( + label="导入调试包", + on_upload=self._import_derivation_bundle, + auto_upload=True, + max_file_size=DerivationDebugBundleCodec.MAX_BYTES, + ).props("accept=.json flat dense").classes("bp-secondary-action").mark("upload-derivation-bundle") + + with ui.row().classes("w-full gap-2 mt-3"): + for index, stage_kind in enumerate(CANONICAL_STAGE_ORDER, start=1): + stage = trace.stage_for(stage_kind, self.ir_selected_branch) + active = stage_kind is self.ir_selected_stage + stage_copy = { + CanonicalIRStage.MODEL: "模型语义", + CanonicalIRStage.DISTRIBUTED: "分布式任务", + CanonicalIRStage.PORTABLE: "可移植计划", + CanonicalIRStage.CONCRETE: "具体执行计划", + CanonicalIRStage.MACHINE: "目标机器程序", + }[stage_kind] + button = ui.button( + f"0{index} · {self._ir_stage_label(stage_kind)} · {stage_copy}", + icon="verified" if stage is not None else "hourglass_empty", + on_click=partial(self._select_ir_stage, stage_kind), + ).props("dense no-caps" if active else "outline dense no-caps") + button.mark(f"ir-stage-{stage_kind.value}") + button.classes("bp-primary-action" if active else "bp-secondary-action") + if stage is None: + button.disable() + + stage = trace.stage_for(self.ir_selected_stage, self.ir_selected_branch) + if stage is None: + return + graph = trace.graph(self.ir_selected_stage, self.ir_selected_branch) + assert graph is not None + overlays = tuple(item for item in trace.overlays if item.stage_digest == stage.digest) + overlay = overlays[0] if self.ir_overlay_enabled and overlays else None + filtered_graph = self._filtered_ir_graph(graph) + narrative = ir_stage_narrative(stage.ir) + expression = canonical_ir_expression(stage.ir) + + with ui.element("div").classes("bp-evidence-section"): + with ui.row().classes("w-full items-center gap-2"): + ui.label(stage.label).classes("bp-card-title") + ui.label(stage.schema).classes("bp-data-chip bp-mono") + ui.label(f"{len(graph.nodes):,} entities · {len(graph.edges):,} edges").classes( + "bp-data-chip bp-mono" + ) + ui.label(format_seconds(stage.duration_ns / 1e9)).classes("bp-data-chip bp-mono") + ui.space() + if overlays: + ui.switch( + "Derived cost overlay", + value=self.ir_overlay_enabled, + on_change=self._toggle_ir_overlay, + ).mark("ir-overlay-toggle") + if overlay is not None: + ui.label(f"DERIVED · {overlay.provider} · {overlay.revision}").classes("bp-fidelity-tag bp-mono") + with ui.row().classes("w-full items-stretch gap-0 mt-3"): + with ui.element("section").classes("bp-evidence-block grow basis-0"): + ui.label("本层回答").classes("bp-kicker") + ui.label(narrative.question).classes("bp-card-title mt-1") + ui.label(narrative.answer).classes("bp-card-copy mt-1") + with ui.element("section").classes("bp-evidence-block grow basis-0"): + ui.label("本次结果").classes("bp-kicker") + ui.label(narrative.result).classes("bp-card-title mt-1") + ui.label("这是 verified canonical snapshot 的结构事实。 ").classes("bp-card-copy mt-1") + with ui.element("section").classes("bp-evidence-block grow basis-0"): + ui.label("边界与下一步").classes("bp-kicker") + ui.label(narrative.excludes).classes("bp-card-title mt-1") + ui.label(narrative.next_step).classes("bp-card-copy mt-1") + ui.label("Canonical IR 表达").classes("bp-card-title mt-3") + with ui.tabs().props("dense no-caps align=left").classes("w-full") as expression_tabs: + short_tab = ui.tab("Short · 语义骨架").mark("ir-expression-short-tab") + detailed_tab = ui.tab("Detailed · typed entities").mark("ir-expression-detailed-tab") + with ui.tab_panels(expression_tabs, value=short_tab, animated=False).classes("w-full bg-transparent"): + with ui.tab_panel(short_tab).classes("px-0 py-2"): + ui.code(expression.short, language="text").classes("bp-code").style( + "max-height: 340px; overflow: auto" + ).mark("ir-short-expression") + with ui.tab_panel(detailed_tab).classes("px-0 py-2"): + ui.code(expression.detailed, language="text").classes("bp-code").style( + "max-height: 520px; overflow: auto" + ).mark("ir-detailed-expression") + structure_title = { + CanonicalIRStage.MODEL: "模型数据流结构", + CanonicalIRStage.DISTRIBUTED: "逻辑任务结构", + CanonicalIRStage.PORTABLE: "目标无关计划结构", + CanonicalIRStage.CONCRETE: "物理执行结构", + CanonicalIRStage.MACHINE: "机器程序结构", + }[stage.stage] + ui.label(structure_title).classes("bp-card-title mt-3") + ui.label( + "列表示 phase,行固定为 subsystem × entity kind;空白单元表示该阶段没有对应实体。" + "依赖线默认淡化,悬停节点时只强调相关结构依赖。" + ).classes("bp-card-copy") + with ui.row().classes("w-full items-center gap-2"): + groups = ("all",) + tuple(sorted({node.group for node in graph.nodes})) + ui.select( + list(groups), + label="Semantic group", + value=self.ir_group_filter if self.ir_group_filter in groups else "all", + on_change=self._set_ir_group, + ).props("outlined dense options-dense").classes("min-w-64").mark("ir-group-filter") + ui.input( + "搜索 ID / label / property", + value=self.ir_search, + on_change=self._set_ir_search, + ).props("outlined dense clearable debounce=300").classes("grow").mark("ir-search") + chart = ( + ui.echart( + ir_graph_chart_options( + filtered_graph, + overlay, + max_nodes=80 if self.ir_group_filter != "all" or self.ir_search else 20, + ), + renderer="canvas", + ) + .classes("w-full bp-ir-graph") + .style("height: 300px" if len(filtered_graph.nodes) <= 10 else "height: 420px") + .mark("ir-layer-graph") + ) + chart.on( + "click", + self._select_ir_entity_event, + js_handler="(params) => emit(params.data && params.data.entity_id ? params.data.entity_id : '')", + ) + self.ir_entity_host = ui.column().classes("w-full") + self._render_ir_entity_inspector(graph, overlay) + + with ui.element("div").classes("bp-evidence-section"): + ui.label("相邻 lowering 边界").classes("bp-card-title") + ui.label("Source 与 target 分栏显示;映射只来自目标实体的 typed Lineage.sources。 ").classes( + "bp-card-copy" + ) + related = [ + item + for item in trace.transitions + if item.source_digest == stage.digest or item.target_digest == stage.digest + ] + if not related: + ui.label("当前层没有已捕获的相邻 lowering。 ").classes("bp-card-copy") + for transition in related: + self._render_ir_boundary(transition, trace) + + with ( + ui.element("div").classes("bp-evidence-section"), + ui.expansion("Canonical JSON 与 stage diagnostics", icon="data_object", value=False).classes( + "bp-card w-full" + ), + ui.column().classes("w-full gap-3 pt-2"), + ): + for diagnostic in stage.diagnostics: + with ui.row().classes("items-start gap-2 no-wrap"): + ui.icon("error" if diagnostic.level == "error" else "info", size="16px") + ui.label(f"{diagnostic.code} · {diagnostic.message}").classes("bp-card-copy") + pretty_snapshot = json.dumps(json.loads(stage.snapshot_json), ensure_ascii=False, indent=2) + ui.code(pretty_snapshot, language="json").classes("bp-code") + ui.button( + "下载 canonical snapshot", + icon="download", + on_click=partial( + self._download_snapshot, + stage.stage.value, + stage.digest, + pretty_snapshot, + ), + ).props("outline dense no-caps").classes("bp-secondary-action") + + @staticmethod + def _ir_stage_label(stage: CanonicalIRStage) -> str: + return { + CanonicalIRStage.MODEL: "ModelIR", + CanonicalIRStage.DISTRIBUTED: "DistributedTaskIR", + CanonicalIRStage.PORTABLE: "PortablePlanIR", + CanonicalIRStage.CONCRETE: "ConcretePlanIR", + CanonicalIRStage.MACHINE: "MachineIR", + }[stage] + + def _select_ir_stage(self, stage: CanonicalIRStage) -> None: + self.ir_selected_stage = stage + self.ir_selected_entity = "" + self.ir_group_filter = "all" + self.ir_search = "" + self._render_ir_explorer() + + def _set_ir_branch(self, event: Any) -> None: + self.ir_selected_branch = str(event.value) + self.ir_selected_stage = CanonicalIRStage.MODEL + self.ir_selected_entity = "" + self.ir_group_filter = "all" + self.ir_search = "" + self._render_ir_explorer() + + def _set_ir_group(self, event: Any) -> None: + self.ir_group_filter = str(event.value or "all") + self.ir_selected_entity = "" + self._render_ir_explorer() + + def _set_ir_search(self, event: Any) -> None: + self.ir_search = str(event.value or "") + self.ir_selected_entity = "" + self._render_ir_explorer() + + def _toggle_ir_overlay(self, event: Any) -> None: + self.ir_overlay_enabled = bool(event.value) + self._render_ir_explorer() + + def _filtered_ir_graph(self, graph: IRGraphView) -> IRGraphView: + nodes = list(graph.nodes) + if self.ir_group_filter != "all": + nodes = [item for item in nodes if item.group == self.ir_group_filter] + query = self.ir_search.strip().lower() + if query: + matched = { + item.ref.key + for item in nodes + if query in item.ref.entity_id.lower() + or query in item.label.lower() + or any(query in key.lower() or query in value.lower() for key, value in item.properties) + } + adjacent = set(matched) + for edge in graph.edges: + if edge.source.key in matched or edge.target.key in matched: + adjacent.update((edge.source.key, edge.target.key)) + nodes = [item for item in nodes if item.ref.key in adjacent] + nodes = nodes[:1000] + node_keys = {item.ref.key for item in nodes} + edges = tuple(item for item in graph.edges if item.source.key in node_keys and item.target.key in node_keys) + return IRGraphView(graph.stage, graph.snapshot_digest, tuple(nodes), edges) + + def _select_ir_entity_event(self, event: Any) -> None: + self.ir_selected_entity = str(getattr(event, "args", "") or "") + trace = self._active_derivation_trace() + graph = trace.graph(self.ir_selected_stage, self.ir_selected_branch) if trace is not None else None + overlay = None + if trace is not None and self.ir_overlay_enabled and graph is not None: + overlay = next((item for item in trace.overlays if item.stage_digest == graph.snapshot_digest), None) + if graph is not None: + self._render_ir_entity_inspector(graph, overlay) + + def _render_ir_entity_inspector(self, graph: IRGraphView, overlay: Any) -> None: + if self.ir_entity_host is None: + return + self.ir_entity_host.clear() + with self.ir_entity_host: + node = graph.node(self.ir_selected_entity) + if node is None: + ui.label("通过 Semantic group 或搜索定位局部结构;单实体节点可点击查看完整属性。 ").classes( + "bp-card-copy" + ) + return + with ui.element("section").classes("bp-evidence-block"): + ui.label(node.label).classes("bp-card-title") + ui.label(node.ref.entity_id).classes("bp-card-copy bp-mono break-all") + self._fact_row("Kind", node.ref.kind) + self._fact_row("Group", node.group) + for key, value in node.properties: + self._fact_row(key, value) + if overlay is not None: + entity = next((item for item in overlay.entities if item.entity_id == node.ref.entity_id), None) + if entity is not None: + ui.label(f"DERIVED · {overlay.provider} · {overlay.revision}").classes( + "bp-fidelity-tag bp-mono mt-2" + ) + for key, value in entity.metrics: + self._fact_row(key, format_seconds(value) if key.endswith("seconds") else str(value)) + + def _render_ir_boundary(self, transition: Any, trace: DerivationTrace) -> None: + boundary = transition.boundary + summary = boundary.summary + source_graph = trace.graph(boundary.source_stage, self.ir_selected_branch) + target_graph = trace.graph(boundary.target_stage, self.ir_selected_branch) + narrative = lowering_narrative(boundary, source_graph, target_graph) + mapping_rows = semantic_boundary_rows(boundary, source_graph, target_graph) + correspondence_rows = lowering_correspondence_rows(transition, mapping_rows) + expression = lowering_expression(transition, mapping_rows) + with ( + ui.expansion( + f"{self._ir_stage_label(boundary.source_stage)} → {self._ir_stage_label(boundary.target_stage)}", + caption=f"{boundary.pass_name} · {len(boundary.relations):,} relations", + icon="account_tree", + value=boundary.source_stage is self.ir_selected_stage, + ).classes("bp-card w-full"), + ui.column().classes("w-full gap-3 pt-2"), + ): + ui.label("Verified lowering 表达").classes("bp-card-title") + with ui.tabs().props("dense no-caps align=left").classes("w-full") as lowering_tabs: + lowering_short_tab = ui.tab("Short · pass contract").mark("lowering-expression-short-tab") + lowering_detailed_tab = ui.tab("Detailed · rules & evidence").mark("lowering-expression-detailed-tab") + with ui.tab_panels(lowering_tabs, value=lowering_short_tab, animated=False).classes( + "w-full bg-transparent" + ): + with ui.tab_panel(lowering_short_tab).classes("px-0 py-2"): + ui.code(expression.short, language="text").classes("bp-code").style( + "max-height: 360px; overflow: auto" + ).mark("lowering-short-expression") + with ui.tab_panel(lowering_detailed_tab).classes("px-0 py-2"): + ui.code(expression.detailed, language="text").classes("bp-code").style( + "max-height: 520px; overflow: auto" + ).mark("lowering-detailed-expression") + with ui.element("div").classes("bp-insight"): + ui.label("这一步做了什么").classes("bp-kicker") + ui.label(narrative.headline).classes("bp-result-title mt-1") + ui.label(narrative.detail).classes("bp-card-copy mt-1") + with ui.element("div").classes("bp-metric-grid"): + for metric in narrative.metrics: + with ui.element("div").classes("bp-metric").style(f"--metric-color: {METRIC_COLORS[metric.tone]}"): + ui.label(metric.label).classes("bp-metric-label") + ui.label(metric.value).classes("bp-metric-value bp-metric-value--range") + ui.label(metric.detail).classes("bp-metric-detail") + ui.label("Lowering 对应关系").classes("bp-card-title") + ui.label( + "Source | Pass | Target 的完整表达式使用一个随文字换行的外层 span;" + "内部 name、参数和括号由连续的语义 span 分块,分别高亮结构、类型、拓扑、workload 与映射信息。" + "超长表达在单元格内换行," + "box-decoration-break 使每个视觉行的高亮仅跟随文字," + "并直接编码本层拥有的 shape、dtype、logical ranks、collective 或 exact workload facts。" + ).classes("bp-card-copy") + ui.aggrid( + { + "columnDefs": [ + { + "headerName": "Source", + "field": "source_span_key", + "pinned": "left", + "minWidth": 390, + "flex": 1, + "spanRows": True, + ":cellRenderer": _segmented_expression_renderer( + "source_expression_name", "source_expression_parameters" + ), + ":filterValueGetter": "(params) => params.data.source_type + ' ' + " + "params.data.source_expression + ' ' + (params.data.source_entity_ids || []).join(' ')", + ":cellClass": "(params) => 'bp-entity-cell bp-entity-cell--' + params.data.source_tone", + "wrapText": True, + "autoHeight": True, + }, + { + "headerName": "Pass", + "field": "transform_span_key", + "minWidth": 460, + "flex": 1, + "spanRows": True, + ":cellRenderer": _segmented_expression_renderer( + "pass_expression_name", + "pass_expression_parameters", + "rule_signature", + "rule_rewrite", + ), + ":filterValueGetter": "(params) => params.data.qualified_rule + ' ' + " + "params.data.rule_signature + ' ' + params.data.rule_rewrite", + ":cellClass": "(params) => 'bp-entity-cell bp-pass-cell bp-entity-cell--' + " + "params.data.pass_tone", + "wrapText": True, + "autoHeight": True, + }, + { + "headerName": "Target", + "field": "target_expression", + "minWidth": 440, + "flex": 1, + ":cellRenderer": _segmented_expression_renderer( + "target_expression_name", "target_expression_parameters" + ), + ":filterValueGetter": "(params) => params.data.target_type + ' ' + " + "params.data.target_expression + ' ' + (params.data.target_entity_ids || []).join(' ')", + ":cellClass": "(params) => 'bp-entity-cell bp-entity-cell--' + params.data.target_tone", + "wrapText": True, + "autoHeight": True, + }, + ], + "rowData": correspondence_rows, + "enableCellSpan": True, + "rowHeight": 52, + "defaultColDef": {"sortable": True, "filter": True, "resizable": True}, + "pagination": True, + "paginationPageSize": 20, + }, + theme="quartz", + auto_size_columns=False, + ).classes("w-full bp-grid bp-lowering-table").style("height: 390px").mark("ir-boundary-table") + with ( + ui.expansion( + f"查看 canonical entity 映射与 pass contract ({len(boundary.relations):,})", + icon="manage_search", + value=False, + ).classes("w-full"), + ui.column().classes("w-full gap-3 pt-2"), + ): + with ui.row().classes("gap-2"): + for label, value in ( + ("Mapped targets", f"{summary.mapped_target_entities}/{summary.target_entities}"), + ("Mapped sources", f"{summary.mapped_source_entities}/{summary.source_entities}"), + ("1:1", str(summary.one_to_one)), + ("1:N", str(summary.one_to_many)), + ("N:1", str(summary.many_to_one)), + ("Dangling", str(summary.dangling_sources)), + ): + ui.label(f"{label} · {value}").classes("bp-data-chip bp-mono") + if boundary.diagnostics: + with ui.expansion(f"审计诊断 ({len(boundary.diagnostics)})", icon="rule", value=True).classes( + "w-full" + ): + for diagnostic in boundary.diagnostics: + with ui.row().classes("items-start gap-2 no-wrap"): + ui.icon("warning" if diagnostic.level == "warning" else "info", size="16px") + ui.label(f"{diagnostic.code} · {diagnostic.message}").classes("bp-card-copy") + ui.aggrid( + { + "columnDefs": [ + {"headerName": "Target", "field": "target", "pinned": "left", "minWidth": 280}, + {"headerName": "Kind", "field": "target_kind"}, + {"headerName": "Sources", "field": "sources", "minWidth": 320}, + {"headerName": "Count", "field": "source_count", "type": "numericColumn"}, + {"headerName": "Lineage", "field": "lineage_kind"}, + {"headerName": "Transform", "field": "transform", "minWidth": 220}, + {"headerName": "Unresolved", "field": "unresolved", "minWidth": 240}, + ], + "rowData": boundary_rows(boundary), + "defaultColDef": {"sortable": True, "filter": True, "resizable": True}, + "pagination": True, + "paginationPageSize": 25, + }, + theme="quartz", + auto_size_columns=False, + ).classes("w-full bp-grid").style("height: 420px") + contract = transition.contract + ui.label("Lowering contract").classes("bp-card-title") + for label, value in ( + ("Pass", contract.name), + ("Schemas", f"{contract.input_schema} → {contract.output_schema}"), + ("Bindings", ", ".join(contract.required_bindings) or "none"), + ("Required analyses", ", ".join(contract.required_analyses) or "none"), + ("Produced analyses", ", ".join(contract.produced_analyses) or "none"), + ("Preserved analyses", ", ".join(contract.preserved_analyses) or "none"), + ( + "Policy", + f"{contract.mutation_model} · verify={contract.verification} · " + f"deterministic={contract.deterministic} · seed={contract.uses_session_seed}", + ), + ): + self._fact_row(label, value) + + @staticmethod + def _download_derivation_bundle(trace: DerivationTrace) -> None: + content = DerivationDebugBundleCodec.dumps(trace) + ui.download( + content.encode("utf-8"), + filename=f"derivation-{trace.request_digest[:12]}.json", + media_type="application/json", + ) + + async def _import_derivation_bundle(self, event: Any) -> None: + try: + payload = await event.file.text() + trace = DerivationDebugBundleCodec.loads(payload) + except (UnicodeDecodeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as error: + ui.notify(f"调试包导入失败:{error}", type="negative", position="bottom-right") + return + self.imported_derivation_trace = trace + self.ir_selected_branch = trace.branches[0] + self.ir_selected_stage = trace.stages[0].stage + self.ir_selected_entity = "" + self.ir_group_filter = "all" + self.ir_search = "" + self._render_ir_explorer() + ui.notify("调试包已验证并载入", type="positive", position="bottom-right") @staticmethod def _download_snapshot(stage: str, digest: str, content: str) -> None: diff --git a/src/blueprinting/workbench/presentation.py b/src/blueprinting/workbench/presentation.py index 5d3a11d..32ab66b 100644 --- a/src/blueprinting/workbench/presentation.py +++ b/src/blueprinting/workbench/presentation.py @@ -8,11 +8,26 @@ from __future__ import annotations +import json from dataclasses import dataclass from math import sqrt from typing import Any -from blueprinting.application import AnalysisOutcome, AnalysisReport, SweepReport +from blueprinting.application import ( + AnalysisOutcome, + AnalysisReport, + DerivedOverlay, + IRBoundaryView, + IRGraphEdge, + IRGraphNode, + IRGraphView, + SweepReport, +) +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR @dataclass(frozen=True) @@ -35,6 +50,26 @@ class TimelineSummary: span_seconds: float +@dataclass(frozen=True) +class IRStageNarrative: + """Human-facing explanation of one canonical representation boundary.""" + + question: str + answer: str + result: str + excludes: str + next_step: str + + +@dataclass(frozen=True) +class LoweringNarrative: + """Readable summary of a typed-lineage boundary.""" + + headline: str + detail: str + metrics: tuple[MetricView, ...] + + _TIME_CATEGORY_LABELS = { "forward": "前向计算", "backward": "反向计算", @@ -64,6 +99,27 @@ class TimelineSummary: "vector": "#7c3aed", "collective": "#0891b2", } +_IR_KIND_COLORS = { + "operation": "#2563eb", + "task": "#2563eb", + "command": "#7c3aed", + "instruction": "#7c3aed", + "value": "#0891b2", + "buffer": "#0891b2", + "device": "#b45309", + "queue": "#d97706", + "memory_region": "#ca8a04", + "section": "#475569", + "entry_point": "#15803d", + "sync_token": "#dc2626", + "compute": "#2563eb", + "local_compute": "#2563eb", + "collective": "#0891b2", + "transfer": "#0f766e", + "barrier": "#dc2626", + "host": "#64748b", + "group": "#64748b", +} def format_seconds(value: float) -> str: @@ -94,6 +150,67 @@ def format_count(value: int | float) -> str: return f"{number:.0f}" +def _kind_summary(items: tuple[Any, ...], *, limit: int = 3) -> str: + counts: dict[str, int] = {} + for item in items: + kind = getattr(item, "kind", None) + label = getattr(kind, "value", str(kind)) + counts[label] = counts.get(label, 0) + 1 + ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0])) + return " · ".join(f"{count:,} {label}" for label, count in ranked[:limit]) or "0" + + +def ir_stage_narrative(ir: Any) -> IRStageNarrative: + """Explain ownership and the concrete contents of a canonical snapshot.""" + + if isinstance(ir, ModelIR): + return IRStageNarrative( + "这个模型在语义上做什么?", + "显式记录 tensor value、operation、数据流与副作用,作为后续推导不变的语义起点。", + f"{len(ir.operations):,} 个 operation · {len(ir.values):,} 个 value · " + f"{len(ir.inputs):,} 入 / {len(ir.outputs):,} 出", + "不包含并行 placement、硬件吞吐或预测时间。", + "下一步:把模型语义展开到逻辑 mesh 上的 local compute、collective 与依赖。", + ) + if isinstance(ir, DistributedTaskIR): + axes = " × ".join(f"{axis.name}={axis.size}" for axis in ir.mesh.axes) + return IRStageNarrative( + "模型工作如何分布到逻辑参与者?", + "记录逻辑 mesh、sharding、collective 与分布式依赖;rank 仍是虚拟参与者。", + f"{len(ir.tasks):,} 个 task({_kind_summary(ir.tasks)})· mesh {axes} = {ir.mesh.size:,} ranks", + "不选择物理设备、queue、kernel 或目标实现。", + "下一步:固化已选策略的精确 workload、抽象 buffer 与资源需求。", + ) + if isinstance(ir, PortablePlanIR): + return IRStageNarrative( + "已选策略需要完成哪些精确工作?", + "用 target-neutral task DAG 表达 workload facts、抽象 buffer、资源需求与目标。", + f"{len(ir.tasks):,} 个 task({_kind_summary(ir.tasks)})· {len(ir.buffers):,} 个 buffer · " + f"{len(ir.objectives):,} 个 objective", + "不包含 kernel ID、物理 queue、经验 duration 或 wall-clock timestamp。", + "下一步:绑定 target 与 deployment,完成实现选择、placement、ordering 与内存区域规划。", + ) + if isinstance(ir, ConcretePlanIR): + return IRStageNarrative( + "这个 target 上合法的执行计划是什么?", + "记录实现选择、物理 placement、ordering、buffer region、同步与 command DAG。", + f"{len(ir.commands):,} 个 command · {len(ir.devices):,} 个 device · " + f"{len(ir.queues):,} 个 queue · {len(ir.memory_regions):,} 个 memory region", + "预测时间不是执行正确性的事实源;target-only 语义属于 typed extension。", + "下一步:同一 concrete digest 可进入 timing/simulation,或由 target plugin 实现为 MachineIR。", + ) + if isinstance(ir, MachineIR): + return IRStageNarrative( + "目标机器最终执行什么程序?", + "由 target plugin 拥有指令 dialect、section、entry point 与 ABI。", + f"{len(ir.instructions):,} 条 instruction · {len(ir.sections):,} 个 section · " + f"{len(ir.entry_points):,} 个 entry point · {ir.program_format}", + "不为了统一表面形式而把 target 语义上提到 portable 层。", + "下一步:生成 target artifact 或 replay package,并沿 lineage 关联观测。", + ) + raise TypeError(f"unsupported canonical IR type: {type(ir).__name__}") + + def analysis_metrics(report: AnalysisReport) -> tuple[MetricView, ...]: utilization = report.memory["total"] / report.memory["capacity"] return ( @@ -153,6 +270,842 @@ def task_rows(outcome: AnalysisOutcome) -> list[dict[str, Any]]: ] +_STRUCTURE_PHASE_ORDER = { + "input": 0, + "model": 10, + "forward": 20, + "recompute": 30, + "activation_gradient": 40, + "weight_gradient": 50, + "recommunication": 60, + "optimizer": 70, + "output": 80, + "topology": 10, + "placement": 20, + "memory": 30, + "commands": 40, + "synchronization": 50, + "entry": 10, + "sections": 20, + "instructions": 30, +} +_STRUCTURE_PHASE_LABELS = { + "input": "输入", + "model": "模型语义", + "forward": "前向", + "recompute": "重计算", + "activation_gradient": "激活梯度", + "weight_gradient": "权重梯度", + "recommunication": "重通信", + "optimizer": "优化器", + "output": "输出", + "topology": "Target 拓扑", + "placement": "物理放置", + "memory": "内存计划", + "commands": "Command DAG", + "synchronization": "同步", + "entry": "入口", + "sections": "Sections", + "instructions": "指令流", +} + + +def _structure_parts(node: IRGraphNode, stage: Any) -> tuple[str, str, str]: + """Map one entity to a stable stage/subsystem/type structure coordinate.""" + + prefix, _, suffix = (node.group or "unscoped").partition("/") + entity_kind = node.ref.kind + semantic_kind = dict(node.properties).get("kind", entity_kind) + if stage.value == "model": + if entity_kind == "value" and suffix in {"input", "output"}: + return suffix, "tensor", entity_kind + return "model", suffix or prefix, entity_kind + if stage.value in {"distributed", "portable"}: + if entity_kind in {"value", "buffer"}: + phase = suffix if suffix in {"input", "output"} else "input" + return phase, entity_kind, entity_kind + subsystem = suffix.split(".", maxsplit=1)[0] if suffix else entity_kind + return prefix or "unscoped", subsystem or entity_kind, semantic_kind + if stage.value == "concrete": + phase = { + "device": "topology", + "queue": "placement", + "memory_region": "placement", + "buffer": "memory", + "command": "commands", + "sync_token": "synchronization", + }.get(entity_kind, "commands") + return phase, entity_kind, semantic_kind + if stage.value == "machine": + phase = { + "entry_point": "entry", + "section": "sections", + "instruction": "instructions", + }.get(entity_kind, "instructions") + return phase, suffix or entity_kind, entity_kind + return prefix or "unscoped", suffix or entity_kind, entity_kind + + +def _bounded_ir_graph(graph: IRGraphView, max_nodes: int = 20) -> tuple[list[IRGraphNode], list[IRGraphEdge], bool]: + """Collapse a dense graph by semantic stage, subsystem, and entity kind.""" + + if len(graph.nodes) <= max_nodes: + return list(graph.nodes), list(graph.edges), False + groups: dict[tuple[str, str, str], list[IRGraphNode]] = {} + for node in graph.nodes: + groups.setdefault(_structure_parts(node, graph.stage), []).append(node) + ranked = sorted(groups.items(), key=lambda item: (-len(item[1]), item[0])) + if len(ranked) > max_nodes: + kept = ranked[: max_nodes - 1] + overflow = [node for _, members in ranked[max_nodes - 1 :] for node in members] + ranked = kept + [(("other", "mixed", "group"), overflow)] + digest = graph.snapshot_digest + group_nodes = [] + node_to_group = {} + for index, ((phase, subsystem, entity_kind), members) in enumerate(ranked): + ref = members[0].ref.__class__( + graph.stage, + digest, + "group", + f"group:{index}:{phase}:{subsystem}:{entity_kind}", + ) + noun = entity_kind.replace("_", " ") + group_nodes.append( + IRGraphNode( + ref, + f"{subsystem}\n{len(members):,} {noun}", + f"{phase}/{subsystem}", + ( + ("phase", phase), + ("subsystem", subsystem), + ("entity_kind", entity_kind), + ("entity_count", str(len(members))), + ), + ) + ) + for member in members: + node_to_group[member.ref.key] = ref + edge_counts: dict[tuple[str, str, str], int] = {} + refs = {node.ref.key: node.ref for node in group_nodes} + for edge in graph.edges: + source = node_to_group.get(edge.source.key) + target = node_to_group.get(edge.target.key) + if source is None or target is None or source == target: + continue + key = (source.key, target.key, edge.kind) + edge_counts[key] = edge_counts.get(key, 0) + edge.count + refs[source.key] = source + refs[target.key] = target + edges = [ + IRGraphEdge(refs[source], refs[target], kind, count=count) + for (source, target, kind), count in sorted(edge_counts.items()) + ] + return group_nodes, edges, True + + +def ir_graph_chart_options( + graph: IRGraphView, + overlay: DerivedOverlay | None = None, + *, + max_nodes: int = 20, +) -> dict[str, Any]: + nodes, edges, aggregated = _bounded_ir_graph(graph, max_nodes) + overlay_metrics = {item.entity_id: dict(item.metrics) for item in overlay.entities} if overlay is not None else {} + max_cost = max((metrics.get("total_seconds", 0.0) for metrics in overlay_metrics.values()), default=0.0) + node_parts: dict[str, tuple[str, str, str]] = {} + for node in nodes: + properties = dict(node.properties) + node_parts[node.ref.key] = ( + properties.get("phase", _structure_parts(node, graph.stage)[0]), + properties.get("subsystem", _structure_parts(node, graph.stage)[1]), + properties.get("entity_kind", _structure_parts(node, graph.stage)[2]), + ) + phases = sorted( + {parts[0] for parts in node_parts.values()}, + key=lambda phase: (_STRUCTURE_PHASE_ORDER.get(phase, 45), phase), + ) + lanes = sorted( + {(parts[1], parts[2]) for parts in node_parts.values()}, + key=lambda lane: (lane[0] not in {"tensor", "value", "buffer"}, lane[0], lane[1]), + ) + lane_y = {lane: 92 + index * 76 for index, lane in enumerate(lanes)} + by_phase: dict[str, list[IRGraphNode]] = {phase: [] for phase in phases} + for node in nodes: + by_phase[node_parts[node.ref.key][0]].append(node) + for members in by_phase.values(): + members.sort(key=lambda node: (node_parts[node.ref.key][1], node_parts[node.ref.key][2], node.label)) + phase_x = {phase: 190 + index * 210 for index, phase in enumerate(phases)} + data = [] + for subsystem, entity_kind in lanes: + data.append( + { + "id": f"lane:{subsystem}:{entity_kind}", + "name": f"{subsystem}\n{_ENTITY_TYPE_LABELS.get(entity_kind, entity_kind.replace('_', ' '))}", + "x": 0, + "y": lane_y[(subsystem, entity_kind)], + "symbol": "roundRect", + "symbolSize": [132, 46], + "itemStyle": {"color": "#f8fafc", "borderColor": "#cbd5e1", "borderWidth": 1}, + "label": { + "show": True, + "position": "inside", + "color": "#475569", + "fontSize": 10, + "lineHeight": 14, + }, + "is_lane": True, + "subsystem": subsystem, + "entity_kind": entity_kind, + } + ) + for phase in phases: + members = by_phase[phase] + data.append( + { + "id": f"phase:{phase}", + "name": _STRUCTURE_PHASE_LABELS.get(phase, phase.replace("_", " ")), + "x": phase_x[phase], + "y": 0, + "symbol": "roundRect", + "symbolSize": [154, 34], + "itemStyle": {"color": "#e2e8f0", "borderColor": "#cbd5e1", "borderWidth": 1}, + "label": {"show": True, "color": "#334155", "fontSize": 11, "fontWeight": 650}, + "is_phase": True, + "entity_count": len(members), + } + ) + for node in nodes: + phase, subsystem, entity_kind = node_parts[node.ref.key] + total_seconds = overlay_metrics.get(node.ref.entity_id, {}).get("total_seconds") + color = _IR_KIND_COLORS.get(entity_kind, "#64748b") + if total_seconds is not None and max_cost > 0: + opacity = 0.35 + 0.65 * (total_seconds / max_cost) ** 0.5 + color = f"rgba(220, 38, 38, {opacity:.3f})" + data.append( + { + "id": node.ref.key, + "entity_id": "" if aggregated else node.ref.entity_id, + "name": node.label, + "kind": node.ref.kind, + "subsystem": subsystem, + "entity_kind": entity_kind, + "group": node.group, + "properties": "
".join(f"{key} · {value}" for key, value in node.properties), + "x": phase_x[phase], + "y": lane_y[(subsystem, entity_kind)], + "symbol": "roundRect", + "symbolSize": [154, 52], + "itemStyle": {"color": color, "borderColor": "#ffffff", "borderWidth": 1.5}, + "overlay_seconds": total_seconds, + "label": { + "show": True, + "position": "inside", + "color": "#ffffff", + "fontSize": 10, + "lineHeight": 14, + }, + } + ) + return { + "backgroundColor": "transparent", + "animationDuration": 250, + "tooltip": { + ":formatter": ( + "function(params) { const d = params.data;" + "if (!d) return '';" + "if (d.is_phase) return '' + d.name + '
' + d.entity_count + ' structure group(s)';" + "if (d.is_lane) return '' + d.name.replace('\\n', ' · ') + '
semantic lane';" + "let result = '' + d.name.replace('\\n', ' · ') + '
' + d.entity_kind;" + "if (d.entity_id) result += '
' + d.entity_id;" + "if (d.properties) result += '
' + d.properties;" + "if (d.overlay_seconds != null) result += '
DERIVED total · ' + d.overlay_seconds + ' s';" + "return result; }" + ) + }, + "series": [ + { + "type": "graph", + "layout": "none", + "roam": False, + "draggable": False, + "data": data, + "links": [ + { + "source": edge.source.key, + "target": edge.target.key, + "value": edge.count, + "kind": edge.kind, + "lineStyle": {"width": min(1 + edge.count**0.5, 5), "opacity": 0.1, "curveness": 0.04}, + } + for edge in edges + ], + "edgeSymbol": ["none", "arrow"], + "edgeSymbolSize": 7, + "emphasis": {"focus": "adjacency", "lineStyle": {"width": 3, "opacity": 0.8}}, + } + ], + "graphic": [ + { + "type": "text", + "right": 10, + "bottom": 8, + "style": { + "text": ( + f"Semantic structure projection · {len(graph.nodes):,} canonical entities" + if aggregated + else "Canonical entity structure" + ), + "fill": "#64748b", + "fontSize": 11, + }, + } + ], + } + + +def boundary_rows(boundary: IRBoundaryView) -> list[dict[str, Any]]: + return [ + { + "target": relation.target.entity_id, + "target_kind": relation.target.kind, + "sources": ", ".join(item.entity_id for item in relation.sources), + "source_count": len(relation.sources), + "unresolved": ", ".join(relation.unresolved_sources), + "lineage_kind": relation.lineage_kind, + "transform": relation.transform, + "explicit_mismatch": relation.explicit_source_mismatch, + } + for relation in boundary.relations + ] + + +def _node_lookup(graph: IRGraphView | None) -> dict[str, IRGraphNode]: + if graph is None: + return {} + result: dict[str, IRGraphNode] = {} + for node in graph.nodes: + result[node.ref.key] = node + result[node.ref.entity_id] = node + return result + + +def _graph_display_aliases(graph: IRGraphView | None) -> dict[str, str]: + """Assign readable snapshot-local aliases without replacing stable IDs.""" + + aliases: dict[str, str] = {} + counters: dict[str, int] = {} + if graph is None: + return aliases + for node in graph.nodes: + identifier = node.ref.entity_id + prefix, separator, _ = identifier.partition(":") + if node.label != identifier or not separator or prefix not in {"value", "node", "buffer"}: + continue + counters[prefix] = counters.get(prefix, 0) + 1 + aliases[identifier] = f"{prefix}#{counters[prefix]}" + return aliases + + +def _display_label(node: IRGraphNode, aliases: dict[str, str]) -> str: + return aliases.get(node.ref.entity_id, node.label) + + +def _semantic_boundary_bucket(node: IRGraphNode) -> tuple[str, str, int]: + phase, subsystem, entity_kind = _structure_parts(node, node.ref.stage) + phase_label = _STRUCTURE_PHASE_LABELS.get(phase, phase.replace("_", " ")) + return ( + f"{phase_label} · {subsystem}", + entity_kind.replace("_", " ").replace("-", " "), + _STRUCTURE_PHASE_ORDER.get(phase, 45), + ) + + +_ENTITY_TYPE_LABELS = { + "operation": "Operation", + "value": "Value", + "task": "Task", + "local_compute": "Local compute", + "local compute": "Local compute", + "compute": "Compute", + "collective": "Collective", + "point_to_point": "Point-to-point", + "point to point": "Point-to-point", + "reshard": "Reshard", + "buffer": "Buffer", + "command": "Command", + "device": "Device", + "queue": "Queue", + "memory_region": "Memory region", + "memory region": "Memory region", + "sync_token": "Sync token", + "sync token": "Sync token", + "instruction": "Instruction", + "section": "Section", + "entry_point": "Entry point", + "entry point": "Entry point", + "generated": "Generated", +} + + +def _entity_type_label(kind: str) -> str: + return _ENTITY_TYPE_LABELS.get(kind, kind.replace("_", " ").replace("-", " ").title()) + + +def _entity_tone(kind: str) -> str: + normalized = kind.replace(" ", "_").replace("-", "_") + if normalized in {"operation", "task", "local_compute", "compute"}: + return "blue" + if normalized in {"value", "buffer", "memory_region"}: + return "green" + if normalized in {"collective", "point_to_point", "reshard", "sync_token"}: + return "violet" + if normalized in {"command", "queue", "device"}: + return "amber" + if normalized in {"instruction", "section", "entry_point"}: + return "cyan" + return "slate" + + +def _decoded_property(node: IRGraphNode, name: str) -> Any: + raw = dict(node.properties).get(name) + if raw is None: + return None + try: + value = json.loads(raw) + except (TypeError, ValueError, json.JSONDecodeError): + return raw + if isinstance(value, dict) and set(value) == {"$tuple"}: + return value["$tuple"] + return value + + +def _unique_property(nodes: list[IRGraphNode], name: str) -> list[Any]: + result = [] + for node in nodes: + value = _decoded_property(node, name) + if value is not None and value not in result: + result.append(value) + return result + + +def _sum_integer_property(nodes: list[IRGraphNode], name: str) -> int | None: + values = [_decoded_property(node, name) for node in nodes] + if not values or any(isinstance(value, bool) or not isinstance(value, int) for value in values): + return None + return sum(int(value) for value in values) + + +def _format_parameter(value: Any) -> str: + if isinstance(value, int): + return str(value) + if isinstance(value, list): + return "[" + ",".join(str(item) for item in value) + "]" + return str(value) + + +def _parameter_category(name: str) -> str: + """Classify expression parameters for stable semantic highlighting.""" + + if name in {"shape", "layout", "storage", "sharding"}: + return "structure" + if name in {"dtype", "role", "kind", "implementation", "opcode"}: + return "type" + if name in {"ranks", "rank", "replicated", "collective", "participants", "queue", "ordered"}: + return "topology" + if name in { + "ops", + "read_B", + "write_B", + "message_B", + "size_B", + "capacity_bytes", + "data_bytes", + "offset", + "alignment", + "alignment_bytes", + "count", + }: + return "workload" + if name in {"relation", "cardinality"}: + return "mapping" + return "property" + + +def _semantic_expression_name( + label: str, + nodes: list[IRGraphNode], + display_aliases: dict[str, str] | None = None, +) -> str: + if len(nodes) == 1: + return _display_label(nodes[0], display_aliases or {}) + if nodes: + phase, subsystem, _ = _structure_parts(nodes[0], nodes[0].ref.stage) + family = {"mlp": "MLP", "attention": "Attention"}.get(subsystem, subsystem) + if family and phase not in {"model", "input", "output", "unscoped"}: + return f"{family}.{phase}" + if family: + return family + return label.replace(" · ", ".") + + +def _short_entity_expression_parts( + label: str, + kind: str, + nodes: list[IRGraphNode], + count: int, + display_aliases: dict[str, str] | None = None, +) -> tuple[str, tuple[dict[str, str], ...]]: + """Build structured, stage-correct tokens for one semantic expression.""" + + parameters: list[tuple[str, str]] = [] + + def add_parameter(name: str, value: Any) -> None: + parameters.append((name, _format_parameter(value))) + + for name in ("shape", "dtype", "role", "storage", "sharding"): + values = _unique_property(nodes, name) + if len(values) == 1: + add_parameter(name, values[0]) + elif values: + add_parameter(name, values) + + if len(nodes) == 1: + for name in ( + "phase", + "source_layer", + "queue", + "implementation", + "opcode", + "operands", + "rank", + "ordered", + "offset", + "alignment", + "alignment_bytes", + "capacity_bytes", + "data_bytes", + ): + values = _unique_property(nodes, name) + if len(values) == 1: + add_parameter(name, values[0]) + if nodes[0].ref.kind in {"command", "queue", "section"}: + kinds = _unique_property(nodes, "kind") + if len(kinds) == 1: + add_parameter("kind", kinds[0]) + + rank_counts = _unique_property(nodes, "rank_count") or _unique_property(nodes, "owner_count") + if len(rank_counts) == 1: + add_parameter("ranks", rank_counts[0]) + elif rank_counts: + add_parameter("ranks", rank_counts) + + replicated_axes = _unique_property(nodes, "replicated_axes") + if replicated_axes: + add_parameter("replicated", replicated_axes[0]) + + collective_kinds = _unique_property(nodes, "collective_kind") + if collective_kinds: + value = collective_kinds[0] if len(collective_kinds) == 1 else collective_kinds + add_parameter("collective", value) + participant_counts = _unique_property(nodes, "participants") + if len(participant_counts) == 1: + add_parameter("participants", participant_counts[0]) + + for property_name, parameter_name in ( + ("operations", "ops"), + ("read_bytes", "read_B"), + ("write_bytes", "write_B"), + ("message_bytes", "message_B"), + ("size_bytes", "size_B"), + ): + total = _sum_integer_property(nodes, property_name) + if total is not None and total != 0: + add_parameter(parameter_name, total) + + if count > 1: + add_parameter("count", count) + if not nodes and count: + add_parameter("count", count) + name = _semantic_expression_name(label, nodes, display_aliases) + return name, tuple({"name": key, "value": value, "category": _parameter_category(key)} for key, value in parameters) + + +def _short_entity_expression( + label: str, + kind: str, + nodes: list[IRGraphNode], + count: int, + display_aliases: dict[str, str] | None = None, +) -> str: + """Render the plain-text equivalent of a structured semantic expression.""" + + name, parameters = _short_entity_expression_parts(label, kind, nodes, count, display_aliases) + body = ", ".join(f"{item['name']}={item['value']}" for item in parameters) + return f"{name}({body})" if parameters else name + + +def lowering_narrative( + boundary: IRBoundaryView, + source_graph: IRGraphView | None = None, + target_graph: IRGraphView | None = None, +) -> LoweringNarrative: + """Turn cardinality and lineage into a short lowering explanation.""" + + source_nodes = _node_lookup(source_graph) + target_nodes = _node_lookup(target_graph) + source_aliases = _graph_display_aliases(source_graph) + expansions: dict[str, set[str]] = {} + kinds: dict[str, int] = {} + transforms: dict[str, int] = {} + for relation in boundary.relations: + target_node = target_nodes.get(relation.target.key) or target_nodes.get(relation.target.entity_id) + target_kind = target_node.ref.kind if target_node is not None else relation.target.kind + kinds[target_kind] = kinds.get(target_kind, 0) + 1 + transform = relation.transform or boundary.pass_name + transforms[transform] = transforms.get(transform, 0) + 1 + for source in relation.sources: + expansions.setdefault(source.entity_id, set()).add(relation.target.entity_id) + + largest_source, largest_targets = max(expansions.items(), key=lambda item: len(item[1]), default=("", set())) + source_node = source_nodes.get(largest_source) + source_label = _display_label(source_node, source_aliases) if source_node is not None else largest_source + kind_text = ( + " · ".join(f"{count:,} {kind}" for kind, count in sorted(kinds.items(), key=lambda item: (-item[1], item[0]))) + or "无目标实体" + ) + primary_transform, transform_count = max( + transforms.items(), key=lambda item: item[1], default=(boundary.pass_name, 0) + ) + summary = boundary.summary + if len(largest_targets) > 1: + headline = f"{source_label} 被展开为 {len(largest_targets):,} 个目标实体" + shape = "语义展开(1:N)" + elif summary.many_to_one > summary.one_to_many: + headline = "多个 source 语义被融合到较少的目标实体" + shape = "语义融合(N:1)" + else: + headline = "lowering 主要保持 source 与 target 的一一对应" + shape = "结构保持(1:1)" + detail = ( + f"目标结构:{kind_text}。{summary.mapped_target_entities:,}/{summary.target_entities:,} 个目标实体" + f"具有可解析 typed lineage;{summary.dangling_sources:,} 条 source 引用悬空。" + ) + return LoweringNarrative( + headline, + detail, + ( + MetricView("Lowering 形态", shape, f"最大展开 {len(largest_targets):,} 个 target", "primary"), + MetricView("目标结构", kind_text, f"共 {summary.target_entities:,} 个 canonical entity", "cyan"), + MetricView( + "Lineage 完整性", + f"{summary.mapped_target_entities:,}/{summary.target_entities:,}", + f"dangling source · {summary.dangling_sources:,}", + "violet" if summary.dangling_sources == 0 else "amber", + ), + MetricView("主要 transform", primary_transform, f"覆盖 {transform_count:,} 个 target", "neutral"), + ), + ) + + +def semantic_boundary_rows( + boundary: IRBoundaryView, + source_graph: IRGraphView | None = None, + target_graph: IRGraphView | None = None, +) -> list[dict[str, Any]]: + """Aggregate typed lineage into readable source/transform/target table rows.""" + + relations = boundary.relations + source_nodes = _node_lookup(source_graph) + target_nodes = _node_lookup(target_graph) + source_aliases = _graph_display_aliases(source_graph) + target_aliases = _graph_display_aliases(target_graph) + distinct_sources = {item.entity_id for relation in relations for item in relation.sources} + keep_source_entities = len(distinct_sources) <= 12 + grouped: dict[tuple[str, str, str, str, str, str], dict[str, Any]] = {} + for relation in relations: + target_node = target_nodes.get(relation.target.key) or target_nodes.get(relation.target.entity_id) + if target_node is None: + target_label, target_kind, target_order = relation.target.kind, relation.target.kind, 45 + target_example = relation.target.entity_id + else: + target_label, target_kind, target_order = _semantic_boundary_bucket(target_node) + target_example = _display_label(target_node, target_aliases) + if target_example == relation.target.entity_id: + target_example = target_label + source_refs = relation.sources or (None,) + for source_ref in source_refs: + source_node = ( + source_nodes.get(source_ref.key) or source_nodes.get(source_ref.entity_id) + if source_ref is not None + else None + ) + if source_node is None: + source_label = "generated / no source" if source_ref is None else source_ref.entity_id + source_kind = "generated" if source_ref is None else source_ref.kind + source_order = 45 + else: + grouped_source_label, grouped_source_kind, source_order = _semantic_boundary_bucket(source_node) + if keep_source_entities: + source_label = _display_label(source_node, source_aliases) + source_kind = source_node.ref.kind + else: + source_label, source_kind = grouped_source_label, grouped_source_kind + transform = relation.transform or boundary.pass_name + key = ( + source_label, + source_kind, + transform, + target_label, + target_kind, + relation.lineage_kind, + ) + item = grouped.setdefault( + key, + { + "source_entities": set(), + "target_entities": set(), + "target_examples": set(), + "relation_count": 0, + "source_order": source_order, + "target_order": target_order, + }, + ) + if source_ref is not None: + item["source_entities"].add(source_ref.entity_id) + item["target_entities"].add(relation.target.entity_id) + item["target_examples"].add(target_example) + item["relation_count"] += 1 + + rows = [] + for (source, source_kind, transform, target, target_kind, lineage_kind), item in grouped.items(): + source_count = len(item["source_entities"]) + target_count = len(item["target_entities"]) + if source_count == 0: + mapping_kind = "生成" + elif source_count == 1 and target_count > 1: + mapping_kind = "展开" + elif source_count > 1 and target_count == 1: + mapping_kind = "融合" + elif source_count == target_count: + mapping_kind = "保持" + else: + mapping_kind = "重组" + examples = sorted(item["target_examples"]) + target_member_nodes = [ + target_nodes[identifier] for identifier in item["target_entities"] if identifier in target_nodes + ] + target_expression_name, target_expression_parameters = _short_entity_expression_parts( + target, + target_kind, + target_member_nodes, + target_count, + target_aliases, + ) + rows.append( + { + "source": source, + "source_kind": source_kind, + "transform": transform, + "target": target, + "target_kind": target_kind, + "mapping": f"{source_count:,} → {target_count:,}", + "mapping_kind": mapping_kind, + "source_count": source_count, + "target_count": target_count, + "source_entity_ids": tuple(sorted(item["source_entities"])), + "target_entity_ids": tuple(sorted(item["target_entities"])), + "relation_count": item["relation_count"], + "target_examples": " · ".join(examples[:3]) + (" · …" if len(examples) > 3 else ""), + "target_expression": _short_entity_expression( + target, + target_kind, + target_member_nodes, + target_count, + target_aliases, + ), + "target_expression_name": target_expression_name, + "target_expression_parameters": target_expression_parameters, + "target_type": _entity_type_label(target_kind), + "target_tone": _entity_tone(target_kind), + "lineage_kind": lineage_kind, + "source_order": item["source_order"], + "target_order": item["target_order"], + } + ) + rows = sorted( + rows, + key=lambda row: ( + row["source_order"], + row["source"], + row["transform"], + row["target_order"], + row["target"], + row["target_kind"], + ), + ) + totals: dict[tuple[str, str, str], dict[str, set[str]]] = {} + for row in rows: + group_key = (row["source"], row["source_kind"], row["transform"]) + total = totals.setdefault(group_key, {"source_entities": set(), "target_entities": set()}) + total["source_entities"].update(row["source_entity_ids"]) + total["target_entities"].update(row["target_entity_ids"]) + + previous_source = "" + previous_group = "" + for row in rows: + group_key = (row["source"], row["source_kind"], row["transform"]) + group_id = "\u241f".join(group_key) + total = totals[group_key] + source_count = len(total["source_entities"]) + target_count = len(total["target_entities"]) + if source_count == 0: + mapping_kind = "生成" + elif source_count == 1 and target_count > 1: + mapping_kind = "展开" + elif source_count > 1 and target_count == 1: + mapping_kind = "融合" + elif source_count == target_count: + mapping_kind = "保持" + else: + mapping_kind = "重组" + source_member_nodes = [ + source_nodes[identifier] for identifier in total["source_entities"] if identifier in source_nodes + ] + source_expression_name, source_expression_parameters = _short_entity_expression_parts( + row["source"], + row["source_kind"], + source_member_nodes, + source_count, + source_aliases, + ) + row.update( + { + "source_span_key": f"{group_id}\u241fsource", + "transform_span_key": f"{group_id}\u241ftransform", + "mapping_span_key": f"{group_id}\u241fmapping", + "mapping_kind_span_key": f"{group_id}\u241frelation", + "mapping": f"{source_count:,} → {target_count:,}", + "mapping_kind": mapping_kind, + "source_expression": _short_entity_expression( + row["source"], + row["source_kind"], + source_member_nodes, + source_count, + source_aliases, + ), + "source_expression_name": source_expression_name, + "source_expression_parameters": source_expression_parameters, + "source_type": _entity_type_label(row["source_kind"]), + "source_tone": _entity_tone(row["source_kind"]), + "source_group_start": row["source"] != previous_source, + "transform_group_start": group_id != previous_group, + } + ) + previous_source = row["source"] + previous_group = group_id + return rows + + def sweep_rows(report: SweepReport) -> list[dict[str, Any]]: return [ { @@ -319,7 +1272,7 @@ def _scaled_time_children(report: AnalysisReport, category: str, seconds: float) scale = seconds / raw_total children: list[dict[str, Any]] = [] for group_name, layers in groups.items(): - layer_nodes = [] + layer_nodes: list[dict[str, Any]] = [] for layer_name, operations in layers.items(): operation_nodes = [ {"name": operation.replace("_", " "), "value": raw_seconds * scale} @@ -328,14 +1281,16 @@ def _scaled_time_children(report: AnalysisReport, category: str, seconds: float) layer_nodes.append( { "name": layer_name, - "value": sum(float(node["value"]) for node in operation_nodes), + "value": sum(raw_seconds * scale for raw_seconds in operations.values()), "children": operation_nodes, } ) children.append( { "name": group_name, - "value": sum(float(node["value"]) for node in layer_nodes), + "value": sum( + sum(raw_seconds * scale for raw_seconds in operations.values()) for operations in layers.values() + ), "children": layer_nodes, } ) diff --git a/src/blueprinting/workload/__init__.py b/src/blueprinting/workload/__init__.py index 341ebac..6b3d4b4 100644 --- a/src/blueprinting/workload/__init__.py +++ b/src/blueprinting/workload/__init__.py @@ -1,10 +1,21 @@ """Target-neutral model and workload contracts.""" -from .transformer import TransformerModelSpec, TransformerTrainingWorkloadSpec +from .transformer import ( + TRANSFORMER_DATA_TYPES, + TransformerDataType, + TransformerModelSpec, + TransformerTrainingWorkloadSpec, + require_transformer_data_type, + transformer_element_bytes, +) from .transformer_inference import TransformerInferenceRequestSpec __all__ = [ "TransformerInferenceRequestSpec", + "TRANSFORMER_DATA_TYPES", + "TransformerDataType", "TransformerModelSpec", "TransformerTrainingWorkloadSpec", + "require_transformer_data_type", + "transformer_element_bytes", ] diff --git a/src/blueprinting/workload/transformer.py b/src/blueprinting/workload/transformer.py index 429321c..3fa4fa6 100644 --- a/src/blueprinting/workload/transformer.py +++ b/src/blueprinting/workload/transformer.py @@ -8,43 +8,51 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any +from typing import Any, Final, Literal, TypeAlias, cast, get_args -from blueprinting.schema.codec import record_type +from blueprinting.schema.authoring import NonEmptyText, PositiveInt, record +TransformerDataType: TypeAlias = Literal["float8", "float16", "bfloat16", "float32"] +TRANSFORMER_DATA_TYPES: Final = cast(tuple[TransformerDataType, ...], get_args(TransformerDataType)) -def _positive_integer(value: Any, name: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ValueError(f"{name} must be a positive integer") +_TRANSFORMER_ELEMENT_BYTES: Final[dict[TransformerDataType, int]] = { + "float8": 1, + "float16": 2, + "bfloat16": 2, + "float32": 4, +} + +if set(_TRANSFORMER_ELEMENT_BYTES) != set(TRANSFORMER_DATA_TYPES): # pragma: no cover - import-time contract + raise RuntimeError("Transformer datatype widths must cover the complete datatype domain") + + +def require_transformer_data_type(value: object) -> TransformerDataType: + """Narrow an untyped boundary value to the closed Transformer datatype domain.""" + + if type(value) is not str or value not in TRANSFORMER_DATA_TYPES: + raise ValueError(f"unsupported Transformer datatype: {value!r}") return value -@record_type("compiler.transformer.model_spec.v1") -@dataclass(frozen=True) +def transformer_element_bytes(datatype: TransformerDataType) -> int: + """Return the canonical storage width for one Transformer element.""" + + return _TRANSFORMER_ELEMENT_BYTES[require_transformer_data_type(datatype)] + + +@record("blueprinting.workload.transformer-model") class TransformerModelSpec: """Target-independent decoder-only Transformer dimensions.""" - name: str - hidden_size: int - feedforward_size: int - sequence_length: int - attention_heads: int - attention_head_size: int - block_count: int + name: NonEmptyText + hidden_size: PositiveInt + feedforward_size: PositiveInt + sequence_length: PositiveInt + attention_heads: PositiveInt + attention_head_size: PositiveInt + block_count: PositiveInt def __post_init__(self) -> None: - if not isinstance(self.name, str) or not self.name: - raise ValueError("Transformer model name must not be empty") - for field_name in ( - "hidden_size", - "feedforward_size", - "sequence_length", - "attention_heads", - "attention_head_size", - "block_count", - ): - _positive_integer(getattr(self, field_name), field_name) if self.attention_heads * self.attention_head_size != self.hidden_size: raise ValueError("attention_heads * attention_head_size must equal hidden_size") @@ -61,26 +69,21 @@ def from_mapping(cls, name: str, data: Mapping[str, Any]) -> TransformerModelSpe ) -@record_type("blueprinting.workload.transformer-training.v1") -@dataclass(frozen=True) +@record("blueprinting.workload.transformer-training") class TransformerTrainingWorkloadSpec: """Training scenario facts independent of parallel mapping and hardware.""" - global_batch_size: int - microbatch_size: int - datatype: str + global_batch_size: PositiveInt + microbatch_size: PositiveInt + datatype: TransformerDataType def __post_init__(self) -> None: - for field_name in ("global_batch_size", "microbatch_size"): - _positive_integer(getattr(self, field_name), field_name) - if self.datatype not in {"float16", "bfloat16", "float32", "float8"}: - raise ValueError(f"unsupported datatype: {self.datatype!r}") if self.microbatch_size > self.global_batch_size: raise ValueError("microbatch_size cannot exceed global_batch_size") @property def bytes_per_element(self) -> int: - return {"float8": 1, "float16": 2, "bfloat16": 2, "float32": 4}[self.datatype] + return transformer_element_bytes(self.datatype) @classmethod def from_mapping(cls, data: Mapping[str, Any]) -> TransformerTrainingWorkloadSpec: diff --git a/src/blueprinting/workload/transformer_inference.py b/src/blueprinting/workload/transformer_inference.py index 83f4c81..ca20c9a 100644 --- a/src/blueprinting/workload/transformer_inference.py +++ b/src/blueprinting/workload/transformer_inference.py @@ -13,39 +13,25 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass from typing import Any -from blueprinting.schema.codec import record_type +from blueprinting.schema.authoring import PositiveInt, record -from .transformer import TransformerModelSpec +from .transformer import TransformerDataType, TransformerModelSpec, transformer_element_bytes -def _positive_integer(value: Any, name: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ValueError(f"{name} must be a positive integer") - return value - - -@record_type("compiler.transformer.inference_request_spec.v1") -@dataclass(frozen=True) +@record("blueprinting.workload.transformer-inference-request") class TransformerInferenceRequestSpec: """A homogeneous request cohort before online scheduling is applied.""" - batch_size: int - prompt_tokens: int - generated_tokens: int - datatype: str = "float16" - - def __post_init__(self) -> None: - for field_name in ("batch_size", "prompt_tokens", "generated_tokens"): - _positive_integer(getattr(self, field_name), field_name) - if self.datatype not in {"float8", "float16", "bfloat16", "float32"}: - raise ValueError(f"unsupported datatype: {self.datatype!r}") + batch_size: PositiveInt + prompt_tokens: PositiveInt + generated_tokens: PositiveInt + datatype: TransformerDataType = "float16" @property def bytes_per_element(self) -> int: - return {"float8": 1, "float16": 2, "bfloat16": 2, "float32": 4}[self.datatype] + return transformer_element_bytes(self.datatype) @property def decode_iterations(self) -> int: diff --git a/tests/analysis/test_cost_model_providers.py b/tests/analysis/test_cost_model_providers.py index a2f5996..afdbb0d 100644 --- a/tests/analysis/test_cost_model_providers.py +++ b/tests/analysis/test_cost_model_providers.py @@ -25,16 +25,26 @@ cost_query_for_inference_task, estimate_inference_phase, ) -from blueprinting.analysis.cost import InvalidCostEvidenceError -from blueprinting.mapping import NetworkTierBinding, TransformerInferenceMappingSpec +from blueprinting.mapping import ( + ForwardOnly, + NetworkTierBinding, + PipelineParallel, + ReplicaParallel, + TensorParallel, + TensorParallelCommunication, + TransformerInferenceMappingSpec, + TransformerInferenceParallelism, +) +from blueprinting.schema import Err from blueprinting.schema.frozen import FrozenDict from blueprinting.synthesizer.bindings import InferencePhase from blueprinting.synthesizer.frontend import ( build_transformer_inference_model_ir, inference_synthesis_session_for, ) -from blueprinting.synthesizer.lowering import DistributeTransformerInferencePass, PlanTransformerInferencePass from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerInferencePass +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerInferencePass from blueprinting.system import SystemProfile from blueprinting.workload import TransformerModelSpec @@ -53,12 +63,20 @@ def _provenance(*, digest: str = "fixture-data") -> EvidenceProvenance: return EvidenceProvenance( source="fixture-simulator", source_revision="sim-r1", - importer="fixture-importer-v1", + importer="fixture-importer-v0", data_digest=digest, method=EstimateMethod.SIMULATED, ) +def test_cost_query_context_uses_typed_canonical_maps() -> None: + with pytest.raises(TypeError, match="CostQueryContext.implementations"): + CostQueryContext(implementations=FrozenDict({"gemm": ""})) + + with pytest.raises(TypeError, match="CostQueryContext.runtime"): + CostQueryContext(runtime=" VLLM ") + + def test_roofline_exposes_components_and_uses_max_bound(): hardware = _hardware() provider = RooflineCostProvider( @@ -160,8 +178,10 @@ def test_ambiguous_database_evidence_is_not_hidden_by_roofline_fallback(): dimensions=FrozenDict({"m": 8}), ) - with pytest.raises(InvalidCostEvidenceError, match="equally specific"): - resolver.resolve(query) + resolution = resolver.resolve(query) + assert isinstance(resolution, Err) + assert resolution.error.errors[0].code == "cost.invalid_evidence" + assert "equally specific" in resolution.error.errors[0].message def test_simulator_import_maps_units_and_communication_selectors(tmp_path: Path): @@ -397,13 +417,15 @@ def _inference_fixture(): block_count=8, ) mapping = TransformerInferenceMappingSpec( - tensor_parallel=2, - pipeline_parallel=2, - replicas=1, + TransformerInferenceParallelism( + TensorParallel(2, TensorParallelCommunication.ALL_REDUCE), + PipelineParallel(2, ForwardOnly()), + ReplicaParallel(1), + ) ) plan = ( PassManager() - .run( + .require_run( PassPipeline.of(DistributeTransformerInferencePass(), PlanTransformerInferencePass()), build_transformer_inference_model_ir(model), session=inference_synthesis_session_for( @@ -424,7 +446,7 @@ def test_inference_costing_uses_database_then_explicit_roofline_fallback(): model, mapping, plan = _inference_fixture() hardware = _hardware() network_binding = NetworkTierBinding() - attention_task = next(task for task in plan.tasks if task.workload.attributes.get("primitive") == "attention_core") + attention_task = next(task for task in plan.tasks if task.semantic.primitive == "attention_core") query = cost_query_for_inference_task( attention_task, hardware=hardware, diff --git a/tests/analysis/test_domain_contracts.py b/tests/analysis/test_domain_contracts.py index 627c1c9..c62d480 100644 --- a/tests/analysis/test_domain_contracts.py +++ b/tests/analysis/test_domain_contracts.py @@ -3,11 +3,20 @@ import pytest from blueprinting.mapping import ( + DataParallel, + ForwardOnly, + InterleavedOneForwardOneBackward, NetworkTierBinding, + PipelineParallel, + ReplicaParallel, + TensorParallel, + TensorParallelCommunication, TransformerInferenceMappingSpec, + TransformerInferenceParallelism, TransformerTrainingMappingSpec, + TransformerTrainingParallelism, ) -from blueprinting.workload import TransformerTrainingWorkloadSpec +from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec def test_legacy_aliases_may_match_but_cannot_define_conflicting_truth() -> None: @@ -57,3 +66,79 @@ def test_workload_and_network_aliases_reject_conflicts() -> None: assert NetworkTierBinding.from_mapping({"tensor_parallel_network": 1, "tensor_par_net": 1}).tensor_parallel == 1 with pytest.raises(ValueError, match="tensor_parallel_network conflicts"): NetworkTierBinding.from_mapping({"tensor_parallel_network": 1, "tensor_par_net": 0}) + + +def test_workload_scalar_domains_are_part_of_the_runtime_type_contract() -> None: + with pytest.raises(TypeError, match="TransformerTrainingWorkloadSpec.datatype"): + TransformerTrainingWorkloadSpec(8, 1, "fp16") # type: ignore[arg-type] + + with pytest.raises(TypeError, match="TransformerModelSpec.hidden_size"): + TransformerModelSpec("invalid", 0, 256, 128, 8, 8, 2) + + +def test_megatron_parallel_axes_are_exposed_as_pattern_matchable_values() -> None: + mapping = TransformerTrainingMappingSpec.from_mapping( + { + "tensor_parallel": 4, + "pipeline_parallel": 8, + "data_parallel": 2, + "recompute": "full", + "pipeline_interleaving": 3, + "optimizer_sharding": True, + "tensor_parallel_communication": "rs_ag", + } + ) + + match mapping.parallelism: + case TransformerTrainingParallelism( + TensorParallel(4, TensorParallelCommunication.REDUCE_SCATTER_ALL_GATHER), + PipelineParallel(8, InterleavedOneForwardOneBackward(3)), + DataParallel(2, True), + recompute, + ): + assert recompute.value == "full" + case unexpected: # pragma: no cover - diagnostic if the ADT shape regresses + pytest.fail(f"unexpected training strategy: {unexpected!r}") + + inference = TransformerInferenceMappingSpec( + TransformerInferenceParallelism( + TensorParallel(4, TensorParallelCommunication.ALL_REDUCE), + PipelineParallel(2, ForwardOnly()), + ReplicaParallel(3), + ) + ) + match inference.parallelism: + case TransformerInferenceParallelism( + TensorParallel(4), + PipelineParallel(2, ForwardOnly()), + ReplicaParallel(3), + ): + pass + case unexpected: # pragma: no cover + pytest.fail(f"unexpected inference strategy: {unexpected!r}") + + +def test_pipeline_strategy_rejects_unrepresentable_static_partitions() -> None: + mapping = TransformerTrainingMappingSpec.from_mapping( + { + "tensor_parallel": 1, + "pipeline_parallel": 4, + "data_parallel": 1, + "recompute": "none", + "pipeline_interleaving": 2, + "optimizer_sharding": False, + "tensor_parallel_communication": "ar", + } + ) + model = TransformerModelSpec( + "pipeline-fixture", + hidden_size=64, + feedforward_size=256, + sequence_length=128, + attention_heads=8, + attention_head_size=8, + block_count=12, + ) + + with pytest.raises(ValueError, match="interleaving must divide"): + mapping.validate_model(model) diff --git a/tests/analysis/test_package_boundary.py b/tests/analysis/test_package_boundary.py index 24802b3..46cc8b0 100644 --- a/tests/analysis/test_package_boundary.py +++ b/tests/analysis/test_package_boundary.py @@ -10,8 +10,11 @@ import blueprinting.analysis as analysis import blueprinting.mapping as mapping import blueprinting.schema as schema +import blueprinting.schema.authoring as schema_authoring import blueprinting.synthesizer as synthesizer import blueprinting.synthesizer.frontend as frontend +import blueprinting.synthesizer.passes as passes +import blueprinting.synthesizer.passes.authoring as pass_authoring import blueprinting.system as system import blueprinting.workload as workload @@ -66,6 +69,15 @@ def test_synthesizer_is_the_only_formal_synthesis_package() -> None: def test_legacy_public_symbols_are_not_reexported() -> None: assert not hasattr(synthesizer, "CompilationSession") assert not hasattr(synthesizer, "CompilerError") + assert not hasattr(analysis, "InferenceCostProvider") + + +def test_decorator_authoring_surfaces_are_explicitly_separated() -> None: + assert not any(hasattr(schema, name) for name in ("record", "adt", "variant", "record_type", "enum_type")) + assert all(hasattr(schema_authoring, name) for name in ("record", "enum", "adt", "variant", "seal_adt")) + assert not any(hasattr(passes, name) for name in ("derivation", "relation", "claim", "rule")) + assert all(hasattr(pass_authoring, name) for name in ("derivation", "relation", "claim")) + assert not hasattr(pass_authoring, "rule") def test_workload_and_system_are_top_level_domain_packages() -> None: @@ -90,6 +102,41 @@ def test_domain_ownership_is_not_hidden_by_compatibility_reexports() -> None: assert not hasattr(analysis, "PrimitiveInvocation") +def test_canonical_wire_identities_are_domain_owned_and_versionless() -> None: + tags: list[str] = [] + adt_families: list[str] = [] + low_level_codec_authors: list[str] = [] + for source in PACKAGE_ROOT.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for class_node in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)): + for decorator in class_node.decorator_list: + if not isinstance(decorator, ast.Call): + continue + name = decorator.func.id if isinstance(decorator.func, ast.Name) else None + if name in {"record", "enum", "record_type", "enum_type"}: + assert ( + decorator.args + and isinstance(decorator.args[0], ast.Constant) + and isinstance(decorator.args[0].value, str) + ) + tags.append(decorator.args[0].value) + if name in {"record_type", "enum_type"} and source.parent != PACKAGE_ROOT / "schema": + low_level_codec_authors.append(str(source.relative_to(PACKAGE_ROOT))) + assert all(keyword.arg != "field_aliases" for keyword in decorator.keywords) + elif name == "adt": + arguments = {keyword.arg: keyword.value for keyword in decorator.keywords} + wire = arguments.get("wire") + assert isinstance(wire, ast.Constant) and isinstance(wire.value, str) + assert "version" not in arguments + adt_families.append(wire.value) + + assert len(tags) >= 70 + assert low_level_codec_authors == [] + assert all(tag.startswith("blueprinting.") and "_" not in tag for tag in tags) + assert all(not tag.rpartition(".")[2].removeprefix("v").isdigit() for tag in tags) + assert all(wire.startswith("blueprinting.") and "_" not in wire for wire in adt_families) + + def test_supported_architecture_dependencies_are_acyclic_and_layered() -> None: _assert_only_domain_dependencies("schema", ("schema",)) _assert_only_domain_dependencies("workload", ("schema", "workload")) @@ -108,3 +155,23 @@ def test_supported_architecture_dependencies_are_acyclic_and_layered() -> None: def test_validation_is_outside_the_synthesizer_dependency_closure() -> None: assert importlib.util.find_spec("blueprinting.validation") is not None assert not any(name.startswith("blueprinting.validation") for name in _imports("synthesizer")) + + +def test_calculon_is_confined_to_one_post_derivation_validation_adapter() -> None: + importers = [] + for source in PACKAGE_ROOT.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + imports_calculon = any( + (isinstance(node, ast.Import) and any(alias.name == "calculon" for alias in node.names)) + or ( + isinstance(node, ast.ImportFrom) + and node.level == 0 + and node.module is not None + and node.module.startswith("calculon") + ) + for node in ast.walk(tree) + ) + if imports_calculon: + importers.append(source.relative_to(PACKAGE_ROOT).as_posix()) + + assert importers == ["validation/calculon.py"] diff --git a/tests/analysis/test_system_profile.py b/tests/analysis/test_system_profile.py index 53b9283..18ca2e7 100644 --- a/tests/analysis/test_system_profile.py +++ b/tests/analysis/test_system_profile.py @@ -45,7 +45,7 @@ def test_system_profile_round_trip_preserves_legacy_wire_identity() -> None: restored = canonical_loads(payload) assert restored == profile - assert '"$type":"compiler.analysis.hardware_profile.v1"' in payload + assert '"$type":"blueprinting.system.profile"' in payload def test_interconnect_rejects_participant_counts_beyond_its_capacity() -> None: diff --git a/tests/application/test_analysis_service.py b/tests/application/test_analysis_service.py index cee906d..cf95361 100644 --- a/tests/application/test_analysis_service.py +++ b/tests/application/test_analysis_service.py @@ -71,11 +71,19 @@ def test_analysis_service_is_the_complete_client_boundary() -> None: assert all(stage.valid for stage in report.stages) assert report.tasks assert report.evidence_revision == report.evidence["revision"] + assert report.schema == "blueprinting.analysis-report.v0" + assert tuple(stage.stage.value for stage in report.derivation_trace.stages) == ( + "model", + "distributed", + "portable", + ) + assert len(report.derivation_trace.transitions) == 2 + assert report.derivation_trace.overlays[0].stage_digest == report.plan_digest def test_analysis_service_accepts_canonical_mapping_names_and_sweeps_them() -> None: - legacy = _draft() - execution = dict(legacy.execution_data.items()) + preset = _draft() + execution = dict(preset.execution_data.items()) for canonical, alias in ( ("tensor_parallel", "tensor_par"), ("pipeline_parallel", "pipeline_par"), @@ -85,12 +93,12 @@ def test_analysis_service_accepts_canonical_mapping_names_and_sweeps_them() -> N ): execution[canonical] = execution.pop(alias) canonical = AnalysisDraft.from_mappings( - model_name=legacy.model_name, - model_data=dict(legacy.model_data.items()), - execution_name=legacy.execution_name, + model_name=preset.model_name, + model_data=dict(preset.model_data.items()), + execution_name=preset.execution_name, execution_data=execution, - hardware_name=legacy.hardware_name, - hardware_data=dict(legacy.hardware_data.items()), + hardware_name=preset.hardware_name, + hardware_data=dict(preset.hardware_data.items()), ) outcome = BlueprintingService().analyze(canonical.with_parallelism(4, 1, 2)) diff --git a/tests/application/test_inference_analysis_service.py b/tests/application/test_inference_analysis_service.py index 328bab0..f743a01 100644 --- a/tests/application/test_inference_analysis_service.py +++ b/tests/application/test_inference_analysis_service.py @@ -80,18 +80,18 @@ def test_single_generated_token_stops_after_prefill(): def test_service_accepts_canonical_inference_mapping_names(): - legacy = _draft(generated_tokens=1) - execution = dict(legacy.execution_data.items()) + preset = _draft(generated_tokens=1) + execution = dict(preset.execution_data.items()) execution["tensor_parallel"] = execution.pop("tensor_par") execution["pipeline_parallel"] = execution.pop("pipeline_par") canonical = InferenceAnalysisDraft.from_mappings( - model_name=legacy.model_name, - model_data=dict(legacy.model_data.items()), - execution_name=legacy.execution_name, + model_name=preset.model_name, + model_data=dict(preset.model_data.items()), + execution_name=preset.execution_name, execution_data=execution, - request_data=dict(legacy.request_data.items()), - hardware_name=legacy.hardware_name, - hardware_data=dict(legacy.hardware_data.items()), + request_data=dict(preset.request_data.items()), + hardware_name=preset.hardware_name, + hardware_data=dict(preset.hardware_data.items()), ) outcome = BlueprintingService().analyze_inference(canonical) @@ -132,7 +132,7 @@ def test_service_routes_resolver_evidence_and_uncertainty_to_task_reports(): provenance = EvidenceProvenance( source="fixture-simulator", source_revision="sim-r1", - importer="fixture-importer-v1", + importer="fixture-importer-v0", data_digest="fixture-data", method=EstimateMethod.SIMULATED, ) @@ -172,16 +172,3 @@ def test_service_routes_resolver_evidence_and_uncertainty_to_task_reports(): assert attention.evidence_assumptions assert fallback.evidence_method == "analytical" assert outcome.report.evidence["cost_resolver_revision"] == resolver.revision - - -def test_service_rejects_legacy_provider_and_resolver_together(): - draft = _draft(generated_tokens=1) - hardware = SystemProfile.from_mapping( - draft.hardware_name, - dict(draft.hardware_data.items()), - datatype="float16", - ) - resolver = CostResolver((RooflineCostProvider(hardware),)) - - with pytest.raises(ValueError, match="mutually exclusive"): - BlueprintingService(inference_cost_provider=object(), inference_cost_resolver=resolver) diff --git a/tests/docs/test_code_documentation.py b/tests/docs/test_code_documentation.py new file mode 100644 index 0000000..82d7707 --- /dev/null +++ b/tests/docs/test_code_documentation.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import importlib +import inspect +from pathlib import Path + +from blueprinting.synthesizer.passes import DerivationPass + +_ROOT = Path(__file__).resolve().parents[2] +_PASS_MODULES = ( + "blueprinting.synthesizer.stages.model.passes", + "blueprinting.synthesizer.stages.distributed.passes", + "blueprinting.synthesizer.stages.portable_plan.passes", + "blueprinting.synthesizer.stages.concrete_plan.passes", + "blueprinting.synthesizer.stages.machine.passes", +) +_IR_PAGES = { + "model-ir": "blueprinting.synthesizer.stages.model.ir", + "distributed-task-ir": "blueprinting.synthesizer.stages.distributed.ir", + "portable-plan-ir": "blueprinting.synthesizer.stages.portable_plan.ir", + "concrete-plan-ir": "blueprinting.synthesizer.stages.concrete_plan.ir", + "machine-ir": "blueprinting.synthesizer.stages.machine.ir", +} + + +def _public_passes() -> tuple[type[DerivationPass], ...]: + result = [] + for module_name in _PASS_MODULES: + module = importlib.import_module(module_name) + for name in module.__all__: + value = getattr(module, name) + if inspect.isclass(value) and issubclass(value, DerivationPass): + assert value.__module__ == module_name, f"{name} must be defined by its owning stage, not forwarded" + result.append(value) + return tuple(result) + + +def test_every_public_stage_pass_has_renderable_theory_and_provenance() -> None: + passes = _public_passes() + assert {item.__name__ for item in passes} == { + "DistributeTransformerTrainingPass", + "DistributeTransformerInferencePass", + "PlanTransformerTrainingPass", + "PlanTransformerInferencePass", + "BindReferenceQueueTargetPass", + "BindReferenceSlotTargetPass", + } + + for pass_type in passes: + doc = inspect.getdoc(pass_type) or "" + assert doc.count("$$") >= 2, f"{pass_type.__name__} needs a display equation in its source docstring" + assert "References:" in doc, f"{pass_type.__name__} needs explicit provenance" + assert "https://arxiv.org/" in doc or "no paper" in doc, ( + f"{pass_type.__name__} must cite primary research or explicitly disclaim a paper-derived algorithm" + ) + + +def test_pass_api_page_covers_every_public_stage_pass_in_both_locales() -> None: + for locale in ("en", "zh"): + page = (_ROOT / f"docs/reference/passes.{locale}.md").read_text() + for pass_type in _public_passes(): + identifier = f"{pass_type.__module__}.{pass_type.__name__}" + assert identifier in page + + +def test_every_canonical_ir_stage_has_a_bilingual_generated_api_page() -> None: + for page_name, module_name in _IR_PAGES.items(): + for locale in ("en", "zh"): + page = (_ROOT / f"docs/reference/{page_name}.{locale}.md").read_text() + assert f"::: {module_name}" in page + + +def test_mkdocs_enables_source_api_and_math_rendering() -> None: + config = (_ROOT / "mkdocs.yml").read_text() + assert "- mkdocstrings:" in config + assert "- pymdownx.arithmatex:" in config + assert "- javascripts/mathjax.js" in config + assert "reference/passes.md" in config + mathjax = (_ROOT / "docs/javascripts/mathjax.js").read_text() + assert "MathJax.typesetPromise()" in mathjax + workflow = (_ROOT / ".github/workflows/docs.yml").read_text() + assert "scripts/check_rendered_code_docs.py" in workflow + assert '"src/blueprinting/**"' in workflow diff --git a/tests/regression/test_baseline_quality_gate.py b/tests/regression/test_baseline_quality_gate.py index 91fdcba..a51311e 100644 --- a/tests/regression/test_baseline_quality_gate.py +++ b/tests/regression/test_baseline_quality_gate.py @@ -34,7 +34,7 @@ def test_inference_vidur_baseline_regression_gate(): def test_regression_gate_reports_every_failed_predicate(): gate = BaselineRegressionGate( - schema="blueprinting.baseline-regression-gate.v1", + schema="blueprinting.baseline-regression-gate.v0", domain="test/baseline", checks=( RegressionCheck("first", False, "<= 1", 2), diff --git a/tests/synthesizer/conftest.py b/tests/synthesizer/conftest.py index a5b87ce..ec97d50 100644 --- a/tests/synthesizer/conftest.py +++ b/tests/synthesizer/conftest.py @@ -15,52 +15,61 @@ TokenId, ValueId, ) -from blueprinting.synthesizer.ir import ( - AbstractStorageClass, +from blueprinting.synthesizer.stages.common import OperationName, TensorType, make_header +from blueprinting.synthesizer.stages.concrete_plan.ir import ( AccessMode, BufferBinding, BufferUse, - CollectiveKind, - CollectiveSpec, - CommandKind, + CollectiveCommand, ConcreteCommand, ConcretePlanIR, DevicePlacement, + ImplementationRef, + Launch, + MemoryRegion, + QueueIssueOrder, + QueueKind, + QueueScheduleExtension, + QueueSpec, + SignalAfter, + WaitFor, +) +from blueprinting.synthesizer.stages.distributed.ir import ( + Collective, + CollectiveKind, DistributedTask, DistributedTaskIR, - DistributedTaskKind, DistributedValue, - ImplementationRef, - ImplementationRequirement, + LocalCompute, LogicalMesh, + MeshAxis, + ReductionKind, + ShardingSpec, + make_collective_spec, +) +from blueprinting.synthesizer.stages.machine.ir import ( MachineEntryPoint, MachineInstruction, MachineIR, MachineOpcode, MachineSection, MachineSectionKind, - MemoryRegion, - MeshAxis, - ModelIR, - ModelOperation, - ModelValue, +) +from blueprinting.synthesizer.stages.model.ir import ModelIR, ModelOperation, ModelValue, ValueRole +from blueprinting.synthesizer.stages.portable_plan.ir import ( + AbstractStorageClass, + CollectiveTask, + ComputeTask, + ImplementationRequirement, ObjectiveDirection, ObjectiveKind, - OperationName, PlanBuffer, PlanBufferRole, PlanObjective, PlanTask, - PlanTaskKind, PortablePlanIR, - QueueKind, - QueueSpec, - ReductionKind, ResourceKind, ResourceRequirement, - ShardingSpec, - TensorType, - ValueRole, WorkloadFacts, ) @@ -147,7 +156,7 @@ def distributed_ir(model_ir: ModelIR) -> DistributedTaskIR: tasks=( DistributedTask( compute_id, - DistributedTaskKind.LOCAL_COMPUTE, + LocalCompute(), OperationName("core", "matmul"), (0, 1), (input_id, weight_id), @@ -157,23 +166,29 @@ def distributed_ir(model_ir: ModelIR) -> DistributedTaskIR: ), DistributedTask( collective_id, - DistributedTaskKind.COLLECTIVE, + Collective( + make_collective_spec( + CollectiveKind.ALL_REDUCE, + (0, 1), + 64, + reduction=ReductionKind.SUM, + ) + ), OperationName("collective", "all_reduce"), (0, 1), (partial_id,), (output_id,), (compute_id,), Lineage.lowered("insert-collective", (model_ir.operations[0].id,)), - collective=CollectiveSpec( - CollectiveKind.ALL_REDUCE, - (0, 1), - 64, - reduction=ReductionKind.SUM, - ), ), ), inputs=(input_id, weight_id), outputs=(output_id,), + header=make_header( + DistributedTaskIR.SCHEMA_NAME, + DistributedTaskIR.SCHEMA_VERSION, + parent_digests=(model_ir.digest,), + ), ) @@ -189,11 +204,11 @@ def portable_ir(distributed_ir: DistributedTaskIR) -> PortablePlanIR: name="fixture-portable", source_distributed_digest=distributed_ir.digest, strategy_fingerprint="a" * 40, - planner_revision="fixture-planner-v1", + planner_revision="fixture-planner-v0", tasks=( PlanTask( compute_id, - PlanTaskKind.COMPUTE, + ComputeTask(), OperationName("core", "matmul"), (), (input_id, weight_id), @@ -207,7 +222,7 @@ def portable_ir(distributed_ir: DistributedTaskIR) -> PortablePlanIR: ), PlanTask( collective_id, - PlanTaskKind.COLLECTIVE, + CollectiveTask(), OperationName("collective", "all_reduce"), (compute_id,), (partial_id,), @@ -262,6 +277,11 @@ def portable_ir(distributed_ir: DistributedTaskIR) -> PortablePlanIR: inputs=(input_id, weight_id), outputs=(output_id,), objectives=(PlanObjective(ObjectiveKind.LATENCY, ObjectiveDirection.MINIMIZE),), + header=make_header( + PortablePlanIR.SCHEMA_NAME, + PortablePlanIR.SCHEMA_VERSION, + parent_digests=(distributed_ir.digest,), + ), ) @@ -282,9 +302,9 @@ def concrete_ir(portable_ir: PortablePlanIR) -> ConcretePlanIR: source_portable_digest=portable_ir.digest, target_fingerprint="b" * 40, deployment_fingerprint="c" * 40, - abi_revision="virtual-abi-v1", - evidence_revision="fixture-evidence-v1", - planner_revision="fixture-planner-v1", + abi_revision="virtual-abi-v0", + evidence_revision="fixture-evidence-v0", + planner_revision="fixture-planner-v0", devices=( DevicePlacement(device_0, 0, "virtual:0"), DevicePlacement(device_1, 1, "virtual:1"), @@ -306,32 +326,39 @@ def concrete_ir(portable_ir: PortablePlanIR) -> ConcretePlanIR: commands=( ConcreteCommand( compute_command, - CommandKind.LAUNCH, + Launch(ImplementationRef("virtual", "matmul", "0", "virtual-abi-v0"), compute_queue), (), - compute_queue, - ImplementationRef("virtual", "matmul", "1", "virtual-abi-v1"), ( BufferUse(input_buffer, AccessMode.READ), BufferUse(weight_buffer, AccessMode.READ), BufferUse(partial_buffer, AccessMode.WRITE), ), Lineage.lowered("bind-command", (portable_ir.tasks[0].id,)), - signal_tokens=(ready,), + SignalAfter((ready,)), ), ConcreteCommand( collective_command, - CommandKind.COLLECTIVE, + CollectiveCommand(ImplementationRef("virtual", "all-reduce", "0", "virtual-abi-v0"), collective_queue), (compute_command,), - collective_queue, - ImplementationRef("virtual", "all-reduce", "1", "virtual-abi-v1"), ( BufferUse(partial_buffer, AccessMode.READ), BufferUse(output_buffer, AccessMode.WRITE), ), Lineage.lowered("bind-command", (portable_ir.tasks[1].id,)), - wait_tokens=(ready,), + WaitFor((ready,)), ), ), + target_extension=QueueScheduleExtension( + ( + QueueIssueOrder(compute_queue, (compute_command,)), + QueueIssueOrder(collective_queue, (collective_command,)), + ) + ), + header=make_header( + ConcretePlanIR.SCHEMA_NAME, + ConcretePlanIR.SCHEMA_VERSION, + parent_digests=(portable_ir.digest,), + ), ) @@ -345,8 +372,8 @@ def machine_ir(concrete_ir: ConcretePlanIR) -> MachineIR: target_fingerprint=concrete_ir.target_fingerprint, target_plugin="virtual", target_abi=concrete_ir.abi_revision, - emitter_revision="fixture-emitter-v1", - program_format="virtual-json-v1", + emitter_revision="fixture-emitter-v0", + program_format="virtual-json-v0", sections=( MachineSection( ".text", @@ -379,4 +406,9 @@ def machine_ir(concrete_ir: ConcretePlanIR) -> MachineIR: ), ), entry_points=(MachineEntryPoint("main", launch_id),), + header=make_header( + MachineIR.SCHEMA_NAME, + MachineIR.SCHEMA_VERSION, + parent_digests=(concrete_ir.digest,), + ), ) diff --git a/tests/synthesizer/test_bindings.py b/tests/synthesizer/test_bindings.py index 021bf11..0442992 100644 --- a/tests/synthesizer/test_bindings.py +++ b/tests/synthesizer/test_bindings.py @@ -2,46 +2,57 @@ import pytest -from blueprinting.schema import FrozenDict, SerializationError, canonical_dumps, canonical_loads +from blueprinting.schema import FrozenDict from blueprinting.synthesizer import ( - BindingAxis, BindingError, DeploymentProfile, - Symbol, + InferencePhase, + InferenceWorkload, + StrategyBinding, SynthesisSession, TargetProfile, TargetRequirements, WorkloadBinding, - WorkloadMode, ) -from blueprinting.synthesizer.ir import PortablePlanIR +from blueprinting.synthesizer.dialects.transformer import TransformerInferenceWorkloadSemantic +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR def _target(name: str, architecture: str) -> TargetProfile: return TargetProfile( name=name, architecture=architecture, - architecture_revision="1", + architecture_revision="0", runtime_stack="fixture-runtime", - runtime_revision="1", - target_abi="fixture-abi-v1", + runtime_revision="0", + target_abi="fixture-abi-v0", supported_dtypes=frozenset({"f16"}), supported_collectives=frozenset({"all_reduce"}), capabilities=frozenset({"matrix_multiply"}), ) -def test_workload_binding_only_accepts_finite_workload_symbols() -> None: - batch = Symbol("batch", BindingAxis.WORKLOAD, positive=True) +def test_workload_binding_has_one_typed_source_for_workload_facts() -> None: + mode = InferenceWorkload(InferencePhase.PREFILL) + semantic = TransformerInferenceWorkloadSemantic(4, 16, "float16") + binding = WorkloadBinding(mode, semantic) - assert WorkloadBinding(WorkloadMode.INFERENCE, batch_size=batch).batch_size is batch - with pytest.raises(BindingError, match="workload symbols"): + assert binding.semantic is semantic + assert not hasattr(binding, "batch_size") + with pytest.raises(TypeError, match="batch_size"): + WorkloadBinding(mode, semantic, batch_size=4) # type: ignore[call-arg] + + +def test_binding_semantics_cannot_be_smuggled_through_attributes() -> None: + with pytest.raises(BindingError, match="typed semantic field"): WorkloadBinding( - WorkloadMode.INFERENCE, - batch_size=Symbol("target_batch", BindingAxis.TARGET), + InferenceWorkload(InferencePhase.DECODE), + attributes=FrozenDict({"nested": FrozenDict({"query_tokens": 8})}), ) - with pytest.raises(BindingError, match="finite"): - WorkloadBinding(WorkloadMode.INFERENCE, batch_size=float("nan")) + with pytest.raises(BindingError, match="typed semantic field"): + StrategyBinding(attributes=FrozenDict({"mapping_spec": "legacy"})) + with pytest.raises(BindingError, match="typed semantic field"): + StrategyBinding(attributes=FrozenDict({"nested": FrozenDict({"tensor_parallel": 8})})) def test_target_and_deployment_requirements_are_checked() -> None: @@ -52,12 +63,12 @@ def test_target_and_deployment_requirements_are_checked() -> None: required_collectives=frozenset({"all_reduce"}), required_capabilities=frozenset({"matrix_multiply"}), ) - target = _target("virtual", "virtual-v1") + target = _target("virtual", "virtual-abi-v0") deployment = DeploymentProfile( "fixture-deployment", 2, FrozenDict({"links": ((0, 1),)}), - "environment-v1", + "environment-v0", available_memory_bytes=(80, 96), ) @@ -67,7 +78,7 @@ def test_target_and_deployment_requirements_are_checked() -> None: "too-small", 1, FrozenDict(), - "environment-v1", + "environment-v0", available_memory_bytes=(128,), ).satisfies(requirements) @@ -75,43 +86,9 @@ def test_target_and_deployment_requirements_are_checked() -> None: def test_target_binding_changes_session_not_portable_plan( portable_ir: PortablePlanIR, ) -> None: - first = SynthesisSession().with_binding(_target("virtual-a", "virtual-v1")) - second = SynthesisSession().with_binding(_target("virtual-b", "virtual-v2")) + first = SynthesisSession().with_binding(_target("virtual-a", "virtual-abi-v0")) + second = SynthesisSession().with_binding(_target("virtual-b", "virtual-abi-v0")) portable_digest = portable_ir.digest assert first.fingerprint != second.fingerprint assert portable_ir.digest == portable_digest - - -LEGACY_TARGET_PROFILE_JSON = ( - '{"$type":"compiler.binding.target","fields":{' - '"architecture":"virtual","architecture_revision":"1",' - '"attributes":{"$map":[]},"capabilities":{"$frozenset":[]},' - '"collective_library":"none","compiler_abi":"abi-v1",' - '"execution_engines":{"$frozenset":[]},"kernel_library":"none",' - '"memory_spaces":{"$frozenset":[]},"name":"fixture",' - '"runtime_revision":"1","runtime_stack":"runtime",' - '"supported_collectives":{"$frozenset":[]},' - '"supported_dtypes":{"$frozenset":[]},' - '"supported_operations":{"$frozenset":[]}}}' -) - - -def test_legacy_target_profile_field_decodes_and_reencodes_canonically() -> None: - target = canonical_loads(LEGACY_TARGET_PROFILE_JSON) - - assert isinstance(target, TargetProfile) - assert target.target_abi == "abi-v1" - encoded = canonical_dumps(target) - assert '"target_abi":"abi-v1"' in encoded - assert '"compiler_abi"' not in encoded - - -def test_target_profile_rejects_legacy_and_current_field_together() -> None: - conflicting = LEGACY_TARGET_PROFILE_JSON.replace( - '"compiler_abi":"abi-v1",', - '"compiler_abi":"abi-v1","target_abi":"abi-v1",', - ) - - with pytest.raises(SerializationError, match="both a current field and its legacy alias"): - canonical_loads(conflicting) diff --git a/tests/synthesizer/test_canonical_ir.py b/tests/synthesizer/test_canonical_ir.py index 43a9489..33d8fe9 100644 --- a/tests/synthesizer/test_canonical_ir.py +++ b/tests/synthesizer/test_canonical_ir.py @@ -5,15 +5,16 @@ import pytest -from blueprinting.schema import FrozenDict, SerializationError, canonical_dumps, canonical_loads, record_type -from blueprinting.synthesizer import NodeId -from blueprinting.synthesizer.ir import ( - ConcretePlanIR, - DistributedTaskIR, - MachineIR, - ModelIR, - PortablePlanIR, -) +from blueprinting.schema import FrozenDict, SerializationError, canonical_dumps, canonical_loads +from blueprinting.schema.codec import record_type +from blueprinting.synthesizer import BindingAxis, NodeId, Symbol +from blueprinting.synthesizer.errors import InvalidIdError +from blueprinting.synthesizer.stages.common import IRHeader, IRSnapshot +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR, require_concrete_quantity @pytest.mark.parametrize( @@ -31,9 +32,9 @@ def test_all_canonical_irs_round_trip(request: pytest.FixtureRequest, fixture_na serialized = ir.to_json() - assert ir.verify().ok - assert ir_type.from_json(serialized) == ir - assert ir_type.from_json(serialized).digest == ir.digest + assert ir.verify().is_ok + assert ir_type.from_json(serialized).or_raise() == ir + assert ir_type.from_json(serialized).or_raise().digest == ir.digest assert ir.to_json() == serialized assert '"content_digest"' in serialized @@ -41,7 +42,30 @@ def test_all_canonical_irs_round_trip(request: pytest.FixtureRequest, fixture_na def test_model_snapshot_has_golden_text_digest(model_ir: ModelIR) -> None: textual_digest = hashlib.sha256(model_ir.to_json().encode("utf-8")).hexdigest() - assert textual_digest == "25974dbb92796fa575ff258df15ca59988b8f86160e05c2efd3fced828932d5e" + assert textual_digest == "b9773940c665731f0e94179b41aa5a90a47991b801477bae4b275e0a268fd41b" + + +def test_same_version_snapshot_without_typed_semantics_epoch_is_rejected(model_ir: ModelIR) -> None: + old_header = IRHeader( + model_ir.SCHEMA_NAME, + model_ir.SCHEMA_VERSION, + producer_version=model_ir.header.producer_version, + feature_set=frozenset(), + ) + old_model = replace(model_ir, header=old_header) + payload = canonical_dumps( + IRSnapshot( + schema_name=old_model.SCHEMA_NAME, + schema_version=old_model.SCHEMA_VERSION, + producer_version=old_header.producer_version, + feature_set=frozenset(), + content_digest=old_model.digest, + payload=old_model, + ) + ) + + with pytest.raises(SerializationError, match="typed-semantics"): + ModelIR.require_from_json(payload) def test_snapshot_digest_detects_payload_tampering(model_ir: ModelIR) -> None: @@ -49,21 +73,21 @@ def test_snapshot_digest_detects_payload_tampering(model_ir: ModelIR) -> None: tampered = serialized.replace("fixture-model", "tampered-model", 1) with pytest.raises(SerializationError, match="digest mismatch"): - ModelIR.from_json(tampered) + ModelIR.require_from_json(tampered) def test_snapshot_schema_is_checked(model_ir: ModelIR) -> None: with pytest.raises(SerializationError, match="is not blueprinting.portable-plan"): - PortablePlanIR.from_json(model_ir.to_json()) + PortablePlanIR.require_from_json(model_ir.to_json()) def test_snapshot_envelope_metadata_cannot_diverge_from_payload(model_ir: ModelIR) -> None: serialized = model_ir.to_json() - prefix, separator, suffix = serialized.rpartition('"producer_version":"0.1.0"') - tampered = prefix + separator.replace("0.1.0", "9.9.9") + suffix + prefix, separator, suffix = serialized.rpartition('"producer_version":"0.0.0"') + tampered = prefix + separator.replace("0.0.0", "9.9.9") + suffix with pytest.raises(SerializationError, match="envelope metadata"): - ModelIR.from_json(tampered) + ModelIR.require_from_json(tampered) def test_codec_is_closed_world() -> None: @@ -80,6 +104,14 @@ def test_codec_rejects_duplicate_keys_nonfinite_values_and_nonstring_map_keys() canonical_dumps({1: "invalid"}) +def test_codec_decodes_canonical_maps_as_frozen_values() -> None: + decoded = canonical_loads(canonical_dumps(FrozenDict({"nested": {"values": [1, 2]}}))) + + assert isinstance(decoded, FrozenDict) + assert isinstance(decoded["nested"], FrozenDict) + assert decoded["nested"]["values"] == (1, 2) + + def test_codec_registration_requires_frozen_records() -> None: @dataclass class MutableRecord: @@ -91,7 +123,7 @@ class MutableRecord: def test_ir_snapshot_is_deeply_immutable(model_ir: ModelIR) -> None: source = {"nested": {"labels": ["initial"]}} - snapshot = replace(model_ir, attributes=source) + snapshot = replace(model_ir, attributes=FrozenDict(source)) digest = snapshot.digest source["nested"]["labels"].append("mutated") @@ -104,8 +136,29 @@ def test_ir_snapshot_is_deeply_immutable(model_ir: ModelIR) -> None: snapshot.attributes["new"] = "value" # type: ignore[index] +def test_canonical_record_construction_is_strict(model_ir: ModelIR) -> None: + with pytest.raises(TypeError, match="ModelIR.attributes"): + replace(model_ir, attributes={"mutable": True}) # type: ignore[arg-type] + + def test_typed_ids_are_deterministic_and_namespace_separated() -> None: assert NodeId.derive("fixture", 1) == NodeId.derive("fixture", 1) assert str(NodeId.derive("fixture", 1)).startswith("node:") assert NodeId.derive("fixture", 1) != NodeId.derive("fixture", 2) assert FrozenDict({"id": NodeId.derive("fixture", 1)}) == FrozenDict({"id": NodeId.derive("fixture", 1)}) + + +def test_typed_ids_preserve_base_invariants() -> None: + with pytest.raises(InvalidIdError, match="invalid NodeId"): + NodeId("bad") + + +def test_concrete_workload_quantity_gate_rejects_symbolic_float_and_negative_values() -> None: + assert require_concrete_quantity(0, "operations") == 0 + + with pytest.raises(TypeError, match="concrete integer"): + require_concrete_quantity(1.0, "operations") + with pytest.raises(TypeError, match="concrete integer"): + require_concrete_quantity(Symbol("batch", BindingAxis.WORKLOAD), "operations") + with pytest.raises(ValueError, match="non-negative"): + require_concrete_quantity(-1, "operations") diff --git a/tests/synthesizer/test_derivation_views.py b/tests/synthesizer/test_derivation_views.py new file mode 100644 index 0000000..3ea4e05 --- /dev/null +++ b/tests/synthesizer/test_derivation_views.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import json +from dataclasses import replace + +import pytest + +from blueprinting.application import ( + CanonicalIRStage, + DerivationDebugBundleCodec, + DerivationStage, + DerivationTrace, + DerivationTransition, + PassContractView, + boundary_view, + graph_view, +) +from blueprinting.schema.errors import SerializationError +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR +from blueprinting.workbench.ir_expressions import ( + canonical_ir_expression, + lowering_correspondence_rows, + lowering_expression, +) +from blueprinting.workbench.presentation import semantic_boundary_rows + + +def _contract(name: str, source: object, target: object) -> PassContractView: + return PassContractView( + name=name, + input_schema=type(source).__name__, + output_schema=type(target).__name__, + required_bindings=(), + required_analyses=(), + produced_analyses=(), + mutation_model="immutable", + verification="both", + deterministic=True, + ) + + +def _full_trace( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, + portable_ir: PortablePlanIR, + concrete_ir: ConcretePlanIR, + machine_ir: MachineIR, +) -> DerivationTrace: + rows = ( + (CanonicalIRStage.MODEL, "model", model_ir), + (CanonicalIRStage.DISTRIBUTED, "distributed", distributed_ir), + (CanonicalIRStage.PORTABLE, "portable", portable_ir), + (CanonicalIRStage.CONCRETE, "concrete", concrete_ir), + (CanonicalIRStage.MACHINE, "machine", machine_ir), + ) + stages = tuple(DerivationStage(stage, "training", label, label, 1, ir) for stage, label, ir in rows) + transitions = [] + for source, target in zip(stages, stages[1:]): + contract = _contract(target.pass_name, source.ir, target.ir) + transitions.append( + DerivationTransition( + source.digest, + target.digest, + contract, + 1, + boundary_view(source.ir, target.ir, contract.name), + ) + ) + return DerivationTrace("request", "session", stages, tuple(transitions)) + + +def test_all_five_canonical_layers_have_structural_graph_adapters( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, + portable_ir: PortablePlanIR, + concrete_ir: ConcretePlanIR, + machine_ir: MachineIR, +) -> None: + graphs = tuple(graph_view(item) for item in (model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir)) + + assert tuple(item.stage for item in graphs) == tuple(CanonicalIRStage) + assert all(item.nodes for item in graphs) + assert all(item.edges for item in graphs) + assert {item.ref.kind for item in graphs[3].nodes} >= {"device", "queue", "buffer", "command"} + assert {item.ref.kind for item in graphs[4].nodes} >= {"section", "instruction", "entry_point"} + + +def test_all_five_canonical_layers_have_short_and_detailed_typed_expressions( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, + portable_ir: PortablePlanIR, + concrete_ir: ConcretePlanIR, + machine_ir: MachineIR, +) -> None: + expressions = tuple( + canonical_ir_expression(item) for item in (model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir) + ) + + assert [item.short.split(maxsplit=1)[0] for item in expressions] == [ + "model", + "distributed", + "portable_plan", + "concrete_plan", + "machine_program", + ] + for ir, expression in zip((model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir), expressions): + assert str(ir.header.schema_version) in expression.short + assert ir.digest in expression.detailed + assert "lineage=" in expression.detailed or isinstance(ir, MachineIR) and not ir.instructions + + +def test_lowering_expression_exposes_contract_rules_and_evidence( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, +) -> None: + contract = _contract("transformer-distribute", model_ir, distributed_ir) + transition = DerivationTransition( + model_ir.digest, + distributed_ir.digest, + contract, + 1, + boundary_view(model_ir, distributed_ir, contract.name), + ) + source_graph = graph_view(model_ir) + target_graph = graph_view(distributed_ir) + rows = semantic_boundary_rows(transition.boundary, source_graph, target_graph) + + expression = lowering_expression(transition, rows) + + assert "pass @transformer-distribute" in expression.short + assert "transaction immutable; verify=both; deterministic=true; seed=false" in expression.short + assert "commit_gate verify(input) -> run immutable transformation -> verify(output)" in expression.detailed + assert "rules {" in expression.detailed + assert "canonical_relations=" in expression.detailed + + +def test_correspondence_marks_undeclared_transforms_as_lineage_evidence( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, +) -> None: + contract = _contract("transformer-distribute", model_ir, distributed_ir) + transition = DerivationTransition( + model_ir.digest, + distributed_ir.digest, + contract, + 1, + boundary_view(model_ir, distributed_ir, contract.name), + ) + rows = semantic_boundary_rows(transition.boundary, graph_view(model_ir), graph_view(distributed_ir)) + + correspondence = lowering_correspondence_rows(transition, rows) + + assert correspondence + assert all(not row["rule_declared"] for row in correspondence) + assert all("" in row["transform_expression"] for row in correspondence) + assert all("correspondence is evidence only" in row["transform_expression"] for row in correspondence) + + +def test_boundary_mapping_uses_typed_lineage_and_reports_explicit_mismatch( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, +) -> None: + boundary = boundary_view(model_ir, distributed_ir, "distribute") + + assert boundary.summary.target_entities == len(distributed_ir.tasks) + len(distributed_ir.values) + assert boundary.summary.mapped_source_entities == len(model_ir.operations) + len(model_ir.values) + assert boundary.summary.dangling_sources == 0 + assert boundary.summary.one_to_many > 0 + + changed_value = replace(distributed_ir.values[0], source_value=model_ir.values[-1].id) + changed = replace(distributed_ir, values=(changed_value,) + distributed_ir.values[1:]) + mismatch = boundary_view(model_ir, changed, "distribute") + + assert any(item.code == "lineage.explicit_source_mismatch" for item in mismatch.diagnostics) + + +def test_debug_bundle_round_trip_revalidates_a_full_five_layer_chain( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, + portable_ir: PortablePlanIR, + concrete_ir: ConcretePlanIR, + machine_ir: MachineIR, +) -> None: + trace = _full_trace(model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir) + + encoded = DerivationDebugBundleCodec.dumps(trace) + restored = DerivationDebugBundleCodec.loads(encoded) + + assert tuple(item.stage for item in restored.stages) == tuple(CanonicalIRStage) + assert tuple(item.digest for item in restored.stages) == tuple(item.digest for item in trace.stages) + assert len(restored.transitions) == 4 + assert DerivationDebugBundleCodec.dumps(restored) == encoded + + +def test_debug_bundle_rejects_tampered_snapshot( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, + portable_ir: PortablePlanIR, + concrete_ir: ConcretePlanIR, + machine_ir: MachineIR, +) -> None: + payload = json.loads( + DerivationDebugBundleCodec.dumps(_full_trace(model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir)) + ) + snapshot = json.loads(payload["stages"][0]["snapshot_json"]) + snapshot["fields"]["content_digest"] = "0" * 40 + payload["stages"][0]["snapshot_json"] = json.dumps(snapshot) + + with pytest.raises(SerializationError, match="digest mismatch"): + DerivationDebugBundleCodec.loads(json.dumps(payload)) + + +def test_debug_bundle_rejects_a_missing_adjacent_transition( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, + portable_ir: PortablePlanIR, + concrete_ir: ConcretePlanIR, + machine_ir: MachineIR, +) -> None: + payload = json.loads( + DerivationDebugBundleCodec.dumps(_full_trace(model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir)) + ) + payload["transitions"].pop() + + with pytest.raises(ValueError, match="cover every adjacent stage"): + DerivationDebugBundleCodec.loads(json.dumps(payload)) diff --git a/tests/synthesizer/test_deriving.py b/tests/synthesizer/test_deriving.py new file mode 100644 index 0000000..1b97dbe --- /dev/null +++ b/tests/synthesizer/test_deriving.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError, dataclass + +import pytest + +from blueprinting.schema import canonical_dumps, canonical_loads +from blueprinting.schema.authoring import ( + adt, + adt_manifest, + is_adt_variant, + record, + seal_adt, + variant, +) + + +@adt(wire="tests.expression") +class _Expression: + pass + + +@variant("literal") +class _Literal(_Expression): + value: int + + +@variant("pair") +class _Pair(_Expression): + left: _Literal + right: _Literal + + +_ExpressionVariant = _Literal | _Pair +seal_adt(_Expression, _ExpressionVariant) + + +def test_variant_derives_frozen_slotted_canonical_records_and_short_wire_tags() -> None: + value = _Pair(_Literal(1), _Literal(2)) + + assert canonical_loads(canonical_dumps(value)) == value + assert not hasattr(value, "__dict__") + with pytest.raises(FrozenInstanceError): + value.left = _Literal(3) # type: ignore[misc] + assert [(item.local_tag, item.wire_tag) for item in adt_manifest(_Expression)] == [ + ("literal", "tests.expression.literal"), + ("pair", "tests.expression.pair"), + ] + + +def test_authoring_decorators_own_dataclass_derivation() -> None: + @dataclass(frozen=True) + class _LegacyRecord: + value: int + + with pytest.raises(TypeError, match="@record derives its own frozen dataclass"): + record("tests.legacy-record")(_LegacyRecord) + + @dataclass(frozen=True) + class _LegacyFamily: + pass + + with pytest.raises(TypeError, match="@adt derives its own frozen dataclass"): + adt(wire="tests.legacy-family")(_LegacyFamily) + + +def test_variant_structural_validation_is_derived_from_annotations() -> None: + with pytest.raises(TypeError, match="_Literal.value"): + _Literal("not-an-int") # type: ignore[arg-type] + with pytest.raises(TypeError, match="_Pair.right"): + _Pair(_Literal(1), "not-a-literal") # type: ignore[arg-type] + + +def test_variant_requires_one_family_and_short_stable_tag() -> None: + with pytest.raises(TypeError, match="kebab-case"): + variant("Not_A_Tag") + + with pytest.raises(TypeError, match="exactly one"): + + @variant("orphan") + class _Orphan: + pass + + +def test_adt_root_is_abstract_and_late_variants_are_rejected() -> None: + with pytest.raises(TypeError, match="is abstract"): + _Expression() + + with pytest.raises(RuntimeError, match="sealed and cannot accept late variants"): + + @variant("late") + class _Late(_Expression): + pass + + +def test_adt_closure_requires_exact_membership() -> None: + with pytest.raises(ValueError, match="exactly its registered variants"): + seal_adt(_Expression, _Literal) + + +def test_registered_constructor_annotations_reject_unregistered_subclasses() -> None: + class _RogueLiteral(_Literal): + pass + + rogue = _RogueLiteral(1) + + assert not is_adt_variant(rogue, _Expression) + with pytest.raises(TypeError, match="_Pair.left"): + _Pair(rogue, _Literal(2)) diff --git a/tests/synthesizer/test_pass_manager.py b/tests/synthesizer/test_pass_manager.py index 80c9b5e..6dfff36 100644 --- a/tests/synthesizer/test_pass_manager.py +++ b/tests/synthesizer/test_pass_manager.py @@ -4,20 +4,20 @@ import pytest -from blueprinting.schema import FrozenDict +from blueprinting.schema import Err, FrozenDict from blueprinting.synthesizer import ( BindingAxis, MissingAnalysisError, MissingBindingError, + NodeId, PassContractError, SynthesisSession, ) from blueprinting.synthesizer.errors import PassExecutionError -from blueprinting.synthesizer.ir import DistributedTaskIR, ModelIR -from blueprinting.synthesizer.ir.common import make_header from blueprinting.synthesizer.passes import ( AnalysisKey, AnalysisProduct, + DeterminismPolicy, FunctionPass, MutationModel, PassCheckpoint, @@ -27,12 +27,74 @@ PassPipeline, PassResult, ) +from blueprinting.synthesizer.passes.authoring import ( + DerivationPass, + PassContext, + PassRule, + RelationCheckContext, + claim, + derivation, + relation, +) +from blueprinting.synthesizer.stages.common import make_header +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.model.ir import ModelIR, ModelOperation def _model_contract(name: str, **options: object) -> PassContract: return PassContract.create(name, ModelIR, ModelIR, **options) +def test_pass_contract_owns_typed_semantic_rule_declarations() -> None: + rule = PassRule( + "fixture.lower", + "ModelOperation", + "ModelOperation", + "Preserve the fixture operation", + preserves=(claim("operation identity", lambda _source, _target, _context: None),), + introduces=("test marker",), + forbids=("predicted time",), + ) + + contract = _model_contract("fixture.rules", rules=(rule,)) + + assert contract.rules == (rule,) + with pytest.raises(ValueError, match="transforms must be unique"): + _model_contract("fixture.duplicate-rules", rules=(rule, rule)) + + +def test_pass_contract_is_derived_from_generic_types_and_explicit_relations() -> None: + def verify(source: ModelOperation, target: ModelOperation, _context: RelationCheckContext) -> None: + if source.id != target.id: + raise ValueError("operation identity changed") + + preserve = relation( + "fixture.preserve", + "Preserve the fixture operation", + source=ModelOperation, + target=ModelOperation, + verifier=verify, + preserves=(claim("operation identity", lambda _source, _target, _context: None),), + introduces=("test marker",), + ) + + @derivation( + "fixture.derived", + revision="1", + bindings=(BindingAxis.WORKLOAD,), + rules=(preserve,), + ) + class DerivedPass(DerivationPass[ModelIR, ModelIR]): + def run(self, ir: ModelIR, _context: PassContext) -> ModelIR: + return ir + + assert DerivedPass.contract.input_type is ModelIR + assert DerivedPass.contract.output_type is ModelIR + assert DerivedPass.contract.required_bindings == frozenset({BindingAxis.WORKLOAD}) + assert DerivedPass.contract.rules[0].source_entity == "ModelOperation" + assert DerivedPass.contract.rules[0].target_entity == "ModelOperation" + + def test_analysis_products_are_atomic_and_context_addressed(model_ir: ModelIR) -> None: key = AnalysisKey("fixture", "shape") analyze = FunctionPass( @@ -50,7 +112,7 @@ def test_analysis_products_are_atomic_and_context_addressed(model_ir: ModelIR) - session = SynthesisSession() manager = PassManager() - result = manager.run(PassPipeline.of(analyze, consume), model_ir, session=session) + result = manager.require_run(PassPipeline.of(analyze, consume), model_ir, session=session) assert result.ir is model_ir assert tuple(item.pass_name for item in result.records) == ("fixture.analyze", "fixture.consume") @@ -69,7 +131,7 @@ def inspect(self, checkpoint: PassCheckpoint, _context: object) -> None: observer = CaptureObserver() derivation_pass = FunctionPass(_model_contract("fixture.profiled"), lambda ir, _context: ir) - result = PassManager(observers=(observer,)).run( + result = PassManager(observers=(observer,)).require_run( PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession(), @@ -93,7 +155,7 @@ def inspect(self, _checkpoint: PassCheckpoint, _session: object) -> None: manager = PassManager(observers=(RejectObserver(),)) with pytest.raises(PassExecutionError, match="RejectObserver"): - manager.run(PassPipeline.of(derivation_pass), model_ir, session=session) + manager.require_run(PassPipeline.of(derivation_pass), model_ir, session=session) assert not manager.analyses.has(model_ir.digest, key, session.fingerprint) @@ -105,7 +167,7 @@ def test_missing_analysis_fails_before_pass_runs(model_ir: ModelIR) -> None: ) with pytest.raises(MissingAnalysisError, match="requires missing analyses"): - PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) def test_analysis_store_rejects_noncanonical_product_atomically(model_ir: ModelIR) -> None: @@ -118,7 +180,7 @@ def test_analysis_store_rejects_noncanonical_product_atomically(model_ir: ModelI session = SynthesisSession() with pytest.raises(PassContractError, match="not a canonical immutable value"): - manager.run(PassPipeline.of(derivation_pass), model_ir, session=session) + manager.require_run(PassPipeline.of(derivation_pass), model_ir, session=session) assert not manager.analyses.has(model_ir.digest, key, session.fingerprint) @@ -132,7 +194,7 @@ def test_analysis_store_defensively_freezes_extension_data(model_ir: ModelIR) -> manager = PassManager() session = SynthesisSession() - manager.run(PassPipeline.of(derivation_pass), model_ir, session=session) + manager.require_run(PassPipeline.of(derivation_pass), model_ir, session=session) source["values"].append(2) assert manager.analyses.get(model_ir.digest, key, session.fingerprint) == FrozenDict({"values": (1,)}) @@ -145,7 +207,7 @@ def test_required_binding_is_enforced(model_ir: ModelIR) -> None: ) with pytest.raises(MissingBindingError, match="target"): - PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) def test_pipeline_rejects_declared_type_mismatch() -> None: @@ -159,6 +221,19 @@ def test_pipeline_rejects_declared_type_mismatch() -> None: PassPipeline.of(first, second) +def test_cross_stage_pass_without_rules_is_rejected( + model_ir: ModelIR, + distributed_ir: DistributedTaskIR, +) -> None: + derivation_pass = FunctionPass( + PassContract.create("fixture.missing-rules-cross-stage", ModelIR, DistributedTaskIR), + lambda _ir, _context: distributed_ir, + ) + + with pytest.raises(PassContractError, match="must declare executable lineage rules"): + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + + def test_immutable_pass_input_mutation_is_detected(model_ir: ModelIR) -> None: def mutate(ir: ModelIR, _context: object) -> ModelIR: object.__setattr__(ir, "name", "illegally-mutated") @@ -167,7 +242,7 @@ def mutate(ir: ModelIR, _context: object) -> ModelIR: derivation_pass = FunctionPass(_model_contract("fixture.illegal-mutation"), mutate) with pytest.raises(PassContractError, match="mutated its input"): - PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) def test_transactional_pass_cannot_mutate_caller_snapshot(model_ir: ModelIR) -> None: @@ -182,16 +257,64 @@ def mutate_copy(ir: ModelIR, _context: object) -> ModelIR: ) return ir + def normal_form(ir: ModelIR, _session: SynthesisSession) -> ModelIR: + return replace( + ir, + name="transactional-copy", + header=make_header(ir.SCHEMA_NAME, ir.SCHEMA_VERSION, parent_digests=(ir.digest,)), + ) + derivation_pass = FunctionPass( - _model_contract("fixture.transaction", mutation_model=MutationModel.TRANSACTIONAL), + _model_contract( + "fixture.transaction", + mutation_model=MutationModel.TRANSACTIONAL, + normalizer=normal_form, + ), mutate_copy, ) - result = PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + result = PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) assert model_ir.name == "fixture-model" assert model_ir.digest == original_digest assert result.ir.name == "transactional-copy" + report = result.records[0].transition_report + assert report.status.value == "canonical_conformant" + assert report.canonical_conformant + assert report.verified_relations == 0 + + +def test_same_stage_rewrite_requires_a_law(model_ir: ModelIR) -> None: + source_digest = model_ir.digest + derivation_pass = FunctionPass( + _model_contract("fixture.implicit-lawless-rewrite"), + lambda ir, _context: replace( + ir, + name="rewritten", + header=make_header(ir.SCHEMA_NAME, ir.SCHEMA_VERSION, parent_digests=(source_digest,)), + ), + ) + + with pytest.raises(PassContractError, match="without a declared normal form or rules"): + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + + +def test_run_returns_expected_contract_failure_as_checked_diagnostics(model_ir: ModelIR) -> None: + source_digest = model_ir.digest + derivation_pass = FunctionPass( + _model_contract("fixture.checked-failure"), + lambda ir, _context: replace( + ir, + name="rewritten", + header=make_header(ir.SCHEMA_NAME, ir.SCHEMA_VERSION, parent_digests=(source_digest,)), + ), + ) + + result = PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + + assert isinstance(result, Err) + assert result.error.errors[0].code == "pass.contract" + assert "without a declared normal form or rules" in result.error.errors[0].message def test_rewrite_must_retain_parent_digest(model_ir: ModelIR) -> None: @@ -201,7 +324,7 @@ def test_rewrite_must_retain_parent_digest(model_ir: ModelIR) -> None: ) with pytest.raises(PassContractError, match="without retaining its input digest"): - PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) def test_unexpected_pass_failure_is_wrapped(model_ir: ModelIR) -> None: @@ -211,4 +334,45 @@ def fail(_ir: ModelIR, _context: object) -> ModelIR: derivation_pass = FunctionPass(_model_contract("fixture.failure"), fail) with pytest.raises(PassExecutionError, match="fixture.failure"): - PassManager().run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + PassManager().require_run(PassPipeline.of(derivation_pass), model_ir, session=SynthesisSession()) + + +def test_determinism_verify_rejects_random_stable_ids(model_ir: ModelIR) -> None: + input_digest = model_ir.digest + + def nondeterministic(ir: ModelIR, _context: object) -> ModelIR: + operation = replace(ir.operations[0], id=NodeId.new()) + return replace( + ir, + operations=(operation,), + header=make_header(ir.SCHEMA_NAME, ir.SCHEMA_VERSION, parent_digests=(input_digest,)), + ) + + derivation_pass = FunctionPass(_model_contract("fixture.nondeterministic"), nondeterministic) + + with pytest.raises(PassContractError, match="failed deterministic replay"): + PassManager(determinism=DeterminismPolicy.VERIFY).require_run( + PassPipeline.of(derivation_pass), + model_ir, + session=SynthesisSession(seed=7), + ) + + +def test_determinism_verify_invokes_observers_only_once(model_ir: ModelIR) -> None: + class CountObserver(PassObserver): + def __init__(self) -> None: + self.count = 0 + + def inspect(self, _checkpoint: PassCheckpoint, _session: object) -> None: + self.count += 1 + + observer = CountObserver() + derivation_pass = FunctionPass(_model_contract("fixture.deterministic"), lambda ir, _context: ir) + + PassManager(observers=(observer,), determinism=DeterminismPolicy.VERIFY).require_run( + PassPipeline.of(derivation_pass), + model_ir, + session=SynthesisSession(seed=7), + ) + + assert observer.count == 1 diff --git a/tests/synthesizer/test_reference_targets.py b/tests/synthesizer/test_reference_targets.py new file mode 100644 index 0000000..02c2205 --- /dev/null +++ b/tests/synthesizer/test_reference_targets.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from blueprinting.schema import FrozenDict +from blueprinting.synthesizer import DeploymentProfile, PassContractError, SynthesisSession, TargetProfile +from blueprinting.synthesizer.bindings import BindingSet +from blueprinting.synthesizer.passes import ( + DeterminismPolicy, + PassManager, + PassPipeline, + TransitionVerificationStatus, + TransitionVerifier, +) +from blueprinting.synthesizer.stages.concrete_plan.ir import ( + ConcretePlanIR, + ImplementationRef, + QueueScheduleExtension, + SlotDataflowExtension, +) +from blueprinting.synthesizer.stages.concrete_plan.passes import ( + BindReferenceQueueTargetPass, + BindReferenceSlotTargetPass, +) + + +def _session() -> SynthesisSession: + target = TargetProfile( + name="reference", + architecture="virtual", + architecture_revision="0", + runtime_stack="reference-runtime", + runtime_revision="0", + target_abi="reference-abi-v0", + ) + deployment = DeploymentProfile( + name="reference-2", + device_count=2, + topology=FrozenDict({"kind": "fully-connected"}), + environment_revision="0", + available_memory_bytes=(4096, 4096), + ) + return SynthesisSession( + bindings=BindingSet(target=target, deployment=deployment), + evidence_snapshot="reference-evidence-v0", + ) + + +def test_two_reference_binders_materialize_distinct_verified_concrete_plans(portable_ir) -> None: + session = _session() + queue_result = PassManager(determinism=DeterminismPolicy.VERIFY).require_run( + PassPipeline.of(BindReferenceQueueTargetPass()), + portable_ir, + session=session, + ) + slot_result = PassManager(determinism=DeterminismPolicy.VERIFY).require_run( + PassPipeline.of(BindReferenceSlotTargetPass()), + portable_ir, + session=session, + ) + + queue = queue_result.ir + slot = slot_result.ir + assert isinstance(queue, ConcretePlanIR) + assert isinstance(queue.target_extension, QueueScheduleExtension) + assert isinstance(slot.target_extension, SlotDataflowExtension) + assert queue.verify().is_ok and slot.verify().is_ok + assert queue.digest != slot.digest + assert queue_result.records[0].transition_report.verified_relations == len(queue.buffers) + len(queue.commands) + assert slot_result.records[0].transition_report.verified_relations == len(slot.buffers) + len(slot.commands) + assert queue_result.records[0].transition_report.status is TransitionVerificationStatus.RELATION_VERIFIED + assert queue_result.records[0].contract_digest == BindReferenceQueueTargetPass.contract.digest + assert queue_result.records[0].transition_report.verified_claims == len( + queue_result.records[0].transition_report.relations + ) + + +def test_queue_extension_rejects_missing_command_coverage(portable_ir) -> None: + plan = ( + PassManager() + .require_run( + PassPipeline.of(BindReferenceQueueTargetPass()), + portable_ir, + session=_session(), + ) + .ir + ) + broken = replace(plan, target_extension=QueueScheduleExtension(())) + + assert broken.verify().is_err + with pytest.raises(Exception, match="queue orders must cover"): + broken.require_valid() + + +def test_slot_extension_rejects_dependency_order_violation(portable_ir) -> None: + plan = ( + PassManager() + .require_run( + PassPipeline.of(BindReferenceSlotTargetPass()), + portable_ir, + session=_session(), + ) + .ir + ) + slots = tuple( + replace(slot, cycle=len(plan.target_extension.issue_slots) - index - 1) + for index, slot in enumerate(plan.target_extension.issue_slots) + ) + broken = replace(plan, target_extension=SlotDataflowExtension(slots, plan.target_extension.routes)) + + assert broken.verify().is_err + + +def test_cross_boundary_rule_rejects_wrong_command_implementation(portable_ir) -> None: + class WrongImplementationPass(BindReferenceQueueTargetPass): + def run(self, ir, context): + plan = super().run(ir, context) + command = plan.commands[0] + assert command.implementation is not None + wrong = replace( + command, + body=replace( + command.body, + implementation=ImplementationRef( + command.implementation.namespace, + "another.operation", + command.implementation.version, + command.implementation.abi, + command.implementation.variant, + ), + ), + ) + return replace(plan, commands=(wrong, *plan.commands[1:])) + + with pytest.raises(PassContractError, match="canonical normal form"): + PassManager().require_run( + PassPipeline.of(WrongImplementationPass()), + portable_ir, + session=_session(), + ) + + +def test_cross_stage_reports_canonical_conformance_separately_from_relation_invariants(portable_ir) -> None: + plan = ( + PassManager() + .require_run( + PassPipeline.of(BindReferenceQueueTargetPass()), + portable_ir, + session=_session(), + ) + .ir + ) + contract = replace( + BindReferenceQueueTargetPass.contract, + rules=tuple(replace(rule, preserves=()) for rule in BindReferenceQueueTargetPass.contract.rules), + ) + + report = TransitionVerifier.verify(portable_ir, plan, contract, _session()) + + assert report.status is TransitionVerificationStatus.RELATION_VERIFIED + assert report.canonical_conformant + assert report.canonical_conformance is not None + assert report.canonical_conformance.name == "canonical normal form" + assert all(relation.evidence[0].name == "relation invariant" for relation in report.relations) + + +def test_independent_relation_invariant_rejects_a_canonically_accepted_bad_output(portable_ir) -> None: + session = _session() + plan = ( + PassManager() + .require_run( + PassPipeline.of(BindReferenceQueueTargetPass()), + portable_ir, + session=session, + ) + .ir + ) + command = plan.commands[0] + assert command.implementation is not None + wrong_command = replace( + command, + body=replace( + command.body, + implementation=replace(command.implementation, name="another.operation"), + ), + ) + wrong_plan = replace(plan, commands=(wrong_command, *plan.commands[1:])) + + # This deliberately permissive normalizer accepts the bad implementation. + # The independently declared semantic relation must still reject it. + contract = replace(BindReferenceQueueTargetPass.contract, normalizer=lambda _source, _session: wrong_plan) + + with pytest.raises(PassContractError, match="selected implementation does not match"): + TransitionVerifier.verify(portable_ir, wrong_plan, contract, session) diff --git a/tests/synthesizer/test_runtime_contracts.py b/tests/synthesizer/test_runtime_contracts.py new file mode 100644 index 0000000..bf0a17c --- /dev/null +++ b/tests/synthesizer/test_runtime_contracts.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from dataclasses import replace + +import blueprinting.mapping # noqa: F401 - load its algebraic contracts +from blueprinting.contracts import compile_runtime_contracts +from blueprinting.schema import ( + Diagnostic, + DiagnosticSet, + Err, + Ok, + Severity, + checked, + collect_results, +) + + +def test_result_maps_chains_and_accumulates_independent_diagnostics() -> None: + warning = DiagnosticSet.of(Diagnostic("fixture.warning", "warning", severity=Severity.WARNING)) + failure_one = DiagnosticSet.of(Diagnostic("fixture.one", "first failure")) + failure_two = DiagnosticSet.of(Diagnostic("fixture.two", "second failure")) + + success = Ok(2, warning).map(lambda value: value + 1).and_then(lambda value: Ok(value * 2)) + failures = collect_results((Ok(1), Err(failure_one), Ok(2), Err(failure_two))) + + assert isinstance(success, Ok) + assert success.value == 6 + assert success.diagnostics == warning + assert isinstance(failures, Err) + assert tuple(item.code for item in failures.error.errors) == ("fixture.one", "fixture.two") + + +def test_checked_selects_success_or_failure_from_diagnostics() -> None: + assert isinstance(checked("value"), Ok) + assert isinstance(checked("value", DiagnosticSet.of(Diagnostic("fixture.error", "broken"))), Err) + + +def test_runtime_type_universe_is_closed_and_deterministic() -> None: + first = compile_runtime_contracts().or_raise().types + second = compile_runtime_contracts().or_raise().types + + assert first == second + assert first.digest == second.digest + assert {item.wire for item in first.algebraic_families} >= { + "blueprinting.expression.scalar", + "blueprinting.ir.distributed-task.task", + "blueprinting.ir.concrete-plan.command-body", + "blueprinting.ir.concrete-plan.synchronization", + "blueprinting.mapping.pipeline-schedule", + "blueprinting.binding.workload-mode", + "blueprinting.ir.portable-plan.task-body", + "blueprinting.analysis.cost.support", + "blueprinting.ir.distributed-task.collective", + } + + +def test_runtime_contract_digest_covers_record_fields_defaults_and_enum_members() -> None: + manifest = compile_runtime_contracts().or_raise() + types = manifest.types + workload = next(item for item in types.canonical_types if item.wire == "blueprinting.binding.workload") + phase = next(item for item in types.canonical_types if item.wire == "blueprinting.binding.inference-phase") + all_to_all = next( + item for item in types.canonical_types if item.wire == "blueprinting.ir.distributed-task.collective.all-to-all" + ) + machine_opcode = next(item for item in types.canonical_types if item.wire == "blueprinting.ir.machine.opcode") + training_workload = next( + item for item in types.canonical_types if item.wire == "blueprinting.workload.transformer-training" + ) + collective = next( + item for item in types.algebraic_families if item.wire == "blueprinting.ir.distributed-task.collective" + ) + + assert tuple(field.name for field in workload.fields) == ("mode", "semantic", "attributes") + assert workload.fields[0].annotation.startswith("union[") + assert workload.fields[0].default_kind == "required" + assert workload.fields[2].default_kind == "factory" + assert workload.fields[2].default_identity == "blueprinting.schema.frozen.FrozenDict" + assert workload.fields[2].default_value == '{"$map":[]}' + assert tuple((member.name, member.value) for member in phase.enum_members) == ( + ("PREFILL", '"prefill"'), + ("DECODE", '"decode"'), + ) + participant_shape = all_to_all.fields[0].annotation + assert participant_shape.startswith("annotated[builtins.tuple[builtins.int,builtins.Ellipsis]") + assert '"value":"non_empty"' in participant_shape + assert '"value":"unique_items"' in participant_shape + assert '"value":"non_negative_items"' in participant_shape + assert all('"value":"non_empty"' in field.annotation for field in machine_opcode.fields) + assert training_workload.fields[2].annotation == 'literal["float8","float16","bfloat16","float32"]' + + def with_canonical_type(updated): + return replace( + types, + canonical_types=tuple(updated if item.wire == updated.wire else item for item in types.canonical_types), + ) + + altered_universes = ( + with_canonical_type(replace(workload, fields=tuple(reversed(workload.fields)))), + with_canonical_type( + replace( + workload, + fields=(replace(workload.fields[0], annotation="tests.changed-shape"), *workload.fields[1:]), + ) + ), + with_canonical_type( + replace( + workload, + fields=(*workload.fields[:2], replace(workload.fields[2], default_value='{"$map":[["x",1]]}')), + ) + ), + with_canonical_type( + replace( + phase, + enum_members=(replace(phase.enum_members[0], value='"changed"'), *phase.enum_members[1:]), + ) + ), + replace( + types, + algebraic_families=tuple( + replace(collective, variants=collective.variants[:-1]) if item is collective else item + for item in types.algebraic_families + ), + ), + ) + + assert all(item.digest != types.digest for item in altered_universes) + assert all(replace(manifest, types=item).digest != manifest.digest for item in altered_universes) + + +def test_runtime_contract_compiler_covers_all_builtin_derivations() -> None: + first = compile_runtime_contracts() + second = compile_runtime_contracts() + + assert isinstance(first, Ok) + assert isinstance(second, Ok) + assert first.value.digest == second.value.digest + assert {item.name for item in first.value.derivations} >= { + "transformer-distribute", + "transformer-inference-distribute", + "transformer-plan-work", + "transformer-inference-plan-work", + "reference-queue-bind", + "reference-slot-bind", + } + production = tuple(item for item in first.value.derivations if not item.name.startswith("fixture.")) + assert all(item.normalizer is not None for item in production) diff --git a/tests/synthesizer/test_schema_migration.py b/tests/synthesizer/test_schema_migration.py new file mode 100644 index 0000000..c51102e --- /dev/null +++ b/tests/synthesizer/test_schema_migration.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import Any + +import pytest + +from blueprinting.schema import ( + canonical_dump_raw, + canonical_dumps, + canonical_parse, + content_digest, +) +from blueprinting.schema.codec import record_type +from blueprinting.schema.errors import SerializationError +from blueprinting.synthesizer.schema_migration import ( + DEFAULT_SCHEMA_MIGRATIONS, + SchemaMigration, + SchemaMigrationRegistry, +) +from blueprinting.synthesizer.stages.common import IRSnapshot, SchemaVersion +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR + +SCHEMA = "blueprinting.test-migration" +V0 = SchemaVersion(0, 0, 0) +V01 = SchemaVersion(0, 1, 0) +V02 = SchemaVersion(0, 2, 0) + + +@record_type("blueprinting.test.migration-payload") +@dataclass(frozen=True) +class _Payload: + revision: int + + +def _snapshot(version: SchemaVersion, revision: int) -> str: + payload = _Payload(revision) + return canonical_dumps( + IRSnapshot( + schema_name=SCHEMA, + schema_version=version, + producer_version="test", + feature_set=frozenset(), + content_digest=content_digest(payload, f"ir:{SCHEMA}"), + payload=payload, + ) + ) + + +def _upgrade(to_version: SchemaVersion) -> Callable[[Any], Any]: + def transform(raw: Any) -> Any: + from blueprinting.schema import canonical_decode + + snapshot = canonical_decode(raw) + payload = replace(snapshot.payload, revision=snapshot.payload.revision + 1) + upgraded = replace( + snapshot, + schema_version=to_version, + content_digest=content_digest(payload, f"ir:{SCHEMA}"), + payload=payload, + ) + return canonical_parse(canonical_dumps(upgraded)) + + return transform + + +def _registry() -> SchemaMigrationRegistry: + registry = SchemaMigrationRegistry() + registry.register(SchemaMigration(SCHEMA, V0, V01, "test.0.0-to-0.1", _upgrade(V01))) + registry.register(SchemaMigration(SCHEMA, V01, V02, "test.0.1-to-0.2", _upgrade(V02))) + return registry + + +def test_current_canonical_schema_epoch_is_zero_and_has_no_history() -> None: + assert { + ModelIR.SCHEMA_VERSION, + DistributedTaskIR.SCHEMA_VERSION, + PortablePlanIR.SCHEMA_VERSION, + ConcretePlanIR.SCHEMA_VERSION, + MachineIR.SCHEMA_VERSION, + } == {V0} + for ir_type in (ModelIR, DistributedTaskIR, PortablePlanIR, ConcretePlanIR, MachineIR): + with pytest.raises(SerializationError, match="no migration path"): + DEFAULT_SCHEMA_MIGRATIONS.path(ir_type.SCHEMA_NAME, V0, V01) + + +def test_migration_mechanism_is_explicit_deterministic_and_digest_checked() -> None: + result = _registry().migrate_json(_snapshot(V0, 1), schema_name=SCHEMA, target_version=V02) + + assert result.source_version == V0 + assert result.target_version == V02 + assert result.migration_ids == ("test.0.0-to-0.1", "test.0.1-to-0.2") + assert result.source_digest != result.target_digest + + +def test_current_version_load_is_an_idempotent_noop() -> None: + original = _snapshot(V02, 3) + result = _registry().migrate_json(original, schema_name=SCHEMA, target_version=V02) + + assert result.payload == original + assert result.migration_ids == () + assert result.source_digest == result.target_digest + + +def test_registry_rejects_unknown_ambiguous_duplicate_and_backward_edges() -> None: + registry = _registry() + with pytest.raises(SerializationError, match="no migration path"): + registry.migrate_json(_snapshot(V0, 1), schema_name=SCHEMA, target_version=SchemaVersion(1, 0, 0)) + registry.register(SchemaMigration(SCHEMA, V0, V02, "test.direct", _upgrade(V02))) + with pytest.raises(SerializationError, match="ambiguous migration path"): + registry.migrate_json(_snapshot(V0, 1), schema_name=SCHEMA, target_version=V02) + with pytest.raises(ValueError, match="duplicate schema migration edge"): + registry.register(SchemaMigration(SCHEMA, V0, V01, "test.duplicate", _upgrade(V01))) + with pytest.raises(ValueError, match="advance the version"): + SchemaMigration(SCHEMA, V02, V0, "test.backward", _upgrade(V0)) + + +def test_migration_rejects_tampered_source_digest() -> None: + raw = canonical_parse(_snapshot(V0, 1)) + raw["fields"]["payload"]["fields"]["revision"] = 99 + + with pytest.raises(SerializationError, match="digest mismatch"): + _registry().migrate_json(canonical_dump_raw(raw), schema_name=SCHEMA, target_version=V01) diff --git a/tests/synthesizer/test_stage_layout.py b/tests/synthesizer/test_stage_layout.py new file mode 100644 index 0000000..550c7ab --- /dev/null +++ b/tests/synthesizer/test_stage_layout.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import importlib.util + +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR +from blueprinting.synthesizer.stages.concrete_plan.passes import ( + BindReferenceQueueTargetPass, + BindReferenceSlotTargetPass, +) +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.distributed.passes import ( + DistributeTransformerInferencePass, + DistributeTransformerTrainingPass, +) +from blueprinting.synthesizer.stages.machine.ir import MachineIR +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR +from blueprinting.synthesizer.stages.portable_plan.passes import ( + PlanTransformerInferencePass, + PlanTransformerTrainingPass, +) + + +def test_stage_modules_are_the_only_definition_sites() -> None: + stage_types = (ModelIR, DistributedTaskIR, PortablePlanIR, ConcretePlanIR, MachineIR) + + assert all(".stages." in item.__module__ and item.__module__.endswith(".ir") for item in stage_types) + assert importlib.util.find_spec("blueprinting.synthesizer.stages.distributed") is not None + assert importlib.util.find_spec("blueprinting.synthesizer.stages.distributed_task") is None + assert importlib.util.find_spec("blueprinting.synthesizer.ir") is None + assert importlib.util.find_spec("blueprinting.synthesizer.lowering") is None + + +def test_each_implemented_boundary_is_discoverable_from_its_target_stage() -> None: + assert DistributeTransformerTrainingPass.contract.output_type is DistributedTaskIR + assert DistributeTransformerInferencePass.contract.output_type is DistributedTaskIR + assert PlanTransformerTrainingPass.contract.output_type is PortablePlanIR + assert PlanTransformerInferencePass.contract.output_type is PortablePlanIR + assert BindReferenceQueueTargetPass.contract.output_type is ConcretePlanIR + assert BindReferenceSlotTargetPass.contract.output_type is ConcretePlanIR diff --git a/tests/synthesizer/test_transformer_inference.py b/tests/synthesizer/test_transformer_inference.py index 6855571..5c7d635 100644 --- a/tests/synthesizer/test_transformer_inference.py +++ b/tests/synthesizer/test_transformer_inference.py @@ -1,24 +1,46 @@ from __future__ import annotations import json +from dataclasses import replace from pathlib import Path import pytest from blueprinting.analysis import ( - InferenceCostProvider, InferenceEvidenceQuery, VidurProfileBaseline, estimate_inference_phase, ) -from blueprinting.mapping import NetworkTierBinding, TransformerInferenceMappingSpec +from blueprinting.mapping import ( + ForwardOnly, + NetworkTierBinding, + PipelineParallel, + ReplicaParallel, + SingleStage, + TensorParallel, + TensorParallelCommunication, + TransformerInferenceMappingSpec, + TransformerInferenceParallelism, +) from blueprinting.synthesizer.bindings import InferencePhase +from blueprinting.synthesizer.dialects.transformer import ( + TransformerBufferSemantic, + TransformerInferencePlanSemantic, + TransformerInferencePlanTaskSemantic, +) +from blueprinting.synthesizer.errors import PassContractError from blueprinting.synthesizer.frontend import ( build_transformer_inference_model_ir, inference_synthesis_session_for, ) -from blueprinting.synthesizer.lowering import DistributeTransformerInferencePass, PlanTransformerInferencePass -from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.passes import ( + DeterminismPolicy, + PassManager, + PassPipeline, + TransitionVerifier, +) +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerInferencePass +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerInferencePass from blueprinting.system import SystemProfile from blueprinting.validation import ( VidurExperimentCase, @@ -47,47 +69,65 @@ def _model() -> TransformerModelSpec: def _execution() -> TransformerInferenceMappingSpec: return TransformerInferenceMappingSpec( - tensor_parallel=2, - pipeline_parallel=2, - replicas=1, + TransformerInferenceParallelism( + TensorParallel(2, TensorParallelCommunication.ALL_REDUCE), + PipelineParallel(2, ForwardOnly()), + ReplicaParallel(1), + ) ) def _derive(phase: InferencePhase, context_tokens: int): + source, result, _ = _derive_result(phase, context_tokens) + return source, result.ir + + +def _derive_result(phase: InferencePhase, context_tokens: int): model = _model() execution = _execution() source = build_transformer_inference_model_ir(model) - result = PassManager().run( + session = inference_synthesis_session_for( + model, + execution, + phase=phase, + batch_size=3, + context_tokens=context_tokens, + datatype="float16", + ) + result = PassManager(determinism=DeterminismPolicy.VERIFY).require_run( PassPipeline.of(DistributeTransformerInferencePass(), PlanTransformerInferencePass()), source, - session=inference_synthesis_session_for( - model, - execution, - phase=phase, - batch_size=3, - context_tokens=context_tokens, - datatype="float16", - ), + session=session, ) - return source, result.ir + return source, result, session def _task(plan, primitive: str): - return next(task for task in plan.tasks if task.workload.attributes.get("primitive") == primitive) + return next( + task + for task in plan.tasks + if isinstance(task.semantic, TransformerInferencePlanTaskSemantic) and task.semantic.primitive == primitive + ) @pytest.mark.parametrize("phase", tuple(InferencePhase)) def test_inference_lowering_has_valid_auditable_phase_plans(phase: InferencePhase): source, plan = _derive(phase, 64) - assert source.verify().ok - assert plan.verify().ok - assert plan.attributes["inference_phase"] is phase + assert source.verify().is_ok + assert plan.verify().is_ok + assert isinstance(plan.semantic, TransformerInferencePlanSemantic) + assert plan.semantic.phase is phase assert all("invocation" not in task.attributes for task in plan.tasks) - assert all(task.workload.attributes.get("phase") == phase.value for task in plan.tasks) + assert all( + isinstance(task.semantic, TransformerInferencePlanTaskSemantic) and task.semantic.phase is phase + for task in plan.tasks + ) assert all("network_tier" not in task.workload.attributes for task in plan.tasks) assert all("network_tier" not in resource.capabilities for task in plan.tasks for resource in task.resources) - assert {buffer.attributes.get("semantic") for buffer in plan.buffers} >= { + assert { + buffer.semantic.role for buffer in plan.buffers if isinstance(buffer.semantic, TransformerBufferSemantic) + } >= { "block_weights", "block_working_upper_bound", "kv_cache", @@ -113,15 +153,40 @@ def test_prefill_attention_is_quadratic_and_decode_attention_is_linear_in_contex def test_kv_cache_capacity_is_derived_from_shape_not_a_correction_factor(): _, plan = _derive(InferencePhase.DECODE, 96) - kv_buffer = next(buffer for buffer in plan.buffers if buffer.attributes.get("semantic") == "kv_cache") + kv_buffer = next( + buffer + for buffer in plan.buffers + if isinstance(buffer.semantic, TransformerBufferSemantic) and buffer.semantic.role == "kv_cache" + ) workspace = next( - buffer for buffer in plan.buffers if buffer.attributes.get("semantic") == "block_working_upper_bound" + buffer + for buffer in plan.buffers + if isinstance(buffer.semantic, TransformerBufferSemantic) + and buffer.semantic.role == "block_working_upper_bound" ) assert kv_buffer.size_bytes == 2 * 3 * 96 * (64 // 2) * 2 assert workspace.size_bytes > 0 +@pytest.mark.parametrize("role", ("block_weights", "block_working_upper_bound")) +def test_normal_form_rejects_generated_buffer_capacity_mutation(role: str) -> None: + _, result, session = _derive_result(InferencePhase.DECODE, 96) + distributed = result.checkpoints[0].ir + plan = result.ir + index = next( + index + for index, buffer in enumerate(plan.buffers) + if isinstance(buffer.semantic, TransformerBufferSemantic) and buffer.semantic.role == role + ) + broken_buffer = replace(plan.buffers[index], size_bytes=plan.buffers[index].size_bytes + 16) + broken = replace(plan, buffers=(*plan.buffers[:index], broken_buffer, *plan.buffers[index + 1 :])) + broken.require_valid() + + with pytest.raises(PassContractError, match="canonical normal form"): + TransitionVerifier.verify(distributed, broken, PlanTransformerInferencePass.contract, session) + + def test_network_tier_binding_changes_cost_without_changing_portable_plan(): _, plan = _derive(InferencePhase.DECODE, 96) hardware = SystemProfile.from_mapping( @@ -151,9 +216,11 @@ def test_request_semantics_count_prefill_as_the_first_output_token(): def test_invalid_mapping_is_rejected_before_lowering(): model = _model() invalid = TransformerInferenceMappingSpec( - tensor_parallel=3, - pipeline_parallel=1, - replicas=1, + TransformerInferenceParallelism( + TensorParallel(3, TensorParallelCommunication.ALL_REDUCE), + PipelineParallel(1, SingleStage()), + ReplicaParallel(1), + ) ) with pytest.raises(ValueError, match="hidden_size must be divisible"): @@ -243,7 +310,7 @@ def test_vidur_adapter_uses_only_exact_shape_matches(tmp_path: Path): assert attention_estimate.evidence_provider == "analytical-system-profile" assert attention_comparison.baseline_seconds == pytest.approx(0.00025) assert attention_comparison.estimated_seconds == attention_estimate.total_seconds - assert not isinstance(baseline, InferenceCostProvider) + assert not hasattr(baseline, "resolve") assert plan.digest == digest_before experiment = run_vidur_experiment( diff --git a/tests/synthesizer/test_transformer_training.py b/tests/synthesizer/test_transformer_training.py index 898c667..98e7d49 100644 --- a/tests/synthesizer/test_transformer_training.py +++ b/tests/synthesizer/test_transformer_training.py @@ -5,16 +5,35 @@ import pytest from blueprinting.mapping import ( + DataParallel, + PipelineParallel, RecomputePolicy, + SingleStage, + TensorParallel, TensorParallelCommunication, TransformerTrainingMappingSpec, + TransformerTrainingParallelism, ) -from blueprinting.synthesizer.dialects.transformer import TrainingPhase, derive_transformer_block -from blueprinting.synthesizer.errors import PassExecutionError +from blueprinting.synthesizer.dialects.transformer import ( + TrainingPhase, + TransformerTrainingDistributedTaskSemantic, + TransformerTrainingPlanTaskSemantic, + derive_transformer_block, +) +from blueprinting.synthesizer.errors import PassContractError from blueprinting.synthesizer.frontend import build_transformer_model_ir, synthesis_session_for -from blueprinting.synthesizer.ir import DistributedTaskIR, PortablePlanIR -from blueprinting.synthesizer.lowering import DistributeTransformerTrainingPass, PlanTransformerTrainingPass -from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.ids import Lineage, NodeId +from blueprinting.synthesizer.passes import ( + DeterminismPolicy, + FunctionPass, + PassManager, + PassPipeline, + TransitionVerifier, +) +from blueprinting.synthesizer.stages.distributed.ir import DistributedTaskIR +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerTrainingPass +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerTrainingPass from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec @@ -40,13 +59,12 @@ def _mapping( communication: TensorParallelCommunication = TensorParallelCommunication.ALL_REDUCE, ) -> TransformerTrainingMappingSpec: return TransformerTrainingMappingSpec( - tensor_parallel=tensor_parallel, - pipeline_parallel=1, - data_parallel=1, - recompute=RecomputePolicy.ATTENTION, - pipeline_interleaving=1, - optimizer_sharding=False, - tensor_parallel_communication=communication, + TransformerTrainingParallelism( + TensorParallel(tensor_parallel, communication), + PipelineParallel(1, SingleStage()), + DataParallel(1), + RecomputePolicy.ATTENTION, + ) ) @@ -55,16 +73,17 @@ def _derive(): workload = _workload() mapping = _mapping() source = build_transformer_model_ir(model) - result = PassManager().run( + session = synthesis_session_for(model, workload, mapping) + result = PassManager(determinism=DeterminismPolicy.VERIFY).require_run( PassPipeline.of(DistributeTransformerTrainingPass(), PlanTransformerTrainingPass()), source, - session=synthesis_session_for(model, workload, mapping), + session=session, ) - return source, result + return source, result, session def test_training_output_is_produced_by_forward_not_optimizer() -> None: - _, result = _derive() + _, result, _ = _derive() distributed = result.checkpoints[0].ir plan = result.ir @@ -74,15 +93,18 @@ def test_training_output_is_produced_by_forward_not_optimizer() -> None: plan_output = next(buffer for buffer in plan.buffers if buffer.id == plan.outputs[0]) plan_producer = next(task for task in plan.tasks if task.id == plan_output.producer) - assert distributed_producer.attributes["invocation"].phase is TrainingPhase.FORWARD - assert plan_producer.workload.attributes["phase"] == TrainingPhase.FORWARD.value + assert isinstance(distributed_producer.semantic, TransformerTrainingDistributedTaskSemantic) + assert distributed_producer.semantic.invocation.phase is TrainingPhase.FORWARD + assert isinstance(plan_producer.semantic, TransformerTrainingPlanTaskSemantic) + assert plan_producer.semantic.phase is TrainingPhase.FORWARD assert plan.outputs[0] in plan_producer.outputs - assert plan.tasks[-1].workload.attributes["phase"] == TrainingPhase.OPTIMIZER.value + assert isinstance(plan.tasks[-1].semantic, TransformerTrainingPlanTaskSemantic) + assert plan.tasks[-1].semantic.phase is TrainingPhase.OPTIMIZER assert plan.outputs[0] not in plan.tasks[-1].outputs def test_training_phases_are_conservatively_ordered_and_round_trip_with_lineage() -> None: - source, result = _derive() + source, result, _ = _derive() distributed = result.checkpoints[0].ir plan = result.ir stage = { @@ -93,12 +115,12 @@ def test_training_phases_are_conservatively_ordered_and_round_trip_with_lineage( TrainingPhase.WEIGHT_GRADIENT.value: 2, TrainingPhase.OPTIMIZER.value: 3, } - stages = tuple(stage[task.workload.attributes["phase"]] for task in plan.tasks) + stages = tuple(stage[task.semantic.phase.value] for task in plan.tasks) assert stages == tuple(sorted(stages)) assert all(task.dependencies == (plan.tasks[index - 1].id,) for index, task in enumerate(plan.tasks[1:], 1)) - assert DistributedTaskIR.from_json(distributed.to_json()) == distributed - assert PortablePlanIR.from_json(plan.to_json()) == plan + assert DistributedTaskIR.from_json(distributed.to_json()).or_raise() == distributed + assert PortablePlanIR.from_json(plan.to_json()).or_raise() == plan assert source.digest in distributed.header.parent_digests assert distributed.digest in plan.header.parent_digests assert all(task.lineage.sources for task in distributed.tasks) @@ -130,19 +152,116 @@ def test_tp1_communication_policies_have_identical_work_and_memory() -> None: ) -def test_lowering_rejects_strategy_policy_that_disagrees_with_typed_mapping() -> None: +def test_strategy_binding_has_one_typed_source_for_mapping_facts() -> None: model = _model() workload = _workload() mapping = _mapping() source = build_transformer_model_ir(model) session = synthesis_session_for(model, workload, mapping) assert session.bindings.strategy is not None - mismatched_strategy = replace(session.bindings.strategy, recompute_policy=RecomputePolicy.NONE.value) - session = replace(session, bindings=session.bindings.with_binding(mismatched_strategy)) + assert session.bindings.strategy.semantic.mapping == mapping + assert not hasattr(session.bindings.strategy, "recompute_policy") + with pytest.raises(TypeError, match="recompute_policy"): + replace(session.bindings.strategy, recompute_policy=RecomputePolicy.NONE.value) - with pytest.raises(PassExecutionError, match="strategy binding is inconsistent"): - PassManager().run( + distributed = PassManager().require_run( + PassPipeline.of(DistributeTransformerTrainingPass()), + source, + session=session, + ) + assert distributed.ir.verify().is_ok + + +def test_transition_gate_rejects_undeclared_cross_boundary_lineage_before_commit() -> None: + model = _model() + workload = _workload() + mapping = _mapping() + source = build_transformer_model_ir(model) + session = synthesis_session_for(model, workload, mapping) + distributed = ( + PassManager() + .require_run( PassPipeline.of(DistributeTransformerTrainingPass()), source, session=session, ) + .ir + ) + broken_task = replace( + distributed.tasks[0], + lineage=Lineage.lowered("undeclared-transform", (NodeId.derive("missing-source"),)), + ) + broken = replace(distributed, tasks=(broken_task,) + distributed.tasks[1:]) + derivation_pass = FunctionPass(DistributeTransformerTrainingPass.contract, lambda _ir, _context: broken) + + with pytest.raises(PassContractError, match="undeclared lineage transform"): + PassManager().require_run(PassPipeline.of(derivation_pass), source, session=session) + + +def test_transition_claim_rejects_distributed_shape_mutation() -> None: + source, result, session = _derive() + distributed = result.checkpoints[0].ir + value = distributed.values[0] + broken = replace( + distributed, + values=(replace(value, type=replace(value.type, shape=(999, *value.type.shape[1:]))), *distributed.values[1:]), + ) + + with pytest.raises(PassContractError, match="canonical normal form"): + TransitionVerifier.verify(source, broken, DistributeTransformerTrainingPass.contract, session) + + +def test_transition_claim_rejects_plan_buffer_size_mutation() -> None: + _, result, session = _derive() + distributed = result.checkpoints[0].ir + plan = result.ir + buffer = plan.buffers[0] + broken = replace(plan, buffers=(replace(buffer, size_bytes=buffer.size_bytes + 2), *plan.buffers[1:])) + + with pytest.raises(PassContractError, match="canonical normal form"): + TransitionVerifier.verify(distributed, broken, PlanTransformerTrainingPass.contract, session) + + +def test_transition_claim_rejects_same_cardinality_dependency_rewire() -> None: + _, result, session = _derive() + distributed = result.checkpoints[0].ir + plan = result.ir + task = plan.tasks[2] + assert len(task.dependencies) == 1 and task.dependencies != (plan.tasks[0].id,) + broken = replace(plan, tasks=(*plan.tasks[:2], replace(task, dependencies=(plan.tasks[0].id,)), *plan.tasks[3:])) + broken.require_valid() + + with pytest.raises(PassContractError, match="canonical normal form"): + TransitionVerifier.verify(distributed, broken, PlanTransformerTrainingPass.contract, session) + + +def test_normal_form_rejects_semantic_payload_substitution() -> None: + source, result, session = _derive() + distributed = result.checkpoints[0].ir + plan = result.ir + + broken_distributed = replace( + distributed, + tasks=(replace(distributed.tasks[0], semantic=distributed.tasks[1].semantic), *distributed.tasks[1:]), + ) + broken_distributed.require_valid() + with pytest.raises(PassContractError, match="canonical normal form"): + TransitionVerifier.verify( + source, + broken_distributed, + DistributeTransformerTrainingPass.contract, + session, + ) + + broken_plan = replace( + plan, + tasks=(replace(plan.tasks[0], semantic=plan.tasks[1].semantic), *plan.tasks[1:]), + ) + broken_plan.require_valid() + with pytest.raises(PassContractError, match="canonical normal form"): + TransitionVerifier.verify( + distributed, + broken_plan, + PlanTransformerTrainingPass.contract, + session, + ) diff --git a/tests/synthesizer/test_verifiers.py b/tests/synthesizer/test_verifiers.py index 5a36f5d..81ec047 100644 --- a/tests/synthesizer/test_verifiers.py +++ b/tests/synthesizer/test_verifiers.py @@ -2,21 +2,36 @@ from dataclasses import fields, replace +import pytest + from blueprinting.schema import FrozenDict -from blueprinting.synthesizer import BufferId, NodeId, ValueId -from blueprinting.synthesizer.ir import ( - ConcretePlanIR, +from blueprinting.synthesizer import BufferId, NodeId, TokenId, ValueId +from blueprinting.synthesizer.stages.common import OperationName, SchemaVersion +from blueprinting.synthesizer.stages.concrete_plan.ir import ConcretePlanIR, Signal, WaitFor +from blueprinting.synthesizer.stages.distributed.ir import ( + AllGather, + AllReduce, + AllToAll, + Broadcast, + CollectiveKind, + CollectiveSpec, DistributedTaskIR, + ReductionKind, + collective_kind, + make_collective_spec, +) +from blueprinting.synthesizer.stages.machine.ir import ( MachineIR, MachineOpcode, - ModelIR, - OperationName, - PortablePlanIR, + MachineSection, + MachineSectionKind, ) +from blueprinting.synthesizer.stages.model.ir import ModelIR +from blueprinting.synthesizer.stages.portable_plan.ir import PortablePlanIR def _codes(ir: object) -> set[str]: - return {item.code for item in ir.verify().diagnostics} # type: ignore[attr-defined] + return {item.code for item in ir.diagnostics().diagnostics} # type: ignore[attr-defined] def test_fixture_snapshots_are_valid( @@ -27,7 +42,7 @@ def test_fixture_snapshots_are_valid( machine_ir: MachineIR, ) -> None: for ir in (model_ir, distributed_ir, portable_ir, concrete_ir, machine_ir): - assert ir.verify().ok, tuple(item.render() for item in ir.verify().diagnostics) + assert ir.verify().is_ok, tuple(item.render() for item in ir.diagnostics().diagnostics) def test_model_rejects_target_dialect_and_unknown_output(model_ir: ModelIR) -> None: @@ -41,6 +56,12 @@ def test_model_rejects_target_dialect_and_unknown_output(model_ir: ModelIR) -> N assert {"model.target_dialect", "reference.unknown"}.issubset(_codes(invalid)) +def test_model_rejects_typed_semantics_smuggled_through_attributes(model_ir: ModelIR) -> None: + operation = replace(model_ir.operations[0], attributes=FrozenDict({"model_spec": "legacy"})) + + assert "attribute.reserved" in _codes(replace(model_ir, operations=(operation,))) + + def test_distributed_rejects_physical_rank(distributed_ir: DistributedTaskIR) -> None: invalid_task = replace(distributed_ir.tasks[0], ranks=(0, 99)) invalid = replace(distributed_ir, tasks=(invalid_task,) + distributed_ir.tasks[1:]) @@ -48,12 +69,81 @@ def test_distributed_rejects_physical_rank(distributed_ir: DistributedTaskIR) -> assert "rank.unknown" in _codes(invalid) +def test_collective_adt_makes_kind_specific_fields_structural() -> None: + all_reduce = make_collective_spec( + CollectiveKind.ALL_REDUCE, + (0, 1), + 64, + reduction=ReductionKind.SUM, + ) + broadcast = make_collective_spec(CollectiveKind.BROADCAST, (0, 1), 64, root=0) + + assert isinstance(all_reduce, AllReduce) + assert isinstance(broadcast, Broadcast) + assert collective_kind(all_reduce) is CollectiveKind.ALL_REDUCE + assert {field.name for field in fields(AllGather)} == {"participants", "message_bytes"} + assert {field.name for field in fields(AllReduce)} == {"participants", "message_bytes", "reduction"} + assert {field.name for field in fields(Broadcast)} == {"participants", "message_bytes", "root"} + with pytest.raises(TypeError, match="is abstract"): + CollectiveSpec() + with pytest.raises(ValueError, match="requires reduction"): + make_collective_spec(CollectiveKind.ALL_REDUCE, (0, 1), 64) + with pytest.raises(ValueError, match="does not accept reduction or root"): + make_collective_spec( + CollectiveKind.ALL_GATHER, + (0, 1), + 64, + reduction=ReductionKind.SUM, + ) + with pytest.raises(ValueError, match="root must be one of its participants"): + Broadcast((0, 1), 64, 2) + + +@pytest.mark.parametrize("participants", ((), (0, 0), (-1, 0))) +def test_collective_participant_refinement_is_shared(participants: tuple[int, ...]) -> None: + with pytest.raises(TypeError, match="AllToAll.participants"): + AllToAll(participants, 64) + + +def test_synchronization_token_refinement_is_shared() -> None: + token = TokenId.derive("fixture", "synchronization") + + with pytest.raises(TypeError, match="Signal.tokens"): + Signal(()) + with pytest.raises(TypeError, match="WaitFor.tokens"): + WaitFor((token, token)) + + +@pytest.mark.parametrize( + ("constructor", "field"), + ( + (lambda: MachineOpcode("", "launch"), "MachineOpcode.dialect"), + (lambda: MachineOpcode("virtual", ""), "MachineOpcode.name"), + (lambda: SchemaVersion(-1), "SchemaVersion.major"), + ), +) +def test_scalar_refinements_are_enforced_by_record_deriving(constructor, field: str) -> None: + with pytest.raises(TypeError, match=field): + constructor() + + +def test_relational_invariants_remain_on_the_owning_record() -> None: + with pytest.raises(ValueError, match="code section cannot contain opaque data"): + MachineSection("text", MachineSectionKind.CODE, data=b"not-code") + + def test_portable_rejects_timing_smuggled_through_attributes(portable_ir: PortablePlanIR) -> None: invalid = replace(portable_ir, attributes=FrozenDict({"duration": 10.0})) assert "attribute.reserved" in _codes(invalid) +def test_portable_rejects_dialect_semantics_smuggled_through_attributes(portable_ir: PortablePlanIR) -> None: + task = replace(portable_ir.tasks[0], attributes=FrozenDict({"source_layer": "legacy"})) + + assert "attribute.reserved" in _codes(replace(portable_ir, tasks=(task,) + portable_ir.tasks[1:])) + + def test_portable_unknown_producer_is_diagnostic_not_verifier_crash(portable_ir: PortablePlanIR) -> None: unknown = NodeId.derive("fixture", "unknown-producer") invalid_buffer = replace(portable_ir.buffers[2], producer=unknown) diff --git a/tests/validation/test_calculon.py b/tests/validation/test_calculon.py index 8336ac6..62aa11b 100644 --- a/tests/validation/test_calculon.py +++ b/tests/validation/test_calculon.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json from pathlib import Path @@ -7,11 +8,13 @@ from blueprinting.analysis.cost_model import CalibrationMode, estimate_iteration from blueprinting.mapping import NetworkTierBinding, TransformerTrainingMappingSpec -from blueprinting.synthesizer.dialects.transformer import EngineKind, TrainingPhase +from blueprinting.synthesizer.dialects.transformer import EngineKind, TrainingPhase, TransformerTrainingPlanTaskSemantic from blueprinting.synthesizer.frontend import build_transformer_model_ir, synthesis_session_for -from blueprinting.synthesizer.lowering import DistributeTransformerTrainingPass, PlanTransformerTrainingPass from blueprinting.synthesizer.passes import PassManager, PassPipeline +from blueprinting.synthesizer.stages.distributed.passes import DistributeTransformerTrainingPass +from blueprinting.synthesizer.stages.portable_plan.passes import PlanTransformerTrainingPass from blueprinting.system import SystemProfile +from blueprinting.validation import calculon as calculon_validation from blueprinting.validation import discover_seqsel_tab5_cases, run_calculon_experiment from blueprinting.workload import TransformerModelSpec, TransformerTrainingWorkloadSpec @@ -23,6 +26,10 @@ def _json(path: Path): return json.load(stream) +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + def _derive(model_name: str, mode: str): model_data = _json(ROOT / "data" / "models" / f"{model_name}.json") execution_data = _json(ROOT / "data" / "validation" / "seqsel" / "tab5" / f"{model_name}_{mode}.json") @@ -31,7 +38,7 @@ def _derive(model_name: str, mode: str): mapping = TransformerTrainingMappingSpec.from_mapping(execution_data) network_binding = NetworkTierBinding.from_mapping(execution_data) source = build_transformer_model_ir(model, datatype=workload.datatype) - result = PassManager().run( + result = PassManager().require_run( PassPipeline.of(DistributeTransformerTrainingPass(), PlanTransformerTrainingPass()), source, session=synthesis_session_for(model, workload, mapping), @@ -44,8 +51,8 @@ def test_transformer_lowering_produces_auditable_ir_checkpoints(): assert source.require_valid() is None assert tuple(record.pass_name for record in result.records) == ( - "transformer-distribute-v2", - "transformer-plan-work-v2", + "transformer-distribute", + "transformer-plan-work", ) assert tuple(checkpoint.ir.header.schema_name for checkpoint in result.checkpoints) == ( "blueprinting.distributed-task", @@ -59,9 +66,10 @@ def test_transformer_lowering_produces_auditable_ir_checkpoints(): def test_selective_recompute_is_structural_and_linear_gradients_are_derived(): _, _, _, _, _, result = _derive("gpt3-175B", "seqsel") recomputed_layers = { - task.workload.attributes["source_layer"] + task.semantic.source_layer for task in result.ir.tasks - if task.workload.attributes["phase"] == TrainingPhase.RECOMPUTE.value + if isinstance(task.semantic, TransformerTrainingPlanTaskSemantic) + and task.semantic.phase is TrainingPhase.RECOMPUTE } assert recomputed_layers == { @@ -70,10 +78,11 @@ def test_selective_recompute_is_structural_and_linear_gradients_are_derived(): "attention.probability_dropout", } query = { - TrainingPhase(task.workload.attributes["phase"]): task.workload.operations + task.semantic.phase: task.workload.operations for task in result.ir.tasks - if task.workload.attributes["source_layer"] == "attention.query" - and task.workload.attributes["engine"] == EngineKind.MATRIX.value + if isinstance(task.semantic, TransformerTrainingPlanTaskSemantic) + and task.semantic.source_layer == "attention.query" + and task.semantic.engine is EngineKind.MATRIX } assert query[TrainingPhase.FORWARD] == query[TrainingPhase.ACTIVATION_GRADIENT] assert query[TrainingPhase.FORWARD] == query[TrainingPhase.WEIGHT_GRADIENT] @@ -138,3 +147,59 @@ def test_explicit_recompute_does_not_copy_calculon_prefix_counter(): assert audit["derived_explicit_operations"] < audit["calculon_block_re_flops"] assert result.calibrated.recompute == pytest.approx(result.calculon_stats["recompute_time"], rel=1e-12) + + +def test_alignment_report_freezes_oracle_and_input_provenance(): + case = discover_seqsel_tab5_cases(ROOT / "data")[0] + report = run_calculon_experiment((case,)) + payload = report.to_dict() + + assert report.schema == "blueprinting.calculon-calibration-experiment.v0" + assert report.oracle["name"] == "Calculon" + assert report.oracle["package_version"] == "0.1.0" + assert len(report.oracle["source_digest"]) == 64 + assert payload["paper_baseline"]["source"] == "https://arxiv.org/abs/2205.05198" + assert payload["cases"][0]["inputs"] == { + "model": {"file": case.model_path.name, "sha256": _sha256(case.model_path)}, + "execution": {"file": case.execution_path.name, "sha256": _sha256(case.execution_path)}, + "system": {"file": case.system_path.name, "sha256": _sha256(case.system_path)}, + } + + +def test_oracle_runs_only_after_both_blueprinting_estimates(monkeypatch): + events = [] + estimate = calculon_validation.estimate_iteration + oracle = calculon_validation._run_calculon + + def record_estimate(*args, **kwargs): + events.append(f"estimate:{args[2].value}") + return estimate(*args, **kwargs) + + def record_oracle(*args, **kwargs): + events.append("oracle") + return oracle(*args, **kwargs) + + monkeypatch.setattr(calculon_validation, "estimate_iteration", record_estimate) + monkeypatch.setattr(calculon_validation, "_run_calculon", record_oracle) + run_calculon_experiment((discover_seqsel_tab5_cases(ROOT / "data")[0],)) + + assert events == ["estimate:peak_only", "estimate:system_evidence", "oracle"] + + +def test_committed_alignment_artifact_and_bilingual_report_match_current_experiment(): + report = run_calculon_experiment(discover_seqsel_tab5_cases(ROOT / "data")) + committed = _json(ROOT / "examples" / "calculon_calibration_result.json") + + assert committed == report.to_dict() + for locale in ("en", "zh"): + document = (ROOT / "docs" / "experiments" / f"calculon-calibration.{locale}.md").read_text() + for expected in ( + report.schema, + report.oracle["source_digest"], + "12.99%", + "3.65%", + "8.87%", + "152", + "1e-9%", + ): + assert expected in document diff --git a/tests/workbench/test_chrome_trace.py b/tests/workbench/test_chrome_trace.py index b3bc89c..e4dc3f2 100644 --- a/tests/workbench/test_chrome_trace.py +++ b/tests/workbench/test_chrome_trace.py @@ -28,7 +28,7 @@ def test_portable_projection_exports_tasks_metadata_and_dependency_flows() -> No assert len(flow_starts) == sum(len(task.dependencies) for task in report.tasks) assert {event["id"] for event in flow_starts} == {event["id"] for event in flow_ends} assert document["metadata"] == { - "schema": "blueprinting.chrome-trace.portable-projection.v1", + "schema": "blueprinting.chrome-trace.portable-projection.v0", "timeline_kind": TRACE_KIND, "source_plan_digest": report.plan_digest, "request_digest": report.request_digest, diff --git a/tests/workbench/test_nicegui_workbench.py b/tests/workbench/test_nicegui_workbench.py index 7cf3787..1f92a97 100644 --- a/tests/workbench/test_nicegui_workbench.py +++ b/tests/workbench/test_nicegui_workbench.py @@ -91,6 +91,56 @@ async def test_nicegui_analysis_reuses_one_result_across_views( await user.should_see("模型语义", retries=100) await user.should_see("分布式任务", retries=100) await user.should_see("可移植计划", retries=100) + await user.should_see("IR Explorer", retries=100) + await user.should_see("Canonical IR 表达", retries=100) + await user.should_see("Short · 语义骨架", retries=100) + await user.should_see("Detailed · typed entities", retries=100) + await user.should_see(marker="ir-short-expression", retries=100) + user.find(marker="ir-expression-detailed-tab").click() + await user.should_see(marker="ir-detailed-expression", retries=100) + await user.should_see("本层回答", retries=100) + await user.should_see("本次结果", retries=100) + await user.should_see("边界与下一步", retries=100) + await user.should_see("模型数据流结构", retries=100) + await user.should_see("列表示 phase,行固定为 subsystem × entity kind", retries=100) + await user.should_see("这一步做了什么", retries=100) + await user.should_see("Lowering 形态", retries=100) + await user.should_see("查看 canonical entity 映射与 pass contract", retries=100) + await user.should_see(marker="ir-layer-graph", retries=100) + await user.should_see("Lowering 对应关系", retries=100) + await user.should_see("Verified lowering 表达", retries=100) + await user.should_see("Short · pass contract", retries=100) + await user.should_see("Detailed · rules & evidence", retries=100) + await user.should_see(marker="lowering-short-expression", retries=100) + user.find(marker="lowering-expression-detailed-tab").click() + await user.should_see(marker="lowering-detailed-expression", retries=100) + await user.should_see("内部 name、参数和括号由连续的语义 span 分块", retries=100) + await user.should_see(marker="ir-boundary-table", retries=100) + lowering_grid = next(iter(user.find(marker="ir-boundary-table").elements)) + lowering_options = lowering_grid.options + assert lowering_options["enableCellSpan"] + assert [column["headerName"] for column in lowering_options["columnDefs"]] == ["Source", "Pass", "Target"] + assert lowering_options["columnDefs"][0]["spanRows"] + assert lowering_options["columnDefs"][1]["spanRows"] + assert "spanRows" not in lowering_options["columnDefs"][2] + assert "source_type" not in lowering_options["columnDefs"][0][":cellRenderer"] + assert "token.name + '=' + token.value" in lowering_options["columnDefs"][0][":cellRenderer"] + assert "expression.appendChild(component)" in lowering_options["columnDefs"][0][":cellRenderer"] + assert "token.category || 'property'" in lowering_options["columnDefs"][0][":cellRenderer"] + assert "pass_expression_parameters" in lowering_options["columnDefs"][1][":cellRenderer"] + assert "target_type" not in lowering_options["columnDefs"][2][":cellRenderer"] + await user.should_see(marker="download-derivation-bundle", retries=100) + await user.should_see(marker="upload-derivation-bundle", retries=100) + concrete_stage = next(iter(user.find(marker="ir-stage-concrete").elements)) + machine_stage = next(iter(user.find(marker="ir-stage-machine").elements)) + assert concrete_stage._props["disable"] + assert machine_stage._props["disable"] + + user.find(marker="ir-stage-distributed").click() + await user.should_see("逻辑任务结构", retries=100) + + user.find(marker="ir-stage-portable").click() + await user.should_see(marker="ir-overlay-toggle", retries=100) async def test_nicegui_marks_results_stale_after_configuration_change( @@ -187,6 +237,21 @@ def test_sidebar_mode_switch_uses_a_non_scrolling_two_by_two_grid() -> None: assert "q-tab__indicator" not in WORKBENCH_CSS +def test_lowering_expressions_use_nested_semantic_highlights() -> None: + assert ".bp-expression-stack {\n display: block;" in WORKBENCH_CSS + assert ".bp-expression-stack {\n display: flex;" not in WORKBENCH_CSS + assert ".bp-expression {" in WORKBENCH_CSS + assert "display: inline" in WORKBENCH_CSS + assert "box-decoration-break: clone" in WORKBENCH_CSS + assert ".bp-expression-component--name" in WORKBENCH_CSS + assert ".bp-expression-component--structure" in WORKBENCH_CSS + assert ".bp-expression-component--type" in WORKBENCH_CSS + assert ".bp-expression-component--topology" in WORKBENCH_CSS + assert ".bp-expression-component--workload" in WORKBENCH_CSS + assert ".bp-expression-component--mapping" in WORKBENCH_CSS + assert ".bp-pass-cell" in WORKBENCH_CSS + + def test_workbench_cli_accepts_server_overrides() -> None: args: Any = build_parser().parse_args(["--host", "0.0.0.0", "--port", "9000", "--no-open", "--reload"]) diff --git a/tests/workbench/test_presentation.py b/tests/workbench/test_presentation.py index 2d6a1c1..95710c7 100644 --- a/tests/workbench/test_presentation.py +++ b/tests/workbench/test_presentation.py @@ -6,12 +6,27 @@ import pytest from blueprinting.analysis import CalibrationMode -from blueprinting.application import AnalysisDraft, BlueprintingService, SweepCase, SweepReport +from blueprinting.application import ( + AnalysisDraft, + BlueprintingService, + CanonicalIRStage, + EntityRef, + IRGraphEdge, + IRGraphNode, + IRGraphView, + SweepCase, + SweepReport, +) from blueprinting.workbench import default_catalog +from blueprinting.workbench.ir_expressions import lowering_correspondence_rows, lowering_expression from blueprinting.workbench.presentation import ( analysis_metrics, dependency_timeline_chart_options, + ir_graph_chart_options, + ir_stage_narrative, + lowering_narrative, memory_chart_options, + semantic_boundary_rows, sweep_chart_options, sweep_distribution_chart_options, task_dependency_projection, @@ -73,6 +88,178 @@ def test_analysis_summary_has_four_decision_metrics() -> None: assert [metric.label for metric in metrics] == ["迭代延迟", "全局吞吐", "单设备吞吐", "单设备内存"] +def test_large_ir_graph_uses_a_bounded_semantic_group_projection() -> None: + digest = "a" * 40 + refs = tuple(EntityRef(CanonicalIRStage.PORTABLE, digest, "task", f"node:{index:05d}") for index in range(10_000)) + graph = IRGraphView( + CanonicalIRStage.PORTABLE, + digest, + tuple(IRGraphNode(ref, ref.entity_id, f"phase-{index % 100}") for index, ref in enumerate(refs)), + tuple(IRGraphEdge(refs[index - 1], refs[index], "dependency") for index in range(1, len(refs))), + ) + + options = ir_graph_chart_options(graph) + + entity_nodes = [ + item for item in options["series"][0]["data"] if not item.get("is_phase") and not item.get("is_lane") + ] + + assert len(entity_nodes) <= 20 + assert all(item["kind"] == "group" for item in entity_nodes) + assert "10,000 canonical entities" in options["graphic"][0]["style"]["text"] + + +def test_distributed_structure_uses_ordered_phase_and_subsystem_lanes() -> None: + trace = _analysis_report().derivation_trace + graph = trace.graph(CanonicalIRStage.DISTRIBUTED, trace.branches[0]) + assert graph is not None + + options = ir_graph_chart_options(graph, max_nodes=20) + series = options["series"][0] + phase_nodes = [item for item in series["data"] if item.get("is_phase")] + lane_nodes = [item for item in series["data"] if item.get("is_lane")] + entity_nodes = [item for item in series["data"] if not item.get("is_phase") and not item.get("is_lane")] + + assert series["layout"] == "none" + assert not series["roam"] + assert not series["draggable"] + assert [item["name"] for item in phase_nodes] == ["输入", "前向", "激活梯度", "权重梯度", "优化器", "输出"] + assert [item["x"] for item in phase_nodes] == sorted(item["x"] for item in phase_nodes) + assert any(item["name"] == "attention\nCollective" for item in lane_nodes) + assert any(item["name"].startswith("attention\n") for item in entity_nodes) + assert any(item["entity_kind"] == "collective" for item in entity_nodes) + assert all("other" not in item["name"] for item in entity_nodes) + lane_positions = {(item["subsystem"], item["entity_kind"]): item["y"] for item in lane_nodes} + assert all(item["y"] == lane_positions[(item["subsystem"], item["entity_kind"])] for item in entity_nodes) + + +def test_ir_stage_narrative_explains_ownership_and_snapshot_result() -> None: + report = _analysis_report() + model_stage = report.derivation_trace.stages[0] + + narrative = ir_stage_narrative(model_stage.ir) + + assert narrative.question == "这个模型在语义上做什么?" + assert "1 个 operation" in narrative.result + assert "不包含并行 placement" in narrative.excludes + assert "逻辑 mesh" in narrative.next_step + + +def test_boundary_presentation_explains_and_groups_one_to_many_lowering() -> None: + trace = _analysis_report().derivation_trace + transition = trace.transitions[0] + boundary = transition.boundary + branch = trace.branches[0] + source_graph = trace.graph(boundary.source_stage, branch) + target_graph = trace.graph(boundary.target_stage, branch) + assert source_graph is not None + assert target_graph is not None + + narrative = lowering_narrative(boundary, source_graph, target_graph) + rows = semantic_boundary_rows(boundary, source_graph, target_graph) + + assert "transformer.decoder_training 被展开" in narrative.headline + assert "typed lineage" in narrative.detail + assert len(rows) < len(boundary.relations) + decoder_rows = [row for row in rows if row["source"] == "transformer.decoder_training"] + assert len(decoder_rows) > 1 + assert {row["mapping"] for row in decoder_rows} == {"1 → 57"} + assert sum(row["source_group_start"] for row in decoder_rows) == 1 + assert len({row["transform_span_key"] for row in decoder_rows}) == 1 + assert any( + row["source"] == "transformer.decoder_training" + and row["target"] == "前向 · attention" + and row["target_kind"] == "local compute" + and row["mapping_kind"] == "展开" + and row["mapping"].startswith("1 → ") + for row in rows + ) + assert all("node:" not in row["source"] for row in rows) + + expression = lowering_expression(transition, rows) + assert "ModelOperation -> DistributedTask" in expression.short + assert "normal_form blueprinting.synthesizer.dialects.transformer.training_derivation." in expression.detailed + assert "normalize_training_distribution" in expression.detailed + assert "transition_status relation_verified" in expression.detailed + assert transition.canonical_conformance is not None + assert "canonical_conformance blueprinting.synthesizer.dialects.transformer.training_derivation." in ( + expression.detailed + ) + assert "invariant @blueprinting.synthesizer.dialects.transformer.training_derivation." in expression.detailed + assert "verified_claims" in expression.detailed + assert "forbids [physical device, queue, kernel, predicted time]" in expression.detailed + + correspondence = lowering_correspondence_rows(transition, rows) + decoder_correspondence = [row for row in correspondence if row["source"] == "transformer.decoder_training"] + assert decoder_correspondence + assert {row["qualified_rule"] for row in decoder_correspondence} == {"transformer-distribute.transformer-decompose"} + assert {row["rule_signature"] for row in decoder_correspondence} == {"ModelOperation ⇒ DistributedTask"} + assert all(row["rule_declared"] for row in decoder_correspondence) + assert all("relation=展开, cardinality=1 → 57" in row["transform_expression"] for row in decoder_correspondence) + assert all( + "Expand one semantic decoder-training operation into an ordered local/collective task DAG" + in row["transform_expression"] + for row in decoder_correspondence + ) + assert {row["source_type"] for row in decoder_correspondence} == {"Operation"} + assert {row["source_tone"] for row in decoder_correspondence} == {"blue"} + assert all(row["pass_type"] == "Pass" for row in decoder_correspondence) + assert all( + row["pass_expression_parameters"] + == ( + {"name": "relation", "value": "展开", "category": "mapping"}, + {"name": "cardinality", "value": "1 → 57", "category": "mapping"}, + ) + for row in decoder_correspondence + ) + assert all( + row["source_expression"] + == "transformer.decoder_training(shape=[microbatch_size, sequence_length, 128], dtype=float16)" + for row in decoder_correspondence + ) + assert any( + row["target_expression"] == "MLP.forward(ranks=2, count=6)" + and row["target_type"] == "Local compute" + and row["target_tone"] == "blue" + for row in decoder_correspondence + ) + + +def test_portable_target_expression_exposes_exact_workload_facts() -> None: + trace = _analysis_report().derivation_trace + transition = trace.transitions[1] + boundary = transition.boundary + branch = trace.branches[0] + rows = semantic_boundary_rows( + boundary, + trace.graph(boundary.source_stage, branch), + trace.graph(boundary.target_stage, branch), + ) + + value_to_buffer = next(row for row in rows if row["source_kind"] == "value" and row["target_kind"] == "buffer") + assert value_to_buffer["source_expression_name"].startswith("value#") + assert value_to_buffer["target_expression_name"].startswith("buffer#") + assert value_to_buffer["source_entity_ids"][0].startswith("value:") + assert value_to_buffer["target_entity_ids"][0].startswith("buffer:") + + mlp_forward = next(row for row in rows if row["target"] == "前向 · mlp" and row["target_kind"] == "compute") + + assert mlp_forward["source_expression"] == "MLP.forward(ranks=2, count=6)" + assert mlp_forward["target_type"] == "Compute" + assert mlp_forward["target_tone"] == "blue" + assert mlp_forward["target_expression_name"] == "MLP.forward" + assert mlp_forward["target_expression_parameters"] == ( + {"name": "ranks", "value": "2", "category": "topology"}, + {"name": "ops", "value": "4304896", "category": "workload"}, + {"name": "read_B", "value": "217600", "category": "workload"}, + {"name": "write_B", "value": "57344", "category": "workload"}, + {"name": "count", "value": "6", "category": "workload"}, + ) + assert mlp_forward["target_expression"] == ( + "MLP.forward(ranks=2, ops=4304896, read_B=217600, write_B=57344, count=6)" + ) + + def test_memory_chart_stacks_components_and_marks_capacity() -> None: options = memory_chart_options(_analysis_report()) diff --git a/uv.lock b/uv.lock index bc1e5f5..51c9e56 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.15'", + "python_full_version >= '3.13' and python_full_version < '3.15'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", @@ -276,23 +277,10 @@ dependencies = [ { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pandas" }, { name = "psutil" }, - { name = "rich" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] -all = [ - { name = "mkdocs" }, - { name = "mkdocs-material" }, - { name = "mkdocs-static-i18n", extra = ["material"] }, - { name = "mkdocstrings", extra = ["python"] }, - { name = "plotly" }, - { name = "pyarrow" }, - { name = "pymdown-extensions" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, @@ -306,12 +294,12 @@ docs = [ { name = "mkdocstrings", extra = ["python"] }, { name = "pymdown-extensions" }, ] -full = [ - { name = "plotly" }, -] performance-data = [ { name = "pyarrow" }, ] +typing = [ + { name = "mypy" }, +] [package.dev-dependencies] dev = [ @@ -320,38 +308,30 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, ] +typing = [ + { name = "mypy" }, +] [package.metadata] requires-dist = [ - { name = "mkdocs", marker = "extra == 'all'", specifier = ">=1.5.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.5.0" }, - { name = "mkdocs-material", marker = "extra == 'all'", specifier = ">=9.0.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.0.0" }, - { name = "mkdocs-static-i18n", extras = ["material"], marker = "extra == 'all'", specifier = ">=1.3.1,<2.0.0" }, { name = "mkdocs-static-i18n", extras = ["material"], marker = "extra == 'docs'", specifier = ">=1.3.1,<2.0.0" }, - { name = "mkdocstrings", extras = ["python"], marker = "extra == 'all'", specifier = ">=0.24.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, + { name = "mypy", marker = "extra == 'typing'", specifier = ">=1.17,<2" }, { name = "nicegui", specifier = ">=3.15,<4" }, { name = "numpy", specifier = ">=1.20.0" }, { name = "pandas", specifier = ">=1.3.0" }, - { name = "plotly", marker = "extra == 'all'", specifier = ">=5.0.0" }, - { name = "plotly", marker = "extra == 'full'", specifier = ">=5.0.0" }, { name = "psutil", specifier = ">=5.9.0" }, - { name = "pyarrow", marker = "extra == 'all'", specifier = ">=12.0.0" }, { name = "pyarrow", marker = "extra == 'performance-data'", specifier = ">=12.0.0" }, - { name = "pymdown-extensions", marker = "extra == 'all'", specifier = ">=10.21.3,<11.0.0" }, { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=10.21.3,<11.0.0" }, - { name = "pytest", marker = "extra == 'all'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'all'", specifier = ">=0.24.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, - { name = "pytest-cov", marker = "extra == 'all'", specifier = ">=4.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "rich", specifier = ">=12.0.0" }, - { name = "ruff", marker = "extra == 'all'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "typing-extensions", specifier = ">=4.4.0" }, ] -provides-extras = ["all", "dev", "docs", "full", "performance-data"] +provides-extras = ["dev", "docs", "performance-data", "typing"] [package.metadata.requires-dev] dev = [ @@ -360,6 +340,7 @@ dev = [ { name = "pytest-cov", specifier = ">=4.0.0" }, { name = "ruff", specifier = ">=0.1.0" }, ] +typing = [{ name = "mypy", specifier = ">=1.17,<2" }] [[package]] name = "certifi" @@ -901,6 +882,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + [[package]] name = "lxml" version = "6.1.1" @@ -1040,18 +1151,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, ] -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - [[package]] name = "markdown2" version = "2.5.5" @@ -1146,15 +1245,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - [[package]] name = "mergedeep" version = "1.3.4" @@ -1440,12 +1530,70 @@ wheels = [ ] [[package]] -name = "narwhals" -version = "2.16.0" +name = "mypy" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/6f/713be67779028d482c6e0f2dde5bc430021b2578a4808c1c9f6d7ad48257/narwhals-2.16.0.tar.gz", hash = "sha256:155bb45132b370941ba0396d123cf9ed192bf25f39c4cea726f2da422ca4e145", size = 618268, upload-time = "2026-02-02T10:31:00.545Z" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/cc/7cb74758e6df95e0c4e1253f203b6dd7f348bf2f29cf89e9210a2416d535/narwhals-2.16.0-py3-none-any.whl", hash = "sha256:846f1fd7093ac69d63526e50732033e86c30ea0026a44d9b23991010c7d1485d", size = 443951, upload-time = "2026-02-02T10:30:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] @@ -1555,7 +1703,8 @@ name = "numpy" version = "2.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.15'", + "python_full_version >= '3.13' and python_full_version < '3.15'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] @@ -1813,19 +1962,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] -[[package]] -name = "plotly" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "narwhals" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e3/4f/8a10a9b9f5192cb6fdef62f1d77fa7d834190b2c50c0cd256bd62879212b/plotly-6.5.2.tar.gz", hash = "sha256:7478555be0198562d1435dee4c308268187553cc15516a2f4dd034453699e393", size = 7015695, upload-time = "2026-01-14T21:26:51.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl", hash = "sha256:91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4", size = 9895973, upload-time = "2026-01-14T21:26:47.135Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -2407,19 +2543,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "rich" -version = "14.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, -] - [[package]] name = "ruff" version = "0.14.14" @@ -2936,7 +3059,8 @@ name = "websockets" version = "17.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.15'", + "python_full_version >= '3.13' and python_full_version < '3.15'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ]