"""Pure-Python validation layer for the DAG builder.
Validation is a free function over a :class:`BuilderState`; every state
mutation through the dispatcher pipes the new state through
:func:`validate` and the resulting list of :class:`Issue` objects drives
the canvas's red/yellow border decoration plus the toolbar's
preview/save enable state.
The blocking rules plus advisory stage-order hint are enumerated in spec
``2026-05-12-builder-dag-redesign-design.md`` §5.3. This module contains
no side effects; it depends only on :mod:`phenotypic.gui.builder._state`
(the dataclasses) and the operation registry (for required-aux
introspection and stage classification).
Examples:
Empty scope (only auto-seeded ``InputImage``) is valid:
>>> from phenotypic.gui.builder._state import BuilderScope, BuilderState
>>> from phenotypic.gui.builder._validation import validate
>>> state = BuilderState(root=BuilderScope())
>>> validate(state)
[]
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal, Optional, Tuple
from phenotypic.abc_ import MeasureFeatures, PostMeasurement
from phenotypic.gui._operation_registry import OperationInfo, get_registry
from phenotypic.gui.builder._state import (
BlockNode,
BuilderScope,
BuilderState,
INPUT_IMAGE_CLASS_NAME,
PIPELINE_CLASS_NAME,
)
IssueKind = Literal[
"fork",
"stub",
"required_aux",
"cycle",
"container_mode",
"missing_input",
"duplicate_input",
"stage_order_hint",
"unknown_class",
"unsupported_linear",
]
IssueSeverity = Literal["error", "advisory"]
StageName = Literal["ops", "meas", "post", "pipeline"]
_STAGE_ORDER: Dict[StageName, int] = {
"ops": 0,
"meas": 1,
"post": 2,
"pipeline": 0,
}
[docs]
@dataclass
class Issue:
"""A single validation finding for a block or scope.
Attributes:
kind: One of the nine taxonomy entries (see :data:`IssueKind`).
The first seven are blocking errors that disable preview/save;
``stage_order_hint`` is advisory and decorates with a yellow
border instead of red. ``unknown_class`` and
``unsupported_linear`` are blocking because the runtime cannot
safely materialize a pipeline from them.
block_id: ``None`` for scope-level findings (the only one that
actually uses ``None`` is ``missing_input``); otherwise the
offender's block id.
detail: Free-form human-readable explanation. Used by tooltips
and toasts; not part of the test-normalisation key.
scope_path: List of container block_id values walked from the
root scope to the offender's scope. Empty when the issue
lives in the root scope. The UI consumes this to pan/zoom
across container boundaries when a badge is clicked.
severity: ``"error"`` for the seven blocking rules,
``"advisory"`` for ``stage_order_hint``. Populated by the rule
that emits the issue; callers gate Run/Save by filtering on this
field.
"""
kind: IssueKind
block_id: Optional[str]
detail: str
scope_path: List[str] = field(default_factory=list)
severity: IssueSeverity = "error"
[docs]
def validate(state: BuilderState) -> List[Issue]:
"""Run all validation rules across every scope reachable from the root.
Returns issues with ``scope_path`` populated so the UI can pan/zoom
across container boundaries when an issue badge is clicked.
Args:
state: The current :class:`BuilderState`. Only the
``state.root`` scope (and its transitively nested scopes via
``BlockNode.nested``) is inspected; ``selected_block_id``,
``breadcrumb``, ``toast_queue`` etc. are ignored.
Returns:
A flat list of :class:`Issue` records in deterministic order:
rules emit in the order ``missing_input`` / ``duplicate_input``
→ ``fork`` → ``stub`` → ``required_aux`` / ``unknown_class`` →
``cycle`` → ``container_mode`` → ``stage_order_hint``, and
nested-scope issues are appended after the parent scope's
issues. The ordering is stable for snapshot-style tests.
Examples:
An empty scope (only the auto-seeded ``InputImage`` block)
passes every rule and returns an empty issue list. The
synth-yeast fixture is loaded only to anchor the example in
the project's microbiology context — validation does not
touch image data:
>>> from phenotypic.data import load_synth_yeast_plate
>>> from phenotypic.gui.builder._state import (
... BuilderScope, BuilderState,
... )
>>> from phenotypic.gui.builder._validation import validate
>>> state = BuilderState(root=BuilderScope())
>>> validate(state)
[]
>>> _ = load_synth_yeast_plate() # microbiology context anchor
"""
return _validate_scope(state.root, scope_path=[])
def _validate_scope(scope: BuilderScope, scope_path: List[str]) -> List[Issue]:
"""Apply every rule to a single scope, then recurse into containers.
Args:
scope: The scope under inspection (root or nested).
scope_path: List of container ``block_id``s walked from the root
to this scope. Each emitted :class:`Issue` copies this list
into its own ``scope_path`` field.
Returns:
List of :class:`Issue` records for this scope and its
transitively nested scopes.
"""
issues: List[Issue] = []
registry = get_registry()
# Single-pass edge index reused by Rules 1/2/3/4/5/7. Building the
# adjacency / reverse-adjacency / counters / aux-wired map ONCE
# collapses the rules' total cost from O(V·E) (Rule 2's BFS used
# to scan ``scope.edges`` on every frontier expansion) to O(V+E).
image_out_count: Dict[str, int] = defaultdict(int)
image_in_count: Dict[Tuple[str, str], int] = defaultdict(int)
total_out_count: Dict[str, int] = defaultdict(int)
aux_wired: Dict[Tuple[str, str], int] = defaultdict(int)
# ``adjacency`` is the unified ``source -> [target, ...]`` map over
# ALL edges; consumed by the cycle detector (Rule 4).
adjacency: Dict[str, List[str]] = defaultdict(list)
# Image-flow forward edges only (Rule 2 walks image edges forward).
image_forward: Dict[str, List[str]] = defaultdict(list)
# Aux edges keyed by their consumer (target) — Rule 2 walks aux
# backwards from a consumer to its aux source. Each entry is the
# list of source block ids that feed *target* over an aux wire.
aux_reverse: Dict[str, List[str]] = defaultdict(list)
# Pipeline container left/right wiring buckets (Rule 5).
container_left_wired: Dict[str, bool] = {}
container_right_kinds: Dict[str, set] = defaultdict(set)
pipeline_block_ids: set = {
b.block_id for b in scope.blocks if b.class_name == PIPELINE_CLASS_NAME
}
for edge in scope.edges:
src = edge.source_block_id
tgt = edge.target_block_id
total_out_count[src] += 1
adjacency[src].append(tgt)
if edge.kind == "image":
image_out_count[src] += 1
image_in_count[(tgt, edge.target_port)] += 1
image_forward[src].append(tgt)
if tgt in pipeline_block_ids and edge.target_port == "in":
container_left_wired[tgt] = True
else: # aux
aux_wired[(tgt, edge.target_port)] += 1
aux_reverse[tgt].append(src)
if src in pipeline_block_ids:
container_right_kinds[src].add(edge.kind)
# Rule 6 — exactly one Input Image per scope.
input_blocks = [
b for b in scope.blocks if b.class_name == INPUT_IMAGE_CLASS_NAME
]
if not input_blocks:
issues.append(
Issue(
kind="missing_input",
block_id=None,
detail="scope has no Input Image",
scope_path=list(scope_path),
)
)
elif len(input_blocks) > 1:
for extra in input_blocks[1:]:
issues.append(
Issue(
kind="duplicate_input",
block_id=extra.block_id,
detail="extra Input Image",
scope_path=list(scope_path),
)
)
root_id = input_blocks[0].block_id if input_blocks else None
# Rule 1 — image-flow forks.
#
# Three sub-cases all reported as ``kind="fork"``:
# (a) a single source has >1 outgoing ``image`` edge.
# (b) a single ``(target_block_id, "in")`` port has >1 incoming
# ``image`` edge.
# (c) a single source has >1 outgoing wires *total* across image
# and aux (spec §4.2 — "at most one outgoing wire, total").
seen_fork_source: set = set()
for block_id, n in image_out_count.items():
if n > 1:
issues.append(
Issue(
kind="fork",
block_id=block_id,
detail="image-out has >1 wire",
scope_path=list(scope_path),
)
)
seen_fork_source.add(block_id)
for (block_id, port), n in image_in_count.items():
if n > 1 and port == "in":
issues.append(
Issue(
kind="fork",
block_id=block_id,
detail="image-in has >1 wire",
scope_path=list(scope_path),
)
)
for block_id, n in total_out_count.items():
# Skip sources we already flagged for "image-out has >1 wire" so
# we don't double-count the same logical fork. ``mixed_kind``
# fan-out (one image-out + one aux-out from the same source)
# ends up here.
if n > 1 and block_id not in seen_fork_source:
issues.append(
Issue(
kind="fork",
block_id=block_id,
detail="block has >1 outgoing wires (image + aux combined)",
scope_path=list(scope_path),
)
)
# Rule 2 — stubs. BFS from the InputImage block. Image edges only
# forward; aux edges traversed both forward and backward so an
# aux-producer (sink in image flow) is still reachable from a
# consumer along the chain. Uses the prebuilt indices so the walk is
# O(V+E) instead of O(V·E).
reachable: set = set()
if root_id is not None:
frontier: List[str] = [root_id]
while frontier:
curr = frontier.pop()
if curr in reachable:
continue
reachable.add(curr)
# Forward across all edges (image + aux outgoing).
frontier.extend(adjacency.get(curr, ()))
# Backward across aux edges so an aux producer (no image
# output, only an aux wire feeding a consumer) is still
# reachable from its consumer.
frontier.extend(aux_reverse.get(curr, ()))
for block in scope.blocks:
if block.block_id in reachable:
continue
# Extra ``InputImage`` blocks are already reported as
# ``duplicate_input`` (Rule 6) — flagging them additionally as a
# stub is redundant noise. Skip them here so the badge count
# accurately reflects the user's mental model: one offence per
# extra Input Image.
if block.class_name == INPUT_IMAGE_CLASS_NAME:
continue
issues.append(
Issue(
kind="stub",
block_id=block.block_id,
detail="not reachable from Input Image",
scope_path=list(scope_path),
)
)
# Rule 3 — required aux ports must be wired.
#
# ``ParamInfo.default`` is normalised to ``None`` by the registry
# (line ~404 of ``_operation_registry.py``); the right predicate is
# ``not p.has_default``, NOT ``p.default is inspect.Parameter.empty``.
# The latter is always False on a ParamInfo instance because the
# registry replaced ``inspect.Parameter.empty`` with ``None`` before
# it stored the value.
# ``aux_wired`` is the (target, port) -> count map built in the
# top-of-function single-pass scan; reused here as-is.
for block in scope.blocks:
# The InputImage sentinel never has registered params; skip it
# so we don't emit a spurious ``unknown_class`` issue.
if block.class_name == INPUT_IMAGE_CLASS_NAME:
continue
# The Pipeline sentinel represents a container; its "params" are
# the nested scope's blocks. We don't enforce required aux on
# containers themselves — the nested scope's own validation
# walks its inner blocks.
if block.class_name == PIPELINE_CLASS_NAME:
continue
info = registry.get(block.class_name)
if info is None:
issues.append(
Issue(
kind="unknown_class",
block_id=block.block_id,
detail=f"class '{block.class_name}' not in registry",
scope_path=list(scope_path),
)
)
continue
for param_name, p in info.parameters.items():
if not (p.is_operation or p.is_pipeline):
# Scalar param — out of scope for Rule 3.
continue
if p.has_default:
# Optional aux — empty is fine.
continue
# Required aux. Count wired edges into this port.
if aux_wired.get((block.block_id, param_name), 0) == 0:
issues.append(
Issue(
kind="required_aux",
block_id=block.block_id,
detail=f"{param_name} is required",
scope_path=list(scope_path),
)
)
# Rule 4 — cycle detection over ALL edges (image + aux).
#
# We use Tarjan's strongly-connected-components algorithm: O(V+E)
# iterative, no risk of recursion-limit issues even on deeply
# nested scopes. Any node that participates in a non-trivial SCC
# (size > 1, OR size 1 with a self-loop) is reported as
# ``kind="cycle"``. ``adjacency`` was built in the top-of-function
# single-pass scan; reused here.
cycle_members = _find_cycle_nodes(adjacency, scope.blocks)
for block_id in cycle_members:
issues.append(
Issue(
kind="cycle",
block_id=block_id,
detail="block participates in a cycle",
scope_path=list(scope_path),
)
)
# Rule 5 — container left/right wiring consistency.
#
# A pipeline container has two operating modes:
# * "Consumer-fed" (left wired image-in): the container is fed
# downstream as a single op; its right ports must wire image-out
# (blue). Wiring aux out is illegal.
# * "Aux-fed" (left unwired): the container's right wire is an
# aux marker for a consumer; image-out is illegal.
# Either incoherent combination raises ``kind="container_mode"``.
# ``container_left_wired`` / ``container_right_kinds`` were built
# in the top-of-function single-pass scan; reused here.
for block_id in pipeline_block_ids:
left_wired = container_left_wired.get(block_id, False)
right_kinds = container_right_kinds.get(block_id, ())
if left_wired and "aux" in right_kinds:
issues.append(
Issue(
kind="container_mode",
block_id=block_id,
detail="left wired but right wires to aux",
scope_path=list(scope_path),
)
)
if not left_wired and "image" in right_kinds:
issues.append(
Issue(
kind="container_mode",
block_id=block_id,
detail="right wires to image but left is unwired",
scope_path=list(scope_path),
)
)
# Rule 7 (advisory) — stage ordering hint.
#
# Walk every image-flow edge; if the source's stage > the target's
# stage, emit a yellow-border advisory. The runtime partitions by
# ``isinstance`` so a misordered chain still works (each stage runs
# in its own block), but the canvas warns the user that the visual
# ordering does not match the execution ordering. ``image_forward``
# was built in the top-of-function single-pass scan; reused here.
if root_id is not None:
order_of: Dict[str, int] = {}
for block in scope.blocks:
stage = _safe_stage(block.class_name, registry=registry)
order_of[block.block_id] = _STAGE_ORDER.get(stage, 0)
for source_id, targets in image_forward.items():
src_stage = order_of.get(source_id)
if src_stage is None:
continue
for target_id in targets:
tgt_stage = order_of.get(target_id)
if tgt_stage is not None and src_stage > tgt_stage:
issues.append(
Issue(
kind="stage_order_hint",
block_id=source_id,
detail=(
"runs in a later stage than its downstream "
"block; runtime partitions by isinstance."
),
scope_path=list(scope_path),
severity="advisory",
)
)
# Recurse into containers AFTER the parent scope's rules so the
# parent scope's issues are listed before the child scope's.
for block in scope.blocks:
if block.nested is not None:
issues.extend(
_validate_scope(
block.nested,
scope_path=[*scope_path, block.block_id],
)
)
return issues
def _safe_stage(
class_name: str,
registry: Optional[Any] = None,
) -> StageName:
"""Classify an operation by stage for Rule 7's ordering hint.
Sentinels (``InputImage``, ``ImagePipeline``) collapse to ``"ops"``
so they don't trip the advisory at the head of every chain. Real
operation classes are looked up in the registry and then matched
against :class:`~phenotypic.abc_.MeasureFeatures` and
:class:`~phenotypic.abc_.PostMeasurement`; everything else falls
back to ``"ops"``.
Args:
class_name: Block's ``class_name`` (registry key or sentinel).
registry: Optional pre-fetched registry; useful for tests that
inject a fake or for hot loops that have already called
:func:`get_registry`. Defaults to the singleton.
Returns:
One of ``"ops"``, ``"meas"``, ``"post"``, or ``"pipeline"``.
"""
if class_name == INPUT_IMAGE_CLASS_NAME:
return "ops"
if class_name == PIPELINE_CLASS_NAME:
return "pipeline"
reg = registry if registry is not None else get_registry()
info: Optional[OperationInfo] = reg.get(class_name)
if info is None:
return "ops"
cls = info.cls
try:
if issubclass(cls, PostMeasurement):
return "post"
if issubclass(cls, MeasureFeatures):
return "meas"
except TypeError:
# ``cls`` is not a class (e.g. a stub for unknown_class); fall
# back to ops.
return "ops"
return "ops"
def _find_cycle_nodes(
adjacency: Dict[str, List[str]],
blocks: List[BlockNode],
) -> List[str]:
"""Return the ``block_id``s that participate in any cycle.
Implements Tarjan's strongly-connected-components algorithm
iteratively to avoid Python's recursion limit on deep graphs. A
block participates in a cycle iff it is in an SCC of size > 1 OR
it is a single-node SCC with a self-loop.
Args:
adjacency: Directed graph as ``source_block_id -> [target_block_id, ...]``.
Only blocks with outgoing edges need to appear as keys;
sinks are inferred from ``blocks``.
blocks: All blocks in the scope (so isolated nodes that don't
appear in ``adjacency`` still enter the search and we don't
miss a self-loop on a node with no outgoing edges other
than to itself).
Returns:
Sorted list of block_ids in cycles.
"""
# Collect every node mentioned by either ``blocks`` or
# ``adjacency`` so the SCC search visits the full vertex set.
all_nodes: set = set(b.block_id for b in blocks)
for src, targets in adjacency.items():
all_nodes.add(src)
for t in targets:
all_nodes.add(t)
index_counter = [0]
stack: List[str] = []
on_stack: Dict[str, bool] = {}
indices: Dict[str, int] = {}
lowlinks: Dict[str, int] = {}
result: List[str] = []
# Iterative Tarjan: each "call" is a (node, neighbour-iterator)
# frame on ``work_stack``. We pop a frame, advance to the next
# neighbour, and either push a child frame or backtrack and update
# the parent's lowlink.
for start in all_nodes:
if start in indices:
continue
work_stack: List[Tuple[str, int]] = [(start, 0)]
# When we encounter ``start`` for the first time, initialise its
# SCC bookkeeping; the rest of the algorithm operates on the
# invariant that any node on ``work_stack`` already has indices.
indices[start] = index_counter[0]
lowlinks[start] = index_counter[0]
index_counter[0] += 1
stack.append(start)
on_stack[start] = True
while work_stack:
node, neighbour_idx = work_stack[-1]
neighbours = adjacency.get(node, [])
if neighbour_idx < len(neighbours):
# Advance the parent's neighbour cursor BEFORE the
# potential recursion so we resume at the right spot.
work_stack[-1] = (node, neighbour_idx + 1)
child = neighbours[neighbour_idx]
if child not in indices:
indices[child] = index_counter[0]
lowlinks[child] = index_counter[0]
index_counter[0] += 1
stack.append(child)
on_stack[child] = True
work_stack.append((child, 0))
elif on_stack.get(child, False):
lowlinks[node] = min(lowlinks[node], indices[child])
# else: cross-edge to a finished SCC; ignore.
else:
# All neighbours processed; finalise this node.
if lowlinks[node] == indices[node]:
# Pop the SCC rooted at ``node``.
scc: List[str] = []
while True:
w = stack.pop()
on_stack[w] = False
scc.append(w)
if w == node:
break
# Determine cycle-ness.
if len(scc) > 1:
result.extend(scc)
elif len(scc) == 1:
# Single-node SCC: cycle iff it has a self-loop.
only = scc[0]
if only in adjacency and only in adjacency[only]:
result.append(only)
# Backtrack: update parent's lowlink with ours.
work_stack.pop()
if work_stack:
parent = work_stack[-1][0]
lowlinks[parent] = min(lowlinks[parent], lowlinks[node])
# Deterministic ordering for snapshot-style tests.
return sorted(result)
__all__ = [
"Issue",
"IssueKind",
"IssueSeverity",
"StageName",
"validate",
]