Skip to main content

windows_topology_sys/
domain.rs

1// Copyright (c) 2026 Mike Grier
2//! The topology description: an open-kinded domain over a set of processors.
3//!
4//! This is the "interpretation" layer built on top of `relation.rs`'s
5//! faithful, Win32-shaped records (D-2 in `DESIGN-NOTES.md`). Everything here
6//! is plain data with no Win32 dependency, so it can be built either by
7//! discovering the running system or entirely by hand -- or fed in from
8//! elsewhere, which is this crate's whole reason for separating discovery
9//! from description.
10
11use std::collections::BTreeMap;
12
13use crate::CacheKind;
14use crate::observation::{Observation, Source};
15use crate::observed::Observed;
16use crate::processor_set::ProcessorSet;
17
18/// The identity of one logical processor: its group and its number within
19/// that group.
20///
21/// Never flattened to a single index (D-7 in `DESIGN-NOTES.md`): a Windows
22/// thread's affinity is a `GROUP_AFFINITY`, one group and a bitmask within
23/// it, so the group is a hard boundary a flattened index would lose.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct ProcessorId {
27    /// The processor group.
28    pub group: u16,
29    /// The processor's number within that group (0..`MAX_PROCESSORS_PER_GROUP`,
30    /// i.e. 0..`usize::BITS`).
31    pub number: u8,
32}
33
34/// One logical processor.
35#[derive(Clone, Debug, PartialEq, Eq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct Processor {
38    /// This processor's identity.
39    pub id: ProcessorId,
40    /// Whether the processor is currently active. A processor slot can exist
41    /// -- count toward a group's maximum -- without being online, since
42    /// Windows reserves group capacity for processors that may be added
43    /// later.
44    pub online: bool,
45    /// A relative scheduling weight for this processor, higher meaning more
46    /// capable, on no fixed scale. On Windows this is the owning core's raw
47    /// `EfficiencyClass`; `0` for an offline processor or one with no known
48    /// owning core.
49    pub capacity: u32,
50}
51
52/// What a [`Domain`] represents.
53///
54/// Open-kinded rather than a fixed enumeration (D-4 in `DESIGN-NOTES.md`):
55/// Linux alone models `die`, `cluster`, and (on `s390x`) `book` and `drawer`,
56/// none of which Windows reports and none of which a cache domain reliably
57/// approximates. Enumerating every level any architecture will ever have is a
58/// losing game, so an unrecognised kind is carried in [`DomainKind::Other`]
59/// rather than rejected.
60#[derive(Clone, Debug, PartialEq)]
61#[non_exhaustive]
62pub enum DomainKind {
63    /// A Windows processor group.
64    Group,
65    /// A physical processor package (socket).
66    Package,
67    /// A processor die, on systems that report the distinction from package.
68    Die,
69    /// A processor module, on systems that report it.
70    Module,
71    /// A physical core: one or more logical processors as SMT siblings.
72    Core {
73        /// Whether this core has more than one logical processor.
74        simultaneous_multithreading: bool,
75        /// The scheduler's raw Windows efficiency class for this core.
76        efficiency_class: u8,
77    },
78    /// A cache level shared by a set of logical processors.
79    Cache {
80        /// Cache level (1, 2, 3, ...).
81        level: u8,
82        /// Associativity Windows reported, or `0xFF` for fully associative.
83        associativity: u8,
84        /// Cache line size in bytes.
85        line_size: u16,
86        /// Total cache size in bytes.
87        size_bytes: u32,
88        /// What the cache holds.
89        cache_type: CacheKind,
90    },
91    /// A memory locality domain -- a NUMA node modelled as a memory domain
92    /// that may contain no processors at all (D-5), because CXL expanders,
93    /// persistent memory, HBM tiers, and coherent GPU memory all present that
94    /// way.
95    Memory {
96        /// The domain's memory capacity.
97        ///
98        /// [`Observed::NotObserved`] from `discover`: Windows's own enumeration
99        /// (`GetLogicalProcessorInformationEx`) does not report a NUMA node's
100        /// capacity at all, so this crate never learns it. A hand-written or
101        /// fed-in description may supply one.
102        ///
103        /// # Why this is an `Observed` and not an `Option`
104        ///
105        /// So that a description **omitting** the field and one writing
106        /// `"memory_bytes": null` stop being the same value: the first is
107        /// `NotObserved` (nobody addressed it) and the second is `Absent` (the
108        /// writer addressed it and had no value). Both still mean "the capacity
109        /// is unknown" to a planner, so the distinction is one of **provenance
110        /// of the description**, not of planning -- recorded plainly because
111        /// `M5+.2` framed it as more than that.
112        ///
113        /// The distinction that actually matters was already available and is
114        /// preserved: `Known(0)` is a node with genuinely no memory -- the
115        /// CXL-expander shape [D-5](../DESIGN-NOTES.md) exists to represent --
116        /// and is not confusable with an unknown capacity. That is
117        /// [D-11](../DESIGN-NOTES.md)'s point, and it is why `Some(0)` was
118        /// rejected as a stand-in in the first place.
119        memory_bytes: Observed<u64>,
120    },
121    /// A domain kind this crate does not have a name for, carrying its raw
122    /// name and whatever attributes came with it, so a description this
123    /// crate cannot fully interpret still round-trips losslessly.
124    Other {
125        /// The raw kind name.
126        name: String,
127        /// Whatever attributes accompanied this domain, beyond `id` and
128        /// `processors`.
129        attributes: BTreeMap<String, AttributeValue>,
130    },
131}
132
133/// A plain value for an unrecognised [`DomainKind::Other`]'s attributes.
134///
135/// Deliberately not `serde_json::Value`: this crate depends only on `serde`'s
136/// traits, optionally, following `windows-file-watcher`'s D-72 precedent --
137/// never on a specific format crate. A consumer chooses their own serializer.
138#[derive(Clone, Debug, PartialEq)]
139#[non_exhaustive]
140pub enum AttributeValue {
141    /// A JSON `null`.
142    Null,
143    /// A JSON boolean.
144    Bool(bool),
145    /// A JSON number that decoded as a non-negative integer, preserved
146    /// exactly (PR #20 review response). Collapsing every number to `f64`
147    /// loses precision above 2^53 (~9 PB), silently corrupting both the
148    /// documented lossless round-trip for an unknown attribute and known
149    /// numeric field decoding (e.g. `memory_bytes`).
150    UnsignedInteger(u64),
151    /// A JSON number that decoded as a negative integer, preserved exactly.
152    SignedInteger(i64),
153    /// A JSON number with a fractional part (or one too large for either
154    /// integer variant), represented as `f64`.
155    Float(f64),
156    /// A JSON string.
157    String(String),
158    /// A JSON array.
159    Array(Vec<AttributeValue>),
160    /// A JSON object.
161    Object(BTreeMap<String, AttributeValue>),
162}
163
164/// One domain: a named relationship over a set of processors.
165///
166/// Domains reference processors; they do not nest (D-6 in `DESIGN-NOTES.md`).
167/// No hierarchy is imposed, so this type never asserts that a package
168/// contains a node or that a node contains a cache -- chiplets and CXL
169/// already violate assumptions like that, and Linux's own levels do not form
170/// a strict hierarchy either.
171///
172/// With the `serde` feature, serializes as `{"kind": <string>, "processors":
173/// [...], ...fields specific to that kind}` -- an internally tagged shape,
174/// implemented by hand rather than derived, because `kind` is open (D-4) and
175/// an unrecognised one must still round-trip its attributes.
176///
177/// An `"id"` is written when the relationship walk labelled this relation, and
178/// is **optional on read and discarded**. It carries no model meaning: a label
179/// belongs to the observation that issued it (D-15), and a file cannot
180/// establish which source observed anything (D-12).
181///
182/// **This JSON shape is explicitly not covered by this crate's semver
183/// contract (D-8 in `DESIGN-NOTES.md`).** The Rust API above (`Domain`,
184/// `DomainKind`, and friends) is covered by semver as always; the wire shape
185/// they produce and accept is allowed to change in a minor release. This is
186/// what makes D-9's deferrals (an HMAT-style attributed-relation model,
187/// devices as topology participants) safe to defer rather than merely
188/// convenient: adding them later is a schema evolution, not a breaking
189/// change to a promise this crate never made.
190#[derive(Clone, Debug, PartialEq)]
191pub struct Domain {
192    /// What this domain represents.
193    pub kind: DomainKind,
194
195    /// The logical processors this domain covers. Empty for a memory-only
196    /// domain (D-5).
197    pub processors: ProcessorSet,
198    /// Which sources reported this relation, and what each called it.
199    ///
200    /// Empty for a relation nobody reported -- one built by hand, which is
201    /// honest rather than a gap: no platform API said anything about it. A
202    /// relation both Windows APIs describe carries **two** observations, which
203    /// is what makes agreement visible without either label being discarded
204    /// (D-15, D-19).
205    pub observations: Vec<Observation>,
206}
207
208impl Domain {
209    /// What `source` called this relation, if `source` reported it.
210    ///
211    /// The replacement for the removed `id` field, and it takes a source
212    /// because there is no single answer without one: the two Windows APIs
213    /// agree on the core partition while labelling it `[0, 2, 4, ..., 14]` and
214    /// `[0, 1, ..., 7]`, so "the id" was never well defined once both were
215    /// consulted (D-15).
216    ///
217    /// A caller wanting a *stable* handle for grouping should use the
218    /// relation's position in [`MachineMemoryTopology::domains`][domains]
219    /// instead: an observation label is meaningful only to the source that
220    /// issued it, and a relation may have none at all.
221    ///
222    /// [domains]: crate::MachineMemoryTopology::domains
223    #[must_use]
224    pub fn label_from(&self, source: Source) -> Option<u32> {
225        self.observations
226            .iter()
227            .find(|observation| observation.source == source)
228            .map(|observation| observation.label)
229    }
230
231    /// Whether `source` reported this relation.
232    #[must_use]
233    pub fn observed_by(&self, source: Source) -> bool {
234        self.observations
235            .iter()
236            .any(|observation| observation.source == source)
237    }
238}
239
240/// Manual `Serialize`/`Deserialize` for the open-kinded types.
241///
242/// `AttributeValue` and `Domain` cannot be `#[derive(Serialize, Deserialize)]`:
243/// `AttributeValue` is a self-describing value (its own shape is not known
244/// until read), and `Domain`'s wire shape is internally tagged on `kind` with
245/// the kind-specific fields flattened alongside `id`/`processors` -- and an
246/// unrecognised `kind` must still capture whatever fields came with it (D-4).
247/// Both are read by buffering the whole object into a
248/// `BTreeMap<String, AttributeValue>` first, since which fields are expected
249/// is only known once `kind` itself has been read, and a hand-written or
250/// fed-in description cannot be relied on to place `kind` first.
251#[cfg(feature = "serde")]
252mod serde_impl {
253    use std::collections::BTreeMap;
254    use std::fmt;
255
256    use serde::de::{Error, MapAccess, SeqAccess, Visitor};
257    use serde::ser::{Error as SerError, SerializeMap};
258    use serde::{Deserialize, Deserializer, Serialize, Serializer};
259
260    use super::{AttributeValue, Domain, DomainKind};
261    use crate::CacheKind;
262    use crate::processor_set::MAX_PROCESSORS_PER_GROUP;
263    use crate::processor_set::ProcessorSet;
264
265    impl Serialize for AttributeValue {
266        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
267            match self {
268                AttributeValue::Null => serializer.serialize_unit(),
269                AttributeValue::Bool(b) => serializer.serialize_bool(*b),
270                AttributeValue::UnsignedInteger(n) => serializer.serialize_u64(*n),
271                AttributeValue::SignedInteger(n) => serializer.serialize_i64(*n),
272                AttributeValue::Float(n) => serializer.serialize_f64(*n),
273                AttributeValue::String(s) => serializer.serialize_str(s),
274                AttributeValue::Array(items) => items.serialize(serializer),
275                AttributeValue::Object(map) => map.serialize(serializer),
276            }
277        }
278    }
279
280    impl<'de> Deserialize<'de> for AttributeValue {
281        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
282            struct ValueVisitor;
283
284            impl<'de> Visitor<'de> for ValueVisitor {
285                type Value = AttributeValue;
286
287                fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288                    f.write_str("a JSON-like value")
289                }
290                fn visit_unit<E: Error>(self) -> Result<Self::Value, E> {
291                    Ok(AttributeValue::Null)
292                }
293                fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
294                    Ok(AttributeValue::Null)
295                }
296                fn visit_bool<E: Error>(self, v: bool) -> Result<Self::Value, E> {
297                    Ok(AttributeValue::Bool(v))
298                }
299                fn visit_i64<E: Error>(self, v: i64) -> Result<Self::Value, E> {
300                    Ok(AttributeValue::SignedInteger(v))
301                }
302                fn visit_u64<E: Error>(self, v: u64) -> Result<Self::Value, E> {
303                    Ok(AttributeValue::UnsignedInteger(v))
304                }
305                fn visit_f64<E: Error>(self, v: f64) -> Result<Self::Value, E> {
306                    Ok(AttributeValue::Float(v))
307                }
308                fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
309                    Ok(AttributeValue::String(v.to_string()))
310                }
311                fn visit_string<E: Error>(self, v: String) -> Result<Self::Value, E> {
312                    Ok(AttributeValue::String(v))
313                }
314                fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
315                    let mut items = Vec::new();
316                    while let Some(item) = seq.next_element()? {
317                        items.push(item);
318                    }
319                    Ok(AttributeValue::Array(items))
320                }
321                fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
322                    let mut object = BTreeMap::new();
323                    while let Some((k, v)) = map.next_entry()? {
324                        object.insert(k, v);
325                    }
326                    Ok(AttributeValue::Object(object))
327                }
328            }
329
330            deserializer.deserialize_any(ValueVisitor)
331        }
332    }
333
334    fn as_bool<E: Error>(value: AttributeValue) -> Result<bool, E> {
335        match value {
336            AttributeValue::Bool(b) => Ok(b),
337            _ => Err(E::custom("expected a boolean")),
338        }
339    }
340
341    /// A number is preserved exactly when it decoded as an integer variant;
342    /// only a [`AttributeValue::Float`] (a fractional source, or one too
343    /// large for `i64`/`u64`) is converted, and even then only when it is a
344    /// non-negative whole number representable in `f64` (PR #20 review
345    /// response: the same ~9 PB ceiling D-11 already accepts for
346    /// `memory_bytes`, not the silent precision loss the old
347    /// `f64`-for-everything encoding had).
348    fn as_u64<E: Error>(value: AttributeValue) -> Result<u64, E> {
349        match value {
350            AttributeValue::UnsignedInteger(n) => Ok(n),
351            AttributeValue::SignedInteger(n) => {
352                u64::try_from(n).map_err(|_| E::custom("expected a non-negative whole number"))
353            }
354            // **Exclusive at the top, and that is not a style choice.**
355            // `u64::MAX as f64` rounds *up* to 2^64, which is one greater than
356            // any `u64`. An inclusive bound therefore admitted exactly 2^64,
357            // and `n as u64` saturates it to `u64::MAX` -- so a description
358            // carrying 18446744073709551616 was silently read as a different
359            // number. Excluding the bound rejects it instead, and costs
360            // nothing: the largest `f64` below 2^64 is 2^64 - 2048, which is a
361            // representable `u64` and still accepted. Raised in the PR #56
362            // review.
363            AttributeValue::Float(n) if n.fract() == 0.0 && (0.0..u64::MAX as f64).contains(&n) => {
364                Ok(n as u64)
365            }
366            _ => Err(E::custom("expected a non-negative whole number")),
367        }
368    }
369
370    fn as_u32<E: Error>(value: AttributeValue) -> Result<u32, E> {
371        u32::try_from(as_u64(value)?).map_err(|_| E::custom("number is too large for this field"))
372    }
373
374    /// As [`as_u64`], but over the signed range: `CacheKind::Other` carries a
375    /// raw `PROCESSOR_CACHE_TYPE`, a C enum backed by `i32`, which is not
376    /// guaranteed non-negative. Preserved exactly for either integer variant;
377    /// only a [`AttributeValue::Float`] is converted, subject to the same
378    /// fractional/range check as before.
379    fn as_i64<E: Error>(value: AttributeValue) -> Result<i64, E> {
380        match value {
381            AttributeValue::SignedInteger(n) => Ok(n),
382            AttributeValue::UnsignedInteger(n) => {
383                i64::try_from(n).map_err(|_| E::custom("expected a whole number"))
384            }
385            // Exclusive at the top for the reason `as_u64` gives, and found by
386            // sweeping for the same shape rather than reported: `i64::MAX as
387            // f64` rounds up to 2^63, which no `i64` can hold, and the cast
388            // would saturate it to `i64::MAX`. The *lower* bound stays
389            // inclusive because `i64::MIN as f64` is -2^63 exactly -- it is a
390            // power of two and representable, so it converts back losslessly.
391            AttributeValue::Float(n)
392                if n.fract() == 0.0 && (i64::MIN as f64..i64::MAX as f64).contains(&n) =>
393            {
394                Ok(n as i64)
395            }
396            _ => Err(E::custom("expected a whole number")),
397        }
398    }
399
400    fn as_i32<E: Error>(value: AttributeValue) -> Result<i32, E> {
401        i32::try_from(as_i64(value)?).map_err(|_| E::custom("number is too large for this field"))
402    }
403
404    fn as_u16<E: Error>(value: AttributeValue) -> Result<u16, E> {
405        u16::try_from(as_u64(value)?).map_err(|_| E::custom("number is too large for this field"))
406    }
407
408    fn as_u8<E: Error>(value: AttributeValue) -> Result<u8, E> {
409        u8::try_from(as_u64(value)?).map_err(|_| E::custom("number is too large for this field"))
410    }
411
412    /// `CacheKind`'s own derived `Serialize` produces a bare string for a
413    /// unit variant and `{"other": <number>}` for `Other`; decoded here by
414    /// hand against the already-buffered value rather than by re-entering
415    /// serde's enum machinery, which expects a real `Deserializer` rather
416    /// than a value already read into an [`AttributeValue`].
417    fn cache_kind_from_value<E: Error>(value: AttributeValue) -> Result<CacheKind, E> {
418        match value {
419            AttributeValue::String(s) => match s.as_str() {
420                "unified" => Ok(CacheKind::Unified),
421                "instruction" => Ok(CacheKind::Instruction),
422                "data" => Ok(CacheKind::Data),
423                "trace" => Ok(CacheKind::Trace),
424                other => Err(E::custom(format!("unrecognised cache_type \"{other}\""))),
425            },
426            AttributeValue::Object(mut map) if map.len() == 1 => match map.remove("other") {
427                Some(raw) => Ok(CacheKind::Other(as_i32(raw)?)),
428                None => Err(E::custom("cache_type object must have an \"other\" key")),
429            },
430            _ => Err(E::custom(
431                "cache_type must be a string or {\"other\": <number>}",
432            )),
433        }
434    }
435
436    /// Convert a buffered `"processors"` value (a JSON array of
437    /// `{"group":_,"number":_}` objects) into a [`ProcessorSet`], without
438    /// going through `ProcessorSet`'s own `Deserialize` impl: that impl
439    /// expects a real `Deserializer`, and this value has already been
440    /// buffered into an [`AttributeValue`].
441    fn processors_from_value<E: Error>(value: AttributeValue) -> Result<ProcessorSet, E> {
442        let AttributeValue::Array(items) = value else {
443            return Err(E::custom("domain \"processors\" must be an array"));
444        };
445        let mut set = ProcessorSet::empty();
446        for item in items {
447            let AttributeValue::Object(mut object) = item else {
448                return Err(E::custom(
449                    "each processor must be an object with \"group\" and \"number\"",
450                ));
451            };
452            let group = as_u16(take(&mut object, "group")?)?;
453            let number = as_u8(take(&mut object, "number")?)?;
454            if u32::from(number) >= MAX_PROCESSORS_PER_GROUP {
455                // A real Windows GROUP_AFFINITY.Mask is one machine word, so
456                // a group genuinely cannot hold this processor: reject rather
457                // than silently truncate (D-10 in `DESIGN-NOTES.md`).
458                return Err(E::custom(format!(
459                    "processor number {number} is out of range: a processor group has at most \
460                     {MAX_PROCESSORS_PER_GROUP} processors on this platform"
461                )));
462            }
463            set.insert(group, number);
464        }
465        Ok(set)
466    }
467
468    fn take<E: Error>(
469        fields: &mut BTreeMap<String, AttributeValue>,
470        key: &str,
471    ) -> Result<AttributeValue, E> {
472        fields
473            .remove(key)
474            .ok_or_else(|| E::custom(format!("domain is missing required field \"{key}\"")))
475    }
476
477    /// Every kind name this crate decodes into a named [`DomainKind`].
478    ///
479    /// **Must list exactly the arms the deserializer matches before its
480    /// `other =>` fallback**, and `every_well_known_name_decodes_to_a_named_kind`
481    /// asserts that it does rather than leaving the two to drift.
482    pub(super) const WELL_KNOWN_KIND_NAMES: &[&str] = &[
483        "group", "package", "die", "module", "core", "cache", "memory",
484    ];
485
486    impl Serialize for Domain {
487        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
488            let mut map = serializer.serialize_map(None)?;
489            let kind_name: &str = match &self.kind {
490                DomainKind::Group => "group",
491                DomainKind::Package => "package",
492                DomainKind::Die => "die",
493                DomainKind::Module => "module",
494                DomainKind::Core { .. } => "core",
495                DomainKind::Cache { .. } => "cache",
496                DomainKind::Memory { .. } => "memory",
497                DomainKind::Other { name, .. } => {
498                    // The same rule as the attribute-name collision check
499                    // below, for the same reason and by the same remedy.
500                    //
501                    // `Other` exists so a description this crate cannot fully
502                    // interpret "still round-trips losslessly", and a name this
503                    // crate *does* interpret breaks exactly that: the document
504                    // would say `"kind": "group"`, and reading it back yields
505                    // `DomainKind::Group`, not the `Other` that was written.
506                    // `Group`, `Package`, `Die` and `Module` carry no fields,
507                    // so that substitution succeeds silently and the attributes
508                    // are dropped on the floor; `core`, `cache` and `memory`
509                    // fail loudly on a missing field, or -- worse -- succeed as
510                    // a different kind when the attributes happen to match.
511                    //
512                    // Refused rather than escaped or renamed, because both of
513                    // those would change a name the caller chose. Raised in the
514                    // PR #56 review.
515                    if WELL_KNOWN_KIND_NAMES.contains(&name.as_str()) {
516                        return Err(S::Error::custom(format!(
517                            "domain kind name \"{name}\" collides with a kind this crate names \
518                             itself, so the document would deserialize as that kind rather than \
519                             as `Other`"
520                        )));
521                    }
522                    name.as_str()
523                }
524            };
525            map.serialize_entry("kind", kind_name)?;
526            // The wire shape keeps an "id" because a description is written by
527            // hand and a human-meaningful number is worth having. It is the
528            // relationship walk's label where there is one -- not "the id",
529            // which stopped being well defined once two sources labelled the
530            // same relation differently (D-15).
531            if let Some(label) = self.label_from(crate::observation::Source::RelationshipWalk) {
532                map.serialize_entry("id", &label)?;
533            }
534            map.serialize_entry("processors", &self.processors)?;
535            match &self.kind {
536                DomainKind::Core {
537                    simultaneous_multithreading,
538                    efficiency_class,
539                } => {
540                    map.serialize_entry(
541                        "simultaneous_multithreading",
542                        simultaneous_multithreading,
543                    )?;
544                    map.serialize_entry("efficiency_class", efficiency_class)?;
545                }
546                DomainKind::Cache {
547                    level,
548                    associativity,
549                    line_size,
550                    size_bytes,
551                    cache_type,
552                } => {
553                    map.serialize_entry("level", level)?;
554                    map.serialize_entry("associativity", associativity)?;
555                    map.serialize_entry("line_size", line_size)?;
556                    map.serialize_entry("size_bytes", size_bytes)?;
557                    map.serialize_entry("cache_type", cache_type)?;
558                }
559                DomainKind::Memory { memory_bytes } => {
560                    // Three states, three wire shapes: a number, an explicit
561                    // `null` for "addressed and unknown", and omission for
562                    // "nobody said". Writing `null` for both would put the
563                    // ambiguity back on the wire that the type just removed.
564                    match memory_bytes {
565                        crate::observed::Observed::Known(bytes) => {
566                            map.serialize_entry("memory_bytes", bytes)?;
567                        }
568                        crate::observed::Observed::Absent => {
569                            map.serialize_entry("memory_bytes", &Option::<u64>::None)?;
570                        }
571                        crate::observed::Observed::NotObserved => {}
572                    }
573                }
574                DomainKind::Other { attributes, .. } => {
575                    for (key, value) in attributes {
576                        if matches!(key.as_str(), "kind" | "id" | "processors") {
577                            // These three keys are always written above;
578                            // silently letting an attribute reuse one would
579                            // corrupt the wire shape rather than round-trip.
580                            return Err(S::Error::custom(format!(
581                                "domain attribute \"{key}\" collides with the reserved \
582                                 \"kind\"/\"id\"/\"processors\" field names"
583                            )));
584                        }
585                        map.serialize_entry(key, value)?;
586                    }
587                }
588                DomainKind::Group | DomainKind::Package | DomainKind::Die | DomainKind::Module => {}
589            }
590            map.end()
591        }
592    }
593
594    impl<'de> Deserialize<'de> for Domain {
595        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
596            let mut fields = BTreeMap::<String, AttributeValue>::deserialize(deserializer)?;
597
598            let kind_name = match take::<D::Error>(&mut fields, "kind")? {
599                AttributeValue::String(s) => s,
600                _ => return Err(D::Error::custom("domain \"kind\" must be a string")),
601            };
602            // Read and discarded, and OPTIONAL. The wire "id" is one source's
603            // label; a file cannot establish which source observed the relation
604            // (D-12), so it never becomes an observation. Since it carries no
605            // model meaning, requiring it would reject a description that is
606            // otherwise complete -- including one this crate itself writes for a
607            // relation no source labelled.
608            let _ = fields.remove("id");
609            let processors = processors_from_value(take::<D::Error>(&mut fields, "processors")?)?;
610            // A described relation carries NO platform observation, and the
611            // wire shape does not encode them.
612            //
613            // This is the same downgrade `Provenance::downgraded_to` performs
614            // one level up (D-12): a file saying "the relationship walk
615            // observed this" cannot establish that it did, so the claim is not
616            // carried across the boundary. Serializing observations faithfully
617            // would carry exactly the claim D-12 refuses.
618            //
619            // Nor is a `Source::Description` observation synthesized here. That
620            // would restate what the object already says -- a deserialized
621            // topology's `Provenance` is capped at `Restored` -- and D-22 has
622            // just established these two are different questions that should
623            // not duplicate each other.
624            let observations = Vec::new();
625
626            let kind = match kind_name.as_str() {
627                "group" => DomainKind::Group,
628                "package" => DomainKind::Package,
629                "die" => DomainKind::Die,
630                "module" => DomainKind::Module,
631                "core" => DomainKind::Core {
632                    simultaneous_multithreading: as_bool(take(
633                        &mut fields,
634                        "simultaneous_multithreading",
635                    )?)?,
636                    efficiency_class: as_u8(take(&mut fields, "efficiency_class")?)?,
637                },
638                "cache" => DomainKind::Cache {
639                    level: as_u8(take(&mut fields, "level")?)?,
640                    associativity: as_u8(take(&mut fields, "associativity")?)?,
641                    line_size: as_u16(take(&mut fields, "line_size")?)?,
642                    size_bytes: as_u32(take(&mut fields, "size_bytes")?)?,
643                    cache_type: cache_kind_from_value(take(&mut fields, "cache_type")?)?,
644                },
645                "memory" => DomainKind::Memory {
646                    memory_bytes: match fields.remove("memory_bytes") {
647                        None => crate::observed::Observed::NotObserved,
648                        Some(AttributeValue::Null) => crate::observed::Observed::Absent,
649                        Some(value) => crate::observed::Observed::Known(as_u64(value)?),
650                    },
651                },
652                // Every arm above must appear in `WELL_KNOWN_KIND_NAMES`, or
653                // `Serialize` would let an `Other` claim that name and the
654                // document would decode as the named kind instead. The test
655                // named on that constant is what enforces it.
656                other => DomainKind::Other {
657                    name: other.to_string(),
658                    attributes: fields,
659                },
660            };
661
662            Ok(Domain {
663                kind,
664                processors,
665                observations,
666            })
667        }
668    }
669}
670
671/// Everything this crate knows about one processor, with each absence named.
672///
673/// Produced by [`MachineMemoryTopology::shard_set`](crate::MachineMemoryTopology::shard_set). Deliberately **not** a
674/// second copy of [`Processor`]: that type is the platform's own record, while
675/// this is the assembled answer to "may this processor host work, and where
676/// does it allocate from" -- gathered from both Win32 sources plus the derived
677/// relations.
678///
679/// # No sentinels, anywhere
680///
681/// Every optional field is an [`Observed`], so "the platform said zero" and
682/// "nobody asked" are different values rather than the same one. The field
683/// this exists to replace, [`Processor::capacity`], spells three facts as `0`
684/// -- offline, in no core, and efficiency class zero -- and the third is every
685/// processor on every non-hybrid machine.
686#[derive(Clone, Debug, PartialEq)]
687pub struct ProcessorFacts<'a> {
688    /// The processor's identity, always `(group, number)` and never flattened
689    /// (D-7): a Windows affinity is a `GROUP_AFFINITY`, so a bare index names a
690    /// different processor in every group.
691    pub id: ProcessorId,
692    /// Whether the slot is active. An offline slot exists and counts toward its
693    /// group's maximum, so planning work onto one is planning a thread that
694    /// cannot run.
695    pub online: bool,
696    /// The core relation this processor belongs to, if any names it.
697    ///
698    /// `None` is a firmware gap the crate tolerates by design, not a
699    /// contradiction -- see [`Self::efficiency_class`], which is
700    /// [`Observed::NotObserved`] in exactly that case rather than `0`.
701    pub core: Option<&'a Domain>,
702    /// Whether the owning core has more than one logical processor.
703    pub simultaneous_multithreading: Observed<bool>,
704    /// The scheduler's efficiency class for the owning core.
705    ///
706    /// [`Observed::NotObserved`] when no core names this processor -- which is
707    /// the distinction `Processor::capacity` cannot make. On a hybrid part
708    /// Windows orders class `0` as the *least* performant, so an unknown
709    /// processor reported as `0` is indistinguishable from an efficiency core:
710    /// a policy excluding efficiency cores silently drops a possible
711    /// performance core, and one tiering them mis-tiers it. Neither fails a
712    /// functional test.
713    pub efficiency_class: Observed<u8>,
714    /// Whether the scheduler is currently avoiding this processor.
715    ///
716    /// [`Observed::NotObserved`] when the CPU-set enumeration was not
717    /// consulted, which is any topology not produced by
718    /// [`MachineMemoryTopology::discover`](crate::MachineMemoryTopology::discover). Parked is **not** offline: the
719    /// processor is active and the scheduler is merely avoiding it.
720    pub parked: Observed<bool>,
721    /// Whether this processor is allocated to *this* process.
722    ///
723    /// A planner ignoring it places work on processors the process may not use,
724    /// which is a wrong plan rather than a slow one.
725    ///
726    /// **Measured to carry no information on Windows 11 25H2** (D-23): the
727    /// whole `AllFlags` byte reads `0x00` for every processor even after CPU
728    /// sets are successfully allocated to this process, so `Known(false)` here
729    /// reports a byte the kernel did not write.
730    ///
731    /// It also does not mean what its name suggests -- allocation is the
732    /// explicit `SetProcessDefaultCpuSets` kind, not "may we run here". Do not
733    /// branch on it; see [`CpuSet::allocated_to_target_process`](crate::CpuSet::allocated_to_target_process).
734    pub allocated_to_this_process: Observed<bool>,
735    /// The memory domain this processor allocates from, or
736    /// [`Observed::NotObserved`] for the **unplaced** case, which has no honest
737    /// fallback -- see [`MachineMemoryTopology::memory_domain_of`](crate::MachineMemoryTopology::memory_domain_of).
738    pub memory_domain: Observed<&'a Domain>,
739}
740
741#[cfg(test)]
742mod tests;