net/adapter/net/behavior/capability.rs
1//! Capability Announcements (CAP-ANN) for Phase 4A.
2//!
3//! This module provides:
4//! - `CapabilitySet` - Structured capability representation
5//! - `CapabilityAnnouncement` - Versioned capability broadcast
6//! - `CapabilityFilter` - Query capabilities by various criteria
7//! - `CardinalityProvider` - Trait used by the predicate planner
8//!
9//! The legacy `CapabilityIndex` in-memory store was removed in
10//! Phase 3B of the multifold migration. Membership + cardinality
11//! data now live on the `CapabilityFold` (see
12//! `behavior/fold/capability`); downstream callers go through
13//! `MeshNode`'s fold helpers or `capability_bridge`.
14
15use serde::{Deserialize, Serialize};
16use std::cell::OnceCell;
17use std::collections::{BTreeMap, HashSet};
18use std::hash::Hash;
19
20use crate::adapter::net::behavior::tag::Tag;
21
22/// Version-discriminator byte for the compact (postcard) wire format
23/// used by [`CapabilitySet::to_bytes_compact`] and
24/// [`CapabilityAnnouncement::to_bytes_compact`]. JSON serializations
25/// start with `b'{'` (`0x7B`); compact serializations start with this
26/// byte. Any value other than `b'{'` or this constant in the leading
27/// byte position causes `from_bytes` to return `None`.
28///
29/// The numeric value is fixed at the wire-format level — bumping it
30/// is a wire-protocol break.
31const COMPACT_FORMAT_TAG: u8 = 0x01;
32
33// ============================================================================
34// Hardware Capabilities
35// ============================================================================
36
37/// GPU vendor enumeration
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[repr(u8)]
40pub enum GpuVendor {
41 /// Unrecognized or unspecified GPU vendor.
42 #[default]
43 Unknown = 0,
44 /// NVIDIA Corporation.
45 Nvidia = 1,
46 /// Advanced Micro Devices (AMD).
47 Amd = 2,
48 /// Intel Corporation.
49 Intel = 3,
50 /// Apple Inc. (e.g., M-series integrated GPU).
51 Apple = 4,
52 /// Qualcomm (e.g., Adreno GPU).
53 Qualcomm = 5,
54}
55
56impl From<u8> for GpuVendor {
57 fn from(v: u8) -> Self {
58 match v {
59 1 => Self::Nvidia,
60 2 => Self::Amd,
61 3 => Self::Intel,
62 4 => Self::Apple,
63 5 => Self::Qualcomm,
64 _ => Self::Unknown,
65 }
66 }
67}
68
69/// GPU information
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct GpuInfo {
72 /// GPU vendor
73 pub vendor: GpuVendor,
74 /// Model name (e.g., "RTX 4090", "M2 Ultra")
75 pub model: String,
76 /// VRAM in GB
77 pub vram_gb: u32,
78 /// Compute units / SMs
79 pub compute_units: u16,
80 /// Tensor cores (0 if none)
81 pub tensor_cores: u16,
82 /// FP16 TFLOPS (scaled by 10, e.g., 825 = 82.5 TFLOPS).
83 ///
84 /// Widened from `u16` to `u32` because the old ceiling
85 /// (`u16::MAX / 10 ≈ 6.5 PFLOPS`) silently saturated on any
86 /// aggregated cluster figure worth reporting; individual GPUs
87 /// still fit in `u16` but operators roll these up per-node
88 /// and per-mesh.
89 pub fp16_tflops_x10: u32,
90}
91
92impl Default for GpuInfo {
93 fn default() -> Self {
94 Self {
95 vendor: GpuVendor::Unknown,
96 model: String::new(),
97 vram_gb: 0,
98 compute_units: 0,
99 tensor_cores: 0,
100 fp16_tflops_x10: 0,
101 }
102 }
103}
104
105impl GpuInfo {
106 /// Create new GPU info
107 pub fn new(vendor: GpuVendor, model: impl Into<String>, vram_gb: u32) -> Self {
108 Self {
109 vendor,
110 model: model.into(),
111 vram_gb,
112 ..Default::default()
113 }
114 }
115
116 /// Set compute units
117 pub fn with_compute_units(mut self, units: u16) -> Self {
118 self.compute_units = units;
119 self
120 }
121
122 /// Set tensor cores
123 pub fn with_tensor_cores(mut self, cores: u16) -> Self {
124 self.tensor_cores = cores;
125 self
126 }
127
128 /// Set FP16 performance.
129 ///
130 /// Clamped at `u32::MAX` to be explicit about the ceiling: a
131 /// pathological f32 (NaN, negative, > ~4.3e8 TFLOPS) saturates
132 /// rather than wrapping to a garbage value.
133 pub fn with_fp16_tflops(mut self, tflops: f32) -> Self {
134 let scaled = (tflops * 10.0).max(0.0);
135 self.fp16_tflops_x10 = if scaled.is_finite() && scaled < u32::MAX as f32 {
136 scaled as u32
137 } else {
138 u32::MAX
139 };
140 self
141 }
142}
143
144/// Accelerator type
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
146#[repr(u8)]
147pub enum AcceleratorType {
148 /// Unrecognized or unspecified accelerator type.
149 #[default]
150 Unknown = 0,
151 /// Tensor Processing Unit (e.g., Google TPU).
152 Tpu = 1,
153 /// Neural Processing Unit for on-device AI inference.
154 Npu = 2,
155 /// Field-Programmable Gate Array.
156 Fpga = 3,
157 /// Application-Specific Integrated Circuit.
158 Asic = 4,
159 /// Digital Signal Processor.
160 Dsp = 5,
161}
162
163impl From<u8> for AcceleratorType {
164 fn from(v: u8) -> Self {
165 match v {
166 1 => Self::Tpu,
167 2 => Self::Npu,
168 3 => Self::Fpga,
169 4 => Self::Asic,
170 5 => Self::Dsp,
171 _ => Self::Unknown,
172 }
173 }
174}
175
176/// Accelerator information (TPU, NPU, etc.)
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct AcceleratorInfo {
179 /// Accelerator type
180 pub accel_type: AcceleratorType,
181 /// Model/name
182 pub model: String,
183 /// Memory in GB (if applicable)
184 pub memory_gb: u32,
185 /// TOPS (tera operations per second, scaled by 10)
186 pub tops_x10: u16,
187}
188
189impl AcceleratorInfo {
190 /// Create new accelerator info
191 pub fn new(accel_type: AcceleratorType, model: impl Into<String>) -> Self {
192 Self {
193 accel_type,
194 model: model.into(),
195 memory_gb: 0,
196 tops_x10: 0,
197 }
198 }
199}
200
201/// Hardware capabilities
202#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
203pub struct HardwareCapabilities {
204 /// CPU cores
205 pub cpu_cores: u16,
206 /// CPU threads (if different from cores due to SMT)
207 pub cpu_threads: u16,
208 /// Total memory in GB
209 pub memory_gb: u32,
210 /// GPU info (if present)
211 pub gpu: Option<GpuInfo>,
212 /// Additional GPUs (for multi-GPU setups)
213 pub additional_gpus: Vec<GpuInfo>,
214 /// Storage in GB
215 pub storage_gb: u64,
216 /// Network bandwidth in Gbps
217 pub network_gbps: u32,
218 /// Accelerators (TPU, NPU, etc.)
219 pub accelerators: Vec<AcceleratorInfo>,
220}
221
222impl HardwareCapabilities {
223 /// Create new hardware capabilities
224 pub fn new() -> Self {
225 Self::default()
226 }
227
228 /// Set CPU cores
229 pub fn with_cpu(mut self, cores: u16, threads: u16) -> Self {
230 self.cpu_cores = cores;
231 self.cpu_threads = threads;
232 self
233 }
234
235 /// Set memory
236 pub fn with_memory(mut self, memory_gb: u32) -> Self {
237 self.memory_gb = memory_gb;
238 self
239 }
240
241 /// Set primary GPU
242 pub fn with_gpu(mut self, gpu: GpuInfo) -> Self {
243 self.gpu = Some(gpu);
244 self
245 }
246
247 /// Add additional GPU
248 pub fn add_gpu(mut self, gpu: GpuInfo) -> Self {
249 self.additional_gpus.push(gpu);
250 self
251 }
252
253 /// Set storage
254 pub fn with_storage(mut self, storage_gb: u64) -> Self {
255 self.storage_gb = storage_gb;
256 self
257 }
258
259 /// Set network bandwidth
260 pub fn with_network(mut self, network_gbps: u32) -> Self {
261 self.network_gbps = network_gbps;
262 self
263 }
264
265 /// Add accelerator
266 pub fn add_accelerator(mut self, accel: AcceleratorInfo) -> Self {
267 self.accelerators.push(accel);
268 self
269 }
270
271 /// Total GPU count
272 pub fn gpu_count(&self) -> usize {
273 self.gpu.as_ref().map(|_| 1).unwrap_or(0) + self.additional_gpus.len()
274 }
275
276 /// Total VRAM across all GPUs
277 pub fn total_vram_gb(&self) -> u32 {
278 let primary = self.gpu.as_ref().map(|g| g.vram_gb).unwrap_or(0);
279 let additional: u32 = self.additional_gpus.iter().map(|g| g.vram_gb).sum();
280 primary + additional
281 }
282
283 /// Check if has any GPU
284 pub fn has_gpu(&self) -> bool {
285 self.gpu.is_some()
286 }
287
288 /// Get primary GPU vendor
289 pub fn gpu_vendor(&self) -> Option<GpuVendor> {
290 self.gpu.as_ref().map(|g| g.vendor)
291 }
292}
293
294// ============================================================================
295// Software Capabilities
296// ============================================================================
297
298/// Software/runtime capabilities
299#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
300pub struct SoftwareCapabilities {
301 /// Operating system
302 pub os: String,
303 /// OS version
304 pub os_version: String,
305 /// Runtime versions (e.g., "python:3.11", "node:20")
306 pub runtimes: Vec<(String, String)>,
307 /// Installed frameworks (e.g., "pytorch:2.1", "tensorflow:2.15")
308 pub frameworks: Vec<(String, String)>,
309 /// CUDA version (if applicable)
310 pub cuda_version: Option<String>,
311 /// Driver versions
312 pub drivers: Vec<(String, String)>,
313}
314
315impl SoftwareCapabilities {
316 /// Create new software capabilities
317 pub fn new() -> Self {
318 Self::default()
319 }
320
321 /// Set OS
322 pub fn with_os(mut self, os: impl Into<String>, version: impl Into<String>) -> Self {
323 self.os = os.into();
324 self.os_version = version.into();
325 self
326 }
327
328 /// Add runtime
329 pub fn add_runtime(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
330 self.runtimes.push((name.into(), version.into()));
331 self
332 }
333
334 /// Add framework
335 pub fn add_framework(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
336 self.frameworks.push((name.into(), version.into()));
337 self
338 }
339
340 /// Set CUDA version
341 pub fn with_cuda(mut self, version: impl Into<String>) -> Self {
342 self.cuda_version = Some(version.into());
343 self
344 }
345
346 /// Check if has a specific runtime
347 pub fn has_runtime(&self, name: &str) -> bool {
348 self.runtimes.iter().any(|(n, _)| n == name)
349 }
350
351 /// Check if has a specific framework
352 pub fn has_framework(&self, name: &str) -> bool {
353 self.frameworks.iter().any(|(n, _)| n == name)
354 }
355}
356
357// ============================================================================
358// Model Capabilities
359// ============================================================================
360
361/// Modality support
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
363#[repr(u8)]
364pub enum Modality {
365 /// Plain text input/output.
366 Text = 0,
367 /// Static image understanding or generation.
368 Image = 1,
369 /// Audio understanding or synthesis.
370 Audio = 2,
371 /// Video understanding or generation.
372 Video = 3,
373 /// Source code generation or analysis.
374 Code = 4,
375 /// Vector embedding production.
376 Embedding = 5,
377 /// Structured tool/function calling.
378 ToolUse = 6,
379}
380
381impl From<u8> for Modality {
382 fn from(v: u8) -> Self {
383 match v {
384 0 => Self::Text,
385 1 => Self::Image,
386 2 => Self::Audio,
387 3 => Self::Video,
388 4 => Self::Code,
389 5 => Self::Embedding,
390 6 => Self::ToolUse,
391 _ => Self::Text,
392 }
393 }
394}
395
396/// Model capability
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct ModelCapability {
399 /// Unique model identifier (e.g., "llama-3.1-70b")
400 pub model_id: String,
401 /// Model family (e.g., "llama", "mistral", "claude")
402 pub family: String,
403 /// Parameter count (in billions, scaled by 10: 700 = 70B)
404 pub parameters_b_x10: u32,
405 /// Context length in tokens
406 pub context_length: u32,
407 /// Quantization (e.g., "fp16", "int8", "int4")
408 pub quantization: Option<String>,
409 /// Supported modalities
410 pub modalities: Vec<Modality>,
411 /// Estimated tokens per second (for this hardware)
412 pub tokens_per_sec: u32,
413 /// Whether model is currently loaded
414 pub loaded: bool,
415}
416
417impl ModelCapability {
418 /// Create new model capability
419 pub fn new(model_id: impl Into<String>, family: impl Into<String>) -> Self {
420 Self {
421 model_id: model_id.into(),
422 family: family.into(),
423 parameters_b_x10: 0,
424 context_length: 0,
425 quantization: None,
426 modalities: vec![Modality::Text],
427 tokens_per_sec: 0,
428 loaded: false,
429 }
430 }
431
432 /// Set parameter count in billions
433 pub fn with_parameters(mut self, billions: f32) -> Self {
434 self.parameters_b_x10 = (billions * 10.0) as u32;
435 self
436 }
437
438 /// Set context length
439 pub fn with_context_length(mut self, length: u32) -> Self {
440 self.context_length = length;
441 self
442 }
443
444 /// Set quantization
445 pub fn with_quantization(mut self, quant: impl Into<String>) -> Self {
446 self.quantization = Some(quant.into());
447 self
448 }
449
450 /// Add modality
451 pub fn add_modality(mut self, modality: Modality) -> Self {
452 if !self.modalities.contains(&modality) {
453 self.modalities.push(modality);
454 }
455 self
456 }
457
458 /// Set tokens per second
459 pub fn with_tokens_per_sec(mut self, tps: u32) -> Self {
460 self.tokens_per_sec = tps;
461 self
462 }
463
464 /// Set loaded status
465 pub fn with_loaded(mut self, loaded: bool) -> Self {
466 self.loaded = loaded;
467 self
468 }
469
470 /// Get parameter count as f32
471 pub fn parameters(&self) -> f32 {
472 self.parameters_b_x10 as f32 / 10.0
473 }
474}
475
476// ============================================================================
477// Tool Capabilities
478// ============================================================================
479
480/// Tool capability
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482pub struct ToolCapability {
483 /// Unique tool identifier
484 pub tool_id: String,
485 /// Human-readable name
486 pub name: String,
487 /// Version
488 pub version: String,
489 /// Input schema (JSON Schema as string)
490 pub input_schema: Option<String>,
491 /// Output schema (JSON Schema as string)
492 pub output_schema: Option<String>,
493 /// Required capabilities/dependencies
494 pub requires: Vec<String>,
495 /// Estimated execution time in ms (for typical input)
496 pub estimated_time_ms: u32,
497 /// Whether tool is stateless
498 pub stateless: bool,
499}
500
501impl ToolCapability {
502 /// Metadata key carrying this tool's input JSON Schema.
503 ///
504 /// Phase A.5.N convention: tool input/output schemas live in
505 /// `CapabilitySet::metadata` rather than the tag wire format
506 /// (JSON contains `=`/`:`/`,` which can't round-trip through
507 /// tags). Format: `tool::<tool_id>::input_schema`.
508 pub fn input_schema_metadata_key(tool_id: &str) -> String {
509 format!("tool::{tool_id}::input_schema")
510 }
511
512 /// Metadata key carrying this tool's output JSON Schema.
513 /// See [`Self::input_schema_metadata_key`].
514 pub fn output_schema_metadata_key(tool_id: &str) -> String {
515 format!("tool::{tool_id}::output_schema")
516 }
517
518 /// Create new tool capability
519 pub fn new(tool_id: impl Into<String>, name: impl Into<String>) -> Self {
520 Self {
521 tool_id: tool_id.into(),
522 name: name.into(),
523 version: "1.0.0".into(),
524 input_schema: None,
525 output_schema: None,
526 requires: Vec::new(),
527 estimated_time_ms: 0,
528 stateless: true,
529 }
530 }
531
532 /// Set version
533 pub fn with_version(mut self, version: impl Into<String>) -> Self {
534 self.version = version.into();
535 self
536 }
537
538 /// Set input schema
539 pub fn with_input_schema(mut self, schema: impl Into<String>) -> Self {
540 self.input_schema = Some(schema.into());
541 self
542 }
543
544 /// Set output schema
545 pub fn with_output_schema(mut self, schema: impl Into<String>) -> Self {
546 self.output_schema = Some(schema.into());
547 self
548 }
549
550 /// Add requirement
551 pub fn requires(mut self, dep: impl Into<String>) -> Self {
552 self.requires.push(dep.into());
553 self
554 }
555
556 /// Set estimated time
557 pub fn with_estimated_time(mut self, ms: u32) -> Self {
558 self.estimated_time_ms = ms;
559 self
560 }
561
562 /// Set stateless flag
563 pub fn with_stateless(mut self, stateless: bool) -> Self {
564 self.stateless = stateless;
565 self
566 }
567}
568
569// ============================================================================
570// Resource Limits
571// ============================================================================
572
573/// Resource limits
574#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
575pub struct ResourceLimits {
576 /// Maximum concurrent requests
577 pub max_concurrent_requests: u32,
578 /// Maximum tokens per request
579 pub max_tokens_per_request: u32,
580 /// Rate limit (requests per minute)
581 pub rate_limit_rpm: u32,
582 /// Maximum batch size
583 pub max_batch_size: u32,
584 /// Maximum input size in bytes
585 pub max_input_bytes: u32,
586 /// Maximum output size in bytes
587 pub max_output_bytes: u32,
588}
589
590impl ResourceLimits {
591 /// Create new resource limits
592 pub fn new() -> Self {
593 Self::default()
594 }
595
596 /// Set max concurrent requests
597 pub fn with_max_concurrent(mut self, max: u32) -> Self {
598 self.max_concurrent_requests = max;
599 self
600 }
601
602 /// Set max tokens per request
603 pub fn with_max_tokens(mut self, max: u32) -> Self {
604 self.max_tokens_per_request = max;
605 self
606 }
607
608 /// Set rate limit
609 pub fn with_rate_limit(mut self, rpm: u32) -> Self {
610 self.rate_limit_rpm = rpm;
611 self
612 }
613
614 /// Set max batch size
615 pub fn with_max_batch(mut self, max: u32) -> Self {
616 self.max_batch_size = max;
617 self
618 }
619}
620
621// ============================================================================
622// Capability Scope (reserved-tag discovery filter)
623// ============================================================================
624
625/// Reserved tag prefix marking a capability set as advertised under
626/// a specific tenant. Format: `scope:tenant:<id>`.
627pub const TAG_SCOPE_TENANT_PREFIX: &str = "scope:tenant:";
628
629/// Reserved tag prefix marking a capability set as advertised under
630/// a specific region. Format: `scope:region:<name>`.
631pub const TAG_SCOPE_REGION_PREFIX: &str = "scope:region:";
632
633/// Reserved tag marking a capability set as visible only to peers
634/// in the same subnet as the announcer. Mutually exclusive with
635/// tenant / region scopes — when present, the scope resolver
636/// returns `SubnetLocal` regardless of the other reserved tags
637/// (strictest scope wins).
638pub const TAG_SCOPE_SUBNET_LOCAL: &str = "scope:subnet-local";
639
640/// Optional explicit form of the default global scope. Carries no
641/// extra meaning over absence of any `scope:*` tag — included so
642/// callers can spell their intent.
643pub const TAG_SCOPE_GLOBAL: &str = "scope:global";
644
645/// Resolved scope of a capability announcement, derived from the
646/// reserved `scope:*` tags inside the announcer's [`CapabilitySet`].
647/// Pure derivation — never stored, recomputed on each query via
648/// `behavior::fold::capability_bridge::scope_from_membership_tags`.
649///
650/// Precedence: `SubnetLocal` > tenants/regions > `Global`. A node
651/// that tags itself with both `scope:subnet-local` and
652/// `scope:tenant:foo` resolves to `SubnetLocal` (strictest wins).
653#[derive(Debug, Clone, PartialEq, Eq)]
654pub(crate) enum CapabilityScope {
655 /// No `scope:*` tag, or `scope:global` only — visible to every
656 /// query that doesn't explicitly opt out (`GlobalOnly` /
657 /// `SameSubnet`).
658 Global,
659 /// `scope:subnet-local` present — visible only under
660 /// [`ScopeFilter::SameSubnet`]. Excluded from
661 /// [`ScopeFilter::Any`] and every other filter, because the
662 /// announcer has explicitly opted out of cross-subnet
663 /// discovery.
664 SubnetLocal,
665 /// One or more `scope:tenant:*` tags, no regions, no
666 /// subnet-local.
667 Tenants(Vec<String>),
668 /// One or more `scope:region:*` tags, no tenants, no
669 /// subnet-local.
670 Regions(Vec<String>),
671 /// Both tenants and regions present. Queries match if either
672 /// list satisfies the filter (logical OR — a tenant query and
673 /// a region query against the same node are independent
674 /// concerns).
675 TenantsAndRegions {
676 /// Tenant ids declared via `scope:tenant:*` tags.
677 tenants: Vec<String>,
678 /// Region names declared via `scope:region:*` tags.
679 regions: Vec<String>,
680 },
681}
682
683/// Parse `subnet:<hex32>` and `group:<hex64>` tags out of an
684/// announcement's tag set. Used at index time so the
685/// capability-auth `may_execute` gate can look up a peer's
686/// declared membership in O(1) without re-walking tags per call.
687///
688/// Multiple `subnet:` tags on one announcement are out of model:
689/// the substrate treats subnet membership as single-valued. To
690/// keep the gate verdict deterministic across receivers — a
691/// previous implementation read whichever subnet tag the
692/// `HashSet<Tag>` iterator surfaced first, which is hash-order
693/// dependent — multiple distinct subnet tags collapse to `None`
694/// and the announcement contributes no subnet membership. Single
695/// subnet tag works as expected. All distinct `group:` tags
696/// accumulate (deterministically sorted by byte value so receivers
697/// agree on iteration order); duplicates (Eq) are removed.
698///
699/// Kept (with `#[allow(dead_code)]`) for downstream consumers
700/// (capability_bridge translates the same shape onto the fold).
701/// The legacy `CapabilityIndex` caller was removed in Phase 3B
702/// of the multifold migration.
703#[allow(dead_code)]
704pub(crate) fn parse_membership_tags(
705 tags: &HashSet<Tag>,
706) -> (Option<super::subnet::SubnetId>, Vec<super::group::GroupId>) {
707 let mut subnet_candidates: Vec<super::subnet::SubnetId> = Vec::new();
708 let mut groups: Vec<super::group::GroupId> = Vec::new();
709 for tag in tags {
710 let rendered = tag.to_string();
711 if let Some(s) = super::subnet::SubnetId::from_tag(&rendered) {
712 if !subnet_candidates.contains(&s) {
713 subnet_candidates.push(s);
714 }
715 continue;
716 }
717 if let Some(g) = super::group::GroupId::from_tag(&rendered) {
718 if !groups.contains(&g) {
719 groups.push(g);
720 }
721 }
722 }
723 // Single distinct subnet → use it; zero or multiple → no
724 // subnet membership (multiple is out-of-model malformed and
725 // would otherwise pick a hash-order-dependent winner).
726 let subnet = if subnet_candidates.len() == 1 {
727 Some(subnet_candidates[0])
728 } else {
729 None
730 };
731 // Deterministic group order so receivers agree on iteration
732 // sequence regardless of local hash randomization. Lexicographic
733 // by byte value is stable and cheap on the 32-byte payload.
734 groups.sort_by_key(|g| g.0);
735 (subnet, groups)
736}
737
738/// Caller's intent for narrowing peer discovery by reserved scope
739/// tags. The legacy `CapabilityIndex::find_nodes_scoped` /
740/// `find_best_node_scoped` callers were rewired to the
741/// `CapabilityFold` in Phase 3B; this filter still parameterizes
742/// the scope-axis decision on the fold side.
743///
744/// `Any` reproduces v1 behavior for non-`SubnetLocal` peers but
745/// excludes peers that explicitly tagged themselves
746/// `scope:subnet-local` — that tag is an opt-out from cross-subnet
747/// discovery.
748#[derive(Debug, Clone)]
749pub enum ScopeFilter<'a> {
750 /// Match every peer regardless of scope, except those tagged
751 /// `scope:subnet-local` (which always require [`Self::SameSubnet`]).
752 Any,
753 /// Match only peers with no `scope:*` tag (resolve to
754 /// `Global`). Useful for opting out of all scoped peers.
755 GlobalOnly,
756 /// Match peers whose subnet equals the caller's. The actual
757 /// subnet comparison is supplied by the caller (typically by
758 /// closing over `MeshNode::peer_subnets`); the index doesn't
759 /// own subnet state.
760 SameSubnet,
761 /// Match peers tagged `scope:tenant:<t>` OR untagged
762 /// (`Global` is permissive across tenants by design).
763 Tenant(&'a str),
764 /// Match peers tagged `scope:tenant:<t>` for any `t` in the
765 /// list, OR untagged.
766 Tenants(&'a [&'a str]),
767 /// Match peers tagged `scope:region:<r>` OR untagged.
768 Region(&'a str),
769 /// Match peers tagged `scope:region:<r>` for any `r` in the
770 /// list, OR untagged.
771 Regions(&'a [&'a str]),
772}
773
774/// Predicate: does this candidate's resolved [`CapabilityScope`]
775/// satisfy the caller's [`ScopeFilter`]?
776///
777/// `same_subnet` is supplied by the caller and is consulted only
778/// when the filter is [`ScopeFilter::SameSubnet`] or the candidate
779/// is [`CapabilityScope::SubnetLocal`] (which always requires
780/// same-subnet membership). For the warm-up case where one
781/// side's subnet isn't known yet, callers default `same_subnet`
782/// to `true` (permissive).
783pub(crate) fn matches_scope(
784 candidate_scope: &CapabilityScope,
785 filter: &ScopeFilter<'_>,
786 same_subnet: bool,
787) -> bool {
788 use CapabilityScope as S;
789 use ScopeFilter as F;
790 match (filter, candidate_scope) {
791 // SubnetLocal is asymmetric: the announcer has explicitly
792 // opted out of cross-subnet discovery, so it shows up only
793 // under SameSubnet.
794 (F::SameSubnet, S::SubnetLocal) => same_subnet,
795 (_, S::SubnetLocal) => false,
796
797 // Any matches every non-SubnetLocal peer.
798 (F::Any, _) => true,
799
800 // GlobalOnly is the strict opposite of "include scoped peers."
801 (F::GlobalOnly, S::Global) => true,
802 (F::GlobalOnly, _) => false,
803
804 // SameSubnet for non-SubnetLocal candidates falls through to
805 // the caller-supplied predicate. Permissive when subnet is
806 // unknown for either side.
807 (F::SameSubnet, _) => same_subnet,
808
809 // Global candidates match every tenant/region query —
810 // permissive default, matches the v1 expectation that a
811 // node which doesn't tag itself stays discoverable.
812 (F::Tenant(_), S::Global)
813 | (F::Tenants(_), S::Global)
814 | (F::Region(_), S::Global)
815 | (F::Regions(_), S::Global) => true,
816
817 (F::Tenant(t), S::Tenants(ts))
818 | (F::Tenant(t), S::TenantsAndRegions { tenants: ts, .. }) => ts.iter().any(|x| x == t),
819 (F::Tenant(_), S::Regions(_)) => false,
820
821 (F::Tenants(wanted), S::Tenants(ts))
822 | (F::Tenants(wanted), S::TenantsAndRegions { tenants: ts, .. }) => {
823 ts.iter().any(|x| wanted.iter().any(|w| w == x))
824 }
825 (F::Tenants(_), S::Regions(_)) => false,
826
827 (F::Region(r), S::Regions(rs))
828 | (F::Region(r), S::TenantsAndRegions { regions: rs, .. }) => rs.iter().any(|x| x == r),
829 (F::Region(_), S::Tenants(_)) => false,
830
831 (F::Regions(wanted), S::Regions(rs))
832 | (F::Regions(wanted), S::TenantsAndRegions { regions: rs, .. }) => {
833 rs.iter().any(|x| wanted.iter().any(|w| w == x))
834 }
835 (F::Regions(_), S::Tenants(_)) => false,
836 }
837}
838
839// ============================================================================
840// Capability Set
841// ============================================================================
842
843/// Complete capability set for a node.
844///
845/// Phase A.5.N.3 final shape: a typed `tags: HashSet<Tag>` plus
846/// a `metadata: BTreeMap` for data that can't safely round-trip
847/// through the tag wire format. Hardware / Software / Model /
848/// Tool / ResourceLimits are *projections* of these two fields,
849/// computed on demand via `views()` / the `From<&CapabilitySet>`
850/// impls. Typed-struct fields no longer exist on the storage
851/// shape — every read goes through the projection layer; every
852/// write goes through the typed setters which re-encode into the
853/// canonical tag set.
854#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
855pub struct CapabilitySet {
856 /// Canonical typed tag set. Holds:
857 ///
858 /// - `Tag::AxisPresent` / `Tag::AxisValue` axis-prefixed tags
859 /// (`hardware.gpu`, `hardware.memory_gb=64`,
860 /// `software.model.0.id=llama-3.1-70b`, …) that encode the
861 /// five projections.
862 /// - `Tag::Reserved` cross-axis tags (`scope:tenant:foo`,
863 /// `causal:<hex>`, `fork-of:<hex>`, `heat:*`).
864 /// - `Tag::Legacy` untyped tags (free-form strings, e.g.
865 /// `nat:full-cone` / `nrpc:<service>`).
866 ///
867 /// Wire format emits tags in sorted `Tag::to_string()` order so
868 /// every serialization is canonical. The `HashSet` keeps O(1)
869 /// membership for in-memory lookups; the `serialize_with` hook
870 /// flattens to a sorted `Vec` on the way out. Two sides of a
871 /// signed-announcement round-trip therefore produce identical
872 /// bytes regardless of `HashSet` iteration order (which is
873 /// process-local random and would otherwise cause spurious
874 /// signature-verification failures across processes).
875 #[serde(default, serialize_with = "serialize_tags_sorted")]
876 pub tags: HashSet<Tag>,
877 /// Free-form key-value metadata.
878 ///
879 /// Phase A.5.N introduction. Carries data that doesn't fit the
880 /// typed-tag taxonomy:
881 ///
882 /// - **Tool schemas**: `tool::<tool_id>::input_schema` and
883 /// `tool::<tool_id>::output_schema` keys hold JSON Schema
884 /// strings (the `=`/`:`/`,` characters in JSON make these
885 /// unsafe to round-trip through the tag wire format).
886 /// - **Intent**: `intent` key carries the application-defined
887 /// placement intent (Phase F).
888 /// - **Colocation hints**: `colocate-with` key carries a chain
889 /// origin hash for chain-aware placement.
890 /// - Application-defined keys (subject to the metadata size cap
891 /// in Phase C: 4 KB soft / 16 KB hard).
892 ///
893 /// `BTreeMap` for deterministic iteration order over the wire.
894 #[serde(default)]
895 pub metadata: BTreeMap<String, String>,
896}
897
898impl CapabilitySet {
899 /// Create empty capability set
900 pub fn new() -> Self {
901 Self::default()
902 }
903
904 /// Set hardware capabilities
905 pub fn with_hardware(mut self, hardware: HardwareCapabilities) -> Self {
906 self.set_hardware(hardware);
907 self
908 }
909
910 /// Set software capabilities
911 pub fn with_software(mut self, software: SoftwareCapabilities) -> Self {
912 self.set_software(software);
913 self
914 }
915
916 /// Add model capability. Read-modify-write through `views()`
917 /// since models live in the canonical tag set as
918 /// `software.model.<i>.*` indexed-encoding.
919 pub fn add_model(mut self, model: ModelCapability) -> Self {
920 let mut models = self.views().models().clone();
921 models.push(model);
922 self.set_models(models);
923 self
924 }
925
926 /// Add tool capability. Read-modify-write through `views()`
927 /// since tools live in the canonical tag set as
928 /// `software.tool.<i>.*` indexed-encoding; schemas are mirrored
929 /// into `metadata` by `set_tools`.
930 ///
931 /// For adding more than one tool, prefer
932 /// [`Self::add_tools`] — the batch form invokes `set_tools`
933 /// exactly once instead of N times, dropping the announce-path
934 /// cost from O(N²) to O(N).
935 pub fn add_tool(mut self, tool: ToolCapability) -> Self {
936 let mut tools = self.views().tools().clone();
937 tools.push(tool);
938 self.set_tools(tools);
939 self
940 }
941
942 /// Batch counterpart to [`Self::add_tool`] — extends the current
943 /// tool list with every element of `tools` and invokes
944 /// `set_tools` exactly once. The single-`set_tools` call clears
945 /// stale tags + metadata once and re-encodes the final list, so
946 /// the cost is O(N) regardless of how many tools the iterator
947 /// yields.
948 ///
949 /// Use this from announce paths that drain a `tool_registry`
950 /// (which can hold many tools); the per-call `add_tool` rebuilds
951 /// every previously-added tool's tags + metadata, an O(N²)
952 /// pattern in the size of the registry.
953 pub fn add_tools(mut self, tools: impl IntoIterator<Item = ToolCapability>) -> Self {
954 let mut merged = self.views().tools().clone();
955 merged.extend(tools);
956 self.set_tools(merged);
957 self
958 }
959
960 /// Add a tag (parsed via the application-facing parser, which
961 /// rejects reserved cross-axis prefixes — use the dedicated
962 /// scope helpers for those). Untyped strings parse as
963 /// `Tag::Legacy`; axis-prefixed strings (`hardware.gpu`,
964 /// `software.os=linux`) parse as `AxisPresent` / `AxisValue`.
965 /// Empty tags and reserved-prefix tags are silently dropped
966 /// (the parser returns `Err` and we ignore it).
967 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
968 let s: String = tag.into();
969 if let Ok(t) = Tag::parse_user(&s) {
970 self.tags.insert(t);
971 }
972 self
973 }
974
975 /// Add a typed `BlobCapability` projection. Emits the matching
976 /// `dataforts.blob.*` tags via the projection's `write_into`.
977 /// Builder-style; producer-side counterpart to
978 /// `BlobCapability::from_capability_set`. Round-tripping
979 /// through both functions returns the original projection.
980 #[cfg(feature = "dataforts")]
981 pub fn with_blob_capability(self, blob: super::dataforts_capabilities::BlobCapability) -> Self {
982 blob.write_into(self)
983 }
984
985 /// Add a typed `GreedyCapability` projection. Emits
986 /// `dataforts.greedy.*` tags.
987 #[cfg(feature = "dataforts")]
988 pub fn with_greedy_capability(
989 self,
990 greedy: super::dataforts_capabilities::GreedyCapability,
991 ) -> Self {
992 greedy.write_into(self)
993 }
994
995 /// Add a typed `GravityCapability` projection. Emits
996 /// `dataforts.gravity.*` tags.
997 #[cfg(feature = "dataforts")]
998 pub fn with_gravity_capability(
999 self,
1000 gravity: super::dataforts_capabilities::GravityCapability,
1001 ) -> Self {
1002 gravity.write_into(self)
1003 }
1004
1005 /// Add a `scope:tenant:<id>` reserved tag, marking this
1006 /// announcement as advertised under the given tenant. Idempotent
1007 /// — repeated calls with the same id do not duplicate. Empty
1008 /// `tenant_id` is silently dropped (matches the scope resolver,
1009 /// which rejects empty ids).
1010 pub fn with_tenant_scope(mut self, tenant_id: impl Into<String>) -> Self {
1011 let id = tenant_id.into();
1012 if id.is_empty() {
1013 return self;
1014 }
1015 let tag = format!("{TAG_SCOPE_TENANT_PREFIX}{id}");
1016 if let Ok(t) = Tag::parse(&tag) {
1017 self.tags.insert(t);
1018 }
1019 self
1020 }
1021
1022 /// Add a `scope:region:<name>` reserved tag, marking this
1023 /// announcement as advertised under the given region.
1024 /// Idempotent. Empty `region` is silently dropped.
1025 pub fn with_region_scope(mut self, region: impl Into<String>) -> Self {
1026 let name = region.into();
1027 if name.is_empty() {
1028 return self;
1029 }
1030 let tag = format!("{TAG_SCOPE_REGION_PREFIX}{name}");
1031 if let Ok(t) = Tag::parse(&tag) {
1032 self.tags.insert(t);
1033 }
1034 self
1035 }
1036
1037 /// Add the `scope:subnet-local` reserved tag, opting this
1038 /// announcement out of cross-subnet discovery. The strictest
1039 /// scope wins: any tenant / region tags also present on this
1040 /// set are ignored by the scope resolver while
1041 /// `scope:subnet-local` is set. Idempotent.
1042 pub fn with_subnet_local_scope(mut self) -> Self {
1043 if let Ok(t) = Tag::parse(TAG_SCOPE_SUBNET_LOCAL) {
1044 self.tags.insert(t);
1045 }
1046 self
1047 }
1048
1049 // ========================================================================
1050 // Chain composition helpers — Phase 3 of CAPABILITY_ENHANCEMENTS_PLAN.md.
1051 //
1052 // Pure syntactic sugar over the underlying `causal:` / `fork-of:` /
1053 // `heat:` reserved-prefix tags documented in CAPABILITY_SYSTEM_PLAN.md
1054 // §2 + CAPABILITIES_SCHEMA.md "Reserved cross-axis prefixes".
1055 // Each helper is a single-line wrapper around `Tag::parse(...)` plus
1056 // `tags.insert(...)` — the substrate gains no new primitives, just
1057 // ergonomic emission paths so call sites read cleanly.
1058 //
1059 // Empty / blank chain hashes are silently dropped (matches the
1060 // scope-helper convention so a builder fed an empty value doesn't
1061 // produce a malformed tag).
1062 // ========================================================================
1063
1064 /// Declare this node holds the chain identified by `chain_hash`.
1065 ///
1066 /// Emits the `causal:<chain_hash>` reserved tag. Idempotent —
1067 /// repeated calls with the same hash do not duplicate.
1068 pub fn require_chain(mut self, chain_hash: impl AsRef<str>) -> Self {
1069 let hash = chain_hash.as_ref();
1070 if hash.is_empty() {
1071 return self;
1072 }
1073 if let Ok(t) = Tag::parse(&format!("causal:{hash}")) {
1074 self.tags.insert(t);
1075 }
1076 self
1077 }
1078
1079 /// Declare this node holds the chain `<chain_hash>` up to the
1080 /// named `tip_seq`.
1081 ///
1082 /// Emits `causal:<chain_hash>:<tip_seq>`. Per
1083 /// `CAPABILITY_SYSTEM_PLAN.md` §2: receivers downsample chains
1084 /// shorter than they need, so a peer announcing a tip_seq is
1085 /// implicitly also a holder for every prefix of that chain.
1086 pub fn require_chain_tip(mut self, chain_hash: impl AsRef<str>, tip_seq: u64) -> Self {
1087 let hash = chain_hash.as_ref();
1088 if hash.is_empty() {
1089 return self;
1090 }
1091 if let Ok(t) = Tag::parse(&format!("causal:{hash}:{tip_seq}")) {
1092 self.tags.insert(t);
1093 }
1094 self
1095 }
1096
1097 /// Declare this node holds the half-open range `[start_seq..end_seq)`
1098 /// of the chain `<chain_hash>`.
1099 ///
1100 /// Emits `causal:<chain_hash>[<start>..<end>]`. The validator
1101 /// enforces `start_seq < end_seq`; equal or inverted ranges are
1102 /// silently dropped.
1103 pub fn require_chain_range(
1104 mut self,
1105 chain_hash: impl AsRef<str>,
1106 start_seq: u64,
1107 end_seq: u64,
1108 ) -> Self {
1109 let hash = chain_hash.as_ref();
1110 if hash.is_empty() || start_seq >= end_seq {
1111 return self;
1112 }
1113 if let Ok(t) = Tag::parse(&format!("causal:{hash}[{start_seq}..{end_seq}]")) {
1114 self.tags.insert(t);
1115 }
1116 self
1117 }
1118
1119 /// Declare this node holds any of the named chains. One
1120 /// `causal:<hash>` reserved tag emitted per non-empty hash.
1121 /// Empty / blank hashes in the iterator are silently skipped.
1122 pub fn require_any_chain<I, S>(mut self, chain_hashes: I) -> Self
1123 where
1124 I: IntoIterator<Item = S>,
1125 S: AsRef<str>,
1126 {
1127 for hash in chain_hashes {
1128 self = self.require_chain(hash);
1129 }
1130 self
1131 }
1132
1133 /// Declare this chain forks from `parent_chain_hash`.
1134 ///
1135 /// Emits the `fork-of:<parent_chain_hash>` reserved tag, used
1136 /// by the chain-discovery layer for lineage walks.
1137 pub fn from_fork(mut self, parent_chain_hash: impl AsRef<str>) -> Self {
1138 let hash = parent_chain_hash.as_ref();
1139 if hash.is_empty() {
1140 return self;
1141 }
1142 if let Ok(t) = Tag::parse(&format!("fork-of:{hash}")) {
1143 self.tags.insert(t);
1144 }
1145 self
1146 }
1147
1148 /// Declare this node's heat (read-rate / activity score) for
1149 /// the named chain.
1150 ///
1151 /// `rate` is clamped to `[0.0, 1.0]` and emitted with two-decimal
1152 /// precision (`heat:<chain_hash>=0.85`). Heat is per-chain, not
1153 /// per-node; one call per chain.
1154 pub fn heat_level(mut self, chain_hash: impl AsRef<str>, rate: f64) -> Self {
1155 let hash = chain_hash.as_ref();
1156 if hash.is_empty() {
1157 return self;
1158 }
1159 let clamped = if rate.is_finite() {
1160 rate.clamp(0.0, 1.0)
1161 } else {
1162 return self;
1163 };
1164 if let Ok(t) = Tag::parse(&format!("heat:{hash}={clamped:.2}")) {
1165 self.tags.insert(t);
1166 }
1167 self
1168 }
1169
1170 /// Set resource limits
1171 pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
1172 self.set_limits(limits);
1173 self
1174 }
1175
1176 /// Set or overwrite a metadata key-value entry.
1177 ///
1178 /// CR-16: silently drops writes whose key matches a
1179 /// substrate-reserved *prefix* (`tool::`). Those keys are
1180 /// authored by the substrate's own codecs (the tool codec
1181 /// emits `tool::<id>::input_schema` etc.) and user code
1182 /// must not collide with them — same shape as `Tag::parse_user`
1183 /// rejecting reserved tag prefixes.
1184 ///
1185 /// Note: the schema's `metadata_reserved` *exact-match* list
1186 /// (`intent`, `colocate-with`, `priority`, `owner`) is
1187 /// intentionally NOT gated — those are well-known *user-facing*
1188 /// scheduler hints; the substrate reads them to make placement
1189 /// decisions, but user code is expected to *set* them. The
1190 /// validator (`validate_capabilities`) does flag user writes
1191 /// onto exact-match reserved keys as a `MetadataReservedKey`
1192 /// warning so misconfiguration is visible without being fatal.
1193 ///
1194 /// Substrate-internal callers that need to emit `tool::*` keys
1195 /// use the `with_metadata_unchecked` sibling (crate-private).
1196 pub fn with_metadata(self, key: impl Into<String>, value: impl Into<String>) -> Self {
1197 let key: String = key.into();
1198 if super::schema::AXIS_SCHEMA
1199 .metadata_reserved_prefixes
1200 .iter()
1201 .any(|p| key.starts_with(*p))
1202 {
1203 return self;
1204 }
1205 self.with_metadata_unchecked(key, value)
1206 }
1207
1208 /// Internal counterpart to [`Self::with_metadata`] that bypasses
1209 /// the reserved-prefix gate. Substrate-side code that authors
1210 /// reserved metadata (`tool::<id>::input_schema` from the tool
1211 /// codec) goes through this; user code MUST use the gated
1212 /// [`Self::with_metadata`].
1213 pub(crate) fn with_metadata_unchecked(
1214 mut self,
1215 key: impl Into<String>,
1216 value: impl Into<String>,
1217 ) -> Self {
1218 self.metadata.insert(key.into(), value.into());
1219 self
1220 }
1221
1222 // ========================================================================
1223 // Mutable setters — Phase A.5.6 write-path seam.
1224 //
1225 // These are the *only* places that should write to typed-struct
1226 // state on a `CapabilitySet`. The diff engine, the FFI layer
1227 // (when applying a remote update), and any other write path go
1228 // through these methods, so Phase A.5.N can rewrite the bodies
1229 // (e.g. to re-encode into a `tag_set: HashSet<Tag>`) without
1230 // touching call sites.
1231 //
1232 // Each setter takes ownership of the new value to make the
1233 // replacement obvious (no ambiguity about whether the caller
1234 // retains a partial view) and to give the eventual tag-set
1235 // reencoder a single owned input to consume.
1236 // ========================================================================
1237
1238 /// Replace the hardware projection in-place.
1239 ///
1240 /// Phase A.5.N.3: clears every `hardware.*` tag (excluding
1241 /// `hardware.limits.*` which belongs to `ResourceLimits`) and
1242 /// re-emits the new ones via `hardware_to_tags`.
1243 pub fn set_hardware(&mut self, hardware: HardwareCapabilities) {
1244 self.tags
1245 .retain(|t| !crate::adapter::net::behavior::tag_codec::is_hardware_owned_tag(t));
1246 self.tags
1247 .extend(crate::adapter::net::behavior::tag_codec::hardware_to_tags(
1248 &hardware,
1249 ));
1250 }
1251
1252 /// Replace the software projection in-place.
1253 ///
1254 /// Phase A.5.N.3: clears every `software.*` tag (excluding
1255 /// `software.model.*` and `software.tool.*` which belong to
1256 /// model/tool sub-collections) and re-emits the new ones.
1257 pub fn set_software(&mut self, software: SoftwareCapabilities) {
1258 self.tags
1259 .retain(|t| !crate::adapter::net::behavior::tag_codec::is_software_owned_tag(t));
1260 self.tags
1261 .extend(crate::adapter::net::behavior::tag_codec::software_to_tags(
1262 &software,
1263 ));
1264 }
1265
1266 /// Replace the resource-limits projection in-place.
1267 ///
1268 /// Phase A.5.N.3: clears every `hardware.limits.*` tag and
1269 /// re-emits the new ones.
1270 pub fn set_limits(&mut self, limits: ResourceLimits) {
1271 self.tags
1272 .retain(|t| !crate::adapter::net::behavior::tag_codec::is_resource_limits_owned_tag(t));
1273 self.tags
1274 .extend(crate::adapter::net::behavior::tag_codec::resource_limits_to_tags(&limits));
1275 }
1276
1277 /// Replace the loaded-model list in-place.
1278 ///
1279 /// Phase A.5.N.3: clears every `software.model.*` tag and
1280 /// re-emits the new indexed encoding via `models_to_tags`.
1281 pub fn set_models(&mut self, models: Vec<ModelCapability>) {
1282 self.tags
1283 .retain(|t| !crate::adapter::net::behavior::tag_codec::is_models_owned_tag(t));
1284 self.tags
1285 .extend(crate::adapter::net::behavior::tag_codec::models_to_tags(
1286 &models,
1287 ));
1288 }
1289
1290 /// Replace the available-tool list in-place.
1291 ///
1292 /// Phase A.5.N.3: clears every `software.tool.*` tag, prunes
1293 /// stale `tool::<id>::*_schema` metadata, re-emits the indexed
1294 /// tag encoding, and mirrors fresh schemas into metadata.
1295 pub fn set_tools(&mut self, tools: Vec<ToolCapability>) {
1296 // Clear tool tags from the canonical set.
1297 self.tags
1298 .retain(|t| !crate::adapter::net::behavior::tag_codec::is_tools_owned_tag(t));
1299
1300 // Drop schema metadata entries for tools no longer present.
1301 let new_ids: HashSet<&str> = tools.iter().map(|t| t.tool_id.as_str()).collect();
1302 self.metadata.retain(|key, _| {
1303 let Some(rest) = key.strip_prefix("tool::") else {
1304 return true;
1305 };
1306 let Some((id, _suffix)) = rest.split_once("::") else {
1307 return true;
1308 };
1309 new_ids.contains(id)
1310 });
1311
1312 // Re-emit the tag encoding (which intentionally drops
1313 // schemas — they ride in metadata).
1314 self.tags
1315 .extend(crate::adapter::net::behavior::tag_codec::tools_to_tags(
1316 &tools,
1317 ));
1318
1319 // Mirror fresh schemas into metadata.
1320 for tool in &tools {
1321 if let Some(schema) = &tool.input_schema {
1322 self.metadata.insert(
1323 ToolCapability::input_schema_metadata_key(&tool.tool_id),
1324 schema.clone(),
1325 );
1326 }
1327 if let Some(schema) = &tool.output_schema {
1328 self.metadata.insert(
1329 ToolCapability::output_schema_metadata_key(&tool.tool_id),
1330 schema.clone(),
1331 );
1332 }
1333 }
1334 }
1335
1336 /// Check if has a specific tag.
1337 ///
1338 /// The query string is parsed via the permissive parser
1339 /// ([`Tag::parse`]) so reserved-prefix queries (`scope:tenant:foo`)
1340 /// resolve correctly. Set membership is exact: a query for
1341 /// `hardware.gpu` matches the AxisPresent tag, not an
1342 /// AxisValue with a different value.
1343 pub fn has_tag(&self, tag: &str) -> bool {
1344 let Ok(parsed) = Tag::parse(tag) else {
1345 return false;
1346 };
1347 // Separator-agnostic membership: a stored `software.os=linux`
1348 // matches a query `software.os:linux` (and vice versa). Plain
1349 // `HashSet::contains` would distinguish them via PartialEq's
1350 // separator field — see CR-1 in
1351 // `CODE_REVIEW_2026_05_10_CAPABILITY_SYSTEM_2.md`.
1352 self.tags.iter().any(|t| t.semantic_eq(&parsed))
1353 }
1354
1355 /// Check if has a specific model.
1356 ///
1357 /// Phase A.5.N.3: scans for `software.model.<i>.id=<model_id>`
1358 /// directly in the canonical tag set rather than reconstructing
1359 /// the full `Vec<ModelCapability>` via `views()`.
1360 pub fn has_model(&self, model_id: &str) -> bool {
1361 self.has_indexed_software_value("model.", "id", model_id)
1362 }
1363
1364 /// Check if has a specific tool.
1365 ///
1366 /// Phase A.5.N.3: scans for `software.tool.<i>.tool_id=<tool_id>`
1367 /// directly in the canonical tag set.
1368 pub fn has_tool(&self, tool_id: &str) -> bool {
1369 self.has_indexed_software_value("tool.", "tool_id", tool_id)
1370 }
1371
1372 /// Shared scan body for `has_model` / `has_tool` — looks for a
1373 /// `software.<family_prefix><idx>.<sub_key>=<expected_value>` tag
1374 /// (e.g. `software.model.0.id=llama-3.1-7b`).
1375 ///
1376 /// Performance note: matches `Tag::AxisValue` directly to avoid
1377 /// `Tag::axis_key()`'s per-tag `String` clone. The value compare
1378 /// runs first because most tags in the set won't carry the target
1379 /// value — that lets the key parse (`strip_prefix` + `split_once`)
1380 /// run only on the small set of value-matching candidates. See
1381 /// `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #5.
1382 fn has_indexed_software_value(
1383 &self,
1384 family_prefix: &str,
1385 sub_key: &str,
1386 expected_value: &str,
1387 ) -> bool {
1388 use crate::adapter::net::behavior::tag::TaxonomyAxis;
1389 self.tags.iter().any(|tag| match tag {
1390 Tag::AxisValue {
1391 axis: TaxonomyAxis::Software,
1392 key,
1393 value,
1394 ..
1395 } if value == expected_value => {
1396 let Some(rest) = key.strip_prefix(family_prefix) else {
1397 return false;
1398 };
1399 let Some((_idx, sub)) = rest.split_once('.') else {
1400 return false;
1401 };
1402 sub == sub_key
1403 }
1404 _ => false,
1405 })
1406 }
1407
1408 /// Check if has GPU.
1409 ///
1410 /// Phase A.5.N.3: looks for the `hardware.gpu` AxisPresent
1411 /// marker directly. Cheaper than reconstructing the full
1412 /// `HardwareCapabilities` projection.
1413 pub fn has_gpu(&self) -> bool {
1414 use crate::adapter::net::behavior::tag::TaxonomyAxis;
1415 self.tags.contains(&Tag::AxisPresent {
1416 axis: TaxonomyAxis::Hardware,
1417 key: "gpu".into(),
1418 })
1419 }
1420
1421 /// First `AxisValue` tag matching `(axis, key)`, returning its
1422 /// value if present. Linear in `tags` count with early return.
1423 ///
1424 /// Phase A.5.N.3 fast-path helper for single-field predicates
1425 /// (`CapabilityFilter::matches` memory / VRAM checks). Avoids
1426 /// forcing the full `HardwareCapabilities` decode via
1427 /// `views().hardware()` when only one tag is needed. See
1428 /// `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #2.
1429 pub(crate) fn axis_value(
1430 &self,
1431 axis: crate::adapter::net::behavior::tag::TaxonomyAxis,
1432 key: &str,
1433 ) -> Option<&str> {
1434 self.tags.iter().find_map(|tag| match tag {
1435 Tag::AxisValue {
1436 axis: a,
1437 key: k,
1438 value,
1439 ..
1440 } if *a == axis && k == key => Some(value.as_str()),
1441 _ => None,
1442 })
1443 }
1444
1445 /// Get all model IDs.
1446 ///
1447 /// Phase A.5.N.3: returns owned `String`s (rather than borrowed
1448 /// `&str` over a typed-struct field that no longer exists).
1449 pub fn model_ids(&self) -> Vec<String> {
1450 self.views()
1451 .models()
1452 .iter()
1453 .map(|m| m.model_id.clone())
1454 .collect()
1455 }
1456
1457 /// Get all tool IDs.
1458 pub fn tool_ids(&self) -> Vec<String> {
1459 self.views()
1460 .tools()
1461 .iter()
1462 .map(|t| t.tool_id.clone())
1463 .collect()
1464 }
1465
1466 /// Serialize to bytes — JSON format, kept as the default for wire
1467 /// compatibility with peers running pre-postcard code. New callers
1468 /// that don't need to interop with old peers should prefer
1469 /// [`Self::to_bytes_compact`] (~10× faster, ~3× smaller).
1470 pub fn to_bytes(&self) -> Vec<u8> {
1471 serde_json::to_vec(self).unwrap_or_default()
1472 }
1473
1474 /// Serialize to bytes using the compact postcard wire format —
1475 /// a single leading `0x01` version byte followed by the postcard
1476 /// payload. [`Self::from_bytes`] reads either format via
1477 /// first-byte sniff, so receivers running this code accept both
1478 /// compact and JSON inputs.
1479 ///
1480 /// See `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #3 for
1481 /// the rollout staging — flipping `to_bytes` itself to compact is
1482 /// a separate, deliberate wire-format change.
1483 pub fn to_bytes_compact(&self) -> Vec<u8> {
1484 let out = vec![COMPACT_FORMAT_TAG];
1485 postcard::to_extend(self, out).unwrap_or_default()
1486 }
1487
1488 /// Deserialize from bytes. Accepts both the legacy JSON wire
1489 /// format (peers running pre-postcard code) and the compact
1490 /// postcard format (peers using [`Self::to_bytes_compact`]).
1491 /// Discriminates on the first byte: `b'{'` → JSON, `0x01` →
1492 /// postcard, anything else → `None`.
1493 pub fn from_bytes(data: &[u8]) -> Option<Self> {
1494 match data.first() {
1495 Some(&b'{') => serde_json::from_slice(data).ok(),
1496 Some(&COMPACT_FORMAT_TAG) => postcard::from_bytes(&data[1..]).ok(),
1497 _ => None,
1498 }
1499 }
1500
1501 /// Compute the structural change from `prev` to `self`.
1502 ///
1503 /// Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`: a cheap
1504 /// before/after change detector that returns the raw set/map
1505 /// difference — added tags, removed tags, and per-key
1506 /// metadata changes (Added / Removed / Updated). Powers
1507 /// event-driven placement updates, capability-aware dashboards,
1508 /// and delta-based metadata propagation.
1509 ///
1510 /// Cost: `O(|tags| + |metadata|)`. Two `HashSet::difference`
1511 /// scans + a `BTreeMap` walk; no allocation beyond the output
1512 /// collections.
1513 ///
1514 /// **Composes with [`crate::adapter::net::behavior::diff::DiffEngine`]**:
1515 /// `DiffEngine::diff` produces structural `DiffOp`s (used by
1516 /// the propagation path); this method returns the raw set/map
1517 /// diff (better for change-event consumers). Same input data;
1518 /// pick the surface that matches the consumer's shape.
1519 pub fn diff(&self, prev: &CapabilitySet) -> CapabilitySetDiff {
1520 // Tag diff: separator-agnostic. Plain `HashSet::difference`
1521 // would compare via `Tag::PartialEq`, which distinguishes
1522 // `=` vs `:` on `AxisValue` — two semantically-identical
1523 // tags would land as both Added and Removed. The structural
1524 // `DiffEngine::diff` was patched for this in 38612b61; this
1525 // companion API was not. See CR-3 in
1526 // `CODE_REVIEW_2026_05_10_CAPABILITY_SYSTEM_2.md`.
1527 let added_tags: HashSet<Tag> = self
1528 .tags
1529 .iter()
1530 .filter(|t| !prev.tags.iter().any(|p| p.semantic_eq(t)))
1531 .cloned()
1532 .collect();
1533 let removed_tags: HashSet<Tag> = prev
1534 .tags
1535 .iter()
1536 .filter(|t| !self.tags.iter().any(|c| c.semantic_eq(t)))
1537 .cloned()
1538 .collect();
1539
1540 // Metadata diff: walk both maps simultaneously. Both are
1541 // `BTreeMap` so we can rely on ordered iteration; merge
1542 // by key.
1543 let mut changed_metadata = Vec::new();
1544 let mut prev_iter = prev.metadata.iter().peekable();
1545 let mut curr_iter = self.metadata.iter().peekable();
1546 loop {
1547 match (prev_iter.peek(), curr_iter.peek()) {
1548 (Some((pk, pv)), Some((ck, cv))) => match pk.cmp(ck) {
1549 std::cmp::Ordering::Less => {
1550 changed_metadata.push(MetadataChange::Removed {
1551 key: (*pk).clone(),
1552 prev_value: (*pv).clone(),
1553 });
1554 prev_iter.next();
1555 }
1556 std::cmp::Ordering::Greater => {
1557 changed_metadata.push(MetadataChange::Added {
1558 key: (*ck).clone(),
1559 value: (*cv).clone(),
1560 });
1561 curr_iter.next();
1562 }
1563 std::cmp::Ordering::Equal => {
1564 if pv != cv {
1565 changed_metadata.push(MetadataChange::Updated {
1566 key: (*pk).clone(),
1567 prev_value: (*pv).clone(),
1568 new_value: (*cv).clone(),
1569 });
1570 }
1571 prev_iter.next();
1572 curr_iter.next();
1573 }
1574 },
1575 (Some((pk, pv)), None) => {
1576 changed_metadata.push(MetadataChange::Removed {
1577 key: (*pk).clone(),
1578 prev_value: (*pv).clone(),
1579 });
1580 prev_iter.next();
1581 }
1582 (None, Some((ck, cv))) => {
1583 changed_metadata.push(MetadataChange::Added {
1584 key: (*ck).clone(),
1585 value: (*cv).clone(),
1586 });
1587 curr_iter.next();
1588 }
1589 (None, None) => break,
1590 }
1591 }
1592
1593 CapabilitySetDiff {
1594 added_tags,
1595 removed_tags,
1596 changed_metadata,
1597 }
1598 }
1599
1600 // ========================================================================
1601 // View projections — Capability System Plan §1, Phase A.4.
1602 //
1603 // Today these are simple field clones because `CapabilitySet`
1604 // still carries the typed structs as fields. Phase A.5 removes
1605 // the typed-struct fields and migrates wire format to
1606 // `tags: HashSet<Tag>`; the same `From<&CapabilitySet>` impls
1607 // then reconstruct the typed view by scanning the tag set.
1608 //
1609 // Downstream code SHOULD adopt the projection accessors NOW
1610 // (`caps.views().hardware`, `HardwareCapabilities::from(&caps)`)
1611 // so the migration in A.5 doesn't ripple through every call
1612 // site. The legacy direct-field access (`caps.hardware`)
1613 // continues to work in this commit but is documented as
1614 // deprecated in `CAPABILITY_SYSTEM_PLAN.md` Locked decision 1.
1615 // ========================================================================
1616
1617 /// All five view projections rolled into one struct, computed
1618 /// once per call. Cheaper than calling each `From<&CapabilitySet>`
1619 /// individually when the consumer reads more than one of them.
1620 ///
1621 /// ```
1622 /// # use net::adapter::net::behavior::capability::CapabilitySet;
1623 /// let caps = CapabilitySet::new();
1624 /// let views = caps.views();
1625 /// let _ = views.hardware();
1626 /// let _ = views.software();
1627 /// let _ = views.resource_limits();
1628 /// let _ = views.models();
1629 /// let _ = views.tools();
1630 /// ```
1631 /// Borrowing handle exposing the five typed projections
1632 /// ([`HardwareCapabilities`], [`SoftwareCapabilities`],
1633 /// [`ResourceLimits`], `Vec<ModelCapability>`,
1634 /// `Vec<ToolCapability>`).
1635 ///
1636 /// Phase A.5.N.3 + Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`:
1637 /// each projection is decoded from the canonical tag set
1638 /// (+ metadata, for tool schemas) on first access and cached
1639 /// for the lifetime of the handle. Repeated reads of the same
1640 /// projection hit the cache; reads of unrelated projections
1641 /// don't force the full set of decoders.
1642 ///
1643 /// The handle borrows `self`. Mutations to `self` invalidate
1644 /// the handle (compiler-enforced through the lifetime).
1645 pub fn views(&self) -> CapabilityViews<'_> {
1646 CapabilityViews {
1647 caps: self,
1648 sorted_tags: OnceCell::new(),
1649 hardware: OnceCell::new(),
1650 software: OnceCell::new(),
1651 resource_limits: OnceCell::new(),
1652 models: OnceCell::new(),
1653 tools: OnceCell::new(),
1654 }
1655 }
1656
1657 // ========================================================================
1658 // Typed-tag-set access — Phase A.5.1 ergonomic accessors.
1659 //
1660 // These methods give downstream code the future access pattern
1661 // for capability data. Downstream code SHOULD adopt these now
1662 // so Phase A.5.2+ (when typed-struct fields are removed from
1663 // `CapabilitySet`) is invisible at the consumer level.
1664 //
1665 // Uses the bijection helpers from `behavior::tag_codec`. Today
1666 // computed on demand (no field change); Phase A.5.N introduces
1667 // internal `tag_set: HashSet<Tag>` storage as the source of truth
1668 // and removes the typed-struct fields. Either way, the surface
1669 // below stays stable.
1670 //
1671 // Migration path for downstream code:
1672 //
1673 // ```text
1674 // // Before (typed-struct field access):
1675 // if caps.hardware.gpu.is_some() { ... }
1676 // for tag in &caps.tags { ... }
1677 //
1678 // // After (read via the projection — canonical):
1679 // let views = caps.views();
1680 // if views.hardware().gpu.is_some() { ... }
1681 // for model in views.models() { ... }
1682 //
1683 // // Or directly through the `From` impl when only one field is needed:
1684 // if HardwareCapabilities::from(&caps).gpu.is_some() { ... }
1685 //
1686 // // Tags survive Phase A.5.N as a top-level field; iterate as before:
1687 // for tag in &caps.tags { ... }
1688 //
1689 // // Or read the typed-tag set (Phase A.5.1):
1690 // for tag in caps.typed_tags() { ... }
1691 //
1692 // // Writes go through the typed setters (Phase A.5.6):
1693 // caps.set_hardware(new_hw);
1694 // ```
1695 //
1696 // Application code that needs to compose with federated query
1697 // primitives (Phase E) will use `typed_tags()` to feed the
1698 // tag set into `Predicate::evaluate`'s `EvalContext`.
1699 // ========================================================================
1700
1701 /// All capability data as a typed-tag set, including the
1702 /// hardware / software / models / tools / limits structs
1703 /// re-encoded as axis-prefixed tags AND the legacy `tags`
1704 /// `Vec<String>` parsed via [`Tag::parse`]. The future wire
1705 /// format (Phase A.5.2+) is exactly this `HashSet<Tag>`.
1706 ///
1707 /// Round-trip-stable: `Self::from_typed_tags(&caps.typed_tags())`
1708 /// produces a `CapabilitySet` semantically equal to `caps`,
1709 /// modulo the documented order non-preservation for non-indexed
1710 /// `Vec` fields (runtimes / frameworks / drivers).
1711 ///
1712 /// Cost: linear in tag count. Currently computed on every
1713 /// call; downstream callers that read in a hot loop should
1714 /// cache the result.
1715 pub fn typed_tags(&self) -> std::collections::HashSet<crate::adapter::net::behavior::tag::Tag> {
1716 crate::adapter::net::behavior::tag_codec::capability_set_to_tag_set(self)
1717 }
1718
1719 /// Build a `CapabilitySet` from a typed-tag set. Inverse of
1720 /// [`Self::typed_tags`]; uses the per-struct decoders to
1721 /// reconstruct the typed fields plus a legacy-carrier scan for
1722 /// reserved-prefix tags + unknown axis tags.
1723 ///
1724 /// See [`Self::typed_tags`] for the round-trip contract.
1725 pub fn from_typed_tags(
1726 tags: &std::collections::HashSet<crate::adapter::net::behavior::tag::Tag>,
1727 ) -> Self {
1728 crate::adapter::net::behavior::tag_codec::capability_set_from_tag_set(tags)
1729 }
1730}
1731
1732/// Lazy borrowing handle exposing the five typed projections of a
1733/// [`CapabilitySet`].
1734///
1735/// Returned by [`CapabilitySet::views`]. Each projection is decoded
1736/// from the canonical tag set on first access and cached for the
1737/// lifetime of the handle:
1738///
1739/// ```ignore
1740/// let caps = CapabilitySet::default();
1741/// let v = caps.views();
1742/// let _ = v.hardware(); // first read: decodes hardware tags
1743/// let _ = v.hardware(); // cached; no re-decode
1744/// let _ = v.models(); // separate cache; decodes model tags
1745/// ```
1746///
1747/// Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`: callers that
1748/// previously read `views.hardware` (field) now call
1749/// `views.hardware()` (accessor). Hot-path post-cache cost is a
1750/// single pointer load (`OnceCell::get`); pre-cache cost is one
1751/// invocation of the underlying `*_from_tags` decoder.
1752#[derive(Debug)]
1753pub struct CapabilityViews<'a> {
1754 caps: &'a CapabilitySet,
1755 sorted_tags: OnceCell<Vec<Tag>>,
1756 hardware: OnceCell<HardwareCapabilities>,
1757 software: OnceCell<SoftwareCapabilities>,
1758 resource_limits: OnceCell<ResourceLimits>,
1759 models: OnceCell<Vec<ModelCapability>>,
1760 tools: OnceCell<Vec<ToolCapability>>,
1761}
1762
1763impl<'a> CapabilityViews<'a> {
1764 /// Sorted tag vector — shared scratch for the per-axis
1765 /// decoders. Sort stabilizes Vec-valued fields whose tag
1766 /// encoding is non-indexed (`software.runtimes` etc.) so
1767 /// repeated reads produce identical projections.
1768 fn sorted_tags(&self) -> &Vec<Tag> {
1769 self.sorted_tags
1770 .get_or_init(|| decoder_sorted_tag_vec(&self.caps.tags))
1771 }
1772
1773 /// Hardware projection. Decodes the `hardware.*` axis tags
1774 /// (excluding `hardware.limits.*`) on first call; subsequent
1775 /// calls return the cached projection.
1776 pub fn hardware(&self) -> &HardwareCapabilities {
1777 self.hardware.get_or_init(|| {
1778 crate::adapter::net::behavior::tag_codec::hardware_from_tags(self.sorted_tags())
1779 })
1780 }
1781
1782 /// Software projection. Decodes the `software.*` axis tags
1783 /// (excluding `software.model.*` and `software.tool.*`) on
1784 /// first call.
1785 pub fn software(&self) -> &SoftwareCapabilities {
1786 self.software.get_or_init(|| {
1787 crate::adapter::net::behavior::tag_codec::software_from_tags(self.sorted_tags())
1788 })
1789 }
1790
1791 /// Resource-limits projection. Decodes the `hardware.limits.*`
1792 /// tags on first call.
1793 pub fn resource_limits(&self) -> &ResourceLimits {
1794 self.resource_limits.get_or_init(|| {
1795 crate::adapter::net::behavior::tag_codec::resource_limits_from_tags(self.sorted_tags())
1796 })
1797 }
1798
1799 /// Loaded-model projection. Decodes the `software.model.<i>.*`
1800 /// indexed tags on first call.
1801 pub fn models(&self) -> &Vec<ModelCapability> {
1802 self.models.get_or_init(|| {
1803 crate::adapter::net::behavior::tag_codec::models_from_tags(self.sorted_tags())
1804 })
1805 }
1806
1807 /// Available-tool projection. Decodes the `software.tool.<i>.*`
1808 /// indexed tags on first call AND layers tool input/output JSON
1809 /// Schemas back from `caps.metadata` (key shape:
1810 /// `tool::<id>::input_schema` / `tool::<id>::output_schema`).
1811 pub fn tools(&self) -> &Vec<ToolCapability> {
1812 self.tools.get_or_init(|| {
1813 let mut tools =
1814 crate::adapter::net::behavior::tag_codec::tools_from_tags(self.sorted_tags());
1815 for tool in &mut tools {
1816 if let Some(s) = self
1817 .caps
1818 .metadata
1819 .get(&ToolCapability::input_schema_metadata_key(&tool.tool_id))
1820 {
1821 tool.input_schema = Some(s.clone());
1822 }
1823 if let Some(s) = self
1824 .caps
1825 .metadata
1826 .get(&ToolCapability::output_schema_metadata_key(&tool.tool_id))
1827 {
1828 tool.output_schema = Some(s.clone());
1829 }
1830 }
1831 tools
1832 })
1833 }
1834}
1835
1836// ============================================================================
1837// View projections — `From<&CapabilitySet>` for each typed struct.
1838//
1839// Phase A.5.N.3: each impl scans the canonical `tags: HashSet<Tag>`
1840// via the `tag_codec::*_from_tags` decoders. The typed-struct
1841// fields they previously cloned no longer exist; the tag set is
1842// the source of truth.
1843// ============================================================================
1844
1845/// Materialize the tag set as a sorted `Vec<Tag>` for the
1846/// per-struct decoders. Sort stabilizes Vec-valued fields
1847/// whose tag encoding is non-indexed (`software.runtimes` etc.)
1848/// so consecutive `views()` calls produce identical projections.
1849fn sorted_tag_vec(tags: &HashSet<Tag>) -> Vec<Tag> {
1850 let mut v: Vec<Tag> = tags.iter().cloned().collect();
1851 // `sort_by_cached_key` computes each `Tag::to_string()` exactly once
1852 // (N allocations) instead of `sort_by_key`'s re-evaluation on every
1853 // comparison (~N log N allocations). Output order is identical, so signed
1854 // announcement bytes stay stable. See
1855 // docs/misc/PERF_AUDIT_2026_06_08_BENCHMARK_WINS.md §3.
1856 v.sort_by_cached_key(|a| a.to_string());
1857 v
1858}
1859
1860/// Decoder-path sort: stabilizes tag order for the per-axis
1861/// projection decoders. Uses `Tag`'s derived `Ord` (no per-element
1862/// `String` allocation) — any total order works here as long as it
1863/// is deterministic. Wire serialization keeps `sorted_tag_vec`'s
1864/// `Tag::to_string()` order so signed-announcement bytes stay stable.
1865fn decoder_sorted_tag_vec(tags: &HashSet<Tag>) -> Vec<Tag> {
1866 let mut v: Vec<Tag> = tags.iter().cloned().collect();
1867 v.sort_unstable();
1868 v
1869}
1870
1871/// Serialize a `HashSet<Tag>` as a sequence of tags. For
1872/// human-readable formats (JSON) the sequence is sorted via
1873/// `Tag::to_string()` so a signed `CapabilityAnnouncement`
1874/// round-trips byte-for-byte regardless of process-local `HashSet`
1875/// iteration order — that byte stability is what makes signature
1876/// verification work across peers.
1877///
1878/// For non-human-readable formats (postcard via
1879/// [`CapabilitySet::to_bytes_compact`]) the sort is skipped:
1880/// `CapabilityAnnouncement` itself never takes the compact path
1881/// (its `#[serde(skip_serializing_if)]` fields don't survive
1882/// positional encoding), and bare `CapabilitySet` bytes aren't
1883/// signed — readers reconstruct the same `HashSet` regardless of
1884/// iteration order. Skipping the sort avoids ~N × `Tag::to_string()`
1885/// allocations on every compact serialize.
1886fn serialize_tags_sorted<S: serde::Serializer>(
1887 tags: &HashSet<Tag>,
1888 serializer: S,
1889) -> Result<S::Ok, S::Error> {
1890 use serde::ser::SerializeSeq;
1891 if serializer.is_human_readable() {
1892 let sorted = sorted_tag_vec(tags);
1893 let mut seq = serializer.serialize_seq(Some(sorted.len()))?;
1894 for t in &sorted {
1895 seq.serialize_element(t)?;
1896 }
1897 seq.end()
1898 } else {
1899 let mut seq = serializer.serialize_seq(Some(tags.len()))?;
1900 for t in tags {
1901 seq.serialize_element(t)?;
1902 }
1903 seq.end()
1904 }
1905}
1906
1907impl From<&CapabilitySet> for HardwareCapabilities {
1908 fn from(caps: &CapabilitySet) -> Self {
1909 crate::adapter::net::behavior::tag_codec::hardware_from_tags(&decoder_sorted_tag_vec(
1910 &caps.tags,
1911 ))
1912 }
1913}
1914
1915impl From<&CapabilitySet> for SoftwareCapabilities {
1916 fn from(caps: &CapabilitySet) -> Self {
1917 crate::adapter::net::behavior::tag_codec::software_from_tags(&decoder_sorted_tag_vec(
1918 &caps.tags,
1919 ))
1920 }
1921}
1922
1923impl From<&CapabilitySet> for ResourceLimits {
1924 fn from(caps: &CapabilitySet) -> Self {
1925 crate::adapter::net::behavior::tag_codec::resource_limits_from_tags(
1926 &decoder_sorted_tag_vec(&caps.tags),
1927 )
1928 }
1929}
1930
1931// ============================================================================
1932// CapabilitySet diff (Phase 1 of CAPABILITY_ENHANCEMENTS_PLAN.md)
1933// ============================================================================
1934
1935/// Structural difference between two [`CapabilitySet`] values.
1936///
1937/// Returned by [`CapabilitySet::diff`]. Carries:
1938///
1939/// - `added_tags`: tags in `self` that aren't in `prev`.
1940/// - `removed_tags`: tags in `prev` that aren't in `self`.
1941/// - `changed_metadata`: per-key metadata changes (Added /
1942/// Removed / Updated). Key renames surface as Removed + Added,
1943/// not as Updated, since the key identity changed.
1944///
1945/// The diff is the input shape for event-driven placement
1946/// updates, capability-change dashboards, and delta-based
1947/// metadata propagation. For the structural ops shape consumed
1948/// by the propagation path, use
1949/// [`crate::adapter::net::behavior::diff::DiffEngine::diff`].
1950#[derive(Debug, Clone, PartialEq)]
1951pub struct CapabilitySetDiff {
1952 /// Tags newly present in `self`.
1953 pub added_tags: HashSet<Tag>,
1954 /// Tags that were in `prev` but are no longer in `self`.
1955 pub removed_tags: HashSet<Tag>,
1956 /// Per-key metadata changes, in key order.
1957 pub changed_metadata: Vec<MetadataChange>,
1958}
1959
1960impl CapabilitySetDiff {
1961 /// True if no tags or metadata entries differ.
1962 pub fn is_empty(&self) -> bool {
1963 self.added_tags.is_empty()
1964 && self.removed_tags.is_empty()
1965 && self.changed_metadata.is_empty()
1966 }
1967}
1968
1969/// One metadata-key change between two [`CapabilitySet`]s.
1970///
1971/// Renamed keys surface as `Removed { old_key } + Added { new_key }`,
1972/// not `Updated`, because key identity changes are semantically
1973/// distinct from value changes.
1974#[derive(Debug, Clone, PartialEq)]
1975pub enum MetadataChange {
1976 /// Key was not present in `prev`; now has `value`.
1977 Added {
1978 /// Metadata key.
1979 key: String,
1980 /// New value.
1981 value: String,
1982 },
1983 /// Key was present in `prev` with `prev_value`; no longer in `self`.
1984 Removed {
1985 /// Metadata key.
1986 key: String,
1987 /// Value held in the previous state.
1988 prev_value: String,
1989 },
1990 /// Key present in both; value changed.
1991 Updated {
1992 /// Metadata key.
1993 key: String,
1994 /// Value held in the previous state.
1995 prev_value: String,
1996 /// New value.
1997 new_value: String,
1998 },
1999}
2000
2001// ============================================================================
2002// Capability Announcement
2003// ============================================================================
2004
2005/// Capability announcement message
2006#[derive(Debug, Clone, Serialize, Deserialize)]
2007pub struct CapabilityAnnouncement {
2008 /// Announcing node ID
2009 pub node_id: u64,
2010 /// Announcing entity — the 32-byte ed25519 public key. Pairs
2011 /// with `signature` so receivers can verify end-to-end, and
2012 /// lets the mesh's channel-auth path resolve
2013 /// `node_id → EntityId` for token lookups.
2014 pub entity_id: super::super::identity::EntityId,
2015 /// Monotonic version (for diffing)
2016 pub version: u64,
2017 /// Timestamp of announcement (nanoseconds since epoch)
2018 pub timestamp_ns: u64,
2019 /// TTL for this announcement in seconds
2020 pub ttl_secs: u32,
2021 /// Capability set
2022 pub capabilities: CapabilitySet,
2023 /// Optional Ed25519 signature (64 bytes, hex encoded for serde).
2024 /// Covers every other field EXCEPT [`Self::hop_count`] — the
2025 /// internal signing helper zeros `hop_count` before serializing
2026 /// and hashing, so forwarders can increment it without
2027 /// invalidating this signature. See [`Self::sign`] /
2028 /// [`Self::verify`] for the public API; the zeroing is an
2029 /// implementation detail of both.
2030 #[serde(default, skip_serializing_if = "Option::is_none")]
2031 pub signature: Option<Signature64>,
2032 /// Number of times this announcement has been forwarded. Origin
2033 /// sets 0; each forwarder increments before re-broadcasting.
2034 /// Sits *outside* the signed envelope so forwarders don't need
2035 /// the origin's secret key. Capped at `MAX_CAPABILITY_HOPS` —
2036 /// announcements at or beyond the cap are dropped rather than
2037 /// re-broadcast. Old-format announcements missing this field
2038 /// deserialize as 0 via `#[serde(default)]`.
2039 ///
2040 /// `skip_serializing_if` omits the field when it's zero so the
2041 /// SIGNED byte form stays identical to pre-M-1 announcements —
2042 /// a pre-M-1 node's signature verifies on a post-M-1 node
2043 /// during a rolling upgrade because both produce the same
2044 /// canonical bytes for the origin (hop_count=0). Forwarded
2045 /// announcements (hop_count > 0) serialize the field; receivers
2046 /// still zero it in `signed_payload()` so verification hits the
2047 /// omitted-when-zero form.
2048 #[serde(default, skip_serializing_if = "is_hop_count_zero")]
2049 pub hop_count: u8,
2050 /// Observer-visible reflexive `SocketAddr` as seen by this
2051 /// node's anchor peers during NAT classification. Populated
2052 /// once the `ClassifyFsm` (under the `nat-traversal` feature,
2053 /// in `adapter/net/traversal/classify.rs`) has ≥ 2 probe
2054 /// results; stays `None` on nodes that haven't classified
2055 /// yet, ran with `nat-traversal` disabled, or landed in the
2056 /// `Unknown` bucket (different peers disagree on our port
2057 /// so no single address is truthful).
2058 ///
2059 /// **Peer usage.** Receivers cache this alongside the
2060 /// `nat:*` tag and use it as the initial rendezvous target
2061 /// for hole punching — one fewer reflex round-trip per
2062 /// first-contact punch. The field is advisory: the punch
2063 /// step still waits for a real keep-alive exchange on the
2064 /// advertised address before handing off to the Noise
2065 /// handshake, so a lying peer can only fail its own
2066 /// incoming punches, not redirect traffic to a third party
2067 /// (see `docs/NAT_TRAVERSAL_PLAN.md` §7 for the trust model).
2068 ///
2069 /// **Wire compat.** `skip_serializing_if` keeps the old
2070 /// on-wire shape when the field is `None`, so pre-stage-2
2071 /// nodes round-trip through a post-stage-2 deserializer
2072 /// without breaking signatures. A post-stage-2 node
2073 /// deserializing a pre-stage-2 announcement sees the field
2074 /// default to `None` via `#[serde(default)]`.
2075 #[serde(default, skip_serializing_if = "Option::is_none")]
2076 pub reflex_addr: Option<std::net::SocketAddr>,
2077 /// v0.4 capability-auth allow-list — explicit `NodeId`s that
2078 /// may invoke any capability listed in `capabilities`. Empty
2079 /// vec = permissive default (anyone may invoke, subject to
2080 /// the other two lists). See `CAPABILITY_AUTH_PLAN.md`.
2081 ///
2082 /// Capped at [`MAX_ALLOW_LIST_LEN`] (64) per axis — past that,
2083 /// operators use a [`super::group::GroupId`] instead.
2084 ///
2085 /// `skip_serializing_if` preserves byte-identity with pre-v0.4
2086 /// announcements: an unrestricted (empty) list serializes to
2087 /// nothing, so an existing signature verifies on a v0.4 reader
2088 /// and a v0.4 signature verifies on a pre-v0.4 reader (which
2089 /// defaults the field to empty via `#[serde(default)]`).
2090 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2091 pub allowed_nodes: Vec<u64>,
2092 /// v0.4 capability-auth allow-list — [`super::subnet::SubnetId`]s
2093 /// whose members may invoke. Empty = permissive default.
2094 /// Receivers determine a caller's subnet via the `subnet:<hex>`
2095 /// tag on the caller's own announcement (self-declared, signed,
2096 /// TOFU-bound). Same wire-compat treatment as `allowed_nodes`.
2097 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2098 pub allowed_subnets: Vec<super::subnet::SubnetId>,
2099 /// v0.4 capability-auth allow-list — [`super::group::GroupId`]s
2100 /// whose claimants may invoke. Empty = permissive default.
2101 /// Group membership is self-declared via `group:<hex>` tags on
2102 /// the caller's own announcement. Same wire-compat treatment
2103 /// as `allowed_nodes`.
2104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2105 pub allowed_groups: Vec<super::group::GroupId>,
2106}
2107
2108/// Cap on any single allow-list axis on a
2109/// [`CapabilityAnnouncement`]. 64 entries keeps the announcement
2110/// under the wire-size ceiling and matches the operator guidance
2111/// "lists > 64 use a group, not inline node enumeration."
2112pub const MAX_ALLOW_LIST_LEN: usize = 64;
2113
2114/// Serde predicate: skip serializing `hop_count` when it's zero.
2115/// Preserves on-wire byte-compat with pre-M-1 announcements that
2116/// didn't carry this field at all. See
2117/// [`CapabilityAnnouncement::hop_count`] for the rationale.
2118fn is_hop_count_zero(v: &u8) -> bool {
2119 *v == 0
2120}
2121
2122/// Hard cap on `CapabilityAnnouncement::hop_count`. Mirrors the
2123/// pingwave `MAX_HOPS` so both multi-hop broadcast paths share the
2124/// same forwarding-depth contract.
2125pub const MAX_CAPABILITY_HOPS: u8 = 16;
2126
2127/// 64-byte signature wrapper with serde support
2128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2129pub struct Signature64(pub [u8; 64]);
2130
2131impl Serialize for Signature64 {
2132 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2133 where
2134 S: serde::Serializer,
2135 {
2136 // Serialize as hex string for JSON compatibility
2137 if serializer.is_human_readable() {
2138 let hex = hex::encode(self.0);
2139 serializer.serialize_str(&hex)
2140 } else {
2141 serializer.serialize_bytes(&self.0)
2142 }
2143 }
2144}
2145
2146impl<'de> Deserialize<'de> for Signature64 {
2147 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2148 where
2149 D: serde::Deserializer<'de>,
2150 {
2151 if deserializer.is_human_readable() {
2152 let hex_str = String::deserialize(deserializer)?;
2153 let bytes = hex::decode(&hex_str).map_err(serde::de::Error::custom)?;
2154 if bytes.len() != 64 {
2155 return Err(serde::de::Error::custom("signature must be 64 bytes"));
2156 }
2157 let mut arr = [0u8; 64];
2158 arr.copy_from_slice(&bytes);
2159 Ok(Signature64(arr))
2160 } else {
2161 let bytes = <Vec<u8>>::deserialize(deserializer)?;
2162 if bytes.len() != 64 {
2163 return Err(serde::de::Error::custom("signature must be 64 bytes"));
2164 }
2165 let mut arr = [0u8; 64];
2166 arr.copy_from_slice(&bytes);
2167 Ok(Signature64(arr))
2168 }
2169 }
2170}
2171
2172impl CapabilityAnnouncement {
2173 /// Default `ttl_secs` value assigned by [`Self::new`]. Five
2174 /// minutes — long enough that a missed re-announcement on one
2175 /// node doesn't immediately evict it from every peer's
2176 /// capability fold, short enough that stale state clears on
2177 /// realistic operational timescales. Exposed as a constant so
2178 /// multi-hop dedup retention can be scaled off it.
2179 pub const DEFAULT_TTL_SECS: u32 = 300;
2180
2181 /// Create a new unsigned announcement. Receivers that run with
2182 /// `require_signed_capabilities = true` will drop it until
2183 /// [`Self::sign`] is called.
2184 pub fn new(
2185 node_id: u64,
2186 entity_id: super::super::identity::EntityId,
2187 version: u64,
2188 capabilities: CapabilitySet,
2189 ) -> Self {
2190 use std::time::{SystemTime, UNIX_EPOCH};
2191 let timestamp_ns = SystemTime::now()
2192 .duration_since(UNIX_EPOCH)
2193 .map(|d| d.as_nanos() as u64)
2194 .unwrap_or(0);
2195
2196 Self {
2197 node_id,
2198 entity_id,
2199 version,
2200 timestamp_ns,
2201 ttl_secs: Self::DEFAULT_TTL_SECS,
2202 capabilities,
2203 signature: None,
2204 hop_count: 0,
2205 reflex_addr: None,
2206 allowed_nodes: Vec::new(),
2207 allowed_subnets: Vec::new(),
2208 allowed_groups: Vec::new(),
2209 }
2210 }
2211
2212 /// Set TTL
2213 pub fn with_ttl(mut self, ttl_secs: u32) -> Self {
2214 self.ttl_secs = ttl_secs;
2215 self
2216 }
2217
2218 /// Attach the classifier's observed reflex address. Typically
2219 /// called by the mesh's capability-broadcast path after NAT
2220 /// classification has completed at least two probes. Pass
2221 /// `None` to clear a previously-set address — e.g. if
2222 /// reclassification landed in `Unknown`.
2223 ///
2224 /// Included in the signed envelope: a post-signing change
2225 /// invalidates verification.
2226 pub fn with_reflex_addr(mut self, reflex: Option<std::net::SocketAddr>) -> Self {
2227 self.reflex_addr = reflex;
2228 self
2229 }
2230
2231 /// Set signature
2232 pub fn with_signature(mut self, sig: [u8; 64]) -> Self {
2233 self.signature = Some(Signature64(sig));
2234 self
2235 }
2236
2237 /// Serialize the sign/verify payload: same bytes on both sides
2238 /// of the signature round-trip, with `signature` cleared AND
2239 /// `hop_count` zeroed. Keeping `hop_count` out of the signed
2240 /// envelope is what lets downstream forwarders bump it without
2241 /// invalidating the origin's signature — standard multi-hop
2242 /// gossip design (libp2p gossipsub, Chord, etc.).
2243 ///
2244 /// Pre-fix this called `to_bytes()` (= `unwrap_or_default`) on
2245 /// the canonical clone. A `serde_json::to_vec` failure produced
2246 /// an empty `Vec` that signer + verifier both observed as the
2247 /// same constant transcript, defeating the signature for every
2248 /// affected announcement and making a single captured signature
2249 /// replay across every other failing call. The failure mode is
2250 /// unreachable today (none of the `CapabilityAnnouncement`
2251 /// fields have a fallible `Serialize`), but propagating the
2252 /// error explicitly with a panic gives a loud diagnostic if a
2253 /// future refactor ever adds one — strictly better than silent
2254 /// signature-compromise.
2255 #[expect(
2256 clippy::expect_used,
2257 reason = "no CapabilityAnnouncement field has a fallible Serialize impl today; panic is the documented loud-diagnostic strategy for a future refactor that introduces one"
2258 )]
2259 fn signed_payload(&self) -> Vec<u8> {
2260 let mut canonical = self.clone();
2261 canonical.signature = None;
2262 canonical.hop_count = 0;
2263 serde_json::to_vec(&canonical).expect(
2264 "CapabilityAnnouncement::signed_payload: serde_json::to_vec is infallible \
2265 over the current field set; if this ever fires, a fallible Serialize impl \
2266 was added and the signed transcript must be re-designed before merging",
2267 )
2268 }
2269
2270 /// Sign this announcement in place with `keypair`. The resulting
2271 /// signature covers every field EXCEPT [`Self::hop_count`] — the
2272 /// caller must still ensure `keypair.entity_id() == self.entity_id`
2273 /// or receivers will reject with `InvalidSignature`.
2274 pub fn sign(&mut self, keypair: &super::super::identity::EntityKeypair) {
2275 let payload = self.signed_payload();
2276 let sig = keypair.sign(&payload);
2277 self.signature = Some(Signature64(sig.to_bytes()));
2278 }
2279
2280 /// Verify the signature against the announcement's own
2281 /// `entity_id`. Ignores [`Self::hop_count`] — forwarders are
2282 /// expected to bump it. Returns `Err` if no signature is
2283 /// present, if the signature can't be decoded, or if
2284 /// verification fails.
2285 pub fn verify(&self) -> Result<(), super::super::identity::EntityError> {
2286 let Some(Signature64(raw)) = self.signature else {
2287 return Err(super::super::identity::EntityError::InvalidSignature);
2288 };
2289 let payload = self.signed_payload();
2290 let sig = ed25519_dalek::Signature::from_bytes(&raw);
2291 self.entity_id.verify(&payload, &sig)
2292 }
2293
2294 /// Serialize to bytes — JSON. The compact-postcard codec used by
2295 /// [`CapabilitySet::to_bytes_compact`] is not applied here:
2296 /// `CapabilityAnnouncement`'s wire-compat surface relies on
2297 /// several `#[serde(skip_serializing_if = ...)]` field
2298 /// omissions (`signature`, `hop_count`, `reflex_addr`, the three
2299 /// `allowed_*` lists) so signed bytes round-trip byte-for-byte
2300 /// against pre-M-1 / pre-v0.4 peers — and postcard's positional
2301 /// encoding can't reconstruct an omitted field. A compact
2302 /// announcement codec would need a separate canonicalized wire
2303 /// struct (TODO; tracked in PERF_AUDIT_2026_05_28_CAPABILITY.md
2304 /// fix #3 follow-ups).
2305 pub fn to_bytes(&self) -> Vec<u8> {
2306 serde_json::to_vec(self).unwrap_or_default()
2307 }
2308
2309 /// Deserialize from bytes (JSON only — see [`Self::to_bytes`]).
2310 /// Returns `None` on a parse failure OR when any v0.4
2311 /// capability-auth allow-list exceeds [`MAX_ALLOW_LIST_LEN`] —
2312 /// the cap is a wire-level invariant (operators above 64 entries
2313 /// per axis must use a group), so receivers reject oversized
2314 /// announcements at the deserializer boundary rather than
2315 /// scanning unbounded vectors inside `may_execute` on every call.
2316 /// Symmetric with the CLI's announce-side check; closes the
2317 /// asymmetry where the substrate accepted any vector length the
2318 /// wire delivered.
2319 pub fn from_bytes(data: &[u8]) -> Option<Self> {
2320 let ann: Self = serde_json::from_slice(data).ok()?;
2321 if ann.allowed_nodes.len() > MAX_ALLOW_LIST_LEN
2322 || ann.allowed_subnets.len() > MAX_ALLOW_LIST_LEN
2323 || ann.allowed_groups.len() > MAX_ALLOW_LIST_LEN
2324 {
2325 return None;
2326 }
2327 Some(ann)
2328 }
2329
2330 /// Drop every metadata key that the substrate reserves for
2331 /// local trust use (`intent`, `colocate-with`, `priority`,
2332 /// `owner`). Call this on every announcement decoded from an
2333 /// inbound peer before its metadata is consulted by greedy
2334 /// admission, placement scoring, or anything else that lets a
2335 /// metadata value steer substrate decisions: pre-fix a peer
2336 /// could stamp `intent = "high-priority-tenant-X"` on its own
2337 /// announcement and steer the receiver's admission to itself.
2338 ///
2339 /// `tool::*` keys are NOT stripped — they're peer-advertised
2340 /// AI tool descriptors (schemas, descriptions, tags) that
2341 /// `MeshNode::list_tools` surfaces to agents. Substrate never
2342 /// makes trust decisions from them, so stripping would only
2343 /// defeat cross-mesh tool discovery. See
2344 /// [`schema::METADATA_RESERVED_PREFIXES`](super::schema).
2345 ///
2346 /// The schema's `metadata_reserved` doc says these keys are
2347 /// **writable by user code on the local node** — the local
2348 /// node knows its own legitimate intent. But the same wire
2349 /// shape carries inbound peer announcements that the
2350 /// substrate must NOT trust for those decisions. This method
2351 /// is the boundary that draws the distinction; callers on the
2352 /// receive path invoke it after `from_bytes`.
2353 pub fn strip_reserved_metadata(&mut self) {
2354 use super::schema::AXIS_SCHEMA;
2355 self.capabilities.metadata.retain(|key, _| {
2356 if AXIS_SCHEMA.metadata_reserved.contains(&key.as_str()) {
2357 return false;
2358 }
2359 // `metadata_reserved_prefixes` is empty as of A-4 — the
2360 // `tool::*` family that used to live here is intentionally
2361 // peer-advertised content. See the prefix-list doc in
2362 // `behavior::schema`. The retain loop is kept for forward
2363 // compat if a future substrate-trust prefix needs gating.
2364 !AXIS_SCHEMA
2365 .metadata_reserved_prefixes
2366 .iter()
2367 .any(|prefix| key.starts_with(prefix))
2368 });
2369 }
2370
2371 /// Check if expired
2372 pub fn is_expired(&self) -> bool {
2373 use std::time::{SystemTime, UNIX_EPOCH};
2374 let now_ns = SystemTime::now()
2375 .duration_since(UNIX_EPOCH)
2376 .map(|d| d.as_nanos() as u64)
2377 .unwrap_or(0);
2378 let age_secs = (now_ns.saturating_sub(self.timestamp_ns)) / 1_000_000_000;
2379 // Inclusive-expiry: at age == ttl the announcement is already expired.
2380 // Matches `PermissionToken::is_valid` (see identity/token.rs) so the
2381 // effective lifetime is exactly `ttl_secs` seconds.
2382 age_secs >= self.ttl_secs as u64
2383 }
2384}
2385
2386// ============================================================================
2387// Capability Filter
2388// ============================================================================
2389
2390/// Filter for querying capabilities
2391#[derive(Debug, Clone, Default)]
2392pub struct CapabilityFilter {
2393 /// Require specific tags (all must match)
2394 pub require_tags: Vec<String>,
2395 /// Require specific models (any must match)
2396 pub require_models: Vec<String>,
2397 /// Require specific tools (any must match)
2398 pub require_tools: Vec<String>,
2399 /// Minimum memory in GB
2400 pub min_memory_gb: Option<u32>,
2401 /// Require GPU
2402 pub require_gpu: bool,
2403 /// Specific GPU vendor
2404 pub gpu_vendor: Option<GpuVendor>,
2405 /// Minimum VRAM in GB
2406 pub min_vram_gb: Option<u32>,
2407 /// Minimum context length
2408 pub min_context_length: Option<u32>,
2409 /// Require specific modalities
2410 pub require_modalities: Vec<Modality>,
2411}
2412
2413impl CapabilityFilter {
2414 /// Create empty filter (matches all)
2415 pub fn new() -> Self {
2416 Self::default()
2417 }
2418
2419 /// Require tag
2420 pub fn require_tag(mut self, tag: impl Into<String>) -> Self {
2421 self.require_tags.push(tag.into());
2422 self
2423 }
2424
2425 /// Require model
2426 pub fn require_model(mut self, model: impl Into<String>) -> Self {
2427 self.require_models.push(model.into());
2428 self
2429 }
2430
2431 /// Require tool
2432 pub fn require_tool(mut self, tool: impl Into<String>) -> Self {
2433 self.require_tools.push(tool.into());
2434 self
2435 }
2436
2437 /// Set minimum memory
2438 pub fn with_min_memory(mut self, gb: u32) -> Self {
2439 self.min_memory_gb = Some(gb);
2440 self
2441 }
2442
2443 /// Require GPU
2444 pub fn require_gpu(mut self) -> Self {
2445 self.require_gpu = true;
2446 self
2447 }
2448
2449 /// Require specific GPU vendor
2450 pub fn with_gpu_vendor(mut self, vendor: GpuVendor) -> Self {
2451 self.gpu_vendor = Some(vendor);
2452 self.require_gpu = true;
2453 self
2454 }
2455
2456 /// Set minimum VRAM
2457 pub fn with_min_vram(mut self, gb: u32) -> Self {
2458 self.min_vram_gb = Some(gb);
2459 self.require_gpu = true;
2460 self
2461 }
2462
2463 /// Set minimum context length
2464 pub fn with_min_context(mut self, length: u32) -> Self {
2465 self.min_context_length = Some(length);
2466 self
2467 }
2468
2469 /// Require modality
2470 pub fn require_modality(mut self, modality: Modality) -> Self {
2471 self.require_modalities.push(modality);
2472 self
2473 }
2474
2475 /// Check if a capability set matches this filter.
2476 ///
2477 /// Phase A.5.2: reads through `caps.views()` for the
2478 /// hardware / models / tools projections. Methods that already
2479 /// abstract field access (`has_tag` / `has_gpu` / `has_model`
2480 /// / `has_tool`) keep working unchanged. Once Phase A.5.N
2481 /// removes the typed-struct fields from `CapabilitySet`, the
2482 /// `views()` body becomes a tag-set scan and this matcher
2483 /// keeps working without further changes.
2484 pub fn matches(&self, caps: &CapabilitySet) -> bool {
2485 use crate::adapter::net::behavior::tag::{AxisSeparator, TaxonomyAxis};
2486 use crate::adapter::net::behavior::tag_codec::gpu_vendor_str;
2487
2488 // Check tags (all required tags must be present)
2489 for tag in &self.require_tags {
2490 if !caps.has_tag(tag) {
2491 return false;
2492 }
2493 }
2494
2495 // Check models (any required model must be present)
2496 if !self.require_models.is_empty() {
2497 let has_model = self.require_models.iter().any(|m| caps.has_model(m));
2498 if !has_model {
2499 return false;
2500 }
2501 }
2502
2503 // Check tools (any required tool must be present)
2504 if !self.require_tools.is_empty() {
2505 let has_tool = self.require_tools.iter().any(|t| caps.has_tool(t));
2506 if !has_tool {
2507 return false;
2508 }
2509 }
2510
2511 // Tag-direct fast paths for single-field hardware predicates —
2512 // avoid forcing the full `HardwareCapabilities` decode (sort +
2513 // per-tag axis_key parse) when only one tag's value is needed.
2514 // See `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #2.
2515 if let Some(min_mem) = self.min_memory_gb {
2516 let mem = caps
2517 .axis_value(TaxonomyAxis::Hardware, "memory_gb")
2518 .and_then(|s| s.parse::<u32>().ok())
2519 .unwrap_or(0);
2520 if mem < min_mem {
2521 return false;
2522 }
2523 }
2524
2525 if self.require_gpu && !caps.has_gpu() {
2526 return false;
2527 }
2528
2529 if let Some(vendor) = self.gpu_vendor {
2530 // O(1) HashSet probe for `hardware.gpu.vendor=<vendor>`.
2531 let expected = Tag::AxisValue {
2532 axis: TaxonomyAxis::Hardware,
2533 key: "gpu.vendor".to_string(),
2534 value: gpu_vendor_str(vendor).to_string(),
2535 separator: AxisSeparator::Eq,
2536 };
2537 if !caps.tags.contains(&expected) {
2538 return false;
2539 }
2540 }
2541
2542 if let Some(min_vram) = self.min_vram_gb {
2543 // Single-GPU fast path: `hardware.gpu.vram_gb=<n>`. Falls
2544 // through to the full `HardwareCapabilities::total_vram_gb`
2545 // sum for multi-GPU configs (where additional `gpu.<i>.*`
2546 // tags exist beyond the primary).
2547 let vram = caps
2548 .axis_value(TaxonomyAxis::Hardware, "gpu.vram_gb")
2549 .and_then(|s| s.parse::<u32>().ok())
2550 .unwrap_or(0);
2551 if vram < min_vram {
2552 let total = caps.views().hardware().total_vram_gb();
2553 if total < min_vram {
2554 return false;
2555 }
2556 }
2557 }
2558
2559 // Remaining predicates need the model projection — decode lazily.
2560 if self.min_context_length.is_some() || !self.require_modalities.is_empty() {
2561 let views = caps.views();
2562
2563 if let Some(min_ctx) = self.min_context_length {
2564 let has_sufficient = views.models().iter().any(|m| m.context_length >= min_ctx);
2565 if !has_sufficient {
2566 return false;
2567 }
2568 }
2569
2570 for modality in &self.require_modalities {
2571 let has_modality = views
2572 .models()
2573 .iter()
2574 .any(|m| m.modalities.contains(modality));
2575 if !has_modality {
2576 return false;
2577 }
2578 }
2579 }
2580
2581 true
2582 }
2583}
2584
2585// ============================================================================
2586// Capability Requirement (for load balancing)
2587// ============================================================================
2588
2589/// Capability requirement with scoring
2590#[derive(Debug, Clone, Default)]
2591pub struct CapabilityRequirement {
2592 /// Base filter
2593 pub filter: CapabilityFilter,
2594 /// Prefer more memory (weight 0.0-1.0)
2595 pub prefer_more_memory: f32,
2596 /// Prefer more VRAM (weight 0.0-1.0)
2597 pub prefer_more_vram: f32,
2598 /// Prefer faster tokens/sec (weight 0.0-1.0)
2599 pub prefer_faster_inference: f32,
2600 /// Prefer loaded models (weight 0.0-1.0)
2601 pub prefer_loaded_models: f32,
2602}
2603
2604impl CapabilityRequirement {
2605 /// Create from filter
2606 pub fn from_filter(filter: CapabilityFilter) -> Self {
2607 Self {
2608 filter,
2609 ..Default::default()
2610 }
2611 }
2612
2613 /// Set memory preference weight
2614 pub fn prefer_memory(mut self, weight: f32) -> Self {
2615 self.prefer_more_memory = weight.clamp(0.0, 1.0);
2616 self
2617 }
2618
2619 /// Set VRAM preference weight
2620 pub fn prefer_vram(mut self, weight: f32) -> Self {
2621 self.prefer_more_vram = weight.clamp(0.0, 1.0);
2622 self
2623 }
2624
2625 /// Set inference speed preference
2626 pub fn prefer_speed(mut self, weight: f32) -> Self {
2627 self.prefer_faster_inference = weight.clamp(0.0, 1.0);
2628 self
2629 }
2630
2631 /// Set loaded model preference
2632 pub fn prefer_loaded(mut self, weight: f32) -> Self {
2633 self.prefer_loaded_models = weight.clamp(0.0, 1.0);
2634 self
2635 }
2636
2637 /// Score a capability set (higher is better)
2638 pub fn score(&self, caps: &CapabilitySet) -> f32 {
2639 if !self.filter.matches(caps) {
2640 return 0.0;
2641 }
2642
2643 // Phase A.5.5: read through views() once. Same projection
2644 // pattern Phase A.5.2/A.5.3/A.5.4 applied to filter / proximity
2645 // / diff — survives Phase A.5.N field removal unchanged.
2646 let views = caps.views();
2647
2648 let mut score = 1.0;
2649
2650 // Memory score (normalized to 256GB)
2651 if self.prefer_more_memory > 0.0 {
2652 let mem_score = (views.hardware().memory_gb as f32 / 256.0).min(1.0);
2653 score += self.prefer_more_memory * mem_score;
2654 }
2655
2656 // VRAM score (normalized to 80GB)
2657 if self.prefer_more_vram > 0.0 {
2658 let vram_score = (views.hardware().total_vram_gb() as f32 / 80.0).min(1.0);
2659 score += self.prefer_more_vram * vram_score;
2660 }
2661
2662 // Inference speed score (normalized to 1000 tok/s)
2663 if self.prefer_faster_inference > 0.0 {
2664 let max_tps: u32 = views
2665 .models()
2666 .iter()
2667 .map(|m| m.tokens_per_sec)
2668 .max()
2669 .unwrap_or(0);
2670 let speed_score = (max_tps as f32 / 1000.0).min(1.0);
2671 score += self.prefer_faster_inference * speed_score;
2672 }
2673
2674 // Loaded model score
2675 if self.prefer_loaded_models > 0.0 {
2676 let models = views.models();
2677 let loaded_count = models.iter().filter(|m| m.loaded).count();
2678 let loaded_ratio = if models.is_empty() {
2679 0.0
2680 } else {
2681 loaded_count as f32 / models.len() as f32
2682 };
2683 score += self.prefer_loaded_models * loaded_ratio;
2684 }
2685
2686 score
2687 }
2688}
2689
2690// ============================================================================
2691// CardinalityProvider trait — used by the predicate planner for
2692// per-key selectivity estimates. The legacy CapabilityIndex impl
2693// was removed in Phase 3B of the multifold migration; downstream
2694// users now bring their own provider (the fold side ships one
2695// through `capability::CapabilityFold`).
2696// ============================================================================
2697
2698/// Source of per-key cardinality data for the predicate query
2699/// planner. Implementors return distinct-value counts for axis
2700/// tag keys and metadata keys; the planner uses these to order
2701/// And-clauses (rare-true first) and Or-clauses (often-true
2702/// first) for early-out savings.
2703pub trait CardinalityProvider {
2704 /// Distinct-value count for the given axis tag key. Returns 0
2705 /// when the key is absent — planner treats this as "no data,
2706 /// fall back to static cost".
2707 fn axis_cardinality(&self, key: &crate::adapter::net::behavior::tag::TagKey) -> usize;
2708
2709 /// Distinct-value count for the given metadata key.
2710 fn metadata_value_cardinality(&self, key: &str) -> usize;
2711}
2712
2713// ============================================================================
2714// Tests
2715// ============================================================================
2716
2717#[cfg(test)]
2718mod tests {
2719 use super::*;
2720 /// Fixed-bytes `EntityId` for unit-test fixtures. Valid as a
2721 /// *value* (it's just 32 bytes) but not a valid ed25519 public
2722 /// key — callers that also exercise signature verification
2723 /// should construct a real `EntityKeypair` instead.
2724 fn test_entity() -> super::super::super::identity::EntityId {
2725 super::super::super::identity::EntityId::from_bytes([0u8; 32])
2726 }
2727 /// `strip_reserved_metadata` drops every exact-match reserved
2728 /// key (`intent`, `colocate-with`, `priority`, `owner`) and
2729 /// leaves all other keys intact. The substrate calls this on
2730 /// every inbound peer announcement before downstream consumers
2731 /// (greedy admission, placement scoring) read metadata, so a
2732 /// peer can't steer receiver decisions through the
2733 /// substrate-trusted slot keys.
2734 ///
2735 /// A-4 update: `tool::*` keys are NOT stripped any more — they
2736 /// carry peer-advertised AI tool schemas / descriptions /
2737 /// tags that `MeshNode::list_tools` surfaces to agents. The
2738 /// substrate never makes trust decisions from those values, so
2739 /// stripping them would only defeat cross-mesh tool discovery.
2740 #[test]
2741 fn strip_reserved_metadata_drops_reserved_keys() {
2742 let mut ann = CapabilityAnnouncement::new(0xDEAD, test_entity(), 7, CapabilitySet::new());
2743 ann.capabilities
2744 .metadata
2745 .insert("intent".into(), "evil-tenant".into());
2746 ann.capabilities
2747 .metadata
2748 .insert("colocate-with".into(), "0xdeadbeef".into());
2749 ann.capabilities
2750 .metadata
2751 .insert("priority".into(), "9999".into());
2752 ann.capabilities
2753 .metadata
2754 .insert("owner".into(), "attacker".into());
2755 ann.capabilities.metadata.insert(
2756 "tool::web_search::description".into(),
2757 "Search the web.".into(),
2758 );
2759 ann.capabilities
2760 .metadata
2761 .insert("app::region".into(), "us-east".into());
2762 ann.capabilities
2763 .metadata
2764 .insert("user_tag".into(), "fine".into());
2765
2766 ann.strip_reserved_metadata();
2767
2768 assert!(!ann.capabilities.metadata.contains_key("intent"));
2769 assert!(!ann.capabilities.metadata.contains_key("colocate-with"));
2770 assert!(!ann.capabilities.metadata.contains_key("priority"));
2771 assert!(!ann.capabilities.metadata.contains_key("owner"));
2772 // `tool::*` keys survive — they are peer-advertised AI tool
2773 // descriptor content, not substrate trust signal.
2774 assert_eq!(
2775 ann.capabilities
2776 .metadata
2777 .get("tool::web_search::description")
2778 .map(String::as_str),
2779 Some("Search the web."),
2780 );
2781 // Non-reserved keys survive — substrate only filters its
2782 // own reserved namespace, not the caller's app namespace.
2783 assert_eq!(
2784 ann.capabilities
2785 .metadata
2786 .get("app::region")
2787 .map(String::as_str),
2788 Some("us-east"),
2789 );
2790 assert_eq!(
2791 ann.capabilities
2792 .metadata
2793 .get("user_tag")
2794 .map(String::as_str),
2795 Some("fine"),
2796 );
2797 }
2798 /// The signature transcript covers `capabilities.metadata`, so
2799 /// `strip_reserved_metadata` invalidates the signature. The
2800 /// inbound dispatch path must therefore re-broadcast the
2801 /// announcement BEFORE stripping; otherwise a multi-hop
2802 /// receiver with `require_signed_capabilities = true` would
2803 /// reject every forwarded announcement that originally carried
2804 /// a reserved metadata key.
2805 #[test]
2806 fn strip_reserved_metadata_invalidates_signature() {
2807 use super::super::super::identity::EntityKeypair;
2808 let keypair = EntityKeypair::generate();
2809 let mut ann =
2810 CapabilityAnnouncement::new(1, keypair.entity_id().clone(), 1, sample_capability_set());
2811 ann.capabilities
2812 .metadata
2813 .insert("intent".into(), "compute".into());
2814 ann.sign(&keypair);
2815
2816 // Baseline: signed announcement verifies, and the bytes a
2817 // forwarder would re-broadcast also verify (the bug a
2818 // pre-forward strip would cause).
2819 assert!(ann.verify().is_ok());
2820 let forward_bytes = ann.to_bytes();
2821 let forwarded =
2822 CapabilityAnnouncement::from_bytes(&forward_bytes).expect("forwarded parses");
2823 assert!(
2824 forwarded.verify().is_ok(),
2825 "downstream verifier must accept the un-stripped wire bytes"
2826 );
2827
2828 // After strip the signature transcript no longer matches —
2829 // pins the invariant the inbound dispatch order in
2830 // `mesh.rs::process_capability_announcement` relies on.
2831 ann.strip_reserved_metadata();
2832 assert!(
2833 ann.verify().is_err(),
2834 "strip must invalidate the signature so the substrate is forced \
2835 to strip the local copy AFTER any re-broadcast"
2836 );
2837 }
2838 fn sample_capability_set() -> CapabilitySet {
2839 let gpu = GpuInfo::new(GpuVendor::Nvidia, "RTX 4090", 24)
2840 .with_compute_units(128)
2841 .with_tensor_cores(512)
2842 .with_fp16_tflops(82.5);
2843
2844 let hardware = HardwareCapabilities::new()
2845 .with_cpu(16, 32)
2846 .with_memory(64)
2847 .with_gpu(gpu)
2848 .with_storage(2000)
2849 .with_network(10);
2850
2851 let software = SoftwareCapabilities::new()
2852 .with_os("linux", "6.1")
2853 .add_runtime("python", "3.11")
2854 .add_framework("pytorch", "2.1")
2855 .with_cuda("12.1");
2856
2857 let model = ModelCapability::new("llama-3.1-70b", "llama")
2858 .with_parameters(70.0)
2859 .with_context_length(128000)
2860 .with_quantization("fp16")
2861 .add_modality(Modality::Text)
2862 .add_modality(Modality::Code)
2863 .with_tokens_per_sec(50)
2864 .with_loaded(true);
2865
2866 let tool = ToolCapability::new("python_repl", "Python REPL")
2867 .with_version("1.0.0")
2868 .with_estimated_time(100);
2869
2870 CapabilitySet::new()
2871 .with_hardware(hardware)
2872 .with_software(software)
2873 .add_model(model)
2874 .add_tool(tool)
2875 .add_tag("inference")
2876 .add_tag("gpu")
2877 .with_limits(ResourceLimits::new().with_max_concurrent(10))
2878 }
2879 #[test]
2880 fn test_capability_set_creation() {
2881 let caps = sample_capability_set();
2882 assert!(caps.has_gpu());
2883 assert!(caps.has_tag("inference"));
2884 assert!(caps.has_model("llama-3.1-70b"));
2885 assert!(caps.has_tool("python_repl"));
2886 assert_eq!(caps.views().hardware().memory_gb, 64);
2887 }
2888 #[test]
2889 fn test_capability_set_serialization() {
2890 let caps = sample_capability_set();
2891 let bytes = caps.to_bytes();
2892 let parsed = CapabilitySet::from_bytes(&bytes).unwrap();
2893
2894 assert_eq!(
2895 caps.views().hardware().memory_gb,
2896 parsed.views().hardware().memory_gb,
2897 );
2898 assert_eq!(caps.tags, parsed.tags);
2899 assert_eq!(caps.views().models().len(), parsed.views().models().len());
2900 }
2901
2902 /// Regression guard for the `sort_by_cached_key` optimization in
2903 /// `sorted_tag_vec`: human-readable (JSON) serialization must remain
2904 /// byte-stable regardless of `HashSet` iteration order, otherwise signed
2905 /// `CapabilityAnnouncement` bytes would diverge across peers.
2906 #[test]
2907 fn json_tag_serialization_is_byte_stable_across_insertion_orders() {
2908 // Plain legacy tags (no axis prefixes, no substring overlaps) so
2909 // `Tag::to_string()` == the input string and ordering is unambiguous.
2910 let tags = [
2911 "inference",
2912 "alpha",
2913 "zulu",
2914 "mike",
2915 "bravo",
2916 "yankee",
2917 "delta",
2918 ];
2919
2920 let mut forward = CapabilitySet::new();
2921 for t in tags.iter() {
2922 forward = forward.add_tag(*t);
2923 }
2924 let mut reverse = CapabilitySet::new();
2925 for t in tags.iter().rev() {
2926 reverse = reverse.add_tag(*t);
2927 }
2928 assert_eq!(forward.tags.len(), tags.len(), "all tags should parse");
2929
2930 // JSON (human-readable) bytes must be identical regardless of the
2931 // order tags were inserted into the underlying HashSet.
2932 let a = serde_json::to_vec(&forward).unwrap();
2933 let b = serde_json::to_vec(&reverse).unwrap();
2934 assert_eq!(
2935 a, b,
2936 "JSON serialization must be insertion-order-independent"
2937 );
2938
2939 // The emitted `tags` array must be in `Tag::to_string()` sorted order.
2940 let value: serde_json::Value = serde_json::from_slice(&a).unwrap();
2941 let emitted: Vec<String> = value["tags"]
2942 .as_array()
2943 .expect("tags is a JSON array")
2944 .iter()
2945 .map(|t| t.as_str().unwrap().to_string())
2946 .collect();
2947 let mut expected = emitted.clone();
2948 expected.sort();
2949 assert_eq!(emitted, expected, "tags must be emitted in sorted order");
2950 }
2951 /// A-4: `with_metadata` consults `METADATA_RESERVED_PREFIXES`,
2952 /// which is now empty — the `tool::*` family was hoisted out
2953 /// because tool descriptors are peer-advertised content, not
2954 /// substrate-trust slots. The gate stays wired so a future
2955 /// re-add (e.g. a new substrate-internal prefix) plugs back
2956 /// in here without a fan-out edit, but the current contract is
2957 /// "tool::* writes pass through". Exact-match reserved keys
2958 /// (`intent`, `owner`, …) are NOT gated by `with_metadata`
2959 /// either — those are well-known user-facing scheduler hints
2960 /// the substrate reads and the user is expected to set.
2961 #[test]
2962 fn with_metadata_preserves_tool_prefix_after_a4() {
2963 // `tool::*` writes survive — A-4 contract.
2964 let caps = CapabilitySet::new()
2965 .with_metadata("tool::web_search::input_schema", "{}")
2966 .with_metadata("region", "us-east");
2967 assert_eq!(
2968 caps.metadata
2969 .get("tool::web_search::input_schema")
2970 .map(|s| s.as_str()),
2971 Some("{}"),
2972 "tool::* writes must pass through with_metadata: {:?}",
2973 caps.metadata,
2974 );
2975 // Non-reserved key passes through.
2976 assert_eq!(
2977 caps.metadata.get("region").map(|s| s.as_str()),
2978 Some("us-east")
2979 );
2980
2981 // Exact-match reserved keys (NOT gated) — these are
2982 // user-facing scheduler hints, the substrate reads them
2983 // and user code is expected to set them.
2984 let caps = CapabilitySet::new().with_metadata("intent", "ml-training");
2985 assert_eq!(
2986 caps.metadata.get("intent").map(|s| s.as_str()),
2987 Some("ml-training")
2988 );
2989 }
2990 /// E-2 regression: add_tools must produce the same final
2991 /// CapabilitySet as N successive add_tool calls, but via one
2992 /// set_tools invocation. We verify the equivalence by building
2993 /// the same capability set both ways and comparing.
2994 #[test]
2995 fn add_tools_batch_matches_repeated_add_tool() {
2996 let tools = [
2997 ToolCapability::new("web_search", "Web Search").with_version("1.0.0"),
2998 ToolCapability::new("summarize", "Summarize").with_version("1.0.0"),
2999 ToolCapability::new("code_eval", "Code Eval")
3000 .with_version("2.0.0")
3001 .with_input_schema(r#"{"type":"object"}"#),
3002 ];
3003
3004 let via_repeated = tools
3005 .iter()
3006 .fold(CapabilitySet::new(), |caps, t| caps.add_tool(t.clone()));
3007 let via_batch = CapabilitySet::new().add_tools(tools.iter().cloned());
3008
3009 // Tag sets must be byte-equal (the canonical software.tool.*
3010 // indexed encoding is order-stable for set_tools).
3011 assert_eq!(via_repeated.tags, via_batch.tags);
3012 // Schema metadata must be byte-equal too — set_tools is the
3013 // codepath that mirrors input/output schemas.
3014 assert_eq!(via_repeated.metadata, via_batch.metadata);
3015 // And the typed view must agree.
3016 assert_eq!(
3017 via_repeated.views().tools().len(),
3018 via_batch.views().tools().len()
3019 );
3020 }
3021
3022 /// E-2 regression: add_tools onto a non-empty set must extend,
3023 /// not replace. Guards against a future implementation that
3024 /// might mistakenly call `set_tools(iter.collect())` and drop
3025 /// the prior tools.
3026 #[test]
3027 fn add_tools_extends_existing_tools() {
3028 let caps = CapabilitySet::new()
3029 .add_tool(ToolCapability::new("first", "First").with_version("1.0.0"))
3030 .add_tools(vec![
3031 ToolCapability::new("second", "Second").with_version("1.0.0"),
3032 ToolCapability::new("third", "Third").with_version("1.0.0"),
3033 ]);
3034 assert!(caps.has_tool("first"));
3035 assert!(caps.has_tool("second"));
3036 assert!(caps.has_tool("third"));
3037 }
3038
3039 #[test]
3040 fn has_tag_matches_across_separator_forms() {
3041 // Regression for CR-1: `Tag::AxisValue` derives `PartialEq`
3042 // including the `=` vs `:` separator. A capability set built
3043 // by inserting one wire form must still be findable when the
3044 // caller queries the other — the separator is a serialization
3045 // detail, not part of identity. Mirrors the prior diff-engine
3046 // fix in commit 38612b61 but for the public membership API.
3047 use crate::adapter::net::behavior::tag::{AxisSeparator, Tag, TaxonomyAxis};
3048 let mut caps = CapabilitySet::new();
3049 caps.tags.insert(Tag::AxisValue {
3050 axis: TaxonomyAxis::Software,
3051 key: "os".to_string(),
3052 value: "linux".to_string(),
3053 separator: AxisSeparator::Colon,
3054 });
3055 // Stored colon, queried equals — must hit.
3056 assert!(caps.has_tag("software.os=linux"));
3057 // Stored colon, queried colon — must hit.
3058 assert!(caps.has_tag("software.os:linux"));
3059 // Different value — must miss.
3060 assert!(!caps.has_tag("software.os=darwin"));
3061
3062 let mut caps = CapabilitySet::new();
3063 caps.tags.insert(Tag::AxisValue {
3064 axis: TaxonomyAxis::Hardware,
3065 key: "gpu.vram_gb".to_string(),
3066 value: "80".to_string(),
3067 separator: AxisSeparator::Eq,
3068 });
3069 // Stored equals, queried colon — must hit.
3070 assert!(caps.has_tag("hardware.gpu.vram_gb:80"));
3071 // Stored equals, queried equals — must hit.
3072 assert!(caps.has_tag("hardware.gpu.vram_gb=80"));
3073 }
3074 #[test]
3075 fn test_capability_filter_matches() {
3076 let caps = sample_capability_set();
3077
3078 // Tag filter
3079 let filter = CapabilityFilter::new().require_tag("inference");
3080 assert!(filter.matches(&caps));
3081
3082 let filter = CapabilityFilter::new().require_tag("training");
3083 assert!(!filter.matches(&caps));
3084
3085 // GPU filter
3086 let filter = CapabilityFilter::new().require_gpu();
3087 assert!(filter.matches(&caps));
3088
3089 let filter = CapabilityFilter::new().with_gpu_vendor(GpuVendor::Nvidia);
3090 assert!(filter.matches(&caps));
3091
3092 let filter = CapabilityFilter::new().with_gpu_vendor(GpuVendor::Amd);
3093 assert!(!filter.matches(&caps));
3094
3095 // Memory filter
3096 let filter = CapabilityFilter::new().with_min_memory(32);
3097 assert!(filter.matches(&caps));
3098
3099 let filter = CapabilityFilter::new().with_min_memory(128);
3100 assert!(!filter.matches(&caps));
3101
3102 // Model filter
3103 let filter = CapabilityFilter::new().require_model("llama-3.1-70b");
3104 assert!(filter.matches(&caps));
3105
3106 let filter = CapabilityFilter::new().require_model("gpt-4");
3107 assert!(!filter.matches(&caps));
3108 }
3109 #[test]
3110 fn test_capability_requirement_scoring() {
3111 let caps = sample_capability_set();
3112
3113 let req = CapabilityRequirement::from_filter(CapabilityFilter::new().require_gpu())
3114 .prefer_memory(0.5)
3115 .prefer_vram(0.5)
3116 .prefer_speed(0.5);
3117
3118 let score = req.score(&caps);
3119 assert!(score > 1.0); // Base score + preferences
3120 }
3121 #[test]
3122 fn test_capability_announcement_expiry() {
3123 let caps = sample_capability_set();
3124 let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, caps);
3125
3126 // Fresh announcement should not be expired
3127 assert!(!ann.is_expired());
3128
3129 // Set timestamp to the past
3130 ann.timestamp_ns = 0;
3131 ann.ttl_secs = 1;
3132
3133 // Should be expired now
3134 assert!(ann.is_expired());
3135 }
3136 /// `CapabilityAnnouncement::is_expired()` uses `SystemTime`, so
3137 /// we can backdate `timestamp_ns` and exercise the ttl boundary
3138 /// directly. Covers the inclusive-expiry contract at every TTL
3139 /// bucket in the plan.
3140 #[test]
3141 fn announcement_is_expired_table_driven_across_ttl_buckets() {
3142 use std::time::{SystemTime, UNIX_EPOCH};
3143
3144 let now_ns = SystemTime::now()
3145 .duration_since(UNIX_EPOCH)
3146 .unwrap()
3147 .as_nanos() as u64;
3148 let sec_ns = 1_000_000_000u64;
3149
3150 // (ttl_secs, age_secs, expected_is_expired, label)
3151 let cases: &[(u32, u64, bool, &str)] = &[
3152 // TTL=0: inclusive-expiry — any age (including 0) is expired.
3153 (0, 0, true, "ttl=0 fresh"),
3154 // TTL=1: 0s age → fresh; 2s age → expired.
3155 (1, 0, false, "ttl=1s fresh"),
3156 (1, 2, true, "ttl=1s aged 2s"),
3157 // TTL=1h: boundary at 3600s.
3158 (3_600, 1, false, "ttl=1h aged 1s"),
3159 (3_600, 3_599, false, "ttl=1h aged 3599s"),
3160 (3_600, 3_600, true, "ttl=1h aged exactly 3600s (inclusive)"),
3161 (3_600, 3_601, true, "ttl=1h aged 3601s"),
3162 // TTL=1yr: day-old is fresh, 2yr-old is expired.
3163 (31_536_000, 86_400, false, "ttl=1yr aged 1 day"),
3164 (31_536_000, 31_536_001, true, "ttl=1yr aged just past"),
3165 // TTL=u32::MAX: a 1-year-old entry is still fresh. Pins
3166 // that `ttl_secs as u64` widens without wrapping.
3167 (u32::MAX, 31_536_000, false, "ttl=u32::MAX aged 1 year"),
3168 ];
3169
3170 for &(ttl_secs, age_secs, expected, label) in cases {
3171 let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3172 ann.ttl_secs = ttl_secs;
3173 ann.timestamp_ns = now_ns.saturating_sub(age_secs.saturating_mul(sec_ns));
3174
3175 assert_eq!(
3176 ann.is_expired(),
3177 expected,
3178 "is_expired({label}) must be {expected}",
3179 );
3180 }
3181 }
3182 // ========================================================================
3183 // Multi-hop wire format (M-1)
3184 // ========================================================================
3185
3186 #[test]
3187 fn hop_count_defaults_to_zero() {
3188 let ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3189 assert_eq!(ann.hop_count, 0);
3190 }
3191 #[test]
3192 fn hop_count_roundtrips_through_serde() {
3193 let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3194 ann.hop_count = 7;
3195 let bytes = ann.to_bytes();
3196 let restored = CapabilityAnnouncement::from_bytes(&bytes).expect("parse");
3197 assert_eq!(restored.hop_count, 7);
3198 }
3199 #[test]
3200 fn old_format_without_hop_count_parses_as_zero() {
3201 // Hand-crafted JSON missing the `hop_count` field — the
3202 // #[serde(default)] attribute should rescue us.
3203 let payload = serde_json::json!({
3204 "node_id": 1,
3205 "entity_id": hex::encode([0u8; 32]),
3206 "version": 1,
3207 "timestamp_ns": 0u64,
3208 "ttl_secs": 300u32,
3209 "capabilities": sample_capability_set(),
3210 });
3211 let bytes = serde_json::to_vec(&payload).expect("serialize");
3212 let parsed = CapabilityAnnouncement::from_bytes(&bytes).expect("parse old format");
3213 assert_eq!(parsed.hop_count, 0);
3214 }
3215 #[test]
3216 fn signature_verifies_across_hop_count_bumps() {
3217 use super::super::super::identity::EntityKeypair;
3218 let keypair = EntityKeypair::generate();
3219 let mut ann =
3220 CapabilityAnnouncement::new(1, keypair.entity_id().clone(), 1, sample_capability_set());
3221 ann.sign(&keypair);
3222 // Baseline: freshly signed announcement verifies.
3223 assert!(ann.verify().is_ok());
3224
3225 // Simulate a forwarder bumping the counter. Signature still
3226 // holds because `hop_count` sits outside the signed envelope.
3227 for bumped in 1..=MAX_CAPABILITY_HOPS {
3228 ann.hop_count = bumped;
3229 assert!(
3230 ann.verify().is_ok(),
3231 "signature should remain valid after hop_count={}",
3232 bumped
3233 );
3234 }
3235 }
3236 #[test]
3237 fn signature_rejects_tampered_payload_even_at_hop_zero() {
3238 use super::super::super::identity::EntityKeypair;
3239 let keypair = EntityKeypair::generate();
3240 let mut ann =
3241 CapabilityAnnouncement::new(1, keypair.entity_id().clone(), 1, sample_capability_set());
3242 ann.sign(&keypair);
3243 // Flip a byte inside the signed envelope (node_id).
3244 ann.node_id ^= 0x01;
3245 assert!(ann.verify().is_err());
3246 }
3247 #[test]
3248 fn max_capability_hops_matches_pingwave_contract() {
3249 // MAX_CAPABILITY_HOPS is documented to mirror the pingwave
3250 // MAX_HOPS. If the pingwave side is ever renumbered this
3251 // test flags the divergence at compile time.
3252 assert_eq!(MAX_CAPABILITY_HOPS, 16);
3253 }
3254 // ─────────────────────────────────────────────────────────────────
3255 // v0.4 capability-auth: allow-list wire-format + signing tests
3256 // ─────────────────────────────────────────────────────────────────
3257
3258 /// An announcement with all three allow-lists empty must
3259 /// produce JSON bytes identical to a pre-v0.4 announcement.
3260 /// This is the wire-compat contract the plan §"What ships"
3261 /// pins: existing peers must round-trip a v0.4-produced
3262 /// unrestricted announcement byte-for-byte.
3263 #[test]
3264 fn empty_allow_lists_omit_fields_from_wire() {
3265 let ann = CapabilityAnnouncement::new(
3266 42,
3267 super::super::super::identity::EntityId::from_bytes([0xAA; 32]),
3268 1,
3269 sample_capability_set(),
3270 );
3271 let bytes = ann.to_bytes();
3272 let s = std::str::from_utf8(&bytes).unwrap();
3273 assert!(
3274 !s.contains("allowed_nodes"),
3275 "empty allowed_nodes must be skipped on the wire; got: {}",
3276 s
3277 );
3278 assert!(
3279 !s.contains("allowed_subnets"),
3280 "empty allowed_subnets must be skipped on the wire; got: {}",
3281 s
3282 );
3283 assert!(
3284 !s.contains("allowed_groups"),
3285 "empty allowed_groups must be skipped on the wire; got: {}",
3286 s
3287 );
3288 }
3289 /// Round-trip an announcement with each allow-list populated
3290 /// — the decoder must reconstruct the exact field values.
3291 #[test]
3292 fn populated_allow_lists_round_trip() {
3293 let mut ann = CapabilityAnnouncement::new(
3294 7,
3295 super::super::super::identity::EntityId::from_bytes([0xBB; 32]),
3296 2,
3297 sample_capability_set(),
3298 );
3299 ann.allowed_nodes = vec![100, 200, 300];
3300 ann.allowed_subnets = vec![super::super::subnet::SubnetId([0x11; 16])];
3301 ann.allowed_groups = vec![
3302 super::super::group::GroupId([0x33; 32]),
3303 super::super::group::GroupId([0x44; 32]),
3304 ];
3305 let bytes = ann.to_bytes();
3306 let decoded = CapabilityAnnouncement::from_bytes(&bytes).expect("decode");
3307 assert_eq!(decoded.allowed_nodes, ann.allowed_nodes);
3308 assert_eq!(decoded.allowed_subnets, ann.allowed_subnets);
3309 assert_eq!(decoded.allowed_groups, ann.allowed_groups);
3310 }
3311 /// The canonical signed payload of an unrestricted
3312 /// announcement must NOT carry the three allow-list keys at
3313 /// all — that's what keeps the v0.4 signed byte-pattern
3314 /// identical to the pre-v0.4 shape, so a pre-v0.4 verifier
3315 /// validates a v0.4 unrestricted announcement and vice versa.
3316 /// Distinct from `empty_allow_lists_omit_fields_from_wire`,
3317 /// which checks the same invariant on the serialized wire
3318 /// form (`to_bytes`); this one checks the canonical signed
3319 /// payload (`signed_payload`, which also zeroes `hop_count`).
3320 #[test]
3321 fn signed_payload_omits_empty_allow_lists() {
3322 use super::super::super::identity::EntityKeypair;
3323 let keypair = EntityKeypair::generate();
3324 let ann =
3325 CapabilityAnnouncement::new(5, keypair.entity_id().clone(), 1, sample_capability_set());
3326 let canonical = ann.signed_payload();
3327 let v: serde_json::Value = serde_json::from_slice(&canonical).expect("parse");
3328 let obj = v.as_object().expect("object");
3329 assert!(
3330 !obj.contains_key("allowed_nodes"),
3331 "pre-v0.4 wire shape must not carry allowed_nodes when empty"
3332 );
3333 assert!(
3334 !obj.contains_key("allowed_subnets"),
3335 "pre-v0.4 wire shape must not carry allowed_subnets when empty"
3336 );
3337 assert!(
3338 !obj.contains_key("allowed_groups"),
3339 "pre-v0.4 wire shape must not carry allowed_groups when empty"
3340 );
3341 }
3342 /// A signed announcement carrying non-empty allow-lists
3343 /// verifies after wire round-trip. Pins that the signature
3344 /// covers the new fields end-to-end.
3345 #[test]
3346 fn signed_announcement_with_allow_lists_verifies_after_round_trip() {
3347 use super::super::super::identity::EntityKeypair;
3348 let keypair = EntityKeypair::generate();
3349 let mut ann =
3350 CapabilityAnnouncement::new(9, keypair.entity_id().clone(), 1, sample_capability_set());
3351 ann.allowed_nodes = vec![1, 2, 3];
3352 ann.allowed_subnets = vec![super::super::subnet::SubnetId([0x55; 16])];
3353 ann.allowed_groups = vec![super::super::group::GroupId([0x66; 32])];
3354 ann.sign(&keypair);
3355 let bytes = ann.to_bytes();
3356 let decoded = CapabilityAnnouncement::from_bytes(&bytes).expect("decode");
3357 assert!(
3358 decoded.verify().is_ok(),
3359 "signature must cover the new allow-list fields end-to-end"
3360 );
3361 }
3362 /// Tampering with any allow-list after signing must fail
3363 /// verification — proves the signature covers each new field.
3364 #[test]
3365 fn signed_announcement_rejects_tampered_allow_lists() {
3366 use super::super::super::identity::EntityKeypair;
3367 let keypair = EntityKeypair::generate();
3368 for which in &["nodes", "subnets", "groups"] {
3369 let mut ann = CapabilityAnnouncement::new(
3370 9,
3371 keypair.entity_id().clone(),
3372 1,
3373 sample_capability_set(),
3374 );
3375 ann.allowed_nodes = vec![1, 2];
3376 ann.allowed_subnets = vec![super::super::subnet::SubnetId([0x77; 16])];
3377 ann.allowed_groups = vec![super::super::group::GroupId([0x88; 32])];
3378 ann.sign(&keypair);
3379 // Tamper post-sign.
3380 match *which {
3381 "nodes" => ann.allowed_nodes.push(999),
3382 "subnets" => ann
3383 .allowed_subnets
3384 .push(super::super::subnet::SubnetId([0x99; 16])),
3385 "groups" => ann
3386 .allowed_groups
3387 .push(super::super::group::GroupId([0xAA; 32])),
3388 _ => unreachable!(),
3389 }
3390 assert!(
3391 ann.verify().is_err(),
3392 "tampering with allowed_{} must invalidate signature",
3393 which
3394 );
3395 }
3396 }
3397 #[test]
3398 fn allow_list_cap_documented() {
3399 // Sanity: keep the doc-string + the constant in sync. If
3400 // someone bumps the cap they have to re-think wire-size
3401 // budgeting — explicit pin makes the change visible.
3402 assert_eq!(MAX_ALLOW_LIST_LEN, 64);
3403 }
3404 /// M1 regression — pre-fix, `from_bytes` accepted any allow-list
3405 /// length the wire delivered; a malicious or buggy peer could
3406 /// ship a million-entry `allowed_nodes` and the receiver would
3407 /// fold it, with every `may_execute` then linearly scanning the
3408 /// unbounded vector. Post-fix, the deserializer rejects
3409 /// announcements exceeding the documented per-axis cap.
3410 #[test]
3411 fn from_bytes_rejects_allow_list_over_cap() {
3412 for which in ["nodes", "subnets", "groups"] {
3413 let mut ann = CapabilityAnnouncement::new(
3414 1,
3415 super::super::super::identity::EntityId::from_bytes([0xAA; 32]),
3416 1,
3417 sample_capability_set(),
3418 );
3419 match which {
3420 "nodes" => {
3421 ann.allowed_nodes = (0..(MAX_ALLOW_LIST_LEN as u64) + 1).collect();
3422 }
3423 "subnets" => {
3424 ann.allowed_subnets = (0..(MAX_ALLOW_LIST_LEN as u8) + 1)
3425 .map(|i| super::super::subnet::SubnetId([i; 16]))
3426 .collect();
3427 }
3428 "groups" => {
3429 ann.allowed_groups = (0..(MAX_ALLOW_LIST_LEN as u8) + 1)
3430 .map(|i| super::super::group::GroupId([i; 32]))
3431 .collect();
3432 }
3433 _ => unreachable!(),
3434 }
3435 let bytes = ann.to_bytes();
3436 assert!(
3437 CapabilityAnnouncement::from_bytes(&bytes).is_none(),
3438 "from_bytes must reject allowed_{which} exceeding MAX_ALLOW_LIST_LEN",
3439 );
3440 }
3441 }
3442 /// Boundary check — exactly `MAX_ALLOW_LIST_LEN` entries
3443 /// must STILL deserialize (the cap is inclusive).
3444 #[test]
3445 fn from_bytes_accepts_allow_list_at_cap() {
3446 let mut ann = CapabilityAnnouncement::new(
3447 1,
3448 super::super::super::identity::EntityId::from_bytes([0xAB; 32]),
3449 1,
3450 sample_capability_set(),
3451 );
3452 ann.allowed_nodes = (0..MAX_ALLOW_LIST_LEN as u64).collect();
3453 let bytes = ann.to_bytes();
3454 let decoded =
3455 CapabilityAnnouncement::from_bytes(&bytes).expect("exactly-at-cap must deserialize");
3456 assert_eq!(decoded.allowed_nodes.len(), MAX_ALLOW_LIST_LEN);
3457 }
3458 /// Regression for a cubic-flagged P1: adding `hop_count` to the
3459 /// signed canonical serialization broke rolling-upgrade
3460 /// compatibility — pre-M-1 announcements were signed over bytes
3461 /// that had no `hop_count` key, so a post-M-1 verifier's
3462 /// recomputed `signed_payload()` (which unconditionally
3463 /// serialized `hop_count: 0`) produced different bytes and the
3464 /// signature failed.
3465 ///
3466 /// The fix is `#[serde(skip_serializing_if = "is_hop_count_zero")]`:
3467 /// both pre-M-1 signers AND post-M-1 signed_payload (which
3468 /// always zeros hop_count) omit the field, producing identical
3469 /// canonical bytes.
3470 ///
3471 /// Approach: construct a mirror struct matching pre-M-1's layout
3472 /// (same fields, no hop_count) and compare its serialized output
3473 /// byte-for-byte with the current node's `signed_payload()`.
3474 /// Can't use `serde_json::json!` — that goes through
3475 /// `serde_json::Map` which sorts keys alphabetically, whereas
3476 /// `CapabilityAnnouncement`'s derived Serialize writes in
3477 /// struct-declaration order. The mirror struct keeps the same
3478 /// serialization path.
3479 #[test]
3480 fn reflex_addr_roundtrips_through_serde_when_set() {
3481 // Stage 2 of NAT traversal: `reflex_addr` rides the
3482 // signed envelope when the classifier has an observed
3483 // address. Round-trip must preserve it intact.
3484 let reflex: std::net::SocketAddr = "198.51.100.5:54321".parse().unwrap();
3485 let ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set())
3486 .with_reflex_addr(Some(reflex));
3487 let bytes = ann.to_bytes();
3488 let restored = CapabilityAnnouncement::from_bytes(&bytes).expect("parse");
3489 assert_eq!(restored.reflex_addr, Some(reflex));
3490 }
3491 #[test]
3492 fn reflex_addr_none_is_omitted_from_wire_bytes() {
3493 // The `skip_serializing_if = "Option::is_none"` on
3494 // `reflex_addr` is what preserves on-wire byte-compat
3495 // with pre-stage-2 announcements. The canonical bytes
3496 // must not mention `reflex_addr` at all when it's None —
3497 // otherwise pre-stage-2 nodes' signatures wouldn't
3498 // verify on post-stage-2 nodes (same shape of
3499 // compatibility guarantee as `hop_count`).
3500 let ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3501 let bytes = ann.to_bytes();
3502 let text = std::str::from_utf8(&bytes).expect("valid utf8");
3503 assert!(
3504 !text.contains("reflex_addr"),
3505 "reflex_addr key must be omitted when the field is None; got: {text}",
3506 );
3507 }
3508 // `signed_payload_stays_compatible_with_pre_hop_count_format`
3509 // intentionally removed in Phase A.5.N.3. That test pinned the
3510 // pre-hop_count byte-identical serialization so signatures
3511 // issued before that field landed could still verify after a
3512 // rolling upgrade. Phase A.5.N.3 changes the CapabilitySet
3513 // wire format outright (no more `hardware`/`software`/`models`/
3514 // `tools`/`limits` keys; just `tags` + `metadata`), so peers
3515 // must upgrade together — there is no rolling-upgrade path
3516 // across this commit. The hop_count omission contract itself
3517 // is still pinned by `hop_count_zero_omits_key_while_nonzero_keeps_it`.
3518
3519 #[test]
3520 fn hop_count_zero_omits_key_while_nonzero_keeps_it() {
3521 // Complements the cross-version compat test: proves the
3522 // serde predicate behaves as documented — hop_count=0 is
3523 // elided (old-format compat) but hop_count=N>0 survives on
3524 // the wire so forwarders can read + bump it.
3525 let caps = sample_capability_set();
3526 let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, caps);
3527
3528 let zero_bytes = ann.to_bytes();
3529 let zero_str = std::str::from_utf8(&zero_bytes).expect("utf8");
3530 assert!(
3531 !zero_str.contains("hop_count"),
3532 "hop_count=0 must be omitted from serialized output",
3533 );
3534
3535 ann.hop_count = 3;
3536 let bumped_bytes = ann.to_bytes();
3537 let bumped_str = std::str::from_utf8(&bumped_bytes).expect("utf8");
3538 assert!(
3539 bumped_str.contains("\"hop_count\":3"),
3540 "hop_count>0 must survive serialization so forwarders \
3541 can read + bump. Got: {}",
3542 bumped_str,
3543 );
3544 }
3545 // ========================================================================
3546 // Scope helpers (`matches_scope`) — scope tag resolution itself
3547 // is tested in `behavior::fold::capability_bridge::tests` under
3548 // `scope_from_membership_tags`.
3549 // ========================================================================
3550
3551 #[test]
3552 fn matches_scope_global_visible_to_tenant_filter() {
3553 // A peer that doesn't tag itself stays discoverable under
3554 // tenant queries — this is the v1-permissive default that
3555 // keeps existing announcements working when scoped queries
3556 // ship.
3557 let global = CapabilityScope::Global;
3558 assert!(matches_scope(
3559 &global,
3560 &ScopeFilter::Tenant("oem-123"),
3561 false
3562 ));
3563 assert!(matches_scope(
3564 &global,
3565 &ScopeFilter::Region("eu-west"),
3566 false
3567 ));
3568 assert!(matches_scope(&global, &ScopeFilter::Any, false));
3569
3570 // GlobalOnly filter: only Global candidates pass.
3571 assert!(matches_scope(&global, &ScopeFilter::GlobalOnly, false));
3572 let tenant_only = CapabilityScope::Tenants(vec!["foo".to_string()]);
3573 assert!(!matches_scope(
3574 &tenant_only,
3575 &ScopeFilter::GlobalOnly,
3576 false
3577 ));
3578 }
3579 #[test]
3580 fn matches_scope_subnet_local_excluded_from_any() {
3581 // SubnetLocal is opt-out from cross-subnet discovery: it
3582 // shows up only under SameSubnet (and only when the
3583 // caller-supplied predicate confirms membership).
3584 let sl = CapabilityScope::SubnetLocal;
3585 assert!(!matches_scope(&sl, &ScopeFilter::Any, false));
3586 assert!(!matches_scope(&sl, &ScopeFilter::Any, true));
3587 assert!(!matches_scope(&sl, &ScopeFilter::Tenant("foo"), true));
3588 assert!(!matches_scope(&sl, &ScopeFilter::GlobalOnly, true));
3589
3590 // SameSubnet with same_subnet=true admits SubnetLocal.
3591 assert!(matches_scope(&sl, &ScopeFilter::SameSubnet, true));
3592 // SameSubnet with same_subnet=false rejects SubnetLocal.
3593 assert!(!matches_scope(&sl, &ScopeFilter::SameSubnet, false));
3594
3595 // Tenant filter against a tenant-tagged candidate behaves
3596 // as expected — verifies the SubnetLocal branch isn't
3597 // bleeding into the tenant arm.
3598 let tenants = CapabilityScope::Tenants(vec!["oem-123".to_string()]);
3599 assert!(matches_scope(
3600 &tenants,
3601 &ScopeFilter::Tenant("oem-123"),
3602 false
3603 ));
3604 assert!(!matches_scope(
3605 &tenants,
3606 &ScopeFilter::Tenant("other"),
3607 false
3608 ));
3609 }
3610 // ========================================================================
3611 // CapabilitySet builders for reserved scope tags
3612 // ========================================================================
3613
3614 #[test]
3615 fn with_tenant_scope_appends_prefixed_tag() {
3616 let caps = CapabilitySet::new()
3617 .add_tag("gpu")
3618 .with_tenant_scope("oem-123");
3619 assert!(caps.has_tag("gpu"));
3620 assert!(caps.has_tag("scope:tenant:oem-123"));
3621
3622 // The builder writes the wire string the bridge's
3623 // `scope_from_membership_tags` matches on.
3624 let wire_tags: Vec<String> = caps.tags.iter().map(|t| t.to_string()).collect();
3625 let resolved =
3626 super::super::fold::capability_bridge::scope_from_membership_tags(&wire_tags);
3627 assert_eq!(
3628 resolved,
3629 CapabilityScope::Tenants(vec!["oem-123".to_string()]),
3630 );
3631 }
3632 #[test]
3633 fn with_tenant_scope_is_idempotent_and_drops_empty() {
3634 let caps = CapabilitySet::new()
3635 .with_tenant_scope("oem-123")
3636 .with_tenant_scope("oem-123") // duplicate
3637 .with_tenant_scope(""); // empty — silently dropped
3638 // Phase A.5.N.2: tags are typed; render to wire form
3639 // for prefix-string filtering.
3640 let tenant_tags: Vec<String> = caps
3641 .tags
3642 .iter()
3643 .map(|t| t.to_string())
3644 .filter(|s| s.starts_with(TAG_SCOPE_TENANT_PREFIX))
3645 .collect();
3646 assert_eq!(
3647 tenant_tags.len(),
3648 1,
3649 "duplicate not deduped: {:?}",
3650 caps.tags
3651 );
3652 assert_eq!(tenant_tags[0], "scope:tenant:oem-123");
3653 }
3654 #[test]
3655 fn with_region_and_subnet_local_scope_compose_with_resolver() {
3656 use super::super::fold::capability_bridge::scope_from_membership_tags;
3657 let to_wire = |caps: &CapabilitySet| -> Vec<String> {
3658 caps.tags.iter().map(|t| t.to_string()).collect()
3659 };
3660
3661 // Region builder produces a Regions scope.
3662 let caps_region = CapabilitySet::new().with_region_scope("eu-west");
3663 assert!(caps_region.has_tag("scope:region:eu-west"));
3664 assert_eq!(
3665 scope_from_membership_tags(&to_wire(&caps_region)),
3666 CapabilityScope::Regions(vec!["eu-west".to_string()]),
3667 );
3668
3669 // Empty region is dropped by the builder (matches the
3670 // resolver's empty-id rejection).
3671 let caps_empty_region = CapabilitySet::new().with_region_scope("");
3672 assert!(caps_empty_region.tags.is_empty());
3673
3674 // SubnetLocal builder is idempotent and dominates tenant
3675 // tags (strictest scope wins) — the resolver test below
3676 // is what locks in the precedence; the builder just has
3677 // to produce a list the resolver reads correctly.
3678 let caps_local = CapabilitySet::new()
3679 .with_tenant_scope("oem-123")
3680 .with_subnet_local_scope()
3681 .with_subnet_local_scope(); // idempotent
3682 let local_tags: Vec<String> = caps_local
3683 .tags
3684 .iter()
3685 .map(|t| t.to_string())
3686 .filter(|s| s.as_str() == TAG_SCOPE_SUBNET_LOCAL)
3687 .collect();
3688 assert_eq!(local_tags.len(), 1);
3689 assert_eq!(
3690 scope_from_membership_tags(&to_wire(&caps_local)),
3691 CapabilityScope::SubnetLocal
3692 );
3693 }
3694 // ========================================================================
3695 // Chain composition helpers — Phase 3 of CAPABILITY_ENHANCEMENTS_PLAN.md.
3696 // ========================================================================
3697
3698 fn reserved_tag(prefix: &str, body: &str) -> Tag {
3699 Tag::Reserved {
3700 prefix: prefix.to_string(),
3701 body: body.to_string(),
3702 }
3703 }
3704 #[test]
3705 fn require_chain_emits_causal_reserved_tag() {
3706 let caps = CapabilitySet::new().require_chain("abc123");
3707 assert!(caps.tags.contains(&reserved_tag("causal:", "abc123")));
3708 }
3709 #[test]
3710 fn require_chain_is_idempotent() {
3711 let caps = CapabilitySet::new()
3712 .require_chain("abc123")
3713 .require_chain("abc123");
3714 let causal_count = caps
3715 .tags
3716 .iter()
3717 .filter(|t| matches!(t, Tag::Reserved { prefix, .. } if prefix == "causal:"))
3718 .count();
3719 assert_eq!(causal_count, 1);
3720 }
3721 #[test]
3722 fn require_chain_drops_empty_hash() {
3723 let caps = CapabilitySet::new().require_chain("");
3724 assert!(caps.tags.is_empty());
3725 }
3726 #[test]
3727 fn require_chain_tip_emits_with_seq_separator() {
3728 let caps = CapabilitySet::new().require_chain_tip("abc", 100);
3729 assert!(caps.tags.contains(&reserved_tag("causal:", "abc:100")));
3730 }
3731 #[test]
3732 fn require_chain_range_emits_bracket_form() {
3733 let caps = CapabilitySet::new().require_chain_range("abc", 100, 200);
3734 assert!(caps
3735 .tags
3736 .contains(&reserved_tag("causal:", "abc[100..200]")));
3737 }
3738 #[test]
3739 fn require_chain_range_drops_inverted_or_equal_range() {
3740 // Equal range: silently dropped (zero-length range is meaningless).
3741 let caps = CapabilitySet::new().require_chain_range("abc", 100, 100);
3742 assert!(caps.tags.is_empty());
3743 // Inverted range: silently dropped.
3744 let caps = CapabilitySet::new().require_chain_range("abc", 200, 100);
3745 assert!(caps.tags.is_empty());
3746 }
3747 #[test]
3748 fn require_any_chain_emits_one_tag_per_hash() {
3749 let caps = CapabilitySet::new().require_any_chain(["abc", "def", "ghi"]);
3750 assert!(caps.tags.contains(&reserved_tag("causal:", "abc")));
3751 assert!(caps.tags.contains(&reserved_tag("causal:", "def")));
3752 assert!(caps.tags.contains(&reserved_tag("causal:", "ghi")));
3753 assert_eq!(caps.tags.len(), 3);
3754 }
3755 #[test]
3756 fn require_any_chain_skips_empty_hashes() {
3757 let caps = CapabilitySet::new().require_any_chain(["abc", "", "def"]);
3758 assert_eq!(caps.tags.len(), 2);
3759 }
3760 #[test]
3761 fn from_fork_emits_fork_of_reserved_tag() {
3762 let caps = CapabilitySet::new().from_fork("parent_hash");
3763 assert!(caps.tags.contains(&reserved_tag("fork-of:", "parent_hash")));
3764 }
3765 #[test]
3766 fn heat_level_emits_chain_hash_equals_rate_with_two_decimals() {
3767 let caps = CapabilitySet::new().heat_level("abc", 0.85);
3768 assert!(caps.tags.contains(&reserved_tag("heat:", "abc=0.85")));
3769 }
3770 #[test]
3771 fn heat_level_clamps_out_of_range_rate() {
3772 // Above 1.0 clamps to 1.00.
3773 let caps = CapabilitySet::new().heat_level("abc", 1.5);
3774 assert!(caps.tags.contains(&reserved_tag("heat:", "abc=1.00")));
3775 // Below 0.0 clamps to 0.00.
3776 let caps = CapabilitySet::new().heat_level("abc", -0.3);
3777 assert!(caps.tags.contains(&reserved_tag("heat:", "abc=0.00")));
3778 }
3779 #[test]
3780 fn heat_level_drops_non_finite_rate() {
3781 let caps = CapabilitySet::new().heat_level("abc", f64::NAN);
3782 assert!(caps.tags.is_empty());
3783 let caps = CapabilitySet::new().heat_level("abc", f64::INFINITY);
3784 assert!(caps.tags.is_empty());
3785 }
3786 #[test]
3787 fn chain_helpers_compose_naturally_in_a_builder_chain() {
3788 // Pinned: helpers chain ergonomically without intermediate
3789 // bindings or `.clone()`s. This is the contract that makes
3790 // the surface readable in operator code.
3791 let caps = CapabilitySet::new()
3792 .require_chain("origin-hash")
3793 .require_chain_tip("chain-with-tip", 1024)
3794 .require_chain_range("range-chain", 100, 500)
3795 .require_any_chain(["alt-1", "alt-2"])
3796 .from_fork("parent")
3797 .heat_level("origin-hash", 0.5);
3798 // Six emissions: 1 + 1 + 1 + 2 + 1 + 1 = 7 reserved tags.
3799 let reserved_count = caps
3800 .tags
3801 .iter()
3802 .filter(|t| matches!(t, Tag::Reserved { .. }))
3803 .count();
3804 assert_eq!(reserved_count, 7, "tags: {:?}", caps.tags);
3805 }
3806 // ========================================================================
3807 // View projections — `From<&CapabilitySet>` + `CapabilitySet::views`.
3808 // Phase A.4: pin the contract so Phase A.5's wire-format migration
3809 // doesn't drift the projection semantics.
3810 // ========================================================================
3811
3812 #[test]
3813 fn projection_hardware_round_trips_via_from_impl() {
3814 // Phase A.5.N.3: `From<&CapabilitySet>` reconstructs the
3815 // typed view by scanning the tag set. The round-trip
3816 // through builder → views → comparison pins the bijection
3817 // for hardware fields the codec covers.
3818 let hw_input = HardwareCapabilities::new().with_cpu(8, 16).with_memory(64);
3819 let caps = CapabilitySet::new().with_hardware(hw_input.clone());
3820 let hw_via_from: HardwareCapabilities = (&caps).into();
3821 assert_eq!(hw_via_from, hw_input);
3822 }
3823 #[test]
3824 fn projection_software_and_resource_limits_round_trip() {
3825 // Round-trip via builder → views for software and limits.
3826 let sw_input = SoftwareCapabilities::new().with_os("linux", "6.5");
3827 let limits_input = ResourceLimits::new()
3828 .with_max_concurrent(64)
3829 .with_rate_limit(100);
3830 let caps = CapabilitySet::new()
3831 .with_software(sw_input.clone())
3832 .with_limits(limits_input.clone());
3833 let sw: SoftwareCapabilities = (&caps).into();
3834 assert_eq!(sw, sw_input);
3835 let limits: ResourceLimits = (&caps).into();
3836 assert_eq!(limits, limits_input);
3837 }
3838 #[test]
3839 fn views_struct_returns_all_five_projections() {
3840 // Pin: `views()` returns the five typed projections together,
3841 // each lazily decoded on first access. Cheaper than reaching
3842 // for the From impls when the consumer reads more than one
3843 // axis (the OnceCell cache hits subsequent reads).
3844 let caps = sample_capability_set();
3845 let views = caps.views();
3846 // Round-trip via builder → views — assert the projection
3847 // is non-default for the fields the sample populates.
3848 assert!(views.hardware().memory_gb > 0);
3849 assert!(!views.models().is_empty());
3850 assert!(!views.tools().is_empty());
3851 }
3852 #[test]
3853 fn lazy_view_handle_caches_per_projection() {
3854 // Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`: each
3855 // projection is decoded at most once per handle. A second
3856 // read of the same projection returns the cached value
3857 // (proven via pointer-equality on the borrowed reference).
3858 let caps = sample_capability_set();
3859 let views = caps.views();
3860 let hw_ptr_1 = views.hardware() as *const _;
3861 let hw_ptr_2 = views.hardware() as *const _;
3862 assert_eq!(hw_ptr_1, hw_ptr_2, "hardware projection must be cached");
3863 let models_ptr_1 = views.models() as *const _;
3864 let models_ptr_2 = views.models() as *const _;
3865 assert_eq!(
3866 models_ptr_1, models_ptr_2,
3867 "models projection must be cached",
3868 );
3869 }
3870 // ========================================================================
3871 // Phase A.5.1: typed-tag access methods + wire-format snapshots.
3872 // ========================================================================
3873
3874 #[test]
3875 fn typed_tags_method_round_trips() {
3876 // `CapabilitySet::typed_tags()` and `from_typed_tags()`
3877 // are the future access pattern; pin the round-trip
3878 // contract here as inherent-method tests, mirroring the
3879 // standalone-function pin in `tag_codec`.
3880 let caps = sample_capability_set();
3881 let tag_set = caps.typed_tags();
3882 let caps2 = CapabilitySet::from_typed_tags(&tag_set);
3883 // Phase A.5.N.3: round-trip is via the canonical tag set;
3884 // compare projections (tool schemas live in metadata so
3885 // they don't survive `from_typed_tags`, which gets only
3886 // the bare tag set).
3887 let v1 = caps.views();
3888 let v2 = caps2.views();
3889 assert_eq!(v1.hardware(), v2.hardware());
3890 assert_eq!(v1.models(), v2.models());
3891 assert_eq!(v1.resource_limits(), v2.resource_limits());
3892 // Tools' non-schema fields round-trip; schemas are dropped
3893 // (`from_typed_tags` produces empty metadata by design).
3894 let v1_tools = v1.tools();
3895 let v2_tools = v2.tools();
3896 assert_eq!(v1_tools.len(), v2_tools.len());
3897 for (a, b) in v1_tools.iter().zip(v2_tools.iter()) {
3898 assert_eq!(a.tool_id, b.tool_id);
3899 assert_eq!(a.name, b.name);
3900 assert_eq!(a.version, b.version);
3901 }
3902 }
3903 #[test]
3904 fn typed_tags_default_capability_set_is_empty() {
3905 // Pinned: a default CapabilitySet's typed-tag set is empty.
3906 // Future Phase A.5.2's wire-format change (omitting
3907 // empty-tag-set sets from the wire) depends on this.
3908 let caps = CapabilitySet::default();
3909 assert!(caps.typed_tags().is_empty());
3910 }
3911 // ========================================================================
3912 // CapabilitySet::diff tests (Phase 1 of CAPABILITY_ENHANCEMENTS_PLAN.md).
3913 // ========================================================================
3914
3915 #[test]
3916 fn diff_empty_vs_empty_is_empty() {
3917 let prev = CapabilitySet::default();
3918 let curr = CapabilitySet::default();
3919 let diff = curr.diff(&prev);
3920 assert!(diff.is_empty());
3921 assert!(diff.added_tags.is_empty());
3922 assert!(diff.removed_tags.is_empty());
3923 assert!(diff.changed_metadata.is_empty());
3924 }
3925 #[test]
3926 fn diff_against_empty_reports_full_added() {
3927 let prev = CapabilitySet::default();
3928 let curr = CapabilitySet::new()
3929 .add_tag("inference")
3930 .with_metadata("intent", "ml-training");
3931 let diff = curr.diff(&prev);
3932 assert!(!diff.is_empty());
3933 assert_eq!(diff.added_tags.len(), 1);
3934 let inference_tag = Tag::parse("inference").unwrap();
3935 assert!(diff.added_tags.contains(&inference_tag));
3936 assert!(diff.removed_tags.is_empty());
3937 assert_eq!(diff.changed_metadata.len(), 1);
3938 assert!(matches!(
3939 &diff.changed_metadata[0],
3940 MetadataChange::Added { key, value }
3941 if key == "intent" && value == "ml-training"
3942 ));
3943 }
3944 #[test]
3945 fn diff_added_and_removed_tags_are_separated() {
3946 // Distinct sets: prev has {a, b}, curr has {b, c}.
3947 // Diff must show added={c}, removed={a}; b is unchanged.
3948 let prev = CapabilitySet::new().add_tag("a").add_tag("b");
3949 let curr = CapabilitySet::new().add_tag("b").add_tag("c");
3950 let diff = curr.diff(&prev);
3951 let added: Vec<_> = diff.added_tags.iter().map(|t| t.to_string()).collect();
3952 let removed: Vec<_> = diff.removed_tags.iter().map(|t| t.to_string()).collect();
3953 assert_eq!(added, vec!["c".to_string()]);
3954 assert_eq!(removed, vec!["a".to_string()]);
3955 }
3956 #[test]
3957 fn diff_ignores_separator_form_on_axis_value_tags() {
3958 // Regression for CR-3: `Tag::AxisValue` PartialEq distinguishes
3959 // `=` vs `:`. A naive `HashSet::difference` would land two
3960 // semantically-identical tags as both Added and Removed.
3961 // The structural `DiffEngine::diff` was patched in 38612b61;
3962 // the companion `CapabilitySet::diff` API was not.
3963 use crate::adapter::net::behavior::tag::{AxisSeparator, Tag, TaxonomyAxis};
3964 let mut prev = CapabilitySet::new();
3965 prev.tags.insert(Tag::AxisValue {
3966 axis: TaxonomyAxis::Software,
3967 key: "os".to_string(),
3968 value: "linux".to_string(),
3969 separator: AxisSeparator::Eq,
3970 });
3971 let mut curr = CapabilitySet::new();
3972 curr.tags.insert(Tag::AxisValue {
3973 axis: TaxonomyAxis::Software,
3974 key: "os".to_string(),
3975 value: "linux".to_string(),
3976 separator: AxisSeparator::Colon,
3977 });
3978 let diff = curr.diff(&prev);
3979 assert!(
3980 diff.added_tags.is_empty(),
3981 "added tags should be empty for separator-only difference, got {:?}",
3982 diff.added_tags
3983 );
3984 assert!(
3985 diff.removed_tags.is_empty(),
3986 "removed tags should be empty for separator-only difference, got {:?}",
3987 diff.removed_tags
3988 );
3989 }
3990 #[test]
3991 fn diff_metadata_updated_for_value_change() {
3992 let prev = CapabilitySet::new().with_metadata("intent", "ml-training");
3993 let curr = CapabilitySet::new().with_metadata("intent", "embedding");
3994 let diff = curr.diff(&prev);
3995 assert!(diff.added_tags.is_empty());
3996 assert!(diff.removed_tags.is_empty());
3997 assert_eq!(diff.changed_metadata.len(), 1);
3998 match &diff.changed_metadata[0] {
3999 MetadataChange::Updated {
4000 key,
4001 prev_value,
4002 new_value,
4003 } => {
4004 assert_eq!(key, "intent");
4005 assert_eq!(prev_value, "ml-training");
4006 assert_eq!(new_value, "embedding");
4007 }
4008 other => panic!("expected Updated, got {other:?}"),
4009 }
4010 }
4011 #[test]
4012 fn diff_metadata_key_rename_is_remove_plus_add_not_update() {
4013 // Pinned: a key rename surfaces as Removed + Added, NOT
4014 // as Updated. Key identity changes are semantically
4015 // distinct from value-of-same-key changes.
4016 let prev = CapabilitySet::new().with_metadata("old-key", "v");
4017 let curr = CapabilitySet::new().with_metadata("new-key", "v");
4018 let diff = curr.diff(&prev);
4019 assert_eq!(diff.changed_metadata.len(), 2);
4020 // BTreeMap iteration is sorted, so "new-key" comes before "old-key".
4021 let kinds: Vec<_> = diff
4022 .changed_metadata
4023 .iter()
4024 .map(|c| match c {
4025 MetadataChange::Added { key, .. } => format!("added:{key}"),
4026 MetadataChange::Removed { key, .. } => format!("removed:{key}"),
4027 MetadataChange::Updated { key, .. } => format!("updated:{key}"),
4028 })
4029 .collect();
4030 assert!(
4031 kinds.contains(&"added:new-key".to_string())
4032 && kinds.contains(&"removed:old-key".to_string()),
4033 "expected Added(new-key) + Removed(old-key); got {kinds:?}"
4034 );
4035 }
4036 #[test]
4037 fn diff_changed_metadata_preserves_btreemap_ordering() {
4038 // BTreeMap iteration order is stable + sorted. The diff
4039 // walk emits changes in key order so consumers can rely
4040 // on deterministic output.
4041 let prev = CapabilitySet::default();
4042 let curr = CapabilitySet::new()
4043 .with_metadata("zebra", "z")
4044 .with_metadata("alpha", "a")
4045 .with_metadata("middle", "m");
4046 let diff = curr.diff(&prev);
4047 let keys: Vec<_> = diff
4048 .changed_metadata
4049 .iter()
4050 .map(|c| match c {
4051 MetadataChange::Added { key, .. }
4052 | MetadataChange::Removed { key, .. }
4053 | MetadataChange::Updated { key, .. } => key.clone(),
4054 })
4055 .collect();
4056 assert_eq!(keys, vec!["alpha", "middle", "zebra"]);
4057 }
4058 #[test]
4059 fn diff_round_trips_via_apply_diff_on_canonical_diff_engine() {
4060 // Property-style: applying the structural DiffEngine ops
4061 // computed from `prev → curr` produces a CapabilitySet
4062 // whose tags + metadata match `curr`. The two diff surfaces
4063 // (this method's set/map diff, DiffEngine's structural ops)
4064 // are different shapes of the same change information; this
4065 // test pins they agree on the underlying state transition.
4066 use crate::adapter::net::behavior::diff::{CapabilityDiff, DiffEngine};
4067
4068 let prev = CapabilitySet::new()
4069 .add_tag("inference")
4070 .with_metadata("intent", "old");
4071 let curr = prev
4072 .clone()
4073 .add_tag("training")
4074 .with_metadata("intent", "new")
4075 .with_metadata("colocate-with", "chain-a");
4076 // DiffEngine produces structural ops; apply them to prev
4077 // and assert tags + metadata match curr (state convergence).
4078 let ops = DiffEngine::diff(&prev, &curr);
4079 let applied =
4080 DiffEngine::apply_with_version(&prev, 1, &CapabilityDiff::new(1, 1, 2, ops), true)
4081 .unwrap();
4082 assert_eq!(applied.tags, curr.tags);
4083 // DiffEngine doesn't emit metadata ops yet; metadata diff
4084 // ships separately and is consumed by event-driven listeners,
4085 // not by the diff-apply propagation path. Pin the contract
4086 // here so a future DiffEngine extension that adds metadata
4087 // ops doesn't accidentally regress this surface.
4088 let cset_diff = curr.diff(&prev);
4089 assert!(!cset_diff.is_empty());
4090 assert_eq!(cset_diff.changed_metadata.len(), 2);
4091 }
4092 #[test]
4093 fn wire_format_serialization_snapshot() {
4094 // Pin the post-Phase-A.5.N.3 wire format. CapabilitySet
4095 // ships exactly two top-level keys now: `tags` (the
4096 // canonical tag-set, holding axis-prefixed + reserved +
4097 // legacy entries as a JSON string array via Tag's
4098 // custom serde) and `metadata` (a free-form key-value
4099 // map). Hardware / software / models / tools / limits
4100 // fields no longer exist on the wire — their content is
4101 // encoded as tags.
4102 let caps = CapabilitySet::new()
4103 .with_hardware(HardwareCapabilities::new().with_cpu(8, 16))
4104 .add_tag("inference");
4105 let json = String::from_utf8(caps.to_bytes()).unwrap();
4106 assert!(json.contains("\"tags\":"), "missing tags field: {json}");
4107 assert!(
4108 json.contains("\"metadata\":"),
4109 "missing metadata field: {json}"
4110 );
4111 // The legacy untyped tag rides through unchanged.
4112 assert!(json.contains("\"inference\""), "missing legacy tag: {json}");
4113 // Hardware fields are encoded as axis tags inside `tags`.
4114 assert!(
4115 json.contains("\"hardware.cpu_cores=8\""),
4116 "missing hardware.cpu_cores=8 tag: {json}",
4117 );
4118 assert!(
4119 json.contains("\"hardware.cpu_threads=16\""),
4120 "missing hardware.cpu_threads=16 tag: {json}",
4121 );
4122 // Old top-level typed-struct keys are gone.
4123 assert!(
4124 !json.contains("\"hardware\":"),
4125 "stale hardware key: {json}"
4126 );
4127 assert!(
4128 !json.contains("\"software\":"),
4129 "stale software key: {json}"
4130 );
4131 assert!(!json.contains("\"models\":"), "stale models key: {json}");
4132 assert!(!json.contains("\"tools\":"), "stale tools key: {json}");
4133 assert!(!json.contains("\"limits\":"), "stale limits key: {json}");
4134 }
4135 #[test]
4136 fn wire_format_round_trips_through_json() {
4137 // Pinned: a CapabilitySet round-trips through `to_bytes` →
4138 // `from_bytes`. Phase A.5.N.2's wire format change must
4139 // preserve this property — a CapabilitySet built via the
4140 // typed builder methods then serialized then deserialized
4141 // produces an equal value. Test against a non-trivial
4142 // capability set to exercise every field.
4143 let caps = sample_capability_set();
4144 let bytes = caps.to_bytes();
4145 let caps2 = CapabilitySet::from_bytes(&bytes).expect("round-trip parses");
4146 assert_eq!(caps, caps2);
4147 }
4148
4149 #[test]
4150 fn compact_wire_format_round_trips_and_interops_with_json() {
4151 // Pinned: postcard-format round-trip preserves the
4152 // CapabilitySet AND a single `from_bytes` accepts both
4153 // encodings. Rollout from JSON to compact requires that any
4154 // peer running this code can read either format.
4155 let caps = sample_capability_set();
4156 let json_bytes = caps.to_bytes();
4157 let compact_bytes = caps.to_bytes_compact();
4158 assert_eq!(json_bytes.first(), Some(&b'{'));
4159 assert_eq!(compact_bytes.first(), Some(&COMPACT_FORMAT_TAG));
4160 assert!(
4161 compact_bytes.len() < json_bytes.len(),
4162 "compact ({} bytes) should be smaller than JSON ({} bytes)",
4163 compact_bytes.len(),
4164 json_bytes.len()
4165 );
4166 let from_json = CapabilitySet::from_bytes(&json_bytes).expect("json parses");
4167 let from_compact = CapabilitySet::from_bytes(&compact_bytes).expect("compact parses");
4168 assert_eq!(caps, from_json);
4169 assert_eq!(caps, from_compact);
4170 }
4171
4172 #[test]
4173 fn from_bytes_rejects_unknown_format_tag() {
4174 // Any leading byte other than `b'{'` or COMPACT_FORMAT_TAG
4175 // is a forward-compat unknown version — we reject loudly
4176 // rather than mis-decode.
4177 assert!(CapabilitySet::from_bytes(&[0xFF]).is_none());
4178 assert!(CapabilitySet::from_bytes(&[]).is_none());
4179 // Empty postcard body after the version tag is also invalid.
4180 assert!(CapabilitySet::from_bytes(&[COMPACT_FORMAT_TAG]).is_none());
4181 }
4182
4183 #[test]
4184 fn typed_tags_includes_legacy_string_tags() {
4185 // Pinned: legacy `Vec<String>` tags appear in the typed-
4186 // tag set as `Tag::Legacy` / `Tag::Reserved` / parsed
4187 // axis tags. Downstream code reading via `typed_tags()`
4188 // sees them all uniformly.
4189 use crate::adapter::net::behavior::tag::Tag as TagT;
4190 let caps = CapabilitySet::new()
4191 .add_tag("inference")
4192 .with_tenant_scope("acme");
4193 let tag_set = caps.typed_tags();
4194 // "inference" → Legacy
4195 assert!(tag_set
4196 .iter()
4197 .any(|t| matches!(t, TagT::Legacy(s) if s == "inference")));
4198 // "scope:tenant:acme" → Reserved
4199 assert!(tag_set
4200 .iter()
4201 .any(|t| matches!(t, TagT::Reserved { prefix, body }
4202 if prefix == "scope:" && body == "tenant:acme")));
4203 }
4204}