chap-analytics 0.1.0__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.
- chap_analytics/__init__.py +45 -0
- chap_analytics/_patch.py +211 -0
- chap_analytics/frames.py +1704 -0
- chap_analytics/load.py +308 -0
- chap_analytics/py.typed +0 -0
- chap_analytics/sample.py +281 -0
- chap_analytics/schema.py +368 -0
- chap_analytics-0.1.0.dist-info/METADATA +307 -0
- chap_analytics-0.1.0.dist-info/RECORD +12 -0
- chap_analytics-0.1.0.dist-info/WHEEL +5 -0
- chap_analytics-0.1.0.dist-info/licenses/LICENSE +201 -0
- chap_analytics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chap-analytics: a CHAP chain as tables.
|
|
3
|
+
|
|
4
|
+
A CHAP audit log records what humans decided about agent work: what an agent
|
|
5
|
+
produced, what a person changed, why, and under which rule. This package
|
|
6
|
+
projects that log into documented pandas tables, so it can be analysed as the
|
|
7
|
+
supervision dataset it already is.
|
|
8
|
+
|
|
9
|
+
from chap_analytics import from_sqlite, frames
|
|
10
|
+
|
|
11
|
+
chain = from_sqlite("./chap.db", workspace="wsp_support")
|
|
12
|
+
f = frames(chain)
|
|
13
|
+
|
|
14
|
+
print(f.summary())
|
|
15
|
+
f.overrides.groupby("top_path").size().sort_values(ascending=False)
|
|
16
|
+
|
|
17
|
+
Eleven tables: events, tasks, decisions, overrides, patch_ops, participants,
|
|
18
|
+
deliberations, votes, whispers, handoffs, routing. Every column is declared in
|
|
19
|
+
``schema.py`` with its dtype and its provenance. A column the source lacked a
|
|
20
|
+
value for is present and null.
|
|
21
|
+
|
|
22
|
+
This is stage one of ANALYTICS_ROADMAP.md, and it stops at the tables.
|
|
23
|
+
Statistics belong in a layer above, where their assumptions can be stated.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from . import sample
|
|
28
|
+
from .frames import Frames, frames
|
|
29
|
+
from .load import (
|
|
30
|
+
Chain,
|
|
31
|
+
from_coordinator,
|
|
32
|
+
from_json,
|
|
33
|
+
from_sqlite,
|
|
34
|
+
from_url,
|
|
35
|
+
redact_artefacts,
|
|
36
|
+
)
|
|
37
|
+
from .schema import BY_NAME, TABLES, Column, Table, describe
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Chain", "Column", "Frames", "Table", "TABLES", "BY_NAME",
|
|
43
|
+
"describe", "frames", "from_coordinator", "from_json", "from_sqlite",
|
|
44
|
+
"from_url", "redact_artefacts", "sample", "__version__",
|
|
45
|
+
]
|
chap_analytics/_patch.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RFC 6902 JSON Patch, for reconstructing the corrected artefact.
|
|
3
|
+
|
|
4
|
+
``decide.override`` carries the patch a reviewer applied; ``review.request``
|
|
5
|
+
carried the artefact they applied it to. Applying the one to the other gives
|
|
6
|
+
the corrected artefact from envelopes alone, which is what makes the
|
|
7
|
+
``result`` column available to a client that has only ``audit.read``.
|
|
8
|
+
|
|
9
|
+
This is the coordinator's own applier, copied rather than imported, because
|
|
10
|
+
the package runs with pandas alone and the two implementations have to agree
|
|
11
|
+
exactly for ``result`` to equal what the coordinator stored. The differential
|
|
12
|
+
suite applies every patch both ways and requires the same document. Keep this
|
|
13
|
+
file in step with ``chap_coordinator/patch.py``; the limits and the refused
|
|
14
|
+
path segments are deliberately the same.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
__all__ = ["apply_json_patch", "PatchError"]
|
|
23
|
+
|
|
24
|
+
# Refused in every JSON Pointer segment, as the coordinator refuses them: in a
|
|
25
|
+
# JavaScript runtime they enable prototype pollution, and every implementation
|
|
26
|
+
# refuses the same patches.
|
|
27
|
+
_DANGEROUS_KEYS = frozenset({"__proto__", "constructor", "prototype"})
|
|
28
|
+
|
|
29
|
+
# An RFC 6901 array index: "0" or a positive integer with no leading zero.
|
|
30
|
+
_ARRAY_INDEX_RE = re.compile(r"^(0|[1-9][0-9]*)$")
|
|
31
|
+
|
|
32
|
+
MAX_PATCH_OPS = 1000
|
|
33
|
+
MAX_DOCUMENT_NODES = 100_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PatchError(Exception):
|
|
37
|
+
"""A patch operation that fails to apply to this document."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _array_index(seg: str) -> int:
|
|
41
|
+
if not _ARRAY_INDEX_RE.match(seg):
|
|
42
|
+
raise PatchError(f"Array index expected at {seg!r}")
|
|
43
|
+
return int(seg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unescape(token: str) -> str:
|
|
47
|
+
return token.replace("~1", "/").replace("~0", "~")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _split_path(path: str) -> list[str]:
|
|
51
|
+
if path == "":
|
|
52
|
+
return []
|
|
53
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
54
|
+
raise PatchError(f"JSON Pointer must start with '/': {path!r}")
|
|
55
|
+
segments = [_unescape(tok) for tok in path[1:].split("/")]
|
|
56
|
+
for seg in segments:
|
|
57
|
+
if seg in _DANGEROUS_KEYS:
|
|
58
|
+
raise PatchError(f"Refusing unsafe path segment {seg!r}")
|
|
59
|
+
return segments
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _navigate(doc: Any, parts: list[str]) -> tuple[Any, str | int]:
|
|
63
|
+
"""The parent of the target, and the key of the target within it."""
|
|
64
|
+
if not parts:
|
|
65
|
+
raise PatchError("Cannot operate on root with this helper.")
|
|
66
|
+
parent = doc
|
|
67
|
+
for i, part in enumerate(parts[:-1]):
|
|
68
|
+
if isinstance(parent, list):
|
|
69
|
+
idx = _array_index(part)
|
|
70
|
+
if idx >= len(parent):
|
|
71
|
+
raise PatchError(f"Index out of range at /{'/'.join(parts[:i + 1])}")
|
|
72
|
+
parent = parent[idx]
|
|
73
|
+
elif isinstance(parent, dict):
|
|
74
|
+
if part not in parent:
|
|
75
|
+
raise PatchError(f"Path not found: /{'/'.join(parts[:i + 1])}")
|
|
76
|
+
parent = parent[part]
|
|
77
|
+
else:
|
|
78
|
+
raise PatchError(f"Cannot traverse into {type(parent).__name__} at {part!r}")
|
|
79
|
+
last = parts[-1]
|
|
80
|
+
if isinstance(parent, list):
|
|
81
|
+
if last == "-":
|
|
82
|
+
return parent, "-"
|
|
83
|
+
return parent, _array_index(last)
|
|
84
|
+
return parent, last
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get(doc: Any, path: str) -> Any:
|
|
88
|
+
parts = _split_path(path)
|
|
89
|
+
if not parts:
|
|
90
|
+
return doc
|
|
91
|
+
parent, key = _navigate(doc, parts)
|
|
92
|
+
if isinstance(parent, list):
|
|
93
|
+
if key == "-":
|
|
94
|
+
raise PatchError("Cannot read '-' position.")
|
|
95
|
+
if not isinstance(key, int) or key < 0 or key >= len(parent):
|
|
96
|
+
raise PatchError(f"Index out of range: {path}")
|
|
97
|
+
return parent[key]
|
|
98
|
+
if not isinstance(parent, dict) or key not in parent:
|
|
99
|
+
raise PatchError(f"Path not found: {path}")
|
|
100
|
+
return parent[key]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _apply_one(doc: Any, op: dict) -> Any: # noqa: C901 - one branch per RFC 6902 operation
|
|
104
|
+
kind = op.get("op")
|
|
105
|
+
path = op.get("path", "")
|
|
106
|
+
|
|
107
|
+
if kind == "add":
|
|
108
|
+
if "value" not in op:
|
|
109
|
+
raise PatchError("'add' requires 'value'")
|
|
110
|
+
parts = _split_path(path)
|
|
111
|
+
if not parts:
|
|
112
|
+
return op["value"]
|
|
113
|
+
parent, key = _navigate(doc, parts)
|
|
114
|
+
if isinstance(parent, list):
|
|
115
|
+
if key == "-":
|
|
116
|
+
parent.append(op["value"])
|
|
117
|
+
else:
|
|
118
|
+
if not isinstance(key, int) or key < 0 or key > len(parent):
|
|
119
|
+
raise PatchError(f"Index out of range for add: {path}")
|
|
120
|
+
parent.insert(key, op["value"])
|
|
121
|
+
elif isinstance(parent, dict):
|
|
122
|
+
parent[key] = op["value"]
|
|
123
|
+
else:
|
|
124
|
+
raise PatchError(f"Cannot add into {type(parent).__name__}")
|
|
125
|
+
return doc
|
|
126
|
+
|
|
127
|
+
if kind == "replace":
|
|
128
|
+
if "value" not in op:
|
|
129
|
+
raise PatchError("'replace' requires 'value'")
|
|
130
|
+
parts = _split_path(path)
|
|
131
|
+
if not parts:
|
|
132
|
+
return op["value"]
|
|
133
|
+
parent, key = _navigate(doc, parts)
|
|
134
|
+
if isinstance(parent, list):
|
|
135
|
+
if not isinstance(key, int) or key < 0 or key >= len(parent):
|
|
136
|
+
raise PatchError(f"Path not found for replace: {path}")
|
|
137
|
+
parent[key] = op["value"]
|
|
138
|
+
elif isinstance(parent, dict):
|
|
139
|
+
if key not in parent:
|
|
140
|
+
raise PatchError(f"Path not found for replace: {path}")
|
|
141
|
+
parent[key] = op["value"]
|
|
142
|
+
else:
|
|
143
|
+
raise PatchError(f"Cannot replace in {type(parent).__name__}")
|
|
144
|
+
return doc
|
|
145
|
+
|
|
146
|
+
if kind == "remove":
|
|
147
|
+
parts = _split_path(path)
|
|
148
|
+
if not parts:
|
|
149
|
+
raise PatchError("Cannot remove root.")
|
|
150
|
+
parent, key = _navigate(doc, parts)
|
|
151
|
+
if isinstance(parent, list):
|
|
152
|
+
if not isinstance(key, int) or key < 0 or key >= len(parent):
|
|
153
|
+
raise PatchError(f"Index out of range for remove: {path}")
|
|
154
|
+
del parent[key]
|
|
155
|
+
elif isinstance(parent, dict):
|
|
156
|
+
if key not in parent:
|
|
157
|
+
raise PatchError(f"Path not found for remove: {path}")
|
|
158
|
+
del parent[key]
|
|
159
|
+
else:
|
|
160
|
+
raise PatchError(f"Cannot remove from {type(parent).__name__}")
|
|
161
|
+
return doc
|
|
162
|
+
|
|
163
|
+
if kind == "copy":
|
|
164
|
+
src = op.get("from")
|
|
165
|
+
if src is None:
|
|
166
|
+
raise PatchError("'copy' requires 'from'")
|
|
167
|
+
value = copy.deepcopy(_get(doc, src))
|
|
168
|
+
return _apply_one(doc, {"op": "add", "path": path, "value": value})
|
|
169
|
+
|
|
170
|
+
if kind == "move":
|
|
171
|
+
src = op.get("from")
|
|
172
|
+
if src is None:
|
|
173
|
+
raise PatchError("'move' requires 'from'")
|
|
174
|
+
if isinstance(path, str) and path.startswith(src + "/"):
|
|
175
|
+
raise PatchError("'move' cannot move a location into its own child.")
|
|
176
|
+
value = copy.deepcopy(_get(doc, src))
|
|
177
|
+
doc = _apply_one(doc, {"op": "remove", "path": src})
|
|
178
|
+
return _apply_one(doc, {"op": "add", "path": path, "value": value})
|
|
179
|
+
|
|
180
|
+
if kind == "test":
|
|
181
|
+
if "value" not in op:
|
|
182
|
+
raise PatchError("'test' requires 'value'")
|
|
183
|
+
if _get(doc, path) != op["value"]:
|
|
184
|
+
raise PatchError(f"'test' failed at {path}")
|
|
185
|
+
return doc
|
|
186
|
+
|
|
187
|
+
raise PatchError(f"Unsupported op: {kind!r}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _node_count(value: Any) -> int:
|
|
191
|
+
if isinstance(value, dict):
|
|
192
|
+
return 1 + sum(_node_count(v) for v in value.values())
|
|
193
|
+
if isinstance(value, list):
|
|
194
|
+
return 1 + sum(_node_count(v) for v in value)
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def apply_json_patch(doc: Any, patch: list[dict]) -> Any:
|
|
199
|
+
"""Apply a patch to a copy of ``doc`` and return the result."""
|
|
200
|
+
if not isinstance(patch, list):
|
|
201
|
+
raise PatchError("A patch is a list of operations.")
|
|
202
|
+
if len(patch) > MAX_PATCH_OPS:
|
|
203
|
+
raise PatchError(f"Patch has too many operations ({len(patch)} > {MAX_PATCH_OPS})")
|
|
204
|
+
out = copy.deepcopy(doc)
|
|
205
|
+
for op in patch:
|
|
206
|
+
if not isinstance(op, dict):
|
|
207
|
+
raise PatchError("Each operation is an object.")
|
|
208
|
+
out = _apply_one(out, op)
|
|
209
|
+
if _node_count(out) > MAX_DOCUMENT_NODES:
|
|
210
|
+
raise PatchError(f"Patched document exceeds the node limit ({MAX_DOCUMENT_NODES})")
|
|
211
|
+
return out
|