perceptkit 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- perceptkit/__init__.py +85 -0
- perceptkit/algorithms/__init__.py +40 -0
- perceptkit/algorithms/attribution.py +147 -0
- perceptkit/algorithms/glance.py +236 -0
- perceptkit/algorithms/history.py +663 -0
- perceptkit/algorithms/identity.py +43 -0
- perceptkit/algorithms/observation.py +44 -0
- perceptkit/algorithms/streaks.py +111 -0
- perceptkit/algorithms/trend_models.py +184 -0
- perceptkit/algorithms/wake.py +149 -0
- perceptkit/catalog.py +252 -0
- perceptkit/conformance/__init__.py +28 -0
- perceptkit/conformance/memory.py +364 -0
- perceptkit/conformance/report.py +170 -0
- perceptkit/conformance/suite.py +419 -0
- perceptkit/conformance/wake.py +151 -0
- perceptkit/contracts/__init__.py +97 -0
- perceptkit/contracts/_time.py +89 -0
- perceptkit/contracts/availability.py +77 -0
- perceptkit/contracts/context.py +50 -0
- perceptkit/contracts/delivery.py +167 -0
- perceptkit/contracts/errors.py +22 -0
- perceptkit/contracts/event.py +137 -0
- perceptkit/contracts/observation.py +172 -0
- perceptkit/contracts/receipt.py +129 -0
- perceptkit/contracts/records.py +367 -0
- perceptkit/contracts/report.py +127 -0
- perceptkit/contracts/versioning.py +63 -0
- perceptkit/fields.py +184 -0
- perceptkit/kit.py +223 -0
- perceptkit/manifest/__init__.py +57 -0
- perceptkit/manifest/checks.py +323 -0
- perceptkit/manifest/mapping.py +96 -0
- perceptkit/manifest/minimal.py +1282 -0
- perceptkit/manifest/types.py +211 -0
- perceptkit/manifest/units.py +84 -0
- perceptkit/ports/__init__.py +19 -0
- perceptkit/ports/storage.py +288 -0
- perceptkit/ports/wake.py +43 -0
- perceptkit/processing/__init__.py +49 -0
- perceptkit/processing/aggregate.py +80 -0
- perceptkit/processing/dispatch.py +356 -0
- perceptkit/processing/normalize.py +458 -0
- perceptkit/processing/pipeline.py +406 -0
- perceptkit/processing/recompute.py +170 -0
- perceptkit/processing/recurrence.py +166 -0
- perceptkit/processing/scheduled.py +233 -0
- perceptkit/prompts.py +75 -0
- perceptkit/queries/__init__.py +32 -0
- perceptkit/queries/api.py +457 -0
- perceptkit/retention.py +84 -0
- perceptkit/rules/__init__.py +19 -0
- perceptkit/rules/engine.py +112 -0
- perceptkit/rules/evaluators.py +228 -0
- perceptkit/rules/types.py +236 -0
- perceptkit-0.2.2.dist-info/METADATA +439 -0
- perceptkit-0.2.2.dist-info/RECORD +59 -0
- perceptkit-0.2.2.dist-info/WHEEL +4 -0
- perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Wake adapter 的一致性检查 —— 宿主用它证明自己的 ``WakePort`` 实现是对的。
|
|
2
|
+
|
|
3
|
+
产品规范 §20 把 storage / wake / report 三种 adapter conformance 并列为最低
|
|
4
|
+
交付物。storage 那一套早就有了;这是 wake 那一套。
|
|
5
|
+
|
|
6
|
+
用法(在宿主自己的测试里)::
|
|
7
|
+
|
|
8
|
+
from perceptkit.conformance import run_wake_conformance
|
|
9
|
+
|
|
10
|
+
def test_my_runtime_adapter_is_conformant():
|
|
11
|
+
problems = run_wake_conformance(lambda: MyRuntimeWake(fake_queue()))
|
|
12
|
+
assert not problems, "\\n".join(problems)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 为什么 wake 需要单独一套
|
|
17
|
+
|
|
18
|
+
storage 错了通常会崩或者查不到;**wake 错了只会让用户被同一件事提醒两次**,
|
|
19
|
+
或者一条提醒永远不到 —— 两种都不报错,都只有用户会发现。
|
|
20
|
+
|
|
21
|
+
三件最容易做错的事,这套检查全都盯着:
|
|
22
|
+
|
|
23
|
+
幂等 崩溃重投是常态不是异常。投出去之后、回执存下来之前进程挂掉,
|
|
24
|
+
重启一定会再投一次。runtime 不按 event_id 幂等,用户就被提醒两次
|
|
25
|
+
别抛异常 "runtime 拒绝"是一种**正常应答**,用 rejected / suppressed 表达。
|
|
26
|
+
抛异常会被当成投递失败,于是无限重试一条对方明确不想要的事件
|
|
27
|
+
回执对得上 回执里的 event_id / attempt_id 必须原样回传。对不上的话,
|
|
28
|
+
调用方就无法判断这条回执说的是哪一次投递
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from datetime import datetime, timedelta, timezone
|
|
33
|
+
from typing import Any, Callable
|
|
34
|
+
|
|
35
|
+
from ..contracts.delivery import DeliveryAttempt
|
|
36
|
+
from ..contracts.event import EventCondition, PerceptionEvent
|
|
37
|
+
from ..contracts.receipt import WAKE_STATUSES, WakeReceipt
|
|
38
|
+
|
|
39
|
+
UTC = timezone.utc
|
|
40
|
+
T0 = datetime(2026, 8, 27, 10, 0, tzinfo=UTC)
|
|
41
|
+
|
|
42
|
+
WakeFactory = Callable[[], Any]
|
|
43
|
+
|
|
44
|
+
#: 这套检查覆盖的保证。宿主读这份清单就知道自己被要求了什么。
|
|
45
|
+
WAKE_GUARANTEES: tuple[str, ...] = (
|
|
46
|
+
"W1 回执的 status 必须是协议里的那几个之一",
|
|
47
|
+
"W2 回执必须原样回传 event_id 和 attempt_id",
|
|
48
|
+
"W3 同一个 event_id 重投必须是幂等的(第二次返回 duplicate,不重复处理)",
|
|
49
|
+
"W4 拒绝和抑制要用回执表达,不能抛异常",
|
|
50
|
+
"W5 attempt_id 变了但 event_id 没变,仍然算同一件事",
|
|
51
|
+
"W6 回执的 received_at 必须是带时区的时间",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
#: 这套检查**证明不了**的事。写下来免得被当成验过了。
|
|
55
|
+
WAKE_NOT_PROVABLE: tuple[str, ...] = (
|
|
56
|
+
"真实的超时行为 —— 要宿主自己让 runtime 卡住,确认返回 enqueue_failed "
|
|
57
|
+
"而不是永远挂着",
|
|
58
|
+
"真正的并发投递 —— 两条连接同时投同一个 event_id,只能有一个真正处理",
|
|
59
|
+
"跨进程幂等 —— 内存里的 seen 集合重启就没了,真实实现要落库",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _event(event_id: str = "evt_1") -> PerceptionEvent:
|
|
64
|
+
return PerceptionEvent(
|
|
65
|
+
event_id=event_id, definition_id="d1", definition_version=1,
|
|
66
|
+
subject_id="u1", type="activity.step_goal_reached", signal="steps",
|
|
67
|
+
occurred_at=T0, received_at=T0,
|
|
68
|
+
condition=EventCondition(type="threshold_crossing", operator="gte", value=3000),
|
|
69
|
+
field_name="step_count", previous=2999, current=3000,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _attempt(event_id: str = "evt_1", attempt_id: str = "att_1",
|
|
74
|
+
count: int = 1) -> DeliveryAttempt:
|
|
75
|
+
return DeliveryAttempt(
|
|
76
|
+
event_id=event_id, attempt_id=attempt_id, attempt_number=count,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _call(wake: Any, event: PerceptionEvent, attempt: DeliveryAttempt,
|
|
81
|
+
problems: list[str], label: str) -> WakeReceipt | None:
|
|
82
|
+
try:
|
|
83
|
+
return wake.wake(event, attempt)
|
|
84
|
+
except Exception as exc: # noqa: BLE001 — 这正是要抓的
|
|
85
|
+
problems.append(
|
|
86
|
+
f"W4 {label}:wake() 抛了 {type(exc).__name__}({exc})。"
|
|
87
|
+
"拒绝和抑制是正常应答,要用 rejected / conversation_suppressed 回执表达 —— "
|
|
88
|
+
"抛异常会被调用方当成投递失败,于是无限重试一条对方明确不想要的事件"
|
|
89
|
+
)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_wake_conformance(new: WakeFactory) -> list[str]:
|
|
94
|
+
"""跑一遍 wake adapter 的一致性检查,返回问题清单(空 = 通过)。
|
|
95
|
+
|
|
96
|
+
``new`` 每次调用要给一个**全新的**适配器实例 —— 检查之间不能共享状态,
|
|
97
|
+
否则 W3 的幂等检查会被上一轮的痕迹影响。
|
|
98
|
+
"""
|
|
99
|
+
problems: list[str] = []
|
|
100
|
+
|
|
101
|
+
# -- W1 / W2 / W6:回执本身的形状 -----------------------------------
|
|
102
|
+
wake = new()
|
|
103
|
+
ev, att = _event(), _attempt()
|
|
104
|
+
receipt = _call(wake, ev, att, problems, "首次投递")
|
|
105
|
+
if receipt is not None:
|
|
106
|
+
if receipt.status not in WAKE_STATUSES:
|
|
107
|
+
problems.append(
|
|
108
|
+
f"W1 回执 status={receipt.status!r} 不在协议里。"
|
|
109
|
+
f"合法值:{sorted(WAKE_STATUSES)}"
|
|
110
|
+
)
|
|
111
|
+
if receipt.event_id != ev.event_id:
|
|
112
|
+
problems.append(
|
|
113
|
+
f"W2 回执的 event_id 是 {receipt.event_id!r},投的是 {ev.event_id!r}。"
|
|
114
|
+
"对不上的话调用方无法判断这条回执说的是哪一次投递"
|
|
115
|
+
)
|
|
116
|
+
if receipt.attempt_id != att.attempt_id:
|
|
117
|
+
problems.append(
|
|
118
|
+
f"W2 回执的 attempt_id 是 {receipt.attempt_id!r},"
|
|
119
|
+
f"投的是 {att.attempt_id!r}"
|
|
120
|
+
)
|
|
121
|
+
if receipt.received_at.tzinfo is None:
|
|
122
|
+
problems.append(
|
|
123
|
+
"W6 回执的 received_at 没有时区。裸时间在跨时区宿主上会被解释错,"
|
|
124
|
+
"而且错得不会报错"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# -- W3 / W5:幂等 ---------------------------------------------------
|
|
128
|
+
wake = new()
|
|
129
|
+
ev = _event("evt_idem")
|
|
130
|
+
first = _call(wake, ev, _attempt("evt_idem", "att_1", 1), problems, "幂等第一次")
|
|
131
|
+
# 同一个 event_id、不同的 attempt_id —— 这正是崩溃重投的形状
|
|
132
|
+
second = _call(wake, ev, _attempt("evt_idem", "att_2", 2), problems, "幂等重投")
|
|
133
|
+
if first is not None and second is not None:
|
|
134
|
+
if second.status != "duplicate":
|
|
135
|
+
problems.append(
|
|
136
|
+
f"W3 同一个 event_id 投第二次返回了 {second.status!r},应该是 'duplicate'。"
|
|
137
|
+
"崩溃重投是常态:投出去之后、回执存下来之前进程挂掉,重启一定会再投一次。"
|
|
138
|
+
"不幂等的话用户会被同一件事提醒两次"
|
|
139
|
+
)
|
|
140
|
+
if second.event_id != ev.event_id:
|
|
141
|
+
problems.append("W5 重投的回执 event_id 变了 —— 它跨重试必须保持不变")
|
|
142
|
+
if second.attempt_id != "att_2":
|
|
143
|
+
problems.append(
|
|
144
|
+
"W5 重投的回执 attempt_id 没跟上这一次的尝试。"
|
|
145
|
+
"event_id 认的是「哪件事」,attempt_id 认的是「哪一次投递」,两个都要对"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return problems
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["WAKE_GUARANTEES", "WAKE_NOT_PROVABLE", "run_wake_conformance"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""契约层 —— 数据在 kit 边界上的样子。
|
|
2
|
+
|
|
3
|
+
四个信封,对应管线的四个交接点:
|
|
4
|
+
|
|
5
|
+
ReportEnvelope 宿主 → kit 一批采集到的数据
|
|
6
|
+
Observation kit 内部 校验、标准化之后的事实
|
|
7
|
+
PerceptionEvent kit → 宿主 一次规则命中
|
|
8
|
+
WakeReceipt 宿主 → kit runtime 对投递的应答
|
|
9
|
+
|
|
10
|
+
外加两样不属于信封、但必须在契约层出现的东西:
|
|
11
|
+
|
|
12
|
+
IngestContext 绝不能从信封里读的可信值(谁的数据、宿主的钟、授权范围)
|
|
13
|
+
IngestReceipt 一批上报的处理结果,用于重传幂等
|
|
14
|
+
|
|
15
|
+
**这一层只描述形状和校验规则,不做任何 I/O。** 谁在什么时候调、
|
|
16
|
+
落到哪张表,分别是 ``processing`` 和 ``ports`` 的事。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .availability import (
|
|
21
|
+
AVAILABILITY_STATES,
|
|
22
|
+
LEGACY_STATES,
|
|
23
|
+
NO_DATA,
|
|
24
|
+
OBSERVED,
|
|
25
|
+
UNAVAILABLE,
|
|
26
|
+
UNAVAILABLE_REASONS,
|
|
27
|
+
enters_trend,
|
|
28
|
+
normalize,
|
|
29
|
+
updates_current,
|
|
30
|
+
)
|
|
31
|
+
from . import delivery, records
|
|
32
|
+
from .context import IngestContext
|
|
33
|
+
from .errors import ContractError
|
|
34
|
+
from .event import EventCondition, PerceptionEvent
|
|
35
|
+
from .observation import Observation
|
|
36
|
+
from .receipt import (
|
|
37
|
+
INGEST_ACCEPTED,
|
|
38
|
+
INGEST_CONFLICT,
|
|
39
|
+
INGEST_DUPLICATE,
|
|
40
|
+
INGEST_REJECTED,
|
|
41
|
+
WAKE_ACCEPTED,
|
|
42
|
+
WAKE_DUPLICATE,
|
|
43
|
+
WAKE_ENQUEUE_FAILED,
|
|
44
|
+
WAKE_REJECTED,
|
|
45
|
+
WAKE_RETRYABLE,
|
|
46
|
+
WAKE_SUPPRESSED,
|
|
47
|
+
IngestReceipt,
|
|
48
|
+
WakeReceipt,
|
|
49
|
+
)
|
|
50
|
+
from .records import (
|
|
51
|
+
CONFLICT,
|
|
52
|
+
IGNORE,
|
|
53
|
+
REPLACE,
|
|
54
|
+
CalendarEventMirror,
|
|
55
|
+
CurrentProjection,
|
|
56
|
+
DailyAggregate,
|
|
57
|
+
DurableDedupeIdentity,
|
|
58
|
+
EventOutboxEntry,
|
|
59
|
+
ReminderItemMirror,
|
|
60
|
+
SourceSyncState,
|
|
61
|
+
StoredObservation,
|
|
62
|
+
decide_current_update,
|
|
63
|
+
)
|
|
64
|
+
from .report import ReportEnvelope
|
|
65
|
+
from .versioning import (
|
|
66
|
+
EVENT_SCHEMA_VERSION,
|
|
67
|
+
REPORT_SCHEMA_VERSION,
|
|
68
|
+
SUPPORTED_REPORT_VERSIONS,
|
|
69
|
+
UnsupportedSchemaVersion,
|
|
70
|
+
check_report_version,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
# availability
|
|
75
|
+
"OBSERVED", "NO_DATA", "UNAVAILABLE",
|
|
76
|
+
"AVAILABILITY_STATES", "LEGACY_STATES", "UNAVAILABLE_REASONS",
|
|
77
|
+
"normalize", "updates_current", "enters_trend",
|
|
78
|
+
# envelopes
|
|
79
|
+
"ReportEnvelope", "Observation", "PerceptionEvent", "EventCondition",
|
|
80
|
+
# trusted context + receipts
|
|
81
|
+
"IngestContext", "IngestReceipt", "WakeReceipt",
|
|
82
|
+
"INGEST_ACCEPTED", "INGEST_DUPLICATE", "INGEST_CONFLICT", "INGEST_REJECTED",
|
|
83
|
+
"WAKE_ACCEPTED", "WAKE_DUPLICATE", "WAKE_SUPPRESSED",
|
|
84
|
+
"WAKE_ENQUEUE_FAILED", "WAKE_REJECTED", "WAKE_RETRYABLE",
|
|
85
|
+
# versioning
|
|
86
|
+
"REPORT_SCHEMA_VERSION", "EVENT_SCHEMA_VERSION", "SUPPORTED_REPORT_VERSIONS",
|
|
87
|
+
"UnsupportedSchemaVersion", "check_report_version",
|
|
88
|
+
# 逻辑存储对象
|
|
89
|
+
"StoredObservation", "CurrentProjection", "DailyAggregate",
|
|
90
|
+
"CalendarEventMirror", "ReminderItemMirror", "SourceSyncState",
|
|
91
|
+
"DurableDedupeIdentity", "EventOutboxEntry",
|
|
92
|
+
"decide_current_update", "REPLACE", "IGNORE", "CONFLICT",
|
|
93
|
+
# 投递状态机
|
|
94
|
+
"delivery", "records",
|
|
95
|
+
# errors
|
|
96
|
+
"ContractError",
|
|
97
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""时间戳解析 —— 只用标准库,兼容到 Python 3.10。
|
|
2
|
+
|
|
3
|
+
为什么不直接用 ``datetime.fromisoformat``:3.10 的版本只认
|
|
4
|
+
``datetime.isoformat()`` 自己吐出来的那个窄格式 —— ``Z`` 后缀不认,
|
|
5
|
+
少写秒不认。真实 producer(iOS / Android / 各种 SDK)发过来的东西比那宽。
|
|
6
|
+
3.11 起放宽了,但这个包声明支持 3.10,所以自己兜一层。
|
|
7
|
+
|
|
8
|
+
**返回的一律是 aware datetime。** naive 的时间戳在这套系统里没有意义 ——
|
|
9
|
+
归属到哪一天完全取决于时区,丢了偏移就只能猜,而猜错会静默写进历史。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
#: ``fromisoformat`` 兜不住时挨个试的格式。按常见程度排。
|
|
16
|
+
_FALLBACK_FORMATS = (
|
|
17
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
18
|
+
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
19
|
+
"%Y-%m-%dT%H:%M%z",
|
|
20
|
+
"%Y-%m-%d %H:%M:%S%z",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TimestampError(ValueError):
|
|
25
|
+
"""时间戳解析不了,或者解析出来是 naive 的。"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_timestamp(raw: object, *, field: str = "timestamp") -> datetime:
|
|
29
|
+
"""把 ISO 8601 字符串解析成带时区的 :class:`datetime`。
|
|
30
|
+
|
|
31
|
+
也接受 :class:`datetime` 本身(必须 aware)。naive 一律拒绝 —— 见模块开头。
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(raw, datetime):
|
|
34
|
+
if raw.tzinfo is None or raw.tzinfo.utcoffset(raw) is None:
|
|
35
|
+
raise TimestampError(
|
|
36
|
+
f"{field}: naive datetime; 时间戳必须带 UTC 偏移,"
|
|
37
|
+
f"否则归属到哪一天只能靠猜"
|
|
38
|
+
)
|
|
39
|
+
return raw
|
|
40
|
+
|
|
41
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
42
|
+
raise TimestampError(f"{field}: expected an ISO 8601 string, got {raw!r}")
|
|
43
|
+
|
|
44
|
+
text = raw.strip()
|
|
45
|
+
# 3.10 的 fromisoformat 不认 Z;统一换成显式偏移。
|
|
46
|
+
if text.endswith(("Z", "z")):
|
|
47
|
+
text = text[:-1] + "+00:00"
|
|
48
|
+
|
|
49
|
+
parsed: datetime | None = None
|
|
50
|
+
try:
|
|
51
|
+
parsed = datetime.fromisoformat(text)
|
|
52
|
+
except ValueError:
|
|
53
|
+
for fmt in _FALLBACK_FORMATS:
|
|
54
|
+
try:
|
|
55
|
+
parsed = datetime.strptime(text, fmt)
|
|
56
|
+
break
|
|
57
|
+
except ValueError:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if parsed is None:
|
|
61
|
+
raise TimestampError(f"{field}: cannot parse {raw!r} as ISO 8601")
|
|
62
|
+
if parsed.tzinfo is None or parsed.tzinfo.utcoffset(parsed) is None:
|
|
63
|
+
raise TimestampError(
|
|
64
|
+
f"{field}: {raw!r} 没有 UTC 偏移;时间戳必须带偏移,"
|
|
65
|
+
f"否则归属到哪一天只能靠猜"
|
|
66
|
+
)
|
|
67
|
+
return parsed
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_iso(value: datetime) -> str:
|
|
71
|
+
"""序列化回 ISO 8601。UTC 用 ``+00:00`` 而不是 ``Z``,保持一种写法。"""
|
|
72
|
+
if value.tzinfo is None:
|
|
73
|
+
raise TimestampError("naive datetime cannot be serialised")
|
|
74
|
+
return value.isoformat()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def utc_now_is_not_available() -> None:
|
|
78
|
+
"""占位:这个包不读时钟。
|
|
79
|
+
|
|
80
|
+
"现在几点"是宿主的事 —— 内核读时钟就没法测试、没法重放、没法在
|
|
81
|
+
conformance test 里固定输入。需要"现在"的地方一律由调用方把时间传进来。
|
|
82
|
+
"""
|
|
83
|
+
raise NotImplementedError(
|
|
84
|
+
"perceptkit 不读时钟:需要 now 的地方由宿主传入,"
|
|
85
|
+
"这样才能重放和测试"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["TimestampError", "parse_timestamp", "to_iso", "timezone"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""一次观测的三种状态 —— 协议层唯一的可用性词表。
|
|
2
|
+
|
|
3
|
+
observed 拿到了可靠的值。``0`` / ``False`` / ``[]`` 都是合法的值,
|
|
4
|
+
不需要单独一个"观测到零"的状态。
|
|
5
|
+
no_data 查询成功了,但在指定范围内没有样本。
|
|
6
|
+
典型:昨晚没戴表 —— 不等于"睡了 0 分钟"。
|
|
7
|
+
unavailable 来源当前给不出这项数据(没授权 / 不支持 / 来源报错)。
|
|
8
|
+
|
|
9
|
+
**为什么是三个而不是四个。** 这个包早期用的是四态(多一个 ``observed_zero``),
|
|
10
|
+
问题在于"零"是值域里的一个普通取值,不是一种观测结果:把它提到状态位上,
|
|
11
|
+
每个消费方都得记住"observed 和 observed_zero 都算观测到了",迟早有人漏一个。
|
|
12
|
+
零值归 ``observed``,状态位就只回答一个问题:**这次到底有没有拿到数**。
|
|
13
|
+
|
|
14
|
+
``LEGACY_STATES`` 保留旧词表到新词表的映射,让还在用四态的宿主平滑过渡;
|
|
15
|
+
新代码一律用这里的三个常量。
|
|
16
|
+
|
|
17
|
+
**``stale`` 不在这里。** 它不是上报状态,是读取 current 时由
|
|
18
|
+
``occurred_at + current_ttl`` 推导出来的结果 —— 属于查询层,不属于上报协议。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
#: 拿到了可靠的值(``0`` / ``False`` / ``[]`` 都算)。
|
|
23
|
+
OBSERVED = "observed"
|
|
24
|
+
|
|
25
|
+
#: 查询成功但范围内没有样本。不参与数值趋势,不当 0 用。
|
|
26
|
+
NO_DATA = "no_data"
|
|
27
|
+
|
|
28
|
+
#: 来源当前无法提供该数据。不覆盖最后一次可靠值。
|
|
29
|
+
UNAVAILABLE = "unavailable"
|
|
30
|
+
|
|
31
|
+
#: 协议层允许的全部状态。
|
|
32
|
+
AVAILABILITY_STATES: frozenset[str] = frozenset({OBSERVED, NO_DATA, UNAVAILABLE})
|
|
33
|
+
|
|
34
|
+
#: 旧四态 -> 新三态。``observed_zero`` 并进 ``observed``,``no_observation``
|
|
35
|
+
#: 改名 ``no_data``(语义不变,只是名字更直白)。
|
|
36
|
+
LEGACY_STATES: dict[str, str] = {
|
|
37
|
+
"observed": OBSERVED,
|
|
38
|
+
"observed_zero": OBSERVED,
|
|
39
|
+
"no_observation": NO_DATA,
|
|
40
|
+
"no_data": NO_DATA,
|
|
41
|
+
"unavailable": UNAVAILABLE,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#: ``unavailable`` 可选的粗粒度原因。刻意不穷举操作系统的每种权限状态 ——
|
|
45
|
+
#: 那些属于 adapter 的诊断信息,不该进 agent 的上下文。
|
|
46
|
+
UNAVAILABLE_REASONS: frozenset[str] = frozenset({
|
|
47
|
+
"permission_denied",
|
|
48
|
+
"not_supported",
|
|
49
|
+
"source_error",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize(state: str) -> str:
|
|
54
|
+
"""把任意(含旧词表的)状态归一成三态之一。
|
|
55
|
+
|
|
56
|
+
未知状态一律当 ``unavailable`` —— 宁可让 agent 觉得"这项现在没有",
|
|
57
|
+
也不要把一个看不懂的状态当成有效观测混进趋势。
|
|
58
|
+
"""
|
|
59
|
+
return LEGACY_STATES.get(state, UNAVAILABLE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def updates_current(state: str) -> bool:
|
|
63
|
+
"""这个状态该不该更新 current 的数值。
|
|
64
|
+
|
|
65
|
+
只有 ``observed`` 会。``no_data`` 只更新 coverage/诊断,``unavailable``
|
|
66
|
+
只标记"当前不可用",两者都不许覆盖最后一次可靠值。
|
|
67
|
+
"""
|
|
68
|
+
return normalize(state) == OBSERVED
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def enters_trend(state: str) -> bool:
|
|
72
|
+
"""这个状态该不该进数值趋势和 streak 计算。
|
|
73
|
+
|
|
74
|
+
只有 ``observed`` 会。把 ``no_data`` 当 0 塞进趋势,是这类系统最常见的
|
|
75
|
+
错误 —— 十四天里两天没戴表,平均值会被两个 0 直接拉垮。
|
|
76
|
+
"""
|
|
77
|
+
return normalize(state) == OBSERVED
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""可信上下文 —— 那些**绝不能**从上报信封里读的值。
|
|
2
|
+
|
|
3
|
+
信封是 producer 写的,producer 在用户设备上,设备是可以被改的。所以三样东西
|
|
4
|
+
必须由宿主从已认证的连接注入,而不是从信封里读:
|
|
5
|
+
|
|
6
|
+
subject_id 这批数据算谁的。信客户端自报 = 任何人都能往别人账号里写观测。
|
|
7
|
+
received_at 宿主自己的钟。producer 报的时间可能不准(设备时钟错、
|
|
8
|
+
离线补传),审计和乱序判断要用可信的那个。
|
|
9
|
+
auth_scope 这个连接被授权写哪些 signal。用户关掉了健康权限,
|
|
10
|
+
设备却还在发 health_* —— 得在这里挡掉,不能等写库了才发现。
|
|
11
|
+
|
|
12
|
+
**为什么做成显式参数而不是全局变量**:如果 ``ingest()`` 的签名里没有它们的位置,
|
|
13
|
+
实现只有两条路 —— 去读某个环境全局(那就没法并发、没法测试),或者退回去信
|
|
14
|
+
信封里的值(那就是跨用户写入漏洞)。签名上留位置,是唯一能让"不信客户端"
|
|
15
|
+
这句话真正成立的做法。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from ._time import parse_timestamp
|
|
23
|
+
from .errors import ContractError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class IngestContext:
|
|
28
|
+
"""一次 ingest 的可信上下文。全部由宿主提供,信封不得覆盖。"""
|
|
29
|
+
|
|
30
|
+
#: 这批数据属于谁。宿主从已认证连接解析,**不读信封**。
|
|
31
|
+
subject_id: str
|
|
32
|
+
#: 宿主收到这批数据的时刻。必须是 aware datetime。
|
|
33
|
+
received_at: datetime
|
|
34
|
+
#: 这个连接被授权写哪些 signal。``None`` = 不限制(宿主自己已经把过关了)。
|
|
35
|
+
#: 空集合 = 一个都不许写(权限全关),和 ``None`` 是两回事。
|
|
36
|
+
auth_scope: frozenset[str] | None = None
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
if not isinstance(self.subject_id, str) or not self.subject_id.strip():
|
|
40
|
+
raise ContractError(["subject_id: required, must be a non-empty string"])
|
|
41
|
+
# 走一遍解析,把 naive datetime 挡在门外 —— 用 naive 的钟做乱序判断,
|
|
42
|
+
# 跨时区部署时会静默算错。
|
|
43
|
+
parse_timestamp(self.received_at, field="received_at")
|
|
44
|
+
|
|
45
|
+
def allows(self, signal: str) -> bool:
|
|
46
|
+
"""这个连接有没有被授权写这个 signal。"""
|
|
47
|
+
return self.auth_scope is None or signal in self.auth_scope
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["IngestContext"]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""投递状态机 —— 事件从落地到送达之间会经过什么。
|
|
2
|
+
|
|
3
|
+
**为什么状态机必须由 kit 定义,而不是留给每个宿主。**
|
|
4
|
+
|
|
5
|
+
投递这件事本身(写哪个队列、起几个 worker、用什么定时器)确实是宿主的。
|
|
6
|
+
但"什么时候算投出去了""崩在中间怎么恢复""重试会不会重复打扰用户",
|
|
7
|
+
是**正确性问题**——每个宿主自己发挥的结果是:
|
|
8
|
+
|
|
9
|
+
A 宿主 先投再落地 → 崩了丢事件
|
|
10
|
+
B 宿主 落地了但不重投 → 崩了事件永远卡住
|
|
11
|
+
C 宿主 重投但没去重 → 用户被同一件事提醒三次
|
|
12
|
+
D 宿主 投出去就扣冷却额度 → runtime 拒了,额度白扣,那轮该说的话没说
|
|
13
|
+
|
|
14
|
+
同一个 kit 装到四个宿主上,四种可靠性 —— "可插拔"就是假的。所以状态和
|
|
15
|
+
转移规则在这里定死,宿主只实现"怎么把状态存下来、怎么把 worker 跑起来"。
|
|
16
|
+
|
|
17
|
+
**冷却额度为什么要先占位再兑现**(这条产品规范里没有)。规范只说
|
|
18
|
+
"accepted 之后提交冷却/额度",但没说 accepted **之前**那段窗口怎么办:
|
|
19
|
+
两个 worker 同时捞到同一个事件、都还没拿到 accepted、于是都不扣额度、
|
|
20
|
+
于是都投出去了。所以 ``claimed`` 状态必须持有一个**可过期的占位**,
|
|
21
|
+
accepted 时兑现成真正的消耗,其余情况释放。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# 状态
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
#: 已经落地,等人来捞。**事件走到这个状态才算不会丢。**
|
|
32
|
+
PENDING = "pending"
|
|
33
|
+
|
|
34
|
+
#: 某个 worker 领走了,持有一个带到期时间的租约和一个冷却额度占位。
|
|
35
|
+
#: 租约到期没有进展 → 回到 ``pending`` 让别人接手(原持有者可能已经死了)。
|
|
36
|
+
CLAIMED = "claimed"
|
|
37
|
+
|
|
38
|
+
#: 终态:runtime 收下了。**只有这个状态会把额度占位兑现成真正的消耗。**
|
|
39
|
+
DELIVERED = "delivered"
|
|
40
|
+
|
|
41
|
+
#: 终态:runtime 收到了但选择不响应(会话正忙、安静时段)。
|
|
42
|
+
#: **这不是失败**,事件已经送达 —— 重投只会打扰第二次。额度占位释放。
|
|
43
|
+
SUPPRESSED = "suppressed"
|
|
44
|
+
|
|
45
|
+
#: 终态:runtime 明确拒绝(事件类型不认、subject 不属于它)。不重试。
|
|
46
|
+
REJECTED = "rejected"
|
|
47
|
+
|
|
48
|
+
#: 终态:重试次数用尽。留着给人看,不再自动投。
|
|
49
|
+
DEAD_LETTER = "dead_letter"
|
|
50
|
+
|
|
51
|
+
#: 终态:规则说了这条不唤醒。**事件仍然是事实、仍然落地**,只是不投。
|
|
52
|
+
#: 和 ``suppressed`` 不同 —— 那是 runtime 收到之后自己选择不响应,
|
|
53
|
+
#: 这个是压根没打算投。两者混用会让"到底送没送到"说不清。
|
|
54
|
+
NOT_DISPATCHED = "not_dispatched"
|
|
55
|
+
|
|
56
|
+
DELIVERY_STATES: frozenset[str] = frozenset({
|
|
57
|
+
PENDING, CLAIMED, DELIVERED, SUPPRESSED, REJECTED, DEAD_LETTER, NOT_DISPATCHED,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
#: 终态:不会再变,也不再占用额度占位。
|
|
61
|
+
TERMINAL_STATES: frozenset[str] = frozenset({
|
|
62
|
+
DELIVERED, SUPPRESSED, REJECTED, DEAD_LETTER, NOT_DISPATCHED,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
#: 合法的状态转移。任何不在这里的转移都是 bug,不是"边界情况"。
|
|
66
|
+
_TRANSITIONS: dict[str, frozenset[str]] = {
|
|
67
|
+
PENDING: frozenset({CLAIMED}),
|
|
68
|
+
# claimed → pending 有两条路:主动放回(投递失败,等下次重试),
|
|
69
|
+
# 或租约到期被别人接管。两条都合法。
|
|
70
|
+
CLAIMED: frozenset({PENDING, DELIVERED, SUPPRESSED, REJECTED, DEAD_LETTER}),
|
|
71
|
+
DELIVERED: frozenset(),
|
|
72
|
+
SUPPRESSED: frozenset(),
|
|
73
|
+
REJECTED: frozenset(),
|
|
74
|
+
DEAD_LETTER: frozenset(),
|
|
75
|
+
NOT_DISPATCHED: frozenset(),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IllegalTransition(ValueError):
|
|
80
|
+
"""试图做一个不合法的状态转移。
|
|
81
|
+
|
|
82
|
+
这类错误不该被 catch 掉当边界情况处理 —— 它意味着投递逻辑有 bug,
|
|
83
|
+
继续往下走只会让状态更乱。
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def can_transition(current: str, target: str) -> bool:
|
|
88
|
+
return target in _TRANSITIONS.get(current, frozenset())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def assert_transition(current: str, target: str) -> None:
|
|
92
|
+
if current not in DELIVERY_STATES:
|
|
93
|
+
raise IllegalTransition(f"未知状态 {current!r}")
|
|
94
|
+
if not can_transition(current, target):
|
|
95
|
+
allowed = sorted(_TRANSITIONS.get(current, frozenset()))
|
|
96
|
+
raise IllegalTransition(
|
|
97
|
+
f"{current} -> {target} 不合法;从 {current} 只能走到 {allowed or ['(终态)']}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_terminal(state: str) -> bool:
|
|
102
|
+
return state in TERMINAL_STATES
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# 回执 -> 状态
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def next_state_for_receipt(status: str, *, attempts_left: bool) -> str:
|
|
110
|
+
"""runtime 给了这个回执之后,事件该进哪个状态。
|
|
111
|
+
|
|
112
|
+
``attempts_left`` 为假时,本该重试的转成 ``dead_letter`` —— 无限重试
|
|
113
|
+
会让一个投不出去的事件永远占着 worker。
|
|
114
|
+
"""
|
|
115
|
+
from .receipt import (
|
|
116
|
+
WAKE_ACCEPTED, WAKE_DUPLICATE, WAKE_ENQUEUE_FAILED,
|
|
117
|
+
WAKE_REJECTED, WAKE_SUPPRESSED,
|
|
118
|
+
)
|
|
119
|
+
if status in (WAKE_ACCEPTED, WAKE_DUPLICATE):
|
|
120
|
+
# duplicate 也算送达:runtime 认得这个 event_id,说明之前那次其实成了,
|
|
121
|
+
# 只是回执没存下来。当成功处理,别再投第三次。
|
|
122
|
+
return DELIVERED
|
|
123
|
+
if status == WAKE_SUPPRESSED:
|
|
124
|
+
return SUPPRESSED
|
|
125
|
+
if status == WAKE_REJECTED:
|
|
126
|
+
return REJECTED
|
|
127
|
+
if status == WAKE_ENQUEUE_FAILED:
|
|
128
|
+
return PENDING if attempts_left else DEAD_LETTER
|
|
129
|
+
raise IllegalTransition(f"没有为回执状态 {status!r} 定义后续状态")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def consumes_budget(state: str) -> bool:
|
|
133
|
+
"""走到这个状态时,冷却额度占位该不该兑现成真正的消耗。
|
|
134
|
+
|
|
135
|
+
**只有 ``delivered``。** 被压制、被拒绝、进死信都要把占位释放掉 ——
|
|
136
|
+
用户那一轮该说的话没说出去、额度却被吃掉了,是最难查的一类问题:
|
|
137
|
+
没有报错、没有日志,只有"它今天怎么不说话"。
|
|
138
|
+
"""
|
|
139
|
+
return state == DELIVERED
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class DeliveryAttempt:
|
|
144
|
+
"""一次投递尝试的身份。
|
|
145
|
+
|
|
146
|
+
``event_id`` 跨重试**保持不变** —— runtime 靠它幂等,变了就等于
|
|
147
|
+
每次重试都是一个新事件,用户会被重复打扰。
|
|
148
|
+
``attempt_id`` 每次都变 —— 用来把回执对上具体是哪一次投递,
|
|
149
|
+
以及在日志里分辨"第三次重试失败"和"三个并发投递失败"。
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
event_id: str
|
|
153
|
+
attempt_id: str
|
|
154
|
+
attempt_number: int
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
if self.attempt_number < 1:
|
|
158
|
+
raise ValueError("attempt_number 从 1 开始")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = [
|
|
162
|
+
"PENDING", "CLAIMED", "DELIVERED", "SUPPRESSED", "REJECTED", "DEAD_LETTER",
|
|
163
|
+
"NOT_DISPATCHED",
|
|
164
|
+
"DELIVERY_STATES", "TERMINAL_STATES",
|
|
165
|
+
"IllegalTransition", "can_transition", "assert_transition", "is_terminal",
|
|
166
|
+
"next_state_for_receipt", "consumes_budget", "DeliveryAttempt",
|
|
167
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""契约层的错误类型。
|
|
2
|
+
|
|
3
|
+
单独一个模块,免得 report / observation 互相 import 成环。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContractError(ValueError):
|
|
11
|
+
"""契约校验失败。
|
|
12
|
+
|
|
13
|
+
**一次报全部问题,不是遇到第一个就抛。** 一批上报里往往同时有好几个字段
|
|
14
|
+
不对,逐个试错要往返很多次;adapter 拿到完整清单才能一次改完。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, errors: Sequence[str]) -> None:
|
|
18
|
+
self.errors = list(errors)
|
|
19
|
+
super().__init__("; ".join(self.errors))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["ContractError"]
|