The program separates graphical interaction from simulation state and numerical rules. Pygame handles the window, input and drawing; the simulation modules do not import Pygame. The conceptual division described in Central Controller remains visible in the implementation: `CentralController` routes assignments and collects capture reports, `ControlledArm` manages one arm's activity, and `ReachController` schedules the local movement rules. Shared scheduling does not mean that the central controller calculates joint angles. Hazard response, reporting and conflict arbitration are separate layers within that schedule. ## Package structure and feature map Application source lives in `src/octopus_controller`. The table maps modules and classes to the features explained in the principles pages. | Module | Class or principal functions | Responsibility and corresponding feature | | -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `app.py` | `Application`, `main` | Window lifecycle, keyboard/mouse input, selection, initial scene and fixed-step timing | | `organism.py` | `CentralController` | Eight-arm layout, assignment routing, shared simulation time, exclusive capture claims and reports | | `organism.py` | `ControlledArm` | One arm's active/idle assignment, contact observations, grasp dwell, holding, carrying and retraction | | `organism.py` | `ArmState`, `CaptureReport` | Displayed activity states and immutable records of completed captures | | `model.py` | `ArmParameters`, `Arm` | Validated dimensions and movement limits, fixed attachment, relative joint angles and connected geometry | | `model.py` | `ReachController` | Target storage, previous neighbour requests, trial joint movements, avoidance status and movement acceptance | | `segment_control.py` | `SegmentRequest`, `request_start`, `turn_segment`, `wrap_angle` | Local request construction, neighbour bend constraints, gradual turning and shortest-angle calculations | | `geometry.py` | `Point`, `validate_point`, `closest_point`, `pose_points` | Common coordinates, validation, finite-segment projection and forward reconstruction of joint positions | | `sensing.py` | `ObjectKind`, `WorldObject`, `Contact` | Object types, immutable circular scene objects and immutable local observations | | `sensing.py` | `detect_contacts` | Segment sensing strips tested against food, obstacle and hazard circles | | `sensing.py` | `Environment` | Editable object collection, stable identities, immutable snapshots and pointer selection | | `avoidance.py` | `steer_around_obstacles`, `safe_motion` | Local circular-boundary steering and conservative swept arm-clearance checks | | `grasping.py` | `grasp_candidate`, `Grip` | Adjacent same-food contact qualification, rigid attachment transforms and swept payload checks | | `view.py` | `draw_scene`, `draw_sidebar`, `draw_arm`, `draw_card`, `draw_keycap` | World rendering, contact/ownership indicators, status cards, selected settings and shortcut reference | | `hazards.py` | `HazardResponse` | Per-segment hazard memory, recoil timer, reassessment, exposure totals and local endpoint steering | | `escalation.py` | `EscalationPolicy`, `HazardSignaller`, `HazardSignal` | Validated thresholds and per-arm/per-hazard reporting episodes | | `escalation.py` | `PriorityReceiver`, `HazardReport`, `PriorityMessage` | Report-only threat knowledge, broad withdrawal requests and bounded diagnostics | | `conflicts.py` | `ConflictPolicy`, `ArmIntent`, `ConflictDecision`, `ConflictResolver` | Immutable intentions/decisions, priority arbitration and resource incumbency | | `activity.py` | `ActivityHistory`, `ActivityEvent`, `state_summary` | Presentation-only significant-event groups, repetition counts and arm-state summary | | `scenario.py` | `Scenario`, `parse_scenario`, `load_scenario`, `save_scenario` | Hazard geometry, policy validation and named-food identity persistence | | `assets/octopus.png` | Packaged image asset | Window icon and the icon beside the main title and subtitle | ```mermaid flowchart TD Entry["Entry point"] --> App["app.py: Application"] App --> Central["organism.py: CentralController"] App --> Scene["sensing.py: Environment"] App --> View["view.py: rendering"] Central --> Arm["organism.py: ControlledArm x 8"] Arm --> Reach["model.py: ReachController and Arm"] Arm --> Sensing["sensing.py: local contacts"] Arm --> Grip["grasping.py: capture and payload"] Arm --> Signals["escalation.py: HazardSignaller"] Signals --> Priority["escalation.py: PriorityReceiver"] Priority --> Conflicts["conflicts.py: ConflictResolver"] Arm -->|"ArmIntent"| Conflicts Conflicts -->|"ConflictDecision"| Arm Reach --> Hazards["hazards.py: HazardResponse"] App --> Activity["activity.py: significant-event history"] Activity --> View Reach --> Local["segment_control.py: neighbour rules"] Reach --> Avoid["avoidance.py: steering and movement guard"] Reach --> Geometry["geometry.py: shared geometry"] Sensing --> Geometry Avoid --> Geometry Grip --> Geometry Scene -.->|"Snapshots"| Arm Central -.->|"Read-only display access"| View Scene -.->|"Objects"| View ``` This is a responsibility and data-flow diagram rather than a complete import graph. All eight arms currently run in one process and are visited sequentially. Their independence comes from separate state and local rules, not separate threads or network connections. ## Application lifecycle and timing `main` initialises Pygame, loads the packaged icon, creates fonts and the window, and then runs the application loop. Cleanup occurs in a `finally` block so the graphical resources are released even if an error interrupts the loop. `Application` owns the `CentralController` and `Environment`. It also owns presentation interaction state: selected arm, placement tool, selected scene object, drag flags, pause flag and accumulated frame time. The frame loop performs three tasks: 1. Handle pending input events. 2. Advance as many complete simulation ticks as the accumulated time allows. 3. Draw the current model state and present the frame. - Simulation ticks are `1 / 120` second - The display clock aims for at most 60 frames per second - `Application.advance` caps each frame at 0.1 wall-clock second before applying the selected 1×, 2×, 4× or 8× playback multiplier - Accumulated time is consumed in unchanged 1/120-second ticks, preserving the solver sequence and all simulation-time timers at higher playback rates - That cap deliberately discards excess wall time: simulation time is not guaranteed to match wall time after a stall - Pause prevents normal advancement - Single-step calls the same full simulation path for exactly one tick - Scene edits refresh sensing while paused, but do not advance contact dwell timers or move arms - A blocked/avoiding movement state is recalculated when simulation movement next advances ## Full simulation step The application uses `CentralController.step(elapsed_seconds, environment)` for the complete lifecycle. ```mermaid flowchart TD Start["Validate duration"] --> Time["Advance shared simulation time"] Time --> Arbitration["Gather reports and arbitrate arm intentions"] Arbitration --> Owners["Collect remaining grip owners"] Owners --> Next["Visit next arm in stable order"] Next --> Local["ControlledArm.update_grasp"] Local --> Capture{"New capture?"} Capture -->|"Yes"| Report["Reserve object and record report"] Capture -->|"No"| More{"More arms?"} Report --> More More -->|"Yes"| Next More -->|"No"| Refresh["Refresh all sensing from final scene"] ``` Each arm takes a current scene snapshot when it is visited. A carried object moved by an earlier arm is therefore visible to a later arm in that same step. The final refresh makes all displayed contact readings correspond to the final scene. `ControlledArm.update_grasp` first reconciles existing attachments and refreshes local contact. Its main branches are: - **Already holding:** delegate to `_carry`, which either holds still or attempts movement. - **Eligible food contact:** keep the pose still, accumulate dwell time, and create a `Grip` when the threshold is met. - **No eligible candidate:** clear incomplete grasp progress and perform ordinary reaching/sensing through `update`. The arm returns a newly captured object ID once. The central controller then reserves that object for later arms in the step, increments `capture_count`, and appends a `CaptureReport`. The report deque retains at most 32 entries; the total count is retained independently. Before this movement loop, Phase 3 gathers local hazard reports and arm intentions, then `ConflictResolver` chooses withdrawal, avoidance, grip, reach or yield. Shared resources use ownership, eligible incumbency and arm number as tie-breaks. The final capture reservation order prevents double ownership. Within an arm, `grasp_candidate` uses stable object identity after filtering incidental food that conflicts with a named task. ## Hazard and conflict processing ### State ownership `ReachController.hazard` owns a `HazardResponse`. Its `_local` dictionary associates segment indices with last-sensed hazard records. `_seen` tracks encountered identities, `_remaining` holds recoil time, and `_quiet` tracks the local clear interval. Encounter counts and accumulated exposure survive local episode resets until the controller is rebuilt. Remembered geometry is replaced by fresh local observations; it is not continuously updated from unseen hazard positions. `ControlledArm.signaller` owns a separate `HazardSignaller`. Its `_episodes` dictionary is keyed by hazard identity because continuous exposure and report clearance belong to a particular arm/hazard pair. Each episode stores continuous exposure, quiet time and the last emitted immutable `HazardSignal`. This state is deliberately separate from the movement response: signalling still runs for idle or holding arms, and a report can remain outstanding after local movement has left the region. `PriorityReceiver.known` is keyed by `(arm_index, object_id)`. Values are immutable `HazardReport` snapshots containing the arm, timestamp and signal. Clearing one arm's observation cannot erase another arm's outstanding report of the same object. The 64-entry `reports` and `messages` deques are diagnostic histories, not the source of active threat knowledge; eviction does not clear threats. `ConflictResolver` owns the current decisions, resource incumbents, a 64-entry changed-outcome history and cumulative counters. `ConflictPolicy` configures critical severity and target matching; the action-priority hierarchy is fixed. Neither the resolver nor `PriorityReceiver` takes an environment map as input. ### Before any arm moves `CentralController.step` increments simulation time, then calls `_signal_priorities` before collecting grip owners or advancing grasp/movement. The pre-movement phase is ordered as follows: 1. Each arm refreshes contact observations and runs its signaller against the supplied object snapshot. 2. New signals are passed to `PriorityReceiver.receive`. Reporting is deduplicated; higher priority and critical-severity upgrades are emitted, while clearance removes the matching active report. 3. The controller derives the all-arm withdrawal flag from outstanding priority-100 reports at or above `critical_severity`. 4. Each arm supplies an `ArmIntent`: activity, target, held object, named-food goal, capture candidate, incidental discovery and avoidance state. The arm constructs this local summary; the resolver does not inspect scene geometry. 5. `ConflictResolver.resolve` selects one `ConflictDecision` per arm from those intentions, the set of individually withdrawing arms and the broadcast flag. 6. `ControlledArm.apply_decision` applies the command. Withdrawal releases any grip first; yielding clears incomplete grasp dwell and suspends ordinary task movement. Actual releases and abandoned active carries update separate counters. The remaining owners are then collected and `update_grasp` runs in stable arm order. This ordering ensures that safety can override an existing grip before carrying moves its payload, and that the final capture reservation check still prevents double ownership. ### Arbitration and application are separate `ConflictDecision` records action, priority, conflict label, reason, optional winner and local/central scope. It contains no joint angles. `request_withdrawal` supplies a temporary goal near the arm's own attachment without replacing an ordinary assigned reach. A released carry loses its attachment and assignment; it is not resumed as if the payload were still held. Resource matching uses food identity when both intentions identify an object; otherwise it compares destinations using `target_tolerance`. Coordinate groups use their first member as an anchor, avoiding transitive chains of increasingly distant targets. Ownership outranks an eligible incumbent, which outranks arm number. `_incumbents` retains the winning intention so a prior winner only keeps precedence while still claiming the same resource. Retreating arms relinquish claims, idle non-owners do not reserve old destinations, and local safety steering is not replaced by Yield. Named-food assignments are represented by `ControlledArm.goal_object`. Filtering capture candidates enforces the assignment against incidental discoveries. The scenario loader resolves external food names to runtime identities; saving uses `focus_food` where necessary to preserve identity while retaining a fixed coordinate destination. Reservations, reports and active recoil episodes are runtime state, not a saved playback checkpoint. ### Local response and safe movement `HazardResponse.sense` detects circles using finite-segment projection. Detection includes the sensing strip and detection range, while exposure requires strict penetration of the actual hazard radius. `steer` replaces a segment's endpoint request during recoil or mild-hazard penetration; otherwise it reuses boundary steering with buffered copies of sensed hazard circles. Those copies never enter the solid-obstacle collision guard. The movement controller preserves joint-speed and bend bounds and tests swept obstacle clearance. If the combined proposal is blocked during aversion or withdrawal, it tests subsets of those same proposed turns from the tip towards the base. Each trial is checked from the original pose rather than only from the preceding trial, so the final simultaneous movement remains safe. This is constrained local recovery, not global path search. `blocked` describes rejected movement even when the conflict decision still requests withdrawal. ### Configuration and lifecycle `scenario.py` validates hazard severity/range, builds `EscalationPolicy` and `ConflictPolicy`, and passes them into the controller and arm signallers. Defaults are report severity 0.4, withdrawal severity 0.75, continuous exposure 2.0 seconds, report clearance 0.6 seconds, critical severity 0.95 and target tolerance 2 pixels. Local recoil has its own threshold of 0.4, duration `0.25 + 0.9 × severity`, and 0.6-second reassessment interval; changing report policy does not rewrite those local rules. Restart/load rebuild model state and reset episodes, reports, reservations and histories. Paused sensor refresh does not advance exposure or report-clearance timers. Deleting a hazard removes it from local steering memory when response sensing next runs; central knowledge persists until the reporting arm completes its clearance interval. ## Snapshot updates versus scene updates Two update interfaces serve different purposes: | Interface | Purpose | Scene mutation and capture reporting | | ---------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `CentralController.step(seconds, environment)` | Full interactive simulation | Supports grasping, ownership, carried-object movement and reports | | `CentralController.update(seconds, objects)` | Isolated reaching/sensing against an immutable snapshot | Advances shared time, signalling, arbitration and arm movement, but not grasp creation or carried-object scene updates | | `CentralController.refresh_sensing(objects)` | Reconcile scene edits and update displayed observations | Does not advance movement or dwell time; can release invalid grips and cancel invalid contact candidates | Use `step` when simulating capture or carrying. `update` is useful for numerical experiments that need only reaching and contact observations, and for testing those behaviours independently. It is not an interchangeable way to advance a live carrying scene. ## Arm geometry and local movement `ArmParameters` validates the segment count, segment length, maximum bend and turning speed. `Arm` stores its fixed base and relative joint angles. Its `points` property reconstructs a fresh list through `geometry.pose_points`, keeping connectivity and lengths exact without storing a second mutable representation of the pose. `ReachController` owns the target and the previous `SegmentRequest` messages. In each movement update it: 1. Updates `HazardResponse` and filters solid collision candidates to obstacle circles; food and hazards are not arm collision walls. 2. Handles an enclosed target or a completed ordinary reach. 3. Builds local endpoint goals from previous neighbour requests, using the withdrawal destination at the tip when present, otherwise the assigned target. 4. Applies segment-local hazard steering and obstacle avoidance, then computes new requests. 5. Proposes bounded joint turns from the fixed base outwards, rechecking local steering from each updated attachment. 6. Accepts the proposed angles only when `avoidance.safe_motion` approves the swept movement. During hazard response or withdrawal, a rejected proposal also triggers a tip-to-base search over safe subsets of the proposed joint turns; every accepted combination is checked against the original pose’s complete sweep. The controller stores new requests even when the geometric guard rejects the trial pose. This allows later requests to evolve without forcing unsafe movement. The guard examines the whole trial movement but only returns an accept/reject result; it does not generate a centrally planned pose. The equations and coordinate conventions are described in Reach Controller and Arm Movement. `segment_control.py` contains the small numerical rules without access to the whole arm, while `ReachController` schedules those rules and materialises the connected chain. ## Sensing and scene ownership `WorldObject` is an immutable dataclass containing a positive identity, centre, type and radius, plus severity and detection range for hazard behaviour. `Environment` owns a private identity-to-object dictionary. Moving an object replaces its record; `objects` returns an immutable tuple snapshot. Removing an object does not reuse its ID later. `detect_contacts` returns immutable `Contact` records containing the segment index, object identity, object type and closest position on the segment. Each `ControlledArm` replaces its own contact tuple on refresh. Multiple observations of one object and multiple objects on one segment remain distinguishable. The environment stores no separate grip-owner table. Existing `Grip` records provide ownership when `CentralController.step` builds the unavailable-ID set. A completed local capture is added immediately to that set for the rest of the step. User object edits call `release_object` before directly moving or deleting a held object. Sensing also checks whether a grip's object still exists at its expected attachment position. This second check reconciles external scene edits and prevents a deleted or dragged object from being recreated or snapped back. ## Grasping and carrying `grasp_candidate` groups food contacts by object identity and looks for consecutive segment indices. Two adjacent segments must maintain the same candidate for 0.25 simulation seconds. During that interval, the arm retains its pose. An interrupted candidate loses its accumulated time. `Grip.attach` records the object's position relative to the final segment in the qualifying pair. `Grip.centre` applies that local offset to any subsequent pose. This is a rigid latch model: it does not solve friction forces or require both original contacts to remain present after capture. When carrying, `ControlledArm._carry` translates the requested object destination into a changing tip objective. It saves the previous angles, lets the reach controller attempt a movement, and then calls `Grip.safe_motion` for the object's swept circle. If the payload check fails, it restores the previous angles and marks movement blocked. The scene object moves only after an accepted payload check. Retraction is an ordinary carrying destination 40 pixels outward from the fixed attachment along the initial arm direction. Arrival within two pixels changes carrying to holding. Releasing clears the attachment and idles the arm. Geometry resets release held food before rebuilding the arm; speed-only edits preserve the attachment. ## State and presentation `ArmState` is derived from underlying flags, contacts and grip state rather than stored as an independently mutable state variable. Display priority is: 1. Withdrawal status: holding under alert if a grip remains, otherwise Withdrawing or Withdrawal blocked. 2. Yielding when resource arbitration suspends ordinary task movement. 3. Blocked while active, followed by the local hazard state, then obstacle Avoiding. 4. Holding, Carrying or Retracting when a grip exists. 5. Grasping while a candidate is maturing, then Contact when observations exist. 6. Idle, Reached or Reaching according to activity and reach progress. A displayed restriction does not erase other state. For example, a blocked carry still has a grip, a carry destination and contact observations. `ReachController.status` separately provides the “Beyond reach” diagnosis used by the selected-arm display. `view.py` reads these values but does not move objects, change angles or assign tasks. It draws the world, arm identities, contact locations, ownership rings and the three sidebar cards. The keypress/action table is a reference, not a set of clickable buttons. Fonts are created at startup and supplied to rendering; the smaller font is shared by settings contents and shortcut contents. World bounds are shared with input handling so drag positions are clamped to the visible area. This is a presentation boundary, not an additional collision wall in the model. ### Significant-event history and camera `Application` owns `ActivityHistory`, observes the controller after each complete tick and after applicable UI edits, and resets the history when rebuilding a scene. `activity.py` has no Pygame dependency and never changes model decisions. It compares current state with its previous baseline to collect captures, releases, escalation, critical broadcasts, newly blocked arms and yielding decisions. Related critical-alert, withdrawal and release changes are grouped into one `ActivityEvent`. Repeated matching category/arm/text groups retain a count and move to their latest occurrence; at most 256 groups are retained. This presentation history is distinct from raw diagnostic deques and omits routine discovery/clear chatter. Its release total counts observed detachments; the resolver's release counter specifically counts releases caused by withdrawal arbitration. `draw_messages` renders a stable three-row summary beneath the scene. `draw_history` wraps and paginates filtered groups. Opening History saves the pause state and pauses playback; its event handler consumes input before normal scene handling, preventing clicks from assigning targets. Closing restores the saved pause state and clears fractional frame time. Explicit scrolling holds a row position; Jump to latest restores following. `SceneCamera.fit` centres the body and fits full possible arm reach for empty, cleared and loaded scenes. Rendering projects model coordinates; pointer editing applies the inverse transform. Camera fitting does not rewrite scenario coordinates or affect sensing and collision calculations. ## Web implementation and parity `explorer/js` mirrors the simulation layers in `hazards.js`, `escalation.js`, `conflicts.js`, `organism.js` and `model.js`. `simulation.js` owns fixed-step playback, pause, restart and speed multiplication independently of DOM rendering. `app.js` connects controls and readouts to that model; the Priority Communication and Conflict Resolution cards in `explorer/index.html` present the corresponding counters and bounded histories. The desktop's `ActivityHistory` inspection overlay is a presentation feature; the web cards currently display their own report/conflict views. Equivalent model behaviour does not require identical interface layout. `tests/web/reference.py` generates fresh Python snapshots for JavaScript comparisons, including angles, contacts, hazard metrics, reports, decisions, grips and scene objects. The extended reef trace includes the ten-second hazard-recovery checkpoint. Separate model tests check that accelerated playback executes the same sequence as normal speed, rather than increasing the solver timestep. ## Important defaults and boundaries | Setting | Current value or distinction | | ----------------------------------------- | --------------------------------------------------------------------------- | | Arms | Eight, with fixed radial attachments | | Body radius | 32 pixels | | Segments per arm | 20 by default; model accepts 2–100, interface adjusts 2–40 | | Segment length | 12 pixels in the eight-arm scene; standalone `ArmParameters` defaults to 18 | | Maximum relative bend | π/3 radians, displayed as 60 degrees | | Turning speed | 1.5 radians per second per joint | | Local steering response | 20 per second | | Ordinary reach and carry tolerance | 2 pixels | | Sensing radius and arm obstacle clearance | Both 3 pixels, but used for separate tests | | Default scene object radius | 14 pixels | | Obstacle lookahead | Up to three segment lengths | | Grasp requirement | Two adjacent contacts on one food for 0.25 seconds | | Retraction offset | 40 pixels from the fixed arm base along its initial heading | These are model and interface defaults, not biological measurements. An arm can remain trapped by the local rules. Obstacles are circles; body collision, arm-to-arm collision, grasp-force modelling and reward-based planning are not implemented. Fixed-priority conflict arbitration is implemented in `conflicts.py` and mirrored in `conflicts.js`. ## Running and verification The package declares Python 3.14 or newer and Pygame Community Edition. The development dependencies are pytest and Ruff. NumPy is not required by the current implementation. From the project root with the supplied environment activated: ```sh python -m octopus_controller ``` The installed `octopus-controller` command calls `app.main`, and `scripts/run.sh` activates the local environment before launching it. Package-relative icon loading avoids depending on the current working directory. The automated checks are: ```sh python -m pytest python -m ruff check src tests python -m ruff format --check src tests ``` Tests are organised by responsibility: | Test file | Main coverage | | ----------------------------- | ---------------------------------------------------------------------------------------------------- | | `test_model.py` | Reaching, retargeting, geometry invariants, speed/bend limits and invalid inputs | | `test_segment_control.py` | Local numerical edge cases and one-neighbour-per-tick propagation | | `test_organism.py` | Independent arms, radial layout, assignments, idle/resume and isolation | | `test_sensing.py` | Contact boundaries, object types, stable identity and contact clearing | | `test_avoidance.py` | Detours, swept crossings, blocked targets and retained constraints | | `test_grasping.py` | Dwell, ownership, reporting, carrying, retraction, release and payload safety | | `test_app.py` | Input, pause/step/speed, camera transforms, history inspection, scene editing and headless rendering | | `test_hazards.py` | Detection, recoil, retained tasks, exposure and safe escape from the reef hazard | | `test_escalation.py` | Reporting thresholds, sustained exposure, deduplication, upgrades and clearance | | `test_conflicts.py` | Safety precedence, releases, resource ownership/incumbency and incidental-food filtering | | `test_activity.py` | Significant-event grouping, repetition counts, filters and bounded presentation history | | `tests/web/*.test.js` | JavaScript model behaviour, Python parity and playback invariance | | `tests/web/browser-check.mjs` | Browser controls, responsive layout, readouts and functional checks | The numerical tests are deterministic. Graphical tests use SDL's dummy display and audio drivers, allowing interface and startup checks without opening a desktop window. Web model checks run with `npm test`; formatting with `npm run format:check`. The separate `npm run test:browser` suite requires the explorer to be served locally and an available Playwright browser (or a configured browser executable).