Canonical architecture and node taxonomy

This document is the audit map for RFGen’s post-refactor scene-graph core. It describes what exists in the source tree, which contracts are public, how nodes and typed edges form an executable scene, how that graph becomes a persisted record, and where the current design is deliberately closed or visibly incomplete. It is descriptive, not a claim that every design choice is optimal.

Design provenance and present status

The architecture began in the internal decision record scene-graph-design.html on the former arch/scene-graph-foundation branch. That record proposed a bounded scene program compiled to a concrete graph, a closed semantic node-role set, rich typed edges, keyed randomness, and one aggregate record. It also proposed capabilities that were not part of the vertical slice. The merged implementation is the authority now; the historical record explains intent but must not be read as an inventory of shipped code.

The original eleven primitives map to the current implementation as follows:

Original primitive

Current status

Current owner/evidence

Plan materialization

Implemented

graph.materialization, PlanNode, PlanContext

Structured qualified codec types

Implemented

nodes.values closed algebra, compatibility, canonical codecs

Multi-input/output transforms and combiners

Implemented

typed ports/refs, Transform, Combiner

Aggregate-record terminal

Implemented as projection

RecordProjection, SceneRecord, project_record; it is not a node role

Fixed-cardinality failure/tombstone

Partial

generation retains sample coordinates and explicit shard failures; no general typed tombstone value is exposed

Batch execution ABI

Not implemented as a node ABI

execution calls scalar evaluate; generation may batch work externally, but nodes have no evaluate_batch contract

Borrowed-resource protocol

Implemented for the 3D-world case

RoleKind.RESOURCE has an ABC (ResourceNode), an entry-point registry, and MATERIALIZE semantics; scene_world is the one core implementation. Installed-package acceptance proves that opaque declared handle metadata can be resolved, serialized, and consumed without alias mutation, but does not attest that named external content exists or turn this into a generic resource-realization protocol

Rich identity and corpus schedule

Partial

canonical graph/record identity and SeedSchedule exist; the full proposed exact-quota identity-bearing scheduler is not a single core primitive

Grouped counterfactual disposition

Type vocabulary only/partial

phase/noise semantic qualifiers exist; no generic grouped-branch execution primitive exists

Semantic scene topology

Partial and RF-focused

scene, provenance, receiver, geometry, and propagation plan facts exist; no universal system/entity/activity topology contract is complete

Sealed applicability and preflight

Partial

normalization, scene-plan building, type analysis, backend capability checks, and config validation exist; there is no separately named immutable “sealed plan” API

This status table is intentionally conservative. A major audit should evaluate the architecture that shipped, not silently credit it with unimplemented parts of the original proposal.

System boundary

RFGen core has six top-level domain owners. A package belongs at this level only when it has an independently explainable responsibility and substantive implementation.

Owner

Responsibility

May depend on

rfgen.nodes

Operator contracts, concrete RF operators, and the value algebra. Not everything under it is a node: RoleKind is closed at eight members (SOURCE, TRANSFORM, COMBINER, ALLOCATOR, RESOURCE, LOOP, LABEL, PLAN), and nodes.values and nodes.waveform are shared vocabulary owned by nodes rather than by a role

Shared errors/plugins and third-party numerical libraries; not scene-plan building or execution

rfgen.graph

Declarative IR, structural materialization, binding, analysis, identity, execution, and record projection

Node contracts and nodes.values

rfgen.storage

Neutral persistence lifecycle and storage discovery; SDS implementation

Published record/shard contracts, not generation policy

rfgen.generation

Configuration, run/sample/shard seeds, orchestration, local and remote executors

Graph and storage public contracts

rfgen.inspection

Read-only validation and deterministic comparison of published collections

Generic storage access; SDS-specific physical checks remain in storage.sds

rfgen.annotation

Prompt claims, inference clients/executors, annotators, and append-only overlays

Generic inference and storage contracts

The root modules are intentionally package-wide infrastructure: rfgen.errors defines the shared exception hierarchy, rfgen.plugins owns entry-point discovery, and rfgen.cli composes domain-owned commands. The empty rfgen/py.typed file is the PEP 561 marker for inline type information; it is package metadata, not another owner.

The central dependency direction is:

Upstream contract

Downstream consumer

nodes.values

node roles and concrete operators

node roles

graph compiler

graph compiler

generation orchestration

storage abstraction

generation, inspection, and annotation

generation/annotation/inspection commands

root CLI composition

The typed job-to-pipeline authoring boundary and generated staged IR are documented in Pipeline authoring and staged compiler IR.

In particular, a node must not call scene-plan building or execution. The compiler knows how to stage nodes; a scientific operator knows only its parameters, typed ports, bound values, and keyed randomness.

The node model

Node is the single root abstract base class. Every node has exactly one core-owned RoleKind and implements spec() -> NodeSpec. NodeSpec is the declarative face of an operator:

  • params: a validated Pydantic model;

  • inputs and outputs: named Port objects with exact ValueType values;

  • refs: input wires to producer/output pairs;

  • repro: a ReproClass describing the reproducibility boundary.

When.PLAN means a value exists after per-sample plan materialization. When.REALIZED means it exists only after runtime evaluation. This distinction, rather than Python inheritance alone, determines scheduling.

Nodes, ports, refs, and edges

A scene graph is a directed graph whose vertices are Node instances. Each node declares named input and output ports. A Ref turns one producer output into one consumer input:

Ref field

Meaning

input_name

Consumer port receiving the value

producer

Structural name of the upstream node

output_name

Producer port supplying the value

The edge itself is therefore not a free-standing mutable object. Its identity is the consumer’s typed input port plus its Ref to a typed producer output. Analysis binds the two declarations and checks compatibility. Execution gathers the producer’s Value and presents it under the consumer’s input_name.

There are two scheduling classes of edge:

Edge class

Producer output

Meaning

Plan edge

Port(..., when=When.PLAN)

Fact is resolved during plan materialization. It may feed another plan node or a later realized node, but does not impose runtime evaluation order.

Realized edge

Port(..., when=When.REALIZED)

Value is produced by evaluate; it imposes runtime dependency order.

Edges carry data and impose order; they do not carry control flow. Authored selection, bounded repetition, conditionals, and named subgraphs belong to the GraphSpec/SceneProgram structural layer. Those constructs are resolved before the runtime DAG executes. General unbounded Python control flow is not a graph primitive.

The graph is acyclic after materialization. Plan-node cycles are rejected before plan resolution; realized cycles are rejected by analysis before evaluation. The graph has no mutable global “current scene” through which nodes communicate: every scientific dependency must be represented by a port/ref edge or by an explicit node-local backend dependency.

Granularity rule

One node per physical effect, wherever that effect is separably computable. Where the physics is genuinely coupled — a joint solve with no intermediate value to put a port on — one node covers the coupling and says so. Authoring burden is solved by composition, never by merging nodes.

This rule decides node boundaries. Two earlier tests are retained below with reduced standing, and one is withdrawn; both changes are recorded here rather than dropped, because the reasoning is what a future reviewer will need.

Why the rule is the physics, not the wiring

You cannot substitute what is not a seam. Five physical effects inside one node means a use case that wants a different model for one of them must fork the whole node, and then owns a copy of four effects it never intended to change. Modularity and visibility outrank node-count economy: a graph whose stages are visible can be read, reordered, and partly replaced, and a merged stage can only be replaced whole.

This follows from core being a substrate that provides options rather than decisions. Core must serve radar, drone, and LiDAR scenes as readily as comms ones, and a node that welds together the five effects a comms link happens to need is a decision imposed on every other domain.

Three properties fine granularity buys that a merged node cannot:

  • Ordering becomes authorable and visible. Order is physics. Which impairment precedes which is a scientific claim about where in the chain each effect occurs; inside one node that claim is a fixed implementation detail nobody can see or change.

  • Each effect gets independently keyed randomness, so one impairment can be toggled or reseeded without perturbing another effect’s draws. Merged effects share one generator, and adding or removing one of them silently moves every other realization in the node.

  • A label can cite a single effect as evidence. emitter_snr already names which node produced the noise it divided by (see EMITTER_SNR_REFERENCE_VOCABULARY); it can only do that because the noise is its own node. An effect buried inside a larger stage cannot be named by a record.

External attachment is a lower bound, not the driver

A computation must be its own node when any of these holds:

  • another graph component must reference its output;

  • it needs independent identity or keyed randomness;

  • callers need to substitute an implementation at that seam;

  • the compiler needs to schedule, stage, or validate it independently;

  • an evidence or scientific contract must be explicit at that boundary.

These are mandatory minima, not the test that earns a boundary. You may never merge across a point where something must attach. But satisfying none of them does not license a merge: a separable physical effect earns its own node whether or not anything currently attaches to the value between it and its neighbour.

Separable computability is the outer limit

The rule stops where the physics stops separating. A joint solve that produces no intermediate value is one node, and splitting it is undefined rather than merely inconvenient.

ray_tracing is the case that forces this clause. One sionna.rt.PathSolver call, with the resulting Paths.cir() turned into discrete-time taps, jointly produces line-of-sight and blockage, specular reflection, optional diffuse scattering, refraction, spreading loss, path delay, and Doppler. Those are distinct physics, but the solver exposes no state between them — there is no “post-reflection, pre-Doppler” field to put on a port — so one node covers them and says so in its docstring. (Note that rfgen neither exposes nor passes Sionna’s diffraction controls; a granularity argument must cite what the solver is actually asked to compute.)

Contrast the transmit-power scale in the same node, which is applied as an explicit separable step after the solve, because the CIR is a unit-power transfer function. Coupling is a property of the computation, not of the file it lives in.

“Prefer fewer nodes” is withdrawn

The earlier guidance instructed authors to prefer fewer nodes between the mandatory boundaries above. Its stated justification was that the fine receiver stages had never paid their authoring cost. They had not — no shipped configuration wires any transmitter-side or receiver-side hardware transform; the shipped graphs now use the three scene-realization transforms, one awgn, and one channel model. That is a reason the cost was never tested, not a reason to merge nodes. The guidance is withdrawn, and the audit that section asked for has been done; it is filed under .agent-state/architecture-decisions/.

What reviewers should enforce

A configuration that authors the same physical fact twice is a core bug, not a config style issue. When a template restates a position, a pose, a power, or a rate that another node already holds, the defect is that core gave the second node no port to read the first one through. Fix the port; do not standardize the duplication.

The obligation this rule creates

Independently wireable nodes mean nothing structural stops a use case wiring quantization before the mixer, or receiver noise before the channel. Fine granularity therefore requires nodes to declare their plane and precedence constraints, checked at validate time rather than left as an authoring obligation. That check does not exist yet and is being built separately; until it lands, ordering correctness is on the author.

Signal-chain ordering

Fine granularity is what makes a receiver front end substitutable, and it is also what lets a configuration wire the quantizer before the mixer. Nodes therefore declare where in a signal chain they act, and rfgen validate refuses a graph whose realized signal paths contradict those declarations.

Two class attributes on Node, both optional and both defaulting to None:

Attribute

Meaning

signal_plane

SignalPlane.TRANSMITTER, PROPAGATION, or RECEIVER. None means the node makes no claim.

plane_stage

An ordinal within that plane. None means the node has a plane but no fixed position inside it.

The rule: along any path the signal actually flows, a node’s plane may not precede its producer’s plane, and within one plane a node’s stage may not precede its producer’s stage. Only realized edges are paths — a plan edge carries a resolved fact, not a signal, and imposes no physical order.

Three properties matter to a node author:

  • None is silence, not a gap. A node declaring neither is never refused and never refuses anything, so the mechanism is additive and every node written before it existed keeps working. Equal stages are unordered, and so is any pair where either side declares None.

  • Transparency is per-comparison. A node with no plane at all is transparent to both comparisons — it can neither cause a violation nor hide one. A node with a plane and no stage is transparent to the stage comparison only, and remains an endpoint for the plane comparison. Without that split, splicing a 1:1 rational_resampler between a quantizer and a mixer would silently convert a refusal into valid.

  • The declaration is on the node, so a third party is checked identically. A package registering its own receiver stage through an entry point declares its own plane; core holds no table of node names. The signal_plane check in rfgen.nodes.testing refuses the two declarations that otherwise fail silently — a stage with no plane, and a plane that is not a SignalPlane member.

Neither attribute folds into plan_identity. They constrain what a graph may say, not what it computes, so declaring one moves no corpus.

Core is deliberately conservative about what it declares, because a mechanism that refuses legitimate physics teaches authors to route around it. It ships the three-plane order and one within-plane rule — the receiver’s analog front end precedes its converter. rational_resampler remains the stage-less receiver variant because it is legitimate on either side of the converter. The paired source and placed selectors are distinct transmitter stages: source-grid correction precedes absolute power calibration, and placed-grid correction follows it.

Declaring an intended ordering

A configuration that means an unusual ordering declares it rather than working around it, with a graph-level ordering_exemptions list:

Static Config — not runnable

Illustrative configuration fragment; not a complete runnable config.

graph:
  schema_version: 1
  ordering_exemptions:
    - producer: early_mixer
      consumer: noise
      reason: modelling an IF-referred noise floor on purpose
  body:
    ...

Each entry names exactly one producer/consumer pair and a non-empty reason. It cannot be widened into an off switch: 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 — documenting an intended ordering must not re-identify a corpus, or saying why would cost more than quietly routing around the check.

What the check does not read

Select and conditional containers are transparent: a branch’s declared outputs are traced back to the inner nodes that produce them. Repeat bodies and subgraph calls are checked internally but are opaque at their boundary, so a path that enters one and leaves it is not followed across. That is a false negative and never a false positive.

ReproClass has four values:

Class

Contract

DETERMINISTIC

Same typed inputs and seed produce byte-identical output

ENV_BOUND

Output may depend on pinned library, runtime, or hardware behavior

OBSERVED

Output comes from an external measurement or trusted capture

NONDETERMINISTIC

No stronger repeatability guarantee is made

Role hierarchy

Role

Base class

Runtime method

Semantic contract

Built-in concrete implementations

Source

Node -> Source, then Source -> WaveformSource for RF

evaluate(inputs, seed)

Has no input ports; produces typed outputs. Source itself makes no radio claim; the WaveformSource subclass adds occupied bandwidth, bandwidth semantics, and active extent

26 canonical selectors, listed below

Transform

Node -> Transform, then Transform -> WaveformTransform for RF

evaluate(inputs, seed)

Maps distinct typed dependencies to outputs. Transform requires no signal port; the WaveformTransform subclass is the one that carries the RF signal surface

24 canonical selectors, listed below

Combiner

Node -> Combiner

evaluate(inputs, seed)

Reduces multiple inputs under a declared algebra and canonical input order

WaveformSum, ReceiverStack

Plan

Node -> PlanNode

resolve(inputs, PlanContext)

Produces only plan facts during materialization, before execution

SceneFactsPlan, SourceProvenancePlan, ReceiverSetupPlan, GeometryGroundTruthPlan, PropagationEndpointFactsPlan

Allocator

Node -> PlanNode -> Allocator

resolve(inputs, PlanContext)

Many-to-many plan-time assignment

No concrete built-in currently

Label

Node -> Label

evaluate(inputs, seed)

Derives typed metadata only from declared evidence inputs

DetectionGroundTruthLabel, EmitterSnrLabel

Resource

Node -> PlanNode -> ResourceNode

resolve(inputs, PlanContext)

Resolves a handle naming a borrowed world, at MATERIALIZE, imposing no RUN ordering. Distinct from Plan in what it resolves rather than when: a plan node resolves a fact, a resource node resolves a name for something a realized node will borrow and realize

SceneWorld

Loop

Declared only as RoleKind.LOOP

None

Reserved; GraphSpec analysis explicitly rejects execution

None

The last two rows are important audit facts. The role enum is versioned and closed, but the implemented role surface is not symmetrical: Allocator is an abstract specialization with no concrete built-in, and LOOP is a reserved enum member without a role ABC, catalog factory, compiler behavior, or implementation. An architecture review should decide whether that is a justified reservation or premature public surface.

RESOURCE was in the same position until scene_world gave it a consumer. Two sites rejected its execution – graph/spec.py and graph/analysis.py – and both now reject LOOP alone. The lift was deliberately narrow: ResourceNode subclasses PlanNode, so MATERIALIZE, which selects what it resolves by isinstance(node, PlanNode) rather than by role, resolves resource nodes through the pass that already existed. MATERIALIZE_ROLES in graph/analysis.py is the set that groups it with the plan roles wherever ordering is decided.

The resource role and the world-and-tracer seam

A resource node answers “what world is this scene in?” and hands back a name for it. scene_world is the only implementation.

One physical object is one nested entry. A target declares its mesh, its material, its scattering coefficient, its size, its pose and its velocity together:

Static Transcript — not runnable

Excerpt of the shipped chirp-radar-scene.yaml, quoted to show the one-object-one-entry shape; not a complete runnable configuration.

- kind: node
  name: world
  role: resource
  selector: scene_world
  params:
    base:
      kind: sionna_builtin_scene
      uri: sionna://builtin/floor_wall
      content_hash: sha256:...
    objects:
    - object_id: drone_0
      mesh_uri: sionna://mesh/sphere
      mesh_content_hash: sha256:...
      material: metal
      scattering_coefficient: 0.9
      position_m: [80.0, 0.0, 50.0]
      velocity_mps: [-50.0, 0.0, 0.0]
      sampling: {bounding_radius_m: 1.0, range_m: [40.0, 400.0]}

Each object becomes one nested field of the emitted world type, named by its own object_id. That is what makes an object individually addressable, and it means adding, removing or renaming one changes the world’s type – so a consumer wired to an object that no longer exists fails at rfgen validate rather than silently reading another target.

The handle is a description, not a live scene. A Sionna Scene is not JSON-like and could not be hashed into plan_identity, and MATERIALIZE’s output crosses a process boundary in distributed generation. So the handle carries content-addressed asset references and per-record poses, and the consumer builds the live object. ray_tracing takes it on an optional world input port; with no world bound it behaves exactly as it did before the port existed, solving in the static asset its own parameters name.

A world and a tracer must not both author the scene. Binding a world while also declaring geometry_uri, or declaring targets beside a world, is refused at bind time: the world already names its base geometry and already positions every object, and every placed object is already a target for the biased launch.

Two configurations that would publish an empty corpus are refused. Both were measured rather than reasoned about, monostatic, 1 m sphere at 100 m:

  • scattering_coefficient: 0.0 – Sionna’s own ITU default – returns zero paths, and no ray budget changes that. A purely specular return from a curved surface back towards the illuminator is a single point that the solver’s shooting-and-bouncing search does not land on, so diffuse scattering is what carries a monostatic return. The coefficient is therefore required and required to be non-zero.

  • diffuse_reflection: false with a world bound is refused for the same reason.

A world’s objects get the same ray-budget guard an explicitly declared target gets. refuse_unusable_target_budget runs on whichever target list the node actually has, at bind time, so a starved cone budget is refused at rfgen validate whether the target was authored as an explicit targets entry or as a scene_world object. This is why each object’s sampling envelope is carried in the world’s type rather than only in its payload: a bound input’s type is what rfgen validate can see, and an envelope that only existed in a record could not be checked until a corpus was already being generated. It also means target_sampling is authorable beside a world — the budget knob applies to the targets the world supplies.

A moving target needs the time axis switched on. cir_num_time_steps defaults to a single snapshot, which applies the impulse response as a time-invariant filter and discards the Doppler that Sionna computed. Set it to any value greater than one to select the dynamic contract, with cir_sampling_frequency_hz at the capture rate. The published result then has exactly N coefficient epochs, one for each retained capture sample; the authored value is a static/dynamic selector rather than an independently owned extent. Otherwise a record carries a radial-velocity label with no evidence for it anywhere in the signal – and because the result is not bit-identical, a digest comparison does not catch it.

What is Sionna’s and what is not. The range law, the round-trip delay and the Doppler shift are all its path solver’s own calculations; nothing in this role or in the tracer computes a gain, a range law, or a Doppler shift. Sionna reads each scene object’s velocity attribute directly (sionna.rt.path_solvers.field_calculator), which is why a target’s Doppler needs no code here.

Known limits. Sionna gives each object one velocity, so a part of an object moving differently from the whole – a spinning rotor blade – cannot be expressed. That is micro-Doppler, and it is out of scope; adding it later means adding a node beside the motion source, not rearranging this seam. Separately, ray_tracing does not expose Sionna’s diffraction controls, so nothing here models diffraction.

Shared role invariants

All concrete operators must satisfy these compiler-enforced rules:

  1. Declaration is pure: spec() does not perform the scientific computation.

  2. Every reference names a declared input and a real producer output.

  3. Producer and consumer ValueType values are compatible before execution.

  4. Runtime output names exactly equal declared output names.

  5. Every runtime output is a Value whose vtype exactly equals its declared type.

  6. Randomness is keyed by sample and structural node path, not consumed from a shared sequential stream.

  7. Combiners receive inputs sorted by input-port name so floating-point reduction order is independent of scheduler or wiring iteration.

  8. Labels may derive claims only from explicit evidence ports; they may not characterize IQ to manufacture missing source facts.

Source implementations

Source plugins are discovered from the rfgen.nodes.source entry-point group. The canonical selectors and implementations are:

Family

Selectors and concrete classes

Basic

tone -> ToneSource; chirp_radar -> ChirpSource; torchsig_chirp -> TorchSigChirpSource

Analog

am -> AMSource; fm -> FMSource; analog_fpv_video -> AnalogFpvVideoSource

Digital modulation/coding

apsk -> APSKSource; constellation -> ConstellationSource; fsk -> FSKSource; oqpsk -> OQPSKSource; ofdm -> OFDMSource; nr_ofdm_conformant -> ConformantOFDMSource; fec_constellation -> FECConstellationSource

Protocol

adsb -> AdsbSource; ble -> BleAdvertisingSource; droneid -> DroneIdSource; fhss_rc_link -> FhssRcLinkSource; lora -> LoRaSource; lte -> LTECaptureSource; nr_pusch -> NRPuschSource; ocusync_surrogate -> OcuSyncSurrogateSource; remote_id -> RemoteIdSource; wifi -> WifiOFDMSource; zigbee -> ZigbeeOQPSKSource

Radar

pulsed_radar -> PulsedRadarSource

Trusted replay

sigmf_capture -> PlaybackSigMFSource

Several additional entry-point names (torchsig_comms, torchsig_fsk, torchsig_ofdm, torchsig_am, torchsig_fm, torchsig_tone, torchsig_target_constellations, and lora_sdr) resolve to those same current classes. They are selector aliases, not separate implementations. This is a useful audit target: aliases preserve configuration vocabulary but expand the public selector surface.

Every source subclass declares its own params_type. Source.from_params() validates authored parameters before construction. WaveformSource also requires occupied_bandwidth_hz() and active_extent_samples() so placement consumes declared/resolved source evidence rather than estimating bandwidth from generated IQ.

Transform implementations

Transform plugins are discovered from rfgen.nodes.transform.

Stage/family

Selectors and concrete classes

Scene realization

carrier_translation -> CarrierTranslation; time_placement -> TimePlacement; placement_evidence -> PlacementEvidence; compatibility selector scene_placement -> ScenePlacement

Transmitter

carrier_frequency_offset -> CarrierFrequencyOffset; leeson_phase_noise -> LeesonPhaseNoise (synthesis only); phase_rotation -> PhaseRotation (applies any phase process); rapp_pa -> RappPowerAmplifier; saleh_pa -> SalehPowerAmplifier; transmit_power -> TransmitPower

Receiver chain

receiver_input_boundary -> ReceiverInputBoundary; receiver_incident_aggregate_boundary -> ReceiverIncidentAggregateBoundary; awgn -> AWGNTransform (synthetic target-SNR receiver-input noise); receiver_thermal_noise -> ReceiverThermalNoise; automatic_gain_control -> AutomaticGainControl (loop only); receiver_voltage_limiter -> ReceiverAnalogVoltageLimiter; iq_imbalance -> IQImbalance; converter_scaling -> ConverterScaling; converter_rounding -> ConverterRounding; converter_clipping -> ConverterClipping; converter_reconstruction -> ConverterReconstruction; if_fir_response -> IFFIRResponse; if_scalar_gain -> IFScalarGain; fir_group_delay_realignment -> FIRGroupDelayRealignment; receiver_lo_error -> ReceiverLOError; receiver_mixer -> ReceiverMixer; rational_resampler -> RationalResampler; sample_clock_offset -> SampleClockOffset

Basic propagation

deterministic_identity_transport -> DeterministicIdentityTransport (pose-free association, no spatial propagation modeled); sionna_rayleigh_block -> SionnaRayleighBlockFading; sionna_flat_fading -> SionnaFlatFading; pdp_rayleigh_fading -> PDPRayleighFading; receiver_incident_contribution -> ReceiverIncidentContribution; receiver_incident_aggregate -> ReceiverIncidentAggregate; receiver_incident_member -> ReceiverIncidentMember

Transmitter limiting

transmitter_crest_limiter -> TransmitterCrestLimiter

CIR consumers

apply_cir -> ApplyCIR; cir_to_cfr -> CIRToCFR

Sionna link/system models

sionna_tdl -> SionnaTDL; sionna_cdl -> SionnaCDL; sionna_uma -> SionnaUMa; sionna_umi -> SionnaUMi; sionna_rma -> SionnaRMa; tr38901_pathloss -> TR38901PathLoss (path loss, factored out of the three system-level models so it can be substituted on its own)

Ray tracing

ray_tracing -> SionnaRayTracing

Transform.from_params() enforces the class’s declared dependency_names, maps any external port names through dependency_ports, validates parameters, and supplies typed refs to the constructor. The role base declares no dependencies at all (Transform.dependency_names = ()): core does not presume that a transform consumes a signal. WaveformTransform is the subclass that declares ("signal",) and hands the resolved TensorType to the constructor, so every transform in the table above whose primary input is complex baseband derives from it. A transform needing geometry, placement, or other evidence declares those dependencies itself.

Placement custody is resolved from the bound port surface, not nominal Python inheritance. Any transform with realized complex-voltage input and output ports must declare the closed placement-effect disposition even when a third-party author subclasses Transform directly. Generic transforms with no voltage surface remain unaffected.

All registered propagation producers implement PropagationProducer, declare typed conditioning, and bind one exact PLAN propagation-authority value. Geometry-, kinematics-, pathloss-, and RT-conditioned families require posed PropagationEndpointFacts. The bounded deterministic_identity_transport context-only family instead requires pose-free PropagationLinkFacts, states that geometry is not modeled, and leaves the signal bytes unchanged. The conditioning-aware registration/probe boundary applies this matrix to third-party producers too. ApplyCIR is an applicator, not a propagation producer.

Registered receiver-input boundaries similarly implement ReceiverInputBoundaryProducer. The catalog checks their canonical four-input PLAN/REALIZED staging and exact signal/facts outputs; the graph recognizes that class contract rather than trusting a node-name or boolean claim. Propagation nodes declare PropagationAncestry: producers establish an immutable singleton from their exact endpoint-facts producer, ApplyCIR inherits it from the CIR, and cascaded effects compare and preserve it. Ordinary signal transforms transparently preserve all incoming authorities, combiners union them, and select, conditional, repeat, and subgraph interfaces cannot erase them. The single-link boundary requires the resulting set to be exactly its own posed or pose-free authority producer and refuses a mixed multi-link set. The separate receiver-incident aggregate contract admits a nonempty set only when each atomic signal remains paired with its exact link/emitter/support row and all links terminate at one receiver; its specialized boundary retains the ordered relation instead of collapsing it. A pose-invariant PropagationLinkRef remains the persisted identity, but equal ids from a separately authored authority do not satisfy this graph-level causal check.

The same completed-graph resolver owns receiver custody; there is no second scope-local comparison of immediate producer names. It carries separate sets for checked ReceiverInputBoundaryProducer identities and authored receiver-plan/index evidence through select, conditional, repeat, and subgraph interfaces. Noise nodes preserve the exact boundary they cite. Ordinary transforms preserve boundary and receiver identity only on complex-voltage signal outputs, while combiners union every input authority, so an arbitrary facts output cannot forge custody and a mixed signal cannot masquerade as one receiver. emitter_snr accepts only one matching boundary across numerator, measurement, and noise and only one scope-qualified receiver authority matching each row’s receiver index.

The eight Sionna CIR producers and pdp_rayleigh_fading have no primary IQ input. They derive directly from Transform, consume the PLAN-staged scene_facts.sample_grid, and publish CIR values for the separate apply_cir waveform operator. This is a custody boundary: describing the capture clock and finite extent does not grant a solver ownership of waveform samples.

Plan, combiner, and label implementations

These roles register through entry-point groups on exactly the terms sources and transforms do: rfgen.nodes.plan, rfgen.nodes.allocator, rfgen.nodes.label, and rfgen.nodes.combiner. The selectors below are core’s own registrations inside those groups, not a closed catalog. Core ships no allocator of its own; rfgen.nodes.allocator exists so a use case can add frequency or PRN assignment without a core change.

Selector

Role

Purpose

scene_facts

Plan

Resolve scene-level authored facts and the authoritative finite CaptureSampleTimes grid

source_provenance

Plan

Carry source/provenance facts

receiver_setup

Plan

Resolve one receiver’s front end, and its pose when it declares one

receiver_measurement

Plan

Bind receiver identity to the receiver-input plane, nominal bandwidth, explicit ENBW, noise figure, and impedance

transmitter_setup

Plan

Resolve one transmitter’s identity, pose, and radiated power

declared_power_delay_profile

Plan

Publish one deterministic analytic PDP and its RMS delay-spread fact

propagation_link_facts

Plan

Join exact TX/RX identities into one pose-free directed association with spatial_scope = not_modeled

propagation_endpoint_facts

Plan

Refine TX/RX identity with authoritative pose and scene frame for a directed link; never owns transmit power

geometry_ground_truth

Plan

Project geometry from the same authoritative endpoint fact propagation consumes

waveform_sum

Combiner

Canonically sum waveform inputs

ragged_waveform_sum

Combiner

Sum a ragged collection of equal-length waveforms into one signal

receiver_stack

Combiner

Stack receiver outputs in canonical order

collect

Combiner

Gather items into one ragged collection; splices ragged items with fixed rows

detection_ground_truth

Label

Produce typed detection ground truth from bound evidence

emitter_snr

Label

Produce emitter/receiver SNR metadata from bound evidence

receiver_incident_reference_snr

Label

Produce ordered contained-member reference SNR rows against one aggregate-boundary variance

The retired sionna_cir_dataset composite is not a transform selector; its compatibility Python class must not be used as an authoring surface. A custom-PDP graph binds declared_power_delay_profile to pdp_rayleigh_fading, then binds that CIR to apply_cir.

collect, and why it is spelled without an underscore

collect closes a kind: repeat expansion. Inputs are read in canonical input-name order and concatenated under the declared ragged output type, and each input contributes according to its own type:

  • a tensor item contributes one row, of its own leading length;

  • a ragged item contributes its own rows, renumbered onto the running offset.

The second case is the ragged-plus-fixed splice, and it is the reason this selector needs a public name. A scene whose repetition draws some of its entities and not others — six satellites drawn by a repeat, plus a victim and a jammer that are fixed — has to put all eight into one field, one family, one row partition. collect does exactly that, and has since before it was documented.

It was private twice: first as a hard-coded selector == "_collect" branch in the scene-plan builder, then as a registered combiner still spelled _collect. Both spellings read as “not yours to use”. A use-case package that needed the splice concluded core had no such node, and shipped a second box family under its own field prefix instead — which core’s own readers then could not see. The underscore spelling is retired, not aliased: keeping it would leave the signal that caused the problem in the catalog.

Renaming a selector moves no record identity. Plan identity folds the implementation’s module:QualName and deliberately not the binding selector, because selector-to-class is not injective.

Scene realization has three independently substitutable nodes. Carrier translation shifts the finite emitter waveform and publishes its frequency decision. Time placement then pads that already-shifted waveform onto the authoritative scene_facts.sample_grid and publishes its start and length. Placement evidence consumes those two typed decisions plus source facts to publish the established gridded ScenePlacementFacts contract. The order is deliberately shift-before-place: phase is emitter-local, so moving an otherwise identical event in time does not rotate its samples. CarrierTranslation accepts signal and source facts from one source and republishes the validated facts. TimePlacement requires the signal, carrier facts, and source facts directly from that same carrier node, then republishes both fact streams. PlacementEvidence requires all three fact inputs from the same time-placement node. Reversing nodes, inserting another transform, or mixing evidence across emitter chains is therefore rejected during binding. scene_placement remains a compatibility selector for existing shipped configurations while they migrate; new graphs use the three selectors above.

source_evidence_resampler is the checked pre-power source-rate conversion stage. Its signal and EmitterFacts must resolve to the same source authority; it publishes both under one new authority before carrier translation and time placement. placed_evidence_resampler performs the corresponding correction after placement while retaining exact signal/facts custody, but still before final absolute power calibration. A paired transmit_power then counts total realized finite-vector energy, including FIR tails, against the nominal resampled emission-cell count. That explicit energy-equivalent convention is separate from legacy active-support power and is published with the measured power. Putting either resampler after final power is a stage violation.

Authoring endpoint facts once

Every physical fact about an endpoint has exactly one owning node. transmitter_setup owns a transmitter’s index, id, pose, velocity and tx_power_dbm; receiver_setup owns a receiver’s front end and, optionally, its pose. propagation_endpoint_facts joins identity and pose projections into one directed-link value containing link id, endpoint roles, and the explicit scene frame. It does not own or include transmit power. Geometry ground truth and every registered propagation/pathloss solver for that link consume this same value, so no solver can re-author a second pose:

Static Config — not runnable

Illustrative configuration fragment; not a complete runnable config.

- kind: node
  name: link
  role: plan
  selector: propagation_endpoint_facts
  params:
    link_id: tx0-rx0
  inputs:
    tx_identity:
      ref: {node: transmitter, output: identity}
      when: plan
    tx_pose:
      ref: {node: transmitter, output: pose}
      when: plan
    rx_identity:
      ref: {node: receiver, output: identity}
      when: plan
    rx_pose:
      ref: {node: receiver, output: pose}
      when: plan

Transmit-power truth remains a separate cited value from transmitter_setup.tx_power_dbm wherever an operator actually applies or labels it. receiver_measurement likewise cites receiver setup and separately authors ENBW and impedance; the final receiver_input_boundary binds that measurement plus link.facts and the final propagation signal/facts. This separation keeps geometry custody, power evidence, and receiver measurement evidence independently inspectable.

!!! warning “Omitting when: plan gives an unhelpful refusal”

These are plan edges. Leaving `when: plan` off one produces a generic
"schema differs" message naming the port but not the fix, because the edge
is then compared as a realized wire against a `When.PLAN` port. If a newly
added plan input reports a schema mismatch, check `when` first. This
applies to every plan edge, not only these; it is called out here because
these are the ones most recently added.

Node binding and extension boundary

Authored graphs do not instantiate arbitrary Python classes. Plan building creates a NodeBinding containing role, selector, validated parameter mapping, explicitly typed input wires, and expected outputs. NodeCatalog.bind() resolves the role-qualified selector and verifies that the factory returns the requested role.

Every role has an external fallback factory. core_node_catalog() registers a RoleRegistryFactory for all seven roles, each scanning its own entry-point group:

Role

Entry-point group

Role base class

Source

rfgen.nodes.source

Source

Transform

rfgen.nodes.transform

Transform

Plan

rfgen.nodes.plan

PlanNode

Allocator

rfgen.nodes.allocator

Allocator

Resource

rfgen.nodes.resource

ResourceNode

Label

rfgen.nodes.label

Label

Combiner

rfgen.nodes.combiner

Combiner

The factory resolves the selector in its group, refuses a loaded object that is not a subclass of the role’s base class, and constructs it through Node.from_binding(). Plan, allocator, and resource nodes consume and publish only When.PLAN ports; realized roles publish only When.REALIZED outputs. That does not prohibit a Source from consuming declared plan dependencies: those inputs are already known before its realized evaluation. More specific role rules still live on their base contracts; for example, a WaveformTransform requires a typed signal input.

The node role set stays closed while registration within a role is open. Adding a new role is a versioned core change; adding a source, transform, plan node, allocator, resource, label, or combiner implementation can be an independently installed entry point.

The one limit on that openness is name ownership. A selector core registers itself is shared vocabulary – waveform_sum means linear RF superposition and detection_ground_truth means core’s detection truth in every install, or the name means nothing – so EntryPointRegistry.register_core() raises RegistryError naming the shadowing distribution and a free alternative name rather than letting an installed distribution silently take the name over. A third party registers as many selectors as it likes, under names of its own. Two installed distributions also cannot claim the same external selector: discovery, catalog listing, origin/target inspection, and loading all refuse that ambiguity rather than exposing whichever distribution happened to be scanned last.

Value algebra

rfgen.nodes.values is the shared type system for graph edges and persisted fields. Its closed type union is TensorType | StructType | RaggedType | ScalarType | EnumType. Types carry the information needed for structural checking and stable identity:

  • dtype and widening policy;

  • tensor axes and symbolic dimensions;

  • physical units;

  • grid, clock, alignment, measurement-plane, phase, noise, and reference-frame semantics;

  • nested and ragged structure;

  • canonical type bytes and value codecs.

Value couples a runtime payload with its exact ValueType. The compiler does not infer a type from a returned tensor. This makes units, axes, semantic qualifiers, and serialization part of the executable contract rather than documentation-only metadata.

Geometry schemas belong to nodes.plan.geometry, detection schemas and native label projections belong to nodes.label, and source-specific metadata belongs beside its source node. The generic graph compiler must not import those domain-specific schemas.

Compiler lifecycle

There are two related authoring paths that converge on ScenePlan:

  1. A declarative GraphSpec is normalized and structurally analyzed. materialize_graph_spec() expands selections, repeats, conditionals, and subgraphs for one sample. build_scene_plan() binds selectors and typed refs through NodeCatalog, producing a ScenePlan and RecordProjection.

  2. Python callers may construct a ScenePlan or SceneProgram directly.

The executable pipeline then proceeds as follows:

Phase

Input -> output

What it is allowed to do

Authored analysis

GraphSpec -> GraphAnalysis

Validate strict schema, scopes, refs, draws, subgraphs, and record projection; run no node

Structural materialization and binding

GraphSpec -> BuiltGraph

Resolve bounded structure for (sample_index, run_seed) and bind concrete nodes

Plan materialization

ScenePlan -> ResolvedScenePlan

Run only PlanNode.resolve() in plan-edge topological order using keyed structural randomness

Runtime analysis

ResolvedScenePlan -> AnalyzeResult

Type-check edges, reject cycles, compute schedule, and assign content identity

Execution

resolved plan + schedule -> node bindings

Run realized roles once each with per-node keyed seeds and validate exact outputs

Projection

node bindings + RecordProjection -> SceneRecord

Select and name typed outputs; preserve graph identity

Publication

records -> Storage.write_shard/publish_shards

Persist through the neutral storage contract; SDS is the default implementation

The two uses of “materialize” are related but distinct: the public GraphSpec phase chooses concrete graph structure, while executable plan materialization resolves When.PLAN values. This naming is accurate in code but may be a cognitive and API-design concern worth testing in the audit.

Lifecycle glossary

Five stage names for a reader who wants the shape of the pipeline in one view. The phase table above remains the precise account; this is the summary that table is a refinement of.

Stage

What happens

CONFIG

A scene graph is authored in YAML

COMPILE

Spec -> resolve plan values -> bind nodes -> type-check every edge

EXECUTE

Per sample: nodes run, producing waveforms and typed facts

RECORD

Selected node outputs become the record’s fields and labels

STORE

Record -> SDS on disk

COMPILE covers authored analysis, structural materialization and binding, plan materialization, and runtime analysis; EXECUTE is the execution phase; RECORD is projection; STORE is publication.

These are reader-facing labels, not identifiers. No function, class, or module is named after them, and none was renamed to match them: build_scene_plan() and project_record() are public API and keep their names, because renaming a published surface for a readability gain in prose is not a trade this project makes.

The distinction that lets both vocabularies stand: a type name may carry precise jargon; a stage name in a reader-facing summary may not. RecordProjection is a projection in the relational sense — which node outputs become which record fields — and a reader meeting the type has its definition to hand. A reader meeting PROJECT as a stage label in a pipeline summary reaches for the common noun first, and nothing at that point corrects them; RECORD says the same thing without the ambiguity. The same reasoning retired the compiler stage word this codebase used to carry: build_scene_plan() says what it returns.

Worked shipped graph: chirp radar

The shipped generation/templates/chirp-radar.yaml demonstrates the current model without relying on a hypothetical future role. Once built, its main dataflow contains these nodes:

Node

Role/selector

Principal incoming edges

Principal outputs

scene

Plan / scene_facts

None

sample rate, duration, bandwidth, carrier, sample count, and finite capture grid

source

Source / chirp_radar

None

chirp IQ, authored waveform facts, source metadata

provenance

Plan / source_provenance

None

source identity/provenance facts

receiver

Plan / receiver_setup

None

receiver facts and receiver pose

placement_carrier_translation

Transform / carrier_translation

source.signal

emitter-local translated IQ and carrier facts

placement_time_placement

Transform / time_placement

carrier-translation outputs plus scene.sample_grid at PLAN

scene-aligned IQ and time-placement facts

placement

Transform / placement_evidence

source, carrier, and time facts

gridded placement facts

ground_truth

Label / detection_ground_truth

placement, provenance, source, and receiver facts

detection fields and segmentation mask

transmitter

Plan / transmitter_setup

None

transmitter facts, transmitter pose, transmit power

geometry_tx, geometry_rx

Plan / geometry_ground_truth

plan edges citing transmitter.pose / transmitter.tx_power_dbm / receiver.pose

typed geometry pose/provenance fields

channel_cir

Transform / sionna_tdl

scene.sample_grid plus carrier and endpoint plan facts; no IQ

unit-transfer CIR, CIR facts with the full leaf application grid, channel facts, backend provenance

channel_apply

Transform / apply_cir

independently power-scaled IQ plus channel_cir outputs

propagated IQ

emitter_snr

Label / emitter_snr

placed IQ and placement facts

per-emitter/per-receiver SNR fields

The record projection is not another executable node. It selects the retained post-channel signal as the persisted waveform and projects authored source facts, scene facts, detection fields, geometry, channel evidence, and SNR fields into one SceneRecord. This distinction keeps record layout policy out of scientific operators while binding every persisted field to a producing node and the same graph identity.

That shipped example remains on the exact unframed detection variant. A graph that opts into entity-group framing additionally supplies the one checked EntityGroupFrame and each event’s checked transmitter identity. The label derives (group_code, global emitter_index) from that causal identity and publishes the frame once; it does not own or reconstruct a private catalog.

Notice that plan and realized dependencies meet only at application. The CIR producer receives the capture grid, carrier, and poses as plan-time facts and never receives IQ. apply_cir receives realized independently power-scaled IQ and the realized CIR. The CIR facts persist that same full grid as their leaf-level application_grid, so clock, origin, cadence, and finite extent survive canonical identity and SDS even for a static one-epoch CIR. Materialization has already fixed the capture grid before either executes. The typed ports make that staging explicit and reject a graph that wires a value at the wrong phase.

Randomness and identity

Generation owns run/sample/shard scheduling in generation.seeds. Graph-owned StructuralPath and keyed splitting determine structural and per-node draws. The dependency points from generation scheduling into graph execution, never from graph into orchestration.

Node identity includes canonical parameter/type/ref structure. Structural paths use typed, length-framed encoding so authored names cannot alias different path shapes. Runtime scheduling is derived after plan resolution, while identity is independent of incidental iteration order.

The intended reproducibility claim is scoped by ReproClass: deterministic operators promise byte identity, whereas environment-bound scientific backends require compatible runtime/library evidence rather than pretending to be pure functions across environments.

At the level of a whole published corpus this resolves into two bars, not one. Ground truth is compared exactly, because labels are decided in the plan layer and never reach a parallel floating-point reduction; waveforms are compared within a relative tolerance, because an environment-bound solver sums thread contributions in completion order and floating-point addition is not associative. Requiring byte identity of the corpus was wrong for any graph containing such a node. See engineering principles.

Storage, inspection, and annotation boundaries

Storage is the persistence ABC with three lifecycle operations: write_shard, publish_shards, and open. SdsStorage is discovered through the rfgen.storage entry-point group and is the built-in default. Generation, Dataproc, inspection, annotation, collision handling, and deterministic comparison use the abstraction rather than constructing SDS directly.

Inspection is read-only. Generic inspection opens a RecordCollection through Storage; SDS-specific physical validation remains with the SDS implementation. Annotation opens an existing collection, prepares keyed inference requests, validates exact response identity and structured output, then publishes a dense, create-only annotation overlay. It does not mutate generation records.

Architecture invariants enforced in CI

The architecture checker enforces:

  • exactly the six substantive top-level domain directories;

  • only cli.py, errors.py, plugins.py, package markers, and py.typed at the root;

  • absence of retired facade/foundation/workflow/adapter/config/resource trees;

  • no wildcard facades, duplicate implementations, unexplained stubs, vague buckets, action-oriented module names, or .pyi mirrors;

  • no node import of scene-plan building or execution;

  • exact inventories for config models, packaged resources, CLI commands, entry-point targets, and public exports.

These checks enforce ownership topology and reachability. They do not prove that a scientific model is realistic, that every public abstraction is needed, or that each role boundary is the best one. Those require the design and scientific audits below.

Major-audit checklist

Use this list to separate architectural questions from implementation bugs:

  1. Role completeness: Should LOOP exist without a contract? Should Allocator remain a distinct role without a concrete implementation? (RESOURCE no longer belongs in this question: it has an ABC, a registry, compiler semantics, and one implementation.)

  2. Selective extensibility: Is opening only source/transform plugins the correct boundary, or do plan/label/combiner implementations need safe public factories?

  3. Staging clarity: Are GraphSpec structural materialization and plan-value materialization sufficiently distinct in name, API, and invariants?

  4. Type authority: Are exact ValueType equality and the current compatibility rules strict enough for units/axes, but not so strict that legitimate polymorphism becomes impossible?

  5. Evidence direction: Can any label, transform, or inspection path create scientific facts that should have been authored or produced upstream?

  6. Identity: Does canonical identity cover every behavior-changing input while excluding scheduler, filesystem, and process accidents?

  7. Randomness: Are structural draws, per-node draws, and run/shard/sample scheduling separated under all nested/repeated/subgraph cases?

  8. Backend boundaries: Are Sionna/TorchSig/runtime dependencies lazy and accurately classified as deterministic versus environment-bound?

  9. Catalog coherence: All seven roles now resolve through one entry-point registry per role, with core’s own selectors registered inside it and protected by register_core(). Does that single track hold everywhere, or does any selector still reach the catalog by a second path?

  10. Selector aliases: Are retained selector aliases valuable configuration vocabulary, or unnecessary compatibility surface after a clean break?

  11. Publication neutrality: Can a non-SDS storage implementation complete generation, reopen, inspection, annotation, collision, and comparison without relying on SDS details?

  12. Domain ownership: Does every geometry, label, source metadata, prompt, template, channel plan, and validation rule have one owner and no duplicate representation elsewhere?

Authoritative code map

Start an audit with these files rather than historical documentation:

  • src/rfgen/nodes/operator.py: root node declaration and closed role enum;

  • src/rfgen/nodes/{source,transform,combiner,label,plan}/operator.py: role contracts;

  • src/rfgen/nodes/binding.py: catalog, uniform binding, and extension seams;

  • src/rfgen/nodes/values/: edge type algebra, compatibility, identity, codec;

  • src/rfgen/graph/spec.py: declarative IR and builder;

  • src/rfgen/graph/scene_plan_builder.py: selector binding and typed plan construction;

  • src/rfgen/graph/materialization.py: plan-node resolution;

  • src/rfgen/graph/analysis.py: edge checks, cycle checks, schedule, identity;

  • src/rfgen/graph/execution.py: realized-node evaluation and output checks;

  • src/rfgen/graph/record.py: projection and record identity;

  • src/rfgen/graph/randomness.py and generation/seeds.py: split randomness ownership;

  • src/rfgen/storage/storage.py and src/rfgen/storage/sds/: neutral and SDS persistence boundaries;

  • pyproject.toml: installed selector and extension inventory;

  • ci/architecture/check_import_boundaries.py: mechanically enforced topology.

For API-level contracts, continue with rfgen.nodes, rfgen.graph, rfgen.storage, and the package ownership index.