Skip to main content

zen_engine/workspace/graph/
enhance.rs

1use std::sync::Arc;
2
3use ahash::{HashMap, HashMapExt, HashSet, HashSetExt};
4use base64::Engine as _;
5use rust_decimal::prelude::ToPrimitive;
6use zen_expression::variable::Variable;
7use zen_expression::Isolate;
8use zen_types::decision::{
9    DecisionNode, DecisionNodeKind, DecisionTableContent, DecisionTableHitPolicy,
10    TransformExecutionMode,
11};
12
13use crate::model::GraphContent;
14use crate::nodes::decision_table::DecisionTableNodeHandler;
15use crate::workspace::db::{Db, Snapshot};
16use crate::workspace::graph::editor::{NodePaths, ReadBase};
17use crate::workspace::graph::function_source;
18use crate::workspace::types::{
19    BlockExecution, BlockTrace, ConditionTrace, DecisionTableExtras, EvaluationError, Trace,
20    WriteTrace,
21};
22use crate::DecisionGraphTrace;
23
24pub type GraphTraceMap = HashMap<Arc<str>, DecisionGraphTrace>;
25
26struct EnhanceState<'a> {
27    db: &'a Db,
28    snapshot: Arc<Snapshot>,
29    executions: Vec<BlockExecution>,
30    visiting: HashSet<Arc<str>>,
31}
32
33impl EnhanceState<'_> {
34    fn dt_environment(
35        &self,
36        content: &DecisionTableContent,
37        node_trace: &DecisionGraphTrace,
38        trace: &GraphTraceMap,
39    ) -> Option<Variable> {
40        let nodes = Variable::from_object(
41            trace
42                .values()
43                .filter(|entry| entry.order < node_trace.order)
44                .map(|entry| {
45                    (
46                        zen_types::symbol::Symbol::from(entry.name.as_ref()),
47                        entry.output.clone(),
48                    )
49                })
50                .collect(),
51        );
52        let base = node_trace.input.depth_clone(1);
53        base.dot_insert("$nodes", nodes.clone());
54        let Some(input_field) = &content.transform_attributes.input_field else {
55            return Some(base);
56        };
57        let mut isolate = Isolate::with_environment(base);
58        let calculated = isolate.run_standard(input_field.as_ref()).ok()?;
59        match &calculated {
60            Variable::Array(items) => {
61                let items = items
62                    .borrow()
63                    .iter()
64                    .map(|item| {
65                        let item = item.depth_clone(1);
66                        item.dot_insert("$nodes", nodes.clone());
67                        item
68                    })
69                    .collect();
70                Some(Variable::from_array(items))
71            }
72            _ => {
73                let calculated = calculated.depth_clone(1);
74                calculated.dot_insert("$nodes", nodes);
75                Some(calculated)
76            }
77        }
78    }
79
80    fn dt_extras(
81        &self,
82        content: &DecisionTableContent,
83        environment: Variable,
84    ) -> DecisionTableExtras {
85        let mut isolate = Isolate::with_environment(environment.depth_clone(1));
86        let bytes_per_row = content.inputs.len().div_ceil(8);
87        let mut bits = vec![0u8; bytes_per_row * content.rules.len()];
88        for (row, rule) in content.rules.iter().enumerate() {
89            for (col, input) in content.inputs.iter().enumerate() {
90                if DecisionTableNodeHandler::cell_passes(rule, input, &mut isolate) {
91                    bits[row * bytes_per_row + (col >> 3)] |= 1 << (col & 7);
92                }
93            }
94        }
95        DecisionTableExtras {
96            input_pass: base64::engine::general_purpose::STANDARD.encode(&bits),
97        }
98    }
99}
100
101impl Db {
102    pub fn enhance_graph_trace(
103        &self,
104        document: &Arc<str>,
105        trace: &GraphTraceMap,
106    ) -> Result<Trace, EvaluationError> {
107        let snapshot = self.snapshot();
108        let Some(content) = snapshot
109            .graphs
110            .get(document)
111            .and_then(|content| content.as_graph())
112            .cloned()
113        else {
114            return Err(EvaluationError::PolicyNotFound(document.clone()));
115        };
116
117        let mut state = EnhanceState {
118            db: self,
119            snapshot: snapshot.clone(),
120            executions: Vec::new(),
121            visiting: HashSet::new(),
122        };
123        state.visiting.insert(document.clone());
124        walk_graph(&mut state, &content, trace, "", None);
125
126        let mut properties: HashMap<Arc<str>, Variable> = HashMap::new();
127        for execution in &state.executions {
128            for write in &execution.writes {
129                properties.insert(write.path.clone(), write.value.clone());
130            }
131            match &execution.trace {
132                BlockTrace::Expression { property, value } if !property.is_empty() => {
133                    properties.insert(property.clone(), value.clone());
134                }
135                BlockTrace::DecisionTable { evaluations, .. } => {
136                    for evaluation in evaluations {
137                        for (path, value) in evaluation {
138                            properties.insert(path.clone(), value.clone());
139                        }
140                    }
141                }
142                _ => {}
143            }
144        }
145
146        Ok(Trace {
147            engine_version: Arc::from(crate::ENGINE_VERSION),
148            properties,
149            executions: state.executions,
150        })
151    }
152}
153
154fn walk_graph(
155    state: &mut EnhanceState,
156    content: &GraphContent,
157    trace: &GraphTraceMap,
158    id_prefix: &str,
159    inherited_instance: Option<&Arc<str>>,
160) {
161    let mut executed: Vec<(&DecisionNode, &DecisionGraphTrace)> = content
162        .nodes
163        .iter()
164        .filter_map(|node| trace.get(node.id.as_ref()).map(|t| (node.as_ref(), t)))
165        .collect();
166    executed.sort_by_key(|(_, node_trace)| node_trace.order);
167
168    for (node, node_trace) in executed {
169        if matches!(
170            node.kind,
171            DecisionNodeKind::InputNode { .. } | DecisionNodeKind::OutputNode { .. }
172        ) {
173            continue;
174        }
175        let block_id = prefixed(id_prefix, &node.id);
176        let paths = NodePaths::new(node);
177
178        match &node.kind {
179            DecisionNodeKind::ExpressionNode { content } => {
180                let loop_mode = matches!(
181                    content.transform_attributes.execution_mode,
182                    TransformExecutionMode::Loop
183                );
184                let iterations = trace_entries(node_trace.trace_data.as_ref(), loop_mode);
185                for row in content.expressions.iter() {
186                    if row.key.is_empty() || row.value.is_empty() {
187                        continue;
188                    }
189                    let row_block_id: Arc<str> = Arc::from(format!("{block_id}:{}", row.id));
190                    let reads = state.db.node_global_reads(
191                        node,
192                        &paths,
193                        Some(std::slice::from_ref(&row.id)),
194                    );
195                    let local_reads = state
196                        .db
197                        .node_local_reads(node, Some(std::slice::from_ref(&row.id)));
198                    let property = output_prefixed(&paths, &row.key);
199                    for (index, entry) in iterations.iter().enumerate() {
200                        let value = expression_trace_result(entry, row.key.as_ref())
201                            .unwrap_or(Variable::Null);
202                        let element = iteration_element(node_trace, &paths, loop_mode, index);
203                        let dollar = expression_dollar_scope(&content.expressions, entry);
204                        state.executions.push(BlockExecution {
205                            block_id: row_block_id.clone(),
206                            policy_path: None,
207                            instance_path: instance_for(
208                                node,
209                                &paths,
210                                loop_mode,
211                                iterations.len(),
212                                index,
213                                inherited_instance,
214                            ),
215                            trace: BlockTrace::Expression {
216                                property: property.clone(),
217                                value,
218                            },
219                            operand_values: operand_values(
220                                &local_reads,
221                                &paths,
222                                &node_trace.input,
223                                element.as_ref(),
224                                Some(&dollar),
225                            ),
226                            writes: Vec::new(),
227                            reads: reads.clone(),
228                        });
229                    }
230                }
231            }
232            DecisionNodeKind::DecisionTableNode { content } => {
233                let loop_mode = matches!(
234                    content.transform_attributes.execution_mode,
235                    TransformExecutionMode::Loop
236                );
237                let collect = matches!(content.hit_policy, DecisionTableHitPolicy::Collect);
238                let column_collect =
239                    !collect && content.outputs.iter().any(|output| output.write_path().1);
240                let iterations = trace_entries(node_trace.trace_data.as_ref(), loop_mode);
241                let reads = state.db.node_global_reads(node, &paths, None);
242                let environment_root = state.dt_environment(content, node_trace, trace);
243                let outputs_root = match &paths.output_path {
244                    Some(path) => node_trace.output.dot(path).unwrap_or(Variable::Null),
245                    None => node_trace.output.clone(),
246                };
247                for (index, entry) in iterations.iter().enumerate() {
248                    let row_traces: Vec<Variable> = if collect || column_collect {
249                        entry
250                            .as_array()
251                            .map(|rows| rows.borrow().iter().cloned().collect())
252                            .unwrap_or_default()
253                    } else {
254                        vec![entry.clone()]
255                    };
256                    let matched_rows: Vec<u32> = row_traces
257                        .iter()
258                        .filter_map(|row| row.dot("index"))
259                        .filter_map(|value| match value {
260                            Variable::Number(number) => number.to_u32(),
261                            _ => None,
262                        })
263                        .collect();
264
265                    let iter_result = if loop_mode {
266                        element_at(&outputs_root, index).unwrap_or(Variable::Null)
267                    } else {
268                        outputs_root.clone()
269                    };
270                    let mut evaluations: Vec<HashMap<Arc<str>, Variable>> = Vec::new();
271                    if collect {
272                        for (row_index, _) in row_traces.iter().enumerate() {
273                            let Some(row) = element_at(&iter_result, row_index) else {
274                                continue;
275                            };
276                            let mut evaluation: HashMap<Arc<str>, Variable> = HashMap::new();
277                            for column in content.outputs.iter() {
278                                let (path, _) = column.write_path();
279                                if let Some(value) = row.dot(path) {
280                                    evaluation
281                                        .insert(output_prefixed(&paths, path), value.deep_clone());
282                                }
283                            }
284                            if !evaluation.is_empty() {
285                                evaluations.push(evaluation);
286                            }
287                        }
288                    } else if column_collect {
289                        let mut collect_positions: HashMap<Arc<str>, usize> = HashMap::new();
290                        for (position, row_idx) in matched_rows.iter().enumerate() {
291                            let rule = content.rules.get(*row_idx as usize);
292                            let mut evaluation: HashMap<Arc<str>, Variable> = HashMap::new();
293                            for column in content.outputs.iter() {
294                                let (path, is_collect) = column.write_path();
295                                if path.is_empty() {
296                                    continue;
297                                }
298                                let cell_filled = rule.is_some_and(|r| {
299                                    r.get(&column.id).is_some_and(|c| !c.is_empty())
300                                });
301                                if !cell_filled {
302                                    continue;
303                                }
304                                if is_collect {
305                                    let counter =
306                                        collect_positions.entry(column.id.clone()).or_default();
307                                    let value = iter_result
308                                        .dot(path)
309                                        .and_then(|values| element_at(&values, *counter));
310                                    *counter += 1;
311                                    if let Some(value) = value {
312                                        evaluation.insert(
313                                            output_prefixed(&paths, path),
314                                            value.deep_clone(),
315                                        );
316                                    }
317                                } else if position == 0 {
318                                    if let Some(value) = iter_result.dot(path) {
319                                        evaluation.insert(
320                                            output_prefixed(&paths, path),
321                                            value.deep_clone(),
322                                        );
323                                    }
324                                }
325                            }
326                            evaluations.push(evaluation);
327                        }
328                    } else {
329                        let mut evaluation: HashMap<Arc<str>, Variable> = HashMap::new();
330                        for column in content.outputs.iter() {
331                            let (path, _) = column.write_path();
332                            if let Some(value) = iter_result.dot(path) {
333                                evaluation
334                                    .insert(output_prefixed(&paths, path), value.deep_clone());
335                            }
336                        }
337                        evaluations.push(evaluation);
338                    }
339
340                    let element = iteration_element(node_trace, &paths, loop_mode, index);
341                    let mut operands: HashMap<Arc<str>, Variable> = HashMap::new();
342                    for row in &row_traces {
343                        let Some(reference_map) =
344                            row.dot("reference_map").and_then(|value| value.as_object())
345                        else {
346                            continue;
347                        };
348                        for (field, value) in reference_map.borrow().iter() {
349                            operands.insert(Arc::from(field.as_ref()), value.deep_clone());
350                        }
351                    }
352                    if operands.is_empty() {
353                        let local_reads = state.db.node_local_reads(node, None);
354                        operands = operand_values(
355                            &local_reads,
356                            &paths,
357                            &node_trace.input,
358                            element.as_ref(),
359                            None,
360                        );
361                    }
362
363                    let environment = if loop_mode {
364                        environment_root
365                            .as_ref()
366                            .and_then(|env| element_at(env, index))
367                    } else {
368                        environment_root.clone()
369                    };
370                    let extras = environment.map(|env| state.dt_extras(content, env));
371                    state.executions.push(BlockExecution {
372                        block_id: block_id.clone(),
373                        policy_path: None,
374                        instance_path: instance_for(
375                            node,
376                            &paths,
377                            loop_mode,
378                            iterations.len(),
379                            index,
380                            inherited_instance,
381                        ),
382                        trace: BlockTrace::DecisionTable {
383                            matched_rows,
384                            evaluations,
385                            extras,
386                        },
387                        operand_values: operands,
388                        writes: Vec::new(),
389                        reads: reads.clone(),
390                    });
391                }
392            }
393            DecisionNodeKind::SwitchNode { content } => {
394                let taken: HashSet<Arc<str>> = node_trace
395                    .trace_data
396                    .as_ref()
397                    .and_then(|data| data.dot("statements"))
398                    .and_then(|statements| statements.as_array())
399                    .map(|statements| {
400                        statements
401                            .borrow()
402                            .iter()
403                            .filter_map(|statement| statement.dot("id"))
404                            .filter_map(|id| id.as_str().map(Arc::from))
405                            .collect()
406                    })
407                    .unwrap_or_default();
408                let arms: Vec<ConditionTrace> = content
409                    .statements
410                    .iter()
411                    .map(|statement| ConditionTrace {
412                        id: statement.id.clone(),
413                        result: taken.contains(&statement.id),
414                    })
415                    .collect();
416                let matched_arm = content
417                    .statements
418                    .iter()
419                    .find(|statement| taken.contains(&statement.id))
420                    .map(|statement| statement.id.clone());
421                let reads = state.db.node_global_reads(node, &paths, None);
422                let local_reads = state.db.node_local_reads(node, None);
423                state.executions.push(BlockExecution {
424                    block_id: block_id.clone(),
425                    policy_path: None,
426                    instance_path: inherited_instance.cloned(),
427                    trace: BlockTrace::Match {
428                        matched_arm,
429                        value: Variable::Null,
430                        arms,
431                    },
432                    operand_values: operand_values(
433                        &local_reads,
434                        &paths,
435                        &node_trace.input,
436                        None,
437                        None,
438                    ),
439                    writes: Vec::new(),
440                    reads,
441                });
442            }
443            DecisionNodeKind::FunctionNode { content } => {
444                let source = function_source(content);
445                let local_reads: Vec<Arc<str>> = Db::function_input_reads(&source)
446                    .into_iter()
447                    .map(Arc::from)
448                    .collect();
449                let reads: Vec<Arc<str>> = local_reads
450                    .iter()
451                    .filter_map(|read| map_read(&paths, read))
452                    .collect();
453                state.executions.push(BlockExecution {
454                    block_id: block_id.clone(),
455                    policy_path: None,
456                    instance_path: inherited_instance.cloned(),
457                    trace: BlockTrace::Expression {
458                        property: Arc::from(""),
459                        value: node_trace.output.clone(),
460                    },
461                    operand_values: operand_values(
462                        &local_reads,
463                        &paths,
464                        &node_trace.input,
465                        None,
466                        None,
467                    ),
468                    writes: shallow_writes(&node_trace.input, &node_trace.output),
469                    reads,
470                });
471            }
472            DecisionNodeKind::DecisionNode { content } => {
473                let key = content.key.clone();
474                let sub_content = (!state.visiting.contains(&key))
475                    .then(|| {
476                        state
477                            .snapshot
478                            .graphs
479                            .get(&key)
480                            .and_then(|content| content.as_graph())
481                            .cloned()
482                    })
483                    .flatten();
484                let sub_traces = sub_content
485                    .as_ref()
486                    .map(|_| sub_trace_maps(node_trace.trace_data.as_ref()))
487                    .unwrap_or_default();
488
489                if let (Some(sub_content), false) = (sub_content, sub_traces.is_empty()) {
490                    let group_index = state.executions.len();
491                    state.executions.push(BlockExecution {
492                        block_id: block_id.clone(),
493                        policy_path: None,
494                        instance_path: inherited_instance.cloned(),
495                        trace: BlockTrace::Expression {
496                            property: Arc::from(""),
497                            value: node_trace.output.clone(),
498                        },
499                        operand_values: HashMap::new(),
500                        writes: shallow_writes(&node_trace.input, &node_trace.output),
501                        reads: Vec::new(),
502                    });
503                    let child_start = state.executions.len();
504                    state.visiting.insert(key.clone());
505                    let looped = sub_traces.len() > 1;
506                    for (index, sub_trace) in sub_traces.iter().enumerate() {
507                        let prefix = if looped {
508                            format!("{block_id}[{index}]/")
509                        } else {
510                            format!("{block_id}/")
511                        };
512                        let instance: Option<Arc<str>> = if looped {
513                            Some(Arc::from(format!("{}.{index}", loop_label(node, &paths))))
514                        } else {
515                            inherited_instance.cloned()
516                        };
517                        walk_graph(state, &sub_content, sub_trace, &prefix, instance.as_ref());
518                    }
519                    state.visiting.remove(&key);
520                    let free_reads = subtree_free_reads(&state.executions[child_start..], &paths);
521                    state.executions[group_index].reads = free_reads;
522                } else {
523                    push_opaque(state, &block_id, node_trace, inherited_instance);
524                }
525            }
526            _ => {
527                push_opaque(state, &block_id, node_trace, inherited_instance);
528            }
529        }
530    }
531}
532
533fn push_opaque(
534    state: &mut EnhanceState,
535    block_id: &Arc<str>,
536    node_trace: &DecisionGraphTrace,
537    inherited_instance: Option<&Arc<str>>,
538) {
539    state.executions.push(BlockExecution {
540        block_id: block_id.clone(),
541        policy_path: None,
542        instance_path: inherited_instance.cloned(),
543        trace: BlockTrace::Expression {
544            property: Arc::from(""),
545            value: node_trace.output.clone(),
546        },
547        operand_values: HashMap::new(),
548        writes: shallow_writes(&node_trace.input, &node_trace.output),
549        reads: Vec::new(),
550    });
551}
552
553fn prefixed(prefix: &str, id: &str) -> Arc<str> {
554    if prefix.is_empty() {
555        Arc::from(id)
556    } else {
557        Arc::from(format!("{prefix}{id}"))
558    }
559}
560
561fn output_prefixed(paths: &NodePaths, key: &str) -> Arc<str> {
562    if paths.output_prefix.is_empty() {
563        Arc::from(key)
564    } else {
565        Arc::from(format!("{}.{key}", paths.output_prefix.join(".")))
566    }
567}
568
569fn map_read(paths: &NodePaths, path: &str) -> Option<Arc<str>> {
570    match &paths.read_base {
571        ReadBase::NodeInput => Some(Arc::from(path)),
572        ReadBase::Opaque => None,
573        ReadBase::Prefixed(prefix) => Some(Arc::from(format!("{}.{path}", prefix.join(".")))),
574    }
575}
576
577fn loop_label(node: &DecisionNode, paths: &NodePaths) -> String {
578    match &paths.read_base {
579        ReadBase::Prefixed(segments) => segments.join("."),
580        _ => node.name.to_string(),
581    }
582}
583
584fn instance_for(
585    node: &DecisionNode,
586    paths: &NodePaths,
587    loop_mode: bool,
588    total: usize,
589    index: usize,
590    inherited: Option<&Arc<str>>,
591) -> Option<Arc<str>> {
592    if loop_mode && total > 1 {
593        Some(Arc::from(format!("{}.{index}", loop_label(node, paths))))
594    } else {
595        inherited.cloned()
596    }
597}
598
599fn trace_entries(trace_data: Option<&Variable>, loop_mode: bool) -> Vec<Variable> {
600    match trace_data {
601        Some(Variable::Array(items)) if loop_mode => items.borrow().iter().cloned().collect(),
602        Some(data) => vec![data.clone()],
603        None => vec![Variable::Null],
604    }
605}
606
607fn element_at(value: &Variable, index: usize) -> Option<Variable> {
608    value
609        .as_array()
610        .and_then(|items| items.borrow().get(index).cloned())
611}
612
613fn iteration_element(
614    node_trace: &DecisionGraphTrace,
615    paths: &NodePaths,
616    loop_mode: bool,
617    index: usize,
618) -> Option<Variable> {
619    if !loop_mode {
620        return None;
621    }
622    let ReadBase::Prefixed(prefix) = &paths.read_base else {
623        return None;
624    };
625    node_trace
626        .input
627        .dot(&prefix.join("."))
628        .and_then(|collection| element_at(&collection, index))
629}
630
631fn operand_values(
632    local_reads: &[Arc<str>],
633    paths: &NodePaths,
634    node_input: &Variable,
635    element: Option<&Variable>,
636    dollar: Option<&Variable>,
637) -> HashMap<Arc<str>, Variable> {
638    let mut out: HashMap<Arc<str>, Variable> = HashMap::new();
639    for read in local_reads {
640        let value = if let Some(rest) = read.strip_prefix("$.") {
641            dollar.and_then(|scope| scope.dot(rest))
642        } else if let Some(element) = element {
643            element.dot(read)
644        } else {
645            match &paths.read_base {
646                ReadBase::NodeInput => node_input.dot(read),
647                ReadBase::Prefixed(prefix) => {
648                    node_input.dot(&format!("{}.{read}", prefix.join(".")))
649                }
650                ReadBase::Opaque => None,
651            }
652        };
653        if let Some(value) = value {
654            out.insert(read.clone(), value.deep_clone());
655        }
656    }
657    out
658}
659
660/// Expression-node trace maps are keyed by the literal row key, which may itself
661/// contain dots (e.g. "quote.annualPremium"), so a `dot()` path lookup would miss
662/// those entries — read the map key directly.
663fn expression_trace_result(entry: &Variable, key: &str) -> Option<Variable> {
664    let obj = entry.as_object()?;
665    let slot = obj
666        .borrow()
667        .get(&zen_types::symbol::Symbol::from(key))?
668        .shallow_clone();
669    slot.dot("result")
670}
671
672fn expression_dollar_scope(rows: &[zen_types::decision::Expression], entry: &Variable) -> Variable {
673    let scope = Variable::empty_object();
674    for row in rows {
675        if row.key.is_empty() {
676            continue;
677        }
678        let Some(value) = expression_trace_result(entry, row.key.as_ref()) else {
679            continue;
680        };
681        scope.dot_insert(row.key.as_ref(), value);
682    }
683    scope
684}
685
686fn shallow_writes(input: &Variable, output: &Variable) -> Vec<WriteTrace> {
687    let Some(entries) = output.as_object() else {
688        return Vec::new();
689    };
690    let mut writes: Vec<WriteTrace> = Vec::new();
691    for (key, value) in entries.borrow().iter() {
692        if key.starts_with('$') {
693            continue;
694        }
695        if input
696            .dot(key.as_str())
697            .is_some_and(|previous| previous == *value)
698        {
699            continue;
700        }
701        writes.push(WriteTrace {
702            path: Arc::from(key.as_ref()),
703            value: value.deep_clone(),
704        });
705    }
706    writes.sort_by(|a, b| a.path.cmp(&b.path));
707    writes
708}
709
710fn sub_trace_maps(trace_data: Option<&Variable>) -> Vec<GraphTraceMap> {
711    match trace_data {
712        Some(Variable::Array(items)) => items.borrow().iter().filter_map(as_trace_map).collect(),
713        Some(data) => as_trace_map(data).map(|map| vec![map]).unwrap_or_default(),
714        None => Vec::new(),
715    }
716}
717
718fn as_trace_map(value: &Variable) -> Option<GraphTraceMap> {
719    if !matches!(value, Variable::Object(_)) {
720        return None;
721    }
722    let json = serde_json::to_value(value).ok()?;
723    let map: GraphTraceMap = serde_json::from_value(json).ok()?;
724    (!map.is_empty()).then_some(map)
725}
726
727fn subtree_free_reads(children: &[BlockExecution], paths: &NodePaths) -> Vec<Arc<str>> {
728    let mut written: Vec<Arc<str>> = Vec::new();
729    for child in children {
730        for write in &child.writes {
731            written.push(write.path.clone());
732        }
733        match &child.trace {
734            BlockTrace::Expression { property, .. } if !property.is_empty() => {
735                written.push(property.clone());
736            }
737            BlockTrace::DecisionTable { evaluations, .. } => {
738                for evaluation in evaluations {
739                    written.extend(evaluation.keys().cloned());
740                }
741            }
742            _ => {}
743        }
744    }
745    let covered = |read: &str| {
746        written
747            .iter()
748            .any(|path| read == path.as_ref() || read.starts_with(&format!("{path}.")))
749    };
750    let mut out: Vec<Arc<str>> = Vec::new();
751    for child in children {
752        for read in &child.reads {
753            if covered(read) {
754                continue;
755            }
756            if let Some(mapped) = map_read(paths, read) {
757                out.push(mapped);
758            }
759        }
760    }
761    out.sort();
762    out.dedup();
763    out
764}