LetChainRegroup

scalus.uplc.transform.LetChainRegroup
See theLetChainRegroup companion object
class LetChainRegroup(logger: Logger = ..., minRunSize: Int = ...) extends Optimizer

Re-associates a chain of nested lets into multi-argument applications.

A let is [(lam x body) rhs], so a chain of them nests to the right. Bindings that do not depend on each other can be applied as one argument list instead:

// before: three nested lets — 3 Apply + 3 LamAbs = 6 machine steps
[(lam a [(lam b [(lam c body) ec]) eb]) ea]

// after: one three-argument application — same step count on its own...
[[[(lam a (lam b (lam c body))) ea] eb] ec]

// ...but [[CaseConstrApply]] then encodes it as Case + Constr + 3 LamAbs = 5 steps
(case (constr 0 [ea, eb, ec]) [(lam a (lam b (lam c body)))])

A group of N bindings therefore saves N - 2 machine steps per execution of the chain. This pass only re-associates — the case/constr encoding is left to CaseConstrApply, which already fires on any application chain with 3+ arguments and must stay the single place that decision is made (Case/Constr are illegal before Plutus V3).

==Why the threshold is five, not three==

Steps are not the whole fee: the encoding also changes the script's size, and the two pull in opposite directions below N = 7.

In the flat encoding an application chain is pure tags, 4N bits. The case-constr form pays fixed framing first — Case tag 4, Constr tag 4, constructor index 8, field-list framing N+1 (one bit per field plus a terminator), branch-list framing 2 — so 19 + N bits. The difference is therefore

Δbits = (19 + N) - 4N = 19 - 3N

which is '''larger''' below N = 7 and smaller from N = 7 up (verified against the encoder for N = 1..12). On mainnet a script byte costs 15 lovelace of reference-script fee in every transaction using the script, while a machine step costs 6.92 lovelace (100 mem * 0.0577 + 16,000 cpu * 0.0000721) per ''execution''. So the break-even depends on how often the chain runs: for a script executed once per transaction it sits just under N = 4; at N = 3 the grouping loses about 12 lovelace, and only turns positive from roughly three executions per transaction.

MinRunSize is 5 rather than 4 because the theoretical N = 4 margin (+0.72 lovelace) does not survive measurement: flat is bit-packed, so a group's 7 theoretical bits round up to a whole byte in the encoded script. Measured over the ten example validators: a threshold of 4 nets +511 lovelace for +19 bytes and still makes one of them worse, while 5 nets +533 for no extra bytes at all and makes none worse.

All of this is conservative for chains that run more than once per transaction (inside a loop, or a script spending several inputs), where the step saving multiplies but the bytes are paid once.

==Grouping rule==

The chain is split into '''maximal contiguous runs'''. A binding joins the current run when

  • its right-hand side does not mention any binder already in the run, and
  • its name does not repeat a binder already in the run.

Bindings are never reordered. Aiken's split_body_lambda moves bindings between groups to grow them, which changes the order effects happen in; Scalus preserves source evaluation order.

==Why no purity guard is needed==

The CEK machine evaluates [f a] function-first, then argument. So [[[F ea] eb] ec] evaluates F (a lambda — effect-free), then ea, then the beta-reduction yields the next lambda (effect-free), then eb, then ec — exactly the order of the nested form. Under the subsequent case (constr 0 [...]) encoding the fields are also evaluated left to right. A right-hand side that errors, traces or diverges therefore does so at the same point relative to the others in both forms, so this pass applies to effectful bindings (a require(...) lowers to a binding with no uses) as readily as to values.

Moving a right-hand side out of the scope of the earlier binders cannot capture or free a name either: in the input it sits under those binders, so a free occurrence of one of them refers to the let, which is exactly what the dependency test rejects.

That argument covers ordering '''within''' the chain. It does not by itself cover a chain in the function position of an enclosing Apply, where CaseConstrApply would otherwise merge the enclosing arguments into the same constr and hoist them ahead of the chain body; rebuild leaves the outermost run nested in that case, which keeps the two forms identical. See rebuild.

Value parameters

logger

Logger for tracking regrouping operations

minRunSize

smallest run worth grouping; see the threshold discussion above

Attributes

See also

CaseConstrApply for the pass that turns the resulting chains into case/constr

Companion
object
Graph
Supertypes
trait Optimizer
class Object
trait Matchable
class Any

Members list

Value members

Concrete methods

def apply(term: Term): Term

Applies the optimization to a UPLC term.

Applies the optimization to a UPLC term.

Value parameters

term

The UPLC term to optimize

Attributes

Returns

The optimized UPLC term, semantically equivalent to the input

def logs: Seq[String]

Returns the log messages from optimization operations.

Returns the log messages from optimization operations.

Each log entry describes an optimization that was applied, useful for debugging and understanding the optimization process.

Attributes

Returns

Sequence of log messages describing applied optimizations