Skip to main content

trilogy_parser/
network_search.rs

1//! The v4 source-network enumeration walk (`_enumerate_covers`), ported from
2//! `trilogy/core/processing/v4_helper/network_search.py` + `network_obligations.py`
3//! + the walk-facing slice of `network_model.py`/`network_topology.py`.
4//!
5//! The Python implementation is the spec. Semantics that must survive exactly
6//! (docs/handoff_rust_network_search.md): level-order walk with deterministic
7//! push order, proper-superset dominance against emitted covers by binding
8//! profile, scarcest-obligation branching with `(len, identity)` tiebreak,
9//! visited dedup by state set, both budgets reported by name, soft full-binder
10//! branches after each emit.
11//!
12//! Everything is interned up front: node ids and address ids are assigned in
13//! sorted-name order, so integer comparison reproduces Python's string
14//! ordering everywhere ordering is load-bearing (UTF-8 byte order equals code
15//! point order, which is Python's `str` ordering).
16
17use std::collections::HashMap;
18use std::collections::HashSet;
19
20/// Fixed-width bitset. All sets of one universe (nodes, addresses, terminal
21/// positions) share a width, so ops never reallocate.
22#[derive(Clone, PartialEq, Eq, Hash, Debug)]
23pub struct Bits {
24    words: Vec<u64>,
25}
26
27impl Bits {
28    fn new(universe: usize) -> Self {
29        Bits {
30            words: vec![0; universe.div_ceil(64)],
31        }
32    }
33
34    fn insert(&mut self, index: usize) {
35        self.words[index / 64] |= 1u64 << (index % 64);
36    }
37
38    fn contains(&self, index: usize) -> bool {
39        (self.words[index / 64] >> (index % 64)) & 1 == 1
40    }
41
42    fn with(&self, index: usize) -> Bits {
43        let mut out = self.clone();
44        out.insert(index);
45        out
46    }
47
48    fn intersects(&self, other: &Bits) -> bool {
49        self.words.iter().zip(&other.words).any(|(a, b)| a & b != 0)
50    }
51
52    fn is_subset(&self, other: &Bits) -> bool {
53        self.words.iter().zip(&other.words).all(|(a, b)| a & !b == 0)
54    }
55
56    fn is_proper_subset(&self, other: &Bits) -> bool {
57        self.is_subset(other) && self != other
58    }
59
60    fn count(&self) -> usize {
61        self.words.iter().map(|w| w.count_ones() as usize).sum()
62    }
63
64    fn is_empty(&self) -> bool {
65        self.words.iter().all(|w| *w == 0)
66    }
67
68    /// Ascending index order — id order is sorted-name order, so this is
69    /// exactly Python's `sorted(...)` iteration.
70    fn ones(&self) -> impl Iterator<Item = usize> + '_ {
71        self.words.iter().enumerate().flat_map(|(wi, w)| {
72            let mut word = *w;
73            std::iter::from_fn(move || {
74                if word == 0 {
75                    return None;
76                }
77                let bit = word.trailing_zeros() as usize;
78                word &= word - 1;
79                Some(wi * 64 + bit)
80            })
81        })
82    }
83}
84
85pub struct CandidateSpec {
86    pub node: String,
87    /// (address, partial)
88    pub bindings: Vec<(String, bool)>,
89    pub grain: Vec<String>,
90    /// `ConditionFit.partial_is_full` (IMPLIED_EXACT).
91    pub partial_is_full: bool,
92}
93
94pub struct NetworkSpec {
95    pub terminals: Vec<String>,
96    pub candidates: Vec<CandidateSpec>,
97    /// (class representative, member carrier lists — order is meaningful).
98    pub axis_families: Vec<(String, Vec<Vec<String>>)>,
99    /// (canonical, left side keys, right side keys).
100    pub join_requirements: Vec<(String, Vec<String>, Vec<String>)>,
101    /// arm node -> subsuming union node.
102    pub subsumed_arms: Vec<(String, String)>,
103    pub cover_limit: usize,
104    pub state_limit: usize,
105}
106
107#[derive(PartialEq, Eq, Debug)]
108pub enum LimitKind {
109    Covers,
110    States,
111}
112
113/// `ObligationKind`, ranked by the STRING order of the Python enum values —
114/// the `identity` tiebreak compares those strings.
115#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
116enum Kind {
117    Axis = 0,      // "axis"
118    Colocated = 1, // "colocated"
119    Connected = 2, // "connected"
120    Cover = 3,     // "cover"
121    Labelable = 4, // "labelable"
122    Paired = 5,    // "paired"
123}
124
125/// A pending obligation reduced to what the branch choice reads: the
126/// `(len(satisfiers), identity)` key and the satisfiers themselves.
127struct Ob {
128    kind: Kind,
129    subject: Vec<u32>,
130    satisfiers: Vec<u32>,
131}
132
133impl Ob {
134    fn key(&self) -> (usize, Kind, &[u32]) {
135        (self.satisfiers.len(), self.kind, &self.subject)
136    }
137}
138
139struct AxisMember {
140    nodes: Vec<u32>,
141    bits: Bits,
142    /// Rank of `str(index)` under string ordering within this family — the
143    /// subject's second element compares as Python's stringified index does.
144    subject_rank: u32,
145}
146
147struct AxisFamily {
148    representative: u32,
149    members: Vec<AxisMember>,
150}
151
152struct Side {
153    subject: Vec<u32>,
154    carrier_bits: Bits,
155    materializer_bits: Bits,
156    satisfiers: Vec<u32>,
157}
158
159struct Net {
160    n_nodes: usize,
161    node_names: Vec<String>,
162    terminals: Vec<u32>,
163    binding_keys: Vec<Bits>,
164    grain: Vec<Bits>,
165    grain_nonempty: Vec<bool>,
166    row_complete: Vec<bool>,
167    /// Per terminal position, over nodes.
168    binder_bits: Vec<Bits>,
169    binders: Vec<Vec<u32>>,
170    full_binder_bits: Vec<Bits>,
171    full_binders: Vec<Vec<u32>>,
172    /// Per node, over terminal positions.
173    bound_terminals: Vec<Bits>,
174    join_partners: Vec<Bits>,
175    functional_partners: Vec<Bits>,
176    func_succ: Vec<Bits>,
177    func_pred: Vec<Bits>,
178    /// Per terminal position, over nodes.
179    chain_completers: Vec<Bits>,
180    axis_families: Vec<AxisFamily>,
181    axis_family_index: HashMap<u32, usize>,
182    sides: Vec<Side>,
183    /// Per node: the union node subsuming this partition arm, if any.
184    subsumed: Vec<Option<u32>>,
185    /// COVER satisfiers per terminal position, already arm-pruned.
186    cover_satisfiers: Vec<Vec<u32>>,
187}
188
189fn intern(sorted: &[String], name: &str) -> u32 {
190    sorted
191        .binary_search_by(|probe| probe.as_str().cmp(name))
192        .unwrap_or_else(|_| panic!("uninterned name: {name}")) as u32
193}
194
195impl Net {
196    fn build(spec: &NetworkSpec) -> Net {
197        let mut node_names: Vec<String> =
198            spec.candidates.iter().map(|c| c.node.clone()).collect();
199        node_names.sort();
200        node_names.dedup();
201        let n_nodes = node_names.len();
202
203        let mut addr_names: Vec<String> = Vec::new();
204        addr_names.extend(spec.terminals.iter().cloned());
205        for candidate in &spec.candidates {
206            addr_names.extend(candidate.bindings.iter().map(|(a, _)| a.clone()));
207            addr_names.extend(candidate.grain.iter().cloned());
208        }
209        for (representative, _) in &spec.axis_families {
210            addr_names.push(representative.clone());
211        }
212        for (canonical, left, right) in &spec.join_requirements {
213            addr_names.push(canonical.clone());
214            addr_names.extend(left.iter().cloned());
215            addr_names.extend(right.iter().cloned());
216        }
217        addr_names.sort();
218        addr_names.dedup();
219        let n_addrs = addr_names.len();
220
221        let node_id = |name: &str| intern(&node_names, name) as usize;
222        let addr_id = |name: &str| intern(&addr_names, name) as usize;
223
224        let mut binding_keys = vec![Bits::new(n_addrs); n_nodes];
225        let mut partial_binds = vec![Bits::new(n_addrs); n_nodes];
226        let mut grain = vec![Bits::new(n_addrs); n_nodes];
227        let mut partial_is_full = vec![false; n_nodes];
228        for candidate in &spec.candidates {
229            let node = node_id(&candidate.node);
230            partial_is_full[node] = candidate.partial_is_full;
231            for (address, partial) in &candidate.bindings {
232                let addr = addr_id(address);
233                binding_keys[node].insert(addr);
234                if *partial {
235                    partial_binds[node].insert(addr);
236                }
237            }
238            for address in &candidate.grain {
239                grain[node].insert(addr_id(address));
240            }
241        }
242        let grain_nonempty: Vec<bool> = grain.iter().map(|g| !g.is_empty()).collect();
243        // `_row_complete`: IMPLIED_EXACT, or every grain-address binding FULL.
244        let row_complete: Vec<bool> = (0..n_nodes)
245            .map(|node| partial_is_full[node] || !grain[node].intersects(&partial_binds[node]))
246            .collect();
247        // `binds_fully`: bound, and not partial or IMPLIED_EXACT.
248        let full_binds: Vec<Bits> = (0..n_nodes)
249            .map(|node| {
250                if partial_is_full[node] {
251                    binding_keys[node].clone()
252                } else {
253                    let mut bits = binding_keys[node].clone();
254                    for (word, partial) in bits.words.iter_mut().zip(&partial_binds[node].words) {
255                        *word &= !partial;
256                    }
257                    bits
258                }
259            })
260            .collect();
261
262        let terminals: Vec<u32> = spec.terminals.iter().map(|t| addr_id(t) as u32).collect();
263        let n_terms = terminals.len();
264
265        let mut binder_bits = vec![Bits::new(n_nodes); n_terms];
266        let mut full_binder_bits = vec![Bits::new(n_nodes); n_terms];
267        for (position, terminal) in terminals.iter().enumerate() {
268            for node in 0..n_nodes {
269                if binding_keys[node].contains(*terminal as usize) {
270                    binder_bits[position].insert(node);
271                }
272                if full_binds[node].contains(*terminal as usize) {
273                    full_binder_bits[position].insert(node);
274                }
275            }
276        }
277        let binders: Vec<Vec<u32>> = binder_bits
278            .iter()
279            .map(|bits| bits.ones().map(|n| n as u32).collect())
280            .collect();
281        let full_binders: Vec<Vec<u32>> = full_binder_bits
282            .iter()
283            .map(|bits| bits.ones().map(|n| n as u32).collect())
284            .collect();
285        let mut bound_terminals = vec![Bits::new(n_terms); n_nodes];
286        for (position, bits) in binder_bits.iter().enumerate() {
287            for node in bits.ones() {
288                bound_terminals[node].insert(position);
289            }
290        }
291
292        // Pairwise predicates, each unordered pair asked once (`_partners`).
293        let mut join_partners = vec![Bits::new(n_nodes); n_nodes];
294        let mut functional_partners = vec![Bits::new(n_nodes); n_nodes];
295        let mut func_succ = vec![Bits::new(n_nodes); n_nodes];
296        let mut func_pred = vec![Bits::new(n_nodes); n_nodes];
297        let mut shared = Bits::new(n_addrs);
298        for left in 0..n_nodes {
299            for right in (left + 1)..n_nodes {
300                for (index, word) in shared.words.iter_mut().enumerate() {
301                    *word = binding_keys[left].words[index] & binding_keys[right].words[index];
302                }
303                if shared.is_empty() {
304                    continue;
305                }
306                join_partners[left].insert(right);
307                join_partners[right].insert(left);
308                // `joins_functionally`: either grain covered by the shared
309                // keys — an EMPTY grain counts as covered, matching Python's
310                // `frozenset() <= keys`.
311                if grain[left].is_subset(&shared) || grain[right].is_subset(&shared) {
312                    functional_partners[left].insert(right);
313                    functional_partners[right].insert(left);
314                }
315                // `functional_into` both directions: target grain non-empty
316                // and covered by the shared keys.
317                if grain_nonempty[right] && grain[right].is_subset(&shared) {
318                    func_succ[left].insert(right);
319                    func_pred[right].insert(left);
320                }
321                if grain_nonempty[left] && grain[left].is_subset(&shared) {
322                    func_succ[right].insert(left);
323                    func_pred[left].insert(right);
324                }
325            }
326        }
327
328        // `chain_completers`: ancestors of the full binders, expanded only
329        // through row-complete nodes.
330        let chain_completers: Vec<Bits> = (0..n_terms)
331            .map(|position| {
332                let mut seen = full_binder_bits[position].clone();
333                let mut stack: Vec<usize> = seen.ones().collect();
334                while let Some(current) = stack.pop() {
335                    for origin in func_pred[current].ones() {
336                        if seen.contains(origin) {
337                            continue;
338                        }
339                        seen.insert(origin);
340                        if row_complete[origin] {
341                            stack.push(origin);
342                        }
343                    }
344                }
345                seen
346            })
347            .collect();
348
349        let mut subsumed: Vec<Option<u32>> = vec![None; n_nodes];
350        for (arm, union_node) in &spec.subsumed_arms {
351            subsumed[node_id(arm)] = Some(node_id(union_node) as u32);
352        }
353        let prune = |satisfiers: Vec<u32>| prune_arms(&subsumed, satisfiers);
354
355        // `sorted(network.axis_families.items())` — keys are unique, so the
356        // representative alone orders the families.
357        let mut axis_families: Vec<AxisFamily> = spec
358            .axis_families
359            .iter()
360            .map(|(representative, members)| {
361                let mut index_strings: Vec<String> =
362                    (0..members.len()).map(|i| i.to_string()).collect();
363                index_strings.sort();
364                AxisFamily {
365                    representative: addr_id(representative) as u32,
366                    members: members
367                        .iter()
368                        .enumerate()
369                        .map(|(index, nodes)| {
370                            let ids: Vec<u32> =
371                                nodes.iter().map(|n| node_id(n) as u32).collect();
372                            let mut bits = Bits::new(n_nodes);
373                            for id in &ids {
374                                bits.insert(*id as usize);
375                            }
376                            AxisMember {
377                                nodes: ids,
378                                bits,
379                                subject_rank: index_strings
380                                    .binary_search(&index.to_string())
381                                    .unwrap() as u32,
382                            }
383                        })
384                        .collect(),
385                }
386            })
387            .collect();
388        axis_families.sort_by_key(|family| family.representative);
389        let axis_family_index: HashMap<u32, usize> = axis_families
390            .iter()
391            .enumerate()
392            .map(|(index, family)| (family.representative, index))
393            .collect();
394
395        // PAIRED sides: carriers, materializers and satisfiers are all
396        // cover-independent, so they are computed once.
397        let mut sides: Vec<Side> = Vec::new();
398        for (canonical, left, right) in &spec.join_requirements {
399            let canonical_addr = addr_id(canonical);
400            for keys in [left, right] {
401                if keys.is_empty() {
402                    continue;
403                }
404                let mut key_ids: Vec<u32> = keys.iter().map(|k| addr_id(k) as u32).collect();
405                key_ids.sort();
406                key_ids.dedup();
407                let mut key_bits = Bits::new(n_addrs);
408                for key in &key_ids {
409                    key_bits.insert(*key as usize);
410                }
411                let mut carrier_bits = Bits::new(n_nodes);
412                let mut materializer_bits = Bits::new(n_nodes);
413                let mut materializers: Vec<u32> = Vec::new();
414                for node in 0..n_nodes {
415                    if !key_bits.is_subset(&binding_keys[node]) {
416                        continue;
417                    }
418                    carrier_bits.insert(node);
419                    if binding_keys[node].contains(canonical_addr) {
420                        materializer_bits.insert(node);
421                        materializers.push(node as u32);
422                    }
423                }
424                // `(not grain <= keys, node)`: the dimension these keys
425                // identify before any wider scan carrying both.
426                materializers.sort_by_key(|node| {
427                    (!grain[*node as usize].is_subset(&key_bits), *node)
428                });
429                let mut subject = vec![canonical_addr as u32];
430                subject.extend(key_ids.iter());
431                sides.push(Side {
432                    subject,
433                    carrier_bits,
434                    materializer_bits,
435                    satisfiers: prune(materializers),
436                });
437            }
438        }
439
440        let cover_satisfiers: Vec<Vec<u32>> =
441            binders.iter().map(|list| prune(list.clone())).collect();
442
443        Net {
444            n_nodes,
445            node_names,
446            terminals,
447            binding_keys,
448            grain,
449            grain_nonempty,
450            row_complete,
451            binder_bits,
452            binders,
453            full_binder_bits,
454            full_binders,
455            bound_terminals,
456            join_partners,
457            functional_partners,
458            func_succ,
459            func_pred,
460            chain_completers,
461            axis_families,
462            axis_family_index,
463            sides,
464            subsumed,
465            cover_satisfiers,
466        }
467    }
468
469    /// `_binding_profile`: per-terminal bound level, axis-aware.
470    fn profile(&self, chosen: &Bits) -> Vec<u8> {
471        self.terminals
472            .iter()
473            .enumerate()
474            .map(|(position, terminal)| {
475                if let Some(family_index) = self.axis_family_index.get(terminal) {
476                    if self.axis_families[*family_index]
477                        .members
478                        .iter()
479                        .all(|member| member.bits.intersects(chosen))
480                    {
481                        return 2;
482                    }
483                } else if self.full_binder_bits[position].intersects(chosen) {
484                    return 2;
485                }
486                if self.binder_bits[position].intersects(chosen) {
487                    1
488                } else {
489                    0
490                }
491            })
492            .collect()
493    }
494
495    /// Components of `chosen` under "shares any binding key". Discovered in
496    /// ascending-minimum order, matching the Python union-find's grouping.
497    fn components(&self, chosen: &Bits) -> Vec<Bits> {
498        let mut assigned = Bits::new(self.n_nodes);
499        let mut components: Vec<Bits> = Vec::new();
500        for start in chosen.ones() {
501            if assigned.contains(start) {
502                continue;
503            }
504            let mut component = Bits::new(self.n_nodes);
505            component.insert(start);
506            assigned.insert(start);
507            let mut stack = vec![start];
508            while let Some(current) = stack.pop() {
509                for partner in self.join_partners[current].ones() {
510                    if chosen.contains(partner) && !assigned.contains(partner) {
511                        assigned.insert(partner);
512                        component.insert(partner);
513                        stack.push(partner);
514                    }
515                }
516            }
517            components.push(component);
518        }
519        components
520    }
521
522    /// `_label_chain_state`: walk the in-cover functional chains off `source`;
523    /// labeled when the walk reaches an in-cover full binder of the terminal,
524    /// otherwise the satisfiers are the frontier completers one hop from a
525    /// row-complete walked origin.
526    fn label_chain(&self, source: usize, position: usize, chosen: &Bits) -> Option<Vec<u32>> {
527        let full = &self.full_binder_bits[position];
528        let mut walked = Bits::new(self.n_nodes);
529        walked.insert(source);
530        let mut origins = Bits::new(self.n_nodes);
531        origins.insert(source);
532        let mut stack = vec![source];
533        while let Some(current) = stack.pop() {
534            for node in self.func_succ[current].ones() {
535                if walked.contains(node) || !chosen.contains(node) {
536                    continue;
537                }
538                if full.contains(node) {
539                    return None;
540                }
541                walked.insert(node);
542                if self.row_complete[node] {
543                    origins.insert(node);
544                    stack.push(node);
545                }
546            }
547        }
548        let frontier: Vec<u32> = self.chain_completers[position]
549            .ones()
550            .filter(|node| !chosen.contains(*node) && self.func_pred[*node].intersects(&origins))
551            .map(|node| node as u32)
552            .collect();
553        Some(frontier)
554    }
555
556    /// `compute_pending_obligations` + `prune_subsumed_arms`, reduced to what
557    /// the walk reads: whether anything is pending, and the minimum obligation
558    /// by `(len(satisfiers), identity)`.
559    fn scarcest_pending(&self, chosen: &Bits) -> Option<Ob> {
560        let mut best: Option<Ob> = None;
561        let mut consider = |candidate: Ob| {
562            match &best {
563                Some(current) if current.key() <= candidate.key() => {}
564                _ => best = Some(candidate),
565            }
566        };
567        // cover
568        for (position, terminal) in self.terminals.iter().enumerate() {
569            if self.binder_bits[position].intersects(chosen) {
570                continue;
571            }
572            if self.cover_satisfiers[position].is_empty() {
573                continue;
574            }
575            consider(Ob {
576                kind: Kind::Cover,
577                subject: vec![*terminal],
578                satisfiers: self.cover_satisfiers[position].clone(),
579            });
580        }
581        // axis — the one kind Python mints without a non-empty satisfier
582        // guard; an (authored-empty) member would kill the state, so the
583        // empty list must survive to the branch step here too.
584        for family in &self.axis_families {
585            for member in &family.members {
586                if !member.bits.intersects(chosen) {
587                    consider(Ob {
588                        kind: Kind::Axis,
589                        subject: vec![family.representative, member.subject_rank],
590                        satisfiers: prune_arms(&self.subsumed, member.nodes.clone()),
591                    });
592                }
593            }
594        }
595        // paired
596        for side in &self.sides {
597            if !side.carrier_bits.intersects(chosen)
598                || side.materializer_bits.intersects(chosen)
599                || side.satisfiers.is_empty()
600            {
601                continue;
602            }
603            consider(Ob {
604                kind: Kind::Paired,
605                subject: side.subject.clone(),
606                satisfiers: side.satisfiers.clone(),
607            });
608        }
609        let chosen_count = chosen.count();
610        for source in chosen.ones() {
611            // labelable
612            if !self.bound_terminals[source].is_empty() {
613                for (position, terminal) in self.terminals.iter().enumerate() {
614                    if self.bound_terminals[source].contains(position)
615                        || !self.chain_completers[position].contains(source)
616                    {
617                        continue;
618                    }
619                    if let Some(frontier) = self.label_chain(source, position, chosen) {
620                        let satisfiers = prune_arms(&self.subsumed, frontier);
621                        if !satisfiers.is_empty() {
622                            consider(Ob {
623                                kind: Kind::Labelable,
624                                subject: vec![source as u32, *terminal],
625                                satisfiers,
626                            });
627                        }
628                    }
629                }
630            }
631            // colocated
632            if chosen_count >= 2 && self.grain_nonempty[source] {
633                let mut others = chosen.clone();
634                others.words[source / 64] &= !(1u64 << (source % 64));
635                if !self.functional_partners[source].intersects(&others) {
636                    let mut extras: Vec<u32> = (0..self.n_nodes)
637                        .filter(|extra| {
638                            !chosen.contains(*extra)
639                                && self.grain[source].is_subset(&self.binding_keys[*extra])
640                                && self.functional_partners[*extra].intersects(&others)
641                        })
642                        .map(|extra| extra as u32)
643                        .collect();
644                    extras.sort_by_key(|extra| {
645                        (self.binding_keys[*extra as usize].count(), *extra)
646                    });
647                    let satisfiers = prune_arms(&self.subsumed, extras);
648                    if !satisfiers.is_empty() {
649                        consider(Ob {
650                            kind: Kind::Colocated,
651                            subject: vec![source as u32],
652                            satisfiers,
653                        });
654                    }
655                }
656            }
657        }
658        // connected: deliberately last and only when nothing else is pending.
659        if best.is_none() && chosen_count > 1 {
660            let components = self.components(chosen);
661            if components.len() > 1 {
662                let mut mergers: Vec<u32> = Vec::new();
663                let mut adjacent: Vec<u32> = Vec::new();
664                for node in 0..self.n_nodes {
665                    if chosen.contains(node) {
666                        continue;
667                    }
668                    let touched = components
669                        .iter()
670                        .filter(|component| self.join_partners[node].intersects(component))
671                        .count();
672                    if touched >= 1 {
673                        adjacent.push(node as u32);
674                    }
675                    if touched >= 2 {
676                        mergers.push(node as u32);
677                    }
678                }
679                let satisfiers = prune_arms(
680                    &self.subsumed,
681                    if mergers.is_empty() { adjacent } else { mergers },
682                );
683                if !satisfiers.is_empty() {
684                    let mut subject: Vec<u32> = components
685                        .iter()
686                        .map(|component| component.ones().next().unwrap() as u32)
687                        .collect();
688                    subject.sort();
689                    best = Some(Ob {
690                        kind: Kind::Connected,
691                        subject,
692                        satisfiers,
693                    });
694                }
695            }
696        }
697        best
698    }
699}
700
701/// `prune_subsumed_arms` for one satisfier list: drop an arm whose subsuming
702/// union is ALSO offered for the same obligation.
703fn prune_arms(subsumed: &[Option<u32>], satisfiers: Vec<u32>) -> Vec<u32> {
704    if subsumed.iter().all(|entry| entry.is_none()) {
705        return satisfiers;
706    }
707    let present: HashSet<u32> = satisfiers.iter().copied().collect();
708    let kept: Vec<u32> = satisfiers
709        .iter()
710        .copied()
711        .filter(|node| match subsumed[*node as usize] {
712            Some(union_node) => !present.contains(&union_node),
713            None => true,
714        })
715        .collect();
716    kept
717}
718
719/// `_enumerate_covers`: the level-order obligation walk. Returns the emitted
720/// covers (as node names, in emission order) and which budget truncated the
721/// walk, if any.
722pub fn enumerate_covers(spec: &NetworkSpec) -> (Vec<Vec<String>>, Option<LimitKind>) {
723    let net = Net::build(spec);
724    let mut covers: Vec<Bits> = Vec::new();
725    let mut emitted: Vec<(Bits, Vec<u8>)> = Vec::new();
726    let mut visited: HashSet<Bits> = HashSet::new();
727    let mut level: Vec<Bits> = vec![Bits::new(net.n_nodes)];
728    let mut limit: Option<LimitKind> = None;
729    'walk: while !level.is_empty() {
730        // Within a level, first-pushed pops first: the push order fixes which
731        // covers survive truncation, so it must stay deterministic.
732        let mut next_level: Vec<Bits> = Vec::new();
733        for chosen in level {
734            if visited.contains(&chosen) {
735                continue;
736            }
737            if covers.len() >= spec.cover_limit {
738                limit = Some(LimitKind::Covers);
739                break 'walk;
740            }
741            if visited.len() >= spec.state_limit {
742                limit = Some(LimitKind::States);
743                break 'walk;
744            }
745            visited.insert(chosen.clone());
746            if !emitted.is_empty() {
747                let profile = net.profile(&chosen);
748                if emitted.iter().any(|(prior, prior_profile)| {
749                    prior.is_proper_subset(&chosen) && *prior_profile == profile
750                }) {
751                    continue;
752                }
753            }
754            if let Some(first) = net.scarcest_pending(&chosen) {
755                for node in &first.satisfiers {
756                    next_level.push(chosen.with(*node as usize));
757                }
758                continue;
759            }
760            let profile = net.profile(&chosen);
761            for (position, _) in net.terminals.iter().enumerate() {
762                if net.full_binder_bits[position].intersects(&chosen) {
763                    continue;
764                }
765                // Soft branch: only nodes that are BOTH binders and full
766                // binders — `binders` order is ascending, like the Python sort.
767                for node in &net.full_binders[position] {
768                    if net.binders[position].contains(node) {
769                        next_level.push(chosen.with(*node as usize));
770                    }
771                }
772            }
773            covers.push(chosen.clone());
774            emitted.push((chosen, profile));
775        }
776        level = next_level;
777    }
778    let names: Vec<Vec<String>> = covers
779        .iter()
780        .map(|cover| {
781            cover
782                .ones()
783                .map(|node| net.node_names[node].clone())
784                .collect()
785        })
786        .collect();
787    (names, limit)
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    fn candidate(node: &str, bindings: &[(&str, bool)], grain: &[&str]) -> CandidateSpec {
795        CandidateSpec {
796            node: node.to_string(),
797            bindings: bindings
798                .iter()
799                .map(|(a, p)| (a.to_string(), *p))
800                .collect(),
801            grain: grain.iter().map(|g| g.to_string()).collect(),
802            partial_is_full: false,
803        }
804    }
805
806    fn spec(terminals: &[&str], candidates: Vec<CandidateSpec>) -> NetworkSpec {
807        NetworkSpec {
808            terminals: terminals.iter().map(|t| t.to_string()).collect(),
809            candidates,
810            axis_families: vec![],
811            join_requirements: vec![],
812            subsumed_arms: vec![],
813            cover_limit: 4096,
814            state_limit: 10_000,
815        }
816    }
817
818    #[test]
819    fn single_source_cover() {
820        let s = spec(
821            &["a", "b"],
822            vec![candidate("ds~one", &[("a", false), ("b", false)], &["a"])],
823        );
824        let (covers, limit) = enumerate_covers(&s);
825        assert_eq!(limit, None);
826        assert_eq!(covers, vec![vec!["ds~one".to_string()]]);
827    }
828
829    #[test]
830    fn state_limit_reports_states() {
831        let s = NetworkSpec {
832            state_limit: 1,
833            ..spec(
834                &["a", "b"],
835                vec![
836                    candidate("ds~one", &[("a", false), ("k", false)], &["a"]),
837                    candidate("ds~two", &[("b", false), ("k", false)], &["b"]),
838                ],
839            )
840        };
841        let (covers, limit) = enumerate_covers(&s);
842        assert_eq!(limit, Some(LimitKind::States));
843        assert!(covers.is_empty());
844    }
845
846    #[test]
847    fn soft_branch_emits_full_binder_upgrade() {
848        // `ds~launch` binds `name` only partially; the walk emits the launch
849        // cover, then the soft branch adds the full binder as a second cover.
850        let s = spec(
851            &["launch", "name"],
852            vec![
853                candidate(
854                    "ds~launch",
855                    &[("launch", false), ("vehicle", false), ("name", true)],
856                    &["launch"],
857                ),
858                candidate(
859                    "ds~vehicle",
860                    &[("vehicle", false), ("name", false)],
861                    &["vehicle"],
862                ),
863            ],
864        );
865        let (covers, limit) = enumerate_covers(&s);
866        assert_eq!(limit, None);
867        assert_eq!(
868            covers,
869            vec![
870                vec!["ds~launch".to_string()],
871                vec!["ds~launch".to_string(), "ds~vehicle".to_string()],
872            ]
873        );
874    }
875
876    #[test]
877    fn subsumed_arm_branches_only_onto_the_union() {
878        let mut s = spec(
879            &["k"],
880            vec![
881                candidate("ds~arm_a", &[("k", true)], &["k"]),
882                candidate("ds~arm_b", &[("k", true)], &["k"]),
883                candidate("ds~union", &[("k", false)], &["k"]),
884            ],
885        );
886        s.subsumed_arms = vec![
887            ("ds~arm_a".to_string(), "ds~union".to_string()),
888            ("ds~arm_b".to_string(), "ds~union".to_string()),
889        ];
890        let (covers, limit) = enumerate_covers(&s);
891        assert_eq!(limit, None);
892        assert_eq!(covers, vec![vec!["ds~union".to_string()]]);
893    }
894
895    #[test]
896    fn disconnected_cover_is_bridged() {
897        let s = spec(
898            &["a_val", "b_val"],
899            vec![
900                candidate("ds~fact_a", &[("a_id", false), ("sk", false), ("a_val", false)], &["a_id"]),
901                candidate("ds~fact_b", &[("b_id", false), ("ok", false), ("b_val", false)], &["b_id"]),
902                candidate("ds~bridge", &[("sk", false), ("ok", false)], &["sk", "ok"]),
903            ],
904        );
905        let (covers, limit) = enumerate_covers(&s);
906        assert_eq!(limit, None);
907        assert!(covers.iter().any(|cover| cover.len() == 3));
908        // no emitted cover is the disconnected two-fact pair
909        assert!(!covers.contains(&vec![
910            "ds~fact_a".to_string(),
911            "ds~fact_b".to_string()
912        ]));
913    }
914
915    #[test]
916    fn empty_terminals_emit_the_empty_cover() {
917        let s = spec(&[], vec![candidate("ds~one", &[("a", false)], &["a"])]);
918        let (covers, limit) = enumerate_covers(&s);
919        assert_eq!(limit, None);
920        assert_eq!(covers, vec![Vec::<String>::new()]);
921    }
922}