Skip to content

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. proposed and applied are different trace states. The dispatcher may validate, merge, reject, or find a proposal changes nothing — so a handler cannot assume its contribution took effect.
  • emits is 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 stops CompiledStage.emits from being the next field that is declared and never read.
  • No universal contribution type. Each phase returns its own — a PromptContribution is not a tool decision. They share only this envelope. A generic bag would push the "what does this mean?" question to every reader.

ModelPortBinding dataclass

ModelPortBinding(run_id: str, scope_path: str, session_id: str, prompt_admissions: tuple[PromptAdmissionReceipt, ...] = ())

Kernel-stamped turn metadata; compiler admission is not delivery.

ModelPortInputAttempt dataclass

ModelPortInputAttempt(binding: ModelPortBinding, round: int, mode: str)

Attempted bound Runnable handoff, not provider receipt or acknowledgement.

PromptAdmissionReceipt dataclass

PromptAdmissionReceipt(run_id: str, scope_path: str, admitted: tuple[str, ...] = (), dropped: tuple[str, ...] = (), session_id: str = '', compiler_scope_path: str = '', compiler_session_id: str = '')

Private compiler decisions, never evidence of provider delivery.

Turn-bound contribution identities only; contains no rendered payload. Arbitrary model adapters may discard the compiled prompt. Admission says nothing about delivery, acknowledgement, or prospective trigger resolution. This value stays inside the invocation and is never put in stage traces.

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

APPLIED = 'applied'

The stage proposed a contribution and the dispatcher applied it.

FAILED class-attribute instance-attribute

FAILED = 'failed'

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

NO_CHANGE = 'no-change'

The stage ran, had nothing to contribute, and said why.

REJECTED class-attribute instance-attribute

REJECTED = 'rejected'

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)(), prompt_admission: PromptAdmissionReceipt | None = None)

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, prompt_admission: PromptAdmissionReceipt | None = None) -> StageResult[ContributionT]

The stage proposes a change.

Source code in src/symfonic/kernel/contracts/results.py
def applied(
    contribution: ContributionT,
    *,
    events: tuple[Any, ...] = (),
    diagnostics: tuple[str, ...] = (),
    counts: Mapping[str, int] | None = None,
    prompt_admission: PromptAdmissionReceipt | None = None,
) -> StageResult[ContributionT]:
    """The stage proposes a change."""
    return StageResult(
        counts=freeze_counts(counts),
        outcome=StageOutcome.APPLIED,
        contribution=contribution,
        events=events,
        diagnostics=diagnostics,
        prompt_admission=prompt_admission,
    )

failed

failed(error: BaseException, *, reason: str = '') -> StageResult[Any]

The stage raised.

Source code in src/symfonic/kernel/contracts/results.py
def failed(error: BaseException, *, reason: str = "") -> StageResult[Any]:
    """The stage raised."""
    return StageResult(outcome=StageOutcome.FAILED, error=error, reason=reason or str(error))

freeze_counts

freeze_counts(counts: Mapping[str, int] | None) -> Mapping[str, int]

A read-only copy, so a stage cannot edit what it already reported.

Source code in src/symfonic/kernel/contracts/results.py
def freeze_counts(counts: Mapping[str, int] | None) -> Mapping[str, int]:
    """A read-only copy, so a stage cannot edit what it already reported."""
    if not counts:
        return EMPTY_COUNTS
    return MappingProxyType({str(name): value for name, value in counts.items()})

no_change

no_change(reason: str, *, diagnostics: tuple[str, ...] = (), counts: Mapping[str, int] | None = None, prompt_admission: PromptAdmissionReceipt | 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
def no_change(
    reason: str,
    *,
    diagnostics: tuple[str, ...] = (),
    counts: Mapping[str, int] | None = None,
    prompt_admission: PromptAdmissionReceipt | 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.
    """
    return StageResult(
        outcome=StageOutcome.NO_CHANGE,
        reason=reason,
        diagnostics=diagnostics,
        counts=freeze_counts(counts),
        prompt_admission=prompt_admission,
    )

rejected

rejected(reason: str, *, diagnostics: tuple[str, ...] = ()) -> StageResult[Any]

The stage declined to act, and says why.

Source code in src/symfonic/kernel/contracts/results.py
def rejected(reason: str, *, diagnostics: tuple[str, ...] = ()) -> StageResult[Any]:
    """The stage declined to act, and says why."""
    return StageResult(outcome=StageOutcome.REJECTED, reason=reason, diagnostics=diagnostics)