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