Evolution

Evolution is the strategy that decides which Stage to use when the pipeline is ready to process the next input.
Every Yield carries an Evolution, and when the pipeline has finished with one input it calls evolve() on that Yield, which in turn calls the Evolution with the current Status.

Three ideas make Evolution work:

import h8io.stages.*
import h8io.stages.base.*

Selecting the Next Stage

evolve(status) returns the next Stage based on the given status.
It is not called by application code directly; Yield.evolve() passes the Yield's own status to it automatically.

A stage that does not change between generations can return itself regardless of the status:

object ParseInt extends Stage[String, Int, String] {
  private val alwaysSelf = new Evolution[String, Int, String] {
    override def evolve(status: Status[?]): Stage[String, Int, String] = ParseInt
    override def dispose(): Unit = ()
  }

  override def apply(in: String): Yield[String, Int, String] =
    in.toIntOption match {
      case Some(n) => Yield.Some(n, Status.Success, alwaysSelf)
      case None    => Yield.None(Status.error(s"not a number: $in"), alwaysSelf)
    }

  override def skip(): Evolution[String, Int, String] = alwaysSelf
}

val okResult  = ParseInt("42")
// okResult: Yield[String, Int, String] = Some(
//   out = 42,
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$ParseInt$$anon$1@153aa039
// )
val errResult = ParseInt("hi")
// errResult: Yield[String, Int, String] = None(
//   status = Complete("not a number: hi"),
//   evolution = repl.MdocSession$MdocApp$ParseInt$$anon$1@153aa039
// )

Calling evolve() on either result returns ParseInt again, since this stage always supplies itself as the continuation:

okResult.evolve()
// res0: Stage[String, Int, String] = <function1>
errResult.evolve()
// res1: Stage[String, Int, String] = <function1>

Disposing Resources

dispose() releases all resources held by the stage that produced this Evolution.
After dispose() is called the producing stage must be considered permanently unusable — it must not be applied or skipped again.

Whoever terminates a pipeline must dispose the evolution of the final Yield, so that resources are released immediately rather than carried forward. The reference terminal driver — the execute extension method from h8io.stages.base — invokes dispose() immediately after the stage runs, since execute is a terminal operation and the continuation will never be needed.

By default at most one of evolve and dispose() is called on any evolution instance: execute only disposes, and a pipeline that continues only evolves, dropping the previous evolution. The two calls are not mutually exclusive, though: a caller that has obtained the continuation via evolve may still call dispose() on the same instance later. Operators that own their inner stage — Loop, Repeat and the rest of the cycles family — do exactly that: they evolve the inner evolution eagerly and keep its dispose as the terminal cleanup handle for the generation just constructed. dispose() must therefore stay valid after evolve and release everything still alive — including resources acquired while constructing the continuation. evolve transfers no ownership: the evolution remains the cleanup point for its lineage until the continuation has run and produced an evolution of its own, which takes over as the terminal handle from that moment on.

Exception handling is not part of the core model. A stage whose apply throws produces no Yield and therefore no Evolution — there is nothing the caller could dispose. Such a stage must release its own resources before letting the exception escape (see the Lifecycle section).

Implementations that hold no external resources may leave dispose() as a no-op.

A stage that acquires a resource at construction time can release it in dispose():

class ResourceStage(name: String) extends Stage[String, String, Nothing] {
  private var open = true
  println(s"[$name] acquired")

  private val evolution = new Evolution[String, String, Nothing] {
    override def evolve(status: Status[?]): Stage[String, String, Nothing] =
      ResourceStage.this

    override def dispose(): Unit = {
      open = false
      println(s"[$name] released")
    }
  }

  override def apply(in: String): Yield[String, String, Nothing] = {
    require(open, s"[$name] already disposed")
    Yield.Some(s"[$name]($in)", Status.Success, evolution)
  }

  override def skip(): Evolution[String, String, Nothing] = evolution
}

execute triggers dispose() automatically after the stage runs:

new ResourceStage("conn").execute("hello")
// [conn] acquired
// [conn] released
// res2: Outcome[String, Nothing] = Some(
//   out = "[conn](hello)",
//   status = Success,
//   disposeFailure = None
// )

When multiple stages are composed into a pipeline, both Evolution methods — evolve and dispose() — are called in the order opposite to the order in which stages are applied: downstream first, then upstream. This ensures that a downstream stage can still access anything the upstream stage provides right up until the moment the upstream stage is torn down:

val pipeline = new ResourceStage("upstream") ~> new ResourceStage("downstream")
// [upstream] acquired
// [downstream] acquired
// pipeline: Stage[String, String, Nothing] = AndThen(
//   upstream = <function1>,
//   downstream = <function1>
// )
pipeline.execute("hello")
// [downstream] released
// [upstream] released
// res3: Outcome[String, Nothing] = Some(
//   out = "[downstream]([upstream](hello))",
//   status = Success,
//   disposeFailure = None
// )

Composing Evolutions

When ~> composes two stages, it also composes their Evolution values using compose. The result is an evolution whose continuation for any status s is the sequential composition of the corresponding continuations of both evolutions:

composed(s) == self(s) ~> that(s)

The composed evolution calls both halves in the same order: pipeline-downstream first, then pipeline-upstream — the reverse of the order in which stages are applied. This applies equally to evolve and dispose(), since both may release or transition resources held by the producing stage.
The Diagram section on finalization walks through a concrete example of why this matters.

compose is an implementation detail of composition and is not part of the typical application-level API; the evolution classes it builds are internal to the core.

Adapting Stages with map

map produces a new Evolution whose continuation is the result of applying a function to the stage the original evolution would have returned.
Disposal is delegated unchanged to the wrapped evolution.

This is how an operator keeps its wrapping in place across generations: a decorator such as Lift, Safe or CompleteIfNone maps the inner evolution with its own constructor, so the continuation comes back wrapped exactly the way the current stage is. The core never calls map itself — it is the tool the lib module builds its decorators on.

This is not what happens when a Yield.None propagates through a composed stage. There the downstream stage cannot be invoked immediately, so it is skipped instead, and the evolution its skip() returns is folded in with compose — so that the whole composed pipeline is applied when the next input arrives.