feat(1.3): deterministische Regel-Engine (Requirements aus Normprognose)

Story 1.3 (TDD, Suite gruen 47/47):
- logic/rules.py: Regeltabelle Addendum §3 + §3a-Degradation, Basis-Outfit immer,
  Offset genau einmal (+1 = Schwellen +2°C nach oben, mehr Waerme), kanonischer
  Merge (max-Prioritaet, warmth-Floor via max), Schwellen aus Options (AD-21/23).
- Doku-Fix: Offset-Beispiel in epics.md-AC korrigiert (Richtung).
- luna-pro-Story-Review: 1 Finding verworfen mit Evidenz (§3a 'uebrige Regeln').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nora 2026-07-13 13:32:54 +00:00
parent 67430837c8
commit 6d87e1082b
4 changed files with 318 additions and 1 deletions

View file

@ -220,6 +220,11 @@ Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale.
vor Aggregation; F4 naive datetimes verwerfen; F5 `_num`-Typguard; F6 kein bool als Messwert;
F8 condition-Fallback per Schwere-Max), 1 teils (F1 lightning-rainy→gewitter behalten, rules-korrekt +
dokumentiert), 1 verworfen mit Evidenz (F7 gemischte Per-Eintrag-Einheiten treten bei HA nicht auf).
- **1.3 Regel-Engine** ✅ — 47 Tests grün. `logic/rules.py` + `tests/logic/test_rules.py`. Doku-Fix:
Offset-Beispiel in epics.md korrigiert (Richtung war verkehrt: +1 = Schwellen +2 °C nach oben ⇒ bei
9 °C Wärmegrad 3→4, mehr Wärme). **luna-pro-Review:** 1 Finding **verworfen mit Evidenz** (Schnee +
temp fehlt → warmth:4 ist korrekt: §3a entfernt nur die Temperatur-Band-Regel, Schnee ist »übrige
Regel« mit Wärme-Floor; temp_missing-Note bleibt richtig).
## Offene Punkte / nächste Schritte
- Phase 4: Epics & Stories aus dem Spine + Party-Mode + Readiness-Check.

View file

@ -251,7 +251,7 @@ So that jede Empfehlung erklärbar auf konkreten Wetterbedingungen beruht.
**When** die Regeln greifen
**Then** folgt das Verhalten exakt der Degradations-Tabelle §3a; ein Datenlage-Hinweis entsteht nur bei betroffenem muss/soll-Requirement (FR-4.3); Tagesmaximum ≥ 2 Bänder über Morgenfenster → `hint_layering` (FR-4.4)
**Given** Grenzwert-Fixtures (halboffene Bänder auf ungerundeten SI-Werten: 7.9999 °C; Offset +1 verschiebt 7.5-°C-Morgen von Band 4 nach Band 3; Frost+Schnee+Starkregen)
**Given** Grenzwert-Fixtures (halboffene Bänder auf ungerundeten SI-Werten: 7.9999 °C bleibt Wärmegrad 4, 8.0 °C wird Wärmegrad 3; Offset +1 schiebt alle °C-Schwellen um +2 °C nach oben, sodass ein 9-°C-Morgen von Wärmegrad 3 auf Wärmegrad 4 steigt — »friert leichter ⇒ früher wärmer«; Frost+Schnee+Starkregen)
**When** die Unit-Tests laufen
**Then** sind sie grün und die Merge-Semantik liefert genau einen `waterproof_outer (must)` und einen `warmth:5`

View file

@ -0,0 +1,144 @@
"""Deterministic rules engine (Story 1.3, FR-4, AD-21/AD-23).
Pure core: turns a :class:`NormForecast` into a canonically merged set of
:class:`Requirement` objects plus language-neutral data-note keys. Thresholds
come entirely from the options mapping (``const.OPTIONS_DEFAULTS`` shape), so the
options flow (Story 3.1) tunes behaviour without any change here. The cold
sensitivity offset shifts every °C threshold once (AD-23): ``+1`` moves the band
edges up by 2 °C, so at a given temperature more warmth is recommended
("friert leichter ⇒ früher wärmer").
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .model import NormForecast, Priority, Requirement, RequirementKey
# Reference values for the day-spread (layering) hint.
_BASE_KEYS = (RequirementKey.BASE_TOP, RequirementKey.BASE_BOTTOM, RequirementKey.BASE_SHOES)
@dataclass(frozen=True, slots=True)
class RulesResult:
"""Merged requirements plus data-note keys (language-neutral, AD-13)."""
requirements: tuple[Requirement, ...]
data_notes: tuple[str, ...]
def _warmth_level(temp: float, limits: list[float]) -> int:
"""Map a temperature to a warmth level 1..5 using half-open bands.
limits = the four inner edges [e0,e1,e2,e3] (already offset-shifted):
(-inf,e0)->5 [e0,e1)->4 [e1,e2)->3 [e2,e3)->2 [e3,inf)->1
"""
if temp < limits[0]:
return 5
if temp < limits[1]:
return 4
if temp < limits[2]:
return 3
if temp < limits[3]:
return 2
return 1
def evaluate(nf: NormForecast, options: dict[str, Any]) -> RulesResult:
"""Evaluate the rule table against ``nf`` using ``options`` thresholds."""
offset = int(options["cold_sensitivity_offset"])
shift = 2.0 * offset
limits = [float(x) + shift for x in options["warmth_band_limits"]]
heat_threshold = float(options["heat_threshold"]) + shift
# merged[key] = (priority, level)
merged: dict[RequirementKey, tuple[Priority, int | None]] = {}
notes: set[str] = set()
def add(key: RequirementKey, priority: Priority, level: int | None = None) -> None:
if key in merged:
old_pri, old_level = merged[key]
priority = max(priority, old_pri)
if level is not None or old_level is not None:
level = max(level or 0, old_level or 0)
merged[key] = (priority, level)
# Base outfit is unconditional.
for k in _BASE_KEYS:
add(k, Priority.MUST)
# --- Temperature / warmth (reference: feels-like morning, §3a fallback) ---
ref = nf.feels_like_morning.value
if ref is None:
ref = nf.temp_morning.value # §3a: fall back to measured (attr-only, no note)
if ref is not None:
level = _warmth_level(ref, limits)
add(RequirementKey.WARMTH, Priority.MUST, level)
if ref < limits[0]: # coldest band adds accessories
add(RequirementKey.HAT, Priority.SHOULD)
add(RequirementKey.GLOVES, Priority.SHOULD)
add(RequirementKey.SCARF, Priority.MAY)
elif ref < limits[1]: # 2nd-coldest band adds a hat (may)
add(RequirementKey.HAT, Priority.MAY)
if ref >= heat_threshold:
add(RequirementKey.SUN_PROTECTION, Priority.SHOULD)
add(RequirementKey.HINT_HEAT, Priority.MAY)
else:
notes.add("temp_missing") # a must-requirement (warmth) is affected
# --- Rain ---
prob = nf.rain_probability.value
amount = nf.rain_amount.value
if prob is None and amount is None:
notes.add("rain_missing") # soll/muss affected
else:
must = (prob is not None and prob >= float(options["rain_prob_must"])) or (
amount is not None and amount >= float(options["rain_amount_must"])
)
should = (prob is not None and prob >= float(options["rain_prob_should"])) or (
amount is not None and amount >= float(options["rain_amount_should"])
)
if must:
add(RequirementKey.WATERPROOF_OUTER, Priority.MUST)
add(RequirementKey.STURDY_SHOES, Priority.SHOULD)
elif should:
add(RequirementKey.WATERPROOF_OUTER, Priority.SHOULD)
add(RequirementKey.STURDY_SHOES, Priority.SHOULD)
# --- Wind (gust; proxy on mean wind when gust missing) ---
gust = nf.gust.value
if gust is not None:
if gust >= float(options["gust_should"]):
add(RequirementKey.WINDPROOF_OUTER, Priority.SHOULD)
elif nf.wind.value is not None:
if nf.wind.value >= float(options["wind_proxy_should"]):
add(RequirementKey.WINDPROOF_OUTER, Priority.SHOULD)
# both missing -> attr-only degradation, no data note.
# --- UV ---
uv = nf.uv_index.value
if uv is not None and uv >= float(options["uv_should"]):
add(RequirementKey.SUN_PROTECTION, Priority.SHOULD)
# --- Condition (snow / thunderstorm) ---
if nf.condition == "schnee":
add(RequirementKey.WARMTH, Priority.MUST, 4) # floor 4 (merges via max)
add(RequirementKey.STURDY_SHOES, Priority.SHOULD)
add(RequirementKey.WATERPROOF_OUTER, Priority.SHOULD)
elif nf.condition == "gewitter":
add(RequirementKey.WATERPROOF_OUTER, Priority.SHOULD)
add(RequirementKey.HINT_THUNDERSTORM, Priority.MAY)
# --- Day-spread layering hint (Zwiebelprinzip, FR-4.4) ---
morning_ref = nf.feels_like_morning.value
if morning_ref is None:
morning_ref = nf.temp_morning.value
daymax = nf.temp_max.value
if morning_ref is not None and daymax is not None:
if _warmth_level(morning_ref, limits) - _warmth_level(daymax, limits) >= 2:
add(RequirementKey.HINT_LAYERING, Priority.MAY)
requirements = tuple(
Requirement(key=k, priority=p, level=lvl) for k, (p, lvl) in merged.items()
)
return RulesResult(requirements=requirements, data_notes=tuple(sorted(notes)))

168
tests/logic/test_rules.py Normal file
View file

@ -0,0 +1,168 @@
"""Story 1.3 — deterministic rules engine (no HA harness)."""
from __future__ import annotations
from custom_components.what_to_wear import const
from custom_components.what_to_wear.logic import rules
from custom_components.what_to_wear.logic.model import NormField, NormForecast, Priority, RequirementKey
def _nf(**over) -> NormForecast:
"""A NormForecast with everything missing unless overridden (value or NormField)."""
base = {k: NormField(None, None, None) for k in (
"feels_like_morning", "temp_morning", "temp_min", "temp_max",
"feels_like_min", "feels_like_max", "rain_probability", "rain_amount",
"wind", "gust", "uv_index",
)}
for key, val in over.items():
if key == "condition":
continue
base[key] = val if isinstance(val, NormField) else NormField(float(val), "daily", None)
return NormForecast(condition=over.get("condition"), **base)
def _opts(**over):
o = const.default_options()
o.update(over)
return o
def _req_map(result):
return {r.key: r for r in result.requirements}
def test_base_outfit_always_present() -> None:
res = rules.evaluate(_nf(), _opts()) # everything missing
keys = _req_map(res)
for k in (RequirementKey.BASE_TOP, RequirementKey.BASE_BOTTOM, RequirementKey.BASE_SHOES):
assert keys[k].priority is Priority.MUST
def test_cold_below_zero_full_winter() -> None:
res = rules.evaluate(_nf(feels_like_morning=-1.0, temp_max=2.0), _opts())
m = _req_map(res)
assert m[RequirementKey.WARMTH].level == 5
assert m[RequirementKey.WARMTH].priority is Priority.MUST
assert m[RequirementKey.HAT].priority is Priority.SHOULD
assert m[RequirementKey.GLOVES].priority is Priority.SHOULD
assert m[RequirementKey.SCARF].priority is Priority.MAY
def test_warmth_bands_half_open() -> None:
# 7.9999 -> warmth 4 ([0,8)); 8.0 -> warmth 3 ([8,15)).
assert _req_map(rules.evaluate(_nf(feels_like_morning=7.9999), _opts()))[RequirementKey.WARMTH].level == 4
assert _req_map(rules.evaluate(_nf(feels_like_morning=8.0), _opts()))[RequirementKey.WARMTH].level == 3
assert _req_map(rules.evaluate(_nf(feels_like_morning=15.0), _opts()))[RequirementKey.WARMTH].level == 2
assert _req_map(rules.evaluate(_nf(feels_like_morning=22.0), _opts()))[RequirementKey.WARMTH].level == 1
def test_offset_shifts_thresholds_up_more_warmth() -> None:
# 9 °C: offset 0 -> warmth 3; offset +1 (edges +2 °C) -> warmth 4 (friert leichter).
assert _req_map(rules.evaluate(_nf(feels_like_morning=9.0), _opts(cold_sensitivity_offset=0)))[RequirementKey.WARMTH].level == 3
assert _req_map(rules.evaluate(_nf(feels_like_morning=9.0), _opts(cold_sensitivity_offset=1)))[RequirementKey.WARMTH].level == 4
# Negative offset -> less warmth: -1 shifts edges down 2 °C, 9 °C -> still 3, but 7 °C -> 3 (from 4).
assert _req_map(rules.evaluate(_nf(feels_like_morning=7.0), _opts(cold_sensitivity_offset=-1)))[RequirementKey.WARMTH].level == 3
def test_feels_like_falls_back_to_measured_no_text_note() -> None:
# feels_like missing but temp_morning present -> use measured, no data note (§3a).
res = rules.evaluate(_nf(temp_morning=-1.0), _opts())
assert _req_map(res)[RequirementKey.WARMTH].level == 5
assert "temp_missing" not in res.data_notes
def test_temp_completely_missing_no_warmth_but_base_and_note() -> None:
res = rules.evaluate(_nf(), _opts()) # no feels, no temp
m = _req_map(res)
assert RequirementKey.WARMTH not in m
assert m[RequirementKey.BASE_TOP].priority is Priority.MUST
assert "temp_missing" in res.data_notes # must-requirement affected -> text note
def test_rain_should_and_must_branches_merge_to_must() -> None:
should = rules.evaluate(_nf(feels_like_morning=10.0, rain_probability=50.0), _opts())
assert _req_map(should)[RequirementKey.WATERPROOF_OUTER].priority is Priority.SHOULD
assert _req_map(should)[RequirementKey.STURDY_SHOES].priority is Priority.SHOULD
must = rules.evaluate(_nf(feels_like_morning=10.0, rain_probability=75.0), _opts())
assert _req_map(must)[RequirementKey.WATERPROOF_OUTER].priority is Priority.MUST
def test_rain_amount_branch_when_probability_missing() -> None:
res = rules.evaluate(_nf(feels_like_morning=10.0, rain_amount=2.0), _opts())
assert _req_map(res)[RequirementKey.WATERPROOF_OUTER].priority is Priority.SHOULD
assert "rain_missing" not in res.data_notes
def test_rain_both_missing_note() -> None:
res = rules.evaluate(_nf(feels_like_morning=10.0), _opts()) # no rain fields
assert RequirementKey.WATERPROOF_OUTER not in _req_map(res)
assert "rain_missing" in res.data_notes
def test_wind_gust_and_proxy() -> None:
gusty = rules.evaluate(_nf(feels_like_morning=10.0, gust=45.0), _opts())
assert _req_map(gusty)[RequirementKey.WINDPROOF_OUTER].priority is Priority.SHOULD
# gust missing -> wind proxy at >= 30 km/h.
proxy = rules.evaluate(_nf(feels_like_morning=10.0, wind=35.0), _opts())
assert _req_map(proxy)[RequirementKey.WINDPROOF_OUTER].priority is Priority.SHOULD
# both missing -> no windproof, and NO data note (attr-only degradation).
none = rules.evaluate(_nf(feels_like_morning=10.0), _opts())
assert RequirementKey.WINDPROOF_OUTER not in _req_map(none)
assert "wind_missing" not in none.data_notes
def test_uv_and_heat() -> None:
uv = rules.evaluate(_nf(feels_like_morning=20.0, uv_index=7.0), _opts())
assert _req_map(uv)[RequirementKey.SUN_PROTECTION].priority is Priority.SHOULD
heat = rules.evaluate(_nf(feels_like_morning=30.0, temp_max=30.0), _opts())
m = _req_map(heat)
assert m[RequirementKey.WARMTH].level == 1
assert m[RequirementKey.SUN_PROTECTION].priority is Priority.SHOULD
assert m[RequirementKey.HINT_HEAT].priority is Priority.MAY
def test_snow_forces_warmth_floor_and_merges() -> None:
# Snow requires warmth >= 4; if the temp band gives 5, keep 5 (max). If temp
# missing, snow still yields warmth 4.
res = rules.evaluate(_nf(condition="schnee"), _opts()) # temp missing
m = _req_map(res)
assert m[RequirementKey.WARMTH].level == 4
assert m[RequirementKey.STURDY_SHOES].priority is Priority.SHOULD
assert m[RequirementKey.WATERPROOF_OUTER].priority is Priority.SHOULD
def test_layering_hint_when_daymax_two_bands_warmer() -> None:
# morning warmth 5 (-1 °C), tagesmax warmth 2 (18 °C) -> 5-2=3 >= 2 -> layering.
res = rules.evaluate(_nf(feels_like_morning=-1.0, temp_max=18.0), _opts())
assert RequirementKey.HINT_LAYERING in _req_map(res)
# small spread -> no hint.
res2 = rules.evaluate(_nf(feels_like_morning=10.0, temp_max=12.0), _opts())
assert RequirementKey.HINT_LAYERING not in _req_map(res2)
def test_frost_snow_heavy_rain_merge_single_entries() -> None:
# AC: exactly one waterproof_outer (must) and one warmth:5.
res = rules.evaluate(
_nf(feels_like_morning=-1.0, temp_max=1.0, rain_probability=75.0, condition="schnee"),
_opts(),
)
wp = [r for r in res.requirements if r.key is RequirementKey.WATERPROOF_OUTER]
wm = [r for r in res.requirements if r.key is RequirementKey.WARMTH]
assert len(wp) == 1 and wp[0].priority is Priority.MUST
assert len(wm) == 1 and wm[0].level == 5
def test_no_duplicate_keys_in_output() -> None:
res = rules.evaluate(
_nf(feels_like_morning=-1.0, temp_max=20.0, rain_probability=80.0,
rain_amount=10.0, gust=50.0, uv_index=8.0, condition="schnee"),
_opts(),
)
keys = [r.key for r in res.requirements]
assert len(keys) == len(set(keys)), f"duplicate requirement keys: {keys}"
def test_rules_reads_thresholds_from_options() -> None:
# Custom rain threshold changes the outcome without touching rules code.
o = _opts(rain_prob_should=90)
res = rules.evaluate(_nf(feels_like_morning=10.0, rain_probability=50.0), o)
assert RequirementKey.WATERPROOF_OUTER not in _req_map(res) # 50 < 90