axiongraph 0.0.1__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.
- axiongraph/__init__.py +59 -0
- axiongraph/py.typed +0 -0
- axiongraph/store_local.py +7 -0
- axiongraph-0.0.1.dist-info/METADATA +113 -0
- axiongraph-0.0.1.dist-info/RECORD +18 -0
- axiongraph-0.0.1.dist-info/WHEEL +4 -0
- axiongraph-0.0.1.dist-info/licenses/LICENSE +21 -0
- axiongraph_core/__init__.py +39 -0
- axiongraph_core/canonical.py +26 -0
- axiongraph_core/py.typed +0 -0
- axiongraph_core/reducer.py +101 -0
- axiongraph_core/store.py +28 -0
- axiongraph_core/types.py +62 -0
- axiongraph_core/vocabulary.py +79 -0
- axiongraph_store_local/__init__.py +9 -0
- axiongraph_store_local/in_memory.py +31 -0
- axiongraph_store_local/py.typed +0 -0
- axiongraph_store_local/sqlite.py +53 -0
axiongraph/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""AxionGraph — append-only execution-graph event model and deterministic reducer.
|
|
2
|
+
|
|
3
|
+
The bare ``axiongraph`` import is the core API; ``axiongraph.store_local`` exposes the
|
|
4
|
+
zero-service reference stores. This mirrors the TypeScript package's ``.`` and ``/store-local``
|
|
5
|
+
subpath exports."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from axiongraph_core import (
|
|
12
|
+
Anomaly,
|
|
13
|
+
AnomalyKind,
|
|
14
|
+
EdgePayload,
|
|
15
|
+
EdgeStatus,
|
|
16
|
+
GraphEvent,
|
|
17
|
+
GraphEventType,
|
|
18
|
+
GraphState,
|
|
19
|
+
GraphStore,
|
|
20
|
+
GraphVocabulary,
|
|
21
|
+
NodePayload,
|
|
22
|
+
OnAnomaly,
|
|
23
|
+
ValidationResult,
|
|
24
|
+
canonicalize,
|
|
25
|
+
empty_state,
|
|
26
|
+
example_vocabulary,
|
|
27
|
+
reduce,
|
|
28
|
+
reduce_all,
|
|
29
|
+
subgraph,
|
|
30
|
+
validate,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
__version__ = version("axiongraph")
|
|
35
|
+
except PackageNotFoundError: # pragma: no cover - only during local source runs
|
|
36
|
+
__version__ = "0.0.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Anomaly",
|
|
40
|
+
"AnomalyKind",
|
|
41
|
+
"EdgePayload",
|
|
42
|
+
"EdgeStatus",
|
|
43
|
+
"GraphEvent",
|
|
44
|
+
"GraphEventType",
|
|
45
|
+
"GraphState",
|
|
46
|
+
"GraphStore",
|
|
47
|
+
"GraphVocabulary",
|
|
48
|
+
"NodePayload",
|
|
49
|
+
"OnAnomaly",
|
|
50
|
+
"ValidationResult",
|
|
51
|
+
"__version__",
|
|
52
|
+
"canonicalize",
|
|
53
|
+
"empty_state",
|
|
54
|
+
"example_vocabulary",
|
|
55
|
+
"reduce",
|
|
56
|
+
"reduce_all",
|
|
57
|
+
"subgraph",
|
|
58
|
+
"validate",
|
|
59
|
+
]
|
axiongraph/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axiongraph
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Invisible events. Replayable graphs. An append-only execution-graph event model and deterministic reducer — provider-agnostic, storage is a port.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cachetronaut/axiongraph-py
|
|
6
|
+
Project-URL: Repository, https://github.com/cachetronaut/axiongraph-py
|
|
7
|
+
Project-URL: Issues, https://github.com/cachetronaut/axiongraph-py/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agentic,agents,ai,ai-agents,append-only,audit,axiongraph,dag,deterministic,directed-graph,event-log,event-sourcing,event-store,execution-graph,genai,graph,graph-events,graph-state,graph-store,immutable,langgraph,lineage,llm,multi-agent,observability,orchestration,provenance,reducer,replay,snapshot,sqlite,telemetry,tracing,workflow
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# AxionGraph (Python)
|
|
25
|
+
|
|
26
|
+
> Invisible events. Replayable graphs.
|
|
27
|
+
|
|
28
|
+
AxionGraph is an append-only event model and deterministic reducer for execution graphs.
|
|
29
|
+
It records graph events from agents, tools, workflows, and connectors, then folds them into
|
|
30
|
+
portable graph state for storage, replay, testing, and visualization.
|
|
31
|
+
|
|
32
|
+
This is the Python mirror of [`axiongraph` on npm](https://www.npmjs.com/package/axiongraph);
|
|
33
|
+
the two cores are kept in lockstep by shared cross-language parity fixtures.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from axiongraph import reduce_all
|
|
37
|
+
|
|
38
|
+
events = [
|
|
39
|
+
{"id": "evt_01", "runId": "run_01", "seq": 1, "ts": "2026-06-02T12:00:00.000Z",
|
|
40
|
+
"type": "node_created", "node": {"id": "agent_research", "kind": "agent", "label": "Research Agent"}},
|
|
41
|
+
{"id": "evt_02", "runId": "run_01", "seq": 2, "ts": "2026-06-02T12:00:01.000Z",
|
|
42
|
+
"type": "node_created", "node": {"id": "tool_web", "kind": "tool", "label": "Web Search"}},
|
|
43
|
+
{"id": "evt_03", "runId": "run_01", "seq": 3, "ts": "2026-06-02T12:00:02.000Z",
|
|
44
|
+
"type": "edge_created",
|
|
45
|
+
"edge": {"id": "edge_01", "kind": "called_tool", "from": "agent_research", "to": "tool_web", "status": "completed"}},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
state = reduce_all("run_01", events)
|
|
49
|
+
print(len(state.nodes)) # 2
|
|
50
|
+
print(len(state.edges)) # 1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Core ideas
|
|
54
|
+
|
|
55
|
+
- Append-only events are the source of truth; graph state is derived by folding them.
|
|
56
|
+
- The reducer is pure and deterministic — identical event logs fold to byte-identical state.
|
|
57
|
+
- A monotonic `seq` per run defines order; wall-clock `ts` is advisory.
|
|
58
|
+
- Node/edge `kind` is an open taxonomy; supply a `GraphVocabulary` to reject unknown kinds.
|
|
59
|
+
- Storage is a port (`GraphStore`); rendering and realtime transport are consumer concerns.
|
|
60
|
+
- Event/payload shapes use the same wire keys as the TypeScript package (`runId`, `from`) so
|
|
61
|
+
the same events flow across both runtimes.
|
|
62
|
+
|
|
63
|
+
## One package, opt-in extras
|
|
64
|
+
|
|
65
|
+
AxionGraph ships as a single `axiongraph` distribution. The core API is the top-level import;
|
|
66
|
+
optional backends are installed as extras (`pip install axiongraph[...]`). The local stores
|
|
67
|
+
need no extra — `sqlite3` is in the standard library.
|
|
68
|
+
|
|
69
|
+
| Import | Description |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `axiongraph` | Event model, deterministic reducer, canonicalizer, vocabulary machinery, and the `GraphStore` port. |
|
|
72
|
+
| `axiongraph.store_local` | Zero-service reference adapters: an in-memory store and a `sqlite3`-backed durable store. |
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pip install axiongraph # or: uv add axiongraph
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Storing and replaying events
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from axiongraph.store_local import SqliteStore # or InMemoryStore
|
|
84
|
+
|
|
85
|
+
store = SqliteStore("./run.db") # ":memory:" by default
|
|
86
|
+
await store.append(events) # idempotent on (runId, seq)
|
|
87
|
+
state = await store.snapshot("run_01")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Both `InMemoryStore` and `SqliteStore` satisfy the same `GraphStore` protocol and are
|
|
91
|
+
interchangeable; any future adapter that passes the shared contract suite drops in the same way.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
Python 3.11+ and [uv](https://docs.astral.sh/uv/). The repo is an internal package set
|
|
96
|
+
(`packages/core`, `packages/store-local`) assembled by hatchling into the one `axiongraph`
|
|
97
|
+
distribution.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
uv sync --dev
|
|
101
|
+
uv run ruff check . && uv run ruff format --check .
|
|
102
|
+
uv run ty check
|
|
103
|
+
uv run pytest
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Status
|
|
107
|
+
|
|
108
|
+
Early development. The reducer and reference stores are implemented and pass the same
|
|
109
|
+
parity fixtures as the TypeScript package.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
axiongraph/__init__.py,sha256=ASWHmnNJWbMARmWxDe04q5QS08M6_Ugb6Vmc6LKKVgM,1287
|
|
2
|
+
axiongraph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
axiongraph/store_local.py,sha256=TpF6Eun0Q4Eu9NyOgC7i_-ljKj67iyITBeLnwCHzU58,233
|
|
4
|
+
axiongraph_core/__init__.py,sha256=DfVJFh4dWrPpqfYyz8n3BdUAYR24fFYmLrUsr_an0wA,975
|
|
5
|
+
axiongraph_core/canonical.py,sha256=OXVBJ75eJSvWi9anlbhEx4Utw-7c3sU0xPg9YwOlmqU,1031
|
|
6
|
+
axiongraph_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
axiongraph_core/reducer.py,sha256=0UNhdt42mo16dLVKuslwlnslLLSVPY8QdL9xhbYeyKI,3865
|
|
8
|
+
axiongraph_core/store.py,sha256=R9mY9lyxo51t-YAznp22cnZiH6AaBmMDGPKGQ7QI0mk,1239
|
|
9
|
+
axiongraph_core/types.py,sha256=FroEsilc9aP-QEWKkmEiW-ynlP91tg1YVTDoKV-ksvc,1809
|
|
10
|
+
axiongraph_core/vocabulary.py,sha256=p5UGBMf6hGba95mSrGz4APvFyL5ovUk45Hw7S3EibEU,2217
|
|
11
|
+
axiongraph_store_local/__init__.py,sha256=MMhML7xCxNDGq-vWfOFm3o32UpxfGBrM4lHPPGmE4b0,331
|
|
12
|
+
axiongraph_store_local/in_memory.py,sha256=LcVbViCDwzgcypFHFHTEmgttTAfm1lmKaEo35FKdNTg,1238
|
|
13
|
+
axiongraph_store_local/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
axiongraph_store_local/sqlite.py,sha256=9XeYpedeQyEvAjxkDF7K7vaDFlmyvVPlGPnCz2-9N3Y,2156
|
|
15
|
+
axiongraph-0.0.1.dist-info/METADATA,sha256=CRU1k7ege739Yd6Je911VFW9l9mTAnLgTNo5oixEbd4,4776
|
|
16
|
+
axiongraph-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
17
|
+
axiongraph-0.0.1.dist-info/licenses/LICENSE,sha256=sY7MtwwAOv-F1CsV_S47AsnkT_IPK2ifi1WBbsjLF-Q,1069
|
|
18
|
+
axiongraph-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cachetronaut
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""axiongraph-core — the append-only event model, deterministic reducer, canonicalizer,
|
|
2
|
+
vocabulary machinery, and the GraphStore port. Published as part of the ``axiongraph`` package."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from .canonical import canonicalize
|
|
7
|
+
from .reducer import Anomaly, AnomalyKind, OnAnomaly, empty_state, reduce, reduce_all, subgraph
|
|
8
|
+
from .store import GraphStore
|
|
9
|
+
from .types import (
|
|
10
|
+
EdgePayload,
|
|
11
|
+
EdgeStatus,
|
|
12
|
+
GraphEvent,
|
|
13
|
+
GraphEventType,
|
|
14
|
+
GraphState,
|
|
15
|
+
NodePayload,
|
|
16
|
+
)
|
|
17
|
+
from .vocabulary import GraphVocabulary, ValidationResult, example_vocabulary, validate
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Anomaly",
|
|
21
|
+
"AnomalyKind",
|
|
22
|
+
"EdgePayload",
|
|
23
|
+
"EdgeStatus",
|
|
24
|
+
"GraphEvent",
|
|
25
|
+
"GraphEventType",
|
|
26
|
+
"GraphState",
|
|
27
|
+
"GraphStore",
|
|
28
|
+
"GraphVocabulary",
|
|
29
|
+
"NodePayload",
|
|
30
|
+
"OnAnomaly",
|
|
31
|
+
"ValidationResult",
|
|
32
|
+
"canonicalize",
|
|
33
|
+
"empty_state",
|
|
34
|
+
"example_vocabulary",
|
|
35
|
+
"reduce",
|
|
36
|
+
"reduce_all",
|
|
37
|
+
"subgraph",
|
|
38
|
+
"validate",
|
|
39
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Deterministic, key-sorted JSON for a state. Identical event logs fold to byte-identical
|
|
2
|
+
output (spec D5) — this is the contract the cross-language parity fixtures pin.
|
|
3
|
+
|
|
4
|
+
Mirrors the TypeScript ``canonicalize``: a ``{runId, seq, nodes, edges}`` shape with nodes and
|
|
5
|
+
edges sorted by id, then ``json.dumps`` with sorted keys and no whitespace (matching
|
|
6
|
+
``JSON.stringify`` over a recursively key-sorted value)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
from .types import GraphState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonicalize(state: GraphState) -> str:
|
|
17
|
+
"""Return the canonical, byte-stable JSON string for ``state``."""
|
|
18
|
+
nodes = sorted(state.nodes.values(), key=lambda node: node["id"])
|
|
19
|
+
edges = sorted(state.edges.values(), key=lambda edge: cast(dict[str, Any], edge)["id"])
|
|
20
|
+
shape: dict[str, Any] = {
|
|
21
|
+
"runId": state.run_id,
|
|
22
|
+
"seq": state.seq,
|
|
23
|
+
"nodes": nodes,
|
|
24
|
+
"edges": edges,
|
|
25
|
+
}
|
|
26
|
+
return json.dumps(shape, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
axiongraph_core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The deterministic reducer. Pure and side-effect free (spec D5): no clock, no I/O,
|
|
2
|
+
no randomness. Identical event sequences fold to identical state."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Any, Literal, cast
|
|
9
|
+
|
|
10
|
+
from .types import EdgePayload, GraphEvent, GraphState, NodePayload
|
|
11
|
+
|
|
12
|
+
AnomalyKind = Literal["wrong_run", "stale_seq", "update_unknown_node", "update_unknown_edge"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Anomaly:
|
|
17
|
+
"""Something the reducer noticed but did not apply. Observation only, never raised."""
|
|
18
|
+
|
|
19
|
+
kind: AnomalyKind
|
|
20
|
+
event: GraphEvent
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
OnAnomaly = Callable[[Anomaly], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def empty_state(run_id: str) -> GraphState:
|
|
27
|
+
"""The fold's seed. ``seq`` starts at 0; the first applied event must have ``seq >= 1``."""
|
|
28
|
+
return GraphState(run_id=run_id, nodes={}, edges={}, seq=0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _report(on_anomaly: OnAnomaly | None, kind: AnomalyKind, event: GraphEvent) -> None:
|
|
32
|
+
if on_anomaly is not None:
|
|
33
|
+
on_anomaly(Anomaly(kind=kind, event=event))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reduce(state: GraphState, event: GraphEvent, on_anomaly: OnAnomaly | None = None) -> GraphState:
|
|
37
|
+
"""Apply one event. Events for another run, or with a non-increasing ``seq``, are
|
|
38
|
+
ignored idempotently (spec D3). Updates to unknown ids are dropped (spec D6)."""
|
|
39
|
+
if event["runId"] != state.run_id:
|
|
40
|
+
_report(on_anomaly, "wrong_run", event)
|
|
41
|
+
return state
|
|
42
|
+
if event["seq"] <= state.seq:
|
|
43
|
+
_report(on_anomaly, "stale_seq", event)
|
|
44
|
+
return state
|
|
45
|
+
|
|
46
|
+
event_type = event["type"]
|
|
47
|
+
|
|
48
|
+
if event_type == "node_created":
|
|
49
|
+
nodes = dict(state.nodes)
|
|
50
|
+
node = cast(NodePayload, event["node"])
|
|
51
|
+
nodes[node["id"]] = node
|
|
52
|
+
return replace(state, nodes=nodes, seq=event["seq"])
|
|
53
|
+
|
|
54
|
+
if event_type == "node_updated":
|
|
55
|
+
update = event["node"]
|
|
56
|
+
existing = state.nodes.get(update["id"])
|
|
57
|
+
if existing is None:
|
|
58
|
+
_report(on_anomaly, "update_unknown_node", event)
|
|
59
|
+
return replace(state, seq=event["seq"])
|
|
60
|
+
nodes = dict(state.nodes)
|
|
61
|
+
nodes[update["id"]] = cast(NodePayload, {**existing, **update})
|
|
62
|
+
return replace(state, nodes=nodes, seq=event["seq"])
|
|
63
|
+
|
|
64
|
+
if event_type == "edge_created":
|
|
65
|
+
edges = dict(state.edges)
|
|
66
|
+
edge = cast(EdgePayload, event["edge"])
|
|
67
|
+
edges[edge["id"]] = edge
|
|
68
|
+
return replace(state, edges=edges, seq=event["seq"])
|
|
69
|
+
|
|
70
|
+
if event_type == "edge_updated":
|
|
71
|
+
update = event["edge"]
|
|
72
|
+
existing = state.edges.get(update["id"])
|
|
73
|
+
if existing is None:
|
|
74
|
+
_report(on_anomaly, "update_unknown_edge", event)
|
|
75
|
+
return replace(state, seq=event["seq"])
|
|
76
|
+
edges = dict(state.edges)
|
|
77
|
+
edges[update["id"]] = cast(EdgePayload, {**existing, **update})
|
|
78
|
+
return replace(state, edges=edges, seq=event["seq"])
|
|
79
|
+
|
|
80
|
+
return state
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reduce_all(
|
|
84
|
+
run_id: str, events: Iterable[GraphEvent], on_anomaly: OnAnomaly | None = None
|
|
85
|
+
) -> GraphState:
|
|
86
|
+
"""Fold an event log into state. Sorted by ``seq`` first, so arrival order does not matter."""
|
|
87
|
+
state = empty_state(run_id)
|
|
88
|
+
for event in sorted(events, key=lambda candidate: candidate["seq"]):
|
|
89
|
+
state = reduce(state, event, on_anomaly)
|
|
90
|
+
return state
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def subgraph(state: GraphState, keep_node: Callable[[NodePayload], bool]) -> GraphState:
|
|
94
|
+
"""Derive a filtered view: keep matching nodes and any edge whose endpoints both survive."""
|
|
95
|
+
nodes = {node_id: node for node_id, node in state.nodes.items() if keep_node(node)}
|
|
96
|
+
edges: dict[str, EdgePayload] = {}
|
|
97
|
+
for edge_id, edge in state.edges.items():
|
|
98
|
+
endpoints: dict[str, Any] = cast(dict[str, Any], edge)
|
|
99
|
+
if endpoints["from"] in nodes and endpoints["to"] in nodes:
|
|
100
|
+
edges[edge_id] = edge
|
|
101
|
+
return replace(state, nodes=nodes, edges=edges)
|
axiongraph_core/store.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The :class:`GraphStore` port (spec D4): the seam between the event model and any backend.
|
|
2
|
+
Core defines the protocol; adapters (``axiongraph.store_local``, and later convex/neo4j/postgres)
|
|
3
|
+
implement it. Writes are append-only and idempotent on ``(runId, seq)`` (ties back to spec D3)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import AsyncIterator, Sequence
|
|
8
|
+
from typing import Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from .types import GraphEvent, GraphState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class GraphStore(Protocol):
|
|
15
|
+
"""Append-only event storage. Realtime fan-out (an optional ``subscribe``) is where the
|
|
16
|
+
hosted product lives; the local adapter omits it. Core never assumes a realtime transport."""
|
|
17
|
+
|
|
18
|
+
async def append(self, events: Sequence[GraphEvent]) -> None:
|
|
19
|
+
"""Append events. Idempotent on ``(runId, seq)``: re-appending a known seq is a no-op."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def read_events(self, run_id: str, since_seq: int = 0) -> AsyncIterator[GraphEvent]:
|
|
23
|
+
"""Read a run's events in ``seq`` order, optionally only those after ``since_seq``."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def snapshot(self, run_id: str) -> GraphState:
|
|
27
|
+
"""The reduced state for a run. May be a live fold or a materialized cache."""
|
|
28
|
+
...
|
axiongraph_core/types.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The append-only event model. The event log is the source of truth (spec D1);
|
|
2
|
+
live :class:`GraphState` is always a fold over events, never mutated in place.
|
|
3
|
+
|
|
4
|
+
The wire shapes mirror the TypeScript contracts and use the same camelCase keys
|
|
5
|
+
(``runId``, ``from``) so the cross-language parity fixtures are byte-identical.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
12
|
+
|
|
13
|
+
GraphEventType = Literal["node_created", "node_updated", "edge_created", "edge_updated"]
|
|
14
|
+
|
|
15
|
+
EdgeStatus = Literal["proposed", "active", "completed", "failed", "blocked"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NodePayload(TypedDict):
|
|
19
|
+
"""A node. ``metadata`` is present only when supplied (open taxonomy, spec D2)."""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
kind: str
|
|
23
|
+
label: str
|
|
24
|
+
metadata: NotRequired[dict[str, Any]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ``from`` is a reserved word, so the edge shape needs the functional TypedDict syntax.
|
|
28
|
+
EdgePayload = TypedDict(
|
|
29
|
+
"EdgePayload",
|
|
30
|
+
{
|
|
31
|
+
"id": str,
|
|
32
|
+
"kind": str,
|
|
33
|
+
"from": str,
|
|
34
|
+
"to": str,
|
|
35
|
+
"status": EdgeStatus,
|
|
36
|
+
"metadata": NotRequired[dict[str, Any]],
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GraphEvent(TypedDict):
|
|
42
|
+
"""A single append-only event. ``node`` / ``edge`` carry full payloads for
|
|
43
|
+
``*_created`` and partial payloads (``id`` plus changed fields) for ``*_updated``."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
runId: str
|
|
47
|
+
seq: int
|
|
48
|
+
ts: str
|
|
49
|
+
type: GraphEventType
|
|
50
|
+
actor: NotRequired[str]
|
|
51
|
+
node: NotRequired[dict[str, Any]]
|
|
52
|
+
edge: NotRequired[dict[str, Any]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class GraphState:
|
|
57
|
+
"""The reduced live state: two id-keyed maps plus the last applied sequence."""
|
|
58
|
+
|
|
59
|
+
run_id: str
|
|
60
|
+
nodes: dict[str, NodePayload] = field(default_factory=dict)
|
|
61
|
+
edges: dict[str, EdgePayload] = field(default_factory=dict)
|
|
62
|
+
seq: int = 0
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Open-taxonomy machinery (spec D2). Core ships the machinery and a neutral example set;
|
|
2
|
+
it never hard-codes a domain's vocabulary into the model."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .types import GraphEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class GraphVocabulary:
|
|
13
|
+
"""A declared closed vocabulary of allowed node and edge kinds."""
|
|
14
|
+
|
|
15
|
+
node_kinds: frozenset[str]
|
|
16
|
+
edge_kinds: frozenset[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ValidationResult:
|
|
21
|
+
"""The typed result of :func:`validate`. ``validate`` never raises on bad input."""
|
|
22
|
+
|
|
23
|
+
ok: bool
|
|
24
|
+
reason: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate(event: GraphEvent, vocab: GraphVocabulary) -> ValidationResult:
|
|
28
|
+
"""Reject ``*_created`` events whose kind is outside the supplied vocabulary."""
|
|
29
|
+
if event["type"] == "node_created":
|
|
30
|
+
kind = event["node"]["kind"]
|
|
31
|
+
if kind not in vocab.node_kinds:
|
|
32
|
+
return ValidationResult(ok=False, reason=f"unknown node kind: {kind}")
|
|
33
|
+
if event["type"] == "edge_created":
|
|
34
|
+
kind = event["edge"]["kind"]
|
|
35
|
+
if kind not in vocab.edge_kinds:
|
|
36
|
+
return ValidationResult(ok=False, reason=f"unknown edge kind: {kind}")
|
|
37
|
+
return ValidationResult(ok=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# A neutral example vocabulary for docs and tests. Reveals no product domain.
|
|
41
|
+
example_vocabulary = GraphVocabulary(
|
|
42
|
+
node_kinds=frozenset(
|
|
43
|
+
{
|
|
44
|
+
"human",
|
|
45
|
+
"agent",
|
|
46
|
+
"task",
|
|
47
|
+
"delegation",
|
|
48
|
+
"connector",
|
|
49
|
+
"tool",
|
|
50
|
+
"artifact",
|
|
51
|
+
"source",
|
|
52
|
+
"approval",
|
|
53
|
+
"policy_decision",
|
|
54
|
+
"budget_check",
|
|
55
|
+
"error",
|
|
56
|
+
"model_call",
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
edge_kinds=frozenset(
|
|
60
|
+
{
|
|
61
|
+
"created_task",
|
|
62
|
+
"delegated_to",
|
|
63
|
+
"handoff_to",
|
|
64
|
+
"called_tool",
|
|
65
|
+
"called_connector",
|
|
66
|
+
"used_model",
|
|
67
|
+
"read_source",
|
|
68
|
+
"created_artifact",
|
|
69
|
+
"requested_approval",
|
|
70
|
+
"approved_by",
|
|
71
|
+
"denied_by",
|
|
72
|
+
"blocked_by_policy",
|
|
73
|
+
"blocked_by_budget",
|
|
74
|
+
"derived_from",
|
|
75
|
+
"cited_source",
|
|
76
|
+
"failed_with",
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""axiongraph-store-local — zero-service reference GraphStore adapters (in-memory + sqlite).
|
|
2
|
+
Published as the ``axiongraph.store_local`` subpath of the ``axiongraph`` package."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from .in_memory import InMemoryStore
|
|
7
|
+
from .sqlite import SqliteStore
|
|
8
|
+
|
|
9
|
+
__all__ = ["InMemoryStore", "SqliteStore"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""A dict-backed GraphStore for tests and ephemeral runs (spec D4). Append-only and
|
|
2
|
+
idempotent on ``(runId, seq)``. No durability; everything lives in process memory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import AsyncIterator, Sequence
|
|
7
|
+
|
|
8
|
+
from axiongraph_core import GraphEvent, GraphState, reduce_all
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryStore:
|
|
12
|
+
"""A :class:`~axiongraph_core.GraphStore` backed by per-run lists kept sorted by ``seq``."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._logs: dict[str, list[GraphEvent]] = {}
|
|
16
|
+
|
|
17
|
+
async def append(self, events: Sequence[GraphEvent]) -> None:
|
|
18
|
+
for event in events:
|
|
19
|
+
log = self._logs.setdefault(event["runId"], [])
|
|
20
|
+
if any(existing["seq"] == event["seq"] for existing in log):
|
|
21
|
+
continue # idempotent on (runId, seq)
|
|
22
|
+
log.append(event)
|
|
23
|
+
log.sort(key=lambda candidate: candidate["seq"])
|
|
24
|
+
|
|
25
|
+
async def read_events(self, run_id: str, since_seq: int = 0) -> AsyncIterator[GraphEvent]:
|
|
26
|
+
for event in self._logs.get(run_id, []):
|
|
27
|
+
if event["seq"] > since_seq:
|
|
28
|
+
yield event
|
|
29
|
+
|
|
30
|
+
async def snapshot(self, run_id: str) -> GraphState:
|
|
31
|
+
return reduce_all(run_id, self._logs.get(run_id, []))
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""A durable single-file GraphStore backed by the stdlib ``sqlite3`` (spec D4). One ``events``
|
|
2
|
+
table keyed on ``(run_id, seq)``; ``append`` uses ``INSERT OR IGNORE`` so it is idempotent on
|
|
3
|
+
``(runId, seq)``. No server, survives restarts. Snapshots live-fold the log."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sqlite3
|
|
9
|
+
from collections.abc import AsyncIterator, Sequence
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from axiongraph_core import GraphEvent, GraphState, reduce_all
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SqliteStore:
|
|
16
|
+
"""A :class:`~axiongraph_core.GraphStore` persisted to a single SQLite database file."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, location: str = ":memory:") -> None:
|
|
19
|
+
"""``location`` is a file path, or ``":memory:"`` (default) for an ephemeral database."""
|
|
20
|
+
self._db = sqlite3.connect(location)
|
|
21
|
+
self._db.execute(
|
|
22
|
+
"CREATE TABLE IF NOT EXISTS events ("
|
|
23
|
+
" run_id TEXT NOT NULL,"
|
|
24
|
+
" seq INTEGER NOT NULL,"
|
|
25
|
+
" payload TEXT NOT NULL,"
|
|
26
|
+
" PRIMARY KEY (run_id, seq)"
|
|
27
|
+
")"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
async def append(self, events: Sequence[GraphEvent]) -> None:
|
|
31
|
+
self._db.executemany(
|
|
32
|
+
"INSERT OR IGNORE INTO events (run_id, seq, payload) VALUES (?, ?, ?)",
|
|
33
|
+
[(event["runId"], event["seq"], json.dumps(event)) for event in events],
|
|
34
|
+
)
|
|
35
|
+
self._db.commit()
|
|
36
|
+
|
|
37
|
+
async def read_events(self, run_id: str, since_seq: int = 0) -> AsyncIterator[GraphEvent]:
|
|
38
|
+
rows = self._db.execute(
|
|
39
|
+
"SELECT payload FROM events WHERE run_id = ? AND seq > ? ORDER BY seq",
|
|
40
|
+
(run_id, since_seq),
|
|
41
|
+
).fetchall()
|
|
42
|
+
for (payload,) in rows:
|
|
43
|
+
yield cast(GraphEvent, json.loads(payload))
|
|
44
|
+
|
|
45
|
+
async def snapshot(self, run_id: str) -> GraphState:
|
|
46
|
+
rows = self._db.execute(
|
|
47
|
+
"SELECT payload FROM events WHERE run_id = ? ORDER BY seq", (run_id,)
|
|
48
|
+
).fetchall()
|
|
49
|
+
return reduce_all(run_id, [cast(GraphEvent, json.loads(payload)) for (payload,) in rows])
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
"""Release the underlying database handle. Not part of the ``GraphStore`` port."""
|
|
53
|
+
self._db.close()
|