Skip to main content

gugen/
candidate_generator.rs

1//! Multi-source candidate generation (Phase 30). `CandidateGenerator`
2//! (`src/provider.rs`) is the per-source contract; this module holds every
3//! type built on top of it: the provenance-carrying output shape
4//! (`GeneratedCandidate`), three real generators (`CatalogExactGenerator`,
5//! `FrequencyPriorGenerator`, `ThermodynamicStabilityGenerator`), and the
6//! ensemble that combines them (`CandidateGeneratorEnsemble`). PR 1 shipped
7//! the first two; PR 2 adds `ThermodynamicStabilityGenerator`.
8//! `prior-experiment` and `literature-analog` are each their own future PR;
9//! `chemical-substitution` has no backing data anywhere in this crate and
10//! is deferred indefinitely pending a separate owner decision on
11//! caller-supplied similarity data.
12
13use crate::composition::{Composition, Element};
14use crate::error::{ProviderError, require_finite};
15use crate::precursor::{InMemoryPrecursorCatalog, PrecursorCandidate, PrecursorId};
16use crate::provider::{CandidateGenerator, PrecursorCatalog};
17use crate::target::PlanningConstraints;
18use std::collections::{BTreeMap, BTreeSet};
19
20/// A generator's stable identity, stamped onto every [`GeneratedCandidate`]
21/// it produces and used to label a failed `generate()` call in
22/// [`EnsembleOutput::generator_errors`]. A string newtype rather than an
23/// enum: PR 1 only populates 2 of the eventual 6 named generators, and an
24/// enum with unbuilt variants would force a premature `#[non_exhaustive]`
25/// decision the crate's own API stability policy reserves for types whose
26/// doc comment already states a growth expectation. Adding generator #3
27/// later never forces a semver decision this way.
28///
29/// `Serialize` only, deliberately no `Deserialize`: the inner `&'static
30/// str` cannot deserialize into a non-`'static` borrow from an arbitrary
31/// input buffer -- matches this crate's existing precedent for
32/// `&'static str`-bearing output-only types (`CommercialOfferSelection`,
33/// `src/commercial_catalog/model.rs`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize))]
36pub struct GeneratorId(pub &'static str);
37
38impl std::fmt::Display for GeneratorId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(self.0)
41    }
42}
43
44/// One precursor candidate as proposed by exactly one generator. This is
45/// where "full provenance" actually lives -- deliberately a wrapper type,
46/// not a field added to `PrecursorCandidate` (not `#[non_exhaustive]`,
47/// adding a field there would be a breaking change). `rank` is a plain
48/// ordinal (0 = the generator's own top pick), never a float/confidence:
49/// a generator's internal priority can never be read as a success
50/// probability, because the type has no score field to misuse (mirrors
51/// `SearchPriority`'s own score/priority separation, `src/precursor.rs`).
52///
53/// `Serialize` only -- see [`GeneratorId`]'s doc comment (`generator`
54/// transitively carries its `&'static str` field).
55#[derive(Debug, Clone, PartialEq)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize))]
57pub struct GeneratedCandidate {
58    pub candidate: PrecursorCandidate,
59    pub generator: GeneratorId,
60    pub rank: usize,
61}
62
63/// Wraps an existing [`InMemoryPrecursorCatalog`] as a `CandidateGenerator`
64/// -- "exact" means literally whatever the catalog returns, in its own
65/// element-overlap-filtered order, stamped with rank = output position.
66/// Near-zero new logic: reuses `InMemoryPrecursorCatalog`'s existing
67/// filter/sort/dedup verbatim rather than reimplementing it.
68pub struct CatalogExactGenerator {
69    catalog: InMemoryPrecursorCatalog,
70}
71
72impl CatalogExactGenerator {
73    pub fn new(catalog: InMemoryPrecursorCatalog) -> Self {
74        Self { catalog }
75    }
76}
77
78impl CandidateGenerator for CatalogExactGenerator {
79    fn id(&self) -> GeneratorId {
80        GeneratorId("catalog-exact")
81    }
82
83    fn generate(
84        &self,
85        target: &Composition,
86        constraints: &PlanningConstraints,
87    ) -> std::result::Result<Vec<GeneratedCandidate>, ProviderError> {
88        let candidates = self.catalog.candidates_for(target, constraints)?;
89        Ok(candidates
90            .into_iter()
91            .enumerate()
92            .map(|(rank, candidate)| GeneratedCandidate {
93                candidate,
94                generator: self.id(),
95                rank,
96            })
97            .collect())
98    }
99}
100
101/// Proposes precursors ranked by a caller-supplied frequency table --
102/// never computed or bundled by this crate itself, matching the
103/// established "caller supplies the data, core never fetches/bundles it"
104/// convention (`ThermodynamicProvider`/`MaterialsProjectSnapshotProvider`'s
105/// own precedent). A caller can build the table from anything: their own
106/// literature database, `LiteratureObservationCorpus`, or a benchmark's
107/// own precursor-formula counts.
108pub struct FrequencyPriorGenerator {
109    /// Sorted once at construction (descending frequency, ascending
110    /// `PrecursorId` as a deterministic tie-break) -- mirrors
111    /// `InMemoryPrecursorCatalog::new`'s own "sort once, not per-query"
112    /// convention.
113    entries: Vec<(PrecursorCandidate, u64)>,
114}
115
116impl FrequencyPriorGenerator {
117    pub fn new(mut entries: Vec<(PrecursorCandidate, u64)>) -> Self {
118        entries.sort_by(|(a, a_freq), (b, b_freq)| {
119            b_freq.cmp(a_freq).then_with(|| a.id.0.cmp(&b.id.0))
120        });
121        Self { entries }
122    }
123}
124
125impl CandidateGenerator for FrequencyPriorGenerator {
126    fn id(&self) -> GeneratorId {
127        GeneratorId("frequency-prior")
128    }
129
130    fn generate(
131        &self,
132        target: &Composition,
133        _constraints: &PlanningConstraints,
134    ) -> std::result::Result<Vec<GeneratedCandidate>, ProviderError> {
135        let target_elements: BTreeSet<Element> = target.elements().collect();
136        Ok(self
137            .entries
138            .iter()
139            .filter(|(candidate, _frequency)| {
140                candidate
141                    .composition
142                    .elements()
143                    .any(|e| target_elements.contains(&e))
144            })
145            .enumerate()
146            .map(|(rank, (candidate, _frequency))| GeneratedCandidate {
147                candidate: candidate.clone(),
148                generator: self.id(),
149                rank,
150            })
151            .collect())
152    }
153}
154
155/// Ranks caller-supplied precursor candidates by absolute thermodynamic
156/// stability -- most-negative 0 K formation enthalpy per atom first, the
157/// standard materials-informatics cross-compound stability proxy (a
158/// convex-hull y-axis value). Never computed or fetched by this crate
159/// itself, matching the established "caller supplies real data, core
160/// never bundles it" convention (`ThermodynamicProvider`/
161/// `MaterialsProjectSnapshotProvider`'s own precedent, and
162/// [`FrequencyPriorGenerator`]'s own shape).
163///
164/// **This ranks each candidate's own absolute stability, not predicted
165/// favorability of any specific reaction toward the search target.** A
166/// true target-fit signal would need to know the other precursors chosen
167/// and the eventual balanced reaction -- information
168/// `CandidateGenerator::generate`'s signature (target + constraints only)
169/// structurally cannot express, not merely something this generator
170/// doesn't yet compute. `decomposition_margin_ev_per_atom`/
171/// `balanced_reaction_delta_ev_per_atom` (`src/thermodynamics.rs`) are the
172/// reaction-level alternatives; neither is reachable from a
173/// `CandidateGenerator`. `ThermodynamicProvider::competing_phases` was
174/// deliberately not repurposed into a ranking margin here either -- its
175/// own doc comment states it is "context only, never converted into a
176/// selectivity score," and reusing it that way here would violate that
177/// stated contract.
178pub struct ThermodynamicStabilityGenerator {
179    /// Sorted once at construction (ascending by formation energy -- most
180    /// negative/most stable first -- ascending `PrecursorId` as a
181    /// deterministic tie-break) -- mirrors
182    /// `InMemoryPrecursorCatalog::new`/`FrequencyPriorGenerator::new`'s own
183    /// "sort once, not per-query" convention.
184    entries: Vec<(PrecursorCandidate, f64)>,
185}
186
187impl ThermodynamicStabilityGenerator {
188    /// Validates every formation-energy value is finite, matching every
189    /// other f64-formation-energy constructor in this crate
190    /// (`CompetingPhase::new`, `SolidThermodynamicEntry::new`,
191    /// `ReactionEnergy::new` all call `require_finite`) -- prioritized
192    /// over infallibly mirroring `FrequencyPriorGenerator`'s shape (owner's
193    /// explicit choice). `f64::total_cmp`, not `partial_cmp`, for the sort
194    /// -- no silent NaN-ordering surprise (`require_finite` above already
195    /// rules NaN out, but `total_cmp` keeps the ordering total and
196    /// panic-free regardless).
197    pub fn new(mut entries: Vec<(PrecursorCandidate, f64)>) -> crate::error::Result<Self> {
198        for (_candidate, formation_energy) in &entries {
199            require_finite("formation_enthalpy_ev_per_atom", *formation_energy)?;
200        }
201        entries.sort_by(|(a, a_energy), (b, b_energy)| {
202            a_energy
203                .total_cmp(b_energy)
204                .then_with(|| a.id.0.cmp(&b.id.0))
205        });
206        Ok(Self { entries })
207    }
208}
209
210impl CandidateGenerator for ThermodynamicStabilityGenerator {
211    fn id(&self) -> GeneratorId {
212        GeneratorId("thermodynamic-stability")
213    }
214
215    fn generate(
216        &self,
217        target: &Composition,
218        _constraints: &PlanningConstraints,
219    ) -> std::result::Result<Vec<GeneratedCandidate>, ProviderError> {
220        let target_elements: BTreeSet<Element> = target.elements().collect();
221        Ok(self
222            .entries
223            .iter()
224            .filter(|(candidate, _formation_energy)| {
225                candidate
226                    .composition
227                    .elements()
228                    .any(|e| target_elements.contains(&e))
229            })
230            .enumerate()
231            .map(
232                |(rank, (candidate, _formation_energy))| GeneratedCandidate {
233                    candidate: candidate.clone(),
234                    generator: self.id(),
235                    rank,
236                },
237            )
238            .collect())
239    }
240}
241
242/// Combined output of every generator in a [`CandidateGeneratorEnsemble`]
243/// run, per design principle 5 (every branch/provider outcome stays
244/// distinguishable, never silently dropped): `candidates` is what a
245/// `PrecursorCatalog` caller (e.g. `search_precursor_sets`) actually
246/// consumes, `provenance` keeps every generator that proposed each id, and
247/// `generator_errors` keeps every generator that failed outright, labeled
248/// by which one.
249#[derive(Debug, Clone, PartialEq)]
250pub struct EnsembleOutput {
251    pub candidates: Vec<PrecursorCandidate>,
252    pub provenance: BTreeMap<PrecursorId, Vec<GeneratedCandidate>>,
253    pub generator_errors: Vec<(GeneratorId, ProviderError)>,
254}
255
256/// Combines multiple [`CandidateGenerator`]s into one candidate list via
257/// min-rank fusion, and implements [`PrecursorCatalog`] itself -- so an
258/// ensemble is a drop-in `Planner::builder` catalog argument, requiring
259/// zero changes to `Planner`/`PlannerBuilder`. PR 1 does not wire this
260/// into `Planner`; every measurement in this PR calls the ensemble
261/// directly, the same way every existing exploration-recall benchmark
262/// already bypasses `Planner` and calls `search_precursor_sets` directly.
263pub struct CandidateGeneratorEnsemble {
264    generators: Vec<Box<dyn CandidateGenerator>>,
265}
266
267impl CandidateGeneratorEnsemble {
268    pub fn new(generators: Vec<Box<dyn CandidateGenerator>>) -> Self {
269        Self { generators }
270    }
271
272    /// Runs every generator, catching each one's failure individually
273    /// (mirrors the existing `route_suitability_provider` catch-and-
274    /// continue loop already in `Planner::plan`, `src/planner.rs`) so one
275    /// generator's failure never prevents the others' candidates from
276    /// being used.
277    ///
278    /// **Combination rule -- min-rank fusion**: a candidate's ensemble
279    /// rank is the smallest rank any generator gave it, ties broken
280    /// alphabetically by `PrecursorId` (matches this crate's existing
281    /// determinism discipline, e.g. `search_precursor_sets`'s own
282    /// tiebreaks). **Duplicate-id conflicts** (two generators proposing
283    /// the same id with different composition/availability data):
284    /// first-generator-in-list wins for the payload, matching
285    /// `InMemoryPrecursorCatalog::new`'s own stated precedent -- but
286    /// every generator that proposed it still appears in `provenance`,
287    /// so nothing is silently collapsed.
288    pub fn generate_with_provenance(
289        &self,
290        target: &Composition,
291        constraints: &PlanningConstraints,
292    ) -> EnsembleOutput {
293        let mut best: BTreeMap<PrecursorId, (usize, PrecursorCandidate)> = BTreeMap::new();
294        let mut provenance: BTreeMap<PrecursorId, Vec<GeneratedCandidate>> = BTreeMap::new();
295        let mut generator_errors = Vec::new();
296
297        for generator in &self.generators {
298            match generator.generate(target, constraints) {
299                Ok(generated) => {
300                    for gc in generated {
301                        let id = gc.candidate.id.clone();
302                        best.entry(id.clone())
303                            .and_modify(|(rank, _payload)| {
304                                if gc.rank < *rank {
305                                    *rank = gc.rank;
306                                }
307                            })
308                            .or_insert_with(|| (gc.rank, gc.candidate.clone()));
309                        provenance.entry(id).or_default().push(gc);
310                    }
311                }
312                Err(err) => generator_errors.push((generator.id(), err)),
313            }
314        }
315
316        let mut fused: Vec<(usize, PrecursorCandidate)> = best.into_values().collect();
317        fused.sort_by(|(rank_a, candidate_a), (rank_b, candidate_b)| {
318            rank_a
319                .cmp(rank_b)
320                .then_with(|| candidate_a.id.0.cmp(&candidate_b.id.0))
321        });
322
323        EnsembleOutput {
324            candidates: fused
325                .into_iter()
326                .map(|(_rank, candidate)| candidate)
327                .collect(),
328            provenance,
329            generator_errors,
330        }
331    }
332}
333
334impl PrecursorCatalog for CandidateGeneratorEnsemble {
335    fn candidates_for(
336        &self,
337        target: &Composition,
338        constraints: &PlanningConstraints,
339    ) -> std::result::Result<Vec<PrecursorCandidate>, ProviderError> {
340        Ok(self
341            .generate_with_provenance(target, constraints)
342            .candidates)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn element(symbol: &str) -> Element {
351        Element::new(symbol).unwrap()
352    }
353
354    fn composition(pairs: &[(&str, f64)]) -> Composition {
355        Composition::new(pairs.iter().map(|&(sym, amt)| (element(sym), amt))).unwrap()
356    }
357
358    fn candidate(id: &str, pairs: &[(&str, f64)]) -> PrecursorCandidate {
359        PrecursorCandidate {
360            id: PrecursorId(id.to_string()),
361            composition: composition(pairs),
362            availability: None,
363        }
364    }
365
366    fn no_constraints() -> PlanningConstraints {
367        PlanningConstraints::default()
368    }
369
370    fn barium_titanate_target() -> Composition {
371        composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)])
372    }
373
374    /// Always fails, to exercise `CandidateGeneratorEnsemble`'s per-
375    /// generator error handling without needing a second real generator.
376    struct AlwaysFailsGenerator;
377
378    impl CandidateGenerator for AlwaysFailsGenerator {
379        fn id(&self) -> GeneratorId {
380            GeneratorId("always-fails")
381        }
382
383        fn generate(
384            &self,
385            _target: &Composition,
386            _constraints: &PlanningConstraints,
387        ) -> std::result::Result<Vec<GeneratedCandidate>, ProviderError> {
388            Err(ProviderError::Unavailable("test failure".to_string()))
389        }
390    }
391
392    #[test]
393    fn catalog_exact_generator_delegates_and_stamps_rank_by_output_position() {
394        let catalog = InMemoryPrecursorCatalog::new(vec![
395            candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
396            candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
397            candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]),
398        ]);
399        let generator = CatalogExactGenerator::new(catalog);
400
401        let generated = generator
402            .generate(&barium_titanate_target(), &no_constraints())
403            .unwrap();
404
405        // NaCl shares no element with Ba-Ti-O, so InMemoryPrecursorCatalog's
406        // own element-overlap filter drops it; BaCO3/TiO2 survive, sorted
407        // by id (InMemoryPrecursorCatalog::new's own sort).
408        let ids: Vec<&str> = generated
409            .iter()
410            .map(|gc| gc.candidate.id.0.as_str())
411            .collect();
412        assert_eq!(ids, vec!["BaCO3", "TiO2"]);
413        assert!(
414            generated
415                .iter()
416                .all(|gc| gc.generator == GeneratorId("catalog-exact"))
417        );
418        assert_eq!(generated[0].rank, 0);
419        assert_eq!(generated[1].rank, 1);
420    }
421
422    #[test]
423    fn frequency_prior_generator_filters_by_element_overlap_and_preserves_frequency_order() {
424        let generator = FrequencyPriorGenerator::new(vec![
425            (candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]), 5),
426            (
427                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
428                50,
429            ),
430            // Irrelevant to the Ba-Ti-O target -- must be filtered out
431            // regardless of its (highest) frequency.
432            (candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]), 1000),
433        ]);
434
435        let generated = generator
436            .generate(&barium_titanate_target(), &no_constraints())
437            .unwrap();
438
439        let ids: Vec<&str> = generated
440            .iter()
441            .map(|gc| gc.candidate.id.0.as_str())
442            .collect();
443        assert_eq!(
444            ids,
445            vec!["BaCO3", "TiO2"],
446            "higher frequency (50) must rank first"
447        );
448        assert!(
449            generated
450                .iter()
451                .all(|gc| gc.generator == GeneratorId("frequency-prior"))
452        );
453        assert_eq!(generated[0].rank, 0);
454        assert_eq!(generated[1].rank, 1);
455    }
456
457    #[test]
458    fn thermodynamic_stability_generator_rejects_a_non_finite_formation_energy() {
459        assert!(
460            ThermodynamicStabilityGenerator::new(vec![(
461                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
462                f64::NAN,
463            )])
464            .is_err()
465        );
466        assert!(
467            ThermodynamicStabilityGenerator::new(vec![(
468                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
469                f64::INFINITY,
470            )])
471            .is_err()
472        );
473        assert!(
474            ThermodynamicStabilityGenerator::new(vec![(
475                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
476                -3.5,
477            )])
478            .is_ok()
479        );
480    }
481
482    #[test]
483    fn thermodynamic_stability_generator_filters_by_element_overlap_and_ranks_most_stable_first() {
484        let generator = ThermodynamicStabilityGenerator::new(vec![
485            (candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]), -3.0),
486            (
487                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
488                -3.5,
489            ),
490            // Irrelevant to the Ba-Ti-O target -- must be filtered out
491            // regardless of how stable it is.
492            (candidate("NaCl", &[("Na", 1.0), ("Cl", 1.0)]), -10.0),
493        ])
494        .unwrap();
495
496        let generated = generator
497            .generate(&barium_titanate_target(), &no_constraints())
498            .unwrap();
499
500        let ids: Vec<&str> = generated
501            .iter()
502            .map(|gc| gc.candidate.id.0.as_str())
503            .collect();
504        assert_eq!(
505            ids,
506            vec!["BaCO3", "TiO2"],
507            "more negative formation energy (-3.5) must rank first"
508        );
509        assert!(
510            generated
511                .iter()
512                .all(|gc| gc.generator == GeneratorId("thermodynamic-stability"))
513        );
514        assert_eq!(generated[0].rank, 0);
515        assert_eq!(generated[1].rank, 1);
516    }
517
518    #[test]
519    fn ensemble_min_rank_fuses_candidates_proposed_by_either_generator() {
520        // catalog-exact ranks BaCO3 (0), TiO2 (1) -- both sorted by id.
521        let catalog_exact = CatalogExactGenerator::new(InMemoryPrecursorCatalog::new(vec![
522            candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
523            candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
524        ]));
525        // frequency-prior ranks TiO2 (0), BaCO3 (1) -- opposite order.
526        let frequency_prior = FrequencyPriorGenerator::new(vec![
527            (candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]), 100),
528            (
529                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
530                1,
531            ),
532        ]);
533
534        let ensemble = CandidateGeneratorEnsemble::new(vec![
535            Box::new(catalog_exact),
536            Box::new(frequency_prior),
537        ]);
538        let output =
539            ensemble.generate_with_provenance(&barium_titanate_target(), &no_constraints());
540
541        // Min-rank fusion: BaCO3's best rank is 0 (from catalog-exact),
542        // TiO2's best rank is also 0 (from frequency-prior) -- tie broken
543        // alphabetically by id.
544        let ids: Vec<&str> = output.candidates.iter().map(|c| c.id.0.as_str()).collect();
545        assert_eq!(ids, vec!["BaCO3", "TiO2"]);
546        assert!(output.generator_errors.is_empty());
547
548        // Both generators proposed both candidates -- provenance keeps
549        // every one, nothing silently collapsed.
550        assert_eq!(
551            output.provenance[&PrecursorId("BaCO3".to_string())].len(),
552            2
553        );
554        assert_eq!(output.provenance[&PrecursorId("TiO2".to_string())].len(), 2);
555    }
556
557    #[test]
558    fn ensemble_fuses_a_third_generator_including_a_candidate_only_it_proposed() {
559        let catalog_exact = CatalogExactGenerator::new(InMemoryPrecursorCatalog::new(vec![
560            candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]),
561            candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
562        ]));
563        let frequency_prior = FrequencyPriorGenerator::new(vec![
564            (candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]), 100),
565            (
566                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
567                1,
568            ),
569        ]);
570        // Proposes BaCO3/TiO2 too, plus BaO -- a candidate neither of the
571        // other two generators knows about at all.
572        let thermodynamic_stability = ThermodynamicStabilityGenerator::new(vec![
573            (candidate("TiO2", &[("Ti", 1.0), ("O", 2.0)]), -3.0),
574            (
575                candidate("BaCO3", &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)]),
576                -3.5,
577            ),
578            (candidate("BaO", &[("Ba", 1.0), ("O", 1.0)]), -2.0),
579        ])
580        .unwrap();
581
582        let ensemble = CandidateGeneratorEnsemble::new(vec![
583            Box::new(catalog_exact),
584            Box::new(frequency_prior),
585            Box::new(thermodynamic_stability),
586        ]);
587        let output =
588            ensemble.generate_with_provenance(&barium_titanate_target(), &no_constraints());
589
590        let ids: std::collections::BTreeSet<&str> =
591            output.candidates.iter().map(|c| c.id.0.as_str()).collect();
592        assert_eq!(
593            ids,
594            std::collections::BTreeSet::from(["BaCO3", "TiO2", "BaO"]),
595            "the union of all three generators' candidates, including the one only \
596            thermodynamic-stability proposed"
597        );
598        assert!(output.generator_errors.is_empty());
599
600        assert_eq!(
601            output.provenance[&PrecursorId("BaCO3".to_string())].len(),
602            3,
603            "all three generators proposed BaCO3"
604        );
605        assert_eq!(
606            output.provenance[&PrecursorId("TiO2".to_string())].len(),
607            3,
608            "all three generators proposed TiO2"
609        );
610        assert_eq!(
611            output.provenance[&PrecursorId("BaO".to_string())].len(),
612            1,
613            "only thermodynamic-stability proposed BaO"
614        );
615    }
616
617    #[test]
618    fn ensemble_duplicate_id_conflict_keeps_first_generators_payload_but_records_every_proposer() {
619        // Two generators proposing the same id with *different*
620        // composition data (a malformed-input scenario, deliberately
621        // constructed to test the conflict rule).
622        let first = CatalogExactGenerator::new(InMemoryPrecursorCatalog::new(vec![candidate(
623            "BaCO3",
624            &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)],
625        )]));
626        let second = FrequencyPriorGenerator::new(vec![(
627            candidate("BaCO3", &[("Ba", 2.0), ("C", 1.0), ("O", 3.0)]),
628            10,
629        )]);
630
631        let ensemble = CandidateGeneratorEnsemble::new(vec![Box::new(first), Box::new(second)]);
632        let output =
633            ensemble.generate_with_provenance(&barium_titanate_target(), &no_constraints());
634
635        assert_eq!(output.candidates.len(), 1);
636        // First-generator-in-list (catalog-exact) wins the payload.
637        assert_eq!(
638            output.candidates[0].composition,
639            composition(&[("Ba", 1.0), ("C", 1.0), ("O", 3.0)])
640        );
641        // But both proposals are still visible in provenance.
642        assert_eq!(
643            output.provenance[&PrecursorId("BaCO3".to_string())].len(),
644            2
645        );
646    }
647
648    #[test]
649    fn ensemble_records_a_failed_generators_error_and_still_returns_the_others_candidates() {
650        let catalog_exact =
651            CatalogExactGenerator::new(InMemoryPrecursorCatalog::new(vec![candidate(
652                "BaCO3",
653                &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)],
654            )]));
655
656        let ensemble = CandidateGeneratorEnsemble::new(vec![
657            Box::new(catalog_exact),
658            Box::new(AlwaysFailsGenerator),
659        ]);
660        let output =
661            ensemble.generate_with_provenance(&barium_titanate_target(), &no_constraints());
662
663        assert_eq!(output.candidates.len(), 1);
664        assert_eq!(output.candidates[0].id, PrecursorId("BaCO3".to_string()));
665        assert_eq!(output.generator_errors.len(), 1);
666        assert_eq!(output.generator_errors[0].0, GeneratorId("always-fails"));
667    }
668
669    #[test]
670    fn ensemble_as_precursor_catalog_returns_the_same_candidates_as_generate_with_provenance() {
671        let catalog_exact =
672            CatalogExactGenerator::new(InMemoryPrecursorCatalog::new(vec![candidate(
673                "BaCO3",
674                &[("Ba", 1.0), ("C", 1.0), ("O", 3.0)],
675            )]));
676        let ensemble = CandidateGeneratorEnsemble::new(vec![Box::new(catalog_exact)]);
677
678        let via_trait = PrecursorCatalog::candidates_for(
679            &ensemble,
680            &barium_titanate_target(),
681            &no_constraints(),
682        )
683        .unwrap();
684        let via_inherent =
685            ensemble.generate_with_provenance(&barium_titanate_target(), &no_constraints());
686
687        assert_eq!(via_trait, via_inherent.candidates);
688    }
689}