Skip to content

Commit d5a933c

Browse files
committed
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.
1 parent f431285 commit d5a933c

9 files changed

Lines changed: 446 additions & 0 deletions

File tree

‎docs/05-mcpp-toml.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,6 +1567,46 @@ error: capability 'gpu-blas' is provided by more than one package, and they
15671567

15681568
The refusal reports `exclusive-capability` in `--format json` (chapter 11).
15691569

1570+
#### `version-floor` — needing more of the machine than it has
1571+
1572+
Some facts about a machine bound what may be built for it, and the failure when
1573+
they are ignored arrives late: a program built against a runtime newer than the
1574+
driver it will meet links cleanly and fails at first use, naming neither side.
1575+
1576+
A package states what it needs:
1577+
1578+
```toml
1579+
[[runtime.requirements]]
1580+
kind = "version-floor"
1581+
value = "cuda.driver >= 12.0"
1582+
```
1583+
1584+
and a package that established a fact about this machine — at install time,
1585+
which is where probing belongs — states it:
1586+
1587+
```toml
1588+
[runtime]
1589+
provides = ["cuda.driver=12.4"]
1590+
```
1591+
1592+
mcpp compares them when capabilities are bound and refuses before anything is
1593+
compiled, reporting `version-floor-unmet`:
1594+
1595+
```
1596+
error: `toolkitnew` requires cuda.driver >= 13.0, and this machine has 12.4.
1597+
stated by: driverfact
1598+
```
1599+
1600+
**No vendor vocabulary reaches the engine.** It reads a name, a relation and a
1601+
version; `cuda.driver` is data passing through, and a backend mcpp has never
1602+
heard of compares the same way.
1603+
1604+
⚠️ **A floor nobody answered is silent.** A machine that never declared what it
1605+
has is not a machine that fails the floor — it is one nobody asked. Turning
1606+
"we do not know" into "no" is the failure mode this exists to avoid, and it is
1607+
asserted directly: `tests/e2e/603_version_floor.sh` builds a project whose floor
1608+
names something no package provides.
1609+
15701610
**It is a claim about this package's own symbols**, so an entry that names a
15711611
capability the package does not provide is reported as a schema warning: there
15721612
is nothing to be exclusive about. And a capability nobody declares exclusive

‎docs/11-machine-output.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ a program classifying the outcome reads `reason`:
378378
| `layer-requirement` | a package requires a layer the resolution did not give it |
379379
| `layer-ordering` | the five layers do not stack |
380380
| `exclusive-capability` | two packages provide one capability and at least one declared it exclusive |
381+
| `version-floor-unmet` | a package requires more of the machine than the machine was declared to have |
381382
| `other` | a refusal whose branch has not been given a token yet |
382383

383384
⚠️ **Exit 0 whenever the question was answered, including "refused".** "Would

‎docs/zh/05-mcpp-toml.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,42 @@ error: capability 'gpu-blas' is provided by more than one package, and they
13471347

13481348
该拒绝在 `--format json` 里报 `exclusive-capability`(见第 11 章)。
13491349

1350+
#### `version-floor` —— 对机器的要求高于它所有
1351+
1352+
有些关于机器的事实约束着能为它构建什么,而忽略它们时的失败来得很晚:
1353+
一个针对比它将遇到的驱动更新的运行时构建出来的程序,**干净地链接**,
1354+
在第一次使用时失败,而消息里两侧都没有。
1355+
1356+
包声明它需要什么:
1357+
1358+
```toml
1359+
[[runtime.requirements]]
1360+
kind = "version-floor"
1361+
value = "cuda.driver >= 12.0"
1362+
```
1363+
1364+
而某个在**安装期**(探测该发生的地方)确立了机器某项事实的包,声明它:
1365+
1366+
```toml
1367+
[runtime]
1368+
provides = ["cuda.driver=12.4"]
1369+
```
1370+
1371+
mcpp 在绑定 capability 时比较二者,并在任何东西被编译之前拒绝,
1372+
报 `version-floor-unmet`:
1373+
1374+
```
1375+
error: `toolkitnew` requires cuda.driver >= 13.0, and this machine has 12.4.
1376+
stated by: driverfact
1377+
```
1378+
1379+
**没有任何厂商词汇抵达引擎。** 它读到的是一个名字、一个关系和一个版本;
1380+
`cuda.driver` 是流过的数据,一个 mcpp 从未听说过的后端比较方式完全相同。
1381+
1382+
⚠️ **没人回答的下界是沉默的。** 一台从未声明自己有什么的机器,不是「未满足下界」的机器,
1383+
而是「没人问过」的机器。把「我们不知道」变成「不行」正是这个机制要避免的失败,
1384+
并且有直接判据:`tests/e2e/603_version_floor.sh` 会构建一个下界指向无人提供之物的工程。
1385+
13501386
**它是关于这个包自己的符号的声明**,所以一条指向本包并不提供的能力的条目会被报为
13511387
schema 警告:那里没有可独占的东西。而无人声明独占的能力行为完全不变 —— 两个 BLAS
13521388
实现照常共存,既有的「两个或更多、未 pin」报错也仍然只在**有人 require** 该能力时出现。

‎docs/zh/11-machine-output.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ mcpp why toolchain [--target <triple>] [--toolchain <spec>] --format json
332332
| `layer-requirement` | 某个包要求的层,解析没有给出 |
333333
| `layer-ordering` | 五层叠不起来 |
334334
| `exclusive-capability` | 一个能力有多个提供者,而其中至少一个声明了独占 |
335+
| `version-floor-unmet` | 一个包对机器的要求高于机器被声明拥有的 |
335336
| `other` | 一处还没有被命名的拒绝分支 |
336337

337338
⚠️ **只要问题被回答了就退 0,包括答案是「拒绝」。** 「它能不能构建,不能的话

‎src/build/prepare.cppm‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import std;
1919
import mcpp.targetside;
2020
import mcpp.diag;
2121
import mcpp.build.refusal;
22+
import mcpp.build.version_floor;
2223
import mcpp.home;
2324
import mcpp.platform.axis;
2425
import mcpp.libs.json;
@@ -7524,6 +7525,57 @@ prepare_build(bool print_fingerprint,
75247525
cap, claimers.size() == 1 ? "it" : "they", list, claimed));
75257526
}
75267527

7528+
// VERSION FLOORS. A package states what it needs of the machine; a
7529+
// package that established a fact about the machine states it. Neither
7530+
// string means anything to this code -- `cuda.driver` is data flowing
7531+
// through -- which is why a second backend needs no change here and why
7532+
// `test_runtime_contract`'s gate stays satisfied.
7533+
//
7534+
// ⚠️ A FLOOR WITH NO FACT IS SILENT. A machine that never declared what
7535+
// it has is not a machine that fails the floor; it is one nobody asked.
7536+
// Reporting a refusal there would turn "we do not know" into "no", and
7537+
// the whole reason this exists is that a wrong answer is worse than no
7538+
// answer.
7539+
{
7540+
std::map<std::string, std::pair<std::string, std::string>> facts; // name -> (version, who)
7541+
for (auto const& pkg : packages) {
7542+
const auto who = pkg.manifest.package.name;
7543+
for (auto const& entry : pkg.manifest.runtimeConfig.provides) {
7544+
auto fact = mcpp::build::parse_version_fact(entry);
7545+
if (fact.valid()) facts.emplace(fact.name, std::pair{fact.version, who});
7546+
}
7547+
}
7548+
for (auto const& pkg : packages) {
7549+
const auto who = pkg.manifest.package.name;
7550+
for (auto const& req : pkg.manifest.runtimeConfig.requirements) {
7551+
if (req.kind != "version-floor") continue;
7552+
auto floor = mcpp::build::parse_version_floor(req.value);
7553+
if (!floor.valid()) {
7554+
return std::unexpected(std::format(
7555+
"`{}` declares a version-floor requirement mcpp "
7556+
"cannot read: '{}'.\n"
7557+
" The shape is `<name> >= <version>`, e.g. "
7558+
"`cuda.driver >= 12.0`.", who, req.value));
7559+
}
7560+
auto it = facts.find(floor.name);
7561+
if (it == facts.end()) continue; // nobody stated it
7562+
auto met = mcpp::build::version_at_least(it->second.first,
7563+
floor.version);
7564+
if (!met || *met) continue;
7565+
refusal::record(refusal::Code::VersionFloorUnmet);
7566+
return std::unexpected(std::format(
7567+
"`{}` requires {} >= {}, and this machine has {}.\n"
7568+
" stated by: {}\n"
7569+
" This is checked before anything is compiled "
7570+
"because the failure it prevents is not:\n"
7571+
" a build against too-new a runtime links "
7572+
"cleanly and fails at first use.",
7573+
who, floor.name, floor.version, it->second.first,
7574+
it->second.second));
7575+
}
7576+
}
7577+
}
7578+
75277579
std::set<std::string> boundCaps;
75287580
for (auto& [cap, requirer] : capRequires) {
75297581
if (!boundCaps.insert(cap).second) continue; // one diagnosis per cap

‎src/build/refusal.cppm‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ enum class Code {
6464
// provider for a requirement, this one is about two implementations of one
6565
// interface being in the same link at all.
6666
ExclusiveCapability,
67+
// A package requires more of the machine than the machine was declared to
68+
// have. Distinct from a capability that is missing entirely: here the thing
69+
// exists and is too old.
70+
VersionFloorUnmet,
6771
Other, // a refusal that has not been given a code yet
6872
};
6973

@@ -85,6 +89,7 @@ constexpr std::string_view name(Code c) {
8589
case Code::HostToolToolchain: return "host-tool-toolchain";
8690
case Code::StdModulePrecompile: return "std-module-precompile";
8791
case Code::ExclusiveCapability: return "exclusive-capability";
92+
case Code::VersionFloorUnmet: return "version-floor-unmet";
8893
case Code::Other: return "other";
8994
}
9095
return "other";

‎src/build/version_floor.cppm‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// mcpp.build.version_floor — "this needs at least that much of something".
2+
//
3+
// WHY THIS EXISTS
4+
//
5+
// Some facts about a machine bound what may be built for it, and the failure
6+
// when they are ignored arrives late. The case this was written for: a device
7+
// runtime must not be newer than the driver it runs against, and when it is,
8+
// the build compiles and links cleanly and then fails at the first allocation
9+
// with a message that names neither the toolkit nor the driver.
10+
//
11+
// Both numbers are knowable before anything is compiled. What mcpp must not do
12+
// is go and ask a vendor's tool for them — `tests/unit/test_runtime_contract`
13+
// refuses provider-specific probes in `src/`, and rightly: an engine that
14+
// learns to run one vendor's probe learns to run four. So the numbers arrive as
15+
// DECLARATIONS and this module compares them.
16+
//
17+
// WHAT THE DECLARATIONS LOOK LIKE
18+
//
19+
// A package that needs something states a floor, in `runtime.requirements`:
20+
//
21+
// [[runtime.requirements]]
22+
// kind = "version-floor"
23+
// value = "cuda.driver >= 12.0"
24+
//
25+
// A package that KNOWS a fact about this machine states it, in
26+
// `runtime.provides`, having established it at install time:
27+
//
28+
// provides = ["cuda.driver=12.4"]
29+
//
30+
// ⚠️ NO VENDOR NAME APPEARS IN THIS FILE, and that is the point rather than a
31+
// coincidence. `cuda.driver` is data flowing through: this module reads a name,
32+
// a relation and a version, and knows nothing about what any of them mean. A
33+
// second backend needs no change here.
34+
//
35+
// WHAT A VERSION IS
36+
//
37+
// A dot-separated sequence of integers, compared component by component, with a
38+
// missing component reading as zero — so `12` and `12.0` are the same version
39+
// and `12.4` is above both. Anything that is not that shape yields no version,
40+
// and an absent version on either side yields no claim: a check that cannot
41+
// reach an answer must not manufacture a refusal.
42+
43+
export module mcpp.build.version_floor;
44+
45+
import std;
46+
47+
export namespace mcpp::build {
48+
49+
// A parsed `<name> >= <version>` requirement.
50+
struct VersionFloor {
51+
std::string name;
52+
std::string version;
53+
bool valid() const { return !name.empty() && !version.empty(); }
54+
};
55+
56+
// A parsed `<name>=<version>` fact.
57+
struct VersionFact {
58+
std::string name;
59+
std::string version;
60+
bool valid() const { return !name.empty() && !version.empty(); }
61+
};
62+
63+
// Read `<name> >= <version>`. Whitespace around each part is ignored; any other
64+
// shape yields an invalid result rather than a guess.
65+
VersionFloor parse_version_floor(std::string_view text);
66+
67+
// Read `<name>=<version>`. The separator is a bare `=` so the spelling matches
68+
// the capability strings a descriptor already writes.
69+
VersionFact parse_version_fact(std::string_view text);
70+
71+
// Is `have` at or above `want`? Both are dot-separated integers; a missing
72+
// component reads as zero.
73+
//
74+
// Returns std::nullopt when either side is not a version, which callers report
75+
// as "no claim" rather than as a failure.
76+
std::optional<bool> version_at_least(std::string_view have, std::string_view want);
77+
78+
} // namespace mcpp::build
79+
80+
namespace mcpp::build {
81+
82+
namespace {
83+
84+
std::string_view trim(std::string_view s) {
85+
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
86+
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
87+
return s;
88+
}
89+
90+
// The components of a dotted version, or an empty vector when the text is not
91+
// one. A trailing or leading dot makes it not one: `12.` is a typo, not `12`.
92+
std::optional<std::vector<long long>> components(std::string_view v) {
93+
v = trim(v);
94+
if (v.empty()) return std::nullopt;
95+
std::vector<long long> out;
96+
long long acc = 0;
97+
bool digits = false;
98+
for (char c : v) {
99+
if (c == '.') {
100+
if (!digits) return std::nullopt;
101+
out.push_back(acc);
102+
acc = 0;
103+
digits = false;
104+
continue;
105+
}
106+
if (!std::isdigit(static_cast<unsigned char>(c))) return std::nullopt;
107+
acc = acc * 10 + (c - '0');
108+
digits = true;
109+
}
110+
if (!digits) return std::nullopt;
111+
out.push_back(acc);
112+
return out;
113+
}
114+
115+
} // namespace
116+
117+
VersionFloor parse_version_floor(std::string_view text) {
118+
VersionFloor f;
119+
auto at = text.find(">=");
120+
if (at == std::string_view::npos) return f;
121+
auto name = trim(text.substr(0, at));
122+
auto ver = trim(text.substr(at + 2));
123+
if (name.empty() || ver.empty()) return f;
124+
if (!components(ver)) return f;
125+
f.name = std::string(name);
126+
f.version = std::string(ver);
127+
return f;
128+
}
129+
130+
VersionFact parse_version_fact(std::string_view text) {
131+
VersionFact f;
132+
auto at = text.find('=');
133+
if (at == std::string_view::npos) return f;
134+
// `>=` is a floor, not a fact; refusing it here keeps one spelling from
135+
// being read as the other when both live in string lists.
136+
if (at > 0 && text[at - 1] == '>') return f;
137+
auto name = trim(text.substr(0, at));
138+
auto ver = trim(text.substr(at + 1));
139+
if (name.empty() || ver.empty()) return f;
140+
if (!components(ver)) return f;
141+
f.name = std::string(name);
142+
f.version = std::string(ver);
143+
return f;
144+
}
145+
146+
std::optional<bool> version_at_least(std::string_view have, std::string_view want) {
147+
auto h = components(have);
148+
auto w = components(want);
149+
if (!h || !w) return std::nullopt;
150+
const auto n = std::max(h->size(), w->size());
151+
for (std::size_t i = 0; i < n; ++i) {
152+
const long long a = i < h->size() ? (*h)[i] : 0;
153+
const long long b = i < w->size() ? (*w)[i] : 0;
154+
if (a != b) return a > b;
155+
}
156+
return true;
157+
}
158+
159+
} // namespace mcpp::build

0 commit comments

Comments
 (0)