1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! Ratchet over the layer graph of `src/`: a file must not gain an
//! **upward** dependency (a `crate::<layer>::…` path into a layer above
//! its own) beyond its allowlisted count of distinct upward targets, and an
//! allowlisted file must not lose one without the allowlist being
//! tightened. See CONTRIBUTING.md, "Architecture → Dependency flow".
//!
//! The order is the *honest* one documented there: `base → poly →
//! transforms → simplify → calculus → output → plotting → domains → api →
//! units`, with the handle types `api::{expr, context, expr_ops,
//! expr_view, eq, macros}` treated as a hub every layer may name. What
//! remains upward is real and listed below with its reason; the ratchet
//! keeps the list from growing silently.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
const LAYERS: &[&str] = &[
"base",
"poly",
"transforms",
"simplify",
"calculus",
"output",
"plotting",
"domains",
"api",
"units",
];
/// Modules every layer may reach: the expression handle and its context.
const HUB: &[&str] = &[
"api::expr",
"api::context",
"api::expr_ops",
"api::expr_view",
"api::eq",
"api::macros",
];
/// Files with upward edges, with the exact number of *distinct* upward
/// target modules each names. Paths are relative to `src/`.
const ALLOWLIST: &[(&str, usize)] = &[
// The `Arena` façade: `arena.expand()`, `.integrate()`, … delegate to the algorithm layers.
("base/arena.rs", 35),
// Assumption queries consult polynomial structure (Sturm, polybridge) and `poly_is_nonnegative_on`.
("base/assumptions.rs", 4),
// `canon_pow` factors small integers through `ntheory`.
("base/canon.rs", 1),
// `as_real_imag` evaluates and expands.
("base/complex.rs", 2),
// Function analysis prints candidates for its error messages.
("calculus/calculus_util.rs", 1),
// Numeric quadrature compiles the integrand.
("calculus/definite.rs", 1),
// ODE systems use `Matrix` and `linsolve`.
("calculus/ode.rs", 2),
// Certificates, polytopes and stats work on the public `Poly` view.
("domains/certificates.rs", 1),
("domains/certificates/outcome.rs", 1),
("domains/certificates/polyhedron.rs", 1),
("domains/certificates/sos.rs", 1),
("domains/polytope.rs", 1),
("domains/stats/family.rs", 1),
("domains/stats/joint.rs", 1),
("domains/stats/wrappers.rs", 1),
// Robotics renders SI quantities.
("domains/robotics.rs", 1),
// Algebraic numbers evaluate, substitute and solve.
("poly/algebraic.rs", 4),
// The Ex ↔ polynomial bridge expands and evaluates coefficients.
("poly/polybridge.rs", 3),
// Polynomial systems fall back to the general solver and re-export `expr_solve_ext` items (public API).
("poly/polysys.rs", 6),
// `factor_terms` shares a sign helper with the printers.
("simplify/factor_terms.rs", 1),
// `apart` rationalises coefficients.
("transforms/apart.rs", 1),
// `eval` folds `Sum`/`Product` through the summation engine, `Integral` through Risch, and binomials through combinatorics.
("transforms/eval.rs", 3),
// `evalf` integrates numerically, compiles, and evaluates combinatorial functions.
("transforms/evalf.rs", 3),
// `expand` delegates trig/log expansion to the simplifiers.
("transforms/expand.rs", 2),
// `integrate` calls Risch and the simplifier.
("transforms/integrate.rs", 2),
// `logic` simplifies relational atoms with the numeric engine.
("transforms/logic.rs", 1),
// The pattern matcher is the engine behind `RuleSet` and the simplifier.
("transforms/pattern.rs", 2),
// `rsolve` uses `linsolve` and `Matrix`.
("transforms/rsolve.rs", 2),
// `solve` normalises with `ratsimp`.
("transforms/solve.rs", 1),
];
fn layer_of(rel: &str) -> Option<usize> {
let first = rel.split('/').next()?.trim_end_matches(".rs");
LAYERS.iter().position(|l| *l == first)
}
/// Distinct upward target modules (`layer::module` or `layer`) named in a file.
fn upward_targets(rel: &str, text: &str) -> BTreeSet<String> {
let own = layer_of(rel).unwrap_or(usize::MAX);
let mut out = BTreeSet::new();
for (i, _) in text.match_indices("crate::") {
let rest = &text[i + "crate::".len()..];
let ident = |s: &str| -> String {
s.chars()
.take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_')
.collect()
};
let layer = ident(rest);
let Some(target) = LAYERS.iter().position(|l| *l == layer) else {
continue;
};
let after = &rest[layer.len()..];
let sub = after
.strip_prefix("::")
.map(ident)
.filter(|s| !s.is_empty());
let path = match &sub {
Some(s) => format!("{layer}::{s}"),
None => layer.clone(),
};
if HUB.contains(&path.as_str()) {
continue;
}
if target > own {
out.insert(path);
}
}
out
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let entries = fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
for entry in entries {
let path = entry.expect("readable directory entry").path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn layer_graph_has_no_unallowlisted_upward_edges() {
let src = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src"));
let mut files = Vec::new();
collect_rs_files(src, &mut files);
files.sort();
let allow: BTreeMap<&str, usize> = ALLOWLIST.iter().copied().collect();
let mut counts: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for path in &files {
let rel = path
.strip_prefix(src)
.expect("under src/")
.to_string_lossy()
.replace('\\', "/");
if rel == "lib.rs" || layer_of(&rel).is_none() {
continue;
}
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{rel}: {e}"));
let up = upward_targets(&rel, &text);
if !up.is_empty() {
counts.insert(rel, up);
}
}
let mut problems = Vec::new();
for (rel, up) in &counts {
let allowed = allow.get(rel.as_str()).copied().unwrap_or(0);
if up.len() > allowed {
problems.push(format!(
" {rel}: {} upward target(s), allowed {allowed} — {:?}\n (move the code down a layer, route through the Arena façade, or extend the allowlist with a reason)",
up.len(),
up
));
} else if up.len() < allowed {
problems.push(format!(
" {rel}: {} upward target(s), allowlist says {allowed} — tighten the allowlist",
up.len()
));
}
}
for (rel, allowed) in &allow {
if !counts.contains_key(*rel) {
problems.push(format!(
" {rel}: no upward edges, allowlist says {allowed} — remove the entry"
));
}
}
assert!(
problems.is_empty(),
"layer ratchet failed:\n{}\n\nper-file upward targets:\n{}",
problems.join("\n"),
counts
.iter()
.map(|(k, v)| format!(" {k}: {v:?}"))
.collect::<Vec<_>>()
.join("\n")
);
}