from __future__ import annotations import operator from pathlib import Path from typing import Any import yaml from sleep_db.hashing import stable_file_hash from sleep_db.schema import ALLOWED_LABELS, ALLOWED_REL_TYPES, REQUIRED_RULE_FIELDS OPS = { ">": operator.gt, ">=": operator.ge, "<": operator.lt, "<=": operator.le, "==": operator.eq, } def load_rule_file(path: Path) -> tuple[dict[str, Any], str]: text = path.read_text(encoding="utf-8") rule = yaml.safe_load(text) or {} validate_rule(rule, path) return rule, stable_file_hash(text) def load_rules_directory(rules_dir: Path) -> list[tuple[dict[str, Any], str]]: return [load_rule_file(path) for path in sorted(rules_dir.rglob("*.yaml"))] def validate_rule(rule: dict[str, Any], path: Path | None = None) -> None: missing = REQUIRED_RULE_FIELDS - set(rule) if missing: raise ValueError(f"{path}: missing rule fields: {sorted(missing)}") if not isinstance(rule["source_doc_ids"], list) or not rule["source_doc_ids"]: raise ValueError(f"{path}: source_doc_ids must be a non-empty list") for node in rule.get("nodes", []): label = node.get("label") node_id = node.get("id") if label not in ALLOWED_LABELS: raise ValueError(f"{path}: unsupported node label {label!r}") if not node_id: raise ValueError(f"{path}: node is missing id") node_ids = {node["id"] for node in rule.get("nodes", [])} for edge in rule.get("edges", []): rel_type = edge.get("type") if rel_type not in ALLOWED_REL_TYPES: raise ValueError(f"{path}: unsupported relationship type {rel_type!r}") if edge.get("from") not in node_ids or edge.get("to") not in node_ids: raise ValueError(f"{path}: edge references a missing node") def eval_condition(condition: dict[str, Any], user_metrics: dict[str, Any], baseline_metrics: dict[str, Any]) -> bool: metric_key = condition["metric_key"] current = user_metrics.get(metric_key) if current is None: return False threshold_type = condition["threshold_type"] if threshold_type == "relative": baseline = _baseline_value(condition, user_metrics, baseline_metrics) if baseline is None: return False value = float(current) - float(baseline) threshold = float(condition["offset"]) elif threshold_type == "absolute": value = float(current) threshold = float(condition["threshold"]) elif threshold_type == "relative_ratio": baseline = _baseline_value(condition, user_metrics, baseline_metrics) if baseline in (None, 0): return False value = float(current) / float(baseline) threshold = float(condition["ratio"]) else: return False op = OPS.get(condition["operator"]) if not op: return False return bool(op(value, threshold)) def _baseline_value( condition: dict[str, Any], user_metrics: dict[str, Any], baseline_metrics: dict[str, Any], ) -> Any: baseline_key = condition.get("baseline_key") if not baseline_key: return None return baseline_metrics.get(baseline_key, user_metrics.get(baseline_key))