1use crate::evidence::{EvidenceKind, EvidenceScope, EvidenceStrength, PlanningEvidence};
2use crate::process::{
3 Atmosphere, DurationRange, HeatingPurpose, PlannedStep, ProcessStep, RampRateRange,
4 TemperatureRange,
5};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, PartialEq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct ProcessPrecedent {
16 pub description: String,
17 pub conditions: Vec<ConditionPrecedent>,
18}
19
20#[derive(Debug, Clone, PartialEq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct ConditionPrecedent {
33 pub purpose: HeatingPurpose,
34 pub temperature: Option<TemperatureRange>,
35 pub duration: Option<DurationRange>,
36 pub atmosphere: Option<Atmosphere>,
37 pub ramp: Option<RampRateRange>,
38 pub evidence_kind: EvidenceKind,
39 pub source_id: Option<String>,
40 pub statement: String,
41 pub strength: EvidenceStrength,
42 pub applicable_to: EvidenceScope,
43}
44
45pub(crate) const CONDITION_FIELD_TEMPERATURE: &str = "temperature";
74pub(crate) const CONDITION_FIELD_DURATION: &str = "duration";
75pub(crate) const CONDITION_FIELD_ATMOSPHERE: &str = "atmosphere";
76pub(crate) const CONDITION_FIELD_RAMP_RATE: &str = "ramp rate";
77
78#[derive(Debug, Clone, PartialEq)]
79pub struct ConditionConflict {
80 pub step_index: usize,
81 pub field: &'static str,
82 pub reason: String,
83}
84
85enum FieldResolution<T> {
90 Resolved(T, Vec<usize>),
95 Conflict(Vec<(T, Option<String>)>),
99}
100
101fn resolve_field<T: PartialEq + Clone>(
102 candidates: impl Iterator<Item = (usize, T, Option<String>)>,
103) -> Option<FieldResolution<T>> {
104 let mut distinct: Vec<(T, Vec<usize>, Option<String>)> = Vec::new();
105 for (idx, value, source_id) in candidates {
106 match distinct.iter_mut().find(|(v, _, _)| *v == value) {
107 Some(entry) => entry.1.push(idx),
108 None => distinct.push((value, vec![idx], source_id)),
109 }
110 }
111 if distinct.is_empty() {
112 return None;
113 }
114 if distinct.len() == 1 {
115 let (value, idxs, _) = distinct.into_iter().next().expect("checked len == 1");
116 return Some(FieldResolution::Resolved(value, idxs));
117 }
118 Some(FieldResolution::Conflict(
119 distinct
120 .into_iter()
121 .map(|(value, _, source_id)| (value, source_id))
122 .collect(),
123 ))
124}
125
126fn format_conflict_reason<T: std::fmt::Debug>(
127 field: &str,
128 values: &[(T, Option<String>)],
129) -> String {
130 let sources: Vec<String> = values
131 .iter()
132 .map(|(v, source_id)| {
133 let cited = source_id.as_deref().unwrap_or("uncited");
134 format!("{v:?} ({cited})")
135 })
136 .collect();
137 format!(
138 "{} matching literature precedents disagree on {field}: {} -- left unresolved rather \
139 than picking one or averaging",
140 sources.len(),
141 sources.join(" vs. "),
142 )
143}
144
145pub(crate) fn apply_condition_precedents(
154 steps: &mut [PlannedStep],
155 precedents: &[ConditionPrecedent],
156) -> (Vec<PlanningEvidence>, Vec<ConditionConflict>) {
157 let mut evidence = Vec::new();
158 let mut conflicts = Vec::new();
159
160 for (step_index, planned) in steps.iter_mut().enumerate() {
161 let ProcessStep::Heat {
162 purpose,
163 temperature,
164 duration,
165 atmosphere,
166 ramp,
167 } = &mut planned.step
168 else {
169 continue;
170 };
171 let matching: Vec<&ConditionPrecedent> = precedents
172 .iter()
173 .filter(|p| p.purpose == *purpose)
174 .collect();
175 if matching.is_empty() {
176 continue;
177 }
178
179 let mut contributed: BTreeMap<usize, Vec<&'static str>> = BTreeMap::new();
185
186 if temperature.is_none() {
187 let candidates = matching
188 .iter()
189 .enumerate()
190 .filter_map(|(i, p)| p.temperature.map(|t| (i, t, p.source_id.clone())));
191 match resolve_field(candidates) {
192 Some(FieldResolution::Resolved(value, idxs)) => {
193 *temperature = Some(value);
194 for i in idxs {
195 contributed
196 .entry(i)
197 .or_default()
198 .push(CONDITION_FIELD_TEMPERATURE);
199 }
200 }
201 Some(FieldResolution::Conflict(values)) => conflicts.push(ConditionConflict {
202 step_index,
203 field: CONDITION_FIELD_TEMPERATURE,
204 reason: format_conflict_reason(CONDITION_FIELD_TEMPERATURE, &values),
205 }),
206 None => {}
207 }
208 }
209 if duration.is_none() {
210 let candidates = matching
211 .iter()
212 .enumerate()
213 .filter_map(|(i, p)| p.duration.map(|d| (i, d, p.source_id.clone())));
214 match resolve_field(candidates) {
215 Some(FieldResolution::Resolved(value, idxs)) => {
216 *duration = Some(value);
217 for i in idxs {
218 contributed
219 .entry(i)
220 .or_default()
221 .push(CONDITION_FIELD_DURATION);
222 }
223 }
224 Some(FieldResolution::Conflict(values)) => conflicts.push(ConditionConflict {
225 step_index,
226 field: CONDITION_FIELD_DURATION,
227 reason: format_conflict_reason(CONDITION_FIELD_DURATION, &values),
228 }),
229 None => {}
230 }
231 }
232 if atmosphere.is_none() {
233 let candidates = matching.iter().enumerate().filter_map(|(i, p)| {
234 p.atmosphere
235 .as_ref()
236 .map(|a| (i, a.clone(), p.source_id.clone()))
237 });
238 match resolve_field(candidates) {
239 Some(FieldResolution::Resolved(value, idxs)) => {
240 *atmosphere = Some(value);
241 for i in idxs {
242 contributed
243 .entry(i)
244 .or_default()
245 .push(CONDITION_FIELD_ATMOSPHERE);
246 }
247 }
248 Some(FieldResolution::Conflict(values)) => conflicts.push(ConditionConflict {
249 step_index,
250 field: CONDITION_FIELD_ATMOSPHERE,
251 reason: format_conflict_reason(CONDITION_FIELD_ATMOSPHERE, &values),
252 }),
253 None => {}
254 }
255 }
256 if ramp.is_none() {
257 let candidates = matching
258 .iter()
259 .enumerate()
260 .filter_map(|(i, p)| p.ramp.map(|r| (i, r, p.source_id.clone())));
261 match resolve_field(candidates) {
262 Some(FieldResolution::Resolved(value, idxs)) => {
263 *ramp = Some(value);
264 for i in idxs {
265 contributed
266 .entry(i)
267 .or_default()
268 .push(CONDITION_FIELD_RAMP_RATE);
269 }
270 }
271 Some(FieldResolution::Conflict(values)) => conflicts.push(ConditionConflict {
272 step_index,
273 field: CONDITION_FIELD_RAMP_RATE,
274 reason: format_conflict_reason(CONDITION_FIELD_RAMP_RATE, &values),
275 }),
276 None => {}
277 }
278 }
279
280 let mut step_evidence: Vec<PlanningEvidence> = contributed
292 .into_iter()
293 .map(|(precedent_idx, resolved_fields)| {
294 let precedent = matching[precedent_idx];
295 PlanningEvidence {
296 kind: precedent.evidence_kind,
297 source_id: precedent.source_id.clone(),
298 statement: precedent.statement.clone(),
299 strength: precedent.strength,
300 applicable_to: precedent.applicable_to,
301 limitations: vec![format!(
302 "resolved {} for the {:?} step from this precedent; other \
303 unresolved fields on this or other steps had no matching \
304 precedent data, or matching data that conflicted with another \
305 precedent",
306 resolved_fields.join("/"),
307 purpose,
308 )],
309 }
310 })
311 .collect();
312 step_evidence.sort_by(|a, b| {
313 (&a.source_id, &a.statement, &a.limitations).cmp(&(
314 &b.source_id,
315 &b.statement,
316 &b.limitations,
317 ))
318 });
319 evidence.extend(step_evidence);
320 }
321 (evidence, conflicts)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::process::StepRequirement;
328
329 fn condition_precedent(purpose: HeatingPurpose) -> ConditionPrecedent {
330 ConditionPrecedent {
331 purpose,
332 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
333 duration: Some(DurationRange::new(2.0, 2.0).unwrap()),
334 atmosphere: Some(Atmosphere::Air),
335 ramp: None,
336 evidence_kind: EvidenceKind::CuratedLiteratureRecord,
337 source_id: Some("10.0000/test".to_string()),
338 statement: "test precedent".to_string(),
339 strength: EvidenceStrength::Moderate,
340 applicable_to: EvidenceScope::ExactTarget,
341 }
342 }
343
344 #[test]
348 fn apply_condition_precedents_only_fills_matching_unset_fields() {
349 let mut steps = vec![
350 PlannedStep {
351 requirement: StepRequirement::Required,
352 step: ProcessStep::Heat {
353 purpose: HeatingPurpose::Calcination,
354 temperature: None,
355 duration: None,
356 atmosphere: None,
357 ramp: None,
358 },
359 },
360 PlannedStep {
361 requirement: StepRequirement::Required,
362 step: ProcessStep::Heat {
363 purpose: HeatingPurpose::Sintering,
364 temperature: Some(TemperatureRange::new(1.0, 1.0).unwrap()),
368 duration: None,
369 atmosphere: None,
370 ramp: None,
371 },
372 },
373 ];
374 let precedents = vec![
375 condition_precedent(HeatingPurpose::Calcination),
376 condition_precedent(HeatingPurpose::Sintering),
377 ];
378
379 let (evidence, conflicts) = apply_condition_precedents(&mut steps, &precedents);
380 assert!(
381 conflicts.is_empty(),
382 "no field had disagreeing precedents: {conflicts:?}"
383 );
384
385 let ProcessStep::Heat {
386 temperature,
387 duration,
388 atmosphere,
389 ..
390 } = &steps[0].step
391 else {
392 panic!("expected Heat step");
393 };
394 assert_eq!(temperature.unwrap().min_celsius, 900.0);
395 assert_eq!(duration.unwrap().min_hours, 2.0);
396 assert!(matches!(atmosphere, Some(Atmosphere::Air)));
397
398 let ProcessStep::Heat { temperature, .. } = &steps[1].step else {
399 panic!("expected Heat step");
400 };
401 assert_eq!(
402 temperature.unwrap().min_celsius,
403 1.0,
404 "an already-resolved field must not be overwritten by a later precedent"
405 );
406
407 assert_eq!(
408 evidence.len(),
409 2,
410 "one evidence entry per step a precedent actually changed: {evidence:?}"
411 );
412 for e in &evidence {
413 assert_eq!(e.kind, EvidenceKind::CuratedLiteratureRecord);
414 assert_eq!(e.source_id.as_deref(), Some("10.0000/test"));
415 }
416 }
417
418 #[test]
422 fn apply_condition_precedents_ignores_a_precedent_with_no_matching_step() {
423 let mut steps = vec![PlannedStep {
424 requirement: StepRequirement::Required,
425 step: ProcessStep::Heat {
426 purpose: HeatingPurpose::Calcination,
427 temperature: None,
428 duration: None,
429 atmosphere: None,
430 ramp: None,
431 },
432 }];
433 let precedents = vec![condition_precedent(HeatingPurpose::Annealing)];
434
435 let (evidence, conflicts) = apply_condition_precedents(&mut steps, &precedents);
436
437 assert!(evidence.is_empty());
438 assert!(conflicts.is_empty());
439 let ProcessStep::Heat { temperature, .. } = &steps[0].step else {
440 panic!("expected Heat step");
441 };
442 assert!(temperature.is_none());
443 }
444
445 fn calcination_step() -> PlannedStep {
446 PlannedStep {
447 requirement: StepRequirement::Required,
448 step: ProcessStep::Heat {
449 purpose: HeatingPurpose::Calcination,
450 temperature: None,
451 duration: None,
452 atmosphere: None,
453 ramp: None,
454 },
455 }
456 }
457
458 #[test]
463 fn two_conflicting_precedents_leave_the_field_unresolved() {
464 let mut steps = vec![calcination_step()];
465 let precedents = vec![
466 ConditionPrecedent {
467 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
468 source_id: Some("10.0000/first".to_string()),
469 ..condition_precedent(HeatingPurpose::Calcination)
470 },
471 ConditionPrecedent {
472 temperature: Some(TemperatureRange::new(1100.0, 1100.0).unwrap()),
473 source_id: Some("10.0000/second".to_string()),
474 ..condition_precedent(HeatingPurpose::Calcination)
475 },
476 ];
477
478 let (evidence, conflicts) = apply_condition_precedents(&mut steps, &precedents);
479
480 let ProcessStep::Heat { temperature, .. } = &steps[0].step else {
481 panic!("expected Heat step");
482 };
483 assert!(
484 temperature.is_none(),
485 "disagreeing precedents must not resolve the field to either value"
486 );
487 assert_eq!(conflicts.len(), 1);
488 assert_eq!(conflicts[0].step_index, 0);
489 assert_eq!(conflicts[0].field, "temperature");
490 assert!(conflicts[0].reason.contains("10.0000/first"));
491 assert!(conflicts[0].reason.contains("10.0000/second"));
492 assert!(
493 evidence
494 .iter()
495 .all(|e| !e.limitations.iter().any(|l| l.contains("temperature"))),
496 "neither precedent may be credited with resolving temperature -- it conflicted: \
497 {evidence:?}"
498 );
499 }
500
501 #[test]
507 fn conflicting_precedent_detection_does_not_depend_on_input_order() {
508 let forward = vec![
509 ConditionPrecedent {
510 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
511 ..condition_precedent(HeatingPurpose::Calcination)
512 },
513 ConditionPrecedent {
514 temperature: Some(TemperatureRange::new(1100.0, 1100.0).unwrap()),
515 ..condition_precedent(HeatingPurpose::Calcination)
516 },
517 ];
518 let reversed: Vec<ConditionPrecedent> = forward.iter().cloned().rev().collect();
519
520 let mut forward_steps = vec![calcination_step()];
521 let (_, forward_conflicts) = apply_condition_precedents(&mut forward_steps, &forward);
522 let mut reversed_steps = vec![calcination_step()];
523 let (_, reversed_conflicts) = apply_condition_precedents(&mut reversed_steps, &reversed);
524
525 let ProcessStep::Heat {
526 temperature: forward_temp,
527 ..
528 } = &forward_steps[0].step
529 else {
530 panic!("expected Heat step");
531 };
532 let ProcessStep::Heat {
533 temperature: reversed_temp,
534 ..
535 } = &reversed_steps[0].step
536 else {
537 panic!("expected Heat step");
538 };
539 assert_eq!(
540 *forward_temp, *reversed_temp,
541 "must agree regardless of input order"
542 );
543 assert!(forward_temp.is_none());
544 assert_eq!(forward_conflicts.len(), reversed_conflicts.len());
545 assert_eq!(forward_conflicts[0].field, reversed_conflicts[0].field);
546 }
547
548 #[test]
552 fn two_agreeing_precedents_resolve_the_field_and_both_are_credited() {
553 let mut steps = vec![calcination_step()];
554 let precedents = vec![
555 ConditionPrecedent {
556 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
557 source_id: Some("10.0000/first".to_string()),
558 ..condition_precedent(HeatingPurpose::Calcination)
559 },
560 ConditionPrecedent {
561 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
562 source_id: Some("10.0000/second".to_string()),
563 ..condition_precedent(HeatingPurpose::Calcination)
564 },
565 ];
566
567 let (evidence, conflicts) = apply_condition_precedents(&mut steps, &precedents);
568
569 let ProcessStep::Heat { temperature, .. } = &steps[0].step else {
570 panic!("expected Heat step");
571 };
572 assert_eq!(temperature.unwrap().min_celsius, 900.0);
573 assert!(conflicts.is_empty());
574 let sources: std::collections::BTreeSet<&str> = evidence
575 .iter()
576 .filter_map(|e| e.source_id.as_deref())
577 .collect();
578 assert_eq!(
579 sources,
580 std::collections::BTreeSet::from(["10.0000/first", "10.0000/second"]),
581 "both agreeing sources should be credited, not just whichever ran first"
582 );
583 }
584
585 #[test]
594 fn resolved_evidence_order_does_not_depend_on_precedent_input_order() {
595 let narrow = ConditionPrecedent {
596 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
597 duration: None,
598 atmosphere: None,
599 source_id: Some("10.0000/narrow".to_string()),
600 ..condition_precedent(HeatingPurpose::Calcination)
601 };
602 let wide = ConditionPrecedent {
603 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
604 duration: Some(DurationRange::new(2.0, 2.0).unwrap()),
605 atmosphere: None,
606 source_id: Some("10.0000/wide".to_string()),
607 ..condition_precedent(HeatingPurpose::Calcination)
608 };
609
610 let mut forward_steps = vec![calcination_step()];
611 let (forward_evidence, _) =
612 apply_condition_precedents(&mut forward_steps, &[narrow.clone(), wide.clone()]);
613 let mut reversed_steps = vec![calcination_step()];
614 let (reversed_evidence, _) =
615 apply_condition_precedents(&mut reversed_steps, &[wide, narrow]);
616
617 assert_eq!(
618 forward_evidence, reversed_evidence,
619 "evidence must come out in the same order regardless of precedent input order"
620 );
621 }
622
623 #[test]
628 fn a_conflict_on_one_field_does_not_block_resolution_of_an_agreeing_field() {
629 let mut steps = vec![calcination_step()];
630 let precedents = vec![
631 ConditionPrecedent {
632 temperature: Some(TemperatureRange::new(900.0, 900.0).unwrap()),
633 duration: Some(DurationRange::new(2.0, 2.0).unwrap()),
634 ..condition_precedent(HeatingPurpose::Calcination)
635 },
636 ConditionPrecedent {
637 temperature: Some(TemperatureRange::new(1100.0, 1100.0).unwrap()),
638 duration: Some(DurationRange::new(2.0, 2.0).unwrap()),
639 ..condition_precedent(HeatingPurpose::Calcination)
640 },
641 ];
642
643 let (_, conflicts) = apply_condition_precedents(&mut steps, &precedents);
644
645 let ProcessStep::Heat {
646 temperature,
647 duration,
648 ..
649 } = &steps[0].step
650 else {
651 panic!("expected Heat step");
652 };
653 assert!(temperature.is_none(), "temperature genuinely conflicts");
654 assert_eq!(
655 duration.unwrap().min_hours,
656 2.0,
657 "duration agrees across both precedents and must still resolve"
658 );
659 assert_eq!(conflicts.len(), 1);
660 assert_eq!(conflicts[0].field, "temperature");
661 }
662}