Skip to main content

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, &region_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}