Skip to content

Commit c05690a

Browse files
authored
fix: prevent overlapping overview pages (#2009)
* fix: make overview pagination stable Use one round-robin entity ordering for every page so the requested limit is honored and next_offset cannot repeat prior entities. Add a regression covering the reported two-page overlap. Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> * test: cover multi-domain overview pagination Walk every page of an equal multi-domain result and assert the round-robin pages are complete and disjoint. Document the allocator mutation contract and stream the ordering so pagination only retains the requested page. Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> --------- Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
1 parent fe544a9 commit c05690a

2 files changed

Lines changed: 112 additions & 62 deletions

File tree

src/ha_mcp/tools/smart_search/_overview.py

Lines changed: 32 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import logging
55
import random
6+
from itertools import islice
67
from typing import Any
78

89
from ...visibility.resolver import load_hidden_set
@@ -433,65 +434,41 @@ def _format_domain_stats(
433434
return formatted_domain_stats
434435

435436
@staticmethod
436-
def _allocate_page_one(
437+
def _allocate_page(
437438
formatted_domain_stats: dict[str, dict[str, Any]],
438439
effective_limit: int,
439-
total_entity_count: int,
440+
offset: int,
440441
) -> int:
441-
"""Distribute the page-1 budget: a min allocation per domain, rest proportional.
442+
"""Take one stable round-robin page across domains.
443+
444+
A single ordering must drive every page. Otherwise a specially balanced
445+
first page followed by sequential offset pages can repeat entities.
442446
443-
Gives each domain a minimum slice so the LLM sees entities from every
444-
domain, then distributes the remaining budget proportionally. Mutates
445-
``formatted_domain_stats`` in place; returns the count included.
447+
Mutates ``formatted_domain_stats`` in place; returns the count included.
446448
"""
447-
min_per_domain = 3
448-
num_domains = len(formatted_domain_stats)
449-
reserved = min(min_per_domain * num_domains, effective_limit)
450-
remaining_budget = effective_limit - reserved
451-
452-
entities_included = 0
453-
for domain_data in formatted_domain_stats.values():
454-
domain_entities = domain_data["entities"]
455-
domain_len = len(domain_entities)
456-
base = min(min_per_domain, domain_len)
457-
if total_entity_count > 0 and remaining_budget > 0:
458-
extra = int(remaining_budget * domain_len / total_entity_count)
459-
else:
460-
extra = 0
461-
take = min(base + extra, domain_len)
462-
if take < domain_len:
463-
domain_data["entities"] = domain_entities[:take]
464-
domain_data["truncated"] = True
465-
entities_included += len(domain_data["entities"])
466-
return entities_included
449+
max_domain_size = max(
450+
(len(data["entities"]) for data in formatted_domain_stats.values()),
451+
default=0,
452+
)
453+
ordered = (
454+
(domain, data["entities"][index])
455+
for index in range(max_domain_size)
456+
for domain, data in formatted_domain_stats.items()
457+
if index < len(data["entities"])
458+
)
467459

468-
@staticmethod
469-
def _allocate_subsequent_pages(
470-
formatted_domain_stats: dict[str, dict[str, Any]],
471-
effective_limit: int,
472-
offset: int,
473-
) -> int:
474-
"""Apply pages-2+ sequential skip/take across domains. Mutates in place."""
475-
entities_skipped = 0
476-
entities_included = 0
477-
for domain_data in formatted_domain_stats.values():
478-
domain_entities = domain_data["entities"]
479-
domain_len = len(domain_entities)
480-
481-
skip_from_domain = max(0, min(domain_len, offset - entities_skipped))
482-
budget_left = effective_limit - entities_included
483-
take_from_domain = max(0, min(domain_len - skip_from_domain, budget_left))
484-
485-
if skip_from_domain > 0 or take_from_domain < domain_len:
486-
domain_data["entities"] = domain_entities[
487-
skip_from_domain : skip_from_domain + take_from_domain
488-
]
489-
if take_from_domain < domain_len:
490-
domain_data["truncated"] = True
460+
selected: dict[str, list[dict[str, Any]]] = {
461+
domain: [] for domain in formatted_domain_stats
462+
}
463+
for domain, entity in islice(ordered, offset, offset + effective_limit):
464+
selected[domain].append(entity)
491465

492-
entities_skipped += skip_from_domain
493-
entities_included += take_from_domain
494-
return entities_included
466+
for domain, data in formatted_domain_stats.items():
467+
original_count = len(data["entities"])
468+
data["entities"] = selected[domain]
469+
if len(data["entities"]) < original_count:
470+
data["truncated"] = True
471+
return sum(len(entities) for entities in selected.values())
495472

496473
def _paginate_overview_entities(
497474
self,
@@ -514,14 +491,9 @@ def _paginate_overview_entities(
514491
total_entity_count = sum(
515492
len(ds["entities"]) for ds in formatted_domain_stats.values()
516493
)
517-
if offset == 0:
518-
entities_included = self._allocate_page_one(
519-
formatted_domain_stats, effective_limit, total_entity_count
520-
)
521-
else:
522-
entities_included = self._allocate_subsequent_pages(
523-
formatted_domain_stats, effective_limit, offset
524-
)
494+
entities_included = self._allocate_page(
495+
formatted_domain_stats, effective_limit, offset
496+
)
525497

526498
has_more = (offset + entities_included) < total_entity_count
527499
return {

tests/src/unit/test_performance_parallelization.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,40 @@ async def test_pagination_offset_skips_entities(self):
539539
assert result["pagination"]["has_more"] is False
540540
assert result["pagination"]["next_offset"] is None
541541

542+
@pytest.mark.asyncio
543+
async def test_pagination_pages_respect_limit_without_overlap(self):
544+
"""Following next_offset returns disjoint pages of the requested size."""
545+
entities = [
546+
{
547+
"entity_id": f"light.l{i}",
548+
"attributes": {"friendly_name": f"Light {i}"},
549+
"state": "on",
550+
}
551+
for i in range(4)
552+
]
553+
tools = _make_tools(MockClient(entities=entities))
554+
555+
first = await tools.get_system_overview(
556+
detail_level="standard", limit=2, offset=0
557+
)
558+
second = await tools.get_system_overview(
559+
detail_level="standard",
560+
limit=2,
561+
offset=first["pagination"]["next_offset"],
562+
)
563+
564+
first_names = {
565+
entity["friendly_name"]
566+
for entity in first["domain_stats"]["light"]["entities"]
567+
}
568+
second_names = {
569+
entity["friendly_name"]
570+
for entity in second["domain_stats"]["light"]["entities"]
571+
}
572+
assert first["pagination"]["entities_returned"] == 2
573+
assert second["pagination"]["entities_returned"] == 2
574+
assert first_names.isdisjoint(second_names)
575+
542576
@pytest.mark.asyncio
543577
async def test_pagination_not_applied_to_minimal(self):
544578
"""Minimal mode does not apply global pagination (already capped per-domain)."""
@@ -561,7 +595,7 @@ async def test_pagination_not_applied_to_minimal(self):
561595

562596
@pytest.mark.asyncio
563597
async def test_pagination_across_multiple_domains(self):
564-
"""Pagination distributes budget fairly across domains on page 1."""
598+
"""Pagination interleaves entities across domains in round-robin order."""
565599
entities = (
566600
[
567601
{
@@ -597,7 +631,7 @@ async def test_pagination_across_multiple_domains(self):
597631
assert result["domain_stats"]["sensor"]["count"] == 150
598632
assert result["domain_stats"]["light"]["count"] == 50
599633
assert result["domain_stats"]["switch"]["count"] == 50
600-
# Every domain gets at least some entities (fair distribution)
634+
# Round-robin interleaving includes entities from every domain
601635
assert len(result["domain_stats"]["sensor"]["entities"]) >= 3
602636
assert len(result["domain_stats"]["light"]["entities"]) >= 3
603637
assert len(result["domain_stats"]["switch"]["entities"]) >= 3
@@ -608,6 +642,50 @@ async def test_pagination_across_multiple_domains(self):
608642
assert total_returned <= 100
609643
assert result["pagination"]["has_more"] is True
610644

645+
@pytest.mark.asyncio
646+
async def test_pagination_walks_multiple_domains_without_overlap(self):
647+
"""Following next_offset tiles a multi-domain round-robin ordering."""
648+
entities = [
649+
{
650+
"entity_id": f"{domain}.{domain[0]}{index}",
651+
"attributes": {"friendly_name": f"{domain.title()} {index}"},
652+
"state": "on",
653+
}
654+
for domain in ("sensor", "light", "switch")
655+
for index in range(3)
656+
]
657+
tools = _make_tools(MockClient(entities=entities))
658+
659+
pages = []
660+
offset = 0
661+
while True:
662+
page = await tools.get_system_overview(
663+
detail_level="standard", limit=3, offset=offset
664+
)
665+
pages.append(page)
666+
next_offset = page["pagination"]["next_offset"]
667+
if next_offset is None:
668+
break
669+
offset = next_offset
670+
671+
returned_names = [
672+
entity["friendly_name"]
673+
for page in pages
674+
for domain in page["domain_stats"].values()
675+
for entity in domain["entities"]
676+
]
677+
expected_names = {entity["attributes"]["friendly_name"] for entity in entities}
678+
679+
assert len(pages) == 3
680+
assert all(page["pagination"]["entities_returned"] == 3 for page in pages)
681+
assert all(
682+
len(domain["entities"]) == 1
683+
for page in pages
684+
for domain in page["domain_stats"].values()
685+
)
686+
assert len(returned_names) == len(set(returned_names))
687+
assert set(returned_names) == expected_names
688+
611689
@pytest.mark.asyncio
612690
async def test_pagination_explicit_limit_overrides_default(self):
613691
"""Explicit limit=50 overrides the 200 default."""

0 commit comments

Comments
 (0)