Skip to content

symfonic.kernel.validation

validation

The compiler's validate step: everything that makes a plan refuse to compile.

Split out of :mod:.compiler because these are the rules, not the assembly. Each function answers one question — "may this event program exist?", "is this ceiling runnable?", "can the loop actually call every port it dereferences?" — and answers it by raising ConfigurationError before any effect has run. That last part is the whole point: a rejection here costs a caller nothing, while the same rejection discovered mid-invocation costs a model call.

Nothing in this module inspects a live object or performs I/O, so the rules stay testable with hand-written plan values alone (IPL-8).

validate_bindings

validate_bindings(bindings: ServiceBindings) -> None

IPL-1: reject a plan the kernel could not execute, before anything runs.

The loop dereferences all four required ports unconditionally, so an unbound one surfaces as AttributeError on None halfway through an invocation — after the prompt was assembled, and possibly after a model call. Naming it here is what keeps "every failure raises ConfigurationError with nothing executed" true for the later capabilities that bind their own ports.

Source code in src/symfonic/kernel/validation.py
def validate_bindings(bindings: ServiceBindings) -> None:
    """IPL-1: reject a plan the kernel could not execute, before anything runs.

    The loop dereferences all four required ports unconditionally, so an unbound
    one surfaces as ``AttributeError`` on ``None`` halfway through an invocation
    — after the prompt was assembled, and possibly after a model call. Naming it
    here is what keeps "every failure raises ``ConfigurationError`` with nothing
    executed" true for the later capabilities that bind their own ports.
    """
    missing = [name for name in REQUIRED_PORTS if getattr(bindings, name) is None]
    if missing:
        raise ConfigurationError(
            f"the plan leaves required port(s) {missing} unbound; the invocation "
            f"loop calls every one of {list(REQUIRED_PORTS)} without guarding. "
            "Only 'event_sink' is optional."
        )

validate_event_program

validate_event_program(program: EventProgram) -> None

IPL-10 and BP-1…BP-5: reject incomplete or dishonest G9 rows.

Source code in src/symfonic/kernel/validation.py
def validate_event_program(program: EventProgram) -> None:
    """IPL-10 and BP-1…BP-5: reject incomplete or dishonest G9 rows."""
    names: set[str] = set()
    for adapter in program.adapters:
        if not adapter.name or adapter.name in names:
            raise ConfigurationError(
                f"event adapter name {adapter.name!r} is empty or duplicated."
            )
        names.add(adapter.name)
        if not adapter.owner:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} must declare an owner."
            )
        if adapter.buffer == "unbounded":
            raise ConfigurationError(
                f"event adapter {adapter.name!r} declares an unbounded buffer; "
                "unbounded queues are forbidden — declare 'rendezvous' or 'bounded'."
            )
        if adapter.name in _RENDEZVOUS_ADAPTERS and (
            adapter.buffer != "rendezvous"
            or adapter.policy != "block"
            or adapter.terminal_policy != "reserve"
        ):
            raise ConfigurationError(
                f"event adapter {adapter.name!r} has a fixed §4 policy of "
                "rendezvous/block/reserve."
            )
        if adapter.name == "callback_fanout" and (
            adapter.policy != "block" or adapter.terminal_policy != "reserve"
        ):
            raise ConfigurationError(
                "event adapter 'callback_fanout' must block and reserve its "
                "terminal event under the §4 policy table."
            )
        if adapter.name == "callback_fanout" and adapter.buffer == "bounded" and (
            adapter.capacity > 256 or adapter.byte_capacity > 8 * 1024 * 1024
        ):
            raise ConfigurationError(
                "bounded callback_fanout exceeds its §4 maximum of 256 events "
                "and 8 MiB."
            )
        if adapter.buffer == "rendezvous" and (
            adapter.capacity != 0 or adapter.byte_capacity != 0
        ):
            raise ConfigurationError(
                f"event adapter {adapter.name!r} is rendezvous and therefore has "
                "capacity 0 and byte capacity 0."
            )
        if adapter.buffer == "bounded" and adapter.capacity < 1:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} has bounded capacity "
                f"{adapter.capacity}; capacity must be positive."
            )
        if adapter.buffer == "bounded" and adapter.byte_capacity < 1:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} has bounded byte capacity "
                f"{adapter.byte_capacity}; byte capacity must be positive."
            )
        if adapter.terminal_policy == "shed":
            raise ConfigurationError(
                f"event adapter {adapter.name!r} declares terminal policy 'shed'; "
                "terminal events may only be reserved or preempt (BP-4)."
            )
        if adapter.policy == "shed" and not adapter.sheddable:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} declares shed policy without "
                "declaring any sheddable event kinds."
            )
        if adapter.policy == "block" and adapter.sheddable:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} blocks but declares sheddable kinds."
            )
        if "text_delta" in adapter.sheddable and adapter.text_reconstruction != "terminal-only":
            raise ConfigurationError(
                f"event adapter {adapter.name!r} sheds text_delta but does not "
                "declare text reconstruction as terminal-only (BP-5)."
            )
        illegal_sheds = adapter.sheddable - program.emitted
        if illegal_sheds:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} declares undeclared sheddable "
                f"event types {sorted(illegal_sheds)}."
            )
        undeclared = adapter.emits - program.emitted
        if undeclared:
            raise ConfigurationError(
                f"event adapter {adapter.name!r} projects undeclared event types "
                f"{sorted(undeclared)}; the emitted set is {sorted(program.emitted)}."
            )
    missing = _REQUIRED_ADAPTERS - names
    if missing:
        raise ConfigurationError(
            "event program is missing mandatory §4 adapter rows "
            f"{sorted(missing)}."
        )

validate_limits

validate_limits(limits: PlanLimits) -> None

IPL-1: a bound the two projections would read differently never compiles.

max_model_rounds = 0 is the load-bearing case. run() spends an empty range, falls into the else branch and reports tool_limit; stream_events() never enters its loop and reports nothing — one plan with two answers to "why did this stop", which is precisely the drift the streaming projection exists to make impossible.

Source code in src/symfonic/kernel/validation.py
def validate_limits(limits: PlanLimits) -> None:
    """IPL-1: a bound the two projections would read differently never compiles.

    ``max_model_rounds = 0`` is the load-bearing case. ``run()`` spends an empty
    ``range``, falls into the ``else`` branch and reports ``tool_limit``;
    ``stream_events()`` never enters its loop and reports nothing — one plan
    with two answers to "why did this stop", which is precisely the drift the
    streaming projection exists to make impossible.
    """
    if limits.max_model_rounds < 1:
        raise ConfigurationError(
            f"max_model_rounds is {limits.max_model_rounds}; a plan must permit at "
            "least one model round."
        )
    if limits.max_recursion_depth < 0:
        raise ConfigurationError(
            f"max_recursion_depth is {limits.max_recursion_depth}; a depth ceiling "
            "cannot be negative."
        )
    if limits.max_event_buffer < 0:
        raise ConfigurationError(
            f"max_event_buffer is {limits.max_event_buffer}; a buffer capacity "
            "cannot be negative."
        )
    if limits.deadline_seconds is not None and limits.deadline_seconds <= 0:
        raise ConfigurationError(
            f"deadline_seconds is {limits.deadline_seconds}; a deadline already "
            "spent at compile time cannot be honoured by any run."
        )
    if limits.teardown_grace_seconds < 0:
        raise ConfigurationError(
            f"teardown_grace_seconds is {limits.teardown_grace_seconds}; a "
            "negative grace window would make teardown's bound unstatable. Use "
            "0 to cancel owned work immediately; finalizers still run, "
            "unbounded (RCX-10)."
        )