Skip to main content

omgbase_mutate/
lower.rs

1//! Lowering (`spec/mutate/README.md` §7.1): the matcher's assignment over a
2//! proposed body becomes kernel ops at top-level granularity — removes,
3//! content updates with `child_ids`, LCS placement, retile — and the
4//! guaranteed full-replace fallback.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8use omgbase_format::Block;
9use omgbase_reconcile::json::detail_to_json;
10use omgbase_reconcile::{Disposition, DispositionKind};
11use serde_json::Value;
12
13use crate::changeset::Op;
14use crate::ops::{At, Expect, Parent, To};
15use crate::opset::{PlanDisposition, PlanOp};
16use crate::tree::{MutBlock, MutDoc, raw_hash_hex};
17
18/// What a lowering produces: the plan ops and the counts the summary needs.
19#[derive(Clone, Debug, PartialEq)]
20pub struct LowerResult {
21    pub ops: Vec<PlanOp>,
22    pub preserved: usize,
23    pub ambiguous: usize,
24}
25
26struct TargetBlock {
27    id: String,
28    raw: String,
29    trivia: String,
30    children: Vec<TargetBlock>,
31    /// The id existed anywhere in the old tree.
32    carried: bool,
33}
34
35fn build_target(
36    blocks: &[Block],
37    assignment: &BTreeMap<String, String>,
38    old_ids: &HashSet<String>,
39    parent_key: Option<&str>,
40) -> Vec<TargetBlock> {
41    blocks
42        .iter()
43        .enumerate()
44        .map(|(index, b)| {
45            let key = format!("{}/{index}", parent_key.unwrap_or(""));
46            let id = assignment.get(&key).cloned().unwrap_or_else(|| key.clone());
47            TargetBlock {
48                carried: old_ids.contains(&id),
49                raw: b.raw.clone(),
50                trivia: b.trivia.clone(),
51                children: build_target(&b.children, assignment, old_ids, Some(&key)),
52                id,
53            }
54        })
55        .collect()
56}
57
58fn collect_target_ids(list: &[TargetBlock], out: &mut HashSet<String>) {
59    for b in list {
60        out.insert(b.id.clone());
61        collect_target_ids(&b.children, out);
62    }
63}
64
65/// §7.1 step 3: the longest common subsequence of two id lists (standard DP;
66/// ties prefer advancing the old side), as the set of kept ids.
67#[must_use]
68pub fn lcs(a: &[String], b: &[String]) -> HashSet<String> {
69    let (n, m) = (a.len(), b.len());
70    let mut dp = vec![vec![0usize; m + 1]; n + 1];
71    for i in (0..n).rev() {
72        for j in (0..m).rev() {
73            dp[i][j] = if a[i] == b[j] {
74                dp[i + 1][j + 1] + 1
75            } else {
76                dp[i + 1][j].max(dp[i][j + 1])
77            };
78        }
79    }
80    let mut keep = HashSet::new();
81    let (mut i, mut j) = (0, 0);
82    while i < n && j < m {
83        if a[i] == b[j] {
84            keep.insert(a[i].clone());
85            i += 1;
86            j += 1;
87        } else if dp[i + 1][j] >= dp[i][j + 1] {
88            i += 1;
89        } else {
90            j += 1;
91        }
92    }
93    keep
94}
95
96struct Meta {
97    confidence: Option<f64>,
98    reason: Option<String>,
99    detail: Option<Value>,
100}
101
102fn meta_of(disp: Option<&Disposition>) -> Meta {
103    match disp {
104        Some(d) => Meta {
105            confidence: d.confidence,
106            reason: d.reason.map(|r| r.as_str().to_owned()),
107            detail: Some(detail_to_json(&d.detail)),
108        },
109        None => Meta {
110            confidence: None,
111            reason: None,
112            detail: None,
113        },
114    }
115}
116
117/// §7.1 step 2: positional-key → id for a container's carried descendants.
118fn carried_child_id_map(
119    container: &TargetBlock,
120    old_ids: &HashSet<String>,
121) -> BTreeMap<String, String> {
122    fn walk(
123        children: &[TargetBlock],
124        parent_key: &str,
125        old_ids: &HashSet<String>,
126        out: &mut BTreeMap<String, String>,
127    ) {
128        for (i, c) in children.iter().enumerate() {
129            let key = format!("{parent_key}/{i}");
130            if old_ids.contains(&c.id) {
131                out.insert(key.clone(), c.id.clone());
132            }
133            if !c.children.is_empty() {
134                walk(&c.children, &key, old_ids, out);
135            }
136        }
137    }
138    let mut out = BTreeMap::new();
139    walk(&container.children, "", old_ids, &mut out);
140    out
141}
142
143fn disposition_for_new(kind: Option<DispositionKind>) -> PlanDisposition {
144    match kind {
145        Some(DispositionKind::CopiedFrom) => PlanDisposition::CopiedFrom,
146        Some(DispositionKind::Resurrected) => PlanDisposition::Resurrected,
147        Some(DispositionKind::SplitFrom) => PlanDisposition::SplitFrom,
148        _ => PlanDisposition::Inserted,
149    }
150}
151
152fn top_level_to(anchor: Option<&str>) -> To {
153    To {
154        parent: Parent::Doc,
155        at: match anchor {
156            None => At::Start,
157            Some(id) => At::After(id.to_owned()),
158        },
159    }
160}
161
162/// §7.1: lower a reconciled whole-document update to kernel ops at top-level
163/// granularity.
164#[must_use]
165pub fn lower_top_level(
166    old_doc: &MutDoc,
167    rest: &[Block],
168    assignment: &BTreeMap<String, String>,
169    dispositions: &[Disposition],
170) -> LowerResult {
171    let doc_id = &old_doc.doc_id;
172    let old_ids: HashSet<String> = old_doc.all_ids().into_iter().collect();
173    let target = build_target(rest, assignment, &old_ids, None);
174    let mut target_id_set = HashSet::new();
175    collect_target_ids(&target, &mut target_id_set);
176    let disp_by: HashMap<&str, &Disposition> = dispositions
177        .iter()
178        .map(|d| (d.block_id.as_str(), d))
179        .collect();
180    let old_by_id: HashMap<&str, &MutBlock> = old_doc.iter().map(|b| (b.id.as_str(), b)).collect();
181    let old_top_ids: Vec<String> = old_doc.children.iter().map(|b| b.id.clone()).collect();
182    let old_top_set: HashSet<&str> = old_top_ids.iter().map(String::as_str).collect();
183
184    let mut ops: Vec<PlanOp> = Vec::new();
185    let ambiguous = dispositions
186        .iter()
187        .filter(|d| {
188            matches!(
189                d.detail.get("near_misses"),
190                Some(omgbase_reconcile::DetailValue::List(_))
191            )
192        })
193        .count();
194    let meta = |id: &str| meta_of(disp_by.get(id).copied());
195
196    // 1. Removes.
197    let target_top_ids: HashSet<&str> = target.iter().map(|t| t.id.as_str()).collect();
198    for ob in &old_doc.children {
199        if !target_top_ids.contains(ob.id.as_str()) {
200            let mut expect = BTreeMap::new();
201            expect.insert(ob.id.clone(), Expect::content(raw_hash_hex(&ob.raw)));
202            ops.push(PlanOp {
203                op: Op::Remove {
204                    blocks: vec![ob.id.clone()],
205                    expect: Some(expect),
206                },
207                disposition: PlanDisposition::Deleted,
208                blocks: vec![ob.id.clone()],
209                confidence: None,
210                reason: Some("tombstone".to_owned()),
211                detail: None,
212            });
213        }
214    }
215
216    // 2. Content updates.
217    for t in &target {
218        if !t.carried {
219            continue;
220        }
221        let Some(ob) = old_by_id.get(t.id.as_str()) else {
222            continue;
223        };
224        if raw_hash_hex(&ob.raw) != raw_hash_hex(&t.raw) {
225            let m = meta(&t.id);
226            let child_ids = if t.children.is_empty() {
227                BTreeMap::new()
228            } else {
229                carried_child_id_map(t, &old_ids)
230            };
231            ops.push(PlanOp {
232                op: Op::Update {
233                    block: t.id.clone(),
234                    markdown: Some(t.raw.clone()),
235                    attrs: None,
236                    expect: Some(Expect::content(raw_hash_hex(&ob.raw))),
237                    trivia: None,
238                    child_ids: (!child_ids.is_empty()).then_some(child_ids),
239                },
240                disposition: PlanDisposition::Edited,
241                blocks: vec![t.id.clone()],
242                confidence: m.confidence,
243                reason: m.reason,
244                detail: m.detail,
245            });
246        }
247    }
248
249    // 3. Placement.
250    let kept_top: Vec<String> = target
251        .iter()
252        .filter(|t| t.carried && old_top_set.contains(t.id.as_str()))
253        .map(|t| t.id.clone())
254        .collect();
255    let survivors_in_old_order: Vec<String> = old_top_ids
256        .iter()
257        .filter(|id| target_id_set.contains(*id) && kept_top.contains(id))
258        .cloned()
259        .collect();
260    let stable = lcs(&survivors_in_old_order, &kept_top);
261
262    let mut prev_ref: Option<String> = None;
263    let mut ref_of: Vec<String> = Vec::with_capacity(target.len());
264    let mut placed: Vec<bool> = Vec::with_capacity(target.len());
265    for t in &target {
266        let anchor = top_level_to(prev_ref.as_deref());
267        let was_top = t.carried && old_top_set.contains(t.id.as_str());
268        placed.push(!was_top || !stable.contains(&t.id));
269        if !was_top {
270            let op_index = ops.len();
271            let d = disp_by.get(t.id.as_str()).copied();
272            ops.push(PlanOp {
273                op: Op::Insert {
274                    doc: Some(doc_id.clone()),
275                    to: anchor,
276                    markdown: t.raw.clone(),
277                    expect: None,
278                },
279                disposition: disposition_for_new(d.map(|d| d.kind)),
280                blocks: Vec::new(),
281                confidence: d.and_then(|d| d.confidence),
282                reason: d.and_then(|d| d.reason).map(|r| r.as_str().to_owned()),
283                detail: None,
284            });
285            prev_ref = Some(format!("${op_index}.ids[0]"));
286        } else {
287            if !stable.contains(&t.id) {
288                let m = meta(&t.id);
289                ops.push(PlanOp {
290                    op: Op::Move {
291                        blocks: vec![t.id.clone()],
292                        to: anchor,
293                        doc: None,
294                        expect: None,
295                    },
296                    disposition: PlanDisposition::Moved,
297                    blocks: vec![t.id.clone()],
298                    confidence: m.confidence,
299                    reason: m.reason,
300                    detail: m.detail,
301                });
302            }
303            prev_ref = Some(t.id.clone());
304        }
305        ref_of.push(prev_ref.clone().expect("set above"));
306    }
307
308    // 4. Retile.
309    for (i, t) in target.iter().enumerate() {
310        let ob = if t.carried {
311            old_by_id.get(t.id.as_str()).copied()
312        } else {
313            None
314        };
315        let was_top = ob.is_some() && old_top_set.contains(t.id.as_str());
316        let moved = was_top && !stable.contains(&t.id);
317        let anchors_placement = placed.get(i + 1).copied().unwrap_or(false);
318        let trivia_differs = ob.is_some_and(|o| o.trivia != t.trivia);
319        if !(!was_top || moved || anchors_placement || trivia_differs) {
320            continue;
321        }
322        ops.push(PlanOp {
323            op: Op::Update {
324                block: ref_of[i].clone(),
325                markdown: None,
326                attrs: None,
327                expect: None,
328                trivia: Some(t.trivia.clone()),
329                child_ids: None,
330            },
331            disposition: PlanDisposition::Retiled,
332            blocks: if was_top {
333                vec![t.id.clone()]
334            } else {
335                Vec::new()
336            },
337            confidence: None,
338            reason: None,
339            detail: None,
340        });
341    }
342
343    let preserved = target
344        .iter()
345        .filter(|t| {
346            t.carried
347                && old_by_id
348                    .get(t.id.as_str())
349                    .is_some_and(|ob| raw_hash_hex(&ob.raw) == raw_hash_hex(&t.raw))
350                && stable.contains(&t.id)
351        })
352        .count();
353
354    LowerResult {
355        ops,
356        preserved,
357        ambiguous,
358    }
359}
360
361/// §7 step 4 "replace": strip a leading frontmatter block and a leading blank
362/// line from the proposed content for a body-only insert.
363#[must_use]
364pub fn body_of(content: &str) -> String {
365    let Some(rest) = strip_frontmatter(content) else {
366        return content.to_owned();
367    };
368    // `.replace(/^\s*\n/, "")`: leading whitespace up to and including the
369    // first line break.
370    match rest.find('\n') {
371        Some(nl) if rest[..nl].chars().all(char::is_whitespace) => rest[nl + 1..].to_owned(),
372        _ => rest.to_owned(),
373    }
374}
375
376/// `^---\r?\n[\s\S]*?\r?\n---\r?\n?`: the content after a leading frontmatter
377/// block, or `None` when there is none.
378#[must_use]
379pub fn strip_frontmatter(content: &str) -> Option<&str> {
380    let after_open = content
381        .strip_prefix("---\r\n")
382        .or_else(|| content.strip_prefix("---\n"))?;
383    // The lazy body ends at the first `\r?\n---` followed by `\r?\n` or the end.
384    let mut search_from = 0;
385    loop {
386        let rel = after_open[search_from..].find("\n---")?;
387        let at = search_from + rel;
388        // `\r?` before the `\n`: optional, so the match is fine either way.
389        let after = &after_open[at + 4..];
390        let tail = if let Some(t) = after.strip_prefix("\r\n") {
391            Some(t)
392        } else if let Some(t) = after.strip_prefix('\n') {
393            Some(t)
394        } else if after.is_empty() {
395            Some(after)
396        } else {
397            // A `\r` followed by something other than `\n` is not a match either
398            // (`\r?\n?` would match `\r` alone? No: `\r?\n?` matches the empty
399            // string, so the fence line may be followed by anything).
400            Some(after)
401        };
402        if let Some(t) = tail {
403            return Some(t);
404        }
405        search_from = at + 1;
406    }
407}
408
409/// §7 step 4 "replace": remove every top-level block, insert the whole body.
410#[must_use]
411pub fn lower_replace(old_doc: &MutDoc, content: &str) -> LowerResult {
412    let mut ops: Vec<PlanOp> = old_doc
413        .children
414        .iter()
415        .map(|ob| PlanOp {
416            op: Op::Remove {
417                blocks: vec![ob.id.clone()],
418                expect: None,
419            },
420            disposition: PlanDisposition::Deleted,
421            blocks: vec![ob.id.clone()],
422            confidence: None,
423            reason: Some("tombstone".to_owned()),
424            detail: None,
425        })
426        .collect();
427    ops.push(PlanOp {
428        op: Op::Insert {
429            doc: Some(old_doc.doc_id.clone()),
430            to: To {
431                parent: Parent::Doc,
432                at: At::End,
433            },
434            markdown: body_of(content),
435            expect: None,
436        },
437        disposition: PlanDisposition::BulkRewrite,
438        blocks: Vec::new(),
439        confidence: None,
440        reason: None,
441        detail: None,
442    });
443    LowerResult {
444        ops,
445        preserved: 0,
446        ambiguous: 0,
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use omgbase_format::parse_markdown;
454    use omgbase_reconcile::{
455        Config, FlatSource, Options, SequentialMinter, flatten, reconcile_document,
456    };
457
458    fn ids(v: &[&str]) -> Vec<String> {
459        v.iter().map(|s| (*s).to_owned()).collect()
460    }
461
462    #[test]
463    fn lcs_keeps_the_longest_common_subsequence() {
464        let a = ids(&["a", "b", "c", "d"]);
465        let b = ids(&["b", "a", "c", "d"]);
466        let keep = lcs(&a, &b);
467        assert_eq!(keep.len(), 3);
468        assert!(keep.contains("c") && keep.contains("d"));
469        // Ties prefer advancing the old side: `a` is skipped, `b` kept.
470        assert!(keep.contains("b"));
471        assert!(lcs(&[], &b).is_empty());
472        assert_eq!(lcs(&a, &a).len(), 4);
473    }
474
475    #[test]
476    fn frontmatter_stripping() {
477        assert_eq!(
478            strip_frontmatter("---\na: 1\n---\n\nbody\n"),
479            Some("\nbody\n")
480        );
481        assert_eq!(
482            strip_frontmatter("---\r\na: 1\r\n---\r\nbody"),
483            Some("body")
484        );
485        assert_eq!(strip_frontmatter("---\na: 1\n---"), Some(""));
486        assert_eq!(strip_frontmatter("no fm\n"), None);
487        assert_eq!(body_of("---\na: 1\n---\n\nbody\n"), "body\n");
488        assert_eq!(body_of("---\na: 1\n---\nbody\n"), "body\n");
489        assert_eq!(body_of("plain\n"), "plain\n");
490    }
491
492    /// A doc with `b_0…` in pre-order and the reconcile of new content
493    /// against it (mirrors the store's planner inputs).
494    fn plan_inputs(
495        old: &str,
496        new: &str,
497    ) -> (
498        MutDoc,
499        Vec<Block>,
500        BTreeMap<String, String>,
501        Vec<Disposition>,
502    ) {
503        let mut m = SequentialMinter::new("b");
504        let old_tree = parse_markdown(old);
505        fn to_mut(b: &Block, m: &mut SequentialMinter) -> MutBlock {
506            let id = omgbase_reconcile::Minter::mint(m);
507            let mut mb = MutBlock::new(&id, b.kind.as_str(), &b.raw, &b.trivia);
508            mb.children = b.children.iter().map(|c| to_mut(c, m)).collect();
509            mb
510        }
511        let children: Vec<MutBlock> = old_tree
512            .children
513            .iter()
514            .map(|b| to_mut(b, &mut m))
515            .collect();
516        let doc = MutDoc::new("d_0", "a.md", children);
517        let old_flat = flatten(&FlatSource::from_tree(
518            &old_tree,
519            Some(&mut SequentialMinter::new("b")),
520        ));
521        let new_tree = parse_markdown(new);
522        let new_flat = flatten(&FlatSource::from_tree(&new_tree, None));
523        let mut n = SequentialMinter::new("n");
524        let result = reconcile_document(
525            &old_flat,
526            &new_flat,
527            Options {
528                config: &Config::default(),
529                pool: &[],
530                minter: &mut n,
531            },
532        );
533        (
534            doc,
535            new_tree.children,
536            result.assignment,
537            result.dispositions,
538        )
539    }
540
541    #[test]
542    fn identical_content_lowers_to_nothing() {
543        let (doc, rest, assignment, disps) = plan_inputs("# T\n\nA.\n\nB.\n", "# T\n\nA.\n\nB.\n");
544        let r = lower_top_level(&doc, &rest, &assignment, &disps);
545        assert!(r.ops.is_empty());
546        assert_eq!(r.preserved, 3);
547    }
548
549    #[test]
550    fn insert_in_the_middle_anchors_after_the_previous_and_retiles() {
551        let (doc, rest, assignment, disps) =
552            plan_inputs("# T\n\nA.\n\nB.\n", "# T\n\nA.\n\nMid.\n\nB.\n");
553        let r = lower_top_level(&doc, &rest, &assignment, &disps);
554        let names: Vec<&str> = r.ops.iter().map(|p| p.op.name()).collect();
555        assert_eq!(names, ["insert", "update", "update"]);
556        match &r.ops[0].op {
557            Op::Insert {
558                doc, to, markdown, ..
559            } => {
560                assert_eq!(doc.as_deref(), Some("d_0"));
561                assert_eq!(to.at, At::After("b_1".into()));
562                assert_eq!(markdown, "Mid.");
563            }
564            other => panic!("{other:?}"),
565        }
566        assert_eq!(r.ops[0].disposition, PlanDisposition::Inserted);
567        // A. anchors the insert → retiled; the insert itself is retiled via its placeholder.
568        assert!(
569            matches!(&r.ops[1].op, Op::Update { block, trivia: Some(t), .. } if block == "b_1" && t == "\n\n")
570        );
571        assert!(
572            matches!(&r.ops[2].op, Op::Update { block, trivia: Some(t), .. } if block == "$0.ids[0]" && t == "\n\n")
573        );
574        assert_eq!(r.ops[2].blocks, Vec::<String>::new());
575        assert_eq!(r.preserved, 3);
576    }
577
578    #[test]
579    fn removal_and_edit_and_reorder() {
580        let (doc, rest, assignment, disps) =
581            plan_inputs("# T\n\nA.\n\nB.\n\nC.\n", "# T\n\nA.\n\nC.\n");
582        let r = lower_top_level(&doc, &rest, &assignment, &disps);
583        assert!(
584            matches!(&r.ops[0].op, Op::Remove { blocks, expect: Some(e) } if blocks == &ids(&["b_2"]) && e["b_2"].content_hash.is_some())
585        );
586        assert_eq!(r.ops[0].reason.as_deref(), Some("tombstone"));
587        let long_a = "Alpha is a reasonably long paragraph about the first topic in this note.";
588        let long_b = "Alpha is a reasonably long paragraph about the first subject in this note.";
589        let (doc, rest, assignment, disps) =
590            plan_inputs(&format!("# T\n\n{long_a}\n"), &format!("# T\n\n{long_b}\n"));
591        let r = lower_top_level(&doc, &rest, &assignment, &disps);
592        assert!(
593            matches!(&r.ops[0].op, Op::Update { block, markdown: Some(md), expect: Some(_), child_ids: None, .. } if block == "b_1" && md == long_b)
594        );
595        assert_eq!(r.ops[0].disposition, PlanDisposition::Edited);
596        assert!(r.ops[0].confidence.is_some());
597        let (doc, rest, assignment, disps) = plan_inputs("# T\n\nA.\n\nB.\n", "# T\n\nB.\n\nA.\n");
598        let r = lower_top_level(&doc, &rest, &assignment, &disps);
599        assert!(
600            r.ops
601                .iter()
602                .any(|p| p.disposition == PlanDisposition::Moved)
603        );
604    }
605
606    #[test]
607    fn container_update_threads_carried_child_ids() {
608        let (doc, rest, assignment, disps) = plan_inputs(
609            "# T\n\n- alpha item text here\n- bravo item text here\n- gamma item text here\n",
610            "# T\n\n- alpha item text here\n- bravo item text CHANGED\n- gamma item text here\n",
611        );
612        let r = lower_top_level(&doc, &rest, &assignment, &disps);
613        let upd = r
614            .ops
615            .iter()
616            .find(|p| {
617                p.op.name() == "update"
618                    && matches!(
619                        &p.op,
620                        Op::Update {
621                            markdown: Some(_),
622                            ..
623                        }
624                    )
625            })
626            .unwrap();
627        match &upd.op {
628            Op::Update {
629                block,
630                child_ids: Some(c),
631                ..
632            } => {
633                assert_eq!(block, "b_1");
634                assert_eq!(c.get("/0").map(String::as_str), Some("b_2"));
635                assert_eq!(c.get("/2").map(String::as_str), Some("b_4"));
636            }
637            other => panic!("{other:?}"),
638        }
639    }
640
641    #[test]
642    fn replace_lowering_removes_everything_and_inserts_the_body() {
643        let (doc, ..) = plan_inputs("# T\n\nA.\n", "x");
644        let r = lower_replace(&doc, "---\nk: v\n---\n\n# N\n");
645        assert_eq!(r.ops.len(), 3);
646        assert!(matches!(&r.ops[0].op, Op::Remove { expect: None, .. }));
647        assert!(
648            matches!(&r.ops[2].op, Op::Insert { markdown, to, .. } if markdown == "# N\n" && to.at == At::End)
649        );
650        assert_eq!(r.ops[2].disposition, PlanDisposition::BulkRewrite);
651    }
652}