Skip to main content

gugen/
condition_precedents.rs

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/// `ProcessEvidenceProvider` output (AGENTS.md §8). `description` is free
9/// text with no structure -- still valid on its own for a provider that
10/// only has prose precedent to offer. `conditions` (Phase 10) carries
11/// structured, per-purpose temperature/duration/atmosphere/ramp data, each
12/// entry traceable to its own citation; empty for a prose-only precedent.
13#[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/// One provider's structured, citable evidence for how a specific `Heat`
21/// step's conditions should be resolved (Phase 10; AGENTS.md §7/§21.3).
22/// Every field the provider doesn't actually have real, sourced data for
23/// stays `None` -- never fabricated to fill a gap. `evidence_kind`,
24/// `strength`, and `source_id` are set by whichever provider returns this,
25/// not assumed by the planner: `ProcessEvidenceProvider` is also the trait
26/// a user-supplied lab-precedent source implements
27/// (`EvidenceKind::UserProvidedPrecedent`), so a curated-literature-only
28/// assumption in the planner would mislabel provenance for every other
29/// kind of implementation.
30#[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
45/// Splices provider-supplied, cited condition data into `steps`'s `Heat`
46/// fields (Phase 10). Only ever fills an already-`None` slot -- never
47/// overwrites a field some other resolution source already set -- so this
48/// composes with any future resolution source rather than one silently
49/// clobbering another. Returns one `PlanningEvidence` entry per `Heat` step
50/// a precedent actually changed, carrying that precedent's own
51/// `evidence_kind`/`strength`/`source_id`/`applicable_to` rather than a
52/// value this function invents.
53/// One `Heat` step field where two or more matching `ConditionPrecedent`s
54/// disagreed, so it was deliberately left unresolved rather than picking
55/// one arbitrarily or averaging (Phase 19 -- the owner's explicit
56/// "架空の平均値を作らず未解決として示す" directive). `step_index` is
57/// this field's position in the `steps` slice `apply_condition_precedents`
58/// was called with, so callers can attach `reason` to the right
59/// `UnresolvedRequirement`. Disagreement is exact-value inequality only
60/// (`PartialEq`) -- an overlapping-but-not-identical range (e.g. a point
61/// value inside a wider reported range) still counts as a conflict, the
62/// conservative reading, since `TemperatureRange`/`DurationRange`/
63/// `RampRateRange` have no overlap/subsumption semantics and inventing
64/// one is explicitly out of scope for this phase.
65/// The four `ConditionConflict.field`/`format_conflict_reason` literals
66/// this module produces, defined once so `score.rs`'s consumer-side
67/// lookup (`condition_conflicts.iter().find(|c| c.field == field)`)
68/// can't silently drift out of sync with the producer side here -- a
69/// rename on either side becomes a compile error instead of a silent
70/// fallback to a generic reason. Only `"temperature"` had a dedicated
71/// regression test pinning this agreement before; the other three
72/// relied on the two sides happening to stay textually identical.
73pub(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
85/// Every matching precedent's value for one field, deduplicated by exact
86/// equality. Distinguishing "no data" / "one agreed value" / "conflicting
87/// values" is the whole point -- `Vec<(T, usize)>`'s length after
88/// deduplication is the signal, not a side effect.
89enum FieldResolution<T> {
90    /// `.1` is which entries in the step's matching-precedent list (by
91    /// index into that list, not into the caller's whole `precedents`
92    /// slice) supplied this value -- possibly more than one, if two
93    /// precedents happen to agree.
94    Resolved(T, Vec<usize>),
95    /// One `(value, source_id)` per distinct value found, in the order
96    /// first encountered (deterministic: `precedents`' own order, not
97    /// insertion into a hash structure).
98    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
145/// Order-independent (Phase 19): every matching precedent for a step's
146/// purpose is evaluated against that field's *original* pre-call state,
147/// never against a state some earlier precedent in `precedents` already
148/// mutated -- so which precedent happens to come first in the slice can
149/// no longer silently decide the outcome. Field-granular: precedents
150/// agreeing on `temperature` but disagreeing on `duration` still resolve
151/// `temperature`; only `duration` is left unresolved (per the owner's
152/// explicit choice over discarding the whole precedent).
153pub(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        // Which fields each precedent (by its index into `matching`)
180        // actually contributed to a successful resolution on this step --
181        // built up per field below, then turned into one evidence entry
182        // per contributing precedent afterward, matching the pre-Phase-19
183        // "one entry per (step, precedent), fields joined by /" shape.
184        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        // `contributed` is keyed by index into `matching`, which is
281        // `precedents`' own filtered order -- so iterating it directly
282        // would make this step's slice of `evidence` swap order whenever
283        // the caller's precedent order changes, even though the *set* of
284        // fields credited to each precedent is unaffected. Sort by each
285        // entry's own content (never by `precedent_idx`) so the emitted
286        // order depends only on what was resolved, not on which precedent
287        // the provider happened to list first. `resolved_fields.join("/")`
288        // (embedded in `limitations` below) is itself already order-stable
289        // -- the four field blocks above always run in fixed source order
290        // (temperature/duration/atmosphere/ramp), never in precedent order.
291        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    /// Phase 10: only a step whose `HeatingPurpose` matches the precedent
345    /// gets its fields filled; an already-resolved field is never
346    /// overwritten; a step with no matching purpose is untouched.
347    #[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                    // Already resolved by some other source -- must survive
365                    // untouched even though the precedent below also
366                    // targets Sintering.
367                    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    /// A precedent for a purpose no step has (e.g. `Annealing` when only
419    /// `Calcination`/`Sintering` steps exist) must not panic or produce
420    /// evidence -- it simply matches nothing.
421    #[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    /// Phase 19: two precedents disagreeing on the same field must leave
459    /// it unresolved rather than one arbitrarily overwriting the other --
460    /// the owner's explicit "架空の平均値を作らず未解決として示す"
461    /// directive, and the specific bug this phase exists to fix.
462    #[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    /// The actual bug Phase 19 fixes: under the pre-Phase-19 implementation,
502    /// whichever precedent happened to come first in the input slice would
503    /// silently resolve the field, so the two orderings below would have
504    /// disagreed with each other. Now both orderings must agree (a
505    /// conflict, since the values genuinely differ).
506    #[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    /// Two precedents that happen to report the *same* value for a field
549    /// are agreement, not a conflict -- both still get credited with
550    /// their own evidence entry.
551    #[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    /// The order-independence guarantee must cover the *resolved* case,
586    /// not just the conflict case above -- two precedents with asymmetric
587    /// field coverage (one supplies only `temperature`, the other supplies
588    /// `temperature` and `duration`, agreeing on the overlap) must produce
589    /// the same `evidence` *sequence*, not merely the same set, regardless
590    /// of which precedent the provider lists first. Emitting evidence in
591    /// `matching`-index order would make this flap the moment a corpus
592    /// target ever has two precedents backing one step.
593    #[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    /// Field-granular (Phase 19, owner's explicit choice over discarding a
624    /// whole precedent on any single disagreement): precedents agreeing on
625    /// `duration` but disagreeing on `temperature` must still resolve
626    /// `duration`.
627    #[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}