Skip to main content

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}