mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:31:59 +00:00
7f949cce92
验收复现:业务日期最晚到明天、今天只写了几行,记出一条 volume_drop。原因有两层: - 比较器 value_drift.daily() 取 last = max(days),只在 last >= today 时退一天;max 落在明天时退到的是今天这个 还没写完的日子。改成最近一个整天 = min(有数据的最后一天, 昨天)。 - 按日行数的查询 day_volume_sql 只有下界,未来的日子也进了窗口。加上界:数到观察当天(含),第二天起不算 (< 第二天,时间列上当天的时分秒照样算进来,文字存的日期按字符串比同样成立)。签名改成必须传 until。 缺天同样的问题也修了:今天以后的空日子不算缺天;以前那次观察只看到它自己那天(data.until)为止,免得它 窗口里未来的空日子被当成「以前就有过缺天」,挡住这一次真缺的天。断档遇到未来值时离今天是负数,只会报不出、 不会误报,观察本身照实记最新业务日期(目录里也照实给人看),不截未来值,写进了模块说明。 「今天」:原来窗口用 UTC 日期、比较器用 now.date()(也是 UTC),业务日期是本地日期,东八区凌晨 0–8 点会差 一天。统一成服务端所在时区(catalog_profile_observe.local_today,和待办汇总 notify.digest_at 同一个口径), 写进模块说明。 测试:test_value_drift 两条(未来值不算最近整天、未来空日子不算缺天且历史按 until 截);test_catalog_observe 两条(SQLite 真跑剖析任务,明天的行不进窗口、不记量跌;local_today 按 TZ 取本地日期)。去掉修复回跑都挂。 金样(tests/fixtures/dialect/*.sql)的 day_volume 一行随上界更新;scripts/dialect_it.py 加了一条「只数到观察 当天」的真库核对。
274 lines
14 KiB
Python
274 lines
14 KiB
Python
"""取值观察的比较器(docs/design/next-round/03-数据接入与目录维护.md B2 ④):纯函数,不连库。
|
||
|
||
**为什么要有它。** 确认过的码值列以前不再取分布,新状态永远发现不了;大表只看最老的前 10 万行;没有最新业务日期、按日
|
||
行数、总行数的历史。剖析引擎的四类观察检查(data/catalog_profile_observe)把每次看到的记进 table_observations,这里拿这一次
|
||
和**同一口径**(basis_key 相同)的历史比,得出取值变化(写进 source_changes)。口径不同不比、照实说(comparable)。
|
||
|
||
规则(阈值第一版,要用真实库跑一段时间再调):
|
||
|
||
- **新码值**(codes_new):码值表标了「已列全」的,出现任何一个目录里没有的取值都算——数据证明它不再列全,这是机器唯一能
|
||
写进目录的事(撤销「已列全」,revoke_complete),严重程度是「要处理」。没列全的,新值要占非空行的至少 0.5%,而且要有同
|
||
口径的历史(第一次只建基线),已见过的(目录里的、历史里出现过的)不算新。
|
||
- **比例剧变**(codes_shift):和上一次同口径的观察算 PSI,两次都至少 1,000 行、都没被截断;PSI ≥ 0.25 记 warning,0.1–0.25
|
||
记 info,列出变化最大的几个取值。
|
||
- **断档**(date_stalled):最新业务日期没前进,且这个日期离今天超过 max(2 × 以前前进的中位间隔, 3 天);要见过至少一次
|
||
前进才知道它多久前进一次。
|
||
- **缺天**(date_gap):窗口里(有数据的第一天到最后一天之间)有日子没数据,而以前同口径的窗口都没有过。只看到观察当天
|
||
为止:业务日期有未来值(预约、计划类的日期,或者写错的)时,今天以后的空日子不是「没数据」,那几天本来就还没到;以前
|
||
那次观察也只看到它自己那天(data.until)为止,不然它窗口里未来的空日子会被当成「以前就有过缺天」,挡住这一次真缺的天。
|
||
- **量跌**(volume_drop):最近一个整天的行数不到它前面 28 天中位数的 30%(前 28 天至少 14 天有数据、中位数至少 20 行)。
|
||
最近一个整天是「有数据的最后一天」和「观察当天的前一天」里早的那个:观察当天还在写入,不是整天;业务日期有未来值时
|
||
有数据的最后一天在今天以后,也不能拿它退一天(那样会退到今天这个不完整的日子上)。只看这一次的窗口,不需要历史。
|
||
- **断档**遇到未来值:最新业务日期在今天以后时离今天是负数,不会报断档(报不出、也不误报);观察查询本身不截未来值
|
||
(最新业务日期照实记,目录里也照实给人看)。
|
||
|
||
「今天」由调用方传进来(today):取值观察按服务端所在时区算(data/catalog_profile_observe.local_today),业务日期是本地
|
||
日期,用 UTC 的日期在本地凌晨会差一天。
|
||
- **行数**(rows_drop / rows_jump):比上一次同口径的少了 30% 以上 / 涨到 3 倍以上;依据是数据库统计的估算值时,要连续
|
||
两次都这样(和跌、涨之前那一次比)才报。数到上限、只知道「至少多少行」的不比。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import statistics
|
||
from collections.abc import Iterable, Mapping
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime, timedelta
|
||
from typing import Any
|
||
|
||
NEW_CODE_SHARE = 0.005
|
||
PSI_MIN_ROWS = 1000
|
||
PSI_WARN = 0.25
|
||
PSI_INFO = 0.1
|
||
PSI_EPS = 1e-4
|
||
PSI_TOP = 5
|
||
STALL_MIN_DAYS = 3
|
||
VOLUME_RATIO = 0.3
|
||
VOLUME_BASELINE_DAYS = 28
|
||
VOLUME_MIN_DAYS = 14
|
||
VOLUME_MIN_MEDIAN = 20
|
||
ROWS_DROP = 0.3
|
||
ROWS_JUMP = 3.0
|
||
GAPS_SHOWN = 10
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Obs:
|
||
"""一次观察:kind、口径(basis_key)、观察到的(data)、什么时候(at)。"""
|
||
|
||
kind: str
|
||
basis_key: str
|
||
data: Mapping[str, Any]
|
||
at: datetime | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Finding:
|
||
"""比出来的一条取值变化。key 是去重指纹的一部分;revoke_complete:要撤销码值项的「已列全」。"""
|
||
|
||
kind: str
|
||
severity: str
|
||
before: Any = None
|
||
after: Any = None
|
||
key: Any = None
|
||
revoke_complete: bool = False
|
||
extra: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
def comparable(current: Obs, history: Iterable[Obs]) -> tuple[list[Obs], str | None]:
|
||
"""同口径的历史(新的在前,照传进来的顺序)和一句说明:有历史、但口径都不同时说「口径不同,不比」;没有历史为 None。"""
|
||
items = list(history)
|
||
same = [h for h in items if h.basis_key == current.basis_key]
|
||
if items and not same:
|
||
return [], f"以前的观察口径不同({items[0].basis_key} → {current.basis_key}),这一次只建新口径的基线、不比"
|
||
return same, None
|
||
|
||
|
||
def _counts(data: Mapping[str, Any]) -> dict[str, int]:
|
||
out: dict[str, int] = {}
|
||
for pair in data.get("values") or []:
|
||
if isinstance(pair, (list, tuple)) and len(pair) == 2 and isinstance(pair[1], (int, float)):
|
||
out[str(pair[0])] = out.get(str(pair[0]), 0) + int(pair[1])
|
||
return out
|
||
|
||
|
||
def _share(n: int, total: int) -> float:
|
||
return round(n / total, 4) if total else 0.0
|
||
|
||
|
||
def psi(before: Mapping[str, int], after: Mapping[str, int]) -> float:
|
||
"""两个分布的 PSI(群体稳定性指数):Σ (q − p) · ln(q / p),p、q 是各取值占比,没出现的取值按 PSI_EPS 算。"""
|
||
total_b, total_a = sum(before.values()), sum(after.values())
|
||
if not total_b or not total_a:
|
||
return 0.0
|
||
value = 0.0
|
||
for code in set(before) | set(after):
|
||
p = max(before.get(code, 0) / total_b, PSI_EPS)
|
||
q = max(after.get(code, 0) / total_a, PSI_EPS)
|
||
value += (q - p) * math.log(q / p)
|
||
return value
|
||
|
||
|
||
def codes(current: Obs, history: Iterable[Obs], *, known: Iterable[str] = (), complete: bool = False) -> list[Finding]:
|
||
"""码值列的这一次和同口径历史比:新码值、比例剧变。known 是目录里码值项列出的取值;complete 是它标了「已列全」。"""
|
||
same, _note = comparable(current, history)
|
||
now = _counts(current.data)
|
||
rows = int(current.data.get("rows") or sum(now.values()))
|
||
listed = {str(k) for k in known}
|
||
out: list[Finding] = []
|
||
if complete:
|
||
fresh = [c for c in now if c not in listed]
|
||
if fresh:
|
||
out.append(Finding("codes_new", "action", before={"known": sorted(listed), "complete": True},
|
||
after={"codes": fresh, "shares": {c: _share(now[c], rows) for c in fresh}},
|
||
key=tuple(sorted(fresh)), revoke_complete=True))
|
||
elif same and not current.data.get("truncated") and not same[0].data.get("truncated"):
|
||
seen = listed | {c for h in same for c in _counts(h.data)}
|
||
fresh = [c for c, n in now.items() if c not in seen and rows and n / rows >= NEW_CODE_SHARE]
|
||
if fresh:
|
||
out.append(Finding("codes_new", "warning", before={"known": sorted(seen)},
|
||
after={"codes": fresh, "shares": {c: _share(now[c], rows) for c in fresh}},
|
||
key=tuple(sorted(fresh))))
|
||
if same:
|
||
prev = same[0]
|
||
then = _counts(prev.data)
|
||
prev_rows = int(prev.data.get("rows") or sum(then.values()))
|
||
if (rows >= PSI_MIN_ROWS and prev_rows >= PSI_MIN_ROWS and not current.data.get("truncated")
|
||
and not prev.data.get("truncated")):
|
||
value = psi(then, now)
|
||
if value >= PSI_INFO:
|
||
top = sorted(set(then) | set(now),
|
||
key=lambda c: -abs(_share(now.get(c, 0), rows) - _share(then.get(c, 0), prev_rows)))
|
||
moved = [[c, _share(then.get(c, 0), prev_rows), _share(now.get(c, 0), rows)] for c in top[:PSI_TOP]]
|
||
out.append(Finding("codes_shift", "warning" if value >= PSI_WARN else "info",
|
||
before={"rows": prev_rows}, after={"psi": round(value, 4), "rows": rows, "top": moved},
|
||
key=(current.basis_key, current.at.isoformat() if current.at else None)))
|
||
return out
|
||
|
||
|
||
def _day(value: Any) -> date | None:
|
||
try:
|
||
return date.fromisoformat(str(value)[:10])
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def freshness(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]:
|
||
"""最新业务日期没前进、而且离今天太久(断档)。"""
|
||
same, _note = comparable(current, history)
|
||
now = _day(current.data.get("max"))
|
||
if now is None or not same:
|
||
return []
|
||
previous = [d for d in (_day(h.data.get("max")) for h in same) if d is not None]
|
||
if not previous or now > max(previous):
|
||
return []
|
||
distinct = sorted(set(previous) | {now})
|
||
gaps = [(b - a).days for a, b in zip(distinct, distinct[1:])]
|
||
if not gaps:
|
||
return []
|
||
median = statistics.median(gaps)
|
||
threshold = max(2 * median, STALL_MIN_DAYS)
|
||
age = (today - now).days
|
||
if age <= threshold:
|
||
return []
|
||
return [Finding("date_stalled", "warning", before={"max": max(previous).isoformat(), "median_days": median},
|
||
after={"max": now.isoformat(), "age_days": age, "threshold_days": threshold},
|
||
key=now.isoformat())]
|
||
|
||
|
||
def _series(data: Mapping[str, Any]) -> dict[date, int]:
|
||
out: dict[date, int] = {}
|
||
for raw, n in (data.get("days") or {}).items():
|
||
d = _day(raw)
|
||
if d is not None and isinstance(n, (int, float)):
|
||
out[d] = out.get(d, 0) + int(n)
|
||
return out
|
||
|
||
|
||
def _holes(days: Mapping[date, int], until: date | None = None) -> list[date]:
|
||
"""有数据的第一天到最后一天之间没数据的日子;until 给了时只看到这一天为止(今天以后的空日子不算缺天)。"""
|
||
if until is not None:
|
||
days = {d: n for d, n in days.items() if d <= until}
|
||
if not days:
|
||
return []
|
||
first, last = min(days), max(days)
|
||
return [first + timedelta(days=i) for i in range((last - first).days + 1)
|
||
if days.get(first + timedelta(days=i), 0) == 0]
|
||
|
||
|
||
def daily(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]:
|
||
"""按日行数:缺天(要和同口径的历史比)、量跌(只看这一次的窗口)。"""
|
||
same, _note = comparable(current, history)
|
||
days = _series(current.data)
|
||
out: list[Finding] = []
|
||
holes = _holes(days, today)
|
||
if holes and same and all(not _holes(_series(h.data), _day(h.data.get("until"))) for h in same):
|
||
out.append(Finding("date_gap", "warning", before={"gaps": 0},
|
||
after={"gaps": len(holes), "days": [d.isoformat() for d in holes[:GAPS_SHOWN]]},
|
||
key=tuple(d.isoformat() for d in holes)))
|
||
if days:
|
||
# 最近一个整天:有数据的最后一天和昨天里早的那个(今天还在写入;未来的日子不是整天)
|
||
last = min(max(days), today - timedelta(days=1))
|
||
base = [days.get(last - timedelta(days=i), 0) for i in range(1, VOLUME_BASELINE_DAYS + 1)]
|
||
present = sum(1 for n in base if n > 0)
|
||
median = statistics.median(base) if base else 0
|
||
value = days.get(last, 0)
|
||
if present >= VOLUME_MIN_DAYS and median >= VOLUME_MIN_MEDIAN and value < VOLUME_RATIO * median:
|
||
out.append(Finding("volume_drop", "warning", before={"median": median, "days": VOLUME_BASELINE_DAYS},
|
||
after={"day": last.isoformat(), "rows": value, "ratio": round(value / median, 4)},
|
||
key=last.isoformat()))
|
||
return out
|
||
|
||
|
||
def _exact(data: Mapping[str, Any]) -> int | None:
|
||
rows = data.get("rows")
|
||
return int(rows) if isinstance(rows, int) and not isinstance(rows, bool) and rows >= 0 else None
|
||
|
||
|
||
def rows(current: Obs, history: Iterable[Obs]) -> list[Finding]:
|
||
"""行数:跌 30% 以上、涨到 3 倍以上。统计估算的要连续两次(都和跌、涨之前那一次比)。"""
|
||
same, _note = comparable(current, history)
|
||
now = _exact(current.data)
|
||
if now is None or not same:
|
||
return []
|
||
estimated = current.data.get("method") == "stats"
|
||
if estimated:
|
||
if len(same) < 2:
|
||
return []
|
||
prev, ref = _exact(same[0].data), _exact(same[1].data)
|
||
if prev is None or ref is None or not ref:
|
||
return []
|
||
dropped = now <= (1 - ROWS_DROP) * ref and prev <= (1 - ROWS_DROP) * ref
|
||
jumped = now >= ROWS_JUMP * ref and prev >= ROWS_JUMP * ref
|
||
else:
|
||
ref = _exact(same[0].data)
|
||
if ref is None or not ref:
|
||
return []
|
||
dropped = now <= (1 - ROWS_DROP) * ref
|
||
jumped = now >= ROWS_JUMP * ref
|
||
if dropped:
|
||
return [Finding("rows_drop", "warning", before={"rows": ref}, after={"rows": now, "estimated": estimated},
|
||
key=(current.basis_key, ref))]
|
||
if jumped:
|
||
return [Finding("rows_jump", "info", before={"rows": ref}, after={"rows": now, "estimated": estimated},
|
||
key=(current.basis_key, ref))]
|
||
return []
|
||
|
||
|
||
def compare(current: Obs, history: Iterable[Obs], *, today: date, known: Iterable[str] = (),
|
||
complete: bool = False) -> list[Finding]:
|
||
"""按种类分派。"""
|
||
if current.kind == "codes_observe":
|
||
return codes(current, history, known=known, complete=complete)
|
||
if current.kind == "freshness":
|
||
return freshness(current, history, today=today)
|
||
if current.kind == "daily_volume":
|
||
return daily(current, history, today=today)
|
||
if current.kind == "rows":
|
||
return rows(current, history)
|
||
return []
|
||
|
||
|
||
__all__ = ["Finding", "NEW_CODE_SHARE", "Obs", "PSI_INFO", "PSI_MIN_ROWS", "PSI_WARN", "codes", "comparable",
|
||
"compare", "daily", "freshness", "psi", "rows"]
|