Skip to main content

lemma/evaluation/
explanations.rs

1//! Root explanation type and formatting.
2//!
3//! The root `Explanation` (with `result: OperationResult`) is built once per
4//! requested rule from its narrated arena entry. Nested rule uses are
5//! [`ExplanationNode::RuleRef`] into [`Explanation::rules`]. The tree types
6//! live in `planning::explanation`; `evaluation::narration` builds them from
7//! the filled value table.
8
9use crate::computation::rational::RationalInteger;
10use crate::computation::{OperationResult, VetoType};
11use crate::planning::explanation::{ExplainedRule, ExplainedRuleId};
12use crate::planning::semantics::{LemmaType, RulePath};
13use crate::planning::unit_family::FamilyUnitCatalog;
14use crate::result_value::{
15    rule_result_value_failure_message, rule_result_value_from_literal, RuleResultValue,
16    RuleResultValueFailure,
17};
18use serde::Serialize;
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21
22// Re-export tree types for use within the evaluation module
23pub use crate::planning::explanation::{
24    Cause, ConversionTraceRole, ExplanationNode, SerializedConversionTraceStep,
25};
26
27#[derive(Debug, Clone)]
28pub struct Explanation {
29    pub name: RulePath,
30    pub result: OperationResult,
31    /// Type of [`Self::result`] for measure/ratio rendering (binding unit).
32    pub result_type: Arc<LemmaType>,
33    /// Family catalog needed to build the rule-node [`RuleResultValue`] on serialize.
34    pub(crate) family_units: Arc<FamilyUnitCatalog>,
35    /// Settled reference-scale factors for this rule's result family.
36    pub(crate) runtime_scales: HashMap<String, RationalInteger>,
37    pub body: String,
38    pub causes: Vec<Cause>,
39    pub children: Vec<ExplanationNode>,
40    /// Reachable narrated rules, ids in first-reach order from this root.
41    pub rules: Vec<ExplainedRule>,
42}
43
44#[derive(Serialize)]
45struct ExplanationSer<'a> {
46    #[serde(rename = "type")]
47    kind: &'static str,
48    #[serde(serialize_with = "crate::planning::explanation::serialize_rule_path_as_name")]
49    name: &'a RulePath,
50    #[serde(flatten)]
51    result: RuleResultValue,
52    body: &'a str,
53    #[serde(skip_serializing_if = "<[_]>::is_empty")]
54    causes: &'a [Cause],
55    #[serde(skip_serializing_if = "<[_]>::is_empty")]
56    children: &'a [ExplanationNode],
57    #[serde(skip_serializing_if = "<[_]>::is_empty")]
58    rules: &'a [ExplainedRule],
59}
60
61impl Serialize for Explanation {
62    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
63    where
64        S: serde::Serializer,
65    {
66        ExplanationSer {
67            kind: "rule",
68            name: &self.name,
69            result: explanation_result_value(
70                &self.result,
71                self.result_type.as_ref(),
72                &self.family_units,
73                Some(&self.runtime_scales),
74            ),
75            body: &self.body,
76            causes: self.causes.as_slice(),
77            children: self.children.as_slice(),
78            rules: self.rules.as_slice(),
79        }
80        .serialize(serializer)
81    }
82}
83
84pub(crate) fn format_operation_result(result: &OperationResult, result_type: &LemmaType) -> String {
85    match result {
86        OperationResult::Value(bound) => bound.to_literal().display_value_with_type(result_type),
87        OperationResult::Veto(VetoType::UserDefined { message: None }) => String::new(),
88        OperationResult::Veto(veto) => veto.to_string(),
89    }
90}
91
92/// Build a [`RuleResultValue`] for an explanation node from a settled operation result.
93pub(crate) fn explanation_result_value(
94    result: &OperationResult,
95    lemma_type: &LemmaType,
96    family_units: &FamilyUnitCatalog,
97    runtime_scales: Option<&HashMap<String, RationalInteger>>,
98) -> RuleResultValue {
99    match result {
100        OperationResult::Value(bound) => {
101            match rule_result_value_from_literal(
102                &bound.to_literal(),
103                lemma_type,
104                family_units,
105                runtime_scales,
106            ) {
107                Ok(value) => value,
108                // Same veto message RuleResult attaches; result is veto text, not magnitude.
109                Err(RuleResultValueFailure::DecimalLimit) => RuleResultValue {
110                    result: Some(
111                        rule_result_value_failure_message(RuleResultValueFailure::DecimalLimit)
112                            .to_string(),
113                    ),
114                    ..RuleResultValue::default()
115                },
116                Err(failure) => panic!(
117                    "BUG: settled explanation value failed rule_result_value_from_literal: {}",
118                    rule_result_value_failure_message(failure)
119                ),
120            }
121        }
122        OperationResult::Veto(VetoType::UserDefined { message: None }) => RuleResultValue {
123            result: Some(String::new()),
124            ..RuleResultValue::default()
125        },
126        OperationResult::Veto(veto) => RuleResultValue {
127            result: Some(veto.to_string()),
128            ..RuleResultValue::default()
129        },
130    }
131}
132
133pub fn format_explanation(explanation: &Explanation) -> String {
134    let mut lines = Vec::new();
135    let result_line = explanation_result_value(
136        &explanation.result,
137        explanation.result_type.as_ref(),
138        &explanation.family_units,
139        Some(&explanation.runtime_scales),
140    )
141    .result
142    .expect("BUG: root explanation must carry result");
143    lines.push(format!("{}: {}", explanation.name, result_line));
144    let mut state = FormatState {
145        lines: &mut lines,
146        rules: &explanation.rules,
147        expanded: HashSet::new(),
148        stack: Vec::new(),
149    };
150    state.push_rule_contents(
151        &result_line,
152        &explanation.body,
153        &explanation.causes,
154        &explanation.children,
155        String::new(),
156    );
157    state.run();
158    lines.join("\n")
159}
160
161#[derive(Copy, Clone)]
162enum Connector {
163    Branch,
164    Last,
165}
166
167enum ConversionSteps {
168    Owned(Vec<SerializedConversionTraceStep>),
169}
170
171impl ConversionSteps {
172    fn as_slice(&self) -> &[SerializedConversionTraceStep] {
173        match self {
174            ConversionSteps::Owned(steps) => steps.as_slice(),
175        }
176    }
177}
178
179/// Visible children of one parent, with tree connectors. `Filter` nodes are
180/// flattened away so matched rows sit directly under the list pipeline.
181struct Siblings<'a> {
182    nodes: Vec<&'a ExplanationNode>,
183    /// Pre-aligned comma headlines for [`ExplanationNode::Row`] entries, keyed by index.
184    row_headlines: std::collections::HashMap<usize, String>,
185}
186
187impl<'a> Siblings<'a> {
188    fn new(nodes: impl Iterator<Item = &'a ExplanationNode>) -> Self {
189        let mut flat = Vec::new();
190        for node in nodes {
191            flatten_visible(node, &mut flat);
192        }
193        let row_headlines = aligned_row_headlines(&flat);
194        Self {
195            nodes: flat,
196            row_headlines,
197        }
198    }
199
200    fn len(&self) -> usize {
201        self.nodes.len()
202    }
203}
204
205fn flatten_visible<'a>(node: &'a ExplanationNode, out: &mut Vec<&'a ExplanationNode>) {
206    let mut pending = vec![node];
207    while let Some(node) = pending.pop() {
208        if is_bare_literal_compose(node) || is_visually_empty_compose(node) {
209            continue;
210        }
211        match node {
212            ExplanationNode::Filter { children, .. } => {
213                for child in children.iter().rev() {
214                    pending.push(child);
215                }
216            }
217            _ => out.push(node),
218        }
219    }
220}
221
222enum Work<'a> {
223    Line {
224        indent: String,
225        connector: Connector,
226        text: String,
227    },
228    RuleBody {
229        id: ExplainedRuleId,
230        result_line: String,
231        indent: String,
232    },
233    Nodes {
234        siblings: Siblings<'a>,
235        parent_body: Option<&'a str>,
236        indent: String,
237        next: usize,
238        elide_list_commands: bool,
239    },
240    CauseChildren {
241        children: Vec<&'a ExplanationNode>,
242        indent: String,
243        next: usize,
244    },
245    Conversion {
246        steps: ConversionSteps,
247        siblings: Siblings<'a>,
248        indent: String,
249        next: usize,
250    },
251}
252
253struct FormatState<'a> {
254    lines: &'a mut Vec<String>,
255    rules: &'a [ExplainedRule],
256    expanded: HashSet<ExplainedRuleId>,
257    stack: Vec<Work<'a>>,
258}
259
260impl<'a> FormatState<'a> {
261    fn push_line(&mut self, indent: &str, connector: Connector, text: &str) {
262        self.lines
263            .push(format!("{indent}{} {text}", connector_str(connector)));
264    }
265
266    fn child_indent(indent: &str, connector: Connector) -> String {
267        match connector {
268            Connector::Branch => format!("{indent}│  "),
269            Connector::Last => format!("{indent}   "),
270        }
271    }
272
273    fn rule_by_id(&self, id: ExplainedRuleId) -> &'a ExplainedRule {
274        self.rules
275            .iter()
276            .find(|rule| rule.id == id)
277            .unwrap_or_else(|| panic!("BUG: explanation missing rules entry for id {}", id.0))
278    }
279
280    fn push_rule_contents(
281        &mut self,
282        result_line: &str,
283        body: &'a str,
284        causes: &'a [Cause],
285        children: &'a [ExplanationNode],
286        indent: String,
287    ) {
288        // Bare data-path rule bodies (`rule guardian: has_ward`) already appear as the
289        // sole Data child (`has_ward: true`); printing the path again nests a duplicate.
290        let body_restated_by_sole_data = matches!(
291            children,
292            [ExplanationNode::Data { name, .. }] if name.input_key() == body
293        );
294        let body_shown = !body.is_empty() && body != result_line && !body_restated_by_sole_data;
295        let total = causes.len() + usize::from(body_shown);
296        // LIFO: push later output first so first cause prints first.
297        if body_shown {
298            let child_indent = Self::child_indent(&indent, Connector::Last);
299            self.stack.push(Work::Nodes {
300                siblings: Siblings::new(children.iter()),
301                parent_body: Some(body),
302                indent: child_indent,
303                next: 0,
304                elide_list_commands: false,
305            });
306            self.stack.push(Work::Line {
307                indent: indent.clone(),
308                connector: Connector::Last,
309                text: body.to_string(),
310            });
311        } else if !children.is_empty() {
312            self.stack.push(Work::Nodes {
313                siblings: Siblings::new(children.iter()),
314                parent_body: None,
315                indent: indent.clone(),
316                next: 0,
317                elide_list_commands: false,
318            });
319        }
320        for (i, cause) in causes.iter().enumerate().rev() {
321            let connector = if i + 1 == total {
322                Connector::Last
323            } else {
324                Connector::Branch
325            };
326            let value = cause.value.as_str();
327            let line = if value == "true" {
328                cause.condition.clone()
329            } else {
330                format!("{} is {}", cause.condition, value)
331            };
332            let visible: Vec<&'a ExplanationNode> = cause
333                .children
334                .iter()
335                .filter(|node| !is_bare_literal_compose(node))
336                .filter(|node| !data_restates_true_cause_line(node, value, &line))
337                .collect();
338            let child_indent = Self::child_indent(&indent, connector);
339            self.stack.push(Work::CauseChildren {
340                children: visible,
341                indent: child_indent,
342                next: 0,
343            });
344            self.stack.push(Work::Line {
345                indent: indent.clone(),
346                connector,
347                text: line,
348            });
349        }
350    }
351
352    fn run(&mut self) {
353        while let Some(work) = self.stack.pop() {
354            match work {
355                Work::Line {
356                    indent,
357                    connector,
358                    text,
359                } => self.push_line(&indent, connector, &text),
360                Work::RuleBody {
361                    id,
362                    result_line,
363                    indent,
364                } => {
365                    let rule = self.rule_by_id(id);
366                    self.push_rule_contents(
367                        &result_line,
368                        rule.body.as_str(),
369                        rule.causes.as_slice(),
370                        rule.children.as_slice(),
371                        indent,
372                    );
373                }
374                Work::Nodes {
375                    siblings,
376                    parent_body,
377                    indent,
378                    next,
379                    elide_list_commands,
380                } => self.step_nodes(siblings, parent_body, indent, next, elide_list_commands),
381                Work::CauseChildren {
382                    children,
383                    indent,
384                    next,
385                } => self.step_cause_children(children, indent, next),
386                Work::Conversion {
387                    steps,
388                    siblings,
389                    indent,
390                    next,
391                } => self.step_conversion(steps, siblings, indent, next),
392            }
393        }
394    }
395
396    fn step_nodes(
397        &mut self,
398        siblings: Siblings<'a>,
399        parent_body: Option<&'a str>,
400        indent: String,
401        next: usize,
402        elide_list_commands: bool,
403    ) {
404        if next >= siblings.len() {
405            return;
406        }
407        let connector = if next + 1 == siblings.len() {
408            Connector::Last
409        } else {
410            Connector::Branch
411        };
412        let node = siblings.nodes[next];
413        let row_headline = siblings.row_headlines.get(&next).cloned();
414        self.stack.push(Work::Nodes {
415            siblings,
416            parent_body,
417            indent: indent.clone(),
418            next: next + 1,
419            elide_list_commands,
420        });
421        self.emit_node(
422            node,
423            connector,
424            parent_body,
425            indent,
426            elide_list_commands,
427            row_headline,
428        );
429    }
430
431    fn step_cause_children(
432        &mut self,
433        children: Vec<&'a ExplanationNode>,
434        indent: String,
435        next: usize,
436    ) {
437        if next >= children.len() {
438            return;
439        }
440        let connector = if next + 1 == children.len() {
441            Connector::Last
442        } else {
443            Connector::Branch
444        };
445        let node = children[next];
446        self.stack.push(Work::CauseChildren {
447            children,
448            indent: indent.clone(),
449            next: next + 1,
450        });
451        self.emit_node(node, connector, None, indent, false, None);
452    }
453
454    fn step_conversion(
455        &mut self,
456        steps: ConversionSteps,
457        siblings: Siblings<'a>,
458        indent: String,
459        next: usize,
460    ) {
461        let sibling_len = siblings.len();
462        let step_len = steps.as_slice().len();
463        let total = step_len + sibling_len;
464        if next >= total {
465            return;
466        }
467        let connector = if next + 1 == total {
468            Connector::Last
469        } else {
470            Connector::Branch
471        };
472        if next < sibling_len {
473            let node = siblings.nodes[next];
474            let row_headline = siblings.row_headlines.get(&next).cloned();
475            self.stack.push(Work::Conversion {
476                steps,
477                siblings,
478                indent: indent.clone(),
479                next: next + 1,
480            });
481            self.emit_node(node, connector, None, indent, false, row_headline);
482            return;
483        }
484        let step_index = next - sibling_len;
485        let text = steps.as_slice()[step_index].text.clone();
486        self.stack.push(Work::Conversion {
487            steps,
488            siblings,
489            indent: indent.clone(),
490            next: next + 1,
491        });
492        self.push_line(&indent, connector, &text);
493    }
494
495    fn emit_node(
496        &mut self,
497        node: &'a ExplanationNode,
498        connector: Connector,
499        parent_body: Option<&'a str>,
500        indent: String,
501        elide_list_commands: bool,
502        row_headline: Option<String>,
503    ) {
504        match node {
505            ExplanationNode::RuleRef { id, name, result } => {
506                let result_str = result
507                    .result
508                    .as_deref()
509                    .expect("BUG: ExplanationNode::RuleRef.result.result not filled by eval");
510                if self.expanded.insert(*id) {
511                    self.push_line(&indent, connector, &format!("{name}: {result_str}"));
512                    self.stack.push(Work::RuleBody {
513                        id: *id,
514                        result_line: result_str.to_string(),
515                        indent: Self::child_indent(&indent, connector),
516                    });
517                } else {
518                    self.push_line(
519                        &indent,
520                        connector,
521                        &format!("{name}: {result_str} (see above)"),
522                    );
523                }
524            }
525            ExplanationNode::Compose {
526                expression,
527                operands,
528                result: _,
529                ..
530            } => {
531                let siblings = Siblings::new(operands.iter());
532                if parent_body.is_some_and(|body| body == expression.as_str()) {
533                    self.stack.push(Work::Nodes {
534                        siblings,
535                        parent_body: None,
536                        indent,
537                        next: 0,
538                        elide_list_commands,
539                    });
540                } else {
541                    self.push_line(&indent, connector, expression);
542                    let child_indent = Self::child_indent(&indent, connector);
543                    self.stack.push(Work::Nodes {
544                        siblings,
545                        parent_body: None,
546                        indent: child_indent,
547                        next: 0,
548                        elide_list_commands,
549                    });
550                }
551            }
552            ExplanationNode::ListCommand {
553                expression,
554                operands,
555                result: _,
556                ..
557            } => {
558                let elide = elide_list_commands
559                    || parent_body.is_some_and(|body| body == expression.as_str());
560                if elide {
561                    self.stack.push(Work::Nodes {
562                        siblings: Siblings::new(operands.iter()),
563                        parent_body: None,
564                        indent,
565                        next: 0,
566                        elide_list_commands: true,
567                    });
568                } else {
569                    self.push_line(&indent, connector, expression);
570                    let child_indent = Self::child_indent(&indent, connector);
571                    self.stack.push(Work::Nodes {
572                        siblings: Siblings::new(operands.iter()),
573                        parent_body: None,
574                        indent: child_indent,
575                        next: 0,
576                        elide_list_commands: true,
577                    });
578                }
579            }
580            ExplanationNode::Data { name, result } => {
581                let result_str = result
582                    .result
583                    .as_deref()
584                    .expect("BUG: ExplanationNode::Data.result.result not filled by eval");
585                if name.data.is_empty() {
586                    self.push_line(&indent, connector, result_str);
587                } else {
588                    self.push_line(&indent, connector, &format!("{name}: {result_str}"));
589                }
590            }
591            ExplanationNode::DataUnused { name } => {
592                self.push_line(&indent, connector, &name.to_string());
593            }
594            ExplanationNode::Conversion {
595                expression,
596                steps,
597                operands,
598                result: _,
599            } => {
600                let expression_is_parent_body =
601                    parent_body.is_some_and(|body| body == expression.as_str());
602                let rule_steps: Vec<SerializedConversionTraceStep> = steps
603                    .iter()
604                    .filter(|step| !matches!(step.role, ConversionTraceRole::Outcome))
605                    .cloned()
606                    .collect();
607                // Rule 2: when the single operand is a Compose, skip the compose line
608                // (its expression is already on this conversion line) and promote the
609                // compose's operands to direct children.
610                let siblings = if let [ExplanationNode::Compose {
611                    operands: compose_operands,
612                    ..
613                }] = operands.as_slice()
614                {
615                    Siblings::new(compose_operands.iter())
616                } else {
617                    Siblings::new(operands.iter())
618                };
619                if expression_is_parent_body {
620                    self.stack.push(Work::Conversion {
621                        steps: ConversionSteps::Owned(rule_steps),
622                        siblings,
623                        indent,
624                        next: 0,
625                    });
626                } else {
627                    self.push_line(&indent, connector, expression);
628                    let child_indent = Self::child_indent(&indent, connector);
629                    self.stack.push(Work::Conversion {
630                        steps: ConversionSteps::Owned(rule_steps),
631                        siblings,
632                        indent: child_indent,
633                        next: 0,
634                    });
635                }
636            }
637            ExplanationNode::Veto { message } => {
638                let text = match message.as_deref() {
639                    Some(msg) if !msg.is_empty() => format!("veto \"{msg}\""),
640                    _ => "veto".to_string(),
641                };
642                self.push_line(&indent, connector, &text);
643            }
644            ExplanationNode::Row {
645                number,
646                appended,
647                key,
648                fields,
649            } => {
650                let tree_fields = row_tree_fields(key, fields);
651                // Numbered row whose only field is a RuleRef: `2. bonus: 950 eur`.
652                if key.is_empty()
653                    && row_comma_columns(key, fields).is_empty()
654                    && tree_fields.len() == 1
655                {
656                    if let ExplanationNode::RuleRef { id, name, result } = tree_fields[0] {
657                        let result_str = result
658                            .result
659                            .as_deref()
660                            .expect("BUG: RuleRef.result.result not filled by eval");
661                        let prefix = if *appended {
662                            format!("appended {number}.")
663                        } else {
664                            format!("{number}.")
665                        };
666                        let child_indent = Self::child_indent(&indent, connector);
667                        if self.expanded.insert(*id) {
668                            self.push_line(
669                                &indent,
670                                connector,
671                                &format!("{prefix} {name}: {result_str}"),
672                            );
673                            self.stack.push(Work::RuleBody {
674                                id: *id,
675                                result_line: result_str.to_string(),
676                                indent: child_indent,
677                            });
678                        } else {
679                            self.push_line(
680                                &indent,
681                                connector,
682                                &format!("{prefix} {name}: {result_str} (see above)"),
683                            );
684                        }
685                        return;
686                    }
687                }
688                let headline = row_headline
689                    .unwrap_or_else(|| row_headline_for(*number, *appended, key, fields));
690                self.push_line(&indent, connector, &headline);
691                if !tree_fields.is_empty() {
692                    let child_indent = Self::child_indent(&indent, connector);
693                    self.stack.push(Work::Nodes {
694                        siblings: Siblings::new(tree_fields.into_iter()),
695                        parent_body: None,
696                        indent: child_indent,
697                        next: 0,
698                        elide_list_commands,
699                    });
700                }
701            }
702            ExplanationNode::Filter { .. } => {
703                unreachable!("BUG: Filter nodes are flattened in Siblings::new");
704            }
705        }
706    }
707}
708
709fn connector_str(connector: Connector) -> &'static str {
710    match connector {
711        Connector::Branch => "├─",
712        Connector::Last => "└─",
713    }
714}
715
716fn aligned_row_headlines(nodes: &[&ExplanationNode]) -> std::collections::HashMap<usize, String> {
717    let mut out = std::collections::HashMap::new();
718    let mut index = 0;
719    while index < nodes.len() {
720        if !matches!(nodes[index], ExplanationNode::Row { .. }) {
721            index += 1;
722            continue;
723        }
724        let start = index;
725        while index < nodes.len() && matches!(nodes[index], ExplanationNode::Row { .. }) {
726            index += 1;
727        }
728        let rows = &nodes[start..index];
729        let branched = rows.iter().any(|node| {
730            let ExplanationNode::Row { key, fields, .. } = node else {
731                unreachable!("BUG: row run contains non-Row");
732            };
733            !row_tree_fields(key, fields).is_empty()
734        });
735        let columns: Vec<Vec<RowColumn>> = rows
736            .iter()
737            .map(|node| {
738                let ExplanationNode::Row { key, fields, .. } = node else {
739                    unreachable!("BUG: row run contains non-Row");
740                };
741                row_comma_columns(key, fields)
742            })
743            .collect();
744        let col_widths = if branched {
745            Vec::new()
746        } else {
747            let width = columns.iter().map(|cols| cols.len()).max().unwrap_or(0);
748            let mut widths = vec![0; width];
749            for cols in &columns {
750                for (i, column) in cols.iter().enumerate() {
751                    widths[i] = widths[i].max(column.text.len());
752                }
753            }
754            widths
755        };
756        for (offset, (node, cols)) in rows.iter().zip(columns.iter()).enumerate() {
757            let ExplanationNode::Row {
758                number, appended, ..
759            } = node
760            else {
761                unreachable!("BUG: row run contains non-Row");
762            };
763            let line = if branched {
764                cols.iter()
765                    .map(|column| column.text.as_str())
766                    .collect::<Vec<_>>()
767                    .join(", ")
768            } else {
769                let mut line = String::new();
770                for (i, column) in cols.iter().enumerate() {
771                    let text = &column.text;
772                    if i + 1 == cols.len() {
773                        line.push_str(text);
774                    } else {
775                        line.push_str(text);
776                        line.push(',');
777                        let pad = col_widths[i].saturating_sub(text.len()) + 1;
778                        line.push_str(&" ".repeat(pad));
779                    }
780                }
781                line
782            };
783            let all_appended = rows
784                .iter()
785                .all(|row| matches!(row, ExplanationNode::Row { appended: true, .. }));
786            let headline = if key_is_empty(node) && !all_appended {
787                let prefix = if *appended {
788                    format!("appended {number}.")
789                } else {
790                    format!("{number}.")
791                };
792                if line.is_empty() {
793                    prefix
794                } else {
795                    format!("{prefix} {line}")
796                }
797            } else {
798                line
799            };
800            out.insert(start + offset, headline);
801        }
802    }
803    out
804}
805
806fn key_is_empty(node: &ExplanationNode) -> bool {
807    matches!(node, ExplanationNode::Row { key, .. } if key.is_empty())
808}
809
810fn row_headline_for(
811    number: usize,
812    appended: bool,
813    key: &[ExplanationNode],
814    fields: &[ExplanationNode],
815) -> String {
816    let cols = row_comma_columns(key, fields);
817    let comma = cols
818        .iter()
819        .map(|column| column.text.as_str())
820        .collect::<Vec<_>>()
821        .join(", ");
822    if key.is_empty() {
823        let prefix = if appended {
824            format!("appended {number}.")
825        } else {
826            format!("{number}.")
827        };
828        if comma.is_empty() {
829            prefix
830        } else {
831            format!("{prefix} {comma}")
832        }
833    } else {
834        comma
835    }
836}
837
838/// One comma column of a row line.
839struct RowColumn {
840    name: String,
841    text: String,
842}
843
844fn row_comma_columns(key: &[ExplanationNode], fields: &[ExplanationNode]) -> Vec<RowColumn> {
845    let mut cols = Vec::new();
846    let mut seen = std::collections::HashSet::new();
847    for node in key {
848        let column = row_column(node);
849        seen.insert(column.name.clone());
850        cols.push(column);
851    }
852    for node in fields {
853        if !is_comma_field(node) {
854            continue;
855        }
856        let column = row_column(node);
857        if seen.insert(column.name.clone()) {
858            cols.push(column);
859        }
860    }
861    cols
862}
863
864fn row_tree_fields<'a>(
865    key: &'a [ExplanationNode],
866    fields: &'a [ExplanationNode],
867) -> Vec<&'a ExplanationNode> {
868    let _ = key;
869    fields.iter().filter(|node| !is_comma_field(node)).collect()
870}
871
872fn is_comma_field(node: &ExplanationNode) -> bool {
873    matches!(
874        node,
875        ExplanationNode::Data { .. }
876            | ExplanationNode::DataUnused { .. }
877            | ExplanationNode::Veto { .. }
878    )
879}
880
881fn row_column(node: &ExplanationNode) -> RowColumn {
882    match node {
883        ExplanationNode::Data { name, result } => {
884            let value = result
885                .result
886                .as_deref()
887                .expect("BUG: row Data must carry result");
888            RowColumn {
889                name: name.to_string(),
890                text: format!("{name}: {value}"),
891            }
892        }
893        ExplanationNode::DataUnused { name } => RowColumn {
894            name: name.to_string(),
895            text: name.to_string(),
896        },
897        ExplanationNode::Veto { message } => {
898            let value = match message.as_deref() {
899                Some(msg) if !msg.is_empty() => format!("veto \"{msg}\""),
900                _ => "veto".to_string(),
901            };
902            RowColumn {
903                name: String::new(),
904                text: format!(": {value}"),
905            }
906        }
907        other => panic!("BUG: comma column node is {other:?}"),
908    }
909}
910
911/// Bare literal compose: expression text already names it; ASCII omits the node.
912fn is_bare_literal_compose(node: &ExplanationNode) -> bool {
913    matches!(node, ExplanationNode::Compose { operands, .. } if operands.is_empty())
914}
915
916/// Visually empty compose: a `Compose` whose every operand is itself omitted by
917/// the formatter (bare literal, or recursively visually empty). Its line would
918/// only restate a sub-expression already shown on the parent and Rule 7 strips
919/// its value, so it is dropped from siblings before tree connectors are assigned.
920fn is_visually_empty_compose(node: &ExplanationNode) -> bool {
921    let ExplanationNode::Compose { operands, .. } = node else {
922        return false;
923    };
924    operands.iter().all(operand_omitted_from_tree)
925}
926
927fn operand_omitted_from_tree(node: &ExplanationNode) -> bool {
928    if is_bare_literal_compose(node) {
929        return true;
930    }
931    is_visually_empty_compose(node)
932}
933
934/// `code is NL` already states the binding; ASCII skips child `code: NL`.
935/// Flipped / inequality cause lines do not match `{key} is {result}`.
936fn data_restates_true_cause_line(node: &ExplanationNode, value: &str, line: &str) -> bool {
937    if value != "true" {
938        return false;
939    }
940    match node {
941        ExplanationNode::Data {
942            name,
943            result: node_result,
944        } => {
945            let result_str = node_result
946                .result
947                .as_deref()
948                .expect("BUG: Data node must carry result");
949            line == format!("{} is {result_str}", name.input_key())
950        }
951        _ => false,
952    }
953}