BEHAVIOR-003: learn causal shadow triggers
This commit is contained in:
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.actuators.models import BehaviorMode, BehaviorStatus
|
||||
from app.actuators.models import BehaviorMode, BehaviorPattern, BehaviorStatus
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
|
||||
from app.config import Settings
|
||||
@@ -189,6 +189,98 @@ def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
|
||||
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
|
||||
|
||||
|
||||
def test_engine_learns_causal_automation_for_shadow_without_user_credit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
actuator_points: list[StateHistoryPoint] = []
|
||||
door_points: list[StateHistoryPoint] = []
|
||||
logbook: list[LogbookEntry] = []
|
||||
for days_ago in (3, 2, 1):
|
||||
action_at = now - timedelta(days=days_ago)
|
||||
actuator_points.extend(
|
||||
[
|
||||
StateHistoryPoint(
|
||||
timestamp=action_at - timedelta(minutes=1),
|
||||
state="off",
|
||||
),
|
||||
StateHistoryPoint(timestamp=action_at, state="on"),
|
||||
]
|
||||
)
|
||||
door_points.extend(
|
||||
[
|
||||
StateHistoryPoint(
|
||||
timestamp=action_at - timedelta(minutes=1),
|
||||
state="off",
|
||||
),
|
||||
StateHistoryPoint(
|
||||
timestamp=action_at - timedelta(seconds=1),
|
||||
state="on",
|
||||
),
|
||||
]
|
||||
)
|
||||
logbook.append(
|
||||
LogbookEntry(
|
||||
entity_id="light.storage",
|
||||
timestamp=action_at,
|
||||
message="turned on",
|
||||
context_domain="automation",
|
||||
context_service="trigger",
|
||||
)
|
||||
)
|
||||
actuator_points.sort(key=lambda point: point.timestamp)
|
||||
door_points.sort(key=lambda point: point.timestamp)
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("light.storage")
|
||||
store.upsert(
|
||||
record.model_copy(
|
||||
update={
|
||||
"assignment": record.assignment.model_copy(
|
||||
update={
|
||||
"selected_context_entity_ids": [
|
||||
"binary_sensor.storage_door"
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
reader = FakeBehaviorReader(
|
||||
entities=[],
|
||||
history=[
|
||||
StateHistorySeries(
|
||||
entity_id="light.storage",
|
||||
points=actuator_points,
|
||||
),
|
||||
StateHistorySeries(
|
||||
entity_id="binary_sensor.storage_door",
|
||||
points=door_points,
|
||||
),
|
||||
],
|
||||
logbook=logbook,
|
||||
)
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
trained = engine.train("light.storage")
|
||||
automation_patterns = [
|
||||
pattern
|
||||
for pattern in trained.behavior.patterns
|
||||
if pattern.source == "automation"
|
||||
]
|
||||
|
||||
assert len(automation_patterns) == 3
|
||||
assert trained.behavior.high_confidence_sample_count == 0
|
||||
assert {
|
||||
(
|
||||
pattern.trigger_entity_id,
|
||||
pattern.trigger_from_state,
|
||||
pattern.trigger_to_state,
|
||||
)
|
||||
for pattern in automation_patterns
|
||||
} == {("binary_sensor.storage_door", "off", "on")}
|
||||
|
||||
|
||||
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
@@ -259,3 +351,66 @@ def test_prediction_requires_temporal_support() -> None:
|
||||
min_support=3,
|
||||
window_minutes=30,
|
||||
) is None
|
||||
|
||||
|
||||
def test_prediction_uses_fresh_causal_context_transition_outside_time_window() -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
patterns = [
|
||||
BehaviorPattern(
|
||||
target_state="on",
|
||||
minute_of_day=60,
|
||||
weekday=0,
|
||||
context_states={"binary_sensor.storage_door": "on"},
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_from_state="off",
|
||||
trigger_to_state="on",
|
||||
source="automation",
|
||||
weight=0.7,
|
||||
observed_at=now - timedelta(days=days_ago),
|
||||
)
|
||||
for days_ago in (3, 2, 1)
|
||||
]
|
||||
|
||||
prediction = predict_behavior(
|
||||
patterns,
|
||||
current_context={"binary_sensor.storage_door": "on"},
|
||||
current_context_changed_at={
|
||||
"binary_sensor.storage_door": now - timedelta(seconds=10)
|
||||
},
|
||||
now=now,
|
||||
min_support=3,
|
||||
window_minutes=30,
|
||||
)
|
||||
|
||||
assert prediction is not None
|
||||
assert prediction.target_state == "on"
|
||||
assert prediction.matching_patterns == 3
|
||||
assert prediction.confidence == 0.7
|
||||
assert "frischen Sensorwechsel" in prediction.reason
|
||||
|
||||
|
||||
def test_prediction_ignores_stale_causal_context_state() -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
pattern = BehaviorPattern(
|
||||
target_state="on",
|
||||
minute_of_day=60,
|
||||
weekday=0,
|
||||
context_states={"binary_sensor.storage_door": "on"},
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_from_state="off",
|
||||
trigger_to_state="on",
|
||||
source="automation",
|
||||
weight=0.7,
|
||||
observed_at=now - timedelta(days=1),
|
||||
)
|
||||
|
||||
assert predict_behavior(
|
||||
[pattern],
|
||||
current_context={"binary_sensor.storage_door": "on"},
|
||||
current_context_changed_at={
|
||||
"binary_sensor.storage_door": now - timedelta(minutes=5)
|
||||
},
|
||||
now=now,
|
||||
min_support=1,
|
||||
window_minutes=30,
|
||||
) is None
|
||||
|
||||
Reference in New Issue
Block a user