Files
AgentLab/backend/app/data/value_drift.py
T
ThearchyHelios 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 加了一条「只数到观察
当天」的真库核对。
2026-10-17 07:59:19 +08:00

274 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""取值观察的比较器(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"]