1use 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#[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#[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
63pub 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
101pub struct FrequencyPriorGenerator {
109 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
155pub struct ThermodynamicStabilityGenerator {
179 entries: Vec<(PrecursorCandidate, f64)>,
185}
186
187impl ThermodynamicStabilityGenerator {
188 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#[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
256pub 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 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 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 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 (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 (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 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 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 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 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 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 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 assert_eq!(
638 output.candidates[0].composition,
639 composition(&[("Ba", 1.0), ("C", 1.0), ("O", 3.0)])
640 );
641 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}