Skip to content

symfonic.kernel.contracts.resolved

resolved

The resolved-input snapshot — what an effectful stage hands a pure one.

STG-7 as reformulated splits prompt-assembly into two kinds of stage: resolution, which may perform I/O once per invocation under a grant, and compilation, which is a pure function of (plan, request, snapshot). This module is the snapshot, and the whole reason it can sit between them.

Neutral. The kernel never interprets an entry's value. A memory recall, a retrieved document set and a fetched policy are all one shape to it: a capability's name, a payload, and a provenance string. That is what lets two capabilities compose without importing each other — the seam capabilities/memory/contribution.py already describes as kept "by vocabulary, not by an import". A kernel that understood the payload would be a third party to that agreement.

Typed. Not dict[str, Any] and not a bare tuple. An entry knows which capability produced it and where the value came from, because the question a reader asks of a compiled prompt is "why is this line in here?", and a payload alone cannot answer it.

Immutable, and that has to reach the payload. A compilation stage that could mutate the snapshot would make the compile depend on which compilation stage ran first, which is precisely the reproducibility STG-7 exists to keep. :meth:ResolvedInputs.extend returns a new snapshot rather than growing this one — but review of PR #94 found that freezing only the outer mapping is not enough, because value kept mutable payloads by reference. Reproduced: a capability contributing ({"contribution_id": ...},) had that dict rewritten by one compiler and the next compiler read the rewritten version.

So containers are frozen on the way in — dict to a read-only mapping, list to tuple, set to frozenset, recursively — and the entries mapping is copied defensively rather than adopted, since a caller holding the original dict could otherwise inject entries after construction and bypass the one-entry-per-capability rule as well.

Fail-closed on what cannot be represented. The first version of this fix froze containers and left unrecognised objects by reference, with the limit written in a docstring. Review was right that a note is not a boundary: a resolver handing over a nested mutable object still let one compilation stage rewrite what the next one compiled. Construction now refuses any payload it cannot vouch for — scalars, mappings, sequences, sets and frozen dataclasses are admitted and traversed; anything else names itself and its path in the refusal. Mapping keys are checked on the same terms as values: deep_freeze rebuilds a mapping keeping its keys by reference, so a hashable-but-mutable key would otherwise slip through the one place nobody looks.

This is deliberately stricter than :func:is_deeply_frozen, which returns True for an unrecognised object because IPL-4 exempts plan group G6's live ports. A payload is not a port: it is data a pure compilation stage reads, so an object nobody can vouch for is a hole rather than an exemption.

ResolvedInput dataclass

ResolvedInput(capability: str, value: Any, provenance: str)

One capability's resolved contribution to this turn.

value is opaque to the kernel and meaningful only to the capability that compiles it. provenance is not decoration: it is how a reader of a compiled prompt gets from a line back to the retrieval that produced it, and how a digest of the snapshot means something.

ResolvedInputs dataclass

ResolvedInputs(entries: Mapping[str, ResolvedInput] = (lambda: MappingProxyType({}))())

The frozen set of resolved inputs a compilation stage may read.

Keyed by capability because that is the unit a compiler asks for — "did memory resolve anything this turn?"

One entry per capability is a constraint of this ledger, not a claim about stages. A capability may declare as many resolution stages as it needs; what it may not do is leave two entries under one name, because then "what did memory resolve?" would depend on which stage ran last and one of the two would vanish silently. A capability that reads from several places composes its own result and hands over one entry. If a real case ever needs several entries under one name, the fix is to widen the ledger deliberately — a list per capability, or namespaced keys — not to let the last writer win.

extend

extend(entry: ResolvedInput) -> ResolvedInputs

Return a new snapshot with entry added. Never mutates.

Raises:

Type Description
ConfigurationError

if the capability already left an entry this turn. Two under one name would make "what did memory resolve?" depend on which stage ran last, and silently drop one. This bounds the ledger, not how many resolution stages a capability may declare.

Source code in src/symfonic/kernel/contracts/resolved.py
def extend(self, entry: ResolvedInput) -> ResolvedInputs:
    """Return a new snapshot with ``entry`` added. Never mutates.

    Raises:
        ConfigurationError: if the capability already left an entry this
            turn. Two under one name would make "what did memory resolve?"
            depend on which stage ran last, and silently drop one. This
            bounds the *ledger*, not how many resolution stages a capability
            may declare.
    """
    # Rebuilt *first*. ``capability`` was read four times across this method
    # and __post_init__, twice before the rebuild produced a stable value --
    # so a subclass whose ``capability`` is a property answering differently
    # on successive reads walked through: read one said "unused-name" and
    # passed the duplicate check, read two supplied the key "memory" and
    # silently replaced the real entry. After the rebuild every attribute is
    # a slot on an exact ResolvedInput, so re-reading is stable. Same
    # principle as the rebuild itself, applied one step earlier: establish
    # the value, then reason about it.
    entry = _rebuilt(entry)
    if entry.capability in self.entries:
        raise ConfigurationError(
            f"capability {entry.capability!r} resolved twice in one turn. "
            "The snapshot holds one entry per capability, so a second would "
            "silently replace the first; compose the two results in the "
            "capability and resolve once."
        )
    return ResolvedInputs(MappingProxyType({**self.entries, entry.capability: entry}))

of

of(capability: str) -> ResolvedInput | None

What capability resolved, or None if it resolved nothing.

None rather than a raise: a compiler that works with memory and without it is the normal case, and "this capability is not installed" is not an error condition for the stage that reads it.

Source code in src/symfonic/kernel/contracts/resolved.py
def of(self, capability: str) -> ResolvedInput | None:
    """What ``capability`` resolved, or ``None`` if it resolved nothing.

    ``None`` rather than a raise: a compiler that works with memory and
    without it is the normal case, and "this capability is not installed"
    is not an error condition for the stage that reads it.
    """
    return self.entries.get(capability)