Skip to content

symfonic.kernel.post_model

post_model

The post-model seam: capability stages that read a completed round.

23 slice 1. Until this module the ladder was a vocabulary with one rung wired

run_phase was called from prompt_assembly and nowhere else, so a capability declaring a stage in any other phase compiled into a plan, took its place in the order, received its effect grants, and was never invoked.

Three things this deliberately does not do, each because the plan for #23 says so and each with a reason in the code rather than in the plan:

  • it does not bind. run_prompt_assembly opens the turn; RequestContext.bind_generation is write-once and raises on a second call. By the time a post-model stage runs the turn is already open, so this module dispatches and nothing else — no bind, no assembly, no require_no_failed_stage.
  • it applies nothing. apply=None is the documented case in dispatch's own docstring, not a placeholder: no contract exists yet for what a post-model contribution mutates. Deciding that is the governance-attachment work, and inventing it here would be the fabrication T3.5.2 refused on run.
  • it grants explicitly. grants= defaults to None in run_phase, and None skips the effect check (dispatch._ungranted). A new call site that forgets it reopens STG-8 silently — the PR #92 class, which is why the plan enumerates it and why a test names it.

PostModelContext dataclass

PostModelContext(plan: Any, request: Any, stage: Any, resolved: Any, turn: Any, transcript: Any = None)

What a post-model stage is handed.

Carries the whole ModelTurn rather than a pre-interpreted "draft". A seam that extracted the answer text would be choosing, for every future capability, which part of a round matters — and the two capabilities this phase is for disagree already: fabrication reads text, budget reads text and pinned content. The turn is the fact; the reading is the stage's.

No RequestContext, for the reason StageContext states: a stage returns rather than mutates, so handing it the mutable run context would be handing it the thing it must not touch.

run_post_model async

run_post_model(plan: Any, request: Any, turn: Any, *, resolved: Any = None, transcript: Any = None, handlers: Mapping[str, StageHandler] | None = None) -> tuple[StageTrace, ...]

Dispatch the post-model stages for one completed model round.

Handlers come from plan.bindings.stage_handlers — G6, where the plan already keeps its live callables — overlaid with handlers, which exists for tests and for a composition root that has not moved yet. Additive, so a plan's own handler cannot be silently replaced.

Returns the traces. The runner discards them today for the same reason it discards prompt-assembly's: RequestContext has no runtime-diagnostic slot. Returning them rather than swallowing them is what lets that change without touching this module.

Source code in src/symfonic/kernel/post_model.py
async def run_post_model(
    plan: Any,
    request: Any,
    turn: Any,
    *,
    resolved: Any = None,
    transcript: Any = None,
    handlers: Mapping[str, StageHandler] | None = None,
) -> tuple[StageTrace, ...]:
    """Dispatch the ``post-model`` stages for one completed model round.

    Handlers come from ``plan.bindings.stage_handlers`` — G6, where the plan
    already keeps its live callables — overlaid with ``handlers``, which exists
    for tests and for a composition root that has not moved yet. Additive, so a
    plan's own handler cannot be silently replaced.

    Returns the traces. The runner discards them today for the same reason it
    discards prompt-assembly's: ``RequestContext`` has no runtime-diagnostic
    slot. Returning them rather than swallowing them is what lets that change
    without touching this module.
    """
    bound = getattr(plan.bindings, "stage_handlers", None) or {}
    table: dict[str, StageHandler] = {**bound, **(handlers or {})}
    traces = await StageDispatcher(table).run_phase(
        plan.stage_program,
        Phase.POST_MODEL,
        context_for=lambda stage: PostModelContext(
            plan=plan, request=request, stage=stage, resolved=resolved, turn=turn,
            transcript=transcript,
        ),
        # Explicit, not defaulted: see the module docstring.
        grants=frozenset(getattr(plan, "effect_grants", frozenset())),
    )
    # EVT-7, the same policy prompt-assembly applies -- and this call exists
    # because it did not. A handler that raises is turned into a FAILED trace by
    # the dispatcher, and nothing read the traces, so a capability stage could
    # crash and the turn would finish reporting nothing.
    #
    # Found by wiring a real memory-write handler that had a genuine bug in it:
    # the AttributeError never surfaced, ``run()`` returned normally, and the
    # only symptom was an empty store. That is the #92 defect at a new call
    # site -- the class this module's own docstring warns about for ``grants``.
    require_no_crashed_stage(traces, phase="post-model")
    return traces