net/adapter/net/subnet/assignment.rs
1//! Label-based subnet assignment.
2//!
3//! Nodes belong to subnets by their capability tags, not static configuration.
4//! A `SubnetPolicy` maps tag patterns to hierarchy levels, deriving a `SubnetId`
5//! from a node's `CapabilitySet`.
6
7use std::collections::HashMap;
8
9use super::error::SubnetError;
10use super::id::SubnetId;
11use crate::adapter::net::behavior::capability::CapabilitySet;
12
13/// Policy for assigning nodes to subnets based on capability tags.
14///
15/// Rules are evaluated in order. Each rule maps a tag prefix to a hierarchy
16/// level and provides a value map for the tag's value.
17///
18/// Example: a node with tags `["region:us-west", "fleet:alpha"]` and rules:
19/// - `SubnetRule { tag_prefix: "region:", level: 0, values: {"us-west": 1} }`
20/// - `SubnetRule { tag_prefix: "fleet:", level: 1, values: {"alpha": 2} }`
21///
22/// Would get `SubnetId::new(&[1, 2])`.
23///
24/// # Semantics (rule precedence and matching contract)
25///
26/// Pinned by unit tests in this module — changes here are
27/// behavioral breaks for operators configuring subnets:
28///
29/// 1. **Rule order is declaration order.** `assign()` walks
30/// `rules` in the order passed to `add_rule()`. Two rules
31/// targeting the *same* `level` with overlapping values
32/// resolve as *later-rule-wins*: the earlier rule may write
33/// the level byte first, but a subsequent match at the same
34/// level overwrites it.
35/// 2. **First tag wins per rule.** Inside one rule, the first
36/// capability tag whose stripped suffix is present in `values`
37/// wins — subsequent tags matching the same rule are ignored.
38/// 3. **No partial-prefix match on values.** `tag_prefix` is
39/// stripped by [`str::strip_prefix`]; the remaining value is
40/// then looked up by *exact* string equality against `values`.
41/// A rule `region:` matching on `"us"` will **not** match the
42/// tag `region:us:extra` (the stripped suffix `"us:extra"` is
43/// not in the values map).
44/// 4. **Unmatched levels stay zero.** Levels with no rule (or a
45/// rule that failed to match) remain `0`, which [`SubnetId`]
46/// interprets as "no restriction at this level".
47#[derive(Debug, Clone)]
48pub struct SubnetPolicy {
49 rules: Vec<SubnetRule>,
50}
51
52/// A single rule mapping a tag pattern to a hierarchy level.
53#[derive(Debug, Clone)]
54pub struct SubnetRule {
55 /// Tag prefix to match (e.g., "region:").
56 pub tag_prefix: String,
57 /// Which hierarchy level this tag fills (0-3).
58 pub level: u8,
59 /// Map from tag value to level value (e.g., "us-west" -> 1).
60 pub values: HashMap<String, u8>,
61}
62
63impl SubnetPolicy {
64 /// Create an empty policy (all nodes get SubnetId::GLOBAL).
65 pub fn new() -> Self {
66 Self { rules: Vec::new() }
67 }
68
69 /// Add a rule to the policy.
70 ///
71 /// # Panics
72 /// Panics if the rule's level is >= 4. For untrusted input
73 /// (config files, FFI, JSON) prefer [`Self::try_add_rule`],
74 /// which returns a [`SubnetError`] instead of panicking.
75 pub fn add_rule(self, rule: SubnetRule) -> Self {
76 self.try_add_rule(rule)
77 .expect("SubnetPolicy::add_rule: invalid rule (use try_add_rule for fallible)")
78 }
79
80 /// Fallible variant of [`Self::add_rule`].
81 ///
82 /// Pre-existing `add_rule` panics on `rule.level >= 4`.
83 /// Subnet policies typically come from config / FFI / JSON and
84 /// a malformed entry should surface as a recoverable error
85 /// rather than crashing the daemon loader.
86 pub fn try_add_rule(mut self, rule: SubnetRule) -> Result<Self, SubnetError> {
87 if rule.level >= 4 {
88 return Err(SubnetError::LevelOutOfRange { got: rule.level });
89 }
90 self.rules.push(rule);
91 Ok(self)
92 }
93
94 /// Assign a subnet ID to a node based on its capability tags.
95 ///
96 /// Evaluates all rules against the node's tags. Unmatched levels
97 /// remain zero (meaning "no restriction at that level").
98 pub fn assign(&self, caps: &CapabilitySet) -> SubnetId {
99 let mut levels = [0u8; 4];
100
101 // Phase A.5.N.2: caps.tags is HashSet<Tag>; render each tag
102 // to its wire-form string AND sort lexicographically so
103 // the first-match-wins resolution is deterministic across
104 // runs (HashSet iteration order is unspecified).
105 let mut tag_strings: Vec<String> = caps.tags.iter().map(|t| t.to_string()).collect();
106 tag_strings.sort();
107
108 for rule in &self.rules {
109 for s in &tag_strings {
110 if let Some(value) = s.strip_prefix(&rule.tag_prefix) {
111 if let Some(&level_value) = rule.values.get(value) {
112 levels[rule.level as usize] = level_value;
113 break; // first match wins for this rule
114 }
115 }
116 }
117 }
118
119 SubnetId::new(&levels)
120 }
121}
122
123impl Default for SubnetPolicy {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl SubnetRule {
130 /// Create a new rule.
131 pub fn new(tag_prefix: impl Into<String>, level: u8) -> Self {
132 Self {
133 tag_prefix: tag_prefix.into(),
134 level,
135 values: HashMap::new(),
136 }
137 }
138
139 /// Map a tag value to a level value.
140 ///
141 /// # Panics
142 /// Panics if `level_value` is 0 (reserved for "unmatched /
143 /// no restriction"). For untrusted input prefer
144 /// [`Self::try_map`].
145 pub fn map(self, tag_value: impl Into<String>, level_value: u8) -> Self {
146 self.try_map(tag_value, level_value)
147 .expect("SubnetRule::map: level_value 0 is reserved (use try_map for fallible)")
148 }
149
150 /// Fallible variant of [`Self::map`].
151 ///
152 /// Pre-existing `map` panics on `level_value == 0`.
153 /// Returns [`SubnetError::LevelValueReserved`] instead.
154 pub fn try_map(
155 mut self,
156 tag_value: impl Into<String>,
157 level_value: u8,
158 ) -> Result<Self, SubnetError> {
159 if level_value == 0 {
160 return Err(SubnetError::LevelValueReserved);
161 }
162 self.values.insert(tag_value.into(), level_value);
163 Ok(self)
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::adapter::net::behavior::capability::CapabilitySet;
171
172 fn caps_with_tags(tags: &[&str]) -> CapabilitySet {
173 let mut caps = CapabilitySet::new();
174 for tag in tags {
175 caps = caps.add_tag(*tag);
176 }
177 caps
178 }
179
180 #[test]
181 fn test_empty_policy() {
182 let policy = SubnetPolicy::new();
183 let caps = caps_with_tags(&["region:us-west"]);
184 assert_eq!(policy.assign(&caps), SubnetId::GLOBAL);
185 }
186
187 #[test]
188 fn test_single_level() {
189 let policy = SubnetPolicy::new().add_rule(
190 SubnetRule::new("region:", 0)
191 .map("us-west", 1)
192 .map("eu-central", 2),
193 );
194
195 let caps = caps_with_tags(&["region:us-west"]);
196 assert_eq!(policy.assign(&caps), SubnetId::new(&[1]));
197
198 let caps = caps_with_tags(&["region:eu-central"]);
199 assert_eq!(policy.assign(&caps), SubnetId::new(&[2]));
200 }
201
202 #[test]
203 fn test_multi_level() {
204 let policy = SubnetPolicy::new()
205 .add_rule(
206 SubnetRule::new("region:", 0)
207 .map("us-west", 1)
208 .map("eu-central", 2),
209 )
210 .add_rule(SubnetRule::new("fleet:", 1).map("alpha", 1).map("beta", 2));
211
212 let caps = caps_with_tags(&["region:us-west", "fleet:beta"]);
213 assert_eq!(policy.assign(&caps), SubnetId::new(&[1, 2]));
214 }
215
216 #[test]
217 fn test_unmatched_tag() {
218 let policy = SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us-west", 1));
219
220 // Tag value not in the map
221 let caps = caps_with_tags(&["region:unknown"]);
222 assert_eq!(policy.assign(&caps), SubnetId::GLOBAL);
223
224 // No matching tag prefix
225 let caps = caps_with_tags(&["fleet:alpha"]);
226 assert_eq!(policy.assign(&caps), SubnetId::GLOBAL);
227 }
228
229 #[test]
230 fn test_partial_match() {
231 let policy = SubnetPolicy::new()
232 .add_rule(SubnetRule::new("region:", 0).map("us-west", 3))
233 .add_rule(SubnetRule::new("fleet:", 1).map("alpha", 7));
234
235 // Only region tag, no fleet
236 let caps = caps_with_tags(&["region:us-west"]);
237 assert_eq!(policy.assign(&caps), SubnetId::new(&[3]));
238 }
239
240 #[test]
241 fn test_four_levels() {
242 let policy = SubnetPolicy::new()
243 .add_rule(SubnetRule::new("region:", 0).map("us", 1))
244 .add_rule(SubnetRule::new("fleet:", 1).map("f1", 2))
245 .add_rule(SubnetRule::new("vehicle:", 2).map("v42", 3))
246 .add_rule(SubnetRule::new("subsystem:", 3).map("lidar", 4));
247
248 let caps = caps_with_tags(&["region:us", "fleet:f1", "vehicle:v42", "subsystem:lidar"]);
249 assert_eq!(policy.assign(&caps), SubnetId::new(&[1, 2, 3, 4]));
250 }
251
252 // ========================================================================
253 // Tie-breaking / ambiguity semantics (TEST_COVERAGE_PLAN §P3-17)
254 //
255 // Pins the three ambiguity cases the doc contract on
256 // `SubnetPolicy` calls out: same-prefix duplicate rules,
257 // rule-order dependency for the same level, and the no-partial-
258 // match contract on values. If any of these assertions flips,
259 // either the doc contract is wrong or a silent behavior change
260 // snuck in — the PR touching `assign()` needs to decide which.
261 // ========================================================================
262
263 /// Duplicate `tag_prefix` rules both writing the same level:
264 /// the later rule wins (last write). An earlier rule's mapping
265 /// is overwritten if a later rule matches the same tag input.
266 #[test]
267 fn duplicate_prefix_same_level_later_rule_wins() {
268 let policy = SubnetPolicy::new()
269 // First rule writes level 0 = 1
270 .add_rule(SubnetRule::new("region:", 0).map("us", 1))
271 // Second rule at the same level remaps "us" to 9
272 .add_rule(SubnetRule::new("region:", 0).map("us", 9));
273
274 let caps = caps_with_tags(&["region:us"]);
275 assert_eq!(
276 policy.assign(&caps),
277 SubnetId::new(&[9]),
278 "a later rule with the same prefix + level must overwrite \
279 the earlier rule's value — pinned as last-write-wins",
280 );
281 }
282
283 /// Duplicate `tag_prefix` rules writing *different* levels
284 /// coexist: both writes land on their respective level slots.
285 /// Exercises the "rules evaluated in declaration order, each
286 /// writes its own level independently" part of the contract.
287 #[test]
288 fn duplicate_prefix_different_levels_both_apply() {
289 let policy = SubnetPolicy::new()
290 .add_rule(SubnetRule::new("region:", 0).map("us", 1))
291 // Same prefix, different level — coexists with the first
292 .add_rule(SubnetRule::new("region:", 2).map("us", 5));
293
294 let caps = caps_with_tags(&["region:us"]);
295 assert_eq!(
296 policy.assign(&caps),
297 SubnetId::new(&[1, 0, 5, 0]),
298 "two rules sharing a prefix but targeting different \
299 levels must both fire; level 1 + 3 remain unset",
300 );
301 }
302
303 /// Rule-order dependency: when two rules both claim the same
304 /// level but match *different* tags, the later rule's match
305 /// still overwrites the earlier rule's match if both tags are
306 /// present on the node. Pins "later rule wins" even across
307 /// different tag prefixes targeting the same level.
308 #[test]
309 fn rule_order_dependency_later_rule_overwrites_earlier_level_write() {
310 let policy = SubnetPolicy::new()
311 // Earlier: region:* writes level 0
312 .add_rule(SubnetRule::new("region:", 0).map("us", 1))
313 // Later: zone:* ALSO writes level 0 — this rule comes
314 // after the first, so it wins when both tags match
315 .add_rule(SubnetRule::new("zone:", 0).map("west", 4));
316
317 let caps = caps_with_tags(&["region:us", "zone:west"]);
318 assert_eq!(
319 policy.assign(&caps),
320 SubnetId::new(&[4]),
321 "later rule targeting the same level must overwrite earlier one",
322 );
323
324 // And: if only the earlier rule's tag is present, level 0
325 // still ends up with the earlier rule's value (the later
326 // rule does not match any tag).
327 let caps = caps_with_tags(&["region:us"]);
328 assert_eq!(
329 policy.assign(&caps),
330 SubnetId::new(&[1]),
331 "later rule does not clobber when it has no matching tag",
332 );
333 }
334
335 /// No partial-match on the stripped value: `values` is an
336 /// exact-string lookup table, not a prefix matcher. A tag
337 /// carrying extra suffix after the prefix does not hit a rule
338 /// keyed on the bare inner token.
339 #[test]
340 fn partial_prefix_on_value_does_not_match() {
341 let policy = SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us", 1));
342
343 // `region:us` → stripped "us" → hits values map.
344 let caps = caps_with_tags(&["region:us"]);
345 assert_eq!(policy.assign(&caps), SubnetId::new(&[1]));
346
347 // `region:us:extra` → stripped "us:extra" → NOT in map,
348 // so rule doesn't fire and level stays at zero.
349 let caps = caps_with_tags(&["region:us:extra"]);
350 assert_eq!(
351 policy.assign(&caps),
352 SubnetId::GLOBAL,
353 "values map is exact-match; suffixes after the matching \
354 inner token must not partial-match against the map key",
355 );
356
357 // Cousin case: tag where the stripped value is a *prefix*
358 // of a values-map entry doesn't match either.
359 let policy = SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us-west", 1));
360 let caps = caps_with_tags(&["region:us"]);
361 assert_eq!(
362 policy.assign(&caps),
363 SubnetId::GLOBAL,
364 "stripped value \"us\" is a prefix of \"us-west\" but \
365 must not partial-match the values map key",
366 );
367 }
368
369 /// First matching tag wins *within* a single rule — a second
370 /// tag for the same rule is ignored (the `break` in `assign`).
371 /// Phase A.5.N.2: tags are now `HashSet<Tag>` (unordered);
372 /// `assign()` sorts tag strings lexicographically before the
373 /// first-match scan, so the result is deterministic regardless
374 /// of insertion order.
375 #[test]
376 fn first_tag_wins_within_a_single_rule() {
377 let policy =
378 SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us", 1).map("eu", 2));
379
380 // Both insertions converge on the same answer — the
381 // lexicographically-first matching tag wins. `region:eu`
382 // sorts before `region:us`, so 2 (eu's level value) wins
383 // regardless of which tag was inserted first.
384 let caps = caps_with_tags(&["region:us", "region:eu"]);
385 assert_eq!(
386 policy.assign(&caps),
387 SubnetId::new(&[2]),
388 "lexicographically-first matching tag wins (`region:eu` < `region:us`)",
389 );
390
391 let caps = caps_with_tags(&["region:eu", "region:us"]);
392 assert_eq!(
393 policy.assign(&caps),
394 SubnetId::new(&[2]),
395 "insertion order is irrelevant — the same tag still wins",
396 );
397 }
398
399 /// Out-of-range `level` must surface as `Err(...)`,
400 /// not panic. Subnet policies typically come from config /
401 /// FFI / JSON; a malformed entry must not crash the daemon
402 /// loader.
403 #[test]
404 fn try_add_rule_rejects_level_out_of_range() {
405 let policy = SubnetPolicy::new();
406 let err = policy
407 .try_add_rule(SubnetRule::new("region:", 4).map("us", 1))
408 .unwrap_err();
409 assert!(
410 matches!(err, SubnetError::LevelOutOfRange { got: 4 }),
411 "expected LevelOutOfRange{{got: 4}}, got {:?}",
412 err
413 );
414 }
415
416 #[test]
417 fn try_add_rule_accepts_max_level() {
418 let policy = SubnetPolicy::new();
419 // Level 3 is the highest valid level (0..=3).
420 policy
421 .try_add_rule(SubnetRule::new("level3:", 3).map("x", 1))
422 .expect("level=3 must be accepted (boundary)");
423 }
424
425 /// Zero `level_value` must surface as `Err(...)`,
426 /// not panic.
427 #[test]
428 fn try_map_rejects_reserved_zero() {
429 let rule = SubnetRule::new("region:", 0);
430 let err = rule.try_map("us", 0).unwrap_err();
431 assert!(
432 matches!(err, SubnetError::LevelValueReserved),
433 "expected LevelValueReserved, got {:?}",
434 err
435 );
436 }
437
438 #[test]
439 fn try_map_accepts_one() {
440 // 1 is the lowest non-reserved level value.
441 SubnetRule::new("region:", 0)
442 .try_map("us", 1)
443 .expect("level_value=1 must be accepted (boundary)");
444 }
445}