Skip to main content

windows_topology_sys/
topology.rs

1// Copyright (c) 2026 Mike Grier
2//! Assembling a [`MachineMemoryTopology`] from discovered relations.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::io;
6
7use crate::EnumerationAnomaly;
8use crate::cpu_set::CpuSet;
9use crate::domain::{Domain, DomainKind, Processor, ProcessorFacts, ProcessorId};
10use crate::observation::{AttributeObservation, Observation, ProcessorAttribute, Source};
11use crate::observed::Observed;
12use crate::processor_set::ProcessorSet;
13use crate::provenance::Provenance;
14use crate::relation::{self, Relations};
15/// How many passes [`MachineMemoryTopology::discover`] makes before concluding
16/// that a disagreement is genuine.
17///
18/// Three, because the bound's job is to separate "the machine changed while we
19/// were looking" from "these two APIs disagree", and one repeat already does
20/// that: a hot-add is not repeated microseconds later. The third pass is
21/// slack for two changes landing in quick succession, and the cost of being
22/// wrong in this direction is one more cheap enumeration, where the cost of
23/// being wrong in the other is reporting a transient as genuine.
24const COHERENCE_ATTEMPTS: u32 = 3;
25
26/// How the two Win32 sources agreed when a topology was collected.
27///
28/// [`MachineMemoryTopology::discover`] reads the relationship walk and the
29/// CPU-set enumeration in two separate calls, and Windows offers no
30/// transactional way to read them together -- so the pair may describe
31/// different instants. [D-16](../DESIGN-NOTES.md#d-16) is the decision this
32/// type implements: retry to remove the transient cases, then represent
33/// whatever survives.
34///
35/// **What is compared is which processors exist**, because that is the
36/// disagreement a second pass can actually settle. A processor hot-added or
37/// removed between the two calls appears in one source and not the other, and
38/// is not hot-added again a microsecond later. Disagreements about how
39/// processors are *grouped* -- which core, which node -- are deliberately not
40/// retried: [D-17](../DESIGN-NOTES.md#d-17) establishes those are expected in
41/// the field and persistent, so re-reading cannot settle them and they are
42/// carried as separate per-source observations instead.
43///
44/// # Serialized spelling
45///
46/// `snake_case`, like [`Provenance`](crate::Provenance),
47/// [`Source`](crate::Source) and [`ProcessorAttribute`](crate::ProcessorAttribute),
48/// and like the lowercase names `Domain`'s hand-written serializer emits for
49/// [`DomainKind`](crate::DomainKind). Every serialized name this crate produces
50/// is lowercase; this one was derived without the attribute and so spelled its
51/// variants `NotCollected` and `Disagreed` until that was corrected.
52#[derive(Clone, Debug, Default, PartialEq, Eq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
55pub enum Coherence {
56    /// Not collected from a running system, so the question does not arise.
57    ///
58    /// A hand-built or deserialized topology takes this: nothing was read
59    /// twice, so there is nothing to have agreed. The default for the same
60    /// reason.
61    #[default]
62    NotCollected,
63    /// Both sources described the same processors on the pass this topology
64    /// was built from.
65    Agreed,
66    /// They never agreed within the retry bound, so the disagreement is
67    /// genuine rather than transient, and is reported rather than hidden.
68    ///
69    /// Per [D-16](../DESIGN-NOTES.md#d-16), exhausting the bound is not a
70    /// failure to collect -- it is the *conclusion* that the disagreement is
71    /// real. The topology is returned, and this says what differed on the
72    /// final pass so a caller can decide what that means for its own use.
73    Disagreed {
74        /// Processors the relationship walk reported and CPU Sets did not.
75        walk_only: Vec<ProcessorId>,
76        /// Processors CPU Sets reported and the relationship walk did not.
77        ///
78        /// **These are absent from [`MachineMemoryTopology::processors`]**,
79        /// which carries the walk's view alone. This field is where they are
80        /// named, and reading it is the only way to learn they exist -- see
81        /// [D-26](../DESIGN-NOTES.md#d-26) for why they are reported here
82        /// rather than synthesized into a list that has one source.
83        cpu_sets_only: Vec<ProcessorId>,
84        /// How many passes were made before giving up.
85        attempts: u32,
86    },
87}
88
89/// A processor, cache, and memory topology: a set of processors and the
90/// domains that relate them.
91///
92/// Built either by [`MachineMemoryTopology::discover`] from the running system, by hand,
93/// or (with the `serde` feature) by deserializing a fed-in description.
94///
95/// The JSON shape this produces and accepts is explicitly not covered by this
96/// crate's semver contract -- see [`Domain`]'s documentation and D-8 in
97/// `DESIGN-NOTES.md`.
98#[derive(Clone, Debug, Default, PartialEq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub struct MachineMemoryTopology {
101    /// Every logical processor, including one for each inactive slot up to a
102    /// group's maximum processor count.
103    ///
104    /// **As the relationship walk reported them.** A processor that only the
105    /// CPU-set enumeration saw is *not* here, and that is a real omission
106    /// rather than an oversight: this list has one source, and inventing an
107    /// entry for a processor the walk never described would put a `Processor`
108    /// here with no `core`, no class and no domain membership -- a fabricated
109    /// record of exactly the kind [`Observed`] exists to prevent.
110    ///
111    /// The disagreement is not lost, it is [reported](Self::coherence):
112    /// [`Coherence::Disagreed`] names such processors in its `cpu_sets_only`
113    /// field. A caller that needs them can read them there, and one that
114    /// ignores coherence sees the walk's view, which is what this field has
115    /// always been.
116    ///
117    /// Reaching that state takes a disagreement surviving every pass of
118    /// [`Self::discover`]'s retry, so it is not the ordinary case -- but the
119    /// ordinary case is not what a topology crate is for. See
120    /// [D-26](../DESIGN-NOTES.md#d-26).
121    pub processors: Vec<Processor>,
122    /// Every domain.
123    pub domains: Vec<Domain>,
124    /// What `GetSystemCpuSetInformation` reported, as **its own observation**.    ///
125    /// Windows describes processors through two APIs, and this is the second
126    /// one. It is not a more convenient spelling of [`Self::domains`]: it
127    /// carries facts the relationship walk has no equivalent for -- whether a
128    /// processor is parked, whether it is allocated to *this* process, the
129    /// scheduler's own last-level-cache grouping, a scheduling class and an
130    /// allocation tag.
131    ///
132    /// It also **duplicates** some facts, deliberately and without
133    /// reconciliation. `CoreIndex`, `NumaNodeIndex` and `EfficiencyClass` also
134    /// appear, derived differently, in `domains` and [`Self::processors`]. The
135    /// two paths can disagree -- under a hypervisor, or where one is stale --
136    /// and merging them here would silently pick a winner and destroy the
137    /// disagreement, which is the only thing a second observer is *for*. Which
138    /// of them a consumer should believe, and what to do when they differ, is a
139    /// decision that has not been taken.
140    ///
141    /// `None` means **not observed**, which is not the same as observed-and-
142    /// empty: a hand-built or deserialized topology has not asked the running
143    /// system, and a consumer must be able to tell that from a machine that
144    /// genuinely reported nothing.
145    #[cfg_attr(feature = "serde", serde(default))]
146    pub cpu_sets: Option<Vec<CpuSet>>,
147    /// What each source said about each processor's attributes.
148    ///
149    /// [D-18](../DESIGN-NOTES.md)'s second subject kind, and the one relation
150    /// unification cannot reach: two sources describing the same core agree on
151    /// `(kind, membership)` even when they disagree about a number hanging off
152    /// it, so that disagreement has nowhere to live among the relation's own
153    /// observations.
154    ///
155    /// Never reduced, for the same reason relations keep both observations:
156    /// collapsing them would destroy the disagreement, which is the only thing
157    /// a second observer is for. Use
158    /// [`Self::attribute_conflicts`] to find the subjects the sources did not
159    /// agree on.
160    ///
161    /// Empty for a hand-built or deserialized topology -- nobody asked, which
162    /// is [`Observed::NotObserved`](crate::Observed::NotObserved)'s meaning
163    /// carried up to a collection.
164    #[cfg_attr(feature = "serde", serde(default, skip))]
165    pub processor_attributes: Vec<AttributeObservation>,
166    /// Where this content came from.
167    ///
168    /// **Defaults to [`Provenance::Synthetic`]**, so a topology built by hand
169    /// or by [`Default`] is tainted unless its author says otherwise. Only
170    /// [`Self::discover`] produces [`Provenance::Measured`], and
171    /// deserialization can never produce it -- see
172    /// [`Provenance`] for why the default points this way.
173    #[cfg_attr(
174        feature = "serde",
175        serde(
176            default,
177            deserialize_with = "crate::provenance::deserialize_downgraded"
178        )
179    )]
180    pub provenance: Provenance,
181    /// Records the enumeration could not decode, if any.
182    ///
183    /// **Empty on every healthy machine**, and empty for a hand-built or
184    /// deserialized topology, which asked nothing. A non-empty list means a
185    /// buffer Windows returned did not describe what it claimed to -- a
186    /// defective hypervisor, a driver corrupting memory -- and it carries the
187    /// byte offset and the reason so that is diagnosable from the data rather
188    /// than from a debugger.
189    ///
190    /// Per [D-24](../DESIGN-NOTES.md#d-24) this is *recorded* rather than
191    /// thrown: a malformed record is not evidence that this crate reached an
192    /// inconsistent state, so it must not panic, and dropping it silently would
193    /// leave a consumer unable to tell a truncated enumeration from a small
194    /// machine. Whatever decoded before the anomaly is still present in the
195    /// fields above and is still correct.
196    ///
197    /// **Written out, never read back in**, for exactly the reason
198    /// [`Self::coherence`] is not: an anomaly is a fact about *an enumeration*,
199    /// and a deserialized topology performed none. A description is welcome to
200    /// carry these so a human reading a dump can see how the run that produced
201    /// it went, but a file cannot *establish* that a buffer walk hit a
202    /// malformed record any more than it can establish that the walk observed
203    /// anything at all (D-12).
204    ///
205    /// This was `serde(default)` until 2026-09-04, which let a document assert
206    /// anomalies into a topology that had asked nothing -- contradicting the
207    /// first paragraph of this very comment, which has always said the list is
208    /// empty for a deserialized topology.
209    #[cfg_attr(feature = "serde", serde(skip_deserializing))]
210    pub enumeration_anomalies: Vec<EnumerationAnomaly>,
211    /// Whether the two Win32 sources described the same machine when this was
212    /// collected.
213    ///
214    /// Stated rather than implied, per [D-16](../DESIGN-NOTES.md#d-16): a
215    /// reader cannot infer from the data's shape how far its parts may be
216    /// *correlated* with each other, and that is a different question from
217    /// whether any single part is accurate.
218    ///
219    /// **Written out, never read back in.** A description is welcome to carry
220    /// this so a human reading a dump can see how the run that produced it
221    /// went, but a file cannot *establish* that two enumerations agreed --
222    /// exactly as it cannot establish that the relationship walk observed
223    /// anything (D-12). So deserialization always yields
224    /// [`Coherence::NotCollected`], which is the true answer for a topology
225    /// nobody collected, and a description claiming `Agreed` gains nothing by
226    /// saying so.
227    #[cfg_attr(feature = "serde", serde(skip_deserializing))]
228    pub coherence: Coherence,
229}
230
231impl MachineMemoryTopology {
232    /// Discover the running system's topology.
233    ///
234    /// # Errors
235    ///
236    /// Returns any error from the underlying `GetLogicalProcessorInformationEx`
237    /// or `GetSystemCpuSetInformation` calls.
238    pub fn discover() -> io::Result<Self> {
239        // D-16's retry. Both calls are whole-machine enumerations and cheap, so
240        // a repeat costs almost nothing, and it is not plausible that several
241        // passes in a row fail to find a coherent set.
242        //
243        // Bounded, and the bound is the point: exhausting it is not a failure
244        // to collect, it is the conclusion that the disagreement is genuine.
245        // The topology is returned either way, with `coherence` saying which
246        // happened.
247        let mut last = None;
248        for attempt in 1..=COHERENCE_ATTEMPTS {
249            let (topology, cpu_sets) = Self::collect_once()?;
250            let (walk_only, cpu_sets_only) = topology.processor_divergence(&cpu_sets);
251            if walk_only.is_empty() && cpu_sets_only.is_empty() {
252                return Ok(topology.finish(cpu_sets, Coherence::Agreed));
253            }
254            last = Some((topology, cpu_sets, walk_only, cpu_sets_only, attempt));
255        }
256
257        // Every pass disagreed. What survives a retry is not transience, so it
258        // is represented rather than retried further or refused over.
259        let (topology, cpu_sets, walk_only, cpu_sets_only, attempts) =
260            last.expect("COHERENCE_ATTEMPTS is a non-zero constant, so the loop ran at least once");
261        Ok(topology.finish(
262            cpu_sets,
263            Coherence::Disagreed {
264                walk_only,
265                cpu_sets_only,
266                attempts,
267            },
268        ))
269    }
270
271    /// One pass over both sources, in the order the fold needs them.
272    fn collect_once() -> io::Result<(Self, Vec<CpuSet>)> {
273        let relations = relation::discover()?;
274        let mut topology = Self::from_relations(relations);
275        // Both are cheap reads of the running system, so both belong to
276        // discovery -- neither is a measurement in the sense that would make it
277        // expensive or optional.
278        // The walk's per-processor claims, recorded before the fold so both
279        // sources' claims about one processor sit side by side (D-18).
280        topology.record_walk_attributes();
281        let (cpu_sets, cpu_set_anomaly) = crate::cpu_set::enumerate()?;
282        topology.enumeration_anomalies.extend(cpu_set_anomaly);
283        // Returned unfolded, because the coherence question is asked of the two
284        // sources *before* they are combined -- once folded, which source said
285        // what about a processor's existence is no longer a question the shape
286        // can answer.
287        Ok((topology, cpu_sets))
288    }
289
290    /// Combine the pass's two sources and state how they agreed.
291    fn finish(mut self, cpu_sets: Vec<CpuSet>, coherence: Coherence) -> Self {
292        // Folded into the relation set, and *also* kept verbatim. Not a
293        // contradiction: D-19's unified view is presented **in addition to**
294        // the individual per-source ones, so a caller wanting what CPU Sets
295        // said, in its own shape, still has it.
296        self.fold_in_cpu_sets(&cpu_sets);
297        self.cpu_sets = Some(cpu_sets);
298        self.coherence = coherence;
299        // The one place in the crate that may claim this is the machine you are
300        // on, because it is the one place that asked the operating system.
301        self.provenance = Provenance::Measured;
302        self
303    }
304
305    /// Which processors one source reported and the other did not.
306    ///
307    /// Existence only. See [`Coherence`] for why grouping disagreements are
308    /// deliberately outside this comparison.
309    fn processor_divergence(&self, cpu_sets: &[CpuSet]) -> (Vec<ProcessorId>, Vec<ProcessorId>) {
310        // Only processors the walk calls active are comparable: it reports a
311        // slot for every position up to a group's maximum, including ones no
312        // processor occupies, and CPU Sets reports only real processors. A raw
313        // set comparison would therefore diverge on every machine with an
314        // inactive slot, which is a difference in what the two APIs enumerate
315        // rather than a difference about the machine.
316        let walk: BTreeSet<ProcessorId> = self
317            .processors
318            .iter()
319            .filter(|processor| processor.online)
320            .map(|processor| processor.id)
321            .collect();
322        let sets: BTreeSet<ProcessorId> = cpu_sets
323            .iter()
324            .map(|set| ProcessorId {
325                group: set.group,
326                number: set.logical_processor_index,
327            })
328            .collect();
329        (
330            walk.difference(&sets).copied().collect(),
331            sets.difference(&walk).copied().collect(),
332        )
333    }
334
335    /// Record what the CPU-set enumeration says about relations, unifying with
336    /// what the relationship walk already reported.    ///
337    /// # What is folded, and what deliberately is not
338    ///
339    /// Only **core** and **NUMA node** membership. Those are the two facts both
340    /// Windows APIs describe, so they are the two where "two sources, one
341    /// relation" can arise at all.
342    ///
343    /// `LastLevelCacheIndex` is **not** folded. Per D-14 it answers a different
344    /// question from the derived cache partitioning -- measured on the
345    /// development host it reports one LLC group where the derivation finds
346    /// eight L2 partitions, and neither is wrong -- so under D-15 it is a
347    /// *different relation*, not a second observation of the same one. Folding
348    /// it into `Cache` would assert an agreement that was never claimed.
349    ///
350    /// `EfficiencyClass` is not folded as a *relation* either, because it is a
351    /// **per-processor attribute** rather than a membership -- D-18's other
352    /// subject kind, tracked as `M3+.1.4`. It is read only when CPU Sets
353    /// reports a core the walk did not, where it supplies that new relation's
354    /// attribute rather than a fabricated one.
355    ///
356    /// # How a relation is matched
357    ///
358    /// By `(kind, membership)` per D-15 -- but *kind* here means which kind, not
359    /// its attributes. Two sources reporting the same core over the same
360    /// processors have observed one relation even if they disagree about its
361    /// efficiency class; that disagreement is an attribute conflict, which is
362    /// the subject `M3+.1.4` covers rather than a reason to treat them as two
363    /// relations.
364    fn fold_in_cpu_sets(&mut self, cpu_sets: &[CpuSet]) {
365        // `CoreIndex` is documented as relative to the record's `Group`, so it
366        // is not unique across a multi-group machine: group 0 core 0 and group 1
367        // core 0 are different cores that share an index. Keying on the index
368        // alone folded them into one cross-group `Core` domain, corrupting both
369        // core membership and the efficiency class derived from it. Raised in
370        // PR #56 review. `NumaNodeIndex` is *not* group-relative -- node numbers
371        // are machine-wide -- so it keys on itself.
372        let cores = Self::grouped_by(cpu_sets, |set| {
373            (u32::from(set.group) << 8) | u32::from(set.core_index)
374        });
375        let nodes = Self::grouped_by(cpu_sets, |set| u32::from(set.numa_node_index));
376
377        self.fold_memberships(
378            &cores,
379            |kind| matches!(kind, DomainKind::Core { .. }),
380            |members| DomainKind::Core {
381                // Derived from the membership rather than reported: CPU Sets
382                // has no SMT field, and a core with more than one logical
383                // processor is what the flag means.
384                simultaneous_multithreading: members.len() > 1,
385                // Taken from what CPU Sets actually reported for these
386                // processors, never fabricated. Defaulting this to `0` would
387                // reinvent the `Processor::capacity` sentinel the reshape
388                // exists to remove -- `0` is a legitimate class, so a stand-in
389                // would be indistinguishable from a real value.
390                efficiency_class: members
391                    .iter()
392                    .map(|set| set.efficiency_class)
393                    .max()
394                    .unwrap_or_default(),
395            },
396            |members| members.first().map_or(0, |set| u32::from(set.core_index)),
397        );
398        self.fold_memberships(
399            &nodes,
400            |kind| matches!(kind, DomainKind::Memory { .. }),
401            |_| DomainKind::Memory {
402                memory_bytes: Observed::NotObserved,
403            },
404            |members| {
405                members
406                    .first()
407                    .map_or(0, |set| u32::from(set.numa_node_index))
408            },
409        );
410        // The attribute subject, which relation unification cannot reach: both
411        // sources report an efficiency class for the same processor, and they
412        // agree about the core while possibly disagreeing about this (D-18).
413        for set in cpu_sets {
414            self.processor_attributes.push(AttributeObservation::new(
415                ProcessorId {
416                    group: set.group,
417                    number: set.logical_processor_index,
418                },
419                ProcessorAttribute::EfficiencyClass,
420                u32::from(set.efficiency_class),
421                Source::CpuSets,
422            ));
423        }
424    }
425
426    /// Record the relationship walk's per-processor attribute claims.
427    ///
428    /// The walk attaches an efficiency class to a *core*, so its claim about a
429    /// processor is its core's value -- fanned out here rather than left for a
430    /// consumer to re-derive, which is the reconstruction this model exists to
431    /// stop.
432    fn record_walk_attributes(&mut self) {
433        let claims: Vec<AttributeObservation> = self
434            .domains
435            .iter()
436            .filter(|domain| domain.observed_by(Source::RelationshipWalk))
437            .filter_map(|domain| match domain.kind {
438                DomainKind::Core {
439                    efficiency_class, ..
440                } => Some((efficiency_class, &domain.processors)),
441                _ => None,
442            })
443            .flat_map(|(efficiency_class, processors)| {
444                processors.iter().map(move |(group, number)| {
445                    AttributeObservation::new(
446                        ProcessorId { group, number },
447                        ProcessorAttribute::EfficiencyClass,
448                        u32::from(efficiency_class),
449                        Source::RelationshipWalk,
450                    )
451                })
452            })
453            .collect();
454        self.processor_attributes.extend(claims);
455    }
456
457    /// The `(processor, attribute)` subjects the sources did not agree on.
458    ///
459    /// Empty is the ordinary answer. A non-empty one is the **attribute
460    /// conflict** [D-17](../DESIGN-NOTES.md) says to expect in the field and to
461    /// record rather than refuse over -- firmware tables are populated
462    /// incrementally, and the places to meet this are hardware nobody here has.
463    ///
464    /// Reported rather than resolved: picking a winner would destroy the
465    /// disagreement, and on a hybrid part the choice decides whether a
466    /// processor is treated as a performance or an efficiency core.
467    #[must_use]
468    pub fn attribute_conflicts(&self) -> Vec<(ProcessorId, ProcessorAttribute)> {
469        let mut claims: BTreeMap<(ProcessorId, ProcessorAttribute), Vec<u32>> = BTreeMap::new();
470        for observation in &self.processor_attributes {
471            claims
472                .entry(observation.subject())
473                .or_default()
474                .push(observation.value);
475        }
476        claims
477            .into_iter()
478            .filter(|(_, values)| {
479                let mut distinct = values.clone();
480                distinct.sort_unstable();
481                distinct.dedup();
482                distinct.len() > 1
483            })
484            .map(|(subject, _)| subject)
485            .collect()
486    }
487
488    /// Group the CPU-set records by whatever `key` names, keeping the records
489    /// themselves so a new relation's attributes come from what was reported.
490    fn grouped_by(
491        cpu_sets: &[CpuSet],
492        key: impl Fn(&CpuSet) -> u32,
493    ) -> BTreeMap<u32, Vec<&CpuSet>> {
494        let mut grouped: BTreeMap<u32, Vec<&CpuSet>> = BTreeMap::new();
495        for set in cpu_sets {
496            grouped.entry(key(set)).or_default().push(set);
497        }
498        grouped
499    }
500
501    /// Attach a CPU-sets observation to each matching relation, adding one
502    /// where no relation of that kind covers the same processors.
503    fn fold_memberships(
504        &mut self,
505        grouped: &BTreeMap<u32, Vec<&CpuSet>>,
506        is_kind: impl Fn(&DomainKind) -> bool,
507        make_kind: impl Fn(&[&CpuSet]) -> DomainKind,
508        label_of: impl Fn(&[&CpuSet]) -> u32,
509    ) {
510        for members in grouped.values() {
511            let mut processors = ProcessorSet::empty();
512            for set in members {
513                processors.insert(set.group, set.logical_processor_index);
514            }
515
516            // The label is the source's own, taken from the records rather than
517            // from the grouping key: those differ wherever the key has to carry
518            // more than the label to be unique, which is the group-relative
519            // `CoreIndex` case below.
520            let observation = Observation::new(Source::CpuSets, label_of(members));
521            match self
522                .domains
523                .iter_mut()
524                .find(|domain| is_kind(&domain.kind) && domain.processors == processors)
525            {
526                // One relation, observed twice. The labels differ and both are
527                // kept, which is the whole of D-15.
528                Some(domain) => domain.observations.push(observation),
529                // A relation only CPU Sets reported. Recorded rather than
530                // dropped: the walk not describing it is a fact about the walk,
531                // not evidence the relation is not there.
532                None => self.domains.push(Domain {
533                    kind: make_kind(members),
534                    processors,
535                    observations: vec![observation],
536                }),
537            }
538        }
539    }
540
541    fn from_relations(relations: Relations) -> Self {
542        let mut domains = Vec::new();
543
544        for group in &relations.groups {
545            domains.push(Domain {
546                kind: DomainKind::Group,
547                processors: group.active_processors.clone(),
548                observations: vec![Observation::new(
549                    Source::RelationshipWalk,
550                    u32::from(group.group),
551                )],
552            });
553        }
554        for (index, package) in relations.packages.iter().enumerate() {
555            domains.push(Domain {
556                kind: DomainKind::Package,
557                processors: package.processors.clone(),
558                observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
559            });
560        }
561        for (index, die) in relations.dies.iter().enumerate() {
562            domains.push(Domain {
563                kind: DomainKind::Die,
564                processors: die.processors.clone(),
565                observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
566            });
567        }
568        for (index, module) in relations.modules.iter().enumerate() {
569            domains.push(Domain {
570                kind: DomainKind::Module,
571                processors: module.processors.clone(),
572                observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
573            });
574        }
575        for (index, core) in relations.cores.iter().enumerate() {
576            domains.push(Domain {
577                kind: DomainKind::Core {
578                    simultaneous_multithreading: core.simultaneous_multithreading,
579                    efficiency_class: core.efficiency_class,
580                },
581                processors: core.processors.clone(),
582                observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
583            });
584        }
585        for (index, cache) in relations.caches.iter().enumerate() {
586            domains.push(Domain {
587                kind: DomainKind::Cache {
588                    level: cache.level,
589                    associativity: cache.associativity,
590                    line_size: cache.line_size,
591                    size_bytes: cache.cache_size,
592                    cache_type: cache.cache_type,
593                },
594                processors: cache.processors.clone(),
595                observations: vec![Observation::new(Source::RelationshipWalk, index as u32)],
596            });
597        }
598        for node in &relations.numa_nodes {
599            domains.push(Domain {
600                kind: DomainKind::Memory {
601                    memory_bytes: Observed::NotObserved,
602                },
603                processors: node.processors.clone(),
604                observations: vec![Observation::new(Source::RelationshipWalk, node.node_number)],
605            });
606        }
607
608        let processors = Self::processors_from(&relations, &domains);
609        Self {
610            processors,
611            domains,
612            cpu_sets: None,
613            processor_attributes: Vec::new(),
614            // Synthetic, not measured: this is a pure transform of whatever
615            // relations it was handed, and cannot know where they came from.
616            // `discover` stamps the claim because `discover` is what read the
617            // machine -- so if this ever gains a second caller, that caller
618            // does not silently inherit an assertion it has not earned.
619            provenance: Provenance::Synthetic,
620            // For the same reason: one set of relations is one source, and
621            // coherence is a statement about two agreeing. `discover` sets this
622            // once it has both.
623            coherence: Coherence::NotCollected,
624            // Carried, not re-derived: whatever the walk could not decode is a
625            // fact about the enumeration that produced these relations, and a
626            // pure transform is the wrong place to lose it.
627            enumeration_anomalies: relations.anomalies,
628        }
629    }
630
631    /// One `Processor` entry per slot up to each group's maximum processor
632    /// count, online or not -- Windows only reports core/cache/node relations
633    /// for active processors, so an inactive slot's capacity falls back to
634    /// `0` rather than being invented.
635    fn processors_from(relations: &Relations, domains: &[Domain]) -> Vec<Processor> {
636        let mut result = Vec::new();
637        for group_info in &relations.groups {
638            for number in 0..group_info.maximum_processor_count {
639                let id = ProcessorId {
640                    group: group_info.group,
641                    number,
642                };
643                let online = group_info
644                    .active_processors
645                    .contains(group_info.group, number);
646                let capacity = online
647                    .then(|| {
648                        domains.iter().find_map(|d| match d.kind {
649                            DomainKind::Core {
650                                efficiency_class, ..
651                            } if d.processors.contains(group_info.group, number) => {
652                                Some(u32::from(efficiency_class))
653                            }
654                            _ => None,
655                        })
656                    })
657                    .flatten()
658                    .unwrap_or(0);
659                result.push(Processor {
660                    id,
661                    online,
662                    capacity,
663                });
664            }
665        }
666        result
667    }
668
669    /// The processor named by `id`, if this topology has one.
670    #[must_use]
671    pub fn processor(&self, id: ProcessorId) -> Option<&Processor> {
672        self.processors.iter().find(|p| p.id == id)
673    }
674
675    /// Every domain of kind [`DomainKind::Group`].
676    pub fn groups(&self) -> impl Iterator<Item = &Domain> {
677        self.domains
678            .iter()
679            .filter(|d| matches!(d.kind, DomainKind::Group))
680    }
681
682    /// Every domain of kind [`DomainKind::Package`].
683    pub fn packages(&self) -> impl Iterator<Item = &Domain> {
684        self.domains
685            .iter()
686            .filter(|d| matches!(d.kind, DomainKind::Package))
687    }
688
689    /// Every domain of kind [`DomainKind::Core`].
690    pub fn cores(&self) -> impl Iterator<Item = &Domain> {
691        self.domains
692            .iter()
693            .filter(|d| matches!(d.kind, DomainKind::Core { .. }))
694    }
695
696    /// Every cache domain, at any level.
697    pub fn caches(&self) -> impl Iterator<Item = &Domain> {
698        self.domains
699            .iter()
700            .filter(|d| matches!(d.kind, DomainKind::Cache { .. }))
701    }
702
703    /// Every cache domain at exactly `level`.
704    pub fn caches_at_level(&self, level: u8) -> impl Iterator<Item = &Domain> {
705        self.domains.iter().filter(
706            move |d| matches!(&d.kind, DomainKind::Cache { level: found, .. } if *found == level),
707        )
708    }
709
710    /// Every cache level this machine reports, ascending, without repeats.
711    ///
712    /// Derived from what the topology actually contains rather than from a
713    /// fixed ceiling. [`DomainKind::Cache`]'s `level` is a `u8`, so a caller
714    /// that sweeps a hard-coded `1..=4` silently reports a partitioning L5 as
715    /// absent -- a wrong answer that looks like a confident one.
716    pub fn cache_levels(&self) -> Vec<u8> {
717        let mut levels: Vec<u8> = self
718            .caches()
719            .filter_map(|d| match &d.kind {
720                DomainKind::Cache { level, .. } => Some(*level),
721                _ => None,
722            })
723            .collect();
724        levels.sort_unstable();
725        levels.dedup();
726        levels
727    }
728
729    /// The distinct processor partitions the caches at `level` form.
730    ///
731    /// # Why this is not [`Self::caches_at_level`]
732    ///
733    /// Windows reports one relationship per *cache*, not per partition, and a
734    /// level is routinely reported more than once over the very same
735    /// processors. Measured on the eight-core development host rather than
736    /// reasoned about: L1 arrives as eight `data` domains **plus** eight
737    /// `instruction` domains covering exactly the same eight processor pairs.
738    ///
739    /// Counting relationships therefore claims sixteen L1 partitions where the
740    /// machine has eight. Two consequences, both silent: a partition count that
741    /// is a whole multiple too large, and -- on a machine whose L1i and L1d are
742    /// its only two cache domains -- a level reported as *partitioning* when it
743    /// divides nothing at all.
744    ///
745    /// Deduplication is by processor set, which is the thing a caller
746    /// partitioning work actually cares about; the first domain covering each
747    /// distinct set is kept, so the returned ids are stable for a topology.
748    ///
749    /// **Distinct is not disjoint.** Two sets that overlap without being equal
750    /// both survive this, so the result is a set of domains rather than a
751    /// proven partition. [`Self::outermost_partitioning_cache`] is where that
752    /// stronger property is required and checked.
753    ///
754    /// **A domain covering no processors is dropped**, because it partitions
755    /// nothing and every consumer of this list wants pieces of the machine. It
756    /// would otherwise survive both filters here and in
757    /// [`Self::outermost_partitioning_cache`]: it is not *equal* to any
758    /// non-empty set, so deduplication keeps it, and it is disjoint from
759    /// everything vacuously, so the pairwise check passes it. A level with one
760    /// real cache plus one empty domain would then count two partitions and be
761    /// reported as dividing a machine it does not divide. Contrast
762    /// [`Self::memory_domains`], which deliberately keeps a processor-less
763    /// domain because a memory domain with no CPUs is real hardware (D-5); a
764    /// *cache* over no processors is not.
765    pub fn cache_partitions_at_level(&self, level: u8) -> Vec<&Domain> {
766        let mut partitions: Vec<&Domain> = Vec::new();
767        for domain in self
768            .caches_at_level(level)
769            .filter(|domain| !domain.processors.is_empty())
770        {
771            if !partitions
772                .iter()
773                .any(|kept| kept.processors == domain.processors)
774            {
775                partitions.push(domain);
776            }
777        }
778        partitions
779    }
780
781    /// The outermost cache level that actually divides this machine, together
782    /// with the distinct partitions it forms.
783    ///
784    /// A level whose caches all cover the same processors partitions nothing --
785    /// a fully shared L3 is one domain spanning everything -- so it is never a
786    /// candidate however far out it sits. `None` means no reported level
787    /// divides the machine, which is a real answer and not a failure: a
788    /// single-core host is one partition by every measure.
789    ///
790    /// This is deliberately not "level 3". A shipping ARM64 laptop measured
791    /// during the 2026-08-30 session reports **no L3 at all**, with two L2
792    /// domains of six processors forming the real cluster boundary.
793    ///
794    /// # A level must be a partition, not merely a set of domains
795    ///
796    /// [`Self::cache_partitions_at_level`] deduplicates by equal processor set,
797    /// which is what the measured case needs (L1i and L1d cover identical
798    /// sets). That alone does **not** make the result a partition: two distinct
799    /// sets can still overlap. Real hardware does not do this, but a
800    /// `MachineMemoryTopology` is deliberately constructible by hand and by deserialization
801    /// (see [`Provenance`](crate::Provenance)), so this method cannot assume
802    /// hardware produced it -- and a caller splitting work across overlapping
803    /// "partitions" double-counts the processors in the intersection and
804    /// overwrites their domain assignment, silently.
805    ///
806    /// So a level qualifies only when its distinct sets are **pairwise
807    /// disjoint**. Full coverage of the online processors is deliberately *not*
808    /// required: a processor with no cache reported at a level is a gap in what
809    /// the firmware said, not evidence that the domains which *were* reported
810    /// overlap, and rejecting the level would discard a true boundary over it.
811    ///
812    /// # "Outermost" is decided by inclusion, not by the level number
813    ///
814    /// Level groups the relations into candidate partitions -- that is reading
815    /// what the source said. It does **not** order them, because "a higher
816    /// number is coarser" is asserted structure of the kind `M2+.2` forbids,
817    /// and a machine that reports L2 over six processors and no L3 at all is
818    /// the counterexample this crate has actually measured.
819    ///
820    /// A candidate is coarser than another when **every block of the other is
821    /// contained in one of its blocks**, which is checkable against the very
822    /// memberships Windows reported. On ordinary hardware the two agree; where
823    /// they disagree, the firmware numbering is the one that is wrong.
824    ///
825    /// Defined here, in the crate that owns the topology, so that every
826    /// consumer asks the same question rather than restating the rule and
827    /// drifting from it.
828    pub fn outermost_partitioning_cache(&self) -> Option<(u8, Vec<&Domain>)> {
829        let candidates: Vec<(u8, Vec<&Domain>)> = self
830            .cache_levels()
831            .into_iter()
832            .filter_map(|level| {
833                let blocks = self.cache_partitions_at_level(level);
834                (blocks.len() > 1 && Self::are_pairwise_disjoint(&blocks))
835                    .then_some((level, blocks))
836            })
837            .collect();
838
839        // Coarsest by inclusion: the candidate no other candidate is coarser
840        // than. Where two candidates describe the *same* partition -- identical
841        // blocks under different levels, which is the ordinary case for an L1
842        // and L2 that split a machine the same way -- neither refines the other,
843        // so the tie is broken by taking the **higher level**. Where they
844        // describe *different* partitions neither of which refines the other,
845        // there is no outermost one and the answer is `None`; see below.
846        //
847        // That tie-break reads the source's own labelling of one boundary and is
848        // not the level ordering `M2+.2` forbids: distinct partitions are still
849        // ordered by inclusion, and level decides only which of two names for
850        // the identical partition is the outer one.
851        // Two candidates can be *incomparable* rather than identical: disjoint
852        // partitions `{0,1}/{2,3}` and `{0,2}/{1,3}` each survive the filter,
853        // neither refines the other, and they are not the same partition. There
854        // is then no outermost partitioning cache, and answering with the
855        // higher-numbered level would invent one -- reintroducing exactly the
856        // level ordering `M2+.2` forbids, in the one case where it changes the
857        // answer. `None` is the honest result, and per D-13 the caller already
858        // has to handle it.
859        let maximal: Vec<_> = candidates
860            .iter()
861            .filter(|candidate| {
862                !candidates
863                    .iter()
864                    .any(|other| Self::refines(&candidate.1, &other.1))
865            })
866            .collect();
867        let first = maximal.first()?;
868        if !maximal
869            .iter()
870            .all(|other| Self::same_partition(&first.1, &other.1))
871        {
872            return None;
873        }
874        maximal
875            .iter()
876            .max_by_key(|(level, _)| *level)
877            .map(|(level, blocks)| (*level, blocks.clone()))
878    }
879
880    /// Whether two candidate partitions have exactly the same blocks.
881    ///
882    /// The identical case `refines` deliberately excludes: an L1 and an L2 that
883    /// split the machine the same way are one boundary under two names, which is
884    /// the ordinary case on real hardware and the only case the level tie-break
885    /// is allowed to decide.
886    fn same_partition(left: &[&Domain], right: &[&Domain]) -> bool {
887        left.len() == right.len()
888            && left
889                .iter()
890                .all(|block| right.iter().any(|o| o.processors == block.processors))
891    }
892
893    /// Whether every block of `finer` sits inside some block of `coarser`, and
894    /// the two are not the same partition.
895    ///
896    /// The refinement order over candidate partitions, derived from the same
897    /// membership inclusion the granularity order uses.
898    fn refines(finer: &[&Domain], coarser: &[&Domain]) -> bool {
899        let all_contained = finer.iter().all(|block| {
900            coarser
901                .iter()
902                .any(|outer| block.processors.is_subset(&outer.processors))
903        });
904        let same = finer.len() == coarser.len()
905            && finer
906                .iter()
907                .all(|block| coarser.iter().any(|o| o.processors == block.processors));
908        all_contained && !same
909    }
910
911    /// Whether no two of these domains claim the same processor.
912    ///
913    /// Quadratic on purpose: the input is one cache level's distinct domains,
914    /// which is a handful even on a large machine, and pairwise intersection
915    /// asks the question directly rather than through a set type this crate
916    /// would otherwise not need.
917    fn are_pairwise_disjoint(domains: &[&Domain]) -> bool {
918        domains.iter().enumerate().all(|(i, left)| {
919            domains[i + 1..]
920                .iter()
921                .all(|right| left.processors.is_disjoint(&right.processors))
922        })
923    }
924
925    /// Everything this crate knows about one processor, without sentinels.
926    ///
927    /// The shard-set surface `M4+.2` calls for: what a caller needs to decide
928    /// whether a processor may host work, gathered so the answer is asked for
929    /// once rather than assembled differently by each consumer.
930    ///
931    /// Every field says which absence it means (D-13), and **none uses a
932    /// sentinel**. That is the point rather than a nicety:
933    /// [`Processor::capacity`] spells "offline", "in no core", and "efficiency
934    /// class zero" as the same `0`, and the third is every processor on every
935    /// non-hybrid machine -- so the stand-in collides with the overwhelmingly
936    /// common real value and no careful caller can tell them apart.
937    #[must_use]
938    pub fn shard_set(&self) -> Vec<ProcessorFacts<'_>> {
939        self.processors
940            .iter()
941            .map(|processor| {
942                let core = self.cores().find(|d| {
943                    d.processors
944                        .contains(processor.id.group, processor.id.number)
945                });
946                let cpu_set = self.cpu_sets.as_ref().and_then(|sets| {
947                    sets.iter().find(|s| {
948                        s.group == processor.id.group
949                            && s.logical_processor_index == processor.id.number
950                    })
951                });
952                ProcessorFacts {
953                    id: processor.id,
954                    online: processor.online,
955                    core,
956                    simultaneous_multithreading: match core.map(|d| &d.kind) {
957                        Some(DomainKind::Core {
958                            simultaneous_multithreading,
959                            ..
960                        }) => Observed::Known(*simultaneous_multithreading),
961                        _ => Observed::NotObserved,
962                    },
963                    efficiency_class: match core.map(|d| &d.kind) {
964                        Some(DomainKind::Core {
965                            efficiency_class, ..
966                        }) => Observed::Known(*efficiency_class),
967                        _ => Observed::NotObserved,
968                    },
969                    parked: cpu_set.map_or(Observed::NotObserved, |s| Observed::Known(s.parked)),
970                    allocated_to_this_process: cpu_set.map_or(Observed::NotObserved, |s| {
971                        Observed::Known(s.allocated_to_target_process)
972                    }),
973                    memory_domain: self.memory_domain_of(processor.id),
974                }
975            })
976            .collect()
977    }
978
979    /// Which memory domain `processor` allocates from.
980    ///
981    /// [`Observed::Known`] with the domain, or [`Observed::NotObserved`] when
982    /// no memory domain names it -- the **unplaced** case, which is deliberately
983    /// not collapsed into "node 0".
984    ///
985    /// # Why the unplaced case gets its own answer
986    ///
987    /// An unknown *cache* domain costs an optimisation. An unknown *memory*
988    /// domain has no honest fallback at all: the pool has to be allocated
989    /// somewhere, and guessing means quietly allocating remote memory for the
990    /// life of the process. `windows-placement-probe` already encodes this
991    /// asymmetry -- it refuses a missing NUMA node while tolerating a missing
992    /// cache domain -- and this method is where that distinction becomes the
993    /// model's rather than each consumer's.
994    ///
995    /// [`Observed::Absent`] is never returned: a memory domain covering no
996    /// processors is a real shape (D-5), but "this processor belongs to no
997    /// node" is a gap in what the firmware said, not a positive statement that
998    /// it has no memory.
999    #[must_use]
1000    pub fn memory_domain_of(&self, processor: ProcessorId) -> Observed<&Domain> {
1001        self.memory_domains()
1002            .find(|domain| {
1003                domain
1004                    .processors
1005                    .contains(processor.group, processor.number)
1006            })
1007            .map_or(Observed::NotObserved, Observed::Known)
1008    }
1009
1010    /// Every processor no memory domain names.
1011    ///
1012    /// The set a caller must decide about before allocating anything, and
1013    /// empty on a machine whose firmware covered every processor. Offered so
1014    /// the question is asked once rather than rediscovered per allocation site.
1015    #[must_use]
1016    pub fn unplaced_processors(&self) -> Vec<ProcessorId> {
1017        self.processors
1018            .iter()
1019            .filter(|processor| !self.memory_domain_of(processor.id).was_observed())
1020            .map(|processor| processor.id)
1021            .collect()
1022    }
1023
1024    /// Every memory domain, including one with no processors (D-5).
1025    pub fn memory_domains(&self) -> impl Iterator<Item = &Domain> {
1026        self.domains
1027            .iter()
1028            .filter(|d| matches!(d.kind, DomainKind::Memory { .. }))
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests;