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)."
)
|