diff --git a/.gitignore b/.gitignore index 4aa26d9ea57..5c1f1e55d68 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,12 @@ papers/*/*.brf papers/*/*.pdf papers/*/_minted*/ +# Paper working directories are local-only by default (drafts stay off the +# remote). The vaxautosci-scrubber paper is the exception and stays tracked. +# To publish another paper, add a matching negation below. +papers/*/ +!papers/2026-vaxautosci-scrubber/ + # Mutmut working tree (regenerated per audit; holds the AST-mutated copy # of src/ + tests/ under apps/api/mutants/). Never committed. apps/api/mutants/ diff --git a/apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py b/apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py new file mode 100644 index 00000000000..3218ac49b22 --- /dev/null +++ b/apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py @@ -0,0 +1,759 @@ +"""Robot-loaded, two-sample lights-out session at APS 2-BM. + +cluster: Runs +archetype: agent +bc_primary: Run +bc_touches: Agent, Campaign, Decision, Equipment, Operation, Recipe, Run, Subject + +One overnight Campaign in which a sample-changing robot loads two samples in +turn. For each sample the operator starts a Run bound to that sample's Subject +and to the Campaign; the robot mounts the Subject; CORA conducts a rotation-axis +centering alignment (a 4-iteration peak-bracket search that converges); the +science scan runs; the robot dismounts the Subject; the Run completes. On the +second sample the beam drops while the third projection is in flight, the +RunSupervisor agent holds the Run and auto-resumes it, and the fly-scan +restarts. + +This is the scenario the paper's Figure 1 is exported from (see +papers/2026-vaxautosci-scrubber/data/build_lights_out_data.py). It extends the +single-sample lights-out scenario +(test_2bm_lights_out_supervised_alignment.py) with the robot sample changer and +the per-sample Subject custody lifecycle grouped under one Campaign. + +Modeling notes (ROBOT-1 posture, adversarially-verified across 16 beamlines): +the robot is one Positioner-presenting Asset (the Manipulator Family), NOT a new +SampleChanger Family. It loads / unloads a Subject via the Subject BC's +mount_subject / dismount_subject custody lifecycle. This scenario is the first +place CORA models robot mount/dismount as activities with real Subject custody; +if 32-id / 19-BM later model the same lifecycle, that is the rule-of-three +trigger to revisit whether SampleChanger should graduate into its own Family +(the Goniometer-graduation precedent). Until then the locked posture holds. + +Scope (modeled vs. deployed). The sample-change hardware at 2-BM (a UR3e arm +with its own EPICS control) is deployed and has executed mount/dismount cycles +with a beamline handshake; tomoscan is PV-scriptable. CORA's orchestration of +the robot is modeled here, played through the real Kernel + Postgres event +store; the supervisor decision layer beyond hold/resume (FOV-fit and lens-change +branches, per-sample recentering as a supervised decision) is out of scope. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +# pyright: reportPrivateUsage=false + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.agent.seed_run_supervisor import RUN_SUPERVISOR_AGENT_ID, seed_run_supervisor_agent +from cora.api._run_supervisor import _MEM_HELD, ObservationRuleConfig, _supervise_tick +from cora.campaign.aggregates.campaign import CampaignIntent +from cora.campaign.features.register_campaign import RegisterCampaign +from cora.campaign.features.register_campaign import bind as bind_register_campaign +from cora.campaign.features.start_campaign import StartCampaign +from cora.campaign.features.start_campaign import bind as bind_start_campaign +from cora.decision.aggregates.decision import load_decision +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.equipment.features.activate_asset import ActivateAsset +from cora.equipment.features.activate_asset import bind as bind_activate_asset +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports.beam_availability_lookup import BeamAvailabilityLookupResult +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from cora.operation._projections import register_operation_projections +from cora.operation.aggregates.procedure import PostgresActivityStore +from cora.operation.features.append_activities import ActivityInput, AppendProcedureActivities +from cora.operation.features.append_activities import bind as bind_append_step +from cora.operation.features.complete_procedure import CompleteProcedure +from cora.operation.features.complete_procedure import bind as bind_complete_procedure +from cora.operation.features.end_iteration import EndProcedureIteration +from cora.operation.features.end_iteration import bind as bind_end_iteration +from cora.operation.features.register_procedure import RegisterProcedure +from cora.operation.features.register_procedure import bind as bind_register_procedure +from cora.operation.features.start_iteration import StartProcedureIteration +from cora.operation.features.start_iteration import bind as bind_start_iteration +from cora.operation.features.start_procedure import StartProcedure +from cora.operation.features.start_procedure import bind as bind_start_procedure +from cora.run._projections import register_run_projections +from cora.run.features.abort_run import bind as bind_abort_run +from cora.run.features.complete_run import CompleteRun +from cora.run.features.complete_run import bind as bind_complete_run +from cora.run.features.hold_run import bind as bind_hold_run +from cora.run.features.list_runs import bind as bind_list_runs +from cora.run.features.resume_run import bind as bind_resume_run +from cora.run.features.start_run import StartRun +from cora.run.features.start_run import bind as bind_start_run +from cora.run.features.stop_run import bind as bind_stop_run +from cora.run.features.truncate_run import bind as bind_truncate_run +from cora.run.ports import InMemoryRunChannelLookup +from cora.shared.identity import ActorId +from cora.subject.features.dismount_subject import DismountSubject +from cora.subject.features.dismount_subject import bind as bind_dismount_subject +from cora.subject.features.mount_subject import MountSubject +from cora.subject.features.mount_subject import bind as bind_mount_subject +from cora.subject.features.register_subject import RegisterSubject +from cora.subject.features.register_subject import bind as bind_register_subject +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import DeviceSpec, install_aps_unit, operator_for +from tests.integration.scenarios._tomography_fixture import ( + RecipeSpec, + define_recipe_ladder, + recipe_ladder_id_prefix, +) + +_RULES_OFF = ObservationRuleConfig( + quality_channel_name=None, + stall_channel_name=None, + stall_window_factor=3.0, + stall_hysteresis_ticks=2, + feed_heartbeat_ceiling_seconds=None, +) + +_NOW = datetime(2026, 5, 19, 1, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000004720bb") + +# Scenario tag: 472 (robot lights-out two-sample). +_2BM_UNIT_ID = UUID("01900000-0000-7000-8000-000000472a01") + +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_LINEAR_STAGE_ID = family_stream_id(FamilyName("LinearStage")) +_CAP_CAMERA_ID = family_stream_id(FamilyName("Camera")) +_CAP_SCINTILLATOR_ID = family_stream_id(FamilyName("Scintillator")) +_CAP_MANIPULATOR_ID = family_stream_id(FamilyName("Manipulator")) + +_ASSET_AEROTECH_ABRS_ID = UUID("01900000-0000-7000-8000-000000472a11") +_ASSET_SAMPLE_TOP_X_ID = UUID("01900000-0000-7000-8000-000000472a21") +_ASSET_ORYX_5MP_ID = UUID("01900000-0000-7000-8000-000000472a31") +_ASSET_SCINTILLATOR_LUAG_ID = UUID("01900000-0000-7000-8000-000000472a41") +_ASSET_ROBOT_ID = UUID("01900000-0000-7000-8000-000000472a51") + +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-000000c0d472") +_METHOD_ID = UUID("01900000-0000-7000-8000-000000472d01") +_PRACTICE_ID = UUID("01900000-0000-7000-8000-000000472d11") +_PLAN_ID = UUID("01900000-0000-7000-8000-000000472d21") +_APS_SITE_ID = UUID("01900000-0000-7000-8000-000000472501") + +_PI_ACTOR_ID = UUID("01900000-0000-7000-8000-000000472b01") +_CAMPAIGN_ID = UUID("01900000-0000-7000-8000-000000472b11") +_SUBJECT_A_ID = UUID("01900000-0000-7000-8000-000000472b21") +_SUBJECT_B_ID = UUID("01900000-0000-7000-8000-000000472b31") + +_DEVICES = ( + DeviceSpec("Rotary", _ASSET_AEROTECH_ABRS_ID, "RotaryStage", _CAP_ROTARY_STAGE_ID), + DeviceSpec("SampleTop_X", _ASSET_SAMPLE_TOP_X_ID, "LinearStage", _CAP_LINEAR_STAGE_ID), + DeviceSpec("Camera", _ASSET_ORYX_5MP_ID, "Camera", _CAP_CAMERA_ID), + DeviceSpec("Scintillator", _ASSET_SCINTILLATOR_LUAG_ID, "Scintillator", _CAP_SCINTILLATOR_ID), + DeviceSpec("SampleChanger", _ASSET_ROBOT_ID, "Manipulator", _CAP_MANIPULATOR_ID), +) + +_RECIPE = RecipeSpec( + capability_id=_CAPABILITY_ID, + capability_code="cora.capability.tomography", + capability_name="Tomography", + method_id=_METHOD_ID, + method_name="tomography", + needed_family_ids=frozenset( + {_CAP_ROTARY_STAGE_ID, _CAP_LINEAR_STAGE_ID, _CAP_CAMERA_ID, _CAP_SCINTILLATOR_ID} + ), + parameters_schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "exposure_ms": {"type": "integer", "minimum": 1}, + "n_projections": {"type": "integer", "minimum": 1}, + "angle_range_deg": {"type": "number", "minimum": 1, "maximum": 360}, + }, + "required": ["exposure_ms", "n_projections", "angle_range_deg"], + }, + practice_id=_PRACTICE_ID, + practice_name="2BM_tomography_practice", + site_id=_APS_SITE_ID, + plan_id=_PLAN_ID, + plan_name="2BM_robot_lights_out_plan", + plan_asset_ids=frozenset( + { + _ASSET_AEROTECH_ABRS_ID, + _ASSET_SAMPLE_TOP_X_ID, + _ASSET_ORYX_5MP_ID, + _ASSET_SCINTILLATOR_LUAG_ID, + } + ), +) + + +@dataclass(frozen=True) +class _SamplePlan: + """One sample's run within the campaign: the sample's Subject, its own COR + search, and whether the beam drops during its scan. Run and Procedure ids + are captured from the handlers at runtime (returned by the bind calls), not + pinned in the id-queue, so the per-sample ceremony is robust to how many ids + mount / start_run / drains consume.""" + + label: str + subject_id: UUID + iterations: tuple[tuple[float, float, str | None, bool, str | None], ...] + beam_loss: bool + + +@dataclass(frozen=True) +class _SampleResult: + """The ids a sample's run produced, for the assertions to load by.""" + + run_id: UUID + procedure_id: UUID + + +# (target_mm, residual_px, direction, converged, reason). Sample A converges to +# a different center than sample B: the per-sample recentering finds each +# sample's position relative to the fixed rotation axis. +_SAMPLE_A = _SamplePlan( + label="A", + subject_id=_SUBJECT_A_ID, + iterations=( + (0.000, 1.80, None, False, "initial residual 1.80 px; minimum not yet bracketed"), + (0.030, 0.90, "better", False, "residual improving (0.90 px); minimum not yet bracketed"), + ( + 0.060, + 1.15, + "worse", + False, + "residual rose to 1.15 px; minimum bracketed in [0.030, 0.060] mm", + ), + (0.045, 0.25, "minimum", True, None), + ), + beam_loss=False, +) +_SAMPLE_B = _SamplePlan( + label="B", + subject_id=_SUBJECT_B_ID, + iterations=( + (0.000, 2.00, None, False, "initial residual 2.00 px; minimum not yet bracketed"), + (0.040, 1.05, "better", False, "residual improving (1.05 px); minimum not yet bracketed"), + ( + 0.080, + 1.40, + "worse", + False, + "residual rose to 1.40 px; minimum bracketed in [0.040, 0.080] mm", + ), + (0.060, 0.30, "minimum", True, None), + ), + beam_loss=True, +) + + +def _id_queue() -> list[UUID]: + """Setup ids, then a generous uuid4 pad. Only aggregates the assertions load + by a fixed id are pinned (assets, recipe ladder, PI actor, subjects, + campaign). Run and Procedure ids are captured from the handlers at runtime, + so they are NOT pinned here; the custody + run + procedure + supervisor-tick + ceremonies draw the rest from the pad.""" + e = uuid4 + ids: list[UUID] = [] + # install_aps_unit: unit + 5 devices (family ids derived from name, not + # popped) then 5 activate events. + from tests.integration.scenarios._facility_fixture import facility_id_prefix + + ids += facility_id_prefix(unit_id=_2BM_UNIT_ID, devices=_DEVICES) + ids += [e() for _ in range(len(_DEVICES))] # activate_asset events + ids += recipe_ladder_id_prefix(spec=_RECIPE) + # Beamtime: PI actor, two subjects, campaign (register + start). + ids += [_PI_ACTOR_ID, e(), _SUBJECT_A_ID, e(), _SUBJECT_B_ID, e(), _CAMPAIGN_ID, e(), e()] + # Per-sample runs + procedures + supervisor ticks draw from the pad. + ids += [e() for _ in range(600)] + return ids + + +def _setpoint(*, target_mm: float, role: str, note: str | None = None) -> ActivityInput: + payload: dict[str, Any] = { + "channel": "SampleTop_X", + "target_value": target_mm, + "units": "mm", + "role": role, + } + if note is not None: + payload["note"] = note + return ActivityInput(event_id=uuid4(), step_kind="setpoint", payload=payload, sampled_at=_NOW) + + +def _acquire(*, purpose: str) -> ActivityInput: + return ActivityInput( + event_id=uuid4(), + step_kind="action", + payload={ + "action_name": "acquire_frame", + "params": {"exposure_ms": 100, "purpose": purpose}, + }, + sampled_at=_NOW, + ) + + +def _centering_check( + *, residual_px: float, direction: str | None, passed: bool = False +) -> ActivityInput: + payload: dict[str, Any] = { + "channel": "cor_residual", + "passed": passed, + "source": "tomopy.recon.rotation", + "actual": residual_px, + "units": "px", + } + if direction is not None: + payload["direction"] = direction + return ActivityInput(event_id=uuid4(), step_kind="check", payload=payload, sampled_at=_NOW) + + +def _projection(*, index: int, angle_deg: float, result: str) -> ActivityInput: + return ActivityInput( + event_id=uuid4(), + step_kind="action", + payload={ + "action_name": "acquire_projection", + "params": {"exposure_ms": 100, "angle_deg": angle_deg, "index": index}, + "result": result, + }, + sampled_at=_NOW, + ) + + +def _fly_scan_setpoint() -> ActivityInput: + return ActivityInput( + event_id=uuid4(), + step_kind="setpoint", + payload={ + "channel": "rotation_angle", + "target_value": 180.0, + "units": "deg", + "role": "fly_scan", + "note": "continuous 0->180 deg sweep", + }, + sampled_at=_NOW, + ) + + +def _taxi_setpoint() -> ActivityInput: + return ActivityInput( + event_id=uuid4(), + step_kind="setpoint", + payload={ + "channel": "rotation_angle", + "target_value": -5.0, + "units": "deg", + "role": "taxi", + "note": "run-up to constant velocity", + }, + sampled_at=_NOW, + ) + + +def _fly_scan_prep() -> ActivityInput: + return ActivityInput( + event_id=uuid4(), + step_kind="action", + payload={"action_name": "fly_scan_prep", "params": {"rearm_pso": True}, "result": "ok"}, + sampled_at=_NOW, + ) + + +def _write_dataset(*, projections: int) -> ActivityInput: + return ActivityInput( + event_id=uuid4(), + step_kind="action", + payload={ + "action_name": "write_dataset", + "params": {"format": "dxfile-hdf5", "projections": projections}, + "result": "ok", + }, + sampled_at=_NOW, + ) + + +class _BeamDown: + async def read(self) -> BeamAvailabilityLookupResult: + return BeamAvailabilityLookupResult( + fes_open=False, sbs_open=True, fes_permit=True, quality_ok=True + ) + + +class _BeamOpen: + async def read(self) -> BeamAvailabilityLookupResult: + return BeamAvailabilityLookupResult( + fes_open=True, sbs_open=True, fes_permit=True, quality_ok=True + ) + + +async def _drain_run(db_pool: asyncpg.Pool) -> None: + registry = ProjectionRegistry() + register_run_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +async def _drain_operation(db_pool: asyncpg.Pool) -> None: + registry = ProjectionRegistry() + register_operation_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +def _tick_kwargs( + deps: Kernel, beam: object, memory: dict[UUID, str], settle: dict[UUID, int] +) -> dict[str, Any]: + return { + "deps": deps, + "list_runs": bind_list_runs(deps), + "hold_run": bind_hold_run(deps), + "resume_run": bind_resume_run(deps), + "truncate_run": bind_truncate_run(deps), + "abort_run": bind_abort_run(deps), + "stop_run": bind_stop_run(deps), + "beam_lookup": beam, + "memory": memory, + "settle": settle, + "liveness": set(), + "channel_lookup": InMemoryRunChannelLookup(), + "rules_config": _RULES_OFF, + "quality": set(), + "stall": set(), + "stall_streak": {}, + "feed_dead_warned": set(), + "truncate_settle": {}, + "quality_act_settle": {}, + "stall_act_settle": {}, + "liveness_ceiling_seconds": None, + "advise_enabled": False, + "resume_enabled": True, + "resume_settle_ticks": 1, + "truncate_enabled": False, + "truncate_settle_ticks": 3, + "quality_act_enabled": False, + "quality_settle_ticks": 3, + "stall_act_enabled": False, + "stall_settle_ticks": 2, + } + + +async def _run_one_sample( + deps: Kernel, db_pool: asyncpg.Pool, step_store: PostgresActivityStore, sp: _SamplePlan +) -> _SampleResult: + """Play one sample's run within the campaign: robot mount, conducted + alignment, science scan (with a hold/resume on the beam-loss sample), robot + dismount, run completion. Returns the run + procedure ids captured from the + handlers.""" + # The robot mounts the sample onto the rotary stage (Subject custody: mount). + # Mounting precedes Run-start: a Run can only start against a Subject that is + # already Mounted (RunSubjectNotMountableError otherwise). + await bind_mount_subject(deps)( + MountSubject( + subject_id=sp.subject_id, + asset_id=_ASSET_AEROTECH_ABRS_ID, + reason=f"robot sample changer mounts sample {sp.label} onto the rotary stage", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # Operator starts the sample's Run, bound to its (now mounted) Subject and + # the Campaign. The handler returns the new Run id. + run_id = await bind_start_run(deps)( + StartRun( + name=f"2-BM robot-loaded tomography (sample {sp.label})", + plan_id=_PLAN_ID, + subject_id=sp.subject_id, + campaign_id=_CAMPAIGN_ID, + override_parameters={ + "exposure_ms": 100, + "n_projections": 1500, + "angle_range_deg": 180.0, + }, + trigger_source="operator-manual; robot-loaded lights-out overnight session", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_run(db_pool) + + # CORA conducts the per-sample rotation-axis centering alignment. The handler + # returns the new Procedure id. + procedure_id = await bind_register_procedure(deps)( + RegisterProcedure( + name=f"2-BM rotation-axis centering (sample {sp.label})", + kind="alignment", + target_asset_ids=frozenset( + {_ASSET_AEROTECH_ABRS_ID, _ASSET_SAMPLE_TOP_X_ID, _ASSET_ORYX_5MP_ID} + ), + parent_run_id=run_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await bind_start_procedure(deps)( + StartProcedure(procedure_id=procedure_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + for index, (target_mm, residual, direction, converged, reason) in enumerate( + sp.iterations, start=1 + ): + role = "initial" if index == 1 else ("bisect" if converged else "step_positive") + note = "recenter after mount" if index == 1 else None + await bind_start_iteration(deps)( + StartProcedureIteration(procedure_id=procedure_id, iteration_index=index), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + count = await bind_append_step(deps, step_store=step_store)( + AppendProcedureActivities( + procedure_id=procedure_id, + entries=( + _setpoint(target_mm=target_mm, role=role, note=note), + _acquire(purpose="alignment"), + _centering_check(residual_px=residual, direction=direction, passed=converged), + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert count == 3 + await bind_end_iteration(deps)( + EndProcedureIteration( + procedure_id=procedure_id, + iteration_index=index, + converged=converged, + reason=reason, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # Lock at the converged center; command the fly-scan; taxi + arm; acquire. + converged_center = sp.iterations[-1][0] + await bind_append_step(deps, step_store=step_store)( + AppendProcedureActivities( + procedure_id=procedure_id, + entries=( + _setpoint(target_mm=converged_center, role="lock_at_center"), + _fly_scan_setpoint(), + _taxi_setpoint(), + _fly_scan_prep(), + _projection(index=1, angle_deg=0.0, result="ok"), + _projection(index=2, angle_deg=30.0, result="ok"), + _projection(index=3, angle_deg=60.0, result="in_flight" if sp.beam_loss else "ok"), + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_run(db_pool) + + if sp.beam_loss: + # Beam dump: the supervisor holds, then auto-resumes when beam returns. + memory: dict[UUID, str] = {} + settle: dict[UUID, int] = {} + await _supervise_tick(**_tick_kwargs(deps, _BeamDown(), memory, settle)) + assert memory[run_id] == _MEM_HELD + await _drain_run(db_pool) + await _supervise_tick(**_tick_kwargs(deps, _BeamOpen(), memory, settle)) + await _drain_run(db_pool) + # Fly-scan restart, re-acquire the interrupted projection, finish. + await bind_append_step(deps, step_store=step_store)( + AppendProcedureActivities( + procedure_id=procedure_id, + entries=( + _taxi_setpoint(), + _fly_scan_prep(), + _projection(index=3, angle_deg=60.0, result="ok"), + _projection(index=4, angle_deg=90.0, result="ok"), + _projection(index=5, angle_deg=120.0, result="ok"), + _projection(index=6, angle_deg=150.0, result="ok"), + _write_dataset(projections=6), + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + else: + await bind_append_step(deps, step_store=step_store)( + AppendProcedureActivities( + procedure_id=procedure_id, + entries=(_write_dataset(projections=3),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await bind_complete_procedure(deps)( + CompleteProcedure(procedure_id=procedure_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # The robot dismounts the sample (Subject custody: dismount). + await bind_dismount_subject(deps)( + DismountSubject( + subject_id=sp.subject_id, + reason=f"robot sample changer dismounts sample {sp.label} after the scan", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await bind_complete_run(deps)( + CompleteRun(run_id=run_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return _SampleResult(run_id=run_id, procedure_id=procedure_id) + + +@pytest.mark.integration +async def test_robot_two_sample_campaign_is_loaded_supervised_and_audited( + db_pool: asyncpg.Pool, +) -> None: + """A robot loads two samples in turn under one Campaign; each is mounted, + recentered, scanned, and dismounted; the second sample's scan is held and + resumed by the supervisor on beam loss. Assert the full auditable record the + scrubber visualizes: robot custody per sample, two run lifecycles (one plain, + one held/resumed), per-sample converged alignments, and the interrupted + projection caught mid-flight.""" + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue()) + step_store = PostgresActivityStore(db_pool) + + await seed_run_supervisor_agent(deps) + + # ----- Facility: the 2-BM imaging chain + the sample-changer robot ----- + await install_aps_unit( + deps, + profile_store=make_pg_profile_store(db_pool), + correlation_id=_CORRELATION_ID, + unit_id=_2BM_UNIT_ID, + devices=_DEVICES, + unit_name="2-BM", + ) + for aid in ( + _ASSET_AEROTECH_ABRS_ID, + _ASSET_SAMPLE_TOP_X_ID, + _ASSET_ORYX_5MP_ID, + _ASSET_SCINTILLATOR_LUAG_ID, + _ASSET_ROBOT_ID, + ): + await bind_activate_asset(deps)( + ActivateAsset(asset_id=aid), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await define_recipe_ladder( + deps, principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, spec=_RECIPE + ) + + # ----- Beamtime: PI, two subjects, one Campaign spanning both samples ----- + from cora.access.features.register_actor import RegisterActor + from cora.access.features.register_actor import bind as bind_register_actor + + await bind_register_actor(deps, profile_store=make_pg_profile_store(db_pool))( + RegisterActor(name="Proposal 2026-5678 PI"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await bind_register_subject(deps)( + RegisterSubject(name="porous sandstone core (Proposal 2026-5678, sample A)"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await bind_register_subject(deps)( + RegisterSubject(name="porous sandstone core (Proposal 2026-5678, sample B)"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await bind_register_campaign(deps)( + RegisterCampaign( + name="Proposal 2026-5678 robot-loaded overnight session", + intent=CampaignIntent.COORDINATION, + lead_actor_id=_PI_ACTOR_ID, + subject_id=_SUBJECT_A_ID, + description="Two-sample robot-loaded lights-out tomography", + tags=frozenset({"proposal", "tomography", "robot", "lights_out"}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await bind_start_campaign(deps)( + StartCampaign(campaign_id=_CAMPAIGN_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # ----- The two samples, in turn, under the campaign ----- + result_a = await _run_one_sample(deps, db_pool, step_store, _SAMPLE_A) + result_b = await _run_one_sample(deps, db_pool, step_store, _SAMPLE_B) + + # ----- Assert: sample A run is a clean two-beat arc ----- + run_a_events, _ = await deps.event_store.load("Run", result_a.run_id) + assert [e.event_type for e in run_a_events] == ["RunStarted", "RunCompleted"] + + # ----- Assert: sample B run is the four-beat autonomous arc ----- + run_b_events, _ = await deps.event_store.load("Run", result_b.run_id) + assert [e.event_type for e in run_b_events] == [ + "RunStarted", + "RunHeld", + "RunResumed", + "RunCompleted", + ] + + # ----- Assert: both runs carry the campaign id ----- + for events in (run_a_events, run_b_events): + started = next(e for e in events if e.event_type == "RunStarted") + assert started.payload["campaign_id"] == str(_CAMPAIGN_ID) + + # ----- Assert: sample B hold + resume were the supervisor's decisions ----- + resumed = next(e for e in run_b_events if e.event_type == "RunResumed") + decision_id = resumed.payload["decided_by_decision_id"] + assert decision_id is not None + decision = await load_decision(deps.event_store, UUID(decision_id)) + assert decision is not None + assert decision.context.value == "RunSupervision" + assert decision.choice.value == "Resume" + assert decision.decided_by == ActorId(RUN_SUPERVISOR_AGENT_ID) + + # ----- Assert: each subject went through the robot custody lifecycle ----- + for subject_id in (_SUBJECT_A_ID, _SUBJECT_B_ID): + subj_events, _ = await deps.event_store.load("Subject", subject_id) + types = [e.event_type for e in subj_events] + assert "SubjectMounted" in types + assert "SubjectDismounted" in types + assert types.index("SubjectMounted") < types.index("SubjectDismounted") + + # ----- Assert: both procedures converged on their last iteration ----- + for result in (result_a, result_b): + proc_events, _ = await deps.event_store.load("Procedure", result.procedure_id) + assert proc_events[0].payload["parent_run_id"] == str(result.run_id) + ended = [e for e in proc_events if e.event_type == "ProcedureIterationEnded"] + assert len(ended) == 4 + assert ended[-1].payload["converged"] is True + assert all(e.payload["converged"] is False for e in ended[:-1]) + + # ----- Assert: sample B's third projection was caught mid-flight ----- + await _drain_operation(db_pool) + async with db_pool.acquire() as conn: + proj_rows = await conn.fetch( + "SELECT payload->'params'->>'index' AS idx, payload->>'result' AS result " + "FROM entries_operation_procedure_activities WHERE procedure_id = $1 " + "AND payload->>'action_name' = 'acquire_projection'", + result_b.procedure_id, + ) + by_index: dict[str, list[str]] = {} + for r in proj_rows: + by_index.setdefault(r["idx"], []).append(r["result"]) + assert sorted(by_index) == ["1", "2", "3", "4", "5", "6"] + assert sorted(by_index["3"]) == ["in_flight", "ok"] + + # ----- Assert: sample A wrote one clean dataset (no hold, 3 projections) --- + async with db_pool.acquire() as conn: + a_proj = await conn.fetch( + "SELECT payload->>'result' AS result FROM entries_operation_procedure_activities " + "WHERE procedure_id = $1 AND payload->>'action_name' = 'acquire_projection'", + result_a.procedure_id, + ) + assert [r["result"] for r in a_proj] == ["ok", "ok", "ok"] diff --git a/apps/api/tests/integration/test_authority_revocation_perf_against_postgres.py b/apps/api/tests/integration/test_authority_revocation_perf_against_postgres.py new file mode 100644 index 00000000000..66909887e1a --- /dev/null +++ b/apps/api/tests/integration/test_authority_revocation_perf_against_postgres.py @@ -0,0 +1,275 @@ +"""Performance characterization of the actor-symmetry kill-switch, against real Postgres. + +Measures the three quantities the actor-symmetry paper reports and, when +`CORA_PERF_OUT` is set, writes them as JSON (the paper's +`data/killswitch_perf.json`); otherwise it just asserts the characterization +holds. All numbers are wall-clock on the developer testcontainer +(`pgvector/pgvector:pg18` via testcontainers, asyncpg), single-threaded, not a +tuned benchmark. + +Three measurements: + - authz-decision latency: the pure Policy `evaluate` (no I/O), batch-sampled. + - event-replay throughput: folding a 100-event Run stream in memory (`fold`), + isolating the pure left-fold from DB IO and deserialization. + - kill-switch propagation vs K concurrent supervised runs: one + `PolicyGrantRevoked` delivered to the holder subscriber holds all K of the + revoked principal's Running runs; we time that `apply()` and confirm K/K + land in Held. K is swept so the figure can show total time and per-run cost. + +Attribution is by the RunStarted envelope principal (the involvement projection's +key), so seeding one RunStarted per run with `principal_id=starter` is enough to +make the holder discover and hold it. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +# pyright: reportPrivateUsage=false + +import json +import os +import statistics +import time +from collections.abc import Mapping +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.agent.seed_authority_revocation_holder import seed_authority_revocation_holder_agent +from cora.agent.subscribers.authority_revocation_holder import ( + make_authority_revocation_holder_subscriber, +) +from cora.infrastructure.adapters.postgres_event_store import PostgresEventStore +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports import UUIDv7Generator +from cora.infrastructure.ports.event_store import NewEvent, StoredEvent +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from cora.run.adapters import PostgresRunActorInvolvementLookup +from cora.run.aggregates.run import RunStatus, load_run +from cora.run.aggregates.run.events import RunCompleted, RunHeld, RunResumed, RunStarted +from cora.run.aggregates.run.evolver import fold +from cora.run.projections import RunActorInvolvementProjection +from cora.trust.aggregates.policy.state import Policy, PolicyName, evaluate +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 7, 10, 12, 0, 0, tzinfo=UTC) +_CORR = UUID("01900000-0000-7000-8000-0000000000cc") + +_K_SWEEP = (1, 2, 5, 10, 20, 50) +_AUTHZ_SAMPLES = 2000 +_AUTHZ_BATCH = 200 +_REPLAY_STREAM = 100 +_REPLAY_ITERS = 20000 + +_ENVIRONMENT = ( + "developer testcontainer (pgvector/pgvector:pg18 via testcontainers, asyncpg); " + "wall-clock, single-threaded, not a tuned benchmark" +) + + +def _write_perf_json(path: str, result: Mapping[str, object]) -> None: + """Sync JSON write, kept off the async test body (ASYNC230).""" + with open(path, "w", encoding="utf-8") as handle: + json.dump(result, handle, indent=2) + handle.write("\n") + + +def _authz_decision_us() -> dict[str, float | int]: + """Batch-sampled latency of the pure Policy decision (no I/O, no events).""" + principal, conduit, surface = uuid4(), uuid4(), uuid4() + policy = Policy( + id=uuid4(), + name=PolicyName("perf-bench"), + conduit_id=conduit, + permitted_principal_ids=frozenset({principal, *(uuid4() for _ in range(63))}), + permitted_commands=frozenset({"HoldRun", "StartRun", *(f"Cmd{i}" for i in range(30))}), + surface_id=surface, + ) + + def _call() -> None: + evaluate( + policy, + principal_id=principal, + command_name="HoldRun", + conduit_id=conduit, + surface_id=surface, + ) + + for _ in range(2000): # warm the interpreter / caches + _call() + + per_call_us: list[float] = [] + for _ in range(_AUTHZ_SAMPLES): + t0 = time.perf_counter_ns() + for _ in range(_AUTHZ_BATCH): + _call() + per_call_us.append((time.perf_counter_ns() - t0) / 1000.0 / _AUTHZ_BATCH) + per_call_us.sort() + return { + "median": statistics.median(per_call_us), + "p95": per_call_us[int(0.95 * len(per_call_us))], + "samples": _AUTHZ_SAMPLES, + } + + +def _replay_throughput() -> dict[str, float | int]: + """Pure in-memory fold throughput of a 100-event Run stream.""" + rid = uuid4() + events: list[object] = [ + RunStarted( + run_id=rid, name="perf-bench", plan_id=uuid4(), subject_id=uuid4(), occurred_at=_NOW + ) + ] + while len(events) < _REPLAY_STREAM - 1: + events.append(RunHeld(run_id=rid, occurred_at=_NOW)) + events.append(RunResumed(run_id=rid, occurred_at=_NOW)) + events.append(RunCompleted(run_id=rid, occurred_at=_NOW)) + + for _ in range(1000): # warm + fold(events) # type: ignore[arg-type] + + t0 = time.perf_counter() + for _ in range(_REPLAY_ITERS): + fold(events) # type: ignore[arg-type] + elapsed = time.perf_counter() - t0 + return { + "events_per_s": _REPLAY_ITERS * len(events) / elapsed, + "stream_length": len(events), + } + + +async def _seed_running_run(store: PostgresEventStore, *, run_id: UUID, starter: UUID) -> None: + """Append one RunStarted whose envelope principal is `starter`. + + The five payload keys are the minimum the holder's fold needs (run_id, name, + plan_id, subject_id present, occurred_at); plan_id/subject_id are not + existence-checked at fold time. + """ + payload = { + "run_id": str(run_id), + "name": f"perf-run-{run_id}", + "plan_id": str(uuid4()), + "subject_id": None, + "occurred_at": _NOW.isoformat(), + } + await store.append( + "Run", + run_id, + 0, + [ + NewEvent( + event_id=uuid4(), + event_type="RunStarted", + schema_version=1, + payload=payload, + occurred_at=_NOW, + correlation_id=_CORR, + causation_id=None, + metadata={}, + principal_id=starter, + ) + ], + ) + + +def _revocation_event(*, revoked_principal_id: UUID) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Policy", + stream_id=uuid4(), + version=1, + event_type="PolicyGrantRevoked", + schema_version=1, + payload={ + "policy_id": str(uuid4()), + "principal_id": str(revoked_principal_id), + "revoked_by": str(uuid4()), + "reason": "trust withdrawn", + "occurred_at": _NOW.isoformat(), + }, + correlation_id=_CORR, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +async def _time_killswitch( + db_pool: asyncpg.Pool, deps: Kernel, store: PostgresEventStore, k: int +) -> tuple[float, int]: + """Seed K Running runs for a fresh principal, revoke, time the hold, count held.""" + revoked = uuid4() + run_ids = [uuid4() for _ in range(k)] + for run_id in run_ids: + await _seed_running_run(store, run_id=run_id, starter=revoked) + + registry = ProjectionRegistry() + registry.register(RunActorInvolvementProjection()) + await drain_projections(db_pool, registry, deadline_seconds=15.0) + + holder = make_authority_revocation_holder_subscriber(deps) + event = _revocation_event(revoked_principal_id=revoked) + + t0 = time.perf_counter() + await holder.apply(event, conn=None) # type: ignore[arg-type] + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + + held = 0 + for run_id in run_ids: + state = await load_run(deps.event_store, run_id) + if state is not None and state.status is RunStatus.HELD: + held += 1 + return elapsed_ms, held + + +@pytest.mark.integration +async def test_authority_revocation_perf_characterizes_authz_replay_and_killswitch( + db_pool: asyncpg.Pool, +) -> None: + deps = build_postgres_deps( + db_pool, + now=_NOW, + id_generator=UUIDv7Generator(), + run_actor_involvement_lookup=PostgresRunActorInvolvementLookup(db_pool), + ) + await seed_authority_revocation_holder_agent(deps) + store = PostgresEventStore(db_pool) + + scales: dict[str, dict[str, float | int]] = {} + for k in _K_SWEEP: + elapsed_ms, held = await _time_killswitch(db_pool, deps, store, k) + scales[str(k)] = { + "kill_switch_propagation_ms": round(elapsed_ms, 2), + "propagation_ms_per_run": round(elapsed_ms / k, 2), + "runs_held": held, + } + + authz = _authz_decision_us() + replay = _replay_throughput() + + result = { + "authz_decision_us": { + "median": round(float(authz["median"]), 3), + "p95": round(float(authz["p95"]), 3), + "samples": int(authz["samples"]), + }, + "replay_events_per_s": round(float(replay["events_per_s"]), 1), + "replay_stream_length": int(replay["stream_length"]), + "environment": _ENVIRONMENT, + "scales": scales, + } + + out = os.environ.get("CORA_PERF_OUT") + if out: + _write_perf_json(out, result) + print("\nKILLSWITCH_PERF " + json.dumps(result)) + + # The characterization must actually hold, not merely produce numbers. + for k in _K_SWEEP: + assert scales[str(k)]["runs_held"] == k, ( + f"K={k}: only {scales[str(k)]['runs_held']}/{k} held" + ) + assert float(authz["median"]) < 5.0 + assert float(replay["events_per_s"]) > 10_000.0 diff --git a/docs/javascripts/scrubber-demo.js b/docs/javascripts/scrubber-demo.js index 80ab3b2479b..5e5ea2190c6 100644 --- a/docs/javascripts/scrubber-demo.js +++ b/docs/javascripts/scrubber-demo.js @@ -298,6 +298,33 @@ g.appendChild(permitSeg(run.beamLoss, run.beamBack, true)); g.appendChild(permitSeg(run.beamBack, run.xmax, false)); + // Run-lifecycle state line: solid while active, dashed while held, absent + // before start and after completion, mirroring the beam-permit lane so the + // run state is legible at every cursor position (not only at the markers). + // Neutral tone, not the beam-permit green, so the two lanes read distinct. + const runAt = {}; + run.run.events.forEach((e) => { + runAt[e.type] = parseT(e.at) - run.t0; + }); + const runSeg = (x0, x1, held) => + svg("line", { + x1: X(x0), + y1: LANE.run, + x2: X(x1), + y2: LANE.run, + class: `cs-run-line ${held ? "cs-run-line--held" : "cs-run-line--active"}`, + }); + if ( + runAt.RunStarted != null && + runAt.RunHeld != null && + runAt.RunResumed != null && + runAt.RunCompleted != null + ) { + g.appendChild(runSeg(runAt.RunStarted, runAt.RunHeld, false)); + g.appendChild(runSeg(runAt.RunHeld, runAt.RunResumed, true)); + g.appendChild(runSeg(runAt.RunResumed, runAt.RunCompleted, false)); + } + // Run-lifecycle markers: operator square, agent diamond, with labels. const labelFor = { RunStarted: ["started", "operator"], diff --git a/docs/papers/2026-vaxautosci-scrubber.pdf b/docs/papers/2026-vaxautosci-scrubber.pdf index f7702ef5a8d..d808900e465 100644 Binary files a/docs/papers/2026-vaxautosci-scrubber.pdf and b/docs/papers/2026-vaxautosci-scrubber.pdf differ diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 656209e103f..c8eb073863b 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1013,6 +1013,10 @@ body::before { .cs-permit--ok { stroke: var(--cs-good); } .cs-permit--lost { stroke: var(--cs-alarm); stroke-dasharray: 2 2.5; } +/* Run lifecycle state line (solid active, dashed held), under the markers */ +.cs-run-line { stroke: var(--cs-sub); stroke-width: 1.6; stroke-linecap: butt; } +.cs-run-line--held { stroke-dasharray: 1.4 1.6; } + /* Run lifecycle markers */ .cs-life { stroke: var(--cs-surface); stroke-width: 1.2; } .cs-life--operator { fill: var(--cs-operator); } diff --git a/papers/2026-vaxautosci-scrubber/README.md b/papers/2026-vaxautosci-scrubber/README.md index 8e8ad1d66a3..36ef88e55a8 100644 --- a/papers/2026-vaxautosci-scrubber/README.md +++ b/papers/2026-vaxautosci-scrubber/README.md @@ -14,9 +14,10 @@ record. conference style, published to IEEE Xplore. - **Deadline:** July 8 2026 (23:59 AoE). - **Status:** Full draft, builds on the official VGTC conference class. - `main.tex` is 5 pages total (references start on page 5), about 4 pages of body - excluding references, inside the 4-6 limit. All sections written; all figures - (F1, F2, F3, Table 1, F5) rendered from real run data; references confirmed. + `main.tex` builds to 7 pages total; the body fits inside the 4-6 page limit, + with references beginning on page 6 and continuing to page 7. All sections + written; all figures (F1, F2, F3, Table 1, F5) rendered from real run data; + references confirmed. ## Topic landing (PCS) @@ -93,9 +94,10 @@ The official VGTC conference class is vendored here (`vgtc.cls` + latexmk -pdf main.tex -This produces the exact VGTC conference format: 5 pages total, references -beginning on page 5, so about 4 pages of body excluding references, inside the -4-6 page limit. For double-blind review, switch to +This produces the exact VGTC conference format: 7 pages total, with the body +inside the 4-6 page limit and references beginning on page 6. Submit the +LaTeX-built `main.pdf` from this folder; do not submit a macOS Preview-resaved +desktop copy. For double-blind review, switch to `\documentclass[review]{vgtc}` and set `\onlineid` to the assigned id. `preview.tex` is a no-frills IEEEtran proxy over the same shared sections, for @@ -107,7 +109,8 @@ vendored class and bibstyle files are tracked. - [x] Produce the figures (F1, F2, F3, Table 1, F5) from real run data. - [x] Fill all sections. - [x] Confirm the four flagged references in `refs.bib`. -- [x] Build `main.tex` on the official VGTC class (5 pages, within limit). +- [x] Build `main.tex` on the official VGTC class (7 pages total, body within + the 4-6 page limit). - [x] Author block: Doga Gursoy (corresponding, dgursoy@anl.gov) and Francesco De Carlo (no email), Advanced Photon Source, Argonne National Laboratory, single-blind. For double-blind instead, set `\documentclass[review]{vgtc}` diff --git a/papers/2026-vaxautosci-scrubber/data/README.md b/papers/2026-vaxautosci-scrubber/data/README.md index 4ac57ce72e8..ff2b5bddbe2 100644 --- a/papers/2026-vaxautosci-scrubber/data/README.md +++ b/papers/2026-vaxautosci-scrubber/data/README.md @@ -5,34 +5,47 @@ artifact). ## `lights_out_run.json` -One lights-out, agent-supervised run at APS 2-BM: the single run all the figures -are drawn from. CORA conducts a rotation-axis centering alignment (a -four-iteration peak-bracket search on `SampleTop_X` that converges), the science -scan begins and its third projection is in flight when the beam drops, the +One robot-loaded, lights-out session at APS 2-BM: a sample-changing robot loads +two samples in turn, under one Campaign, and all the figures are drawn from it. +For each sample the robot mounts it onto the rotary stage, CORA conducts a +rotation-axis centering alignment (a four-iteration peak-bracket search on +`SampleTop_X` that converges), the science scan runs, and the robot dismounts it. +On the second sample the beam drops while the third projection is in flight, the RunSupervisor agent holds the run and auto-resumes it when the beam returns, the fly-scan is restarted, the scan finishes, and the collected dataset is written to disk before the run completes. **Provenance.** Values mirror the passing integration scenario -[`apps/api/tests/integration/scenarios/test_2bm_lights_out_supervised_alignment.py`](../../../apps/api/tests/integration/scenarios/test_2bm_lights_out_supervised_alignment.py), -which produces exactly these activities, iteration verdicts, run-lifecycle -events, and the supervisor Resume Decision against a real Kernel + Postgres. -Regenerate with `python3 data/build_lights_out_data.py`. +[`apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py`](../../../apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py), +which produces these activities, iteration verdicts, run-lifecycle events, the +robot mount/dismount custody events, and the supervisor Resume Decision against a +real Kernel + Postgres. Regenerate with `python3 data/build_lights_out_data.py`. + +**Scope (modeled vs. deployed).** The sample-change hardware at 2-BM (a UR3e arm +with its own EPICS control) is deployed and has executed mount/dismount cycles +with a beamline handshake; tomoscan is PV-scriptable. CORA's orchestration of the +robot is modeled in the scenario, played through the same real event store as the +rest of the run; the supervisor decision layer beyond hold/resume (FOV-fit and +lens-change branches, per-sample recentering as a supervised decision) is out of +scope. See the paper's Limitations section. **What it contains.** -- `run.events`: the four-beat lifecycle `RunStarted` (operator), `RunHeld` - (RunSupervisor), `RunResumed` (RunSupervisor, carrying a Resume Decision), - `RunCompleted` (operator) with per-event `by`/`role`. -- `iterations`: four centering passes with verdicts `[false, false, false, true]` - and the center-of-rotation residual falling 2.00 -> 1.05 -> 1.40 -> 0.30 px, - bracketed in [0.040, 0.080] mm and bisected to 0.060 mm. -- `activities`: setpoint / acquire / check per pass, the lock setpoint, the - fly-scan rotation setpoint, the fly-scan setup (taxi + PSO arm, before the - first frame and again on the restart), six science projections (two complete, - the third in flight at the beam loss), the re-acquired third projection plus - the rest of the scan, and the data save (write the dataset to HDF5/DXfile). +- `robot.events`: the four robot custody events, `mount` / `dismount` per sample, + attributed to the Manipulator sample-changer Asset. +- `samples`: the two samples with their `mount_at` / `dismount_at` custody window. +- `runs`: two run lifecycles, sample A `RunStarted` -> `RunCompleted` (a clean + run) and sample B `RunStarted` -> `RunHeld` -> `RunResumed` -> `RunCompleted` + (the RunSupervisor holds and resumes on beam loss, carrying a Resume Decision), + each with per-event `by`/`role`. +- `iterations`: four centering passes per sample with verdicts + `[false, false, false, true]`; sample A converges at a different center than + sample B (each sample's position relative to the fixed rotation axis). +- `activities`: per sample, setpoint / acquire / check per pass, the lock + setpoint, the fly-scan rotation setpoint, the fly-scan setup (taxi + PSO arm), + the science projections (sample B's third is in flight at the beam loss and is + re-acquired after resume), and the data save. - `provenance.cursor_at` / `beam_loss_at` / `beam_back_at`: the audit instant and - the hold window the figures use. + the hold window the figures use (on sample B's scan). **Caveats.** The `sampled_at` and event times are staggered synthetically and compressed for a readable axis; the scenario records one logical instant, and the diff --git a/papers/2026-vaxautosci-scrubber/data/build_lights_out_data.py b/papers/2026-vaxautosci-scrubber/data/build_lights_out_data.py index ad719a6c57d..778a85a5c57 100644 --- a/papers/2026-vaxautosci-scrubber/data/build_lights_out_data.py +++ b/papers/2026-vaxautosci-scrubber/data/build_lights_out_data.py @@ -1,21 +1,31 @@ #!/usr/bin/env python3 -"""Build the figure data for the lights-out supervised-alignment run. +"""Build the figure data for the robot-loaded, lights-out tomography run. -Emits lights_out_run.json: one autonomous run combining a conducted -rotation-axis centering alignment (a 4-iteration peak-bracket search that -converges), a science scan whose third projection is in flight when the beam -drops, the RunSupervisor agent's hold, auto-resume, and fly-scan restart, and -completion. This is the run the paper's figures are drawn from. +Emits lights_out_run.json: one autonomous overnight session in which a +sample-changing robot loads two samples in turn. For each sample the system +mounts it, conducts a rotation-axis centering alignment (a 4-iteration +peak-bracket search that converges), runs the science scan, then dismounts it. +On the second sample the beam drops while the third projection is in flight; the +RunSupervisor agent holds the run, auto-resumes when the beam returns, and the +fly-scan restarts. This is the run the paper's figures are drawn from. Provenance. Values mirror the passing integration scenario - apps/api/tests/integration/scenarios/test_2bm_lights_out_supervised_alignment.py + apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py -which produces exactly these activities, iteration verdicts, run-lifecycle -events, and the supervisor Resume Decision against a real Kernel + Postgres. -Run with the standard library only (no database). Timestamps are staggered -synthetically for a readable axis (the scenario records one logical instant); -the overnight wall-clock spread is illustrative. +which produces these activities, iteration verdicts, run-lifecycle events, the +robot mount/dismount custody events, and the supervisor Resume Decision against +a real Kernel + Postgres. Run with the standard library only (no database). +Timestamps are staggered synthetically for a readable axis (the scenario records +one logical instant); the overnight wall-clock spread is illustrative. + +Scope (modeled vs. deployed). The sample-change hardware at 2-BM (a UR3e arm +with its own EPICS control) is deployed and has executed mount/dismount cycles +with a beamline handshake; tomoscan is PV-scriptable. CORA's orchestration of +the robot, and the supervisor decision layer (FOV-fit and lens-change branches, +per-sample recentering verdicts), are modeled here rather than deployed, and are +played through the same real Kernel + Postgres event store as the rest of the +run. See the paper's Limitations section. """ from __future__ import annotations @@ -35,8 +45,22 @@ def _at(seconds: float) -> str: return _iso(_BASE + timedelta(seconds=seconds)) -# Per-iteration centering search on SampleTop_X (minimize COR residual, px). -_ITERATIONS = [ +# Two samples the robot loads in turn. Sample A converges to a different center +# than sample B: the per-sample recentering finds each sample's own position +# relative to the fixed rotation axis. Sample B carries the canonical numbers the +# paper walks through, and the beam-loss / hold lands on its scan. +_SAMPLE_A_ITERATIONS = [ + {"index": 1, "target_mm": 0.000, "role": "initial", "residual": 1.80, + "converged": False, "reason": "initial residual 1.80 px; minimum not yet bracketed"}, + {"index": 2, "target_mm": 0.030, "role": "step_positive", "residual": 0.90, "direction": "better", + "converged": False, "reason": "residual improving (0.90 px); minimum not yet bracketed"}, + {"index": 3, "target_mm": 0.060, "role": "step_positive", "residual": 1.15, "direction": "worse", + "converged": False, "reason": "residual rose to 1.15 px; minimum bracketed in [0.030, 0.060] mm"}, + {"index": 4, "target_mm": 0.045, "role": "bisect", "residual": 0.25, "direction": "minimum", + "passed": True, "converged": True, "reason": None}, +] + +_SAMPLE_B_ITERATIONS = [ {"index": 1, "target_mm": 0.000, "role": "initial", "residual": 2.00, "converged": False, "reason": "initial residual 2.00 px; minimum not yet bracketed"}, {"index": 2, "target_mm": 0.040, "role": "step_positive", "residual": 1.05, "direction": "better", @@ -56,30 +80,50 @@ def _at(seconds: float) -> str: "ProcedureCompleted", ] -# Wall-clock anchors (synthetic): alignment runs ~a minute; the scan begins and -# the beam drops during the third projection; later it returns; resume; restart. -# Axis times are synthetic and compressed for a readable single axis; the -# overnight wall-clock spread (the hold can last tens of minutes) lives in the -# prose, not the axis. -_ITER_STRIDE = 12.0 # one acquire every 12 s; the whole axis is a 12 s grid -_BEAM_LOSS = 102.0 # RunHeld: the beam drops during the third projection -_BEAM_BACK = 150.0 # RunResumed (beam returns) after a 5-stride hold -_SAVE_AT = 210.0 # write the dataset one stride after the last projection -_RUN_DONE = 216.0 # run completes right after the save +# Synthetic wall-clock grid. Two per-sample blocks laid on one axis; the beam +# drops during sample B's scan. Axis times are compressed for a readable single +# axis (the overnight spread, and the tens-of-minutes hold, live in the prose). +_ROBOT = "Manipulator (UR3e sample changer)" +_ITER_LEN = 8.0 # one align iteration block: setpoint, acquire, check +_ALIGN_STRIDE = 8.0 +_SCAN_STRIDE = 4.0 # one projection every 4 s within a scan -def _build() -> dict: +def _sample_block( + *, + sample: int, + subject: str, + base: float, + iterations_spec: list[dict], + beam_loss: bool, + seq_start: int, +) -> dict: + """Build one sample's activities, iterations, and lane anchors. + + Layout within the block (offsets from `base`): + robot mount at 0; run starts at 4; alignment iterations from 8; + lock + fly-scan setup; the science scan; (for the beam-loss sample) a + hold + resume + restart; the dataset write; robot dismount. + Returns the block's activities/iterations plus the timing anchors the + renderer needs (custody band, robot events, run events, scan markers). + """ activities: list[dict] = [] iterations: list[dict] = [] - seq = 0 + seq = seq_start + + mount_at = base + 0.0 + custody_start = base + 3.0 + run_started = base + 4.0 + align_base = base + 8.0 - for spec in _ITERATIONS: + for spec in iterations_spec: i = spec["index"] - start = (i - 1) * _ITER_STRIDE + start = align_base + (i - 1) * _ALIGN_STRIDE iterations.append({ + "sample": sample, "iteration_index": i, "started_at": _at(start), - "ended_at": _at(start + 10.0), + "ended_at": _at(start + 7.0), "converged": spec["converged"], "reason": spec["reason"], }) @@ -88,7 +132,7 @@ def _build() -> dict: "units": "mm", "role": spec["role"], } if i == 1: - setpoint_payload["note"] = "user-supplied start" + setpoint_payload["note"] = "recenter after mount" check_payload = { "channel": "cor_residual", "passed": spec.get("passed", False), "source": "tomopy.recon.rotation", "actual": spec["residual"], "units": "px", @@ -97,127 +141,194 @@ def _build() -> dict: check_payload["direction"] = spec["direction"] for kind, payload, at in ( ("setpoint", setpoint_payload, start + 2.0), - ("action", {"action_name": "acquire_frame", "params": {"exposure_ms": 100, "purpose": "alignment"}}, start + 6.0), - ("check", check_payload, start + 9.0), + ("action", {"action_name": "acquire_frame", + "params": {"exposure_ms": 100, "purpose": "alignment"}}, start + 4.0), + ("check", check_payload, start + 6.0), ): seq += 1 activities.append({ - "seq": seq, "iteration": i, "step_kind": kind, + "seq": seq, "sample": sample, "iteration": i, "step_kind": kind, "payload": payload, "sampled_at": _at(at), "result": None, }) - # Lock at the converged center and command the fly-scan rotation. - seq += 1 - activities.append({ - "seq": seq, "iteration": None, "step_kind": "setpoint", - "payload": {"channel": "SampleTop_X", "target_value": 0.060, "units": "mm", "role": "lock_at_center"}, - "sampled_at": _at(46.0), "result": None, - }) - seq += 1 - activities.append({ - "seq": seq, "iteration": None, "step_kind": "setpoint", - "payload": {"channel": "rotation_angle", "target_value": 180.0, "units": "deg", - "role": "fly_scan", "note": "continuous 0->180 deg sweep"}, - "sampled_at": _at(50.0), "result": None, - }) + align_end = align_base + len(iterations_spec) * _ALIGN_STRIDE - def _proj(index: int, angle: float, at: float, result: str) -> None: + def _act(step_kind: str, payload: dict, at: float, result: str | None) -> None: nonlocal seq seq += 1 activities.append({ - "seq": seq, "iteration": None, "step_kind": "action", - "payload": {"action_name": "acquire_projection", - "params": {"exposure_ms": 100, "angle_deg": angle, "index": index}, - "result": result}, - "sampled_at": _at(at), "result": result, + "seq": seq, "sample": sample, "iteration": None, "step_kind": step_kind, + "payload": payload, "sampled_at": _at(at), "result": result, }) + converged_center = iterations_spec[-1]["target_mm"] + _act("setpoint", + {"channel": "SampleTop_X", "target_value": converged_center, "units": "mm", + "role": "lock_at_center"}, align_end + 2.0, None) + _act("setpoint", + {"channel": "rotation_angle", "target_value": 180.0, "units": "deg", + "role": "fly_scan", "note": "continuous 0->180 deg sweep"}, align_end + 4.0, None) + + scan_base = align_end + 6.0 + def _taxi_prep(taxi_at: float, prep_at: float) -> None: - nonlocal seq - seq += 1 - activities.append({ - "seq": seq, "iteration": None, "step_kind": "setpoint", - "payload": {"channel": "rotation_angle", "target_value": -5.0, "units": "deg", - "role": "taxi", "note": "run-up to constant velocity"}, - "sampled_at": _at(taxi_at), "result": None, - }) - seq += 1 - activities.append({ - "seq": seq, "iteration": None, "step_kind": "action", - "payload": {"action_name": "fly_scan_prep", "params": {"rearm_pso": True}, "result": "ok"}, - "sampled_at": _at(prep_at), "result": "ok", - }) + _act("setpoint", + {"channel": "rotation_angle", "target_value": -5.0, "units": "deg", + "role": "taxi", "note": "run-up to constant velocity"}, taxi_at, None) + _act("action", + {"action_name": "fly_scan_prep", "params": {"rearm_pso": True}, "result": "ok"}, + prep_at, "ok") + + def _proj(index: int, angle: float, at: float, result: str) -> None: + _act("action", + {"action_name": "acquire_projection", + "params": {"exposure_ms": 100, "angle_deg": angle, "index": index}, + "result": result}, at, result) + + _taxi_prep(scan_base, scan_base + 2.0) + proj_base = scan_base + 4.0 - # Fly-scan taxi + PSO arm before the first frame, then the scan on the 12 s - # grid: two projections complete and the third is in flight when the beam - # drops at 102 s. - _taxi_prep(54.0, 58.0) - _proj(1, 0.0, 66.0, "ok") - _proj(2, 30.0, 78.0, "ok") - _proj(3, 60.0, 90.0, "in_flight") - - # After the hold the fly-scan is restarted (taxi back to constant velocity, - # re-arm the PSO) before the interrupted third projection is re-acquired and - # the scan finishes. - _taxi_prep(154.0, 158.0) - _proj(3, 60.0, 162.0, "ok") - _proj(4, 90.0, 174.0, "ok") - _proj(5, 120.0, 186.0, "ok") - _proj(6, 150.0, 198.0, "ok") - - # Save the collected scan to disk: the data-collection run ends here. - seq += 1 - activities.append({ - "seq": seq, "iteration": None, "step_kind": "action", - "payload": {"action_name": "write_dataset", - "params": {"format": "dxfile-hdf5", "projections": 6}, "result": "ok"}, - "sampled_at": _at(_SAVE_AT), "result": "ok", - }) - - run_events = [ - {"type": "RunStarted", "at": _at(0.0), "by": "operator", "role": "human"}, - {"type": "RunHeld", "at": _at(_BEAM_LOSS), "by": "RunSupervisor", "role": "agent", - "decision": {"context": "RunSupervision", "choice": "Hold"}}, - {"type": "RunResumed", "at": _at(_BEAM_BACK), "by": "RunSupervisor", "role": "agent", - "decision": {"context": "RunSupervision", "choice": "Resume"}}, - {"type": "RunCompleted", "at": _at(_RUN_DONE), "by": "operator", "role": "human"}, + beam_loss_at: float | None = None + beam_back_at: float | None = None + run_events: list[dict] + + if not beam_loss: + # Clean sample: three projections, all good, then the dataset write. + _proj(1, 0.0, proj_base, "ok") + _proj(2, 90.0, proj_base + _SCAN_STRIDE, "ok") + _proj(3, 180.0, proj_base + 2 * _SCAN_STRIDE, "ok") + save_at = proj_base + 2 * _SCAN_STRIDE + _SCAN_STRIDE + _act("action", + {"action_name": "write_dataset", + "params": {"format": "dxfile-hdf5", "projections": 3}, "result": "ok"}, + save_at, "ok") + custody_end = save_at + 2.0 + dismount_at = custody_end + 1.0 + run_done = save_at + 1.0 + run_events = [ + {"type": "RunStarted", "at": _at(run_started), "by": "operator", "role": "human"}, + {"type": "RunCompleted", "at": _at(run_done), "by": "operator", "role": "human"}, + ] + else: + # Beam-loss sample: two projections complete, the third is in flight when + # the beam drops; the supervisor holds, then resumes; the fly-scan + # restarts and the scan runs to completion. + _proj(1, 0.0, proj_base, "ok") + _proj(2, 30.0, proj_base + _SCAN_STRIDE, "ok") + _proj(3, 60.0, proj_base + 2 * _SCAN_STRIDE, "in_flight") + beam_loss_at = proj_base + 2 * _SCAN_STRIDE + 2.0 + beam_back_at = beam_loss_at + 20.0 + _taxi_prep(beam_back_at + 2.0, beam_back_at + 4.0) + restart_base = beam_back_at + 6.0 + _proj(3, 60.0, restart_base, "ok") + _proj(4, 90.0, restart_base + _SCAN_STRIDE, "ok") + _proj(5, 120.0, restart_base + 2 * _SCAN_STRIDE, "ok") + _proj(6, 150.0, restart_base + 3 * _SCAN_STRIDE, "ok") + save_at = restart_base + 3 * _SCAN_STRIDE + _SCAN_STRIDE + _act("action", + {"action_name": "write_dataset", + "params": {"format": "dxfile-hdf5", "projections": 6}, "result": "ok"}, + save_at, "ok") + custody_end = save_at + 2.0 + dismount_at = custody_end + 1.0 + run_done = save_at + 1.0 + run_events = [ + {"type": "RunStarted", "at": _at(run_started), "by": "operator", "role": "human"}, + {"type": "RunHeld", "at": _at(beam_loss_at), "by": "RunSupervisor", "role": "agent", + "decision": {"context": "RunSupervision", "choice": "Hold"}}, + {"type": "RunResumed", "at": _at(beam_back_at), "by": "RunSupervisor", "role": "agent", + "decision": {"context": "RunSupervision", "choice": "Resume"}}, + {"type": "RunCompleted", "at": _at(run_done), "by": "operator", "role": "human"}, + ] + + return { + "sample": sample, + "subject": subject, + "custody": {"mount_at": _at(custody_start), "dismount_at": _at(custody_end)}, + "robot_events": [ + {"action": "mount", "subject": subject, "at": _at(mount_at)}, + {"action": "dismount", "subject": subject, "at": _at(dismount_at)}, + ], + "run": { + "index": sample, + "subject": subject, + "events": run_events, + }, + "iterations": iterations, + "activities": activities, + "beam_loss_at": _at(beam_loss_at) if beam_loss_at is not None else None, + "beam_back_at": _at(beam_back_at) if beam_back_at is not None else None, + "seq_end": seq, + } + + +def _build() -> dict: + a = _sample_block( + sample=1, subject="sample A (porous sandstone core)", base=0.0, + iterations_spec=_SAMPLE_A_ITERATIONS, beam_loss=False, seq_start=0, + ) + b = _sample_block( + sample=2, subject="sample B (porous sandstone core)", base=72.0, + iterations_spec=_SAMPLE_B_ITERATIONS, beam_loss=True, seq_start=a["seq_end"], + ) + + activities = a["activities"] + b["activities"] + iterations = a["iterations"] + b["iterations"] + robot_events = a["robot_events"] + b["robot_events"] + samples = [ + {"index": a["sample"], "subject": a["subject"], **a["custody"]}, + {"index": b["sample"], "subject": b["subject"], **b["custody"]}, ] + runs = [a["run"], b["run"]] + + beam_loss_at = b["beam_loss_at"] + beam_back_at = b["beam_back_at"] return { "provenance": { "source": ( "Values mirror the passing integration scenario " "apps/api/tests/integration/scenarios/" - "test_2bm_lights_out_supervised_alignment.py, which produces these " - "activities, iteration verdicts, run-lifecycle events, and the " - "supervisor Resume Decision against a real Kernel + Postgres." + "test_2bm_robot_lights_out_two_sample.py, which produces these " + "activities, iteration verdicts, run-lifecycle events, robot " + "mount/dismount custody events, and the supervisor Resume Decision " + "against a real Kernel + Postgres." ), "run": ( - "Lights-out, agent-supervised run at APS 2-BM: conducted rotation-axis " - "centering alignment, the science scan's third projection interrupted " - "by beam loss, RunSupervisor hold + auto-resume + fly-scan restart, " - "then the scan continues to completion." + "Robot-loaded, lights-out session at APS 2-BM: a sample-changing " + "robot loads two samples in turn; each is recentered and scanned; " + "the beam drops during the second sample's scan, and the " + "RunSupervisor holds + auto-resumes + restarts the fly-scan." + ), + "scope": ( + "The sample-change hardware and its EPICS control are deployed and " + "have run; CORA's orchestration of the robot and the supervisor " + "decision layer are modeled here, played through the real event " + "store. See the paper's Limitations section." ), "timestamps": ( - "sampled_at / event times staggered synthetically for a readable axis; " - "the scenario records one logical instant. Overnight spread is illustrative." + "sampled_at / event times staggered synthetically for a readable " + "axis; the scenario records one logical instant. Overnight spread " + "and the tens-of-minutes hold are illustrative." ), - "cursor_at": _at(_BEAM_LOSS), - "beam_loss_at": _at(_BEAM_LOSS), - "beam_back_at": _at(_BEAM_BACK), + "cursor_at": beam_loss_at, + "beam_loss_at": beam_loss_at, + "beam_back_at": beam_back_at, "generated_by": "data/build_lights_out_data.py", }, - "run": { - "name": "2-BM lights-out tomography (pre-scan align + science scan)", - "supervisor_agent": "RunSupervisor (deterministic)", - "events": run_events, + "robot": { + "asset": _ROBOT, + "role": "Positioner (Manipulator family); loads/unloads the sample Subject", + "events": robot_events, }, + "samples": samples, + "runs": runs, "procedure": { "name": "2-BM rotation-axis centering (pre-scan alignment)", "kind": "alignment", "phase_of_run": True, "events": _PROCEDURE_EVENTS, - "iteration_count": len(_ITERATIONS), + "per_sample": True, }, "iterations": iterations, "activities": activities, @@ -228,11 +339,12 @@ def main() -> None: out = Path(__file__).parent / "lights_out_run.json" data = _build() out.write_text(json.dumps(data, indent=2) + "\n") - verdicts = [it["converged"] for it in data["iterations"]] + n_samples = len(data["samples"]) + n_runs = len(data["runs"]) print( - f"wrote {out} : {len(data['activities'])} activities, " - f"{len(data['iterations'])} iterations verdicts={verdicts}, " - f"run={[e['type'] for e in data['run']['events']]}" + f"wrote {out} : {n_samples} samples, {n_runs} runs, " + f"{len(data['activities'])} activities, {len(data['iterations'])} iterations, " + f"robot events={[e['action'] for e in data['robot']['events']]}" ) diff --git a/papers/2026-vaxautosci-scrubber/data/lights_out_run.json b/papers/2026-vaxautosci-scrubber/data/lights_out_run.json index bf9414bc9d9..0d5682ba545 100644 --- a/papers/2026-vaxautosci-scrubber/data/lights_out_run.json +++ b/papers/2026-vaxautosci-scrubber/data/lights_out_run.json @@ -1,51 +1,112 @@ { "provenance": { - "source": "Values mirror the passing integration scenario apps/api/tests/integration/scenarios/test_2bm_lights_out_supervised_alignment.py, which produces these activities, iteration verdicts, run-lifecycle events, and the supervisor Resume Decision against a real Kernel + Postgres.", - "run": "Lights-out, agent-supervised run at APS 2-BM: conducted rotation-axis centering alignment, the science scan's third projection interrupted by beam loss, RunSupervisor hold + auto-resume + fly-scan restart, then the scan continues to completion.", - "timestamps": "sampled_at / event times staggered synthetically for a readable axis; the scenario records one logical instant. Overnight spread is illustrative.", - "cursor_at": "2026-05-19T01:01:42Z", - "beam_loss_at": "2026-05-19T01:01:42Z", - "beam_back_at": "2026-05-19T01:02:30Z", + "source": "Values mirror the passing integration scenario apps/api/tests/integration/scenarios/test_2bm_robot_lights_out_two_sample.py, which produces these activities, iteration verdicts, run-lifecycle events, robot mount/dismount custody events, and the supervisor Resume Decision against a real Kernel + Postgres.", + "run": "Robot-loaded, lights-out session at APS 2-BM: a sample-changing robot loads two samples in turn; each is recentered and scanned; the beam drops during the second sample's scan, and the RunSupervisor holds + auto-resumes + restarts the fly-scan.", + "scope": "The sample-change hardware and its EPICS control are deployed and have run; CORA's orchestration of the robot and the supervisor decision layer are modeled here, played through the real event store. See the paper's Limitations section.", + "timestamps": "sampled_at / event times staggered synthetically for a readable axis; the scenario records one logical instant. Overnight spread and the tens-of-minutes hold are illustrative.", + "cursor_at": "2026-05-19T01:02:12Z", + "beam_loss_at": "2026-05-19T01:02:12Z", + "beam_back_at": "2026-05-19T01:02:32Z", "generated_by": "data/build_lights_out_data.py" }, - "run": { - "name": "2-BM lights-out tomography (pre-scan align + science scan)", - "supervisor_agent": "RunSupervisor (deterministic)", + "robot": { + "asset": "Manipulator (UR3e sample changer)", + "role": "Positioner (Manipulator family); loads/unloads the sample Subject", "events": [ { - "type": "RunStarted", - "at": "2026-05-19T01:00:00Z", - "by": "operator", - "role": "human" + "action": "mount", + "subject": "sample A (porous sandstone core)", + "at": "2026-05-19T01:00:00Z" }, { - "type": "RunHeld", - "at": "2026-05-19T01:01:42Z", - "by": "RunSupervisor", - "role": "agent", - "decision": { - "context": "RunSupervision", - "choice": "Hold" - } + "action": "dismount", + "subject": "sample A (porous sandstone core)", + "at": "2026-05-19T01:01:05Z" }, { - "type": "RunResumed", - "at": "2026-05-19T01:02:30Z", - "by": "RunSupervisor", - "role": "agent", - "decision": { - "context": "RunSupervision", - "choice": "Resume" - } + "action": "mount", + "subject": "sample B (porous sandstone core)", + "at": "2026-05-19T01:01:12Z" }, { - "type": "RunCompleted", - "at": "2026-05-19T01:03:36Z", - "by": "operator", - "role": "human" + "action": "dismount", + "subject": "sample B (porous sandstone core)", + "at": "2026-05-19T01:02:57Z" } ] }, + "samples": [ + { + "index": 1, + "subject": "sample A (porous sandstone core)", + "mount_at": "2026-05-19T01:00:03Z", + "dismount_at": "2026-05-19T01:01:04Z" + }, + { + "index": 2, + "subject": "sample B (porous sandstone core)", + "mount_at": "2026-05-19T01:01:15Z", + "dismount_at": "2026-05-19T01:02:56Z" + } + ], + "runs": [ + { + "index": 1, + "subject": "sample A (porous sandstone core)", + "events": [ + { + "type": "RunStarted", + "at": "2026-05-19T01:00:04Z", + "by": "operator", + "role": "human" + }, + { + "type": "RunCompleted", + "at": "2026-05-19T01:01:03Z", + "by": "operator", + "role": "human" + } + ] + }, + { + "index": 2, + "subject": "sample B (porous sandstone core)", + "events": [ + { + "type": "RunStarted", + "at": "2026-05-19T01:01:16Z", + "by": "operator", + "role": "human" + }, + { + "type": "RunHeld", + "at": "2026-05-19T01:02:12Z", + "by": "RunSupervisor", + "role": "agent", + "decision": { + "context": "RunSupervision", + "choice": "Hold" + } + }, + { + "type": "RunResumed", + "at": "2026-05-19T01:02:32Z", + "by": "RunSupervisor", + "role": "agent", + "decision": { + "context": "RunSupervision", + "choice": "Resume" + } + }, + { + "type": "RunCompleted", + "at": "2026-05-19T01:02:55Z", + "by": "operator", + "role": "human" + } + ] + } + ], "procedure": { "name": "2-BM rotation-axis centering (pre-scan alignment)", "kind": "alignment", @@ -64,34 +125,70 @@ "ProcedureIterationEnded", "ProcedureCompleted" ], - "iteration_count": 4 + "per_sample": true }, "iterations": [ { + "sample": 1, + "iteration_index": 1, + "started_at": "2026-05-19T01:00:08Z", + "ended_at": "2026-05-19T01:00:15Z", + "converged": false, + "reason": "initial residual 1.80 px; minimum not yet bracketed" + }, + { + "sample": 1, + "iteration_index": 2, + "started_at": "2026-05-19T01:00:16Z", + "ended_at": "2026-05-19T01:00:23Z", + "converged": false, + "reason": "residual improving (0.90 px); minimum not yet bracketed" + }, + { + "sample": 1, + "iteration_index": 3, + "started_at": "2026-05-19T01:00:24Z", + "ended_at": "2026-05-19T01:00:31Z", + "converged": false, + "reason": "residual rose to 1.15 px; minimum bracketed in [0.030, 0.060] mm" + }, + { + "sample": 1, + "iteration_index": 4, + "started_at": "2026-05-19T01:00:32Z", + "ended_at": "2026-05-19T01:00:39Z", + "converged": true, + "reason": null + }, + { + "sample": 2, "iteration_index": 1, - "started_at": "2026-05-19T01:00:00Z", - "ended_at": "2026-05-19T01:00:10Z", + "started_at": "2026-05-19T01:01:20Z", + "ended_at": "2026-05-19T01:01:27Z", "converged": false, "reason": "initial residual 2.00 px; minimum not yet bracketed" }, { + "sample": 2, "iteration_index": 2, - "started_at": "2026-05-19T01:00:12Z", - "ended_at": "2026-05-19T01:00:22Z", + "started_at": "2026-05-19T01:01:28Z", + "ended_at": "2026-05-19T01:01:35Z", "converged": false, "reason": "residual improving (1.05 px); minimum not yet bracketed" }, { + "sample": 2, "iteration_index": 3, - "started_at": "2026-05-19T01:00:24Z", - "ended_at": "2026-05-19T01:00:34Z", + "started_at": "2026-05-19T01:01:36Z", + "ended_at": "2026-05-19T01:01:43Z", "converged": false, "reason": "residual rose to 1.40 px; minimum bracketed in [0.040, 0.080] mm" }, { + "sample": 2, "iteration_index": 4, - "started_at": "2026-05-19T01:00:36Z", - "ended_at": "2026-05-19T01:00:46Z", + "started_at": "2026-05-19T01:01:44Z", + "ended_at": "2026-05-19T01:01:51Z", "converged": true, "reason": null } @@ -99,6 +196,7 @@ "activities": [ { "seq": 1, + "sample": 1, "iteration": 1, "step_kind": "setpoint", "payload": { @@ -106,13 +204,14 @@ "target_value": 0.0, "units": "mm", "role": "initial", - "note": "user-supplied start" + "note": "recenter after mount" }, - "sampled_at": "2026-05-19T01:00:02Z", + "sampled_at": "2026-05-19T01:00:10Z", "result": null }, { "seq": 2, + "sample": 1, "iteration": 1, "step_kind": "action", "payload": { @@ -122,38 +221,41 @@ "purpose": "alignment" } }, - "sampled_at": "2026-05-19T01:00:06Z", + "sampled_at": "2026-05-19T01:00:12Z", "result": null }, { "seq": 3, + "sample": 1, "iteration": 1, "step_kind": "check", "payload": { "channel": "cor_residual", "passed": false, "source": "tomopy.recon.rotation", - "actual": 2.0, + "actual": 1.8, "units": "px" }, - "sampled_at": "2026-05-19T01:00:09Z", + "sampled_at": "2026-05-19T01:00:14Z", "result": null }, { "seq": 4, + "sample": 1, "iteration": 2, "step_kind": "setpoint", "payload": { "channel": "SampleTop_X", - "target_value": 0.04, + "target_value": 0.03, "units": "mm", "role": "step_positive" }, - "sampled_at": "2026-05-19T01:00:14Z", + "sampled_at": "2026-05-19T01:00:18Z", "result": null }, { "seq": 5, + "sample": 1, "iteration": 2, "step_kind": "action", "payload": { @@ -163,31 +265,33 @@ "purpose": "alignment" } }, - "sampled_at": "2026-05-19T01:00:18Z", + "sampled_at": "2026-05-19T01:00:20Z", "result": null }, { "seq": 6, + "sample": 1, "iteration": 2, "step_kind": "check", "payload": { "channel": "cor_residual", "passed": false, "source": "tomopy.recon.rotation", - "actual": 1.05, + "actual": 0.9, "units": "px", "direction": "better" }, - "sampled_at": "2026-05-19T01:00:21Z", + "sampled_at": "2026-05-19T01:00:22Z", "result": null }, { "seq": 7, + "sample": 1, "iteration": 3, "step_kind": "setpoint", "payload": { "channel": "SampleTop_X", - "target_value": 0.08, + "target_value": 0.06, "units": "mm", "role": "step_positive" }, @@ -196,6 +300,7 @@ }, { "seq": 8, + "sample": 1, "iteration": 3, "step_kind": "action", "payload": { @@ -205,39 +310,42 @@ "purpose": "alignment" } }, - "sampled_at": "2026-05-19T01:00:30Z", + "sampled_at": "2026-05-19T01:00:28Z", "result": null }, { "seq": 9, + "sample": 1, "iteration": 3, "step_kind": "check", "payload": { "channel": "cor_residual", "passed": false, "source": "tomopy.recon.rotation", - "actual": 1.4, + "actual": 1.15, "units": "px", "direction": "worse" }, - "sampled_at": "2026-05-19T01:00:33Z", + "sampled_at": "2026-05-19T01:00:30Z", "result": null }, { "seq": 10, + "sample": 1, "iteration": 4, "step_kind": "setpoint", "payload": { "channel": "SampleTop_X", - "target_value": 0.06, + "target_value": 0.045, "units": "mm", "role": "bisect" }, - "sampled_at": "2026-05-19T01:00:38Z", + "sampled_at": "2026-05-19T01:00:34Z", "result": null }, { "seq": 11, + "sample": 1, "iteration": 4, "step_kind": "action", "payload": { @@ -247,39 +355,42 @@ "purpose": "alignment" } }, - "sampled_at": "2026-05-19T01:00:42Z", + "sampled_at": "2026-05-19T01:00:36Z", "result": null }, { "seq": 12, + "sample": 1, "iteration": 4, "step_kind": "check", "payload": { "channel": "cor_residual", "passed": true, "source": "tomopy.recon.rotation", - "actual": 0.3, + "actual": 0.25, "units": "px", "direction": "minimum" }, - "sampled_at": "2026-05-19T01:00:45Z", + "sampled_at": "2026-05-19T01:00:38Z", "result": null }, { "seq": 13, + "sample": 1, "iteration": null, "step_kind": "setpoint", "payload": { "channel": "SampleTop_X", - "target_value": 0.06, + "target_value": 0.045, "units": "mm", "role": "lock_at_center" }, - "sampled_at": "2026-05-19T01:00:46Z", + "sampled_at": "2026-05-19T01:00:42Z", "result": null }, { "seq": 14, + "sample": 1, "iteration": null, "step_kind": "setpoint", "payload": { @@ -289,11 +400,12 @@ "role": "fly_scan", "note": "continuous 0->180 deg sweep" }, - "sampled_at": "2026-05-19T01:00:50Z", + "sampled_at": "2026-05-19T01:00:44Z", "result": null }, { "seq": 15, + "sample": 1, "iteration": null, "step_kind": "setpoint", "payload": { @@ -303,11 +415,12 @@ "role": "taxi", "note": "run-up to constant velocity" }, - "sampled_at": "2026-05-19T01:00:54Z", + "sampled_at": "2026-05-19T01:00:46Z", "result": null }, { "seq": 16, + "sample": 1, "iteration": null, "step_kind": "action", "payload": { @@ -317,11 +430,12 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:00:58Z", + "sampled_at": "2026-05-19T01:00:48Z", "result": "ok" }, { "seq": 17, + "sample": 1, "iteration": null, "step_kind": "action", "payload": { @@ -333,27 +447,335 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:01:06Z", + "sampled_at": "2026-05-19T01:00:50Z", "result": "ok" }, { "seq": 18, + "sample": 1, "iteration": null, "step_kind": "action", "payload": { "action_name": "acquire_projection", "params": { "exposure_ms": 100, - "angle_deg": 30.0, + "angle_deg": 90.0, "index": 2 }, "result": "ok" }, - "sampled_at": "2026-05-19T01:01:18Z", + "sampled_at": "2026-05-19T01:00:54Z", "result": "ok" }, { "seq": 19, + "sample": 1, + "iteration": null, + "step_kind": "action", + "payload": { + "action_name": "acquire_projection", + "params": { + "exposure_ms": 100, + "angle_deg": 180.0, + "index": 3 + }, + "result": "ok" + }, + "sampled_at": "2026-05-19T01:00:58Z", + "result": "ok" + }, + { + "seq": 20, + "sample": 1, + "iteration": null, + "step_kind": "action", + "payload": { + "action_name": "write_dataset", + "params": { + "format": "dxfile-hdf5", + "projections": 3 + }, + "result": "ok" + }, + "sampled_at": "2026-05-19T01:01:02Z", + "result": "ok" + }, + { + "seq": 21, + "sample": 2, + "iteration": 1, + "step_kind": "setpoint", + "payload": { + "channel": "SampleTop_X", + "target_value": 0.0, + "units": "mm", + "role": "initial", + "note": "recenter after mount" + }, + "sampled_at": "2026-05-19T01:01:22Z", + "result": null + }, + { + "seq": 22, + "sample": 2, + "iteration": 1, + "step_kind": "action", + "payload": { + "action_name": "acquire_frame", + "params": { + "exposure_ms": 100, + "purpose": "alignment" + } + }, + "sampled_at": "2026-05-19T01:01:24Z", + "result": null + }, + { + "seq": 23, + "sample": 2, + "iteration": 1, + "step_kind": "check", + "payload": { + "channel": "cor_residual", + "passed": false, + "source": "tomopy.recon.rotation", + "actual": 2.0, + "units": "px" + }, + "sampled_at": "2026-05-19T01:01:26Z", + "result": null + }, + { + "seq": 24, + "sample": 2, + "iteration": 2, + "step_kind": "setpoint", + "payload": { + "channel": "SampleTop_X", + "target_value": 0.04, + "units": "mm", + "role": "step_positive" + }, + "sampled_at": "2026-05-19T01:01:30Z", + "result": null + }, + { + "seq": 25, + "sample": 2, + "iteration": 2, + "step_kind": "action", + "payload": { + "action_name": "acquire_frame", + "params": { + "exposure_ms": 100, + "purpose": "alignment" + } + }, + "sampled_at": "2026-05-19T01:01:32Z", + "result": null + }, + { + "seq": 26, + "sample": 2, + "iteration": 2, + "step_kind": "check", + "payload": { + "channel": "cor_residual", + "passed": false, + "source": "tomopy.recon.rotation", + "actual": 1.05, + "units": "px", + "direction": "better" + }, + "sampled_at": "2026-05-19T01:01:34Z", + "result": null + }, + { + "seq": 27, + "sample": 2, + "iteration": 3, + "step_kind": "setpoint", + "payload": { + "channel": "SampleTop_X", + "target_value": 0.08, + "units": "mm", + "role": "step_positive" + }, + "sampled_at": "2026-05-19T01:01:38Z", + "result": null + }, + { + "seq": 28, + "sample": 2, + "iteration": 3, + "step_kind": "action", + "payload": { + "action_name": "acquire_frame", + "params": { + "exposure_ms": 100, + "purpose": "alignment" + } + }, + "sampled_at": "2026-05-19T01:01:40Z", + "result": null + }, + { + "seq": 29, + "sample": 2, + "iteration": 3, + "step_kind": "check", + "payload": { + "channel": "cor_residual", + "passed": false, + "source": "tomopy.recon.rotation", + "actual": 1.4, + "units": "px", + "direction": "worse" + }, + "sampled_at": "2026-05-19T01:01:42Z", + "result": null + }, + { + "seq": 30, + "sample": 2, + "iteration": 4, + "step_kind": "setpoint", + "payload": { + "channel": "SampleTop_X", + "target_value": 0.06, + "units": "mm", + "role": "bisect" + }, + "sampled_at": "2026-05-19T01:01:46Z", + "result": null + }, + { + "seq": 31, + "sample": 2, + "iteration": 4, + "step_kind": "action", + "payload": { + "action_name": "acquire_frame", + "params": { + "exposure_ms": 100, + "purpose": "alignment" + } + }, + "sampled_at": "2026-05-19T01:01:48Z", + "result": null + }, + { + "seq": 32, + "sample": 2, + "iteration": 4, + "step_kind": "check", + "payload": { + "channel": "cor_residual", + "passed": true, + "source": "tomopy.recon.rotation", + "actual": 0.3, + "units": "px", + "direction": "minimum" + }, + "sampled_at": "2026-05-19T01:01:50Z", + "result": null + }, + { + "seq": 33, + "sample": 2, + "iteration": null, + "step_kind": "setpoint", + "payload": { + "channel": "SampleTop_X", + "target_value": 0.06, + "units": "mm", + "role": "lock_at_center" + }, + "sampled_at": "2026-05-19T01:01:54Z", + "result": null + }, + { + "seq": 34, + "sample": 2, + "iteration": null, + "step_kind": "setpoint", + "payload": { + "channel": "rotation_angle", + "target_value": 180.0, + "units": "deg", + "role": "fly_scan", + "note": "continuous 0->180 deg sweep" + }, + "sampled_at": "2026-05-19T01:01:56Z", + "result": null + }, + { + "seq": 35, + "sample": 2, + "iteration": null, + "step_kind": "setpoint", + "payload": { + "channel": "rotation_angle", + "target_value": -5.0, + "units": "deg", + "role": "taxi", + "note": "run-up to constant velocity" + }, + "sampled_at": "2026-05-19T01:01:58Z", + "result": null + }, + { + "seq": 36, + "sample": 2, + "iteration": null, + "step_kind": "action", + "payload": { + "action_name": "fly_scan_prep", + "params": { + "rearm_pso": true + }, + "result": "ok" + }, + "sampled_at": "2026-05-19T01:02:00Z", + "result": "ok" + }, + { + "seq": 37, + "sample": 2, + "iteration": null, + "step_kind": "action", + "payload": { + "action_name": "acquire_projection", + "params": { + "exposure_ms": 100, + "angle_deg": 0.0, + "index": 1 + }, + "result": "ok" + }, + "sampled_at": "2026-05-19T01:02:02Z", + "result": "ok" + }, + { + "seq": 38, + "sample": 2, + "iteration": null, + "step_kind": "action", + "payload": { + "action_name": "acquire_projection", + "params": { + "exposure_ms": 100, + "angle_deg": 30.0, + "index": 2 + }, + "result": "ok" + }, + "sampled_at": "2026-05-19T01:02:06Z", + "result": "ok" + }, + { + "seq": 39, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -365,11 +787,12 @@ }, "result": "in_flight" }, - "sampled_at": "2026-05-19T01:01:30Z", + "sampled_at": "2026-05-19T01:02:10Z", "result": "in_flight" }, { - "seq": 20, + "seq": 40, + "sample": 2, "iteration": null, "step_kind": "setpoint", "payload": { @@ -383,7 +806,8 @@ "result": null }, { - "seq": 21, + "seq": 41, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -393,11 +817,12 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:02:38Z", + "sampled_at": "2026-05-19T01:02:36Z", "result": "ok" }, { - "seq": 22, + "seq": 42, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -409,11 +834,12 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:02:42Z", + "sampled_at": "2026-05-19T01:02:38Z", "result": "ok" }, { - "seq": 23, + "seq": 43, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -425,11 +851,12 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:02:54Z", + "sampled_at": "2026-05-19T01:02:42Z", "result": "ok" }, { - "seq": 24, + "seq": 44, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -441,11 +868,12 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:03:06Z", + "sampled_at": "2026-05-19T01:02:46Z", "result": "ok" }, { - "seq": 25, + "seq": 45, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -457,11 +885,12 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:03:18Z", + "sampled_at": "2026-05-19T01:02:50Z", "result": "ok" }, { - "seq": 26, + "seq": 46, + "sample": 2, "iteration": null, "step_kind": "action", "payload": { @@ -472,7 +901,7 @@ }, "result": "ok" }, - "sampled_at": "2026-05-19T01:03:30Z", + "sampled_at": "2026-05-19T01:02:54Z", "result": "ok" } ] diff --git a/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.pdf b/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.pdf index fd885521817..7aeeb20f1e2 100644 Binary files a/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.pdf and b/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.pdf differ diff --git a/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.png b/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.png index cb9e02a5793..1760d051e8d 100644 Binary files a/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.png and b/papers/2026-vaxautosci-scrubber/figures/f1_scrubber.png differ diff --git a/papers/2026-vaxautosci-scrubber/figures/render_f1.py b/papers/2026-vaxautosci-scrubber/figures/render_f1.py index b51582fc337..1f75e9f7a1a 100644 --- a/papers/2026-vaxautosci-scrubber/figures/render_f1.py +++ b/papers/2026-vaxautosci-scrubber/figures/render_f1.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 """Render Figure 1, the replay scrubber, from data/lights_out_run.json. -Static rendering of the interactive scrubber over one agent-supervised -run: a run-lifecycle / who-drove-it lane (operator vs supervisor), -per-iteration convergence bands colored by verdict (the rotation-axis -centering search), activity swim-lanes, a shaded held band, and a fold-to- -version cursor parked at the beam-loss instant, where the third projection is an -open interval (in flight, no outcome yet); the science scan continues after -resume. +Static rendering of the interactive scrubber over one robot-loaded, lights-out +session: a sample-changing robot loads two samples in turn (a sample-custody +lane and a robot mount/dismount lane), each sample is recentered (per-sample +convergence bands colored by verdict) and scanned, and on the second sample the +beam drops mid-scan (the third projection is an open interval), the supervisor +holds and resumes, and the fly-scan restarts. The fold-to-version cursor is +parked at the beam-loss instant. + +Lanes are grouped by subsystem, top to bottom: sample handling (custody, robot), +run and safety (run lifecycle, beam permit), then the science swim-lanes. Full-width figure: rendered at the full text width. Run: uv run --no-project --with matplotlib python figures/render_f1.py @@ -29,17 +32,22 @@ LANE_LABEL = {2: "Setpoint", 1: "Acquire", 0: "Check"} LANE_COLOR = {"setpoint": s.INK, "action": s.SUBINK, "check": s.STATE} LANE_MARKER = {"setpoint": "s", "action": "^", "check": "o"} -Y_OUTPUT = -1.4 # output lane (dataset written to disk), below the swim-lanes -Y_BAND = (-0.6, 2.55) # iteration band spans the three acquisition swim-lanes -Y_PERMIT = 3.2 # beam-permit lane (safety envelope the supervisor gates on) -Y_RUN = 4.0 # run-lifecycle / who-drove-it lane -Y_PHASE = (4.95, 5.30) # top strip of color-coded phase bars -# The run's three phases, color-coded (light tint + dark label). +# Lane y-coordinates, grouped by subsystem (top to bottom). The run/safety and +# sample-handling lanes are spaced a little wider than the science swim-lanes +# because each carries compact two-line marker labels above and below its +# baseline; gaps are kept as tight as those labels allow. +Y_OUTPUT = -1.15 # dataset written to disk, below the science swim-lanes +Y_BAND = (-0.5, 2.35) # iteration band spans the three science swim-lanes +Y_PERMIT = 2.9 # run + safety group +Y_RUN = 3.75 +Y_ROBOT = 4.85 # sample-handling group +Y_CUSTODY = 5.75 +Y_PHASE = (6.35, 6.65) # top strip: per-sample phase bars + PHASE_STYLE = { - "Alignment (converged)": ("#DCE6F2", "#2C5282"), - "Scan": ("#D7ECE9", "#1D6F66"), - "Save": ("#E7E1F2", "#5B4A8A"), + "Sample A": ("#DCE6F2", "#2C5282"), + "Sample B": ("#D7ECE9", "#1D6F66"), } @@ -50,9 +58,13 @@ def _parse(x: str) -> dt.datetime: def main() -> None: acts = DATA["activities"] iters = DATA["iterations"] - run_events = DATA["run"]["events"] + runs = DATA["runs"] + samples = DATA["samples"] + robot = DATA["robot"] prov = DATA["provenance"] - t0 = _parse(run_events[0]["at"]) + + all_run_events = [e for r in runs for e in r["events"]] + t0 = min(_parse(e["at"]) for e in all_run_events) def secs(x: str) -> float: return (_parse(x) - t0).total_seconds() @@ -60,44 +72,92 @@ def secs(x: str) -> float: cursor = secs(prov["cursor_at"]) beam_loss = secs(prov["beam_loss_at"]) beam_back = secs(prov["beam_back_at"]) - xmax = max(secs(e["at"]) for e in run_events) + xmax = max(secs(a["sampled_at"]) for a in acts) - fig, ax = s.figure(s.FULL_WIDTH, 4.4) + fig, ax = s.figure(s.FULL_WIDTH, 4.6) - # Faint lane baselines anchor the swim-lanes. for y in LANE_Y.values(): ax.axhline(y, color=s.RULE, lw=0.6, zorder=0) - # Held band: the whole run is held between beam loss and beam back. + # Held band across the hold (beam loss to beam back), spanning the figure. + # No standalone label: the band is read together with the dotted run-state + # line, the beam-permit "lost" segment, and the "held (supervisor)" marker. ax.axvspan(beam_loss, beam_back, color=s.HELD, zorder=0) - ax.text((beam_loss + beam_back) / 2, Y_RUN + 0.28, "held", ha="center", - va="bottom", fontsize=s.SIZE["small"], color=s.MUTE, style="italic") - - # Phase anchors from recorded activity times. - proj_times = sorted(secs(a["sampled_at"]) for a in acts - if a["payload"].get("action_name") == "acquire_projection") - save_time = next((secs(a["sampled_at"]) for a in acts - if a["payload"].get("action_name") == "write_dataset"), None) - align_end = secs(iters[-1]["ended_at"]) + 1.5 - scan_start = align_end # the fly-scan setup is the head of the scan; no gap - scan_end = (save_time - 6) if save_time is not None else proj_times[-1] + 6 - run_done = max(secs(e["at"]) for e in run_events) - - # Color-coded phase bars across the top: Alignment, Scan, Save. + + # ----- Per-sample phase bars across the top ----- plo, phi = Y_PHASE - phases = [("Alignment (converged)", -3, align_end), ("Scan", scan_start, scan_end)] - if save_time is not None: - phases.append(("Save", scan_end, save_time + 6)) - for name, x0, x1 in phases: + for sm in samples: + c0, c1 = secs(sm["mount_at"]) - 3.0, secs(sm["dismount_at"]) + 1.5 + name = f"Sample {'A' if sm['index'] == 1 else 'B'}" face, ink = PHASE_STYLE[name] - ax.add_patch(Rectangle((x0, plo), x1 - x0, phi - plo, facecolor=face, + ax.add_patch(Rectangle((c0, plo), c1 - c0, phi - plo, facecolor=face, edgecolor="none", zorder=0)) - ax.text((x0 + x1) / 2, (plo + phi) / 2, name, ha="center", va="center", + ax.text((c0 + c1) / 2, (plo + phi) / 2, name, ha="center", va="center", fontsize=s.SIZE["small"], color=ink, fontweight="bold") - # Beam-permit lane: the safety envelope the supervisor gates hold/resume on. - # Permit is satisfied except across the hold (beam loss to beam back). + # ===== SAMPLE HANDLING group ===== + # Sample-custody lane: a band while a Subject is mounted, a gap during the + # robot swap. The state answer "which sample is on the stage?" at any cursor. + ax.axhline(Y_CUSTODY, color=s.RULE, lw=0.6, zorder=0) + for sm in samples: + a0, a1 = secs(sm["mount_at"]), secs(sm["dismount_at"]) + label = "A" if sm["index"] == 1 else "B" + ax.add_patch(Rectangle((a0, Y_CUSTODY - 0.12), a1 - a0, 0.24, + facecolor=s.SUBINK, edgecolor="none", alpha=0.32, zorder=1)) + ax.plot([a0, a1], [Y_CUSTODY, Y_CUSTODY], color=s.SUBINK, lw=1.4, + solid_capstyle="butt", zorder=2) + ax.text((a0 + a1) / 2, Y_CUSTODY + 0.14, f"sample {label} mounted", + ha="center", va="bottom", fontsize=s.SIZE["small"], color=s.SUBINK) + + # Robot lane: Manipulator mount/dismount markers (the custody-band edges). + # mount labels above, dismount below, so the adjacent A-dismount / B-mount + # pair at the sample swap does not collide. + ax.axhline(Y_ROBOT, color=s.RULE, lw=0.6, zorder=0) + for ev in robot["events"]: + x = secs(ev["at"]) + ax.scatter([x], [Y_ROBOT], marker="D", s=44, color=s.STATE, + edgecolors="white", linewidths=0.7, zorder=4) + dy = 7 if ev["action"] == "mount" else -14 + ax.annotate(ev["action"], (x, Y_ROBOT), textcoords="offset points", + xytext=(0, dy), ha="center", fontsize=s.SIZE["small"], color=s.STATE) + + # ===== RUN & SAFETY group ===== + # Run-lifecycle: one state line per run (started..completed), dotted while + # held. Markers overlay the line: operator square, supervisor diamond. + ax.axhline(Y_RUN, color=s.RULE, lw=0.6, zorder=0) + label_word = {"RunStarted": "started", "RunHeld": "held", + "RunResumed": "resumed", "RunCompleted": "completed"} + for r in runs: + evs = {e["type"]: secs(e["at"]) for e in r["events"]} + if "RunHeld" in evs and "RunResumed" in evs: + segs = [(evs["RunStarted"], evs["RunHeld"], False), + (evs["RunHeld"], evs["RunResumed"], True), + (evs["RunResumed"], evs["RunCompleted"], False)] + else: + segs = [(evs["RunStarted"], evs["RunCompleted"], False)] + for x0, x1, held in segs: + ax.plot([x0, x1], [Y_RUN, Y_RUN], color=s.INK, lw=1.6, + ls=((0, (1.2, 1.2)) if held else "solid"), + dash_capstyle="butt", solid_capstyle="butt", zorder=2) + # started/held label above the lane, completed/resumed below, so the + # A-completed / B-started pair at the swap and the held/resumed pair do + # not overlap. + below = {"RunCompleted", "RunResumed"} + for e in r["events"]: + x = secs(e["at"]) + agent = e["role"] == "agent" + col = s.AGENT if agent else s.OPERATOR + ax.scatter([x], [Y_RUN], marker=("D" if agent else "s"), s=48, color=col, + edgecolors="white", linewidths=0.7, zorder=4) + who = "supervisor" if agent else "operator" + dy, va = (-22, "top") if e["type"] in below else (9, "bottom") + ax.annotate(f"{label_word[e['type']]}\n({who})", (x, Y_RUN), + textcoords="offset points", xytext=(0, dy), ha="center", + va=va, fontsize=s.SIZE["small"], color=col, linespacing=1.0) + + # Beam-permit lane: satisfied except across the hold. ax.axhline(Y_PERMIT, color=s.RULE, lw=0.6, zorder=0) + run_done = max(secs(e["at"]) for e in all_run_events) for x0, x1 in ((-3, beam_loss), (beam_back, run_done)): ax.plot([x0, x1], [Y_PERMIT, Y_PERMIT], color=s.GOOD, lw=3.0, solid_capstyle="butt", alpha=0.85, zorder=2) @@ -107,25 +167,8 @@ def secs(x: str) -> float: textcoords="offset points", xytext=(0, 4), ha="center", fontsize=s.SIZE["small"], color=s.ALARM) - # Run lifecycle / who-drove-it lane. - label_pos = {"RunStarted": (0, 9, "center"), "RunHeld": (0, 9, "center"), - "RunResumed": (0, 9, "center"), "RunCompleted": (0, 9, "center")} - for ev in run_events: - x = secs(ev["at"]) - agent = ev["role"] == "agent" - col = s.AGENT if agent else s.OPERATOR - ax.scatter([x], [Y_RUN], marker=("D" if agent else "s"), s=52, color=col, - edgecolors="white", linewidths=0.7, zorder=4) - label = {"RunStarted": "started", "RunHeld": "held", "RunResumed": "resumed", - "RunCompleted": "completed"}[ev["type"]] - who = "supervisor" if agent else "operator" - dx, dy, ha = label_pos[ev["type"]] - ax.annotate(f"{label}\n({who})", (x, Y_RUN), textcoords="offset points", - xytext=(dx, dy), ha=ha, fontsize=s.SIZE["small"], color=col, - linespacing=1.0) - - # Each iteration is one setpoint-acquire-check cycle: shade its span across - # the lanes, tinted by verdict, capped with a thin verdict-colored rule. + # ===== SCIENCE group ===== + # Per-sample iteration bands (setpoint-acquire-check cycles), tinted by verdict. band_lo, band_hi = Y_BAND for it in iters: a, b = secs(it["started_at"]), secs(it["ended_at"]) @@ -134,154 +177,120 @@ def secs(x: str) -> float: ax.add_patch(Rectangle((a, band_lo), b - a, band_hi - band_lo, facecolor=s.GOOD_BG if converged else s.WARN_BG, edgecolor="none", zorder=-1)) - ax.plot([a, b], [band_hi, band_hi], color=col, lw=2.0, + ax.plot([a, b], [band_hi, band_hi], color=col, lw=1.6, solid_capstyle="butt", zorder=1) - ax.text((a + b) / 2, band_hi + 0.12, f"i{it['iteration_index']}", - ha="center", va="bottom", fontsize=s.SIZE["small"], color=col) - ax.text((secs(iters[0]["started_at"]) + secs(iters[-1]["ended_at"])) / 2, - band_lo - 0.18, "centering converged", fontsize=s.SIZE["small"], - va="top", ha="center", color=s.GOOD) - # Activity swim-lanes (alignment). Science projections are drawn separately. + # Alignment swim-lane marks (per sample). for a in acts: - if (a["payload"].get("role") == "taxi" - or a["payload"].get("action_name") in ("acquire_projection", - "fly_scan_prep", "write_dataset")): + an = a["payload"].get("action_name") + if a["payload"].get("role") == "taxi" or an in ( + "acquire_projection", "fly_scan_prep", "write_dataset"): + continue + if a["payload"].get("role") == "fly_scan": continue x, y = secs(a["sampled_at"]), LANE_Y[a["step_kind"]] - ax.scatter([x], [y], marker=LANE_MARKER[a["step_kind"]], s=46, + ax.scatter([x], [y], marker=LANE_MARKER[a["step_kind"]], s=34, color=LANE_COLOR[a["step_kind"]], zorder=3, edgecolors="white", - linewidths=0.6) + linewidths=0.5) if a["step_kind"] == "check": + # Alternate the label offset by iteration parity so adjacent + # residuals (bands are only ~8 s apart) do not run together. + dy = -10 if (a["iteration"] or 0) % 2 else -19 ax.annotate(f"{a['payload']['actual']:.2f}", (x, y), - textcoords="offset points", xytext=(0, -11), ha="center", + textcoords="offset points", xytext=(0, dy), ha="center", fontsize=s.SIZE["small"], color=s.STATE) - # Science projections (acquire_projection). The interrupted projection has an - # in-flight marker (before the beam loss) and an ok marker (after recovery); - # identify it by its in-flight result. + # Science projections. The interrupted projection on sample B has an + # in-flight marker before the cursor and an ok marker after recovery. projs = [a for a in acts if a["payload"].get("action_name") == "acquire_projection"] y = LANE_Y["action"] - inflight = next(secs(a["sampled_at"]) for a in projs if a["result"] == "in_flight") + inflight_list = [secs(a["sampled_at"]) for a in projs if a["result"] == "in_flight"] ok_times = sorted(secs(a["sampled_at"]) for a in projs if a["result"] == "ok") - pre_ok = [t for t in ok_times if t < cursor] - post_ok = [t for t in ok_times if t > cursor] - reacq = post_ok[0] - - # Before the cursor the scan was acquiring: completed projections are solid - # (closed); the interrupted one is an open (dashed) interval to the cursor. - if pre_ok: - ax.plot([pre_ok[0], inflight], [y, y], color=s.SUBINK, lw=4.5, alpha=0.5, - solid_capstyle="butt", zorder=1) - ax.scatter(pre_ok, [y] * len(pre_ok), marker="^", s=40, color=s.SUBINK, + for t in ok_times: + ghost = t > cursor + ax.scatter([t], [y], marker="^", s=30, color=(s.MUTE if ghost else s.SUBINK), + alpha=(0.5 if ghost else 1.0), edgecolors="white", + linewidths=0.5, zorder=3) + if inflight_list: + inflight = inflight_list[0] + ax.plot([inflight, cursor], [y, y], color=s.ALARM, lw=4.0, ls=(0, (0.9, 0.8)), + dash_capstyle="butt", alpha=0.9, zorder=2) + ax.scatter([inflight], [y], marker="^", s=46, color=s.ALARM, edgecolors="white", linewidths=0.6, zorder=3) - ax.plot([inflight, cursor], [y, y], color=s.ALARM, lw=4.5, ls=(0, (0.9, 0.8)), - dash_capstyle="butt", alpha=0.9, zorder=2) - ax.scatter([inflight], [y], marker="^", s=52, color=s.ALARM, edgecolors="white", - linewidths=0.6, zorder=3) - ax.annotate("projection 3:\nin flight", ((inflight + cursor) / 2, y), - textcoords="offset points", xytext=(0, 8), ha="center", - fontsize=s.SIZE["small"], color=s.ALARM, fontweight="bold", - linespacing=1.0) - - # A fly-scan restart (taxi the rotary stage to constant velocity, re-arm the - # PSO) is needed both before the first frame and again after the hold; show - # each as a hatched band. - taxi_times = sorted(secs(a["sampled_at"]) for a in acts if a["payload"].get("role") == "taxi") - first_proj = min(pre_ok + [inflight]) - for x0, x1 in ((taxi_times[0] - 2, first_proj), (beam_back, reacq)): - ax.axvspan(x0, x1, facecolor="none", hatch="////", edgecolor=s.MUTE, - linewidth=0.0, alpha=0.6, zorder=0) - ax.annotate("fly-scan\nsetup", ((x0 + x1) / 2, LANE_Y["action"]), - textcoords="offset points", xytext=(0, 20), ha="center", - va="center", fontsize=s.SIZE["small"], color=s.SUBINK, - linespacing=0.9) - - # After recovery: the re-acquired projection and the rest of the scan, - # ghosted since they are past the parked cursor. - ax.plot([post_ok[0], post_ok[-1]], [y, y], color=s.MUTE, lw=4.5, alpha=0.4, - solid_capstyle="butt", zorder=1) - ax.scatter(post_ok, [y] * len(post_ok), marker="^", s=34, color=s.MUTE, - alpha=0.55, edgecolors="white", linewidths=0.5, zorder=3) - ax.annotate("scan resumes,\nruns to completion", (post_ok[len(post_ok) // 2], y), - textcoords="offset points", xytext=(0, -17), ha="center", - fontsize=s.SIZE["small"], color=s.MUTE, linespacing=1.0) - - # Data save: the collected dataset is written to disk at the end, on its own - # output lane (an action, not an acquisition). Past the cursor, so ghosted. - if save_time is not None: - ax.axhline(Y_OUTPUT, color=s.RULE, lw=0.6, zorder=0) - ax.plot([post_ok[-1], save_time], [y, Y_OUTPUT], color=s.MUTE, lw=1.0, - ls=(0, (2, 2)), alpha=0.4, zorder=1) - ax.scatter([save_time], [Y_OUTPUT], marker="p", s=52, color=s.MUTE, - alpha=0.6, edgecolors="white", linewidths=0.5, zorder=3) - ax.annotate("dataset written\n(HDF5)", (save_time, Y_OUTPUT), - textcoords="offset points", xytext=(0, 9), ha="center", - fontsize=s.SIZE["small"], color=s.MUTE, linespacing=0.9) - - # The rotary stage rotates continuously (fly-scan), paused during the hold and - # the restart: a faint span on the setpoint lane shows the motor moving. - rot = [secs(a["sampled_at"]) for a in acts if a["payload"].get("role") == "fly_scan"] - if rot: - ysp = LANE_Y["setpoint"] - for x0, x1 in ((first_proj, beam_loss), (reacq, post_ok[-1])): - ax.plot([x0, x1], [ysp, ysp], color=s.MUTE, lw=2.2, alpha=0.5, - solid_capstyle="round", zorder=1) - ax.annotate("fly-scan rotation, 0-180 deg", ((reacq + post_ok[-1]) / 2, ysp), + ax.annotate("projection 3:\nin flight", ((inflight + cursor) / 2, y), textcoords="offset points", xytext=(0, 8), ha="center", - fontsize=s.SIZE["small"], color=s.MUTE) + fontsize=s.SIZE["small"], color=s.ALARM, fontweight="bold", + linespacing=1.0) + + # Dataset writes on the output lane (ghosted if past the cursor). + ax.axhline(Y_OUTPUT, color=s.RULE, lw=0.6, zorder=0) + for a in acts: + if a["payload"].get("action_name") != "write_dataset": + continue + x = secs(a["sampled_at"]) + ghost = x > cursor + ax.scatter([x], [Y_OUTPUT], marker="p", s=46, + color=(s.MUTE if ghost else s.GOOD), + alpha=(0.6 if ghost else 0.9), edgecolors="white", + linewidths=0.5, zorder=3) # Fold-to-version cursor at the beam-loss instant. ax.axvline(cursor, color=s.ALARM, ls="--", lw=1.3, zorder=5) - ax.text(cursor, Y_RUN + 0.7, "cursor: beam loss", color=s.ALARM, + ax.text(cursor, Y_PHASE[1] + 0.12, "cursor: beam loss", color=s.ALARM, fontsize=s.SIZE["anno"], ha="center", va="bottom", fontweight="bold") - # Folded-state readout: an evenly padded info card parked in the held band. + # Folded-state readout card parked in the held band. def _row(text, color, weight="normal", size=s.SIZE["small"]): return TextArea(text, textprops={"color": color, "fontweight": weight, "fontsize": size}) - readout = VPacker(pad=0, sep=4.5, align="left", children=[ + readout = VPacker(pad=0, sep=4.0, align="left", children=[ _row("Folded state at cursor", s.ALARM, "bold", s.SIZE["anno"]), + _row("sample: B mounted (robot)", s.INK), _row("alignment: converged (0.30 px)", s.INK), _row("projections: 2 done, #3 in flight", s.INK), _row("run: held by supervisor", s.INK), _row("fidelity: verified", s.INK), ]) - card = AnchoredOffsetbox(loc="center", child=readout, pad=0.6, borderpad=0, - frameon=True, bbox_to_anchor=((beam_loss + beam_back) / 2, 0.46), - bbox_transform=ax.get_xaxis_transform()) + card = AnchoredOffsetbox(loc="center left", child=readout, pad=0.6, borderpad=0, + frameon=True, bbox_to_anchor=(0.845, 0.46), + bbox_transform=ax.transAxes) card.patch.set(boxstyle="round,pad=0,rounding_size=0.5", facecolor="white", edgecolor=s.RULE, linewidth=1.0) card.set_zorder(6) ax.add_artist(card) - yticks = [Y_OUTPUT] + list(LANE_LABEL) + [Y_PERMIT, Y_RUN] - ylabels = ["Output"] + [LANE_LABEL[k] for k in LANE_LABEL] + ["Beam permit", "Run"] + yticks = [Y_OUTPUT] + list(LANE_LABEL) + [Y_PERMIT, Y_RUN, Y_ROBOT, Y_CUSTODY] + ylabels = (["Output"] + [LANE_LABEL[k] for k in LANE_LABEL] + + ["Beam permit", "Run", "Robot", "Sample"]) ax.set_yticks(yticks) ax.set_yticklabels(ylabels, fontsize=s.SIZE["label"]) - ax.set_ylim(Y_OUTPUT - 0.7, Y_PHASE[1] + 0.35) - ax.set_xlim(-6, xmax + 6) + ax.set_ylim(Y_OUTPUT - 0.7, Y_PHASE[1] + 0.5) + # Reserve right-margin space for the folded-state readout card so it does + # not overlap sample B's science swim-lanes. + ax.set_xlim(-6, xmax + 46) ax.set_xlabel("time (s; synthetic spacing, see data/README.md)", fontsize=s.SIZE["label"]) ax.tick_params(axis="x", labelsize=s.SIZE["tick"]) s.despine(ax, keep=("bottom",)) - s.title(ax, "Replay scrubber: an agent-supervised run at APS 2-BM") + s.title(ax, "Replay scrubber: a robot-loaded, two-sample run at APS 2-BM") legend = [ Line2D([0], [0], marker="s", color="w", markerfacecolor=s.OPERATOR, markersize=6.5, label="operator"), Line2D([0], [0], marker="D", color="w", markerfacecolor=s.AGENT, markersize=6.5, label="supervisor"), + Line2D([0], [0], marker="D", color="w", markerfacecolor=s.STATE, + markersize=6.5, label="robot mount/dismount"), Patch(facecolor=s.WARN_BG, edgecolor=s.WARN, label="iteration: open"), Patch(facecolor=s.GOOD_BG, edgecolor=s.GOOD, label="iteration: converged"), - Line2D([0], [0], color=s.ALARM, lw=4.5, ls=(0, (0.9, 0.8)), + Line2D([0], [0], color=s.ALARM, lw=4.0, ls=(0, (0.9, 0.8)), dash_capstyle="butt", label="in-flight (open)"), Line2D([0], [0], color=s.GOOD, lw=3, label="beam permit OK"), ] - ax.legend(handles=legend, loc="lower left", ncol=6, fontsize=s.SIZE["legend"], - frameon=False, bbox_to_anchor=(0.0, -0.235), handletextpad=0.5, - columnspacing=1.3) + ax.legend(handles=legend, loc="lower left", ncol=4, fontsize=s.SIZE["legend"], + frameon=False, bbox_to_anchor=(0.0, -0.26), handletextpad=0.5, + columnspacing=1.4) s.save(fig, "f1_scrubber") diff --git a/papers/2026-vaxautosci-scrubber/main.tex b/papers/2026-vaxautosci-scrubber/main.tex index d0d632015fa..19774a80e12 100644 --- a/papers/2026-vaxautosci-scrubber/main.tex +++ b/papers/2026-vaxautosci-scrubber/main.tex @@ -75,6 +75,7 @@ \section{Related Work} \input{sections/related-work} \section{Limitations and Future Work} +\label{sec:limitations} \input{sections/limitations} \bibliographystyle{abbrv-doi} diff --git a/papers/2026-vaxautosci-scrubber/sections/abstract.tex b/papers/2026-vaxautosci-scrubber/sections/abstract.tex index 36718725997..31058af03fc 100644 --- a/papers/2026-vaxautosci-scrubber/sections/abstract.tex +++ b/papers/2026-vaxautosci-scrubber/sections/abstract.tex @@ -17,9 +17,9 @@ the content hash recorded at run start. We ground the prototype in an integration-tested, agent-supervised scenario run on an APS 2-BM micro-CT configuration, against the real event-sourcing stack: the system conducts a -rotation-axis centering alignment, a supervisor agent holds the run on beam loss -and auto-resumes it, and the scrubber localizes the interrupted projection by -folding to the beam-loss instant. The run is agent-supervised rather than +rotation-axis centering alignment, a supervisor agent holds the run on a modeled +beam-loss signal and auto-resumes it, and the scrubber localizes the interrupted +projection by folding to that instant. The run is agent-supervised rather than agent-closed: the agent recovers a conducted run, it does not select the science. We discuss how event-sourced records reshape provenance visualization for autonomous science. diff --git a/papers/2026-vaxautosci-scrubber/sections/intro.tex b/papers/2026-vaxautosci-scrubber/sections/intro.tex index 081a37c80e0..a2701a08477 100644 --- a/papers/2026-vaxautosci-scrubber/sections/intro.tex +++ b/papers/2026-vaxautosci-scrubber/sections/intro.tex @@ -67,11 +67,6 @@ the open question of what oversight, replay, and accountability should look like as automation moves from agent-supervised toward agent-closed. -The novelty is not a timeline or time travel as such, but their conjunction in -this setting: deterministic event-stream replay, provenance visualization, a -content-addressed fidelity check run at the cursor, and a task abstraction for -auditing autonomous runs. - \medskip\noindent This paper makes four contributions: \begin{itemize} \item \textbf{Replay-native provenance (insight).} We reframe the @@ -92,6 +87,6 @@ \item \textbf{A grounded case study.} We demonstrate the four tasks on an integration-tested, agent-supervised scenario run on an APS 2-BM micro-CT configuration, exercised against the real event-sourcing stack, - including the beam-loss instant at which the scrubber surfaces the agent - that held the run and the interrupted projection it left open. + including a modeled beam-loss instant at which the scrubber surfaces the + agent that held the run and the projection it left open. \end{itemize} diff --git a/papers/2026-vaxautosci-scrubber/sections/limitations.tex b/papers/2026-vaxautosci-scrubber/sections/limitations.tex index 12f93e58915..10cba570609 100644 --- a/papers/2026-vaxautosci-scrubber/sections/limitations.tex +++ b/papers/2026-vaxautosci-scrubber/sections/limitations.tex @@ -10,15 +10,25 @@ The figures are not mock-ups. They derive from one passing integration scenario, exercised against a real Kernel and Postgres event store, and the run -data, its generator, and the per-figure render scripts are released with the -paper. What is real is what that scenario produced and we mirrored into the -figure data: the activities, the four iteration verdicts (open, open, open, -converged), the four run-lifecycle events, and the supervisor's recorded hold -and resume decisions. What is synthetic is only the per-event timestamps, -staggered for a readable axis because the scenario records one logical instant; -the overnight wall-clock spread is illustrative, stated in prose rather than -measured. The generator depends on nothing but the standard library and needs no -database, so a reader can regenerate the figure data directly. +data, generator, and per-figure render scripts are released with the paper. The +scenario records the campaign grouping, sample custody, per-sample iteration +verdicts, run-lifecycle events, and supervisor hold/resume decisions. What is +modeled rather than deployed is the beam-loss signal: at 2-BM-B today a fly-scan +continues structurally through a beam drop, so no beam-loss event reaches the +supervisor. The demonstrated beam-permit transition is injected; a deployed run +needs a fly-scan beam-loss check and a facility-owned beam-permit bridge into the +same event stream. The supervisor reaction itself is exercised through the real +Kernel and Postgres event store. + +The robot-loaded framing has a similar boundary. The 2-BM sample-change hardware +is real and has executed mount/dismount cycles, and the scan software already +iterates batches over scriptable interfaces. What we model is CORA's +orchestration of that robot as a positioner asset with per-sample subject +custody, recorded through the same event store as the runs it brackets. Fuller +robot-loaded supervision, such as field-of-view checks, lens-change branches, or +per-sample recentering decisions, remains future work. The per-event timestamps +are also synthetic, staggered for a readable axis because the scenario records +one logical instant; the overnight wall-clock spread is illustrative. Two boundaries matter for how the contribution should be read, both about what the run and the badge are not. The run is @@ -34,28 +44,14 @@ append-only store; it is an integrity check, not adversarial tamper resistance, under the conditions stated in Section~\ref{sec:substrate}. -The fold-to-version helper that drives the cursor was written for this -visualization on top of the system's existing fold-on-read path. The fold is -scoped to one run's stream, not the whole store, so the work is bounded by a -single run rather than the entire event history; but because that path keeps no -snapshots, the cost of reconstructing a distant instant still grows with the -length of that stream. For the short run shown here it is imperceptible, and for -long campaigns the standard remedies apply directly, periodic snapshots to fold -from and incremental re-folding rather than replaying from the start each time; -we name these rather than build them. Three further extensions are natural: a -live now-edge that subscribes to the event tail so the same timeline can monitor -a run in progress, run-to-run diffing so two campaigns share one axis, and a -deployed read interface, whose first read-only slice is already on the roadmap. The -closest extension is an agent-closed steered loop, an optimizer that decides the -next action rather than a supervisor that recovers a conducted one; because such -an optimizer would record its choices through the same decision substrate, the -run-lifecycle lane and the decision records would carry its next-action choices -and be audited exactly as the supervisor's holds and resumes are here. We expect -the design to generalize in the same way beyond this run, to other conducted -procedures and other facilities, since nothing in the scrubber is specific to -this case beyond a small vocabulary it reads from the log: the requirement on a -host system is modest, an append-only, deterministically-foldable event log with -a content-addressed expansion and a recorded actor on each event. +The fold-to-version helper was written for this visualization on top of the +system's existing fold-on-read path. It folds one run's stream, so the work is +bounded by a single run, but reconstructing a distant instant still grows with +that stream's length. For long campaigns, periodic snapshots and incremental +re-folding are the natural remedies. Extensions include a live now-edge for +monitoring, run-to-run diffing, a deployed read interface, recorded hold policies +for consumables and apparatus, and agent-closed steered loops whose optimizer +choices would be audited through the same decision records. Three threats temper these claims. The construct question is whether T1 through T4 capture what auditing a conducted run actually needs; we derived them from diff --git a/papers/2026-vaxautosci-scrubber/sections/related-work.tex b/papers/2026-vaxautosci-scrubber/sections/related-work.tex index 8e5ac8f46fa..4dcfc9ea6e1 100644 --- a/papers/2026-vaxautosci-scrubber/sections/related-work.tex +++ b/papers/2026-vaxautosci-scrubber/sections/related-work.tex @@ -4,14 +4,9 @@ \centering \caption{Where the replay scrubber sits relative to the closest prior work. $\bullet$ = present, $\circ$ = absent or not surfaced to the user. - \emph{Replay} names the reconstruction mechanism; \emph{fidelity} is an - integrity check, over the replayed recipe expansion, recomputed at the cursor - and shown to a human; - \emph{run-state provenance} is per-iteration verdict and in-flight semantics - along a run; \emph{who / handoff} is a per-transition actor (human or agent) - record; \emph{scrub} is an interactive time cursor. C2PA/Atlas read $\circ$ on - fidelity because their check is over static artifacts, not replayed run state; - the distinction is the per-cursor reconstruction, not the hash. No prior system + \emph{Fidelity} means a cursor-time integrity check over replayed run state, + not a static artifact hash; \emph{who / handoff} is a per-transition actor + record; and \emph{scrub} is an interactive time cursor. No prior system combines all five columns.} \label{tab:compare} \footnotesize diff --git a/papers/2026-vaxautosci-scrubber/sections/scrubber.tex b/papers/2026-vaxautosci-scrubber/sections/scrubber.tex index 6622580f3d3..0fb904523db 100644 --- a/papers/2026-vaxautosci-scrubber/sections/scrubber.tex +++ b/papers/2026-vaxautosci-scrubber/sections/scrubber.tex @@ -7,45 +7,25 @@ \begin{figure*}[t] \centering \includegraphics[width=\textwidth]{figures/f1_scrubber} - \caption{The replay scrubber over one agent-supervised run at APS - 2-BM. Color-coded bars across the top name the run's phases (alignment, scan, - save). A run-lifecycle lane records who drove each transition: the operator - started and the run completes, while the supervisor agent held the run when the - beam dropped and resumed it when the beam returned (the gray band is the hold; - the two hatched bands are the fly-scan setup, the rotary taxi to constant - velocity and trigger arm, needed before the first frame and again after the - hold). A beam-permit lane shows the safety envelope the supervisor gates on: - satisfied except across the hold, where it reads lost. Below, the setpoint, - acquire, and check swim-lanes, with one verdict-tinted band per pass of the - conducted rotation-axis centering search (amber open, green converged) grouping - that pass's three activities. The fold-to-version cursor is parked at the - beam-loss instant: folding the event stream to there reconstructs the state - shown in the read-out, including the third projection as an open interval (its - in-flight marker is recorded but its outcome is not yet). Past the cursor, - after the run resumes, the scan runs to completion and the dataset is written - to disk. Time spacing is synthetic.} + \caption{The replay scrubber over one robot-loaded, two-sample session at APS + 2-BM. Lanes encode sample custody, robot actions, run lifecycle, beam permit, + and the conducted setpoint/acquire/check loop. The cursor is parked at the + beam-loss instant on sample B: folding the event stream to that position + reconstructs the read-out, including the supervisor hold and projection 3 as an + open interval. After the cursor, the run resumes and completes. Time spacing is + synthetic.} \label{fig:scrubber} \end{figure*} -Before the mechanics, the experience. The scrubber turns a finished run into -something a scientist can play back: drag a handle along time and the run -re-forms at that instant, the way a video scrubber jumps to a frame. - -The scrubber presents one recorded run on a single horizontal time axis +The scrubber presents one recorded session on a single horizontal time axis (Figure~\ref{fig:scrubber}), a stack of lanes that each answer a different -question. The activity swim-lanes carry one track per kind of step the run -performs, a position setpoint, an acquisition, a check, and the dataset write to -disk, with every activity drawn at its recorded time within its lane. Above them -a run-lifecycle lane records the autonomous arc, who or what caused each -transition, colored by actor, so an auditor sees at a glance where control passed -between the operator and the supervisor agent; and a beam-permit lane shows the -safety envelope the supervisor gates on, satisfied except across the hold. Color-coded -bars across the top name the run's phases (alignment, scan, save). The iterative -structure of the conducted alignment is shown as one shaded band per pass, each -tinted by that pass's recorded verdict and grouping the pass's setpoint, acquire, -and check. An activity that was opened but never closed, the -record of intent before effect, is drawn as an open interval rather than a -filled mark, so an unfinished step reads as a visible gap. +question. Activity swim-lanes track setpoints, acquisitions, checks, and output; +the run-lifecycle lane colors each transition by actor; the beam-permit lane +shows the safety condition; and the sample-custody and robot lanes say which +sample was mounted and who put it there. The conducted alignment is shown as one +verdict-tinted band per pass, grouping that pass's setpoint, acquire, and check. +An activity opened but not yet closed is drawn as an open interval, so an +unfinished step reads as a visible gap rather than a silent truncation. The interaction is a single draggable cursor. Setting the cursor to a position $t$ folds the event stream up to $t$ and renders the reconstructed state of the @@ -60,10 +40,8 @@ \includegraphics[width=\linewidth]{figures/f2_fidelity} \caption{The fidelity check the badge runs at the cursor. The content-addressed hashes recorded at run start, over the recipe expansion (the resolved steps and - the parameter bindings), are recomputed from the state reconstructed by folding - to the cursor and compared; a match reads verified, a mismatch would read - altered. The digest shown is an - illustrative content hash of the run data.} + parameter bindings), are recomputed from the folded state and compared; a match + reads verified, a mismatch would read altered.} \label{fig:fidelity} \end{figure} @@ -76,30 +54,31 @@ see at the exact instant they are inspecting, rather than a claim they must take on faith. -We walk through an unattended, agent-supervised run on APS 2-BM. An operator -starts the run and leaves; the system conducts the pre-scan rotation-axis centering -alignment, a four-iteration peak-bracket search over the sample stage -\texttt{SampleTop\_X} that minimizes the center-of-rotation residual. The -residual falls from $2.00$ to $1.05$\,px, rises to $1.40$\,px at $0.080$\,mm -(bracketing the minimum in $[0.040, 0.080]$\,mm), and the fourth pass bisects to -$0.060$\,mm at $0.30$\,px and converges; read by color, the iteration bands show -the verdict sequence open, open, open, converged (T3). The science scan begins; two -projections complete and the third is in flight when the beam drops. The supervisor -agent holds the run; the beam returns and the supervisor resumes it, each -transition recorded as the agent's decision in the lifecycle lane. With the -cursor at the beam-loss instant (Figure~\ref{fig:scrubber}), the fold shows the -alignment already converged, two projections done and the third still open, and the run held -by the agent, and the fidelity badge confirms that the reconstructed recipe -expansion is faithful to the hash recorded at run start (T4). +We walk through an unattended, robot-loaded session on APS 2-BM. An operator +queues two samples and leaves; a sample-changing robot loads each in turn, and +the session runs as one campaign of two runs on a single time axis. For each +sample the robot mounts it onto the rotary stage (the sample-custody lane opens), +and the system conducts a pre-scan rotation-axis centering alignment, a +four-iteration peak-bracket search over the sample stage \texttt{SampleTop\_X} +that minimizes the center-of-rotation residual. On sample B the residual sequence +ends at $0.30$\,px, so the iteration bands read open, open, open, converged (T3). +The science scan then begins; two projections complete and the third is in flight +when the beam drops. With the cursor at that instant (Figure~\ref{fig:scrubber}), +the fold shows sample B mounted by the robot, its alignment already converged, +projection 3 still open, the run held by the supervisor, and the fidelity badge +verified (T4). -Read as a morning audit, the same run answers the auditor's questions in the -order they arise. Dragging the cursor into the gap in the held band localizes the -third projection as an open interval, exactly the exposure in flight when the -beam dropped, rather than guessing it (T1, developed in Section~\ref{sec:crash}). -Dragging to the resume transition opens its decision, the supervisor resumed -because the start-safety envelope was satisfied again, and the folded state shows -what the agent saw at that instant (T2). In a few minutes, and without re-running -anything, the auditor has recovered what the run did overnight, what was +Read as a morning audit, the same session answers the auditor's questions in the +order they arise. Reading the sample-custody lane at any instant says which +sample was on the stage and who put it there, so an interrupted exposure is +attributed to the right sample without cross-referencing a loader log. Dragging +the cursor into the gap in the held band localizes the third projection as an +open interval, exactly the exposure in flight when the beam dropped, rather than +guessing it (T1, developed in Section~\ref{sec:crash}). Dragging to the resume +transition opens its decision, the supervisor resumed because the start-safety +envelope was satisfied again, and the folded state shows what the agent saw at +that instant (T2). In a few minutes, and without re-running anything, the auditor +has recovered what the two-sample session did overnight, which sample was interrupted, and why the agent acted. \textbf{Formalization.} The mechanism behind that walkthrough is a single fold. @@ -118,37 +97,13 @@ $\mathrm{evolve}$ only folds recorded events, $S_t$ reproduces the record rather than re-running the alignment. -\textbf{Design rationale.} Each encoding answers an audit question rather than -decorating the timeline. We give the run lifecycle its own lane, colored by -actor, instead of annotating events in place, so that \emph{who or what was -driving} is legible across the whole run and accountability is separated from -activity. We draw convergence as verdict-colored iteration bands rather than -a metric curve: the auditor is asking whether and on which pass the search -decided it had converged, which is the verdict, not the residual value. We place -the fidelity badge inline at the cursor rather than in a separate trust panel, so -trust is judged at the instant being inspected. We render an unfinished step as -an open interval rather than truncating the timeline, so a missing outcome is a -positive mark that is seen rather than inferred. And we keep one linear time axis -with an explicit held band rather than collapsing the hold, because the gap -itself is the evidence that the run waited, on the same scale as everything -else. - -\textbf{Auditing autonomous agents.} The lifecycle lane, and the decision -records behind it, make the scrubber an instrument for agent accountability, -which is the harder half of the human-agent handoff. Each transition the -supervisor drives is recorded with the actor that caused it and a decision the -auditor can open: not only that the run was held and resumed, but who or what -decided, when, and on what basis. Scrubbing to a transition reconstructs the -state the agent saw at that instant, so ``why did it resume here'' is answered by -replay rather than by trusting a log line. Accountability is not only about the -acquisition: the same fold reconstructs every axis the system records as events, -so the beam-permit lane shown here, and the clearance, training, and -authorization boundaries the system records elsewhere, are recovered at the -cursor alongside the science, and an auditor scrubs the safety envelope that -gated a decision as readily as the data it produced. The questions an auditor -asks do not change as autonomy deepens; only how many of the recorded decisions -were the agent's rather than a person's. The -same lane that here distinguishes operator from supervisor would, with more -agents in the loop, separate their contributions and surface where they -contended or handed off, the multi-agent orchestration view that prior -provenance tools do not provide. +\textbf{Design rationale and agent audit.} Each encoding answers an audit +question rather than decorating the timeline. The lifecycle lane makes who or +what was driving legible across the run; verdict-colored iteration bands answer +whether and when the search converged; the inline fidelity badge judges trust at +the inspected instant; and the explicit held band keeps the wait itself on the +science time axis. The same lane also makes the scrubber an accountability view: +scrubbing to a supervisor transition reconstructs the state the agent saw, so +``why did it resume here'' is answered by replay rather than by trusting a log +line. As autonomy deepens, the same actor field would separate multiple agents +and expose their handoffs or contention. diff --git a/papers/2026-vaxautosci-scrubber/sections/substrate.tex b/papers/2026-vaxautosci-scrubber/sections/substrate.tex index 7fc49344cc1..5d42e44e02a 100644 --- a/papers/2026-vaxautosci-scrubber/sections/substrate.tex +++ b/papers/2026-vaxautosci-scrubber/sections/substrate.tex @@ -21,29 +21,40 @@ substrate here, and stress that it is the enabling condition, not the contribution (Figure~\ref{fig:substrate}). -Three records carry everything the four tasks -need. Each event's envelope records who or what caused it and its position in -the stream, which gives the cursor a well-defined instant to fold to (T2) and -gives an unfinished activity a position to mark as an open interval (T1). The -run-start payload records the recipe the run expanded from, together with -content-addressed hashes of that expansion; recomputing and comparing those -hashes is exactly the fidelity badge (T4). The per-iteration boundary events -carry the verdict that colors each iteration band (T3). The swim-lane -activities, the setpoint, acquire, check, and output rows, are themselves events appended as the -system acts, with the intent recorded before the effect (the open-interval mechanism of -Section~\ref{sec:crash}). -Concretely, the example run is a four-event run-lifecycle stream (started, held, -resumed, completed) alongside a twelve-event procedure stream with around two -dozen activity rows; folding a prefix returns the reconstructed run and -procedure state (status, the current iteration and its verdict, and whether each -activity is open or closed), and the recorded hashes cover the recipe expansion, -the resolved steps and parameter bindings, written at run start. +Three records carry everything the four tasks need. Each event's envelope +records who or what caused it and its stream position, giving the cursor a +well-defined instant to fold to (T2) and an unfinished activity a position to +mark as an open interval (T1). The run-start payload records the expanded recipe +and its content-addressed hashes, feeding the fidelity badge (T4). The +per-iteration boundary events carry the verdict that colors each iteration band +(T3). Activity entries, the setpoint, acquire, check, and output rows, are +appended as the system acts, with intent recorded before effect +(Section~\ref{sec:crash}). Folding a prefix returns the reconstructed run and +procedure state: status, current iteration and verdict, and which activities are +open or closed. -The visual claim, that the timeline you are scrubbing is faithful to an -unaltered record under stated assumptions, is what the paper is about; we -enumerate those assumptions below rather than re-prove them here. +The stream is multi-owner. Every event's envelope carries an actor field +(Table~\ref{tab:schema}), so different actors, the operator, the supervisor +agent, the instrument tasks, and the facility itself, append into a single +ordered log with per-event attribution. This yields two properties the scrubber +renders directly. First, provenance at the cursor: the auditor drags to an +instant and sees not only the reconstructed state but who is authoritative for +each fact under that state. Second, latency between actors: the interval between +one actor's fact and another actor's reaction, a beam-permit drop and an +instrument's ramp-down, for example, is a legible span between two attributed +events on one time axis rather than a datum to correlate from separate logs with +different clocks. Under the everyone-keeps-their-own-log pattern those intervals +are recoverable only with effort; under an attribution-carrying single log they +are directly readable at the scrubbed cursor. The beam-permit fact is owned by +the facility rather than emitted by CORA, so rendering it on a deployed run needs +the bridge noted in Section~\ref{sec:limitations}; the substrate property holds +regardless of which actor writes. -The records the scrubber reads are summarized in Table~\ref{tab:schema}. +The visual claim, that the timeline you are scrubbing is faithful to an +unaltered record under stated assumptions, is what the paper is about. The +records the scrubber reads are summarized in Table~\ref{tab:schema}; the +concrete CORA events behind them are run start, hold, resume, run end, iteration +start and end, activity entries, and a facility-owned beam-permit transition. \begin{table}[t] \centering