Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
55022eb50This file is pinned to the selected release. Download the original to open it in your design tool.
# Copyright (c) 2026 TiaC Systems
# SPDX-License-Identifier: Apache-2.0
"""Mating and socket resolution, with carrier/mux composition,
value-shaped throughout: `resolve_sockets` returns the instance->slot->
BoardSocket map plus every mux-channel scope entry composition creates,
alongside its diagnostics -- no mutable accumulator, no `diags` side
channel.
Two pieces are pulled out as PURE value functions on their own:
mating_ok / subset_gaps -- plug-type-vs-socket-type and needed-vs-offered
bus decisions, each a one-line predicate over
plain strings/sets.
compose_socket -- (parent socket, exposure) -> synthesized
socket + scope entries, over PLAIN
ExposedSocket/BoardSocket values -- no
Instance/Rig/Shield needed to call it.
`resolve_sockets` is the pass: it walks `rig.instances`, recursing through
carrier chains (stack-guarded against cycles, memoizing into the returned
map as it goes), and folds in the stackability check once every instance's
socket is known. Skip-don't-abort is structural here: an instance whose
socket never resolves is simply absent from the returned map, and every
later pass already skips a missing entry rather than aborting.
"""
from __future__ import annotations
import logging
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from ..buskind import bus_kind_of, is_bus_kind
from ..diag import Diagnostic, SourceRef, error
from ..model import Board, BoardSocket, BusRef, ConnectorType, ExposedSocket, Instance, Rig
log = logging.getLogger(__name__)
#: One mux-channel scope this rig's composition created: scope PATH (the
#: composing instance's own socket reference string) -> (mux root label,
#: channel index).
ScopeEntry = tuple[str, tuple[str, int]]
@dataclass
class SocketResolution:
# instance name -> slot name -> resolved socket: a slot absent from
# the inner map never resolved. A shield with one plug has one
# entry, keyed by that plug's name. Consumed ONLY through
# analyzer/socketmap.py's accessor family -- this pass, and this
# module's own `resolve_sockets`, are the sole exception (they
# BUILD the map).
sockets: dict[str, dict[str, BoardSocket]] = field(default_factory=dict)
scopes: dict[str, tuple[str, int]] = field(default_factory=dict)
def mating_ok(plug_type: str, socket_type: str) -> bool:
"""A shield's plug type must equal the socket's own connector
type -- the mating decision as a pure value function."""
return plug_type == socket_type
def subset_gaps(needed: set[str], offered: Iterable[str]) -> list[str]:
"""Which of the buses a shield's devices actually use are NOT
among the buses the socket exposes -- subset exposure is declared by
ABSENCE (a socket offering no socket,uart rejects a uart-needing plug).
Sorted so a caller renders a stable, deterministic list."""
return sorted(needed - set(offered))
def _compose_buses(
socket_label: str,
carrier_name: str,
exposed: ExposedSocket,
parents: dict[str, BoardSocket],
inst_src: SourceRef | None,
is_plural: bool,
) -> tuple[dict[str, BusRef], list[Diagnostic], list[ScopeEntry]]:
"""The bus half of `compose_socket`'s composition, lifted out: a
pass-through bus resolves through the named parent's controller of
the same KIND, never an exact-name match; a scope-creating bus
synthesizes a new `BusRef` rooted at this carrier instance and
records the scope entry the caller must fold into the pass's own
scope map. Pure over its arguments, exactly like `compose_socket`
itself."""
diags: list[Diagnostic] = []
scope_entries: list[ScopeEntry] = []
buses: dict[str, BusRef] = {}
for kind, marker in exposed.buses.items():
if marker[0] == "plug": # pass-through
slot = marker[1]
parent = parents[slot]
kind_query = bus_kind_of(kind) or kind
candidates = sorted(b for b in parent.buses if is_bus_kind(b, kind_query))
slot_note = f" (slot '{slot}')" if is_plural else ""
refs = tuple(x for x in (exposed.src, parent.src, inst_src) if x)
if len(candidates) > 1:
diags.append(
error(
"phys-ambiguous-bus",
f"carrier '{carrier_name}' passes {kind.upper()} through socket "
f"'{exposed.name}' from parent socket '{parent.label}'{slot_note}, "
f"which offers more than one {kind_query.upper()} bus "
f"({', '.join(candidates)}) -- ambiguous pass-through is not "
"supported yet",
refs,
)
)
elif len(candidates) == 1:
parent_bus = parent.buses[candidates[0]]
buses[kind] = BusRef(
label=parent_bus.label,
path=parent_bus.path,
cs_pool=exposed.cs_pool.get(kind),
# existing_cs_gpios/existing_child_regs are facts of
# the PHYSICAL controller (parent_bus.path), not of
# the exposing carrier -- a pass-through composition
# must carry them over unchanged, the same as
# label/path themselves, so analyzer/cs.py sees the
# board's own wiring identically whether a device
# reaches this controller directly or through a
# carrier's re-exported socket.
existing_cs_gpios=parent_bus.existing_cs_gpios,
existing_child_regs=parent_bus.existing_child_regs,
)
else:
diags.append(
error(
"phys-subset",
f"carrier '{carrier_name}' passes {kind.upper()} through socket "
f"'{exposed.name}', but its parent socket '{parent.label}'{slot_note} "
f"offers no socket,{kind} (pass-through needs the parent to provide it)",
refs,
)
)
else: # new scope: ("scope", dev-label)
# A scope-creating exposed socket carries a shield,channel --
# it is what selects which downstream bus this composition
# creates -- so it is never None once this branch is reached.
assert exposed.channel is not None
root = f"{carrier_name}_{marker[1]}"
scope_path = socket_label # per (carrier, channel); shared by co-plugged modules
buses[kind] = BusRef(label=f"{root}_ch{exposed.channel}", path=scope_path)
scope_entries.append((scope_path, (root, exposed.channel)))
return buses, diags, scope_entries
def compose_socket(
socket_label: str,
carrier_name: str,
exposed: ExposedSocket,
parents: dict[str, BoardSocket],
inst_src: SourceRef | None,
) -> tuple[BoardSocket, list[Diagnostic], list[ScopeEntry]]:
"""Pass-through composition over one or more named parents: exposed
positions resolve to the NAMED parent's SoC pins, exposed buses to
the named parent's controllers -- each gpio-map row and each
pass-through bus carries its OWN slot, so a mixed-parent exposed
socket routes different rows/buses through different parents. Pure
over its arguments -- no Instance/Rig/Shield needed, only the
exposure and the ALREADY-resolved parents (every one of the
carrier's slots; the caller -- `resolve_one` -- guarantees this
before ever calling in) -- so this is directly unit-testable
against synthetic ExposedSocket/BoardSocket values. `parents` has
exactly one entry, keyed by that slot's own name, for a single-plug
carrier.
A pass-through selects the named parent's bus of the same KIND, never
an exact-name match -- the child-side qualified name (validated at
parse time against the exposed type's own vocabulary) is independent
of whatever the parent happens to call its own bus. A parent
offering MORE than one bus of that kind is a loud, not-yet-supported
ambiguity (phys-ambiguous-bus) rather than a guess.
Returns (socket, diagnostics, scopes): a NEW synthesized
BoardSocket, the findings, and any scope entries the composition
created. Its inputs are read-only; the caller owns all three."""
diags: list[Diagnostic] = []
scope_entries: list[ScopeEntry] = []
is_plural = len(parents) > 1
gpio_map: dict[int, tuple[str, int, int]] = {}
nexus_rows: list[tuple[int, str, int]] = []
for pos, (slot, parent_pos, _flags) in exposed.gpio_map.items():
parent = parents[slot]
nexus_rows.append((pos, parent.nexus_label or parent.label, parent_pos))
if parent_pos in parent.gpio_map:
gpio_map[pos] = parent.gpio_map[parent_pos]
# else: parent fragment doesn't route it -> stays socket-local (net key)
pwm_map, pwm_nexus_rows, pwm_cells, d = _compose_channel_map(
"pwm",
exposed.pwm_map,
exposed.pwm_cells,
carrier_name,
exposed,
parents,
inst_src,
is_plural,
)
diags += d
adc_map, adc_nexus_rows, adc_cells, d = _compose_channel_map(
"adc",
exposed.adc_map,
exposed.adc_cells,
carrier_name,
exposed,
parents,
inst_src,
is_plural,
)
diags += d
buses, d, bus_scope_entries = _compose_buses(
socket_label, carrier_name, exposed, parents, inst_src, is_plural
)
diags += d
scope_entries += bus_scope_entries
# Single-parent composition anchors the composed path to that one
# parent's own path plus the exposed node's name -- golden-frozen
# for every existing single-plug shield. A multi-parent composition
# has no single parent path to anchor to, so it uses the
# socket_label instead -- the <carrier>.<exposed> reference string,
# unique per carrier instance and deterministic.
if len(parents) == 1:
(only_parent,) = parents.values()
path = f"{only_parent.path}/{exposed.name}"
else:
path = socket_label
socket = BoardSocket(
label=socket_label,
path=path,
type_name=exposed.type_name,
gpio_map=gpio_map,
buses=buses,
pwm_map=pwm_map,
pwm_cells=pwm_cells,
adc_map=adc_map,
adc_cells=adc_cells,
src=exposed.src,
nexus_label=f"{carrier_name}_{exposed.name}",
nexus_rows=nexus_rows,
pwm_nexus_rows=pwm_nexus_rows,
adc_nexus_rows=adc_nexus_rows,
parents=dict(parents),
)
return socket, diags, scope_entries
def _require_matching_cells(
fn: str,
prop: str,
exposed_map: dict[int, tuple[str, int, int]],
declared_cells: int | None,
carrier_name: str,
exposed: ExposedSocket,
parents: dict[str, BoardSocket],
inst_src: SourceRef | None,
is_plural: bool,
parent_cells_of: Callable[[BoardSocket], int | None],
) -> tuple[set[str], list[Diagnostic]]:
"""The require-and-check rule (see `_compose_channel_map`'s own
docstring), lifted out: each distinct slot a row draws from must
declare the same #<prop>-cells as the carrier itself, checked ONCE
per slot (not once per row, so a plural carrier passing several
positions through one mismatched slot gets one finding, not N
duplicates). Returns the refused ("bad") slots plus the diagnostics
naming both counts and both sides."""
diags: list[Diagnostic] = []
bad_slots: set[str] = set()
for slot in sorted({slot for slot, _pp, _f in exposed_map.values()}):
parent = parents[slot]
parent_cells = parent_cells_of(parent)
if parent_cells == declared_cells:
continue
bad_slots.add(slot)
slot_note = f" (slot '{slot}')" if is_plural else ""
refs = tuple(x for x in (exposed.src, parent.src, inst_src) if x)
diags.append(
error(
"phys-subset",
f"carrier '{carrier_name}' declares #{prop}-cells = <{declared_cells}> "
f"on exposed socket '{exposed.name}', but its parent socket "
f"'{parent.label}'{slot_note} declares #{prop}-cells = "
f"<{parent_cells}> -- a carrier does not get to choose its own "
"cell count, it inherits whatever the board it lands on declares",
refs,
)
)
return bad_slots, diags
def _compose_channel_map(
fn: str,
exposed_map: dict[int, tuple[str, int, int]],
declared_cells: int | None,
carrier_name: str,
exposed: ExposedSocket,
parents: dict[str, BoardSocket],
inst_src: SourceRef | None,
is_plural: bool,
) -> tuple[dict[int, tuple[str, int]], list[tuple[int, str, int]], int | None, list[Diagnostic]]:
"""The pwm/adc twin of `compose_socket`'s own gpio_map loop above,
factored out because PWM and ADC need IDENTICAL treatment -- a
branch handling one function while leaving a silent hole for the
other is exactly the divergence this factoring rules out -- at BOTH
of the places gpio and pwm/adc genuinely differ:
A row whose parent does not route it is an ERROR here, never
gpio_map's own "stays socket-local" silent drop: an unrouted
analog position is not a meaningful net, it is a mistake.
The carrier's own declared cell count (`declared_cells`,
ExposedSocket.pwm_cells/.adc_cells) must equal the resolved
parent's (BoardSocket.pwm_cells/.adc_cells) or the whole slot is
refused up front, naming BOTH counts and BOTH sides (the
carrier's shield name and the parent socket's own label) --
checked ONCE per distinct slot a row actually draws from, not once
per row, so a plural carrier passing several positions through one
mismatched slot gets one finding, not N duplicates.
`fn` is "pwm" or "adc"; `exposed_map`/`declared_cells` are that
function's own ExposedSocket fields. Returns (composed map, nexus
rows, this socket's OWN carried cell count, diagnostics) -- the cell
count is None whenever nothing actually composed (mirrors nexus_rows
being empty in the same case: a socket with no resolved rows for a
function has no nexus to synthesize for it either)."""
diags: list[Diagnostic] = []
if not exposed_map:
return {}, [], None, diags
prop = "pwm" if fn == "pwm" else "io-channel"
parent_map_of = (lambda p: p.pwm_map) if fn == "pwm" else (lambda p: p.adc_map)
parent_cells_of = (lambda p: p.pwm_cells) if fn == "pwm" else (lambda p: p.adc_cells)
bad_slots, d = _require_matching_cells(
fn,
prop,
exposed_map,
declared_cells,
carrier_name,
exposed,
parents,
inst_src,
is_plural,
parent_cells_of,
)
diags += d
composed: dict[int, tuple[str, int]] = {}
nexus_rows: list[tuple[int, str, int]] = []
for pos, (slot, parent_pos, _filler) in exposed_map.items():
if slot in bad_slots:
continue
parent = parents[slot]
parent_map = parent_map_of(parent)
if parent_pos not in parent_map:
slot_note = f" (slot '{slot}')" if is_plural else ""
refs = tuple(x for x in (exposed.src, parent.src, inst_src) if x)
diags.append(
error(
"phys-subset",
f"carrier '{carrier_name}' passes {fn.upper()} through socket "
f"'{exposed.name}' at position {pos}, but its parent socket "
f"'{parent.label}'{slot_note} does not route it there (no "
f"{prop}-map entry at parent position {parent_pos})",
refs,
)
)
continue
composed[pos] = parent_map[parent_pos]
nexus_rows.append((pos, parent.nexus_label or parent.label, parent_pos))
cells = declared_cells if composed else None
return composed, nexus_rows, cells, diags
def _subject(inst: Instance, slot: str) -> str:
"""The diagnostic subject phrase for one (instance, slot): bare
`instance '<name>'` for a single-slot shield, golden-frozen
byte-for-byte since a single-slot shield has nothing to
disambiguate; slot-qualified for a plural one. Pure: builds a
string from its two arguments alone."""
if len(inst.shield.plugs) > 1:
return f"instance '{inst.name}': slot '{slot}'"
return f"instance '{inst.name}'"
@dataclass
class _ResolveState:
"""The mutable working state one `resolve_sockets` call threads
through its recursion: the board and instance-by-name lookup every
call needs, the diagnostics collected so far, and the socket/scope
maps being built (memoized as resolution proceeds, so a socket
resolved once is never resolved twice). NOT this pass's own
returned value -- `resolve_sockets` assembles a fresh
`SocketResolution` from `sockets`/`scopes` once resolution is
complete, so no caller ever sees this state directly."""
board: Board
by_name: dict[str, Instance]
diags: list[Diagnostic] = field(default_factory=list)
sockets: dict[str, dict[str, BoardSocket]] = field(default_factory=dict)
scopes: dict[str, tuple[str, int]] = field(default_factory=dict)
def _infer_socket(
state: _ResolveState, inst: Instance, slot: str, plug_type: str
) -> BoardSocket | None:
"""Socket inference, per slot: `mating_ok` run in REVERSE across
every board socket for THIS slot's own connector type, keeping the
candidates instead of a boolean -- board sockets only, never a
carrier's own exported ones (those come from instances, so the
candidate set would change as instances are parsed, making
inference order-dependent). Exactly one candidate resolves
silently; zero or two-or-more is always an error, never a guess --
an implementation that picks between several reasonable candidates
is wrong however sensible its tie-break looks. No bipartite
matching between slots either: two same-type slots on a
two-candidate board both refuse independently -- the explicit
`sockets:` map is the answer, not a tie-break of this function's
own."""
board = state.board
subject = _subject(inst, slot)
candidates = [s for s in board.sockets.values() if mating_ok(plug_type, s.type_name)]
if not candidates:
state.diags.append(
error(
"phys-socket",
f"{subject}: shield '{inst.shield.name}' plugs "
f"'{plug_type}', but no socket of board "
f"'{board.name}' offers a matching type -- add an explicit "
"socket: to a socket of a different type, or use a "
"different board\n"
f"sockets of {board.name}: "
+ ", ".join(f"{s.label} ({s.type_name})" for s in board.sockets.values()),
(inst.src,) if inst.src else (),
)
)
return None
if len(candidates) > 1:
state.diags.append(
error(
"phys-socket",
f"{subject}: shield '{inst.shield.name}' plugs "
f"'{plug_type}', which mates more than one socket "
f"of board '{board.name}' -- add an explicit socket: to "
"pick one\n"
"candidates: " + ", ".join(s.label for s in candidates),
(inst.src,) if inst.src else (),
)
)
return None
return candidates[0]
def _resolve_carrier_socket(
state: _ResolveState,
inst: Instance,
subject: str,
ref: str,
stack: tuple[str, ...],
) -> BoardSocket | None:
"""The carrier-exported-socket branch of `_resolve_one`
("<carrier instance>.<exposed socket>"): a carrier's exposed socket
may draw from ANY of its own plugs -- resolve EVERY slot the
carrier declares before composing, regardless of which ones the
NAMED exposed socket actually uses; any slot failing to resolve
fails the whole composition (skip-don't-abort)."""
carrier_name, _, exp_name = ref.partition(".")
if inst.name in stack or carrier_name in stack:
state.diags.append(
error(
"phys-socket",
f"{subject}: socket nesting is cyclic ({ref})",
(inst.src,) if inst.src else (),
)
)
return None
carrier = state.by_name.get(carrier_name)
if carrier is None:
state.diags.append(
error(
"phys-socket",
f"{subject}: socket '{ref}' names no instance "
f"'{carrier_name}' in this rig\n"
f"instances: {', '.join(sorted(state.by_name))}",
(inst.src,) if inst.src else (),
)
)
return None
parents: dict[str, BoardSocket] = {}
for carrier_slot in carrier.shield.plugs:
parent = _resolve_one(state, carrier, carrier_slot, stack + (inst.name,))
if parent is None:
return None
parents[carrier_slot] = parent
# resolved by the exposed node's DTS LABEL -- the same naming
# authority config:/params:/wires: already share; a node name that
# differs from its own label does not resolve.
exposed = carrier.shield.exposed_socket(exp_name)
if exposed is None:
state.diags.append(
error(
"phys-socket",
f"{subject}: carrier '{carrier_name}' (shield "
f"'{carrier.shield.name}') exposes no socket '{exp_name}'\n"
"exposed sockets: "
+ (', '.join(sorted(e.label for e in carrier.shield.exposes.values())) or 'none'),
tuple(x for x in (inst.src, carrier.src) if x),
)
)
return None
socket, d, scope_entries = compose_socket(ref, carrier.name, exposed, parents, inst.src)
state.diags.extend(d)
for path, entry in scope_entries:
state.scopes[path] = entry
return socket
def _resolve_one(
state: _ResolveState, inst: Instance, slot: str, stack: tuple[str, ...]
) -> BoardSocket | None:
board = state.board
cached = state.sockets.get(inst.name, {}).get(slot)
if cached is not None:
return cached
subject = _subject(inst, slot)
ref = inst.sockets.get(slot)
plug_type = inst.shield.plugs[slot]
if ref is None: # inferred board socket
socket = _infer_socket(state, inst, slot, plug_type)
elif "." not in ref: # board socket
socket = board.resolve(ref)
if socket is None:
state.diags.append(
error(
"phys-socket",
f"{subject}: board '{board.name}' has no socket "
f"'{ref}'\n"
f"sockets of {board.name}: "
+ ", ".join(f"{s.label} ({s.type_name})" for s in board.sockets.values()),
(inst.src,) if inst.src else (),
)
)
else: # carrier-exported socket
socket = _resolve_carrier_socket(state, inst, subject, ref, stack)
if socket is not None:
state.sockets.setdefault(inst.name, {})[slot] = socket
return socket
def _mating_diagnostic(
inst: Instance, slot: str, plug_type: str, socket: BoardSocket
) -> Diagnostic | None:
"""Per slot: the socket this slot resolved to must actually
mate the shield's own plug type. Returns the phys-mating finding, or
None when it mates."""
if mating_ok(plug_type, socket.type_name):
return None
return error(
"phys-mating",
f"{_subject(inst, slot)}: shield '{inst.shield.name}' plugs "
f"'{plug_type}' but socket '{socket.label}' is a "
f"'{socket.type_name}' socket — the connectors do not mate",
tuple(x for x in (inst.src, socket.src) if x),
)
def _subset_exposure_diagnostics(
inst: Instance, slot: str, socket: BoardSocket
) -> list[Diagnostic]:
"""Per slot: which of the buses this slot's devices actually
use are not among the ones its resolved socket exposes -- a bus
needed only by ANOTHER slot must never be demanded of this one's
socket."""
used = {d.bus for d in inst.shield.devices if d.bus and d.plug == slot}
diags: list[Diagnostic] = []
for bus in subset_gaps(used, socket.buses):
diags.append(
error(
"phys-subset",
f"{_subject(inst, slot)}: shield '{inst.shield.name}' needs the "
f"socket's {bus.upper()} but '{socket.label}' does not expose "
f"socket,{bus} (subset exposure is declared by absence)",
tuple(x for x in (inst.src, socket.src) if x),
)
)
return diags
def _distinct_slot_diagnostics(
inst: Instance,
resolved_slots: dict[str, BoardSocket],
) -> list[Diagnostic]:
"""Distinct slots of ONE instance must resolve to DISTINCT physical
sockets -- one physical connector cannot take two plugs at
once, checked regardless of the per-slot mating outcome (the
impossibility is physical, not a function of whether the connector
TYPES happen to agree). The stackability census below would only
ever catch this as a non-stackable-type collision, and with a
message that counts "instances" rather than slots -- a genuinely
miscounting message for this case, which is exactly why this gets
its own, precise diagnostic."""
diags: list[Diagnostic] = []
if len(resolved_slots) <= 1:
return diags
by_label: dict[str, list[str]] = {}
for slot, socket in resolved_slots.items():
by_label.setdefault(socket.label, []).append(slot)
for label, slots in sorted(by_label.items()):
if len(slots) > 1:
diags.append(
error(
"phys-socket",
f"instance '{inst.name}': slots "
f"{', '.join(repr(s) for s in sorted(slots))} both "
f"resolve to physical socket '{label}' — one "
"physical connector cannot take two plugs at once",
(inst.src,) if inst.src else (),
)
)
return diags
def _stackability_diagnostics(
per_socket: dict[str, list[tuple[Instance, BoardSocket]]], types: dict[str, ConnectorType]
) -> list[Diagnostic]:
"""The stackability sweep (final pass, over sorted RESOLVED socket
labels): more than one instance mating a socket of a non-stackable
connector type is refused."""
diags: list[Diagnostic] = []
for label, entries in sorted(per_socket.items()):
if len(entries) < 2:
continue
ctype = types[entries[0][1].type_name]
if not ctype.stackable:
diags.append(
error(
"phys-mating",
f"{len(entries)} instances mate socket '{label}' but connector type "
f"'{ctype.name}' takes exactly one module (not stackable): "
+ ", ".join(inst.name for inst, _socket in entries),
tuple(inst.src for inst, _socket in entries if inst.src),
)
)
return diags
def resolve_sockets(
rig: Rig,
board: Board,
types: dict[str, ConnectorType],
) -> tuple[SocketResolution, list[Diagnostic]]:
"""The pass, PER SLOT: inference, mating, and subset exposure each
run once per (instance, slot), independently (no bipartite matching
between two slots of one instance). Returns the resolution (sockets
+ scopes) alongside every diagnostic, in a fixed discovery order
that is golden-frozen: per-instance, per-slot-in-authoring-order
mating/subset checks in rig.instances order, recursing into
not-yet-resolved carriers depth-first; the stackability sweep last,
over sorted RESOLVED socket labels. A single-plug shield has
exactly one slot -- its name is read from `shield.plugs`, never
assumed to be "plug" -- so it iterates exactly once and this order
is trivially stable for it."""
state = _ResolveState(board=board, by_name={i.name: i for i in rig.instances})
diags = state.diags
sockets = state.sockets
# keyed by the RESOLVED socket's own label, never the reference string
# that named it -- a board socket can be named by either its defining
# label or a conventional alias, so two instances (or two slots of
# ONE instance) naming the SAME physical socket by DIFFERENT strings
# must still land in the same bucket for the exclusivity check below
# to see them.
per_socket: dict[str, list[tuple[Instance, BoardSocket]]] = {}
for inst in rig.instances:
resolved_slots: dict[str, BoardSocket] = {}
for slot, plug_type in inst.shield.plugs.items():
socket = _resolve_one(state, inst, slot, ())
if socket is None:
continue
log.debug(
"instance '%s': slot '%s': resolved socket '%s' (%s)",
inst.name,
slot,
socket.label,
socket.type_name,
)
resolved_slots[slot] = socket
mating_diag = _mating_diagnostic(inst, slot, plug_type, socket)
if mating_diag is not None:
diags.append(mating_diag)
continue
per_socket.setdefault(socket.label, []).append((inst, socket))
diags.extend(_subset_exposure_diagnostics(inst, slot, socket))
diags.extend(_distinct_slot_diagnostics(inst, resolved_slots))
diags.extend(_stackability_diagnostics(per_socket, types))
return SocketResolution(sockets=sockets, scopes=state.scopes), diags