loopiter 0.2.0a1__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.
- loopiter/__init__.py +16 -0
- loopiter/_validation.py +335 -0
- loopiter/analysis.py +261 -0
- loopiter/client.py +637 -0
- loopiter/migrations/001-python-store.sql +25 -0
- loopiter/postgres.py +161 -0
- loopiter/py.typed +0 -0
- loopiter/store.py +143 -0
- loopiter/testing.py +95 -0
- loopiter-0.2.0a1.dist-info/METADATA +295 -0
- loopiter-0.2.0a1.dist-info/RECORD +13 -0
- loopiter-0.2.0a1.dist-info/WHEEL +4 -0
- loopiter-0.2.0a1.dist-info/licenses/LICENSE +21 -0
loopiter/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Loopiter Python: zero runtime dependencies, no telemetry, no automatic deployment."""
|
|
2
|
+
|
|
3
|
+
from ._validation import LoopiterError, fingerprint
|
|
4
|
+
from .client import DeploymentAdapter, FeedbackLoop
|
|
5
|
+
from .store import FeedbackStore, InMemoryStore, StoreTransaction
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0a1"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DeploymentAdapter",
|
|
10
|
+
"FeedbackLoop",
|
|
11
|
+
"FeedbackStore",
|
|
12
|
+
"InMemoryStore",
|
|
13
|
+
"LoopiterError",
|
|
14
|
+
"StoreTransaction",
|
|
15
|
+
"fingerprint",
|
|
16
|
+
]
|
loopiter/_validation.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Strict JSON and lifecycle validation. Not a wire-format compatibility layer."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
Record = dict[str, Any]
|
|
11
|
+
EXECUTION_KINDS = "agent turn inference prediction tool workflow retrieval custom".split()
|
|
12
|
+
SIGNAL_KINDS = "rating correction outcome reward approval tool_result custom".split()
|
|
13
|
+
TARGET_KINDS = (
|
|
14
|
+
"prompt routing rule retrieval model dataset threshold tool_schema workflow "
|
|
15
|
+
"agent_topology code capacity custom"
|
|
16
|
+
).split()
|
|
17
|
+
RISKS = "low medium high critical".split()
|
|
18
|
+
STATUSES = "proposed evaluated approved deployed rejected superseded rolled_back historical".split()
|
|
19
|
+
COLLECTIONS = "executions signals candidates targets attempts events".split()
|
|
20
|
+
EXECUTION_FIELDS = (
|
|
21
|
+
"id kind episode_id parent_execution_id entity_id input output artifacts metadata "
|
|
22
|
+
"started_at completed_at"
|
|
23
|
+
).split()
|
|
24
|
+
SIGNAL_FIELDS = (
|
|
25
|
+
"id execution_id episode_id kind name value correction source confidence metadata observed_at"
|
|
26
|
+
).split()
|
|
27
|
+
CANDIDATE_FIELDS = "id target proposed_change evidence risk metadata".split()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoopiterError(Exception):
|
|
31
|
+
def __init__(self, code: str, message: str, *, attempt_id: str | None = None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.code = code
|
|
34
|
+
self.attempt_id = attempt_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fail(code: str, message: str) -> None:
|
|
38
|
+
raise LoopiterError(code, message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def nonempty(value: Any, name: str) -> None:
|
|
42
|
+
if type(value) is not str or not value.strip() or "\0" in value:
|
|
43
|
+
fail("invalid_input", f"{name} must be nonempty text without NUL.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def enum(value: Any, allowed: list[str], name: str) -> None:
|
|
47
|
+
if type(value) is not str or value not in allowed:
|
|
48
|
+
fail("invalid_input", f"Invalid {name}.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def finite(value: Any, name: str, minimum: float = -math.inf) -> None:
|
|
52
|
+
if type(value) not in (int, float):
|
|
53
|
+
fail("invalid_input", f"{name} must be a finite number.")
|
|
54
|
+
try:
|
|
55
|
+
valid = math.isfinite(value) and value >= minimum
|
|
56
|
+
except OverflowError:
|
|
57
|
+
valid = False
|
|
58
|
+
if not valid:
|
|
59
|
+
fail("invalid_input", f"Invalid {name}.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def integer(value: Any, name: str, minimum: int = 1) -> None:
|
|
63
|
+
if type(value) is not int or not minimum <= value <= 2**53 - 1:
|
|
64
|
+
fail("invalid_input", f"{name} must be an integer >= {minimum}.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def obj(value: Any, name: str = "input") -> None:
|
|
68
|
+
if type(value) is not dict:
|
|
69
|
+
fail("invalid_input", f"{name} must be a plain dict.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fields(value: Any, allowed: list[str], required: tuple[str, ...] = ()) -> None:
|
|
73
|
+
obj(value)
|
|
74
|
+
if set(value) - set(allowed) or set(required) - set(value):
|
|
75
|
+
fail("invalid_input", "Unknown or missing required fields.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def timestamp(value: Any, name: str = "timestamp") -> datetime:
|
|
79
|
+
if type(value) is not str or not re.fullmatch(
|
|
80
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z", value
|
|
81
|
+
):
|
|
82
|
+
fail("invalid_input", f"{name} must be an ISO UTC timestamp ending in Z.")
|
|
83
|
+
try:
|
|
84
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
85
|
+
except ValueError:
|
|
86
|
+
fail("invalid_input", f"Invalid calendar date in {name}.")
|
|
87
|
+
raise AssertionError("unreachable")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def now() -> str:
|
|
91
|
+
return datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def window_valid(window: Any) -> None:
|
|
95
|
+
fields(window, ["from", "to"])
|
|
96
|
+
for key, value in window.items():
|
|
97
|
+
timestamp(value, key)
|
|
98
|
+
if "from" in window and "to" in window and timestamp(window["from"]) >= timestamp(window["to"]):
|
|
99
|
+
fail("invalid_input", "Time window must be increasing (from inclusive, to exclusive).")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def in_window(value: str, window: Record) -> bool:
|
|
103
|
+
t = timestamp(value)
|
|
104
|
+
return ("from" not in window or t >= timestamp(window["from"])) and (
|
|
105
|
+
"to" not in window or t < timestamp(window["to"])
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def json_value(value: Any, maximum: int = 262144) -> None:
|
|
110
|
+
seen: set[int] = set()
|
|
111
|
+
|
|
112
|
+
def visit(v: Any, depth: int) -> None:
|
|
113
|
+
if depth > 32:
|
|
114
|
+
fail("invalid_input", "JSON nesting exceeds 32 levels.")
|
|
115
|
+
if type(v) is str:
|
|
116
|
+
if "\0" in v:
|
|
117
|
+
fail("invalid_input", "NUL is not supported in JSON strings.")
|
|
118
|
+
try:
|
|
119
|
+
v.encode("utf-8")
|
|
120
|
+
except UnicodeEncodeError:
|
|
121
|
+
fail("invalid_input", "JSON strings must be valid Unicode without lone surrogates.")
|
|
122
|
+
return
|
|
123
|
+
if v is None or type(v) is bool:
|
|
124
|
+
return
|
|
125
|
+
if type(v) in (int, float):
|
|
126
|
+
finite(v, "JSON number")
|
|
127
|
+
return
|
|
128
|
+
if type(v) not in (list, dict) or id(v) in seen:
|
|
129
|
+
fail("invalid_input", "Expected acyclic plain JSON values.")
|
|
130
|
+
if type(v) is dict and any(type(k) is not str for k in v):
|
|
131
|
+
fail("invalid_input", "JSON keys must be strings.")
|
|
132
|
+
seen.add(id(v))
|
|
133
|
+
if type(v) is dict:
|
|
134
|
+
for key in v:
|
|
135
|
+
visit(key, depth + 1)
|
|
136
|
+
for item in v.values() if type(v) is dict else v:
|
|
137
|
+
visit(item, depth + 1)
|
|
138
|
+
seen.remove(id(v))
|
|
139
|
+
|
|
140
|
+
visit(value, 0)
|
|
141
|
+
if len(canonical(value).encode("utf-8")) > maximum:
|
|
142
|
+
fail("payload_limit", f"Payload exceeds {maximum} bytes.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def canonical(value: Any) -> str:
|
|
146
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fingerprint(value: Any) -> str:
|
|
150
|
+
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def target(value: Any) -> None:
|
|
154
|
+
fields(value, ["kind", "key"], ("kind", "key"))
|
|
155
|
+
enum(value["kind"], TARGET_KINDS, "target kind")
|
|
156
|
+
nonempty(value["key"], "target key")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def execution_input(value: Any) -> None:
|
|
160
|
+
fields(value, EXECUTION_FIELDS, ("kind",))
|
|
161
|
+
enum(value["kind"], EXECUTION_KINDS, "execution kind")
|
|
162
|
+
for key in ("id", "episode_id", "parent_execution_id", "entity_id"):
|
|
163
|
+
if key in value:
|
|
164
|
+
nonempty(value[key], key)
|
|
165
|
+
for key in ("started_at", "completed_at"):
|
|
166
|
+
if key in value:
|
|
167
|
+
timestamp(value[key], key)
|
|
168
|
+
for key in ("metadata", "artifacts"):
|
|
169
|
+
if key in value:
|
|
170
|
+
obj(value[key], key)
|
|
171
|
+
if "completed_at" in value and "started_at" in value:
|
|
172
|
+
if timestamp(value["completed_at"]) < timestamp(value["started_at"]):
|
|
173
|
+
fail("invalid_input", "Completion predates execution.")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def signal_input(value: Any) -> None:
|
|
177
|
+
fields(value, SIGNAL_FIELDS, ("kind", "name", "source", "value"))
|
|
178
|
+
enum(value["kind"], SIGNAL_KINDS, "signal kind")
|
|
179
|
+
for key in ("name", "source", "id", "episode_id", "execution_id"):
|
|
180
|
+
if key in value:
|
|
181
|
+
nonempty(value[key], key)
|
|
182
|
+
if not (value.get("execution_id") or value.get("episode_id")):
|
|
183
|
+
fail("invalid_input", "Signal needs execution_id or episode_id.")
|
|
184
|
+
if "confidence" in value:
|
|
185
|
+
finite(value["confidence"], "confidence", 0)
|
|
186
|
+
if value["confidence"] > 1:
|
|
187
|
+
fail("invalid_input", "Confidence exceeds 1.")
|
|
188
|
+
if "observed_at" in value:
|
|
189
|
+
timestamp(value["observed_at"])
|
|
190
|
+
if "metadata" in value:
|
|
191
|
+
obj(value["metadata"], "metadata")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def candidate_input(value: Any) -> None:
|
|
195
|
+
fields(value, CANDIDATE_FIELDS, ("target", "proposed_change", "evidence"))
|
|
196
|
+
target(value["target"])
|
|
197
|
+
if "id" in value:
|
|
198
|
+
nonempty(value["id"], "id")
|
|
199
|
+
if "risk" in value:
|
|
200
|
+
enum(value["risk"], RISKS, "risk")
|
|
201
|
+
if "metadata" in value:
|
|
202
|
+
obj(value["metadata"], "metadata")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def evaluation(value: Any) -> None:
|
|
206
|
+
fields(value, ["passed", "metrics", "notes"], ("passed",))
|
|
207
|
+
json_value(value)
|
|
208
|
+
if type(value["passed"]) is not bool:
|
|
209
|
+
fail("invalid_input", "passed must be a boolean.")
|
|
210
|
+
if "metrics" in value:
|
|
211
|
+
obj(value["metrics"], "metrics")
|
|
212
|
+
for key, number in value["metrics"].items():
|
|
213
|
+
nonempty(key, "metric name")
|
|
214
|
+
finite(number, key)
|
|
215
|
+
if "notes" in value and type(value["notes"]) is not str:
|
|
216
|
+
fail("invalid_input", "notes must be text.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def receipt(value: Any) -> None:
|
|
220
|
+
fields(
|
|
221
|
+
value,
|
|
222
|
+
["attempt_id", "artifact_version", "previous_artifact_version", "metadata"],
|
|
223
|
+
("attempt_id", "artifact_version", "previous_artifact_version"),
|
|
224
|
+
)
|
|
225
|
+
nonempty(value["attempt_id"], "attempt_id")
|
|
226
|
+
for key in ("artifact_version", "previous_artifact_version"):
|
|
227
|
+
if value[key] is not None:
|
|
228
|
+
nonempty(value[key], key)
|
|
229
|
+
if "metadata" in value:
|
|
230
|
+
obj(value["metadata"], "metadata")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def content_hash(row: Record) -> str:
|
|
234
|
+
return fingerprint({k: row[k] for k in ("target", "proposed_change", "risk", "metadata")})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def stored(kind: str, row: Any, namespace: str) -> None:
|
|
238
|
+
enum(kind, COLLECTIONS, "collection")
|
|
239
|
+
obj(row, "adapter record")
|
|
240
|
+
json_value(row, 16 * 1024 * 1024)
|
|
241
|
+
nonempty(row.get("id"), "record ID")
|
|
242
|
+
integer(row.get("revision"), "revision")
|
|
243
|
+
if row.get("namespace") != namespace:
|
|
244
|
+
fail("namespace_mismatch", "Adapter returned another namespace.")
|
|
245
|
+
if timestamp(row.get("updated_at")) < timestamp(row.get("created_at")):
|
|
246
|
+
fail("integrity_error", "Update predates creation.")
|
|
247
|
+
if kind in ("executions", "signals", "candidates"):
|
|
248
|
+
allowed, validator = {
|
|
249
|
+
"executions": (EXECUTION_FIELDS, execution_input),
|
|
250
|
+
"signals": (SIGNAL_FIELDS, signal_input),
|
|
251
|
+
"candidates": (CANDIDATE_FIELDS, candidate_input),
|
|
252
|
+
}[kind]
|
|
253
|
+
validator({k: row[k] for k in allowed if k in row})
|
|
254
|
+
nonempty(row.get("input_hash"), "input_hash")
|
|
255
|
+
obj(row.get("metadata"), "metadata")
|
|
256
|
+
if kind == "executions":
|
|
257
|
+
timestamp(row.get("started_at"))
|
|
258
|
+
obj(row.get("artifacts"), "artifacts")
|
|
259
|
+
if kind == "signals":
|
|
260
|
+
timestamp(row.get("observed_at"))
|
|
261
|
+
finite(row.get("confidence"), "confidence", 0)
|
|
262
|
+
if kind == "candidates":
|
|
263
|
+
enum(row.get("status"), STATUSES, "candidate status")
|
|
264
|
+
enum(row.get("risk"), RISKS, "risk")
|
|
265
|
+
if row.get("content_hash") != content_hash(row) or row.get("evidence_hash") != fingerprint(
|
|
266
|
+
row["evidence"]
|
|
267
|
+
):
|
|
268
|
+
fail("integrity_error", "Candidate content/evidence hash mismatch.")
|
|
269
|
+
if type(row.get("evaluations")) is not list:
|
|
270
|
+
fail("integrity_error", "Invalid evaluations.")
|
|
271
|
+
for e in row["evaluations"]:
|
|
272
|
+
obj(e, "evaluation")
|
|
273
|
+
evaluation({k: e[k] for k in ("passed", "metrics", "notes") if k in e})
|
|
274
|
+
for key in ("id", "evaluator", "version", "dataset_hash"):
|
|
275
|
+
nonempty(e.get(key), key)
|
|
276
|
+
timestamp(e.get("created_at"))
|
|
277
|
+
if (
|
|
278
|
+
e.get("candidate_hash") != row["content_hash"]
|
|
279
|
+
or e.get("evidence_hash") != row["evidence_hash"]
|
|
280
|
+
):
|
|
281
|
+
fail("integrity_error", "Evaluation bound to different content.")
|
|
282
|
+
if "approval" in row:
|
|
283
|
+
a = row["approval"]
|
|
284
|
+
obj(a, "approval")
|
|
285
|
+
nonempty(a.get("actor"), "actor")
|
|
286
|
+
timestamp(a.get("approved_at"))
|
|
287
|
+
e = row["evaluations"][-1] if row["evaluations"] else {}
|
|
288
|
+
if (
|
|
289
|
+
not e.get("passed")
|
|
290
|
+
or e.get("id") != a.get("evaluation_id")
|
|
291
|
+
or a.get("candidate_hash") != row["content_hash"]
|
|
292
|
+
):
|
|
293
|
+
fail("integrity_error", "Approval does not match latest passing evaluation.")
|
|
294
|
+
if (
|
|
295
|
+
row["status"] in ("approved", "deployed", "superseded", "rolled_back")
|
|
296
|
+
and "approval" not in row
|
|
297
|
+
):
|
|
298
|
+
fail("integrity_error", "Lifecycle record missing approval.")
|
|
299
|
+
if "deployment_receipt" in row:
|
|
300
|
+
receipt(row["deployment_receipt"])
|
|
301
|
+
if kind in ("targets", "attempts"):
|
|
302
|
+
target(row.get("target"))
|
|
303
|
+
if kind == "targets" and row["id"] != fingerprint(row["target"]):
|
|
304
|
+
fail("integrity_error", "Target identity mismatch.")
|
|
305
|
+
for key in (
|
|
306
|
+
"active_candidate_id",
|
|
307
|
+
"pending_attempt_id",
|
|
308
|
+
"restore_candidate_id",
|
|
309
|
+
"previous_candidate_id",
|
|
310
|
+
):
|
|
311
|
+
if key in row:
|
|
312
|
+
nonempty(row[key], key)
|
|
313
|
+
if kind == "targets" and row.get("artifact_version") is not None:
|
|
314
|
+
nonempty(row["artifact_version"], "artifact_version")
|
|
315
|
+
if kind == "attempts":
|
|
316
|
+
nonempty(row.get("candidate_id"), "candidate_id")
|
|
317
|
+
enum(row.get("operation"), ["apply", "rollback"], "operation")
|
|
318
|
+
enum(row.get("status"), ["pending", "succeeded", "not_applied"], "attempt status")
|
|
319
|
+
for key in ["expected_artifact_version"] + (
|
|
320
|
+
["restore_artifact_version"] if row["operation"] == "rollback" else []
|
|
321
|
+
):
|
|
322
|
+
if key not in row:
|
|
323
|
+
fail("integrity_error", "Missing artifact version.")
|
|
324
|
+
if row[key] is not None:
|
|
325
|
+
nonempty(row[key], key)
|
|
326
|
+
if "receipt" in row:
|
|
327
|
+
receipt(row["receipt"])
|
|
328
|
+
if row["receipt"]["attempt_id"] != row["id"]:
|
|
329
|
+
fail("integrity_error", "Attempt receipt mismatch.")
|
|
330
|
+
if row["status"] == "succeeded" and "receipt" not in row:
|
|
331
|
+
fail("integrity_error", "Succeeded attempt has no receipt.")
|
|
332
|
+
if kind == "events":
|
|
333
|
+
nonempty(row.get("type"), "event type")
|
|
334
|
+
nonempty(row.get("subject_id"), "subject_id")
|
|
335
|
+
obj(row.get("details"), "event details")
|
loopiter/analysis.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Descriptive structured segments, not causal inference or a probability of improvement."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from itertools import combinations
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from . import _validation as v
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import FeedbackLoop
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_signal_score(signal: v.Record) -> float | None:
|
|
16
|
+
value = signal["value"]
|
|
17
|
+
if type(value) is bool:
|
|
18
|
+
return float(value)
|
|
19
|
+
if type(value) in (int, float):
|
|
20
|
+
return value
|
|
21
|
+
if type(value) is str:
|
|
22
|
+
if value.lower() in ("yes", "good", "correct", "positive", "success"):
|
|
23
|
+
return 1
|
|
24
|
+
if value.lower() in ("no", "bad", "incorrect", "negative", "failure"):
|
|
25
|
+
return 0
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _read_all(loop, tx, kind, maximum, window):
|
|
30
|
+
rows, cursor = [], None
|
|
31
|
+
while True:
|
|
32
|
+
limit = min(1000, maximum + 1 - len(rows))
|
|
33
|
+
page = loop._page(
|
|
34
|
+
kind, await tx.list(kind, limit=limit, cursor=cursor, window=window), limit, cursor
|
|
35
|
+
)
|
|
36
|
+
for row in page["items"]:
|
|
37
|
+
when = row["observed_at"] if kind == "signals" else row["started_at"]
|
|
38
|
+
if not v.in_window(when, window):
|
|
39
|
+
v.fail("integrity_error", "Adapter ignored the query window.")
|
|
40
|
+
rows.extend(page["items"])
|
|
41
|
+
if len(rows) > maximum:
|
|
42
|
+
v.fail(
|
|
43
|
+
"query_limit",
|
|
44
|
+
f"Analysis exceeds {maximum} {kind}; narrow the window or increase maximum_records.",
|
|
45
|
+
)
|
|
46
|
+
cursor = page.get("next_cursor")
|
|
47
|
+
if cursor is None:
|
|
48
|
+
return rows
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _summary(units):
|
|
52
|
+
total, weight = 0.0, 0.0
|
|
53
|
+
for values in units.values():
|
|
54
|
+
w = sum(item[0]["confidence"] for item in values.values())
|
|
55
|
+
weighted = sum(item[0]["confidence"] * item[1] for item in values.values())
|
|
56
|
+
unit_weight = w / len(values)
|
|
57
|
+
total += weighted / w * unit_weight
|
|
58
|
+
weight += unit_weight
|
|
59
|
+
if not weight:
|
|
60
|
+
return None
|
|
61
|
+
mean = total / weight
|
|
62
|
+
v.finite(mean, "aggregated mean")
|
|
63
|
+
v.finite(weight, "effective weight")
|
|
64
|
+
return {"mean": mean, "weight": weight, "count": len(units)}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bucket(time, size):
|
|
68
|
+
if size == "day":
|
|
69
|
+
return time[:10]
|
|
70
|
+
if size == "month":
|
|
71
|
+
return time[:7]
|
|
72
|
+
day = v.timestamp(time)
|
|
73
|
+
return (day - timedelta(days=day.weekday())).date().isoformat()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unit(row):
|
|
77
|
+
return "episode:" + row["episode_id"] if row.get("episode_id") else "execution:" + row["id"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def analyze(
|
|
81
|
+
loop: "FeedbackLoop",
|
|
82
|
+
*,
|
|
83
|
+
dimensions: list[str],
|
|
84
|
+
maximum_dimension_depth: int = 2,
|
|
85
|
+
execution_kinds: list[str] | None = None,
|
|
86
|
+
signal_kinds: list[str] | None = None,
|
|
87
|
+
signal_names: list[str] | None = None,
|
|
88
|
+
execution_window: v.Record | None = None,
|
|
89
|
+
observation_window: v.Record | None = None,
|
|
90
|
+
minimum_support: int = 5,
|
|
91
|
+
minimum_scored_count: int = 2,
|
|
92
|
+
minimum_effect_size: float = 0,
|
|
93
|
+
minimum_recurrence: int = 1,
|
|
94
|
+
minimum_distinct_entities: int = 0,
|
|
95
|
+
time_bucket: str = "week",
|
|
96
|
+
include_episode_signals: bool = True,
|
|
97
|
+
maximum_records: int = 20000,
|
|
98
|
+
score=None,
|
|
99
|
+
scoring_version: str = "default-v1",
|
|
100
|
+
) -> list[v.Record]:
|
|
101
|
+
if type(dimensions) is not list or not 1 <= len(dimensions) <= 8:
|
|
102
|
+
v.fail("invalid_input", "Choose 1–8 dimensions.")
|
|
103
|
+
for d in dimensions:
|
|
104
|
+
v.nonempty(d, "dimension")
|
|
105
|
+
if len(set(dimensions)) != len(dimensions):
|
|
106
|
+
v.fail("invalid_input", "Duplicate dimensions.")
|
|
107
|
+
for name, items, allowed in (
|
|
108
|
+
("execution_kinds", execution_kinds, v.EXECUTION_KINDS),
|
|
109
|
+
("signal_kinds", signal_kinds, v.SIGNAL_KINDS),
|
|
110
|
+
("signal_names", signal_names, None),
|
|
111
|
+
):
|
|
112
|
+
if items is not None:
|
|
113
|
+
if type(items) is not list:
|
|
114
|
+
v.fail("invalid_input", f"{name} must be a list.")
|
|
115
|
+
for item in items:
|
|
116
|
+
v.nonempty(item, name)
|
|
117
|
+
if allowed is not None:
|
|
118
|
+
v.enum(item, allowed, name)
|
|
119
|
+
execution_window = {} if execution_window is None else execution_window
|
|
120
|
+
observation_window = {} if observation_window is None else observation_window
|
|
121
|
+
v.window_valid(execution_window)
|
|
122
|
+
v.window_valid(observation_window)
|
|
123
|
+
for name, number in (
|
|
124
|
+
("maximum_records", maximum_records),
|
|
125
|
+
("maximum_dimension_depth", maximum_dimension_depth),
|
|
126
|
+
("minimum_support", minimum_support),
|
|
127
|
+
("minimum_scored_count", minimum_scored_count),
|
|
128
|
+
("minimum_recurrence", minimum_recurrence),
|
|
129
|
+
):
|
|
130
|
+
v.integer(number, name)
|
|
131
|
+
if maximum_dimension_depth > 2:
|
|
132
|
+
v.fail("query_limit", "Alpha supports dimension depth 1 or 2.")
|
|
133
|
+
v.integer(minimum_distinct_entities, "minimum_distinct_entities", 0)
|
|
134
|
+
v.finite(minimum_effect_size, "minimum_effect_size", 0)
|
|
135
|
+
v.enum(time_bucket, ["day", "week", "month"], "time_bucket")
|
|
136
|
+
if type(include_episode_signals) is not bool:
|
|
137
|
+
v.fail("invalid_input", "include_episode_signals must be boolean.")
|
|
138
|
+
v.nonempty(scoring_version, "scoring_version")
|
|
139
|
+
if score is not None and not callable(score):
|
|
140
|
+
v.fail("invalid_input", "score must be a synchronous callable.")
|
|
141
|
+
async with loop.store.transaction(loop.namespace) as tx:
|
|
142
|
+
executions = await _read_all(loop, tx, "executions", maximum_records, execution_window)
|
|
143
|
+
signals = await _read_all(loop, tx, "signals", maximum_records, observation_window)
|
|
144
|
+
selected = [e for e in executions if execution_kinds is None or e["kind"] in execution_kinds]
|
|
145
|
+
by_execution, by_episode = defaultdict(list), defaultdict(list)
|
|
146
|
+
for signal in signals:
|
|
147
|
+
if signal_names is not None and signal["name"] not in signal_names:
|
|
148
|
+
continue
|
|
149
|
+
if signal_kinds is not None and signal["kind"] not in signal_kinds:
|
|
150
|
+
continue
|
|
151
|
+
value = (score or default_signal_score)(deepcopy(signal))
|
|
152
|
+
if value is not None:
|
|
153
|
+
v.finite(value, "score callback result")
|
|
154
|
+
if value is None or signal["confidence"] <= 0:
|
|
155
|
+
continue
|
|
156
|
+
index = by_execution if signal.get("execution_id") else by_episode
|
|
157
|
+
index[signal.get("execution_id", signal.get("episode_id"))].append((signal, value))
|
|
158
|
+
|
|
159
|
+
def units_for(rows):
|
|
160
|
+
units = defaultdict(dict)
|
|
161
|
+
for row in rows:
|
|
162
|
+
items = [*by_execution[row["id"]]]
|
|
163
|
+
if include_episode_signals and row.get("episode_id"):
|
|
164
|
+
items.extend(by_episode[row["episode_id"]])
|
|
165
|
+
for item in items:
|
|
166
|
+
units[_unit(row)][item[0]["id"]] = item
|
|
167
|
+
return units
|
|
168
|
+
|
|
169
|
+
baseline_units = units_for(selected)
|
|
170
|
+
baseline = _summary(baseline_units)
|
|
171
|
+
if baseline is None:
|
|
172
|
+
return []
|
|
173
|
+
sets = [(d,) for d in dimensions]
|
|
174
|
+
if maximum_dimension_depth == 2:
|
|
175
|
+
sets.extend(combinations(dimensions, 2))
|
|
176
|
+
segments: dict[str, Any] = {}
|
|
177
|
+
missing = object()
|
|
178
|
+
for row in selected:
|
|
179
|
+
for subset in sets:
|
|
180
|
+
values = {}
|
|
181
|
+
for path in subset:
|
|
182
|
+
value: Any = row
|
|
183
|
+
for component in path.split("."):
|
|
184
|
+
value = value.get(component, missing) if type(value) is dict else missing
|
|
185
|
+
if value is missing:
|
|
186
|
+
break
|
|
187
|
+
values[path] = value
|
|
188
|
+
if len(values) == len(subset):
|
|
189
|
+
segments.setdefault(v.canonical(values), (values, []))[1].append(row)
|
|
190
|
+
baseline_fingerprint = v.fingerprint(
|
|
191
|
+
{
|
|
192
|
+
"executions": sorted([r["id"], r["revision"], r["input_hash"]] for r in selected),
|
|
193
|
+
"units": sorted(
|
|
194
|
+
[
|
|
195
|
+
key,
|
|
196
|
+
sorted(
|
|
197
|
+
[s["id"], s["input_hash"], val, s["confidence"]]
|
|
198
|
+
for s, val in items.values()
|
|
199
|
+
),
|
|
200
|
+
]
|
|
201
|
+
for key, items in baseline_units.items()
|
|
202
|
+
),
|
|
203
|
+
"scoring_version": scoring_version,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
findings = []
|
|
207
|
+
for values, rows in segments.values():
|
|
208
|
+
units = units_for(rows)
|
|
209
|
+
stats = _summary(units)
|
|
210
|
+
if stats is None or len(units) < minimum_support or stats["count"] < minimum_scored_count:
|
|
211
|
+
continue
|
|
212
|
+
effect = stats["mean"] - baseline["mean"]
|
|
213
|
+
v.finite(effect, "effect size")
|
|
214
|
+
if abs(effect) < minimum_effect_size:
|
|
215
|
+
continue
|
|
216
|
+
unique, corrections = {}, defaultdict(int)
|
|
217
|
+
for items in units.values():
|
|
218
|
+
unit_corrections = set()
|
|
219
|
+
for key, item in items.items():
|
|
220
|
+
unique[key] = item
|
|
221
|
+
if "correction" in item[0]:
|
|
222
|
+
unit_corrections.add(v.canonical(item[0]["correction"]))
|
|
223
|
+
for correction in unit_corrections:
|
|
224
|
+
corrections[correction] += 1
|
|
225
|
+
buckets = {_bucket(item[0]["observed_at"], time_bucket) for item in unique.values()}
|
|
226
|
+
eligible = [r for r in rows if _unit(r) in units]
|
|
227
|
+
entities = {r["entity_id"] for r in eligible if r.get("entity_id")}
|
|
228
|
+
if len(buckets) < minimum_recurrence or len(entities) < minimum_distinct_entities:
|
|
229
|
+
continue
|
|
230
|
+
manifest = {
|
|
231
|
+
"version": 1,
|
|
232
|
+
"execution_ids": sorted(r["id"] for r in eligible),
|
|
233
|
+
"signal_ids": sorted(unique),
|
|
234
|
+
"execution_window": execution_window,
|
|
235
|
+
"observation_window": observation_window,
|
|
236
|
+
"scoring_version": scoring_version,
|
|
237
|
+
}
|
|
238
|
+
manifest["fingerprint"] = v.fingerprint(
|
|
239
|
+
{"manifest": manifest, "baseline": baseline_fingerprint}
|
|
240
|
+
)
|
|
241
|
+
findings.append(
|
|
242
|
+
{
|
|
243
|
+
"id": v.fingerprint({"namespace": loop.namespace, "dimensions": values}),
|
|
244
|
+
"namespace": loop.namespace,
|
|
245
|
+
"dimensions": values,
|
|
246
|
+
"support": len(units),
|
|
247
|
+
"execution_count": len(rows),
|
|
248
|
+
"unique_signal_count": len(unique),
|
|
249
|
+
"episode_count": len({r["episode_id"] for r in eligible if r.get("episode_id")}),
|
|
250
|
+
"scored_count": stats["count"],
|
|
251
|
+
"effective_weight": stats["weight"],
|
|
252
|
+
"mean_score": stats["mean"],
|
|
253
|
+
"baseline_score": baseline["mean"],
|
|
254
|
+
"effect_size": effect,
|
|
255
|
+
"recurrence": len(buckets),
|
|
256
|
+
"distinct_entities": len(entities),
|
|
257
|
+
"correction_counts": dict(corrections),
|
|
258
|
+
"evidence": manifest,
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
return sorted(findings, key=lambda f: (f["effect_size"], f["id"]))
|