rfgen.graph

rfgen.graph is the public declarative compiler. It normalizes and validates GraphSpec, materializes deterministic structure for a sample, binds node references, executes the resulting plan, and projects typed fields into a SceneRecord.

The stable entry points are GraphSpec, GraphBuilder, normalize_graph, analyze_graph_spec, materialize_graph_spec, prepare_graph, build_scene_plan, run_scene_record, and explain_graph. Structural random draws are keyed by rfgen.graph.randomness.StructuralPath; they do not consume generation’s run/sample/shard seed schedule.

The compiler phases and exact boundary types are:

Static Transcript — not runnable

Audit pending; do not treat this block as a runnable example.

normalize_graph(value: GraphSpec | Mapping[str, Any]) -> GraphSpec
analyze_graph_spec(value: GraphSpec | Mapping[str, Any]) -> GraphAnalysis
materialize_graph_spec(value, *, sample_index: int, run_seed: int,
                       resolver: NodeResolver | None = None) -> MaterializedGraph
prepare_graph(value, resolver: NodeResolver | None = None) -> PreparedGraph
build_scene_plan(value: GraphSpec | Mapping | PreparedGraph, *,
                 sample_index: int, run_seed: int,
                 resolver: NodeResolver | None = None) -> BuiltGraph
explain_graph(value, *, view="all", sample_index=0,
              run_seed=0, resolver=None) -> dict[str, Any]

Normalization parses strict authored data and canonicalizes mapping order. Analysis resolves scopes, interfaces, typed references, bounded draws, cycles, and record projection without running a node. Materialization requires a nonnegative sample index and expands bounded structure into lexical node names; its resolver, like analyze_graph_spec’s, types any port the graph leaves for completion to infer. Pass it whenever your catalog is not the default one — without it this phase completes against the default catalog while the rest of your pipeline uses yours, which for a selector that shadows one core ships is a silent disagreement rather than an error. Plan building binds each materialized node to its catalog implementation, verifies that authored and operator schemas match, and produces ScenePlan plus RecordProjection. Execution runs only after these gates.

Four of the builder’s phases answer a question about the graph, not about the sample: normalization, type completion, all-branch analysis, and the all-branch bind. Their answers are identical for sample 0 and sample 199,999, so a caller rendering many samples from one graph used to pay for four constants per record. Their cost scales with the size of the authored graph text, so a configuration with many select branches paid nearly its whole per-record budget on them.

prepare_graph runs all four once and returns a PreparedGraph; pass that to build_scene_plan in place of the graph and the per-sample path does only per-sample work — branch selection, ordinal resolution, $draw resolution, node binding, plan materialization, and per-sample analysis. Passing a raw GraphSpec or mapping still runs every phase, so the gates cannot be lost by forgetting an argument.

A PreparedGraph is one object carrying its own completed graph, its own all-branch evidence, and the resolver that evidence was produced against, so there is nowhere to put a second, different graph and no way to pair evidence with a graph it does not describe. Passing a PreparedGraph together with a custom resolver raises AnalyzeError; prepare against that resolver instead.

Because the builder trusts a prepared graph for all four phases, PreparedGraph refuses direct constructionprepare_graph is the only function that stamps one, and hand-construction raises AnalyzeError. This is a stricter rule than the validated= keyword it replaced, and deliberately so: that keyword skipped one phase and the analysis it did not skip still rejected a bad graph, whereas an unearned prepared graph would skip analysis too and return a plan whose record projection dangles without raising. The guard makes forgery a deliberate act; it does not make it impossible.

Every field of a PreparedGraph is read-only, which is what makes sharing one across the records of a run safe: graph is a frozen model that materialization only reads, typed is a frozen dataclass of plain rows, and the catalog behind CatalogNodeResolver records nothing per bind.

NativeGenerationPlan prepares in its constructor and renders every record from that one plan.prepared, which is why a generation run compiles its graph once per plan rather than once per record. Only the authored graph crosses a process boundary; a shard worker prepares its own against its own installed catalog.

GraphBuilder(schema_version=1) is a functional authoring helper. Chain input, subgraph, add, output, and exactly one record, then call build() -> GraphSpec; missing record projection raises ScenePlanError. run_scene_record(scene_plan, projection, sample_index, run_seed, registry) -> SceneRecord validates projection, executes keyed nodes, and returns typed fields plus compiler identity.

Corpus-axis selection schedules use the public SelectionSchedulePolicy seam. ExactProportion preserves the fixed-phase default, while BalancedCompleteCycles provides seed-rotated complete permutations; resolve them with selection_schedule_policy and persist run facts with selection_schedule_provenance.

Graph code depends on node contracts and rfgen.nodes.values. Node operators must not import scene-plan building or execution.

Execution keeps custody checks separate by concept. gather_inputs and validate_outputs enforce port bindings; isolate_resource_inputs, validate_resource_inputs_unchanged, and detach_resource_consumer_outputs protect PLAN resources. Checked labels use isolate_checked_label_inputs and isolate_checked_label_outputs. validate_signal_transport_custody, validate_label_custody, and validate_aggregate_custody are composed by validate_propagation_custody. Grid-changing nodes use expected_grid_evidence_payload and tensor_bytes_equal to compare the declared transport evidence without re-running the transform.

Static Transcript — not runnable

Audit pending; do not treat this block as a runnable example.

from rfgen.graph import GraphSpec, normalize_graph

normalized = normalize_graph(GraphSpec.model_validate(authored_mapping))

Malformed structure raises rfgen.graph.errors.GraphError subclasses. Value compatibility errors are defined by rfgen.nodes.values.

Signal-chain ordering

check_signal_chain_ordering(graph, node_classes) refuses a GraphSpec whose realized signal paths contradict the planes its nodes declare (see rfgen.nodes.ordering). It is called from validate_bound_graph_spec, which is the one place that already binds every node of every branch and therefore knows which class a selector resolved to, so the check is paid once per graph rather than once per record. It raises AnalyzeError reporting every violation at once, each naming both nodes and why the order is wrong.

signal_chain_edges(graph, nodes) -> tuple[tuple[str, str], ...] is the reading half: it returns realized (producer, consumer) pairs contracted to nodes, treating everything else on a path — a container’s output alias, or any node the caller left out — as transparent. Only realized edges are paths; a plan edge carries a resolved fact, not a signal. Select and conditional containers are transparent; repeat bodies and subgraph calls are checked internally and their declared interfaces preserve ordering across the boundary.

Propagation authority is resolved once over the completed all-branch graph as an immutable set of exact PLAN endpoint-facts producers. Raw waveform sources start with the empty set. A propagation producer establishes a singleton; ApplyCIR inherits and checks one authority; a cascaded propagation effect compares its cited endpoint producer with every nonempty upstream authority. Every ordinary complex-voltage signal transform is transparent to the set, including stage-less and plane-agnostic transforms, and combiners take the union. They therefore cannot erase a mismatch merely by omitting propagation metadata.

Container interfaces follow the same lexical graph semantics as signal-chain ordering. Select and conditional outputs union every possible branch, subgraph inputs and outputs resolve at the call site, and repeat collections retain distinct local endpoint producers for distinct instances. Recursive aliases and cycles are refused. The single-link receiver_input_boundary accepts only one authority equal to its own endpoint producer; a mixed multi-link union is not silently collapsed. This is producer identity, so equal link ids, equal types, and even equal endpoint payloads from a second pose authority do not pass.

The checked multi-link route is structurally different. An atomic ReceiverIncidentContribution pairs each clean signal with one exact pose-free link, emitter, and support row before collection. ReceiverIncidentAggregate requires that exact nonempty collection origin and unions its authorities without erasing row membership; every row must terminate at the same receiver. Its specialized aggregate boundary accepts only signal, facts, and custody from that same aggregate producer and retains the canonical ordered contribution relation. Mixed receiver authority, independent signal/link collections, or a chosen representative link fails closed.

Receiver custody is resolved by the same lexical authority walk, but as two separate sets: checked receiver-input-boundary producers and scope-qualified receiver-plan/index evidence. Boundary signal and facts aliases must resolve to one checked boundary wherever a scalar noise or label consumer cites them; noise outputs retain the exact boundary named by their declared citation. Every boundary’s canonical posed PropagationEndpointFacts or pose-free PropagationLinkFacts must also resolve through its receiver-identity input to exactly one receiver PLAN authority before that authority is compared with the boundary’s receiver measurement facts. An exact canonical payload from a detached no-input plugin is not authority, even if its payload values would pass the runtime comparison.

At the canonical endpoint producer itself, the compiler resolves identity and pose ownership symmetrically for transmitter and receiver through the same lexical select, conditional, subgraph, and repeat rules. Each pair must name one identical scope-qualified setup PLAN owner. The nested endpoint identity types must exactly match the bound EntityRef types, so a plugin cannot take receiver B or transmitter B as input while declaring the singleton identity vocabulary of A in its output. These checks are structural and apply to public plugins without selector or class-name privilege.

Container resolution is branch-sensitive rather than a union that can discard absence. Every select and conditional branch must independently resolve to the same singleton owner; a subgraph output must resolve to one owner; every repeat witness must be nonempty and singleton before distinct repeat-local owners are preserved as a set. An owned branch paired with an unowned but type-compatible projection therefore fails closed instead of collapsing to the owned singleton.

Static ownership cannot prove what a public PLAN implementation returns. At the common materialization output seam, every canonical PropagationEndpointFacts value is therefore codec-validated and compared with the four resolved input values. Its emitted transmitter and receiver identity projections and poses must be canonically equal to those inputs; its link reference must be internally valid and its emitted link id must equal the declared parameter. This check is also selector-independent, so a plugin cannot bind receiver B correctly and then substitute receiver A’s pose during resolve().

The same materialization seam isolates every PLAN edge, not only endpoint facts. A consumer receives a canonical codec round-trip of each input rather than the producer’s stored Value; canonical bytes captured before resolve() must still match afterward or materialization refuses the plugin for mutating a bound input. Accepted outputs are codec-round-tripped again before storage, so returning an input payload directly or retaining a return-value reference cannot create mutable aliases into the resolved plan.

emitter_snr requires its numerator, measurement, and denominator to resolve to the same singleton. Receiver-index evidence follows canonical facts and ordinary complex-voltage signal transforms through all container interfaces; combiners union their signal-input sets. A scalar claim must resolve to one authored receiver matching the row. A repeat-local boundary or receiver plan remains a distinct authority per instance, and recursive aliases are refused. Authority is originated structurally only by a PLAN producer that publishes both receiver-index-bearing facts and ReceiverEntityRef. Consequently, an ordinary waveform plugin cannot forge or collapse custody by declaring a parameter named receiver_index; its signal output inherits the full input authority set.

receiver_incident_reference_snr uses the same lexical machinery for a variable-cardinality label. Its contribution input must resolve to the exact collection whose member producer set established the aggregate cited by both boundary facts and custody. Its variance must cite that same checked aggregate boundary. Select, conditional, repeat, and subgraph aliases preserve those scope-qualified collection and boundary identities; a collection with equal payload rows but a different producer origin is foreign. Runtime then compares the full ordered rows against internal custody before emitting only scalar SNR evidence, so neither static aliasing nor a forged third-party label can invent membership.

Endpoint identity projections deliberately encode one entity in singleton enum types, and endpoint-facts types preserve those exact nested projections. During scene materialization ValueTypeRegistry opts only ReceiverEntityRef, TransmitterEntityRef, PropagationEndpointFacts, and PropagationLinkFacts into structural variants, so multiple legitimate endpoint authorities receive exact, separate codecs. The registry default remains one kind/one structure, and graph-edge compatibility remains exact: opting these identity kinds into codec lookup does not make two endpoint types interchangeable.

OrderingExemptionSpec is the escape hatch, carried on GraphSpec.ordering_exemptions. Each entry names one producer, one consumer, and a non-empty reason; there are no wildcards, and an exemption naming a node the graph does not contain is refused rather than silently protecting nothing. It sits on the graph rather than on a node’s parameters because node parameters fold into plan_identity, and documenting an intended ordering must not re-identify a corpus.