Skip to main content

net/adapter/net/behavior/
required_capability.rs

1//! `RequiredCapability` + the `require!` / `require_axis!` /
2//! `require_axis_value!` macros — Phase A foundation for the
3//! `IntentRegistry` shipped in `CAPABILITY_SYSTEM_PLAN.md` §7.
4//!
5//! Element type of the `IntentRegistry` value vector — each intent
6//! maps to a `Vec<RequiredCapability>` describing what the
7//! `metadata.intent`-tagged artifact needs from a candidate node.
8//! All four `IntentRegistry::defaults()` examples in the substrate
9//! plan land cleanly on the four variants below:
10//!
11//! | Substrate example | Variant produced |
12//! |---|---|
13//! | `require!("hardware.gpu")` | `Tag(Tag::AxisPresent { … })` |
14//! | `require!("hardware.gpu.vram_gb >= 24")` | `Predicate(NumericAtLeast { … })` |
15//! | `require!("software.daemon:postgres")` | `Tag(Tag::AxisValue { … })` |
16//! | `require_axis!("devices")` | `AxisAny(Devices)` |
17//! | `require_axis_value!("software", "model")` | `AxisKey(TagKey { … })` |
18//!
19//! Evaluation: [`RequiredCapability::evaluate`] returns `true` iff
20//! the candidate's `(tags, metadata)` satisfies the requirement.
21//! Pure function; reuses [`Predicate::evaluate`] for the predicate
22//! variant and a tag-set scan for the others.
23
24use crate::adapter::net::behavior::predicate::{EvalContext, Predicate};
25use crate::adapter::net::behavior::tag::{CapabilityTagError, Tag, TagKey, TaxonomyAxis};
26
27// =============================================================================
28// RequiredCapability
29// =============================================================================
30
31/// One requirement an intent imposes on a candidate node.
32///
33/// Built from one of the three macros (`require!`, `require_axis!`,
34/// `require_axis_value!`) or constructed directly via the variant
35/// constructors. Cheap to clone; structural equality (no `Eq`
36/// because the predicate variant carries `f64` thresholds).
37#[derive(Debug, Clone, PartialEq)]
38pub enum RequiredCapability {
39    /// Specific tag must be present in the candidate's tag set.
40    /// Built by `require!("<axis>.<key>")` (axis presence) or
41    /// `require!("<axis>.<key>=<value>")` / `require!("<axis>.<key>:<value>")`
42    /// (axis value).
43    Tag(Tag),
44    /// Predicate must evaluate to `true` against the candidate.
45    /// Built by `require!("<axis>.<key> >= <n>")` and similar
46    /// comparison forms.
47    Predicate(Predicate),
48    /// Any tag in this axis is sufficient. Built by
49    /// `require_axis!("<axis>")` — useful for "any device" /
50    /// "any loaded model" / etc.
51    AxisAny(TaxonomyAxis),
52    /// Any tag with this `(axis, key)` is sufficient (presence or
53    /// value). Built by `require_axis_value!("<axis>", "<key>")`.
54    AxisKey(TagKey),
55}
56
57impl RequiredCapability {
58    /// Evaluate against a candidate's `(tags, metadata)`. Pure
59    /// function; reuses [`Predicate::evaluate`] for the
60    /// `Predicate` variant.
61    pub fn evaluate(&self, ctx: &EvalContext<'_>) -> bool {
62        match self {
63            // Separator-agnostic match: a `require!("software.os:linux")`
64            // must hit a stored `software.os=linux` (and vice versa).
65            // `Tag::PartialEq` distinguishes the two via its `separator`
66            // field; that's a wire-form detail, not part of identity.
67            // See CR-2 in `CODE_REVIEW_2026_05_10_CAPABILITY_SYSTEM_2.md`.
68            Self::Tag(required) => ctx.tags.iter().any(|t| t.semantic_eq(required)),
69            Self::Predicate(p) => p.evaluate(ctx),
70            Self::AxisAny(axis) => ctx.tags.iter().any(|t| t.axis() == Some(*axis)),
71            Self::AxisKey(key) => ctx
72                .tags
73                .iter()
74                .any(|t| matches!(t.axis_key_ref(), Some((a, k)) if a == key.axis && k == key.key)),
75        }
76    }
77}
78
79// =============================================================================
80// Errors
81// =============================================================================
82
83/// Errors raised by the `require!` family of macros at parse time.
84/// All are programmer errors — the macros panic with these
85/// messages so misuse fails loudly at first run.
86#[derive(Debug, thiserror::Error)]
87pub enum RequireParseError {
88    /// Empty input passed to a `require*!` macro.
89    #[error("require! input must be non-empty")]
90    Empty,
91    /// Wrapped [`CapabilityTagError`] from tag parsing
92    /// (e.g. user attempted to emit a reserved-prefix tag).
93    #[error("require! could not parse tag: {0}")]
94    Tag(#[from] CapabilityTagError),
95    /// Numeric comparison's right-hand side did not parse to
96    /// `f64`. Carries the original lhs / rhs spelling for
97    /// diagnostics.
98    #[error("require! numeric value {value:?} for key {key:?} did not parse as f64")]
99    NumericParse {
100        /// Tag-key portion of the failed comparison (`hardware.gpu.vram_gb`).
101        key: String,
102        /// Right-hand-side spelling that failed to parse (`twenty-four`).
103        value: String,
104    },
105    /// Tag key (`<axis>.<key>`) couldn't be parsed (missing dot or
106    /// unknown axis prefix).
107    #[error("require! tag key {key:?} must be `<axis>.<key>` with a known axis")]
108    InvalidKey {
109        /// Offending key spelling.
110        key: String,
111    },
112    /// Unknown axis literal passed to `require_axis!`.
113    #[error("require_axis! axis {axis:?} is not one of: hardware, software, devices, dataforts")]
114    InvalidAxis {
115        /// Offending axis spelling.
116        axis: String,
117    },
118}
119
120// =============================================================================
121// Runtime parsers (used by the macros)
122// =============================================================================
123
124/// Parse the string passed to `require!` into a [`RequiredCapability`].
125/// Three shapes recognized, in priority order:
126///
127/// 1. `<key> >= <number>` / `<key> <= <number>` / `<key> == <value>`
128///    → [`RequiredCapability::Predicate`]
129/// 2. `<axis>.<key>=<value>` / `<axis>.<key>:<value>` (no spaces
130///    around the separator) → [`RequiredCapability::Tag`] holding
131///    a [`Tag::AxisValue`]
132/// 3. `<axis>.<key>` (no operator, no separator) →
133///    [`RequiredCapability::Tag`] holding a [`Tag::AxisPresent`]
134///
135/// User code shouldn't call this directly — the [`require!`] macro
136/// does. It's `pub(crate)` because the macro expands across crate
137/// boundaries to call into here.
138#[doc(hidden)]
139pub fn __require_parse(s: &str) -> Result<RequiredCapability, RequireParseError> {
140    let s = s.trim();
141    if s.is_empty() {
142        return Err(RequireParseError::Empty);
143    }
144
145    // 1a. Equality (==) is checked first so `>=` / `<=` substrings
146    //     inside the value half don't get claimed by the numeric
147    //     comparator branch below. Without this ordering,
148    //     `software.id == v>=1.0` (a perfectly valid equals against
149    //     a value that happens to contain `>=`) would split at the
150    //     `>=` instead, producing a nonsensical `numeric_at_least`
151    //     predicate or a `NumericParse` error.
152    if let Some((lhs, rhs)) = s.split_once("==") {
153        let lhs = lhs.trim();
154        let rhs = rhs.trim().trim_matches('"');
155        let key = parse_tag_key(lhs)?;
156        return Ok(RequiredCapability::Predicate(Predicate::equals(
157            key,
158            rhs.to_string(),
159        )));
160    }
161
162    // 1b. Numeric comparators (longer-first to avoid `=` matching `>=`).
163    for (op, build) in [
164        (
165            ">=",
166            (|key: TagKey, n: f64| Predicate::numeric_at_least(key, n))
167                as fn(TagKey, f64) -> Predicate,
168        ),
169        (
170            "<=",
171            (|key: TagKey, n: f64| Predicate::numeric_at_most(key, n))
172                as fn(TagKey, f64) -> Predicate,
173        ),
174    ] {
175        if let Some((lhs, rhs)) = s.split_once(op) {
176            let lhs = lhs.trim();
177            let rhs = rhs.trim();
178            let key = parse_tag_key(lhs)?;
179            let n: f64 = rhs.parse().map_err(|_| RequireParseError::NumericParse {
180                key: lhs.to_string(),
181                value: rhs.to_string(),
182            })?;
183            return Ok(RequiredCapability::Predicate(build(key, n)));
184        }
185    }
186
187    // 2 + 3. Plain tag (presence or value form).
188    let tag = Tag::parse_user(s)?;
189    Ok(RequiredCapability::Tag(tag))
190}
191
192/// Parse `"<axis>"` into a [`TaxonomyAxis`] for the
193/// [`require_axis!`] macro. Errors on unknown axis spelling.
194#[doc(hidden)]
195pub fn __require_axis_parse(s: &str) -> Result<TaxonomyAxis, RequireParseError> {
196    TaxonomyAxis::from_prefix(s.trim()).ok_or_else(|| RequireParseError::InvalidAxis {
197        axis: s.to_string(),
198    })
199}
200
201/// Parse `"<axis>"` + `"<key>"` into a [`TagKey`] for the
202/// [`require_axis_value!`] macro.
203#[doc(hidden)]
204pub fn __require_axis_value_parse(axis: &str, key: &str) -> Result<TagKey, RequireParseError> {
205    let axis =
206        TaxonomyAxis::from_prefix(axis.trim()).ok_or_else(|| RequireParseError::InvalidAxis {
207            axis: axis.to_string(),
208        })?;
209    let key = key.trim();
210    if key.is_empty() {
211        return Err(RequireParseError::InvalidKey { key: String::new() });
212    }
213    Ok(TagKey::new(axis, key))
214}
215
216/// Parse `"<axis>.<key>"` into a [`TagKey`]. Used by the comparison-
217/// operator branches of [`__require_parse`].
218///
219/// Both halves are trimmed: callers split at an operator and pass
220/// the lhs through here, so leading / trailing whitespace around
221/// the `.` is benign syntax noise. Without trimming, the
222/// constructed `TagKey` would carry the spaces in the key (e.g.
223/// `"hardware. gpu == nvidia"` → `TagKey::new(Hardware, " gpu")`),
224/// silently failing every match against the real `"gpu"` tags.
225fn parse_tag_key(s: &str) -> Result<TagKey, RequireParseError> {
226    let (axis_str, key) = s
227        .split_once('.')
228        .ok_or_else(|| RequireParseError::InvalidKey { key: s.to_string() })?;
229    let axis_str = axis_str.trim();
230    let key = key.trim();
231    let axis = TaxonomyAxis::from_prefix(axis_str)
232        .ok_or_else(|| RequireParseError::InvalidKey { key: s.to_string() })?;
233    if key.is_empty() {
234        return Err(RequireParseError::InvalidKey { key: s.to_string() });
235    }
236    Ok(TagKey::new(axis, key.to_string()))
237}
238
239// =============================================================================
240// Macros
241// =============================================================================
242
243/// `require!(<spec>)` — build a [`RequiredCapability`] from a
244/// string-literal spec. Panics at construction on malformed input
245/// (matches the substrate plan's "validates shapes at parse time"
246/// contract for the macro family).
247///
248/// ## Forms
249///
250/// ```ignore
251/// require!("hardware.gpu");                  // axis presence
252/// require!("software.daemon:postgres");      // axis value
253/// require!("hardware.gpu.vram_gb >= 24");    // numeric ≥
254/// require!("hardware.cpu_cores <= 64");      // numeric ≤
255/// require!("software.runtime == \"cuda-12.4\"");  // string equality
256/// ```
257///
258/// Reserved-prefix tags (`causal:`, `scope:`, etc.) are rejected —
259/// `require!("scope:prod")` panics with `CapabilityTagError::ReservedPrefix`.
260/// Use `require_axis_value!` if a reserved-prefix concept needs to
261/// land in an intent registry (it shouldn't — those prefixes are
262/// substrate-private).
263#[macro_export]
264macro_rules! require {
265    ($spec:literal) => {
266        $crate::adapter::net::behavior::required_capability::__require_parse($spec)
267            .unwrap_or_else(|e| panic!("require!({:?}) failed at parse time: {}", $spec, e))
268    };
269}
270
271/// `require_axis!(<axis>)` — build a [`RequiredCapability::AxisAny`]
272/// matching any tag in the named axis. Useful for "any device" /
273/// "any loaded model" intents where the application doesn't need a
274/// specific tag, just *something* in the axis.
275///
276/// ```ignore
277/// require_axis!("devices");   // any tag with axis = Devices
278/// require_axis!("software");  // any tag with axis = Software
279/// ```
280///
281/// Panics on unknown axis spelling.
282#[macro_export]
283macro_rules! require_axis {
284    ($axis:literal) => {
285        $crate::adapter::net::behavior::required_capability::RequiredCapability::AxisAny(
286            $crate::adapter::net::behavior::required_capability::__require_axis_parse($axis)
287                .unwrap_or_else(|e| panic!("require_axis!({:?}) failed: {}", $axis, e)),
288        )
289    };
290}
291
292/// `require_axis_value!(<axis>, <key>)` — build a
293/// [`RequiredCapability::AxisKey`] matching any tag with the given
294/// `(axis, key)` pair (presence OR value). Useful for "any version
295/// of this thing" intents — e.g. `require_axis_value!("software",
296/// "model")` matches `software.model`, `software.model:llama-7b`,
297/// or `software.model=mistral-large` interchangeably.
298///
299/// ```ignore
300/// require_axis_value!("software", "model");
301/// require_axis_value!("hardware", "gpu");
302/// ```
303///
304/// Panics on unknown axis spelling or empty key.
305#[macro_export]
306macro_rules! require_axis_value {
307    ($axis:literal, $key:literal) => {
308        $crate::adapter::net::behavior::required_capability::RequiredCapability::AxisKey(
309            $crate::adapter::net::behavior::required_capability::__require_axis_value_parse(
310                $axis, $key,
311            )
312            .unwrap_or_else(|e| {
313                panic!("require_axis_value!({:?}, {:?}) failed: {}", $axis, $key, e)
314            }),
315        )
316    };
317}
318
319// =============================================================================
320// Tests
321// =============================================================================
322
323#[cfg(test)]
324mod tests {
325    use std::collections::BTreeMap;
326
327    use super::*;
328    use crate::adapter::net::behavior::tag::{AxisSeparator, Tag, TaxonomyAxis};
329
330    fn axis_present(axis: TaxonomyAxis, key: &str) -> Tag {
331        Tag::AxisPresent {
332            axis,
333            key: key.into(),
334        }
335    }
336
337    fn axis_eq(axis: TaxonomyAxis, key: &str, value: &str) -> Tag {
338        Tag::AxisValue {
339            axis,
340            key: key.into(),
341            value: value.into(),
342            separator: AxisSeparator::Eq,
343        }
344    }
345
346    fn axis_colon(axis: TaxonomyAxis, key: &str, value: &str) -> Tag {
347        Tag::AxisValue {
348            axis,
349            key: key.into(),
350            value: value.into(),
351            separator: AxisSeparator::Colon,
352        }
353    }
354
355    fn meta() -> BTreeMap<String, String> {
356        BTreeMap::new()
357    }
358
359    // ---- require! parsing ---------------------------------------------------
360
361    #[test]
362    fn require_axis_presence() {
363        let r = require!("hardware.gpu");
364        assert_eq!(
365            r,
366            RequiredCapability::Tag(Tag::AxisPresent {
367                axis: TaxonomyAxis::Hardware,
368                key: "gpu".into(),
369            })
370        );
371    }
372
373    #[test]
374    fn require_axis_value_eq() {
375        let r = require!("hardware.gpu.vram_gb=80");
376        assert_eq!(
377            r,
378            RequiredCapability::Tag(Tag::AxisValue {
379                axis: TaxonomyAxis::Hardware,
380                key: "gpu.vram_gb".into(),
381                value: "80".into(),
382                separator: AxisSeparator::Eq,
383            })
384        );
385    }
386
387    #[test]
388    fn require_dataforts_pre_typed_colon() {
389        let r = require!("software.daemon:postgres");
390        match r {
391            RequiredCapability::Tag(Tag::AxisValue {
392                axis,
393                key,
394                value,
395                separator,
396            }) => {
397                assert_eq!(axis, TaxonomyAxis::Software);
398                assert_eq!(key, "daemon");
399                assert_eq!(value, "postgres");
400                assert_eq!(separator, AxisSeparator::Colon);
401            }
402            other => panic!("expected AxisValue with `:` separator, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn require_numeric_at_least() {
408        let r = require!("hardware.gpu.vram_gb >= 24");
409        match r {
410            RequiredCapability::Predicate(Predicate::NumericAtLeast { key, threshold }) => {
411                assert_eq!(key.axis, TaxonomyAxis::Hardware);
412                assert_eq!(key.key, "gpu.vram_gb");
413                assert!((threshold - 24.0).abs() < f64::EPSILON);
414            }
415            other => panic!("expected NumericAtLeast, got {other:?}"),
416        }
417    }
418
419    #[test]
420    fn require_numeric_at_most() {
421        let r = require!("hardware.cpu_cores <= 64");
422        match r {
423            RequiredCapability::Predicate(Predicate::NumericAtMost { key, threshold }) => {
424                assert_eq!(key.key, "cpu_cores");
425                assert!((threshold - 64.0).abs() < f64::EPSILON);
426            }
427            other => panic!("expected NumericAtMost, got {other:?}"),
428        }
429    }
430
431    #[test]
432    fn require_numeric_threshold_can_be_float() {
433        // Pinned: thresholds that aren't integers (e.g. RTT
434        // budgets in milliseconds) parse as f64.
435        let r = require!("hardware.cpu_cores >= 1.5");
436        match r {
437            RequiredCapability::Predicate(Predicate::NumericAtLeast { threshold, .. }) => {
438                assert!((threshold - 1.5).abs() < f64::EPSILON);
439            }
440            other => panic!("expected NumericAtLeast, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn require_string_equality() {
446        let r = require!("software.runtime == \"cuda-12.4\"");
447        match r {
448            RequiredCapability::Predicate(Predicate::Equals { key, value }) => {
449                assert_eq!(key.axis, TaxonomyAxis::Software);
450                assert_eq!(key.key, "runtime");
451                assert_eq!(value, "cuda-12.4");
452            }
453            other => panic!("expected Equals, got {other:?}"),
454        }
455    }
456
457    /// Regression: `==` is checked before `>=` / `<=`, so a value
458    /// half that legitimately contains `>=` (e.g. a semver-range
459    /// string `"v>=1.0"` used as an equality target) doesn't get
460    /// claimed by the numeric comparator branch and routed through
461    /// `parse_tag_key` / `f64::parse` with a nonsensical split.
462    #[test]
463    fn require_equality_value_containing_ge_is_not_claimed_by_numeric_branch() {
464        let r = require!("software.id == v>=1.0");
465        match r {
466            RequiredCapability::Predicate(Predicate::Equals { key, value }) => {
467                assert_eq!(key.axis, TaxonomyAxis::Software);
468                assert_eq!(key.key, "id");
469                assert_eq!(value, "v>=1.0");
470            }
471            other => panic!(
472                "expected Equals(software.id, v>=1.0), got {other:?} \
473                 — `==` should bind tighter than `>=`"
474            ),
475        }
476    }
477
478    /// Regression: `parse_tag_key` trims both halves of the split.
479    /// Pre-fix, `"hardware. gpu == nvidia"` produced
480    /// `TagKey::new(Hardware, " gpu")` (note the leading space) —
481    /// indistinguishable from `gpu` to a human, but every match
482    /// against real `Tag::AxisValue { key: "gpu", … }` failed
483    /// silently because `" gpu" != "gpu"`.
484    #[test]
485    fn require_parse_tag_key_trims_whitespace_around_dot() {
486        let r = require!("hardware. gpu == nvidia");
487        match r {
488            RequiredCapability::Predicate(Predicate::Equals { key, value }) => {
489                assert_eq!(key.axis, TaxonomyAxis::Hardware);
490                assert_eq!(key.key, "gpu", "key must not carry leading whitespace");
491                assert_eq!(value, "nvidia");
492            }
493            other => panic!("expected Equals(hardware.gpu, nvidia), got {other:?}"),
494        }
495
496        // Same for axis_str-side whitespace.
497        let r = require!(" hardware .gpu == nvidia");
498        match r {
499            RequiredCapability::Predicate(Predicate::Equals { key, value: _ }) => {
500                assert_eq!(key.axis, TaxonomyAxis::Hardware);
501                assert_eq!(key.key, "gpu");
502            }
503            other => panic!("expected Equals on hardware axis, got {other:?}"),
504        }
505    }
506
507    // ---- require_axis! ------------------------------------------------------
508
509    #[test]
510    fn require_axis_each_taxonomy() {
511        for axis in TaxonomyAxis::all() {
512            let r = match axis {
513                TaxonomyAxis::Hardware => require_axis!("hardware"),
514                TaxonomyAxis::Software => require_axis!("software"),
515                TaxonomyAxis::Devices => require_axis!("devices"),
516                TaxonomyAxis::Dataforts => require_axis!("dataforts"),
517            };
518            assert_eq!(r, RequiredCapability::AxisAny(axis));
519        }
520    }
521
522    // ---- require_axis_value! ------------------------------------------------
523
524    #[test]
525    fn require_axis_value_basic() {
526        let r = require_axis_value!("software", "model");
527        assert_eq!(
528            r,
529            RequiredCapability::AxisKey(TagKey::new(TaxonomyAxis::Software, "model"))
530        );
531    }
532
533    // ---- evaluation ---------------------------------------------------------
534
535    #[test]
536    fn tag_variant_matches_exact_tag() {
537        let tags = [axis_present(TaxonomyAxis::Hardware, "gpu")];
538        let m = meta();
539        let ctx = EvalContext::new(&tags, &m);
540        let r = require!("hardware.gpu");
541        assert!(r.evaluate(&ctx));
542        // Different key — no match.
543        let r = require!("hardware.tpu");
544        assert!(!r.evaluate(&ctx));
545    }
546
547    #[test]
548    fn tag_variant_value_matches_exactly() {
549        let tags = [axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "80")];
550        let m = meta();
551        let ctx = EvalContext::new(&tags, &m);
552        let r = require!("hardware.gpu.vram_gb=80");
553        assert!(r.evaluate(&ctx));
554        // Different value — no match (Tag variant is exact).
555        let r = require!("hardware.gpu.vram_gb=24");
556        assert!(!r.evaluate(&ctx));
557    }
558
559    #[test]
560    fn tag_variant_evaluates_across_separator_forms() {
561        // Regression for CR-2: `Tag::AxisValue` PartialEq distinguishes
562        // `=` vs `:`, but a `require!("software.os:linux")` placed
563        // against a node whose canonical tag is `software.os=linux`
564        // (or vice versa) must still match — the separator is a
565        // wire-form detail, not part of identity.
566        let m = meta();
567
568        // Stored colon, required equals — must match.
569        let tags = [axis_colon(TaxonomyAxis::Software, "os", "linux")];
570        let ctx = EvalContext::new(&tags, &m);
571        let r = require!("software.os=linux");
572        assert!(r.evaluate(&ctx));
573
574        // Stored equals, required colon — must match.
575        let tags = [axis_eq(TaxonomyAxis::Software, "os", "linux")];
576        let ctx = EvalContext::new(&tags, &m);
577        let r = require!("software.os:linux");
578        assert!(r.evaluate(&ctx));
579
580        // Different value still misses (sanity).
581        let r = require!("software.os:darwin");
582        assert!(!r.evaluate(&ctx));
583    }
584
585    #[test]
586    fn predicate_variant_evaluates_via_predicate() {
587        let tags = [axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "80")];
588        let m = meta();
589        let ctx = EvalContext::new(&tags, &m);
590        // Numeric ≥ 24 against value "80" → true.
591        let r = require!("hardware.gpu.vram_gb >= 24");
592        assert!(r.evaluate(&ctx));
593        let r = require!("hardware.gpu.vram_gb >= 96");
594        assert!(!r.evaluate(&ctx));
595    }
596
597    #[test]
598    fn axis_any_matches_any_tag_in_axis() {
599        let tags = [axis_present(TaxonomyAxis::Devices, "lidar")];
600        let m = meta();
601        let ctx = EvalContext::new(&tags, &m);
602        let r = require_axis!("devices");
603        assert!(r.evaluate(&ctx));
604        // No devices tag — no match.
605        let tags = [axis_present(TaxonomyAxis::Hardware, "gpu")];
606        let ctx = EvalContext::new(&tags, &m);
607        assert!(!r.evaluate(&ctx));
608    }
609
610    #[test]
611    fn axis_key_matches_presence_or_value() {
612        // Presence form matches.
613        let tags = [axis_present(TaxonomyAxis::Software, "model")];
614        let m = meta();
615        let ctx = EvalContext::new(&tags, &m);
616        let r = require_axis_value!("software", "model");
617        assert!(r.evaluate(&ctx));
618        // Value form matches.
619        let tags = [axis_colon(TaxonomyAxis::Software, "model", "llama-7b")];
620        let ctx = EvalContext::new(&tags, &m);
621        assert!(r.evaluate(&ctx));
622        // Different key — no match.
623        let tags = [axis_present(TaxonomyAxis::Software, "runtime")];
624        let ctx = EvalContext::new(&tags, &m);
625        assert!(!r.evaluate(&ctx));
626    }
627
628    // ---- error paths --------------------------------------------------------
629
630    #[test]
631    fn require_unknown_axis_falls_through_to_legacy_tag() {
632        // `bogus.foo` isn't one of the four known axes, so the
633        // parser falls through to `Tag::Legacy("bogus.foo")`. This
634        // is intentional — the deprecation window for untyped tags
635        // (Locked decision 1) keeps such forms parseable. Pin the
636        // behavior here so a future "reject legacy in require!"
637        // change is loud.
638        let r = __require_parse("bogus.foo").unwrap();
639        match r {
640            RequiredCapability::Tag(Tag::Legacy(s)) => assert_eq!(s, "bogus.foo"),
641            other => panic!("expected Tag(Legacy(...)), got {other:?}"),
642        }
643    }
644
645    #[test]
646    fn require_parses_unparseable_threshold_as_error() {
647        match __require_parse("hardware.cpu_cores >= many") {
648            Err(RequireParseError::NumericParse { key, value }) => {
649                assert_eq!(key, "hardware.cpu_cores");
650                assert_eq!(value, "many");
651            }
652            other => panic!("expected NumericParse error, got {other:?}"),
653        }
654    }
655
656    #[test]
657    fn require_rejects_reserved_prefix() {
658        match __require_parse("scope:prod") {
659            Err(RequireParseError::Tag(CapabilityTagError::ReservedPrefix { prefix, .. })) => {
660                assert_eq!(prefix, "scope:");
661            }
662            other => panic!("expected ReservedPrefix, got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn require_rejects_empty() {
668        match __require_parse("") {
669            Err(RequireParseError::Empty) => {}
670            other => panic!("expected Empty, got {other:?}"),
671        }
672        match __require_parse("   ") {
673            Err(RequireParseError::Empty) => {}
674            other => panic!("expected Empty, got {other:?}"),
675        }
676    }
677
678    #[test]
679    fn require_axis_rejects_unknown_axis() {
680        match __require_axis_parse("bogus") {
681            Err(RequireParseError::InvalidAxis { axis }) => {
682                assert_eq!(axis, "bogus");
683            }
684            other => panic!("expected InvalidAxis, got {other:?}"),
685        }
686    }
687
688    #[test]
689    fn require_axis_value_rejects_empty_key() {
690        match __require_axis_value_parse("software", "") {
691            Err(RequireParseError::InvalidKey { .. }) => {}
692            other => panic!("expected InvalidKey, got {other:?}"),
693        }
694    }
695
696    // ---- intent-registry-style usage (substrate plan §7 worked example) ----
697
698    #[test]
699    fn intent_registry_defaults_examples_compile_and_evaluate() {
700        // Mirror the substrate plan's IntentRegistry::defaults() entry
701        // for "ml-training": [hardware.gpu, hardware.gpu.vram_gb >= 24].
702        // A node with both tags satisfies; one tag alone does not.
703        let reqs = [
704            require!("hardware.gpu"),
705            require!("hardware.gpu.vram_gb >= 24"),
706        ];
707
708        // Both required tags present + adequate VRAM → all reqs match.
709        let tags = [
710            axis_present(TaxonomyAxis::Hardware, "gpu"),
711            axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "80"),
712        ];
713        let m = meta();
714        let ctx = EvalContext::new(&tags, &m);
715        assert!(reqs.iter().all(|r| r.evaluate(&ctx)));
716
717        // GPU present but VRAM only 16 → numeric req fails.
718        let tags = [
719            axis_present(TaxonomyAxis::Hardware, "gpu"),
720            axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "16"),
721        ];
722        let ctx = EvalContext::new(&tags, &m);
723        assert!(!reqs.iter().all(|r| r.evaluate(&ctx)));
724
725        // No GPU tag at all → both reqs fail.
726        let tags: Vec<Tag> = vec![];
727        let ctx = EvalContext::new(&tags, &m);
728        assert!(!reqs.iter().any(|r| r.evaluate(&ctx)));
729    }
730}