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.
@@ -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
+ ]
@@ -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