From f2f954f45953d378eabae9242663e4350068de77 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 03:56:57 +0000 Subject: [PATCH] feat(epc-prediction): slice-5d target assembly + eligibility gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_prediction_target assembles an EPC-less Property's PredictionTarget from its identity (postcode), resolved coordinates, and Landlord-Override attributes (property_type / built_form / wall_construction). The eligibility GATE: a Property whose property_type is unknown returns None — never sized from a mixed-type cohort (ADR-0031). property_type is the hard cohort filter. The override attributes are read through a PredictionTargetAttributesReader port (stub seam) — the real adapter (a read over property_overrides) is being built separately by the team; ingestion wiring depends on the abstraction and tests substitute a fake. 2 tests (assembly + gate); pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/epc_prediction/prediction_target.py | 51 +++++++++++++++++ .../prediction_target_attributes_reader.py | 23 ++++++++ .../epc_prediction/test_prediction_target.py | 56 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 domain/epc_prediction/prediction_target.py create mode 100644 repositories/property/prediction_target_attributes_reader.py create mode 100644 tests/domain/epc_prediction/test_prediction_target.py diff --git a/domain/epc_prediction/prediction_target.py b/domain/epc_prediction/prediction_target.py new file mode 100644 index 00000000..70800e55 --- /dev/null +++ b/domain/epc_prediction/prediction_target.py @@ -0,0 +1,51 @@ +"""Assemble an EPC-less Property's PredictionTarget, with the eligibility gate +(ADR-0031 slice-5d). + +A `PredictionTarget` needs the target's own known inputs: its postcode (to find +the cohort), coordinates (to distance-weight it), and the Landlord-Override +attributes that condition selection — `property_type` (the HARD cohort filter), +plus optional `built_form` / `wall_construction`. `property_type` is required: a +Property whose type is unknown is gated out (never sized from a mixed-type +cohort), so the builder returns None and the caller skips prediction. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Union + +from domain.epc_prediction.comparable_properties import PredictionTarget +from domain.geospatial.coordinates import Coordinates +from domain.property.property import PropertyIdentity + + +@dataclass(frozen=True) +class PredictionTargetAttributes: + """The target Property's own attributes resolved from Landlord Overrides, + needed to find and condition its cohort. `property_type` is the code-space + value the cohort EPCs carry (e.g. "2"); None means it could not be resolved, + which gates the Property out of prediction.""" + + property_type: Optional[str] + built_form: Optional[str] = None + wall_construction: Optional[Union[int, str]] = None + + +def build_prediction_target( + identity: PropertyIdentity, + coordinates: Optional[Coordinates], + attributes: PredictionTargetAttributes, +) -> Optional[PredictionTarget]: + """The PredictionTarget for an EPC-less Property, or None when ineligible — + `property_type` is the hard cohort filter, so a Property whose type is unknown + is gated out of prediction (ADR-0031) rather than sized from a mixed-type + cohort.""" + if attributes.property_type is None: + return None + return PredictionTarget( + postcode=identity.postcode, + property_type=attributes.property_type, + built_form=attributes.built_form, + wall_construction=attributes.wall_construction, + coordinates=coordinates, + ) diff --git a/repositories/property/prediction_target_attributes_reader.py b/repositories/property/prediction_target_attributes_reader.py new file mode 100644 index 00000000..dd271d38 --- /dev/null +++ b/repositories/property/prediction_target_attributes_reader.py @@ -0,0 +1,23 @@ +"""Read port for an EPC-less Property's prediction attributes (ADR-0031 slice-5d). + +Returns the `property_type` / `built_form` / `wall_construction` resolved from +Landlord Overrides that `build_prediction_target` needs. Kept a port because the +real adapter — a read over the `property_overrides` fact layer — is being built +separately (see docs/HANDOVER_EPC_PREDICTION_WIRING.md); the ingestion wiring +depends on this abstraction and tests substitute a fake. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from domain.epc_prediction.prediction_target import PredictionTargetAttributes + + +class PredictionTargetAttributesReader(ABC): + @abstractmethod + def attributes_for(self, property_id: int) -> PredictionTargetAttributes: + """The Property's resolved prediction attributes. `property_type` is None + when it could not be resolved — which gates the Property out of + prediction (`build_prediction_target` returns None).""" + ... diff --git a/tests/domain/epc_prediction/test_prediction_target.py b/tests/domain/epc_prediction/test_prediction_target.py new file mode 100644 index 00000000..4aebc452 --- /dev/null +++ b/tests/domain/epc_prediction/test_prediction_target.py @@ -0,0 +1,56 @@ +"""Assembling an EPC-less Property's PredictionTarget, and the eligibility gate: +a Property whose property type is unknown is not predicted (ADR-0031 slice-5d).""" + +from __future__ import annotations + +from typing import Optional + +from domain.epc_prediction.comparable_properties import PredictionTarget +from domain.epc_prediction.prediction_target import ( + PredictionTargetAttributes, + build_prediction_target, +) +from domain.geospatial.coordinates import Coordinates +from domain.property.property import PropertyIdentity + + +def _identity(postcode: str = "LS6 1AA") -> PropertyIdentity: + return PropertyIdentity( + portfolio_id=1, postcode=postcode, address="1 Some Street", uprn=12345 + ) + + +def test_target_is_assembled_from_identity_coords_and_overrides() -> None: + # Arrange — a known property type + built form + wall (Landlord Overrides), + # and resolved coordinates. + here = Coordinates(longitude=-1.55, latitude=53.81) + attributes = PredictionTargetAttributes( + property_type="2", built_form="3", wall_construction=1 + ) + + # Act + target: Optional[PredictionTarget] = build_prediction_target( + _identity(), here, attributes + ) + + # Assert — every known input is threaded onto the target. + assert target is not None + assert target.postcode == "LS6 1AA" + assert target.property_type == "2" + assert target.built_form == "3" + assert target.wall_construction == 1 + assert target.coordinates is here + + +def test_an_unknown_property_type_gates_the_property_out() -> None: + # Arrange — property type is the hard cohort filter; without it the Property + # must not be predicted from a mixed-type cohort (ADR-0031). + attributes = PredictionTargetAttributes(property_type=None) + + # Act + target: Optional[PredictionTarget] = build_prediction_target( + _identity(), None, attributes + ) + + # Assert — gated out: no target to predict from. + assert target is None