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. 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@3db05149
// )
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@e5dbc46
// )
none.status
// res2: Status[String] = Complete("filtered out")

When a Yield.None reaches a Stage.AndThen, 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.

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$5@7605df9f
// )

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)
// res5: Yield[Int, Int, Nothing] = Some(
//   out = 42,
//   status = Success,
//   evolution = repl.MdocSession$MdocApp$DoubleStage$$anon$3@35a02a07
// )

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$6@6fd94e10
// )

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$6@6fd94e10,
//     f = <function1>
//   )
// )

fahrenheit.outOption
// res6: Option[Int] = Some(value = 77)
fahrenheit.status
// res7: 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$6@6fd94e10,
//     f = <function1>
//   )
// )

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