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.
Files changed (59) hide show
  1. perceptkit/__init__.py +85 -0
  2. perceptkit/algorithms/__init__.py +40 -0
  3. perceptkit/algorithms/attribution.py +147 -0
  4. perceptkit/algorithms/glance.py +236 -0
  5. perceptkit/algorithms/history.py +663 -0
  6. perceptkit/algorithms/identity.py +43 -0
  7. perceptkit/algorithms/observation.py +44 -0
  8. perceptkit/algorithms/streaks.py +111 -0
  9. perceptkit/algorithms/trend_models.py +184 -0
  10. perceptkit/algorithms/wake.py +149 -0
  11. perceptkit/catalog.py +252 -0
  12. perceptkit/conformance/__init__.py +28 -0
  13. perceptkit/conformance/memory.py +364 -0
  14. perceptkit/conformance/report.py +170 -0
  15. perceptkit/conformance/suite.py +419 -0
  16. perceptkit/conformance/wake.py +151 -0
  17. perceptkit/contracts/__init__.py +97 -0
  18. perceptkit/contracts/_time.py +89 -0
  19. perceptkit/contracts/availability.py +77 -0
  20. perceptkit/contracts/context.py +50 -0
  21. perceptkit/contracts/delivery.py +167 -0
  22. perceptkit/contracts/errors.py +22 -0
  23. perceptkit/contracts/event.py +137 -0
  24. perceptkit/contracts/observation.py +172 -0
  25. perceptkit/contracts/receipt.py +129 -0
  26. perceptkit/contracts/records.py +367 -0
  27. perceptkit/contracts/report.py +127 -0
  28. perceptkit/contracts/versioning.py +63 -0
  29. perceptkit/fields.py +184 -0
  30. perceptkit/kit.py +223 -0
  31. perceptkit/manifest/__init__.py +57 -0
  32. perceptkit/manifest/checks.py +323 -0
  33. perceptkit/manifest/mapping.py +96 -0
  34. perceptkit/manifest/minimal.py +1282 -0
  35. perceptkit/manifest/types.py +211 -0
  36. perceptkit/manifest/units.py +84 -0
  37. perceptkit/ports/__init__.py +19 -0
  38. perceptkit/ports/storage.py +288 -0
  39. perceptkit/ports/wake.py +43 -0
  40. perceptkit/processing/__init__.py +49 -0
  41. perceptkit/processing/aggregate.py +80 -0
  42. perceptkit/processing/dispatch.py +356 -0
  43. perceptkit/processing/normalize.py +458 -0
  44. perceptkit/processing/pipeline.py +406 -0
  45. perceptkit/processing/recompute.py +170 -0
  46. perceptkit/processing/recurrence.py +166 -0
  47. perceptkit/processing/scheduled.py +233 -0
  48. perceptkit/prompts.py +75 -0
  49. perceptkit/queries/__init__.py +32 -0
  50. perceptkit/queries/api.py +457 -0
  51. perceptkit/retention.py +84 -0
  52. perceptkit/rules/__init__.py +19 -0
  53. perceptkit/rules/engine.py +112 -0
  54. perceptkit/rules/evaluators.py +228 -0
  55. perceptkit/rules/types.py +236 -0
  56. perceptkit-0.2.2.dist-info/METADATA +439 -0
  57. perceptkit-0.2.2.dist-info/RECORD +59 -0
  58. perceptkit-0.2.2.dist-info/WHEEL +4 -0
  59. perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,458 @@
1
+ """标准化 —— 从"producer 发来的东西"到"可以落库的事实"。
2
+
3
+ 这一步做四件事,每件都是后面所有环节的前提:
4
+
5
+ 按 manifest 校验 类型、单位、枚举、区间、可空性
6
+ 定时区 没有时区就没法算归属到哪一天
7
+ 算归属日期 按信号声明的策略(瞬时 / 区间结束 / 跨午夜切分 / 上游给)
8
+ 算去重身份 按信号声明的策略(上游 id / 确定性摘要 / 单例)
9
+
10
+ **校验失败不抛异常,返回问题清单。** 一批上报里可能有十条观测,其中两条
11
+ 字段有问题 —— 因为这两条把另外八条一起丢掉,是最容易让人骂街的设计。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from dataclasses import dataclass
18
+ from datetime import date, datetime
19
+ from typing import Any, Mapping
20
+
21
+ from ..algorithms import attribution
22
+ from ..contracts._time import to_iso
23
+ from ..contracts.context import IngestContext
24
+ from ..contracts.observation import Observation
25
+ from ..contracts.records import StoredObservation
26
+ from ..manifest.types import FieldDefinition, SignalDefinition
27
+
28
+
29
+ class AttributionError(ValueError):
30
+ """算不出这条观测属于哪一天。**只拒这一条,不影响同批其他观测。**"""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class NormalizedObservation:
35
+ """标准化的结果:一条可落库的观测,外加两种身份。
36
+
37
+ **两种身份必须分开,混用会造成两类静默错误。**
38
+
39
+ fact_key 这是【哪一条事实】。同一个 HealthKit 样本、同一个
40
+ 电量槽位,永远是同一个 fact_key —— 修订它的时候
41
+ 靠这个找到旧记录。
42
+ identity_digest 这是【哪一次投递】。fact_key + 版本 + 内容。
43
+ 去重问的是这个。
44
+
45
+ 混用的后果(都发生过,是这两个字段被拆开的原因):
46
+
47
+ 用 fact_key 去重 → 电量第二次上报被当成重传丢掉,current 冻结在
48
+ 第一次;同一个样本从 revision 1 改到 2 也被当成
49
+ 重传,用户在健康 App 里的纠错永远生效不了。
50
+ 用 identity 找旧记录 → 每次内容一变就成了"新事实",修订无从谈起。
51
+ """
52
+
53
+ stored: StoredObservation
54
+ #: 这是哪一次投递。去重问它。明细按保留期删掉之后,
55
+ #: 这是唯一还能回答"这条处理过没有"的东西。
56
+ identity_digest: str
57
+ #: 这是哪一条事实。修订同一条事实时靠它找到旧记录。
58
+ fact_key: str
59
+ #: 内容摘要。用来分辨"同一时刻的重传"和"同一时刻的不同内容"。
60
+ content_digest: str
61
+ #: 跨午夜的区间会摊到多天:``[(本地日期, 分钟数), ...]``。其余为空。
62
+ day_slices: tuple[tuple[str, float], ...] = ()
63
+
64
+
65
+ def _canonical(value: Any) -> str:
66
+ """稳定的内容序列化 —— 字典顺序不能影响摘要,否则同一份数据会算出两个键。"""
67
+ return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"),
68
+ default=str)
69
+
70
+
71
+ def _digest(*parts: str) -> str:
72
+ """带长度前缀的拼接再哈希。
73
+
74
+ 不加长度前缀的话 ``("ab","c")`` 和 ``("a","bc")`` 会撞成同一个键 ——
75
+ 这类碰撞不会报错,只会让两条无关的观测被当成重复。
76
+ """
77
+ joined = "\x1f".join(f"{len(p)}:{p}" for p in parts)
78
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # 字段校验
83
+ # ---------------------------------------------------------------------------
84
+
85
+ _PY_TYPES: dict[str, tuple[type, ...]] = {
86
+ "integer": (int,),
87
+ "number": (int, float),
88
+ "boolean": (bool,),
89
+ "string": (str,),
90
+ "enum": (str,),
91
+ "timestamp": (str, datetime),
92
+ "array": (list, tuple),
93
+ "object": (dict,),
94
+ }
95
+
96
+
97
+ def _check_field(fd: FieldDefinition, raw: Any, where: str) -> list[str]:
98
+ if raw is None:
99
+ return [] if fd.nullable else [f"{where}: 不可为空"]
100
+
101
+ expected = _PY_TYPES.get(fd.value_type, ())
102
+ # bool 是 int 的子类,不拦一下的话 True 会被当成合法的 integer。
103
+ if fd.value_type in ("integer", "number") and isinstance(raw, bool):
104
+ return [f"{where}: 期望 {fd.value_type},收到 boolean"]
105
+ if expected and not isinstance(raw, expected):
106
+ return [f"{where}: 期望 {fd.value_type},收到 {type(raw).__name__}"]
107
+
108
+ problems: list[str] = []
109
+ if fd.enum and raw not in fd.enum:
110
+ problems.append(f"{where}: {raw!r} 不在 {list(fd.enum)}")
111
+ if fd.valid_range and isinstance(raw, (int, float)):
112
+ low, high = fd.valid_range
113
+ if low is not None and raw < low:
114
+ problems.append(f"{where}: {raw} 小于下限 {low}")
115
+ if high is not None and raw > high:
116
+ problems.append(f"{where}: {raw} 大于上限 {high}")
117
+ return problems
118
+
119
+
120
+ def validate_value(sig: SignalDefinition, value: Mapping[str, Any] | None) -> list[str]:
121
+ """按 manifest 校验一条观测的 payload。"""
122
+ if value is None:
123
+ return []
124
+ known = sig.field_map()
125
+ problems: list[str] = []
126
+ for key, raw in value.items():
127
+ fd = known.get(key)
128
+ if fd is None:
129
+ # 不认识的字段忽略而不是报错:让 producer 可以先发新字段、
130
+ # 宿主后升级。代价是拼错的字段名不会报错,由下面的必填检查兜。
131
+ continue
132
+ problems += _check_field(fd, raw, f"{sig.key}.{key}")
133
+ for key, fd in known.items():
134
+ if not fd.nullable and key not in value:
135
+ problems.append(f"{sig.key}.{key}: 必填字段缺失")
136
+ return problems
137
+
138
+
139
+ def sanitize_value(
140
+ sig: SignalDefinition, value: Mapping[str, Any] | None,
141
+ ) -> tuple[dict[str, Any] | None, list[str]]:
142
+ """落库前把不该存的字段去掉。返回 ``(干净的 payload, 被丢掉了什么)``。
143
+
144
+ 丢两类:
145
+
146
+ privacy_class="restricted" manifest 明说永不持久化(精确坐标、BSSID)。
147
+ **在写入边界丢,不是在读取边界过滤** ——
148
+ 靠读取点自觉过滤,漏一个点就是泄漏,
149
+ 而且数据已经在库里了,泄漏是既成事实。
150
+ manifest 里没声明的字段 不报错(让 producer 可以先发新字段),
151
+ 但也不进 canonical value —— 否则任何
152
+ 未声明字段都能被存下来并查出去。
153
+
154
+ 被丢掉的字段名会记在 warning 里,方便排查"我发了它怎么查不到"。
155
+ """
156
+ if value is None:
157
+ return None, []
158
+ known = sig.field_map()
159
+ clean: dict[str, Any] = {}
160
+ dropped: list[str] = []
161
+ for key, raw in value.items():
162
+ fd = known.get(key)
163
+ if fd is None:
164
+ dropped.append(f"{sig.key}.{key}(manifest 未声明)")
165
+ continue
166
+ if fd.privacy_class == "restricted":
167
+ dropped.append(f"{sig.key}.{key}(privacy_class=restricted,永不持久化)")
168
+ continue
169
+ clean[key] = raw
170
+ return clean, dropped
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # 时区与归属日期
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def resolve_timezone(
178
+ obs: Observation, *, fallback: str | None = None,
179
+ ) -> tuple[str | None, str | None]:
180
+ """定这条观测该用哪个时区,返回 ``(时区名, 说明来源的标记)``。
181
+
182
+ 优先级:观测自带 > 宿主给的兜底 > 无。
183
+
184
+ **``occurred_at`` 的偏移不算时区。** 偏移只够算"这条算哪一天",不够处理
185
+ 夏令时 —— 纽约的 ``-04:00`` 和 ``-05:00`` 是同一个时区在不同季节,
186
+ 光看偏移分不出来,切换那天(那天有 25 小时)就会算错。
187
+
188
+ 兜底策略本身还没和产品方对齐(见 OPEN-QUESTIONS B2),所以由调用方传进来,
189
+ 不在这里写死。
190
+ """
191
+ if obs.timezone:
192
+ return obs.timezone, "observation"
193
+ if fallback:
194
+ return fallback, "host_fallback"
195
+ return None, None
196
+
197
+
198
+ def effective_date(
199
+ obs: Observation, sig: SignalDefinition, *, timezone_name: str | None,
200
+ ) -> tuple[date, tuple[tuple[str, float], ...], list[str]]:
201
+ """算这条观测归到哪一天,以及(跨午夜时)怎么摊。"""
202
+ problems: list[str] = []
203
+ value = obs.value or {}
204
+ strategy = sig.attribution_strategy
205
+ iso = to_iso(obs.occurred_at)
206
+
207
+ def _parse(day: str) -> date:
208
+ return date.fromisoformat(day)
209
+
210
+ if strategy == "source_local_date":
211
+ raw = value.get("local_date")
212
+ if isinstance(raw, str):
213
+ try:
214
+ return _parse(raw), (), problems
215
+ except ValueError:
216
+ problems.append(f"{sig.key}: local_date={raw!r} 不是 YYYY-MM-DD")
217
+ # 上游说好了给本地日期却没给 —— 退回按发生时刻算,并记一笔,
218
+ # 不静默换算法。
219
+ problems.append(f"{sig.key}: 声明了 source_local_date 但 payload 里没有 local_date")
220
+ return _parse(attribution.attribute_instant(iso)), (), problems
221
+
222
+ if strategy in ("episode_end", "split_at_midnight"):
223
+ start, end = value.get("start_at"), value.get("end_at")
224
+ if not isinstance(start, str) or not isinstance(end, str):
225
+ problems.append(
226
+ f"{sig.key}: {strategy} 需要 start_at / end_at,"
227
+ f"退回按 occurred_at 归属"
228
+ )
229
+ return _parse(attribution.attribute_instant(iso)), (), problems
230
+ # producer 可能发来不合法的区间(结束早于开始、时间戳格式不对)。
231
+ # **只拒这一条,不能炸掉整批** —— 一批十条里一条有问题就全丢,
232
+ # 是最容易让人骂街的设计,管线其他地方都守住了这条,这里以前漏了。
233
+ try:
234
+ if strategy == "episode_end":
235
+ return _parse(attribution.attribute_episode(start, end)), (), problems
236
+ slices = tuple(attribution.split_across_midnight(start, end, tz=timezone_name))
237
+ return _parse(slices[-1][0]) if slices else _parse(
238
+ attribution.attribute_episode(start, end)
239
+ ), slices, problems
240
+ except (ValueError, TypeError) as exc:
241
+ raise AttributionError(f"{sig.key}: {exc}") from exc
242
+
243
+ return _parse(attribution.attribute_instant(iso)), (), problems
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # 去重身份
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # 设备时钟
253
+ # ---------------------------------------------------------------------------
254
+ #
255
+ # 产品规范说「Producer 时钟明显错误 → 标记质量问题/拒绝」,但没说什么算"明显"。
256
+ # 这是我们定的判据,写在这里是为了让它可被质疑,而不是散落在某个 if 里。
257
+ #
258
+ # **关键的不对称:未来的时间一定是错的,过去的时间可能是正常补传。**
259
+ #
260
+ # 用户手机时间设成了 2027 年
261
+ # → 今天的步数被写进 2027-08-28
262
+ # → 明年那天翻历史,凭空多出一天;今天的记录永远找不到
263
+ # → 没有任何地方报错
264
+ #
265
+ # 用户三个月没开 app,一次性补传
266
+ # → occurred_at 是三个月前,完全正常,必须收
267
+
268
+ #: 这个范围内的偏差照常处理 —— 正常的时钟漂移和网络延迟都在这里面。
269
+ CLOCK_TOLERANCE_SEC = 600.0 # 10 分钟
270
+
271
+ #: 超过这个的【未来】时间拒收。离线补传只会产生过去的时间,
272
+ #: 所以对过去一侧不设上限。
273
+ CLOCK_FUTURE_LIMIT_SEC = 86400.0 # 24 小时
274
+
275
+
276
+ def check_clock(occurred_at: datetime, received_at: datetime,
277
+ signal: str) -> tuple[str | None, str | None]:
278
+ """比对设备时间和宿主的钟。返回 ``(拒收理由, 质量警告)``,都可能为 None。
279
+
280
+ 基准用 ``received_at``(宿主自己的钟,可信),不用本机 ``now()`` ——
281
+ 这个包不读时钟,否则重放和测试都做不了。
282
+ """
283
+ skew = (occurred_at - received_at).total_seconds()
284
+ if skew > CLOCK_FUTURE_LIMIT_SEC:
285
+ return (
286
+ f"{signal}: occurred_at 比收到的时刻晚了 {skew / 3600:.1f} 小时。"
287
+ "未来的时间一定是错的(设备时钟被改过),收下它会把数据写进未来的某一天,"
288
+ "而且不会有任何地方报错",
289
+ None,
290
+ )
291
+ if abs(skew) > CLOCK_TOLERANCE_SEC:
292
+ direction = "晚" if skew > 0 else "早"
293
+ return None, (
294
+ f"{signal}: occurred_at 比收到的时刻{direction}了 {abs(skew) / 60:.0f} 分钟,"
295
+ "时间可疑。仍然收下 —— 离线补传本来就会产生偏差,但这条数据的"
296
+ "归属日期不一定可信"
297
+ )
298
+ return None, None
299
+
300
+
301
+
302
+ def identity_for(
303
+ obs: Observation, sig: SignalDefinition, ctx: IngestContext, *,
304
+ source: str, content_digest: str,
305
+ ) -> tuple[str, str, list[str]]:
306
+ """算两种身份,返回 ``(投递身份, 事实身份, 问题清单)``。
307
+
308
+ 见 :class:`NormalizedObservation` 的文档 —— 这两个混用会造成两类静默错误。
309
+ """
310
+ problems: list[str] = []
311
+ strategy = sig.identity_strategy
312
+
313
+ if strategy == "source_event_id" and not obs.source_event_id:
314
+ # 声明了要用上游 id 却没给。**退回确定性摘要而不是拒收** ——
315
+ # 拒收会让一整类信号(音乐、照片,见 FACTS.md)一条都进不来。
316
+ problems.append(
317
+ f"{sig.key}: 声明了 source_event_id 但没给,退回确定性摘要"
318
+ f"(去重强度下降:挡不住「同一事实换个时间戳重发」)"
319
+ )
320
+ strategy = "deterministic_digest"
321
+
322
+ if strategy == "source_event_id":
323
+ fact = _digest(ctx.subject_id, source, sig.key, obs.source_event_id or "")
324
+ elif strategy == "singleton":
325
+ # 每个 subject+signal 只有一条事实,后来的是它的新版本。
326
+ fact = _digest(ctx.subject_id, source, sig.key)
327
+ else:
328
+ # 没有上游身份:同一时刻同一内容才算同一条事实。
329
+ fact = _digest(ctx.subject_id, source, sig.key, to_iso(obs.occurred_at))
330
+
331
+ # 投递身份 = 事实 + 这一版的时刻、版本、内容。
332
+ # 少了任何一项,都会有一类"新数据"被误判成重传而静默丢掉。
333
+ delivery = _digest(
334
+ fact, to_iso(obs.occurred_at),
335
+ "" if obs.source_revision is None else str(obs.source_revision),
336
+ content_digest,
337
+ )
338
+ return delivery, fact, problems
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # 入口
343
+ # ---------------------------------------------------------------------------
344
+
345
+ @dataclass(frozen=True)
346
+ class NormalizeResult:
347
+ normalized: tuple[NormalizedObservation, ...]
348
+ #: 被拒的观测:``(在这批里的下标, 问题清单)``。其余照常处理。
349
+ rejected: tuple[tuple[int, tuple[str, ...]], ...]
350
+ #: 处理了但有话要说的(退回了备用策略之类)。不影响落库。
351
+ warnings: tuple[str, ...]
352
+
353
+
354
+ def normalize_observations(
355
+ observations: tuple[Observation, ...],
356
+ *,
357
+ context: IngestContext,
358
+ signals: Mapping[str, SignalDefinition],
359
+ source: str,
360
+ timezone_fallback: str | None = None,
361
+ observation_id_for: Any = None,
362
+ ) -> NormalizeResult:
363
+ """把一批 wire 观测标准化成可落库的形态。
364
+
365
+ ``observation_id_for`` 是宿主提供的 id 生成器(kit 不读时钟也不生成随机数
366
+ —— 那会让重放和测试都做不了)。不给时用去重身份当 id。
367
+ """
368
+ out: list[NormalizedObservation] = []
369
+ rejected: list[tuple[int, tuple[str, ...]]] = []
370
+ warnings: list[str] = []
371
+
372
+ for index, obs in enumerate(observations):
373
+ problems: list[str] = []
374
+
375
+ sig = signals.get(obs.signal)
376
+ if sig is None:
377
+ rejected.append((index, (f"{obs.signal}: manifest 里没有这个信号",)))
378
+ continue
379
+ if not context.allows(obs.signal):
380
+ # 用户关掉了这项权限,设备却还在发。挡在这里,不能等写库才发现。
381
+ rejected.append((index, (f"{obs.signal}: 这个连接没有被授权写它",)))
382
+ continue
383
+ if obs.signal_schema_version != sig.schema_version:
384
+ warnings.append(
385
+ f"{obs.signal}: payload 版本 {obs.signal_schema_version} != "
386
+ f"manifest 版本 {sig.schema_version}"
387
+ )
388
+
389
+ problems += validate_value(sig, obs.value)
390
+ if problems:
391
+ rejected.append((index, tuple(problems)))
392
+ continue
393
+
394
+ # 落库边界过滤:受限字段和未声明字段到此为止,不进 canonical value。
395
+ clean, dropped = sanitize_value(sig, obs.value)
396
+ if dropped:
397
+ warnings.append(f"{obs.signal}: 丢弃了 {', '.join(dropped)}")
398
+
399
+ # 设备时钟。放在归属之前 —— 时间不可信的话,算出来的"哪一天"也不可信。
400
+ reject_reason, clock_warning = check_clock(
401
+ obs.occurred_at, context.received_at, obs.signal)
402
+ if reject_reason is not None:
403
+ rejected.append((index, (reject_reason,)))
404
+ continue
405
+ if clock_warning:
406
+ warnings.append(clock_warning)
407
+
408
+ tz_name, _ = resolve_timezone(obs, fallback=timezone_fallback)
409
+ if tz_name is None:
410
+ warnings.append(
411
+ f"{obs.signal}: 没有时区,按 occurred_at 的偏移归属日期"
412
+ f"(夏令时切换当天可能算错)"
413
+ )
414
+ try:
415
+ day, slices, day_problems = effective_date(obs, sig, timezone_name=tz_name)
416
+ except AttributionError as exc:
417
+ rejected.append((index, (str(exc),)))
418
+ continue
419
+ warnings += day_problems
420
+
421
+ content = _digest(_canonical(clean), obs.availability)
422
+ identity, fact_key, id_problems = identity_for(
423
+ obs, sig, context, source=source, content_digest=content,
424
+ )
425
+ warnings += id_problems
426
+ obs_id = (observation_id_for(obs) if callable(observation_id_for) else identity)
427
+
428
+ out.append(NormalizedObservation(
429
+ stored=StoredObservation(
430
+ observation_id=obs_id,
431
+ subject_id=context.subject_id,
432
+ signal=obs.signal,
433
+ signal_schema_version=obs.signal_schema_version,
434
+ source=source,
435
+ occurred_at=obs.occurred_at,
436
+ received_at=context.received_at,
437
+ availability=obs.availability,
438
+ effective_local_date=day,
439
+ typed_value=clean,
440
+ timezone=tz_name,
441
+ source_event_id=obs.source_event_id,
442
+ source_revision=obs.source_revision,
443
+ ),
444
+ identity_digest=identity,
445
+ fact_key=fact_key,
446
+ content_digest=content,
447
+ day_slices=slices,
448
+ ))
449
+
450
+ return NormalizeResult(tuple(out), tuple(rejected), tuple(warnings))
451
+
452
+
453
+ __all__ = [
454
+ "CLOCK_TOLERANCE_SEC", "CLOCK_FUTURE_LIMIT_SEC", "check_clock",
455
+ "NormalizedObservation", "NormalizeResult", "normalize_observations",
456
+ "validate_value", "sanitize_value", "resolve_timezone", "effective_date",
457
+ "identity_for", "AttributionError",
458
+ ]