recon_gen.common.l2.topology

Topology projection of an L2Instance — typed value object + renderer.

Two layers:

  1. The typed projection (TopologyGraph + TopologyNode + TopologyEdge, built by topology_graph_for). Pure data — one walk over an L2Instance, no rendering. Studio’s diagram chrome reads this for entity counts (rails / chains / templates / role scopes); the per-rail emitter also reuses it for role-node iteration so the typed walk isn’t duplicated.

  2. The graphviz renderer (build_topology_graph_per_rail). Builds a graphviz.Digraph with rails as first-class nodes (src_role rail dst_role becomes a 3-rank chain dot can lay out deterministically). Bundle nodes consolidate parallel pure-connectivity rails (anchored rails — chain endpoints / template leg-rails — stay individual). Templates render as clusters around their leg-rails. Chains as dashed edges between rail/template nodes. Control_parent (subledger → control role) as dashed gray edges. Optional focus filter (focus_node_id + smart-default hops) for click-to-zoom-in re-render.

The X.4.b spike (locked 2026-05-13) chose this rails-as-nodes / graphviz-dot model over the d3-force alternative. The dot pivot makes the user’s mental “roles → rails → roles” reading fall out of dot’s rank algorithm with zero knobs; force-directed layouts required extensive per-graph tuning. See docs/audits/x_4_b_diagram_renderer_spike.md.

Functions

build_topology_graph_per_rail(instance, *, ...)

Build a Graphviz Digraph with Rails as first-class nodes (X.4.b dot pivot).

topology_graph_for(instance, *, db_table_prefix)

Walk an L2Instance and return its typed topology projection.

visible_entities_for(instance, focus_node_id)

Return the L2 entity IDs visible in a focused diagram subgraph.

Classes

TopologyEdge(source, target, kind, label[, ...])

An edge in the L2 topology projection.

TopologyGraph(instance_name, nodes, edges)

Typed projection of an L2Instance's topology.

TopologyNode(id, kind, label[, scope, ...])

A node in the L2 topology projection — role, rail, or template.

class recon_gen.common.l2.topology.TopologyEdge(source, target, kind, label, metadata=<factory>)[source]

Bases: object

An edge in the L2 topology projection.

kind discriminates the five edge flavors:

  • rail_bundle — one or more parallel TwoLegRails between the same (source, destination) role pair. metadata carries rail_count (str-of-int) so the renderer can scale stroke width and the d3 side can show a count badge.

  • self_loop — a SingleLegRail rendered as a self-loop on its leg_role. metadata carries direction (Debit / Credit / Variable).

  • template_member — a dotted membership edge from a TransferTemplate’s node to one of its leg_rails. The graphviz renderer wraps these inside the template’s cluster.

  • chain — a Chain row’s parent → child relationship. One edge per child in chain.children (singleton row = 1 edge, multi-children row = N edges). metadata carries cardinality ("required" for singleton-children rows, "xor" for multi-children rows) and, for "xor" edges, xor_siblings (the comma-joined sibling names so the renderer can group them).

  • control_parent — an Account / AccountTemplate’s parent_role relationship (subledger rolls up to control account). Structural, not flow — the chart-of-accounts hierarchy that explains why a “control” account exists even when no rail terminates on it. metadata carries child_kind (“account” / “template”) so the renderer can style differently. When the parent role also carries one or more LimitSchedule entries, has_limits=true flags it for cap-badge rendering.

label is the human-readable display label (may be empty for membership edges; the graphviz renderer suppresses labels on those).

Parameters:
  • source (str)

  • target (str)

  • kind (Literal['rail_bundle', 'self_loop', 'template_member', 'chain', 'control_parent'])

  • label (str)

  • metadata (Mapping[str, str])

kind: Literal['rail_bundle', 'self_loop', 'template_member', 'chain', 'control_parent']
label: str
metadata: Mapping[str, str]
source: str
target: str
class recon_gen.common.l2.topology.TopologyGraph(instance_name, nodes, edges)[source]

Bases: object

Typed projection of an L2Instance’s topology.

Frozen value object — both spike arms read it; neither mutates. Iteration order (nodes + edges) is deterministic across runs of the same input, matching the existing graphviz renderer’s walk so the rendered DOT stays stable for the docs-site diagrams that snapshot against it.

Parameters:
edges: tuple[TopologyEdge, ...]
instance_name: str
nodes: tuple[TopologyNode, ...]
class recon_gen.common.l2.topology.TopologyNode(id, kind, label, scope=None, templated=False, metadata=<factory>)[source]

Bases: object

A node in the L2 topology projection — role, rail, or template.

id carries the discriminated prefix scheme used by the existing graphviz renderer (role__<role>, rail__<rail>, tmpl__<name>) so arm B’s post-processed SVG can key off the rendered id attr to find each node and tag it with data-kind / data-id.

label is the human-readable display label (may contain \n for multi-line). For templates it carries the <name>\nkeys: <list> inner label that the existing renderer puts on the template’s shape="component" node.

scope + templated are role-only (None / False for rails + templates). metadata carries kind-specific extras the renderer may need but the typed model doesn’t promote to first-class fields:

  • On a template node: transfer_key (comma-joined str) — used by the graphviz renderer to build the cluster header text.

  • Open for future use (e.g., row-counts for the X.4.c.5 coverage tint).

Parameters:
  • id (str)

  • kind (Literal['role', 'rail', 'template'])

  • label (str)

  • scope (Literal['internal', 'external'] | None)

  • templated (bool)

  • metadata (Mapping[str, str])

id: str
kind: Literal['role', 'rail', 'template']
label: str
metadata: Mapping[str, str]
scope: Literal['internal', 'external'] | None
templated: bool
recon_gen.common.l2.topology.build_topology_graph_per_rail(instance, *, db_table_prefix, bundle_parallel_rails=True, focus_node_id=None, layer=3)[source]

Build a Graphviz Digraph with Rails as first-class nodes (X.4.b dot pivot).

Sibling to build_topology_graph (which models rails as edges between roles + clusters them inside templates). This view promotes every Rail to its own node + connects it to its endpoint roles via directed edges (src_role rail dst_role for TwoLegRail; leg_role rail or rail leg_role for SingleLegRail by direction). The dot algorithm can then rank-layout the result — the user’s mental “roles → rails → roles” 3-rank reading falls out of dot’s DAG ranking deterministically, no force tuning, no knobs.

The d3-force arm A’s per-rail emit (to_d3_per_rail_json) drove the same model insight; this is the graphviz analog so the dot renderer can be re-evaluated against the layered reading the user wanted. Both emits share the bundling rule: pure-connectivity rails (TwoLegRails sharing exact source/destination role expressions AND SingleLegRails sharing leg_role/direction, with NEITHER referenced by any chain or template) collapse into one bundle node per group. Anchored rails (chain endpoints / template leg-rails) always stay individual since the sequencing/composition edges need stable rail identity.

Templates render as clusters containing their leg-rail nodes; chains as dashed edges between rail/template nodes; control_parent as dashed edges between roles. Orphan roles (declared but unreferenced) are filtered at emit time so the dot layout stays focused on the connectivity story.

bundle_parallel_rails (default True) is the bundling switch; set False to render every rail as its own node (denser graph, occasionally clearer for low-rail-count instances).

focus_node_id (optional) — when set, filter the diagram to that node’s “direct connections + complete rail” neighborhood (see _focus_set). Adjacency is computed over the FULL graph (so bundle IDs stay stable across full-vs-focused renders). Nodes / edges outside the focus set are skipped at emit time; dot re-lays out the smaller subgraph cleanly. Click-away in the chrome navigates back to the no-focus URL to restore the full picture.

layer (1 / 2 / 3, default 3) — conceptual progressive disclosure of the model:

  • 1 — roles + control hierarchy only (chart of accounts).

  • 2 — adds rails + their endpoint connectivity.

  • 3 — adds chains + transfer templates (the full diagram).

Implemented as a server-side filter so dot re-lays-out the smaller subset cleanly per layer (the same “click to zoom in, get a fresh layout” pattern the focus filter uses). Default 3 keeps Python callers (tests, etc.) seeing the full diagram unless they ask otherwise.

Returns a graphviz.Digraph ready for .render() or .source inspection. Typed as Any because the graphviz package ships without type stubs.

Return type:

Any

Parameters:
  • instance (L2Instance)

  • db_table_prefix (str)

  • bundle_parallel_rails (bool)

  • focus_node_id (str | None)

  • layer (int)

recon_gen.common.l2.topology.topology_graph_for(instance, *, db_table_prefix)[source]

Walk an L2Instance and return its typed topology projection.

Pure construction — no graphviz import, no rendering, no I/O. Both spike arms consume this single projection so the topology walk isn’t duplicated between renderers.

Iteration order matches the legacy build_topology_graph walk (roles sorted; templates in declaration order; chains in declaration order) so the graphviz renderer that consumes it produces the same DOT shape it always did.

Z.C — db_table_prefix is the cfg.db_table_prefix (formerly read off the dropped L2Instance.instance field) and surfaces as TopologyGraph.instance_name so the rendered diagram still carries the operator-facing prefix label.

Return type:

TopologyGraph

Parameters:
recon_gen.common.l2.topology.visible_entities_for(instance, focus_node_id)[source]

Return the L2 entity IDs visible in a focused diagram subgraph.

Used by Studio’s home page (X.4.f.8) to filter the entity-card sections when the operator clicks a node in the diagram. The keys are the editor-route entity-kind slugs (account, account_template, rail, transfer_template, chain, limit_schedule); the values are frozen sets of entity IDs in the same shape Studio’s /l2_shape/<kind>/<id> URLs use:

  • account.id, account_template.role, rail.name, transfer_template.name;

  • "<parent>::<child>" composite for chains and "<parent_role>::<rail>" composite for limit_schedules (matches _entity_id in _studio_editor_routes).

When focus_node_id is None or the node ID is unrecognized (typo / stale URL / synthetic bundle id like rail__bundle_3 that doesn’t have a matching individual rail), returns the FULL set per kind so the home page un-filters cleanly.

Adjacency is built directly from instance (rather than from topology_graph_for’s typed projection) so each Rail keeps its own role↔rail edges instead of being collapsed into a bundle edge — focusing on a single Rail must still pull in its endpoint roles even when several parallel rails share those roles.

Return type:

Mapping[str, frozenset[str]]

Parameters:
  • instance (L2Instance)

  • focus_node_id (str | None)