{{ message }}
Tags: mcpp-community/mcpp
Tags
2026.9.6.6: one package, one version — and a rule brings its own envi… …ronment (#584) * 2026.9.6.6 —— 一个包一个版本,规则自带它的环境 版本在一个地方属于包的身份,在另一个地方不属于。条件合并按包去重 (`xim:glibc` 与 `xim:glibc@2.40` 是一个条目),而跨包的划分比对整个地址 串(是两个包)。于是工程钉 `xim:cuda-nvcc@13.3.33`、规则包钉 `@>=12.9.86` 时两份都装 —— 每份数 GB —— 而 `fillXpkgDirs` 里「同名保留第 一个值」让 `xpkg_dir` 只答其中一个。装了两份、用了一份,没有一句话提到它。 身份统一为 `(namespace, name)`,版本永远是这个包上的约束。装哪一版分两 步:裁决 —— 离产物更近的声明赢,不带版本的声明弃权,结果被报出来;校验 —— 赢家必须满足每一条落败的要求,不满足就拒绝并同时点出两侧与出路。裸版 本是选择不是要求,两条不同的精确钉走裁决而不被拒绝,否则每一个「依赖钉了 工具、工程也钉了」的组合会在升级当天变成硬失败。 校验是一次比较不是一次搜索:版本由裁决选定再被检查,所以引擎从不需要问索 引「有哪些版本」,也就不带约束求解器。代价写进文档而不是藏起来。 `mcpp::xpkg_dir` 现在回答范围。版本位一直接受范围表达式且 xlings 真的求解 它,而这个查询把整个位置当目录名比对 —— 于是 `>=8.5.0` 装上了载荷然后回答 「没装」。这正是让规则包无法声明下界的那道缝。 四处新拒绝有了名字:`device-source-unconsumed`、`accel-backend-undeclared`、 `host-module-missing`、`tool-version-conflict`。 xlings pin 从 2026.8.30.2 抬到 2026.9.5.1。 判据:e2e 627(装了什么与答了什么必须同时断言)、628(拒绝 + 抬钉后通过的 反向腿 + `--format json` 的 token)、629(范围被安装并被回答)、630(两重门 的两条腿);单测 test_xlings_address_set 与 xpkg_payload_at 的范围/不可解析 版本两组。三条 e2e 在已发布的 2026.9.6.5 上实测为红。 规范:SPEC-001 §10、SPEC-004 §4.5 与判据 5–7。 * docs: 参考文档不用第二人称,四处改回陈述句 * docs(plan): 实现期的实测回填 —— 七条,其中三条推翻了正文 * e2e: 628 不设能力门,拒绝规则与平台无关;记下 diag 的去重是设计属性 * docs(plan): round 7 的沙箱验证脚本 —— 五节,跳过的那几节在 summary 里重列 * docs(20): lane 表那一列是规则「声明的」载荷,不是使用者要写的 * e2e(629): 补上范围的反向腿 —— 不可满足必须被拒,且拒绝里引用那条范围 * docs(07/17): 依赖的 [xlings] 声明会被采纳,且 xpkg_dir 现在回答范围 —— 两处陈述已过期 * docs(changelog): 2026.9.6.6 单独成节,release.yml 才取得到发布说明 * fix(runtime): DT_RPATH 被整条链继承 —— 闭包检查拒掉了一个加载器能起来的产物 * docs(plan): 回填第八条 —— 生态验证挖出的 DT_RPATH 继承缺陷 * docs(plan): 沙箱脚本的三处判据在预演里被证伪并改掉 —— E 节换成自足构造 --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
docs(plan): the four general build-infrastructure gaps (#583) * docs(plan): the four general build-infrastructure gaps Separates what is general build infrastructure from what belongs to the heterogeneous domain, by a stated criterion: an item qualifies when at least two of CMake / Meson / Autotools / Cargo have a counterpart and its reason for existing names no domain concept. RDC fails that test and is left to docs/20; the primitive it would reuse already exists. Four gaps survive, and two of them were smaller than they first looked once main was measured rather than recalled: * config.h generation is NOT an engine gap. All four pieces are present (toolchain_dir/sysroot_dir for the right compiler, a real program to write the file, include-dir to make the package's TUs see it, rerun-if-changed for incrementality). What is missing is a shared probe library, which is a package rather than an engine change. * package layout does NOT need a new section. `[runtime].artifacts` already declares a relative path plus a role; the white list is missing exactly one role -- a data file read by a loader outside this package. Adding a section would have duplicated an answer another section already gives, which docs/05 Appendix A refuses. The two that remain are an `exports` declaration rendered per platform (one neutral statement, three renderings, the same shape `[runtime]` already established) and a generic `link-flag` directive, which is the member the link-lib / link-search / link-script family is missing and the escape hatch a generated version script needs. Every criterion is two-sided, and C6 is the one that cannot be verified on a developer machine: a probe implementation that wrongly reads the host is green wherever /usr/bin/cc exists, so it has to run in the hermetic container job. * docs(plan): attribution, the programming-model three-way split, and a falsification target Revises the design doc. Two of its own judgements were wrong and are corrected in place rather than appended to, because a design doc whose corrections live at the bottom is read top-down and gets the retracted version. * RDC is plugin-side, engine change zero. Rechecked against the attribution rule this revision adds: it names a vendor (test 1), changes no artifact property (test 2) and invents no edge kind (test 3). Every primitive it needs exists -- `action` with the `object` destination is "an external step's object joins the ordinary link", twice. It therefore does not sit on the critical path, which changes the staging. * stdpar is islandable, so calling it mutually exclusive with mcpp's model was an overclaim. `-stdpar` decomposes into a compile-side island and a link-side allocator, and the allocator is a whole-image property imposed by a dependency -- the shape `cxx_runtime` already established. What is lost is the selling point ("change no source"), not the capability. docs/20's two-way split should become three-way. Three gaps added. The first came out of a manifest that reads badly: [target.'cfg(not(any(accelerator = "cuda", accelerator = "vulkan")))'.build] `accelerator` is an open vocabulary by design, and `not(any(<enumeration>))` over an open vocabulary silently changes meaning as the ecosystem grows -- a fifth backend edits the meaning of every fallback predicate already written. The general rule is that an open vocabulary needs a "none" that does not enumerate, and the spelling is already in this manifest: `os = "none"` is bare metal, so `accelerator = "none"` is no device backend. Preferred over `cpu`, which would make the axis carry two questions and leave the truth value of `cfg(accelerator = "cpu")` under `accel = "cuda"` undecided. The other two are the package-wide barrier a generated header creates (`source`'s documented semantics: every compile edge of the package waits) and Fortran, which is recorded as identified and deliberately given no design. Also: an attribution rule (three ordered tests) so the engine/plugin boundary is decided rather than argued each time; a note that "island" collides with branch-island in linker vocabulary and cuts across single-source/separate- source, with an alignment sentence rather than a rename; and CANN as the experiment that could falsify the central claim, since all four existing lanes are in one vendor lineage. Its decisive criterion is `git diff src/` empty. * docs(plan): the three CANN uncertainties, measured Two confirmed, one not, and the one that failed is the only real obstacle. All CANN repos clone anonymously from gitcode.com/cann at branch 8.5.0 under the CANN Open Software License Agreement 2.0. CONFIRMED -- hardware-free execution exists, and it is the vendor's own test path. Ascend C has three run modes, not two: npu (default), sim (NPU simulation) and cpu (CPU debug), selected by -DCMAKE_ASC_RUN_MODE=. The simulators ship inside the toolkit per SoC and asc-devkit's own unit tests link them (pvmodel_ascend910/310p/610, pem_davinci_ascend910B1/310B/610Lite). The distinction that decides whether a criterion is usable: cpu mode links tikicpulib and the tikcpp headers, so the same kernel source is compiled by the HOST compiler and there is no island in that graph at all. Passing in cpu mode proves the kernel's numerics, not the mechanism under test -- the criterion would be pointed at the wrong object, which is a shape this repository has paid for repeatedly. sim mode is recorded as keeping the island by inference, not by measurement, with the check to run at implementation time stated. CONFIRMED -- operator registration is JSON and is per-SoC. In ops-math (72 MB, 1451 .cpp under math/) each operator carries op_host/config/<soc>/<op>_binary.json mapping the operator signature to the device binary's filename, with six SoC directories. That is a textbook role = "manifest" case and it is target- conditional, so the slice exercises SPEC-004's target axis as well. The source layout is also already islanded: op_kernel/ beside op_host/ per operator. NOT CONFIRMED -- BiSheng is not a standalone artifact. It lives at ${ASCEND_DIR}/compiler/ccec_compiler/bin/bisheng inside the CANN toolkit, whose download requires accepting a separate licence and appears to need login. No stable anonymous URL was found and redistribution terms are unverified. This is the experiment's one real obstacle and it is not a technical one. Consequently the feasibility gate is corrected: "hardware or a simulator" was wrong as a hard gate, because the decisive criterion (git diff src/ empty) is entirely build-time. The only true prerequisite is obtaining BiSheng lawfully. Incidental finding worth recording: CMake registers Ascend C as a LANGUAGE (FindASC.cmake, "plugin support ASC language") with the real machinery inside the toolkit. That is third-party corroboration of the axis in section 9 -- CMake puts it in the engine, mcpp puts it in a package. Same axis, different attribution, which is exactly what section 7 exists to decide. * docs(plan): the toolkit pulls anonymously, so the experiment has no blocker Overturns the previous commit's one negative finding, and corrects the framing error underneath it. The framing error first: BiSheng and the simulator were treated as two acquisition questions. They are one -- ccec_compiler/bin/bisheng and simulator/<SoC>/lib are both inside the CANN toolkit. The toolkit's official distribution is a Docker image, not a .run installer (measured in ops-math/QUICKSTART.md): swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:8.5.0-910b-ubuntu22.04-py3.10-ops and it pulls anonymously. Measured: GET /v2/ 401 GET /swr/auth/v2/registry/auth?...:pull token issued GET /v2/ascendhub/cann/manifests/<tag> with token 200, arm64 + amd64 That 401 is what produced the wrong answer the first time. A bare 401 reads identically to "credentials required", but it is also the first step of an anonymous token handshake -- Docker Hub behaves the same way. Stopping at step one turns "anonymously available" into "unobtainable". The criterion has to walk the whole handshake, which is this repository's recurring lesson about a criterion whose "no" and whose "not measured" produce the same reading. Compliance lands in the tier the invariant already allows: proprietary vendor userspace is fetched from the vendor's own published URL and never copied into an xlings-res release. Pulling the official image from Huawei's own registry is exactly that, so no redistribution right is needed. Also records that AscendNPU IR is now open with a Triton path (Triton IR -> Linalg -> AscendNPU IR), which gives a rule package a second possible entry height. Not selected -- Ascend C remains the direct one, because it is the actual form of the 1451 .cpp files in ops-math. The feasibility gate therefore has no blocking row. One technical question remains before implementation: whether sim mode still invokes bisheng, since that decides which object the device criterion is pointed at. * docs(plan): sim mode does invoke bisheng, measured in vendored cmake Closes the last open question in section 10. It was recorded as inference; it is now source. asc-devkit vendors the toolkit's own ASC_CMake under tools/ascc/cmake/, so the device compile path is readable without pulling the image: CMakeDetermineASCCompiler.cmake:47 find_program(CMAKE_ASC_COMPILER NAMES "bisheng" PATHS ".../ccec_compiler/bin/") CMakeASCInformation.cmake:49 CMAKE_ASC_COMPILE_OBJECT = "<CMAKE_ASC_COMPILER> ... -c -x asc <SOURCE>" host_config.cmake:69 CCEC_LINKER = <toolkit>/ccec_compiler/bin/ld.lld The decisive one is negative evidence: every RUN_MODE test in the repository is STREQUAL "cpu". There is no sim branch anywhere. Build-time therefore distinguishes only cpu from not-cpu, so sim takes the same path as npu and bisheng is invoked. The two differ at run time -- which runtime and simulator libraries load -- not in the build graph. The one extra action on the non-cpu branch is update_host_stub.py, which generates host-side launch stubs, and a launch stub exists precisely because there is a real device binary to start. Worth keeping the reason the question was asked: the opposite design is real and has good reasons behind it, since instruction-level simulation is orders of magnitude slower than host code and a host compiler gives gdb and ASAN. CANN split those concerns instead -- cpu mode IS that design, so sim would duplicate it unless it executed real device instructions. Three modes rather than two is itself the answer. Section 10 now has no open items. * docs(plan): the validation item gets a criterion and a slot, and that criterion was wrong Review pass over the whole document. Three findings, one of which is a defect in a criterion rather than an omission. OMISSION -- the validation experiment had neither a criterion nor a stage. It was described at length in section 10 and then appeared in neither the criteria table nor the staging table, so nothing tied it to the rest of the plan. Added as C9 and as a staging row with 13.1 explaining where it sits. DEFECT -- C9's decisive criterion, "git diff src/ is empty", cannot tell two failures apart: * the diff carries an Ascend-specific identifier -> the claim is refuted * the diff is a general capability like exports -> the engine is merely missing general infrastructure, which is what sections 2-6 exist to add Both read the same, so running it before the general gaps land would report the second as the first. Replaced by two levels: the primary criterion is that test_core_vendor_probes.cpp stays green after the port, which judges by property rather than by whether anything changed and therefore needs no precondition; "git diff src/ empty" is kept as a stricter additional statement, valid only after stages one to three. That also unblocks running the experiment early: the primary criterion works at any time, and an early run may surface a fifth general gap, which is cheaper than discovering it after three stages of engine work. ORPHAN -- kind = "device" appeared once, in the attribution table, with no criterion and no stage. It sits in a cross box: engine-side by test 2, but domain by the section 0.1 admission line, because it talks about accel. Two rulers, and they cross. Stated explicitly and scoped to docs/20 rather than left ambiguous. Also marked the superseded half of revision two in the change record, so a reader scanning the log does not take an overturned conclusion as current. * feat: link-flag, and an open vocabulary that can say "empty" Stage one of the general build-infrastructure design (.agents/docs/2026-09-07-general-build-infrastructure-gaps-design.md). mcpp:link-flag (protocol v8). link-lib, link-search and link-script each name one kind of thing, so a flag the program COMPUTED had no outlet: a version script whose content depends on which features are on, -Wl,--wrap=malloc for a runtime that takes over a C-library symbol, -Wl,--exclude-libs,ALL so a statically absorbed third party does not become part of this package's ABI. It reaches the consumer, and that CORRECTS the design doc, which first ruled it private by analogy with include-dir. The analogy is false and the code says so: linkUsage.ldflags is a copy of buildConfig.ldflags, so a private link flag is not a policy this engine can express, and [build] ldflags -- the declarative twin -- already propagates. Making the computed form behave differently would be the inconsistency rather than the safeguard. The hazard that follows is stated in the docs rather than hidden: a dependency emitting --version-script puts it on the consumer's link too, which a dependency writing the same flag in [build] ldflags has always done. cfg(accelerator = "none"). A CPU fallback could only be written by enumerating the backends it is not, and accelerator's vocabulary is open by design -- so that predicate changes meaning the day a fifth backend exists, and every fallback already written starts treating a build that named the new backend as having no accelerator. The spelling reuses os = "none", which this manifest already means "bare metal" by. Not cpu: that puts a second question on an axis whose job is "which device compiler, which architecture", and leaves cfg(accelerator = "cpu") under accel = "cuda" with no self-consistent answer. Both criteria measure the property rather than its shadow. e2e 620 asserts the LINKER'S BEHAVIOUR -- the program computes -Wl,--defsym=mcpp_e2e_620=42 and the artifact prints that symbol's address -- because grepping build.ninja would pass for a flag written down and never handed to the linker. The unit test SIMULATES the fifth backend arriving: the enumeration starts lying on the spot and none does not, which is the entire reason the row exists and is invisible under a single backend. * feat: exports -- one neutral statement of the symbol surface, three renderings Stage two of the general build-infrastructure design. Both platforms already publish everything: ELF gives symbols default visibility, and PE gets an auto-generated .def listing every symbol (mcpp.build.coff_exports, WINDOWS_EXPORT_ALL_SYMBOLS semantics). What was missing is the other direction. A runtime with a stable ABI publishes a reviewed set so that what is outside it stays free to change; a plugin loaded beside its rivals must not collide -- a Vulkan ICD that exports its internals collides with the loader and with the other ICDs in the process. This repository has the symptom on file: mcpp's own duplicate-symbol check on the SYCL example reports 68 _Unwind_* symbols, because one image holds two C++ runtimes and both export them. exports takes a file of symbol patterns or an inline list, and the backend renders it per platform -- version script, -exported_symbols_list, or the .def that replaces the all-exports one. One statement, three renderings, which is the shape [runtime] already established and the reason this is a manifest key rather than three platform-specific flag lists. IT DOES NOT IMPLY HIDDEN VISIBILITY, and that CORRECTS the design doc, which said it should. The narrowing is a link-time property on all three formats, so implying a compile-time one would give a single key two effects -- and the second effect also changes how this library's own translation units see each other, which is a separate decision with a separate reason. -fvisibility=hidden stays available through [build] cxxflags for the code generation it buys. The export list is read at manifest load rather than at plan time, so every later stage sees one representation; origin's parent is the package root and that holds for the root, a path dependency and a store dependency alike, which is the "same decision in N places" this would otherwise become. e2e 621 builds one source twice and requires the two readings to DIFFER. Asserting only that the public symbol is present passes for a library that exports everything, which is the state before this change; asserting only that the internal one is absent cannot distinguish "correctly hidden" from "never linked at all". * docs(plan): stages one and two landed, and three judgements they corrected Records what implementation established, including where it contradicted the design. * link-flag reaches the consumer. The doc ruled it private by analogy with include-dir; the code refutes the analogy. linkUsage.ldflags is a copy of buildConfig.ldflags and propagateLinkFlags pushes every dependency ldflag to the consumer, so a private link flag is not a policy the engine can express. include-dir is private because a compile interface HAS a declarative public counterpart; link flags do not, so the computed form must behave like its declarative twin. C4 is rewritten accordingly. * exports does not imply hidden visibility. The narrowing is link-time on all three formats, so implying a compile-time effect gives one key two, and the second changes how the library's own TUs see each other -- a separate decision. C3 is retired because it asserted exactly that coupling. * Section 4.3's open question is answered. manifest_emit builds [[runtime.artifacts]] from doc.legs plus one interface entry and does NOT carry the author's declared artifacts; role is a free string in the parser and the white list is an effect of its readers. So stage three is a new path through the packer -- carry a declared file into the artifact -- not a new role value. Staging is unchanged; its rationale is. * docs(examples): a multi-backend artifact, an Ascend one, and the table a rule package belongs in Two examples, and one correction that applies to every example already here. multi-backend -- several backends in ONE artifact, chosen at run time. The four examples beside it are each one seam: a device file and a CPU file define the same symbol and are never in one link, so exactly one exists and the choice is made at build time. That is right for a program. A library cannot make that choice; it is compiled once and consumed by people whose machines differ, so its backends are additive. It is also the first real use of cfg(accelerator = "none"). The dispatcher must be built for cuda, for vulkan, for both, and for a backend that does not exist yet, and not(any(cuda, vulkan)) needs editing every time the ecosystem grows -- the edit that is forgotten being silent. Its default accel is empty, so a plain mcpp build needs no payloads at all, which is what lets CI build it and makes the "none" path genuinely covered rather than described. cann -- Ascend, behind the same seam. It DOES NOT BUILD, and the README says so and names the two missing pieces: a rules-ascendc rule package, and an xim package for the toolkit. It records what was measured instead of guessed: both BiSheng and the simulator live in that one toolkit; the toolkit image pulls anonymously; sim mode needs no hardware AND keeps the island, while cpu mode does not keep it and therefore cannot stand in for a device criterion. The manifest is written out rather than described so the shape is concrete. CANN's own operator libraries already split op_kernel/ from op_host/, so the island is not a shape mcpp imposes on Ascend. THE CORRECTION. All four existing examples declare mcpp:plugins in [dependencies], and docs/05 section 2.6.1 names exactly that case as what [build-dependencies] is for: a package whose library must never reach the target while its rule is still wanted. The two axes are separate -- host-module = true says which build-time product is wanted, the section says whether the package reaches the target, and a rule package answers no on the second. Verified by moving it and rebuilding: build.mcpp compiles, the rule runs, shaders compile, and the artifact runs on a real device. Writing it in [dependencies] still works, which is precisely why the distinction has to be stated rather than left to a failure to teach. * Five defects a second backend found, and none of them was findable by reading The gap list this branch implements came from reading the code and from one device backend. Writing the second one turned up five more in an afternoon, and not one of them could have been found by continuing to read: every reading is either "the build succeeded" or an error message pointing somewhere else. A DEVICE SOURCE CAN COMPILE NOTHING, AND THE BUILD SUCCEEDS. A device-kind file is the one source the engine has no rule for: it goes to the package's build program and comes back as an action, or it is never compiled -- and nothing checked that it came back. The multi-backend example had no build.mcpp at all, so both device globs were dropped in silence and the reading was `undefined reference to opkit_cuda_saxpy` at the link: a message naming a symbol and never the file that would have defined it. A `kind = "lib"` target does not even get that, because an archive is not resolved. Now refused, naming the files, and distinguishing "no build program" from "a program that claimed nothing" because the fixes differ. The criterion is the action INPUTS, which is also the condition an action needs anyway: one that compiles a file it does not declare as an input does not rerun when that file changes. ONE RULE TOOK EVERY DEVICE SOURCE. `device_sources()` is the package's whole set and every rule in a build program reads it. Correct for exactly as long as a build has one rule in it -- a premise never written down. Measured with two: the CUDA rule compiled `scale.comp` AS CUDA and produced an object, and the shader rule then failed on the `.cu` with a message about stages. The louder failure was the harmless one. Fixed in mcpp-plugins 0.2.2 (each rule claims its extensions); the engine's half is the refusal above, because "no rule claimed it" is only visible here. `accelerator` WAS CLASSIFIED AS A RESOLVED LAYER. The five real layers are answered by dependency resolution, so refusing them in `[xlings]` predicates is right: payloads are installed before resolution. The accelerator is not one of them -- it is `--accel`, or `[build] accel`, read before the first package is looked up. The cost was paid on every build with a device island and paid worst on the cheapest one: a vendor toolkit could be declared unconditionally or not at all, so a CPU-only build downloaded gigabytes for a device it was not compiling for. Split by SCHEDULE rather than by subject; the connected consequence is that `[target.'cfg(accelerator = "cuda")'.dependencies]` now applies, there being nothing circular about it. NAMING A SUBSET OF BACKENDS WAS TREATED AS A MISMATCH. The refusal is right about architectures -- a file for sm_89 in a build targeting sm_80 is not a variant -- and was applied across backends, so a package with a CUDA island and a Vulkan one was refused when built with `--accel vulkan1.2`. A package could have several device backends only if every build took all of them, which is exactly what an additive-backend library cannot do. A glob whose backend is not named is now left out as `--no-accel` leaves it; what keeps that from turning `accel = "cude12.9"` into a file that is never compiled and never mentioned is a new check against `[package] accelerators`. A RULE'S PAYLOAD WAS UNREACHABLE FROM THE CONSUMER. A rule's code runs inside its consumer's build program, so `xpkg_dir` is asked there while the payload was declared in the rule's own `[feature-xlings]`. The graph already installed it; only the answer was missing, because `fillXpkgDirs` read one manifest. The reading was "the toolkit is not installed" with the toolkit on disk. Also: an import no dependency provides is refused by name. Left to the compiler it is `failed to read compiled module` plus a note that imports must be built first -- true, and naming neither the package nor `host-module = true`. The set of names that can compile there is closed, so a name outside it is refused with the candidates that could have provided it. VERIFICATION. e2e 622 through 626, five files. 622 and 625 were run against the released 2026.9.6.4 and failed as designed. 626 has a negative leg because two of its three legs would pass on an engine that refused every device source. 107 unit tests pass; the two cfg tests that stated the old classification now state the new one. examples/09-heterogeneous/multi-backend is the example all of this came from, and it now works: four command lines, three of them on a real RTX 4080. mcpp run backend: cpu (only backend in this build) mcpp run --accel "vulkan1.2" backend: vulkan (NVIDIA GeForce RTX 4080) mcpp run --accel "cuda12.9+{sm_89}" backend: cuda mcpp run --accel "cuda12.9+{sm_89}, vulkan1.2" backend: cuda Its Vulkan half was a stub that declined; a stub would have printed `backend: cpu` for a Vulkan build, which reads as "Vulkan failed here" rather than "this was never written", so the real implementation is ported from the sibling example. Its CUDA leg takes the clang route, measured rather than chosen: on the 12.9 line nvcc's own front end refuses the toolkit's non-`noexcept` `cospi` against the C library's, an older `xim:gcc` payload does not help because the declarations come from the C library, and the 13.x line raises the driver floor to r580 -- a requirement on the machine rather than a decision the project makes. * `.asc` and `.cce` enter the device-source table, and the CANN toolkit turns out to be one download The Ascend example named an `xim:cann-toolkit` that did not exist and a device extension the engine did not know. Both are now answerable, and the answer to the second was simpler than the design doc said. THE EXTENSION. `.asc` is Ascend C, compiled by `ccec` (BiSheng) from the CANN toolkit, and `.cce` is the older spelling of the same thing. They belong in the table for the reason `.sycl` does: the content is ordinary C++ and the criterion is the compiler. CANN's own operator libraries already split `op_kernel/` from `op_host/`, and CMake registers ASC as a language of its own -- the island is a shape Ascend already has, not one mcpp imposes on it. Adding to this table cannot change a build that works today: device extensions are absent from the default source glob, and one named in `sources` was until now a hard error. THE TOOLKIT. The design doc recorded that the vendor's container image pulls anonymously, which is true and is not the simplest route -- it stopped one layer short. Measured today, with no credentials at all: every toolkit from 8.0.RC1 to 8.5.0 is a plain `.run` on Huawei's own OBS, answering 200 to a HEAD request. 8.5.0 is 1.12 GB for x86_64 and 1.10 GB for aarch64. It installs without root and without a driver: ./Ascend-cann-toolkit_8.5.0_linux-x86_64.run --install \ --install-path=<dir> --quiet and 2.9 GB later the two pieces the lane needs are both there: `x86_64-linux/ccec_compiler/bin/{ccec,bisheng}` runs and reports clang 15.0.5, and `x86_64-linux/simulator/` carries 38 SoC directories, each with its own `libpem_davinci.so`. So the whole lane -- compiler and a hardware-free way to run what it produces -- is one anonymous download. The installer writes two things outside its install path, `~/Ascend` (8 KB, an install record) and `~/var/log/ascend_seclog`. Measured: overriding `HOME` for the duration contains both, and the payload installs identically, so a package recipe need not be a bad citizen of the user's home directory. This settles what the design doc listed as the lane's only remaining prerequisite. Compliance-wise it sits in the first tier of the standing rule -- closed-source and not redistributable, so it is fetched from the vendor's own URL with no CN mirror, which costs nothing here because the vendor's URL is already in-country. Verified after the mcpp-plugins 0.2.2 release: the multi-backend example now resolves the PUBLISHED `mcpp:plugins@0.2.2` from the index with no path override and no lock file, and runs -- `backend: cpu` with no accelerator and `backend: cuda` on a real device. * The first thing the new refusal caught was this repository's own fixtures Three e2e fixtures built a `build.mcpp` that read `mcpp::device_sources()`, printed it, and compiled nothing. They were asserting on the env contract, which is a real thing to assert on -- and they were modelling a project whose device files compile to nothing, which is exactly the defect the new refusal exists to catch. So the refusal caught them, and it was right to. 606, 609 and 613 now declare a `check` action per device source, which is the edge a real rule package declares, and go on asserting on the variable exactly as before. The action's input has to be ABSOLUTE: `device_sources()` is package-root-relative and an action does not run in the package root, which the first attempt got wrong and `cp: cannot stat 'shaders/s.tesc'` said plainly. EXAMPLE 05 PINS ITS TOOLCHAIN, and this is a different failure that surfaced in the same run. `mcpp pack` names its output directory after the ABI tag, and the consumer beside it names that directory literally -- so the pair only agrees on a machine whose DEFAULT toolchain is the one that produced the tag. Measured: a runner whose sandbox had been left with an LLVM default produced `mathkit-0.1.0-x86_64-linux-gnu-clang22-libcxx22-c++23` while the consumer named the `gcc16-libstdcxx16` one, and the example that had passed for months failed without anything in it changing. An example that hard-codes an ABI tag has to name the toolchain that produces it. That is not a workaround for CI: a packed artifact IS specific to the ABI it was built against, which is the entire subject of that example, so leaving the toolchain implicit was the inconsistency. * The Ascend example stops being a sketch: the kernel compiles, and the driver is what is missing It was written as a shape with two named gaps -- a rule package and an xim package for the toolkit -- and both now exist. What replaced them is a list of measurements. On an x86_64 machine with no Ascend hardware and no Ascend driver: `xim:cann-toolkit` provisions 2.9 GB without root; `build.mcpp` compiles and imports `mcpp.rules.ascendc`; the kernel compiles under `bisheng -x asc --cce-aicore-arch=dav-c220`; the resulting object joins the ORDINARY link, because the rule compiles in mixed mode and gets an x86-64 object carrying the device binary rather than a Da Vinci one the host linker cannot place; the host half links against ACL and the six-library closure the rule names. The artifact then does not start, because `libascend_hal.so` is missing -- the DRIVER, which is the role `libcuda.so.1` plays for CUDA: in ABI lockstep with the kernel module, not redistributable, and correctly absent here. `mcpp run --no-accel` builds and runs: `12 24 36 48`, `device: cpu`. So the example completes everywhere and RUNS only on an Ascend machine, which is the same statement `examples/09-heterogeneous/cuda` makes about a machine with no NVIDIA driver. Its skip reason says that instead of what it used to say. The payloads are gated on the accelerator, so the CPU leg installs nothing at all -- which is the first real use of that gating outside the multi-backend example. THE SEAM IS A C FUNCTION, and that is measured rather than stylistic: BiSheng's own launcher for a `__global__` function is C++-MANGLED even when the kernel is declared `extern "C"`. Calling it directly would make the program depend on BiSheng and the project's C++ compiler agreeing about mangling -- clang 15 and whatever the project chose. The `.asc` file exports an `extern "C"` wrapper instead, and the `<<<...>>>` launch spelling never leaves the translation unit the device compiler owns. The README's "what was established about the toolkit" section is replaced by what the toolkit turned out to BE, including the 38 SoC simulators that make this lane verifiable without an NPU -- which is the next thing this example should use, and a separate piece of work with its own contract. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
fix: a dependency's [feature-xlings] tools must be installed before i… …ts build.mcpp runs (#581) * fix: a dependency's [feature-xlings] tools must be installed before its build.mcpp runs The root's `[xlings.workspace]` declarations are provisioned early. The GRAPH's were provisioned about 1700 lines further down, after every build.mcpp had already run. So a package that declares a tool under the feature that needs it and then asks for it with `xpkg_dir` worked as the ROOT and was refused as a DEPENDENCY -- with the very declaration it had already made quoted back at it. Measured on the published `ggml-org:llamacpp@b10069.2`, which is exactly this shape: [feature-xlings.backend-vulkan] "xim:shaderc" = "2026.3" A consumer that writes the one line its README asks for could not build on a clean machine: error: dependency 'llamacpp': build.mcpp exited with 2 (build aborted): ggml-org:llamacpp backend-vulkan needs glslc. Declare it: ... With an isolated MCPP_HOME the unfixed engine pulled twenty-four xim payloads for that graph and not shaderc; the fixed one pulls it and the consumer builds, 1921 shaders compiled. WHY NOTHING CAUGHT IT. Once the tool is in the registry for any reason, and building the package itself puts it there, `xpkg_dir` finds it and the ordering stops mattering. llama.cpp-m's own CI builds it as the root; every machine that had touched the package had the payload. Only an empty registry can see this, which is what the sandbox run is for. The split is now computed once, beside `activeFeaturesByPackage`, and read by both the early provisioning pass and the later record-writing pass, so the two cannot disagree about what "the graph declared" means. The late provisioning call is kept: the stamp is keyed by content, so it is normally a hit, and it remains the site that makes the record true if the early pass did not run. e2e 618 is the criterion, and its first version was a false green worth recording: it used `xim:ninja`, which mcpp installs to run its own builds, so `xpkg_dir` answered on the broken engine too. It now uses the tool the defect was actually found with. * feat: the tool plane gets its target axis, and docs/spec becomes docs/specs A tool entry answers one of two different questions. `xim:dpcpp` is a compiler that runs on the build machine whatever it is asked to emit; `xim:glibc` is what the produced code is compiled against. `[xlings]` resolves its platform-keyed values against the HOST at load, so only the first was expressible. The SYCL example wrote both in that one table and was right by accident: on a native build the two axes name one platform. `[target.<selector>.xlings.workspace]` and `[target.<selector>.feature-xlings.<f>]` are now accepted and resolved against the TARGET, in the same shape `[target.<selector>.build]` and `[target.<selector>.feature-deps.<f>]` already had -- the condition written once, outside, on the selector. Zero new vocabulary, zero new sections, no published key renamed, every existing spelling unchanged in meaning. The rule this is an instance of is written down as SPEC-004 (docs/specs/manifest-semantics.md); the field reference is docs/05 section 2.13. Four things are refused rather than resolved, because each of them is a statement that would otherwise disappear in silence: * A value under a selector that also carries platform keys. The selector already said which targets this applies to; two statements of one fact can disagree, and the message names the outer selector because that is the half the author is not looking at. * A selector that names a target-side layer (`accelerator`, `c-abi`, `compiler`, ...). This one is schedule, not style: a layer is answered by dependency resolution, so a predicate naming one is held back to the second merge pass -- which runs after tools are provisioned and after every build.mcpp. An entry admitted there would be declared and never installed, and the build that results SUCCEEDS with the tool absent. e2e 619 measures both sides: on the previous engine that same project builds. * `subos` under a selector. A project has one environment, not one per target. * Nothing, at publish time -- but it is reported. A descriptor has one block per platform and a selector is not a platform, so a target-axis entry produces no descriptor edge. Guessing a representative triple per platform would drop every entry that triple did not satisfy into the same silence. Where both axes name one package the conditional entry wins and the override is reported, and dedup is by PACKAGE rather than by address: `xim:glibc` and `xim:glibc@2.40` are two addresses for one install, and keeping both is not a build that fails, it is a build whose environment depends on iteration order. THE CRITERION IS CROSS-TARGET AND RUNS ON ONE MACHINE. `tests/unit/test_target_xlings_axis.cpp` parses one manifest and merges it against two different resolved targets: the target-axis entries appear and disappear with the target while the host-axis entry is present both times. A non-cross build carries zero information about this difference, so an e2e that builds green here would have proved nothing. With the wiring removed, five of its six assertions fail; the one that passes is the host-axis regression, which is what it is there to say. The SYCL example moves its four compiled-against payloads to the target axis and still builds, with all five provisioned and the ecosystem C library ahead of the host on the device compiler's own search list. Also: `docs/spec/` is now `docs/specs/`, with the 45 references rewritten. * docs(plan): remove the one emoji marker from the design doc * test(round 6): the sandbox script for the target axis, and two consequences the spec now states The script is what section 7's criteria are run by, against a PUBLISHED binary in an empty registry -- which is the only place the provisioning half of the target axis can be asked at all. On a machine that has built the package before, the payload is already there and the answer is the same whether the axis was read or not. Rehearsed against an isolated MCPP_HOME with the local build: six sections, zero failures, and section B's tool arrived by a real install into a registry the run had just observed to be empty. The spec also now states the two consequences the implementation makes true and nothing else records: a consumer sees the UNION of the two axes (a rule package does not learn which axis declared a payload), and the materialised .mcpp/.xlings.json describes the LAST BUILD'S target rather than the project. * test: a bare-triple selector reaches the tool dispatcher too `[target.<triple>.xlings.workspace]` is the other spelling of a selector, and whether it reaches this dispatcher rather than the `[target.<triple>]` handler that owns `toolchain` and `runner` is not visible from a manifest. If it went to the wrong one the entry would simply never appear, which is a failure with no output at all. Both legs: the matching triple carries the entry, a different one does not. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
feat: cxx_stdlib, the curriculum in CI, and two criteria that machine… … state decided (#579) `mcpp::cxx_stdlib()` / `MCPP_CXX_STDLIB` (2026.9.6.3). The engine has resolved this value for a long time -- it is in the cache key, the ABI tag, the toolchain fingerprint and `resolution.json` -- and never handed it to the layer that has to decide on it. `compiler()` cannot answer it: clang links libc++ on one machine and libstdc++ on another and reports "clang" either way, while the two differ in what they accept. llama.cpp-m has the case today, and its refusal lands in the same round. e2e 617 compares the answer against `resolution.json` and against the compiler family; removing the wiring turns it red. The examples say which device ran them. All four islands in 09-heterogeneous printed the same four numbers as their CPU fallback, so a silent fallback was indistinguishable from a device run -- in the one set of examples whose subject is which device ran the computation. The seam carries `saxpy_device_name()`, each backend fills in its own device, and it is set only after a successful call: a device run that did not happen has no device to report. Measured: `device: llvmpipe (LLVM 22.1.8, 256 bits)` against `device: cpu (this build names no accelerator)`. CI builds the curriculum. No job did. `.github/tools/build_examples.sh` enumerates the example ROOTS from the tree and compares them against a build list and a skip table; a root in neither fails the job, and every skip names its reason and where the coverage is. Six of fifteen build, including 05-lib-distribution through its own README's two-step order -- which also checks that the ABI tag its consumer hardcodes is still the one `mcpp pack` produces. The Vulkan example is built and RUN on the lavapipe payload. Two e2e criteria were decided by machine state, and both were green on every runner because a runner installs exactly one of anything. `206_runtime_binding_physics` asserted `inconclusive` on the assumption that the private loader cannot reach a host `libtinfo`; `xim:ncurses` is an ordinary ecosystem package, and a sub-OS that has it puts `libtinfo.so.6` on the artifact's RPATH, where the closure genuinely closes. It now reads the artifact's own search path and asserts in both directions. `168_build_mcpp_musl_host_static` selected its payload with `ls | head -1` -- lexicographic order, hence the oldest -- and on a machine with three musl toolchains chose one that predates the `std` module. It takes the newest, which is what resolution picks when nothing pins a version. The four device examples move to `mcpp:plugins` 0.2.1; the SYCL one also declares `xim:glibc` and `xim:linux-headers`, unpinned, because the device compiler is a second compiler and does not inherit the toolchain the engine configured. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
Round 5 opens: two engine defects llama.cpp's Vulkan backend exposed,… … and the examples regrouped (#576) * refactor(examples): group the four device examples under 09-heterogeneous The four examples are one lesson in four programming models. They share the kernel, the seam, the constrained glob, the CPU fallback and the answer; they differ only in which compiler the rule package drives. Four consecutive numbers in a curriculum say "four lessons", and a fifth model would have said five. examples/09-heterogeneous/ README.md the shared structure: island, seam, constrained glob, rule package, and the four layers underneath cuda/ was 09-cuda-kernel vulkan/ was 10-vulkan-compute sycl/ was 11-sycl-kernel hip/ was 12-hip-kernel The move carries no behaviour change. It also aligns the pins that had fallen behind the released ecosystem, because those edits touch the same manifests: * examples cuda and vulkan pinned `mcpp:plugins 0.1.1`; sycl and hip already pinned 0.2.0, so a reader comparing four examples saw two answers to one question. * examples cuda and hip named `compat:cuda-runtime`, an entry that is frozen and renamed. Its own recipe states the correction: what it farms is libcuda.so.1, the driver's userspace library, and "CUDA Runtime" in NVIDIA's vocabulary is libcudart, which `xim:cuda-cudart` already delivers as a payload. Both now name `compat:cuda-driver`. The old key still resolves, so this is a spelling correction rather than a repair. Version references in prose are left alone on purpose. A floor marker such as `*(2026.9.5.2+)*` states when a feature landed and is a historical fact; bumping it to the current release would make the document lie about its own subject. Only current pins track the release. References in CHANGELOG.md and in the dated design documents keep the old paths: they record what shipped under a released version, and rewriting them would make the record state something that was not true at the time. The round 4 verification harness moves with the examples, since it is a tool rather than a record. Its two example sections now fail loudly when MCPP_VERIFY_SRC names a checkout without them; previously a stale path would have read as "not requested" and skipped. * fix(build): a tool `[feature-xlings]` declared is now answerable through xpkg_dir `[feature-xlings.<f>]` has provisioned its packages since it existed: naming one downloads and installs it when `<f>` is active. The build program's environment, however, was filled from `[xlings.workspace]` alone, so `mcpp::xpkg_dir` returned "" for a payload sitting in the store. The shape is an answer that was resolved and never wired to the decision that reads it: the provisioning side reads "declarations plus the active features", the lookup side read only "declarations". It surfaced building llama.cpp's Vulkan backend, where the rule could not find `xim:shaderc` and the only sensible thing it can print is "declare xim:shaderc" -- naming a declaration the author had already written. A diagnostic that points at the wrong file is worse than none. `fillXpkgDirs` now folds in the `[feature-xlings]` entries of the feature closure the caller had already computed, so "which features are on" is still answered once. Installation stays the only filter, which is what keeps a `when = "dev"` entry answering "" for a consumer. e2e 614 is the criterion and it carries a control: the same project built twice, the payload path under `--features gpu` and an empty answer without it. Asserting only the first would pass on an mcpp that answered for every package it had ever installed. The package is `xim:ninja` -- one mcpp bootstraps into its own sandbox, so the test needs no network -- and its version is read from the store rather than written down, since a hardcoded version would turn an mcpp that bumped ninja into a failing test about features. Measured: the test exits 1 on the previous binary and 0 on this one. * fix(plan): a shared library from `[feature-deps]` now reaches the link line The second defect of the same family, surfaced by the same build. `[feature-deps]` is folded into the root's dependency map during resolution, so the dependency was resolved, fetched and COMPILED -- every log line said it was there. The plan read the root's edges from `packages[0]`, a snapshot taken BEFORE that fold, so the dependency's shared library was neither linked nor named as an implicit input. It was quiet because of the shape it needs. `mcpp build` on a library package produces an archive, and an archive resolves no symbols, so the build succeeded. The failure appeared in whoever linked an executable: llama.cpp's Vulkan backend built `libllama.a` cleanly and then failed to link its test with `undefined reference to vkGetInstanceProcAddr`. The fix follows the idiom already in this file: the root's truth lives in `*m`, not in `packages[0]` -- `checkVersionFloors` takes the same branch for the same reason. e2e 615's SHAPE is the criterion. Written as a binary, the same project passes on both the defective and the fixed engine -- measured -- so a fixture built around `mcpp run` would have been a test that could not fail. What surfaces the defect is a library root whose test binary does the linking, which is the shape every library package in this ecosystem has. Measured on llama.cpp-m with the two binaries side by side: `mcpp test vulkan_decode --features backend-vulkan` fails to link on the previous binary and answers `LLAMACPP_VULKAN_TEST=PASS` on this one. * docs(20): the shape a framework takes on top of the four lanes Round 5's first entry measured five things that generalise beyond llama.cpp, and docs/20 is where the shape of a device build is stated once rather than per project. Both languages. The one worth naming here: a software Vulkan device is not automatically a substitute for hardware. ggml keeps only devices whose type is not `eCpu`, so Mesa's lavapipe is excluded for its type alone while advertising every feature the backend requires. That is the framework's policy rather than a packaging defect, and the way past it is the framework's own selector. * chore(release): 2026.9.6.2 Two engine defects, both found building llama.cpp's Vulkan backend and both of the same shape -- an answer that was resolved and never wired to the decision that reads it. A tool declared under `[feature-xlings]` was installed and then invisible to `mcpp::xpkg_dir`; a shared library acquired through `[feature-deps]` was compiled and then left off the link line. Neither had a diagnostic: the first printed "declare this package" about a declaration the author had already written, and the second let a library package build cleanly and fail in whoever linked an executable. Both sites are updated together, as `.github/tools/check_version_pins.sh` requires. * docs(plan): round 5's task list, its status, and the harness that decides it The plan asked for a task breakdown that survives a context break; this records what each task is, which repository owns it, what it depends on, and -- for the ones already done -- the measurement rather than a checkbox. Two of the plan's own assumptions were wrong and are corrected in place rather than quietly dropped. "Correct tokens" is too weak a criterion: a token inside the vocabulary is produced by a backend that computed nonsense and by a build that never reached a device, so the criterion is now an equality between the device decode and the host decode, with the offload asserted first because two host decodes agree trivially. And "on lavapipe" was not reachable as stated: ggml keeps only Vulkan devices whose type is not `eCpu`, so a software implementation is dropped for its type alone. The round 5 harness follows round 4's rule, which round 4 earned the hard way: every check that selects its own object prints which object it selected. * docs(plan.cppm): name the rule the fix follows, and record the enumeration 'A root package's dependency EDGES are read from *m, never from packages[0]'. The four readers in this build were checked rather than sampled; this site was the only one taking the snapshot for a question about edges, and publisher.cppm wants the unmerged edges on purpose. * test(e2e 614): a payload directory is not required to have bin/ The guard picking a ninja version asked for `bin/ninja`, and this payload puts its executable at the payload root. On this machine that turned a working criterion into a skip -- which reads the same as a pass. Non-empty is the property the test actually needs, and it also handles the case the guard was added for: a directory an uninstall left behind, which `xpkg_dir` answers "" for and which would have failed the test about features while really measuring the store. Re-measured against the binary that predates both engine fixes: exit 1, with the intended assertion failing and the control still passing. * test(verify): the additivity criterion belongs on mcpp.lock, not resolution.json The first draft grepped resolution.json for a backend package name. That file records the runtime BINDING of the environment -- it lists whatever the machine's SubOS provides -- so on this machine it names mesa-lavapipe whether or not the project asked for it, and the check would have failed about the feature while measuring the host. mcpp.lock records what the project resolved, and it is state rather than a log line. It has to be removed first: a lock left by an earlier build of the same tree still lists that build's packages. Measured: with the lock removed, a CPU-only build of llama.cpp-m writes no lock at all. It resolves nothing. * docs(verify): the sandbox invocation in both harness headers could not be followed Two things, both measured on 2026-09-06: * `xlings subos <name>` is rejected -- the verb is `xlings subos use <name>`, and the environment has to exist (`xlings subos new <name>`) first; * the sandbox gets an EMPTY $HOME and a fresh /tmp, so `bash <this file>` finds nothing. The script has to be passed in, and base64 through --cmd is what works. mcpp is addressed by its store path for the same reason: the xlings data directory is shared into the sandbox, and a bare `mcpp` is not on PATH there. An instruction nobody can follow is worse than none, because it reads as though the step were done. * docs(plan): llama.cpp leaves the 'not built' row F1 is measured: the Vulkan backend builds, and on a software device with no GPU the decode equals the host's. The gate did what a gate is for -- two engine defects in its first hours -- so F2 may start. * test(e2e 616): the examples on disk and the ones the docs list must be the same set No CI job builds an example, so the curriculum is the part of this repository a rename can break silently -- the docs keep pointing at a path that is gone and every job stays green. This round moved four examples and edited 14 files by hand. The check is bidirectional, and one direction alone would not be worth running: 'every listed path exists' passes on a document that lists nothing, and 'every example is listed' passes on a document that lists everything and points half of it at the wrong place. It found a pre-existing gap on its first run: examples 04 through 07 exist on disk and neither language's 01-examples.md listed any of them. They are listed now. Structural, and it says so: it builds nothing, so it cannot tell whether an example still works. * test(verify): a section that measures the PUBLISHED form, not the checkout Every other section reads a source tree, which is the development form. A sandbox is the one place that can tell whether what was published resolves and works, and section F is the only one that asks: a consumer declaring `ggml-org:llamacpp` with `features = ["backend-vulkan"]` and nothing else -- which is also the claim the README makes about the diff a user writes. It is red between the tag and the index PR by construction, because the release order is the reverse of the dependency order. That is the correct reading, not a flake. * fix(manifest): the version warning must not predict a failure it cannot see `cmdline = "0.0.x"` earns a warning for a good reason: the form does not parse, and the failure that follows names the PACKAGE, sending the reader to investigate a package that exists. But the message asserted "The fetch will fail", unconditionally. A string that is not a range is used as an EXACT index key, so whether the fetch fails depends on whether the index carries that key -- which this parser cannot see. `0.0.x` fails because no such key exists; `b10069` succeeds because the key is there verbatim, and that is the version scheme of a published package in this ecosystem (mcpp#363). Every build of that package printed a warning predicting a failure that did not happen, which teaches a reader to ignore the channel. The message now states the mechanism instead. The three assertions moved with it, from the prose to the PROPERTIES the message must carry: it quotes the offending string (or the reader cannot find it), it contains the word PACKAGE (because that is what the subsequent failure names), and it shows an accepted form. One of the three also only asserted "some warning exists", which a warning about anything else satisfies. * docs(01): example 07 shows PATH from the declared environment, not xpkg_dir The row listed `mcpp::xpkg_dir` as one of the example's key concepts. Its build program reads `MCPP_XPKG_QEMU_RISCV_DIR` directly and, more centrally, demonstrates that a project declaring `subos` gets that environment's `bin` at the front of its build program's PATH. An index that names a concept the example does not show sends a reader there to find it. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
2026.9.6.1: `.sycl` is a device source, and the criterion is the comp… …iler (#574) * 2026.9.6.1: `.sycl` is a device source, and the criterion is the compiler (#573) `SourceKind::Device` documents itself as a GRAPH ROLE -- "not scanned, no BMI, compiled by a device compiler mcpp does not drive" -- explicitly so the table does not grow a row per vendor. Until now every extension in it was also a dialect an ordinary C++ compiler would refuse, so the distinction had never been tested. `.sycl` tests it: its content is ordinary C++ and nothing in the file would tell a reader otherwise. What makes it a device unit is that it goes to a second compiler with a device back end -- icpx, or a clang built with the SYCL front end -- which mcpp does not drive and which does not accept C++20 modules. One row, and nothing else in the engine changes. THE ALTERNATIVE, AND WHY IT WAS REJECTED. A constrained glob could have carried `.cpp`. Then one extension would mean two things depending on which glob matched first, and the seam a device build is written around -- device code reaches the program only through an `extern "C"` header -- is legible precisely because the file name says which side of it a unit is on. THE ADDITION IS INERT ON EXISTING BUILDS, and the two conditions hold jointly: device extensions are absent from `default_source_globs`, so no glob widens; and a file with one of these extensions named in `sources` was a hard error before the row existed, so nothing silently switches role. e2e 613 is five sections and two of them are the ones that make the other three a measurement: * section three is the control -- the SAME CONTENT named `.cpp` must still be compiled as C++ and must NOT reach the build program. A test naming only `.sycl` would pass on a table that had started classifying by content. * section five asserts the WHOLE default glob list rather than the absence of one name, so a future addition cannot pass this file while changing what a default build compiles. Section four is the variant switch applied to the new row: `--no-accel` takes the constrained glob out, which is what makes the CPU half of a seam reachable without editing the manifest. Its first draft asserted that a `.sycl` in an UNCONSTRAINED glob is refused; that was wrong and the run said so -- an extension the table names is accepted wherever it is globbed, and the compatibility guarantee is section five, not a refusal. Chapter 20 gains the table of lanes -- which rule drives which compiler, which payloads it needs, and what `[build] accel` says -- in both languages, and its "Not implemented" section is corrected: the whole-target shape is no longer among the missing, because a SYCL toolchain is now reached THROUGH the island rather than beside it. The rules themselves ship in `mcpp:plugins` 0.2.0: `mcpp.rules.sycl` drives the `xim:dpcpp` payload, `mcpp.rules.hip` treats HIP on the NVIDIA platform as the header layer over the CUDA runtime that it is, and `mcpp.rules.spirv` gains the glslc route now that `xim:shaderc` publishes one. Unit 105/105 (the new case verified to run by name, not by the suite being green); e2e 606, 609, 610 and 613 pass against this binary. * docs(plan): round 4's landed rows, the six corrections, and the verification script Statuses for what has merged (the two payloads, the dpcpp repair, the SYCL runtime adapter) and section 7.5 for what the work changed about the plan: Z3 withdrawn because nothing consumes those two payloads yet; N1's second criterion replaced because the run showed it was measuring a rule that does not exist; the dpcpp repair that took two attempts and what CI's closure check was actually saying; the `-lstdc++` rewrite; `compat.sycl-runtime`, which was not in the plan and without which the round does not close; and why the round is five pull requests rather than three. The round-4 verification script carries the split the sandbox forces: a subos has 14 entries in /dev and none is /dev/nvidia*, so it asserts that the link is complete and the device result is asserted on the host. * docs(plan): the round-4 verification script Its shape is decided by what a sandbox cannot answer: `xlings subos --sandbox` has 14 entries in /dev and none of them is /dev/nvidia*, so any program that reaches for a GPU reports no device whatever the build did. The script asserts that the LINK is complete in the sandbox -- payload resolved, rule ran, object produced, runtime closure satisfied -- and asserts the device result on the host. A criterion naming the device result in a sandbox would be unreachable by construction rather than a statement about the ecosystem. Section G names each soname the SYCL farm had to gain and why, so a farm that silently loses one is caught: `libumf.so.1` (without which the runtime loads and enumerates nothing), `libstdc++.so.6` (which compat.cudart deliberately does not farm) and `libz.so.1` (which only a fresh runner found). * docs(plan): the dpcpp correction took three attempts, and the second broke every consumer The regression was not visible from the payload: `sycl-ls` reported the GPU in attempts two and three alike. What differed was whether a CONSUMER of the payload still worked, and only rebuilding the example showed it. A non-empty DT_RUNPATH on a payload library switches off the inherited DT_RPATH of whatever loaded it, so the CUDA adapter stopped seeing the artifact's own farm. Also records compat.sycl-runtime 2026.09.07, which moves the driver hop from the user's manifest into the adapter: which back end the SYCL runtime dlopens is the runtime's business, not the project's. --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
2026.9.5.4: the fast path compares every declared build-program input (… …#570) * chore: remove the remaining decorative markers from comments and documentation The sweep that accompanied 2026.9.5.3 covered docs, README.md, CHANGELOG, the engine sources, tests, examples and the workflow files. It did not cover bench/, tools/, scripts/, mcpp.toml or README.zh-CN.md, which still carried the warning and star markers in comments and prose. Program output keeps its markers: a line a user reads on a terminal is neither documentation nor a comment. README.zh-CN.md also gains the Cortex-M row its English counterpart has, and its status column now reads the same words that one does. * fix(build): the fast path compares every declared build-program input `rerun_if_changed("data/table.csv")` states that the program must run again when that file's content changes. The project-level fast path skips prepare_build when no source is newer than build.ninja, and prepare_build is where the program's cache is read; the check the fast path did run asked only about glob path sets. A data file is neither under src/ nor named with a C++ extension, so the mtime sweep cannot see it either. Editing it therefore left the previous run's generated header in place: `Finished dev in 0.00s`, and the program compiled the previous bytes. The fast path now compares the three kinds of input the cache records: a glob's path set, a declared file's content hash, and a declared environment variable's value. `glob_inputs_stale` is renamed `program_inputs_stale` because the name was the reason the other two were never asked about. e2e 612 uses the program's output as its criterion -- a stale header and a fresh one make the binary print different strings -- and runs the other half of the control: with nothing touched, the next build still takes the fast path, which "always rebuild" would also pass the first assertion with. Verified failing on 2026.9.5.3 and passing here. Found by mcpp.tools.embed, the first non-rule member of mcpp:plugins: it writes a data file into a header while the build program runs, so it submits no action and falls entirely on this path. Documentation for both languages, CHANGELOG, and version 2026.9.5.4. * fix(prepare): a version conflict on a package with no C++ module says so Two versions of one package are reconciled by mangling the secondary copy's module names. The two branches that refuse name both versions and who asked for them; the third reported only that the package declares no named C++ module to rewrite, which is a true statement about a package the reader never asked to be staged and says nothing about the conflict that brought it there. A C package reaches it: compat.vulkan-runtime compiles one C file, so a manifest that pins one version of it while a dependency asks for another got a message about modules. It now names both versions, both requesters, and what to do. examples/10 moves its pin to compat.vulkan-runtime 2026.09.06, the version compat.vulkan asks for since mcpp-index#350. The example is the consumer that found this. * fix(examples): 10 pins compat.vulkan-runtime 2026.09.07 The version compat.vulkan asks for since mcpp-index#351, where a soname carried by two installed payloads is decided by symbol coverage rather than by which store path sorts last. * docs(plan): the second round's rows, and the two items the first document had left open The heterogeneous-build plan gains the rows for this round: the declared payload set and the candidate walk in the two runtime adapters, the fast path comparing every declared build-program input, the diagnostic for a version conflict on a package with no C++ module, and the first member of the tools half of mcpp:plugins. The multi-device document's section 12.4 listed pocl/lavapipe and the rule package in the index as not done. Both are done, and neither took more hours: conda-forge publishes Linux binaries for the two drivers, so the work was repacking and closure verification rather than building mesa, and the rules moved to their own repository and are published as one package whose members features select. * docs(plan): the C1 row names the module the round settled on --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
2026.9.5.3: a feature-selected rule collection, mcpp.rules.* naming, … …and a variant switch the fast path no longer replays (#566) * feat: 2026.9.5.3 -- a feature-selected rule collection, mcpp.rules.* naming, and a variant switch the fast path no longer replays A host-module package contributes every module interface unit among its feature-resolved sources, the lib root first; only listed sources take part. This is what lets mcpp:plugins carry mcpp.rules.cuda and mcpp.rules.spirv, selected by features. The rule packages leave examples/; examples 09 and 10 consume the index package. The prefix is mcpp.rules.* for rules and mcpp.tools.* for utilities; mcpp.build.* is the engine's own module family and the specification is corrected accordingly. The graph header records whether --accel/--no-accel chose the variant, and the fast paths decline a graph an override wrote: a plain build after --no-accel used to report Finished in 0.00s and run the CPU variant. The device-source table is by compiler, not by vendor: 18 extensions, none in the default globs, all a hard error before. Chapter 20 is renamed to Heterogeneous Builds; docs 05 and 07 gain the collection and naming sections in both languages. * style: remove decorative symbols from documentation, comments and workflow comments Table cells whose value was carried by a symbol alone now carry a word (yes, no, partial, planned); three comment lists that distinguished accepted from refused forms keep the distinction in words. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
feat(accel): version floors, a probe channel, constrained device glob… …s, and a CUDA example that takes two routes (#563) * feat(doctor): read the device toolkit from a payload before the host, and state the driver relation without probing for it ## The report was answering about the wrong toolkit `mcpp self doctor` located `crt/host_config.h` through `CUDA_PATH`, `CUDA_HOME`, `/usr/local/cuda` and `/usr/include` — host locations only. A toolkit installed through xlings is the one a build will use, and it is usually the newer one: measured here, a 12.9 payload states `gcc <= 14` and a 13.3 payload `gcc <= 15`, where this machine's distribution CUDA 12.0 states `gcc <= 12`. The report said `gcc 13 exceeds the bound of 12` about a toolkit the build was not using. Payload stores are now searched first. Both of them: mcpp keeps its own under `<mcpp home>/registry/data/xpkgs`, and `xlings install` writes to `<xlings home>/data/xpkgs` — 191 packages in one and 211 in the other on this machine, with the CUDA components only in the second. The host locations remain, last, because a machine with a distribution toolkit and no payload is real. Reading changes accordingly: before warning: cuda will refuse this host compiler: gcc 13 exceeds the bound of 12 stated in /usr/include/crt/host_config.h after ok cuda accepts this host compiler (gcc 13 <= 15) ## The driver relation, and why the acquisition is not here A device runtime must not be newer than the driver it runs against. Measured on a driver serving CUDA 12.4: the 13.3 payload compiles and links cleanly and then fails at the first allocation with "CUDA driver version is insufficient for CUDA runtime version", while the 12.9 payload prints the right answer. `mcpp::toolchain::driver_accepts_toolkit` states when one version may meet another, including that minor-version compatibility makes 12.9 fine against a driver serving 12.4 — the case a naive "toolkit <= driver" check would have refused. Five unit tests, including that either side unknown makes no claim. The acquisition is NOT here. Asking a machine which driver it has means running a vendor's tool, and `tests/unit/test_runtime_contract.cpp` refuses exactly that in `src/`. It caught the first revision of this change, which launched one. The rule predates this work and it is right: a core that learns to run one vendor's probe learns to run four. Those numbers will reach the report as declarations — a toolkit payload stating the driver it needs, and the package that owns the host driver stating what the host has — which is the rule-package channel. ## Verified `tests/e2e/602_device_toolkit_payload_first.sh` fabricates a payload store whose header states `gcc <= 41`, a bound nothing real would state, and asserts the report reads it. The control is the half that matters: without the payload store the same command must not report 41, or the assertion would pass against a doctor that hardcoded it. 100 test binaries pass, including the contract test that rejected the earlier revision. * feat(example): the CUDA example takes its toolkit from a payload, not from the host The example shipped in 2026.9.5.1 wrote `-L/usr/local/cuda/lib64` in its manifest and probed `/usr/local/cuda/bin/nvcc` and `/usr/include/crt/host_config.h` in its rule package. It was the project's own demonstration of how to do this, and what it demonstrated was reaching for the host. The project now names the toolkit: [xlings.workspace] "xim:cuda-nvcc" = "12.9.86" "xim:cuda-cudart" = "12.9.79" and the rule resolves it with `mcpp::xpkg_dir`, building the whole invocation from what it finds — compiler, include directories, and library search paths through `mcpp::link_search`. The manifest names libraries and no locations. Host paths remain in the rule as a last fallback so a machine with only a distribution toolkit still builds.⚠️ THE PAYLOAD'S HEADERS HAVE TO BE NAMED. nvcc adds `<its own directory>/../include` by itself, and on the 12.x line that holds `crt/` but not `cuda_runtime.h` — which is in the `cuda-cudart` component. The first revision of this change left it out: nvcc resolved `cuda_runtime.h` from /usr/include and then read the HOST's `crt/host_config.h` beside it, and the build failed with the host toolkit's complaint (`clang version must be less than 15`) while using the payload's compiler. Naming the payload include directories is what makes "uses the payload" true rather than nearly true. Verified: `mcpp run` prints `12 24 36 48`, and $ mcpp build -v | grep -c '/usr/local/cuda\|/usr/bin/nvcc|-I/usr/include' 0 nvcc and both include directories resolve under `registry/data/xpkgs/xim-x-cuda-*`. 100 test binaries pass. * feat(build): version floors, so a machine's limits are known before anything is compiled Some facts about a machine bound what may be built for it, and the failure when they are ignored arrives late. Measured on a driver serving CUDA 12.4: a binary built with the 13.3 toolkit compiles cleanly, links cleanly, and fails at the first allocation with "CUDA driver version is insufficient for CUDA runtime version" — a message naming neither the toolkit nor the driver. Both numbers are knowable beforehand. What mcpp must not do is go and ask a vendor's tool for them, and `tests/unit/test_runtime_contract.cpp` refuses exactly that in `src/`. So the numbers arrive as declarations. A package states what it needs: [[runtime.requirements]] kind = "version-floor" value = "cuda.driver >= 12.0" A package that established a fact about this machine — at install time, which is where probing belongs — states it: [runtime] provides = ["cuda.driver=12.4"] They are compared when capabilities are bound, and a short machine is refused before anything is compiled, reporting `version-floor-unmet`. ## No vendor vocabulary reaches the engine `src/build/version_floor.cppm` reads a name, a relation and a version and knows nothing about what any of them mean. `cuda.driver` is data passing through, no new manifest keys were needed — `kind` was already a free string — and a backend mcpp has never heard of compares the same way. That is asserted with a name no backend uses. ## A floor nobody answered is silent A machine that never declared what it has is not a machine that fails the floor; it is one nobody asked. Turning "we do not know" into "no" is the failure mode this exists to avoid, so it is the third case in the e2e rather than a comment. Without that control the first case would also pass against an engine that refused every version-floor requirement. ## Verified `tests/e2e/603_version_floor.sh`, three halves: PASS: refused, naming the requirement, both versions and who stated the fact PASS: a met floor builds PASS: a floor with no stated fact is silent, for a name no backend uses Eight unit tests over the parsing and the comparison, including that `>=` is never read as `=` — both spellings live in string lists, and reading a floor as a fact would turn a requirement into its own satisfaction. 101 test binaries pass. * test(e2e): give 317 the window its bound needs, and run 602 where the report exists Reaching "five consecutive short failures" costs at least 1.25 s (four restart delays of 250 ms and five 50 ms polls). A two-second pause left 150 ms per spawn, and a loaded macOS runner exceeded it twice in a row on main while the same code passed twice on this branch. The pause is now five seconds, so the property is decided by the code. 602 declared no platform, and on Windows the doctor does not emit the device-toolkit section at all: the payloads are Linux builds and the bound a Windows toolkit states is an _MSC_VER range the report does not read. It also ran the doctor in a fresh home, which provisioned a full toolchain into the temporary directory: 229 s and 1.4 GB per run. It now requires unix-shell and runs under MCPP_OFFLINE. * fix(config): offline mode skips the first-use sandbox bootstrap --offline promises never to touch the network. In a home that had never been used, load_or_init cloned the package index through `xlings self init` and installed ninja and patchelf through `xlings install` before any command ran: measured, 26 s and 126 MB under MCPP_OFFLINE=1. The three network-bound steps are now skipped under offline mode, the skip is announced once per process, and the completion markers are still written from what is on disk, so a bootstrapped home loses nothing. The commands that need the tools report their absence through check_base_init. e2e 604 asserts the fresh-home behaviour and, as its control, that a bootstrapped home announces nothing. * feat(build): the probe channel, and the device axis as a build program sees it A rule package is the thing that knows how to ask a machine what it has, and the engine is the thing that must not. Two directives close that gap: `mcpp:fact=<name>=<version>` states what the program established about the machine, `mcpp:floor=<name> >= <version>` what the package needs of it. Both fold into the runtime declarations a manifest could have carried itself, so the existing version-floor check reads one list; it now runs a second time after the root's build program, which is where a rule package speaks from. Protocol 7. MCPP_ACCEL carries the resolved device axis (`--accel` / `--no-accel` over `[build] accel`) to build programs, so an architecture set is written once, in the manifest. The same value now feeds the `cfg(accelerator = "...")` layer key, a field that was declared, documented and never written. The axis also enters the fingerprint and disqualifies the fast path: measured before, `mcpp build --no-accel` after a device build finished in 0.00 s and handed back the device build. * feat(build): a source glob may carry the accel it is for, and the build narrows to it `[build] sources` accepts `{ glob = "...", accel = "..." }` entries. The glob joins the plain list, so every reader keeps working; the constraint is resolved in prepare after feature application. A constrained glob that matches nothing is refused, naming it. Under a build that asks for no accelerator the glob is excluded through the same `!` mechanism feature gates use, which is how one project yields its CPU-only variant. Under a build that targets something the constraint is not within, the build is refused naming the glob and both sides (`accel-mismatch`). Device-kind files the effective set matches reach the build program as MCPP_DEVICE_SOURCES; the engine has no compile rule for them and the rule package turns each into an action. Unit tests cover the table form and its refusals; e2e 606 measures the four outcomes with a backend nothing knows. * docs(plan): record the engine batch, the fingerprint drift, and the local 168 reading * feat(build): what a second compiler needs, and the device axis on run and test Three gaps a rule package that drives a compiler mcpp did not resolve fell into, each measured on the CUDA example. **The flags mcpp passes to its own compiler.** `MCPP_TOOLCHAIN_SYSROOT` and `MCPP_TOOLCHAIN_BINUTILS_DIR` state the `--sysroot` and the `-B` directory, empty when mcpp passes none. They are not `MCPP_TARGET_SYSROOT`, which is a tier fact and empty on a hosted target: under a sub-OS the C library is not at `/usr/include` and the assembler is not at `/usr/bin`, so a compiler that mcpp did not place fails at the first `#include`: crt/host_config.h:218: fatal error: features.h: No such file or directory `hipcc`, `-fsycl-host-compiler` and any generator that compiles what it emits have the same gap, so the answer belongs to the engine. Both read from the single producer that already decides them for the engine's own command lines; `gcc::binutils_prefix_dir` now states the `-B` guard once, where three copies of it stood and one said so in a comment. **A dynamic build-program helper on Linux gets `DT_RPATH`.** The driver's default is the new tag, and a runpath is consulted only for the helper's own needed libraries. A build program that opens a host library at run time then fails one hop later: measured, `dlopen("<sentinel>/lib/libcuda.so.1")` answered `libdl.so.2: cannot open shared object file` while the very directories that hold it sat in the helper's RUNPATH. The artifacts mcpp links already carry DT_RPATH for this reason. The link policy is part of the helper's cache identity, so an older helper is rebuilt rather than replayed. **`--accel` / `--no-accel` on `run` and `test`.** The axis existed on `build` only, so a project could build its CPU-only variant and not run it. Both verbs take the same two flags, with `--no-accel` travelling as the same explicit sentinel, and both bypass their fast path when either is given — a cached artefact was built for whatever axis the last build used. e2e 607 covers the chained-action shape a device link needs: an artifact-role action's output consumed by an object-role action, with the intermediate absent from the link line. * refactor(doctor): the device toolkit report belongs to the package that owns the tool `mcpp self doctor` grew a CUDA section: it located a toolkit payload, read NVIDIA's `crt/host_config.h` for the host-compiler bound, and parsed `nvcc --dryrun` to name a back-end stage the tool could not reach. Every one of those readings was correct, and none of them belonged to the engine. The repository already states the rule as an invariant. `test_runtime_contract` refuses a vendor name beside a probe launch in `src/`, and the reason is that an engine which learns to run one vendor's tool learns to run four: the second backend arrives as a second section, the third as a third, and the engine acquires a table of tools it must keep current with releases it does not control. The same answers are now produced where the tool is known — the rule package the project imports — and reach mcpp as declarations through the build program's channel, which the engine compares without knowing what any of the names mean. Nothing is lost: the CUDA example reports the driver relation, the host-compiler bound and the unreachable stage before its first compile, and the version floor refuses a build the machine cannot run. `test_core_vendor_probes` states the property the removal establishes, over comment-stripped sources so that recording a vendor's name in a comment stays possible. It carries its own denominator: an enumeration that found fewer than a hundred files is a broken scan rather than a clean result. e2e 602 is removed with the section it tested. Its subject was the doctor report, not a build, and the same preference — payload before host — is what the example's rule package now exercises end to end. * feat(example): two routes for the CUDA example, and the pairings it refuses The example took one route — nvcc driving the project's own compiler — and that route has two constraints neither the engine nor the project controls. Both now produce a sentence before anything is compiled, and a second route exists that has neither. **clang is the primary route.** `[toolchain] default = "llvm@22.1.8"` and the device unit is compiled by the same compiler as the rest of the project (`-x cuda --cuda-path=<payload>`). No second host compiler, no host-compiler bound, no CUDA host header in the way. `MCPP_EXAMPLE_CUDA_ROUTE` selects the other route, and the rule declares `rerun_if_env_changed` for it. **nvcc is the alternate, and it refuses two pairings by name.** The host compiler must satisfy the bound nvcc states in its own `crt/host_config.h`: the rule uses the project's toolchain when it fits, otherwise a `xim:gcc` payload the project declared, otherwise a refusal naming the declaration to add. Measured — GCC 16 under nvcc 12.9 fails inside GCC's own `<type_traits>` even with `-allow-unsupported-compiler`, which admits a compiler one step past the bound and not a standard library two majors newer. The second pairing is a toolkit older than the C library. Toolkit 12.9's `crt/math_functions.h` redeclares the C23 functions `cospi`, `sinpi` and `rsqrt` for the host without `noexcept`; glibc 2.41 and later declare them with it, and since C++17 that is part of the function type. The compile stops with six `exception specification is incompatible` errors naming a glibc header and a CUDA header, and no decision. The rule reads the C library's `bits/mathcalls.h` through `mcpp::toolchain_sysroot()` and states the pair it cannot have, naming the 13.x toolkit as the way out. **A CPU implementation behind the same seam.** `src/cpu/saxpy.cpp` is selected by `cfg(not(accelerator = "cuda"))` while the `.cu` carries the accel its glob is for, so `mcpp build --no-accel` compiles one and `mcpp build` the other, with no hand-written condition on either side. Measured on an RTX 4080, driver 550.144.03 reporting CUDA 12.4: `mcpp run` and `mcpp run --no-accel` both print `12 24 36 48`, from different artifact directories, and the second contains no `cudaMalloc`. The nvcc route is not exercisable there — 12.9 meets the driver and not the C library, 13.3 meets the C library and not the driver — and both refusals are the ones above. * fix(build): key a package's device sources by its root, not by its bare name Two packages in one graph may share a bare name and differ only by namespace — that is what namespaces are for — and the map that carries device sources to a build program was keyed by the bare name at both its write site and its two read sites. A collision would hand one package's device sources to the other's build program, and nothing would report it: the receiving program would emit actions for files it does not own, and the owner would emit none. Keyed by the package root instead, which is unique by construction. Found by re-reading the diff before merge; no test covered it, and a test that did would need two same-named packages in one graph, which is worth adding when a second consumer of this map appears. * release: 2026.9.5.2 The version annotations in the manual said 2026.9.6 while the release lands today, so they name the version that actually carries them. `mcpp.toml` and `MCPP_VERSION` move together, as `01_help_and_version.sh` requires. The CHANGELOG's Unreleased section described the doctor's `nvcc --dryrun` check as a shipped feature. That check left the engine in this same batch — the reading was right and the place was wrong — so the entry is rewritten to say where it went rather than to announce something the release does not contain. * fix(build): "no accelerator" is the empty string, not the display spelling `accel_str` prints `(none)` for an empty set so an ABI tag reads as a sentence. `resolvedAccel` handed that spelling on as a value, and two readers were wrong at once: - A build program saw `MCPP_ACCEL=(none)` while the manual promised an empty string, so a rule package asking whether there is an accelerator got a yes and a backend named `(none)`. This reached every project that never mentioned an accelerator, not only those passing `--no-accel`. - The fingerprint's own guard, `if (!accel.empty())`, was true for every project, appending `#accel=(none)` to builds that had asked for nothing — the opposite of what the comment beside it says. Measured with a build program that wrote the value to a file, which is the only way to see it: mcpp shows a build program's stdout only when it exits non-zero. e2e 605's fourth section was titled "--no-accel empties both the variable and the layer" and measured only the layer, which is how this passed a suite it was meant to be covered by. It now asserts both, and the fixture prints the value delimited — an empty warning is indistinguishable from an absent one. * docs(plan): batch ④ landed, and three readings the engine batch produced The adapters and the rename are in mcpp-index#347; the plan table says so and records the three findings that came out of measuring them — the display spelling that leaked into a value, the two upstream couplings the CUDA recipes now carry, and the path-index namespace behaviour that made local verification look like a resolution failure. The verification script travels with the plan rather than living only in a scratch directory: it is the thing that found the `(none)` defect, and it is what the release is checked with inside a sandbox. * refactor(example): the CUDA rule package moves into the mcpplibs namespace The rule is content this ecosystem wrote rather than anyone's upstream, so `mcpplibs` is its namespace by the same rule the rest of the index follows, and the module follows the namespace: `mcpplibs.rules.cuda`. It also makes the package publishable. The index entry for it points at this same directory inside an mcpp release tarball — the shape `grpcgen` already uses — and a descriptor's identity has to match the manifest it points at, so a package that stays in an `example` namespace can only ever be copied into the index rather than referenced there. Measured: `mcpp run` and `mcpp run --no-accel` both print 12 24 36 48 after a clean rebuild. * docs(design): §12 — what the implementation overturned, produced, and left Five places the plan was wrong and two defects the implementation created and fixed, each with the reading that settled it. Also what a consumer writes after this batch, and the list of what remains with the reason each item is not done rather than a silence that reads as done. * feat(build): an object action's outputs reach a static library, not only a linked image A package whose device code is its point declares `kind = "lib"`. Until this, the actions its build program emitted were dropped — with a warning, and with the archive coming out containing none of them: build.mcpp action 'cuda:wkv' has role = "object" but this build produces no executable, shared library or test binary to link its outputs into Measured on llama.cpp's CUDA backend, which is 305 `.cu` files behind exactly such a target: every action reported that line and the build succeeded, having produced a library with no device code in it. The archive rule already consumes `lu.objects`; a static library was simply absent from the predicate that decides which link units an object action attaches to. The objects an action produced belong there for the same reason a compiled `.cpp`'s do — a target's content is what it was told to contain. e2e 608 asserts the member list of the archive rather than the exit status: an `ar` handed nothing still writes a well-formed archive and reports success. This is the engine half of the multi-device design's C-6. * docs(plan): T5.1 as a gate paid for itself, and what it left The framework batch's first item found the engine gap C-6 named and nothing had measured, and it got as far as compiling before stopping on a payload version rather than on anything about the design. Both readings are recorded with what would close them. * fix(example): the clang route passes NVIDIA's own libc++ escape A device unit that includes <cuda_runtime.h> stops on crt/host_defines.h:67: error: "libc++ is not supported on x86 system" whenever the toolchain is LLVM, which is the toolchain the clang route exists for. The guard is `#if defined(__CUDACC__) && … && defined(_LIBCPP_VERSION)`, and clang defines `__CUDACC__` when it compiles CUDA itself, so the refusal — which is about nvcc's host pass — lands on a compiler it was not written about. The escape hatch is upstream's own and is passed only on the clang route: nvcc really does break against libc++, and nothing here weakens that refusal. Found on ggml's CUDA backend. The example's own kernel never showed it because a bare kernel includes no toolkit header at all — which is worth knowing about the example: it exercises the plumbing, not the headers. * docs(plan): where T5.1's CUDA lane actually stops, and what it proved on the way The chain the design is about — axis, narrowed glob, device source list, rule package, actions, static archive, link — runs end to end and produced 48 device objects. What stops it is a four-way payload matrix that has no satisfiable point on this machine, and none of its four sides is mcpp's. * test(e2e): 188's no-consumer case needs a target that does not exist, not a library A `kind = "lib"` target used to serve as "nothing that takes an object action's outputs", because a static library did not take them. It does now, so that fixture stopped exercising the case and the suite reported it — which is the whole value of running it. The one shape that still leaves an object action with nowhere to go is a target behind an inactive `required_features`: it is absent from the build entirely. A control runs the same project with the feature on and asserts the diagnostic is gone, so the assertion cannot pass on a build that stopped emitting it for everyone. * docs(design): §12.5 — what the framework gate actually measured T5.1 did what a gate is for: it found the engine gap C-6 named and nothing had exercised, and it carried the whole chain to 48 device objects before stopping on a four-way payload matrix with no satisfiable point on this machine. The matrix is written out with the reading for each side, and the three general findings it produced are recorded with where they went. * docs(example): the clang route does pass one flag, and the README should say which The README claimed "no CUDA host header in the way", which stopped being true the moment a device unit includes <cuda_runtime.h>. It says what the flag is, why the guard fires on a compiler it was not written about, and why this example's own kernel never showed it. * test(verify): section E asserted a silence that a fresh sandbox gives for free Rehearsed inside a sandbox against the previous release — the one that still had the doctor's toolkit reader — and section E reported ok. It was watching for a section `self doctor` only prints when a CUDA toolkit is installed, and a fresh sandbox has none, so the assertion could not fail there. It now reads the engine binary for the names the removed code carried. `crt/host_config.h` appears twice in 2026.9.5.1 and not at all in 2026.9.5.2, so the check discriminates: the control run against the older binary fails with that exact count. The source-level property stays where it belongs, in `test_core_vendor_probes`. Also fixed while writing it: `grep -c` prints 0 and exits 1 when it matches nothing, so `|| echo 0` appended a second line, `[` said "integer expression expected", and the section reported ok having skipped every comparison. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
feat: accelerator support — device translation units, the accelerator… … dimension of artifact identity, and the host-compiler pairing (#559) * feat(source-kind): classify device translation units A device translation unit is compiled by a vendor device compiler that mcpp does not drive directly, and no such compiler accepts C++20 modules. The new kind states the graph role — never scanned for imports, never produces a BMI — and says nothing about the language, which is what lets one kind cover CUDA C++, HIP, and device dialects that are not C++ at all. Device headers are classified as headers rather than device units because their role is the header role: they are not compiled, and editing one can change what the graph should be. Leaving them in Other is how a project would observe an edited kernel header producing no rebuild. The default source glob is deliberately unchanged. Widening it would make a published package that vendors a device source it builds elsewhere start compiling it on the next mcpp upgrade, which its author cannot fix because that version's tarball has already shipped. * feat(cfg): accelerator as the first multi-valued target-side layer One build can enable several accelerator backends at once, which is what an inference framework shipping CUDA and ROCm device code in one artifact requires, so this layer holds a set rather than the single answer the other five hold. The comparison is membership, and it is membership everywhere rather than only inside any(...). Letting a combinator change the meaning of its operand would make all(accelerator = "cuda", accelerator = "rocm") unsatisfiable rather than "both backends are enabled". Membership everywhere keeps any, all and not pure boolean combinators, and a single-backend build still answers its own backend true and every other backend false. The multi-valued case differs in exactly one function, Ctx::layer_matches, so adding a second such layer later is a row rather than a second code path. * docs(design): how DPC++ and NVHPC attach to the toolchain model The open-source intel/llvm DPC++ compiler is clang: one driver, GNU-style flags, the same BMI format. Its NVIDIA and AMD backends are configured into the compiler itself through buildbot/configure.py --cuda and --hip, so the Codeplay plugins the v4 text referred to are a property of Intel's binary distribution rather than of DPC++. It therefore attaches as a payload variant of the existing LLVM family plus a provider capability, and requires no new value in the closed Family enumeration. nvc++ attaches as a device rule compiler rather than a toolchain family. The HPC SDK user guide states C++17 with GNU compatibility as its standard, and a compiler without C++20 modules cannot host projects that import std. * docs(design): the host-dependency axis for accelerators, and what xim already provides The governing rule needs no accelerator-specific extension. A toolchain is mcpp's contract and must come from a payload; a library the program links is the program's own business. Applied here, nvcc and the CUDA runtime libraries are payload material because NVIDIA's redistributable manifests permit it, while libcuda.so.1 cannot be: the driver EULA forbids third-party redistribution and the userspace library is in ABI lockstep with the kernel module. The lower half of that axis already exists in xim as libcuda-host-link, a sentinel package that installs one symlink to the host library and gives every GPU consumer a single source of truth instead of a reimplemented ldconfig probe. The upper half does not: there is no CUDA toolchain package. Compiling needs only the nvcc and cudart components rather than the whole toolkit, which is the same per-component selection the hermetic Bazel CUDA rules make. * feat(pack): the accelerator dimension of a prebuilt artifact's identity An artifact that carries device code constrains which devices can run it, and until now nothing recorded that. Two builds differing only in the compute capability they targeted produced identical tags, linked cleanly, and failed at the first kernel launch. The dimension is a parallel structured field rather than a tag segment. The tag is dash-joined and parsed from the end, and an architecture list is a set; joining it in would put separators inside a segment of a string whose triple already carries a variable number of dashes. tag_check compares it all the same, so there is one comparator and two storage locations rather than a second comparator. Comparison is membership widened two ways, each matching a mechanism that exists in the hardware rather than a convenience. A family target covers its own major with an equal-or-higher minor, which is what makes publishing one artifact per generation viable instead of one per chip. An embedded portable form covers everything at or above its floor, which is how NVIDIA's own guidance for a distributed binary works. AMD has neither a portable form nor a reason for one, leaves the floor empty, and obtains the same reach through family targets on the architecture side; an empty floor therefore widens nothing. * feat(toolchain): read a device toolkit's host-compiler bound instead of tabulating it nvcc refuses host compilers newer than a bound that moves with every CUDA release, and the bound is not documentation: it is a preprocessor guard in the toolkit's own crt/host_config.h. A table transcribed into mcpp would be a copy of one release, correct until the next and silently wrong afterwards. Reading the guard means a toolkit mcpp has never heard of still answers. mcpp supplies the host compiler, so it is in a position to know both sides of this pairing before either compiler runs. The value is the earlier refusal: forwarding -ccbin and letting nvcc fail produces a diagnostic naming a compiler the user did not choose and a bound they cannot see. The parse is narrow on purpose. A header it cannot make sense of yields no bound, and no bound means no check, because a refusal invented from an unreadable file is worse than the failure it would have prevented. Verified against the CUDA 12.0 header on the development machine: the guard reads __GNUC__ > 12 and the clang bound is stated as less than 15, which the parse reports as 12 and 14. * feat(doctor): report whether this host compiler can drive the device toolkit nvcc refuses host compilers newer than a bound the toolkit states in its own crt/host_config.h. The failure is late, the message names a compiler the user did not choose, and the bound is invisible. Every other build system forwards -ccbin and lets nvcc discover this; mcpp supplies the host compiler and can answer before anything is compiled. Reported rather than enforced. A project that compiles no device code is unaffected by an incompatible pair, and refusing its build would be a false alarm. Observed on the development machine, where the toolkit is CUDA 12.0 and the resolved host compiler is gcc 13: Checking device toolkit warning: cuda will refuse this host compiler: gcc 13 exceeds the bound of 12 stated in /usr/include/crt/host_config.h. * docs: the ecosystem packages that make accelerator support usable An engine capability with no packages behind it does not exist for a developer. The design and the plan now name what has to land in xim and mcpp-index, in dependency order, with what each package validates. Two entries carry most of the weight. AdaptiveCpp comes first among the payloads because its OpenMP backend runs SYCL kernels on the CPU, which makes the whole of form B verifiable end to end on a machine with no GPU. llama.cpp-m comes first among the libraries because one package exercises three of the seven user roles at once, it has no Python packaging layer to confuse the result, and it already exists in the ecosystem, so the step is adding a backend rather than adopting a large project. The two header-only libraries, CCCL and CUTLASS, are listed before anything with a binary artifact: they carry no size or variant-matrix problem and are therefore the first real libraries reachable by the source-distribution path. * feat: carry the accelerator dimension from descriptor to refusal The comparison added earlier had no reader: nothing populated the field, so it was empty everywhere and accepted everything. This connects both ends. An artifact declares what device code it carries in a separate descriptor field, in one wire form that is also the diagnostic form, so what a publisher writes is what a refusal prints back. A build declares what it targets through [build] accel, overridden for one build by --accel, which is the relationship --target already has with [toolchain]. --no-accel is not the absence of --accel: it is an explicit request for none, which is what a user needs in order to take the CPU-only variant of a package that also publishes device builds. A build that asks for no accelerator is satisfied by every artifact vacuously. That is correct rather than permissive, and it is why a descriptor lists its CPU-only variant first: the first accepted artifact wins, so an older mcpp that does not know this field at all still selects the variant that runs anywhere. * feat: refuse a prebuilt artifact whose device code cannot run on the target This is the failure the whole dimension exists to move. A library built for one compute capability and consumed by a build targeting another linked cleanly and failed at the first kernel launch, with a runtime message that names neither the package nor the architecture either side expected. The refusal now names both, and the remedy is the one that can work. The generic advice to pin [toolchain] is actively misleading on this axis, because no toolchain pin changes which GPU architecture a build targets; the device case offers --accel and --no-accel instead. The published listing carries the dimension too, since it travels beside the tag rather than inside it and a listing showing only tags would print two indistinguishable lines for a package publishing a CPU and a device build of one triple. Observed: error: mcpplibs.gpuonly@0.1.0: no prebuilt artifact matches this toolchain. your toolchain : x86_64-linux-gnu-gcc16-libstdcxx16-c++23 accel=cuda12.8+{sm_86} published tags : x86_64-linux-gnu accel=cuda12.8+{sm_90f} closest is x86_64-linux-gnu, and it differs on: accel needs cuda12.8+{sm_90f}, this build has cuda12.8+{sm_86} The accompanying end-to-end test also covers the ordering rule: a descriptor listing its CPU-only artifact first lets a build targeting an architecture no device variant carries fall back to the portable one, which is the same ordering that keeps an mcpp predating this field selecting a usable artifact. * feat: a CUDA kernel behind a seam module, and the package-level declaration The example is the design's form A end to end: a device translation unit that never enters the module graph, an extern "C" interface free of standard-library types, a seam module that turns that interface back into a C++ one, and a build-rule package that owns every fact about nvcc's spelling. The seam is the part worth reading. Its reason for existing is not that nvcc rejects modules; it is that this is the one place a backend can be exchanged without any consumer changing, and the one place a cfg(accelerator = ...) section has somewhere to apply. The rule package reads the toolkit's own host-compiler bound and selects a compiler that satisfies it, because mcpp's payload is routinely newer than what a given toolkit accepts. On the machine this was verified on the payload is gcc 16.1.0 and the toolkit refuses anything above gcc 12, so the rule selects clang++-14 and says so. Verified on an RTX 4080 with CUDA 12.0 and driver 550.144.03: mcpp run prints 12 24 36 48, which is the saxpy the kernel computed on the device. [package] accelerators is declared alongside, mirroring platforms: a statement of intent and a CI-matrix hint. It is deliberately a different field from an artifact's accel, because a declaration is written by hand and can be aspirational while the artifact field is measured from the build and is what a consumer is refused against. The example's README records the one hop that is not closed: [xlings] deps provisions a payload and puts its bin/ on PATH, which is what a payload providing a program needs, while a payload providing a library also needs its lib/ on the artifact's runtime search path. Until an mcpp-index package declares that directory for the driver sentinel, the run needs it supplied. * docs: accelerators chapter, and the manifest keys it introduces Chapter 20 states the two shapes an accelerator toolchain takes, says which one mcpp implements, and gives the reasoning for the three decisions a reader will otherwise find arbitrary: why device sources are excluded from the default glob, why the seam module is an architectural unit rather than a workaround for nvcc's lack of module support, and why the accelerator dimension travels beside the compatibility tag instead of inside it. The manifest reference gains [build] accel and [package] accelerators, kept apart on purpose: one is measured from a build, the other is written by hand. * docs(design): what implementation overturned, and what the ecosystem enforced Five design statements did not survive contact with the code. The cfg semantics were wrong in a way that made the design more complex than it needed to be: letting any(...) change the meaning of its operand would have made a conjunction of two backends unsatisfiable rather than "both are enabled", and membership everywhere is both correct and one rule fewer for a user to learn. The compatibility risk was real but on a different path than the one checked. Form B needs no new toolchain family. nvc++ belongs to form A. And the host compiler bound should be read from the vendor's header rather than tabulated. The ecosystem refused three shortcuts on its own: mcpp's runtime closure check rejected linking the host CUDA runtime, allow_host_libs was not enough to get past it, and the index already documented that [xlings] deps materialises for the root project only. Every one of those constraints was already there and every one was right. Two mistakes were mine and both are shapes this document warns about. A first draft of the index package reimplemented a host probe that the sentinel's own recipe forbids reimplementing, and which xim's hostlib module records as having been got wrong three times out of four. The same draft carried an extra library pattern justified by a reason written from memory; measurement showed PTX JIT works without it. * chore(release): 2026.9.5.1 * feat(examples): reach the driver through the index rather than an env var The example previously needed LD_LIBRARY_PATH pointing at the xim sentinel, because nothing declared a runtime search directory for the driver's userspace library. compat.cuda-runtime now does, so the dependency is an ordinary one and mcpp run works unaided. The two packages divide the question by layer. libcuda-host-link in xim owns where the host's copy is, so no consumer reimplements an ldconfig probe. compat.cuda-runtime in mcpp-index owns how a built artifact reaches it, which needs a directory on the runtime search path and is therefore something only an mcpp package can declare. Verified end to end on an RTX 4080: mcpp run prints 12 24 36 48 with no environment variables set. * docs(design): the cross-repository ordering, walked once and closed The design states that the ordering between repositories is hard. This records what walking it actually cost. The index package used only keys the current floor supports, so it did not have to wait for the engine to ship and could land independently. What it did have to wait for was not the merge but the artifact publish: xlings consumes the index as artifact:<sha>, so there is a workflow between "merged" and "resolvable". The example's dependency resolves only after that workflow is green on the merged commit. What remains genuinely blocked is the other direction: an index package that uses one of the new engine keys cannot land until the engine ships. This one was lucky in needing none of them. * docs(accelerators): state the two boundaries rather than leave them as gaps --accel is a build option alongside --static and --toolchain, not repeated on run, test or pack. Those read the manifest key like any other build input. mcpp pack does not emit the accel field, and the reason is the point rather than an omission. The field states what an artifact carries, and mcpp does not yet compile device code itself, so it has nothing to measure. Writing the declared value into a field whose meaning is measured would make the identity lie in precisely the way the dimension exists to prevent. * docs(design): correct the cfg semantics at the point they are stated Section 14.1 recorded that the original three-line semantics were overturned during implementation, while section 3.4 still stated them. A correction that lives only in a later section is the shape where one layer covers another and a reader takes the first one they reach. The implemented rule is one line: membership, everywhere. The combinators do not change the meaning of their operand, which is what makes a conjunction of two backends mean "both are enabled" rather than being unsatisfiable. * docs(design): record which stages shipped, and why decision 4 did not Two of the decisions taken at design time did not land, and a staged plan that does not say so lets a reader assume otherwise. Decision 4 turned out not to be implementable as stated. Refusing whenever one capability has several providers in the graph would also refuse the OpenBLAS and MKL case, which the manual documents as normal use; refusing only when their symbols overlap requires reading object files that do not exist yet at binding time. What is missing is a declaration that a capability's providers are mutually exclusive, which only the capability's definer knows. That is a new manifest key and a design decision rather than an implementation, so it is recorded as such instead of being quietly skipped. Decision 3 depends on the device-target primitive, and this round compiles device code through a build-rule package, which the staged plan already placed before that primitive. The completion table states the evidence for every stage that did ship. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
PreviousNext
