somatize_tree/walk.rs
1//! A line of exploration, as data.
2//!
3//! What `log` prints and what a browser draws are the **same answer** read two
4//! ways, so it is worked out once and here. A second copy of this in a request
5//! handler would be a view that quietly disagreed with the terminal about what
6//! an investigation contains, which is the one thing neither can afford.
7
8use crate::findings::Findings;
9use crate::journal::{Journal, Verdict};
10use crate::moves::{Course, Moves};
11use crate::revision;
12use crate::snapshot::{Probing, Snapshot};
13use crate::trials::{Goal, Tally, Trials};
14use serde::Serialize;
15use somatize_store::Store;
16use std::collections::{HashMap, HashSet};
17use std::path::Path;
18
19/// One commit of a line, with everything known about it that is not a step.
20#[derive(Debug, Serialize)]
21pub struct Stop {
22 pub commit: String,
23 /// Twelve characters, which is what a person reads.
24 pub short: String,
25 pub subject: String,
26 /// Who it comes from. **The edges of the DAG**: a range flattens two
27 /// branches into an order, and drawing that order would be drawing a lie.
28 pub parents: Vec<String>,
29 /// Whether somebody found something wrong with this commit itself.
30 pub verdict: Option<Verdict>,
31 /// What the reasoning decided about the line this commit is on, if
32 /// anything. **Derived** — from a decision's scope, down through the moves
33 /// it covers, out to the commits those cite — so abandoning a question's
34 /// line reaches an attempt hung under it tomorrow with nobody writing
35 /// anything down. It deliberately does not reach a fork off an abandoned
36 /// attempt: that is a sibling and starts clean, because trying something
37 /// else is the move you make *because* it was a dead end.
38 pub decided: Option<Course>,
39 /// Whether something above it is [`Verdict::Invalid`]. Worked out from git
40 /// rather than stored, so a commit made after the verdict is marked the
41 /// moment it exists.
42 pub doubted: bool,
43 /// What was run with this version: how many trials and how they are going.
44 ///
45 /// A commit is the version and does not change; trials grow and are
46 /// **associated** with it, not versioned. From the same scan as everything
47 /// else, because soma put the state and the score in the record — counting
48 /// forty versions is one walk and only the curve is paid for apart.
49 pub trials: Tally,
50 /// Whether it folds when drawn: it is on a line somebody decided to
51 /// abandon or call superseded, and nobody found anything wrong with it.
52 ///
53 /// **Pruning is not drawing, never deleting.** The stop still comes back
54 /// whole; all this says is that a tree of forty variants does not read, and
55 /// whoever draws may fold this one. Computed here and not by whoever draws,
56 /// or the rule would live in two languages and the terminal and the view
57 /// would fold different things, both looking right.
58 pub pruned: bool,
59 /// Whether it is only here so the one above it has something to be
60 /// compared against. A range says which commits to *show*.
61 pub context: bool,
62 /// When it was made. Which of three variants was tried first is a question
63 /// about this and not about the order a walk arrived in.
64 pub when: u64,
65}
66
67/// One step: what the edit from `from` to `to` did.
68#[derive(Debug, Serialize)]
69pub struct Step {
70 pub from: String,
71 pub to: String,
72 #[serde(flatten)]
73 pub found: Findings,
74 /// What was built differently around the two probes. Usually empty.
75 pub drift: Vec<(String, String, String)>,
76}
77
78/// A whole line, ready to print or to draw.
79#[derive(Debug, Serialize)]
80pub struct Walk {
81 pub tree: String,
82 pub built_from: String,
83 pub stops: Vec<Stop>,
84 pub steps: Vec<Step>,
85}
86
87impl Walk {
88 /// The step arriving at that commit, if it has one.
89 pub fn step_to(&self, commit: &str) -> Option<&Step> {
90 self.steps.iter().find(|step| step.to == commit)
91 }
92}
93
94/// Whether a stop folds when a pruned line is drawn.
95///
96/// The whole rule in one place, because the terminal and the view both use it.
97///
98/// What somebody decided to abandon or call superseded folds. **What somebody
99/// judged wrong does not**: an `invalid` commit is what casts doubt on the
100/// measurement the decision leaned on, and hiding it would hide the very
101/// reason to look again — same for whatever inherits that doubt. A `sound`
102/// does fold: it says somebody looked and found nothing, so the decision
103/// stands.
104pub fn folds(decided: Option<Course>, judged: Option<Verdict>, doubted: bool) -> bool {
105 if !matches!(decided, Some(Course::Abandon) | Some(Course::Superseded)) {
106 return false;
107 }
108 !doubted && !matches!(judged, Some(Verdict::Invalid))
109}
110
111/// What it takes to read what is already known of an investigation: its name,
112/// where it is kept, and which way is better.
113///
114/// The three travel together because they come out of one `soma-tree.toml` and
115/// none means anything without the others: a store without the tree's name
116/// returns another investigation's records, and a score without the direction
117/// does not say whether it is good.
118pub struct Remembered<'a> {
119 pub tree: &'a str,
120 pub kept: &'a dyn Store,
121 pub goal: Option<Goal>,
122}
123
124/// Works out a line: what was probed, what each step did, and what was said.
125///
126/// `shown` is what a range named; `commits` is that plus the one underneath,
127/// which is probed and never drawn as a stop of its own.
128pub fn walked(
129 repo: &Path,
130 known_as: Remembered<'_>,
131 probing: &Probing,
132 shown: &[String],
133 commits: &[String],
134 known: &HashMap<&str, Snapshot>,
135) -> Result<Walk, Box<dyn std::error::Error>> {
136 let Remembered { tree, kept, goal } = known_as;
137 // Probing is optional. A repository from before soma — a finished paper,
138 // work nobody runs any more — has a history, a journal, trials and a line
139 // of reasoning worth reading, and no graph to probe. Without a probe there
140 // are stops and no steps: what is missing is **what each edit did**.
141 let probing = if known.is_empty() && !commits.is_empty() {
142 None
143 } else {
144 if commits
145 .iter()
146 .any(|commit| !known.contains_key(commit.as_str()))
147 {
148 return Err("some commit was never probed".into());
149 }
150 Some(probing)
151 };
152 let parents: HashMap<String, Vec<String>> =
153 revision::parents_of(repo, commits).into_iter().collect();
154
155 // A step is an **edge**, so it is read off the parents. Pairing adjacent
156 // entries of a walk would, with three branches off one commit, compare
157 // three different lines of exploration with each other and answer
158 // confidently about an edit nobody made.
159 let pairs: Vec<(String, String)> = commits
160 .iter()
161 .flat_map(|commit| {
162 parents
163 .get(commit)
164 .into_iter()
165 .flatten()
166 .filter(|parent| known.contains_key(parent.as_str()))
167 .map(move |parent| (parent.clone(), commit.clone()))
168 })
169 .collect();
170 let found = match probing {
171 Some(probing) => probing.compared(known, &pairs)?,
172 None => Vec::new(),
173 };
174
175 // A verdict is written about **one** commit; that its descendants are
176 // suspect is worked out here. Walked over the parents already in hand
177 // rather than asked of git: an ancestry-path question needs a tip to walk
178 // **towards**, and with three branches the tip is usually on somebody
179 // else's, so a verdict cast on one variant would reach nothing.
180 let journal = Journal::of(tree, kept);
181 let verdicts = journal.verdicts()?;
182 let mut doubted: HashSet<String> = HashSet::new();
183 let mut asking: Vec<&String> = verdicts
184 .iter()
185 .filter(|(_, verdict)| verdict.reaches_down())
186 .map(|(commit, _)| commit)
187 .collect();
188 while let Some(commit) = asking.pop() {
189 if !doubted.insert(commit.clone()) {
190 continue;
191 }
192 // Its children: whoever names it as a parent.
193 asking.extend(
194 parents
195 .iter()
196 .filter(|(_, of)| of.contains(commit))
197 .map(|(child, _)| child),
198 );
199 }
200
201 // Not being able to read the reasoning is no reason not to draw the
202 // record — a tree with no decisions is what there is on day one — and the
203 // same goes for not being able to count what was run.
204 let decided = Moves::of(tree, kept).decided().unwrap_or_default();
205 let mut counted = Trials::of(tree, kept)
206 .towards(goal)
207 .counted()
208 .unwrap_or_default();
209
210 let told = revision::told(repo, commits);
211 let stops = commits
212 .iter()
213 .map(|commit| Stop {
214 short: commit[..12.min(commit.len())].to_string(),
215 when: told.get(commit).map(|(when, _)| *when).unwrap_or_default(),
216 subject: told
217 .get(commit)
218 .map(|(_, said)| said.clone())
219 .unwrap_or_default(),
220 parents: parents.get(commit).cloned().unwrap_or_default(),
221 verdict: verdicts.get(commit).copied(),
222 decided: decided.get(commit).copied(),
223 pruned: folds(
224 decided.get(commit).copied(),
225 verdicts.get(commit).copied(),
226 doubted.contains(commit),
227 ),
228 trials: counted.remove(commit).unwrap_or_default(),
229 doubted: doubted.contains(commit),
230 context: !shown.contains(commit),
231 commit: commit.clone(),
232 })
233 .collect();
234
235 let steps = pairs
236 .into_iter()
237 .zip(found)
238 .map(|((from, to), found)| Step {
239 drift: known[from.as_str()]
240 .drifted_from(&known[to.as_str()])
241 .into_iter()
242 .map(|(what, was, is)| (what.to_string(), was, is))
243 .collect(),
244 from,
245 to,
246 found,
247 })
248 .collect();
249
250 Ok(Walk {
251 tree: tree.to_string(),
252 // Said out loud rather than left blank, which would look like a fault.
253 built_from: if probing.is_none() {
254 "no probe — this repository does not declare what to build".to_string()
255 } else {
256 commits
257 .first()
258 .and_then(|first| known.get(first.as_str()))
259 .map(|first| first.built_from.clone())
260 .unwrap_or_default()
261 },
262 stops,
263 steps,
264 })
265}