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,137 @@
1
+ """感知事件 —— kit 交给宿主的**唯一**产物。
2
+
3
+ 规则可以随便加、随便改,但这个信封必须稳。宿主接的是信封,不是规则 ——
4
+ 信封稳定,用户新配十条规则,宿主一行代码都不用动。
5
+
6
+ 事件只表达四件事:
7
+
8
+ 发生了什么 type / signal / field
9
+ 哪条规则命中 definition_id + definition_version
10
+ 新旧事实是什么 previous / current
11
+ 为什么符合条件 condition
12
+
13
+ 事件**不**表达:agent 该说什么、要不要给用户发通知、用哪个模型、
14
+ 走宿主的哪条 runtime。那些是宿主的决定 —— **戳醒不等于该开口**。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from typing import Any, Mapping
21
+
22
+ from . import versioning
23
+ from ._time import to_iso
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class EventCondition:
28
+ """命中时的判据快照 —— 让宿主(和事后排查的人)看得懂为什么触发。
29
+
30
+ 存快照而不是存规则的引用:规则会被改、被删,而事件是**已经发生的事实**,
31
+ 不该因为规则后来变了就解释不通。
32
+ """
33
+
34
+ type: str
35
+ operator: str | None = None
36
+ value: Any = None
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ out: dict[str, Any] = {"type": self.type}
40
+ if self.operator is not None:
41
+ out["operator"] = self.operator
42
+ if self.value is not None:
43
+ out["value"] = self.value
44
+ return out
45
+
46
+
47
+ #: 事件 ``context`` 允许出现的键。**白名单,不是建议** —— 事件会被存下来、
48
+ #: 投出去、进模型上下文,任何"顺手多带一点"都会同时撑上下文和漏隐私。
49
+ #: 宿主要更多信息,应该拿 ``signal`` + ``occurred_at`` 自己去查,而不是让
50
+ #: kit 把存储 doc 塞进信封。
51
+ ALLOWED_CONTEXT_KEYS = (
52
+ "scope",
53
+ # 触发字段的单位。产品规范 §13 的信封示例里就带着它,而且它是必要的:
54
+ # 一个只说 "current: 3012" 的事件,读到的人(和模型)分不出这是步数、
55
+ # 毫升还是分钟 —— manifest 花那么大力气要求数值必须有单位,
56
+ # 到了事件这一层丢掉,等于前功尽弃。
57
+ "unit",
58
+ "reason",
59
+ "streak_length",
60
+ "silent_seconds",
61
+ )
62
+
63
+ #: 单个 context 值序列化后的字符数上限。主要防的是 ``reason`` ——
64
+ #: 它来自 evaluator,而宿主可以注册自己的 evaluator,返回什么完全不受我们控制。
65
+ MAX_CONTEXT_VALUE_CHARS = 200
66
+
67
+
68
+ def safe_context(raw: Mapping[str, Any] | None) -> dict[str, Any]:
69
+ """把任意来源的 context 收敛成信封允许的形状。
70
+
71
+ 白名单外的键**丢掉**(不是报错:一条规则多带了个字段,不该让整个事件消失);
72
+ 超长的值截断并留一个显式的省略号,让读到的人知道这里被截过。
73
+ """
74
+ out: dict[str, Any] = {}
75
+ for key in ALLOWED_CONTEXT_KEYS:
76
+ if raw is None or key not in raw:
77
+ continue
78
+ value = raw[key]
79
+ # 值是 None 的键**整个省掉**,不写成 `"unit": null`。
80
+ # 布尔和枚举本来就没有单位,给它编一个空位比不给更糟 ——
81
+ # 读到的人会以为"这里本该有个单位,只是没填"。
82
+ # 和信封里 condition 省略 operator 是同一条约定。
83
+ if value is None:
84
+ continue
85
+ if isinstance(value, str) and len(value) > MAX_CONTEXT_VALUE_CHARS:
86
+ value = value[:MAX_CONTEXT_VALUE_CHARS] + "…(截断)"
87
+ out[key] = value
88
+ return out
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class PerceptionEvent:
93
+ """一次规则命中产生的不可变事实。"""
94
+
95
+ event_id: str
96
+ definition_id: str
97
+ definition_version: int
98
+ subject_id: str
99
+ type: str
100
+ signal: str
101
+ occurred_at: datetime
102
+ received_at: datetime
103
+ condition: EventCondition
104
+ #: 触发这条规则的字段。``occurrence`` 型规则(整条信号的到达本身就是事件)
105
+ #: 没有具体字段,为 ``None``。
106
+ field_name: str | None = None
107
+ #: 变化前后的值。``occurrence`` 型两者都是 ``None``。
108
+ previous: Any = None
109
+ current: Any = None
110
+ #: 受控的附加事实。键限定在 ``ALLOWED_CONTEXT_KEYS``、值有长度上限,
111
+ #: 都由 ``safe_context`` 收敛 —— 不许把整个存储 doc 透传进来,
112
+ #: 那既撑爆上下文,也漏隐私。
113
+ context: dict[str, Any] = field(default_factory=dict)
114
+ schema_version: int = versioning.EVENT_SCHEMA_VERSION
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ """序列化成宿主可以直接投递的形状。"""
118
+ return {
119
+ "event_id": self.event_id,
120
+ "definition_id": self.definition_id,
121
+ "definition_version": self.definition_version,
122
+ "subject_id": self.subject_id,
123
+ "type": self.type,
124
+ "signal": self.signal,
125
+ "field": self.field_name,
126
+ "occurred_at": to_iso(self.occurred_at),
127
+ "received_at": to_iso(self.received_at),
128
+ "previous": self.previous,
129
+ "current": self.current,
130
+ "condition": self.condition.to_dict(),
131
+ "context": dict(self.context),
132
+ "schema_version": self.schema_version,
133
+ }
134
+
135
+
136
+ __all__ = ["ALLOWED_CONTEXT_KEYS", "MAX_CONTEXT_VALUE_CHARS",
137
+ "EventCondition", "PerceptionEvent", "safe_context"]
@@ -0,0 +1,172 @@
1
+ """标准观测 —— 上报数据经过校验和标准化之后的样子。
2
+
3
+ 这是整套系统里**唯一**的事实载体:current 是它的投影,日聚合是它的派生,
4
+ 事件是对它的判断。三者都可以从观测重算,反过来不行 —— 聚合是压缩过的,
5
+ 压缩不可逆。所以"只存 current 和日聚合、不存观测"会让修订、删除、
6
+ 算法升级后的重算**全部做不了**。
7
+
8
+ 时区为什么单独一个字段,而不是从 ``occurred_at`` 的偏移推:
9
+ 偏移只够算"这条算哪一天",不够处理夏令时。``+08:00`` 能定位到日期,
10
+ 但纽约的 ``-04:00`` 和 ``-05:00`` 是同一个时区在不同季节 —— 光看偏移
11
+ 分不出来,DST 切换那天(那天有 25 小时)就会算错。所以要 IANA 名字。
12
+
13
+ ``timezone`` 缺失时怎么办属于处理层的兜底策略,不在契约里定死 ——
14
+ 见 ``OPEN-QUESTIONS.md`` B2,这一条还没和产品方对齐。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from typing import Any
21
+
22
+ from . import availability as _availability
23
+ from ._time import TimestampError, parse_timestamp
24
+ from .errors import ContractError
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Observation:
29
+ """一条标准观测(wire 形态 —— producer 发过来的样子)。
30
+
31
+ 存储形态比这个多几个字段(``observation_id`` / ``subject_id`` /
32
+ ``received_at`` / ``effective_local_date``),那些由宿主或处理层补,
33
+ 不由 producer 提供 —— 见 :mod:`perceptkit.contracts.records`。
34
+ """
35
+
36
+ signal: str
37
+ signal_schema_version: int
38
+ occurred_at: datetime
39
+ availability: str
40
+ #: ``observed`` 时必填。``0`` / ``False`` / ``[]`` 都是合法值。
41
+ value: dict[str, Any] | None = None
42
+ #: 上游的稳定身份,用于重传幂等。事件型/样本型必填;
43
+ #: 纯状态型(如电量快照)可以没有,由 manifest 声明确定性 identity 策略。
44
+ source_event_id: str | None = None
45
+ #: 发生时的 IANA 时区名(如 ``Asia/Shanghai``)。见模块开头。
46
+ timezone: str | None = None
47
+ #: 来源侧的版本号。只有支持修订的来源(HealthKit / Calendar)才有。
48
+ source_revision: str | int | None = None
49
+ #: ``unavailable`` 时的粗粒度原因。默认不进 agent 的上下文。
50
+ reason: str | None = None
51
+ #: 协议没定义的额外字段。原样保留便于排查,不参与判断。
52
+ extensions: dict[str, Any] = field(default_factory=dict)
53
+
54
+ # -- 派生判断(都只读 availability,集中在这里免得每个调用方各判一次) --
55
+
56
+ @property
57
+ def is_observed(self) -> bool:
58
+ return _availability.normalize(self.availability) == _availability.OBSERVED
59
+
60
+ @property
61
+ def updates_current(self) -> bool:
62
+ """这条该不该更新 current 的数值。"""
63
+ return _availability.updates_current(self.availability)
64
+
65
+ @property
66
+ def enters_trend(self) -> bool:
67
+ """这条该不该进趋势和 streak。``no_data`` 不当 0 用。"""
68
+ return _availability.enters_trend(self.availability)
69
+
70
+ @classmethod
71
+ def parse(cls, payload: object) -> "Observation":
72
+ """从一个 dict 解析并校验。错误一次报全,不是遇到第一个就抛。"""
73
+ errors: list[str] = []
74
+ if not isinstance(payload, dict):
75
+ raise ContractError(
76
+ [f"observation must be an object, got {type(payload).__name__}"]
77
+ )
78
+
79
+ signal = payload.get("signal")
80
+ if not isinstance(signal, str) or not signal.strip():
81
+ errors.append("signal: required, must be a non-empty string")
82
+
83
+ version = payload.get("signal_schema_version")
84
+ if isinstance(version, bool) or not isinstance(version, int):
85
+ errors.append("signal_schema_version: required, must be an integer")
86
+
87
+ occurred_at: datetime | None = None
88
+ try:
89
+ occurred_at = parse_timestamp(payload.get("occurred_at"), field="occurred_at")
90
+ except TimestampError as exc:
91
+ errors.append(str(exc))
92
+
93
+ # 产品规范 §7.1 的最小契约把这个字段叫 `state`,把身份字段叫
94
+ # `sample_id`。我们内部用更长的名字(`state` 这个词在别处已经被
95
+ # 用作"规则状态",`sample_id` 又太偏健康样本),但**两种写法都收** ——
96
+ # 照着规范文档实现的 producer 必须能直接接上,否则它的每一条观测
97
+ # 都会以"availability 缺失"被拒,而实现者手里那份文档写的就是 `state`。
98
+ raw_state = payload.get("availability", payload.get("state"))
99
+ if not isinstance(raw_state, str):
100
+ errors.append("availability: required, must be a string")
101
+ state = _availability.UNAVAILABLE
102
+ elif raw_state not in _availability.LEGACY_STATES:
103
+ errors.append(
104
+ f"availability: {raw_state!r} is not one of "
105
+ f"{sorted(_availability.AVAILABILITY_STATES)}"
106
+ )
107
+ state = _availability.UNAVAILABLE
108
+ else:
109
+ state = _availability.normalize(raw_state)
110
+
111
+ value = payload.get("value")
112
+ if state == _availability.OBSERVED:
113
+ # observed 就必须有值。注意 `{}` 是合法的(某些信号的 payload 本来就空),
114
+ # 但 None 不是 —— 那说明 producer 自己也不知道观测到了什么。
115
+ if value is None:
116
+ errors.append("value: required when availability is 'observed'")
117
+ elif not isinstance(value, dict):
118
+ errors.append(
119
+ f"value: must be an object, got {type(value).__name__}"
120
+ )
121
+ elif value is not None and not isinstance(value, dict):
122
+ errors.append(f"value: must be an object when present, got {type(value).__name__}")
123
+
124
+ source_event_id = payload.get("source_event_id",
125
+ payload.get("sample_id"))
126
+ if source_event_id is not None and (
127
+ not isinstance(source_event_id, str) or not source_event_id.strip()
128
+ ):
129
+ errors.append("source_event_id: must be a non-empty string when present")
130
+
131
+ tz = payload.get("timezone")
132
+ if tz is not None and (not isinstance(tz, str) or not tz.strip()):
133
+ errors.append("timezone: must be a non-empty IANA name when present")
134
+
135
+ revision = payload.get("source_revision")
136
+ if revision is not None and not isinstance(revision, (str, int)):
137
+ errors.append("source_revision: must be a string or integer when present")
138
+
139
+ reason = payload.get("reason")
140
+ if reason is not None:
141
+ if not isinstance(reason, str):
142
+ errors.append("reason: must be a string when present")
143
+ elif reason not in _availability.UNAVAILABLE_REASONS:
144
+ # 不拒收 —— 只是提醒用粗粒度的那几个。穷举操作系统的每种权限
145
+ # 状态属于 adapter 的诊断,不该硬塞进公共协议。
146
+ errors.append(
147
+ f"reason: {reason!r} is not one of "
148
+ f"{sorted(_availability.UNAVAILABLE_REASONS)}"
149
+ )
150
+
151
+ if errors:
152
+ raise ContractError(errors)
153
+
154
+ known = {
155
+ "signal", "signal_schema_version", "occurred_at", "availability",
156
+ "value", "source_event_id", "timezone", "source_revision", "reason",
157
+ }
158
+ return cls(
159
+ signal=signal.strip(), # type: ignore[union-attr]
160
+ signal_schema_version=version, # type: ignore[arg-type]
161
+ occurred_at=occurred_at, # type: ignore[arg-type]
162
+ availability=state,
163
+ value=value,
164
+ source_event_id=source_event_id.strip() if source_event_id else None,
165
+ timezone=tz.strip() if tz else None,
166
+ source_revision=revision,
167
+ reason=reason,
168
+ extensions={k: v for k, v in payload.items() if k not in known},
169
+ )
170
+
171
+
172
+ __all__ = ["Observation"]
@@ -0,0 +1,129 @@
1
+ """两种回执 —— 上报进来的回执,和事件投出去的回执。
2
+
3
+ ``IngestReceipt`` 让同一批上报重传时能返回相同结果,不重复处理。
4
+ ``WakeReceipt`` 让"到底投没投成"变成一个可持久化、可查询的事实,
5
+ 而不是一个只存在于某次函数调用里的瞬时值。
6
+
7
+ **为什么 wake 的状态不能只有成功/失败两种**:runtime 拒绝一次唤醒有好几种
8
+ 完全不同的原因,处理方式也不同 —— 会话里正忙(等下次就好)和入队失败
9
+ (得重试)混成一个"失败",要么该重试的没重试,要么不该重试的一直重试。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+
16
+ from ._time import parse_timestamp
17
+ from .errors import ContractError
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # 上报回执
21
+ # ---------------------------------------------------------------------------
22
+
23
+ #: 这批上报第一次被处理。
24
+ INGEST_ACCEPTED = "accepted"
25
+ #: 同一个 (subject, producer, report_id) + 相同内容 —— 返回原结果,不重复处理。
26
+ INGEST_DUPLICATE = "duplicate"
27
+ #: 同一个 identity 但内容不同。**必须报冲突,不能静默覆盖** ——
28
+ #: 静默覆盖会让"到底哪份数据生效了"永远说不清。
29
+ INGEST_CONFLICT = "conflict"
30
+ #: 校验没过。``error_code`` 说明原因。
31
+ INGEST_REJECTED = "rejected"
32
+
33
+ INGEST_STATUSES: frozenset[str] = frozenset({
34
+ INGEST_ACCEPTED, INGEST_DUPLICATE, INGEST_CONFLICT, INGEST_REJECTED,
35
+ })
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class IngestReceipt:
40
+ """一批上报的处理结果。唯一身份是 ``(subject_id, producer, report_id)``。"""
41
+
42
+ subject_id: str
43
+ producer: str
44
+ report_id: str
45
+ #: 信封内容的摘要。同 identity 同摘要 → duplicate;同 identity 不同摘要 → conflict。
46
+ payload_digest: str
47
+ received_at: datetime
48
+ status: str
49
+ error_code: str | None = None
50
+ #: 这批里有几条观测被真正处理了(duplicate 时为 0)。
51
+ observations_applied: int = 0
52
+
53
+ def __post_init__(self) -> None:
54
+ if self.status not in INGEST_STATUSES:
55
+ raise ContractError(
56
+ [f"status: {self.status!r} is not one of {sorted(INGEST_STATUSES)}"]
57
+ )
58
+ parse_timestamp(self.received_at, field="received_at")
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # 唤醒回执
63
+ # ---------------------------------------------------------------------------
64
+
65
+ #: runtime 收下了这次唤醒。**只有这个状态才允许正式扣冷却/额度。**
66
+ WAKE_ACCEPTED = "accepted"
67
+ #: runtime 认得这个 ``event_id``,之前已经处理过。重试撞上它是正常的,不是错误。
68
+ WAKE_DUPLICATE = "duplicate"
69
+ #: runtime 现在不想被打扰(会话正忙、安静时段…)。**这不是失败** ——
70
+ #: 事件已经送到了,是 runtime 自己决定不响应。不该重试。
71
+ WAKE_SUPPRESSED = "conversation_suppressed"
72
+ #: 投递本身失败了(队列写不进、连接断)。**该重试。**
73
+ WAKE_ENQUEUE_FAILED = "enqueue_failed"
74
+ #: runtime 明确拒绝(事件类型不认、subject 不属于它…)。不该重试。
75
+ WAKE_REJECTED = "rejected"
76
+
77
+ WAKE_STATUSES: frozenset[str] = frozenset({
78
+ WAKE_ACCEPTED, WAKE_DUPLICATE, WAKE_SUPPRESSED,
79
+ WAKE_ENQUEUE_FAILED, WAKE_REJECTED,
80
+ })
81
+
82
+ #: 这几种状态该重试。其余的是终态。
83
+ #: 注意 ``conversation_suppressed`` **不在**里面 —— 事件已经送达,
84
+ #: 是 runtime 自己选择不响应,重投只会打扰第二次。
85
+ WAKE_RETRYABLE: frozenset[str] = frozenset({WAKE_ENQUEUE_FAILED})
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class WakeReceipt:
90
+ """runtime 对一次投递的应答。"""
91
+
92
+ event_id: str
93
+ #: 这是第几次尝试。``event_id`` 跨重试**保持不变**(runtime 靠它幂等),
94
+ #: ``attempt_id`` 每次都变(用来对上具体是哪一次投递的回执)。
95
+ attempt_id: str
96
+ status: str
97
+ received_at: datetime
98
+ #: 宿主自己的引用(job id、任务号…)。kit 不解释它的内容,原样存。
99
+ runtime_ref: str | None = None
100
+ reason: str | None = None
101
+
102
+ def __post_init__(self) -> None:
103
+ if self.status not in WAKE_STATUSES:
104
+ raise ContractError(
105
+ [f"status: {self.status!r} is not one of {sorted(WAKE_STATUSES)}"]
106
+ )
107
+ parse_timestamp(self.received_at, field="received_at")
108
+
109
+ @property
110
+ def should_retry(self) -> bool:
111
+ return self.status in WAKE_RETRYABLE
112
+
113
+ @property
114
+ def consumes_budget(self) -> bool:
115
+ """这次投递该不该正式扣冷却/额度。
116
+
117
+ **只有 accepted 算。** 被压制、入队失败、被拒绝都不该扣 —— 用户
118
+ 那一轮该说的话没说出去,额度却被吃掉了,是最难查的一类问题。
119
+ """
120
+ return self.status == WAKE_ACCEPTED
121
+
122
+
123
+ __all__ = [
124
+ "INGEST_ACCEPTED", "INGEST_DUPLICATE", "INGEST_CONFLICT", "INGEST_REJECTED",
125
+ "INGEST_STATUSES", "IngestReceipt",
126
+ "WAKE_ACCEPTED", "WAKE_DUPLICATE", "WAKE_SUPPRESSED",
127
+ "WAKE_ENQUEUE_FAILED", "WAKE_REJECTED", "WAKE_STATUSES", "WAKE_RETRYABLE",
128
+ "WakeReceipt",
129
+ ]