on master help sign in
Walk the zipper once per navigation, not once per level

`by` is how the language reaches a commit: `commitAt`, `goto`, `at`, `matching` and every revset built on them go through it. It climbed to the top and then refocused every node it visited, and both halves copied more than they needed: - `top_of` asked whether the context was empty by copying the whole list, once per level, so climbing was quadratic in the depth on its own; `up_of` then copied the remaining frames again. The first is a borrow now, and the second shares them. - `by_id` refocused at every node of the search, building a location — a record with the siblings split around a hole — for commits it was only passing through. It now finds the commit first and assembles the location in one pass, so it allocates once per level of depth rather than once per commit. The id comparison no longer copies the id out to make it. Applying `by` once per commit of a 800-commit history: 15.6s -> 1.9s. `conflicts` on the same repository 15.5s -> 2.4s, `tree` 991ms -> 521ms. Two allocations every function application paid, both on the contract path: `check` unfolded the declared type by cloning it — a `Vec` of `String`s for a record type — where a borrow does, and it asked `shape_of` for a record shape that was rebuilt, field set and all, on each call. `unfold` borrows and `Shapes` memoises; the declarations do not change once the config is loaded. On the 401-commit repository, against the start of this branch: tree -47%, treeFull -56%, treeData -52%, conflicts -59%, all -89%, ancestors -82%, status -26%, log -25%. 280 command outputs across ten repositories are unchanged. `by` rebuilding the zipper is the part worth distrusting, so it is pinned from both ends: a property test that the location it returns matches the one the language reaches and still climbs back to the whole history, and a deterministic eight-deep chain asserting the context runs from the immediate parent outwards. Reversing the frame order fails three of them.

commit6f838ec2eb5c3d2ced4265e21b4e6d1095a31cf1 authoriko <ilyakooo0@gmail.com> dateMon 21 Sep 2026, 21:20 (3 weeks ago) parent33cb7e3
4 files changed+231−19
src/repo.rs+71−13 src/shape.rs+39−6 tests/laws.proptest-regressions+1 tests/laws.rs+120
src/repo.rs+71−13
@@ -7,21 +7,72 @@ use crate::value::{value_eq, Crash, Value};
77use std::collections::{BTreeMap, BTreeSet};
88
99/// navigate: refocus `repo` on the commit with change id `id` (§10 reference).
10+///
11+/// Finding the commit and refocusing onto it are separate walks: refocusing
12+/// builds a new location — a record, with the siblings split around a hole —
13+/// so doing it for every node visited on the way cost an allocation per
14+/// commit. Only the commits on the path to the target need one, and there are
15+/// as many of those as the history is deep.
1016pub fn by_id(repo: &Value, id: &str) -> Result<Option<Value>, Crash> {
1117 let top = top_of(repo)?;
12− let mut stack = vec![top];
13− while let Some(loc) = stack.pop() {
14− let root_id = id_of(&loc.field("root")?)?;
15− if root_id == id {
16− return Ok(Some(loc));
18+ if id_is(&top.field("root")?, id)? {
19+ return Ok(Some(top));
20+ }
21+ let mut path = Vec::new();
22+ let children = top.field("children")?;
23+ if !find_path(children.as_list()?, id, &mut path)? {
24+ return Ok(None);
25+ }
26+ // Descend the path collecting one frame per level and assemble the
27+ // location once. Refocusing step by step would rebuild the context at
28+ // every level, which is quadratic in the depth on its own.
29+ let mut root = top.field("root")?;
30+ let mut children = top.field("children")?;
31+ let mut frames: Vec<Value> = Vec::with_capacity(path.len());
32+ for idx in path {
33+ let kids = children.as_list()?;
34+ let child = kids[idx].clone();
35+ frames.push(Value::record(&[
36+ ("left", Value::list(kids[..idx].to_vec())),
37+ ("parent", root),
38+ ("right", Value::list(kids[idx + 1..].to_vec())),
39+ ]));
40+ root = child.field("root")?;
41+ children = child.field("children")?;
42+ }
43+ // innermost frame first: `up` reads context.first() as the parent
44+ frames.reverse();
45+ // `top_of` leaves no context above, but do not depend on that here
46+ frames.extend_from_slice(top.field("context")?.as_list()?);
47+ Ok(Some(Value::record(&[
48+ ("children", children),
49+ ("context", Value::list(frames)),
50+ ("root", root),
51+ ])))
52+}
53+
54+/// indices of the children to descend through to reach `id`, if it is there
55+fn find_path(children: &[Value], id: &str, path: &mut Vec<usize>) -> Result<bool, Crash> {
56+ for (i, c) in children.iter().enumerate() {
57+ path.push(i);
58+ if id_is(&c.field("root")?, id)? {
59+ return Ok(true);
1760 }
18− let children = loc.field("children").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
19− // push children as refocused locations; iterate in order
20− for child in children.iter() {
21− stack.push(refocus_child(&loc, child)?);
61+ let grandchildren = c.field("children")?;
62+ if find_path(grandchildren.as_list()?, id, path)? {
63+ return Ok(true);
2264 }
65+ path.pop();
66+ }
67+ Ok(false)
68+}
69+
70+/// compare a commit's id without copying it out
71+fn id_is(commit: &Value, id: &str) -> Result<bool, Crash> {
72+ match commit.field("id")? {
73+ Value::Id(i) => Ok(i.as_str() == id),
74+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
2375 }
24− Ok(None)
2576}
2677
2778fn id_of(commit: &Value) -> Result<String, Crash> {
@@ -35,8 +86,10 @@ fn id_of(commit: &Value) -> Result<String, Crash> {
3586fn top_of(repo: &Value) -> Result<Value, Crash> {
3687 let mut cur = repo.clone();
3788 loop {
38− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
39− if ctx.is_empty() {
89+ // ask whether the context is empty without copying it: this runs once
90+ // per level, and copying made climbing quadratic in the depth
91+ let ctx = cur.field("context")?;
92+ if ctx.as_list()?.is_empty() {
4093 return Ok(cur);
4194 }
4295 cur = up_of(&cur)?;
@@ -60,9 +113,14 @@ fn up_of(repo: &Value) -> Result<Value, Crash> {
60113 ("children", repo.field("children")?),
61114 ]));
62115 children.extend_from_slice(right);
116+ // the remaining context shares the frames rather than copying them
117+ let rest = match repo.field("context")? {
118+ Value::List(xs) => Value::List(xs.skip(1)),
119+ v => return Err(Crash::new(format!("expected a list, got a {}", v.kind_name()))),
120+ };
63121 Ok(Value::record(&[
64122 ("children", Value::list(children)),
65− ("context", Value::list(ctx[1..].to_vec())),
123+ ("context", rest),
66124 ("root", parent),
67125 ]))
68126}
src/shape.rs+39−6
@@ -8,19 +8,32 @@ use std::collections::{BTreeSet, HashMap};
88pub struct Shapes {
99 /// name -> its declared type
1010 pub decls: HashMap<String, TypeExpr>,
11+ /// Memo for `shape_of`. A contract check against a record type asks for
12+ /// the shape on every call, and computing it builds the field set afresh;
13+ /// the declarations never change once the config is loaded.
14+ memo: std::cell::RefCell<HashMap<String, Option<ShapeVal>>>,
1115}
1216
1317impl Shapes {
1418 pub fn new() -> Shapes {
15− Shapes {
16− decls: HashMap::new(),
17− }
19+ Shapes::default()
1820 }
1921
2022 /// Compute the ShapeVal for a type name used as a value (§4.12).
2123 /// Returns None if the name is undeclared, or its shape is a function,
2224 /// a list, or otherwise not usable.
2325 pub fn shape_of(&self, name: &str) -> Option<ShapeVal> {
26+ if let Some(hit) = self.memo.borrow().get(name) {
27+ return hit.clone();
28+ }
29+ let computed = self.shape_of_uncached(name);
30+ self.memo
31+ .borrow_mut()
32+ .insert(name.to_string(), computed.clone());
33+ computed
34+ }
35+
36+ fn shape_of_uncached(&self, name: &str) -> Option<ShapeVal> {
2437 match name {
2538 "Int" => Some(ShapeVal {
2639 name: name.into(),
@@ -73,6 +86,27 @@ impl Shapes {
7386 }
7487 }
7588
89+ /// Unfold an alias chain by reference. Contracts are checked at every
90+ /// call, so cloning the type each time — a `Vec` of `String`s for a record
91+ /// type — was a per-application allocation.
92+ pub fn unfold<'a>(&'a self, ty: &'a TypeExpr) -> &'a TypeExpr {
93+ let mut cur = ty;
94+ // a declaration cycle would otherwise spin here; the bound is far
95+ // above any real alias chain
96+ for _ in 0..64 {
97+ match cur {
98+ TypeExpr::Con(n) if !is_primitive(n) && !is_type_var(n) => {
99+ match self.decls.get(n) {
100+ Some(decl) => cur = decl,
101+ None => return cur,
102+ }
103+ }
104+ _ => return cur,
105+ }
106+ }
107+ cur
108+ }
109+
76110 /// Unfold an alias chain, owned, so it works with partially-populated
77111 /// decl maps too.
78112 pub fn unfold_owned(&self, ty: &TypeExpr) -> TypeExpr {
@@ -136,8 +170,7 @@ pub fn compile_contract(shapes: &Shapes, ty: &TypeExpr) -> Contract {
136170
137171/// Check one level deep (§4.13). On failure, describe expected and got.
138172pub fn check(shapes: &Shapes, ty: &TypeExpr, v: &Value) -> Result<(), String> {
139− let owned = shapes.unfold_owned(ty);
140− let ty = &owned;
173+ let ty = shapes.unfold(ty);
141174 match ty {
142175 TypeExpr::Con(n) => {
143176 if is_type_var(n) {
@@ -338,7 +371,7 @@ impl ContractExpr {
338371 /// True if the next parameter is definitely a function type.
339372 pub fn next_param_is_function(&self, shapes: &Shapes) -> bool {
340373 match self.next_param() {
341− Some(t) => matches!(shapes.unfold_owned(&t), TypeExpr::Fun(_, _)),
374+ Some(t) => matches!(shapes.unfold(&t), TypeExpr::Fun(_, _)),
342375 None => false,
343376 }
344377 }
tests/laws.proptest-regressions+1
@@ -12,3 +12,4 @@ cc 6d383bd080eefe9dd1dad18f50b5778c689783bff306ad599fe8e09e6e5a36e1 # shrinks to
1212cc c82f268e4e3f722b703eb4582271c0ef82a0c4f815e93742c5671b6eb5036dfd # shrinks to (tree, focus, backend) = (GTree { root: GCommit { id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", message: "p dlxs ", labels: [], files: [(["ugyox", "akvsx"], "\ny")] }, children: [GTree { root: GCommit { id: "loomukln", message: "f khli b", labels: ["dvwv"], files: [] }, children: [] }] }, [], MemBackend { metas: {"loomukln": MetaInfo { hash: "hash-loomukln", author: "A U Thor", email: "a@x", time: 1700000000 }, "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": MetaInfo { hash: "hash-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", author: "A U Thor", email: "a@x", time: 1700000000 }}, parents: {"loomukln": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"], "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": []} })
1313cc f34a9453f280fe95b8e112bb1496878f1298798bef46af2fbea172b533b7f096 # shrinks to (tree, focus, backend) = (GTree { root: GCommit { id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", message: "", labels: [], files: [(["uz"], "\nu\nlz\n\nuvj\n\n")] }, children: [] }, [], MemBackend { metas: {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": MetaInfo { hash: "hash-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", author: "A U Thor", email: "a@x", time: 1700000000 }}, parents: {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": []} })
1414cc 8b00d467e52c70d9a640a9007b734274865179eb569e837cfe264a6a59562337 # shrinks to (tree, focus, backend) = (GTree { root: GCommit { id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", message: "qoe ", labels: ["gtt"], files: [(["hx", "kdh"], "tge\n\n\n\nj\n\n\nli\n\ncc\n\nb"), (["kzn"], "\n\n\n\nl\n\n\n\n\n\n\n\n\n\nd\n")] }, children: [GTree { root: GCommit { id: "uxwrmqto", message: "lv m", labels: ["jovv"], files: [] }, children: [] }] }, [], MemBackend { metas: {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": MetaInfo { hash: "hash-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", author: "A U Thor", email: "a@x", time: 1700000000 }, "uxwrmqto": MetaInfo { hash: "hash-uxwrmqto", author: "A U Thor", email: "a@x", time: 1700000000 }}, parents: {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": [], "uxwrmqto": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]} })
15+cc 9a84983c2b4edd8f833af86eea38f38c4dd830e5e569495ecd49b7ed00b57dc2 # shrinks to (tree, focus, backend) = (GTree { root: GCommit { id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", message: "", labels: [], files: [] }, children: [GTree { root: GCommit { id: "qovwurzp", message: "", labels: [], files: [] }, children: [GTree { root: GCommit { id: "ltovswzk", message: "", labels: [], files: [] }, children: [] }] }] }, [0, 0], MemBackend { metas: {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": MetaInfo { hash: "hash-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", author: "A U Thor", email: "a@x", time: 1700000000 }, "ltovswzk": MetaInfo { hash: "hash-ltovswzk", author: "A U Thor", email: "a@x", time: 1700000000 }, "qovwurzp": MetaInfo { hash: "hash-qovwurzp", author: "A U Thor", email: "a@x", time: 1700000000 }}, parents: {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz": [], "qovwurzp": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"], "ltovswzk": ["qovwurzp"]}, answers_tree_queries: false, conflicts: {}, empties: {} })
tests/laws.rs+120
@@ -498,6 +498,36 @@ proptest! {
498498 }
499499 }
500500
501+ #[test]
502+ fn law_by_rebuilds_the_same_zipper((tree, focus, backend) in arb_repo()) {
503+ // `by` finds the commit first and then assembles the location in one
504+ // pass, instead of refocusing at every node it visits. The location it
505+ // produces must be the one a step-by-step walk produced: same focus,
506+ // same children, and a context that still climbs back to the top.
507+ let repo = repo_value(&tree, &focus);
508+ let stored: BTreeSet<String> = backend.metas.keys().cloned().collect();
509+ let (mut i, cfg) = make_interp(backend);
510+ for id in &stored {
511+ let got = j::repo::by_id(&repo, id).unwrap();
512+ prop_assert!(got.is_some(), "by {} found nothing", id);
513+ let got = got.unwrap();
514+ // the focus is the commit asked for
515+ match got.field("root").unwrap().field("id").unwrap() {
516+ Value::Id(f) => prop_assert_eq!(&*f, id),
517+ _ => prop_assert!(false, "focus has no id"),
518+ }
519+ // `goto` through the language agrees with it
520+ let viaz = eval_fn(&mut i, &cfg, &format!("by @{}", id), repo.clone());
521+ prop_assert!(viaz.is_ok(), "by @{} failed: {:?}", id, viaz.err());
522+ prop_assert!(repos_match(&viaz.unwrap(), &got, &stored));
523+ // climbing all the way up returns the whole history unchanged
524+ let up = eval_fn(&mut i, &cfg, "top", got.clone());
525+ prop_assert!(up.is_ok(), "top failed: {:?}", up.err());
526+ let from_repo = eval_fn(&mut i, &cfg, "top", repo.clone()).unwrap();
527+ prop_assert!(repos_match(&up.unwrap(), &from_repo, &stored));
528+ }
529+ }
530+
501531 #[test]
502532 fn law_top_top((tree, focus, backend) in arb_repo()) {
503533 let repo = repo_value(&tree, &focus);
@@ -522,3 +552,93 @@ proptest! {
522552 prop_assert!(value_eq(&a, &b).unwrap());
523553 }
524554}
555+
556+/// A linear repo root -> c0 -> c1 -> ... focused on the top, built directly.
557+fn linear_repo(n: usize) -> (Value, Vec<String>) {
558+ let ids: Vec<String> = (0..n).map(|k| format!("kaaa{:04}", k)).collect();
559+ let mut subtree = Value::record(&[
560+ ("children", Value::list(vec![])),
561+ (
562+ "root",
563+ Value::record(&[
564+ ("files", Value::list(vec![])),
565+ ("id", Value::Id(Rc::new(ids[n - 1].clone()))),
566+ ("labels", Value::list(vec![])),
567+ ("message", Value::text(format!("commit {}", n - 1))),
568+ ]),
569+ ),
570+ ]);
571+ for k in (0..n - 1).rev() {
572+ subtree = Value::record(&[
573+ ("children", Value::list(vec![subtree])),
574+ (
575+ "root",
576+ Value::record(&[
577+ ("files", Value::list(vec![])),
578+ ("id", Value::Id(Rc::new(ids[k].clone()))),
579+ ("labels", Value::list(vec![])),
580+ ("message", Value::text(format!("commit {}", k))),
581+ ]),
582+ ),
583+ ]);
584+ }
585+ let repo = Value::record(&[
586+ ("children", Value::list(vec![subtree])),
587+ ("context", Value::list(vec![])),
588+ (
589+ "root",
590+ Value::record(&[
591+ ("files", Value::list(vec![])),
592+ ("id", Value::Id(Rc::new(ROOT_ID.to_string()))),
593+ ("labels", Value::list(vec![])),
594+ ("message", Value::text("")),
595+ ]),
596+ ),
597+ ]);
598+ (repo, ids)
599+}
600+
601+#[test]
602+fn by_builds_the_context_innermost_first() {
603+ // `by` assembles the location in one pass now; the context must still run
604+ // from the immediate parent outwards, which is what `up` reads
605+ let (repo, ids) = linear_repo(8);
606+ for (depth, id) in ids.iter().enumerate() {
607+ let loc = j::repo::by_id(&repo, id).unwrap().expect("found");
608+ match loc.field("root").unwrap().field("id").unwrap() {
609+ Value::Id(f) => assert_eq!(&*f, id, "wrong focus"),
610+ _ => panic!("focus has no id"),
611+ }
612+ let ctx = loc.field("context").unwrap();
613+ let ctx = ctx.as_list().unwrap();
614+ assert_eq!(ctx.len(), depth + 1, "context depth for {}", id);
615+ // frame k names the ancestor k+1 levels up; the last is the root
616+ for (k, frame) in ctx.iter().enumerate() {
617+ let want = if depth >= k + 1 {
618+ ids[depth - k - 1].clone()
619+ } else {
620+ ROOT_ID.to_string()
621+ };
622+ match frame.field("parent").unwrap().field("id").unwrap() {
623+ Value::Id(p) => assert_eq!(&*p, &want, "frame {} of {}", k, id),
624+ _ => panic!("frame parent has no id"),
625+ }
626+ }
627+ }
628+}
629+
630+#[test]
631+fn by_then_climbing_back_restores_the_history() {
632+ let (repo, ids) = linear_repo(8);
633+ let (mut i, cfg) = make_interp(MemBackend::new());
634+ let whole = eval_fn(&mut i, &cfg, "top", repo.clone()).unwrap();
635+ for id in &ids {
636+ let loc = j::repo::by_id(&repo, id).unwrap().expect("found");
637+ let back = eval_fn(&mut i, &cfg, "top", loc).unwrap();
638+ assert!(
639+ value_eq(&back, &whole).unwrap_or(false),
640+ "climbing back from {} changed the history",
641+ id
642+ );
643+ }
644+}