Both postcode batchers share one group-preserving packing core 🟪

Review feedback (#1481): the address batcher and the Modelling Run batcher
implemented the same greedy packing; the core moves to
utilities/grouped_batching.py and both become thin wrappers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 13:24:02 +00:00
parent 07190fc332
commit cc4bf4394e
3 changed files with 59 additions and 54 deletions

View file

@ -6,6 +6,7 @@ same packing the trigger script has been running.
"""
from backend.app.modelling.property_filters import FilteredProperty
from utilities.grouped_batching import iter_grouped_batches
BATCH_SIZE = 50
@ -16,18 +17,8 @@ def pack_postcode_batches(
"""Pack *properties* into batches of ~*batch_size*, never splitting a
postcode across batches. A single postcode larger than *batch_size*
becomes its own oversized batch."""
groups: dict[str, list[FilteredProperty]] = {}
for prop in properties:
groups.setdefault(prop.postcode or "", []).append(prop)
batches: list[list[FilteredProperty]] = []
current: list[FilteredProperty] = []
for group in groups.values():
if current and len(current) + len(group) > batch_size:
batches.append(current)
current = list(group)
else:
current.extend(group)
if current:
batches.append(current)
return batches
return list(
iter_grouped_batches(
properties, key=lambda p: p.postcode or "", max_batch_size=batch_size
)
)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Iterable, Iterator
from domain.addresses.unstandardised_address import AddressList, UnstandardisedAddress
from domain.postcode import Postcode
from utilities.grouped_batching import iter_grouped_batches
def iter_postcode_grouped_batches(
@ -11,41 +11,7 @@ def iter_postcode_grouped_batches(
*,
max_batch_size: int = 500,
) -> Iterator[AddressList]:
if max_batch_size < 1:
raise ValueError("max_batch_size must be >= 1")
groups = _group_by_postcode_in_order(addresses)
buffer: AddressList = AddressList([])
for group in groups.values():
group_len = len(group)
# Oversize single-Postcode group: flush buffer first, then dispatch
# the group as its own batch. Mirrors the legacy
# ``if group_len >= batch_size`` branch.
if group_len >= max_batch_size:
if buffer:
yield buffer
buffer = AddressList([])
yield group
continue
# Adding this group would overflow: flush buffer before appending.
if len(buffer) + group_len > max_batch_size:
yield buffer
buffer = AddressList([])
buffer.extend(group)
# Final flush.
if buffer:
yield buffer
def _group_by_postcode_in_order(
addresses: Iterable[UnstandardisedAddress],
) -> dict[Postcode, AddressList]:
groups: dict[Postcode, AddressList] = {}
for address in addresses:
groups.setdefault(address.postcode, AddressList([])).append(address)
return groups
for batch in iter_grouped_batches(
addresses, key=lambda a: a.postcode, max_batch_size=max_batch_size
):
yield AddressList(batch)

View file

@ -0,0 +1,48 @@
"""Greedy group-preserving batching.
Packs items into batches of at most ``max_batch_size`` without ever splitting
a group (items sharing a key) across batches; a single group larger than the
cap becomes its own oversized batch. Shared by the address postcode batcher
(``domain/addresses/postcode_batching.py``) and the Modelling Run distributor
(``backend/app/modelling/batching.py``).
"""
from collections.abc import Callable, Hashable, Iterable, Iterator
from typing import TypeVar
T = TypeVar("T")
def iter_grouped_batches(
items: Iterable[T],
*,
key: Callable[[T], Hashable],
max_batch_size: int,
) -> Iterator[list[T]]:
if max_batch_size < 1:
raise ValueError("max_batch_size must be >= 1")
groups: dict[Hashable, list[T]] = {}
for item in items:
groups.setdefault(key(item), []).append(item)
buffer: list[T] = []
for group in groups.values():
# Oversize single-key group: flush the buffer first, then dispatch
# the group as its own batch.
if len(group) >= max_batch_size:
if buffer:
yield buffer
buffer = []
yield group
continue
# Adding this group would overflow: flush the buffer before appending.
if len(buffer) + len(group) > max_batch_size:
yield buffer
buffer = []
buffer.extend(group)
if buffer:
yield buffer