Skip to content

Core Minimality & Deconstruction

What is the irreducible core, and how much can be removed while the system still cleans a room?

This is the measured answer to that question β€” the second axis of the doc-as-spec / deconstruction work (see 10-learning-system.md Β§9.3 for the same exercise scoped to one subsystem). It is a map, not a changelog: nothing here has been refactored. It records where the real waterline sits so that a "make the core stand alone" refactor (called B below) can be scoped honestly β€” or deliberately declined.

Method: an AST subsystem-dependency map plus a runtime trace of a single room_clean from its service entry to the adapter wire call. Line anchors are as of this audit (2026-07-11) β€” cite the method names, which are stable; the line numbers drift.


1. Two claims, only one of which matters

The bundled-subsystem pattern (self.X = XManager(manager=self)) invites a weak claim and a strong one, and they are easy to confuse:

  • "Subsystems detach" β€” outside-in. Remove a leaf, does the system survive? This proves the feature ring is removable. It is real but weak: maintenance falling off cleanly says maintenance is a module; it says nothing about what's left.
  • "The core stands alone" β€” inside-out. Start from the claimed atom, everything else absent, and fire one clean. This proves the core is a core. It is the claim worth testing, and the only way to test it is to build up from the middle, never to peel down from the edge.

The stated atom is adapter + dispatch + some input for dispatch. This document measures how close that is to true.

2. The genesis (why the atom is shaped the way it is)

At its base this integration is eufy-clean plus a clean way to call the rooms as a segment. The original heart was a ~118-line HA script (ALFRED): assemble the room input from dashboard helpers β†’ dual-gate it β†’ vacuum.send_command room_clean β†’ reset. A brand integration and a room-segment caller. Nothing more.

The base has evolved exactly once: when the eufy core was split from the primitives, eufy-clean stopped being the base and became an adapter β€” adapter #1, with Roborock as #2. The foundation didn't get more capable; it got generic. Everything else in the system accreted as strata above that unchanged base.

3. The atom, measured

Trace of the simplest room clean β€” core/manager.py::start_selected_rooms with strict_order=False, the atomic single-phase path a rooms-only brand would hit:

start_selected_rooms                       core/manager.py     ── entry
  get_start_status                           (gates: onboarding, paused-job)
  _build_effective_start_plan  ──▢ run_plan  (BUILDS the payload)
  _run_global_pre_calls        ──▢ dispatch  (no-op for a dumb brand)
  _resolve_live_dispatch_payload──▢ dispatch  (passthrough for a dumb brand)
  _dispatch_clean_payload      ──▢ dispatch  β˜… WIRE SEND β˜…
        hass.services.async_call(domain, name, data)   dispatch/manager.py:84
  build_active_job_state       ──▢ queue     (post-wire bookkeeping)

The caller never grew. The wire send is one line β€” dispatch/manager.py:84, command defaulting to the literal "room_clean" at :67 β€” structurally the same call ALFRED ended on. The DispatchManager reads only adapter config + hass; it touches no feature ring.

The set that must exist to fire that one send:

Atom member Where Role
adapter adapters/registry.py get_adapter_config + the adapter object the brand connection
dispatch dispatch/manager.py + queue/dispatch_engines.py get_dispatch_engine shape payload β†’ wire envelope β†’ send
rooms rooms/ segment identity (the IDs)
spine self.hass, self.storage, self.data HA handle + the internalized state ALFRED kept in helpers
active_job jobs/ ActiveJobTracker tracks the run you dispatched (paused-job gate + post-wire)
(+ the input pipeline) see Β§5 mis-homed β€” the interesting part

4. The rings (genuinely optional)

Confirmed by the trace, on the room-clean path:

  • Never touched: themes, maintenance, dock, room_map, map_source, live_room_refresh.
  • Guarded-optional β€” the model to copy: learning / external_run. Every reach-in is already if learning is None: return … (learning/utils.py, core/manager.py preflight + try/except snapshot). This is exactly why learning is the one heavy subsystem a bare core can run without β€” and the pattern the welds below lack.
  • Conditionally needed: phase_runner β€” only for strict_order / charge-step runs (atomic jobs build with phases=None, so the branch is never entered).

5. The welds β€” atom-logic living in the wrong house

Five rings are load-bearing on the current path. Decoded, they are not five dependencies β€” they are one legit atom member, three mis-homed room-definition primitives, and one self-satisfiable VA gate that isn't really a weld at all:

Ring Reach-in (as of audit) What it really is
run_plan _build_effective_start_plan β†’ run_plan._build_effective_start_plan IS "the input for dispatch." The dispatch engine is invoked through it. Atom-adjacent.
access_graph core/manager.py:1626 _normalized_managed_rooms_with_automation β†’ self.access_graph.* the room-config normalizer that feeds the payload. A dumb vac has no access rules β€” but the normalizing primitive was housed in the graph ring.
profiles core/manager.py:1250-1254 _protected_room_config / _match_profile_from_fields β†’ self.profiles.* the effective-room shaper. A dumb vac has no profiles β€” but the shaping primitive was housed in the profiles ring.
onboarding core/manager.py:2490 if not onboarding["floor_types_complete"]: β†’ blocks start onboarding_required a self-satisfiable VA gate, not a weld. Floor type is pure VA state β€” floor_types_confirmed is a VA-owned dict in data["onboarding"] (onboarding/manager.py:99), confirm_floor_type just flips a boolean; the adapter is never consulted. VA owns the room list and the flags, so it can always complete this itself. It blocks today only because it waits for a human confirm β€” a step that earns its keep for mopping (don't mop an unclassified carpet), and is cosmetic for a non-mopping vac.
active_job jobs/ paused-gate + post-wire live settings run tracking. Legit atom member β€” keep.

The verb stayed tiny; the noun got rich. The calling is unchanged (one async_call). What inflated is "which rooms, shaped how" β€” ALFRED's helper-string became run_plan β†’ access_graph β†’ profiles. Every mis-homed weld is room-definition; the onboarding gate is about when a room may be called β€” and since floor type is VA-owned, VA can answer that itself. None of them is about the call.

The seam is already there. The core holds thin delegators β€” _normalized_managed_rooms_with_automation (:1626), _protected_room_config (:1250) β€” whose signatures live in core but whose bodies just forward into the ring. Giving those bodies a ring-free default is most of B.

6. "How much can you pull off?" β€” a two-layer answer

  • Functionally, today: it still cleans. async_initialize constructs all subsystems unconditionally, so nothing is ever None; a rooms-only brand's send fires β€” once it clears the onboarding floor-type gate (:2490). That gate is brand-independent and self-satisfiable (VA owns the flags); it surprises only because it currently waits for a human confirm.
  • Structurally: not yet. Remove the rings and the path raises AttributeError before the wire send, at: run_plan (_build_effective_start_plan), access_graph (:1626), profiles (:1250/:1254), active_job (paused-gate), onboarding (:2490/:3740).

The distance between those two layers is the deconstruction work. Answered plainly: pull off everything and you are back at ALFRED β€” and it still calls a room.

7. B β€” the core-stands refactor (blueprint, not done)

Not "cut five welds." Really two relocations + one default (plus one keep):

  1. Room-normalizer β†’ core. Move _normalized_managed_rooms_with_automation's implementation out of access_graph into core/run_plan as a ring-free default; access_graph augments (rules, grants) only when present.
  2. Effective-room shaper β†’ core. Same for _protected_room_config / _match_profile_from_fields: a plain default in core; profiles tags only when present.
  3. Default the onboarding gate away (pure VA logic β€” not a relocation). Floor type is VA-owned, so auto-confirm a sensible default on room discovery and gate the requirement on mop capability: a non-mopping vac passes trivially (floor type is cosmetic for it), a mopping vac still confirms (don't mop a carpet). No brand or ring involvement.
  4. active_job stays β€” mechanism, not fit.

Result: the atom boots alone β€” adapter + dispatch + queue + rooms + spine + active_job, a plain payload path needing no feature ring. That is a dumb-vac core, and it is the genesis re-exposed rather than anything new.

B is speculative-value. It buys portability β€” "true adapters/modules that attach elsewhere" β€” not correctness. Sequence it behind an actual need to reuse the core. The smallest safe first cut is the onboarding default (item 3): pure VA logic, no ring and no adapter touched. Gating the requirement on mop capability makes it a true no-op for the vacs where floor type is cosmetic; the behavior-flip only exists for mopping vacs β€” which is exactly why the mop-capability condition, not a blind default, is the right shape.

8. Acceptance test

A dumb vac with rooms only β€” a brand whose adapter exposes segment IDs and a room_clean verb and nothing else (no learning, no maps, no maintenance, no planning). If the core cleans a room for it with every feature ring absent, the core stands.

  • Today: functionally yes, past the onboarding gate; structurally no (rings-absent β†’ AttributeError at the Β§6 anchors).
  • After B: yes, structurally.

The experience would be miserable β€” no ETA, no live map, no upkeep, no learned order. That is the point: those are rings, and a ring is a thing you add for fit, above a caller that has been the same size since ALFRED.


9. Subsystem pull-checklist

The map above walked the room-clean path. This section walks every manager-constructed subsystem the same way. As of the 2026-07-11 audit it is complete β€” every ring is classified, and the result is tidy: of 14 subsystems, most are already clean rings (LEAVE) or atom members (KEEP); the entire "core stands alone" refactor reduces to making three optional features cleanly optional β€” two mechanism relocations + one automatic import path (see Β§10).

How to walk one

For each subsystem, ask in order β€” the first "yes" sets the verdict:

  1. Dead coupling? Vestigial import/alias core never uses β†’ CLIP (see the maintenance clip, 3dc2a06).
  2. Mis-homed atom-logic? Does core reach in for a primitive it should own (a payload/identity builder, not a feature)? β†’ RELOCATE the primitive to core; the ring augments when present.
  3. Self-satisfiable gate? Does it block on VA-owned state the adapter never provides? β†’ DEFAULT it (see onboarding).
  4. Portable engine? Is it read-a-lot / write-a-little logic reused elsewhere (learning, battery, water)? β†’ EXTRACT behind a host contract (see Β§9.3 of 10-learning-system.md).
  5. Already clean? Lazy import, low reach-in count, no core-owned logic inside β†’ LEAVE (it's a proper ring today).
  6. Mechanism, not fit? The core needs it to fire/track a clean β†’ KEEP (atom member).

Signals to score it on: import (hard = spine candidate / lazy = ring candidate) Β· ctor (manager=self back-ref = can reach into core, tighter / data+hass = already loose) Β· core reach-ins (count of self.X. in core/, the weave; all currently originate in manager.py).

The checklist (reach-ins as of 2026-07-11 audit)

Subsystem Imp Ctor Reach-ins Verdict / status
active_job lazy mgr 36 KEEP β€” run tracking (paused-gate + live settings). Atom member. βœ… walked
profiles lazy mgr 20 SPLIT (relocate 4 / leave 16) β€” relocate the effective-room shaper (_protected_room_config :1251, _match_profile_from_fields :1254, _finalize_room_update :1257, get_effective_room_details :1219) to core; leave 15 profile/run CRUD + start_run_profile orchestration as the ring. Shaper is live on two atom paths (build_room_payload :2184, run_plan.py:1284-85) + the room-write path. βœ… walked
themes lazy data 13 LEAVE β€” the cleanest ring; no manager back-ref at all (ctor data only), owns data["theme"] + its own callback list. The reference for a fully detached ring. βœ… walked
access_graph lazy data+hass 12 RELOCATE β€” _normalized_managed_rooms_with_automation (room-normalizer) belongs in core; graph augments with rules/grants. βœ… walked
run_plan lazy mgr 12 KEEP/absorb β€” is the input pipeline for dispatch. Atom-adjacent. βœ… walked
external_run (learning) lazy mgr 12 EXTRACT β€” mapped in Β§9.3; already if … is None-guarded; the portable one. βœ… walked
dock lazy mgr 9 LEAVE β€” clean ring; reaches back into core read-only to gate actions (capabilities/lifecycle/active-job). Confirmed home for maintenance-level actions: self-clean/empty/descale slot into its generic action_buttons map + a supports_* gate, no structural change. βœ… walked
maintenance lazy mgr 7 LEAVE β€” dead import clipped (3dc2a06); now lazy-only, detachable. 7 live reach-ins are legit upkeep delegators. βœ… walked
room_map lazy mgr 7 LEAVE β€” atom-adjacency disproven: active-map resolution for the atom is already core-owned (_resolve_active_map_id :2712 β†’ pure rooms.room_discovery.get_active_map_id). A discovery/save/reconcile CRUD ring; its heavy back-reach is core plumbing, no trapped logic. βœ… walked
onboarding lazy data+hass 6 DEFAULT β†’ opt-in curation β€” a self-satisfiable gate (floor type is VA-owned), and really an optional curation feature (floor types + room review). Began as Eufy's phantom-0 mΒ² filter; the core path can auto-import + default floor types, curation opt-in. Not core. βœ… walked
map_source lazy mgr 5 LEAVE β€” live-map backdrop reader (provider segmentation + live-pose), off the room-clean path. Reaches back via two deliberate shared seams (_map_state_source_cache, _resolve_live_map_image_entity) β€” formalize as host contract, don't dissolve the ring. βœ… walked
dispatch lazy mgr 4 KEEP β€” the caller; reads only adapter cfg + hass. Ring-free. βœ… walked
phase_runner lazy mgr 3 KEEP (conditional) β€” needed only for strict-order / charge-step runs; atomic path never enters it. βœ… walked
live_room_refresh lazy mgr 1 LEAVE β€” the cleanest: one reach-in (maybe_pulse :3949), reaches back only for hass; all config/rate-limit/local-gate self-contained. No-op for Eufy. Already extracted from core for this reason. βœ… walked

Out of the ring set

  • Singletons (constructed in __init__.async_setup_entry, not the manager): LearningManager, BatteryHealthManager, ErrorTracker, MappingTracker. Battery/water are the cheap siblings β€” same estimation engine as learning; walk them after the learning extraction lands the shared host contract.
  • Atom / spine (hard-imported, the thing that stands): adapters, queue (engine + dispatch_engines), maps, models, rooms (identity), jobs, and core (storage/capabilities/charging). Not pulled β€” this is the core.
  • HA glue (platform wiring, not detachment candidates): listeners, services, sensor, setup, frontend, translations, textures.

Result

Audit complete. Five of five unwalked rings came back LEAVE β€” already-detachable, no trapped core logic, no dead coupling. The one suspected atom-adjacency (room_map / map_id) was disproven (core already owns active-map resolution). The only structural weld in the entire subsystem set is the profiles effective-room shaper (and its sibling, the access_graph room-normalizer). Two notable side-findings: themes is the reference fully-detached ring (no back-ref), and dock is confirmed as the natural home for maintenance-level device actions.

10. B β€” the "core stands alone" work plan

Plan now, work later. The complete walk reduces B to making three optional features cleanly optional: two mechanism relocations (B1/B2 — the room-shaping the core wrongly hid inside access_graph / profiles) plus one automatic import path (B3 — the map→rooms path onboarding gates). B1/B2 fix a genuine category error (core mechanism trapped inside a feature) and are worth doing on their own merits; B3 and the learning extraction are the portability plays — a core that boots for a rooms-only brand. Everything else is already LEAVE / KEEP or a separate track. Sequence behind an actual need to reuse the core.

The principle behind B1–B3: access_graph, profiles, and onboarding are all optional user features, not core. Access rules are all-or-nothing (configure the whole graph or none; core default "no rules, rooms clean freely"). Profiles are an opt-in preset layer (core default: a plain effective room). Onboarding is opt-in curation (floor-type confirmation + room review; core default: import straight from the map). Each is a human-configuration layer over an automatic core path; none is core. The bug is only that a piece of core mechanism β€” room-shaping (B1/B2) or the import-to-ready path (B3) β€” got parked inside, or gated behind, them. B doesn't pull logic out of the core; it pulls the core's automatic path back home and leaves all three features cleanly optional (present in full, or absent entirely).

B1 Β· Relocate the room-normalizer (access_graph β†’ core). Move _normalized_managed_rooms_with_automation's body into core (or run_plan); core produces a plain normalized-rooms payload with no access rules, and access_graph becomes a self-contained optional overlay β€” configured in full or not at all. It is already loosely built (data+hass, no back-ref), so this is a clean lift. Test: room-payload build + a no-graph path. Risk: low–moderate.

B2 Β· Relocate the effective-room shaper (profiles β†’ core). Move the 4 shaper primitives (_protected_room_config, _match_profile_from_fields, _finalize_room_update, get_effective_room_details) into core; profiles becomes a pure opt-in preset feature that supplies the catalog (get_room_profiles) when present. The 15 CRUD + start_run_profile orchestration stay in the ring and consume the relocated shaper via core. This is the highest-value cut β€” the shaper is live on two atom paths (build_room_payload :2184, run_plan.py:1284-85) plus the room-write path (update_room_fields :1351). Test: payload-build + room-update + profile-apply suites. Risk: moderate (hot path + write path).

B1 and B2 are the same job β€” reclaiming the room-definition mechanism β€” and should land together. After them the atom builds a plain rooms payload with access_graph/profiles absent entirely.

B3 Β· Make onboarding optional curation, not a gate (independent). Onboarding began as data hygiene β€” it kept Eufy's phantom 0 mΒ² "non-room" from being created (the human review excluded it; there is no automatic area filter β€” room_crud.py:238 keys only on map_id). But that one real need is a cheap import-time rule (auto-drop degenerate / 0 mΒ² segments); everything else it does β€” floor-type confirmation, room naming / enable review β€” is opt-in polish. So the core path becomes fully automatic: import map β†’ import rooms (skip phantoms) β†’ default floor types β†’ clean, with the start-gate (:2490) removed from the core path entirely. Curation is then a feature a user opts into; floor-type confirmation still matters for mopping, so surface it as a recommendation, not a block. Test: auto-import path + a phantom-segment fixture. Risk: runs previously blocked now proceed β†’ design pass (the floor-type default is mop-cosmetic for non-moppers).

Not B (separate tracks): - EXTRACT learning behind its Β§9.3 host contract β†’ battery/water/bounds fall out as cheap siblings on the shared engine. - dock feature growth β€” add self-clean/empty/descale as action_buttons entries + supports_* gates. Independent of B; the deconstruction just confirmed dock is the right home.

Acceptance (the dumb-vac test, structural): after B1–B3, instantiate the manager with access_graph/profiles/onboarding/learning/maps/maintenance absent, fire one room_clean for a rooms-only brand, and reach the wire send at dispatch/manager.py:84. That is the genesis re-exposed β€” ALFRED, generalized to any adapter.


See also: 01-architecture-overview.md Β· 05-core-manager.md Β· 10-learning-system.md (Β§9.3, the same host-contract exercise scoped to learning) Β· 21-adapter-system.md.