Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/refactor/consolidation-20261009/G6-domain-blind-viewers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# G6: Domain-blind viewers

**Head audited:** `openhcs` `main` at `1c1059867` (#1172). **Rules:** [00-RULES.md](00-RULES.md). **Step 3.** Replaces C7.
**Architecture:** [04-ARCHITECTURE.md](04-ARCHITECTURE.md), "Viewers". *Builds:* `DeclaredAxis`, `ViewerSlotFamily` (napari and Fiji leaves), one `ViewerControlAction` family, one `ViewerFamily` class per viewer. *Uses:* G1's `Axis`/`AxisRole`, `AutoRegisterMeta`.

## What is wrong

**The viewer processes learn which axes exist from the producer's active axis family instead of from the payload, the placement vocabulary is a flat enum restated by two config enums, and each viewer restates the lifecycle control actions, the viewer identity and its forwarding (IDEN-1, MEMB-2, IMPL-2, IMPL-4).**

- **Exact-set rejection.** `AxisRoleModeDisplayConfig.role_modes_from_wire` (`core/config.py:281-306`) rebuilds the display config in the viewer process from `AxisFamily.active()` and raises unless the payload's `component_modes` names exactly the viewer process's family. Eight more `AxisFamily.active()` reads run in the viewer process: `viewer_component_system.py:107,127,384` (labels, ordinal normalisation), `napari_viewer_server.py:1024` (partition → LAYER), `:1306,2377` (fractional-Z stack), `napari_streaming_handlers.py:1426` (spatial labels). A payload streamed under any other family is rejected or mislabelled.
- **The wire carries no axis declarations.** `display_config` carries `component_modes` and `component_order` (names only); roles, labels and value kinds are re-derived on the receiving side.
- **Flat placement enum.** zmqruntime `ViewerComponentMode` (`viewer_protocol.py:285`) mixes napari (`stack`, `layer`) and Fiji (`window`, `channel`, `slice`, `frame`) slots; `NapariDimensionMode` and `FijiDimensionMode` (`config.py:309,447`) restate both subsets by hand, and every config default restates the Fiji role mapping (colour→CHANNEL, stack→SLICE, else FRAME, `config.py:491-534`). PolyStore's Fiji window grouping (`receivers/core/window_projection.py:24-165`) and napari route key (`receivers/napari/layer_key.py:51`) re-read the flat enum; only OpenHCS calls them.
- **Labels by role table.** `ViewerAxisLabelStrategy` and its four leaves (`viewer_component_system.py:97-160`) hardcode `Ch`/`Z`/`T` per role instead of reading a declared label.
- **Two control families restate the lifecycle.** `NapariControlMessageAction` (`napari_viewer_server.py:3036-3296`) and `FijiControlMessagePlan` (`fiji_viewer_server.py:1229-1495`) each declare shutdown, force-shutdown, clear-state, process-launch and settle; Fiji spells the first three as string literals. **Bug:** `FijiControlMessageAuthority.response_for` answers an unregistered message with SUCCESS (`fiji_viewer_server.py:1490`), so MCP navigate/viewport/measure requests to Fiji silently do nothing. Fiji carries three `FijiUnsupported*Plan` placeholders that restate the unknown-message error, and its pong advertises no control capabilities.
- **Identity and forwarding.** `ViewerType` (enum, `streaming_config_declarations.py:66`) points at `*ViewerDeclaration` leaves; `StreamingConfigBehaviorMixin` forwards six properties to it (`streaming_config_factory.py:185-209`), and `config_key`/`from_config_key` spell and reverse-search an f-string. `start_viewer`/`stop_viewer` are written twice (`napari_stream_visualizer.py:95-170`, `fiji_stream_visualizer.py:69-112`); `from_display_payload` is written twice (`config.py:412-435, 548-570`).
- **Import reach.** `agent/dto/viewer.py:44` imports `agent/dto/execution.py` for `ExecutionConnectionProjection`; through `zmq_execution_client` that loads 203 openhcs modules, 16 of them `openhcs.microscopes`, plus `core.orchestrator`, into the napari server process.

## Target

```python
# openhcs/runtime/viewer_axes.py (imports only core.axes and the wire libraries)
@dataclass(frozen=True)
class DeclaredAxis: # on the wire inside display_config
name: str; label: str; roles: tuple[type[AxisRole], ...]; value_kind: type[AxisValueKind]
@classmethod
def of(cls, axis: type[Axis]) -> DeclaredAxis: ...

class ViewerSlot(ABC): # one native placement slot
wire_value: ClassVar[str]; roles: ClassVar[tuple[type[AxisRole], ...]] = ()
class ViewerSlotFamily(ABC): # per viewer; leaves register by inheritance
default_slot: ClassVar[type[ViewerSlot]]
def slot_for(axis: DeclaredAxis) -> type[ViewerSlot] # first slot owning one of its roles
def named(wire_value) -> type[ViewerSlot]; def choice_enum(name) -> type[Enum] # form boundary only
class NapariSlots(ViewerSlotFamily): Stack (default), Layer
class FijiSlots(ViewerSlotFamily): Channel(ColourAxis), Slice(StackAxis), Frame(TimeAxis, default), Window
```

- The display-config wire section carries `declared_axes` (name, label, roles, value kind) beside `component_modes`/`component_order`. The viewer validates `component_modes` and `component_order` against the payload's own declared axes; it never reads `AxisFamily.active()`. Role queries (partition → separate layers, stack → fractional Z and spatial labels, labels, ordinal normalisation) read `DeclaredAxis`.
- `Axis.label` is declared on the axis (default `name.title()`); the microscopy family declares `Ch`, `Z`, `T`. `ViewerAxisLabelStrategy` and its leaves are deleted.
- Per-role mode fields stay (G1); their defaults come from the slot family, an axis with no matching field takes the family's slot for its roles, and `NapariDimensionMode`/`FijiDimensionMode` are views derived from the slot families (same member names, so saved configs load unchanged).
- The viewer process decodes viewer-side settings (`NapariDisplaySettings`, `FijiDisplaySettings`) once through one generic decoder; `from_display_payload` is deleted from `config.py`, and the viewer servers no longer import `openhcs.core.config`.
- zmqruntime: `ViewerComponentMode`, `ViewerComponentModeGroups`, `viewer_component_mode_value` and the mode-grouping helpers are deleted (lockstep PR, version bump). PolyStore: the Fiji window grouping and napari route key move to the OpenHCS viewer modules that call them (lockstep PR, version bump).
- **One control family** (`openhcs/runtime/viewer_control_actions.py`): `ViewerControlAction` keyed by the control message-type wire value, per-viewer registries (`NapariControlAction`, `FijiControlAction`), lifecycle mixins (shutdown, force-shutdown, clear-state, process-launch, settle) written once over a `ViewerServerPort`, one shared `UnknownControlAction` that answers ERROR, and pong capabilities derived from each registry for both viewers. The `FijiUnsupported*` plans are deleted (the shared ERROR answers them).
- **One `ViewerFamily` class per viewer** (`NapariViewer`, `FijiViewer`) owns backend, config key, title, visualizer, slot family and entrypoint; `ViewerType` becomes the boundary view derived from the registry; `ViewerDeclarationABC` and its two leaves are deleted; `StreamingConfigBehaviorMixin` reads the family directly. `start_viewer`/`stop_viewer` move into `ManagedViewerLifecycleMixin` once.
- `ExecutionConnectionProjection` (renamed `ExecutionConnectionFields`, rule 1b) moves to `agent/dto/execution_connection.py`; `agent/dto/viewer.py` no longer imports `agent/dto/execution.py`.

## Guards

`tests/unit/test_g6_viewer_guards.py`:
1. Importing `openhcs.runtime.napari_viewer_server` and `openhcs.runtime.fiji_viewer_server` (fresh subprocess) loads no `openhcs.microscopes`, no `openhcs.core.orchestrator` and no `openhcs.core.config`.
2. No `AxisFamily` reference in the viewer modules (`runtime/*viewer*`, `napari_*`, `fiji_*`, `viewer_component_system`).
3. No `ViewerComponentMode` anywhere in `openhcs/` or the submodules' `src/`.
4. No `ControlMessagePlan`, `Unsupported*ControlPlan` or `NapariControlMessageAction` names; no `"shutdown"`/`"force_shutdown"`/`"clear_state"` literals in the Fiji server.
5. No `ViewerDeclarationABC`; `agent/dto/viewer.py` imports nothing from `agent.dto.execution`.

## Tests

- One family test over both viewers' control registries: lifecycle actions present, unknown message answers ERROR, capabilities derived from the registry.
- One new-case test: a non-microscopy family (`Scene` partition, `Band` colour, `Date` time, no stack) streams a display payload that a viewer decodes against its own declared axes, with Fiji slots C/T/FRAME and labels from declarations, while the process's active family is microscopy.
- Existing viewer tests that assert the exact-set rejection, `ViewerAxisLabelStrategy`, `ViewerComponentMode` members or the `FijiUnsupported*` plans are deleted or rewritten at the family level.

## New-case experiments

- *A new domain axis with no viewer role* (for example a `Replicate` with only `DefaultVariable`). Today: `mode_field_for_axis` raises and both viewers reject the payload unless the viewer process's family matches; three label leaves would need a fourth. After: zero edits; it takes each viewer's default slot and its declared label.
- *A new control message on Fiji.* Today: a plan class, plus the unknown path silently succeeds if forgotten. After: one leaf; forgetting it answers ERROR and the capability list omits it.
- *A third viewer.* Today: an enum member, a declaration leaf, a visualizer with its own start/stop, a config with hand-written mode fields and `from_display_payload`, a control family. After: one `ViewerFamily` leaf, one slot family, one settings dataclass and its control leaves.

## Done when

The guards pass; the exact-set rejection, `ViewerComponentMode`, `ViewerAxisLabelStrategy`, `FijiControlMessagePlan`, `NapariControlMessageAction`, `ViewerDeclarationABC` and the duplicated start/stop and `from_display_payload` are gone; Fiji answers unknown control messages with ERROR and advertises its registry; the viewer tests touched pass; no viewer process from the worktree remains.
20 changes: 13 additions & 7 deletions docs/source/architecture/streaming_boundary_and_wrappers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,19 @@ Viewer startup and readiness

``ManagedViewerLifecycleMixin`` is OpenHCS's application lifecycle boundary;
ZMQRuntime's ``ViewerStateManager`` remains the process-wide instance registry.
``ViewerType`` is the singular OpenHCS viewer-family authority. Each member
carries its nominal viewer declaration, while configuration keys, compiled
output keys, presentation, backend selection, and lifecycle construction are
projected from that member. The streaming-config registry is keyed by these
members; ObjectState field names are boundary projections rather than registry
identities. Lowercase viewer names exist only as explicit wire identifiers at
the generic ZMQRuntime boundary.
Each viewer is one ``ViewerFamily`` class (``NapariViewer``, ``FijiViewer``)
declaring its wire name, backend, display settings and lifecycle class;
configuration keys, compiled output keys and titles derive from it. The
streaming-config registry is keyed by these classes, and ``ViewerType`` is the
boundary enum derived from the registry for wire and form fields.

A stream's display config carries its own axis declarations
(``DeclaredAxis``: name, label, roles, value kind). A viewer lays the stream
out from those declarations and its own ``ViewerSlotFamily`` (napari: stack or
layer; Fiji: C, Z, T or a window), never from the axis family active in the
viewer process, so it shows any domain's axes. Control messages go through one
``ViewerControlAction`` family: every viewer inherits the lifecycle actions,
unknown messages answer ERROR, and pong capabilities derive from the registry.
An existing endpoint is reusable only after a typed control reply proves that a
compatible viewer is ready. Endpoint files or bound ports are discovery facts,
not readiness evidence.
Expand Down
48 changes: 17 additions & 31 deletions openhcs/agent/dto/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@
AgentResultEnvelope,
AgentWarning,
)
from openhcs.agent.dto.execution_connection import ExecutionConnectionSpec
from openhcs.agent.dto.execution_connection import (
ExecutionConnectionFields,
ExecutionConnectionSpec,
)
from openhcs.agent.ui_bridge_identities import (
PlateManagerOrchestratorCodeDocumentIdentity,
)
Expand All @@ -48,23 +51,6 @@
MAX_EXECUTION_STATUS_TRACEBACK_CHARS = 3000


@dataclass(frozen=True, kw_only=True)
class ExecutionConnectionProjection:
connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec)

@property
def host(self) -> str:
return self.connection.host

@property
def port(self) -> int | None:
return self.connection.port

@property
def transport_mode(self) -> TransportMode | None:
return self.connection.transport_mode


class RuntimeServerConnectionToolRequest(AgentCliRequest):
"""Nominal request type for runtime-server tools using connection fields."""

Expand Down Expand Up @@ -120,7 +106,7 @@ class OrchestratorSessionRef(OrchestratorSessionIdentity):


@dataclass(frozen=True, slots=True)
class OrchestratorSessionCreationRequest(ExecutionConnectionProjection):
class OrchestratorSessionCreationRequest(ExecutionConnectionFields):
"""Create an execution session from an in-memory pipeline draft."""

plate_path: str
Expand Down Expand Up @@ -159,7 +145,7 @@ def from_fields(


@dataclass(frozen=True, slots=True)
class PipelineSourceOrchestratorSessionRequest(ExecutionConnectionProjection):
class PipelineSourceOrchestratorSessionRequest(ExecutionConnectionFields):
"""Create an execution session from pycodified pipeline source."""

plate_path: str
Expand Down Expand Up @@ -428,7 +414,7 @@ class ArtifactPlanInspection(AgentResultEnvelope):


@dataclass(frozen=True, slots=True)
class RuntimeServerInfo(ExecutionConnectionProjection):
class RuntimeServerInfo(ExecutionConnectionFields):
schema_version: str
reachable: bool
ready: bool | None = None
Expand Down Expand Up @@ -472,7 +458,7 @@ def from_pong(


@dataclass(frozen=True, slots=True)
class RuntimeServerScanResult(ExecutionConnectionProjection):
class RuntimeServerScanResult(ExecutionConnectionFields):
schema_version: str
ports: tuple[int, ...]
timeout_ms: int
Expand Down Expand Up @@ -580,7 +566,7 @@ def as_tool_arguments(self) -> JsonObject:
@dataclass(frozen=True, slots=True)
class RuntimeServerInfoRequest(
RuntimeServerConnectionToolRequest,
ExecutionConnectionProjection,
ExecutionConnectionFields,
):
"""Request a read-only runtime-server snapshot."""

Expand Down Expand Up @@ -724,7 +710,7 @@ class RuntimeBootstrapCloseResult(AgentResultEnvelope):


@dataclass(frozen=True, slots=True)
class RuntimeExecutionStatus(ExecutionConnectionProjection):
class RuntimeExecutionStatus(ExecutionConnectionFields):
schema_version: str
execution_id: str | None
status: str
Expand All @@ -735,7 +721,7 @@ class RuntimeExecutionStatus(ExecutionConnectionProjection):
@dataclass(frozen=True, slots=True)
class RuntimeServerExecutionStatusRequest(
RuntimeServerConnectionToolRequest,
ExecutionConnectionProjection,
ExecutionConnectionFields,
):
"""Request bounded execution status from a runtime server."""

Expand Down Expand Up @@ -775,7 +761,7 @@ def as_tool_arguments(self) -> JsonObject:


@dataclass(frozen=True, slots=True)
class RuntimeDebugInspectionResult(ExecutionConnectionProjection):
class RuntimeDebugInspectionResult(ExecutionConnectionFields):
"""Renderer-independent values visible in one paused debug worker."""

schema_version: str
Expand All @@ -787,7 +773,7 @@ class RuntimeDebugInspectionResult(ExecutionConnectionProjection):
@dataclass(frozen=True, slots=True)
class RuntimeDebugInspectionRequest(
RuntimeServerConnectionToolRequest,
ExecutionConnectionProjection,
ExecutionConnectionFields,
):
"""Request the typed runtime-value view for one paused debug session."""

Expand Down Expand Up @@ -839,7 +825,7 @@ def as_tool_arguments(self) -> JsonObject:
@dataclass(frozen=True, slots=True)
class RuntimeDebugCommandRequest(
RuntimeServerConnectionToolRequest,
ExecutionConnectionProjection,
ExecutionConnectionFields,
):
"""Request one debug-worker command on a paused OpenHCS debug session.

Expand Down Expand Up @@ -908,7 +894,7 @@ def as_tool_arguments(self) -> JsonObject:


@dataclass(frozen=True, slots=True)
class RuntimeDebugCommandResult(ExecutionConnectionProjection):
class RuntimeDebugCommandResult(ExecutionConnectionFields):
"""One debug-worker command's resulting paused-worker status."""

schema_version: str
Expand All @@ -922,7 +908,7 @@ class RuntimeDebugCommandResult(ExecutionConnectionProjection):
@dataclass(frozen=True, slots=True)
class RuntimeDebugArtifactExportRequest(
RuntimeServerConnectionToolRequest,
ExecutionConnectionProjection,
ExecutionConnectionFields,
):
"""Request server-side export of one paused debug worker's artifact.

Expand Down Expand Up @@ -1013,7 +999,7 @@ def as_tool_arguments(self) -> JsonObject:


@dataclass(frozen=True, slots=True)
class RuntimeDebugArtifactExportResult(ExecutionConnectionProjection):
class RuntimeDebugArtifactExportResult(ExecutionConnectionFields):
"""One debug artifact export's resulting exported reference."""

schema_version: str
Expand Down
21 changes: 20 additions & 1 deletion openhcs/agent/dto/execution_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from dataclasses import asdict, dataclass, replace
from dataclasses import asdict, dataclass, field, replace
from typing import TYPE_CHECKING

from python_introspect import (
Expand Down Expand Up @@ -110,3 +110,22 @@ def execution_client(
transport_mode=self.transport_mode,
config=config,
)


@dataclass(frozen=True, kw_only=True)
class ExecutionConnectionFields:
"""A request or result that carries one execution connection."""

connection: ExecutionConnectionSpec = field(default_factory=ExecutionConnectionSpec)

@property
def host(self) -> str:
return self.connection.host

@property
def port(self) -> int | None:
return self.connection.port

@property
def transport_mode(self) -> TransportMode | None:
return self.connection.transport_mode
Loading
Loading