Yield

Yield is the immediate result returned by a Stage when it processes an input value.
Every Yield bundles three things together:

Yield is not the final answer the pipeline gives to the outside world — that is Outcome in the lib module. Yield is the internal value that flows from one stage to the next, carrying the continuation forward.

import h8io.stages.*

Carrying a Value vs Carrying Nothing

There are exactly two variants.

Yield.Some is produced when the stage has a result to hand downstream. It carries the output value together with the status and the evolution:

val some = Yield.Some(42, Status.Success, new Evolution[Int, Int, Nothing] {
  override def evolve(status: Status[?]): Stage[Int, Int, Nothing] = ???
  override def dispose(): Unit = ()
})
// some: Yield.Some[Int, Int, Nothing] = Some(
//   out = 42,
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$$anon$1@477570f4
// )
some.out
// res0: Int = 42
some.status
// res1: Status[Nothing] = Success

Yield.None is produced when the stage deliberately emits nothing — for example, a filter that drops a particular input. There is no output value, but the status and the evolution are still present so the pipeline can continue correctly:

val none = Yield.None[Int, String, String](Status.error("filtered out"), new Evolution[Int, String, String] {
  override def evolve(status: Status[?]): Stage[Int, String, String] = ???
  override def dispose(): Unit = ()
})
// none: Yield.None[Int, String, String] = None(
//   status = Complete("filtered out"),
//   evolution = repl.MdocSession$MdocApp$$anon$2@27039c8f
// )
none.status
// res2: Status[String] = Complete("filtered out")

When a Yield.None reaches a composed stage, the downstream stage is not applied to the current input.
Instead it is wired into the evolution so that the full composed stage will be called when a value eventually arrives.

Accessing the Output

outOption returns the output wrapped in Some, or None when no value was produced:

some.outOption   // Some(42)
// res3: Option[Int] = Some(value = 42)
none.outOption   // None
// res4: Option[String] = None

Use outOption when you only need to know whether a value was produced. When you also need the status or evolution, pattern-match on the concrete subtype directly instead.

Constructing from an Option

The companion object provides an apply that selects the variant from an optional output: a Some becomes Yield.Some, a None becomes Yield.None. It is convenient when the presence of an output is only known at runtime — for example, when it comes from a lookup:

def lookup(key: Int): Option[String] = if (key > 0) Some(s"value-$key") else None

val lookupEvolution = new Evolution[Int, String, Nothing] {
  override def evolve(status: Status[?]): Stage[Int, String, Nothing] = ???
  override def dispose(): Unit = ()
}
// lookupEvolution: AnyRef with Evolution[Int, String, Nothing] = repl.MdocSession$MdocApp$$anon$3@5f2140bd

Yield(lookup(1), Status.Success, lookupEvolution)
// res5: Yield[Int, String, Nothing] = Some(
//   out = "value-1",
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$$anon$3@5f2140bd
// )
Yield(lookup(-1), Status.Success, lookupEvolution)
// res6: Yield[Int, String, Nothing] = None(
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$$anon$3@5f2140bd
// )

Matching Without Naming the Variant

The companion's unapply makes Yield itself usable as an extractor: a single case binds the optional output, the status and the evolution, whichever variant the value is. It is the inverse of apply; because it returns scala.Some, the compiler knows the extractor always matches, and a single case Yield(...) is exhaustive:

def render(yld: Yield[Int, String, Nothing]): String = yld match {
  case Yield(out, status, _) => s"out: $out, status: $status"
}

render(Yield(lookup(1), Status.Success, lookupEvolution))
// res7: String = "out: Some(value-1), status: Success"
render(Yield(lookup(-1), Status.Success, lookupEvolution))
// res8: String = "out: None, status: Success"

Prefer matching on Yield.Some / Yield.None when the two variants are handled differently — the generic extractor is for the cases where they are treated uniformly.

Evolving the Pipeline

Once the pipeline has processed an input and is ready for the next one, it calls evolve() on the Yield returned by the last stage.
evolve() calls evolution.evolve(status) — the Evolution receives the current status and returns the next stage.

object DoubleStage extends Stage[Int, Int, Nothing] {
  private def stub: Evolution[Int, Int, Nothing] = new Evolution[Int, Int, Nothing] {
    override def evolve(status: Status[?]): Stage[Int, Int, Nothing] = ???
    override def dispose(): Unit = ()
  }

  override def apply(in: Int): Yield[Int, Int, Nothing] =
    Yield.Some(in * 2, Status.Success, stub)

  override def skip(): Evolution[Int, Int, Nothing] = stub
}

object ErrorRecovery extends Stage[Int, Int, Nothing] {
  private def stub: Evolution[Int, Int, Nothing] = new Evolution[Int, Int, Nothing] {
    override def evolve(status: Status[?]): Stage[Int, Int, Nothing] = ???
    override def dispose(): Unit = ()
  }

  override def apply(in: Int): Yield[Int, Int, Nothing] =
    Yield.Some(0, Status.complete, stub)

  override def skip(): Evolution[Int, Int, Nothing] = stub
}

val yldSuccess = Yield.Some(
  21,
  Status.Success,
  new Evolution[Int, Int, Nothing] {
    override def evolve(status: Status[?]): Stage[Int, Int, Nothing] = status match {
      case Status.Success => DoubleStage
      case _              => ErrorRecovery
    }
    override def dispose(): Unit = ()
  })
// yldSuccess: Yield.Some[Int, Int, Nothing] = Some(
//   out = 21,
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$$anon$6@661607d0
// )

val nextStage = yldSuccess.evolve()
// nextStage: Stage[Int, Int, Nothing] = <function1>

Because the status is Success, the evolution returns DoubleStage.
Applying it to a new input produces the expected result:

nextStage(21)
// res9: Yield[Int, Int, Nothing] = Some(
//   out = 42,
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$DoubleStage$$anon$4@46825169
// )

Transforming a Yield

map transforms all three components of a Yield in one step. Each component gets its own mapping function. This is mainly used inside pipeline combinators rather than in application code, but it is part of the public API.

Imagine a stage that reads a temperature sensor and yields the value in Celsius. A downstream combinator needs Fahrenheit: it converts the value, marks each successful reading as complete, and wraps the evolution with Evolution.map so that future stages also go through the same conversion:

object ThermStage extends Stage[Int, Int, Nothing] {
  private val evo: Evolution[Int, Int, Nothing] = new Evolution[Int, Int, Nothing] {
    override def evolve(status: Status[?]): Stage[Int, Int, Nothing] = ThermStage
    override def dispose(): Unit = ()
  }
  override def apply(in: Int): Yield[Int, Int, Nothing] = Yield.Some(25, Status.Success, evo)
  override def skip(): Evolution[Int, Int, Nothing] = evo
}

object ToFahrenheit extends Stage[Int, Int, Nothing] {
  private val evo: Evolution[Int, Int, Nothing] = new Evolution[Int, Int, Nothing] {
    override def evolve(status: Status[?]): Stage[Int, Int, Nothing] = ToFahrenheit
    override def dispose(): Unit = ()
  }
  override def apply(in: Int): Yield[Int, Int, Nothing] = Yield.Some(in * 9 / 5 + 32, Status.Success, evo)
  override def skip(): Evolution[Int, Int, Nothing] = evo
}

val celsius = ThermStage(0)
// celsius: Yield[Int, Int, Nothing] = Some(
//   out = 25,
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$ThermStage$$anon$7@59dd5e9d
// )

val fahrenheit = celsius.map(
  mapOut    = c => c * 9 / 5 + 32,
  mapStatus = _ => Status.complete,
  mapEvolution = evo => evo.map(_ ~> ToFahrenheit))
// fahrenheit: Yield[Int, Int, Nothing] = Some(
//   out = 77,
//   status = Complete(),
//   evolution = Mapped(
//     evolution = repl.MdocSession$MdocApp$ThermStage$$anon$7@59dd5e9d,
//     f = <function1>
//   )
// )

fahrenheit.outOption
// res10: Option[Int] = Some(value = 77)
fahrenheit.status
// res11: Status[Nothing] = Complete()

For Yield.None, map applies mapStatus and mapEvolution as usual — mapOut is accepted for type-consistency but is never invoked because there is no value to transform. Here the sensor went offline before any reading was taken:

val noReading = Yield.None[Int, Int, String](Status.error("sensor offline"), ThermStage.skip())
  .map(
    mapOut    = c => c * 9 / 5 + 32,   // not called — no value
    mapStatus = _ => Status.error("no data"),
    mapEvolution = evo => evo.map(_ ~> ToFahrenheit))
// noReading: Yield[Int, Int, String] = None(
//   status = Complete("no data"),
//   evolution = Mapped(
//     evolution = repl.MdocSession$MdocApp$ThermStage$$anon$7@59dd5e9d,
//     f = <function1>
//   )
// )

noReading.outOption
// res12: Option[Int] = None
noReading.status
// res13: Status[String] = Complete("no data")