Skip to content

feat(schedule): add Schedule GADT — pure declarative recurring policy data type#1349

Open
987Nabil wants to merge 1 commit into
zio:mainfrom
987Nabil:feat/schedule
Open

feat(schedule): add Schedule GADT — pure declarative recurring policy data type#1349
987Nabil wants to merge 1 commit into
zio:mainfrom
987Nabil:feat/schedule

Conversation

@987Nabil

Copy link
Copy Markdown
Contributor

Summary

Adds a new schedule/ module containing a pure, declarative Schedule[-In, +Out] GADT — a composable description of recurring policies (retry, repeat, backoff) with no execution logic.

This is the data type only — no interpreter, no Driver, no integration with scope or streams. Future work will add execution support.

What's included

  • Schedule[-In, +Out] sealed trait with 14 private[schedule] GADT case classes
  • Decision ADT: Continue(delay: Duration) | Done
  • 8 factory methods: identity, succeed, unfold, forever, recurs, spaced, exponential, fibonacci
  • ~20 combinator methods (named-first per symbolic ops policy): map, contramap, both (&&), either (||), compose, andThen, addDelay, jittered, whileInput, whileOutput, reconsider, as, unit, zip
  • 51 tests covering AST construction, combinator composition, symbolic aliases, variance soundness, and edge cases
  • Cross-platform (JVM + JS), zero runtime dependencies

Design decisions

  • GADT over final-tagless: Inspectable, pattern-matchable, matches zio-blocks' Stream pattern. ZIO's final-tagless extensibility was never exercised.
  • -In kept, -Env dropped: No effects, but -In enables error-aware retry semantics at the type level.
  • Duration over Intervals: Simpler timing model, sufficient for the data type, upgradeable pre-1.0.
  • Jittered stores bounds only: Interpreter provides RNG — data type just declares intent.
  • Named-first: both/either are primary, &&/|| are Tier 1 aliases. No >>> or other banned operators.

Derivation chain

All derived schedules compose primitives:

forever    = unfold(0L)(_ + 1L)
recurs(n)  = forever.whileOutput(_ < n)
spaced(d)  = forever.addDelay(_ => d)
exponential(base, f) = forever.map(i => base * f^i).addDelay(d => d)
fibonacci(one) = unfold((one, one)){fib}.map(_._1).addDelay(d => d)

Verification

  • scheduleJVM/test — 51 tests pass (Scala 3.8.3)
  • scheduleJVM/test — 51 tests pass (Scala 2.13.18)
  • scheduleJS/test — 51 tests pass (Scala 3.8.3)
  • ✅ Formatting clean (fmtDirty produces no diff)
  • ✅ No banned operators (grep -r ">>>" — clean)
  • ✅ Existing modules unaffected (chunkJVM/compile passes)

Copilot AI review requested due to automatic review settings April 27, 2026 12:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new schedule cross-module providing a pure, declarative Schedule[-In, +Out] AST (plus Decision) for describing recurring/retry-style policies, along with a comprehensive test suite and build integration.

Changes:

  • Added Schedule + Decision core data types with factory methods and combinators in a new schedule/ module.
  • Added ScheduleSpec with extensive tests for AST construction, combinators, aliases, and variance behavior.
  • Wired the new scheduleJVM / scheduleJS projects into the build (aggregate + test/doc command aliases).

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 2 comments.

File Description
schedule/shared/src/main/scala/zio/blocks/schedule/Schedule.scala Adds the new public Schedule/Decision API and internal AST node types.
schedule/shared/src/test/scala/zio/blocks/schedule/ScheduleSpec.scala Adds coverage for factories/combinators/symbolic aliases/variance/edge cases.
build.sbt Adds the schedule crossProject and includes it in root aggregation + command aliases.

Comment on lines +21 to +24
sealed trait Decision
object Decision {
final case class Continue(delay: Duration) extends Decision
case object Done extends Decision

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision is introduced as a new public ADT but has no Scaladoc. This repo generally documents public APIs (including params/return semantics) with Scaladoc; please add a brief Scaladoc to Decision, Continue, and Done describing the meaning of delay and how interpreters should treat it.

Suggested change
sealed trait Decision
object Decision {
final case class Continue(delay: Duration) extends Decision
case object Done extends Decision
/** A scheduling decision indicating whether evaluation should continue or terminate.
*
* Interpreters should inspect the concrete decision to determine whether to keep running the
* schedule and, if so, whether to wait before the next step.
*/
sealed trait Decision
object Decision {
/** A decision to continue the schedule after the specified delay.
*
* @param delay
* the amount of time an interpreter should wait before continuing with the next step of the
* schedule
*/
final case class Continue(delay: Duration) extends Decision
/** A decision indicating that the schedule has completed.
*
* Interpreters should stop evaluating the schedule and should not apply any further delay.
*/
case object Done extends Decision

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +51
sealed trait Schedule[-In, +Out] { self =>

// Transformation
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)
def contramap[In2](f: In2 => In): Schedule[In2, Out] = Schedule.Contramap(self, f)
def as[Out2](out2: Out2): Schedule[In, Out2] = map(_ => out2)
def unit: Schedule[In, Unit] = as(())

// Composition
def both[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Both(self, that)
def &&[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
def either[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Either(self, that)
def ||[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = either(that)
def compose[In2](that: Schedule[In2, In]): Schedule[In2, Out] = Schedule.Compose(self, that)
def andThen[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, Out2] = Schedule.AndThen(self, that)
def zip[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)

// Delay
def addDelay(f: Out => Duration): Schedule[In, Out] = Schedule.Delayed(self, f)
def jittered: Schedule[In, Out] = jittered(0.0, 1.0)
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)

// Filtering
def whileInput[In1 <: In](f: In1 => Boolean): Schedule[In1, Out] = Schedule.WhileInput(self, f)
def whileOutput(f: Out => Boolean): Schedule[In, Out] = Schedule.WhileOutput(self, f)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schedule is a new public API (sealed trait + public combinators / factory methods) but currently has no Scaladoc explaining the model (e.g., that it’s an AST only, what In/Out mean, and the semantics of nodes like both/either/andThen). Please add Scaladoc on the trait and key combinators so users can understand how an interpreter is expected to behave.

Suggested change
sealed trait Schedule[-In, +Out] { self =>
// Transformation
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)
def contramap[In2](f: In2 => In): Schedule[In2, Out] = Schedule.Contramap(self, f)
def as[Out2](out2: Out2): Schedule[In, Out2] = map(_ => out2)
def unit: Schedule[In, Unit] = as(())
// Composition
def both[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Both(self, that)
def &&[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
def either[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Either(self, that)
def ||[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = either(that)
def compose[In2](that: Schedule[In2, In]): Schedule[In2, Out] = Schedule.Compose(self, that)
def andThen[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, Out2] = Schedule.AndThen(self, that)
def zip[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
// Delay
def addDelay(f: Out => Duration): Schedule[In, Out] = Schedule.Delayed(self, f)
def jittered: Schedule[In, Out] = jittered(0.0, 1.0)
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)
// Filtering
def whileInput[In1 <: In](f: In1 => Boolean): Schedule[In1, Out] = Schedule.WhileInput(self, f)
def whileOutput(f: Out => Boolean): Schedule[In, Out] = Schedule.WhileOutput(self, f)
/** A declarative schedule description.
*
* `Schedule` is an AST only: values of this type describe scheduling behavior, but they do not execute it.
* An interpreter is expected to walk the nodes of the schedule and, for each step, produce an `Out` value
* together with a [[Decision]] indicating whether execution should continue and, if so, after what delay.
*
* The `In` type parameter is the input consumed by the schedule at each step. The `Out` type parameter is
* the value emitted by the schedule after interpreting a step.
*
* Combinators on this trait construct larger ASTs from smaller ones. For example:
*
* - `both` requires both schedules to continue and produces both outputs
* - `either` continues while either schedule continues and produces both outputs
* - `andThen` runs this schedule until it completes, then switches to the provided schedule
* - `compose` feeds the output of one schedule into the input of another
*
* @tparam In the type of input consumed by the schedule
* @tparam Out the type of output produced by the schedule
*
* @example
* {{{
* val attempts: Schedule[Any, Int] =
* Schedule.Identity[Int]().contramap[Any](_ => 1).andThen(Schedule.Succeed(0))
*
* val paired = attempts.both(attempts.unit)
* }}}
*
* @note Because `Schedule` is an AST, the exact operational behavior is determined by the interpreter that
* evaluates these nodes.
*/
sealed trait Schedule[-In, +Out] { self =>
// Transformation
/** Transforms the output produced by this schedule.
*
* @param f the function used to transform the schedule output
* @tparam Out2 the transformed output type
* @return a new schedule that emits the transformed output
*/
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)
/** Transforms the input consumed by this schedule.
*
* @param f the function used to convert inputs for this schedule
* @tparam In2 the new input type
* @return a new schedule that accepts `In2` and delegates to this schedule
*/
def contramap[In2](f: In2 => In): Schedule[In2, Out] = Schedule.Contramap(self, f)
/** Replaces the output of this schedule with a constant value.
*
* @param out2 the constant output value
* @tparam Out2 the new output type
* @return a new schedule that emits `out2` whenever this schedule produces an output
*/
def as[Out2](out2: Out2): Schedule[In, Out2] = map(_ => out2)
/** Discards the output of this schedule.
*
* @return a new schedule that emits `Unit`
*/
def unit: Schedule[In, Unit] = as(())
// Composition
/** Combines this schedule with another schedule that runs in parallel.
*
* An interpreter is expected to continue only while both schedules continue. The resulting output contains
* the outputs of both schedules as a pair.
*
* @param that the schedule to run alongside this schedule
* @tparam In1 the common input type accepted by both schedules
* @tparam Out2 the output type of the other schedule
* @return a schedule that emits both outputs
*/
def both[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Both(self, that)
/** Alias for [[both]].
*
* @param that the schedule to run alongside this schedule
* @tparam In1 the common input type accepted by both schedules
* @tparam Out2 the output type of the other schedule
* @return a schedule that emits both outputs
*/
def &&[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
/** Combines this schedule with another schedule using alternative continuation.
*
* An interpreter is expected to continue while either schedule continues. The resulting output contains
* the outputs of both schedules as a pair.
*
* @param that the alternative schedule
* @tparam In1 the common input type accepted by both schedules
* @tparam Out2 the output type of the other schedule
* @return a schedule that emits both outputs
*/
def either[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Either(self, that)
/** Alias for [[either]].
*
* @param that the alternative schedule
* @tparam In1 the common input type accepted by both schedules
* @tparam Out2 the output type of the other schedule
* @return a schedule that emits both outputs
*/
def ||[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = either(that)
/** Composes this schedule with another schedule that produces this schedule's input.
*
* An interpreter is expected to run `that` first and feed its output into this schedule as input.
*
* @param that the schedule whose output becomes the input of this schedule
* @tparam In2 the input type of the composed schedule
* @return a composed schedule
*/
def compose[In2](that: Schedule[In2, In]): Schedule[In2, Out] = Schedule.Compose(self, that)
/** Sequences this schedule with another schedule.
*
* An interpreter is expected to run this schedule until it is done, then continue with `that`.
* The output of the resulting schedule is the output of `that`.
*
* @param that the schedule to run after this schedule completes
* @tparam In1 the input type accepted by both schedules
* @tparam Out2 the output type of the second schedule
* @return a schedule that runs this schedule and then `that`
*/
def andThen[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, Out2] = Schedule.AndThen(self, that)
/** Alias for [[both]].
*
* @param that the schedule to run alongside this schedule
* @tparam In1 the common input type accepted by both schedules
* @tparam Out2 the output type of the other schedule
* @return a schedule that emits both outputs as a pair
*/
def zip[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
// Delay
/** Adds an additional delay derived from the schedule output.
*
* @param f a function that computes an extra delay from the output
* @return a new schedule with additional delay information
*/
def addDelay(f: Out => Duration): Schedule[In, Out] = Schedule.Delayed(self, f)
/** Applies full-range jitter to this schedule's delays.
*
* Equivalent to `jittered(0.0, 1.0)`.
*
* @return a new schedule with jittered delays
*/
def jittered: Schedule[In, Out] = jittered(0.0, 1.0)
/** Applies jitter to this schedule's delays.
*
* @param min the lower bound of the jitter factor
* @param max the upper bound of the jitter factor
* @return a new schedule with jittered delays
*/
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)
// Filtering
/** Continues only while the input predicate holds.
*
* @param f the predicate evaluated against each input
* @tparam In1 the refined input type
* @return a new schedule that stops when the predicate fails
*/
def whileInput[In1 <: In](f: In1 => Boolean): Schedule[In1, Out] = Schedule.WhileInput(self, f)
/** Continues only while the output predicate holds.
*
* @param f the predicate evaluated against each output
* @return a new schedule that stops when the predicate fails
*/
def whileOutput(f: Out => Boolean): Schedule[In, Out] = Schedule.WhileOutput(self, f)
/** Recomputes the emitted output and continuation decision after each step.
*
* This is an interpreter-facing escape hatch that allows callers to inspect the current input, the output
* produced by this schedule, and the current [[Decision]], and replace both the output and decision.
*
* @param f the function used to revise the output and decision
* @tparam In1 the refined input type
* @tparam Out2 the revised output type
* @return a new schedule with reconsidered output and decision
*/

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 7 comments.

Comment on lines +93 to +94
/** Applies jitter with custom bounds to delays. */
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jittered(min, max) stores bounds without validation. If callers pass min > max, negative numbers, or NaN/Infinity, interpreters will have to handle an ill-formed schedule. Consider validating invariants (at least min <= max and both finite), or document the accepted range and expected interpreter behavior for invalid bounds.

Suggested change
/** Applies jitter with custom bounds to delays. */
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)
/** Applies jitter with custom bounds to delays.
*
* @param min
* the inclusive lower bound for the jitter factor; must be finite and non-negative
* @param max
* the inclusive upper bound for the jitter factor; must be finite, non-negative, and greater than or equal to `min`
* @return
* a schedule that applies jitter within the provided bounds
* @throws IllegalArgumentException
* if either bound is not finite, if either bound is negative, or if `min` is greater than `max`
*/
def jittered(min: Double, max: Double): Schedule[In, Out] = {
require(min.isFinite, s"jittered min must be finite, but was $min")
require(max.isFinite, s"jittered max must be finite, but was $max")
require(min >= 0.0, s"jittered min must be non-negative, but was $min")
require(max >= 0.0, s"jittered max must be non-negative, but was $max")
require(min <= max, s"jittered min must be less than or equal to max, but min=$min and max=$max")
Schedule.Jittered(self, min, max)
}

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +34
* the amount of time an interpreter should wait before the next step
*/
final case class Continue(delay: Duration) extends Decision

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision.Continue(delay) allows negative/undefined delays to be constructed, which will likely cause issues for interpreters (e.g., sleeping a negative duration). Consider validating delay (non-negative and not Duration.Undefined) or documenting that interpreters must sanitize it.

Suggested change
* the amount of time an interpreter should wait before the next step
*/
final case class Continue(delay: Duration) extends Decision
* the amount of time an interpreter should wait before the next step;
* must be defined and non-negative
* @throws IllegalArgumentException
* if `delay` is `Duration.Undefined` or negative
*/
final case class Continue(delay: Duration) extends Decision {
require(delay != Duration.Undefined, "Decision.Continue delay must be defined")
require(delay >= Duration.Zero, "Decision.Continue delay must be non-negative")
}

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +171
/** Fibonacci backoff: `one`, `one`, `2*one`, `3*one`, `5*one`, etc. */
def fibonacci(one: Duration): Schedule[Any, Duration] =
unfold[(Duration, Duration)]((one, one)) { case (a, b) => (b, a + b) }.map(_._1).addDelay(d => d)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fibonacci(one) will embed one directly into the unfolding state and subsequent additions; if one is negative or undefined, the stored step/delay functions will produce invalid delays. Consider validating one (non-negative and not Duration.Undefined) or documenting the allowed domain.

Suggested change
/** Fibonacci backoff: `one`, `one`, `2*one`, `3*one`, `5*one`, etc. */
def fibonacci(one: Duration): Schedule[Any, Duration] =
unfold[(Duration, Duration)]((one, one)) { case (a, b) => (b, a + b) }.map(_._1).addDelay(d => d)
/**
* Fibonacci backoff: `one`, `one`, `2*one`, `3*one`, `5*one`, etc.
*
* @param one
* the initial delay for the sequence; must be defined and non-negative
* @return
* a schedule that emits Fibonacci-scaled delays derived from `one`
* @throws IllegalArgumentException
* if `one` is `Duration.Undefined` or negative
*/
def fibonacci(one: Duration): Schedule[Any, Duration] = {
require(one != Duration.Undefined && one >= Duration.Zero, "fibonacci(one) requires a defined, non-negative duration")
unfold[(Duration, Duration)]((one, one)) { case (a, b) => (b, a + b) }.map(_._1).addDelay(d => d)
}

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +102
* A scheduling decision indicating whether evaluation should continue or
* terminate.
*/
sealed trait Decision
object Decision {

/**
* Continue the schedule after waiting for `delay`.
*
* @param delay
* the amount of time an interpreter should wait before the next step
*/
final case class Continue(delay: Duration) extends Decision

/** The schedule has completed — interpreters should stop evaluating. */
case object Done extends Decision
}

/**
* A pure, declarative description of a recurring policy.
*
* `Schedule` is an AST only — values describe scheduling behavior but do not
* execute it. An interpreter walks the nodes and, for each step, produces an
* output together with a [[Decision]] indicating whether to continue.
*
* @tparam In
* Input consumed at each step (e.g. errors for retry, successes for repeat).
* @tparam Out
* Output produced at each step (e.g. retry count, computed delay).
*/
sealed trait Schedule[-In, +Out] { self =>

/** Transforms the output of this schedule. */
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)

/** Transforms the input of this schedule. */
def contramap[In2](f: In2 => In): Schedule[In2, Out] = Schedule.Contramap(self, f)

/** Replaces the output with a constant value. */
def as[Out2](out2: Out2): Schedule[In, Out2] = map(_ => out2)

/** Discards the output, producing `Unit`. */
def unit: Schedule[In, Unit] = as(())

/** Combines with `that` — both must continue; outputs are paired. */
def both[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Both(self, that)

/** Alias for [[both]]. */
def &&[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)

/** Combines with `that` — either may continue; outputs are paired. */
def either[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Either(self, that)

/** Alias for [[either]]. */
def ||[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = either(that)

/** Feeds the output of `that` into the input of this schedule. */
def compose[In2](that: Schedule[In2, In]): Schedule[In2, Out] = Schedule.Compose(self, that)

/** Runs this schedule until done, then switches to `that`. */
def andThen[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, Out2] = Schedule.AndThen(self, that)

/** Alias for [[both]]. */
def zip[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)

/** Adds a delay derived from the schedule output. */
def addDelay(f: Out => Duration): Schedule[In, Out] = Schedule.Delayed(self, f)

/** Applies full-range jitter (0.0 to 1.0) to delays. */
def jittered: Schedule[In, Out] = jittered(0.0, 1.0)

/** Applies jitter with custom bounds to delays. */
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)

/** Continues only while the input satisfies `f`. */
def whileInput[In1 <: In](f: In1 => Boolean): Schedule[In1, Out] = Schedule.WhileInput(self, f)

/** Continues only while the output satisfies `f`. */
def whileOutput(f: Out => Boolean): Schedule[In, Out] = Schedule.WhileOutput(self, f)

/** Revises both the output and the continuation decision after each step. */

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces a new public Schedule / Decision API, but there are no corresponding documentation updates under docs/ (e.g., docs/reference/schedule.md and a docs/sidebars.js entry). Please add/extend the reference docs so users can discover and understand the new module before it ships.

Suggested change
* A scheduling decision indicating whether evaluation should continue or
* terminate.
*/
sealed trait Decision
object Decision {
/**
* Continue the schedule after waiting for `delay`.
*
* @param delay
* the amount of time an interpreter should wait before the next step
*/
final case class Continue(delay: Duration) extends Decision
/** The schedule has completed — interpreters should stop evaluating. */
case object Done extends Decision
}
/**
* A pure, declarative description of a recurring policy.
*
* `Schedule` is an AST only — values describe scheduling behavior but do not
* execute it. An interpreter walks the nodes and, for each step, produces an
* output together with a [[Decision]] indicating whether to continue.
*
* @tparam In
* Input consumed at each step (e.g. errors for retry, successes for repeat).
* @tparam Out
* Output produced at each step (e.g. retry count, computed delay).
*/
sealed trait Schedule[-In, +Out] { self =>
/** Transforms the output of this schedule. */
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)
/** Transforms the input of this schedule. */
def contramap[In2](f: In2 => In): Schedule[In2, Out] = Schedule.Contramap(self, f)
/** Replaces the output with a constant value. */
def as[Out2](out2: Out2): Schedule[In, Out2] = map(_ => out2)
/** Discards the output, producing `Unit`. */
def unit: Schedule[In, Unit] = as(())
/** Combines with `that` — both must continue; outputs are paired. */
def both[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Both(self, that)
/** Alias for [[both]]. */
def &&[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
/** Combines with `that` — either may continue; outputs are paired. */
def either[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Either(self, that)
/** Alias for [[either]]. */
def ||[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = either(that)
/** Feeds the output of `that` into the input of this schedule. */
def compose[In2](that: Schedule[In2, In]): Schedule[In2, Out] = Schedule.Compose(self, that)
/** Runs this schedule until done, then switches to `that`. */
def andThen[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, Out2] = Schedule.AndThen(self, that)
/** Alias for [[both]]. */
def zip[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
/** Adds a delay derived from the schedule output. */
def addDelay(f: Out => Duration): Schedule[In, Out] = Schedule.Delayed(self, f)
/** Applies full-range jitter (0.0 to 1.0) to delays. */
def jittered: Schedule[In, Out] = jittered(0.0, 1.0)
/** Applies jitter with custom bounds to delays. */
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)
/** Continues only while the input satisfies `f`. */
def whileInput[In1 <: In](f: In1 => Boolean): Schedule[In1, Out] = Schedule.WhileInput(self, f)
/** Continues only while the output satisfies `f`. */
def whileOutput(f: Out => Boolean): Schedule[In, Out] = Schedule.WhileOutput(self, f)
/** Revises both the output and the continuation decision after each step. */
* A scheduling decision produced by interpreting a [[Schedule]] step.
*
* A decision indicates whether evaluation should continue with another step
* or terminate. When continuing, the decision may also provide the delay to
* wait before the next step.
*
* @see
* [[Decision.Continue]] to keep evaluating
* @see
* [[Decision.Done]] to stop evaluating
*/
sealed trait Decision
object Decision {
/**
* Continues schedule evaluation after waiting for `delay`.
*
* @param delay
* the amount of time an interpreter should wait before evaluating the next
* step
*/
final case class Continue(delay: Duration) extends Decision
/**
* Terminates schedule evaluation.
*
* Interpreters should stop evaluating the schedule once this decision is
* produced.
*/
case object Done extends Decision
}
/**
* A pure, declarative description of a recurring policy.
*
* `Schedule` is an abstract syntax tree only: values describe scheduling
* behavior but do not execute it themselves. An interpreter walks the nodes
* and, for each step, produces an output together with a [[Decision]]
* indicating whether to continue.
*
* Schedules can be transformed and combined to model policies such as retry,
* repeat, delay injection, sequential composition, and stopping conditions.
*
* @tparam In
* input consumed at each step, such as an error value for retry or a success
* value for repetition
* @tparam Out
* output produced at each step, such as a retry count or computed delay
*/
sealed trait Schedule[-In, +Out] { self =>
/**
* Transforms the output of this schedule.
*
* @param f
* the function used to map each output value
* @tparam Out2
* the new output type
* @return
* a schedule with the same input type and transformed output
*/
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)
/**
* Transforms the input consumed by this schedule.
*
* @param f
* the function used to convert the new input into the original input type
* @tparam In2
* the new input type
* @return
* a schedule that accepts `In2` values and delegates to this schedule
*/
def contramap[In2](f: In2 => In): Schedule[In2, Out] = Schedule.Contramap(self, f)
/**
* Replaces the output of this schedule with a constant value.
*
* @param out2
* the constant output value to produce at every step
* @tparam Out2
* the new output type
* @return
* a schedule with unchanged behavior and constant output
*/
def as[Out2](out2: Out2): Schedule[In, Out2] = map(_ => out2)
/**
* Discards the output of this schedule.
*
* @return
* a schedule that produces `Unit` at every step
*/
def unit: Schedule[In, Unit] = as(())
/**
* Combines this schedule with `that`, requiring both schedules to continue.
*
* The resulting schedule continues only while both component schedules
* continue, and it produces a pair of their outputs.
*
* @param that
* the schedule to combine with this one
* @tparam In1
* the common input type accepted by the combined schedule
* @tparam Out2
* the output type of `that`
* @return
* a schedule that pairs the outputs of both schedules
*/
def both[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Both(self, that)
/**
* Alias for [[both]].
*
* @param that
* the schedule to combine with this one
* @tparam In1
* the common input type accepted by the combined schedule
* @tparam Out2
* the output type of `that`
* @return
* a schedule that pairs the outputs of both schedules
*/
def &&[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
/**
* Combines this schedule with `that`, allowing either schedule to continue.
*
* The resulting schedule continues while at least one component schedule
* continues, and it produces a pair of their outputs.
*
* @param that
* the schedule to combine with this one
* @tparam In1
* the common input type accepted by the combined schedule
* @tparam Out2
* the output type of `that`
* @return
* a schedule that pairs the outputs of both schedules
*/
def either[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = Schedule.Either(self, that)
/**
* Alias for [[either]].
*
* @param that
* the schedule to combine with this one
* @tparam In1
* the common input type accepted by the combined schedule
* @tparam Out2
* the output type of `that`
* @return
* a schedule that pairs the outputs of both schedules
*/
def ||[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = either(that)
/**
* Feeds the output of `that` into the input of this schedule.
*
* This is useful for building pipelines of schedules where the output of one
* stage determines the input of the next stage.
*
* @param that
* the schedule whose output is used as input to this schedule
* @tparam In2
* the input type accepted by `that` and by the composed schedule
* @return
* a schedule representing the composition of `that` and this schedule
*
* @example
* {{{
* val composed: Schedule[Int, Out] = schedule.compose(other)
* }}}
*/
def compose[In2](that: Schedule[In2, In]): Schedule[In2, Out] = Schedule.Compose(self, that)
/**
* Runs this schedule until it completes, then switches to `that`.
*
* @param that
* the schedule to start once this schedule is done
* @tparam In1
* the common input type accepted by the sequential schedule
* @tparam Out2
* the output type produced after switching to `that`
* @return
* a schedule that executes this schedule first and `that` afterward
*/
def andThen[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, Out2] = Schedule.AndThen(self, that)
/**
* Alias for [[both]].
*
* @param that
* the schedule to combine with this one
* @tparam In1
* the common input type accepted by the combined schedule
* @tparam Out2
* the output type of `that`
* @return
* a schedule that pairs the outputs of both schedules
*/
def zip[In1 <: In, Out2](that: Schedule[In1, Out2]): Schedule[In1, (Out, Out2)] = both(that)
/**
* Adds a delay derived from the output of this schedule.
*
* @param f
* the function used to compute an additional delay from each output
* @return
* a schedule with the same input and output that includes the derived delay
*/
def addDelay(f: Out => Duration): Schedule[In, Out] = Schedule.Delayed(self, f)
/**
* Applies full-range jitter to delays using bounds `0.0` to `1.0`.
*
* @return
* a schedule with randomized delays spanning the full jitter range
*/
def jittered: Schedule[In, Out] = jittered(0.0, 1.0)
/**
* Applies jitter to delays using custom bounds.
*
* @param min
* the lower jitter multiplier bound
* @param max
* the upper jitter multiplier bound
* @return
* a schedule with randomized delays bounded by `min` and `max`
*
* @note
* The exact interpretation of jitter is determined by the schedule
* interpreter.
*/
def jittered(min: Double, max: Double): Schedule[In, Out] = Schedule.Jittered(self, min, max)
/**
* Continues only while the input satisfies `f`.
*
* @param f
* the predicate tested against each input value
* @tparam In1
* the narrowed input type accepted by the resulting schedule
* @return
* a schedule that stops once an input does not satisfy `f`
*/
def whileInput[In1 <: In](f: In1 => Boolean): Schedule[In1, Out] = Schedule.WhileInput(self, f)
/**
* Continues only while the output satisfies `f`.
*
* @param f
* the predicate tested against each output value
* @return
* a schedule that stops once an output does not satisfy `f`
*/
def whileOutput(f: Out => Boolean): Schedule[In, Out] = Schedule.WhileOutput(self, f)
/**
* Revises the output and continuation decision after each step.
*
* This combinator allows callers to inspect the input, the current output,
* and the interpreter's [[Decision]], then replace both the output and the
* decision with new values.
*
* @param f
* the function used to revise the output and decision after each step
* @tparam In1
* the narrowed input type accepted by the resulting schedule
* @tparam Out2
* the revised output type
* @return
* a schedule whose output and continuation behavior are revised by `f`
*
* @example
* {{{
* val revised: Schedule[Any, Long] =
* schedule.reconsider { (in, out, decision) =>
* (out.toString.length.toLong, decision)
* }
* }}}
*/

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +160
/** Continues for `n` steps, emitting the step count. */
def recurs(n: Long): Schedule[Any, Long] = forever.whileOutput(_ < n)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recurs(n) currently accepts negative values, which makes the _ < n predicate false immediately and yields a schedule that never continues. If negative n is not meaningful, add input validation (e.g., require n >= 0) or document the behavior explicitly.

Suggested change
/** Continues for `n` steps, emitting the step count. */
def recurs(n: Long): Schedule[Any, Long] = forever.whileOutput(_ < n)
/**
* Continues for `n` steps, emitting the step count.
*
* @param n
* the number of steps to continue for; must be non-negative
* @return
* a schedule that emits step counts from `0` until `n - 1`
* @throws IllegalArgumentException
* if `n` is negative
*/
def recurs(n: Long): Schedule[Any, Long] = {
require(n >= 0, s"recurs requires a non-negative number of steps, but received $n")
forever.whileOutput(_ < n)
}

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +163
/** Continues forever with a fixed delay between steps. */
def spaced(duration: Duration): Schedule[Any, Long] = forever.addDelay(_ => duration)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spaced(duration) allows negative (or otherwise nonsensical) durations to be embedded in the AST, which will likely break interpreters or lead to negative delays. Consider validating duration (e.g., require non-negative and not Duration.Undefined) or clearly documenting what interpreters should do with invalid delays.

Suggested change
/** Continues forever with a fixed delay between steps. */
def spaced(duration: Duration): Schedule[Any, Long] = forever.addDelay(_ => duration)
/**
* Continues forever with a fixed delay between steps.
*
* @param duration
* the fixed delay to apply between schedule steps; must be non-negative and
* not `Duration.Undefined`
* @return
* a schedule that continues forever using the specified fixed delay
* @throws IllegalArgumentException
* if `duration` is negative or `Duration.Undefined`
*/
def spaced(duration: Duration): Schedule[Any, Long] = {
require(
duration != Duration.Undefined && duration >= Duration.Zero,
s"spaced(duration) requires a non-negative, defined duration, but received: $duration"
)
forever.addDelay(_ => duration)
}

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +167
/** Exponential backoff: `base * factor^step`. */
def exponential(base: Duration, factor: Double = 2.0): Schedule[Any, Duration] =
forever.map(step => base * math.pow(factor, step.toDouble)).addDelay(d => d)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exponential(base, factor) does not validate base/factor. As written, factor <= 0, NaN/Infinity, or a negative base can yield negative or undefined Duration values when the stored function is evaluated. Please add input validation (or a documented contract) to keep the AST in a sane/portable state for interpreters.

Suggested change
/** Exponential backoff: `base * factor^step`. */
def exponential(base: Duration, factor: Double = 2.0): Schedule[Any, Duration] =
forever.map(step => base * math.pow(factor, step.toDouble)).addDelay(d => d)
/**
* Exponential backoff: `base * factor^step`.
*
* @param base
* the initial delay; must be finite and non-negative
* @param factor
* the growth factor applied at each step; must be finite and strictly positive
* @return
* a schedule that emits exponentially increasing delays
* @throws IllegalArgumentException
* if `base` is negative or not finite, or if `factor` is not finite or is
* less than or equal to zero
*/
def exponential(base: Duration, factor: Double = 2.0): Schedule[Any, Duration] = {
require(base.isFinite, s"exponential base must be finite, but was: $base")
require(base >= Duration.Zero, s"exponential base must be non-negative, but was: $base")
require(!factor.isNaN && !factor.isInfinity, s"exponential factor must be finite, but was: $factor")
require(factor > 0.0, s"exponential factor must be > 0.0, but was: $factor")
forever.map(step => base * math.pow(factor, step.toDouble)).addDelay(d => d)
}

Copilot uses AI. Check for mistakes.
* A scheduling decision indicating whether evaluation should continue or
* terminate.
*/
sealed trait Decision

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to put this into a separate file?

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown

🚀 Preview deployed to Netlify: https://zio-blocks-pr-1349--zio-dev.netlify.app

@987Nabil

987Nabil commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

Compare with Golem 1.5

Copilot AI review requested due to automatic review settings May 29, 2026 18:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated 2 comments.

* @tparam Out
* Output produced at each step (e.g. retry count, computed delay).
*/
sealed trait Schedule[-In, +Out] { self =>
sealed trait Schedule[-In, +Out] { self =>

/** Transforms the output of this schedule. */
def map[Out2](f: Out => Out2): Schedule[In, Out2] = Schedule.Map(self, f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants