Model/scripts/audit/neighbour_divergence.py
Jun-te Kim 003defcf55 Add find-weird-recommendations skill + its two detectors
A focused sibling to audit-ara-portfolio: that skill audits baselines/plans/SAP;
this one audits the *recommendations themselves* — why a measure was or wasn't
offered. Motivated by the portfolio-814 review (Khalim's HHRSH-on-community-
heating, missing-HHRSH, missing-secondary-heating-removal, and a neighbour split).

Adds:
- .claude/skills/find-weird-recommendations/SKILL.md — scan -> neighbour scan ->
  live re-model deep-dive -> root-cause -> codify, with a seeded known-bug
  catalogue and the query-safety rules inherited from audit-ara-portfolio.
- scripts/audit/anomalies.py: new `plan-stops-short-of-goal` HIGH check — the
  default plan ends below the goal band on an unlimited-budget scenario (the
  deterministic worklist for "why didn't this get recommended X"). Adds
  scenario_budget to the bundle/query so budget-capped scenarios are excluded.
- scripts/audit/neighbour_divergence.py: groups a portfolio by (postcode,
  property_type, built_form) and flags effective-SAP outliers vs the cohort
  median. Never touches the 26m-row recommendation table, so it is safe
  portfolio-wide.
- Tests for both (12 passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:06:03 +00:00

226 lines
8.4 KiB
Python

"""Flag neighbouring dwellings that were modelled *differently despite looking
the same* — the SAP half of the "neighbours should agree" heuristic.
Motivation (portfolio 814): Khalim found a dwelling that gets an HHRSH bundle
while its next-door neighbour of the same type does not. Neighbours in one
postcode, of the same property type and built form, are usually near-identical
stock; a big split in their *effective baseline SAP* is a strong tell that one of
the pair was mis-mapped, mis-overridden, or mis-rebaselined — and a SAP split is
what then drives a recommendation split. This script surfaces those pairs
cheaply so the deep-dive (``run_modelling_e2e`` on both neighbours) can compare
their actual measure sets and root-cause the divergence.
It reaches only ``property`` + ``epc_property`` + ``property_baseline_performance``,
all via the indexed ``property_id`` — it NEVER touches the 26m-row
``recommendation`` table, so it is safe to run portfolio-wide (unlike the
measure-set comparison, which the skill does per flagged cohort in the deep-dive).
Run:
python -m scripts.audit.neighbour_divergence --portfolio 814
python -m scripts.audit.neighbour_divergence --portfolio 814 --min-gap 15
Writes ``neighbour_divergence.md`` + ``neighbour_divergence.csv`` and prints a
summary. Read-only: never writes to the DB.
A cohort is the set of a portfolio's properties sharing (postcode, property_type,
built_form). Within a cohort of >= 2, any member whose effective SAP is >=
``--min-gap`` points from the cohort median is flagged. Floor area is reported,
not keyed on, so the reviewer can discount a genuinely bigger/smaller neighbour;
promoting an area guard into the cohort key is a clean future change once the
false-positive rate is measured on a real portfolio (skill Phase 6).
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass
from statistics import median
from typing import Optional
from sqlalchemy import text
from scripts.e2e_common import build_engine, load_env
# Hard ceiling so a bad plan aborts instead of saturating the shared DB, mirroring
# scripts/audit/anomalies.py. Every table here is reached via the indexed
# property_id and scoped to one portfolio, so this is a backstop, not a crutch.
_STATEMENT_TIMEOUT_MS = 120_000
# Default SAP gap (points from the cohort median) at which a neighbour is flagged.
# 12 is a starting threshold, not a tuned one — a full band is ~10-11 SAP points,
# so >= 12 means the neighbour sits a clear band apart from otherwise-identical
# stock. Re-justify against a real portfolio's distribution before trusting it
# (skill Phase 6); expose it as --min-gap so a run can sweep the threshold.
_DEFAULT_MIN_GAP = 12.0
@dataclass(frozen=True)
class Neighbour:
"""One modelled property placed in its postcode/type/form cohort."""
property_id: int
uprn: Optional[int]
postcode: Optional[str]
property_type: Optional[str]
built_form: Optional[str]
floor_area_m2: Optional[float]
effective_sap: Optional[float]
effective_band: Optional[str]
@dataclass(frozen=True)
class Divergence:
property_id: int
uprn: Optional[int]
cohort_key: str
cohort_size: int
detail: str
# DISTINCT ON picks the latest ingested epc_property row per property (its
# property_id is NOT unique — ingestion keeps history), ordered by id DESC.
_QUERY = text(
"""
SELECT p.id, p.uprn, p.postcode,
ep.property_type, ep.built_form, ep.total_floor_area_m2,
pbp.effective_sap_score, pbp.effective_epc_band
FROM property p
JOIN property_baseline_performance pbp ON pbp.property_id = p.id
LEFT JOIN LATERAL (
SELECT property_type, built_form, total_floor_area_m2
FROM epc_property e
WHERE e.property_id = p.id
ORDER BY e.id DESC
LIMIT 1
) ep ON TRUE
WHERE p.portfolio_id = :portfolio_id
ORDER BY p.id
"""
)
def _load(portfolio_id: int) -> list[Neighbour]:
engine = build_engine()
out: list[Neighbour] = []
with engine.connect() as conn:
conn.execute(text(f"SET statement_timeout = {_STATEMENT_TIMEOUT_MS}"))
for row in conn.execute(_QUERY, {"portfolio_id": portfolio_id}):
m = row._mapping # noqa: SLF001 — SQLAlchemy row mapping, mirrors anomalies.py
out.append(
Neighbour(
property_id=m["id"],
uprn=m["uprn"],
postcode=m["postcode"],
property_type=m["property_type"],
built_form=m["built_form"],
floor_area_m2=m["total_floor_area_m2"],
effective_sap=m["effective_sap_score"],
effective_band=m["effective_epc_band"],
)
)
return out
def _cohort_key(n: Neighbour) -> Optional[str]:
"""A stable cohort label, or None when the neighbour can't be placed (no
postcode or no property type — it has no comparable peers to diverge from)."""
if not n.postcode or not n.property_type:
return None
form = n.built_form or "?"
return f"{n.postcode.strip().upper()} · {n.property_type} · {form}"
def find_divergences(
neighbours: list[Neighbour], min_gap: float
) -> list[Divergence]:
cohorts: dict[str, list[Neighbour]] = {}
for n in neighbours:
key = _cohort_key(n)
if key is None or n.effective_sap is None:
continue
cohorts.setdefault(key, []).append(n)
found: list[Divergence] = []
for key, members in cohorts.items():
if len(members) < 2:
continue
saps = [m.effective_sap for m in members if m.effective_sap is not None]
cohort_median = median(saps)
for m in members:
if m.effective_sap is None:
continue
gap = m.effective_sap - cohort_median
if abs(gap) < min_gap:
continue
area = f"{m.floor_area_m2:.0f}" if m.floor_area_m2 is not None else "?m²"
found.append(
Divergence(
property_id=m.property_id,
uprn=m.uprn,
cohort_key=key,
cohort_size=len(members),
detail=(
f"effective SAP {m.effective_sap:.0f} ({m.effective_band}) "
f"vs cohort median {cohort_median:.0f}{gap:+.0f}, "
f"{area}, {len(members)} neighbours)"
),
)
)
found.sort(key=lambda d: (d.cohort_key, d.property_id))
return found
def _write_reports(divergences: list[Divergence], scanned: int) -> None:
with open("neighbour_divergence.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["property_id", "uprn", "cohort", "cohort_size", "detail"])
for d in divergences:
w.writerow([d.property_id, d.uprn, d.cohort_key, d.cohort_size, d.detail])
by_cohort: dict[str, list[Divergence]] = {}
for d in divergences:
by_cohort.setdefault(d.cohort_key, []).append(d)
lines = [
"# Neighbour SAP-divergence audit",
"",
f"Scanned **{scanned}** properties · flagged **{len(divergences)}** "
f"divergent neighbours across **{len(by_cohort)}** cohorts.",
"",
]
for key in sorted(by_cohort):
rows = by_cohort[key]
lines.append(f"## {key}{len(rows)} divergent")
lines.append("")
for d in rows:
lines.append(f"- property **{d.property_id}** (uprn {d.uprn}): {d.detail}")
lines.append("")
with open("neighbour_divergence.md", "w") as f:
f.write("\n".join(lines))
def main() -> None:
load_env()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--portfolio", type=int, required=True, help="portfolio_id")
parser.add_argument(
"--min-gap",
type=float,
default=_DEFAULT_MIN_GAP,
help=f"SAP points from cohort median to flag (default {_DEFAULT_MIN_GAP})",
)
args = parser.parse_args()
neighbours = _load(args.portfolio)
divergences = find_divergences(neighbours, args.min_gap)
_write_reports(divergences, len(neighbours))
print(
f"scanned {len(neighbours)} properties · {len(divergences)} divergent "
f"neighbours (>= {args.min_gap:.0f} SAP from cohort median)"
)
print("wrote neighbour_divergence.md / neighbour_divergence.csv")
if __name__ == "__main__":
main()