Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The N-H-R algebra factorization

A catamorphism’s algebra collapses one layer of recursive structure. The standard formulation is a single morphism F R → R. Both hylic and Milewski’s monoidal catamorphism factor this morphism into composable steps. They factor it differently. This page establishes the precise relationship between the two and shows when one can be derived from the other.

The two formulations

hylicMilewski
Extractinit: &N → Hs: a → m (scatter)
Combineacc: &mut H, &R⊕: m × m → m (monoid)
Outputfin: &H → R (every node)g: m → b (root only)
Working typeH (unconstrained)m (associative, with identity)
CarrierRm

In hylic, the carrier is R. Every subtree produces R. In Milewski, the carrier is m. Every subtree produces m, and a separate function g converts to the output type b once at the root.

The bracket

At each node, init opens mutable working state H, accumulate folds each child’s R into it, and finalize closes it to R. The heap H never crosses node boundaries. Only R flows between nodes.

bracketcluster_child1child 1cluster_child2child 2cluster_parentparentc1_initinit(N₁) → Hc1_finfin(H) → Rc1_init->c1_fin(leaf)p_acc1acc(&mut H, &R₁)c1_fin->p_acc1R₁c2_initinit(N₂) → Hc2_finfin(H) → Rc2_init->c2_fin(leaf)p_acc2acc(&mut H, &R₂)c2_fin->p_acc2R₂p_initinit(N₀) → Hp_init->p_acc1p_acc1->p_acc2p_finfin(H) → Rp_acc2->p_fin

Green is H-world (mutable working state). Blue is R (immutable result). The green-to-blue transition at each node is the finalize step, the bracket closing.

The node type N seeds the heap but is not part of the algebra. It is the node’s identity; the recursive structure lives in Treeish<N>, not in N. The pair (N, Treeish<N>) is hylic’s runtime equivalent of Milewski’s type-level Fix (f a).

The bracket separates mutable working state from immutable results. H can be a growable Vec while R is a frozen Arc<[T]>, for example. Without the bracket, the user would either accumulate into Arc (expensive reallocation on every push) or return Vec as the result (wrong invariant for the parent, which expects immutable data). The Rust type system reinforces this: &mut H is single-owner and never shared, while R can be Send and cross thread boundaries. The Funnel executor exploits this directly. R values are delivered across threads via slot delivery; H stays on the sweeping thread. For single-child nodes, the bracket is carried as a direct continuation with no allocation and no atomic. Each phase can be wrapped independently via wrap_init, wrap_accumulate, wrap_finalize.

The monoidal form

In Milewski’s decomposition, the working type m is a monoid (associative binary operation with identity ε). A Fold(s, g) pairs a scatter function s: a → m with a gather function g: m → b. An MAlgebra provides the structural combination rule, combining one layer of the functor using only and ε.

The catamorphism cat malg (Fold s g) = g ∘ cata (malg ∘ bimap s id) produces m at every node. g converts to b once at the root.

monoidalcluster_child1mchild 1cluster_child2mchild 2cluster_parentmparentc1m_ss(a₁) → mpm_op1m ⊕ m₁c1m_s->pm_op1m₁c2m_ss(a₂) → mpm_op2m ⊕ m₂c2m_s->pm_op2m₂pm_ss(a₀) → mpm_s->pm_op1pm_op1->pm_op2gg(m) → bpm_op2->gm

Compare the two diagrams. In the bracket form, every node has a green-to-blue transition (per-node finalize). In the monoidal form, green m flows uniformly and the single blue step occurs at the root.

Relationship

Claim. Milewski’s monoidal catamorphism is a special case of hylic’s N-H-R fold.

Proof. Given a Milewski fold with monoid (m, ⊕, ε), scatter s: a → m, and gather g: m → b, construct the hylic fold:

H = R = m,   init = s,   acc = ⊕,   fin = identity

At each node, hylic computes acc(acc(init(n), r₁), r₂) = s(n) ⊕ r₁ ⊕ r₂. This is the value Milewski’s catamorphism produces at every node. The user applies g to the root result to obtain b. ∎

Conditions for the converse. A hylic fold is expressible as a Milewski monoidal catamorphism when:

  • H = R and fin = identity
  • acc is a monoid (associative with identity element)

These make (H, acc, ε) a monoid. The correspondence is then m = H, s = init, ⊕ = acc, g = identity.

Without these conditions, hylic’s fold is strictly more general. It admits non-associative accumulation and distinct working/result types.

Examples

Folds that satisfy the monoid conditions:

  • Sum. H = R = u64, acc = +, fin = id. Addition with identity 0.
  • Extend. H = R = Vec<T>, acc = extend, fin = clone. Concatenation with identity vec![]. The filesystem summary uses this.
  • Union. H = R = HashSet<K>, acc = union, fin = clone. Associative and commutative.

Folds that do not:

  • Child count. acc((s,c), r) = (s+r, c+1). The count tracks immediate children, not descendants. Not associative: (h₁⊕h₂)⊕h₃ yields c+2 while h₁⊕(h₂⊕h₃) yields c+1.
  • Bracketed formatting. fin(h) = format!("[{}]", h). Here H ≠ R and regrouping changes the nesting: [a[b]][c] ≠ [a][b[c]].

Associativity and parallel accumulation

A monoid’s associativity allows the executor to contract adjacent sibling results in any grouping. If children b and c have completed but a has not, b ⊕ c can proceed without waiting for a. When a eventually completes, it combines with the already-contracted result. For n children, this reduces the accumulation depth from O(n) to O(log n).

hylic’s Funnel executor does not perform this contraction. It parallelizes subtree computation (children run concurrently on different workers) and accumulates their results left-to-right as the sweep cursor advances. This is a design choice: sequential accumulation enables progressive memory freeing, where each child’s R is consumed and dropped as the cursor passes. It also means the executor imposes no algebraic requirements on acc. It is up to the user to supply an appropriate accumulate function, and up to the executor to decide how results are folded into H.

A lift can recover O(log n) depth when needed: by transforming the tree structure to insert balanced reduction nodes, the contraction becomes a property of the tree shape rather than the algebra.

The general structure

In algebraic terms, acc: H × R → H is an action of R on H. When H = R and acc is a monoid, this is a monoid acting on itself, which is Milewski’s formulation. In general, it is an R-module: R acts on a distinct type H through acc, with fin: H → R as the projection. A monoid is a module over itself; a module is not necessarily a monoid.

hylic’s API does not distinguish between these cases. The user writes init, acc, fin. The executor runs them with sequential accumulation and parallel subtree computation via CPS work-stealing.

Composability

hylic’s fold combinators (product, map, zipmap, wrap_*) and graph combinators (filter, memoize, contramap) achieve the same practical composability as Milewski’s Functor/Applicative on Fold. Lifts transform both fold and treeish in sync, changing the carrier types through GATs. The SeedPipeline uses a lift internally to bridge coalgebra and algebra when they speak different types.

Bridging coalgebra and algebra: SeedPipeline

A hylomorphism fuses a coalgebra (produce children) with an algebra (fold results). Both operate on the same type N. In practice, the dependency structure often speaks a different type. A module resolver starts with module names (seeds), not parsed modules (nodes). A grow function resolves one into the other.

The user provides:

grow:            Fn(&Seed) → N           resolve a reference
seeds_from_node: N → Seed*              a node's dependency references
fold:            FoldOps<N, H, R>        the algebra, defined over N

In hylic, N → Seed* is Edgy<N, Seed>, the general edge function. N → N* is Treeish<N>, the special case where node and edge types match.

The coalgebra produces Seed. The algebra consumes N. The morphism grow: Seed → N bridges them. SeedPipeline reconciles this through two combinator chains.

Chain 1: coalgebra composition. Close N → Seed* into N → N* via .map(grow):

seeds_from_node: Edgy<N, Seed>             N → Seed*
    .map(grow)                             Seed → N
= treeish:       Edgy<N, N>               N → N*  (= Treeish<N>)

In code: the (grow, seeds_from_node) pair is fused internally at run time via Shared::fuse_grow_with_seeds, producing the Treeish<N> that drives traversal past the entry. The underlying combinator is Edgy::map — see hylic/src/graph/edgy.rs.

Chain 2: entry lifting. The SeedLift constructs a Treeish<SeedNode<N>> with two variants: Node(n) visits the original treeish (wrapping children as Node), and EntryRoot fans out the entry seeds by running grow(seed) on each and wrapping the result as Node.

The relevant struct and its Lift impl:

#![allow(unused)]
fn main() {
/// The finishing lift that closes a `SeedPipeline`'s grow axis.
/// Composes entry-seed dispatch on top of a `(grow, seeds, fold)`
/// triple and produces a treeish over `SeedNode<N>`. Not
/// user-constructed; assembled internally by
/// `Stage2Pipeline::run` at call time.
///
/// Domain-parametric: storage of the entry-seeds graph and the
/// entry-heap thunk is per-domain via `<D as Domain<()>>::Graph<Seed>`
/// and `<D as ShapeCapable<N>>::EntryHeap<H>`. No hand-rolled
/// domain discriminator.
#[must_use]
pub struct SeedLift<D, N, Seed, H>
where D: ShapeCapable<N> + Domain<()>,
      N: 'static, Seed: 'static, H: 'static,
{
    pub(crate) grow:          <D as Domain<N>>::Grow<Seed, N>,
    pub(crate) entry_seeds:   <D as Domain<()>>::Graph<Seed>,
    pub(crate) entry_heap_fn: <D as ShapeCapable<N>>::EntryHeap<H>,
    _m: PhantomData<fn() -> (D, N, Seed, H)>,
}
}
#![allow(unused)]
fn main() {
/// Opaque row type in a seed-closed chain's treeish. Values are
/// either the synthetic `EntryRoot` row (seed fan-out) or a resolved
/// `Node(N)`. User code inspects via [`is_entry_root`](Self::is_entry_root),
/// [`as_node`](Self::as_node), [`into_node`](Self::into_node), and
/// [`map_node`](Self::map_node); the variants are sealed.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SeedNode<N> {
    // Exposed `pub` (not `pub(crate)`) so the doc-hidden
    // `seed_node_internal` module can re-export it for
    // `hylic-pipeline`'s dispatch. User code should treat this field
    // as opaque and use `is_entry_root` / `as_node` / `map_node`.
    #[doc(hidden)]
    pub inner: SeedNodeInner<N>,
}

/// Library-internal variant carrier for `SeedNode<N>`. Exposed
/// `pub` only to make crate-external re-export through the
/// `seed_node_internal` doc-hidden module possible. User code
/// should never name this directly.
#[doc(hidden)]
#[derive(Clone, PartialEq, Eq, Hash)]
pub enum SeedNodeInner<N> {
    EntryRoot,
    Node(N),
}
}

Node(n) delegates to the inner treeish. EntryRoot has no children of its own in the treeish — its children come from the entry seeds provided at run time, grown inline at the EntryRoot visit.

seed_bridgecluster_useruser providescluster_composepipeline constructscluster_liftSeedLift extendscluster_entryentrysfnseeds_from_nodeN → Seed*tTreeish<N>seeds_from_node.map(grow)sfn->t.map(grow)growgrowFn(&Seed) → Ngrow->tfold_ufoldFoldOps<N, H, R>lfFold<SeedNode<N>,     H, R>fold_u->lflift_foldltTreeish<SeedNode<N>>per-variant dispatcht->ltlift_treeishentry_pointEntryRootlt->entry_pointexec.runentry_seedSeed(s)entry_point->entry_seedentry seedsentry_nodeNode(grow(s))entry_seed->entry_nodegrowentry_restoriginal treeish + folddrive all further traversalentry_node->entry_restconverges

After the EntryRoot → Node(grow(s)) transition, the original coalgebra and algebra drive all further recursion. The SeedNode<N> row and the composed treeish are internal to the pipeline.

Entry seeds are supplied at run time via Edgy<(), Seed> passed to pipeline.run(exec, entry_seeds, initial_heap), or via pipeline.run_from_slice(exec, &[seed1, seed2], initial_heap). The pipeline itself stores no entry concerns — only grow, seeds_from_node, and the fold.

Further reading

  • Milewski. Monoidal Catamorphisms (2020).
  • Gonzalez. foldl — the left-fold-with-extraction type.
  • Meijer, Fokkinga, Paterson. Functional Programming with Bananas, Lenses, Envelopes and Barbed Wire (1991).