net/adapter/net/behavior/fold/capability.rs
1//! `CapabilityFold` — per-publisher capability membership.
2//!
3//! Each `(class_hash, publisher_node_id)` pair carries at most
4//! one entry whose payload describes what the publisher claims
5//! about its own membership in that capability class — tags,
6//! hardware summary, current state, optional region + price
7//! quote.
8//!
9//! Replaces the deleted `behavior::capability::CapabilityIndex` —
10//! see `docs/internal/plans/MULTIFOLD_PHASE_3B_CUTOVER.md` for the
11//! end-to-end cutover that landed.
12//!
13//! Tags ship as canonical `String`s — the same form the legacy
14//! [`Tag`](super::super::tag::Tag) enum would emit when
15//! displayed — to keep the wire envelope parseable by operator
16//! tools regardless of the in-memory shape downstream.
17//!
18//! Key shape: `(class_hash, publisher_node_id)`. The publisher's
19//! `node_id` IS the key component, so each publisher writes only
20//! its own entries. Unlike [`RoutingFold`](super::routing) (where
21//! multiple publishers compete for a shared destination key),
22//! the security model here is trivial: signature verification at
23//! dispatch time gates the publisher claim; the key shape gates
24//! which entries that publisher may write.
25
26use std::collections::{BTreeMap, HashMap, HashSet};
27use std::time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use super::state::{FoldIndex, FoldState, FxU64Hasher, NodeId};
32use super::FoldKind;
33
34/// Coarse-grained node state for capability matching. The
35/// scheduler / market matcher filters on this when picking
36/// candidates: an `Idle` node is a candidate, a `Faulty` node
37/// is not.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum NodeState {
41 /// Node is idle and accepting work.
42 Idle,
43 /// Node is running work but might still accept more.
44 Busy,
45 /// Node has been reserved by a scheduler; not currently
46 /// accepting placement decisions from other schedulers.
47 Reserved,
48 /// Node is known unhealthy. Don't place on it.
49 Faulty,
50}
51
52/// Lightweight hardware-summary the scheduler reads when
53/// filtering candidates by hardware shape. NOT a complete
54/// hardware inventory — the legacy
55/// [`HardwareCapabilities`](super::super::capability::HardwareCapabilities)
56/// struct stays the source of truth; this is the small
57/// always-shipped projection that callers want to filter on
58/// without paying for the full announcement.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
60pub struct HardwareSummary {
61 /// GPU vendor string (canonical lowercase: `"nvidia"`,
62 /// `"amd"`, `"intel"`). `None` if the node has no GPU.
63 pub gpu_vendor: Option<String>,
64 /// GPU count.
65 pub gpu_count: u8,
66 /// System memory in gigabytes. `None` if unknown.
67 pub memory_gb: Option<u32>,
68 /// Total GPU video memory in gigabytes (sum across all
69 /// installed GPUs). `None` if the node has no GPU or the
70 /// publisher didn't fill it.
71 pub vram_gb: Option<u32>,
72}
73
74/// Wire payload for one capability announcement. The publisher
75/// declares its own membership in `class_hash`.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct CapabilityMembership {
78 /// Capability class this announcement is about. Each
79 /// announcement covers one (class, publisher) pair; a
80 /// publisher in multiple classes emits one announcement
81 /// per class.
82 pub class_hash: u64,
83 /// Canonical-form tag strings the publisher claims
84 /// (e.g. `"hardware.gpu"`, `"hardware.gpu.vram_gb=80"`,
85 /// `"causal:<hex>"`). See the module doc on tag
86 /// representation.
87 pub tags: Vec<String>,
88 /// Optional hardware projection for fast filtering.
89 pub hardware: Option<HardwareSummary>,
90 /// Current state — the load-bearing filter for the
91 /// scheduler's "find idle candidates" path.
92 pub state: NodeState,
93 /// Optional region string. Free-form; operator chooses
94 /// the granularity (`"us-east"`, `"us-east.dc-1"`, etc.).
95 pub region: Option<String>,
96 /// Optional price-per-unit quote for compute-marketplace
97 /// workloads. Units intentionally opaque (operator
98 /// decides — could be µ$/sec, µ$/job, µ$/GPU-hour).
99 pub price_quote: Option<u64>,
100 /// Publisher's last-advertised public reflex `SocketAddr`.
101 /// Used by NAT-traversal rendezvous (stage 3) to look up
102 /// the punch target's public address. The publisher emits
103 /// this whenever it observes its own public side via a
104 /// reflex probe; receivers cache it across class entries
105 /// (one publisher tends to publish the same reflex across
106 /// every class it joins).
107 pub reflex_addr: Option<std::net::SocketAddr>,
108 /// v0.4 capability-auth allow-list — peer `node_id`s
109 /// authorized to invoke any of this publisher's `tags`. Empty
110 /// = unrestricted (permissive default). Union semantics with
111 /// `allowed_subnets` and `allowed_groups`; the caller is
112 /// admitted if it matches at least one populated axis.
113 pub allowed_nodes: Vec<u64>,
114 /// v0.4 capability-auth allow-list — caller subnets authorized
115 /// to invoke this publisher's tags. Same union semantics as
116 /// `allowed_nodes`.
117 pub allowed_subnets: Vec<super::super::subnet::SubnetId>,
118 /// v0.4 capability-auth allow-list — caller groups authorized
119 /// to invoke this publisher's tags. Same union semantics as
120 /// `allowed_nodes`.
121 pub allowed_groups: Vec<super::super::group::GroupId>,
122 /// Free-form per-publisher metadata. Carries the same opaque
123 /// key/value pairs the legacy
124 /// [`CapabilitySet::metadata`](super::super::capability::CapabilitySet)
125 /// exposes; predicates that test `metadata_exists`/
126 /// `metadata_equals` consult this map after `synthesize_capability_set`
127 /// hydrates the synthesized set from the fold.
128 pub metadata: BTreeMap<String, String>,
129 /// OA-1 ownership projection — the publisher's verified owner,
130 /// populated ONLY when BOTH the enclosing announcement
131 /// signature AND the embedded `owner_cert` passed ingest
132 /// verification (announcement signature, cert signature,
133 /// window, `member == entity_id` binding, revocation floors).
134 /// `None` for unowned publishers, for unsigned announcements
135 /// (a valid replayed cert must not lend ownership to an
136 /// unauthenticated capability statement — review-8 §1), and
137 /// for announcements whose cert failed verification (the cert
138 /// is dropped; the entry is kept).
139 ///
140 /// Unlike `allowed_*` / tag-derived axes this is not
141 /// self-declared — it is proven belonging. It is also NOT
142 /// execution authority: `may_execute` never consults it
143 /// (`ORG_CAPABILITY_AUTH_PLAN.md`, authority-dark OA-1).
144 ///
145 /// `#[serde(skip)]` is load-bearing twice over: (1) the fold
146 /// payload rides `SUBPROTOCOL_FOLD` as positional postcard, so
147 /// a serialized field would break every mixed-fleet fold frame
148 /// at upgrade time; (2) a wire-carried owner would be a
149 /// SELF-DECLARED ownership claim — the projection must only
150 /// ever be derived on the receiving node from a cert it
151 /// verified itself, and fold state (including snapshots) is
152 /// never admission evidence. Decode always yields `None`.
153 #[serde(skip)]
154 pub owner: Option<VerifiedOwner>,
155}
156
157/// An ingest-verified ownership projection: WHICH ENTITY published,
158/// which org vouched for it, and at which certificate generation.
159/// The generation is retained so a rising revocation floor can
160/// retract exactly the projections that fell below it — no
161/// re-announcement required, no still-valid (higher-generation)
162/// projection over-cleared (review-8 §9).
163///
164/// The `member` is retained (review-10 P1-1) because a `NodeId` is
165/// the low 8 bytes of an entity id and therefore NOT an identity: a
166/// consumer that reads a projection under one snapshot and then
167/// resolves `NodeId → EntityId` through the live session pin can
168/// pair one publisher's verified owner with a DIFFERENT entity that
169/// currently holds the pin. Carrying the verified publisher inside
170/// the projection lets such a consumer compare against the exact
171/// entity whose cert was checked, instead of trusting the node id to
172/// name it.
173///
174/// Construction is `pub(crate)` and the fields are private
175/// (review-9): the verification bridge
176/// (`capability_bridge::verify_announced_owner_cert`) is the only
177/// legitimate producer, so a caller outside this crate cannot
178/// synthesize an unverified "verified" projection and feed it
179/// through `translate_announcement`.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct VerifiedOwner {
182 /// The publisher whose owner cert verified at ingest, as raw
183 /// bytes rather than an `EntityId` so this projection stays
184 /// `Copy` — the ingest path passes it by value four times per
185 /// announcement and `EntityId` is deliberately `Clone`-only.
186 member: [u8; 32],
187 /// The organization whose certificate verified at ingest.
188 org: super::super::org::OrgId,
189 /// The verified certificate's revocation generation.
190 generation: u32,
191}
192
193impl VerifiedOwner {
194 /// In-crate constructor — the verification bridge is the only
195 /// legitimate producer; everything else consumes.
196 pub(crate) fn new(
197 member: &crate::adapter::net::identity::EntityId,
198 org: super::super::org::OrgId,
199 generation: u32,
200 ) -> Self {
201 Self {
202 member: *member.as_bytes(),
203 org,
204 generation,
205 }
206 }
207
208 /// The verified publishing entity.
209 #[inline]
210 pub fn member(&self) -> crate::adapter::net::identity::EntityId {
211 crate::adapter::net::identity::EntityId::from_bytes(self.member)
212 }
213
214 /// The verified publishing entity, as raw bytes — the
215 /// comparison form, free of the `EntityId` reconstruction.
216 #[inline]
217 pub fn member_bytes(&self) -> &[u8; 32] {
218 &self.member
219 }
220
221 /// The vouching organization.
222 #[inline]
223 pub fn org(&self) -> super::super::org::OrgId {
224 self.org
225 }
226
227 /// The verified certificate's revocation generation.
228 #[inline]
229 pub fn generation(&self) -> u32 {
230 self.generation
231 }
232}
233
234/// Query shapes the [`CapabilityFold`] answers.
235///
236/// `Composite` is the kitchen-sink form the scheduler uses;
237/// individual single-axis variants exist so simpler callers
238/// don't have to construct the full struct.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum CapabilityQuery {
241 /// Every member of a class regardless of state / tags.
242 InClass(u64),
243 /// Every entry carrying ALL of these tags. Set semantics —
244 /// tags-all over an empty list matches everything.
245 HasAllTags(Vec<String>),
246 /// Every entry carrying AT LEAST ONE of these tags. Empty
247 /// list matches nothing (vs `HasAllTags` empty matching
248 /// everything — same asymmetric semantic the substrate
249 /// uses for `require_any_tag` / `require_all_tags`).
250 HasAnyTag(Vec<String>),
251 /// Every entry currently in `state`.
252 InState(NodeState),
253 /// Every entry in `region` (exact string match).
254 InRegion(String),
255 /// Composite predicate — the scheduler's typical shape.
256 /// Conjunctive AND across every populated field.
257 Composite(CapabilityFilter),
258}
259
260/// Composite filter for [`CapabilityQuery::Composite`]. Every
261/// `None` / empty field is "no constraint on this axis"; every
262/// populated field tightens the candidate set.
263#[derive(Debug, Clone, PartialEq, Eq, Default)]
264pub struct CapabilityFilter {
265 /// Restrict to this class (None = any class).
266 pub class: Option<u64>,
267 /// Tags the entry MUST carry (intersection).
268 pub tags_all: Vec<String>,
269 /// Tags the entry must carry at least one of (union).
270 /// Empty = no constraint.
271 pub tags_any: Vec<String>,
272 /// Conjunction of disjunctions: the entry must carry at least
273 /// one tag from *every* group (AND across groups, OR within a
274 /// group). Used for filter axes whose legacy semantics are
275 /// "any of these must match" but which AND with the other
276 /// axes — `require_models`, `require_tools`, `require_gpu`,
277 /// `gpu_vendor` — encoded as the index-only synthetic tags
278 /// `derive_synthetic_index_tags` produces. Empty = no
279 /// constraint.
280 pub tag_groups_all: Vec<Vec<String>>,
281 /// State filter (None = any).
282 pub state: Option<NodeState>,
283 /// Region filter (None = any).
284 pub region: Option<String>,
285 /// Optional result cap. `0` = no cap.
286 pub limit: usize,
287}
288
289impl CapabilityFilter {
290 /// `true` when no field constrains the candidate set — every
291 /// node in the fold is admissible. Per PERF_AUDIT §4.11 the
292 /// bulk `find_nodes_matching` path short-circuits on this so
293 /// the permissive case (e.g. `LegacyPlacement::permissive`)
294 /// skips the full `HashSet<(class, NodeId)>` build + retain
295 /// loop + sort + dedup that the general path runs.
296 #[inline]
297 pub fn is_permissive(&self) -> bool {
298 self.class.is_none()
299 && self.tags_all.is_empty()
300 && self.tags_any.is_empty()
301 && self.tag_groups_all.is_empty()
302 && self.state.is_none()
303 && self.region.is_none()
304 }
305}
306
307/// One query result row.
308pub type CapabilityMatch = ((u64, NodeId), CapabilityMembership);
309
310/// Secondary index maintained alongside the primary
311/// `(class, node) → CapabilityMembership` store. Three
312/// inverted-index dimensions — by tag, by region, by state —
313/// matching the plan's `CapabilityIndexInner` shape. Powers the
314/// fast path for the most common query shapes (find-by-tag,
315/// find-in-region, find-by-state) without scanning the full
316/// store. `Composite` queries pick the most selective indexed
317/// dimension and filter the others in-memory.
318///
319/// **PERF_AUDIT §4.6** — the inner `HashSet<(u64, NodeId)>` candidate
320/// sets use `BuildU64TupleHasher` (private to this module), a fast
321/// multiplicative mixer for
322/// `(u64, u64)` keys. Pre-fix these used the std SipHash default,
323/// which paid ~15-25 ns of mixing per insert/contains/remove on keys
324/// that are already xxh3-hashed identity bytes (so collision
325/// resistance is already there at construction; SipHash's DoS
326/// resistance adds zero protection). The outer `HashMap<String, _>` /
327/// `HashMap<NodeState, _>` keep the default hasher: tag / region
328/// strings come from publishers and the SipHash protection is
329/// legitimately relevant there.
330#[derive(Debug, Default)]
331pub struct CapabilityIndexInner {
332 /// tag → set of (class, node) keys carrying that tag.
333 by_tag: HashMap<String, HashSet<(u64, NodeId), BuildU64TupleHasher>>,
334 /// Index-only synthetic tag (`model:`/`tool:`/`gpu:`) → set of
335 /// (class, node) keys. Kept in a SEPARATE map from `by_tag` so a
336 /// raw published tag string can never collide with a synthetic
337 /// key: published tags are arbitrary strings (`Tag::Legacy`
338 /// round-trips verbatim), so a publisher emitting a plain
339 /// `"model:llama3"` tag must not be able to satisfy a
340 /// `require_models` query it lacks the real bundle for. The bulk
341 /// model/tool/gpu axes (`tag_groups_all`) resolve against this
342 /// map only — see [`group_union`].
343 by_synthetic: HashMap<String, HashSet<(u64, NodeId), BuildU64TupleHasher>>,
344 /// region → set of (class, node) keys.
345 by_region: HashMap<String, HashSet<(u64, NodeId), BuildU64TupleHasher>>,
346 /// state → set of (class, node) keys.
347 by_state: HashMap<NodeState, HashSet<(u64, NodeId), BuildU64TupleHasher>>,
348}
349
350/// Fast multiplicative `(u64, u64)` mixer for the inverted-index
351/// candidate sets. Per PERF_AUDIT §4.6 — see [`CapabilityIndexInner`]
352/// for the threat-model rationale (keys come from already-verified
353/// announcements; SipHash DoS resistance is irrelevant).
354///
355/// `Hash for (u64, u64)` is `write_u64(self.0); write_u64(self.1);`,
356/// so [`FxU64Hasher`]'s `write_u64` step mixes the pair correctly in
357/// 2 multiplications, and its byte fallback covers a future change to
358/// the tuple's hash impl. Nothing about the mixer is arity-specific —
359/// this alias and `state::BuildU64Hasher` differ only in which keys
360/// they are pointed at, so they share one implementation rather than
361/// two copies that can drift apart.
362pub(crate) type BuildU64TupleHasher = std::hash::BuildHasherDefault<FxU64Hasher>;
363
364impl FoldIndex<CapabilityFold> for CapabilityIndexInner {
365 fn on_insert(&mut self, key: &(u64, NodeId), payload: &CapabilityMembership) {
366 for tag in &payload.tags {
367 self.by_tag.entry(tag.clone()).or_default().insert(*key);
368 }
369 // Index-only synthetic tags (model:/tool:/gpu:) live in
370 // their own `by_synthetic` map so the model / tool / gpu
371 // filter axes resolve without a per-query full scan and
372 // without risking collision against a raw published tag of
373 // the same string. Parsed once here at insert, never per
374 // query.
375 for tag in derive_synthetic_index_tags(payload) {
376 self.by_synthetic.entry(tag).or_default().insert(*key);
377 }
378 if let Some(region) = &payload.region {
379 self.by_region
380 .entry(region.clone())
381 .or_default()
382 .insert(*key);
383 }
384 self.by_state.entry(payload.state).or_default().insert(*key);
385 }
386
387 fn on_remove(&mut self, key: &(u64, NodeId), payload: &CapabilityMembership) {
388 for tag in &payload.tags {
389 if let Some(set) = self.by_tag.get_mut(tag) {
390 set.remove(key);
391 if set.is_empty() {
392 self.by_tag.remove(tag);
393 }
394 }
395 }
396 // Mirror the synthetic tags added in `on_insert`. Derived
397 // from the same payload, so the set is identical.
398 for tag in derive_synthetic_index_tags(payload) {
399 if let Some(set) = self.by_synthetic.get_mut(&tag) {
400 set.remove(key);
401 if set.is_empty() {
402 self.by_synthetic.remove(&tag);
403 }
404 }
405 }
406 if let Some(region) = &payload.region {
407 if let Some(set) = self.by_region.get_mut(region) {
408 set.remove(key);
409 if set.is_empty() {
410 self.by_region.remove(region);
411 }
412 }
413 }
414 if let Some(set) = self.by_state.get_mut(&payload.state) {
415 set.remove(key);
416 if set.is_empty() {
417 self.by_state.remove(&payload.state);
418 }
419 }
420 }
421
422 fn clear(&mut self) {
423 self.by_tag.clear();
424 self.by_synthetic.clear();
425 self.by_region.clear();
426 self.by_state.clear();
427 }
428
429 /// PERF_AUDIT §4.5 — `on_insert` keys this index on
430 /// `(tags, derived synthetic tags, region, state)`. If the two
431 /// payloads agree on every one of those, an on_remove +
432 /// on_insert against them nets to a no-op on every bucket
433 /// (`derive_synthetic_index_tags` is pure over the payload, so
434 /// identical inputs produce identical synthetic outputs).
435 ///
436 /// The synthetic tags derive from TWO payload fields: the
437 /// `software.model.*` / `software.tool.*` bundles inside
438 /// `tags` (covered by the `tags` equality) AND the
439 /// `gpu:present` / `gpu:vendor:<v>` projection of `hardware`
440 /// — so `hardware` MUST be part of this comparison or a
441 /// refresh that changes only the GPU shape would leave
442 /// `by_synthetic` stale. Comparing the whole
443 /// `HardwareSummary` is slightly conservative (a
444 /// memory_gb/vram_gb-only delta forces a rebuild the index
445 /// doesn't strictly need), but the steady-state refresh the
446 /// audit targets keeps hardware identical, so the win is
447 /// unaffected and the check stays future-proof against new
448 /// hardware-derived synthetic tags. Allow-lists / metadata /
449 /// price_quote / reflex_addr are NOT consulted by this index
450 /// and may differ freely.
451 fn index_payload_equivalent(old: &CapabilityMembership, new: &CapabilityMembership) -> bool {
452 old.state == new.state
453 && old.region == new.region
454 && old.tags == new.tags
455 && old.hardware == new.hardware
456 }
457}
458
459/// Derive the index-only synthetic tags for a membership: the
460/// `model:<id>` / `tool:<id>` / `gpu:present` / `gpu:vendor:<v>`
461/// keys that let the secondary index resolve the filter axes the
462/// plain-tag index doesn't natively carry.
463///
464/// These live ONLY in the index — they are never written into
465/// `payload.tags`, so tag enumeration (`capability_tags_for`) is
466/// unaffected. Models / tools are read from the canonical
467/// `software.model.<i>.id=<v>` / `software.tool.<i>.tool_id=<v>`
468/// bundles using the same `Tag::AxisValue` shape
469/// `CapabilitySet::has_model` / `has_tool` match; GPU presence /
470/// vendor come from the hardware projection, matching the legacy
471/// `require_gpu` (`gpu_count > 0 || gpu_vendor.is_some()`) and
472/// `gpu_vendor` predicates.
473///
474/// Must stay the exact inverse of the tags `translate_filter`
475/// emits, or model / tool / gpu queries silently diverge between
476/// the bulk index path and the single-target post-filter path —
477/// `target_matches_filter_agrees_with_find_nodes_matching` guards
478/// this.
479fn derive_synthetic_index_tags(payload: &CapabilityMembership) -> Vec<String> {
480 use super::super::tag::{Tag, TaxonomyAxis};
481 let mut out = Vec::new();
482 for s in &payload.tags {
483 let Ok(Tag::AxisValue {
484 axis: TaxonomyAxis::Software,
485 key,
486 value,
487 ..
488 }) = Tag::parse(s)
489 else {
490 continue;
491 };
492 if let Some(rest) = key.strip_prefix("model.") {
493 if matches!(rest.split_once('.'), Some((_, "id"))) {
494 out.push(format!("model:{value}"));
495 }
496 } else if let Some(rest) = key.strip_prefix("tool.") {
497 if matches!(rest.split_once('.'), Some((_, "tool_id"))) {
498 out.push(format!("tool:{value}"));
499 }
500 }
501 }
502 if let Some(h) = &payload.hardware {
503 if h.gpu_count > 0 || h.gpu_vendor.is_some() {
504 out.push("gpu:present".to_string());
505 }
506 if let Some(vendor) = &h.gpu_vendor {
507 out.push(format!("gpu:vendor:{vendor}"));
508 }
509 }
510 out
511}
512
513/// Marker type for the [`FoldKind`] impl.
514#[derive(Debug)]
515pub struct CapabilityFold;
516
517impl FoldKind for CapabilityFold {
518 /// Reserved built-in fold id `1` per the plan's
519 /// "Reserved range" note in [`FoldKind::KIND_ID`].
520 const KIND_ID: u16 = 1;
521 const CHANNEL_PREFIX: &'static str = "fold:cap:";
522 /// 60-second TTL matches the plan's recommendation: the
523 /// background sweeper removes stale memberships that
524 /// haven't been refreshed within a minute. Operator-tuned
525 /// per-announcement TTLs override.
526 const DEFAULT_TTL: Duration = Duration::from_secs(60);
527
528 type Key = (u64, NodeId);
529 type Payload = CapabilityMembership;
530 type Query = CapabilityQuery;
531 type Result = Vec<CapabilityMatch>;
532 type Index = CapabilityIndexInner;
533
534 fn key_for(node_id: NodeId, payload: &Self::Payload) -> Self::Key {
535 (payload.class_hash, node_id)
536 }
537
538 fn build_index() -> CapabilityIndexInner {
539 CapabilityIndexInner::default()
540 }
541
542 fn query(
543 state: &FoldState<Self>,
544 index: &CapabilityIndexInner,
545 query: CapabilityQuery,
546 ) -> Vec<CapabilityMatch> {
547 match query {
548 CapabilityQuery::InClass(class) => state
549 .entries
550 .iter()
551 .filter(|((c, _), _)| *c == class)
552 .map(|(k, e)| (*k, e.payload.clone()))
553 .collect(),
554 CapabilityQuery::HasAllTags(tags) => resolve_keys_all_tags(index, &tags)
555 .into_iter()
556 .filter_map(|k| state.entries.get(&k).map(|e| (k, e.payload.clone())))
557 .collect(),
558 CapabilityQuery::HasAnyTag(tags) => {
559 let mut seen: HashSet<(u64, NodeId)> = HashSet::new();
560 for tag in &tags {
561 if let Some(keys) = index.by_tag.get(tag) {
562 seen.extend(keys.iter().copied());
563 }
564 }
565 seen.into_iter()
566 .filter_map(|k| state.entries.get(&k).map(|e| (k, e.payload.clone())))
567 .collect()
568 }
569 CapabilityQuery::InState(s) => index
570 .by_state
571 .get(&s)
572 .into_iter()
573 .flat_map(|set| set.iter().copied())
574 .filter_map(|k| state.entries.get(&k).map(|e| (k, e.payload.clone())))
575 .collect(),
576 CapabilityQuery::InRegion(r) => index
577 .by_region
578 .get(&r)
579 .into_iter()
580 .flat_map(|set| set.iter().copied())
581 .filter_map(|k| state.entries.get(&k).map(|e| (k, e.payload.clone())))
582 .collect(),
583 CapabilityQuery::Composite(filter) => composite_query(state, index, &filter),
584 }
585 }
586}
587
588/// Resolve the set of keys that carry EVERY tag in `tags`.
589/// Uses the inverted-tag index: pick the smallest tag-bucket
590/// as the candidate set, then retain only candidates present
591/// in every subsequent bucket. Empty `tags` returns every key
592/// (matches the `tags_all = []` "no constraint" convention).
593fn resolve_keys_all_tags(
594 index: &CapabilityIndexInner,
595 tags: &[String],
596) -> HashSet<(u64, NodeId), BuildU64TupleHasher> {
597 if tags.is_empty() {
598 // No tag constraint → every indexed key. Use the by_state
599 // index as a proxy: every entry is indexed under exactly
600 // one state, which gives the full key set without walking
601 // by_tag.
602 return index
603 .by_state
604 .values()
605 .flat_map(|set| set.iter().copied())
606 .collect();
607 }
608 // Pick the most-selective tag bucket as the candidate set.
609 let mut tags_by_selectivity: Vec<&String> = tags.iter().collect();
610 tags_by_selectivity.sort_by_key(|t| index.by_tag.get(*t).map(|s| s.len()).unwrap_or(0));
611
612 let Some(first) = tags_by_selectivity.first() else {
613 return HashSet::default();
614 };
615 let Some(initial) = index.by_tag.get(*first) else {
616 // First tag has no entries → intersection is empty.
617 return HashSet::default();
618 };
619 let mut candidates: HashSet<(u64, NodeId), BuildU64TupleHasher> =
620 initial.iter().copied().collect();
621 for tag in tags_by_selectivity.iter().skip(1) {
622 let Some(bucket) = index.by_tag.get(*tag) else {
623 return HashSet::default();
624 };
625 candidates.retain(|k| bucket.contains(k));
626 if candidates.is_empty() {
627 break;
628 }
629 }
630 candidates
631}
632
633/// Borrow-or-own candidate set returned by
634/// [`resolve_candidate_keys`]. A single-constraint filter resolves
635/// to exactly one index bucket, and that bucket IS the answer —
636/// returning it borrowed skips cloning every candidate key into a
637/// fresh owned set (alloc + rehash of M keys; the dominant cost of
638/// a high-cardinality single-tag discovery query). Composite
639/// filters still materialize an owned, tightened set.
640pub(crate) enum CandidateKeys<'a> {
641 /// The filter constrained exactly one indexed dimension —
642 /// the bucket is borrowed from the index untouched.
643 Borrowed(&'a HashSet<(u64, NodeId), BuildU64TupleHasher>),
644 /// Composite (or empty-result) filter — materialized set.
645 Owned(HashSet<(u64, NodeId), BuildU64TupleHasher>),
646}
647
648impl CandidateKeys<'_> {
649 /// The resolved key set, regardless of arm.
650 pub(crate) fn as_set(&self) -> &HashSet<(u64, NodeId), BuildU64TupleHasher> {
651 match self {
652 Self::Borrowed(s) => s,
653 Self::Owned(s) => s,
654 }
655 }
656}
657
658/// Resolve the set of `(class, node)` keys a
659/// [`CapabilityFilter`] selects on its *indexed* axes — tags,
660/// state, region, class. Chooses the most-selective indexed
661/// dimension as the seed, then tightens with the rest in memory.
662/// Single-constraint filters return the index bucket borrowed
663/// (see [`CandidateKeys`]); composite filters materialize.
664///
665/// Does NOT clone any payload, and does NOT apply `filter.limit`
666/// or non-indexed predicates (hardware / model / tool). Callers
667/// that only need keys — or that post-filter against borrowed
668/// payloads — use this directly via
669/// [`Fold::with_state_and_index`]; [`composite_query`] layers the
670/// payload materialization + limit on top for the
671/// `Vec<CapabilityMatch>` query path.
672pub(crate) fn resolve_candidate_keys<'a>(
673 state: &FoldState<CapabilityFold>,
674 index: &'a CapabilityIndexInner,
675 filter: &CapabilityFilter,
676) -> CandidateKeys<'a> {
677 // Single-constraint fast path (2026-06-11 service-discovery
678 // follow-up): when the filter constrains exactly one indexed
679 // dimension and nothing else would tighten the seed, the index
680 // bucket already IS the final candidate set. Borrow it instead
681 // of cloning every key into an owned set — and, for the state /
682 // region shapes, instead of also running the general path's
683 // redundant self-retain against the very bucket it seeded from.
684 // `tags_all` resolves against `by_tag` only (synthetic model /
685 // tool / gpu axes ride `tag_groups_all` → `by_synthetic`), so
686 // borrowing the raw-tag bucket cannot leak a synthetic match.
687 if filter.tag_groups_all.is_empty() && filter.tags_any.is_empty() && filter.class.is_none() {
688 match (&filter.tags_all[..], filter.state, &filter.region) {
689 ([tag], None, None) => {
690 return match index.by_tag.get(tag) {
691 Some(bucket) => CandidateKeys::Borrowed(bucket),
692 None => CandidateKeys::Owned(HashSet::default()),
693 };
694 }
695 ([], Some(state_filter), None) => {
696 return match index.by_state.get(&state_filter) {
697 Some(bucket) => CandidateKeys::Borrowed(bucket),
698 None => CandidateKeys::Owned(HashSet::default()),
699 };
700 }
701 ([], None, Some(region)) => {
702 return match index.by_region.get(region) {
703 Some(bucket) => CandidateKeys::Borrowed(bucket),
704 None => CandidateKeys::Owned(HashSet::default()),
705 };
706 }
707 _ => {}
708 }
709 }
710
711 // Each group's union (OR within a group) is needed both to seed
712 // (when no `tags_all` is present) and to tighten further down.
713 // `group_unions` holds the ones that still need to be applied as
714 // retain filters; the seed branch may consume one of them.
715 let mut group_unions: Vec<HashSet<(u64, NodeId), BuildU64TupleHasher>> = Vec::new();
716
717 // Seed candidate set: prefer tags_all (typically most
718 // selective), then the most-selective tag group, then state,
719 // then region, then class scan as fallback.
720 let mut candidates: HashSet<(u64, NodeId), BuildU64TupleHasher> = if !filter.tags_all.is_empty()
721 {
722 let seed = resolve_keys_all_tags(index, &filter.tags_all);
723 // Only materialize the group unions if the seed left
724 // something to filter — when `tags_all` selects nothing the
725 // result is already empty, so building them is wasted work.
726 if !seed.is_empty() {
727 group_unions = build_group_unions(index, &filter.tag_groups_all);
728 }
729 seed
730 } else {
731 group_unions = build_group_unions(index, &filter.tag_groups_all);
732 if !group_unions.is_empty() {
733 // Seed from the smallest group union, removing it so the
734 // retain pass below doesn't re-scan it.
735 // Non-empty (checked above), so min_by_key yields Some;
736 // the `unwrap_or(0)` is just a panic-free fallback.
737 let smallest = group_unions
738 .iter()
739 .enumerate()
740 .min_by_key(|(_, u)| u.len())
741 .map(|(i, _)| i)
742 .unwrap_or(0);
743 group_unions.swap_remove(smallest)
744 } else if let Some(state_filter) = filter.state {
745 index
746 .by_state
747 .get(&state_filter)
748 .cloned()
749 .unwrap_or_default()
750 } else if let Some(region) = &filter.region {
751 index.by_region.get(region).cloned().unwrap_or_default()
752 } else if let Some(class) = filter.class {
753 state
754 .entries
755 .keys()
756 .filter(|(c, _)| *c == class)
757 .copied()
758 .collect()
759 } else {
760 // No selective predicate → every key.
761 state.entries.keys().copied().collect()
762 }
763 };
764
765 // Tighten with remaining predicates.
766 if let Some(class) = filter.class {
767 candidates.retain(|(c, _)| *c == class);
768 }
769 if let Some(state_filter) = filter.state {
770 if let Some(bucket) = index.by_state.get(&state_filter) {
771 candidates.retain(|k| bucket.contains(k));
772 } else {
773 candidates.clear();
774 }
775 }
776 if let Some(region) = &filter.region {
777 if let Some(bucket) = index.by_region.get(region) {
778 candidates.retain(|k| bucket.contains(k));
779 } else {
780 candidates.clear();
781 }
782 }
783 if !filter.tags_any.is_empty() {
784 // Keep only candidates that carry at least one of the
785 // tags_any list. Build the union of those tag buckets
786 // once, then `retain`. Same PERF_AUDIT §4.6 fast mixer as
787 // the other `(u64, NodeId)` intermediates in this resolver.
788 let mut tags_any_union: HashSet<(u64, NodeId), BuildU64TupleHasher> = HashSet::default();
789 for tag in &filter.tags_any {
790 if let Some(bucket) = index.by_tag.get(tag) {
791 tags_any_union.extend(bucket.iter().copied());
792 }
793 }
794 candidates.retain(|k| tags_any_union.contains(k));
795 }
796
797 // AND across groups, OR within each group: a candidate must
798 // appear in every remaining group's union (the seed group, if
799 // any, was already consumed above).
800 for union in &group_unions {
801 candidates.retain(|k| union.contains(k));
802 if candidates.is_empty() {
803 break;
804 }
805 }
806
807 // No tags_all re-check needed: when `tags_all` is non-empty it
808 // is always the seed (the first branch above), so `candidates`
809 // already equals its intersection and every retain since has
810 // only narrowed it.
811 CandidateKeys::Owned(candidates)
812}
813
814/// Materialize the union for each non-empty group in
815/// `tag_groups_all` (OR within a group). Empty groups carry no
816/// constraint, so they're skipped rather than producing an empty
817/// union that would wrongly clear every candidate.
818fn build_group_unions(
819 index: &CapabilityIndexInner,
820 groups: &[Vec<String>],
821) -> Vec<HashSet<(u64, NodeId), BuildU64TupleHasher>> {
822 groups
823 .iter()
824 .filter(|g| !g.is_empty())
825 .map(|g| group_union(index, g))
826 .collect()
827}
828
829/// Union of the `(class, node)` keys carrying at least one tag in
830/// `group` — the OR-within-a-group half of `tag_groups_all`.
831///
832/// Resolves against `by_synthetic`, NOT `by_tag`: every
833/// `tag_groups_all` entry is an index-only synthetic key
834/// (`model:`/`tool:`/`gpu:`) manufactured by
835/// `derive_synthetic_index_tags`. Reading the synthetic map keeps a
836/// raw published tag of the same string from satisfying a model /
837/// tool / gpu axis it has no real bundle / hardware for.
838fn group_union(
839 index: &CapabilityIndexInner,
840 group: &[String],
841) -> HashSet<(u64, NodeId), BuildU64TupleHasher> {
842 let mut union: HashSet<(u64, NodeId), BuildU64TupleHasher> = HashSet::default();
843 for tag in group {
844 if let Some(bucket) = index.by_synthetic.get(tag) {
845 union.extend(bucket.iter().copied());
846 }
847 }
848 union
849}
850
851/// Evaluate a [`CapabilityQuery::Composite`] filter — resolves
852/// the indexed-axis candidate set via [`resolve_candidate_keys`],
853/// then materializes each match (cloning the payload) and applies
854/// `filter.limit`.
855fn composite_query(
856 state: &FoldState<CapabilityFold>,
857 index: &CapabilityIndexInner,
858 filter: &CapabilityFilter,
859) -> Vec<CapabilityMatch> {
860 let candidates = resolve_candidate_keys(state, index, filter);
861 // Materialize matches + apply limit during materialization.
862 //
863 // PERF_AUDIT §4.10 — pre-fix this collected every match (deep-
864 // cloning every `CapabilityMembership` payload — tags Vec,
865 // metadata BTreeMap, allow-lists) and only truncated AFTER. A
866 // query with a small `limit` against a large candidate set
867 // paid the full deep-clone cost on every over-limit match
868 // just to drop it on the next line. With `take` before
869 // `collect`, the clone runs exactly `limit` times.
870 let it = candidates
871 .as_set()
872 .iter()
873 .filter_map(|&k| state.entries.get(&k).map(|e| (k, e.payload.clone())));
874 if filter.limit > 0 {
875 it.take(filter.limit).collect()
876 } else {
877 it.collect()
878 }
879}
880
881/// Return the union of every tag this publisher has advertised
882/// across its [`CapabilityMembership`] class entries. Walks the
883/// publisher's `by_node` reverse index; O(num classes * tags
884/// per class), typically tiny. Used by the dataforts greedy
885/// admission path to feed the scope gate after origin_hash →
886/// node_id resolution.
887///
888/// Callers iterating over every publisher should use
889/// [`capability_tags_for_all`] instead — single-shot batched
890/// variant that avoids the `1 + N` `with_state` lock pattern.
891pub fn capability_tags_for(fold: &super::Fold<CapabilityFold>, node_id: NodeId) -> Vec<String> {
892 fold.with_state(|state| tags_union_for(state, node_id))
893}
894
895/// Return `(node_id, tags)` pairs for every publisher in the fold
896/// under one `with_state` lock. Equivalent to
897/// `state.by_node.keys().map(|n| (n, capability_tags_for(fold, n)))`
898/// but acquires the lock once instead of `1 + N` times — the
899/// planner's coverage walk and similar full-fold sweeps want this
900/// shape.
901pub fn capability_tags_for_all(
902 fold: &super::Fold<CapabilityFold>,
903) -> std::collections::HashMap<NodeId, Vec<String>> {
904 fold.with_state(|state| {
905 let mut out: std::collections::HashMap<NodeId, Vec<String>> =
906 std::collections::HashMap::with_capacity(state.by_node.len());
907 for node_id in state.by_node.keys() {
908 out.insert(*node_id, tags_union_for(state, *node_id));
909 }
910 out
911 })
912}
913
914/// Shared implementation: union the publisher's tag set across
915/// every class entry it owns. Callers hold the state read lock.
916/// Of `node_ids`, those advertising `tag` in their folded capability
917/// set — computed under a single `with_state` lock, with no per-node
918/// tag-`Vec` allocation. Coordinator selection filters its direct-peer
919/// candidates by `RELAY_CAPABLE_TAG` this way, instead of taking the
920/// fold lock and materializing a full tag union once per candidate
921/// (a `1 + N`-lock, `N`-allocation pattern).
922pub fn nodes_with_capability_tag(
923 fold: &super::Fold<CapabilityFold>,
924 node_ids: &[NodeId],
925 tag: &str,
926) -> std::collections::HashSet<NodeId> {
927 fold.with_state(|state| {
928 node_ids
929 .iter()
930 .copied()
931 .filter(|node_id| node_has_tag(state, *node_id, tag))
932 .collect()
933 })
934}
935
936/// Whether `node_id` advertises `tag` in any of its folded class
937/// entries. Non-allocating — walks the publisher's `by_node` reverse
938/// index and short-circuits on the first match.
939fn node_has_tag(state: &FoldState<CapabilityFold>, node_id: NodeId, tag: &str) -> bool {
940 let Some(keys) = state.by_node.get(&node_id) else {
941 return false;
942 };
943 keys.iter().any(|key| {
944 state
945 .entries
946 .get(key)
947 .is_some_and(|entry| entry.payload.tags.iter().any(|t| t == tag))
948 })
949}
950
951fn tags_union_for(state: &FoldState<CapabilityFold>, node_id: NodeId) -> Vec<String> {
952 let Some(keys) = state.by_node.get(&node_id) else {
953 return Vec::new();
954 };
955 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
956 for key in keys {
957 if let Some(entry) = state.entries.get(key) {
958 for tag in &entry.payload.tags {
959 seen.insert(tag.clone());
960 }
961 }
962 }
963 seen.into_iter().collect()
964}
965
966/// Return `node_id`'s last-advertised reflex `SocketAddr`, or
967/// `None` if no entry from that publisher carries one. Walks the
968/// publisher's class entries via the `by_node` reverse index;
969/// O(num classes this publisher is in), typically 0-3. Used by
970/// NAT-traversal rendezvous (stage 3) — the punch coordinator
971/// looks up the target's public address before scheduling the
972/// punch fire.
973pub fn reflex_addr_for(
974 fold: &super::Fold<CapabilityFold>,
975 node_id: NodeId,
976) -> Option<std::net::SocketAddr> {
977 fold.with_state(|state| {
978 let keys = state.by_node.get(&node_id)?;
979 for key in keys {
980 if let Some(entry) = state.entries.get(key) {
981 if let Some(addr) = entry.payload.reflex_addr {
982 return Some(addr);
983 }
984 }
985 }
986 None
987 })
988}
989
990#[cfg(test)]
991mod tests {
992 use std::sync::Arc;
993 use std::time::Duration;
994
995 use super::*;
996 use crate::adapter::net::behavior::fold::{
997 ApplyOutcome, EnvelopeMeta, Fold, FoldRegistry, SignedAnnouncement,
998 };
999 use crate::adapter::net::identity::EntityKeypair;
1000
1001 fn sign_cap(
1002 keypair: &EntityKeypair,
1003 publisher: NodeId,
1004 generation: u64,
1005 class: u64,
1006 tags: Vec<&str>,
1007 state: NodeState,
1008 region: Option<&str>,
1009 ) -> SignedAnnouncement<CapabilityMembership> {
1010 sign_cap_with_reflex(
1011 keypair, publisher, generation, class, tags, state, region, None,
1012 )
1013 }
1014
1015 #[allow(clippy::too_many_arguments)]
1016 fn sign_cap_with_reflex(
1017 keypair: &EntityKeypair,
1018 publisher: NodeId,
1019 generation: u64,
1020 class: u64,
1021 tags: Vec<&str>,
1022 state: NodeState,
1023 region: Option<&str>,
1024 reflex_addr: Option<std::net::SocketAddr>,
1025 ) -> SignedAnnouncement<CapabilityMembership> {
1026 SignedAnnouncement::sign(
1027 keypair,
1028 CapabilityFold::KIND_ID,
1029 class,
1030 publisher,
1031 generation,
1032 EnvelopeMeta::default(),
1033 CapabilityMembership {
1034 class_hash: class,
1035 tags: tags.into_iter().map(String::from).collect(),
1036 hardware: None,
1037 state,
1038 region: region.map(String::from),
1039 price_quote: None,
1040 reflex_addr,
1041 allowed_nodes: Vec::new(),
1042 allowed_subnets: Vec::new(),
1043 allowed_groups: Vec::new(),
1044 metadata: BTreeMap::new(),
1045 owner: None,
1046 },
1047 )
1048 .expect("sign succeeds")
1049 }
1050
1051 fn new_fold() -> Fold<CapabilityFold> {
1052 Fold::with_sweep_interval(Duration::ZERO)
1053 }
1054
1055 #[test]
1056 fn first_announcement_installs_and_populates_secondary_index() {
1057 let fold = new_fold();
1058 let kp = EntityKeypair::generate();
1059 let outcome = fold
1060 .apply(sign_cap(
1061 &kp,
1062 0xA,
1063 1,
1064 0x100,
1065 vec!["hardware.gpu", "vendor.nvidia"],
1066 NodeState::Idle,
1067 Some("us-east"),
1068 ))
1069 .expect("apply");
1070 assert_eq!(outcome, ApplyOutcome::Inserted);
1071
1072 // by-class scan finds it
1073 let hits = fold.query(CapabilityQuery::InClass(0x100));
1074 assert_eq!(hits.len(), 1);
1075 assert_eq!(hits[0].0, (0x100, 0xA));
1076
1077 // by-tag indexed lookup finds it
1078 let hits = fold.query(CapabilityQuery::HasAllTags(vec!["hardware.gpu".into()]));
1079 assert_eq!(hits.len(), 1);
1080
1081 // by-state indexed lookup
1082 let hits = fold.query(CapabilityQuery::InState(NodeState::Idle));
1083 assert_eq!(hits.len(), 1);
1084
1085 // by-region indexed lookup
1086 let hits = fold.query(CapabilityQuery::InRegion("us-east".into()));
1087 assert_eq!(hits.len(), 1);
1088 }
1089
1090 #[test]
1091 fn each_publisher_owns_its_own_class_entry_no_cross_override() {
1092 // Two distinct publishers in the same class. Each
1093 // writes its own key; neither can overwrite the
1094 // other.
1095 let fold = new_fold();
1096 let kp_a = EntityKeypair::generate();
1097 let kp_b = EntityKeypair::generate();
1098
1099 fold.apply(sign_cap(
1100 &kp_a,
1101 0xA,
1102 1,
1103 0x100,
1104 vec!["gpu"],
1105 NodeState::Idle,
1106 None,
1107 ))
1108 .expect("a");
1109 fold.apply(sign_cap(
1110 &kp_b,
1111 0xB,
1112 1,
1113 0x100,
1114 vec!["gpu"],
1115 NodeState::Busy,
1116 None,
1117 ))
1118 .expect("b");
1119
1120 let hits = fold.query(CapabilityQuery::InClass(0x100));
1121 assert_eq!(hits.len(), 2, "both publishers' entries coexist");
1122
1123 // Idle filter sees only A; busy filter sees only B.
1124 let idle = fold.query(CapabilityQuery::InState(NodeState::Idle));
1125 assert_eq!(idle.len(), 1);
1126 assert_eq!(idle[0].0, (0x100, 0xA));
1127
1128 let busy = fold.query(CapabilityQuery::InState(NodeState::Busy));
1129 assert_eq!(busy.len(), 1);
1130 assert_eq!(busy[0].0, (0x100, 0xB));
1131 }
1132
1133 #[test]
1134 fn replace_updates_secondary_index_drops_stale_tags() {
1135 // A publisher transitions Idle → Busy AND swaps tags
1136 // (gpu → tpu). The secondary index must reflect both
1137 // changes: querying by the old tag finds nothing,
1138 // querying by the new tag finds the entry.
1139 let fold = new_fold();
1140 let kp = EntityKeypair::generate();
1141
1142 fold.apply(sign_cap(
1143 &kp,
1144 0xA,
1145 1,
1146 0x100,
1147 vec!["gpu"],
1148 NodeState::Idle,
1149 Some("us-east"),
1150 ))
1151 .expect("v1");
1152
1153 fold.apply(sign_cap(
1154 &kp,
1155 0xA,
1156 2,
1157 0x100,
1158 vec!["tpu"],
1159 NodeState::Busy,
1160 Some("us-west"),
1161 ))
1162 .expect("v2");
1163
1164 // Stale tag finds nothing.
1165 let stale = fold.query(CapabilityQuery::HasAllTags(vec!["gpu".into()]));
1166 assert!(stale.is_empty());
1167 // New tag finds it.
1168 let fresh = fold.query(CapabilityQuery::HasAllTags(vec!["tpu".into()]));
1169 assert_eq!(fresh.len(), 1);
1170
1171 // Stale state bucket: empty.
1172 let stale_state = fold.query(CapabilityQuery::InState(NodeState::Idle));
1173 assert!(stale_state.is_empty());
1174 // New state bucket: 1 entry.
1175 let new_state = fold.query(CapabilityQuery::InState(NodeState::Busy));
1176 assert_eq!(new_state.len(), 1);
1177
1178 // Stale region: empty. New region: 1.
1179 assert!(fold
1180 .query(CapabilityQuery::InRegion("us-east".into()))
1181 .is_empty());
1182 assert_eq!(
1183 fold.query(CapabilityQuery::InRegion("us-west".into()))
1184 .len(),
1185 1
1186 );
1187 }
1188
1189 /// PERF_AUDIT §4.5 — when a refresh announcement carries the
1190 /// same (tags, region, state) as the existing entry, the
1191 /// secondary index must NOT be churned. The skip optimization
1192 /// must still let the entry's generation/TTL update, and
1193 /// queries must continue to return the entry — verifying that
1194 /// the index dance was unnecessary, not just absent.
1195 ///
1196 /// `index_payload_equivalent` itself is unit-tested below.
1197 #[test]
1198 fn replace_same_payload_keeps_index_consistent_and_query_returns_entry() {
1199 let fold = new_fold();
1200 let kp = EntityKeypair::generate();
1201
1202 // v1: gpu+h100 tags, Idle, us-east.
1203 fold.apply(sign_cap(
1204 &kp,
1205 0xCAFE,
1206 1,
1207 0x100,
1208 vec!["gpu", "h100"],
1209 NodeState::Idle,
1210 Some("us-east"),
1211 ))
1212 .expect("v1");
1213
1214 // v2: identical payload, higher generation (steady-state
1215 // refresh).
1216 let outcome = fold
1217 .apply(sign_cap(
1218 &kp,
1219 0xCAFE,
1220 2,
1221 0x100,
1222 vec!["gpu", "h100"],
1223 NodeState::Idle,
1224 Some("us-east"),
1225 ))
1226 .expect("v2");
1227 assert_eq!(outcome, ApplyOutcome::Replaced);
1228
1229 // The post-refresh query results must reflect the entry
1230 // through every indexed dimension.
1231 let by_tag = fold.query(CapabilityQuery::HasAllTags(vec!["gpu".into()]));
1232 assert_eq!(by_tag.len(), 1, "tag bucket must still resolve the entry");
1233 let by_state = fold.query(CapabilityQuery::InState(NodeState::Idle));
1234 assert_eq!(by_state.len(), 1, "state bucket must still resolve");
1235 let by_region = fold.query(CapabilityQuery::InRegion("us-east".into()));
1236 assert_eq!(by_region.len(), 1, "region bucket must still resolve");
1237 }
1238
1239 /// PERF_AUDIT §4.5 — `index_payload_equivalent` is the gate
1240 /// between "skip the index dance" and "rebuild the buckets".
1241 /// Pin both sides: identical (tags, region, state) returns
1242 /// true; any differing dimension returns false.
1243 #[test]
1244 fn index_payload_equivalent_matches_indexed_dimensions() {
1245 use std::collections::BTreeMap;
1246 let base = CapabilityMembership {
1247 class_hash: 0x100,
1248 tags: vec!["gpu".into(), "h100".into()],
1249 hardware: None,
1250 state: NodeState::Idle,
1251 region: Some("us-east".into()),
1252 price_quote: None,
1253 reflex_addr: None,
1254 allowed_nodes: Vec::new(),
1255 allowed_subnets: Vec::new(),
1256 allowed_groups: Vec::new(),
1257 metadata: BTreeMap::new(),
1258 owner: None,
1259 };
1260 assert!(
1261 <CapabilityIndexInner as super::super::FoldIndex<CapabilityFold>>::index_payload_equivalent(&base, &base.clone()),
1262 "byte-identical payload is equivalent"
1263 );
1264
1265 // Tags differ.
1266 let mut t = base.clone();
1267 t.tags.push("a100".into());
1268 assert!(
1269 !<CapabilityIndexInner as super::super::FoldIndex<CapabilityFold>>::index_payload_equivalent(&base, &t),
1270 "tag delta must invalidate"
1271 );
1272
1273 // State differs.
1274 let mut s = base.clone();
1275 s.state = NodeState::Busy;
1276 assert!(
1277 !<CapabilityIndexInner as super::super::FoldIndex<CapabilityFold>>::index_payload_equivalent(&base, &s),
1278 "state delta must invalidate"
1279 );
1280
1281 // Region differs.
1282 let mut r = base.clone();
1283 r.region = Some("us-west".into());
1284 assert!(
1285 !<CapabilityIndexInner as super::super::FoldIndex<CapabilityFold>>::index_payload_equivalent(&base, &r),
1286 "region delta must invalidate"
1287 );
1288
1289 // Hardware differs — the `gpu:present` / `gpu:vendor:<v>`
1290 // synthetic index tags derive from `hardware`, so a GPU
1291 // shape change MUST invalidate or `by_synthetic` goes
1292 // stale on a tags-identical refresh.
1293 let mut h = base.clone();
1294 h.hardware = Some(HardwareSummary {
1295 gpu_vendor: Some("nvidia".into()),
1296 gpu_count: 1,
1297 memory_gb: None,
1298 vram_gb: None,
1299 });
1300 assert!(
1301 !<CapabilityIndexInner as super::super::FoldIndex<CapabilityFold>>::index_payload_equivalent(&base, &h),
1302 "hardware delta must invalidate — synthetic gpu tags derive from it"
1303 );
1304
1305 // Non-indexed dimension (metadata) — these CAN differ
1306 // without forcing an index rebuild. The skip is correct
1307 // because the index doesn't key on metadata at all.
1308 let mut m = base.clone();
1309 m.metadata.insert("intent".into(), "ml-training".into());
1310 assert!(
1311 <CapabilityIndexInner as super::super::FoldIndex<CapabilityFold>>::index_payload_equivalent(&base, &m),
1312 "metadata delta is OK to skip — index doesn't key on metadata"
1313 );
1314 }
1315
1316 /// PERF_AUDIT §4.5 regression — a refresh that keeps (tags,
1317 /// region, state) identical but CHANGES the hardware GPU
1318 /// shape must still rebuild the synthetic index. Pre-fix the
1319 /// equivalence check ignored `hardware`, so the gained GPU
1320 /// never landed in `by_synthetic` (a `gpu:present` group
1321 /// query kept missing the node) and a lost GPU lingered
1322 /// stale. Drives the full apply path end-to-end.
1323 #[test]
1324 fn replace_with_changed_hardware_updates_synthetic_index() {
1325 let fold = new_fold();
1326 let kp = EntityKeypair::generate();
1327 let sign_with_hw = |generation: u64, hardware: Option<HardwareSummary>| {
1328 SignedAnnouncement::sign(
1329 &kp,
1330 CapabilityFold::KIND_ID,
1331 0x100,
1332 0xFACE,
1333 generation,
1334 EnvelopeMeta::default(),
1335 CapabilityMembership {
1336 class_hash: 0x100,
1337 tags: vec!["worker".into()],
1338 hardware,
1339 state: NodeState::Idle,
1340 region: Some("us-east".into()),
1341 price_quote: None,
1342 reflex_addr: None,
1343 allowed_nodes: Vec::new(),
1344 allowed_subnets: Vec::new(),
1345 allowed_groups: Vec::new(),
1346 metadata: BTreeMap::new(),
1347 owner: None,
1348 },
1349 )
1350 .expect("sign succeeds")
1351 };
1352 let gpu_present_filter = || CapabilityFilter {
1353 tag_groups_all: vec![vec!["gpu:present".into()]],
1354 ..CapabilityFilter::default()
1355 };
1356
1357 // v1: no hardware → no gpu:present synthetic tag.
1358 fold.apply(sign_with_hw(1, None)).expect("v1");
1359 let hits = fold.query(CapabilityQuery::Composite(gpu_present_filter()));
1360 assert!(hits.is_empty(), "no GPU yet — synthetic axis must miss");
1361
1362 // v2: same tags/region/state, GPU appears. The refresh
1363 // must rebuild by_synthetic.
1364 fold.apply(sign_with_hw(
1365 2,
1366 Some(HardwareSummary {
1367 gpu_vendor: Some("nvidia".into()),
1368 gpu_count: 1,
1369 memory_gb: Some(64),
1370 vram_gb: Some(24),
1371 }),
1372 ))
1373 .expect("v2");
1374 let hits = fold.query(CapabilityQuery::Composite(gpu_present_filter()));
1375 assert_eq!(
1376 hits.len(),
1377 1,
1378 "GPU gained on refresh must be visible via the synthetic index"
1379 );
1380
1381 // v3: GPU disappears again — the stale gpu:present bucket
1382 // must be dropped.
1383 fold.apply(sign_with_hw(3, None)).expect("v3");
1384 let hits = fold.query(CapabilityQuery::Composite(gpu_present_filter()));
1385 assert!(
1386 hits.is_empty(),
1387 "GPU lost on refresh must drop the stale synthetic bucket"
1388 );
1389 }
1390
1391 #[test]
1392 fn has_all_tags_finds_only_entries_carrying_every_tag() {
1393 let fold = new_fold();
1394 let kp = EntityKeypair::generate();
1395 fold.apply(sign_cap(
1396 &kp,
1397 0x1,
1398 1,
1399 0x100,
1400 vec!["a", "b", "c"],
1401 NodeState::Idle,
1402 None,
1403 ))
1404 .unwrap();
1405 fold.apply(sign_cap(
1406 &kp,
1407 0x2,
1408 1,
1409 0x100,
1410 vec!["a", "b"],
1411 NodeState::Idle,
1412 None,
1413 ))
1414 .unwrap();
1415 fold.apply(sign_cap(
1416 &kp,
1417 0x3,
1418 1,
1419 0x100,
1420 vec!["a"],
1421 NodeState::Idle,
1422 None,
1423 ))
1424 .unwrap();
1425
1426 // Need a + b + c → only node 1
1427 let hits: std::collections::HashSet<_> = fold
1428 .query(CapabilityQuery::HasAllTags(vec![
1429 "a".into(),
1430 "b".into(),
1431 "c".into(),
1432 ]))
1433 .into_iter()
1434 .map(|((_, n), _)| n)
1435 .collect();
1436 assert_eq!(hits, [0x1].into_iter().collect());
1437
1438 // Need a + b → nodes 1 and 2
1439 let hits: std::collections::HashSet<_> = fold
1440 .query(CapabilityQuery::HasAllTags(vec!["a".into(), "b".into()]))
1441 .into_iter()
1442 .map(|((_, n), _)| n)
1443 .collect();
1444 assert_eq!(hits, [0x1, 0x2].into_iter().collect());
1445
1446 // Need just a → all three
1447 let hits: std::collections::HashSet<_> = fold
1448 .query(CapabilityQuery::HasAllTags(vec!["a".into()]))
1449 .into_iter()
1450 .map(|((_, n), _)| n)
1451 .collect();
1452 assert_eq!(hits, [0x1, 0x2, 0x3].into_iter().collect());
1453 }
1454
1455 #[test]
1456 fn has_any_tag_returns_union_across_buckets() {
1457 let fold = new_fold();
1458 let kp = EntityKeypair::generate();
1459 fold.apply(sign_cap(
1460 &kp,
1461 0x1,
1462 1,
1463 0x100,
1464 vec!["x"],
1465 NodeState::Idle,
1466 None,
1467 ))
1468 .unwrap();
1469 fold.apply(sign_cap(
1470 &kp,
1471 0x2,
1472 1,
1473 0x100,
1474 vec!["y"],
1475 NodeState::Idle,
1476 None,
1477 ))
1478 .unwrap();
1479 fold.apply(sign_cap(
1480 &kp,
1481 0x3,
1482 1,
1483 0x100,
1484 vec!["z"],
1485 NodeState::Idle,
1486 None,
1487 ))
1488 .unwrap();
1489
1490 let hits: std::collections::HashSet<_> = fold
1491 .query(CapabilityQuery::HasAnyTag(vec!["x".into(), "y".into()]))
1492 .into_iter()
1493 .map(|((_, n), _)| n)
1494 .collect();
1495 assert_eq!(hits, [0x1, 0x2].into_iter().collect());
1496 }
1497
1498 #[test]
1499 fn composite_query_intersects_every_populated_filter_axis() {
1500 let fold = new_fold();
1501 let kp = EntityKeypair::generate();
1502
1503 // Three entries: A (gpu/idle/us-east), B (gpu/busy/us-east),
1504 // C (gpu/idle/us-west). Composite filter (class + gpu +
1505 // idle + us-east) → only A.
1506 fold.apply(sign_cap(
1507 &kp,
1508 0xA,
1509 1,
1510 0x100,
1511 vec!["gpu"],
1512 NodeState::Idle,
1513 Some("us-east"),
1514 ))
1515 .unwrap();
1516 fold.apply(sign_cap(
1517 &kp,
1518 0xB,
1519 1,
1520 0x100,
1521 vec!["gpu"],
1522 NodeState::Busy,
1523 Some("us-east"),
1524 ))
1525 .unwrap();
1526 fold.apply(sign_cap(
1527 &kp,
1528 0xC,
1529 1,
1530 0x100,
1531 vec!["gpu"],
1532 NodeState::Idle,
1533 Some("us-west"),
1534 ))
1535 .unwrap();
1536
1537 let filter = CapabilityFilter {
1538 class: Some(0x100),
1539 tags_all: vec!["gpu".into()],
1540 state: Some(NodeState::Idle),
1541 region: Some("us-east".into()),
1542 ..CapabilityFilter::default()
1543 };
1544 let hits: Vec<_> = fold
1545 .query(CapabilityQuery::Composite(filter))
1546 .into_iter()
1547 .map(|((_, n), _)| n)
1548 .collect();
1549 assert_eq!(hits, vec![0xA]);
1550 }
1551
1552 #[test]
1553 fn composite_query_honours_limit() {
1554 let fold = new_fold();
1555 let kp = EntityKeypair::generate();
1556 for i in 0..10 {
1557 fold.apply(sign_cap(
1558 &kp,
1559 i,
1560 1,
1561 0x100,
1562 vec!["gpu"],
1563 NodeState::Idle,
1564 None,
1565 ))
1566 .unwrap();
1567 }
1568 let filter = CapabilityFilter {
1569 class: Some(0x100),
1570 limit: 3,
1571 ..CapabilityFilter::default()
1572 };
1573 let hits = fold.query(CapabilityQuery::Composite(filter));
1574 assert_eq!(hits.len(), 3);
1575 }
1576
1577 /// 2026-06-11 service-discovery follow-up — single-constraint
1578 /// filters must take the borrowed fast path (the index bucket
1579 /// IS the answer; no clone/rehash of M candidate keys),
1580 /// composite filters must materialize, and the borrowed arm
1581 /// must select exactly what the general path would.
1582 #[test]
1583 fn single_constraint_filters_borrow_the_index_bucket() {
1584 let fold = new_fold();
1585 let kp = EntityKeypair::generate();
1586 fold.apply(sign_cap(
1587 &kp,
1588 0xA,
1589 1,
1590 0x100,
1591 vec!["gpu", "fast"],
1592 NodeState::Idle,
1593 Some("us-east"),
1594 ))
1595 .unwrap();
1596 fold.apply(sign_cap(
1597 &kp,
1598 0xB,
1599 1,
1600 0x100,
1601 vec!["gpu"],
1602 NodeState::Busy,
1603 Some("us-west"),
1604 ))
1605 .unwrap();
1606 fold.apply(sign_cap(
1607 &kp,
1608 0xC,
1609 1,
1610 0x200,
1611 vec!["cpu"],
1612 NodeState::Idle,
1613 Some("us-east"),
1614 ))
1615 .unwrap();
1616
1617 fold.with_state_and_index(|state, index| {
1618 let nodes = |keys: &CandidateKeys<'_>| -> Vec<NodeId> {
1619 let mut v: Vec<NodeId> = keys.as_set().iter().map(|&(_, n)| n).collect();
1620 v.sort_unstable();
1621 v
1622 };
1623
1624 // Single tag → Borrowed: exactly the by_tag bucket.
1625 let tag_only = CapabilityFilter {
1626 tags_all: vec!["gpu".into()],
1627 ..CapabilityFilter::default()
1628 };
1629 let got = resolve_candidate_keys(state, index, &tag_only);
1630 assert!(
1631 matches!(got, CandidateKeys::Borrowed(_)),
1632 "single-tag filter must borrow the index bucket"
1633 );
1634 assert_eq!(nodes(&got), vec![0xA, 0xB]);
1635
1636 // Single state → Borrowed.
1637 let state_only = CapabilityFilter {
1638 state: Some(NodeState::Idle),
1639 ..CapabilityFilter::default()
1640 };
1641 let got = resolve_candidate_keys(state, index, &state_only);
1642 assert!(matches!(got, CandidateKeys::Borrowed(_)));
1643 assert_eq!(nodes(&got), vec![0xA, 0xC]);
1644
1645 // Single region → Borrowed.
1646 let region_only = CapabilityFilter {
1647 region: Some("us-east".into()),
1648 ..CapabilityFilter::default()
1649 };
1650 let got = resolve_candidate_keys(state, index, ®ion_only);
1651 assert!(matches!(got, CandidateKeys::Borrowed(_)));
1652 assert_eq!(nodes(&got), vec![0xA, 0xC]);
1653
1654 // Unknown single tag → provably empty (Owned default,
1655 // no bucket to borrow).
1656 let missing = CapabilityFilter {
1657 tags_all: vec!["nope".into()],
1658 ..CapabilityFilter::default()
1659 };
1660 let got = resolve_candidate_keys(state, index, &missing);
1661 assert!(matches!(got, CandidateKeys::Owned(_)));
1662 assert!(got.as_set().is_empty());
1663
1664 // Composite (tag + state) → Owned: the general path
1665 // must still materialize and intersect.
1666 let composite = CapabilityFilter {
1667 tags_all: vec!["gpu".into()],
1668 state: Some(NodeState::Idle),
1669 ..CapabilityFilter::default()
1670 };
1671 let got = resolve_candidate_keys(state, index, &composite);
1672 assert!(
1673 matches!(got, CandidateKeys::Owned(_)),
1674 "composite filter must materialize a tightened set"
1675 );
1676 assert_eq!(nodes(&got), vec![0xA]);
1677 });
1678 }
1679
1680 #[test]
1681 fn composite_query_with_tags_any_filters_correctly() {
1682 let fold = new_fold();
1683 let kp = EntityKeypair::generate();
1684 fold.apply(sign_cap(
1685 &kp,
1686 0xA,
1687 1,
1688 0x100,
1689 vec!["common", "fast"],
1690 NodeState::Idle,
1691 None,
1692 ))
1693 .unwrap();
1694 fold.apply(sign_cap(
1695 &kp,
1696 0xB,
1697 1,
1698 0x100,
1699 vec!["common", "slow"],
1700 NodeState::Idle,
1701 None,
1702 ))
1703 .unwrap();
1704 fold.apply(sign_cap(
1705 &kp,
1706 0xC,
1707 1,
1708 0x100,
1709 vec!["common"],
1710 NodeState::Idle,
1711 None,
1712 ))
1713 .unwrap();
1714
1715 // tags_all=[common] + tags_any=[fast, slow] → A and B,
1716 // not C (C carries `common` but neither `fast` nor
1717 // `slow`).
1718 let filter = CapabilityFilter {
1719 tags_all: vec!["common".into()],
1720 tags_any: vec!["fast".into(), "slow".into()],
1721 ..CapabilityFilter::default()
1722 };
1723 let hits: std::collections::HashSet<_> = fold
1724 .query(CapabilityQuery::Composite(filter))
1725 .into_iter()
1726 .map(|((_, n), _)| n)
1727 .collect();
1728 assert_eq!(hits, [0xA, 0xB].into_iter().collect());
1729 }
1730
1731 #[test]
1732 fn evict_node_drops_every_class_entry_and_cleans_indexes() {
1733 let fold = new_fold();
1734 let kp = EntityKeypair::generate();
1735 // Publisher 0xA in two classes; publisher 0xB in one
1736 // class as a control.
1737 fold.apply(sign_cap(
1738 &kp,
1739 0xA,
1740 1,
1741 0x100,
1742 vec!["gpu"],
1743 NodeState::Idle,
1744 Some("r1"),
1745 ))
1746 .unwrap();
1747 fold.apply(sign_cap(
1748 &kp,
1749 0xA,
1750 1,
1751 0x200,
1752 vec!["tpu"],
1753 NodeState::Busy,
1754 Some("r2"),
1755 ))
1756 .unwrap();
1757 fold.apply(sign_cap(
1758 &kp,
1759 0xB,
1760 1,
1761 0x100,
1762 vec!["gpu"],
1763 NodeState::Idle,
1764 Some("r1"),
1765 ))
1766 .unwrap();
1767 assert_eq!(fold.stats().entries, 3);
1768
1769 fold.evict_node(0xA, "test");
1770 assert_eq!(fold.stats().entries, 1);
1771 assert_eq!(fold.stats().evictions, 2);
1772
1773 // Tag indexes for evicted A's tags must be cleared (or
1774 // narrowed): "gpu" survives because B still carries it;
1775 // "tpu" had only A and is now empty.
1776 let gpu_hits: std::collections::HashSet<_> = fold
1777 .query(CapabilityQuery::HasAllTags(vec!["gpu".into()]))
1778 .into_iter()
1779 .map(|((_, n), _)| n)
1780 .collect();
1781 assert_eq!(gpu_hits, [0xB].into_iter().collect());
1782 let tpu_hits = fold.query(CapabilityQuery::HasAllTags(vec!["tpu".into()]));
1783 assert!(tpu_hits.is_empty());
1784 }
1785
1786 #[test]
1787 fn reflex_addr_for_returns_first_advertised_addr_across_publisher_classes() {
1788 use std::net::SocketAddr;
1789 let fold = new_fold();
1790 let kp = EntityKeypair::generate();
1791 let addr: SocketAddr = "203.0.113.4:7000".parse().unwrap();
1792
1793 // Publisher 0xAA in two classes; only the second carries a
1794 // reflex_addr. The lookup walks by_node and returns the
1795 // first Some across the class entries.
1796 fold.apply(sign_cap_with_reflex(
1797 &kp,
1798 0xAA,
1799 1,
1800 0x100,
1801 vec![],
1802 NodeState::Idle,
1803 None,
1804 None,
1805 ))
1806 .expect("class 0x100");
1807 fold.apply(sign_cap_with_reflex(
1808 &kp,
1809 0xAA,
1810 1,
1811 0x101,
1812 vec![],
1813 NodeState::Idle,
1814 None,
1815 Some(addr),
1816 ))
1817 .expect("class 0x101");
1818
1819 assert_eq!(super::reflex_addr_for(&fold, 0xAA), Some(addr));
1820 // Unknown node → None (not in by_node).
1821 assert_eq!(super::reflex_addr_for(&fold, 0xBB), None);
1822 }
1823
1824 #[test]
1825 fn reflex_addr_for_returns_none_when_publisher_advertises_no_addr() {
1826 let fold = new_fold();
1827 let kp = EntityKeypair::generate();
1828 fold.apply(sign_cap(&kp, 0xAA, 1, 0x100, vec![], NodeState::Idle, None))
1829 .expect("class 0x100");
1830 assert_eq!(super::reflex_addr_for(&fold, 0xAA), None);
1831 }
1832
1833 #[test]
1834 fn capability_tags_for_all_matches_per_node_walk() {
1835 // Pin that the batched helper returns the same per-publisher
1836 // tag set as the single-node helper, but in one lock
1837 // acquisition. The shape callers depend on: every
1838 // `by_node` publisher gets an entry; tag sets are unioned
1839 // across the publisher's class entries.
1840 let fold = new_fold();
1841 let kp_a = EntityKeypair::generate();
1842 let kp_b = EntityKeypair::generate();
1843 fold.apply(sign_cap(
1844 &kp_a,
1845 0xA,
1846 1,
1847 0x100,
1848 vec!["gpu", "vendor.nvidia"],
1849 NodeState::Idle,
1850 None,
1851 ))
1852 .expect("a-100");
1853 // Same publisher, different class — tags should union.
1854 fold.apply(sign_cap(
1855 &kp_a,
1856 0xA,
1857 1,
1858 0x200,
1859 vec!["gpu", "model:llama"],
1860 NodeState::Idle,
1861 None,
1862 ))
1863 .expect("a-200");
1864 fold.apply(sign_cap(
1865 &kp_b,
1866 0xB,
1867 1,
1868 0x100,
1869 vec!["cpu-only"],
1870 NodeState::Idle,
1871 None,
1872 ))
1873 .expect("b-100");
1874
1875 let batched = super::capability_tags_for_all(&fold);
1876 assert_eq!(batched.len(), 2);
1877
1878 let mut tags_a = batched.get(&0xA).cloned().unwrap_or_default();
1879 tags_a.sort();
1880 assert_eq!(
1881 tags_a,
1882 vec![
1883 "gpu".to_string(),
1884 "model:llama".to_string(),
1885 "vendor.nvidia".to_string()
1886 ],
1887 "publisher A unions tags across both class entries"
1888 );
1889
1890 let mut tags_b = batched.get(&0xB).cloned().unwrap_or_default();
1891 tags_b.sort();
1892 assert_eq!(tags_b, vec!["cpu-only".to_string()]);
1893
1894 // Each entry should equal the single-node helper's result
1895 // for that publisher.
1896 for (node_id, batched_tags) in &batched {
1897 let mut single = super::capability_tags_for(&fold, *node_id);
1898 single.sort();
1899 let mut batched_sorted = batched_tags.clone();
1900 batched_sorted.sort();
1901 assert_eq!(single, batched_sorted, "mismatch for node 0x{:x}", node_id);
1902 }
1903 }
1904
1905 #[test]
1906 fn capability_tags_for_all_returns_empty_for_empty_fold() {
1907 let fold = new_fold();
1908 let batched = super::capability_tags_for_all(&fold);
1909 assert!(batched.is_empty());
1910 }
1911
1912 #[test]
1913 fn nodes_with_capability_tag_filters_the_batch() {
1914 const RELAY_CAPABLE_TAG: &str =
1915 crate::adapter::net::behavior::capability::RELAY_CAPABLE_TAG;
1916 // A and C advertise `relay-capable`; B does not. C carries it
1917 // on a *second* class entry only, so the union walk must see
1918 // it. Querying a mix (incl. an absent node id D) returns
1919 // exactly the matching subset.
1920 let fold = new_fold();
1921 let kp_a = EntityKeypair::generate();
1922 let kp_b = EntityKeypair::generate();
1923 let kp_c = EntityKeypair::generate();
1924 fold.apply(sign_cap(
1925 &kp_a,
1926 0xA,
1927 1,
1928 0x100,
1929 vec!["gpu", RELAY_CAPABLE_TAG],
1930 NodeState::Idle,
1931 None,
1932 ))
1933 .expect("a");
1934 fold.apply(sign_cap(
1935 &kp_b,
1936 0xB,
1937 1,
1938 0x100,
1939 vec!["cpu-only"],
1940 NodeState::Idle,
1941 None,
1942 ))
1943 .expect("b");
1944 fold.apply(sign_cap(
1945 &kp_c,
1946 0xC,
1947 1,
1948 0x100,
1949 vec!["gpu"],
1950 NodeState::Idle,
1951 None,
1952 ))
1953 .expect("c-100");
1954 fold.apply(sign_cap(
1955 &kp_c,
1956 0xC,
1957 1,
1958 0x200,
1959 vec![RELAY_CAPABLE_TAG],
1960 NodeState::Idle,
1961 None,
1962 ))
1963 .expect("c-200");
1964
1965 let mut got: Vec<u64> =
1966 super::nodes_with_capability_tag(&fold, &[0xA, 0xB, 0xC, 0xD], RELAY_CAPABLE_TAG)
1967 .into_iter()
1968 .collect();
1969 got.sort();
1970 assert_eq!(
1971 got,
1972 vec![0xA, 0xC],
1973 "only A and C advertise the tag (D is absent)"
1974 );
1975
1976 // Batch predicate agrees with the per-node union helper.
1977 for nid in [0xA, 0xB, 0xC] {
1978 let via_union = super::capability_tags_for(&fold, nid)
1979 .iter()
1980 .any(|t| t == RELAY_CAPABLE_TAG);
1981 let via_batch =
1982 super::nodes_with_capability_tag(&fold, &[nid], RELAY_CAPABLE_TAG).contains(&nid);
1983 assert_eq!(
1984 via_union, via_batch,
1985 "batch vs union disagree for 0x{nid:x}"
1986 );
1987 }
1988
1989 // Empty query → empty result.
1990 assert!(super::nodes_with_capability_tag(&fold, &[], RELAY_CAPABLE_TAG).is_empty());
1991 }
1992
1993 #[test]
1994 fn runtime_ttl_sweeps_stale_capability_entries() {
1995 let fold = new_fold();
1996 let kp = EntityKeypair::generate();
1997 let ann = SignedAnnouncement::sign(
1998 &kp,
1999 CapabilityFold::KIND_ID,
2000 0x100,
2001 0xA,
2002 1,
2003 EnvelopeMeta {
2004 ttl_secs: Some(0),
2005 ..Default::default()
2006 },
2007 CapabilityMembership {
2008 class_hash: 0x100,
2009 tags: vec!["gpu".into()],
2010 hardware: None,
2011 state: NodeState::Idle,
2012 region: None,
2013 price_quote: None,
2014 reflex_addr: None,
2015 allowed_nodes: Vec::new(),
2016 allowed_subnets: Vec::new(),
2017 allowed_groups: Vec::new(),
2018 metadata: BTreeMap::new(),
2019 owner: None,
2020 },
2021 )
2022 .unwrap();
2023 fold.apply(ann).unwrap();
2024 assert_eq!(fold.stats().entries, 1);
2025
2026 std::thread::sleep(Duration::from_millis(10));
2027 let n = fold.sweep_expired_now();
2028 assert_eq!(n, 1);
2029 assert_eq!(fold.stats().entries, 0);
2030 assert_eq!(fold.stats().expiries, 1);
2031
2032 // Secondary index must also be cleared by sweep.
2033 assert!(fold
2034 .query(CapabilityQuery::HasAllTags(vec!["gpu".into()]))
2035 .is_empty());
2036 }
2037
2038 #[test]
2039 fn capability_fold_plugs_into_registry_and_dispatches_signed_envelopes() {
2040 let registry = FoldRegistry::new();
2041 let fold: Arc<Fold<CapabilityFold>> = Arc::new(new_fold());
2042 registry.register(fold.clone());
2043
2044 let kp = EntityKeypair::generate();
2045 // Dispatch verifies the publisher-binding, so an honest
2046 // envelope must carry the signer's own node_id.
2047 let ann = sign_cap(
2048 &kp,
2049 kp.entity_id().node_id(),
2050 1,
2051 0x100,
2052 vec!["gpu"],
2053 NodeState::Idle,
2054 Some("us-east"),
2055 );
2056 let bytes = ann.encode().expect("encode");
2057 let outcome = registry.dispatch(&bytes, kp.entity_id()).expect("dispatch");
2058 assert_eq!(outcome, ApplyOutcome::Inserted);
2059 assert_eq!(fold.stats().entries, 1);
2060 }
2061}