symfonic.kernel.contracts.results¶
results ¶
What a stage hands back, and why the envelope is not just a value.
Decision 2 of W2/1b: a stage returns a contribution, it does not mutate the request context. Decision 3: it returns its events in the same result, so one execution is one transactional unit — the dispatcher validates, applies the contribution, then publishes a contiguous block of events. A stage that fails leaves no partial events announcing a change that never landed.
The envelope exists because an empty contribution does not explain itself. This
codebase reached ~27,000 unreachable lines by letting "declared" and "does
something" be indistinguishable, six separate times. NoChange(reason) is the
antidote: a stage that legitimately had nothing to do says so and says why,
and is therefore distinguishable from a stage that is broken, refused, or inert.
Three rules that are easy to get wrong and are load-bearing here:
- Returning is not applying.
proposedandappliedare different trace states. The dispatcher may validate, merge, reject, or find a proposal changes nothing — so a handler cannot assume its contribution took effect. emitsis an upper bound, checked. A stage may emit fewer events than it declared (emission is conditional); it may never emit one it did not declare. That is what stopsCompiledStage.emitsfrom being the next field that is declared and never read.- No universal contribution type. Each phase returns its own — a
PromptContributionis not a tool decision. They share only this envelope. A generic bag would push the "what does this mean?" question to every reader.
StageOutcome ¶
Bases: StrEnum
What happened to one stage execution.
Four states, not two, because "nothing changed" has three distinct causes and conflating them is how an inert stage hides behind a healthy one.
APPLIED
class-attribute
instance-attribute
¶
The stage proposed a contribution and the dispatcher applied it.
FAILED
class-attribute
instance-attribute
¶
The stage raised. Carries the error for the trace; the dispatcher decides whether that ends the turn (EVT-7) or degrades.
NO_CHANGE
class-attribute
instance-attribute
¶
The stage ran, had nothing to contribute, and said why.
REJECTED
class-attribute
instance-attribute
¶
The stage proposed something the dispatcher refused. Not an error: a contribution can be well-formed and still inadmissible.
StageResult
dataclass
¶
StageResult(outcome: StageOutcome, contribution: ContributionT | None = None, events: tuple[Any, ...] = (), reason: str = '', error: BaseException | None = None, diagnostics: tuple[str, ...] = tuple(), counts: Mapping[str, int] = (lambda: EMPTY_COUNTS)())
Bases: Generic[ContributionT]
One stage execution: what it proposes, what it emits, and why.
contribution is None for every outcome except :attr:StageOutcome
.APPLIED — and the constructors below are the supported way to build one,
because they make that invariant unstatable rather than merely documented.
applied ¶
applied(contribution: ContributionT, *, events: tuple[Any, ...] = (), diagnostics: tuple[str, ...] = (), counts: Mapping[str, int] | None = None) -> StageResult[ContributionT]
The stage proposes a change.
Source code in src/symfonic/kernel/contracts/results.py
failed ¶
freeze_counts ¶
A read-only copy, so a stage cannot edit what it already reported.
Source code in src/symfonic/kernel/contracts/results.py
no_change ¶
no_change(reason: str, *, diagnostics: tuple[str, ...] = (), counts: Mapping[str, int] | None = None) -> StageResult[Any]
The stage ran and had nothing to do. reason is not optional.
Counts are accepted here too, and that is the interesting case: a
retrieval that found nothing reports found=0, which is a measurement.
Reporting no counts at all would leave a reader unable to tell it from a
stage that does not count.
Source code in src/symfonic/kernel/contracts/results.py
rejected ¶
The stage declined to act, and says why.