pacta/protocol/weave

Weaving two protocols into one, guided by their contact points.

Sequencing puts one protocol after another, which is type application and needs nothing from this module. Weaving is the other thing: two protocols that have to advance in step, each constraining the other in both directions. A banking service and the authentication it depends on are the standard example. The login has to precede the menu, and each payment needs its own second factor, so neither protocol can simply be appended to the other.

This is an attempt to implement some ideas from Bocchi, Orchard and Voinea’s A Theory of Composing Protocols (2023) Their prefix actions are a parameter of the theory, so instantiating them with spec.Message is within what the paper covers, and the output is one ordinary spec.Spec that projects and checks like any other.

Composition is a relation, not a function

Two protocols may have no valid interleaving, one, or many. That is not a deficiency to be smoothed over by picking a winner: which weaving is wanted is a question about the domain, and nothing here can answer it. So compose returns candidates, ranked, and the way to narrow them is to add contact points rather than to guess.

The paper’s own figures are worth knowing before running this on something large. Their suite mostly yields one or two candidates, but an example with nested recursion reaches fourteen. Options.limit bounds the search, and Composition.truncated says when it bit.

Contact points

Assert introduces a guarantee, Require demands one and leaves it, and Consume demands one and spends it. They are what turn an intractable set of interleavings into a useful one.

// Payment first, then dispatch. Not the other way round.
let paying = spec.Message("Buyer", "Shop", "pay", "Money", spec.Assert("paid", spec.End))
let sending = spec.Consume("paid", spec.Message("Shop", "Buyer", "item", "Link", spec.End))

let assert weave.Composition(candidates: [only], ..) =
  weave.compose(paying, sending, weave.defaults())

Without the annotations there are two interleavings and no reason to prefer either. With them there is exactly one, and it is the right one.

Branching

How a choice composes is the one knob worth understanding, because the strict reading returns nothing surprisingly often.

Each relaxation widens the result set rather than narrowing it, so a candidate derivable strongly is still derivable under All. Every candidate records which relaxations its derivation needed, and that is what the ranking is built on: candidates needing none come first.

What this does not do

Composition drops the cosmetic state names spec.At carries, because a woven protocol’s states do not correspond to either input’s.

Nothing here checks that the result is projectable. Weaving can easily produce a protocol some participant cannot follow, and graph.compile is what says so. Run it on the candidate you pick.

Types

How much freedom the search has when it meets a choice.

Ordered from strictest to loosest. Each admits everything the ones before it do, so widening never loses a candidate.

pub type Branching {
  Strong
  Weak
  Correlating
  All
}

Constructors

  • Strong

    Compose the other protocol into every arm. The reading with no relaxations, and the one that most often returns nothing.

  • Weak

    Allow an arm that cannot compose to stand alone, as long as at least one arm does compose. This is the authentication case.

  • Correlating

    Pair each arm with the arms of the other protocol it can compose with, instead of distributing into all of them.

  • All

    Both relaxations.

One way of weaving the two protocols together.

pub type Candidate {
  Candidate(protocol: spec.Spec, relaxations: List(Relaxation))
}

Constructors

Every weaving the search found, best first.

pub type Composition {
  Composition(candidates: List(Candidate), truncated: Bool)
}

Constructors

  • Composition(candidates: List(Candidate), truncated: Bool)

What to allow, and how hard to look.

pub type Options {
  Options(branching: Branching, given: List(String), limit: Int)
}

Constructors

  • Options(branching: Branching, given: List(String), limit: Int)

    Arguments

    given

    Guarantees the surrounding context already provides. Usually empty: starting with something in hand means the protocols are not required to establish it between them.

    limit

    Ceiling on candidates carried through any one step of the search. Reached only by protocols with nested recursion in practice.

A liberty a derivation had to take.

Empty means the candidate is derivable under Strong, which is the strongest thing that can be said about a weaving.

pub type Relaxation {
  WeakBranch(label: String)
  CorrelatedBranch(left: String, right: String)
}

Constructors

  • WeakBranch(label: String)

    This arm could not compose, and was left as it was.

  • CorrelatedBranch(left: String, right: String)

    These two arms were paired off rather than distributed into each other.

Why a composition cannot be selected without guessing.

Example

let error = weave.UnexpectedCandidateCount(2)
pub type SelectionError {
  SearchTruncated
  UnexpectedCandidateCount(found: Int)
}

Constructors

  • SearchTruncated

    The candidate limit was reached, so the returned set is incomplete.

  • UnexpectedCandidateCount(found: Int)

    A complete search found something other than one candidate.

Values

pub fn compose(
  left: spec.Spec,
  right: spec.Spec,
  options: Options,
) -> Composition

Weave two protocols together, returning every valid interleaving.

Example

let options = weave.Options(..weave.defaults(), branching: weave.Weak)
let weave.Composition(candidates:, truncated: _) =
  weave.compose(banking, authentication, options)

case candidates {
  [] -> io.println("no valid interleaving; try adding contact points")
  [best, ..] -> chosen(best.protocol)
}
pub fn defaults() -> Options

Strong branching, nothing granted, a limit that is generous for anything hand-written.

pub fn describe(relaxation: Relaxation) -> String

Render a relaxation as a line suitable for a terminal.

pub fn interleave(
  left: spec.Protocol,
  right: spec.Protocol,
  options: Options,
) -> List(spec.Protocol)

Weave two whole protocols, keeping the participants and imports of both.

A convenience over compose for the common case. The candidates come back ranked, and each is an ordinary protocol, so graph.compile is the next call. It has to be: weaving can produce a protocol that no longer projects.

Example

let assert [best, ..] = weave.interleave(banking, authentication, options)
let assert Ok(graphs) = graph.compile(best)
pub fn select_unique(
  composition: Composition,
) -> Result(Candidate, SelectionError)

Select the sole candidate from a complete composition search.

This is the acceptance check for code generators. It never chooses among alternatives, and a truncated search is rejected even if it currently contains one candidate.

Example

case weave.select_unique(composition) {
  Ok(candidate) -> generate(candidate.protocol)
  Error(weave.SearchTruncated) -> increase_the_search_limit()
  Error(weave.UnexpectedCandidateCount(_)) -> refine_the_contact_points()
}
pub fn summarise(candidate: Candidate) -> String

Render a candidate’s relaxations as a line suitable for a terminal.

Search Document