Entity Resolution Reliability โ recognise, don't derive¶
Status: design APPROVED 2026-08-14 (Chris ruled all three open decisions โ see ยง8). PARTLY BUILT as of 2026-08-16 โ see the table below before reading anything here as outstanding.
| Part | State |
|---|---|
User entity override (entity_overrides) |
SHIPPED. Live in brands.py, both adapters, const.py, capabilities.py; written by the System tab and the options flow |
The contest ladder (_narrow_competing, four rungs) |
SHIPPED. object_id โ translation_key โ state_class โ magnitude, documented in 22 โ The Adapter Contract |
Localized-id rescue by upstream translation_key |
SHIPPED (ef810519, 35ce560f) โ not in the original design; it emerged from issue #51 |
| Trait scorer โ recognise entities instead of deriving names | NOT BUILT. The one substantial item left, and the only mechanism that can separate the cleaning_area / total_cleaning_area collision, which name matching provably cannot |
| Census enrichment (device_class, unit, options, state) | NOT BUILT. Prerequisite for validating any scorer against hardware other than the maintainer's |
โ This file was reading as wholly unbuilt while three of its five parts had shipped. That is the ยง5a stack forming at document scale: a status line true when written, left beside work that overtook it. The table is the fix โ one live statement per part. Written after issue #49 field data disproved the diagnosis this project was carrying. Staged deliberately: "features are nice, working system is better".
SHIPS AS ONE SET โ P0 + P1 + P2 + the ยง5 census enrichment go out together, not incrementally. P3 remains blocked and out of scope for this release.
Scope note. This is a delta on 22 โ The Adapter Contract ยง5.2 step 4 and ยง6, not a parallel design. Every seam it uses already exists.
1. What the field data actually proved¶
Issue #49: a reporter whose vacuum is vacuum.robovac_x10_pro_omni while its 65 companion
entities are named sensor.living_room_eufy_clean_x10_pro_omni_* โ two unrelated stems on
one device. Twelve roles unresolved.
The diagnosis this project carried was wrong. It said the per-candidate rescue "was never
wired into entity_candidates". It is wired in:
augment_candidates_from_device(core/capabilities.py::augment_candidates_from_device), called bydetect_capabilitiesat:310โ already documented at 22 โ The Adapter Contract ยง5.2 step 4.- Landed in
9a37ad6c(2026-08-04); in v2.0.0 bygit merge-base --is-ancestor. The reporter runs 2.0.1, so they have it. - Run against the reporter's exact ids it rescues every probed role. The matcher is correct.
Eliminated, each by a specific check โ do not re-walk these:
| theory | killed by |
|---|---|
| not shipped / older build | commit is an ancestor of both v2.0.0 and v2.0.1 |
| load-order between the two rescues | both run in ONE function, adapters/eufy/adapter.py::register_eufy_adapter_for_vacuum and :314 |
| vacuum absent from registry at setup | resolve_declared_entities read config_entry_id off the SAME lookup and succeeded |
| diagnostics recomputing one half | both halves are stored setup-time state, diagnostics.py::_vacuum_diagnostics |
| stale caps surviving the upgrade | adapter registration runs from async_setup_entry (brands.py::register_brand_adapter); caps rebuild every start |
| the failing entities are disabled | census says disabled=false for all ten โ but see ยง2.2 |
Still unknown: entry.device_id empty at setup, no siblings at that instant, or the bare
except Exception (capabilities.py::augment_candidates_from_device) swallowing on HA 2026.8.1 (we pin 2026.5.3 and
cannot introspect theirs). P0-3 exists to make this answerable instead of inferable.
Battery โ the issue's headline โ is resolved on their 2.0.1 dump
(sensor.living_room_eufy_clean_x10_pro_omni_battery, state 100). What remains broken is the
ten roles that tell the card what the vacuum is doing.
1.1 Two defects confirmed on real hardware¶
Suffix collision โ silently wrong data, not missing data. Both
..._cleaning_area and ..._total_cleaning_area exist on that device, and both satisfy
endswith("_cleaning_area"). Same for cleaning_time. Which one wins depends on entity-registry
ordering, so cleaning_area can resolve to the lifetime total. This is the strongest
argument in the whole document: name matching provably cannot separate these two, because one
declared suffix is a substring of another.
"Absent" and "present but switched off" render identically. Five entities on that device are
disabled, including both ..._robot_position_x_raw and ..._robot_position_y_raw. They exist
with exactly the ids we derive; a disabled entity has no state, so _find returns None and the
role reads as missing. Same output, opposite fixes โ one is our bug, the other is a toggle in the
user's UI. This is 22 โ The Adapter Contract ยง5.2's DIAG-1 argument one level
deeper.
2. P0 โ correctness (small, independently shippable)¶
Nothing here needs approval on the later stages. These are bug fixes.
2.1 Exclusive, longest-suffix-wins sibling matching¶
endswith(suffix) is not a safe test when one declared suffix is a substring of another.
Fix: build the claim set from all declared suffixes across roles, and let the longest
matching declared suffix claim a sibling. ..._total_cleaning_area ends with a longer declared
suffix (_total_cleaning_area) than _cleaning_area, so it belongs to total_cleaning_area and
must not be offered to cleaning_area. Exclusivity is the property, not a heuristic.
Regression test must use the real colliding pair, not a synthetic one.
2.2 Distinguish absent / disabled / resolved¶
_find returning None currently erases why. Carry a reason
(resolved | disabled | absent) and surface it in entity_resolution. A disabled companion
is user-actionable immediately and costs us nothing to report.
2.3 Break the silence in the swallow¶
except Exception: return dict(cands) (capabilities.py::augment_candidates_from_device) logs nothing, so a field rescue
failure is indistinguishable from "ran and found nothing" โ the exact silent-failure shape the
function's own docstring calls the worst outcome. Log at WARNING with the exception, and record
in diagnostics: augmentation: {ran, siblings_seen, merged, error}.
This is what would have made #49 answerable from the first dump.
3. P1 โ companion-stem derivation¶
RULED 2026-08-14, refined same day: pre-fill ONLY for ambiguous roles that do not resolve today. Three cases, and the boundaries are what matter:
| case | behaviour |
|---|---|
| role resolves today | UNTOUCHED. No suggestion, no prompt, no change. Non-negotiable โ this is the ENT-1 safety property and it is what keeps every working install byte-identical. |
| does not resolve, exactly one candidate | AUTO-APPLY. The install self-heals with no user action. |
| does not resolve, candidates compete | PRE-FILL and ask. Confirmation is spent only where there is a real choice. |
CORRECTION, made while building this (2026-08-14). The framing above โ stem derivation as the thing that fixes a #49-class install โ is wrong, and the code proved it. Deriving the stem from device siblings requires reading device siblings, which is precisely the step suspected of failing on that install; the derivation inherits the failure it was meant to route around. Circular.
What actually closes the gap is SCOPE, not stems.
resolve_declared_entitiessearched the vacuum's CONFIG ENTRY and rescued battery on that very install;augment_candidates_from_devicesearched only its DEVICE and rescued nothing. Same instant, same registry. Searching both (live:ENT-5) is the fix with field evidence behind it, and it retires "device_id absent at setup" as a suspect rather than diagnosing it.The stem keeps a real but smaller job: breaking ties when several siblings match one role (live:ENT-6). It never synthesises an entity id โ a majority vote is evidence about naming, not proof that an entity exists.
โ SUPERSEDED โ the majority-stem vote was never shipped, and
2c1d847fdeleted it. Competing candidates are settled by the FOUR-RUNG CONTEST LADDER instead (_narrow_competing,capabilities.py::_narrow_competing):object_idโtranslation_keyโstate_classโmagnitude, strongest evidence first, each rung DECISIVE or the next is tried, and no rung deciding leaves the role unresolved. ยง4.5.1 of this same document describes the ladder; this section is kept for the reasoning that led there โ the tie-is-not-a-majority insight below is what became "an undecidable contest leaves the role UNRESOLVED rather than guessing". Do not implement the paragraph that follows.Why the ladder won: a stem vote is evidence about NAMING, and the first three ladder rungs read the entity REGISTRY (
translation_key,state_class), which is the upstream integration's own declaration and is available before any entity has a state.
Derive the companion stem by majority vote across the sibling object_ids (strip each one's owning suffix; what remains is its stem), and use it ONLY to rank competing candidates. A tie is not a majority โ two vacuums on one config entry produce a dead heat, and that must stay ambiguous rather than resolve to whichever stem was inserted first.
3.1 "Unambiguous" is a predicate, not a judgement¶
Auto-apply requires ALL of:
- exactly one surviving candidate after ยง2.1's exclusive longest-suffix claiming;
- that candidate is enabled and has a state (a disabled match is ยง2.2's case, not this one);
- its stem IS the device-majority stem.
Anything else is ambiguous and goes to the user. Worked example from #49: cleaning_area has two
raw endswith matches, but exclusive claiming assigns _total_cleaning_area to its own role,
leaving exactly one โ so it auto-applies. That is the collision fix and the self-heal being the
same mechanism.
Evidence limit stays on the record: the stem rule is n=1. Auto-apply is bounded by the predicate above rather than by confidence in the heuristic โ a second install that disagrees produces ambiguity and a prompt, not a silent wrong answer.
Verified against the reporter's real census: 65/65 companions share
living_room_eufy_clean_x10_pro_omni, and stem + declared suffix hits a real entity for every
failing role. (An earlier 64/65 count was a scrape artifact โ the outlier was the derived
select.robovac_x10_pro_omni_scene, which does not exist.)
Safety is the ENT-1 argument unchanged: candidates are appended, derived stays first, so an install where derivation already works resolves byte-identically.
Evidence limit โ state it plainly: n=1 install. Ship P2's record first if we want this confirmed before it becomes automatic (ยง6).
4. P2 โ user entity override (the escape hatch)¶
Unblocks a user regardless of how good our resolution ever gets, and reaches the cases
prefix-rescue provably cannot: scene (declared _scene, actual _scene_task โ a suffix
mismatch, not a stem one) and a companion on a different device.
4.1 Mirror the existing precedent โ do not invent a mechanism¶
โ PRECEDENT WITHDRAWN, 2026-08-16. This section rested on
brand_overridesas an established shape. That key has been removed: brand support is a statement about a tested upstream integration, so a rename is ours to follow and an unsupported system wants an adapter โ there was never going to be a writer for it, and a read path with no writer is a declaration defended by a comment rather than by a reader.The design below is unaffected in substance: entity overrides are a genuinely different question. Which entity fills a role IS install-specific (two device slugs on one vacuum, a renamed companion) and no amount of adapter maintenance can know it from here โ which is exactly why
entity_overrideshas writers andbrand_overridesnever would have. Read "mirror the existing precedent" below as "this is the shape we chose", not as "another key already does it".
The shape is a per-vacuum user override in config-entry data, read by core
(see 22 โ The Adapter Contract ยง6.1 for how brand selection resolves
now that no such override exists):
4.2 THE TRAP โ do not store this as an adapter config¶
22 โ The Adapter Contract ยง6: stored configs load first, then
register_brand_adapter overwrites them โ "code adapters always win". An override
persisted as a stored adapter config would be silently clobbered at every startup. It must be
read inside the code-adapter path.
4.3 Where it plugs in โ one place, both brands free¶
augment_candidates_from_device already speaks role โ candidate list. Feeding the override in
there means Roborock and Dreame inherit it with zero adapter changes, and core stays owner
of the mechanism with no brand vocabulary in it.
4.4 Precedence โ RULED: OVERRIDE WINS¶
Chris, 2026-08-14: "override wins โ it's a user choice." The override is consulted FIRST, ahead of every derived and device-sibling candidate. Not a last-resort fallback.
Rationale on the record: fallback-only cannot correct a wrong auto-resolution (ยง1.1's collision succeeds and is wrong), and a user who sets an override and sees nothing change is the same silent failure this document exists to remove.
Fall-through, and it is NOT silent. If the override does not resolve โ the entity was
renamed or deleted after it was set โ resolution continues to the normal candidates rather than
pinning a dead id. But it reports override_unresolved through the ยง2.2 reason field. A user
choice that has quietly stopped working must be visible; silently substituting our own guess for
their stated intent would reintroduce exactly the failure mode being fixed.
4.5 UI¶
A step on the existing EufyVacuumOptionsFlow (config_flow.py::EufyVacuumOptionsFlow โ today a single
async_step_init, already using selector.EntitySelector). Add a step, not a subsystem.
Default the screen to the unresolved roles only (already computed as
entity_resolution_summary.unresolved), with a "show all" toggle for correcting a wrong
resolution. Picker defaults to the vacuum's own device entities, ranked; plain entity selector
as the escape hatch, since the different-device case is precisely who needs this.
4.5.1 The "System" sub-tab โ WHAT WE ARE READING (surface 2)¶
Name provisional (Chris, 2026-08-14: "System for now, I can pick a better name
before we ship"). Rejected: "System settings and configuration" โ settings and
configuration are the same word twice, and the label lives in the Setup tab strip, which is
the tightest horizontal space in the panel at 360px. It measures 31 characters in English and
German pushes it past 40; the theme editor's sub-tab strip already had to be rebuilt for exactly
that. Frontend string, so it routes through the project's own i18n pipeline โ not the 18-file
HA-side path.
Its DEFAULT view is the FULL binding table, not a filtered one. This is the whole point and the easiest thing to get wrong. Every surface built so far renders only roles that FAILED, and a failures-only view is structurally blind to the defect that motivated this document: the ยง1.1 collision resolves successfully โ a real, existing, enabled entity โ it is simply the WRONG one, off by ~4000x. That user's screen looks perfectly healthy. A view that lists problems cannot show them a problem that does not look like one.
One row per role: role ยท entity id ยท HOW IT WAS CHOSEN, with editing as the exception on a row rather than the purpose of the screen.
The provenance column is the part with no home today. All of it is already computed and lands only in a diagnostics dump, which is a file you download and paste into an issue:
| shown as | source |
|---|---|
| derived name | the adapter's own candidate resolved |
| device sibling / config-entry sibling | live:ENT-5, and entity_augmentation reports the split |
| translation_key / state_class / magnitude | live:ENT-9, which rung of the ladder decided |
| your override | overrides_applied |
| disabled ยท absent ยท override_unresolved | live:ENT-2 entity_resolution_reasons |
Two reasons beyond the collision. The ENT-9 ladder now makes AUTOMATED choices between competing
candidates, and the magnitude rung is the least certain of them โ a human should be able to
sanity-check it. And issue #49's battery is not a naming bug at all: the reporter could see we
ARE bound to sensor.living_room_..._battery at state 100, which moves their question from
"what is broken" to "why is it not drawing".
This also carries the removal path the options flow cannot offer (ยง4.5: that screen merges and never clears, because its fields disappear once a role is fixed).
Per-vacuum, inheriting the Setup tab's existing per-vacuum iteration. The container name is deliberately broad. (It was originally worded so a brand override could land beside it later; that idea has since been ruled out โ see the ยง4.1 note.)
Two levels, and the CURRENT VALUE on the summary row¶
Refined after an external review (ChatGPT, 2026-08-14), which was right on all three counts.
Summary row โ role ยท entity id ยท current value ยท how it was chosen ยท [Change].
The value is the cheapest sanity check that exists, and it needs no engineering literacy:
Cleaning time sensor.robin_cleaning_time 0 min
Total cleaning time sensor.robin_total_cleaning_time 166 min
A reader confirms that in under a second. It also separates the two failure modes that look
identical from outside โ issue #49's battery resolves to a real entity reading 100 while the
card draws nothing, so seeing the value instantly moves the question from "the resolver picked
wrong" to "the resolver picked right and the consumer is broken".
Expanded row โ the forensic trail: every rung, the traits behind it, and the alternatives with WHY each was rejected.
THE TRAIL MUST NOT MANUFACTURE AGREEMENT. The ladder short-circuits on the first decisive
rung, so a role decided by translation_key never evaluated state_class or magnitude. Those
render as not evaluated, never as concurring. Showing unrun rungs as corroboration would
produce exactly the confident-and-wrong artifact this surface exists to catch โ the review's
sketch had magnitude as "supporting" on a row where it was never computed.
augment_candidates_from_device now records this (report["decisions"]), verified on live
hardware:
ivy cleaning_area -> by=translation_key rejected {ivy_total_cleaning_area: translation_key=total_cleaning_area}
alfred cleaning_area -> by=state_class rejected {..._total_cleaning_area: state_class=total}
robin task_status -> by=translation_key rejected {robin_status: translation_key=status}
Provenance is a first-class enum, not prose (BY_OBJECT_ID, BY_TRANSLATION_KEY,
BY_STATE_CLASS, BY_MAGNITUDE, plus derived / sibling / override / recorder). Six months from
now "chosen by: magnitude" sends you somewhere completely different from "chosen by: manual
override" โ one is a resolver bug to chase, the other is the user's own decision and not a bug
at all.
When the recorder pass lands it renders in the same human register โ "Observed reset behaviour
across 3 cleaning runs", never recorder_reset_behavior.
4.6 One override generalises to the whole device¶
Diff declared against chosen: the suffix is identical, so the stem is the only variable. Learn it from one pick and pre-fill the other unresolved roles for confirmation. The user fixes one role, not eleven.
4.7 Costs to accept before coding¶
- New persisted key โ trips the large-change rule; that is why this is a spec.
- Config-flow labels are HA-side: a new
translation_keymeans all 18 locale files by hand (see the HA-side translations ruling โ services are names and are never localized). - No user-facing string may be added without routing through i18n at creation.
5. P3 โ trait scorer (recognise, don't derive) โ BLOCKED on data¶
The endgame Chris named: stop generating names, start recognising entities. Score each device entity per role on traits the registry already holds:
| role | discriminating trait |
|---|---|
battery |
device_class: battery โ nothing to guess |
cleaning_area |
unit ftยฒ/mยฒ and not the token total (the ยง1.1 collision, solved by shape) |
water_level |
a select whose options match a known water vocabulary โ the options list is a fingerprint |
task_status vs work_mode |
both plain string sensors, so name is useless; only their state vocabularies separate them โ already in adapters/eufy/vocabulary.py |
Brand-agnostic by construction: core owns the traits (HA-standard device_class/unit), each
brand supplies only its vocabulary. One mechanism, three jobs โ auto-resolve when confident,
rank the P2 picker when not, and P2's record scores the scorer.
BLOCKER โ build the census enrichment first, and let it ship alone.
_device_entity_census (diagnostics.py::_device_entity_census) records only entity_id, disabled, platform.
None of the traits above are in any dump we hold, so a scorer built today would be tuned against
Alfred and called general. Add device_class, unit, select options and state to the census, then
validate against dumps that come back.
6. Learning โ what is honest, and what is not¶
Local generalisation (ยง4.6) needs no collection at all and is where the value is.
Cross-user learning means telemetry. Not proposed. Do not add silently. The honest channel is the dump: put the before/after record (candidates considered, siblings seen, what the user picked instead) in diagnostics, and every filed issue hands us the pattern with no consent problem.
That record is also P0-3's instrumentation โ one artifact, both jobs.
7. Implementation map (proposed)¶
| Piece | Where |
|---|---|
| Exclusive longest-suffix matching | core/capabilities.py augment_candidates_from_device |
| absent/disabled/resolved reason | core/capabilities.py _find* + diagnostics.py entity_resolution |
| Augmentation telemetry + WARNING | core/capabilities.py::augment_candidates_from_device + diagnostics.py |
| Companion-stem derivation | core/capabilities.py (new helper, brand-agnostic) |
| Override read path | augment_candidates_from_device, keyed by custom_components/eufy_vacuum/const.py::ENTITY_OVERRIDES_KEY |
| Override UI step | config_flow.py EufyVacuumOptionsFlow + 18 locale files |
| Census enrichment | diagnostics.py::_device_entity_census _device_entity_census |
| Trait scorer | core (traits) + adapters/*/vocabulary.py (vocabularies) |
Testing: core tests stay engine-agnostic with a fake/stub adapter; the real Eufy collision and
stem cases belong in tests/adapters/eufy/. pytest tests --no-cov is the behaviour gate.
8. Decisions โ RULED 2026-08-14¶
| # | Question | Ruling |
|---|---|---|
| 1 | ยง4.4 precedence | Override wins โ "it's a user choice". Consulted first, ahead of all derived and sibling candidates. Falls through if unresolvable, but reports override_unresolved rather than failing silently. |
| 2 | ยง3 stem: automatic or suggested | (The RULING stands; the MECHANISM it ruled on does not โ the stem vote was replaced by the contest ladder, see the banner in ยง3.) Pre-fill ONLY for ambiguous roles that do not resolve today. A role that works is never touched; an unresolved role with exactly one candidate auto-applies (self-heals); only competing candidates prompt. "Unambiguous" is the three-part predicate in ยง3.1, not a confidence call. |
| 3 | Stage gate | Ship as one set. P0 + P1 + P2 + ยง5 census enrichment release together; P0 does NOT go out alone. P3 stays blocked. |
8.1 Consequences of ruling 3¶
Shipping as a set means the wrong-data collision fix (ยง2.1) waits for the override UI and its 18 locale files. That is the accepted trade: one coherent release rather than a correctness patch followed by a feature that changes the same code paths again.
The set is therefore gated on the slowest item โ the options-flow step and its translations โ so that is the schedule risk to watch, not the core logic.