From f4ebcf8d70e3cbc33640407b9de48562869445fc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 22:57:50 -0400 Subject: [PATCH 01/24] WIP B6b: B6's root-cause fixes on current main, without formatting-only edits or ratchet mixin moves Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 7 + benchmark/adapters/openhcs.py | 13 +- benchmark/cellprofiler_benchmark_cli.py | 9 +- benchmark/openhcs_measured_run.py | 14 +- .../consolidation-20261009/B6-ci-green.md | 130 ++++++++++++++++ .../U1-session-interface.md | 2 +- external/ObjectState | 2 +- external/PolyStore | 2 +- external/zmqruntime | 2 +- openhcs/agent/capabilities.py | 4 +- openhcs/agent/dto/session.py | 2 + openhcs/agent/knowledge_manifest_schema.py | 33 ++-- .../authoring/session/operations/datasets.py | 1 - openhcs/authoring/session/session.py | 23 ++- openhcs/authoring/session/views.py | 22 +-- openhcs/core/pipeline/function_contracts.py | 54 +++---- openhcs/core/processing_preparation.py | 21 ++- openhcs/core/source_bindings.py | 10 +- openhcs/core/source_image_provenance.py | 69 ++++++--- openhcs/core/steps/function_outputs.py | 27 +++- openhcs/core/steps/function_runtime.py | 88 +++++++---- openhcs/mcp/installed_demo.py | 5 +- .../cellprofiler/_granularity_native.cpp | 20 ++- .../backends/enhance/basic_processor_jax.py | 4 + .../processing/backends/enhance/flatfield.py | 5 +- .../backends/lib_registry/openhcs_registry.py | 138 +++++++++++++++-- .../processing/custom_functions/manager.py | 10 +- openhcs/pyqt_gui/main.py | 7 +- pyproject.toml | 10 +- scripts/build_mcp_knowledge_assets.py | 42 +++-- scripts/smoke_installed_gui.py | 35 +++-- scripts/smoke_installed_mcp.py | 11 +- tests/conftest.py | 42 ++--- .../test_agent_observation_export_zmq.py | 13 +- tests/integration/test_basicpy_real_fit.py | 7 + .../test_benchmark_openhcs_adapter_cppipe.py | 5 +- .../test_cellprofiler_official30_zmq.py | 145 ++++++++---------- .../test_count_cells_simple_dual_channel.py | 10 +- .../test_custom_source_transport.py | 24 ++- .../integration/test_measured_cli_session.py | 37 ++++- .../test_neurite_outgrowth_synthetic_plate.py | 46 +++--- tests/integration/test_session_journey.py | 100 ++---------- .../test_end_to_end_workflow_foundation.py | 34 ++-- .../test_image_browser_filter_controller.py | 18 ++- tests/pyqt_gui/test_managed_windows.py | 8 +- tests/pyqt_gui/test_session_gui_journey.py | 119 ++++++++++++++ tests/unit/agent/test_capabilities.py | 24 +++ .../unit/agent/test_knowledge_base_service.py | 22 ++- .../test_mcp_dev_client_sampling_results.py | 16 +- .../agent/test_mcp_dev_client_ui_renderers.py | 2 +- .../agent/test_mcp_plate_stream_progress.py | 5 +- .../test_selected_plane_reopen_stream.py | 5 +- tests/unit/conftest.py | 16 ++ tests/unit/test_aggregate_source_scope.py | 33 +++- ...utomatic_image_role_materialization_435.py | 6 +- tests/unit/test_build_mcp_knowledge_assets.py | 48 +++++- tests/unit/test_ci_import_guards.py | 38 +++++ ...colocalization_metric_setting_authority.py | 2 +- tests/unit/test_dataset_access.py | 22 +++ tests/unit/test_debug_runtime.py | 7 +- .../unit/test_derived_role_publication_435.py | 6 +- tests/unit/test_function_outputs.py | 57 +++++++ .../test_gray_to_color_binding_axis_432.py | 4 +- ..._gray_to_color_payload_axis_compile_432.py | 7 +- tests/unit/test_library_registry_discovery.py | 104 +++++++++++++ .../unit/test_named_checkpoint_publication.py | 2 +- tests/unit/test_openhcs_adapter.py | 16 +- tests/unit/test_preparation_admission.py | 8 +- .../test_rescale_stream_plane_declaration.py | 4 +- ...t_selected_plane_stream_materialization.py | 4 +- ...test_singleton_rgb_label_invocation_477.py | 23 ++- tests/unit/test_smoke_installed_gui.py | 31 ++++ tests/unit/test_snapshot_deadline.py | 2 +- tests/unit/test_viewer_spatial_sample.py | 2 +- 74 files changed, 1474 insertions(+), 472 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/refactor/consolidation-20261009/B6-ci-green.md create mode 100644 tests/pyqt_gui/test_session_gui_journey.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_ci_import_guards.py create mode 100644 tests/unit/test_dataset_access.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..afe469b493 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Tracked files are checked out as their committed bytes on every platform. +# Benchmark corpora, reference-export sidecars and documentation audits are +# compared by sha256, which Windows end-of-line conversion would change. +* -text +# Windows command scripts keep the platform's line endings. +*.cmd text +*.bat text diff --git a/benchmark/adapters/openhcs.py b/benchmark/adapters/openhcs.py index 41ae61024b..1abd59d761 100644 --- a/benchmark/adapters/openhcs.py +++ b/benchmark/adapters/openhcs.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import Any +from objectstate import replace_raw + from benchmark.adapters.cppipe_source import ( CPPipeSourceRequest, CPPipeSourceResolution, @@ -308,7 +310,6 @@ def _execution_global_config( request: OpenHCSRunRequest, *, output_suffix: str, - output_dataset_root: Path, compilation_debug_config: CompilationDebugConfig, ) -> GlobalPipelineConfig: """Select benchmark output paths without replacing declared path policy.""" @@ -330,7 +331,6 @@ def _execution_global_config( ), compilation_debug_config=compilation_debug_config, materialize_runtime_artifacts=request.materialize_runtime_artifacts, - materialization_results_path=output_dataset_root / "results", ) def _run_converted_cppipe_pipeline( @@ -361,9 +361,6 @@ def _run_converted_cppipe_pipeline( ) from exc output_suffix = f"_{request.pipeline_name}_converted_cppipe" - output_dataset_root = ( - request.output_dir / f"{request.dataset_path.name}{output_suffix}" - ) generated_source_path = request.output_dir / f"{cppipe_path.stem}_openhcs.py" source_workspace_path = ( request.output_dir @@ -417,7 +414,9 @@ def _run_converted_cppipe_pipeline( "UI/ZMQ FunctionStep source before execution." ) if compilation_debug_config.enabled: - pipeline_config = replace( + # replace_raw keeps the imported config's unset fields inheriting + # from the global config; dataclasses.replace would freeze them. + pipeline_config = replace_raw( pipeline_config, compilation_debug_config=LazyCompilationDebugConfig( enabled=compilation_debug_config.enabled, @@ -430,7 +429,6 @@ def _run_converted_cppipe_pipeline( global_config = self._execution_global_config( request, output_suffix=output_suffix, - output_dataset_root=output_dataset_root, compilation_debug_config=compilation_debug_config, ) ensure_global_config_context(GlobalPipelineConfig, global_config) @@ -550,7 +548,6 @@ def _run_converted_cppipe_pipeline( raise ToolExecutionError(equivalence_failure_message) metric_results = self._metric_results(request.metrics) - output_dataset_root.mkdir(parents=True, exist_ok=True) execution_output_root.mkdir(parents=True, exist_ok=True) provenance = { diff --git a/benchmark/cellprofiler_benchmark_cli.py b/benchmark/cellprofiler_benchmark_cli.py index 1bb056d84f..9170a09535 100644 --- a/benchmark/cellprofiler_benchmark_cli.py +++ b/benchmark/cellprofiler_benchmark_cli.py @@ -6,6 +6,7 @@ import json import math import os +import signal import sys from abc import ABC, abstractmethod from collections.abc import Sequence @@ -322,7 +323,9 @@ def run(self, args: argparse.Namespace) -> int: ) return 130 except MeasuredRunEnded as ended: - print(json.dumps(to_jsonable(ended.row), sort_keys=True), file=sys.stderr) + print( + json.dumps(to_jsonable(ended.row), sort_keys=True), file=sys.stderr + ) raise RuntimeError( f"Ordinary pipeline run did not complete: {ended}" ) from ended @@ -1069,6 +1072,10 @@ def create_benchmark_argument_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: """Run the registered benchmark CLI.""" + if sys.platform == "win32": + # Ctrl+Break is the Windows console interrupt a launcher can deliver + # to this process alone; it stops a run exactly as Ctrl+C does. + signal.signal(signal.SIGBREAK, signal.default_int_handler) arguments = tuple(sys.argv[1:] if argv is None else argv) parser = create_benchmark_argument_parser() parsed = parser.parse_args(arguments) diff --git a/benchmark/openhcs_measured_run.py b/benchmark/openhcs_measured_run.py index 9523226820..ef66246f87 100644 --- a/benchmark/openhcs_measured_run.py +++ b/benchmark/openhcs_measured_run.py @@ -8,10 +8,7 @@ import sys import time from collections.abc import Callable, Mapping -from dataclasses import ( - dataclass, - field, -) +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -134,7 +131,12 @@ class _ZMQProgressTimingObserver: compile_completed_at: float | None = None execution_started_at: float | None = None execution_completed_at: float | None = None - last_progress_monotonic: float = field(default_factory=time.monotonic) + last_progress_monotonic: float | None = None + """Monotonic time of the latest progress; None until a run is submitted. + + Starting the execution server is not execution: its client owns the + startup readiness timeout, so silence is measured from begin_run. + """ last_progress_phase: str = "" last_progress_status: str = "" on_event: Callable[[Mapping[str, Any]], None] | None = None @@ -217,6 +219,8 @@ def _duration(started_at: float | None, ended_at: float | None) -> float | None: def inactivity_seconds(self, *, observed_at: float | None = None) -> float: """Return elapsed monotonic time since the latest server progress event.""" + if self.last_progress_monotonic is None: + return 0.0 current = time.monotonic() if observed_at is None else observed_at return max(0.0, current - self.last_progress_monotonic) diff --git a/docs/refactor/consolidation-20261009/B6-ci-green.md b/docs/refactor/consolidation-20261009/B6-ci-green.md new file mode 100644 index 0000000000..301fca9327 --- /dev/null +++ b/docs/refactor/consolidation-20261009/B6-ci-green.md @@ -0,0 +1,130 @@ +# B6: main's CI green + +**Head audited:** `openhcs` `main` at `84e7a2904` (#1193). **Rules:** [00-RULES.md](00-RULES.md). **Input:** the last completed runs on main: Integration Tests 38116770859 (`b468e7359`), Website 38110269836 and Documentation 38116770933. Integration Tests last passed on main at `fc1f94632` (2026-09-24). Documentation had not passed in the last 1,000 main runs. + +## What was wrong + +**Three workflows were red on main because of about twenty independent causes.** Most of them only CI exposes: the installed wheel with `--import-mode=importlib`, Windows, Intel macOS, xcb under Xvfb, path filters, and jobs nobody watched. The table gives each cause, its fix, and its certificate where something was deleted. + +| Job(s) | First cause | Fix | +|---|---|---| +| Website | The job installed a five-package roster, then imported `openhcs`, whose import graph needs numba. The `paths:` filter only listed `openhcs/__init__.py`. | Install the candidate through `install_ci_candidate` with the `dev` extra, because `pytest.ini`'s addopts need pytest-xdist. Trigger on `openhcs/**` and `external/**`. | +| Documentation (`validate_docs`) | 950 findings: 141 of 155 pages had stale editorial audits (828 changed and 78 deleted authorities), plus 10 false "missing path" findings. | The path regex now matches whole repository paths and covers `packaging/`. All 141 pages were re-read against the diff of each stale authority since its reviewed version, wrong claims were corrected (see the commit), and digests were pinned after review. B6's own later edits were compared and re-pinned. | +| Documentation (sphinx `-W`) | PolyStore and pyqt-reactive had agent run logs outside every toctree. zmqruntime's API page listed members removed by L3. | Deleted the logs (**contract**: Q4, validation logs nothing links to). zmqruntime's page derives its members with plain `:members:`. | +| unit-tests (collection) | 14 test modules imported a sibling test module by bare name, which fails under importlib mode. | Import through the `tests` package; guard in `test_ci_import_guards.py`. | +| unit-tests (abort, exit 134) | An in-process MCP transport created a `QCoreApplication`, so a later `QWidget` aborted. | The session creates its `QApplication` first (`tests/conftest.py`). | +| code-quality | 412 unformatted files since the last success. A test used Python 3.12-only f-string quote reuse, which is a syntax error on 3.11. | Black over this PR's changed files; quote fixed. Main's baseline becomes this PR's green head. | +| structural ratchet | Black wrapping and B6 fixes grew six classes past their budget. | Cohesive method groups moved into inherited mixins: `MainOutputSliceProjectionMixin`, `FunctionCoreDebugMixin`, `CustomFunctionSourceFilesMixin`, `SessionDebugMixin`. Catalog policy moved to module functions. Four unused main-window theme methods and `SourceImageProvenance.source_component_identity` were deleted (**unused**: no caller, menu, bridge or string reference). | +| Windows (every ZMQ job) | `tests/conftest.py` used `os.getpgid`. zmqruntime's `startup_owner` reopened the lock file that portalocker holds (mandatory locks give PermissionError). | Process-group reaping only on POSIX. zmqruntime reads and writes the owner record through the held handle (0.6.2). | +| Windows | NumPy exports uint32 as format `'L'`; custom-function sources were written in text mode, which changes their hashes; SIGINT can't be sent to a child; chmod doesn't make a directory read-only and `os.access` ignores ACLs; autocrlf rewrote checksummed `.cppipe` files; packaged knowledge paths were longer than MAX_PATH. | Native granularity accepts both formats. Sources are persisted as exact bytes. The CLI treats Ctrl+Break as Ctrl+C. Writability is decided by an actual file-creation attempt, and the test denies writes with an ACL. `.gitattributes` keeps committed bytes (`* -text`). Recipes are placed by case name within a declared path budget. | +| Intel macOS | `openhcs-basicpy` is excluded by marker, but the catalog imported its module. The kernel cache compiled serially because macOS has no `sched_getaffinity`. | Modules declare `REQUIRED_DISTRIBUTIONS`, and the catalog excludes those whose package markers leave them out. Workers come from `os.cpu_count()` where affinity is unavailable. | +| python-314 | ObjectState read a class's own annotations through `__dict__`, which 3.14 no longer populates. mahotas sat only in an extra, but core catalog modules need it. | ObjectState uses `inspect.get_annotations` (2.1.1). mahotas is a core dependency. | +| gui-tests (13) | Stale tests after U1/U5. | Rewritten against the session-backed plate manager and the current stubs. | +| backend-microscope, boundary, OMERO | STEP_INPUT filters matched virtual names instead of stored source paths. Passthrough outputs dropped their producer's filename qualifier. BaSiC read `.metadata` off plain arrays. `OMEROLocalBackend` had no `is_dir`. The metadata document was written to local disk for a virtual backend. Viewer tests used `ViewerType` members. The GUI journey test ran in headless jobs. Several integration tests had gone stale. | Fixed at each owner: `SourceImageIdentity.filter_paths`, producer qualifiers, `ImagePayload.of`, `OMEROLocalBackend.is_dir`/`is_file` (PolyStore 0.6.1), `OpenHCSMetadataTarget.hosts_metadata_document` (not for a `VirtualBackend`), viewer families, and the GUI journey moved to gui-tests. | +| Official30 real-viewer smoke | `required_scalar_components` pooled contributors across runtime planes and dropped `site`, so napari rejected the stack. | Contributors are grouped per plane (`contributor_groups`). | +| Installed demo (Windows, Intel macOS) | The one-plane label projection (#477) lived only on `MatchImageStackLabels`; the skeleton step used `SliceAlignedLabels`. | The projection is owned by `ObjectLabelInputExecutionMode` (**derived**: `MatchImageStackLabels`' copy deleted). Dataset rows carry the finished execution's error. | +| desktop live MCP | The smoke read the catalog before the GUI's endpoint was up. Requestless UI-bridge operations received the connection in the request slot. | `execution_services_prepared` future; connection passed by keyword. | +| wheel-integration | The MCP smoke sent `wells` (G2 renamed it `partition_values`). Bio-Formats tests ran without the declared Fiji bundle. | `partition_values`; the job restores and prewarms the bundle. | +| pypi-dependency-readiness | Pinned submodule sources differ from their release tags. | Version bumps: zmqruntime 0.6.2, PolyStore 0.6.1, ObjectState 2.1.1 (pycodify's change shipped in main's 0.2.0). Floors raised. **The job stays red until the owner merges and tags the three library PRs.** | + +## Guards + +- `tests/unit/test_ci_import_guards.py`: no test module imports a test-tree module by bare name. +- `tests/unit/test_ci_package_boundaries.py`: the website job installs the candidate with the dev extra. +- `tests/unit/test_build_website.py`: the website job triggers on `openhcs/**`. +- `tests/unit/test_validate_documentation_audit.py`: repository paths match only whole paths. +- `tests/unit/test_build_mcp_knowledge_assets.py`: every packaged resource fits the Windows path budget. +- `tests/unit/test_library_registry_discovery.py`: marker-excluded distributions are left out of the catalog, while a required but missing one fails. +- `tests/unit/test_dataset_access.py`; `external/zmqruntime` held-handle owner-record test; `external/PolyStore/tests/test_omero_virtual_paths.py`. + +## Done when + +Website, Documentation and Integration Tests are green on this PR's head, or each remaining red job names its exact cause and owner (see the PR's final status). + +## Lockstep library PRs (owner merges and tags) + +- OpenHCSDev/ZMQRuntime#29 (0.6.2; docs; startup lock) +- OpenHCSDev/PolyStore#38 (0.6.1; OMERO `is_dir`; docs logs) +- OpenHCSDev/ObjectState#26 (2.1.1) +- OpenHCSDev/PyQT-reactive#21 (docs logs) + +## Handoff + +**Branch `refactor/B6-ci-green`, head `abf42ae1b` plus this receipt commit, merged with main `625150118`. PR #1195. GitHub Actions stalled before any job ran on this head, so nothing after `f419cb3bb` has a completed CI run.** + +### Last completed CI runs + +- **`f419cb3bb` (merged with main `eb64b3c13`): 36 jobs passed, 7 failed.** Website, Documentation and Refactor Guardrails were green. This is the last trustworthy picture. +- **`2e0deafe6` (merged with main `44980dfe2`): 27 jobs failed.** Fixed since, unverified (see below). + +### Root causes fixed, with commits + +| Cause | Commit | +|---|---| +| Docs path regex matched inside longer paths | `c9de5fab0` | +| Website installed a package roster; needs dev extra; path filter; shared `pages` concurrency group | `6bccc4d6d`, `5544c445f`, `ce529e459` | +| Sibling test-module imports under importlib mode | `6bccc4d6d` | +| `os.getpgid` on Windows; top-level import in GUI smoke | `62752cf33` | +| 141/126/127 stale documentation pages (three rounds, after each main merge) | `e59901e76`, `b2fb1bcd2`, `297a423d5` | +| Library docs: orphaned logs, removed zmqruntime members | `6669b3401`, `f3eb57aa9` | +| zmqruntime startup-owner lock on Windows | `333d3e0ab` (library PR ZMQRuntime#29) | +| Windows uint32 format; custom-function bytes | `47724ccd4`, `d5172f398` | +| Stale GUI tests; catalog race; requestless UI-bridge call; `partition_values` | `f59da9b8f`, `03ca14cc9`, `c3ab99415`, `87e4df3ff` | +| Marker-excluded distributions in the catalog (Intel macOS basicpy) | `c1013dd51` | +| BaSiC `.metadata`; STEP_INPUT filter paths; viewer families; GUI journey job | `c00a652d4`, `35abb00d6`, `4b1dec1e2`, `2eb48fb9d`, `dbae3e0b4` | +| mahotas core dependency; measured CLI child environment | `5e519401f`, `42f51b7a7` | +| OMERO `is_dir` (PolyStore#38); metadata document for virtual backends | `54e87d5b4`, `956326e3e` | +| Passthrough filename qualifier; adapter config; watchdog; stale synthetic tests | `89269b9a8`, `0f0ad9f7c`, `0d83e1e16`, `34556a1ac` | +| ObjectState own annotations on 3.14 (ObjectState#26) | `fac0519e5`, re-based to 2.1.1 in the first main merge | +| Ratchet: mixins, dead theme methods, catalog policy functions | `239953c4c`, `ab03ba7ba` | +| Packaged recipe paths over MAX_PATH; macOS serial kernel preparation | `35833f3a5`, `ded24279e` | +| Runtime-plane axes dropped from streams (napari settle) | `7066af9f3` | +| Windows read-only datasets, Ctrl+Break, one-shot write probe | `433b26fef`, `e1a1719d3`, `c74b2bd55` | +| Wheel job Fiji bundle; `.gitattributes` exact bytes | `e82ae3398`, `fbe17e02d` | +| One-plane label projection; demo error surfaced; `ViewerType.value` | `5de70ef71`, `88e22432e` | +| Unit session QApplication before MCP transports | `f322e395b`, `a5ce88b63`, `d43533f90` | +| `PARTITION_COUNT` in observation-export test | `b14856fbd` | +| E1 boundary: export scope and UI action status | `7379deb90`, `f0498a630` | +| Unit job split into three serial groups | `542f7ffeb` | + +### Still red at the last completed run (`f419cb3bb`) + +| Job | First cause | Owner | +|---|---|---| +| pypi-dependency-readiness | Pinned ObjectState 2.1.1, PolyStore 0.6.1 and zmqruntime 0.6.2 have no release tags | owner: merge and tag ObjectState#26, PolyStore#38, ZMQRuntime#29 | +| unit-tests | Cancelled at the 45-minute limit with 63% run. `test_custom_function_events.py` alone took 9 minutes (200 `gc.collect()` calls on a large heap). Of 48 failures before the cut, 29 are in `known.fail`; 18 pass locally on the merged tree; `test_declaration_selected_discovery.py::test_examplehuman_selected_source_uses_same_declaration_owner` fails on main `44980dfe2` too and is not in `known.fail` | job time: B6 (split unverified); the test: unowned, needs an owner | +| official30-headless-parity (macOS 3.14) | ExampleHuman `cells/zernike_*` values differ from the native reference; Linux 3.12 and 3.14 pass | parity owner (Zernike numerics off Linux) | +| official30-headless-parity (Windows 3.14) | ExampleImagingFlowCytometryObjectsInGrid `ssc/zernike_phase_*` values differ | same | +| official30-parity-required | Aggregates the two above | same | +| desktop-candidate-live-mcp and desktop-installer-source-test (macos-15-intel) | Cold function-catalog preparation runs past the smoke's 300-second deadline (it passed once at about 200 s) | owner decision: performance work or the budget | + +### Unverified because CI stalled + +- The E1 boundary fixes (`7379deb90`, `f0498a630`); locally the four integration tests and the GUI journey pass. +- The three-group unit job (`542f7ffeb`). The earlier xdist attempt (`f8062d2cd`) lost a worker in `test_mcp_server.py` under xcb and was replaced. +- The Website per-ref concurrency group (`ce529e459`). +- The third documentation round and re-pins after `44980dfe2` and `625150118`; `validate_docs.py` passes locally on every target and the OpenHCS Sphinx `-W` build passed locally after round two. +- Windows-only fixes confirmed by one run each but not on this head: read-only probe (`c74b2bd55` never ran on Windows), `.gitattributes`. +- An earlier CI-only segfault in `tests/unit/pyqt_gui` teardown under xcb was not reproduced or root-caused; it has not recurred since `f322e395b`. + +### Why 27 jobs failed after merging main `44980dfe2` + +**E1 left two boundaries half-migrated on main, and every job that runs a dataset or the GUI smoke hit one of them:** + +- `DatasetRunRequest.runtime_observation_export_scope` is an enum-view field, but the measured CLI passed the family class into it, and `RunDatasets` passed the enum member to `Session.observation_export_params`, which expects the family. Every `run_datasets` call failed: four integration tests in all 17 backend, boundary and OMERO jobs, the wheel MCP smoke and the GUI journey. +- `scripts/smoke_installed_gui.py` read `UiActionInvocationStatus.ACCEPTED`, which E1 replaced with the `ActionAccepted` class: all desktop installer and live-MCP jobs. +- The unit job failed separately on the xdist worker crash described above. + +Main's own run at `625150118` had not started, so "main is red the same way" is inferred from the code being identical on main, not observed. + +### Standing problem for the owner + +**The documentation audit gate goes red on every surface merge.** Each merge changes authority files, which invalidates about 125 pages; B6 re-reviewed them three times in one day. Unless each surface re-reviews its dependent pages in its own PR, Documentation will be red again after the next merge. + +### Also found, not fixed + +- `openhcs/agent/services/config_service.py` `_default_value_repr` prints family-class defaults as `` in the generated config reference. +- `openhcs/mcp/bootstrap.py` `--surface` help says the desktop surface hides headless tools; it does not. +- `scripts/check_basicpy_source.py` expects a `jax_func` decorator the module no longer uses. +- `polystore/atomic.py` `_try_acquire_lock` retries `PermissionError` until its 30-second timeout. +- About 20 pages still say "Plate Manager" where the window title is now "Datasets". diff --git a/docs/refactor/consolidation-20261009/U1-session-interface.md b/docs/refactor/consolidation-20261009/U1-session-interface.md index 1f2929f8b5..1380f5346e 100644 --- a/docs/refactor/consolidation-20261009/U1-session-interface.md +++ b/docs/refactor/consolidation-20261009/U1-session-interface.md @@ -88,7 +88,7 @@ Measured against `origin/main` (`89b8fdbff`, Python plus the MCP contract fixtur Corrections to the plan above: - **MCP tools are derived, not subclassed.** `SessionOperation` does not subclass `AgentCapabilityDeclaration` (that import cycles through the DTOs); `capabilities.py` derives one `openhcs_` capability per `HeadlessOperation`. The guard checks the derivation is total. -- **The headless journey runs a real execution server**, not a fake client; it and the GUI journey live in `tests/integration/test_session_journey.py`. +- **The headless journey runs a real execution server**, not a fake client; it lives in `tests/integration/test_session_journey.py`; the GUI journey, which needs the GUI extras and a Qt platform, lives in `tests/pyqt_gui/test_session_gui_journey.py`. - **The pipeline editor keeps `displayed_steps`**, a render projection of `PipelineStepsView`, never written back; the guard allows it. - **500 ms polling advice remains on the UI bridge** (`recommended_poll_interval_ms`): bridge events belong to U2. The session's own event stream is push (`openhcs_session_events`). - **The session package loads PyQt6 transitively** through `pyqt_reactive.protocols`; the guard checks direct imports. Owner: U4 (library moves). diff --git a/external/ObjectState b/external/ObjectState index 4eb8de1398..a44b90b08a 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 4eb8de1398428c539e1e14360c847ccea2fe4b34 +Subproject commit a44b90b08afab86b9d142fe59f9fa01cd61beab4 diff --git a/external/PolyStore b/external/PolyStore index 7ac9d01110..25ee78d94a 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit 7ac9d011100b18f4ad1a54635ee94f52a47efb30 +Subproject commit 25ee78d94aa31f3e12693c14be3de0d10e104086 diff --git a/external/zmqruntime b/external/zmqruntime index dc327e6ca2..3bb1b13098 160000 --- a/external/zmqruntime +++ b/external/zmqruntime @@ -1 +1 @@ -Subproject commit dc327e6ca2496aa4ad405479832f4e9e219750e4 +Subproject commit 3bb1b13098aaf022cc295e2949b5fbfb393c6f94 diff --git a/openhcs/agent/capabilities.py b/openhcs/agent/capabilities.py index 27a03c7018..18482e2405 100644 --- a/openhcs/agent/capabilities.py +++ b/openhcs/agent/capabilities.py @@ -1659,7 +1659,9 @@ class UiBridgeOperationExecution(AgentCapabilityInvocation): def execute(self, context: object, *arguments: object) -> object: *request, connection = arguments - return context.ui_bridge_service.invoke(self.operation, *request, connection) + return context.ui_bridge_service.invoke( + self.operation, *request, connection=connection + ) @dataclass(frozen=True, slots=True) diff --git a/openhcs/agent/dto/session.py b/openhcs/agent/dto/session.py index 45e37b84cc..37bb36388f 100644 --- a/openhcs/agent/dto/session.py +++ b/openhcs/agent/dto/session.py @@ -164,6 +164,8 @@ class DatasetRowState: debug_session_id: str | None = None finished_execution_id: str | None = None """The dataset's latest finished ordinary execution (its evidence id).""" + finished_execution_error: str | None = None + """The server's error for that execution, when it did not complete.""" scope_accent_color: str | None = None diff --git a/openhcs/agent/knowledge_manifest_schema.py b/openhcs/agent/knowledge_manifest_schema.py index 9dd6751aaf..e71f892b53 100644 --- a/openhcs/agent/knowledge_manifest_schema.py +++ b/openhcs/agent/knowledge_manifest_schema.py @@ -4,6 +4,7 @@ import json import os +import re from collections.abc import Mapping from dataclasses import dataclass from enum import Enum @@ -13,6 +14,12 @@ "docs/source/development/mcp_knowledge_base_manifest.json" ) PACKAGED_KNOWLEDGE_BASE_ROOT = Path("resources/knowledge") +# Windows without long-path support rejects installed paths of 260 characters +# or more. The installer places the knowledge root at +# /env-XXXXXXXX/Lib/site-packages/openhcs/agent/resources/knowledge, +# so every resource path relative to that root must stay within this budget. +PACKAGED_KNOWLEDGE_PATH_BUDGET = 120 +_PORTABLE_CASE_NAME = re.compile(r"[A-Za-z0-9_](?:[A-Za-z0-9_ .-]*[A-Za-z0-9_])?") class KnowledgeBaseManifestField(str, Enum): @@ -145,21 +152,21 @@ def load(cls, path: Path, *, source_root: Path) -> ComparisonManifestSnapshot: return manifest def native_source_relative_path(self, case: Mapping[str, object]) -> Path: - """Derive one placement from its original manifest/root/path identity.""" + """Place one native recipe by its manifest-unique case name. + + Upstream root/path layouts are deep; keying by case name keeps every + installed resource within ``PACKAGED_KNOWLEDGE_PATH_BUDGET``. + """ path = Path(str(case["cppipe_path"])) - root = Path(str(case.get("cppipe_path_root", "unrooted"))) - if ( - path.is_absolute() - or root.is_absolute() - or ".." in path.parts - or ".." in root.parts - or path.suffix != ".cppipe" - ): + name = str(case.get("name")) + if path.is_absolute() or ".." in path.parts or path.suffix != ".cppipe": raise ValueError("Native knowledge source must be a relative .cppipe path.") - return ( - self.path.relative_to(self.source_root).with_suffix(".sources") - / root - / path + if not _PORTABLE_CASE_NAME.fullmatch(name): + raise ValueError( + f"Native knowledge case name {name!r} is not a portable file name." + ) + return self.path.relative_to(self.source_root).with_suffix(".sources") / ( + f"{name}.cppipe" ) def native_source_path(self, case: Mapping[str, object]) -> Path: diff --git a/openhcs/authoring/session/operations/datasets.py b/openhcs/authoring/session/operations/datasets.py index 615072d580..d52eef726f 100644 --- a/openhcs/authoring/session/operations/datasets.py +++ b/openhcs/authoring/session/operations/datasets.py @@ -461,4 +461,3 @@ class ShowDatasetImages(InitializedTargetsOperation, RendererOperation): tooltip = "Browse dataset images and metadata" description = "Opens the image and metadata browser of the selected datasets." side_effects = ("opens_dataset_viewer_window",) - diff --git a/openhcs/authoring/session/session.py b/openhcs/authoring/session/session.py index 90db36c345..e4ae608b78 100644 --- a/openhcs/authoring/session/session.py +++ b/openhcs/authoring/session/session.py @@ -16,6 +16,7 @@ import os import threading import time +import uuid from abc import ABC, abstractmethod from dataclasses import dataclass from collections.abc import Callable, Iterable @@ -204,6 +205,25 @@ def call(self, callback: Callable[[], ResultT]) -> ResultT: return self._dispatcher.call(callback) +def _creates_files_in(directory: Path) -> bool: + """Whether the filesystem lets this process create a file in ``directory``. + + Permission bits (``os.access``) do not see Windows ACLs or read-only + shares; an actual creation does. One attempt answers: ``tempfile`` on + Windows reads a refused creation as a name collision whenever ``os.access`` + passes and retries up to ``os.TMP_MAX`` (2**31 - 1) times. The probe file is + removed at once, so it leaves nothing behind. + """ + + probe = Path(directory) / f".openhcs-write-probe-{uuid.uuid4().hex}" + try: + os.close(os.open(probe, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)) + except OSError: + return False + probe.unlink() + return True + + class DatasetAccess(ABC): """Which dataset roots this session may read and initialize.""" @@ -218,7 +238,7 @@ def initializes_in_place(self, root: Path) -> bool: """ return all( - os.access(directory, os.W_OK) + _creates_files_in(directory) for directory in { Path(root), *(path.parent for path in METADATA_CONFIG.managed_paths(root)), @@ -1513,4 +1533,3 @@ def current_debug_context(self) -> DebugSessionProjectionContext | None: if not self.current_scope_id: return None return self.debug_context(self.current_scope_id) - diff --git a/openhcs/authoring/session/views.py b/openhcs/authoring/session/views.py index a736b56a43..257fccacd8 100644 --- a/openhcs/authoring/session/views.py +++ b/openhcs/authoring/session/views.py @@ -279,6 +279,16 @@ def row_state( terminal = activity.terminal_status runtime = activity.runtime debug_phase = activity.debug_phase + finished_id, finished = next( + ( + (execution_id, finished) + for execution_id, finished in reversed( + session.finished_executions.items() + ) + if finished.scope_id == row.scope_id + ), + (None, None), + ) return DatasetRowState( scope_id=row.scope_id, name=row.name, @@ -305,15 +315,9 @@ def row_state( source_root=relation.source_root, debug_phase=None if debug_phase is None else debug_phase.name, debug_session_id=activity.debug_session_id, - finished_execution_id=next( - ( - execution_id - for execution_id, finished in reversed( - session.finished_executions.items() - ) - if finished.scope_id == row.scope_id - ), - None, + finished_execution_id=finished_id, + finished_execution_error=( + None if finished is None else finished.record.error ), ) diff --git a/openhcs/core/pipeline/function_contracts.py b/openhcs/core/pipeline/function_contracts.py index 5668ea78e6..2b89c1dffe 100644 --- a/openhcs/core/pipeline/function_contracts.py +++ b/openhcs/core/pipeline/function_contracts.py @@ -171,8 +171,22 @@ def invocation_kwargs( runtime_projection: RuntimePlaneAxisValueProjection | None, semantic_controls: Mapping[str, Any], ) -> dict[str, Any]: - """Return the invocation kwargs once the image's execution mode is known.""" - del execution_mode, image_projection, runtime_projection + """Match a scalar image to its declared singleton runtime root. + + Labels can consume a singleton root only after the image's final + execution mode and retained plane projection have been resolved. + """ + if ( + execution_mode.per_plane + and image_projection is None + and runtime_projection is not None + and runtime_projection.axis_size == 1 + ): + from openhcs.core.runtime_slice_projection import RuntimeSliceProjection + + kwargs = RuntimeSliceProjection.kwargs_for_slice( + kwargs, runtime_projection.selected_plane(0) + ) return {**kwargs, **semantic_controls} @classmethod @@ -243,16 +257,6 @@ def image_execution_mode(cls, labels, default, *, runtime_slice_count=None): del labels, default, runtime_slice_count return FullStackExecution - -class MatchImageStackLabels(ObjectLabelInputExecutionMode): - """Labels follow the image: a stack when the image needs one.""" - - key = "match_image_stack" - - @classmethod - def preserves_full_stack(cls, *, image_stack_required: bool) -> bool: - return image_stack_required - @classmethod def invocation_kwargs( cls, @@ -263,23 +267,19 @@ def invocation_kwargs( runtime_projection, semantic_controls, ): - """Match scalar images to their declared singleton runtime root. + """Full-stack label consumers keep their declared label domain.""" + del execution_mode, image_projection, runtime_projection + return {**kwargs, **semantic_controls} - Matching labels can consume a singleton root only after the image's - final execution mode and retained plane projection have been resolved. - """ - if ( - execution_mode.per_plane - and image_projection is None - and runtime_projection is not None - and runtime_projection.axis_size == 1 - ): - from openhcs.core.runtime_slice_projection import RuntimeSliceProjection - kwargs = RuntimeSliceProjection.kwargs_for_slice( - kwargs, runtime_projection.selected_plane(0) - ) - return {**kwargs, **semantic_controls} +class MatchImageStackLabels(ObjectLabelInputExecutionMode): + """Labels follow the image: a stack when the image needs one.""" + + key = "match_image_stack" + + @classmethod + def preserves_full_stack(cls, *, image_stack_required: bool) -> bool: + return image_stack_required def _artifact_spec_from_output_declaration( diff --git a/openhcs/core/processing_preparation.py b/openhcs/core/processing_preparation.py index 74c9c4d995..4ff7b08efb 100644 --- a/openhcs/core/processing_preparation.py +++ b/openhcs/core/processing_preparation.py @@ -312,6 +312,18 @@ def close(self) -> None: self.result_connection.close() +def _schedulable_cpu_count() -> int: + """CPUs this process may run on. + + Linux exposes the scheduler's affinity set. macOS has no affinity API: + every online CPU is schedulable there. + """ + try: + return len(os.sched_getaffinity(0)) + except AttributeError: + return os.cpu_count() or 1 + + @dataclass(frozen=True, slots=True) class PreparationCacheBatch: """Schedule derived cache operations without interpreting backend families.""" @@ -340,14 +352,7 @@ def populate_child_caches( raise ValueError("Preparation worker budget must be positive.") if "fork" not in multiprocessing.get_all_start_methods(): return - try: - worker_capacity = min(max_workers, len(os.sched_getaffinity(0))) - except AttributeError: - # Without an affinity witness there is no admitted parallel cache - # work. The existing parent preparation still owns readiness. - return - if worker_capacity < 1: - return + worker_capacity = min(max_workers, _schedulable_cpu_count()) operations = { operation.identity: operation for preparation in self.preparations diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index f0331be372..9a9663eabb 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -984,12 +984,12 @@ def matches_provenance(self, provenance: SourceImageProvenance) -> bool: """Apply the same selectors to identities carried by current pixels.""" identities = provenance.represented_source_identities return ( - (not self.filters or self.path_filters_match( - tuple(identity.path for identity in identities if identity.path is not None) - )) - and self.metadata_candidates_match( - tuple(identity.component_metadata or {} for identity in identities) + not self.filters + or self.path_filters_match( + tuple(path for identity in identities for path in identity.filter_paths) ) + ) and self.metadata_candidates_match( + tuple(identity.component_metadata or {} for identity in identities) ) diff --git a/openhcs/core/source_image_provenance.py b/openhcs/core/source_image_provenance.py index 8efcca2271..b75664c7a0 100644 --- a/openhcs/core/source_image_provenance.py +++ b/openhcs/core/source_image_provenance.py @@ -134,6 +134,18 @@ def from_mapping(cls, values: Mapping[str, object]) -> "SourceImageIdentity": def addressable(self) -> bool: return self.path is not None or self.component_metadata is not None + @property + def filter_paths(self) -> tuple[str, ...]: + """Store-owned paths that source filters match. + + A virtual workspace path names the plane; the stored source paths it + was admitted from are what a FILE filter selects. + """ + stored = SourceMetadataFields.source_filter_paths(self.component_metadata or {}) + if stored: + return stored + return () if self.path is None else (self.path,) + @property def filename_extension(self) -> str | None: """Project the declared source filename fact, never a format default.""" @@ -629,6 +641,25 @@ def contributors(self) -> tuple[SourceImageProvenanceContributor, ...]: ) ) + @property + def contributor_groups( + self, + ) -> tuple[tuple[SourceImageProvenanceContributor, ...], ...]: + """Contributors grouped by the pixels they reduce into: top-level ones + into the whole payload, nested ones into their own runtime plane.""" + return ( + tuple( + plane + for plane in self.planes + if isinstance(plane, SourceImageProvenanceContributor) + ), + *( + plane.contributors + for plane in self.planes + if not isinstance(plane, SourceImageProvenanceContributor) + ), + ) + @property def represented_source_image_names(self) -> tuple[str, ...]: """Return every alias owned by a runtime plane or pixel contributor.""" @@ -1058,32 +1089,30 @@ def required_scalar_components( """Exclude only coordinates proven reduced into pixel contributors. Contributors do not own a projectable runtime axis. A component that - varies across their complete source addresses therefore has no scalar - address on the derived image. Absent or incomplete contributor evidence - cannot exempt an acquired plane from its required coordinates. + varies across the contributors of one pixel group therefore has no + scalar address on the derived image; one that varies only between + runtime planes is that plane axis. Absent or incomplete contributor + evidence cannot exempt an acquired plane from its required coordinates. """ - contributors = self.source_image_provenance_planes.contributors scalar_components = SourceMetadataFields.component_domains( self.scalar_source_identity.component_metadata or {} ) - contributor_components = tuple( - SourceMetadataFields.component_domains(contributor.component_metadata or {}) - for contributor in contributors - ) reduced = set() - for component in AxisFamily.active().axes: - if component in scalar_components: - continue - if len(contributor_components) < 2 or not all( - len(values.get(component, ())) == 1 for values in contributor_components - ): - continue - values = frozenset( - value for values in contributor_components for value in values[component] + for contributors in self.source_image_provenance_planes.contributor_groups: + domains = tuple( + SourceMetadataFields.component_domains(item.component_metadata or {}) + for item in contributors ) - if len(values) > 1: - reduced.add(component.name) - return tuple(component for component in component_order if component not in reduced) + for component in AxisFamily.active().axes: + if component in scalar_components or len(domains) < 2: + continue + if not all(len(values.get(component, ())) == 1 for values in domains): + continue + if len({values[component][0] for values in domains}) > 1: + reduced.add(component.name) + return tuple( + component for component in component_order if component not in reduced + ) def image_set_identities( self, diff --git a/openhcs/core/steps/function_outputs.py b/openhcs/core/steps/function_outputs.py index 5a0a891d2b..2fb451b25f 100644 --- a/openhcs/core/steps/function_outputs.py +++ b/openhcs/core/steps/function_outputs.py @@ -17,6 +17,8 @@ RegistryFamily, extract_key_from_class_name, ) +from polystore.backend_registry import STORAGE_BACKENDS +from polystore.base import VirtualBackend from polystore.streaming.identity import StreamProducerIdentity from polystore.streaming.viewer_transport import ViewerStreamProducer, ViewerStreamSourceMetadata from polystore.virtual_workspace import SourcePixelRef @@ -664,6 +666,15 @@ class OpenHCSMetadataTarget(ABC, metaclass=AutoRegisterMeta): def from_plan(cls, plan: CompiledStepPlan) -> OpenHCSMetadataTarget | None: """Resolve this declaration's independently compiled storage target.""" + @staticmethod + def hosts_metadata_document(backend: str) -> bool: + """The metadata document lives in the output root's local directory. + + A virtual backend (for example OMERO) declares that its paths name + remote objects, so its output root has no directory to hold it. + """ + return not issubclass(STORAGE_BACKENDS[backend], VirtualBackend) + @classmethod def for_plan(cls, plan: CompiledStepPlan) -> tuple[OpenHCSMetadataTarget, ...]: """Derive targets from their declarations, without a consumer roster.""" @@ -1119,12 +1130,14 @@ def from_execution( @classmethod def from_plan(cls, plan: CompiledStepPlan) -> PrimaryImageMetadataTarget | None: - if plan.write_backend in (Backend.OMERO_LOCAL.value, Backend.MEMORY.value): + if plan.write_backend == Backend.MEMORY.value: return None if plan.write_backend is None: raise ValueError( f"Step {plan.step_index} ({plan.step_name}) has no write backend." ) + if not cls.hosts_metadata_document(plan.write_backend): + return None if plan.output_dir is None: raise ValueError( f"Step {plan.step_index} ({plan.step_name}) has no output directory." @@ -1153,10 +1166,7 @@ def from_plan( cls, plan: CompiledStepPlan ) -> MaterializedImageMetadataTarget | None: output = plan.materialized_output - if output is None or output.backend in ( - Backend.OMERO_LOCAL.value, - Backend.MEMORY.value, - ): + if output is None or not cls.hosts_metadata_document(output.backend): return None return cls( output_dir=output.output_dir, @@ -1227,7 +1237,12 @@ def for_directory(self, directory: Path) -> RuntimeArtifactMetadataTarget: @classmethod def from_plan(cls, plan: CompiledStepPlan) -> RuntimeArtifactMetadataTarget | None: materialization = plan.runtime_artifact_materialization - if not materialization.has_persistent_target: + if not ( + materialization.has_persistent_target + and cls.hosts_metadata_document( + materialization.require_persistent_backend() + ) + ): return None output_dir = plan.artifact_analysis_output_dir dataset_root = plan.artifact_output_dataset_root diff --git a/openhcs/core/steps/function_runtime.py b/openhcs/core/steps/function_runtime.py index 499f108d68..f7a9f68e1b 100644 --- a/openhcs/core/steps/function_runtime.py +++ b/openhcs/core/steps/function_runtime.py @@ -548,6 +548,21 @@ def _producer_output_contexts( self.context.microscope_handler.parser, ) + def _producer_filename_qualifiers( + self, + matching_files: Sequence[str], + ) -> tuple[str | None, ...]: + """Resolve the filename qualifier each producer gave the loaded paths.""" + + records = step_output_manifest(self.context).producer_output_records_for_paths( + self.execution_plan, + matching_files, + self.context.microscope_handler.parser, + ) + if records is None: + return (None,) * len(matching_files) + return tuple(record.filename_qualifier for record in records) + def load_input_stack( self, ) -> tuple[list[str], RuntimeArrayData]: @@ -1001,6 +1016,24 @@ def _apply_source_binding_payload( ) return binding.apply_loaded_payload(payload, source_context) + def _passes_producer_outputs_through( + self, + processed_stack: ImagePayload, + output_count: int, + input_count: int, + ) -> bool: + """Whether each published slice is the producer's aligned output, passed on. + + A declared passthrough chain names no output of its own, so its slices + keep the exact producer declaration (context and filename qualifier) + of the input file they are aligned to. + """ + if processed_stack.owns_output_surfaces: + return False + if self._unwrapped_main_flow_output_context() is not None: + return False + return output_count == input_count + def _project_output_slices( self, processed_stack: ImagePayload, @@ -1009,21 +1042,13 @@ def _project_output_slices( """Split the function's main output into its published slices.""" if processed_stack.declares_whole_image_output: output_context = self._unwrapped_main_flow_output_context() - contexts = ( - (output_context,) - if output_context is not None - else ( - self._producer_output_contexts(matching_files) - if len(matching_files) == 1 - else () - ) - ) - context = ( - contexts[0] - if contexts - else AlignedImageSliceContext.anonymous_main_flow() - ) - return ((processed_stack, context),) + if output_context is None and self._passes_producer_outputs_through( + processed_stack, 1, len(matching_files) + ): + output_context = self._producer_output_contexts(matching_files)[0] + if output_context is None: + output_context = AlignedImageSliceContext.anonymous_main_flow() + return ((processed_stack, output_context),) unstack_started_at = time.perf_counter() projected = processed_stack.main_output_slices( lambda data: self._unstack_main_output(data, len(matching_files)) @@ -1039,19 +1064,15 @@ def _project_output_slices( if processed_stack.owns_output_surfaces: return projected output_payloads = tuple(payload for payload, _context in projected) - output_context = self._unwrapped_main_flow_output_context() - slice_contexts = ( - (output_context,) * len(output_payloads) - if output_context is not None - else () - ) - if not slice_contexts and len(output_payloads) == len(matching_files): + if self._passes_producer_outputs_through( + processed_stack, len(output_payloads), len(matching_files) + ): slice_contexts = self._producer_output_contexts(matching_files) - if not slice_contexts: - slice_contexts = tuple( - AlignedImageSliceContext.anonymous_main_flow() - for _payload in output_payloads - ) + else: + output_context = self._unwrapped_main_flow_output_context() + if output_context is None: + output_context = AlignedImageSliceContext.anonymous_main_flow() + slice_contexts = (output_context,) * len(output_payloads) return tuple(zip(output_payloads, slice_contexts, strict=True)) def _unstack_main_output( @@ -1125,6 +1146,13 @@ def _save_outputs( output_slices = tuple(payload for payload, _context in projected_outputs) num_outputs = len(output_slices) num_inputs = len(matching_files) + passed_through_qualifiers = ( + self._producer_filename_qualifiers(matching_files) + if self._passes_producer_outputs_through( + processed_stack, num_outputs, num_inputs + ) + else (None,) * num_outputs + ) if num_outputs < num_inputs: logger.debug( @@ -1185,6 +1213,12 @@ def _save_outputs( output_identity = output_identity.with_filename_qualifier( output_context.output_key ) + elif passed_through_qualifiers[i] is not None: + # A passed-through surface keeps the qualifier its producer + # published it under; dropping it would merge sibling surfaces. + output_identity = output_identity.with_filename_qualifier( + passed_through_qualifiers[i] + ) output_path = output_identity.path_for_request(output_path_request) output_path_text = str(output_path) img_slice = output_context.contextualize_image_payload(img_slice) diff --git a/openhcs/mcp/installed_demo.py b/openhcs/mcp/installed_demo.py index c3726b313f..3d713dc9ec 100644 --- a/openhcs/mcp/installed_demo.py +++ b/openhcs/mcp/installed_demo.py @@ -398,7 +398,8 @@ def _execute_pipeline( (row,) = (row for row in datasets.rows if row.root == str(plate_path)) if row.terminal_status != ExecutionCompleted.name: raise InstalledDemoFailure( - f"Portable neurite execution ended with {row.terminal_status}: {row}" + f"Portable neurite execution ended with {row.terminal_status}: " + f"{row.finished_execution_error}; row={row}" ) return row @@ -609,7 +610,7 @@ def run_installed_demo( viewer_type = ( None if viewer_payload is None or viewer_payload.viewer is None - else viewer_payload.viewer.viewer_type.wire_value + else viewer_payload.viewer.viewer_type.value ) result = InstalledDemoResult( openhcs_version=distribution("openhcs").version, diff --git a/openhcs/processing/backends/cellprofiler/_granularity_native.cpp b/openhcs/processing/backends/cellprofiler/_granularity_native.cpp index a49ac9118d..b875d7d539 100644 --- a/openhcs/processing/backends/cellprofiler/_granularity_native.cpp +++ b/openhcs/processing/backends/cellprofiler/_granularity_native.cpp @@ -110,9 +110,27 @@ class OwnedArrayBuffer { return acquired_; } const Py_buffer& view() const { return view_; } + // One unsigned C width has several buffer codes: NumPy exports uint32 as + // 'I' on LP64 platforms and as 'L' on LLP64 Windows. Compare by width. + static Py_ssize_t unsigned_width(const char* format) { + if (format[0] == '\0' || format[1] != '\0') return 0; + switch (format[0]) { + case 'B': return sizeof(unsigned char); + case 'H': return sizeof(unsigned short); + case 'I': return sizeof(unsigned int); + case 'L': return sizeof(unsigned long); + case 'Q': return sizeof(unsigned long long); + default: return 0; + } + } + bool has_format(const char* format) const { + if (std::strcmp(view_.format, format) == 0) return true; + const Py_ssize_t width = unsigned_width(format); + return width != 0 && unsigned_width(view_.format) == width && view_.itemsize == width; + } bool matches(const char* name, const char* format, int ndim, bool writable) const { if (view_.ndim != ndim || view_.format == nullptr || format == nullptr || - std::strcmp(view_.format, format) != 0 || + !has_format(format) || !PyBuffer_IsContiguous(&view_, 'C') || (writable && view_.readonly)) { PyErr_Format(PyExc_ValueError, "%s must be a C-contiguous %d-D array with format %s%s", diff --git a/openhcs/processing/backends/enhance/basic_processor_jax.py b/openhcs/processing/backends/enhance/basic_processor_jax.py index 42ff7f7ebf..edc3df0d2c 100644 --- a/openhcs/processing/backends/enhance/basic_processor_jax.py +++ b/openhcs/processing/backends/enhance/basic_processor_jax.py @@ -32,6 +32,10 @@ NameByArtifactName, ) +# The OpenHCS requirement markers decide where this distribution is installed; +# the function catalog leaves this module out wherever they exclude it. +REQUIRED_DISTRIBUTIONS = ("openhcs-basicpy",) + def _fitted_field_output(name: str) -> ArtifactSpec: """Project group lineage; runtime field owns aggregate contributor context.""" diff --git a/openhcs/processing/backends/enhance/flatfield.py b/openhcs/processing/backends/enhance/flatfield.py index 4f049bb880..1b95dce858 100644 --- a/openhcs/processing/backends/enhance/flatfield.py +++ b/openhcs/processing/backends/enhance/flatfield.py @@ -6,6 +6,7 @@ from openhcs.core.projected_image_output import SourceProjectedImageOutput from openhcs.core.runtime_array_values import RuntimeArrayData +from openhcs.core.runtime_image_values import ImagePayload from openhcs.core.runtime_plane_projection import RuntimePlaneAxisValueProjection from openhcs.core.axes import AxisFamily, AxisRole, TileAxis @@ -34,7 +35,7 @@ def __post_init__(self) -> None: @classmethod def validate_observation_domain(cls, source: RuntimeArrayData) -> None: """Reject mislabeled metadata-backed ensembles before fitting.""" - metadata = source.metadata + metadata = ImagePayload.of(source).metadata family = AxisFamily.active() retained_axes = tuple( family.named(name) for name in metadata.retained_plane_component_values() @@ -67,7 +68,7 @@ def resolve_source_context( if source.shape != (self.observation_count, *self.data.shape): raise ValueError("A fitted field must retain the source spatial grid.") self.validate_observation_domain(source) - metadata = source.metadata.collapse_leading_plane_axis() + metadata = ImagePayload.of(source).metadata.collapse_leading_plane_axis() # Model parameters are not unit-interval input pixels. Contributor source # dtypes/identities remain in provenance, not on the fitted field's range. metadata = metadata.replace_fields( diff --git a/openhcs/processing/backends/lib_registry/openhcs_registry.py b/openhcs/processing/backends/lib_registry/openhcs_registry.py index e89c959654..86803ca427 100644 --- a/openhcs/processing/backends/lib_registry/openhcs_registry.py +++ b/openhcs/processing/backends/lib_registry/openhcs_registry.py @@ -9,6 +9,7 @@ import ast import importlib import importlib.abc +import importlib.metadata import inspect import logging from abc import ABC, abstractmethod @@ -22,6 +23,8 @@ import numpy as np from metaclass_registry import LazyDiscoveryDict, import_module_preserving_root_logging +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name from openhcs.constants import VALID_MEMORY_TYPES, MemoryType from openhcs.core.callable_contract import ( @@ -293,6 +296,18 @@ def _catalog_memory_types(func: Callable) -> frozenset[MemoryType] | None: return declared +def _module_source(module_name: str) -> tuple[str, str] | None: + """Return loader-decoded module source and origin, or None without source.""" + + spec = importlib.util.find_spec(module_name) + if spec is None or not isinstance(spec.loader, importlib.abc.InspectLoader): + return None + source = spec.loader.get_source(module_name) + if source is None: + return None + return source, spec.origin or module_name + + def _module_declares_allowed_memory_type( module_name: str, allowed_memory_types: frozenset[str], @@ -300,15 +315,95 @@ def _module_declares_allowed_memory_type( spec = importlib.util.find_spec(module_name) if spec is not None and spec.submodule_search_locations is not None: return True - if spec is None or not isinstance(spec.loader, importlib.abc.InspectLoader): + module_source = _module_source(module_name) + if module_source is None: return False - source = spec.loader.get_source(module_name) - if source is None: + return _source_declares_allowed_memory_type(*module_source, allowed_memory_types) + + +_REQUIRED_DISTRIBUTIONS_NAME = "REQUIRED_DISTRIBUTIONS" + + +def _openhcs_requirements() -> tuple[Requirement, ...]: + """Return the installed OpenHCS distribution's declared requirements.""" + + return tuple( + Requirement(value) for value in importlib.metadata.requires("openhcs") or () + ) + + +def _distribution_excluded_by_package_metadata(name: str) -> bool: + """Return whether OpenHCS's own requirement markers leave ``name`` out here. + + A distribution is legitimately absent only when no OpenHCS requirement for + it applies to this environment (markers, extras) and it is not installed. + A distribution OpenHCS requires here is never excluded, so its absence + surfaces as the module's import failure. + """ + + canonical = canonicalize_name(name) + requirements = tuple( + requirement + for requirement in _openhcs_requirements() + if canonicalize_name(requirement.name) == canonical + ) + if not requirements: + raise ValueError( + f"Distribution {name!r} is not declared by the OpenHCS package " + "requirements; a module cannot depend on an undeclared distribution." + ) + if any( + requirement.marker is None or requirement.marker.evaluate({"extra": ""}) + for requirement in requirements + ): return False - return _source_declares_allowed_memory_type( - source, - spec.origin or module_name, - allowed_memory_types, + try: + importlib.metadata.distribution(name) + except importlib.metadata.PackageNotFoundError: + return True + return False + + +@lru_cache(maxsize=1024) +def _source_required_distributions(source: str, origin: str) -> tuple[str, ...]: + """Read a module's literal ``REQUIRED_DISTRIBUTIONS`` declaration.""" + + for statement in ast.parse(source, filename=origin).body: + if isinstance(statement, ast.Assign): + targets = statement.targets + elif isinstance(statement, ast.AnnAssign): + targets = (statement.target,) + else: + continue + if not any( + isinstance(target, ast.Name) and target.id == _REQUIRED_DISTRIBUTIONS_NAME + for target in targets + ): + continue + value = ast.literal_eval(statement.value) + if not isinstance(value, tuple) or not all( + isinstance(item, str) and item for item in value + ): + raise ValueError( + f"{origin}: {_REQUIRED_DISTRIBUTIONS_NAME} must be a literal tuple " + "of distribution names." + ) + return value + return () + + +def _module_distributions_excluded_by_package_metadata( + module_name: str, +) -> tuple[str, ...]: + """Return the module's declared distributions this installation excludes.""" + + module_source = _module_source(module_name) + if module_source is None: + return () + return tuple( + name + for name in _source_required_distributions(*module_source) + if _distribution_excluded_by_package_metadata(name) ) @@ -489,8 +584,11 @@ def _get_openhcs_modules(self) -> List[str]: def get_modules_to_scan(self) -> List[Tuple[str, Any]]: """Import every admitted native declaration before catalog publication. - Memory-type eligibility owns exclusions. An eligible OpenHCS module - that cannot import is a broken installation, not an optional function + Memory-type eligibility and the OpenHCS package requirement markers + own exclusions: a module whose ``REQUIRED_DISTRIBUTIONS`` names a + distribution those markers leave out of this environment is not part + of this installation's catalog. Any other eligible OpenHCS module that + cannot import is a broken installation, not an optional function inventory; its failure must prevent apparent catalog readiness. """ self._ensure_module_inventory() @@ -507,6 +605,17 @@ def get_modules_to_scan(self) -> List[Tuple[str, Any]]: module_name, ) continue + excluded = _module_distributions_excluded_by_package_metadata( + module_name + ) + if excluded: + logger.info( + "Skipping OpenHCS module %s - package requirements exclude %s " + "from this environment", + module_name, + ", ".join(excluded), + ) + continue module = import_module_preserving_root_logging(module_name) modules.append((module_name, module)) return modules @@ -540,7 +649,16 @@ def cache_source_mtimes(self) -> Dict[str, float]: def cache_discovery_context(self) -> dict[str, Any]: """Project the declaration-import policy into catalogue cache identity.""" - return {"allowed_memory_types": sorted(_allowed_openhcs_memory_types())} + return { + "allowed_memory_types": sorted(_allowed_openhcs_memory_types()), + "distributions_excluded_by_package_metadata": sorted( + { + canonicalize_name(requirement.name) + for requirement in _openhcs_requirements() + if _distribution_excluded_by_package_metadata(requirement.name) + } + ), + } def is_library_available(self) -> bool: """OpenHCS is always available.""" diff --git a/openhcs/processing/custom_functions/manager.py b/openhcs/processing/custom_functions/manager.py index eea25e3ec7..e6959f5010 100644 --- a/openhcs/processing/custom_functions/manager.py +++ b/openhcs/processing/custom_functions/manager.py @@ -367,7 +367,7 @@ def list_custom_functions(self) -> list[CustomFunctionInfo]: for py_file in sorted(self.storage_dir.glob("*.py")): try: - metadata = self._prepare_source(py_file.read_text(encoding="utf-8")) + metadata = self._prepare_source(py_file.read_bytes().decode("utf-8")) contract = CallableContract.from_callable(metadata.func) functions.append( CustomFunctionInfo( @@ -401,7 +401,7 @@ def get_function_code(self, func_name: str) -> str: if not file_path.exists(): raise ValueError(f"Custom function '{func_name}' not found") - return file_path.read_text(encoding="utf-8") + return file_path.read_bytes().decode("utf-8") def update_custom_function(self, old_name: str, new_code: str) -> str: """ @@ -662,8 +662,10 @@ def _write_temporary_source(self, code: str) -> Path: dir=self.storage_dir, ) try: - with os.fdopen(file_descriptor, "w", encoding="utf-8") as stream: - stream.write(code) + # Persist the exact bytes the revision digest was computed from; + # text-mode newline translation would change them on Windows. + with os.fdopen(file_descriptor, "wb") as stream: + stream.write(code.encode("utf-8")) stream.flush() os.fsync(stream.fileno()) except BaseException: diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index fc933bcc43..7bee719787 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -5,6 +5,7 @@ """ import logging +from concurrent.futures import Future from collections.abc import Callable from functools import singledispatchmethod from pathlib import Path @@ -212,6 +213,8 @@ def __init__( # Core configuration self.runtime_context = runtime_context self.function_catalog_service = function_catalog_service + # Completes once the shared endpoint is attached and its catalog prewarmed. + self.execution_services_prepared: Future[None] | None = None # Create shared components self.storage_registry = storage_registry @@ -362,7 +365,9 @@ def deferred_initialization(self): def start_background_services(self) -> None: """Start non-visual services after the initialized window has painted.""" - self.service_adapter.execute_async_operation(self._prepare_execution_services) + self.execution_services_prepared = self.service_adapter.execute_async_operation( + self._prepare_execution_services + ) self._start_ui_bridge_if_enabled() self._check_for_updates_on_startup() diff --git a/pyproject.toml b/pyproject.toml index d6f3007b95..0a13b4fc05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ dependencies = [ "numba>=0.61.0,<0.63; platform_system == 'Darwin' and platform_machine == 'x86_64'", "numba>=0.61.0; platform_system != 'Darwin' or platform_machine != 'x86_64'", "skan>=0.13.1", + # Every execution server prepares the CellProfiler watershed catalog entries, + # whose CellProfiler 4 paths run mahotas; it is a runtime requirement. + "mahotas>=1.4.18", "threadpoolctl>=3.6.0", # Image I/O and formats @@ -99,13 +102,13 @@ dependencies = [ "psutil>=5.9.0", # External dependencies (PyPI versions for production builds) - "zmqruntime>=0.6.1,<0.7", + "zmqruntime>=0.6.2,<0.7", "pycodify>=0.2.0,<0.3", - "objectstate>=2.1,<3", + "objectstate>=2.1.1,<3", "python-introspect>=0.2.8,<0.3", "metaclass-registry>=0.3.0,<0.4", "arraybridge>=0.4.1,<0.5", - "polystore>=0.6,<0.7", + "polystore>=0.6.1,<0.7", "pyqt-reactive>=0.5,<0.6", ] @@ -132,7 +135,6 @@ dev-gui = [ # Production CellProfiler compatibility dependencies cellprofiler-compat = [ - "mahotas>=1.4.18", "matplotlib>=3.10.0", ] diff --git a/scripts/build_mcp_knowledge_assets.py b/scripts/build_mcp_knowledge_assets.py index 8ed94107b1..59f8660cb7 100644 --- a/scripts/build_mcp_knowledge_assets.py +++ b/scripts/build_mcp_knowledge_assets.py @@ -32,6 +32,7 @@ def _build_module(relative_path: str) -> SimpleNamespace: ManifestRootAcquisitionKind = _UPSTREAM_SOURCES.ManifestRootAcquisitionKind ManifestRootAcquisitionSpec = _UPSTREAM_SOURCES.ManifestRootAcquisitionSpec KNOWLEDGE_MANIFEST_RELATIVE_PATH = _MANIFEST_SCHEMA.DEFAULT_KNOWLEDGE_BASE_MANIFEST_PATH +PACKAGED_KNOWLEDGE_PATH_BUDGET = _MANIFEST_SCHEMA.PACKAGED_KNOWLEDGE_PATH_BUDGET PACKAGED_KNOWLEDGE_ROOT_RELATIVE_PATH = ( Path("openhcs/agent") / _MANIFEST_SCHEMA.PACKAGED_KNOWLEDGE_BASE_ROOT ) @@ -191,6 +192,35 @@ def project_knowledge_assets( ) +def knowledge_asset_projections( + project_root: Path, + recipe_type: type[ComparisonManifestSnapshot] = ComparisonManifestSnapshot, +) -> dict[Path, Path]: + """Packaged resource path -> canonical source, within the path budget.""" + projections = knowledge_source_projections( + project_root / KNOWLEDGE_MANIFEST_RELATIVE_PATH, + source_root=project_root, + recipe_type=recipe_type, + ) + plugin_manifest = project_root / AGENT_PLUGIN_MANIFEST_PATH + if plugin_manifest.is_file(): + skill_sources = AgentSkillBundle.from_manifest(plugin_manifest).source_paths() + projections.update( + {source.relative_to(project_root): source for source in skill_sources} + ) + over_budget = tuple( + relative.as_posix() + for relative in projections + if len(relative.as_posix()) > PACKAGED_KNOWLEDGE_PATH_BUDGET + ) + if over_budget: + raise ValueError( + f"MCP knowledge resource paths exceed {PACKAGED_KNOWLEDGE_PATH_BUDGET} " + f"characters: {over_budget}" + ) + return projections + + def _project_knowledge_assets( project_root: Path, destination_root: Path, @@ -207,17 +237,7 @@ def _project_knowledge_assets( raise ValueError( f"MCP knowledge destination must not own the project root: {destination}" ) - projections = knowledge_source_projections( - root / KNOWLEDGE_MANIFEST_RELATIVE_PATH, - source_root=root, - recipe_type=native_sources.recipe_type(), - ) - plugin_manifest = root / AGENT_PLUGIN_MANIFEST_PATH - if plugin_manifest.is_file(): - skill_sources = AgentSkillBundle.from_manifest(plugin_manifest).source_paths() - projections.update( - {source.relative_to(root): source for source in skill_sources} - ) + projections = knowledge_asset_projections(root, native_sources.recipe_type()) native_sources.materialize() missing = tuple(source for source in projections.values() if not source.is_file()) if missing: diff --git a/scripts/smoke_installed_gui.py b/scripts/smoke_installed_gui.py index aa543cd014..d688ff6e1f 100644 --- a/scripts/smoke_installed_gui.py +++ b/scripts/smoke_installed_gui.py @@ -1,7 +1,6 @@ """Operate an installed OpenHCS GUI through MCP outside the source checkout.""" from __future__ import annotations -from openhcs.authoring.session.operations.datasets import ShowDatasetCode import argparse import faulthandler @@ -39,6 +38,7 @@ JsonValue = str | int | bool | None | list["JsonValue"] | dict[str, "JsonValue"] AgentDtoT = TypeVar("AgentDtoT") +ResultT = TypeVar("ResultT") @dataclass(frozen=True, slots=True) @@ -510,20 +510,31 @@ def _capture_installed_gui_snapshot( ) -def verify_installed_function_catalog( - service: ZMQFunctionCatalogService, - *, - deadline: OperationDeadline, -) -> InstalledFunctionCatalogSmokeResult: - """Require the GUI's background endpoint catalogue and one usable reference.""" +def _result_within(future: Future[ResultT], deadline: OperationDeadline) -> ResultT: + """Wait inside the smoke deadline; an owner's own timeout stays its own.""" - future = service.prepare() try: - page = future.result(timeout=deadline.remaining_seconds()) + return future.result(timeout=deadline.remaining_seconds()) except TimeoutError as error: if not future.cancelled() and future.done() and future.exception() is error: raise raise deadline.timeout_error() from error + + +def verify_installed_function_catalog( + service: ZMQFunctionCatalogService, + *, + execution_services_prepared: Future[None], + deadline: OperationDeadline, +) -> InstalledFunctionCatalogSmokeResult: + """Require the GUI's background endpoint catalogue and one usable reference. + + The catalog attaches only to an existing endpoint, so first wait for the + GUI's own endpoint start and catalog prewarm to finish. + """ + + _result_within(execution_services_prepared, deadline) + page = _result_within(service.prepare(), deadline) if not page.revision.strip(): raise AssertionError("Installed function catalog omitted its revision.") if page.total <= 0 or not page.items: @@ -555,6 +566,7 @@ def run_installed_live_mcp_smoke( evidence_directory: Path, forbidden_root: Path, function_catalog_service: ZMQFunctionCatalogService, + execution_services_prepared: Future[None], execution_config: OpenHCSZMQConfig, deadline: OperationDeadline, journal: InstalledGuiSmokeEvidenceJournal, @@ -577,6 +589,7 @@ def run_installed_live_mcp_smoke( MainWindowWidgetIdentity, PlateManagerWidgetIdentity, ) + from openhcs.authoring.session.operations.datasets import ShowDatasetCode from openhcs.mcp.dev_client import McpDevClient from openhcs.mcp.dev_client_commanding import CapabilityBackedCommandSpec from openhcs.mcp.dev_client_commands.ui import ( @@ -587,6 +600,7 @@ def run_installed_live_mcp_smoke( journal.record(InstalledGuiSmokePhase.WAITING_FOR_FUNCTION_CATALOG) function_catalog = verify_installed_function_catalog( function_catalog_service, + execution_services_prepared=execution_services_prepared, deadline=deadline, ) journal.record( @@ -877,6 +891,9 @@ def run_live_mcp() -> None: evidence_directory=evidence_directory, forbidden_root=forbidden_root, function_catalog_service=application.function_catalog_service, + execution_services_prepared=( + main_window.execution_services_prepared + ), execution_config=ui_config.zmq, deadline=deadline, journal=journal, diff --git a/scripts/smoke_installed_mcp.py b/scripts/smoke_installed_mcp.py index 5f3bd995f0..06c48c5286 100644 --- a/scripts/smoke_installed_mcp.py +++ b/scripts/smoke_installed_mcp.py @@ -280,7 +280,7 @@ async def _run_measured_execution_protocol_smoke( "wavelengths": 1, "z_stack_levels": 1, "num_cells": 2, - "wells": ["A01"], + "partition_values": ["A01"], "format": "ImageXpress", "random_seed": 7, }, @@ -302,6 +302,7 @@ async def _run_measured_execution_protocol_smoke( pipeline_source = PipelineDocumentCodec.render(document) source_file = output_dir / "pipeline.py" source_file.write_text(pipeline_source, encoding="utf-8") + async def call(name: str, arguments: dict, timeout: float = 90) -> dict: return _tool_payload( await asyncio.wait_for(session.call_tool(name, arguments), timeout=timeout) @@ -330,7 +331,9 @@ async def call(name: str, arguments: dict, timeout: float = 90) -> dict: "openhcs_run_datasets", { "runtime_observation_export_path": str( - MeasuredPipelineRunArtifact.RUNTIME_OBSERVATION.path_in(evidence_dir) + MeasuredPipelineRunArtifact.RUNTIME_OBSERVATION.path_in( + evidence_dir + ) ), "runtime_observation_export_scope": "outcomes", }, @@ -340,7 +343,9 @@ async def call(name: str, arguments: dict, timeout: float = 90) -> dict: ): started = await call(tool, {"scope_ids": [scope_id], **arguments}) if started.get("status") != "accepted": - raise AssertionError(f"Installed MCP rejected {tool}: {started} {connected}") + raise AssertionError( + f"Installed MCP rejected {tool}: {started} {connected}" + ) sequence = started["event_sequence"] deadline = time.monotonic() + 180 while True: diff --git a/tests/conftest.py b/tests/conftest.py index 068fef6fc7..4171a9b1cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from pathlib import Path from openhcs._source_dependencies import ensure_source_checkout_external_paths +from openhcs.utils.environment import OpenHCSProcessEnvironment ensure_source_checkout_external_paths() @@ -63,7 +64,7 @@ def pytest_sessionstart(session): # Conditionally import pytest-qt only when not in CPU-only mode -CPU_ONLY_MODE = os.getenv("OPENHCS_CPU_ONLY", "false").lower() == "true" +CPU_ONLY_MODE = OpenHCSProcessEnvironment.cpu_only_mode() if not CPU_ONLY_MODE: pytest_plugins = ["pytestqt"] else: @@ -151,25 +152,30 @@ def reap_spawned_processes(monkeypatch) -> Iterator[None]: marker = uuid.uuid4().hex monkeypatch.setenv(SPAWN_MARKER_VARIABLE, marker) yield - own_group = os.getpgid(0) survivors = _marked_processes(marker) for process in survivors: - try: - group = os.getpgid(process.pid) - if group != own_group: - os.killpg(group, signal.SIGTERM) - else: - process.terminate() - except (ProcessLookupError, psutil.NoSuchProcess): - continue + _stop_marked_process(process, force=False) _gone, alive = psutil.wait_procs(survivors, timeout=10) for process in alive: - try: - group = os.getpgid(process.pid) - if group != own_group: - os.killpg(group, signal.SIGKILL) - else: - process.kill() - except (ProcessLookupError, psutil.NoSuchProcess): - continue + _stop_marked_process(process, force=True) psutil.wait_procs(alive, timeout=10) + + +def _stop_marked_process(process: psutil.Process, *, force: bool) -> None: + """Signal a detached POSIX session's whole group, else the process itself. + + Windows has no process groups; every descendant carries the marker, so + stopping each marked process covers the tree there. + """ + try: + if os.name == "posix": + group = os.getpgid(process.pid) + if group != os.getpgid(0): + os.killpg(group, signal.SIGKILL if force else signal.SIGTERM) + return + if force: + process.kill() + else: + process.terminate() + except (ProcessLookupError, psutil.NoSuchProcess): + return diff --git a/tests/integration/test_agent_observation_export_zmq.py b/tests/integration/test_agent_observation_export_zmq.py index 57d1bd6252..9b11333092 100644 --- a/tests/integration/test_agent_observation_export_zmq.py +++ b/tests/integration/test_agent_observation_export_zmq.py @@ -11,7 +11,7 @@ import pytest from objectstate.object_state import ObjectStateRegistry from zmqruntime import DataControlPortPairAuthority -from zmqruntime.messages import ExecutionStatus +from zmqruntime.messages import ExecutionStatus, MessageFields from benchmark.cellprofiler_benchmark_cli import create_benchmark_argument_parser from benchmark.contracts.measured_run_receipt import MeasuredPipelineRunReceipt @@ -335,17 +335,18 @@ def test_ordinary_execution_can_export_outcomes_without_value_observation( exported = ZMQRuntimeExecutionOutcomeExport.read(export_path) exported.require_successful_axes() - assert completed.results_summary["well_count"] == exported.axis_count == 1 + assert ( + completed.results_summary[MessageFields.PARTITION_COUNT] + == exported.axis_count + == 1 + ) assert completed.results_summary["runtime_observation_export_scope"] == "outcomes" assert exported.successful_axis_count == 1 assert exported.output_roots assert exported.server_environment is not None assert exported.server_environment.python_executable assert completed.observation is None - assert ( - completed.receipt.observation_export_scope - is ExportRuntimeOutcomes - ) + assert completed.receipt.observation_export_scope is ExportRuntimeOutcomes assert { BenchmarkPhase.COMPILE_OPENHCS, BenchmarkPhase.EXECUTE_OPENHCS, diff --git a/tests/integration/test_basicpy_real_fit.py b/tests/integration/test_basicpy_real_fit.py index 8c271094f9..4025252c49 100644 --- a/tests/integration/test_basicpy_real_fit.py +++ b/tests/integration/test_basicpy_real_fit.py @@ -17,6 +17,7 @@ def test_real_algorithm_uses_numpy_transport_and_is_cpu_catalog_eligible(monkeyp from openhcs.processing.backends.lib_registry.openhcs_registry import ( _catalog_memory_types, _module_declares_allowed_memory_type, + _module_distributions_excluded_by_package_metadata, ) monkeypatch.setenv("OPENHCS_CPU_ONLY", "true") @@ -28,6 +29,12 @@ def test_real_algorithm_uses_numpy_transport_and_is_cpu_catalog_eligible(monkeyp assert _module_declares_allowed_memory_type( basic_flatfield_correction_jax.__module__, frozenset({MemoryType.NUMPY.value}) ) + assert ( + _module_distributions_excluded_by_package_metadata( + basic_flatfield_correction_jax.__module__ + ) + == () + ) def run_fit(observations): diff --git a/tests/integration/test_benchmark_openhcs_adapter_cppipe.py b/tests/integration/test_benchmark_openhcs_adapter_cppipe.py index 42621085c3..d2fb082d19 100644 --- a/tests/integration/test_benchmark_openhcs_adapter_cppipe.py +++ b/tests/integration/test_benchmark_openhcs_adapter_cppipe.py @@ -105,8 +105,9 @@ def test_openhcs_adapter_retains_declared_path_policy_and_explicit_exports( for path in result.output_path.rglob("*") if path.is_file() and path.suffix.lower() in {".tif", ".tiff"} ) - assert tiff_outputs - assert all(path.name.endswith(".labels.tif") for path in tiff_outputs) + # With automatic materialization disabled only declared exports persist: + # the spreadsheet. Retained labels and QA checkpoints stay in memory. + assert tiff_outputs == () def test_openhcs_adapter_resolves_dataset_reference_cppipe( diff --git a/tests/integration/test_cellprofiler_official30_zmq.py b/tests/integration/test_cellprofiler_official30_zmq.py index a298d1d30d..ced0397664 100644 --- a/tests/integration/test_cellprofiler_official30_zmq.py +++ b/tests/integration/test_cellprofiler_official30_zmq.py @@ -1,7 +1,5 @@ from __future__ import annotations -from itertools import product - import json import os import shutil @@ -51,7 +49,12 @@ runtime_artifact_viewer_component_identity, ) from openhcs.core.source_spatial_domain import SourceSpatialDomain -from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.streaming_config_declarations import ( + FijiViewer, + NapariViewer, + ViewerFamily, + ViewerType, +) from openhcs.runtime.fiji_macro_runtime import FijiMacroExecutionRequest from openhcs.runtime.viewer_protocol import ( ViewerControlMessageRequest, @@ -75,20 +78,20 @@ for case_index, case in enumerate(_OFFICIAL30_CASES) ) _OFFICIAL30_FIJI_VIEWER_VARIANTS = ( - (ViewerType.FIJI,), - (ViewerType.FIJI, ViewerType.NAPARI), + (FijiViewer,), + (FijiViewer, NapariViewer), ) _OFFICIAL30_FIJI_VIEWER_PARAMS = tuple( pytest.param( - viewer_types, + viewers, case_index, case, id=( - "+".join(viewer_type.value for viewer_type in viewer_types) + "+".join(viewer.wire_value for viewer in viewers) + f"-{case_index:02d}-{case.name}" ), ) - for viewer_types in _OFFICIAL30_FIJI_VIEWER_VARIANTS + for viewers in _OFFICIAL30_FIJI_VIEWER_VARIANTS for case_index, case in enumerate(_OFFICIAL30_CASES) ) _FIJI_FRESH_PROCESS_IMAGE_PROBE = r"""#@ String Directory @@ -236,13 +239,13 @@ def _free_zmq_port_pair( def _registered_streaming_config_kwargs( - viewer_types: Sequence[ViewerType], - ports_by_viewer: Mapping[ViewerType, int], + viewers: Sequence[type[ViewerFamily]], + ports_by_viewer: Mapping[type[ViewerFamily], int], ) -> dict[str, StreamingConfig]: """Project one viewer selection through registered config owners.""" - selected_viewers = set(viewer_types) - if len(selected_viewers) != len(viewer_types): + selected_viewers = set(viewers) + if len(selected_viewers) != len(viewers): raise ValueError("Official30 viewer variants require unique declarations.") if set(ports_by_viewer) != selected_viewers: raise ValueError( @@ -250,8 +253,8 @@ def _registered_streaming_config_kwargs( ) config_kwargs: dict[str, StreamingConfig] = {} - for viewer_type, config_type in StreamingConfig.__registry__.items(): - enabled = viewer_type in selected_viewers + for viewer, config_type in StreamingConfig.__registry__.items(): + enabled = viewer in selected_viewers init_kwargs: dict[str, object] = { "enabled": enabled, "persistent": enabled, @@ -259,10 +262,10 @@ def _registered_streaming_config_kwargs( if enabled: init_kwargs.update( host="127.0.0.1", - port=ports_by_viewer[viewer_type], + port=ports_by_viewer[viewer], transport_mode=TransportMode.TCP, ) - config_kwargs[viewer_type.family.config_key] = config_type(**init_kwargs) + config_kwargs[viewer.config_key] = config_type(**init_kwargs) if selected_viewers - set(StreamingConfig.__registry__): raise ValueError("Official30 viewer variant selected an unregistered config.") @@ -399,7 +402,7 @@ def _assert_napari_state_matches_runtime( runtime_artifact_viewer_component_identity(values) for values in layer.component_values ) == Counter( - runtime_artifact_viewer_component_identity(payload["components"]) + runtime_artifact_viewer_component_identity(payload.components) for payload in layer.payload_summaries ) assert all(component in layer.axis_labels for component in layer.stack_axes) @@ -411,59 +414,44 @@ def _assert_napari_state_matches_runtime( assert layer.data_shape[axis_index] == len(component_values) for expected in runtime_observation.expectation.artifact_viewer: - # One producer identity can legitimately back both its native image - # layer and its derived shapes overlay; the payload expectation below - # compares ndarray summaries, so match the image layer. - matching_layers = tuple( + # One producer identity can back both its native image layer and its + # derived shapes overlay. The producer's expected payloads are every + # payload it streamed, so compare against all of its layers' payloads. + producer_layers = tuple( layer for layer in state.layers if expected.producer_identity in layer.producer_identities - and StreamingDataType.IMAGE.value in layer.data_types ) - assert len(matching_layers) == 1 - layer = matching_layers[0] + image_layers = tuple( + layer + for layer in producer_layers + if StreamingDataType.IMAGE.value in layer.data_types + ) + assert len(image_layers) == 1 + layer = image_layers[0] assert layer.mounted - # The presentation may aggregate a varying component axis (e.g. site) - # into one layer item. That item represents both the aggregate payload - # itself and each per-coordinate payload it covers, so the actual - # identity set is the union of the summary's own identity and every - # expanded per-coordinate identity. - actual_payloads: list[RuntimeArtifactViewerPayloadExpectation] = [] - for payload in layer.payload_summaries: - aggregates = payload.get("aggregate_component_values") or {} - varying_axes = tuple(aggregates.items()) - - def summary_identity(components) -> RuntimeArtifactViewerPayloadExpectation: - return RuntimeArtifactViewerPayloadExpectation( - components=runtime_artifact_viewer_component_identity(components), - source_spatial_domain=SourceSpatialDomain.from_viewer_wire_mapping( - payload, - source_label="official30 Napari payload summary", - ), - ) - - actual_payloads.append(summary_identity(payload["components"])) - axes_values = [values for _, values in varying_axes] - for combination in product(*axes_values): - expanded = dict(payload["components"]) - expanded.update( - { - component: value - for (component, _), value in zip( - varying_axes, combination, strict=True - ) - } - ) - actual_payloads.append(summary_identity(expanded)) + actual_payloads = tuple( + RuntimeArtifactViewerPayloadExpectation( + components=runtime_artifact_viewer_component_identity( + payload.components + ), + source_spatial_domain=SourceSpatialDomain.from_viewer_wire_mapping( + payload.to_wire_mapping(), + source_label="official30 Napari payload summary", + ), + ) + for producer_layer in producer_layers + for payload in producer_layer.payload_summaries + ) assert Counter(payload.identity_key for payload in actual_payloads) == Counter( payload.identity_key for payload in expected.payloads ) - payload_paths = tuple(payload["path"] for payload in layer.payload_summaries) + payload_paths = tuple(payload.path for payload in layer.payload_summaries) assert all(isinstance(path, str) and path for path in payload_paths) payload_data_types = tuple( - dict.fromkeys(payload["data_type"] for payload in layer.payload_summaries) + dict.fromkeys(payload.data_type for payload in layer.payload_summaries) ) assert len(payload_data_types) == 1 assert layer.data_types == payload_data_types @@ -563,13 +551,12 @@ def test_official30_fiji_variants_project_registered_viewer_configs() -> None: partition_filter_config=PartitionFilterConfig(partition_filter=1) ) - for viewer_types in _OFFICIAL30_FIJI_VIEWER_VARIANTS: + for viewers in _OFFICIAL30_FIJI_VIEWER_VARIANTS: ports_by_viewer = { - viewer_type: 20_000 + index * 100 - for index, viewer_type in enumerate(viewer_types) + viewer: 20_000 + index * 100 for index, viewer in enumerate(viewers) } configs = _registered_streaming_config_kwargs( - viewer_types, + viewers, ports_by_viewer, ) variant_config = GlobalPipelineConfig( @@ -585,11 +572,11 @@ def test_official30_fiji_variants_project_registered_viewer_configs() -> None: if field.name not in registered_keys ) assert { - config.viewer_family.viewer_type() for config in configs.values() if config.enabled - } == set(viewer_types) + config.viewer_family for config in configs.values() if config.enabled + } == set(viewers) assert all(config.persistent is config.enabled for config in configs.values()) assert { - config.viewer_family.viewer_type(): config.port + config.viewer_family: config.port for config in configs.values() if config.enabled } == ports_by_viewer @@ -742,12 +729,12 @@ def test_official30_nonpersistent_napari_isolated_per_case( @pytest.mark.integration @pytest.mark.parametrize( - ("viewer_types", "case_index", "case"), + ("viewers", "case_index", "case"), _OFFICIAL30_FIJI_VIEWER_PARAMS, ) def test_official30_persistent_fiji_variants_isolated_per_case( tmp_path: Path, - viewer_types: tuple[ViewerType, ...], + viewers: tuple[type[ViewerFamily], ...], case_index: int, case: CellProfilerComparisonCase, ) -> None: @@ -760,14 +747,14 @@ def test_official30_persistent_fiji_variants_isolated_per_case( transport_mode=OPENHCS_ZMQ_CONFIG.transport_mode, ) ports_by_viewer = { - viewer_type: _free_zmq_port_pair( + viewer: _free_zmq_port_pair( excluded_ports, transport_mode=TransportMode.TCP, ) - for viewer_type in viewer_types + for viewer in viewers } streaming_configs = _registered_streaming_config_kwargs( - viewer_types, + viewers, ports_by_viewer, ) global_config = GlobalPipelineConfig( @@ -775,15 +762,15 @@ def test_official30_persistent_fiji_variants_isolated_per_case( **streaming_configs, ) endpoints = { - viewer_type.value: ViewerRuntimeEndpoint( - streaming_configs[viewer_type.family.config_key] + viewer.wire_value: ViewerRuntimeEndpoint( + streaming_configs[viewer.config_key] .viewer_runtime_config() .transport_endpoint, OPENHCS_ZMQ_CONFIG, ) - for viewer_type in viewer_types + for viewer in viewers } - variant_id = "+".join(viewer_type.value for viewer_type in viewer_types) + variant_id = "+".join(viewer.wire_value for viewer in viewers) case_output_root = tmp_path / variant_id / f"case_{case_index:02d}" macro_path = tmp_path / f"{variant_id}_fresh_process_probe.ijm" macro_path.write_text(_FIJI_FRESH_PROCESS_IMAGE_PROBE, encoding="utf-8") @@ -821,18 +808,18 @@ def test_official30_persistent_fiji_variants_isolated_per_case( assert (case_output_root / "summary.csv").is_file() runtime_observation = _runtime_observation(observations[0]) - for viewer_type in viewer_types: - config = streaming_configs[viewer_type.family.config_key] - if viewer_type is ViewerType.NAPARI: + for viewer in viewers: + config = streaming_configs[viewer.config_key] + if viewer is NapariViewer: _assert_live_napari_state(config, runtime_observation) - elif viewer_type is ViewerType.FIJI: + elif viewer is FijiViewer: _assert_fiji_fresh_process_image( config=config, macro_path=macro_path, ) else: raise AssertionError( - f"Unhandled registered Official30 viewer {viewer_type.name!r}." + f"Unhandled registered Official30 viewer {viewer.display_name!r}." ) except Exception as error: failures.append( diff --git a/tests/integration/test_count_cells_simple_dual_channel.py b/tests/integration/test_count_cells_simple_dual_channel.py index 8804075b1c..a94fea77e4 100644 --- a/tests/integration/test_count_cells_simple_dual_channel.py +++ b/tests/integration/test_count_cells_simple_dual_channel.py @@ -55,6 +55,7 @@ SyntheticMicroscopyGenerator, ) from openhcs.domains.microscopy.axes import Microscopy +from openhcs.microscopes.imagexpress import ImageXpressHandler def _write_known_dual_channel_images(plate_dir): @@ -74,19 +75,19 @@ def _write_known_dual_channel_images(plate_dir): image_dir = plate_dir / "TimePoint_1" for well in ("A01", "B01"): tifffile.imwrite( - image_dir / f"{well}_s001_w1_z001_t001.tif", + image_dir / f"{well}_s001_w1.tif", channel_1, ) tifffile.imwrite( - image_dir / f"{well}_s001_w2_z001_t001.tif", + image_dir / f"{well}_s001_w2.tif", channel_2, ) tifffile.imwrite( - image_dir / f"{well}_s002_w1_z001_t001.tif", + image_dir / f"{well}_s002_w1.tif", channel_1, ) tifffile.imwrite( - image_dir / f"{well}_s002_w2_z001_t001.tif", + image_dir / f"{well}_s002_w2.tif", channel_2, ) @@ -186,7 +187,6 @@ def test_dual_channel_count_runs_on_synthetic_plate_with_channel_stack( from openhcs.core.materialization import CsvOptions, MaterializationSpec, ROIOptions import numpy as np -from openhcs.microscopes.imagexpress import ImageXpressHandler @numpy diff --git a/tests/integration/test_custom_source_transport.py b/tests/integration/test_custom_source_transport.py index 9863a0e07c..1b4fe5f505 100644 --- a/tests/integration/test_custom_source_transport.py +++ b/tests/integration/test_custom_source_transport.py @@ -503,12 +503,13 @@ def _compiled_custom_contexts(payload, runtime): def _spawn(path): import multiprocessing from openhcs.core.compiled_execution import ( - CompiledRuntimeEnvironmentPlan, CompiledWorkerStartPlan, + CompiledRuntimeEnvironmentPlan, + CompiledWorkerStartPlan, ) from openhcs.core.config import MultiprocessingStartMethod from openhcs.core.orchestrator.cancellation import ExecutionCancellationSignal from openhcs.core.orchestrator.worker_execution import WorkerExecutorFactory - from openhcs.core.progress import ProgressExecutionContext + from openhcs.core.progress import ProgressExecutionContext, ProgressStatus # A future derived context can carry nontransportable local runtime state; # no generic worker-bootstrap consumer should need to learn its fields. @@ -521,19 +522,25 @@ class RichProgressContext(ProgressExecutionContext): payload = _load(path) method = MultiprocessingStartMethod.SPAWN runtime = CompiledRuntimeEnvironmentPlan( - worker_start=CompiledWorkerStartPlan(method, method, "source fixture", False, False), - use_threading=False, configured_num_workers=2, + worker_start=CompiledWorkerStartPlan( + method, method, "source fixture", False, False + ), + use_threading=False, + configured_num_workers=2, ) queue = multiprocessing.get_context(method.value).Queue() resources = WorkerExecutorFactory( - log_file_base=None, progress_queue=queue, + log_file_base=None, + progress_queue=queue, cancellation=ExecutionCancellationSignal(), ).create(runtime_environment=runtime, actual_max_workers=2) contexts = _compiled_custom_contexts(payload, runtime) try: with resources.execution_context(): tasks = tuple( - resources.executor.submit(_spawned_compiled_custom_task, lane_context, helpers) + resources.executor.submit( + _spawned_compiled_custom_task, lane_context, helpers + ) for lane_context, (_reference, helpers) in zip( contexts.values(), payload, strict=True ) @@ -547,7 +554,10 @@ class RichProgressContext(ProgressExecutionContext): assert values == [[0, 1, 2], [3, 4, 5]] assert type(row) is helpers[4] and row == helpers[5] pid, rows = resources.executor.submit( - _spawned_custom_task, payload, context.execution_id, context.subject_id, + _spawned_custom_task, + payload, + context.execution_id, + context.subject_id, ).result(timeout=15) assert pid != os.getpid() for row, (_reference, helpers) in zip(rows, payload, strict=True): diff --git a/tests/integration/test_measured_cli_session.py b/tests/integration/test_measured_cli_session.py index 7eb363e046..8754e3cb36 100644 --- a/tests/integration/test_measured_cli_session.py +++ b/tests/integration/test_measured_cli_session.py @@ -78,7 +78,37 @@ def _source_state(root: Path) -> dict[str, tuple]: return state +# Everyone may not create, change or delete entries; inherited by the tree. +# Write rights are named one by one: the generic W also denies SYNCHRONIZE, +# which every open (reads included) requests. +# Deny adding files and folders to the root itself. An inherited deny over the +# tree (OI)(CI) also refused reading the source files on the CI runner. +WINDOWS_READ_ONLY_ACE = "*S-1-1-0:(WD,AD)" + +if sys.platform == "win32": + # A console's Ctrl+C cannot be sent to one process; Ctrl+Break can, to a + # child started in its own process group. + INTERRUPT_LAUNCH = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + INTERRUPT_SIGNAL = signal.CTRL_BREAK_EVENT +else: + INTERRUPT_LAUNCH = {} + INTERRUPT_SIGNAL = signal.SIGINT + + def _set_writable(root: Path, writable: bool) -> None: + """Make ``root`` and its tree writable again, or genuinely not writable. + + Windows ignores the read-only attribute on directories (and os.access + ignores ACLs), so there a deny ACE on the root refuses creating files in it; + the before/after source state still catches any write below it. + """ + + if sys.platform == "win32": + change = ( + ("/remove:d", "*S-1-1-0") if writable else ("/deny", WINDOWS_READ_ONLY_ACE) + ) + subprocess.run(("icacls", str(root), *change), check=True, capture_output=True) + return for path in (root, *root.rglob("*")): mode = path.stat().st_mode path.chmod(mode | 0o200 if writable else mode & ~0o222) @@ -114,7 +144,9 @@ def _command( def _environment(tmp_path: Path) -> dict[str, str]: - environment = {**os.environ, "PYTHONPATH": str(REPOSITORY_ROOT)} + # The CLI child imports benchmark and openhcs from the same installation as + # this test process; a checkout path would shadow an installed candidate. + environment = dict(os.environ) environment.setdefault("XDG_DATA_HOME", str(tmp_path / "xdg")) environment.pop("DISPLAY", None) return environment @@ -176,6 +208,7 @@ def test_ctrl_c_stops_the_run_and_its_server(tmp_path: Path) -> None: stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + **INTERRUPT_LAUNCH, ) stderr_lines: list[str] = [] try: @@ -186,7 +219,7 @@ def test_ctrl_c_stops_the_run_and_its_server(tmp_path: Path) -> None: else: pytest.fail("".join(stderr_lines)[-4000:]) time.sleep(1.0) - process.send_signal(signal.SIGINT) + process.send_signal(INTERRUPT_SIGNAL) _stdout, rest = process.communicate(timeout=RUN_DEADLINE_SECONDS) finally: if process.poll() is None: diff --git a/tests/integration/test_neurite_outgrowth_synthetic_plate.py b/tests/integration/test_neurite_outgrowth_synthetic_plate.py index 8cea2dba2f..b34de4ca62 100644 --- a/tests/integration/test_neurite_outgrowth_synthetic_plate.py +++ b/tests/integration/test_neurite_outgrowth_synthetic_plate.py @@ -67,6 +67,7 @@ ViewerStreamBackendCallKwargs, ) from openhcs.processing.backends.analysis.neurite_outgrowth import ( + CellProfilerNeuriteEngineProfile, MetaXpressCellBodySettings, MetaXpressNuclearSettings, MetaXpressOutgrowthSettings, @@ -94,13 +95,13 @@ def _write_known_neurite_images(plate_dir): image_dir = plate_dir / "TimePoint_1" for site in (1, 2): - tifffile.imwrite(image_dir / f"A01_s{site:03d}_w1_z001_t001.tif", neurites) - tifffile.imwrite(image_dir / f"A01_s{site:03d}_w2_z001_t001.tif", nuclei) + tifffile.imwrite(image_dir / f"A01_s{site:03d}_w1.tif", neurites) + tifffile.imwrite(image_dir / f"A01_s{site:03d}_w2.tif", nuclei) return np.stack((neurites, nuclei)) def test_neurite_outgrowth_runs_on_synthetic_plate_as_2d_channel_stack( - tmp_path, caplog, monkeypatch + tmp_path, caplog, monkeypatch, viewer_ack_return_route ): caplog.set_level(logging.CRITICAL) plate_dir = tmp_path / "synthetic_neurite_plate" @@ -220,9 +221,7 @@ def observe_dense_output(output, components): progress_events = [] while True: try: - progress_events.append( - progress_queue.get_nowait() - ) + progress_events.append(progress_queue.get_nowait()) except queue.Empty: break live_payloads = tuple( @@ -311,19 +310,14 @@ def observe_dense_output(output, components): np.testing.assert_array_equal(retained, expected_labels) checkpoint_paths = [] + # Checkpoints are the declared outputs between the label artifacts and + # the trailing morphology table; the callable's output family owns them. + checkpoint_names = tuple( + spec.name + for spec in CellProfilerNeuriteEngineProfile.artifact_outputs()[6:-1] + ) for artifact_name, expected_checkpoint in zip( - ( - "neurite_candidate_mask", - "neurite_unrooted_residual", - "neurite_secondary_ownership", - "neurite_topology_dropped_trace", - "neurite_topology_added_trace", - "neurite_enhanced_response", - "neurite_threshold_support", - "neurite_retained_support", - "neurite_local_response", - "neurite_local_support", - ), + checkpoint_names, expected[7:-1], strict=True, ): @@ -336,7 +330,9 @@ def observe_dense_output(output, components): retained, np.asarray(expected_checkpoint)[0], ) - assert len({path.name for path in checkpoint_paths}) == 20 + assert len({path.name for path in checkpoint_paths}) == 2 * len( + checkpoint_names + ) assert compiled_plan.output_dataset_root is not None output_dataset_root = Path(compiled_plan.output_dataset_root) @@ -368,7 +364,8 @@ def observe_dense_output(output, components): # Exercise the real stream projection of the compiled writer outputs. # Artifact storage axes are legitimately empty; source image planes are not. - assert len(dense_outputs) == 18 + # Both sites publish four label images and every declared checkpoint. + assert len(dense_outputs) == 2 * (4 + len(checkpoint_names)) stream_kwargs = ViewerStreamBackendCallKwargs( ViewerStreamBackendKwargs( ViewerStreamRequest( @@ -424,13 +421,16 @@ def observe_dense_output(output, components): request ), display_payload_extra=backend.display_payload_extra(request), + return_route=viewer_ack_return_route, ), ) for output, item in zip(outputs, batch.batch_images, strict=True): assert output.variable_components == () - # This fixture injects physical pixel_size into the callable, - # but does not declare voxel spacing on its runtime images. - assert output.metadata.source_voxel_spacing == SourceVoxelSpacing() + # The acquisition's calibration is the same physical pixel + # size the compiler injected into the callable. + assert output.metadata.source_voxel_spacing == SourceVoxelSpacing( + (compiled_invocation.kwargs_dict["pixel_size"],) * 2 + ) if checkpoint_batch: assert "_w1_" in Path(output.path).name else: diff --git a/tests/integration/test_session_journey.py b/tests/integration/test_session_journey.py index fe7966d343..5330775114 100644 --- a/tests/integration/test_session_journey.py +++ b/tests/integration/test_session_journey.py @@ -1,8 +1,9 @@ -"""One dataset through the session, headless over MCP and in the Qt GUI. +"""One dataset through the session, headless over MCP. -Both journeys add a synthetic dataset, give it a pipeline, initialize, compile -and run it on a real execution server, following the session's events. They -use the same SessionOperation classes; only the client differs. +The journey adds a synthetic dataset, gives it a pipeline, initializes, +compiles and runs it on a real execution server, following the session's +events. tests/pyqt_gui/test_session_gui_journey.py runs the same +SessionOperation classes through the Qt widgets. """ from __future__ import annotations @@ -18,12 +19,9 @@ from openhcs.agent.path_policy import AgentPathPolicy from openhcs.authoring.session.operations.datasets import ( AddDatasets, - CompileDatasets, InitializeDatasets, - RunDatasets, ) from openhcs.authoring.session.session import CallerThread -from openhcs.authoring.session.views import DatasetListView from openhcs.core.config import PipelineConfig from openhcs.core.pipeline_document import PipelineDocumentCodec from openhcs.core.steps import FunctionStep @@ -152,91 +150,9 @@ def test_headless_mcp_journey_adds_compiles_and_runs_a_dataset(tmp_path: Path) - context.session.close() -def test_gui_journey_runs_the_same_operations_through_the_widgets( - tmp_path: Path, -) -> None: - pytest.importorskip("PyQt6") - from PyQt6.QtWidgets import QApplication - from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher - - from openhcs.authoring.session.session import DispatcherThread, Session - from openhcs.core.config import GlobalPipelineConfig - from openhcs.pyqt_gui.config import get_default_ui_config - from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget - from tests.unit.pyqt_gui.session_harness import GuiServiceStub, release_widgets - - application = QApplication.instance() or QApplication([]) - root, source = _synthetic_dataset(tmp_path) - dispatcher = UiThreadDispatcher() - session = Session( - transport_config=OpenHCSZMQConfig(default_port=_port(1), persistent=False), - global_config=GlobalPipelineConfig(), - main_thread=DispatcherThread(dispatcher), - ) - services = GuiServiceStub() - manager = PlateManagerWidget( - services, session, gui_config=get_default_ui_config() - ) - try: - assert session.invoke(AddDatasets, AddDatasets.request(roots=(str(root),))).accepted - (scope_id,) = session.dataset_scope_ids() - session.set_pipeline_source(scope_id, source) - application.processEvents() - assert [row.scope_id for row in manager.plates] == [scope_id] - manager.item_list.setCurrentRow(0) - application.processEvents() - assert manager.selection_scope_ids() == (scope_id,) - - def wait_until(condition, label) -> None: - deadline = time.monotonic() + STAGE_TIMEOUT_SECONDS - while not condition(): - application.processEvents() - assert time.monotonic() < deadline, label - time.sleep(0.05) - - for operation, finished in ( - (InitializeDatasets, lambda row: row.initialized and not row.init_pending), - (CompileDatasets, lambda row: row.compiled and not row.compile_pending), - ( - RunDatasets, - lambda row: row.terminal_status is not None and not row.execution_active, - ), - ): - if operation is CompileDatasets: - # Disconnected, the compile button is Connect; connect first. - assert manager.buttons[operation.operation_id].text() == "Connect" - manager.handle_button_action(operation.operation_id) - wait_until( - lambda: CompileDatasets.resolved(session) is CompileDatasets, - "connect", - ) - manager.update_button_states() - assert manager.buttons[operation.operation_id].isEnabled(), operation - manager.handle_button_action(operation.operation_id) - deadline = time.monotonic() + STAGE_TIMEOUT_SECONDS - while True: - application.processEvents() - (row,) = DatasetListView.state_of(session).rows - if finished(row): - break - assert time.monotonic() < deadline, (operation, row) - time.sleep(0.05) - assert row.terminal_status == "complete", row - wait_until(lambda: not session.execution_state.busy, "batch finished") - application.processEvents() - assert manager.buttons[RunDatasets.operation_id].text() == "Run" - finally: - manager.cleanup() - manager.close() - release_widgets(application, manager) - session.close() - dispatcher.close() - - def test_read_only_dataset_initializes_in_a_mirrored_workspace(tmp_path: Path) -> None: """Initialization never writes into a source it may not write.""" - from openhcs.authoring.session.operations.datasets import AddDatasets from openhcs.authoring.session.session import Session from openhcs.core.config import GlobalPipelineConfig @@ -256,7 +172,9 @@ def test_read_only_dataset_initializes_in_a_mirrored_workspace(tmp_path: Path) - workspace_root=workspaces, ) try: - assert session.invoke(AddDatasets, AddDatasets.request(roots=(str(root),))).accepted + assert session.invoke( + AddDatasets, AddDatasets.request(roots=(str(root),)) + ).accepted (scope_id,) = session.dataset_scope_ids() started = session.invoke( InitializeDatasets, InitializeDatasets.request(scope_ids=(scope_id,)) @@ -272,7 +190,7 @@ def test_read_only_dataset_initializes_in_a_mirrored_workspace(tmp_path: Path) - assert not failures, failures assert time.monotonic() < deadline time.sleep(0.05) - assert session.orchestrator(scope_id).plate_path.is_relative_to(workspaces) + assert session.orchestrator(scope_id).dataset_root.is_relative_to(workspaces) assert _source_state(root) == before finally: session.close() diff --git a/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py b/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py index 454a2e3aef..61f640c85b 100644 --- a/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py +++ b/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py @@ -37,6 +37,12 @@ ) from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager +from openhcs.authoring.session.operations.datasets import ( + AddDatasets, + EditDatasetConfig, + InitializeDatasets, +) + from openhcs.core.config import ( GlobalPipelineConfig, LazyPathPlanningConfig, @@ -630,8 +636,12 @@ def _access_plate_manager(context: WorkflowContext) -> WorkflowContext: def _add_and_select_plate(context: WorkflowContext) -> WorkflowContext: - """Add synthetic plate and select it in plate manager.""" - context.plate_manager_widget.add_plate_callback([context.synthetic_plate_dir]) + """Add synthetic plate through the session and select it in plate manager.""" + result = context.plate_manager_widget.session.invoke( + AddDatasets, + AddDatasets.request(roots=(str(context.synthetic_plate_dir),)), + ) + assert result.accepted, result.errors _wait_for_gui(TIMING.ACTION_DELAY) item_list = context.plate_manager_widget.item_list @@ -647,14 +657,21 @@ def _add_and_select_plate(context: WorkflowContext) -> WorkflowContext: def _initialize_plate(context: WorkflowContext) -> WorkflowContext: """Initialize plate using Init button.""" - init_button = context.plate_manager_widget.buttons["init_plate"] + plate_manager = context.plate_manager_widget + init_button = plate_manager.buttons[InitializeDatasets.operation_id] if not init_button.isEnabled(): raise AssertionError( "Init button is disabled - plate may not be properly added" ) init_button.click() - _wait_for_gui(TIMING.SAVE_DELAY) + scope_id = plate_manager.selected_plate_path + session = plate_manager.session + deadline = time.monotonic() + 60.0 + while not session.is_initialized(scope_id) or scope_id in session.init_pending: + if time.monotonic() >= deadline: + raise AssertionError(f"Dataset {scope_id!r} did not finish initializing") + _wait_for_gui(0.05) return context @@ -665,9 +682,8 @@ def _apply_orchestrator_config(context: WorkflowContext) -> WorkflowContext: "Test scenario must be provided for parameterized orchestrator configuration" ) - orchestrator = context.plate_manager_widget.get_selected_orchestrator() - if orchestrator is None: - raise AssertionError("The selected plate has no initialized orchestrator") + plate_manager = context.plate_manager_widget + orchestrator = plate_manager.session.orchestrator(plate_manager.selected_plate_path) orchestrator.apply_pipeline_config(context.test_scenario.pipeline_config) _wait_for_gui(TIMING.ACTION_DELAY) @@ -709,7 +725,7 @@ def _wait_for_form_build( ) def _open_config_window(context: WorkflowContext) -> WorkflowContext: """Open plate-specific configuration window.""" - edit_button = context.plate_manager_widget.buttons["edit_config"] + edit_button = context.plate_manager_widget.buttons[EditDatasetConfig.operation_id] if not edit_button.isEnabled(): raise AssertionError( "Edit button is disabled - plate may not be properly initialized" @@ -891,7 +907,7 @@ def _reopen_config_window(context: WorkflowContext) -> WorkflowContext: context = _close_config_window(context) # Validate edit button state - edit_button = context.plate_manager_widget.buttons["edit_config"] + edit_button = context.plate_manager_widget.buttons[EditDatasetConfig.operation_id] if not edit_button.isEnabled(): raise AssertionError( "LAZY CONFIG BUG: Edit button disabled after closing config window. " diff --git a/tests/pyqt_gui/test_image_browser_filter_controller.py b/tests/pyqt_gui/test_image_browser_filter_controller.py index 6c2256f128..041c9a7a32 100644 --- a/tests/pyqt_gui/test_image_browser_filter_controller.py +++ b/tests/pyqt_gui/test_image_browser_filter_controller.py @@ -24,6 +24,7 @@ from openhcs.core.dataset_sources.interfaces import ( AnalysisResultDirectory, FilenameParserCapability, + MetadataHandler, ) from openhcs.pyqt_gui.widgets.image_browser import ( ImageBrowserFilterController, @@ -130,7 +131,8 @@ def parse_filename(self, filename: str): ) -class _InventoryMetadataHandler: +class _InventoryMetadataHandler(MetadataHandler): + """Inherits the handler defaults (workspace root, no virtual workspace).""" def __init__(self, handler_result_dir: Path) -> None: self._handler_result_dir = handler_result_dir @@ -138,11 +140,17 @@ def get_image_files(self, _plate_path: Path, *, all_subdirs: bool): assert all_subdirs is True return ("images/A01_w1.tif",) - def source_workspace_metadata_document(self, _plate_path: Path): - return None + def find_metadata_file(self, _plate_path: Path) -> Path: + raise AssertionError("Inventory does not read the metadata file.") - def source_dataset(self, _plate_path: Path): - return None + def get_grid_dimensions(self, _plate_path: Path): + raise AssertionError("Inventory does not read grid dimensions.") + + def get_pixel_size(self, _plate_path: Path): + raise AssertionError("Inventory does not read pixel size.") + + def component_value_set(self, _plate_path: Path): + raise AssertionError("Inventory does not read the component value set.") def source_workspace_root(self, plate_path: Path) -> Path: return plate_path diff --git a/tests/pyqt_gui/test_managed_windows.py b/tests/pyqt_gui/test_managed_windows.py index b82f64d0a9..e167e980d3 100644 --- a/tests/pyqt_gui/test_managed_windows.py +++ b/tests/pyqt_gui/test_managed_windows.py @@ -33,7 +33,7 @@ def __init__(self, orchestrator: _OrchestratorStub) -> None: self._orchestrator = orchestrator self.plate_selected = _Signal() - def get_selected_orchestrator(self) -> _OrchestratorStub: + def _get_current_orchestrator(self) -> _OrchestratorStub: return self._orchestrator def select(self, orchestrator: _OrchestratorStub) -> None: @@ -141,12 +141,14 @@ class PlateManagerStub(QWidget): def __init__( self, service_adapter, + session, color_scheme, *, gui_config, ) -> None: super().__init__() del service_adapter, color_scheme + captured["session"] = session captured["gui_config"] = gui_config def cleanup(self) -> None: @@ -159,11 +161,13 @@ def cleanup(self) -> None: "PlateManagerWidget", PlateManagerStub, ) - monkeypatch.setattr(PlateManagerWindow, "_setup_connections", lambda self: None) + session = object() main_window = QWidget() + main_window.session = session window = PlateManagerWindow(main_window, ServiceAdapterStub()) qtbot.addWidget(main_window) qtbot.addWidget(window) assert captured["gui_config"] is resolved_ui_config + assert captured["session"] is session diff --git a/tests/pyqt_gui/test_session_gui_journey.py b/tests/pyqt_gui/test_session_gui_journey.py new file mode 100644 index 0000000000..aad333283c --- /dev/null +++ b/tests/pyqt_gui/test_session_gui_journey.py @@ -0,0 +1,119 @@ +"""One dataset through the session, driven through the Qt widgets. + +The same SessionOperation classes as the headless MCP journey in +tests/integration/test_session_journey.py run here through the plate manager +on a real execution server; only the client differs. +""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest +from objectstate.object_state import ObjectStateRegistry +from PyQt6.QtWidgets import QApplication +from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher + +from openhcs.authoring.session.operations.datasets import ( + AddDatasets, + CompileDatasets, + InitializeDatasets, + RunDatasets, +) +from openhcs.authoring.session.session import DispatcherThread, Session +from openhcs.authoring.session.views import DatasetListView +from openhcs.core.config import GlobalPipelineConfig +from openhcs.pyqt_gui.config import get_default_ui_config +from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget +from openhcs.runtime.zmq_config import OpenHCSZMQConfig +from tests.integration.test_session_journey import ( + STAGE_TIMEOUT_SECONDS, + _port, + _synthetic_dataset, +) +from tests.unit.pyqt_gui.session_harness import GuiServiceStub, release_widgets + + +@pytest.fixture(autouse=True) +def fresh_object_state_registry(): + """The session starts from an empty dataset list.""" + + ObjectStateRegistry.clear() + yield + ObjectStateRegistry.clear() + + +def test_gui_journey_runs_the_same_operations_through_the_widgets( + tmp_path: Path, +) -> None: + application = QApplication.instance() or QApplication([]) + root, source = _synthetic_dataset(tmp_path) + dispatcher = UiThreadDispatcher() + session = Session( + transport_config=OpenHCSZMQConfig(default_port=_port(1), persistent=False), + global_config=GlobalPipelineConfig(), + main_thread=DispatcherThread(dispatcher), + ) + services = GuiServiceStub() + manager = PlateManagerWidget(services, session, gui_config=get_default_ui_config()) + try: + assert session.invoke( + AddDatasets, AddDatasets.request(roots=(str(root),)) + ).accepted + (scope_id,) = session.dataset_scope_ids() + session.set_pipeline_source(scope_id, source) + application.processEvents() + assert [row.scope_id for row in manager.plates] == [scope_id] + manager.item_list.setCurrentRow(0) + application.processEvents() + assert manager.selection_scope_ids() == (scope_id,) + + def wait_until(condition, label) -> None: + deadline = time.monotonic() + STAGE_TIMEOUT_SECONDS + while not condition(): + application.processEvents() + assert time.monotonic() < deadline, label + time.sleep(0.05) + + for operation, finished in ( + (InitializeDatasets, lambda row: row.initialized and not row.init_pending), + (CompileDatasets, lambda row: row.compiled and not row.compile_pending), + ( + RunDatasets, + lambda row: row.terminal_status is not None + and not row.execution_active, + ), + ): + if operation is CompileDatasets: + # Disconnected, the compile button is Connect; connect first. + assert manager.buttons[operation.operation_id].text() == "Connect" + manager.handle_button_action(operation.operation_id) + wait_until( + lambda: CompileDatasets.resolved(session) is CompileDatasets, + "connect", + ) + manager.update_button_states() + # The previous stage's batch ends after its row reports done. + wait_until(lambda: not session.execution_state.busy, "batch finished") + manager.update_button_states() + assert manager.buttons[operation.operation_id].isEnabled(), operation + manager.handle_button_action(operation.operation_id) + deadline = time.monotonic() + STAGE_TIMEOUT_SECONDS + while True: + application.processEvents() + (row,) = DatasetListView.state_of(session).rows + if finished(row): + break + assert time.monotonic() < deadline, (operation, row) + time.sleep(0.05) + assert row.terminal_status == "complete", row + wait_until(lambda: not session.execution_state.busy, "batch finished") + application.processEvents() + assert manager.buttons[RunDatasets.operation_id].text() == "Run" + finally: + manager.cleanup() + manager.close() + release_widgets(application, manager) + session.close() + dispatcher.close() diff --git a/tests/unit/agent/test_capabilities.py b/tests/unit/agent/test_capabilities.py index eacfe31e20..119a690218 100644 --- a/tests/unit/agent/test_capabilities.py +++ b/tests/unit/agent/test_capabilities.py @@ -528,3 +528,27 @@ def test_connection_shape_is_declared_by_the_invocation_family(): ): assert issubclass(capability, RuntimeServerCapability) assert isinstance(capability.invocation, AgentFromFieldsServiceInvocation) + + +def test_requestless_ui_bridge_capability_passes_connection_as_connection(): + """A requestless operation's only argument is the connection, never a request.""" + + from types import SimpleNamespace + + from openhcs.agent.services.ui_bridge_service import UiBridgeService + + seen = [] + + class RecordingUiBridgeService(UiBridgeService): + def invoke(self, operation, request=None, connection=None): + seen.append((operation, request, connection)) + return "served" + + connection = object() + invocation = agent_capabilities.ui_bridge_status.invocation + context = SimpleNamespace( + ui_bridge_service=RecordingUiBridgeService(gateway=object()) + ) + + assert invocation.execute(context, connection) == "served" + assert seen == [(invocation.operation, None, connection)] diff --git a/tests/unit/agent/test_knowledge_base_service.py b/tests/unit/agent/test_knowledge_base_service.py index f636461b22..ae307b83d9 100644 --- a/tests/unit/agent/test_knowledge_base_service.py +++ b/tests/unit/agent/test_knowledge_base_service.py @@ -314,14 +314,19 @@ def test_knowledge_base_retrieves_canonical_image_source_guide(): assert "CZI" in document.content assert "OME-TIFF" in document.content assert "OME-Zarr" in document.content - assert "WELL" in document.content - assert "SITE" in document.content - assert "CHANNEL" in document.content - assert "Z_INDEX" in document.content - assert "TIMEPOINT" in document.content + from openhcs.domains.microscopy.axes import Microscopy + + for axis in ( + Microscopy.Well, + Microscopy.Site, + Microscopy.Channel, + Microscopy.ZIndex, + Microscopy.Timepoint, + ): + assert f"Microscopy.{axis.__name__}" in document.content assert "Binding positions are not microscope channel values" in document.content assert "full named physical" in document.content - assert "inputs consumed by the nominal handler projection" in document.content + assert "inputs consumed by the dataset source projection" in document.content assert "Seeing all three in the source workspace inventory is valid" in ( document.content ) @@ -348,8 +353,9 @@ def test_knowledge_base_retrieves_canonical_image_source_guide(): assert "``neurite_channel_index=0``" in document.content assert "``nuclear_stain.channel_index=1``" in document.content assert "not equivalent to MAP2-seeded analysis" in document.content - assert "``variable_components=[CHANNEL]`` assembles the channel stack" in ( - document.content + assert ( + "``variable_components=[Microscopy.Channel]`` assembles the channel stack" + in (document.content) ) assert "compiler normalizes it to" in document.content assert "``Ungrouped``" in document.content diff --git a/tests/unit/agent/test_mcp_dev_client_sampling_results.py b/tests/unit/agent/test_mcp_dev_client_sampling_results.py index ffb7621857..fbc178b467 100644 --- a/tests/unit/agent/test_mcp_dev_client_sampling_results.py +++ b/tests/unit/agent/test_mcp_dev_client_sampling_results.py @@ -81,20 +81,30 @@ def command_render(response, argv): @pytest.mark.parametrize("structured", [False, True]) @pytest.mark.parametrize("selected_case", [False, True]) -def test_original_session_framing_to_generated_sampling_command(structured, selected_case, monkeypatch): - from test_mcp_dev_client_pipeline_results import ControlledWireSession +def test_original_session_framing_to_generated_sampling_command( + structured, selected_case, monkeypatch +): + from tests.unit.agent.test_mcp_dev_client_pipeline_results import ( + ControlledWireSession, + ) import openhcs.mcp.dev_client_core as ingress value = selected(sample()) if selected_case else sample() - argv = ("selected-plate-sample",) if selected_case else ("sample-plate-image", "source-plate", "image.tif") + argv = ( + ("selected-plate-sample",) + if selected_case + else ("sample-plate-image", "source-plate", "image.tif") + ) args = dev_client._build_parser().parse_args(argv) command = dev_client.McpDevCommandSpec.for_name(args.command) peer = ControlledWireSession((value,), structured=structured) decoded = [] original_decode = ingress.dataclass_from_mapping + def observe(contract, receipt): decoded.append(contract) return original_decode(contract, receipt) + monkeypatch.setattr(ingress, "dataclass_from_mapping", observe) response = asyncio.run(command.run_session(peer, args)) assert decoded == [type(value)] diff --git a/tests/unit/agent/test_mcp_dev_client_ui_renderers.py b/tests/unit/agent/test_mcp_dev_client_ui_renderers.py index 5f8a8a6057..f7dd2edd0e 100644 --- a/tests/unit/agent/test_mcp_dev_client_ui_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_ui_renderers.py @@ -60,7 +60,7 @@ UiActionCatalogRenderOptions, WidgetTreeRenderOptions, ) -from test_mcp_dev_client_renderers import SERVER, framed, sample_dto +from tests.unit.agent.test_mcp_dev_client_renderers import SERVER, framed, sample_dto def render(payload, options=None) -> str: diff --git a/tests/unit/agent/test_mcp_plate_stream_progress.py b/tests/unit/agent/test_mcp_plate_stream_progress.py index 87289aa9c1..7e97bb2fea 100644 --- a/tests/unit/agent/test_mcp_plate_stream_progress.py +++ b/tests/unit/agent/test_mcp_plate_stream_progress.py @@ -30,7 +30,10 @@ from openhcs.mcp.server import build_server from openhcs.mcp.socket import McpSocketTransport, wait_for_socket -from test_persisted_output_reopen import NoRuntimeBridge, declared_output +from tests.unit.agent.test_persisted_output_reopen import ( + NoRuntimeBridge, + declared_output, +) from tests.unit.test_streaming_service import FakeViewer diff --git a/tests/unit/agent/test_selected_plane_reopen_stream.py b/tests/unit/agent/test_selected_plane_reopen_stream.py index 333e000a04..9f95643b93 100644 --- a/tests/unit/agent/test_selected_plane_reopen_stream.py +++ b/tests/unit/agent/test_selected_plane_reopen_stream.py @@ -39,7 +39,10 @@ ) from openhcs.runtime.napari_viewer_server import NapariBatchPayload, NapariImagePayload -from test_persisted_output_reopen import NoRuntimeBridge, declared_output +from tests.unit.agent.test_persisted_output_reopen import ( + NoRuntimeBridge, + declared_output, +) @pytest.fixture diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000000..53cfa68a4f --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,16 @@ +"""Unit-suite process setup.""" + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def session_qapplication(request): + """Create the unit session's one QApplication before any test runs. + + A process holds one Qt application. In-process MCP transports adopt an + existing one and otherwise create a QCoreApplication, after which no later + unit test could create a widget. Sessions without pytest-qt (CPU-only, or a + narrowed ``--confcutdir``) run no widget tests. + """ + if request.config.pluginmanager.hasplugin("pytestqt"): + request.getfixturevalue("qapp") diff --git a/tests/unit/test_aggregate_source_scope.py b/tests/unit/test_aggregate_source_scope.py index 7855206b26..fb4bd6ea81 100644 --- a/tests/unit/test_aggregate_source_scope.py +++ b/tests/unit/test_aggregate_source_scope.py @@ -27,7 +27,10 @@ ) from openhcs.core.runtime_tabular_values import FieldSpec from openhcs.core.source_image_provenance import ( - RuntimeSourceImageProvenancePlane, SourceImageIdentity, SourceImageProvenancePlanes, + RuntimeSourceImageProvenancePlane, + SourceImageIdentity, + SourceImageProvenanceContributor, + SourceImageProvenancePlanes, ) from openhcs.core.source_metadata import SourceVoxelSpacing from openhcs.core.steps.abstract import StepExecutionObservation @@ -36,7 +39,7 @@ from polystore.filemanager import FileManager from polystore.memory import MemoryStorageBackend -from test_materialization_core import _viewer_stream_backend_kwargs +from tests.unit.test_materialization_core import _viewer_stream_backend_kwargs from tests.materialization_plans import output_plan as materialization_output_plan from openhcs.core.artifacts.plans import MAIN_FLOW @@ -135,6 +138,32 @@ def test_absence_one_equal_or_incomplete_contributors_cannot_exempt_acquired_pla adapter.filemanager_batches((output,)) +def test_axis_varying_between_runtime_planes_is_kept_not_reduced(): + common = {"well": "A01", "z_index": 1, "timepoint": 1} + planes = tuple( + RuntimeSourceImageProvenancePlane( + SourceImageIdentity(component_metadata={**common, "site": site}), + contributors=tuple( + SourceImageProvenanceContributor( + SourceImageIdentity( + component_metadata={**common, "site": site, "channel": channel} + ) + ) + for channel in (1, 2) + ), + ) + for site in (1, 2) + ) + metadata = ImagePayloadMetadata( + plane_axis=RuntimePlaneAxis.SOURCE_BINDING, + source_component_metadata=common, + source_image_provenance_planes=SourceImageProvenancePlanes(planes), + ) + assert metadata.source_provenance.required_scalar_components( + ("site", "channel", "well") + ) == ("site", "well") + + def test_present_scalar_override_remains_required_despite_varied_contributors(): metadata = (_source((1, 3))).metadata.collapse_leading_plane_axis() metadata = metadata.replace_fields(source_component_metadata={ diff --git a/tests/unit/test_automatic_image_role_materialization_435.py b/tests/unit/test_automatic_image_role_materialization_435.py index 97f84d7bfc..1609cb978b 100644 --- a/tests/unit/test_automatic_image_role_materialization_435.py +++ b/tests/unit/test_automatic_image_role_materialization_435.py @@ -12,7 +12,11 @@ from polystore.filemanager import FileManager from polystore.memory import MemoryStorageBackend -from test_function_outputs import context_stub, function_step_plan, record_output_path +from tests.unit.test_function_outputs import ( + context_stub, + function_step_plan, + record_output_path, +) from openhcs.constants.constants import Backend from openhcs.core.aligned_image_payload import AlignedImageSliceContext, ImagePayloadBundleContext from openhcs.core.artifacts.plans import ArtifactOutputPlan diff --git a/tests/unit/test_build_mcp_knowledge_assets.py b/tests/unit/test_build_mcp_knowledge_assets.py index aa05965926..07adb739b0 100644 --- a/tests/unit/test_build_mcp_knowledge_assets.py +++ b/tests/unit/test_build_mcp_knowledge_assets.py @@ -3,13 +3,15 @@ import glob import json import tomllib -from pathlib import Path +from pathlib import Path, PureWindowsPath import pytest from scripts.build_mcp_knowledge_assets import ( KNOWLEDGE_MANIFEST_RELATIVE_PATH, + PACKAGED_KNOWLEDGE_PATH_BUDGET, PACKAGED_KNOWLEDGE_ROOT_RELATIVE_PATH, + knowledge_asset_projections, project_knowledge_assets, ) from openhcs.agent.knowledge_manifest_schema import ( @@ -294,3 +296,47 @@ def test_wheel_globs_include_the_projected_hidden_plugin_manifest(tmp_path): assert plugin_manifest in included plugin_root = plugin_manifest.parent.parent assert {path for path in projected if path.is_relative_to(plugin_root)} <= included + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +# The installer CI proof root under the runner profile, with long paths disabled. +WINDOWS_INSTALLED_KNOWLEDGE_ROOT = PureWindowsPath( + r"C:\Users\runneradmin\AppData\Local\OpenHCS Installer Smoke", + "env-00000000", + "Lib", + "site-packages", + *PACKAGED_KNOWLEDGE_ROOT_RELATIVE_PATH.parts, +) +WINDOWS_MAX_PATH = 260 + + +def test_every_packaged_resource_fits_the_windows_installed_path_budget(): + workflow = PROJECT_ROOT / ".github" / "workflows" / "integration-tests.yml" + assert '"OpenHCS Installer Smoke"' in workflow.read_text(encoding="utf-8") + root_length = len(str(WINDOWS_INSTALLED_KNOWLEDGE_ROOT)) + 1 + assert root_length + PACKAGED_KNOWLEDGE_PATH_BUDGET < WINDOWS_MAX_PATH + projections = knowledge_asset_projections(PROJECT_ROOT) + assert any(path.suffix == ".cppipe" for path in projections) + too_long = [ + str(WINDOWS_INSTALLED_KNOWLEDGE_ROOT / relative) + for relative in projections + if len(str(WINDOWS_INSTALLED_KNOWLEDGE_ROOT / relative)) >= WINDOWS_MAX_PATH + ] + assert too_long == [] + + +@pytest.mark.parametrize( + "case_name", ["x" * PACKAGED_KNOWLEDGE_PATH_BUDGET, "nested/name", "name:stream"] +) +def test_native_placement_rejects_unbudgeted_case_names(tmp_path, case_name): + project_root, manifest_path, _ = _project_with_document(tmp_path) + recipe_path = project_root / "recipe.json" + recipe_path.write_text( + json.dumps({"cases": [{"name": case_name, "cppipe_path": "a.cppipe"}]}), + encoding="utf-8", + ) + catalog = json.loads(manifest_path.read_text(encoding="utf-8")) + catalog["documents"].append({"source_path": "recipe.json"}) + manifest_path.write_text(json.dumps(catalog), encoding="utf-8") + with pytest.raises(ValueError): + knowledge_asset_projections(project_root) diff --git a/tests/unit/test_ci_import_guards.py b/tests/unit/test_ci_import_guards.py new file mode 100644 index 0000000000..ccc9036f75 --- /dev/null +++ b/tests/unit/test_ci_import_guards.py @@ -0,0 +1,38 @@ +"""CI runs pytest with ``--import-mode=importlib`` against the installed wheel +(``scripts/run_installed_tests.py``). In that mode a test directory is not on +``sys.path``, so a test module can reach shared helpers only through the +``tests`` package, never by a sibling module's bare name.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +TESTS_ROOT = Path(__file__).resolve().parents[1] + + +def _test_tree_module_names() -> frozenset[str]: + return frozenset(path.stem for path in TESTS_ROOT.rglob("*.py")) - {"__init__"} + + +def test_test_modules_import_helpers_through_the_tests_package() -> None: + local_names = _test_tree_module_names() + offenders: list[str] = [] + for path in sorted(TESTS_ROOT.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names = (node.module,) + elif isinstance(node, ast.Import): + names = tuple(alias.name for alias in node.names) + else: + continue + for name in names: + if name.split(".")[0] in local_names: + offenders.append( + f"{path.relative_to(TESTS_ROOT.parent)}:{node.lineno}: {name}" + ) + assert not offenders, ( + "Import test helpers as tests..; bare sibling imports " + "fail under --import-mode=importlib:\n" + "\n".join(offenders) + ) diff --git a/tests/unit/test_colocalization_metric_setting_authority.py b/tests/unit/test_colocalization_metric_setting_authority.py index 59d99fede6..e5ae55a195 100644 --- a/tests/unit/test_colocalization_metric_setting_authority.py +++ b/tests/unit/test_colocalization_metric_setting_authority.py @@ -153,7 +153,7 @@ def test_recorded_source_pair_features_exclude_disabled_metrics(disabled, famili for name in enabled.columns if not any( name.startswith( - f"Correlation_{(family if family == "RWC" else family.title())}_" + f"Correlation_{(family if family == 'RWC' else family.title())}_" ) for family in families ) diff --git a/tests/unit/test_dataset_access.py b/tests/unit/test_dataset_access.py new file mode 100644 index 0000000000..1ed8f06e1b --- /dev/null +++ b/tests/unit/test_dataset_access.py @@ -0,0 +1,22 @@ +"""Whether a dataset initializes in place is the filesystem's answer. + +The non-writable case (POSIX mode bits, Windows ACLs) is exercised by the +read-only integration journeys, which make a genuinely non-writable tree. +""" + +from pathlib import Path + +from openhcs.authoring.session.session import UnrestrictedDatasetAccess + + +def test_writable_root_initializes_in_place_and_the_probe_leaves_nothing( + tmp_path: Path, +) -> None: + (tmp_path / "image.tif").write_bytes(b"pixels") + + assert UnrestrictedDatasetAccess().initializes_in_place(tmp_path) + assert [path.name for path in tmp_path.iterdir()] == ["image.tif"] + + +def test_missing_root_does_not_initialize_in_place(tmp_path: Path) -> None: + assert not UnrestrictedDatasetAccess().initializes_in_place(tmp_path / "absent") diff --git a/tests/unit/test_debug_runtime.py b/tests/unit/test_debug_runtime.py index 0c51d22d04..06fac724b6 100644 --- a/tests/unit/test_debug_runtime.py +++ b/tests/unit/test_debug_runtime.py @@ -564,10 +564,15 @@ def test_debug_artifact_boundary_has_no_name_key_reconstruction(): "DebugArtifactRefProjection", {"from_artifact_plans"}, ), + ( + repository_root / "openhcs/core/steps/function_runtime.py", + "FunctionCoreDebugMixin", + {"debug_artifacts", "debug_event"}, + ), ( repository_root / "openhcs/core/steps/function_runtime.py", "FunctionCoreExecutor", - {"debug_artifacts", "debug_event", "invoke"}, + {"invoke"}, ), ) violations: list[tuple[str, int, str]] = [] diff --git a/tests/unit/test_derived_role_publication_435.py b/tests/unit/test_derived_role_publication_435.py index 1e136e8780..47df77bc3a 100644 --- a/tests/unit/test_derived_role_publication_435.py +++ b/tests/unit/test_derived_role_publication_435.py @@ -11,8 +11,10 @@ from polystore.filemanager import FileManager from polystore.memory import MemoryStorageBackend -from test_function_outputs import ( - context_stub, function_step_plan, record_output_path, +from tests.unit.test_function_outputs import ( + context_stub, + function_step_plan, + record_output_path, ) from openhcs.core.dataset_sources.source_schema import SourceSchemaFilenameParser from openhcs.constants.constants import Backend diff --git a/tests/unit/test_function_outputs.py b/tests/unit/test_function_outputs.py index 20f35e2b54..bfffc339e1 100644 --- a/tests/unit/test_function_outputs.py +++ b/tests/unit/test_function_outputs.py @@ -79,6 +79,7 @@ MemoryOutputWriter, OpenHCSMetadataTarget, MaterializedImageMetadataTarget, + PrimaryImageMetadataTarget, RuntimeArtifactMetadataTarget, StreamOutputsAuthority, StreamOutputBatch, @@ -188,6 +189,62 @@ def test_function_step_metadata_follows_runtime_artifact_persistence( ] +@pytest.mark.parametrize( + ("backend", "expected_targets"), + [ + ( + Backend.DISK.value, + { + PrimaryImageMetadataTarget, + MaterializedImageMetadataTarget, + RuntimeArtifactMetadataTarget, + }, + ), + (Backend.OMERO_LOCAL.value, set()), + ], +) +def test_metadata_document_targets_follow_backend_declaration( + monkeypatch, backend, expected_targets +) -> None: + """A virtual backend's output root holds no local metadata document.""" + root = "/omero/plate_1_outputs" + plan = CompiledStepPlan( + step_index=0, + step_name="count", + axis_id="A01", + output_dir=Path(root) / "images", + output_dataset_root=root, + sub_dir="images", + analysis_results_dir=f"{root}/results", + write_backend=backend, + materialized_output=MaterializedOutputPlan( + output_dir=Path(root) / "checkpoint", + backend=backend, + dataset_root=root, + sub_dir="checkpoint", + analysis_results_dir=f"{root}/results", + ), + runtime_artifact_materialization=RuntimeArtifactMaterializationPlan( + persistent_enabled=True, persistent_backend=backend + ), + create_openhcs_metadata=True, + ) + assert {type(t) for t in OpenHCSMetadataTarget.for_plan(plan)} == expected_targets + + documents = [] + monkeypatch.setattr( + AtomicMetadataWriter, + "reconcile_completed_dataset", + lambda _self, metadata_path, *_args, **_kwargs: documents.append(metadata_path), + ) + OpenHCSMetadataTarget.finalize_completed_dataset( + {"A01": SimpleNamespace(step_plans={0: plan})} + ) + assert documents == ( + [METADATA_CONFIG.metadata_path(root)] if expected_targets else [] + ) + + def test_memory_output_writer_rejects_payload_path_cardinality_mismatch(): with pytest.raises(ValueError, match="1 payloads for 0 paths"): MemoryOutputWriter.payloads( diff --git a/tests/unit/test_gray_to_color_binding_axis_432.py b/tests/unit/test_gray_to_color_binding_axis_432.py index 5bb11ef1d0..139c4d75e5 100644 --- a/tests/unit/test_gray_to_color_binding_axis_432.py +++ b/tests/unit/test_gray_to_color_binding_axis_432.py @@ -31,7 +31,9 @@ CellProfilerOutputRecorder, ) from openhcs.processing.backends.cellprofiler.color import GrayToColorModule, gray_to_color -from test_cellprofiler_generic_special_input_binding import _compile_public_step +from tests.unit.test_cellprofiler_generic_special_input_binding import ( + _compile_public_step, +) from tests.unit.cellprofiler_runtime_test_support import cellprofiler_runtime_adapter_for_test from openhcs.domains.microscopy.axes import Microscopy from openhcs.core.axes import ColourAxis diff --git a/tests/unit/test_gray_to_color_payload_axis_compile_432.py b/tests/unit/test_gray_to_color_payload_axis_compile_432.py index d66fd1323b..61c79b6023 100644 --- a/tests/unit/test_gray_to_color_payload_axis_compile_432.py +++ b/tests/unit/test_gray_to_color_payload_axis_compile_432.py @@ -25,8 +25,11 @@ ColorToGrayMode, ImageChannelType, color_to_gray, gray_to_color, split_color_to_gray, ) -from test_gray_to_color_binding_axis_432 import _execute_bound_stack, _source_plane -from test_payload_axis_compile_gate import _compiled_pattern, _session +from tests.unit.test_gray_to_color_binding_axis_432 import ( + _execute_bound_stack, + _source_plane, +) +from tests.unit.test_payload_axis_compile_gate import _compiled_pattern, _session from openhcs.core.payload_axes import ColourSampleAxisSpec from openhcs.core.axes import ColourAxis diff --git a/tests/unit/test_library_registry_discovery.py b/tests/unit/test_library_registry_discovery.py index f364215d7f..73fc7c4082 100644 --- a/tests/unit/test_library_registry_discovery.py +++ b/tests/unit/test_library_registry_discovery.py @@ -50,6 +50,110 @@ def test_native_catalog_import_failures_follow_declared_memory_admission( assert registry.get_modules_to_scan() == [] +@pytest.mark.parametrize( + "requirement, outcome", + [ + # The package's own marker leaves the distribution out of this environment. + ("absent-catalog-probe>=1; platform_system == 'NoSuchSystem'", "excluded"), + # Declared only for an extra this installation did not select. + ("absent-catalog-probe>=1; extra == 'probe'", "excluded"), + # Required here, yet absent: a broken installation fails loudly. + ("absent-catalog-probe>=1", "broken"), + # A module cannot claim a distribution the package never declares. + ("unrelated-catalog-probe>=1", "undeclared"), + ], +) +def test_native_catalog_excludes_modules_whose_distribution_markers_exclude_here( + tmp_path, monkeypatch, requirement, outcome +) -> None: + from packaging.requirements import Requirement + + from openhcs.processing.backends.lib_registry import openhcs_registry + from openhcs.processing.backends.lib_registry.openhcs_registry import ( + OpenHCSRegistry, + ) + + package_name = "native_catalog_distribution_probe" + package = tmp_path / package_name + package.mkdir() + (package / "__init__.py").write_text("") + (package / "processing.py").write_text( + "from openhcs.core.memory.decorators import numpy\n" + "import absent_catalog_probe\n" + "REQUIRED_DISTRIBUTIONS = ('absent-catalog-probe',)\n" + "@numpy\n" + "def process(image):\n return image\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setenv("OPENHCS_CPU_ONLY", "1") + monkeypatch.setattr( + openhcs_registry, + "_openhcs_requirements", + lambda: (Requirement(requirement),), + ) + registry = OpenHCSRegistry() + registry.MODULES_TO_SCAN = [f"{package_name}.processing"] + + if outcome == "excluded": + assert registry.get_modules_to_scan() == [] + assert registry.cache_discovery_context()[ + "distributions_excluded_by_package_metadata" + ] == ["absent-catalog-probe"] + elif outcome == "broken": + with pytest.raises(ModuleNotFoundError, match="absent_catalog_probe"): + registry.get_modules_to_scan() + else: + with pytest.raises(ValueError, match="absent-catalog-probe"): + registry.get_modules_to_scan() + + +@pytest.mark.parametrize( + "platform_system, platform_machine, excluded", + [ + ("Darwin", "x86_64", True), + ("Darwin", "arm64", False), + ("Linux", "x86_64", False), + ], +) +def test_basicpy_catalog_module_follows_the_package_basicpy_marker( + monkeypatch, platform_system, platform_machine, excluded +) -> None: + """Intel macOS installs omit openhcs-basicpy; its catalog module follows.""" + + import importlib.metadata + + import packaging.markers + + from openhcs.processing.backends.lib_registry.openhcs_registry import ( + _module_distributions_excluded_by_package_metadata, + ) + + environment = packaging.markers.default_environment() + monkeypatch.setattr( + packaging.markers, + "default_environment", + lambda: { + **environment, + "platform_system": platform_system, + "platform_machine": platform_machine, + }, + ) + installed_distribution = importlib.metadata.distribution + + def distribution_without_basicpy(name: str): + if name == "openhcs-basicpy": + raise importlib.metadata.PackageNotFoundError(name) + return installed_distribution(name) + + monkeypatch.setattr( + importlib.metadata, "distribution", distribution_without_basicpy + ) + + assert _module_distributions_excluded_by_package_metadata( + "openhcs.processing.backends.enhance.basic_processor_jax" + ) == (("openhcs-basicpy",) if excluded else ()) + + def test_concurrent_catalog_requests_share_one_metadata_load(monkeypatch) -> None: from openhcs.processing.backends.lib_registry.registry_service import ( RegistryService, diff --git a/tests/unit/test_named_checkpoint_publication.py b/tests/unit/test_named_checkpoint_publication.py index e18897c8c4..b4fc278335 100644 --- a/tests/unit/test_named_checkpoint_publication.py +++ b/tests/unit/test_named_checkpoint_publication.py @@ -12,7 +12,7 @@ import pytest import tifffile -from test_artifact_publication_journey import _plate, progress_events +from tests.unit.test_artifact_publication_journey import _plate, progress_events from openhcs.core.artifacts.sidecars import QaCheckpoint from openhcs.core.artifacts.kinds import ImageArtifactType from openhcs.core.artifacts.specs import ( diff --git a/tests/unit/test_openhcs_adapter.py b/tests/unit/test_openhcs_adapter.py index 766cc3ac75..eb78980f87 100644 --- a/tests/unit/test_openhcs_adapter.py +++ b/tests/unit/test_openhcs_adapter.py @@ -153,7 +153,6 @@ def test_openhcs_adapter_preserves_declared_main_flow_output_policy( configured = adapter._execution_global_config( request, output_suffix="_candidate", - output_dataset_root=tmp_path / "candidate-plate", compilation_debug_config=CompilationDebugConfig(), ) @@ -168,6 +167,8 @@ def test_openhcs_adapter_preserves_declared_main_flow_output_policy( assert ( configured.analysis_consolidation_config.output_filename == "custom-summary.csv" ) + # Analysis results resolve inside the compiled output root by default. + assert configured.materialization_results_path == Path("results") assert base_config.path_planning_config.global_output_folder is None assert base_config.analysis_consolidation_config.enabled is True @@ -486,6 +487,19 @@ def test_openhcs_progress_observer_tracks_every_server_event( assert observer.progress_description() == "artifact_transfer/running" +def test_openhcs_progress_silence_is_measured_from_run_submission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Server startup belongs to the client's readiness timeout, not the watchdog.""" + observer = _ZMQProgressTimingObserver() + assert observer.inactivity_seconds(observed_at=500.0) == 0.0 + + monkeypatch.setattr("benchmark.openhcs_measured_run.time.monotonic", lambda: 200.0) + observer.begin_run() + + assert observer.inactivity_seconds(observed_at=321.0) == 121.0 + + def test_openhcs_watchdog_allows_execution_without_interval_timer( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_preparation_admission.py b/tests/unit/test_preparation_admission.py index 42c4d60a8f..6f1049f93b 100644 --- a/tests/unit/test_preparation_admission.py +++ b/tests/unit/test_preparation_admission.py @@ -242,14 +242,16 @@ def prepare_selected(): assert events == ["selected", "unselected", "dynamic"] -def test_unavailable_affinity_does_not_admit_speculative_parallelism(monkeypatch): +def test_platform_without_affinity_admits_its_online_cpus(monkeypatch): events = [] - started, _, _ = simulated_workers(monkeypatch, 8) + started, peak, _ = simulated_workers(monkeypatch, 8) monkeypatch.delattr(os, "sched_getaffinity") + monkeypatch.setattr(os, "cpu_count", lambda: 2) PreparationCacheBatch( tuple(DeclaredCache(str(i), events) for i in range(3)) ).populate_child_caches(max_workers=8) - assert not started and not events + assert started == [("0",), ("1",), ("2",)] + assert max(peak) == 2 def test_affinity_query_failure_is_not_misreported_as_success(monkeypatch): diff --git a/tests/unit/test_rescale_stream_plane_declaration.py b/tests/unit/test_rescale_stream_plane_declaration.py index 4c9a9cf4c0..cc3f6099a2 100644 --- a/tests/unit/test_rescale_stream_plane_declaration.py +++ b/tests/unit/test_rescale_stream_plane_declaration.py @@ -39,7 +39,9 @@ ) # Reuse the original provider-free compiler and runtime fixtures, not a second ABI. -from test_cellprofiler_generic_special_input_binding import _compile_public_step +from tests.unit.test_cellprofiler_generic_special_input_binding import ( + _compile_public_step, +) from tests.unit.cellprofiler_runtime_test_support import ( cellprofiler_runtime_adapter_for_test, ) diff --git a/tests/unit/test_selected_plane_stream_materialization.py b/tests/unit/test_selected_plane_stream_materialization.py index 4df0ccb6d6..9307148d35 100644 --- a/tests/unit/test_selected_plane_stream_materialization.py +++ b/tests/unit/test_selected_plane_stream_materialization.py @@ -56,12 +56,12 @@ ) from openhcs.core.materialization import materialize -from test_function_artifact_materialization import ( +from tests.unit.test_function_artifact_materialization import ( _context, _plan, streaming_config_stub, ) -from test_materialization_core import _viewer_stream_backend_kwargs +from tests.unit.test_materialization_core import _viewer_stream_backend_kwargs from openhcs.domains.microscopy.axes import Microscopy from tests.materialization_plans import output_plan as materialization_output_plan from openhcs.core.artifacts.plans import MAIN_FLOW diff --git a/tests/unit/test_singleton_rgb_label_invocation_477.py b/tests/unit/test_singleton_rgb_label_invocation_477.py index c771043b81..2bc6f64b0d 100644 --- a/tests/unit/test_singleton_rgb_label_invocation_477.py +++ b/tests/unit/test_singleton_rgb_label_invocation_477.py @@ -26,6 +26,8 @@ ) from openhcs.core.pipeline.function_contracts import ( FullStackLabels, + MatchImageStackLabels, + SliceAlignedLabels, ) @@ -69,21 +71,34 @@ def _execute(case, invocation): ) -def test_match_image_labels_consume_declared_singleton_root_for_scalar_rgb(): +@pytest.mark.parametrize("label_mode", (MatchImageStackLabels, SliceAlignedLabels)) +def test_plane_label_modes_consume_declared_singleton_root_for_scalar_rgb( + monkeypatch, label_mode +): + raw = OverlayOutlinesModule.require_callable() + monkeypatch.setattr( + raw, FunctionContractAttribute.object_label_input_execution_mode, label_mode + ) case = _invocation_case() raw, contract, image, labels, *_ = case image_before = image.data.copy() labels_before = object_label_dense_array(labels).copy() invocation = _bind(case) - selected = invocation.kwargs['object_labels'][0] + selected = invocation.kwargs["object_labels"][0] assert invocation.execution_mode is NaturalExecution assert invocation.plane_projection is None assert invocation.payload.metadata.axis_position(ColourAxis) == 2 assert selected.plane_axis is None assert object_label_dense_array(selected).shape == (8, 9) output = _execute(case, invocation) - scalar_labels = ObjectLabelSet(name='Nuclei', variant_data=ObjectLabelVariantData(labels=labels_before[0]), domain=ObjectLabelDomain(declared_object_ids=(1,), scope=ObjectLabelDomainScope.PAYLOAD)) - expected = raw(image, object_labels=(scalar_labels,), outline_colors=('#21FFFF',)) + scalar_labels = ObjectLabelSet( + name="Nuclei", + variant_data=ObjectLabelVariantData(labels=labels_before[0]), + domain=ObjectLabelDomain( + declared_object_ids=(1,), scope=ObjectLabelDomainScope.PAYLOAD + ), + ) + expected = raw(image, object_labels=(scalar_labels,), outline_colors=("#21FFFF",)) np.testing.assert_array_equal(output.data, expected.data) np.testing.assert_array_equal(image.data, image_before) np.testing.assert_array_equal(object_label_dense_array(labels), labels_before) diff --git a/tests/unit/test_smoke_installed_gui.py b/tests/unit/test_smoke_installed_gui.py index e75a869b2d..6b00fbfd77 100644 --- a/tests/unit/test_smoke_installed_gui.py +++ b/tests/unit/test_smoke_installed_gui.py @@ -170,12 +170,41 @@ def test_gui_smoke_allows_wheel_venv_inside_checkout(tmp_path: Path) -> None: ) +def _prepared_execution_services() -> Future[None]: + future: Future[None] = Future() + future.set_result(None) + return future + + +def test_gui_smoke_waits_for_gui_endpoint_before_reading_catalog() -> None: + class UnreachableFunctionCatalogService: + @staticmethod + def prepare() -> Future[FunctionCatalogPage]: + raise AssertionError( + "The catalog attaches only after the GUI endpoint starts." + ) + + with pytest.raises( + OperationTimeoutError, + match="Timed out waiting for test installed function catalog", + ): + verify_installed_function_catalog( + UnreachableFunctionCatalogService(), + execution_services_prepared=Future(), + deadline=OperationDeadline.after_milliseconds( + 1, + operation="test installed function catalog", + ), + ) + + def test_gui_smoke_requires_prepared_catalog_and_resolvable_reference() -> None: entry = _catalog_entry() service = _FunctionCatalogService(_catalog_page(entry)) result = verify_installed_function_catalog( service, + execution_services_prepared=_prepared_execution_services(), deadline=OperationDeadline.after_milliseconds( 2_000, operation="test installed function catalog", @@ -201,6 +230,7 @@ def prepare() -> Future[FunctionCatalogPage]: ): verify_installed_function_catalog( PendingFunctionCatalogService(), + execution_services_prepared=_prepared_execution_services(), deadline=OperationDeadline.after_milliseconds( 1, operation="test installed function catalog", @@ -221,6 +251,7 @@ def prepare() -> Future[FunctionCatalogPage]: with pytest.raises(TimeoutError, match="catalog owner timed out") as caught: verify_installed_function_catalog( FailedFunctionCatalogService(), + execution_services_prepared=_prepared_execution_services(), deadline=OperationDeadline.after_milliseconds( 2_000, operation="test installed function catalog", diff --git a/tests/unit/test_snapshot_deadline.py b/tests/unit/test_snapshot_deadline.py index b0dbb1a6da..09460a9580 100644 --- a/tests/unit/test_snapshot_deadline.py +++ b/tests/unit/test_snapshot_deadline.py @@ -8,7 +8,7 @@ import pytest -from test_napari_render_snapshot import queued_viewer +from tests.unit.test_napari_render_snapshot import queued_viewer from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ViewerWindowSnapshotRequest from openhcs.agent.services import viewer_window_service as service_module diff --git a/tests/unit/test_viewer_spatial_sample.py b/tests/unit/test_viewer_spatial_sample.py index 12bce6de08..903e7d7b6c 100644 --- a/tests/unit/test_viewer_spatial_sample.py +++ b/tests/unit/test_viewer_spatial_sample.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from test_viewer_feature_measurement import native_route +from tests.unit.test_viewer_feature_measurement import native_route from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ViewerWindowImageSampleRequest From e0ff775fbab93c7cd2bb1c72becfcab537b4ceb8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:02:45 -0400 Subject: [PATCH 02/24] WIP B6b: keep main's debug-boundary guard; registry takes B6's module-function catalog policy Co-Authored-By: Claude Opus 5.5 --- .../backends/lib_registry/openhcs_registry.py | 97 ++++++++++--------- tests/unit/test_debug_runtime.py | 7 +- 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/openhcs/processing/backends/lib_registry/openhcs_registry.py b/openhcs/processing/backends/lib_registry/openhcs_registry.py index 86803ca427..74317254d9 100644 --- a/openhcs/processing/backends/lib_registry/openhcs_registry.py +++ b/openhcs/processing/backends/lib_registry/openhcs_registry.py @@ -353,8 +353,8 @@ def _distribution_excluded_by_package_metadata(name: str) -> bool: "requirements; a module cannot depend on an undeclared distribution." ) if any( - requirement.marker is None or requirement.marker.evaluate({"extra": ""}) - for requirement in requirements + marker is None or marker.evaluate({"extra": ""}) + for marker in (requirement.marker for requirement in requirements) ): return False try: @@ -392,6 +392,46 @@ def _source_required_distributions(source: str, origin: str) -> tuple[str, ...]: return () +def _module_in_this_catalog( + module_name: str, + allowed_memory_types: frozenset[str], +) -> bool: + """Memory-type eligibility and the package requirement markers decide. + + A module whose ``REQUIRED_DISTRIBUTIONS`` names a distribution those + markers leave out of this environment is not in this installation's catalog. + """ + + if not _module_declares_allowed_memory_type(module_name, allowed_memory_types): + logger.debug( + "Skipping OpenHCS module %s - no allowed memory decorators", module_name + ) + return False + excluded = _module_distributions_excluded_by_package_metadata(module_name) + if excluded: + logger.info( + "Skipping OpenHCS module %s - package requirements exclude %s here", + module_name, + ", ".join(excluded), + ) + return not excluded + + +def _catalog_policy_context() -> dict[str, Any]: + """The declaration-import policy as catalogue cache identity.""" + + return { + "allowed_memory_types": sorted(_allowed_openhcs_memory_types()), + "distributions_excluded_by_package_metadata": sorted( + { + canonicalize_name(requirement.name) + for requirement in _openhcs_requirements() + if _distribution_excluded_by_package_metadata(requirement.name) + } + ), + } + + def _module_distributions_excluded_by_package_metadata( module_name: str, ) -> tuple[str, ...]: @@ -582,43 +622,19 @@ def _get_openhcs_modules(self) -> List[str]: return modules def get_modules_to_scan(self) -> List[Tuple[str, Any]]: - """Import every admitted native declaration before catalog publication. - - Memory-type eligibility and the OpenHCS package requirement markers - own exclusions: a module whose ``REQUIRED_DISTRIBUTIONS`` names a - distribution those markers leave out of this environment is not part - of this installation's catalog. Any other eligible OpenHCS module that - cannot import is a broken installation, not an optional function - inventory; its failure must prevent apparent catalog readiness. + """Import every native declaration in this installation's catalog. + + ``_module_in_this_catalog`` owns exclusions; any other OpenHCS module + that cannot import is a broken installation and fails catalog readiness. """ self._ensure_module_inventory() assert self.MODULES_TO_SCAN is not None - modules = [] allowed_memory_types = _allowed_openhcs_memory_types() - for module_name in self.MODULES_TO_SCAN: - if not _module_declares_allowed_memory_type( - module_name, - allowed_memory_types, - ): - logger.debug( - "Skipping OpenHCS module %s - no allowed memory decorators", - module_name, - ) - continue - excluded = _module_distributions_excluded_by_package_metadata( - module_name - ) - if excluded: - logger.info( - "Skipping OpenHCS module %s - package requirements exclude %s " - "from this environment", - module_name, - ", ".join(excluded), - ) - continue - module = import_module_preserving_root_logging(module_name) - modules.append((module_name, module)) - return modules + return [ + (module_name, import_module_preserving_root_logging(module_name)) + for module_name in self.MODULES_TO_SCAN + if _module_in_this_catalog(module_name, allowed_memory_types) + ] # ===== ESSENTIAL ABC METHODS ===== def get_library_version(self) -> str: @@ -649,16 +665,7 @@ def cache_source_mtimes(self) -> Dict[str, float]: def cache_discovery_context(self) -> dict[str, Any]: """Project the declaration-import policy into catalogue cache identity.""" - return { - "allowed_memory_types": sorted(_allowed_openhcs_memory_types()), - "distributions_excluded_by_package_metadata": sorted( - { - canonicalize_name(requirement.name) - for requirement in _openhcs_requirements() - if _distribution_excluded_by_package_metadata(requirement.name) - } - ), - } + return _catalog_policy_context() def is_library_available(self) -> bool: """OpenHCS is always available.""" diff --git a/tests/unit/test_debug_runtime.py b/tests/unit/test_debug_runtime.py index 06fac724b6..0c51d22d04 100644 --- a/tests/unit/test_debug_runtime.py +++ b/tests/unit/test_debug_runtime.py @@ -564,15 +564,10 @@ def test_debug_artifact_boundary_has_no_name_key_reconstruction(): "DebugArtifactRefProjection", {"from_artifact_plans"}, ), - ( - repository_root / "openhcs/core/steps/function_runtime.py", - "FunctionCoreDebugMixin", - {"debug_artifacts", "debug_event"}, - ), ( repository_root / "openhcs/core/steps/function_runtime.py", "FunctionCoreExecutor", - {"invoke"}, + {"debug_artifacts", "debug_event", "invoke"}, ), ) violations: list[tuple[str, int, str]] = [] From 3c410052543f28325a76f53b69c70a1a20a826d2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:03:40 -0400 Subject: [PATCH 03/24] WIP: R1 temporary debug workflow (cold catalog preparation on macos-15-intel) Co-Authored-By: Claude Opus 5.5 --- .github/r1-debug/cold_prep.py | 50 +++++++ .github/workflows/r1-debug.yml | 240 +++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 .github/r1-debug/cold_prep.py create mode 100644 .github/workflows/r1-debug.yml diff --git a/.github/r1-debug/cold_prep.py b/.github/r1-debug/cold_prep.py new file mode 100644 index 0000000000..8c4fa14dfb --- /dev/null +++ b/.github/r1-debug/cold_prep.py @@ -0,0 +1,50 @@ +"""TEMPORARY (surface R1): time cold function-catalog preparation phase by phase.""" + +import json +import os +import platform +import sys +import time + +START = time.perf_counter() +rows = [] + + +def status(message): + now = time.perf_counter() - START + rows.append((now, str(message))) + + +def main(): + out = sys.argv[1] + print("platform", platform.platform(), platform.machine(), "cpus", os.cpu_count()) + print("NUMBA_CACHE_DIR", os.environ.get("NUMBA_CACHE_DIR"), "OPENHCS_CPU_ONLY", os.environ.get("OPENHCS_CPU_ONLY")) + status("process started") + from openhcs.processing.backends.lib_registry.registry_service import RegistryService + + status("registry service imported") + if os.environ.get("R1_PROFILE"): + import cProfile + import pstats + + profile = cProfile.Profile() + profile.runcall(RegistryService.prepare_in_current_process, status_callback=status) + pstats.Stats(profile).sort_stats("cumulative").print_stats(70) + else: + RegistryService.prepare_in_current_process(status_callback=status) + status("prepared") + with open(out, "w", encoding="utf-8") as handle: + for at, message in rows: + handle.write(json.dumps({"t": at, "message": message}) + "\n") + gaps = sorted( + ((b[0] - a[0], a[1], b[1]) for a, b in zip(rows, rows[1:])), reverse=True + ) + print(f"TOTAL {rows[-1][0]:.1f}s over {len(rows)} status rows") + for gap, before, after in gaps[:30]: + print(f"{gap:8.2f}s {before[:110]} -> {after[:110]}") + heavy = sorted(name for name in sys.modules if name.split(".")[0] in {"torch", "jax", "tensorflow", "cupy", "basicpy", "skimage", "numba", "scipy", "napari", "PyQt6"} and "." not in name) + print("top-level heavy modules loaded:", heavy) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/r1-debug.yml b/.github/workflows/r1-debug.yml new file mode 100644 index 0000000000..5cd2feb2cd --- /dev/null +++ b/.github/workflows/r1-debug.yml @@ -0,0 +1,240 @@ +# TEMPORARY (surface R1). Runs one failing piece on one failing runner. +# Deleted before the PR is final. +# +# Dispatch: gh workflow run r1-debug.yml --ref -f target=parity-macos +# GitHub only dispatches workflows that exist on the default branch, so a push +# to an R1 branch whose head commit message contains "[r1 ]" runs the +# same job. A push without a tag starts nothing. +name: R1 debug + +on: + workflow_dispatch: + inputs: + target: + description: parity-macos | parity-windows | cold-intel + required: true + type: choice + options: [parity-macos, parity-windows, cold-intel] + case: + description: Official30 case names (space separated); empty uses the failing case for the runner + required: false + default: "" + push: + branches: ["refactor/R1-*"] + +permissions: + contents: read + +concurrency: + group: r1-debug-${{ github.ref }}-${{ github.event.inputs.target || github.sha }} + cancel-in-progress: true + +jobs: + parity-macos: + name: parity ExampleHuman (macos-latest) + if: >- + github.event.inputs.target == 'parity-macos' || + contains(github.event.head_commit.message, '[r1 parity-macos]') + runs-on: macos-latest + timeout-minutes: 60 + defaults: + run: + shell: bash + env: + OPENHCS_CP_NATIVE_REFERENCE_ROOT: ${{ github.workspace }}/benchmark/native_refs/official30_scoped_rows + OPENHCS_REFERENCE_EXPORT_PIPELINES_ROOT: ${{ github.workspace }}/benchmark/reference_exports/official30_value_completion_20260914 + OPENHCS_CPU_ONLY: true + QT_QPA_PLATFORM: offscreen + MPLBACKEND: Agg + NUMBA_DISABLE_INTEL_SVML: ${{ 'macos-latest' == 'macos-latest' && '1' || '0' }} + NPY_DISABLE_CPU_FEATURES: AVX512_SKX,X86_V4 + R1_CASES: ${{ github.event.inputs.case || 'ExampleHuman' }} + steps: + - name: Enable Windows long paths + if: runner.os == 'Windows' + shell: pwsh + run: git config --global core.longpaths true + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Build and install the candidate wheels + run: | + python -m pip install --upgrade pip build + python -m scripts.install_ci_candidate \ + --dependency-source submodules \ + --wheel-directory "$RUNNER_TEMP/openhcs-candidate-wheels" \ + --extras dev,cellprofiler-compat + + - name: Materialize inputs + env: + OPENHCS_BENCHMARK_AUTO_ACQUIRE: "1" + CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples + OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets + run: | + python -c "from benchmark.cellprofiler_comparison import load_comparison_cases; print(len(load_comparison_cases('benchmark/manifests/official30_portable_axis1.json')))" + + - name: Run the case and print value differences + env: + OPENHCS_BENCHMARK_AUTO_ACQUIRE: "0" + CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples + OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets + PYTHONPATH: ${{ github.workspace }}/.github/r1-debug + R1_DEBUG_DUMP_DIR: ${{ runner.temp }}/r1/circles + OPENHCS_ZERNIKE_INTENSITY_DEBUG_TRACE_DIR: ${{ runner.temp }}/r1/intensity-traces + run: | + mkdir -p "$RUNNER_TEMP/r1" + cd "$RUNNER_TEMP/r1" + python "$GITHUB_WORKSPACE/.github/r1-debug/run_case.py" "$GITHUB_WORKSPACE" "$RUNNER_TEMP/r1/out" $R1_CASES 2>&1 | tee "$RUNNER_TEMP/r1/report.txt" + + - name: Upload measurement tables and dumps + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: r1-parity-macos + path: | + ${{ runner.temp }}/r1/report.txt + ${{ runner.temp }}/r1/circles + ${{ runner.temp }}/r1/intensity-traces + ${{ runner.temp }}/r1/out/**/*.csv + ${{ runner.temp }}/r1/out/**/*.jsonl + if-no-files-found: warn + retention-days: 7 + + parity-windows: + name: parity ExampleImagingFlowCytometryObjectsInGrid (windows-latest) + if: >- + github.event.inputs.target == 'parity-windows' || + contains(github.event.head_commit.message, '[r1 parity-windows]') + runs-on: windows-latest + timeout-minutes: 60 + defaults: + run: + shell: bash + env: + OPENHCS_CP_NATIVE_REFERENCE_ROOT: ${{ github.workspace }}/benchmark/native_refs/official30_scoped_rows + OPENHCS_REFERENCE_EXPORT_PIPELINES_ROOT: ${{ github.workspace }}/benchmark/reference_exports/official30_value_completion_20260914 + OPENHCS_CPU_ONLY: true + QT_QPA_PLATFORM: offscreen + MPLBACKEND: Agg + NUMBA_DISABLE_INTEL_SVML: ${{ 'windows-latest' == 'macos-latest' && '1' || '0' }} + NPY_DISABLE_CPU_FEATURES: AVX512_SKX,X86_V4 + R1_CASES: ${{ github.event.inputs.case || 'ExampleImagingFlowCytometryObjectsInGrid' }} + steps: + - name: Enable Windows long paths + if: runner.os == 'Windows' + shell: pwsh + run: git config --global core.longpaths true + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Build and install the candidate wheels + run: | + python -m pip install --upgrade pip build + python -m scripts.install_ci_candidate \ + --dependency-source submodules \ + --wheel-directory "$RUNNER_TEMP/openhcs-candidate-wheels" \ + --extras dev,cellprofiler-compat + + - name: Materialize inputs + env: + OPENHCS_BENCHMARK_AUTO_ACQUIRE: "1" + CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples + OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets + run: | + python -c "from benchmark.cellprofiler_comparison import load_comparison_cases; print(len(load_comparison_cases('benchmark/manifests/official30_portable_axis1.json')))" + + - name: Run the case and print value differences + env: + OPENHCS_BENCHMARK_AUTO_ACQUIRE: "0" + CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples + OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets + PYTHONPATH: ${{ github.workspace }}/.github/r1-debug + R1_DEBUG_DUMP_DIR: ${{ runner.temp }}/r1/circles + OPENHCS_ZERNIKE_INTENSITY_DEBUG_TRACE_DIR: ${{ runner.temp }}/r1/intensity-traces + run: | + mkdir -p "$RUNNER_TEMP/r1" + cd "$RUNNER_TEMP/r1" + python "$GITHUB_WORKSPACE/.github/r1-debug/run_case.py" "$GITHUB_WORKSPACE" "$RUNNER_TEMP/r1/out" $R1_CASES 2>&1 | tee "$RUNNER_TEMP/r1/report.txt" + + - name: Upload measurement tables and dumps + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: r1-parity-windows + path: | + ${{ runner.temp }}/r1/report.txt + ${{ runner.temp }}/r1/circles + ${{ runner.temp }}/r1/intensity-traces + ${{ runner.temp }}/r1/out/**/*.csv + ${{ runner.temp }}/r1/out/**/*.jsonl + if-no-files-found: warn + retention-days: 7 + + cold-intel: + name: cold catalog preparation (macos-15-intel) + if: >- + github.event.inputs.target == 'cold-intel' || + contains(github.event.head_commit.message, '[r1 cold-intel]') + runs-on: macos-15-intel + timeout-minutes: 45 + defaults: + run: + shell: bash + env: + OPENHCS_CPU_ONLY: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Build and install the desktop candidate wheels + run: | + python -m pip install --upgrade pip build + desktop_extras=$(python "$GITHUB_WORKSPACE/scripts/sync_mcp_release_metadata.py" --print-desktop-extras) + python -m scripts.install_ci_candidate \ + --dependency-source submodules \ + --wheel-directory "$RUNNER_TEMP/openhcs-desktop-candidate-wheels" \ + --extras "$desktop_extras" + + - name: Cold preparation as the installed GUI smoke starts it + run: | + mkdir -p "$RUNNER_TEMP/r1" && cd "$RUNNER_TEMP/r1" + sysctl -n machdep.cpu.brand_string hw.ncpu || true + XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/cold-default.jsonl" + + - name: Second start on the same caches + run: | + cd "$RUNNER_TEMP/r1" + XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/warm-default.jsonl" + + - name: Profile of catalog discovery on a cold function catalog + run: | + cd "$RUNNER_TEMP/r1" + R1_PROFILE=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/b-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/b-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/b-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/profile.jsonl" | head -150 + + - name: Upload timelines + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: r1-cold-intel + path: ${{ runner.temp }}/r1/*.jsonl + retention-days: 7 From d32ae673119963471b6fa123c2cfea64ed5fe4c6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 04:43:27 -0400 Subject: [PATCH 04/24] B6: macOS kernel cache preparation uses its online CPUs e55e2ec0b restored compiling every registered kernel before the execution server reports ready, but ffba8426c admits child cache workers only through os.sched_getaffinity, which macOS lacks, so macOS compiled every kernel serially in the server. On the macos-15-intel runner the cold catalog then took past the 300 s installed-GUI acceptance deadline (discovery 113 s, serial kernel compilation still running at 290 s). macOS has no affinity API: every online CPU is schedulable, so its capacity is os.cpu_count(); Linux keeps its affinity set and Windows (no fork) is unchanged. Co-Authored-By: Claude Opus 5.5 --- openhcs/core/processing_preparation.py | 21 +++++++++++++-------- tests/unit/test_preparation_admission.py | 8 +++++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/openhcs/core/processing_preparation.py b/openhcs/core/processing_preparation.py index 74c9c4d995..4ff7b08efb 100644 --- a/openhcs/core/processing_preparation.py +++ b/openhcs/core/processing_preparation.py @@ -312,6 +312,18 @@ def close(self) -> None: self.result_connection.close() +def _schedulable_cpu_count() -> int: + """CPUs this process may run on. + + Linux exposes the scheduler's affinity set. macOS has no affinity API: + every online CPU is schedulable there. + """ + try: + return len(os.sched_getaffinity(0)) + except AttributeError: + return os.cpu_count() or 1 + + @dataclass(frozen=True, slots=True) class PreparationCacheBatch: """Schedule derived cache operations without interpreting backend families.""" @@ -340,14 +352,7 @@ def populate_child_caches( raise ValueError("Preparation worker budget must be positive.") if "fork" not in multiprocessing.get_all_start_methods(): return - try: - worker_capacity = min(max_workers, len(os.sched_getaffinity(0))) - except AttributeError: - # Without an affinity witness there is no admitted parallel cache - # work. The existing parent preparation still owns readiness. - return - if worker_capacity < 1: - return + worker_capacity = min(max_workers, _schedulable_cpu_count()) operations = { operation.identity: operation for preparation in self.preparations diff --git a/tests/unit/test_preparation_admission.py b/tests/unit/test_preparation_admission.py index 42c4d60a8f..6f1049f93b 100644 --- a/tests/unit/test_preparation_admission.py +++ b/tests/unit/test_preparation_admission.py @@ -242,14 +242,16 @@ def prepare_selected(): assert events == ["selected", "unselected", "dynamic"] -def test_unavailable_affinity_does_not_admit_speculative_parallelism(monkeypatch): +def test_platform_without_affinity_admits_its_online_cpus(monkeypatch): events = [] - started, _, _ = simulated_workers(monkeypatch, 8) + started, peak, _ = simulated_workers(monkeypatch, 8) monkeypatch.delattr(os, "sched_getaffinity") + monkeypatch.setattr(os, "cpu_count", lambda: 2) PreparationCacheBatch( tuple(DeclaredCache(str(i), events) for i in range(3)) ).populate_child_caches(max_workers=8) - assert not started and not events + assert started == [("0",), ("1",), ("2",)] + assert max(peak) == 2 def test_affinity_query_failure_is_not_misreported_as_success(monkeypatch): From 6c5406ce0e8bfec3351a43ccdcc2eee0a7cd333d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:05:37 -0400 Subject: [PATCH 05/24] WIP: R1 cold preparation probe: serial, forked workers, warm, discovery profile [r1 cold-intel] Co-Authored-By: Claude Opus 5.5 --- .github/r1-debug/cold_prep.py | 70 ++++++++++++++++++++++++++++------ .github/workflows/r1-debug.yml | 24 +++++++----- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/.github/r1-debug/cold_prep.py b/.github/r1-debug/cold_prep.py index 8c4fa14dfb..371b1a226d 100644 --- a/.github/r1-debug/cold_prep.py +++ b/.github/r1-debug/cold_prep.py @@ -1,5 +1,10 @@ -"""TEMPORARY (surface R1): time cold function-catalog preparation phase by phase.""" +"""TEMPORARY (surface R1): time cold function-catalog preparation phase by phase. +Wall time per status row, CPU time of this process and its finished children, +and (R1_NUMBA_EVENTS=1) the self time of every Numba compilation. +""" + +import collections import json import os import platform @@ -10,9 +15,42 @@ rows = [] +def cpu_seconds(): + t = os.times() + return t.user + t.system + t.children_user + t.children_system + + def status(message): - now = time.perf_counter() - START - rows.append((now, str(message))) + rows.append((time.perf_counter() - START, cpu_seconds(), str(message))) + + +def numba_compile_report(listener): + stack = [] + self_time = collections.Counter() + count = collections.Counter() + for timestamp, event in listener.buffer: + function = event.data["dispatcher"].py_func + name = f"{getattr(function, '__module__', '?')}.{getattr(function, '__qualname__', '?')}" + if event.is_start: + stack.append([name, timestamp, 0.0]) + elif stack: + started_name, started, child = stack.pop() + total = timestamp - started + self_time[started_name] += total - child + count[started_name] += 1 + if stack: + stack[-1][2] += total + total = sum(self_time.values()) + print(f"NUMBA compile self time {total:.1f}s over {sum(count.values())} compilations of {len(count)} functions") + by_module = collections.Counter() + for name, seconds in self_time.items(): + by_module[name.split(".")[0].rsplit(".", 1)[0]] += seconds + print("-- by module") + for module, seconds in by_module.most_common(25): + print(f"{seconds:8.2f}s {module}") + print("-- by function (signatures compiled)") + for name, seconds in self_time.most_common(40): + print(f"{seconds:8.2f}s x{count[name]:<3d} {name}") def main(): @@ -23,6 +61,16 @@ def main(): from openhcs.processing.backends.lib_registry.registry_service import RegistryService status("registry service imported") + if os.environ.get("R1_NO_CHILDREN"): + from openhcs.core.processing_preparation import PreparationCacheBatch + + PreparationCacheBatch.populate_child_caches = lambda *args, **kwargs: None + listener = None + if os.environ.get("R1_NUMBA_EVENTS"): + from numba.core import event + + listener = event.RecordingListener() + event.register("numba:compile", listener) if os.environ.get("R1_PROFILE"): import cProfile import pstats @@ -34,16 +82,16 @@ def main(): RegistryService.prepare_in_current_process(status_callback=status) status("prepared") with open(out, "w", encoding="utf-8") as handle: - for at, message in rows: - handle.write(json.dumps({"t": at, "message": message}) + "\n") + for wall, cpu, message in rows: + handle.write(json.dumps({"t": wall, "cpu": cpu, "message": message}) + "\n") gaps = sorted( - ((b[0] - a[0], a[1], b[1]) for a, b in zip(rows, rows[1:])), reverse=True + ((b[0] - a[0], b[1] - a[1], a[2], b[2]) for a, b in zip(rows, rows[1:])), reverse=True ) - print(f"TOTAL {rows[-1][0]:.1f}s over {len(rows)} status rows") - for gap, before, after in gaps[:30]: - print(f"{gap:8.2f}s {before[:110]} -> {after[:110]}") - heavy = sorted(name for name in sys.modules if name.split(".")[0] in {"torch", "jax", "tensorflow", "cupy", "basicpy", "skimage", "numba", "scipy", "napari", "PyQt6"} and "." not in name) - print("top-level heavy modules loaded:", heavy) + print(f"TOTAL wall {rows[-1][0]:.1f}s cpu {rows[-1][1]:.1f}s over {len(rows)} status rows") + for wall, cpu, before, after in gaps[:30]: + print(f"wall {wall:7.2f}s cpu {cpu:7.2f}s {before[:100]} -> {after[:100]}") + if listener is not None: + numba_compile_report(listener) if __name__ == "__main__": diff --git a/.github/workflows/r1-debug.yml b/.github/workflows/r1-debug.yml index 5cd2feb2cd..a5f8b22da0 100644 --- a/.github/workflows/r1-debug.yml +++ b/.github/workflows/r1-debug.yml @@ -212,24 +212,30 @@ jobs: --wheel-directory "$RUNNER_TEMP/openhcs-desktop-candidate-wheels" \ --extras "$desktop_extras" - - name: Cold preparation as the installed GUI smoke starts it + - name: A. Cold preparation as the installed GUI smoke starts it (kernels compile in the server) run: | mkdir -p "$RUNNER_TEMP/r1" && cd "$RUNNER_TEMP/r1" sysctl -n machdep.cpu.brand_string hw.ncpu || true - XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ - python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/cold-default.jsonl" + R1_NUMBA_EVENTS=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/a-cold-serial.jsonl" - - name: Second start on the same caches + - name: B. Cold preparation with a Numba cache directory (kernels compile in forked workers) run: | cd "$RUNNER_TEMP/r1" - XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ - python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/warm-default.jsonl" + NUMBA_CACHE_DIR="$RUNNER_TEMP/r1/b-numba" HOME="$RUNNER_TEMP/r1/b-home" XDG_CACHE_HOME="$RUNNER_TEMP/r1/b-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/b-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/b-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/b-cold-workers.jsonl" - - name: Profile of catalog discovery on a cold function catalog + - name: C. Second start on warm caches run: | cd "$RUNNER_TEMP/r1" - R1_PROFILE=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/b-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/b-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/b-data" \ - python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/profile.jsonl" | head -150 + NUMBA_CACHE_DIR="$RUNNER_TEMP/r1/b-numba" HOME="$RUNNER_TEMP/r1/b-home" XDG_CACHE_HOME="$RUNNER_TEMP/r1/b-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/b-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/b-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/c-warm.jsonl" + + - name: D. Profile of catalog discovery (cold function catalog, warm kernels) + run: | + cd "$RUNNER_TEMP/r1" + R1_PROFILE=1 NUMBA_CACHE_DIR="$RUNNER_TEMP/r1/b-numba" HOME="$RUNNER_TEMP/r1/d-home" XDG_CACHE_HOME="$RUNNER_TEMP/r1/d-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/d-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/d-data" \ + python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/d-profile.jsonl" | head -170 - name: Upload timelines if: always() From 7947ca0be3407e244b6172b74d6b227c18120c58 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:12:12 -0400 Subject: [PATCH 06/24] WIP B6b: smoke test list Co-Authored-By: Claude Opus 5.5 --- tests/smoke_tests.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/smoke_tests.txt diff --git a/tests/smoke_tests.txt b/tests/smoke_tests.txt new file mode 100644 index 0000000000..f516d9c30b --- /dev/null +++ b/tests/smoke_tests.txt @@ -0,0 +1,11 @@ +# Fast end-to-end smoke: datasets run through the real entry points. +# One pytest path or node id per line. Read by the pull-request smoke job and +# by the local merge gate; keep the whole list under about five minutes on two +# workers. +tests/integration/test_session_journey.py::test_headless_mcp_journey_adds_compiles_and_runs_a_dataset +tests/integration/test_session_journey.py::test_read_only_dataset_initializes_in_a_mirrored_workspace +tests/pyqt_gui/test_session_gui_journey.py +tests/integration/test_agent_observation_export_zmq.py::test_headless_observation_export_uses_ordinary_execution +tests/integration/test_agent_observation_export_zmq.py::test_ordinary_execution_can_export_outcomes_without_value_observation +tests/integration/test_agent_observation_export_zmq.py::test_measured_cli_uses_ordinary_source_session_and_shared_finalizer +tests/integration/test_measured_cli_session.py::test_read_only_source_plate_completes_and_stays_byte_identical From e667b45d871ee3aa62950184ba222b0d83d79889 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:15:08 -0400 Subject: [PATCH 07/24] WIP: R1 receipt with handoff; cold preparation probe options Co-Authored-By: Claude Opus 5.5 --- .../R1-release-blockers.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/refactor/consolidation-20261009/R1-release-blockers.md diff --git a/docs/refactor/consolidation-20261009/R1-release-blockers.md b/docs/refactor/consolidation-20261009/R1-release-blockers.md new file mode 100644 index 0000000000..9cc8be08cb --- /dev/null +++ b/docs/refactor/consolidation-20261009/R1-release-blockers.md @@ -0,0 +1,99 @@ +# R1: platform-specific CI failures that block a release + +**Status: checkpointed mid-work on 2026-10-12. Nothing is merged, no PR is open, and items 1 and 2 are unverified on macOS and Windows.** Four branches from `origin/main` `a751b0eba`, one per failure. Fresh agents continue from the Handoff section. + +## What is wrong + +At the last completed CI run (`38155492658`, B6 head `d43533f90`): + +- `official30-headless-parity` fails on macOS 3.14 and Windows 3.14 (Zernike values). +- `desktop-candidate-live-mcp` and `desktop-installer-source-test` on `macos-15-intel` exceed the smoke's 300 s deadline. +- `unit-tests` is cancelled at 45 minutes. +- `test_examplehuman_selected_source_uses_same_declaration_owner` fails on main. + +## Target + +Each failure fixed at its cause, in its own PR. No global loosening of the parity tolerance. + +## Guards + +- Item 4: `tests/unit/test_callable_contract.py::test_only_serving_readiness_reads_the_members_of_a_registry` (preparing a family never reads registry members); the previously failing test is the behavioural guard. +- Item 3: `test_collection_under_the_frozen_heap_still_reclaims_new_cycles`. +- Items 1 and 2: not written yet. + +## Handoff + +### Shared facts + +- **B6's PR #1195 is still open.** Every log from run `38155492658` comes from B6's branch, which carries fixes main does not have. +- **`workflow_dispatch` cannot start a workflow that is absent from the default branch.** `.github/workflows/r1-debug.yml` therefore also runs on a push to `refactor/R1-*` whose head commit message contains a tag: `[r1 parity-macos]`, `[r1 parity-windows]` or `[r1 cold-intel]`. Trigger with, for example, `git commit --allow-empty -m "WIP: rerun [r1 parity-macos]" && git push`. Once the file is on main, `gh workflow run r1-debug.yml --ref -f target=parity-macos` (or `parity-windows`, `cold-intel`; optional `-f case=""`) works too. +- Read results with `gh run view --log` and `gh run download -n r1-parity-macos` (or `r1-parity-windows`, `r1-cold-intel`). +- **Delete `.github/workflows/r1-debug.yml` and `.github/r1-debug/` before a PR is final.** They exist on the zernike and cold-start branches only. +- Actions was stalled for most of the session. At checkpoint, run `38196784224` (zernike branch) had finished its Windows job and its macOS job was still queued; run `38198397594` (cold-start branch, `cold-intel`) was queued. **Check both first.** +- The local machine had load average 24 to 28; local wall times are inflated. CPU seconds are the usable local measure. + +### Item 1: Zernike parity off Linux — branch `refactor/R1-zernike-parity`, debug tooling only, no fix yet + +**Found** + +- The CI logs and artifacts of run `38155492658` name the columns but carry no values. macOS: ExampleHuman `cells/zernike_N_M` and `cytoplasm/zernike_N_M` (all 30 orders, 60 differences); ExampleImagingFlowCytometryObjectsInGrid `bf_cells_on_grid` shape Zernikes, `zernike_magnitude_*` and `zernike_phase_*` (103 differences). Windows: `ssc/zernike_phase_N_M_df_image` for m ≠ 0 only, magnitudes equal. **Both platforms also fail `cp_tutorial_translocation_final` `sample/thumbnail_raw_dna` and `thumbnail_raw_gfp`, which B6's table does not mention.** +- The default backend (`NativeNumpyShapeZernikeBackendStrategy`, `openhcs/processing/backends/cellprofiler/zernike.py`) zeroes the radial term where `radius_squared > 1`. The two or three pixels that define each enclosing circle sit at radius 1 ± one ulp, so a last-bit change in the circle moves a pixel in or out of the disk. +- The circle (`minimum_enclosing_circle_from_labels`, `label_geometry.py`) uses only exact IEEE operations except `arccos`, which decides ties. With AVX512 disabled (as parity requires) `arccos` is the platform libm's `acos`. +- **Linux reproduction (a hypothesis for macOS, not yet confirmed there):** on the labels the two workflows produce, perturbing `arccos` results by ±1 ulp at random changes 1 to 3 circles per image by a relative 1e-15, and shape Zernikes of those objects move by 1.6e-3 to 2.0e-2 (tolerance 1e-6). That would make this a magnitude difference caused by a discontinuity, not phase wrapping. Script: `.github/r1-debug/real_sens.py `. +- glibc `acos` is not correctly rounded: it differs from round-to-nearest on 301 of 209,874 distinct inputs recorded from the two workflows (`.github/r1-debug/cmp_dump.py`), and those 301 flip no decision there. +- A round-to-nearest `arccos` built only from `+ - * /` (double-double Newton step on `cos(y) - x`, Taylor `sin`/`cos`, three-part π/2, iterated to a fixed point) gave 0 wrong results in 70,000 inputs against mpmath, independent of the seed. Prototype: `.github/r1-debug/cracos.py` (run its self-check with `uv run --no-project --with mpmath python .github/r1-debug/cracos.py 40000`). +- **Windows cannot be debugged from main yet:** the Windows debug job on main fails before any Zernike with `ValueError: queue_rows must be a C-contiguous 1-D array with format I and writable` (fixed on B6's branch by `47724ccd4`). Cherry-pick that commit onto the debug branch, or wait for #1195. +- The Windows phase-only failure is probably a different cause: the phase of a near-zero moment is ill-conditioned (magnitude within 1e-6, phase free). Not examined. + +**Done** + +- `.github/workflows/r1-debug.yml`, `.github/r1-debug/run_case.py` (runs named cases, prints environment, `np.show_config()`, libm probes and a per-column value diff) and `.github/r1-debug/sitecustomize.py` (records labels, ids, centers, radii and every `arccos` input with the kernel's, `math.acos`'s and `np.arccos`'s result into `circles/*.npz`). Verified locally on Linux: both cases equivalent, 18 circle dumps, 9 intensity traces. +- The per-column diff in `run_case.py` compares rows by position and is wrong for wide tables such as `BF_cells_on_grid.csv`; diff the uploaded CSVs locally instead. + +**Remains** + +1. Get the macOS artifact. Compare its `arccos_libm` with glibc on the same inputs, and its `labels` with Linux, to confirm or refute the `acos` hypothesis. +2. If confirmed: replace the libm branch of `_numpy_124_arccos` with the round-to-nearest implementation (Numba), run the full 30-case parity under flock on Linux (it must stay 30/30, since round-to-nearest differs from glibc on about 0.14% of inputs), then rerun the macOS job. +3. Cherry-pick `47724ccd4`, rerun Windows, and examine the `ssc` phase columns with the intensity traces (`OPENHCS_ZERNIKE_INTENSITY_DEBUG_TRACE_DIR` is already set by the workflow). +4. Find the cause of the `thumbnail_raw_*` differences. +5. Add the Linux sensitivity test, remove the debug files, run guardrails, open the PR. + +### Item 2: Intel macOS cold start — branch `refactor/R1-cold-start`, measurements only, no fix yet + +**Found** (Intel runner, run `38155492658`) + +- `desktop-candidate-live-mcp` (no `NUMBA_CACHE_DIR`, kernels compile serially in the server): 301 s total = 3.3 s import + **69.7 s "Discovering registered callables"** (about 35 s importing 68 modules, 24 s probing skimage functions, 17 s of that in `skimage.restoration`) + **about 222 s compiling kernels** + 5 s loading the catalog. Largest gaps: watershed 42.5 s, medial_axis→threshold 28.7 s, threshold→closing 26.3 s. +- `desktop-installer-source-test` (launcher sets `NUMBA_CACHE_DIR`, so B6's `ded24279e` forks workers): discovery 90 s, then 24 one-family workers ran for more than 193 s without finishing. **Forked workers were no faster than serial on the 4-CPU runner.** +- Local serial cold run: 196 CPU-s; Numba compile self time is spread over 665 compilations of 321 functions, the largest single function 9.7 s (`_weighted_projection_numba`, 48 signatures). There is no single hot kernel. +- Local forked run (12 logical CPUs): 81.7 s wall but 254.5 CPU-s against 196 serial. Each one-family worker recompiles shared callees, which would explain why 4 CPUs gain nothing. +- `PersistentNumbaKernelPreparation.can_prepare_in_child` requires `OPENHCS_CPU_ONLY` and a set `NUMBA_CACHE_DIR`. Only the installed launchers export the cache directory (`openhcs/desktop/deployment.py`); `pip install` users and the GUI smoke always compile serially. +- On main, macOS never forks workers (`os.sched_getaffinity` is missing); `ded24279e` is cherry-picked on this branch as `d32ae6731`. + +**Done**: `cold-intel` job and `.github/r1-debug/cold_prep.py` (wall and CPU time per status row; `R1_NUMBA_EVENTS=1` for per-function compile time, `R1_NO_CHILDREN=1` to force serial, `R1_PROFILE=1` for a cProfile of discovery). Steps A to D measure serial, forked, warm and discovery. + +**Remains** + +1. Read run `38198397594` for the Intel numbers of steps A to D. Step B sets `HOME` to a fresh directory and may need adjusting. +2. Candidate speedup, untested: in `PreparationCacheBatch.populate_child_caches`, give each of the `worker_capacity` workers a batch of families instead of one family per worker, so shared callees compile once per worker. Compare total CPU seconds locally, then on Intel. +3. Candidate, untested: have the application set the Numba cache directory itself (after `ensure_source_checkout_external_paths()` in `openhcs/__init__.py`, before Numba is imported) so the launchers stop restating it. +4. If neither brings the Intel cold start clearly under 300 s, raise the deadline for that runner only, citing the step A measurement. + +### Item 3: `unit-tests` time — branch `refactor/R1-gc-test-cost`, fix done, PR not opened + +**Found**: the four `test_qt_adapter_mixed_native_lifetime_orders[*]` cases did not merely run slowly; **each hit the 120 s pytest timeout** (`Timeout (0:02:00)!`), 527 s for the file. Each case makes about 37 full `gc.collect()` calls, and a full collection walks the whole session heap (more than 3 s per call at 50% of the suite). + +**Done**: an autouse fixture freezes the pre-existing heap (`gc.freeze()` / `gc.unfreeze()`) so collections cover exactly what the test allocates; no assertion changed. Locally the file went from 21.4 s to 1.67 s (11 passed); one collection from 0.05–0.19 s to 30 µs. Mutation check: removing `self.destroyed.connect(subscription.cancel)` from `signals.py` still fails 5 tests under the fixture. A canary test shows a new cycle is still reclaimed. + +**Remains**: guardrails, PR. **Findings for B6b:** approximate seconds per file in the cancelled run: `test_custom_function_events.py` 527, `agent/test_agent_services.py` 166 (one case also timed out at 120 s: `test_repository_catalog_finds_nucleus_segmentation_from_callable_owner`), `test_cellprofiler_kernel_preparation.py` 111, `test_configuration_mirror_deletion.py` 103, `test_cellprofiler_static_deletion_gates.py` 99, `agent/test_mcp_server.py` 94. A session heap that costs more than 3 s per full collection suggests tests leak tracked objects across the session. + +### Item 4: unowned failing test — branch `refactor/R1-selected-source-discovery`, fix done, PR not opened + +**Found**: the test is not stale. `91f05c26b` (issue #763, 2026-10-05) made `CellProfilerModule` a `CompilerPreparedAutoRegisterFamily` whose hook reads every registry member, and `_scan_module_registries` also counted the members of every prepared family. Preparing any callable whose module imports `CellProfilerModule` therefore imported all 91 declarations. Pipeline import prepares callables since `5c3e12ecd`, so retrieving one knowledge-base example discovered the whole family (about 1.5 to 2.5 s extra locally; 73 backend modules loaded instead of 35). + +**Done**: `RegistryService.prepare_in_current_process` now completes the registries its callables' modules use (`AutoRegisterRegistryPreparation.discover_module_registries`, previously uncalled), which keeps #763's requirement "discovered before READY". Preparing one callable only runs family hooks. `CellProfilerModule.prepare_registered_family` and its `CompilerPreparedAutoRegisterFamily` base are deleted (**derived**: readiness discovery now owns it); `AutoRegisterRegistryPreparationReport` and its counts are deleted (**unused**: only a test read `prepared_family_count`). 158 tests passed across the eleven preparation and discovery test files, including the target test. + +**Remains**: full 30-case parity under flock (readiness preparation changed), guardrails, PR. The readiness step discovers every registry referenced by callable modules, about 60 lazy registries; locally each took under 0.01 s and no new top-level package was imported in CPU-only mode. Not checked with GPU libraries installed. + +## Done when + +All four PRs merged, the three target jobs green on their runners, and the debug workflow deleted. From 6c74ffa9c44695625534bb11516d0a3b5e1b76b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:18:24 -0400 Subject: [PATCH 08/24] WIP B6b: stopped batch ends cancelled; GUI smoke reports the status name; handoff Co-Authored-By: Claude Opus 5.5 --- .../B6b-ci-and-smoke.md | 25 ++++++ .../authoring/session/execution_control.py | 11 ++- scripts/smoke_installed_gui.py | 9 ++- ...t_execution_control_stop_during_compile.py | 76 +++++++++++++++++++ tests/unit/test_smoke_installed_gui.py | 6 +- 5 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md create mode 100644 tests/unit/test_execution_control_stop_during_compile.py diff --git a/docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md b/docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md new file mode 100644 index 0000000000..6c018fba93 --- /dev/null +++ b/docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md @@ -0,0 +1,25 @@ +# B6b: CI triggers, local smoke, and B6's remaining fixes + +## B6b handoff + +All work is on branch `refactor/B6b-ci-green` (merged with main `77bf7e803`; last commit is this WIP). Job 1 merged as #1212. Scratch: `scratch/b6-merged` (local only) is B6's head with formatting-only edits undone by `.claude/dechurn.py` in the agent worktree. + +**Job 2, smoke script: done, not yet in a PR.** +- `/home/ts/code/projects/openhcs/.claude/verify/smoke_local.sh ` is installed. It runs `tests/smoke_tests.txt` (in this branch; a built-in list on older branches) with 2 workers, then `scripts/smoke_installed_gui.py` from source, offscreen. +- Observed: main `a751b0eba` `smoke rc=1` (5 failed, GUI catalog timeout, 651 s); this branch `smoke rc=0` (7 passed, GUI passed, 187 s). +- After #1212 alone the GUI part still fails; it needs this branch's catalog wait (`03ca14cc9`) and `accepted_ui_action_status` (the smoke result held the status class, not JSON). Until this merges use `SMOKE_SKIP_GUI=1`. +- Verify: `bash /home/ts/code/projects/openhcs/.claude/verify/smoke_local.sh `. + +**Job 3, CI triggers: not started (design only).** +- Plan: new `pr-checks.yml` (pull_request): guards plus changed test files plus `tests/core`, and `tests/smoke_tests.txt`. `integration-tests.yml` and `docs.yml` go to `workflow_dispatch`, nightly `schedule`, tags `v*`; delete the per-job `if: github.event_name == ...` lines (they are false on `schedule`). +- Release gate: `scripts/release_readiness.py` `ReleaseWorkflowGate.successful_run` filters `--event push`; drop that filter so a dispatch or nightly run on the exact commit counts. +- nra-r1: still `--budget-seconds 160`, job `timeout-minutes: 10`. Local gate uses 600. Not changed yet. +- Doc audits: `scripts/validate_docs.py` pins each authority by sha256 of the whole file, so any edit to a hub file (`compiler.py` is cited by 24 pages) invalidates every citing page. Measured, B6's pins against main: 98 stale pages by bytes, 98 by AST-normalized digest, 86 if scoped to symbols the page names. Formatting is not the cause; scoping needs a reviewer-declared `symbols` list per authority. +- Unowned: the `code-quality` job runs `black --check` on every file changed since its last green run; it will fail the full matrix. +- B6's workflow commits belong here: `542f7ffeb`, `ce529e459`, `5544c445f`, `e82ae3398`, `6bccc4d6d` (website part). + +**Job 4, B6's remaining fixes: WIP, not a PR.** +- Done: all B6 functional commits carried, plus two new fixes (stop during compile ends `cancelled`; smoke status name). `266 passed` on the touched unit files; smoke rc=0. +- Dropped: black commits (`cf0b8eb8c`, `f2ae23c97`, `2ed28bb32`, `ae618be9d`, `7f5710ae9`, `3e089a3f2`), ratchet mixin moves (`239953c4c`, `ab03ba7ba`, `24bfafede`; the registry keeps B6's module functions), all `docs/source`, `docs/audits`, `README.md` and `validate_docs.py` changes (re-review and re-pin commits), workflows (job 3), the pyqt-reactive pin (PyQT-reactive#21 still open), `f8062d2cd`. +- Next: run `guardrails_local.sh` (the ratchet may object to classes grown without the mixins), parity under flock (execution code changed), check 3.11 syntax of touched files, replace B6's receipt text, open the PR, close #1195 with a comment. +- Verify: `bash /home/ts/code/projects/openhcs/.claude/verify/guardrails_local.sh ` and the smoke command above. diff --git a/openhcs/authoring/session/execution_control.py b/openhcs/authoring/session/execution_control.py index 59b8c2ab0a..fa0d402555 100644 --- a/openhcs/authoring/session/execution_control.py +++ b/openhcs/authoring/session/execution_control.py @@ -46,9 +46,14 @@ def check_all_completed(self) -> None: async def handle_execution_failure(self) -> None: session = self._session - for scope_id in tuple(session.batch.active_plates): - session.batch.mark_terminal(scope_id, ExecutionFailed) - session.set_dataset_state(scope_id, OrchestratorExecFailed) + if session.execution_state.stop_pending: + # The batch was stopped (for example while it was still compiling): + # the error is the stop arriving, so its datasets end cancelled. + self.cancel_active_datasets() + else: + for scope_id in tuple(session.batch.active_plates): + session.batch.mark_terminal(scope_id, ExecutionFailed) + session.set_dataset_state(scope_id, OrchestratorExecFailed) session.execution_state = ManagerExecutionState.IDLE try: await session.client.disconnect() diff --git a/scripts/smoke_installed_gui.py b/scripts/smoke_installed_gui.py index d688ff6e1f..5a841adb21 100644 --- a/scripts/smoke_installed_gui.py +++ b/scripts/smoke_installed_gui.py @@ -458,13 +458,14 @@ def _typed_tool_payload( return payload -def require_ui_action_accepted(action_result: UiActionInvokeResult) -> None: - """Fail the smoke unless the live GUI took the requested action.""" +def accepted_ui_action_status(action_result: UiActionInvokeResult) -> str: + """The wire name of the accepted status; fail unless the GUI took the action.""" from openhcs.agent.dto.ui_bridge import ActionAccepted if action_result.status is not ActionAccepted: raise AssertionError(f"Installed UI action was not accepted: {action_result}") + return action_result.status.name def _window_ids(catalog: UiWindowCatalog) -> frozenset[str]: @@ -717,7 +718,7 @@ def run_installed_live_mcp_smoke( capability=agent_capabilities.ui_invoke_action, payload_type=UiActionInvokeResult, ) - require_ui_action_accepted(action_result) + action_status = accepted_ui_action_status(action_result) timeout_text = f"{deadline.remaining_seconds():g}" windows_execution = client.execute( @@ -757,7 +758,7 @@ def run_installed_live_mcp_smoke( journal.record(InstalledGuiSmokePhase.LIVE_MCP_COMPLETE) return InstalledLiveMcpSmokeResult( - action_status=action_result.status, + action_status=action_status, bridge_count=len(bridge_catalog.bridges), bridge_port=bridge_status.connection.require_port("Installed UI bridge smoke"), bridge_reachable=bridge_status.reachable, diff --git a/tests/unit/test_execution_control_stop_during_compile.py b/tests/unit/test_execution_control_stop_during_compile.py new file mode 100644 index 0000000000..5af2d2094d --- /dev/null +++ b/tests/unit/test_execution_control_stop_during_compile.py @@ -0,0 +1,76 @@ +"""A batch that errors out because it was stopped ends cancelled, not failed.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from openhcs.authoring.session.execution_control import ExecutionControl +from openhcs.core.execution_state import ( + ExecutionCancelled, + ExecutionFailed, + ManagerExecutionState, +) + + +class _Batch: + active_plates = ("dataset",) + + def __init__(self) -> None: + self.terminal: dict[str, type] = {} + + def cancellable_plates(self) -> tuple[str, ...]: + return self.active_plates + + def execution_id(self, scope_id: str) -> None: + return None + + def mark_terminal(self, scope_id: str, status: type) -> None: + self.terminal[scope_id] = status + + +class _Client: + async def disconnect(self) -> None: + return None + + +class _Session: + def __init__(self, state: ManagerExecutionState) -> None: + self.execution_state = state + self.batch = _Batch() + self.client = _Client() + self.finished: list[tuple[type, str]] = [] + + def set_dataset_state(self, scope_id: str, state: type) -> None: + del scope_id, state + + def finish_dataset_execution(self, completion, scope_id: str) -> None: + self.finished.append((completion.status, scope_id)) + + def refresh(self) -> None: + return None + + +@pytest.mark.parametrize( + "state", + [state for state in ManagerExecutionState if state.stop_pending], +) +def test_a_stopped_batch_that_errors_is_cancelled(state: ManagerExecutionState) -> None: + session = _Session(state) + + asyncio.run(ExecutionControl(session).handle_execution_failure()) + + assert session.finished == [(ExecutionCancelled, "dataset")] + assert session.batch.terminal == {} + assert session.execution_state is ManagerExecutionState.IDLE + + +def test_a_running_batch_that_errors_is_failed() -> None: + session = _Session(ManagerExecutionState.RUNNING) + + asyncio.run(ExecutionControl(session).handle_execution_failure()) + + assert session.finished == [] + assert session.batch.terminal == {"dataset": ExecutionFailed} + assert session.execution_state is ManagerExecutionState.IDLE diff --git a/tests/unit/test_smoke_installed_gui.py b/tests/unit/test_smoke_installed_gui.py index 6b00fbfd77..3f013478d2 100644 --- a/tests/unit/test_smoke_installed_gui.py +++ b/tests/unit/test_smoke_installed_gui.py @@ -35,7 +35,7 @@ InstalledGuiSnapshotEvidenceAuthority, _StartupObservation, assert_not_source_checkout_import, - require_ui_action_accepted, + accepted_ui_action_status, verify_installed_function_catalog, with_isolated_runtime_topology, ) @@ -444,7 +444,7 @@ def test_gui_smoke_accepts_only_an_accepted_ui_action( action_result = SimpleNamespace(status=status) if status is ActionAccepted: - require_ui_action_accepted(action_result) + assert json.dumps(accepted_ui_action_status(action_result)) == '"accepted"' else: with pytest.raises(AssertionError, match="not accepted"): - require_ui_action_accepted(action_result) + accepted_ui_action_status(action_result) From b59cc8e480f9305235b6684f7e9eff84bdc90500 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:20:23 -0400 Subject: [PATCH 09/24] B6c: undo formatting-only hunks carried from B6 Co-Authored-By: Claude Opus 5.5 --- benchmark/cellprofiler_benchmark_cli.py | 4 +--- .../authoring/session/operations/datasets.py | 1 + openhcs/authoring/session/session.py | 1 + openhcs/core/source_bindings.py | 8 +++---- openhcs/core/source_image_provenance.py | 4 +--- scripts/smoke_installed_mcp.py | 9 ++------ .../test_agent_observation_export_zmq.py | 5 ++++- .../test_custom_source_transport.py | 22 +++++-------------- .../test_neurite_outgrowth_synthetic_plate.py | 4 +++- tests/integration/test_session_journey.py | 4 +--- .../test_mcp_dev_client_sampling_results.py | 8 +------ ...test_singleton_rgb_label_invocation_477.py | 12 +++------- 12 files changed, 28 insertions(+), 54 deletions(-) diff --git a/benchmark/cellprofiler_benchmark_cli.py b/benchmark/cellprofiler_benchmark_cli.py index 9170a09535..b8fde1982d 100644 --- a/benchmark/cellprofiler_benchmark_cli.py +++ b/benchmark/cellprofiler_benchmark_cli.py @@ -323,9 +323,7 @@ def run(self, args: argparse.Namespace) -> int: ) return 130 except MeasuredRunEnded as ended: - print( - json.dumps(to_jsonable(ended.row), sort_keys=True), file=sys.stderr - ) + print(json.dumps(to_jsonable(ended.row), sort_keys=True), file=sys.stderr) raise RuntimeError( f"Ordinary pipeline run did not complete: {ended}" ) from ended diff --git a/openhcs/authoring/session/operations/datasets.py b/openhcs/authoring/session/operations/datasets.py index d52eef726f..615072d580 100644 --- a/openhcs/authoring/session/operations/datasets.py +++ b/openhcs/authoring/session/operations/datasets.py @@ -461,3 +461,4 @@ class ShowDatasetImages(InitializedTargetsOperation, RendererOperation): tooltip = "Browse dataset images and metadata" description = "Opens the image and metadata browser of the selected datasets." side_effects = ("opens_dataset_viewer_window",) + diff --git a/openhcs/authoring/session/session.py b/openhcs/authoring/session/session.py index e4ae608b78..7582a3d43a 100644 --- a/openhcs/authoring/session/session.py +++ b/openhcs/authoring/session/session.py @@ -1513,6 +1513,7 @@ def debug_context(self, scope_id: str) -> DebugSessionProjectionContext: terminal_status=( None if (status := self.batch.terminal_status(scope_id)) is None + else status.name ), ), diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index a249bc99be..0270462cef 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -984,12 +984,12 @@ def matches_provenance(self, provenance: SourceImageProvenance) -> bool: """Apply the same selectors to identities carried by current pixels.""" identities = provenance.represented_source_identities return ( - not self.filters - or self.path_filters_match( + (not self.filters or self.path_filters_match( tuple(path for identity in identities for path in identity.filter_paths) + )) + and self.metadata_candidates_match( + tuple(identity.component_metadata or {} for identity in identities) ) - ) and self.metadata_candidates_match( - tuple(identity.component_metadata or {} for identity in identities) ) diff --git a/openhcs/core/source_image_provenance.py b/openhcs/core/source_image_provenance.py index b75664c7a0..a1d5f33e17 100644 --- a/openhcs/core/source_image_provenance.py +++ b/openhcs/core/source_image_provenance.py @@ -1110,9 +1110,7 @@ def required_scalar_components( continue if len({values[component][0] for values in domains}) > 1: reduced.add(component.name) - return tuple( - component for component in component_order if component not in reduced - ) + return tuple(component for component in component_order if component not in reduced) def image_set_identities( self, diff --git a/scripts/smoke_installed_mcp.py b/scripts/smoke_installed_mcp.py index 06c48c5286..74ef95ebe5 100644 --- a/scripts/smoke_installed_mcp.py +++ b/scripts/smoke_installed_mcp.py @@ -302,7 +302,6 @@ async def _run_measured_execution_protocol_smoke( pipeline_source = PipelineDocumentCodec.render(document) source_file = output_dir / "pipeline.py" source_file.write_text(pipeline_source, encoding="utf-8") - async def call(name: str, arguments: dict, timeout: float = 90) -> dict: return _tool_payload( await asyncio.wait_for(session.call_tool(name, arguments), timeout=timeout) @@ -331,9 +330,7 @@ async def call(name: str, arguments: dict, timeout: float = 90) -> dict: "openhcs_run_datasets", { "runtime_observation_export_path": str( - MeasuredPipelineRunArtifact.RUNTIME_OBSERVATION.path_in( - evidence_dir - ) + MeasuredPipelineRunArtifact.RUNTIME_OBSERVATION.path_in(evidence_dir) ), "runtime_observation_export_scope": "outcomes", }, @@ -343,9 +340,7 @@ async def call(name: str, arguments: dict, timeout: float = 90) -> dict: ): started = await call(tool, {"scope_ids": [scope_id], **arguments}) if started.get("status") != "accepted": - raise AssertionError( - f"Installed MCP rejected {tool}: {started} {connected}" - ) + raise AssertionError(f"Installed MCP rejected {tool}: {started} {connected}") sequence = started["event_sequence"] deadline = time.monotonic() + 180 while True: diff --git a/tests/integration/test_agent_observation_export_zmq.py b/tests/integration/test_agent_observation_export_zmq.py index 9b11333092..6ebd6388b4 100644 --- a/tests/integration/test_agent_observation_export_zmq.py +++ b/tests/integration/test_agent_observation_export_zmq.py @@ -346,7 +346,10 @@ def test_ordinary_execution_can_export_outcomes_without_value_observation( assert exported.server_environment is not None assert exported.server_environment.python_executable assert completed.observation is None - assert completed.receipt.observation_export_scope is ExportRuntimeOutcomes + assert ( + completed.receipt.observation_export_scope + is ExportRuntimeOutcomes + ) assert { BenchmarkPhase.COMPILE_OPENHCS, BenchmarkPhase.EXECUTE_OPENHCS, diff --git a/tests/integration/test_custom_source_transport.py b/tests/integration/test_custom_source_transport.py index f797c585d2..624827fdcb 100644 --- a/tests/integration/test_custom_source_transport.py +++ b/tests/integration/test_custom_source_transport.py @@ -504,8 +504,7 @@ def _compiled_custom_contexts(payload, runtime): def _spawn(path): import multiprocessing from openhcs.core.compiled_execution import ( - CompiledRuntimeEnvironmentPlan, - CompiledWorkerStartPlan, + CompiledRuntimeEnvironmentPlan, CompiledWorkerStartPlan, ) from openhcs.core.config import MultiprocessingStartMethod from openhcs.core.orchestrator.cancellation import ExecutionCancellationSignal @@ -523,25 +522,19 @@ class RichProgressContext(ProgressExecutionContext): payload = _load(path) method = MultiprocessingStartMethod.SPAWN runtime = CompiledRuntimeEnvironmentPlan( - worker_start=CompiledWorkerStartPlan( - method, method, "source fixture", False, False - ), - use_threading=False, - configured_num_workers=2, + worker_start=CompiledWorkerStartPlan(method, method, "source fixture", False, False), + use_threading=False, configured_num_workers=2, ) queue = multiprocessing.get_context(method.value).Queue() resources = WorkerExecutorFactory( - log_file_base=None, - progress_queue=queue, + log_file_base=None, progress_queue=queue, cancellation=ExecutionCancellationSignal(), ).create(runtime_environment=runtime, actual_max_workers=2) contexts = _compiled_custom_contexts(payload, runtime) try: with resources.execution_context(): tasks = tuple( - resources.executor.submit( - _spawned_compiled_custom_task, lane_context, helpers - ) + resources.executor.submit(_spawned_compiled_custom_task, lane_context, helpers) for lane_context, (_reference, helpers) in zip( contexts.values(), payload, strict=True ) @@ -555,10 +548,7 @@ class RichProgressContext(ProgressExecutionContext): assert values == [[0, 1, 2], [3, 4, 5]] assert type(row) is helpers[4] and row == helpers[5] pid, rows = resources.executor.submit( - _spawned_custom_task, - payload, - context.execution_id, - context.subject_id, + _spawned_custom_task, payload, context.execution_id, context.subject_id, ).result(timeout=15) assert pid != os.getpid() for row, (_reference, helpers) in zip(rows, payload, strict=True): diff --git a/tests/integration/test_neurite_outgrowth_synthetic_plate.py b/tests/integration/test_neurite_outgrowth_synthetic_plate.py index 6822d1f031..25e842607e 100644 --- a/tests/integration/test_neurite_outgrowth_synthetic_plate.py +++ b/tests/integration/test_neurite_outgrowth_synthetic_plate.py @@ -221,7 +221,9 @@ def observe_dense_output(output, components): progress_events = [] while True: try: - progress_events.append(progress_queue.get_nowait()) + progress_events.append( + progress_queue.get_nowait() + ) except queue.Empty: break live_payloads = tuple( diff --git a/tests/integration/test_session_journey.py b/tests/integration/test_session_journey.py index 5330775114..f99d4f83ec 100644 --- a/tests/integration/test_session_journey.py +++ b/tests/integration/test_session_journey.py @@ -172,9 +172,7 @@ def test_read_only_dataset_initializes_in_a_mirrored_workspace(tmp_path: Path) - workspace_root=workspaces, ) try: - assert session.invoke( - AddDatasets, AddDatasets.request(roots=(str(root),)) - ).accepted + assert session.invoke(AddDatasets, AddDatasets.request(roots=(str(root),))).accepted (scope_id,) = session.dataset_scope_ids() started = session.invoke( InitializeDatasets, InitializeDatasets.request(scope_ids=(scope_id,)) diff --git a/tests/unit/agent/test_mcp_dev_client_sampling_results.py b/tests/unit/agent/test_mcp_dev_client_sampling_results.py index fbc178b467..8f4d0259f1 100644 --- a/tests/unit/agent/test_mcp_dev_client_sampling_results.py +++ b/tests/unit/agent/test_mcp_dev_client_sampling_results.py @@ -88,19 +88,13 @@ def test_original_session_framing_to_generated_sampling_command( ControlledWireSession, ) import openhcs.mcp.dev_client_core as ingress - value = selected(sample()) if selected_case else sample() - argv = ( - ("selected-plate-sample",) - if selected_case - else ("sample-plate-image", "source-plate", "image.tif") - ) + argv = ("selected-plate-sample",) if selected_case else ("sample-plate-image", "source-plate", "image.tif") args = dev_client._build_parser().parse_args(argv) command = dev_client.McpDevCommandSpec.for_name(args.command) peer = ControlledWireSession((value,), structured=structured) decoded = [] original_decode = ingress.dataclass_from_mapping - def observe(contract, receipt): decoded.append(contract) return original_decode(contract, receipt) diff --git a/tests/unit/test_singleton_rgb_label_invocation_477.py b/tests/unit/test_singleton_rgb_label_invocation_477.py index 2bc6f64b0d..d1c1a4456c 100644 --- a/tests/unit/test_singleton_rgb_label_invocation_477.py +++ b/tests/unit/test_singleton_rgb_label_invocation_477.py @@ -84,21 +84,15 @@ def test_plane_label_modes_consume_declared_singleton_root_for_scalar_rgb( image_before = image.data.copy() labels_before = object_label_dense_array(labels).copy() invocation = _bind(case) - selected = invocation.kwargs["object_labels"][0] + selected = invocation.kwargs['object_labels'][0] assert invocation.execution_mode is NaturalExecution assert invocation.plane_projection is None assert invocation.payload.metadata.axis_position(ColourAxis) == 2 assert selected.plane_axis is None assert object_label_dense_array(selected).shape == (8, 9) output = _execute(case, invocation) - scalar_labels = ObjectLabelSet( - name="Nuclei", - variant_data=ObjectLabelVariantData(labels=labels_before[0]), - domain=ObjectLabelDomain( - declared_object_ids=(1,), scope=ObjectLabelDomainScope.PAYLOAD - ), - ) - expected = raw(image, object_labels=(scalar_labels,), outline_colors=("#21FFFF",)) + scalar_labels = ObjectLabelSet(name='Nuclei', variant_data=ObjectLabelVariantData(labels=labels_before[0]), domain=ObjectLabelDomain(declared_object_ids=(1,), scope=ObjectLabelDomainScope.PAYLOAD)) + expected = raw(image, object_labels=(scalar_labels,), outline_colors=('#21FFFF',)) np.testing.assert_array_equal(output.data, expected.data) np.testing.assert_array_equal(image.data, image_before) np.testing.assert_array_equal(object_label_dense_array(labels), labels_before) From 38e929a29820628e3e967d27e4cd6222d35d3e53 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:21:00 -0400 Subject: [PATCH 10/24] B6c: restore blank lines moved by the dechurn Co-Authored-By: Claude Opus 5.5 --- openhcs/authoring/session/session.py | 2 +- tests/unit/agent/test_mcp_dev_client_sampling_results.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openhcs/authoring/session/session.py b/openhcs/authoring/session/session.py index 7582a3d43a..a7fe6deef2 100644 --- a/openhcs/authoring/session/session.py +++ b/openhcs/authoring/session/session.py @@ -1513,7 +1513,6 @@ def debug_context(self, scope_id: str) -> DebugSessionProjectionContext: terminal_status=( None if (status := self.batch.terminal_status(scope_id)) is None - else status.name ), ), @@ -1534,3 +1533,4 @@ def current_debug_context(self) -> DebugSessionProjectionContext | None: if not self.current_scope_id: return None return self.debug_context(self.current_scope_id) + diff --git a/tests/unit/agent/test_mcp_dev_client_sampling_results.py b/tests/unit/agent/test_mcp_dev_client_sampling_results.py index 8f4d0259f1..7dfbc95538 100644 --- a/tests/unit/agent/test_mcp_dev_client_sampling_results.py +++ b/tests/unit/agent/test_mcp_dev_client_sampling_results.py @@ -88,6 +88,7 @@ def test_original_session_framing_to_generated_sampling_command( ControlledWireSession, ) import openhcs.mcp.dev_client_core as ingress + value = selected(sample()) if selected_case else sample() argv = ("selected-plate-sample",) if selected_case else ("sample-plate-image", "source-plate", "image.tif") args = dev_client._build_parser().parse_args(argv) @@ -98,7 +99,6 @@ def test_original_session_framing_to_generated_sampling_command( def observe(contract, receipt): decoded.append(contract) return original_decode(contract, receipt) - monkeypatch.setattr(ingress, "dataclass_from_mapping", observe) response = asyncio.run(command.run_session(peer, args)) assert decoded == [type(value)] From 0cc7df7c728d23f9eba1a3b0b2a71927f589bd4c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:30:07 -0400 Subject: [PATCH 11/24] B6c: knowledge-base test keeps main's text (its edit followed dropped docs changes) Co-Authored-By: Claude Opus 5.5 --- .../unit/agent/test_knowledge_base_service.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/unit/agent/test_knowledge_base_service.py b/tests/unit/agent/test_knowledge_base_service.py index ae307b83d9..f636461b22 100644 --- a/tests/unit/agent/test_knowledge_base_service.py +++ b/tests/unit/agent/test_knowledge_base_service.py @@ -314,19 +314,14 @@ def test_knowledge_base_retrieves_canonical_image_source_guide(): assert "CZI" in document.content assert "OME-TIFF" in document.content assert "OME-Zarr" in document.content - from openhcs.domains.microscopy.axes import Microscopy - - for axis in ( - Microscopy.Well, - Microscopy.Site, - Microscopy.Channel, - Microscopy.ZIndex, - Microscopy.Timepoint, - ): - assert f"Microscopy.{axis.__name__}" in document.content + assert "WELL" in document.content + assert "SITE" in document.content + assert "CHANNEL" in document.content + assert "Z_INDEX" in document.content + assert "TIMEPOINT" in document.content assert "Binding positions are not microscope channel values" in document.content assert "full named physical" in document.content - assert "inputs consumed by the dataset source projection" in document.content + assert "inputs consumed by the nominal handler projection" in document.content assert "Seeing all three in the source workspace inventory is valid" in ( document.content ) @@ -353,9 +348,8 @@ def test_knowledge_base_retrieves_canonical_image_source_guide(): assert "``neurite_channel_index=0``" in document.content assert "``nuclear_stain.channel_index=1``" in document.content assert "not equivalent to MAP2-seeded analysis" in document.content - assert ( - "``variable_components=[Microscopy.Channel]`` assembles the channel stack" - in (document.content) + assert "``variable_components=[CHANNEL]`` assembles the channel stack" in ( + document.content ) assert "compiler normalizes it to" in document.content assert "``Ungrouped``" in document.content From 08122294975a1e51aa69c339614810d78fe9cbfd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:38:21 -0400 Subject: [PATCH 12/24] B6c: keep the structural ratchet without moving existing code Passthrough helpers sit on the group scope and reuse the manifest's path-aligned record lookup; the four unused main-window theme methods are deleted and guarded. Co-Authored-By: Claude Opus 5.5 --- openhcs/core/steps/function_runtime.py | 88 ++++++++----------- .../processing/custom_functions/manager.py | 4 +- openhcs/pyqt_gui/main.py | 55 ------------ tests/unit/test_dead_ui_code_guards.py | 8 ++ 4 files changed, 48 insertions(+), 107 deletions(-) diff --git a/openhcs/core/steps/function_runtime.py b/openhcs/core/steps/function_runtime.py index 12a22e7b3b..3d7a22cc63 100644 --- a/openhcs/core/steps/function_runtime.py +++ b/openhcs/core/steps/function_runtime.py @@ -324,6 +324,35 @@ def selected_artifact_output_plans(self) -> ArtifactOutputPlans: self.component_key, ) + def _unwrapped_main_flow_output_context( + self, + ) -> AlignedImageSliceContext | None: + ArtifactInputPlan.require_exact_map( + self.execution_plan.artifact_inputs, + boundary="Component artifact input", + ) + return self.compiled_group.unwrapped_main_flow_output_context( + self.selected_artifact_output_plans() + ) + + def _passes_producer_outputs_through( + self, + processed_stack: ImagePayload, + output_count: int, + input_count: int, + ) -> bool: + """Whether each published slice is the producer's aligned output, passed on. + + A declared passthrough chain names no output of its own, so its slices + keep the exact producer declaration (context and filename qualifier) + of the input file they are aligned to. + """ + if processed_stack.owns_output_surfaces: + return False + if self._unwrapped_main_flow_output_context() is not None: + return False + return output_count == input_count + @dataclass(frozen=True, slots=True, kw_only=True) class PatternGroupExecutionRequest(PatternGroupExecutionScope): @@ -548,21 +577,6 @@ def _producer_output_contexts( self.context.microscope_handler.parser, ) - def _producer_filename_qualifiers( - self, - matching_files: Sequence[str], - ) -> tuple[str | None, ...]: - """Resolve the filename qualifier each producer gave the loaded paths.""" - - records = step_output_manifest(self.context).producer_output_records_for_paths( - self.execution_plan, - matching_files, - self.context.microscope_handler.parser, - ) - if records is None: - return (None,) * len(matching_files) - return tuple(record.filename_qualifier for record in records) - def load_input_stack( self, ) -> tuple[list[str], RuntimeArrayData]: @@ -1016,24 +1030,6 @@ def _apply_source_binding_payload( ) return binding.apply_loaded_payload(payload, source_context) - def _passes_producer_outputs_through( - self, - processed_stack: ImagePayload, - output_count: int, - input_count: int, - ) -> bool: - """Whether each published slice is the producer's aligned output, passed on. - - A declared passthrough chain names no output of its own, so its slices - keep the exact producer declaration (context and filename qualifier) - of the input file they are aligned to. - """ - if processed_stack.owns_output_surfaces: - return False - if self._unwrapped_main_flow_output_context() is not None: - return False - return output_count == input_count - def _project_output_slices( self, processed_stack: ImagePayload, @@ -1099,17 +1095,6 @@ def _unstack_main_output( f"{output_shape}" ) from exc - def _unwrapped_main_flow_output_context( - self, - ) -> AlignedImageSliceContext | None: - ArtifactInputPlan.require_exact_map( - self.execution_plan.artifact_inputs, - boundary="Component artifact input", - ) - return self.compiled_group.unwrapped_main_flow_output_context( - self.selected_artifact_output_plans() - ) - def _save_outputs( self, processed_stack: RuntimeArrayData, @@ -1146,12 +1131,14 @@ def _save_outputs( output_slices = tuple(payload for payload, _context in projected_outputs) num_outputs = len(output_slices) num_inputs = len(matching_files) - passed_through_qualifiers = ( - self._producer_filename_qualifiers(matching_files) + passed_through_records = ( + step_output_manifest(context).producer_output_records_for_paths( + self.execution_plan, matching_files, context.microscope_handler.parser + ) if self._passes_producer_outputs_through( processed_stack, num_outputs, num_inputs ) - else (None,) * num_outputs + else None ) if num_outputs < num_inputs: @@ -1213,11 +1200,14 @@ def _save_outputs( output_identity = output_identity.with_filename_qualifier( output_context.output_key ) - elif passed_through_qualifiers[i] is not None: + elif ( + passed_through_records is not None + and passed_through_records[i].filename_qualifier is not None + ): # A passed-through surface keeps the qualifier its producer # published it under; dropping it would merge sibling surfaces. output_identity = output_identity.with_filename_qualifier( - passed_through_qualifiers[i] + passed_through_records[i].filename_qualifier ) output_path = output_identity.path_for_request(output_path_request) output_path_text = str(output_path) diff --git a/openhcs/processing/custom_functions/manager.py b/openhcs/processing/custom_functions/manager.py index e6959f5010..67ff41bda0 100644 --- a/openhcs/processing/custom_functions/manager.py +++ b/openhcs/processing/custom_functions/manager.py @@ -662,9 +662,7 @@ def _write_temporary_source(self, code: str) -> Path: dir=self.storage_dir, ) try: - # Persist the exact bytes the revision digest was computed from; - # text-mode newline translation would change them on Windows. - with os.fdopen(file_descriptor, "wb") as stream: + with os.fdopen(file_descriptor, "wb") as stream: # the digested bytes stream.write(code.encode("utf-8")) stream.flush() os.fsync(stream.fileno()) diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index 7bee719787..ff322d3b3f 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -20,7 +20,6 @@ from PyQt6.QtWidgets import ( QApplication, QDialog, - QFileDialog, QLabel, QMainWindow, QMessageBox, @@ -1550,60 +1549,6 @@ def _close_object_state_subscriptions(self) -> None: self._object_state_unregister_subscription.release() self.time_travel_widget.close() - # ========== THEME MANAGEMENT METHODS ========== - - def switch_to_dark_theme(self): - """Switch to dark theme variant.""" - self.service_adapter.theme_manager.switch_to_dark_theme() - self.status_message.emit("Switched to dark theme") - - def switch_to_light_theme(self): - """Switch to light theme variant.""" - self.service_adapter.theme_manager.switch_to_light_theme() - self.status_message.emit("Switched to light theme") - - def load_theme_from_file(self): - """Load theme from JSON configuration file.""" - file_path, _ = QFileDialog.getOpenFileName( - self, "Load Theme Configuration", "", "JSON Files (*.json);;All Files (*)" - ) - - if file_path: - theme_loaded = self.service_adapter.theme_manager.load_theme_from_config( - file_path - ) - if theme_loaded: - self.status_message.emit(f"Loaded theme from {Path(file_path).name}") - else: - QMessageBox.warning( - self, - "Theme Load Error", - f"Failed to load theme from {Path(file_path).name}", - ) - - def save_theme_to_file(self): - """Save current theme to JSON configuration file.""" - file_path, _ = QFileDialog.getSaveFileName( - self, - "Save Theme Configuration", - "pyqt6_color_scheme.json", - "JSON Files (*.json);;All Files (*)", - ) - - if file_path: - theme_saved = self.service_adapter.theme_manager.save_current_theme( - file_path - ) - if theme_saved: - self.status_message.emit(f"Saved theme to {Path(file_path).name}") - else: - QMessageBox.warning( - self, - "Theme Save Error", - f"Failed to save theme to {Path(file_path).name}", - ) - - @singledispatchmethod def on_session_event(self, event: SessionEvent) -> None: del event diff --git a/tests/unit/test_dead_ui_code_guards.py b/tests/unit/test_dead_ui_code_guards.py index ac47088a6e..8742c049a0 100644 --- a/tests/unit/test_dead_ui_code_guards.py +++ b/tests/unit/test_dead_ui_code_guards.py @@ -56,6 +56,14 @@ "register_theme_change_callback", } ), + "OpenHCSMainWindow": frozenset( + { + "switch_to_dark_theme", + "switch_to_light_theme", + "load_theme_from_file", + "save_theme_to_file", + } + ), } MAIN_WINDOW_SERVICE_ALIASES = frozenset( { From 5c407f2629504f3e86cfa0d2777ea3b1dcba4cdc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 11 Oct 2026 23:42:18 -0400 Subject: [PATCH 13/24] B6c: passthrough qualifiers read from local records Co-Authored-By: Claude Opus 5.5 --- openhcs/core/steps/function_runtime.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openhcs/core/steps/function_runtime.py b/openhcs/core/steps/function_runtime.py index 3d7a22cc63..6c02dcbc53 100644 --- a/openhcs/core/steps/function_runtime.py +++ b/openhcs/core/steps/function_runtime.py @@ -1140,6 +1140,11 @@ def _save_outputs( ) else None ) + passed_through_qualifiers = ( + (None,) * num_outputs + if passed_through_records is None + else tuple(record.filename_qualifier for record in passed_through_records) + ) if num_outputs < num_inputs: logger.debug( @@ -1200,14 +1205,11 @@ def _save_outputs( output_identity = output_identity.with_filename_qualifier( output_context.output_key ) - elif ( - passed_through_records is not None - and passed_through_records[i].filename_qualifier is not None - ): + elif passed_through_qualifiers[i] is not None: # A passed-through surface keeps the qualifier its producer # published it under; dropping it would merge sibling surfaces. output_identity = output_identity.with_filename_qualifier( - passed_through_records[i].filename_qualifier + passed_through_qualifiers[i] ) output_path = output_identity.path_for_request(output_path_request) output_path_text = str(output_path) From b5099d733119c3d1269d390e80b0f506b29447c5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:17:17 -0400 Subject: [PATCH 14/24] B6d: main window keeps its session-event dispatcher The theme-method deletion took the @singledispatchmethod line with it, so the class body failed at the first .register. Co-Authored-By: Claude Opus 5.5 --- openhcs/pyqt_gui/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openhcs/pyqt_gui/main.py b/openhcs/pyqt_gui/main.py index 8d994f8e14..2a2c38e934 100644 --- a/openhcs/pyqt_gui/main.py +++ b/openhcs/pyqt_gui/main.py @@ -1546,6 +1546,7 @@ def _close_object_state_subscriptions(self) -> None: self._object_state_unregister_subscription.release() self.time_travel_widget.close() + @singledispatchmethod def on_session_event(self, event: SessionEvent) -> None: del event From 416acfca8c9d8cf12961972727244f7c292d95b8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:23:51 -0400 Subject: [PATCH 15/24] B6d: provenance test names K1's plane-axis class Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_aggregate_source_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_aggregate_source_scope.py b/tests/unit/test_aggregate_source_scope.py index 9024c9caab..1a245b88e1 100644 --- a/tests/unit/test_aggregate_source_scope.py +++ b/tests/unit/test_aggregate_source_scope.py @@ -156,7 +156,7 @@ def test_axis_varying_between_runtime_planes_is_kept_not_reduced(): for site in (1, 2) ) metadata = ImagePayloadMetadata( - plane_axis=RuntimePlaneAxis.SOURCE_BINDING, + plane_axis=SourceBindingPlaneAxis, source_component_metadata=common, source_image_provenance_planes=SourceImageProvenancePlanes(planes), ) From 2a9dce10732c55f6e9d89022c7f52336bd7ce36d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:57:31 -0400 Subject: [PATCH 16/24] B6d: receipt for the carried fixes; B6 and B6b receipts dropped Co-Authored-By: Claude Opus 5.5 --- .../consolidation-20261009/B6-ci-green.md | 130 ------------------ .../B6b-ci-and-smoke.md | 25 ---- .../B6d-remaining-fixes.md | 37 +++++ .../test_mcp_dev_client_sampling_results.py | 4 +- 4 files changed, 38 insertions(+), 158 deletions(-) delete mode 100644 docs/refactor/consolidation-20261009/B6-ci-green.md delete mode 100644 docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md create mode 100644 docs/refactor/consolidation-20261009/B6d-remaining-fixes.md diff --git a/docs/refactor/consolidation-20261009/B6-ci-green.md b/docs/refactor/consolidation-20261009/B6-ci-green.md deleted file mode 100644 index 301fca9327..0000000000 --- a/docs/refactor/consolidation-20261009/B6-ci-green.md +++ /dev/null @@ -1,130 +0,0 @@ -# B6: main's CI green - -**Head audited:** `openhcs` `main` at `84e7a2904` (#1193). **Rules:** [00-RULES.md](00-RULES.md). **Input:** the last completed runs on main: Integration Tests 38116770859 (`b468e7359`), Website 38110269836 and Documentation 38116770933. Integration Tests last passed on main at `fc1f94632` (2026-09-24). Documentation had not passed in the last 1,000 main runs. - -## What was wrong - -**Three workflows were red on main because of about twenty independent causes.** Most of them only CI exposes: the installed wheel with `--import-mode=importlib`, Windows, Intel macOS, xcb under Xvfb, path filters, and jobs nobody watched. The table gives each cause, its fix, and its certificate where something was deleted. - -| Job(s) | First cause | Fix | -|---|---|---| -| Website | The job installed a five-package roster, then imported `openhcs`, whose import graph needs numba. The `paths:` filter only listed `openhcs/__init__.py`. | Install the candidate through `install_ci_candidate` with the `dev` extra, because `pytest.ini`'s addopts need pytest-xdist. Trigger on `openhcs/**` and `external/**`. | -| Documentation (`validate_docs`) | 950 findings: 141 of 155 pages had stale editorial audits (828 changed and 78 deleted authorities), plus 10 false "missing path" findings. | The path regex now matches whole repository paths and covers `packaging/`. All 141 pages were re-read against the diff of each stale authority since its reviewed version, wrong claims were corrected (see the commit), and digests were pinned after review. B6's own later edits were compared and re-pinned. | -| Documentation (sphinx `-W`) | PolyStore and pyqt-reactive had agent run logs outside every toctree. zmqruntime's API page listed members removed by L3. | Deleted the logs (**contract**: Q4, validation logs nothing links to). zmqruntime's page derives its members with plain `:members:`. | -| unit-tests (collection) | 14 test modules imported a sibling test module by bare name, which fails under importlib mode. | Import through the `tests` package; guard in `test_ci_import_guards.py`. | -| unit-tests (abort, exit 134) | An in-process MCP transport created a `QCoreApplication`, so a later `QWidget` aborted. | The session creates its `QApplication` first (`tests/conftest.py`). | -| code-quality | 412 unformatted files since the last success. A test used Python 3.12-only f-string quote reuse, which is a syntax error on 3.11. | Black over this PR's changed files; quote fixed. Main's baseline becomes this PR's green head. | -| structural ratchet | Black wrapping and B6 fixes grew six classes past their budget. | Cohesive method groups moved into inherited mixins: `MainOutputSliceProjectionMixin`, `FunctionCoreDebugMixin`, `CustomFunctionSourceFilesMixin`, `SessionDebugMixin`. Catalog policy moved to module functions. Four unused main-window theme methods and `SourceImageProvenance.source_component_identity` were deleted (**unused**: no caller, menu, bridge or string reference). | -| Windows (every ZMQ job) | `tests/conftest.py` used `os.getpgid`. zmqruntime's `startup_owner` reopened the lock file that portalocker holds (mandatory locks give PermissionError). | Process-group reaping only on POSIX. zmqruntime reads and writes the owner record through the held handle (0.6.2). | -| Windows | NumPy exports uint32 as format `'L'`; custom-function sources were written in text mode, which changes their hashes; SIGINT can't be sent to a child; chmod doesn't make a directory read-only and `os.access` ignores ACLs; autocrlf rewrote checksummed `.cppipe` files; packaged knowledge paths were longer than MAX_PATH. | Native granularity accepts both formats. Sources are persisted as exact bytes. The CLI treats Ctrl+Break as Ctrl+C. Writability is decided by an actual file-creation attempt, and the test denies writes with an ACL. `.gitattributes` keeps committed bytes (`* -text`). Recipes are placed by case name within a declared path budget. | -| Intel macOS | `openhcs-basicpy` is excluded by marker, but the catalog imported its module. The kernel cache compiled serially because macOS has no `sched_getaffinity`. | Modules declare `REQUIRED_DISTRIBUTIONS`, and the catalog excludes those whose package markers leave them out. Workers come from `os.cpu_count()` where affinity is unavailable. | -| python-314 | ObjectState read a class's own annotations through `__dict__`, which 3.14 no longer populates. mahotas sat only in an extra, but core catalog modules need it. | ObjectState uses `inspect.get_annotations` (2.1.1). mahotas is a core dependency. | -| gui-tests (13) | Stale tests after U1/U5. | Rewritten against the session-backed plate manager and the current stubs. | -| backend-microscope, boundary, OMERO | STEP_INPUT filters matched virtual names instead of stored source paths. Passthrough outputs dropped their producer's filename qualifier. BaSiC read `.metadata` off plain arrays. `OMEROLocalBackend` had no `is_dir`. The metadata document was written to local disk for a virtual backend. Viewer tests used `ViewerType` members. The GUI journey test ran in headless jobs. Several integration tests had gone stale. | Fixed at each owner: `SourceImageIdentity.filter_paths`, producer qualifiers, `ImagePayload.of`, `OMEROLocalBackend.is_dir`/`is_file` (PolyStore 0.6.1), `OpenHCSMetadataTarget.hosts_metadata_document` (not for a `VirtualBackend`), viewer families, and the GUI journey moved to gui-tests. | -| Official30 real-viewer smoke | `required_scalar_components` pooled contributors across runtime planes and dropped `site`, so napari rejected the stack. | Contributors are grouped per plane (`contributor_groups`). | -| Installed demo (Windows, Intel macOS) | The one-plane label projection (#477) lived only on `MatchImageStackLabels`; the skeleton step used `SliceAlignedLabels`. | The projection is owned by `ObjectLabelInputExecutionMode` (**derived**: `MatchImageStackLabels`' copy deleted). Dataset rows carry the finished execution's error. | -| desktop live MCP | The smoke read the catalog before the GUI's endpoint was up. Requestless UI-bridge operations received the connection in the request slot. | `execution_services_prepared` future; connection passed by keyword. | -| wheel-integration | The MCP smoke sent `wells` (G2 renamed it `partition_values`). Bio-Formats tests ran without the declared Fiji bundle. | `partition_values`; the job restores and prewarms the bundle. | -| pypi-dependency-readiness | Pinned submodule sources differ from their release tags. | Version bumps: zmqruntime 0.6.2, PolyStore 0.6.1, ObjectState 2.1.1 (pycodify's change shipped in main's 0.2.0). Floors raised. **The job stays red until the owner merges and tags the three library PRs.** | - -## Guards - -- `tests/unit/test_ci_import_guards.py`: no test module imports a test-tree module by bare name. -- `tests/unit/test_ci_package_boundaries.py`: the website job installs the candidate with the dev extra. -- `tests/unit/test_build_website.py`: the website job triggers on `openhcs/**`. -- `tests/unit/test_validate_documentation_audit.py`: repository paths match only whole paths. -- `tests/unit/test_build_mcp_knowledge_assets.py`: every packaged resource fits the Windows path budget. -- `tests/unit/test_library_registry_discovery.py`: marker-excluded distributions are left out of the catalog, while a required but missing one fails. -- `tests/unit/test_dataset_access.py`; `external/zmqruntime` held-handle owner-record test; `external/PolyStore/tests/test_omero_virtual_paths.py`. - -## Done when - -Website, Documentation and Integration Tests are green on this PR's head, or each remaining red job names its exact cause and owner (see the PR's final status). - -## Lockstep library PRs (owner merges and tags) - -- OpenHCSDev/ZMQRuntime#29 (0.6.2; docs; startup lock) -- OpenHCSDev/PolyStore#38 (0.6.1; OMERO `is_dir`; docs logs) -- OpenHCSDev/ObjectState#26 (2.1.1) -- OpenHCSDev/PyQT-reactive#21 (docs logs) - -## Handoff - -**Branch `refactor/B6-ci-green`, head `abf42ae1b` plus this receipt commit, merged with main `625150118`. PR #1195. GitHub Actions stalled before any job ran on this head, so nothing after `f419cb3bb` has a completed CI run.** - -### Last completed CI runs - -- **`f419cb3bb` (merged with main `eb64b3c13`): 36 jobs passed, 7 failed.** Website, Documentation and Refactor Guardrails were green. This is the last trustworthy picture. -- **`2e0deafe6` (merged with main `44980dfe2`): 27 jobs failed.** Fixed since, unverified (see below). - -### Root causes fixed, with commits - -| Cause | Commit | -|---|---| -| Docs path regex matched inside longer paths | `c9de5fab0` | -| Website installed a package roster; needs dev extra; path filter; shared `pages` concurrency group | `6bccc4d6d`, `5544c445f`, `ce529e459` | -| Sibling test-module imports under importlib mode | `6bccc4d6d` | -| `os.getpgid` on Windows; top-level import in GUI smoke | `62752cf33` | -| 141/126/127 stale documentation pages (three rounds, after each main merge) | `e59901e76`, `b2fb1bcd2`, `297a423d5` | -| Library docs: orphaned logs, removed zmqruntime members | `6669b3401`, `f3eb57aa9` | -| zmqruntime startup-owner lock on Windows | `333d3e0ab` (library PR ZMQRuntime#29) | -| Windows uint32 format; custom-function bytes | `47724ccd4`, `d5172f398` | -| Stale GUI tests; catalog race; requestless UI-bridge call; `partition_values` | `f59da9b8f`, `03ca14cc9`, `c3ab99415`, `87e4df3ff` | -| Marker-excluded distributions in the catalog (Intel macOS basicpy) | `c1013dd51` | -| BaSiC `.metadata`; STEP_INPUT filter paths; viewer families; GUI journey job | `c00a652d4`, `35abb00d6`, `4b1dec1e2`, `2eb48fb9d`, `dbae3e0b4` | -| mahotas core dependency; measured CLI child environment | `5e519401f`, `42f51b7a7` | -| OMERO `is_dir` (PolyStore#38); metadata document for virtual backends | `54e87d5b4`, `956326e3e` | -| Passthrough filename qualifier; adapter config; watchdog; stale synthetic tests | `89269b9a8`, `0f0ad9f7c`, `0d83e1e16`, `34556a1ac` | -| ObjectState own annotations on 3.14 (ObjectState#26) | `fac0519e5`, re-based to 2.1.1 in the first main merge | -| Ratchet: mixins, dead theme methods, catalog policy functions | `239953c4c`, `ab03ba7ba` | -| Packaged recipe paths over MAX_PATH; macOS serial kernel preparation | `35833f3a5`, `ded24279e` | -| Runtime-plane axes dropped from streams (napari settle) | `7066af9f3` | -| Windows read-only datasets, Ctrl+Break, one-shot write probe | `433b26fef`, `e1a1719d3`, `c74b2bd55` | -| Wheel job Fiji bundle; `.gitattributes` exact bytes | `e82ae3398`, `fbe17e02d` | -| One-plane label projection; demo error surfaced; `ViewerType.value` | `5de70ef71`, `88e22432e` | -| Unit session QApplication before MCP transports | `f322e395b`, `a5ce88b63`, `d43533f90` | -| `PARTITION_COUNT` in observation-export test | `b14856fbd` | -| E1 boundary: export scope and UI action status | `7379deb90`, `f0498a630` | -| Unit job split into three serial groups | `542f7ffeb` | - -### Still red at the last completed run (`f419cb3bb`) - -| Job | First cause | Owner | -|---|---|---| -| pypi-dependency-readiness | Pinned ObjectState 2.1.1, PolyStore 0.6.1 and zmqruntime 0.6.2 have no release tags | owner: merge and tag ObjectState#26, PolyStore#38, ZMQRuntime#29 | -| unit-tests | Cancelled at the 45-minute limit with 63% run. `test_custom_function_events.py` alone took 9 minutes (200 `gc.collect()` calls on a large heap). Of 48 failures before the cut, 29 are in `known.fail`; 18 pass locally on the merged tree; `test_declaration_selected_discovery.py::test_examplehuman_selected_source_uses_same_declaration_owner` fails on main `44980dfe2` too and is not in `known.fail` | job time: B6 (split unverified); the test: unowned, needs an owner | -| official30-headless-parity (macOS 3.14) | ExampleHuman `cells/zernike_*` values differ from the native reference; Linux 3.12 and 3.14 pass | parity owner (Zernike numerics off Linux) | -| official30-headless-parity (Windows 3.14) | ExampleImagingFlowCytometryObjectsInGrid `ssc/zernike_phase_*` values differ | same | -| official30-parity-required | Aggregates the two above | same | -| desktop-candidate-live-mcp and desktop-installer-source-test (macos-15-intel) | Cold function-catalog preparation runs past the smoke's 300-second deadline (it passed once at about 200 s) | owner decision: performance work or the budget | - -### Unverified because CI stalled - -- The E1 boundary fixes (`7379deb90`, `f0498a630`); locally the four integration tests and the GUI journey pass. -- The three-group unit job (`542f7ffeb`). The earlier xdist attempt (`f8062d2cd`) lost a worker in `test_mcp_server.py` under xcb and was replaced. -- The Website per-ref concurrency group (`ce529e459`). -- The third documentation round and re-pins after `44980dfe2` and `625150118`; `validate_docs.py` passes locally on every target and the OpenHCS Sphinx `-W` build passed locally after round two. -- Windows-only fixes confirmed by one run each but not on this head: read-only probe (`c74b2bd55` never ran on Windows), `.gitattributes`. -- An earlier CI-only segfault in `tests/unit/pyqt_gui` teardown under xcb was not reproduced or root-caused; it has not recurred since `f322e395b`. - -### Why 27 jobs failed after merging main `44980dfe2` - -**E1 left two boundaries half-migrated on main, and every job that runs a dataset or the GUI smoke hit one of them:** - -- `DatasetRunRequest.runtime_observation_export_scope` is an enum-view field, but the measured CLI passed the family class into it, and `RunDatasets` passed the enum member to `Session.observation_export_params`, which expects the family. Every `run_datasets` call failed: four integration tests in all 17 backend, boundary and OMERO jobs, the wheel MCP smoke and the GUI journey. -- `scripts/smoke_installed_gui.py` read `UiActionInvocationStatus.ACCEPTED`, which E1 replaced with the `ActionAccepted` class: all desktop installer and live-MCP jobs. -- The unit job failed separately on the xdist worker crash described above. - -Main's own run at `625150118` had not started, so "main is red the same way" is inferred from the code being identical on main, not observed. - -### Standing problem for the owner - -**The documentation audit gate goes red on every surface merge.** Each merge changes authority files, which invalidates about 125 pages; B6 re-reviewed them three times in one day. Unless each surface re-reviews its dependent pages in its own PR, Documentation will be red again after the next merge. - -### Also found, not fixed - -- `openhcs/agent/services/config_service.py` `_default_value_repr` prints family-class defaults as `` in the generated config reference. -- `openhcs/mcp/bootstrap.py` `--surface` help says the desktop surface hides headless tools; it does not. -- `scripts/check_basicpy_source.py` expects a `jax_func` decorator the module no longer uses. -- `polystore/atomic.py` `_try_acquire_lock` retries `PermissionError` until its 30-second timeout. -- About 20 pages still say "Plate Manager" where the window title is now "Datasets". diff --git a/docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md b/docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md deleted file mode 100644 index 6c018fba93..0000000000 --- a/docs/refactor/consolidation-20261009/B6b-ci-and-smoke.md +++ /dev/null @@ -1,25 +0,0 @@ -# B6b: CI triggers, local smoke, and B6's remaining fixes - -## B6b handoff - -All work is on branch `refactor/B6b-ci-green` (merged with main `77bf7e803`; last commit is this WIP). Job 1 merged as #1212. Scratch: `scratch/b6-merged` (local only) is B6's head with formatting-only edits undone by `.claude/dechurn.py` in the agent worktree. - -**Job 2, smoke script: done, not yet in a PR.** -- `/home/ts/code/projects/openhcs/.claude/verify/smoke_local.sh ` is installed. It runs `tests/smoke_tests.txt` (in this branch; a built-in list on older branches) with 2 workers, then `scripts/smoke_installed_gui.py` from source, offscreen. -- Observed: main `a751b0eba` `smoke rc=1` (5 failed, GUI catalog timeout, 651 s); this branch `smoke rc=0` (7 passed, GUI passed, 187 s). -- After #1212 alone the GUI part still fails; it needs this branch's catalog wait (`03ca14cc9`) and `accepted_ui_action_status` (the smoke result held the status class, not JSON). Until this merges use `SMOKE_SKIP_GUI=1`. -- Verify: `bash /home/ts/code/projects/openhcs/.claude/verify/smoke_local.sh `. - -**Job 3, CI triggers: not started (design only).** -- Plan: new `pr-checks.yml` (pull_request): guards plus changed test files plus `tests/core`, and `tests/smoke_tests.txt`. `integration-tests.yml` and `docs.yml` go to `workflow_dispatch`, nightly `schedule`, tags `v*`; delete the per-job `if: github.event_name == ...` lines (they are false on `schedule`). -- Release gate: `scripts/release_readiness.py` `ReleaseWorkflowGate.successful_run` filters `--event push`; drop that filter so a dispatch or nightly run on the exact commit counts. -- nra-r1: still `--budget-seconds 160`, job `timeout-minutes: 10`. Local gate uses 600. Not changed yet. -- Doc audits: `scripts/validate_docs.py` pins each authority by sha256 of the whole file, so any edit to a hub file (`compiler.py` is cited by 24 pages) invalidates every citing page. Measured, B6's pins against main: 98 stale pages by bytes, 98 by AST-normalized digest, 86 if scoped to symbols the page names. Formatting is not the cause; scoping needs a reviewer-declared `symbols` list per authority. -- Unowned: the `code-quality` job runs `black --check` on every file changed since its last green run; it will fail the full matrix. -- B6's workflow commits belong here: `542f7ffeb`, `ce529e459`, `5544c445f`, `e82ae3398`, `6bccc4d6d` (website part). - -**Job 4, B6's remaining fixes: WIP, not a PR.** -- Done: all B6 functional commits carried, plus two new fixes (stop during compile ends `cancelled`; smoke status name). `266 passed` on the touched unit files; smoke rc=0. -- Dropped: black commits (`cf0b8eb8c`, `f2ae23c97`, `2ed28bb32`, `ae618be9d`, `7f5710ae9`, `3e089a3f2`), ratchet mixin moves (`239953c4c`, `ab03ba7ba`, `24bfafede`; the registry keeps B6's module functions), all `docs/source`, `docs/audits`, `README.md` and `validate_docs.py` changes (re-review and re-pin commits), workflows (job 3), the pyqt-reactive pin (PyQT-reactive#21 still open), `f8062d2cd`. -- Next: run `guardrails_local.sh` (the ratchet may object to classes grown without the mixins), parity under flock (execution code changed), check 3.11 syntax of touched files, replace B6's receipt text, open the PR, close #1195 with a comment. -- Verify: `bash /home/ts/code/projects/openhcs/.claude/verify/guardrails_local.sh ` and the smoke command above. diff --git a/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md b/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md new file mode 100644 index 0000000000..dd2b8b028d --- /dev/null +++ b/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md @@ -0,0 +1,37 @@ +# B6d: the fixes from B6 that main still needs + +B6 (#1195) repaired main's CI in one branch that also reformatted 412 files and re-pinned the documentation audits. #1212 took its E1 boundary fix and #1214 its trigger restructuring. This is the rest: each fix below changes behaviour. Formatting commits, the ratchet mixin moves, the documentation re-pins, the workflow edits and the pyqt-reactive pin are not carried. + +## Fixes + +Tests named here fail on main `ae6ddace5` and pass on this branch unless marked otherwise. + +| Fix | Cause | Evidence | +|---|---|---| +| Requestless UI-bridge operations pass the connection by keyword | the connection landed in the request slot | `test_capabilities.py::test_requestless_ui_bridge_capability_passes_connection_as_connection` | +| A batch stopped while compiling ends cancelled | the stop surfaced as an execution failure | `test_execution_control_stop_during_compile.py` | +| Metadata document targets follow the backend's declaration | a virtual backend (OMERO) got a local metadata document | `test_function_outputs.py::test_metadata_document_targets_follow_backend_declaration` | +| Catalog leaves out modules whose distributions the package markers exclude | Intel macOS excludes `openhcs-basicpy` but the catalog imported its module | `test_library_registry_discovery.py` (7 cases) | +| Cache preparation uses `os.cpu_count()` where there is no affinity API | macOS prepared the kernel cache serially | `test_preparation_admission.py::test_platform_without_affinity_admits_its_online_cpus` | +| Scalar components are reduced per pixel group | contributors were pooled across runtime planes, dropping `site` | `test_aggregate_source_scope.py::test_axis_varying_between_runtime_planes_is_kept_not_reduced` | +| The one-plane label projection belongs to every plane label mode | it lived only on `MatchImageStackLabels` | `test_singleton_rgb_label_invocation_477.py` (`SliceAlignedLabels` case) | +| Benchmark adapter keeps the imported config's inheritance and declared output policy | `dataclasses.replace` froze unset fields; a results path was forced | `test_openhcs_adapter.py::test_openhcs_adapter_preserves_declared_main_flow_output_policy` | +| Packaged knowledge recipes are placed by case name within a path budget | installed paths exceeded Windows MAX_PATH | `test_build_mcp_knowledge_assets.py` (does not import on main) | +| GUI smoke waits for the window's endpoint start; reports the status name | the catalog was read before the endpoint existed; the result held a class | `test_smoke_installed_gui.py` (does not import on main); `smoke_local.sh` GUI step | +| Unit session creates its `QApplication` first; sibling test modules import through `tests` | an MCP transport created a `QCoreApplication`; bare imports fail under importlib mode | `test_ci_import_guards.py` (passes on main only because the session order hides it) | +| STEP_INPUT filters match stored source paths | they matched virtual workspace names | covered by the backend-microscope CI jobs; no local test fails on main | +| Passthrough outputs keep their producer's filename qualifier | sibling surfaces merged into one file | same | +| BaSiC reads metadata through `ImagePayload.of` | `.metadata` was read off plain arrays | `test_basicpy_real_fit.py` (needs `openhcs-basicpy`) | +| Dataset rows carry the finished execution's error; demo reads `ViewerType.value` | the installed demo failed without a reason | installed demo job only | +| Measured-run silence is counted from `begin_run` | server startup time counted as execution silence | not isolated by a local test | +| `mahotas` is a core dependency | core catalog modules import it; it sat in an extra | packaging; python-314 job | +| MCP smoke sends `partition_values` | G2 renamed `wells` | wheel-integration job only | +| Windows: granularity accepts `'L'` uint32; custom-function sources are written as bytes; Ctrl+Break stops the CLI; dataset writability is probed by creating a file; `.gitattributes` keeps committed bytes; test process reaping skips process groups | each is a Windows-only difference | Windows jobs only; the POSIX paths are run by the touched tests | + +## Ratchet + +`OpenHCSMainWindow` and `PatternGroupExecutionRequest` are at their size budget. The window's four theme methods had no caller and are deleted (**unused**; guarded in `test_dead_ui_code_guards.py`). The passthrough helpers sit on `PatternGroupExecutionScope`. + +## Done when + +Touched tests pass, `smoke_local.sh` prints `smoke rc=0` with the GUI step, `guardrails_local.sh` ends `guardrails rc=0`, and the 30-workflow parity check passes. diff --git a/tests/unit/agent/test_mcp_dev_client_sampling_results.py b/tests/unit/agent/test_mcp_dev_client_sampling_results.py index d2602b568e..ee8392d7ef 100644 --- a/tests/unit/agent/test_mcp_dev_client_sampling_results.py +++ b/tests/unit/agent/test_mcp_dev_client_sampling_results.py @@ -82,9 +82,7 @@ def command_render(response, argv): @pytest.mark.parametrize("structured", [False, True]) @pytest.mark.parametrize("selected_case", [False, True]) -def test_original_session_framing_to_generated_sampling_command( - structured, selected_case, monkeypatch -): +def test_original_session_framing_to_generated_sampling_command(structured, selected_case, monkeypatch): from tests.unit.agent.test_mcp_dev_client_pipeline_results import ( ControlledWireSession, ) From afab85fc14fa334ca56fa80352a02984d5fc1e55 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 01:06:30 -0400 Subject: [PATCH 17/24] B6d: GUI smoke gives the bridge connection its transport config U4 moved the connection spec to formbridge, where transport_endpoint takes the config; the smoke still called it without one. Co-Authored-By: Claude Opus 5.5 --- scripts/smoke_installed_gui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/smoke_installed_gui.py b/scripts/smoke_installed_gui.py index f2572f4329..2171ed2c95 100644 --- a/scripts/smoke_installed_gui.py +++ b/scripts/smoke_installed_gui.py @@ -763,7 +763,9 @@ def run_installed_live_mcp_smoke( bridge_port=bridge_status.connection.require_port("Installed UI bridge smoke"), bridge_reachable=bridge_status.reachable, bridge_transport=( - bridge_status.connection.transport_endpoint().transport_mode.value + bridge_status.connection.transport_endpoint( + execution_config + ).transport_mode.value ), execution_server_log=execution_server_log, function_catalog=function_catalog, From ffc7e2d02c412ce661f3d346c0d6a513c58b9e26 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 01:16:53 -0400 Subject: [PATCH 18/24] B6d: receipt states only what was observed Co-Authored-By: Claude Opus 5.5 --- .../consolidation-20261009/B6d-remaining-fixes.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md b/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md index dd2b8b028d..dc7ddfa8d0 100644 --- a/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md +++ b/docs/refactor/consolidation-20261009/B6d-remaining-fixes.md @@ -17,11 +17,11 @@ Tests named here fail on main `ae6ddace5` and pass on this branch unless marked | The one-plane label projection belongs to every plane label mode | it lived only on `MatchImageStackLabels` | `test_singleton_rgb_label_invocation_477.py` (`SliceAlignedLabels` case) | | Benchmark adapter keeps the imported config's inheritance and declared output policy | `dataclasses.replace` froze unset fields; a results path was forced | `test_openhcs_adapter.py::test_openhcs_adapter_preserves_declared_main_flow_output_policy` | | Packaged knowledge recipes are placed by case name within a path budget | installed paths exceeded Windows MAX_PATH | `test_build_mcp_knowledge_assets.py` (does not import on main) | -| GUI smoke waits for the window's endpoint start; reports the status name | the catalog was read before the endpoint existed; the result held a class | `test_smoke_installed_gui.py` (does not import on main); `smoke_local.sh` GUI step | -| Unit session creates its `QApplication` first; sibling test modules import through `tests` | an MCP transport created a `QCoreApplication`; bare imports fail under importlib mode | `test_ci_import_guards.py` (passes on main only because the session order hides it) | -| STEP_INPUT filters match stored source paths | they matched virtual workspace names | covered by the backend-microscope CI jobs; no local test fails on main | +| GUI smoke waits for the window's endpoint start, reports the status name, and gives the bridge connection its transport config | the catalog was read before the endpoint existed; the result held a class; U4 moved the connection spec to formbridge, where `transport_endpoint` takes the config | `test_smoke_installed_gui.py` (does not import on main); `smoke_local.sh` GUI step | +| Unit session creates its `QApplication` first; sibling test modules import through `tests` | an MCP transport created a `QCoreApplication`; bare imports fail under importlib mode | `test_ci_import_guards.py` guards the imports; it passes on main once the test files are fixed, so no production test fails on main | +| STEP_INPUT filters match stored source paths | they matched virtual workspace names | no touched local test fails on main; B6 found it in the backend-microscope CI jobs | | Passthrough outputs keep their producer's filename qualifier | sibling surfaces merged into one file | same | -| BaSiC reads metadata through `ImagePayload.of` | `.metadata` was read off plain arrays | `test_basicpy_real_fit.py` (needs `openhcs-basicpy`) | +| BaSiC reads metadata through `ImagePayload.of` | `.metadata` was read off plain arrays | not shown failing on main locally; B6 found it in CI | | Dataset rows carry the finished execution's error; demo reads `ViewerType.value` | the installed demo failed without a reason | installed demo job only | | Measured-run silence is counted from `begin_run` | server startup time counted as execution silence | not isolated by a local test | | `mahotas` is a core dependency | core catalog modules import it; it sat in an extra | packaging; python-314 job | From 2e5ab83a26ce6b6f4f00b6003e27f48c9e11432f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 03:39:36 -0400 Subject: [PATCH 19/24] WIP: R1c debug workflow runs only the Intel cold preparation [r1 cold-intel] Co-Authored-By: Claude Opus 5.5 --- .github/workflows/r1-debug.yml | 185 ++------------------------------- 1 file changed, 10 insertions(+), 175 deletions(-) diff --git a/.github/workflows/r1-debug.yml b/.github/workflows/r1-debug.yml index a5f8b22da0..4b1e480b4d 100644 --- a/.github/workflows/r1-debug.yml +++ b/.github/workflows/r1-debug.yml @@ -8,19 +8,8 @@ name: R1 debug on: - workflow_dispatch: - inputs: - target: - description: parity-macos | parity-windows | cold-intel - required: true - type: choice - options: [parity-macos, parity-windows, cold-intel] - case: - description: Official30 case names (space separated); empty uses the failing case for the runner - required: false - default: "" push: - branches: ["refactor/R1-*"] + branches: ["refactor/R1c-cold-start"] permissions: contents: read @@ -30,163 +19,9 @@ concurrency: cancel-in-progress: true jobs: - parity-macos: - name: parity ExampleHuman (macos-latest) - if: >- - github.event.inputs.target == 'parity-macos' || - contains(github.event.head_commit.message, '[r1 parity-macos]') - runs-on: macos-latest - timeout-minutes: 60 - defaults: - run: - shell: bash - env: - OPENHCS_CP_NATIVE_REFERENCE_ROOT: ${{ github.workspace }}/benchmark/native_refs/official30_scoped_rows - OPENHCS_REFERENCE_EXPORT_PIPELINES_ROOT: ${{ github.workspace }}/benchmark/reference_exports/official30_value_completion_20260914 - OPENHCS_CPU_ONLY: true - QT_QPA_PLATFORM: offscreen - MPLBACKEND: Agg - NUMBA_DISABLE_INTEL_SVML: ${{ 'macos-latest' == 'macos-latest' && '1' || '0' }} - NPY_DISABLE_CPU_FEATURES: AVX512_SKX,X86_V4 - R1_CASES: ${{ github.event.inputs.case || 'ExampleHuman' }} - steps: - - name: Enable Windows long paths - if: runner.os == 'Windows' - shell: pwsh - run: git config --global core.longpaths true - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: recursive - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Build and install the candidate wheels - run: | - python -m pip install --upgrade pip build - python -m scripts.install_ci_candidate \ - --dependency-source submodules \ - --wheel-directory "$RUNNER_TEMP/openhcs-candidate-wheels" \ - --extras dev,cellprofiler-compat - - - name: Materialize inputs - env: - OPENHCS_BENCHMARK_AUTO_ACQUIRE: "1" - CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples - OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets - run: | - python -c "from benchmark.cellprofiler_comparison import load_comparison_cases; print(len(load_comparison_cases('benchmark/manifests/official30_portable_axis1.json')))" - - - name: Run the case and print value differences - env: - OPENHCS_BENCHMARK_AUTO_ACQUIRE: "0" - CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples - OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets - PYTHONPATH: ${{ github.workspace }}/.github/r1-debug - R1_DEBUG_DUMP_DIR: ${{ runner.temp }}/r1/circles - OPENHCS_ZERNIKE_INTENSITY_DEBUG_TRACE_DIR: ${{ runner.temp }}/r1/intensity-traces - run: | - mkdir -p "$RUNNER_TEMP/r1" - cd "$RUNNER_TEMP/r1" - python "$GITHUB_WORKSPACE/.github/r1-debug/run_case.py" "$GITHUB_WORKSPACE" "$RUNNER_TEMP/r1/out" $R1_CASES 2>&1 | tee "$RUNNER_TEMP/r1/report.txt" - - - name: Upload measurement tables and dumps - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: r1-parity-macos - path: | - ${{ runner.temp }}/r1/report.txt - ${{ runner.temp }}/r1/circles - ${{ runner.temp }}/r1/intensity-traces - ${{ runner.temp }}/r1/out/**/*.csv - ${{ runner.temp }}/r1/out/**/*.jsonl - if-no-files-found: warn - retention-days: 7 - - parity-windows: - name: parity ExampleImagingFlowCytometryObjectsInGrid (windows-latest) - if: >- - github.event.inputs.target == 'parity-windows' || - contains(github.event.head_commit.message, '[r1 parity-windows]') - runs-on: windows-latest - timeout-minutes: 60 - defaults: - run: - shell: bash - env: - OPENHCS_CP_NATIVE_REFERENCE_ROOT: ${{ github.workspace }}/benchmark/native_refs/official30_scoped_rows - OPENHCS_REFERENCE_EXPORT_PIPELINES_ROOT: ${{ github.workspace }}/benchmark/reference_exports/official30_value_completion_20260914 - OPENHCS_CPU_ONLY: true - QT_QPA_PLATFORM: offscreen - MPLBACKEND: Agg - NUMBA_DISABLE_INTEL_SVML: ${{ 'windows-latest' == 'macos-latest' && '1' || '0' }} - NPY_DISABLE_CPU_FEATURES: AVX512_SKX,X86_V4 - R1_CASES: ${{ github.event.inputs.case || 'ExampleImagingFlowCytometryObjectsInGrid' }} - steps: - - name: Enable Windows long paths - if: runner.os == 'Windows' - shell: pwsh - run: git config --global core.longpaths true - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: recursive - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Build and install the candidate wheels - run: | - python -m pip install --upgrade pip build - python -m scripts.install_ci_candidate \ - --dependency-source submodules \ - --wheel-directory "$RUNNER_TEMP/openhcs-candidate-wheels" \ - --extras dev,cellprofiler-compat - - - name: Materialize inputs - env: - OPENHCS_BENCHMARK_AUTO_ACQUIRE: "1" - CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples - OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets - run: | - python -c "from benchmark.cellprofiler_comparison import load_comparison_cases; print(len(load_comparison_cases('benchmark/manifests/official30_portable_axis1.json')))" - - - name: Run the case and print value differences - env: - OPENHCS_BENCHMARK_AUTO_ACQUIRE: "0" - CELLPROFILER_EXAMPLES_ROOT: ${{ runner.temp }}/openhcs-official30/cellprofiler-examples - OPENHCS_BENCHMARK_DATASET_CACHE_ROOT: ${{ runner.temp }}/openhcs-official30/datasets - PYTHONPATH: ${{ github.workspace }}/.github/r1-debug - R1_DEBUG_DUMP_DIR: ${{ runner.temp }}/r1/circles - OPENHCS_ZERNIKE_INTENSITY_DEBUG_TRACE_DIR: ${{ runner.temp }}/r1/intensity-traces - run: | - mkdir -p "$RUNNER_TEMP/r1" - cd "$RUNNER_TEMP/r1" - python "$GITHUB_WORKSPACE/.github/r1-debug/run_case.py" "$GITHUB_WORKSPACE" "$RUNNER_TEMP/r1/out" $R1_CASES 2>&1 | tee "$RUNNER_TEMP/r1/report.txt" - - - name: Upload measurement tables and dumps - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: r1-parity-windows - path: | - ${{ runner.temp }}/r1/report.txt - ${{ runner.temp }}/r1/circles - ${{ runner.temp }}/r1/intensity-traces - ${{ runner.temp }}/r1/out/**/*.csv - ${{ runner.temp }}/r1/out/**/*.jsonl - if-no-files-found: warn - retention-days: 7 - cold-intel: name: cold catalog preparation (macos-15-intel) - if: >- - github.event.inputs.target == 'cold-intel' || - contains(github.event.head_commit.message, '[r1 cold-intel]') + if: contains(github.event.head_commit.message, '[r1 cold-intel]') runs-on: macos-15-intel timeout-minutes: 45 defaults: @@ -219,24 +54,24 @@ jobs: R1_NUMBA_EVENTS=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/a-cold-serial.jsonl" - - name: B. Cold preparation with a Numba cache directory (kernels compile in forked workers) - run: | - cd "$RUNNER_TEMP/r1" - NUMBA_CACHE_DIR="$RUNNER_TEMP/r1/b-numba" HOME="$RUNNER_TEMP/r1/b-home" XDG_CACHE_HOME="$RUNNER_TEMP/r1/b-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/b-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/b-data" \ - python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/b-cold-workers.jsonl" - - name: C. Second start on warm caches run: | cd "$RUNNER_TEMP/r1" - NUMBA_CACHE_DIR="$RUNNER_TEMP/r1/b-numba" HOME="$RUNNER_TEMP/r1/b-home" XDG_CACHE_HOME="$RUNNER_TEMP/r1/b-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/b-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/b-data" \ + XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/c-warm.jsonl" - name: D. Profile of catalog discovery (cold function catalog, warm kernels) run: | cd "$RUNNER_TEMP/r1" - R1_PROFILE=1 NUMBA_CACHE_DIR="$RUNNER_TEMP/r1/b-numba" HOME="$RUNNER_TEMP/r1/d-home" XDG_CACHE_HOME="$RUNNER_TEMP/r1/d-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/d-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/d-data" \ + R1_PROFILE=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/d-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/d-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/d-data" \ python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/d-profile.jsonl" | head -170 + - name: E. Import time of the registry service + run: | + cd "$RUNNER_TEMP/r1" + python -I -X importtime -c "from openhcs.processing.backends.lib_registry.registry_service import RegistryService" 2> importtime.txt + sort -t'|' -k2 -n -r importtime.txt | head -40 + - name: Upload timelines if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 7987218912850b5117bab7b6871a88ed6b37a279 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 03:43:45 -0400 Subject: [PATCH 20/24] WIP: R1c serving readiness discovers the catalog; kernels compile on first use Co-Authored-By: Claude Opus 5.5 --- .../backends/lib_registry/openhcs_registry.py | 7 +++- .../backends/lib_registry/registry_service.py | 35 +++++-------------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/openhcs/processing/backends/lib_registry/openhcs_registry.py b/openhcs/processing/backends/lib_registry/openhcs_registry.py index 74317254d9..0fb08c107f 100644 --- a/openhcs/processing/backends/lib_registry/openhcs_registry.py +++ b/openhcs/processing/backends/lib_registry/openhcs_registry.py @@ -324,8 +324,13 @@ def _module_declares_allowed_memory_type( _REQUIRED_DISTRIBUTIONS_NAME = "REQUIRED_DISTRIBUTIONS" +@lru_cache(maxsize=1) def _openhcs_requirements() -> tuple[Requirement, ...]: - """Return the installed OpenHCS distribution's declared requirements.""" + """Return the installed OpenHCS distribution's declared requirements. + + Installed metadata is fixed for the life of the process; parsing it once + serves every module the catalog asks about. + """ return tuple( Requirement(value) for value in importlib.metadata.requires("openhcs") or () diff --git a/openhcs/processing/backends/lib_registry/registry_service.py b/openhcs/processing/backends/lib_registry/registry_service.py index 07fa80fb8a..893628af23 100644 --- a/openhcs/processing/backends/lib_registry/registry_service.py +++ b/openhcs/processing/backends/lib_registry/registry_service.py @@ -8,7 +8,6 @@ import gc import inspect import logging -import os import subprocess import sys import tempfile @@ -24,12 +23,7 @@ from zmqruntime import OperationCancellation from zmqruntime.client import endpoint_process -from openhcs.core.callable_contract import CallableProjection from openhcs.core.function_reference import ResolvedRegistryFunction -from openhcs.core.processing_preparation import ( - CallablePreparation, - PreparationCacheBatch, -) from openhcs.runtime.import_authority import OpenHCSRuntimeImportAuthority from openhcs.utils.environment import OpenHCSProcessEnvironment @@ -179,7 +173,14 @@ def cached_metadata_snapshot(cls) -> Dict[str, FunctionMetadata]: def prepare_in_current_process( cls, *, status_callback: RegistryPreparationCallback | None = None ) -> Dict[str, FunctionMetadata]: - """Prepare registered declarations before publishing execution readiness.""" + """Discover registered declarations before publishing execution readiness. + + Readiness is the catalog: what callables exist and how they are called. + A callable's kernels are compiled when a pipeline first uses it + (``CallableContract.from_prepared_callable``), and Numba's persistent + cache carries them to later processes. Compiling every kernel here cost + minutes on a first start for callables the session might never run. + """ emit_status = status_callback or logger.debug emit_status("Discovering registered callables") @@ -187,32 +188,12 @@ def prepare_in_current_process( cls._metadata_cache = cls._metadata_from_instances( cls._available_registry_instances() ) - callables = tuple(dict.fromkeys( - target - for metadata in cls._metadata_cache.values() - for target in CallableProjection.from_callable(metadata.func).prepare_targets() - )) - emit_status("Preparing registered kernel caches") - PreparationCacheBatch.from_callables(callables).populate_child_caches( - max_workers=os.cpu_count() or 1, - status_callback=emit_status, - ) - for func in callables: - preparation = CallablePreparation.from_callable(func) - preparation.prepare() - emit_status( - f"Prepared callable {preparation.projection.module_name}.{preparation.projection.name}" - ) - emit_status("Resolving prepared callable signatures") - for func in callables: - CallableProjection.from_callable(func).warm_canonical_signature() emit_status("Preparing registered configuration source declarations") SignatureAnalyzer.prepare_dataclass_declarations( declaration for pair in LazyDataclassFactory.registered_type_pairs() for declaration in pair ) - emit_status(f"Registered kernels ready ({len(callables)} callables)") emit_status(f"Function catalog ready ({len(cls._metadata_cache)} callables)") return cls._metadata_cache From 5bdc22efa052497cfb99d773ce7a1a34f3904030 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 03:50:59 -0400 Subject: [PATCH 21/24] WIP: R1c tests state first-use preparation; guard counts readiness work Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_callable_abi_preparation.py | 24 +++--- tests/unit/test_catalog_readiness_work.py | 75 +++++++++++++++++++ ...st_execution_server_startup_preparation.py | 42 +++-------- tests/unit/test_preparation_admission.py | 63 +++++----------- tests/unit/test_processing_preparation.py | 70 ++--------------- ...gistered_configuration_source_readiness.py | 18 ++--- 6 files changed, 125 insertions(+), 167 deletions(-) create mode 100644 tests/unit/test_catalog_readiness_work.py diff --git a/tests/unit/test_callable_abi_preparation.py b/tests/unit/test_callable_abi_preparation.py index 32b05ae80b..f8caa2a6ee 100644 --- a/tests/unit/test_callable_abi_preparation.py +++ b/tests/unit/test_callable_abi_preparation.py @@ -114,7 +114,7 @@ def fake_preparation(monkeypatch): return prepare -def test_registry_resolves_signatures_after_all_hooks_and_before_ready(monkeypatch): +def test_first_use_resolves_signatures_after_every_target_hook(monkeypatch): events = [] def first(image: np.ndarray): @@ -124,9 +124,6 @@ def last(image: np.ndarray): return image first.__dict__[FunctionContractAttribute.raw_processing_function] = last - metadata = {"first": SimpleNamespace(func=first), "alias": SimpleNamespace(func=first)} - monkeypatch.setattr(RegistryService, "_metadata_cache", metadata) - monkeypatch.setattr(PreparationCacheBatch, "populate_child_caches", lambda *a, **k: None) def prepare(owner): events.append(("prepare", owner.projection.func)) @@ -141,28 +138,25 @@ def hints(func, **kwargs): monkeypatch.setattr(CallablePreparation, "prepare", prepare) monkeypatch.setattr(contract_module, "get_type_hints", hints) - assert RegistryService.prepare_in_current_process(status_callback=events.append) is metadata - preparations = [i for i, item in enumerate(events) if isinstance(item, tuple) and item[0] == "prepare"] - signatures = [i for i, item in enumerate(events) if isinstance(item, tuple) and item[0] == "signature"] + contract = CallableContract.from_prepared_callable(first) + preparations = [i for i, item in enumerate(events) if item[0] == "prepare"] + signatures = [i for i, item in enumerate(events) if item[0] == "signature"] assert len(preparations) == 2 assert max(preparations) < min(signatures) - assert max(signatures) < next(i for i, item in enumerate(events) if isinstance(item, str) and "kernels ready" in item) - assert CallableContract.from_callable(first).canonical_parameter_annotations["image"] is ArrayPayload - assert CallableContract.from_callable(last).canonical_parameter_annotations["image"] is ArrayPayload + assert contract.canonical_parameter_annotations["image"] is ArrayPayload -def test_explicit_library_rewarm_refreshes_new_contract_not_existing_snapshot(monkeypatch, fake_preparation): +def test_catalog_readiness_never_freezes_a_callable_signature(monkeypatch): def raw(image: np.ndarray): return image metadata = {"raw": SimpleNamespace(func=raw)} monkeypatch.setattr(RegistryService, "_metadata_cache", metadata) RegistryService.prepare_in_current_process() - first = CallableContract.from_callable(raw) + assert FunctionContractAttribute.canonical_signature not in vars(raw) + first = CallableContract.from_prepared_callable(raw) raw.__annotations__["image"] = ArrayPayload - assert CallableContract.from_callable(raw).canonical_parameter_annotations["image"] is np.ndarray - RegistryService.prepare_in_current_process() - second = CallableContract.from_callable(raw) + second = CallableContract.from_prepared_callable(raw) assert first.canonical_parameter_annotations["image"] is np.ndarray assert second.canonical_parameter_annotations["image"] is ArrayPayload diff --git a/tests/unit/test_catalog_readiness_work.py b/tests/unit/test_catalog_readiness_work.py new file mode 100644 index 0000000000..54c122a4d8 --- /dev/null +++ b/tests/unit/test_catalog_readiness_work.py @@ -0,0 +1,75 @@ +"""Catalog readiness does no per-callable work; first use does one callable's.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap + +_PROBE = textwrap.dedent( + """ + import json + + from numba.core import event + + listener = event.RecordingListener() + event.register("numba:compile", listener) + + from openhcs.core.callable_contract import CallableContract, CallableProjection + from openhcs.core.processing_preparation import PreparationOperation + from openhcs.processing.backends.lib_registry.registry_service import RegistryService + + + def compiled_modules(): + return sorted({ + item.data["dispatcher"].py_func.__module__ + for _, item in listener.buffer + if item.data["dispatcher"].py_func.__module__.startswith("openhcs.") + }) + + + metadata = RegistryService.prepare_in_current_process() + report = { + "callables": len(metadata), + "ready_compilations": len(listener.buffer), + "ready_operations": len(PreparationOperation._completed), + } + used = next( + item.func for item in metadata.values() + if item.func.__name__ == "create_composite" + and item.func.__module__.endswith("numpy_processor") + ) + CallableContract.from_prepared_callable(used) + report["use_targets"] = len(CallableProjection.from_callable(used).prepare_targets()) + report["use_operations"] = len(PreparationOperation._completed) + report["use_compiled_modules"] = compiled_modules() + print("REPORT " + json.dumps(report)) + """ +) + + +def test_readiness_compiles_nothing_and_first_use_prepares_one_callable(tmp_path): + """Count the work, not the seconds: 665 kernel compilations once ran here.""" + completed = subprocess.run( + [sys.executable, "-c", _PROBE], + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + ) + assert completed.returncode == 0, completed.stderr[-4000:] + report = json.loads( + next( + line for line in completed.stdout.splitlines() if line.startswith("REPORT ") + )[len("REPORT "):] + ) + + assert report["callables"] > 0 + assert report["ready_compilations"] == 0 + assert report["ready_operations"] == 0 + # Operations are shared per module: never more than the callable's targets. + assert 0 < report["use_operations"] <= report["use_targets"] + assert set(report["use_compiled_modules"]) <= { + "openhcs.processing.backends.processors.numpy_processor" + } diff --git a/tests/unit/test_execution_server_startup_preparation.py b/tests/unit/test_execution_server_startup_preparation.py index 6427801f6e..dc6eb19a03 100644 --- a/tests/unit/test_execution_server_startup_preparation.py +++ b/tests/unit/test_execution_server_startup_preparation.py @@ -12,7 +12,7 @@ from openhcs.agent.dto.functions import FunctionCatalogPreparationOutcome from openhcs.agent.services.function_catalog_service import FunctionCatalogService from openhcs.core.function_contract_metadata import FunctionContractAttribute -from openhcs.core.processing_preparation import PreparationCacheBatch, PreparationOperation +from openhcs.core.processing_preparation import PreparationOperation from openhcs.processing.backends.lib_registry.registry_service import RegistryService from openhcs.runtime.function_catalog_preparation import FunctionCatalogPreparation from openhcs.runtime.zmq_execution_server import ZMQExecutionServer @@ -141,51 +141,31 @@ def warm(*, status_callback): assert observed[-1] == ("Function catalog ready", True) -@pytest.mark.parametrize("fails", [False, True]) -def test_real_registry_readiness_waits_for_unselected_parent_hooks(monkeypatch, fails): +def test_real_registry_readiness_binds_without_running_an_unused_callable_hook(monkeypatch): events = [] module = ModuleType("_openhcs_startup_readiness_test") monkeypatch.setitem(sys.modules, module.__name__, module) - def unselected(image): + def unused(image): return image - unselected.__module__ = module.__name__ + unused.__module__ = module.__name__ server = ZMQExecutionServer() server._function_catalog = PreparedCatalog(events) preparation = FunctionCatalogPreparation(server._function_catalog) server._function_catalog_preparation = preparation - - def parent_hook(): - assert threading.current_thread() is threading.main_thread() - assert not preparation._future.done() - events.append("unselected parent hook") - if fails: - raise RuntimeError("unselected kernel failed") - - unselected.__dict__[FunctionContractAttribute.processing_prepare] = parent_hook - monkeypatch.setattr( - RegistryService, "_metadata_cache", {"unselected": SimpleNamespace(func=unselected)} + unused.__dict__[FunctionContractAttribute.processing_prepare] = ( + lambda: events.append("unused callable hook") ) monkeypatch.setattr( - PreparationCacheBatch, "populate_child_caches", - lambda self, **kwargs: events.append("cache work"), + RegistryService, "_metadata_cache", {"unused": SimpleNamespace(func=unused)} ) monkeypatch.setattr(ExecutionServer, "start", lambda self: events.append("bind")) PreparationOperation.reset() try: - if fails: - with pytest.raises(RuntimeError, match="unselected kernel failed"): - server.start() - assert events == ["cache work", "unselected parent hook"] - state = preparation.start(ExecutionConnectionSpec(port=22319)) - assert state.outcome is FunctionCatalogPreparationOutcome.FAILED - else: - server.start() - server.prepare_runtime_capabilities() - assert events == [ - "cache work", "unselected parent hook", "catalog", "catalog", "bind" - ] - assert preparation._future.done() and preparation._future.exception() is None + server.start() + server.prepare_runtime_capabilities() + assert events == ["catalog", "catalog", "bind"] + assert preparation._future.done() and preparation._future.exception() is None finally: PreparationOperation.reset() diff --git a/tests/unit/test_preparation_admission.py b/tests/unit/test_preparation_admission.py index 6f1049f93b..36548b1979 100644 --- a/tests/unit/test_preparation_admission.py +++ b/tests/unit/test_preparation_admission.py @@ -161,42 +161,29 @@ def test_invalid_budget_cannot_discover_or_launch(monkeypatch, budget): PreparationCacheBatch(()).populate_child_caches(max_workers=budget) -@pytest.mark.parametrize("fails", [False, True]) -def test_startup_prepares_entire_catalog_and_dynamic_compilation_remains_guarded( + + +def test_startup_prepares_no_callable_and_first_use_prepares_only_that_callable( monkeypatch, - fails, ): events = [] module = ModuleType("_admitted_preparation_source") monkeypatch.setitem(sys.modules, module.__name__, module) - def selected(image): - return image - - def unselected(image): + def used(image): return image - def dynamic(image): + def unused(image): return image - for function in (selected, unselected, dynamic): + for function in (used, unused): function.__module__ = module.__name__ - - def prepare_selected(): - events.append("selected") - if fails: - raise RuntimeError("selected preparation failed") - - selected.__dict__[FunctionContractAttribute.processing_prepare] = prepare_selected - unselected.__dict__[FunctionContractAttribute.processing_prepare] = ( - lambda: events.append("unselected") - ) - dynamic.__dict__[FunctionContractAttribute.processing_prepare] = ( - lambda: events.append("dynamic") - ) + function.__dict__[FunctionContractAttribute.processing_prepare] = ( + lambda name=function.__name__: events.append(name) + ) metadata = { - "selected": SimpleNamespace(func=selected), - "unselected": SimpleNamespace(func=unselected), + "used": SimpleNamespace(func=used), + "unused": SimpleNamespace(func=unused), } monkeypatch.setattr(RegistryService, "_metadata_cache", None) monkeypatch.setattr( @@ -212,34 +199,20 @@ def prepare_selected(): "module_registry_families", staticmethod(lambda module: ()), ) - monkeypatch.setattr( - PreparationCacheBatch, "populate_child_caches", lambda self, **kwargs: None - ) - if fails: - for _ in range(2): - with pytest.raises(RuntimeError, match="selected preparation failed"): - RegistryService.prepare_in_current_process() - assert events == ["selected", "selected"] - events.clear() - fails = False assert RegistryService.prepare_in_current_process() is metadata assert RegistryService.prepare_in_current_process() is metadata - assert events == ["selected", "unselected"] + assert events == [] invocation = CompiledFunctionInvocation( - key=FunctionInvocationKey("selected", "default", 0), - contract=CallableContract.from_callable(selected), - ) - dynamic_invocation = CompiledFunctionInvocation( - key=FunctionInvocationKey("dynamic", "default", 1), - contract=CallableContract.from_prepared_callable(dynamic), + key=FunctionInvocationKey("used", "default", 0), + contract=CallableContract.from_prepared_callable(used), ) - assert events == ["selected", "unselected", "dynamic"] - assert dynamic_invocation.contract.prepared_canonical_signature is not None + assert events == ["used"] + assert invocation.contract.prepared_canonical_signature is not None for _ in range(2): invocation.contract.resolve_runtime_callable() - dynamic_invocation.contract.resolve_runtime_callable() - assert events == ["selected", "unselected", "dynamic"] + CallableContract.from_prepared_callable(used) + assert events == ["used"] def test_platform_without_affinity_admits_its_online_cpus(monkeypatch): diff --git a/tests/unit/test_processing_preparation.py b/tests/unit/test_processing_preparation.py index 6ac2fbc071..7b9aba70db 100644 --- a/tests/unit/test_processing_preparation.py +++ b/tests/unit/test_processing_preparation.py @@ -222,47 +222,9 @@ def test_new_cache_operation_runs_in_children_and_still_prepares_parent( ) -def test_registry_startup_prepares_parent_after_cache_work_with_cached_metadata( +def test_first_use_prepares_a_declared_raw_owner_in_another_module( monkeypatch, declared_module ): - from types import SimpleNamespace - - from openhcs.processing.backends.lib_registry.registry_service import ( - RegistryService, - ) - - process = declare_process(declared_module) - events = [] - process.__dict__[FunctionContractAttribute.processing_prepare] = ( - lambda: events.append("hook") - ) - metadata = { - "first": SimpleNamespace(func=process), - "alias": SimpleNamespace(func=process), - } - monkeypatch.setattr(RegistryService, "_metadata_cache", metadata) - monkeypatch.setattr( - PreparationCacheBatch, - "populate_child_caches", - lambda batch, *, max_workers, status_callback: events.append( - tuple(item.module_name for item in batch.preparations) - ), - ) - - assert RegistryService.prepare_in_current_process() is metadata - assert RegistryService.prepare_in_current_process() is metadata - assert events == [(declared_module.__name__,), "hook", (declared_module.__name__,)] - - -def test_registry_startup_includes_declared_raw_owner_in_another_module( - monkeypatch, declared_module -): - from types import SimpleNamespace - - from openhcs.processing.backends.lib_registry.registry_service import ( - RegistryService, - ) - raw_module = ModuleType("_openhcs_raw_preparation_test") monkeypatch.setitem(sys.modules, raw_module.__name__, raw_module) wrapper = declare_process(declared_module) @@ -275,31 +237,13 @@ def test_registry_startup_includes_declared_raw_owner_in_another_module( raw.__dict__[FunctionContractAttribute.processing_prepare] = lambda: events.append( "raw" ) - monkeypatch.setattr( - RegistryService, "_metadata_cache", {"wrapper": SimpleNamespace(func=wrapper)} - ) - monkeypatch.setattr( - PreparationCacheBatch, - "populate_child_caches", - lambda self, **kwargs: events.append( - tuple(item.module_name for item in self.preparations) - ), - ) - RegistryService.prepare_in_current_process() - RegistryService.prepare_in_current_process() - compiler_target = CallableContract.from_callable( - wrapper - ).resolve_canonical_raw_callable() - assert compiler_target is raw - prepare_processing_callable(compiler_target) - - assert events == [ - (declared_module.__name__, raw_module.__name__), - "wrapper", - "raw", - (declared_module.__name__, raw_module.__name__), - ] + contract = CallableContract.from_prepared_callable(wrapper) + assert contract.resolve_canonical_raw_callable() is raw + CallableContract.from_prepared_callable(wrapper) + prepare_processing_callable(raw) + + assert sorted(events) == ["raw", "wrapper"] @pytest.mark.skipif( diff --git a/tests/unit/test_registered_configuration_source_readiness.py b/tests/unit/test_registered_configuration_source_readiness.py index 9580f922c4..c69cba99a2 100644 --- a/tests/unit/test_registered_configuration_source_readiness.py +++ b/tests/unit/test_registered_configuration_source_readiness.py @@ -1,4 +1,4 @@ -"""Configuration source preparation follows hooks and precedes registry readiness.""" +"""Configuration source preparation precedes registry readiness.""" from dataclasses import dataclass, field from types import SimpleNamespace @@ -6,7 +6,6 @@ import objectstate.lazy_factory as lazy_factory from python_introspect import SignatureAnalyzer -from openhcs.core.processing_preparation import CallablePreparation, PreparationCacheBatch from openhcs.processing.backends.lib_registry.registry_service import RegistryService @@ -41,35 +40,28 @@ def raw(image): monkeypatch.setattr(RegistryService, "_metadata_cache", metadata) monkeypatch.setattr(lazy_factory, "_lazy_type_registry", {}) monkeypatch.setattr(lazy_factory, "_base_to_lazy_registry", {}) - monkeypatch.setattr(PreparationCacheBatch, "populate_child_caches", lambda *a, **k: None) - pairs = iter(((LazyConfig, PublicConfig), (LaterLazyConfig, LaterPublicConfig))) - - def prepare(owner): - events.append("hook") - lazy_factory.register_lazy_type_mapping(*next(pairs)) - # Record each declaration the preparation pass actually prepares. original = SignatureAnalyzer._extract_inline_field_docs def declaration(config_type): - assert "hook" in events events.append(config_type) return original(config_type) - monkeypatch.setattr(CallablePreparation, "prepare", prepare) monkeypatch.setattr(SignatureAnalyzer, "_extract_inline_field_docs", declaration) # The registry admits types that are not dataclasses; readiness filters them. lazy_factory.register_lazy_type_mapping(dict, list) + lazy_factory.register_lazy_type_mapping(LazyConfig, PublicConfig) assert RegistryService.prepare_in_current_process(status_callback=events.append) is metadata assert factories == [] - first_ready = next(i for i, item in enumerate(events) if isinstance(item, str) and "kernels ready" in item) + first_ready = next(i for i, item in enumerate(events) if isinstance(item, str) and "catalog ready" in item) assert events.index(LazyConfig) < first_ready assert events.index(PublicConfig) < first_ready assert LaterPublicConfig not in events and dict not in events and list not in events events.clear() # An already loaded callable catalog must not freeze the configuration roster. + lazy_factory.register_lazy_type_mapping(LaterLazyConfig, LaterPublicConfig) RegistryService.prepare_in_current_process(status_callback=events.append) - second_ready = next(i for i, item in enumerate(events) if isinstance(item, str) and "kernels ready" in item) + second_ready = next(i for i, item in enumerate(events) if isinstance(item, str) and "catalog ready" in item) for config_type in (LazyConfig, PublicConfig, LaterLazyConfig, LaterPublicConfig): assert events.count(config_type) == 1 assert events.index(config_type) < second_ready From 16e2cc64e8cf700acfc66dbdcde03586ebc34c5e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 03:51:10 -0400 Subject: [PATCH 22/24] WIP: R1c probe times first use of four callables after readiness [r1 cold-intel] Co-Authored-By: Claude Opus 5.5 --- .github/r1-debug/cold_prep.py | 6 ++++++ .github/workflows/r1-debug.yml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/r1-debug/cold_prep.py b/.github/r1-debug/cold_prep.py index 371b1a226d..7305f539f3 100644 --- a/.github/r1-debug/cold_prep.py +++ b/.github/r1-debug/cold_prep.py @@ -81,6 +81,12 @@ def main(): else: RegistryService.prepare_in_current_process(status_callback=status) status("prepared") + for name in filter(None, os.environ.get("R1_FIRST_USE", "").split(",")): + from openhcs.core.callable_contract import CallableContract + + used = next(m.func for m in RegistryService._metadata_cache.values() if m.func.__name__ == name) + CallableContract.from_prepared_callable(used) + status(f"first use prepared {name}") with open(out, "w", encoding="utf-8") as handle: for wall, cpu, message in rows: handle.write(json.dumps({"t": wall, "cpu": cpu, "message": message}) + "\n") diff --git a/.github/workflows/r1-debug.yml b/.github/workflows/r1-debug.yml index 4b1e480b4d..6a0bff9ccc 100644 --- a/.github/workflows/r1-debug.yml +++ b/.github/workflows/r1-debug.yml @@ -51,7 +51,7 @@ jobs: run: | mkdir -p "$RUNNER_TEMP/r1" && cd "$RUNNER_TEMP/r1" sysctl -n machdep.cpu.brand_string hw.ncpu || true - R1_NUMBA_EVENTS=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ + R1_FIRST_USE=create_composite,threshold,identify_primary_objects,watershed_cellprofiler4 R1_NUMBA_EVENTS=1 XDG_CACHE_HOME="$RUNNER_TEMP/r1/a-cache" XDG_CONFIG_HOME="$RUNNER_TEMP/r1/a-config" XDG_DATA_HOME="$RUNNER_TEMP/r1/a-data" \ python -I "$GITHUB_WORKSPACE/.github/r1-debug/cold_prep.py" "$RUNNER_TEMP/r1/a-cold-serial.jsonl" - name: C. Second start on warm caches From cb18a5da6be887de0ce329d45ad27278300f9580 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 03:59:53 -0400 Subject: [PATCH 23/24] R1c: receipt with measurements, certificates and what remains Co-Authored-By: Claude Opus 5.5 --- .../consolidation-20261009/R1c-cold-start.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/refactor/consolidation-20261009/R1c-cold-start.md diff --git a/docs/refactor/consolidation-20261009/R1c-cold-start.md b/docs/refactor/consolidation-20261009/R1c-cold-start.md new file mode 100644 index 0000000000..7fd5738f72 --- /dev/null +++ b/docs/refactor/consolidation-20261009/R1c-cold-start.md @@ -0,0 +1,56 @@ +# R1c: cold start of function-catalog preparation + +Branch `refactor/R1c-cold-start`. Continues item 2 of `R1-release-blockers.md`. + +## What is wrong + +`RegistryService.prepare_in_current_process` is what an execution server runs before it binds, and what the GUI's catalog child runs. It discovered the catalog and then ran the preparation hook of every registered callable, which JIT-compiles every Numba kernel in the tree. On a first start that is minutes of compilation for callables the session may never run, and nothing can be listed or executed until it ends. + +Measured locally (Linux, 12 CPUs, serial, empty Numba cache and empty catalog cache, `.github/r1-debug/cold_prep.py`): + +| Phase | Before | After | +| --- | --- | --- | +| import of the registry service | 0.7 s | 0.8 s | +| discovery (import 68 backend modules, probe skimage functions) | 18.5 s | 20.1 s | +| kernel preparation | 102 s of Numba compile self time, 665 compilations of 321 functions | 0 compilations | +| signatures, configuration declarations | 1.7 s | 0.3 s | +| total | 123.8 s | 21.1 s | + +The "compiling" phase is Numba JIT only. No single kernel dominates (largest function 4.7 s over 48 signatures). `python -X importtime` shows no import above 0.7 s cumulative locally. A cProfile of discovery shows the rest: skimage behaviour probes (`rolling_ball` 5.6 s, `hough_ellipse` 3.5 s, `denoise_nl_means` 2.4 s), module imports about 6 s, and 63 138 `packaging.Requirement` parses (B6d's `_openhcs_requirements`, re-parsed for each of 613 queries). + +Intel runner (`macos-15-intel`, previous agent, run `38155492658`): 69.7 s discovery, about 222 s kernels, 301 s total against a 300 s deadline. + +PR #1217 does not shorten this: it changes what preparing one callable imports; serving readiness still prepared every callable. + +## Target + +Readiness is the catalog. A callable's kernels are prepared when a pipeline first uses it: `CallableContract.from_prepared_callable` already owns that (it prepares every target whose signature is not published). Every Numba kernel in the tree is `cache=True`, so Numba's own on-disk cache, keyed by source stamp, signature and CPU, carries a kernel compiled in the server to the forked workers and to later starts. No second cache is added. + +## Done + +- `prepare_in_current_process` discovers the catalog and prepares configuration declarations. It no longer runs callable hooks, forks cache workers or publishes signatures. +- `_openhcs_requirements` parses the installed metadata once per process. +- Checked that no hook changes a catalog signature: all 1035 prepare targets of the 269 callables resolve to the same canonical and runtime signatures before and after every hook ran (`.claude/prof/sigcheck.py`, local). + +## Guards + +- `tests/unit/test_catalog_readiness_work.py`: in a fresh interpreter, readiness performs 0 Numba compilations and completes 0 preparation operations; first use of `create_composite` completes at most one operation per target and compiles OpenHCS kernels from `numpy_processor` only. Counts, no wall clock. +- Rewritten to the first-use rule: `test_preparation_admission.py`, `test_processing_preparation.py`, `test_execution_server_startup_preparation.py`, `test_callable_abi_preparation.py`, `test_registered_configuration_source_readiness.py`. + +## Behaviour removed (certificates) + +- Whole-catalog kernel compilation before bind: **tests-only**. What the tests do not exercise: a worker forked before a kernel was compiled now loads it from Numba's disk cache instead of inheriting it; where no cache directory is writable each worker compiles for itself. A broken kernel now fails at first use, not at server start. +- Signature publication at readiness: **derived** (`from_prepared_callable` snapshots the signature after preparing). + +## Remains + +1. Intel timings. Runs queued on the debug workflow: `38218985682` (before, head `2e5ab83a`) and `38220080786` (after, head `16e2cc64`, also times first use of four callables). Read with `gh run view --log`; step A prints `TOTAL wall` and the largest gaps. +2. Official30 parity under flock (`.claude/prof/parity.sh`); it was waiting for the lock at handoff. +3. Delete `.github/workflows/r1-debug.yml` and `.github/r1-debug/` once item 1 is read. +4. `PreparationCacheBatch`, `PreparationCacheWorker`, `can_prepare_in_child`, `cache_operations` and `cache_preparation_operations` in `openhcs/core/processing_preparation.py` have no production caller now; delete with their tests. +5. Discovery itself (20 s locally, 70 s on Intel) is untouched apart from the requirement parse. Candidates: the slow skimage probes; on macOS check `matplotlib.font_manager` in step E of the debug run. +6. The branch carries merges of B6d (#1221, needed for discovery on Intel where `openhcs-basicpy` is not installed). It conflicts with #1217 in `prepare_in_current_process`: keep #1217's registry completion, drop its per-callable preparation. + +## Done when + +The Intel debug run shows cold readiness well under 300 s, parity is 30/30, and the debug workflow is deleted. From e7b077020bf0b305e6935f4711c11a3b658b3c3c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 04:33:23 -0400 Subject: [PATCH 24/24] R1c: record the failed parity run and the next step Co-Authored-By: Claude Opus 5.5 --- docs/refactor/consolidation-20261009/R1c-cold-start.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactor/consolidation-20261009/R1c-cold-start.md b/docs/refactor/consolidation-20261009/R1c-cold-start.md index 7fd5738f72..c2fbafb921 100644 --- a/docs/refactor/consolidation-20261009/R1c-cold-start.md +++ b/docs/refactor/consolidation-20261009/R1c-cold-start.md @@ -45,7 +45,7 @@ Readiness is the catalog. A callable's kernels are prepared when a pipeline firs ## Remains 1. Intel timings. Runs queued on the debug workflow: `38218985682` (before, head `2e5ab83a`) and `38220080786` (after, head `16e2cc64`, also times first use of four callables). Read with `gh run view --log`; step A prints `TOTAL wall` and the largest gaps. -2. Official30 parity under flock (`.claude/prof/parity.sh`); it was waiting for the lock at handoff. +2. **Official30 parity has not passed.** One run under flock ended `1 failed` in 436 s: the first case (`ExampleColocalization`) raised `OpenHCS ZMQ client entered without a connected endpoint` and the other 29 were skipped, so no value was compared. That message is the known symptom of another worktree's server on the same port, but this change alters server startup, so it is not ruled out as the cause. Next step: read the execution-server log of that run under `.claude/pytest-parity`, then rerun `.claude/prof/parity.sh` (it needs `OPENHCS_CP_NATIVE_REFERENCE_ROOT` and `OPENHCS_REFERENCE_EXPORT_PIPELINES_ROOT`; without them the test skips and exits 0). Do not merge before 30/30. 3. Delete `.github/workflows/r1-debug.yml` and `.github/r1-debug/` once item 1 is read. 4. `PreparationCacheBatch`, `PreparationCacheWorker`, `can_prepare_in_child`, `cache_operations` and `cache_preparation_operations` in `openhcs/core/processing_preparation.py` have no production caller now; delete with their tests. 5. Discovery itself (20 s locally, 70 s on Intel) is untouched apart from the requirement parse. Candidates: the slow skimage probes; on macOS check `matplotlib.font_manager` in step E of the debug run.