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 |
|
Structured qualified codec types |
Implemented |
|
Multi-input/output transforms and combiners |
Implemented |
typed ports/refs, |
Aggregate-record terminal |
Implemented as projection |
|
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 |
Borrowed-resource protocol |
Implemented for the 3D-world case |
|
Rich identity and corpus schedule |
Partial |
canonical graph/record identity and |
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 |
|---|---|---|
|
Operator contracts, concrete RF operators, and the value algebra. Not everything under it is a node: |
Shared errors/plugins and third-party numerical libraries; not scene-plan building or execution |
|
Declarative IR, structural materialization, binding, analysis, identity, execution, and record projection |
Node contracts and |
|
Neutral persistence lifecycle and storage discovery; SDS implementation |
Published record/shard contracts, not generation policy |
|
Configuration, run/sample/shard seeds, orchestration, local and remote executors |
Graph and storage public contracts |
|
Read-only validation and deterministic comparison of published collections |
Generic storage access; SDS-specific physical checks remain in |
|
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 |
|---|---|
|
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;inputsandoutputs: namedPortobjects with exactValueTypevalues;refs: input wires to producer/output pairs;repro: aReproClassdescribing 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 |
|---|---|
|
Consumer port receiving the value |
|
Structural name of the upstream node |
|
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 |
|
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 |
|
Value is produced by |
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_snralready names which node produced the noise it divided by (seeEMITTER_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 |
|---|---|
|
|
|
An ordinal within that plane. |
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:
Noneis 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 declaresNone.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_resamplerbetween a quantizer and a mixer would silently convert a refusal intovalid.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_planecheck inrfgen.nodes.testingrefuses the two declarations that otherwise fail silently — a stage with no plane, and a plane that is not aSignalPlanemember.
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 |
|---|---|
|
Same typed inputs and seed produce byte-identical output |
|
Output may depend on pinned library, runtime, or hardware behavior |
|
Output comes from an external measurement or trusted capture |
|
No stronger repeatability guarantee is made |
Role hierarchy¶
Role |
Base class |
Runtime method |
Semantic contract |
Built-in concrete implementations |
|---|---|---|---|---|
Source |
|
|
Has no input ports; produces typed outputs. |
26 canonical selectors, listed below |
Transform |
|
|
Maps distinct typed dependencies to outputs. |
24 canonical selectors, listed below |
Combiner |
|
|
Reduces multiple inputs under a declared algebra and canonical input order |
|
Plan |
|
|
Produces only plan facts during materialization, before execution |
|
Allocator |
|
|
Many-to-many plan-time assignment |
No concrete built-in currently |
Label |
|
|
Derives typed metadata only from declared evidence inputs |
|
Resource |
|
|
Resolves a handle naming a borrowed world, at MATERIALIZE, imposing no RUN ordering. Distinct from |
|
Loop |
Declared only as |
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: falsewith 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.
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 |
|
Analog |
|
Digital modulation/coding |
|
Protocol |
|
Radar |
|
Trusted replay |
|
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 |
|
Transmitter |
|
Receiver chain |
|
Basic propagation |
|
Transmitter limiting |
|
CIR consumers |
|
Sionna link/system models |
|
Ray tracing |
|
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 |
|---|---|---|
|
Plan |
Resolve scene-level authored facts and the authoritative finite |
|
Plan |
Carry source/provenance facts |
|
Plan |
Resolve one receiver’s front end, and its pose when it declares one |
|
Plan |
Bind receiver identity to the receiver-input plane, nominal bandwidth, explicit ENBW, noise figure, and impedance |
|
Plan |
Resolve one transmitter’s identity, pose, and radiated power |
|
Plan |
Publish one deterministic analytic PDP and its RMS delay-spread fact |
|
Plan |
Join exact TX/RX identities into one pose-free directed association with |
|
Plan |
Refine TX/RX identity with authoritative pose and scene frame for a directed link; never owns transmit power |
|
Plan |
Project geometry from the same authoritative endpoint fact propagation consumes |
|
Combiner |
Canonically sum waveform inputs |
|
Combiner |
Sum a ragged collection of equal-length waveforms into one signal |
|
Combiner |
Stack receiver outputs in canonical order |
|
Combiner |
Gather items into one ragged collection; splices ragged items with fixed rows |
|
Label |
Produce typed detection ground truth from bound evidence |
|
Label |
Produce emitter/receiver SNR metadata from bound evidence |
|
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.
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 |
|
|
Transform |
|
|
Plan |
|
|
Allocator |
|
|
Resource |
|
|
Label |
|
|
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:
A declarative
GraphSpecis normalized and structurally analyzed.materialize_graph_spec()expands selections, repeats, conditionals, and subgraphs for one sample.build_scene_plan()binds selectors and typed refs throughNodeCatalog, producing aScenePlanandRecordProjection.Python callers may construct a
ScenePlanorSceneProgramdirectly.
The executable pipeline then proceeds as follows:
Phase |
Input -> output |
What it is allowed to do |
|---|---|---|
Authored analysis |
|
Validate strict schema, scopes, refs, draws, subgraphs, and record projection; run no node |
Structural materialization and binding |
|
Resolve bounded structure for |
Plan materialization |
|
Run only |
Runtime analysis |
|
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 + |
Select and name typed outputs; preserve graph identity |
Publication |
records -> |
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 |
|---|---|---|---|
|
Plan / |
None |
sample rate, duration, bandwidth, carrier, sample count, and finite capture grid |
|
Source / |
None |
chirp IQ, authored waveform facts, source metadata |
|
Plan / |
None |
source identity/provenance facts |
|
Plan / |
None |
receiver facts and receiver pose |
|
Transform / |
|
emitter-local translated IQ and carrier facts |
|
Transform / |
carrier-translation outputs plus |
scene-aligned IQ and time-placement facts |
|
Transform / |
source, carrier, and time facts |
gridded placement facts |
|
Label / |
placement, provenance, source, and receiver facts |
detection fields and segmentation mask |
|
Plan / |
None |
transmitter facts, transmitter pose, transmit power |
|
Plan / |
plan edges citing |
typed geometry pose/provenance fields |
|
Transform / |
|
unit-transfer CIR, CIR facts with the full leaf application grid, channel facts, backend provenance |
|
Transform / |
independently power-scaled IQ plus |
propagated IQ |
|
Label / |
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, andpy.typedat 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
.pyimirrors;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:
Role completeness: Should
LOOPexist without a contract? ShouldAllocatorremain a distinct role without a concrete implementation? (RESOURCEno longer belongs in this question: it has an ABC, a registry, compiler semantics, and one implementation.)Selective extensibility: Is opening only source/transform plugins the correct boundary, or do plan/label/combiner implementations need safe public factories?
Staging clarity: Are GraphSpec structural materialization and plan-value materialization sufficiently distinct in name, API, and invariants?
Type authority: Are exact
ValueTypeequality and the current compatibility rules strict enough for units/axes, but not so strict that legitimate polymorphism becomes impossible?Evidence direction: Can any label, transform, or inspection path create scientific facts that should have been authored or produced upstream?
Identity: Does canonical identity cover every behavior-changing input while excluding scheduler, filesystem, and process accidents?
Randomness: Are structural draws, per-node draws, and run/shard/sample scheduling separated under all nested/repeated/subgraph cases?
Backend boundaries: Are Sionna/TorchSig/runtime dependencies lazy and accurately classified as deterministic versus environment-bound?
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?Selector aliases: Are retained selector aliases valuable configuration vocabulary, or unnecessary compatibility surface after a clean break?
Publication neutrality: Can a non-SDS storage implementation complete generation, reopen, inspection, annotation, collision, and comparison without relying on SDS details?
Domain ownership: Does every geometry, label, source metadata, prompt, template, channel plan, and validation rule have one owner and no duplicate representation elsewhere?