aoa-ocel 1.0.0__tar.gz

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 (27) hide show
  1. aoa_ocel-1.0.0/.gitignore +92 -0
  2. aoa_ocel-1.0.0/CHANGELOG.md +16 -0
  3. aoa_ocel-1.0.0/PKG-INFO +12 -0
  4. aoa_ocel-1.0.0/pyproject.toml +24 -0
  5. aoa_ocel-1.0.0/src/aoa/ocel/README.md +118 -0
  6. aoa_ocel-1.0.0/src/aoa/ocel/__init__.py +39 -0
  7. aoa_ocel-1.0.0/src/aoa/ocel/contracts/__init__.py +4 -0
  8. aoa_ocel-1.0.0/src/aoa/ocel/contracts/ocel_frame.py +79 -0
  9. aoa_ocel-1.0.0/src/aoa/ocel/dto/__init__.py +14 -0
  10. aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_attribute.py +13 -0
  11. aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_event.py +58 -0
  12. aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_object.py +17 -0
  13. aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_object_ref.py +12 -0
  14. aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_object_relationship.py +12 -0
  15. aoa_ocel-1.0.0/src/aoa/ocel/exceptions/__init__.py +12 -0
  16. aoa_ocel-1.0.0/src/aoa/ocel/exceptions/ocel_contract_error.py +11 -0
  17. aoa_ocel-1.0.0/src/aoa/ocel/exceptions/ocel_error.py +6 -0
  18. aoa_ocel-1.0.0/src/aoa/ocel/exceptions/ocel_resource_access_prohibited_error.py +11 -0
  19. aoa_ocel-1.0.0/src/aoa/ocel/plugin/__init__.py +10 -0
  20. aoa_ocel-1.0.0/src/aoa/ocel/plugin/ocel_plugin.py +333 -0
  21. aoa_ocel-1.0.0/src/aoa/ocel/py.typed +0 -0
  22. aoa_ocel-1.0.0/src/aoa/ocel/resource/__init__.py +12 -0
  23. aoa_ocel-1.0.0/src/aoa/ocel/resource/in_memory_ocel_store_resource.py +180 -0
  24. aoa_ocel-1.0.0/src/aoa/ocel/resource/ocel_store_protocol.py +24 -0
  25. aoa_ocel-1.0.0/src/aoa/ocel/resource/ocel_store_resource.py +45 -0
  26. aoa_ocel-1.0.0/src/aoa/ocel/resource/ocel_store_wrapper.py +45 -0
  27. aoa_ocel-1.0.0/src/aoa/ocel/type_id.py +31 -0
@@ -0,0 +1,92 @@
1
+ # ============================================================================
2
+ # Python
3
+ # ============================================================================
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.so
8
+ .Python
9
+
10
+ # ============================================================================
11
+ # Virtual environments
12
+ # ============================================================================
13
+ .venv/
14
+ venv/
15
+ ENV/
16
+ env/
17
+
18
+ # ============================================================================
19
+ # Distribution / packaging
20
+ # ============================================================================
21
+ build/
22
+ dist/
23
+ *.egg-info/
24
+ *.egg
25
+ .eggs/
26
+ wheels/
27
+
28
+ # ============================================================================
29
+ # Testing
30
+ # ============================================================================
31
+ .pytest_cache/
32
+ .coverage
33
+ .coverage.*
34
+ htmlcov/
35
+ *.cover
36
+ .hypothesis/
37
+ .tox/
38
+ .nox/
39
+
40
+ # ============================================================================
41
+ # Type checking
42
+ # ============================================================================
43
+ .mypy_cache/
44
+ .dmypy.json
45
+ dmypy.json
46
+ .pyre/
47
+ .pytype/
48
+
49
+ # ============================================================================
50
+ # Node (Maxitor web client — source lives in git; node_modules stays local only)
51
+ # ============================================================================
52
+ packages/aoa-maxitor/client/node_modules/
53
+ packages/aoa-maxitor/client/.vite/
54
+
55
+ # ============================================================================
56
+ # IDE
57
+ # ============================================================================
58
+ .idea/
59
+ .vscode/
60
+ *.swp
61
+ *.swo
62
+ .DS_Store
63
+
64
+ # ============================================================================
65
+ # Logs and temporary files
66
+ # ============================================================================
67
+ *.log
68
+ code_quality.log
69
+ *.tmp
70
+ *.temp
71
+
72
+ # ============================================================================
73
+ # Project specific - ARCHIVE (ваши локальные снимки)
74
+ # ============================================================================
75
+ archive/
76
+ *.ver
77
+ code.txt
78
+ project_structure.txt
79
+
80
+ # ============================================================================
81
+ # Локальные черновики (русские версии документации и примеров)
82
+ # ============================================================================
83
+ *_draft.md
84
+ *_draft.py
85
+ .ipynb_checkpoints/
86
+ docs/ru/
87
+
88
+ # ============================================================================
89
+ # НЕ ИГНОРИРУЕМ - эти файлы должны быть в git!
90
+ # ============================================================================
91
+ !.python-version
92
+ !uv.lock
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to `aoa-ocel` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] – 2026-06-24
11
+
12
+ ### Added
13
+
14
+ - **Initial standalone release, extracted from `aoa-action-machine`.** `OcelPlugin`, `OcelFrame`, `InMemoryOcelStoreResource`, `OcelStoreResource`, `OCEL_FRAMES_KEY`, and supporting DTOs are now distributed under the `aoa.ocel` namespace as a separate package (`pip install aoa-ocel`). The package depends on `aoa-action-machine` and `xxhash`. ([#71](https://github.com/bystrovmaxim/aoa/issues/71))
15
+
16
+ For the pre-extraction history of `OcelPlugin` (originally introduced in the monorepo at `[0.12.8]`), see the [aoa-action-machine CHANGELOG](../aoa-action-machine/CHANGELOG.md).
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: aoa-ocel
3
+ Version: 1.0.0
4
+ Summary: OCEL 2.0 export plugin for AOA — expose action runs as object-centric event logs for process mining.
5
+ Project-URL: Homepage, https://github.com/action-machine/action-machine
6
+ Project-URL: Documentation, https://action-machine.readthedocs.io
7
+ Project-URL: Repository, https://github.com/action-machine/action-machine
8
+ Author: @Bystrov.Maxim
9
+ License: MIT
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: aoa-action-machine>=1.0.0a5
12
+ Requires-Dist: xxhash<4,>=3.4
@@ -0,0 +1,24 @@
1
+ # packages/aoa-ocel/pyproject.toml
2
+ [build-system]
3
+ requires = ["hatchling"]
4
+ build-backend = "hatchling.build"
5
+
6
+ [project]
7
+ name = "aoa-ocel"
8
+ version = "1.0.0"
9
+ description = "OCEL 2.0 export plugin for AOA — expose action runs as object-centric event logs for process mining."
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "@Bystrov.Maxim" }]
13
+ dependencies = [
14
+ "aoa-action-machine>=1.0.0a5",
15
+ "xxhash>=3.4,<4",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/action-machine/action-machine"
20
+ Documentation = "https://action-machine.readthedocs.io"
21
+ Repository = "https://github.com/action-machine/action-machine"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/aoa"]
@@ -0,0 +1,118 @@
1
+ <p align="center">
2
+ <img src="../../../../../../../docs/assets/aoa-logo.png" alt="AOA" width="660"><br><br>
3
+ <a href="https://github.com/bystrovmaxim/aoa"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
4
+ <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
5
+ <img src="https://img.shields.io/badge/tests-26-brightgreen" alt="26 tests">
6
+ <a href="https://pypi.org/project/aoa-ocel/"><img src="https://img.shields.io/badge/install-aoa--ocel--plugin-blue?logo=pypi&logoColor=white" alt="pip install aoa-ocel"></a>
7
+ <img src="https://img.shields.io/badge/OCEL-2.0-orange" alt="OCEL 2.0">
8
+ </p>
9
+
10
+ # OCEL export (`aoa.ocel`)
11
+
12
+ This module exports data in [OCEL 2.0](https://ocel-standard.org/) format. Install with ``pip install aoa-ocel``. It turns Action execution logs into object-centric event logs that Process Mining tools can consume.
13
+
14
+ <p align="center">
15
+ <img src="../../../../../../../docs/assets/ocel-ocdfg.png" alt="Object-centric process graph (OCEL)" width="900">
16
+ </p>
17
+
18
+ ### Viewing logs in OC-PM
19
+
20
+ After ``await store.close()`` writes JSON, open the file in **[Object-Centric Process Mining (OC-PM)](https://www.ocpm.info/ocel.html)** — a browser app for OCEL JSON/XML logs. Upload the export, then use event/object explorers, OCDFG graphs, filters, and conformance views without extra tooling. See also ``examples/07_ocel.py`` and the Store batch export under ``packages/aoa-examples`` (``archive/logs/ocel.json`` in integration tests).
21
+
22
+ ## How it works
23
+
24
+ The builder (``OcelPlugin.build_ocel_event``) maps domain entities to ``OcelEvent`` DTOs. **v1 uses E2O only; O2O is not exported.**
25
+
26
+ We do **not** know in advance which questions process-mining users will ask. Export therefore prefers **reachability** (objects appear in event relationships when the aspect loaded them) over a minimal graph. Filters in PM tools can narrow views later; missing E2O cannot be recovered without re-export.
27
+
28
+ ### `OcelFrame` input
29
+
30
+ Aspects return one or more `OcelFrame` rows in pipeline state:
31
+
32
+ | Field | Role |
33
+ |-------|------|
34
+ | `object` | Root domain entity for this participation row |
35
+ | `qualifier` | Required E2O role for the root (non-empty string) |
36
+ | `attributes` | Optional event-level attributes (merged across frames; name clash with different values → error) |
37
+
38
+ ### E2O (event → object)
39
+
40
+ **Rule — loaded relations only:** E2O includes **only relation containers that are loaded on `frame.object` at export time** (`BaseEntity.get_foreign_keys(loaded_only=True)`). Scalar FK columns and relations not loaded on the instance are **not** exported. Nothing is read from the database beyond what the aspect already put on the entity.
41
+
42
+ **Rule — one hop:** From each `OcelFrame.object`, the builder walks **one level** of loaded relation fields only. It does **not** recurse into peers’ relations.
43
+
44
+ **Rule — no manual peer frames:** Loaded peers do not require separate `OcelFrame` rows. The builder materializes them automatically.
45
+
46
+ | Object | E2O qualifier |
47
+ |--------|----------------|
48
+ | `frame.object` (root) | `frame.qualifier` |
49
+ | Each loaded peer from a relation field `field_name` | `{frame.qualifier}.{field_name}` |
50
+
51
+ `AssociationMany` yields one E2O per peer id, same composite qualifier prefix.
52
+
53
+ ### O2O (object → object)
54
+
55
+ **v1: not used.** `OcelObject.relationships` stays empty. Structural links are represented only through co-occurrence in E2O for the same event.
56
+
57
+ ### Object attributes
58
+
59
+ | Source on domain entity | OCEL target |
60
+ |-------------------------|-------------|
61
+ | Loaded scalars on `frame.object` | `OcelObject.attributes` for the root |
62
+ | Loaded lifecycle snapshot on root | root attributes (state string, v1) |
63
+ | Loaded scalars on a one-hop peer | that peer’s `OcelObject.attributes` |
64
+
65
+ Event attributes: ``OcelPlugin`` merges ``OcelFrame.attributes`` across frames; not from entity fields.
66
+
67
+ ### Example — “Doctor signed prescription”
68
+
69
+ Aspect loads on the doctor entity before wrapping `OcelFrame`:
70
+
71
+ - `patient` — loaded → **E2O** with qualifier `"Signed prescription.patient"`
72
+ - `clinic` where the doctor works — **not loaded** → no E2O for clinic (clinic is context, not a participant in this action)
73
+
74
+ ```text
75
+ OcelFrame(doctor, qualifier="Signed prescription")
76
+
77
+ ├─ E2O doctor qualifier "Signed prescription"
78
+ ├─ E2O patient qualifier "Signed prescription.patient" (loaded FK, one hop)
79
+ └─ (clinic omitted — relation not loaded on doctor)
80
+ ```
81
+
82
+ If the aspect **did** load `clinic` on the doctor, the builder would add
83
+ `E2O clinic` with `"Signed prescription.clinic"`. That is valid but may add noise in PM graphs; the aspect controls participation via **partial loading**.
84
+
85
+ ### E2O vs “everything in the database”
86
+
87
+ | In DB | On framed entity at export | In OCEL v1 |
88
+ |-------|----------------------------|------------|
89
+ | Order → Customer FK | `customer` loaded | E2O + materialized `OcelObject` |
90
+ | Order → Customer FK | `customer` not loaded | absent |
91
+ | Scalar `customer_id` only | in `get_scalar_fields()` | object attribute on order, not E2O |
92
+
93
+ Participation is **not** inferred from SQL or undeclared fields — only from what the aspect loaded on the entity inside `OcelFrame`.
94
+
95
+ ## OcelPlugin
96
+
97
+ Register on ``ActionProductMachine(plugins=[OcelPlugin(store=resource)])``. The store must be opened by the owning action connection; the plugin only calls ``add_event``.
98
+
99
+ ```python
100
+ from aoa.ocel import OCEL_FRAMES_KEY, OcelFrame, OcelPlugin
101
+
102
+ @regular_aspect("Export")
103
+ @result_instance(OCEL_FRAMES_KEY, OcelFrame, required=False)
104
+ async def ocel_aspect(self, params, state, box, connections):
105
+ order = ... # partial load: only relations that should participate
106
+ return {
107
+ OCEL_FRAMES_KEY: [
108
+ OcelFrame(
109
+ object=order,
110
+ qualifier="Created order with identifier",
111
+ attributes=[OcelAttribute(name="domain", value="shop")],
112
+ ),
113
+ ]
114
+ }
115
+ ```
116
+
117
+ On each trace, ``OcelPlugin`` reads ``GlobalFinishEvent.all_aspect_states`` (and optional frames on ``result``), builds one ``OcelEvent``, and appends to the store. The plugin does not mutate pipeline state.
118
+
@@ -0,0 +1,39 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/__init__.py
2
+ """
3
+ OCEL 2.0 export plugin for AOA — install with ``pip install aoa-ocel``.
4
+
5
+ ═══════════════════════════════════════════════════════════════════════════════
6
+ PURPOSE
7
+ ═══════════════════════════════════════════════════════════════════════════════
8
+
9
+ Aspects return ``list[OcelFrame]``; ``OcelPlugin`` builds ``OcelEvent`` on
10
+ ``GlobalFinishEvent``. Export policy (loaded FK → E2O, one hop, no O2O v1):
11
+ ``packages/aoa-ocel/src/aoa/ocel/README.md``.
12
+
13
+ ═══════════════════════════════════════════════════════════════════════════════
14
+ ARCHITECTURE / DATA FLOW
15
+ ═══════════════════════════════════════════════════════════════════════════════
16
+
17
+ ::
18
+
19
+ aspect → list[OcelFrame] (partial entity controls loaded relations)
20
+
21
+
22
+ OcelPlugin → OcelEvent (E2O + OcelObject attributes) → OcelStoreResource
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from aoa.ocel.contracts import OcelFrame
28
+ from aoa.ocel.plugin import OCEL_FRAMES_KEY, OcelPlugin
29
+ from aoa.ocel.resource import InMemoryOcelStoreResource, OcelStoreResource
30
+ from aoa.ocel.type_id import make_oid
31
+
32
+ __all__ = [
33
+ "OCEL_FRAMES_KEY",
34
+ "InMemoryOcelStoreResource",
35
+ "OcelFrame",
36
+ "OcelPlugin",
37
+ "OcelStoreResource",
38
+ "make_oid",
39
+ ]
@@ -0,0 +1,4 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/contracts/__init__.py
2
+ from aoa.ocel.contracts.ocel_frame import OcelFrame
3
+
4
+ __all__ = ["OcelFrame"]
@@ -0,0 +1,79 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/contracts/ocel_frame.py
2
+ """
3
+ OcelFrame[T] — explicit contract container for OCEL serialization.
4
+
5
+ ═══════════════════════════════════════════════════════════════════════════════
6
+ PURPOSE
7
+ ═══════════════════════════════════════════════════════════════════════════════
8
+
9
+ Aspects declare which domain object anchors an export row and the E2O role
10
+ (``qualifier``) for the root. The future builder adds E2O for **loaded**
11
+ one-hop relation peers on ``frame.object`` without separate frames.
12
+
13
+ Full export rules (E2O-only v1, loaded FK, one hop, analytics trade-off):
14
+ ``packages/aoa-ocel/src/aoa/ocel/README.md`` — section **Export policy (v1)**.
15
+
16
+ ═══════════════════════════════════════════════════════════════════════════════
17
+ ARCHITECTURE / DATA FLOW
18
+ ═══════════════════════════════════════════════════════════════════════════════
19
+
20
+ ::
21
+
22
+ aspect loads relations on entity → OcelFrame(object, qualifier, attributes?)
23
+
24
+
25
+ builder (PR-7): root E2O + one-hop loaded FK → E2O with "{qualifier}.{field}"
26
+
27
+
28
+ OcelEvent → OcelStoreResource
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass, field
34
+ from typing import TypeVar
35
+
36
+ from aoa.action_machine.domain.entity import BaseEntity
37
+ from aoa.ocel.dto.ocel_attribute import OcelAttribute
38
+ from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
39
+
40
+ T = TypeVar("T", bound=BaseEntity)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class OcelFrame[T: BaseEntity]:
45
+ """Explicit contract that an aspect returns for OCEL serialization.
46
+
47
+ Builder mapping (v1, see package README)::
48
+
49
+ OcelFrame
50
+ ├── object ──────────────────────► E2O (qualifier = frame.qualifier)
51
+ │ └── loaded FK peers (1 hop) ► E2O (qualifier = "{frame.qualifier}.{field}")
52
+ └── attributes ──────────────────► OcelPlugin merges → OcelEvent.attributes
53
+
54
+ Loaded-only rule: only ``get_foreign_keys(loaded_only=True)`` on ``object``;
55
+ undeclared DB relations and scalar ``*_id`` columns are not E2O.
56
+
57
+ AI-CORE-BEGIN
58
+ ROLE: Root participation row; builder derives secondary E2O from loaded one-hop relations on ``object``.
59
+ CONTRACT: ``object``, non-empty ``qualifier``, optional ``attributes``; multiple frames per trace allowed.
60
+ INVARIANTS: ``qualifier`` is non-empty; zero frames in finish snapshots → no OCEL write; v1 exports E2O only (no O2O); attribute merge is ``OcelPlugin`` policy on ``GlobalFinishEvent.all_aspect_states``.
61
+ AI-CORE-END
62
+
63
+ Example — one frame; patient loaded, clinic not loaded::
64
+
65
+ OcelFrame(
66
+ object=doctor, # patient relation loaded; clinic relation not loaded
67
+ qualifier="Signed prescription",
68
+ attributes=[OcelAttribute(name="channel", value="electronic")],
69
+ )
70
+ # builder E2O: doctor ("Signed prescription"), patient ("Signed prescription.patient")
71
+ """
72
+
73
+ object: T
74
+ qualifier: str
75
+ attributes: list[OcelAttribute] = field(default_factory=list)
76
+
77
+ def __post_init__(self) -> None:
78
+ if not self.qualifier.strip():
79
+ raise OcelContractError("OcelFrame.qualifier must be non-empty")
@@ -0,0 +1,14 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/dto/__init__.py
2
+ from aoa.ocel.dto.ocel_attribute import OcelAttribute
3
+ from aoa.ocel.dto.ocel_event import OcelEvent
4
+ from aoa.ocel.dto.ocel_object import OcelObject
5
+ from aoa.ocel.dto.ocel_object_ref import OcelObjectRef
6
+ from aoa.ocel.dto.ocel_object_relationship import OcelObjectRelationship
7
+
8
+ __all__ = [
9
+ "OcelAttribute",
10
+ "OcelEvent",
11
+ "OcelObject",
12
+ "OcelObjectRef",
13
+ "OcelObjectRelationship",
14
+ ]
@@ -0,0 +1,13 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/dto/ocel_attribute.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class OcelAttribute:
10
+ """Named attribute of an event or object (no ``time`` field = static)."""
11
+
12
+ name: str
13
+ value: Any
@@ -0,0 +1,58 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/dto/ocel_event.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+
7
+ from aoa.ocel.dto.ocel_attribute import OcelAttribute
8
+ from aoa.ocel.dto.ocel_object import OcelObject
9
+ from aoa.ocel.dto.ocel_object_ref import OcelObjectRef
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class OcelEvent:
14
+ """Composite event record passed to ``OcelStoreResource.add_event()``.
15
+
16
+ DTO graph (maps to OCEL 2.0 ``events[]`` + embedded ``objects[]`` facts)::
17
+
18
+ OcelEvent
19
+ ├── id: str
20
+ ├── type: str → eventTypes[].name
21
+ ├── time: datetime → events[].time (UTC)
22
+
23
+ ├── attributes: list[OcelAttribute]
24
+ │ OcelAttribute
25
+ │ ├── name: str
26
+ │ └── value: Any → static; no ``time`` field
27
+
28
+ ├── relationships: list[OcelObjectRef] → E2O (event → object)
29
+ │ OcelObjectRef
30
+ │ ├── object_id: str → target ``OcelObject.id``
31
+ │ └── qualifier: str
32
+
33
+ └── objects: list[OcelObject] → objects[] section
34
+ OcelObject
35
+ ├── id: str
36
+ ├── type: str → objectTypes[].name
37
+
38
+ └── attributes: list[OcelAttribute]
39
+ └── static object attrs (scalars / lifecycle snapshot)
40
+
41
+ v1 builder policy (E2O only): ``relationships`` are built from ``OcelFrame`` rows
42
+ plus one-hop **loaded** relation peers on each ``frame.object``; composite
43
+ peer qualifier ``{frame.qualifier}.{field_name}``. No O2O export; see
44
+ ``packages/aoa-ocel/src/aoa/ocel/README.md`` — **Export policy (v1)**.
45
+
46
+ AI-CORE-BEGIN
47
+ ROLE: Single composite payload for one ``OcelStoreResource.add_event()`` call.
48
+ CONTRACT: ``relationships`` are E2O rows; ``objects`` materialize root and one-hop loaded peers.
49
+ INVARIANTS: ``time`` is UTC-aware before persist; attribute values are JSON-serializable primitives (plugin builds DTOs).
50
+ AI-CORE-END
51
+ """
52
+
53
+ id: str
54
+ type: str
55
+ time: datetime
56
+ attributes: list[OcelAttribute] = field(default_factory=list)
57
+ relationships: list[OcelObjectRef] = field(default_factory=list)
58
+ objects: list[OcelObject] = field(default_factory=list)
@@ -0,0 +1,17 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/dto/ocel_object.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ from aoa.ocel.dto.ocel_attribute import OcelAttribute
7
+ from aoa.ocel.dto.ocel_object_relationship import OcelObjectRelationship
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class OcelObject:
12
+ """Full object entry for materializing the objects section."""
13
+
14
+ id: str
15
+ type: str
16
+ attributes: list[OcelAttribute] = field(default_factory=list)
17
+ relationships: list[OcelObjectRelationship] = field(default_factory=list)
@@ -0,0 +1,12 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/dto/ocel_object_ref.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class OcelObjectRef:
9
+ """Event-to-object reference (events[].relationships entry)."""
10
+
11
+ object_id: str
12
+ qualifier: str
@@ -0,0 +1,12 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/dto/ocel_object_relationship.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class OcelObjectRelationship:
9
+ """Object-to-object relationship derived from a relation container field."""
10
+
11
+ object_id: str
12
+ qualifier: str
@@ -0,0 +1,12 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/exceptions/__init__.py
2
+ from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
3
+ from aoa.ocel.exceptions.ocel_error import OcelError
4
+ from aoa.ocel.exceptions.ocel_resource_access_prohibited_error import (
5
+ OcelResourceAccessProhibitedError,
6
+ )
7
+
8
+ __all__ = [
9
+ "OcelContractError",
10
+ "OcelError",
11
+ "OcelResourceAccessProhibitedError",
12
+ ]
@@ -0,0 +1,11 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/exceptions/ocel_contract_error.py
2
+ """OcelContractError — data/invariant contract violation."""
3
+
4
+ from aoa.ocel.exceptions.ocel_error import OcelError
5
+
6
+
7
+ class OcelContractError(OcelError):
8
+ """Raised when OCEL data or invariant contract is violated.
9
+
10
+ Catch with: ``except OcelContractError``.
11
+ """
@@ -0,0 +1,6 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/exceptions/ocel_error.py
2
+ """OcelError — base exception for the OCEL package."""
3
+
4
+
5
+ class OcelError(Exception):
6
+ """Base for all OCEL exceptions (lifecycle access and data contract)."""
@@ -0,0 +1,11 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/exceptions/ocel_resource_access_prohibited_error.py
2
+ """OcelResourceAccessProhibitedError — lifecycle call from proxy connection."""
3
+
4
+ from aoa.ocel.exceptions.ocel_error import OcelError
5
+
6
+
7
+ class OcelResourceAccessProhibitedError(OcelError):
8
+ """Raised when action code calls ``open()`` or ``close()`` on a proxy connection.
9
+
10
+ Not a subclass of ``OcelContractError`` — lifecycle access violation, not data contract.
11
+ """
@@ -0,0 +1,10 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/plugin/__init__.py
2
+ """
3
+ OcelPlugin — builds ``OcelEvent`` from ``OcelFrame`` rows on ``GlobalFinishEvent``.
4
+
5
+ See ``packages/aoa-ocel/src/aoa/ocel/README.md`` — **Export policy (v1)**.
6
+ """
7
+
8
+ from aoa.ocel.plugin.ocel_plugin import OCEL_FRAMES_KEY, OcelPlugin
9
+
10
+ __all__ = ["OCEL_FRAMES_KEY", "OcelPlugin"]
@@ -0,0 +1,333 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/plugin/ocel_plugin.py
2
+ """
3
+ OcelPlugin — OCEL 2.0 export on ``GlobalFinishEvent``.
4
+
5
+ ═══════════════════════════════════════════════════════════════════════════════
6
+ PURPOSE
7
+ ═══════════════════════════════════════════════════════════════════════════════
8
+
9
+ Read ``OcelFrame`` rows from ``GlobalFinishEvent.all_aspect_states`` (and
10
+ optional frames on ``result``), build one ``OcelEvent`` (E2O-only v1), append via
11
+ ``OcelStoreProtocol.add_event``. Does not mutate pipeline state or the event.
12
+
13
+ Export policy: ``packages/aoa-ocel/src/aoa/ocel/README.md`` — **Export policy (v1)**.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+ from collections.abc import Iterable, Mapping
20
+ from datetime import UTC, datetime
21
+ from typing import Annotated, Any, Union, get_args, get_origin
22
+
23
+ from aoa.action_machine.domain.entity import BaseEntity
24
+ from aoa.action_machine.domain.relation_containers import BaseRelationMany, BaseRelationOne
25
+ from aoa.action_machine.intents.on import GlobalFinishEvent, on
26
+ from aoa.action_machine.plugin.core import Plugin
27
+ from aoa.ocel.contracts.ocel_frame import OcelFrame
28
+ from aoa.ocel.dto.ocel_attribute import OcelAttribute
29
+ from aoa.ocel.dto.ocel_event import OcelEvent
30
+ from aoa.ocel.dto.ocel_object import OcelObject
31
+ from aoa.ocel.dto.ocel_object_ref import OcelObjectRef
32
+ from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
33
+ from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
34
+ from aoa.ocel.type_id import make_oid
35
+
36
+ OCEL_FRAMES_KEY = "ocel_frames"
37
+ _OCEL_TYPE_SUFFIXES = ("Action", "Entity", "Lifecycle")
38
+
39
+
40
+ class OcelPlugin(Plugin):
41
+ """
42
+ AI-CORE-BEGIN
43
+ ROLE: On ``GlobalFinishEvent``, scan ``all_aspect_states`` for ``OcelFrame`` rows and write one ``OcelEvent``.
44
+ CONTRACT: Requires injected ``OcelStoreProtocol``; optional ``short_names`` strips ``Action``/``Entity``/``Lifecycle`` suffixes from event and object type labels; read-only on event and pipeline state; E2O-only v1 (loaded one-hop FK, composite peer qualifiers).
45
+ INVARIANTS: Zero frames → no ``add_event``; event attribute name conflicts → ``OcelContractError``; store must already be open in the owning action.
46
+ AI-CORE-END
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ store: OcelStoreProtocol,
52
+ *,
53
+ short_names: bool = False,
54
+ ) -> None:
55
+ super().__init__()
56
+ self._store = store
57
+ self._short_names = short_names
58
+
59
+ async def get_initial_state(self) -> dict[str, Any]:
60
+ return {}
61
+
62
+ @on(GlobalFinishEvent, ignore_exceptions=False)
63
+ async def on_export_ocel(
64
+ self,
65
+ state: dict[str, Any],
66
+ event: GlobalFinishEvent,
67
+ log: Any,
68
+ ) -> dict[str, Any]:
69
+ """Build ``OcelEvent`` from finish snapshots and append to the store."""
70
+ frames = collect_ocel_frames(event)
71
+ if not frames:
72
+ return state
73
+ ocel_event = self.build_ocel_event(frames, event)
74
+ await self._store.add_event(ocel_event)
75
+ return state
76
+
77
+ def build_ocel_event(
78
+ self,
79
+ frames: Iterable[OcelFrame[BaseEntity]],
80
+ event: GlobalFinishEvent,
81
+ ) -> OcelEvent:
82
+ """Assemble one composite ``OcelEvent`` from collected frames."""
83
+ frame_list = list(frames)
84
+ if not frame_list:
85
+ raise OcelContractError("build_ocel_event requires at least one OcelFrame")
86
+
87
+ relationships: list[OcelObjectRef] = []
88
+ objects_by_id: dict[str, OcelObject] = {}
89
+ for frame in frame_list:
90
+ refs, objects = self._materialize_frame(frame)
91
+ relationships.extend(refs)
92
+ for obj in objects:
93
+ objects_by_id[obj.id] = obj
94
+
95
+ return OcelEvent(
96
+ id=self._event_id(event),
97
+ type=self._ocel_type_name(event.action_class),
98
+ time=self._event_time(event),
99
+ attributes=self._merge_event_attributes(frame_list),
100
+ relationships=relationships,
101
+ objects=list(objects_by_id.values()),
102
+ )
103
+
104
+ @staticmethod
105
+ def ensure_utc(dt: datetime) -> datetime:
106
+ """Normalize datetime to UTC for OCEL 2.0 JSON (naive → UTC)."""
107
+ if dt.tzinfo is None:
108
+ return dt.replace(tzinfo=UTC)
109
+ return dt.astimezone(UTC)
110
+
111
+ def _merge_event_attributes(
112
+ self,
113
+ frames: Iterable[OcelFrame[BaseEntity]],
114
+ ) -> list[OcelAttribute]:
115
+ merged: dict[str, OcelAttribute] = {}
116
+ for frame in frames:
117
+ for attr in frame.attributes:
118
+ existing = merged.get(attr.name)
119
+ if existing is not None and existing.value != attr.value:
120
+ raise OcelContractError(
121
+ f"Conflicting OcelEvent attribute {attr.name!r}: " f"{existing.value!r} vs {attr.value!r}"
122
+ )
123
+ merged[attr.name] = attr
124
+ return list(merged.values())
125
+
126
+ def _materialize_frame(
127
+ self,
128
+ frame: OcelFrame[BaseEntity],
129
+ ) -> tuple[list[OcelObjectRef], list[OcelObject]]:
130
+ root = frame.object
131
+ root_id = self._entity_object_id(root)
132
+ objects = [self._build_ocel_object(root)]
133
+ refs = [OcelObjectRef(object_id=root_id, qualifier=frame.qualifier)]
134
+
135
+ for field_name, relation in root.get_foreign_keys().items():
136
+ peer_qualifier = f"{frame.qualifier}.{field_name}"
137
+ if isinstance(relation, BaseRelationOne):
138
+ refs.extend(
139
+ self._materialize_relation_one(
140
+ root,
141
+ field_name,
142
+ relation,
143
+ peer_qualifier,
144
+ objects,
145
+ )
146
+ )
147
+ elif isinstance(relation, BaseRelationMany):
148
+ refs.extend(
149
+ self._materialize_relation_many(
150
+ root,
151
+ field_name,
152
+ relation,
153
+ peer_qualifier,
154
+ objects,
155
+ )
156
+ )
157
+ return refs, objects
158
+
159
+ def _materialize_relation_one(
160
+ self,
161
+ owner: BaseEntity,
162
+ field_name: str,
163
+ relation: BaseRelationOne[Any],
164
+ peer_qualifier: str,
165
+ objects: list[OcelObject],
166
+ ) -> list[OcelObjectRef]:
167
+ if relation.entity is not None:
168
+ peer = relation.entity
169
+ peer_id = self._entity_object_id(peer)
170
+ if peer_id not in {obj.id for obj in objects}:
171
+ objects.append(self._build_ocel_object(peer))
172
+ return [OcelObjectRef(object_id=peer_id, qualifier=peer_qualifier)]
173
+
174
+ related_cls = _related_entity_class(type(owner), field_name)
175
+ if related_cls is None:
176
+ raise OcelContractError(
177
+ f"Cannot materialize id-only relation {field_name!r} on "
178
+ f"{type(owner).__name__}: related entity type is unknown"
179
+ )
180
+ peer_id = make_oid(related_cls, relation.id)
181
+ if peer_id not in {obj.id for obj in objects}:
182
+ objects.append(
183
+ OcelObject(
184
+ id=peer_id,
185
+ type=self._ocel_type_name(related_cls),
186
+ attributes=[OcelAttribute(name="id", value=str(relation.id))],
187
+ )
188
+ )
189
+ return [OcelObjectRef(object_id=peer_id, qualifier=peer_qualifier)]
190
+
191
+ def _materialize_relation_many(
192
+ self,
193
+ owner: BaseEntity,
194
+ field_name: str,
195
+ relation: BaseRelationMany[Any],
196
+ peer_qualifier: str,
197
+ objects: list[OcelObject],
198
+ ) -> list[OcelObjectRef]:
199
+ _ = owner
200
+ _ = field_name
201
+ if not relation.is_loaded:
202
+ return []
203
+ refs: list[OcelObjectRef] = []
204
+ known_ids = {obj.id for obj in objects}
205
+ for peer in relation.entities:
206
+ peer_id = self._entity_object_id(peer)
207
+ if peer_id not in known_ids:
208
+ objects.append(self._build_ocel_object(peer))
209
+ known_ids.add(peer_id)
210
+ refs.append(OcelObjectRef(object_id=peer_id, qualifier=peer_qualifier))
211
+ return refs
212
+
213
+ def _build_ocel_object(self, entity: BaseEntity) -> OcelObject:
214
+ attributes: list[OcelAttribute] = []
215
+ for name, value in entity.get_scalar_fields().items():
216
+ attributes.append(OcelAttribute(name=name, value=value))
217
+ for name, lifecycle in entity.get_lifecycle_fields().items():
218
+ attributes.append(OcelAttribute(name=name, value=lifecycle.current_state))
219
+ return OcelObject(
220
+ id=self._entity_object_id(entity),
221
+ type=self._ocel_type_name(type(entity)),
222
+ attributes=attributes,
223
+ )
224
+
225
+ def _ocel_type_name(self, cls: type) -> str:
226
+ """Return OCEL event/object type label (FQN or short class name)."""
227
+ return _ocel_type_name(cls, short_names=self._short_names)
228
+
229
+ @staticmethod
230
+ def _entity_object_id(entity: BaseEntity) -> str:
231
+ pk = entity.get_primary_key()
232
+ entity_id = pk.get("id")
233
+ if entity_id is None:
234
+ raise OcelContractError(f"Entity {type(entity).__name__} has no loaded primary key 'id' for OCEL export")
235
+ return make_oid(entity, entity_id)
236
+
237
+ @staticmethod
238
+ def _event_id(event: GlobalFinishEvent) -> str:
239
+ trace_id = event.context.request.trace_id
240
+ if trace_id:
241
+ return trace_id
242
+ return str(uuid.uuid4())
243
+
244
+ def _event_time(self, event: GlobalFinishEvent) -> datetime:
245
+ ts = event.context.request.request_timestamp
246
+ if ts is not None:
247
+ return self.ensure_utc(ts)
248
+ return datetime.now(UTC)
249
+
250
+
251
+ def collect_ocel_frames(
252
+ source: GlobalFinishEvent | Mapping[str, Any] | None,
253
+ ) -> list[OcelFrame[BaseEntity]]:
254
+ """Collect ``OcelFrame`` rows from a finish event or one state-like mapping."""
255
+ if source is None:
256
+ return []
257
+ if isinstance(source, GlobalFinishEvent):
258
+ frames: list[OcelFrame[BaseEntity]] = []
259
+ for snapshot in source.all_aspect_states:
260
+ frames.extend(collect_ocel_frames(snapshot))
261
+ frames.extend(collect_ocel_frames(_mapping_from_schema(source.result)))
262
+ return frames
263
+
264
+ payload = source
265
+ if not payload:
266
+ return []
267
+ mapping_frames: list[OcelFrame[BaseEntity]] = []
268
+ if OCEL_FRAMES_KEY in payload:
269
+ mapping_frames.extend(_normalize_frame_values(payload[OCEL_FRAMES_KEY]))
270
+ for value in payload.values():
271
+ if isinstance(value, OcelFrame):
272
+ mapping_frames.append(value)
273
+ return mapping_frames
274
+
275
+
276
+ def _normalize_frame_values(value: Any) -> list[OcelFrame[BaseEntity]]:
277
+ if isinstance(value, OcelFrame):
278
+ return [value]
279
+ if isinstance(value, (list, tuple)):
280
+ return [item for item in value if isinstance(item, OcelFrame)]
281
+ return []
282
+
283
+
284
+ def _mapping_from_schema(value: Any) -> dict[str, Any]:
285
+ if isinstance(value, Mapping):
286
+ return dict(value)
287
+ model_dump = getattr(value, "model_dump", None)
288
+ if callable(model_dump):
289
+ dumped = model_dump()
290
+ if isinstance(dumped, Mapping):
291
+ return dict(dumped)
292
+ return {}
293
+
294
+
295
+ def _ocel_type_name(cls: type, *, short_names: bool) -> str:
296
+ if not short_names:
297
+ return f"{cls.__module__}.{cls.__qualname__}"
298
+ name = cls.__name__
299
+ for suffix in _OCEL_TYPE_SUFFIXES:
300
+ if name.endswith(suffix) and len(name) > len(suffix):
301
+ return name[: -len(suffix)]
302
+ return name
303
+
304
+
305
+ def _unwrap_annotation(annotation: Any) -> Any:
306
+ origin = get_origin(annotation)
307
+ if origin is Annotated:
308
+ return get_args(annotation)[0]
309
+ if origin is Union:
310
+ non_none = [arg for arg in get_args(annotation) if arg is not type(None)]
311
+ if len(non_none) == 1:
312
+ return _unwrap_annotation(non_none[0])
313
+ return annotation
314
+
315
+
316
+ def _related_entity_class(
317
+ owner: type[BaseEntity],
318
+ field_name: str,
319
+ ) -> type[BaseEntity] | None:
320
+ field = owner.model_fields.get(field_name)
321
+ if field is None:
322
+ return None
323
+ annotation = _unwrap_annotation(field.annotation)
324
+ origin = get_origin(annotation)
325
+ if origin is None:
326
+ return None
327
+ args = get_args(annotation)
328
+ if not args:
329
+ return None
330
+ related = _unwrap_annotation(args[0])
331
+ if isinstance(related, type) and issubclass(related, BaseEntity):
332
+ return related
333
+ return None
File without changes
@@ -0,0 +1,12 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/resource/__init__.py
2
+ from aoa.ocel.resource.in_memory_ocel_store_resource import InMemoryOcelStoreResource
3
+ from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
4
+ from aoa.ocel.resource.ocel_store_resource import OcelStoreResource
5
+ from aoa.ocel.resource.ocel_store_wrapper import OcelStoreWrapper
6
+
7
+ __all__ = [
8
+ "InMemoryOcelStoreResource",
9
+ "OcelStoreProtocol",
10
+ "OcelStoreResource",
11
+ "OcelStoreWrapper",
12
+ ]
@@ -0,0 +1,180 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/resource/in_memory_ocel_store_resource.py
2
+ """InMemoryOcelStoreResource — in-memory OCEL 2.0 backend."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from aoa.action_machine.exceptions.connection_already_open_error import ConnectionAlreadyOpenError
13
+ from aoa.action_machine.graph.core.exclude_graph_model import exclude_graph_model
14
+ from aoa.ocel.dto.ocel_event import OcelEvent
15
+ from aoa.ocel.dto.ocel_object import OcelObject
16
+ from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
17
+ from aoa.ocel.resource.ocel_store_resource import OcelStoreResource
18
+
19
+
20
+ def _type_catalog(type_attrs: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
21
+ """Build OCEL 2.0 eventTypes/objectTypes JSON from name → {attr: ocel_type}."""
22
+ return [
23
+ {
24
+ "name": type_name,
25
+ "attributes": [
26
+ {"name": attr_name, "type": attr_type} for attr_name, attr_type in sorted(type_attrs[type_name].items())
27
+ ],
28
+ }
29
+ for type_name in sorted(type_attrs)
30
+ ]
31
+
32
+
33
+ def _infer_ocel_type(value: Any) -> str:
34
+ if isinstance(value, bool):
35
+ return "boolean"
36
+ if isinstance(value, int):
37
+ return "integer"
38
+ if isinstance(value, float):
39
+ return "float"
40
+ if isinstance(value, datetime):
41
+ return "date"
42
+ return "string"
43
+
44
+
45
+ @exclude_graph_model
46
+ class InMemoryOcelStoreResource(OcelStoreResource):
47
+ """In-memory backend that writes OCEL 2.0 JSON on ``close()``."""
48
+
49
+ def __init__(self, output_file: Path) -> None:
50
+ self._output_file = output_file
51
+ self._lock = asyncio.Lock()
52
+ self._open = False
53
+ self._events: list[OcelEvent] = []
54
+ self._objects: dict[str, OcelObject] = {}
55
+ self._event_ids: set[str] = set()
56
+
57
+ async def open(self) -> None:
58
+ async with self._lock:
59
+ if self._open:
60
+ raise ConnectionAlreadyOpenError("InMemoryOcelStoreResource is already open.")
61
+ self._open = True
62
+
63
+ async def close(self) -> None:
64
+ async with self._lock:
65
+ if not self._open:
66
+ return
67
+ await self._write_locked()
68
+ self._open = False
69
+
70
+ async def add_event(self, event: OcelEvent) -> None:
71
+ async with self._lock:
72
+ self._assert_open()
73
+ if event.id in self._event_ids:
74
+ raise OcelContractError(f"Duplicate OcelEvent.id: {event.id}")
75
+ self._event_ids.add(event.id)
76
+ self._events.append(event)
77
+ for obj_fact in event.objects:
78
+ self._merge_object_fact(obj_fact)
79
+
80
+ def _assert_open(self) -> None:
81
+ if not self._open:
82
+ raise OcelContractError("Resource is not open. Call await resource.open() first.")
83
+
84
+ def _merge_object_fact(self, incoming: OcelObject) -> None:
85
+ if incoming.id not in self._objects:
86
+ self._objects[incoming.id] = OcelObject(
87
+ id=incoming.id,
88
+ type=incoming.type,
89
+ attributes=list(incoming.attributes),
90
+ relationships=list(incoming.relationships),
91
+ )
92
+ return
93
+
94
+ existing = self._objects[incoming.id]
95
+ existing_attrs = {a.name: a for a in existing.attributes}
96
+ for attr in incoming.attributes:
97
+ existing_attrs[attr.name] = attr
98
+ existing.attributes = list(existing_attrs.values())
99
+
100
+ existing_rels = {(r.object_id, r.qualifier) for r in existing.relationships}
101
+ for rel in incoming.relationships:
102
+ key = (rel.object_id, rel.qualifier)
103
+ if key not in existing_rels:
104
+ existing.relationships.append(rel)
105
+ existing_rels.add(key)
106
+
107
+ async def _write_locked(self) -> None:
108
+ doc = self._materialize()
109
+ self._output_file.parent.mkdir(parents=True, exist_ok=True)
110
+ self._output_file.write_text(
111
+ json.dumps(doc, ensure_ascii=False, indent=2, default=self._json_default),
112
+ encoding="utf-8",
113
+ )
114
+
115
+ @staticmethod
116
+ def _json_default(obj: Any) -> Any:
117
+ if isinstance(obj, datetime):
118
+ return obj.isoformat()
119
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
120
+
121
+ def _materialize(self) -> dict[str, Any]:
122
+ return {
123
+ "eventTypes": self._derive_event_types(),
124
+ "objectTypes": self._derive_object_types(),
125
+ "events": [self._serialize_event(ev) for ev in self._events],
126
+ "objects": [self._serialize_object(obj) for obj in sorted(self._objects.values(), key=lambda o: o.id)],
127
+ }
128
+
129
+ def _derive_event_types(self) -> list[dict[str, Any]]:
130
+ type_attrs: dict[str, dict[str, str]] = {}
131
+ for ev in self._events:
132
+ type_attrs.setdefault(ev.type, {})
133
+ for attr in ev.attributes:
134
+ if attr.name not in type_attrs[ev.type]:
135
+ type_attrs[ev.type][attr.name] = _infer_ocel_type(attr.value)
136
+ return _type_catalog(type_attrs)
137
+
138
+ def _derive_object_types(self) -> list[dict[str, Any]]:
139
+ type_attrs: dict[str, dict[str, str]] = {}
140
+ for obj in self._objects.values():
141
+ type_attrs.setdefault(obj.type, {})
142
+ for attr in obj.attributes:
143
+ if attr.name not in type_attrs[obj.type]:
144
+ type_attrs[obj.type][attr.name] = _infer_ocel_type(attr.value)
145
+ return _type_catalog(type_attrs)
146
+
147
+ def _serialize_event(self, ev: OcelEvent) -> dict[str, Any]:
148
+ return {
149
+ "id": ev.id,
150
+ "type": ev.type,
151
+ "time": ev.time.isoformat(),
152
+ "attributes": [{"name": a.name, "value": self._serialize_attr_value(a.value)} for a in ev.attributes],
153
+ "relationships": [{"objectId": r.object_id, "qualifier": r.qualifier} for r in ev.relationships],
154
+ }
155
+
156
+ def _serialize_object(self, obj: OcelObject) -> dict[str, Any]:
157
+ attrs_out: list[dict[str, Any]] = []
158
+ for attr in sorted(obj.attributes, key=lambda a: a.name):
159
+ attrs_out.append({"name": attr.name, "value": self._serialize_attr_value(attr.value)})
160
+ return {
161
+ "id": obj.id,
162
+ "type": obj.type,
163
+ "attributes": attrs_out,
164
+ "relationships": [
165
+ {"objectId": r.object_id, "qualifier": r.qualifier}
166
+ for r in sorted(obj.relationships, key=lambda r: (r.object_id, r.qualifier))
167
+ ],
168
+ }
169
+
170
+ @staticmethod
171
+ def _serialize_attr_value(value: Any) -> Any:
172
+ if isinstance(value, datetime):
173
+ return value.isoformat()
174
+ if isinstance(value, bool):
175
+ return value
176
+ if isinstance(value, (int, float)):
177
+ return value
178
+ if value is None:
179
+ return None
180
+ return str(value)
@@ -0,0 +1,24 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/resource/ocel_store_protocol.py
2
+ # pylint: disable=unnecessary-ellipsis # Protocol member bodies use ellipsis per PEP 544 stubs.
3
+ """OcelStoreProtocol — full public OCEL store interface."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+ if TYPE_CHECKING:
10
+ from aoa.ocel.dto.ocel_event import OcelEvent
11
+
12
+
13
+ class OcelStoreProtocol(Protocol):
14
+ """Full public protocol for OCEL store (§5.27)."""
15
+
16
+ async def check_rollup_support(self) -> bool:
17
+ """Same contract as ``BaseResource.check_rollup_support``."""
18
+ ...
19
+
20
+ async def open(self) -> None: ...
21
+
22
+ async def add_event(self, event: OcelEvent) -> None: ...
23
+
24
+ async def close(self) -> None: ...
@@ -0,0 +1,45 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/resource/ocel_store_resource.py
2
+ """OcelStoreResource — abstract base for OCEL persistence backends."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+
8
+ from aoa.action_machine.graph.core.exclude_graph_model import exclude_graph_model
9
+ from aoa.action_machine.resources.base_resource import BaseResource
10
+ from aoa.ocel.dto.ocel_event import OcelEvent
11
+ from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
12
+ from aoa.ocel.resource.ocel_store_wrapper import OcelStoreWrapper
13
+
14
+
15
+ @exclude_graph_model
16
+ class OcelStoreResource(BaseResource, OcelStoreProtocol, ABC):
17
+ """
18
+ AI-CORE-BEGIN
19
+ ROLE: Abstract persistent resource for OCEL 2.0 accumulation and persist on close.
20
+ CONTRACT: Public API is ``open``, ``add_event``, ``close``; nested actions get ``OcelStoreWrapper``.
21
+ INVARIANTS: ``add_event`` accepts only ``OcelEvent`` DTO; persistence runs in ``close()``.
22
+ AI-CORE-END
23
+ """
24
+
25
+ async def check_rollup_support(self) -> bool:
26
+ return False
27
+
28
+ def get_wrapper_class(self) -> type[BaseResource] | None:
29
+ return OcelStoreWrapper
30
+
31
+ @abstractmethod
32
+ async def open(self) -> None:
33
+ """Initialize the resource."""
34
+
35
+ @abstractmethod
36
+ async def add_event(self, event: OcelEvent) -> None:
37
+ """Accumulate one composite OCEL event record.
38
+
39
+ Raises:
40
+ OcelContractError: duplicate ``event.id`` or resource not open.
41
+ """
42
+
43
+ @abstractmethod
44
+ async def close(self) -> None:
45
+ """Persist accumulated data and release resources."""
@@ -0,0 +1,45 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/resource/ocel_store_wrapper.py
2
+ """OcelStoreWrapper — proxy for nested actions (mirrors WrapperSqlResource)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from aoa.action_machine.graph.core.exclude_graph_model import exclude_graph_model
7
+ from aoa.action_machine.resources.base_resource import BaseResource
8
+ from aoa.ocel.dto.ocel_event import OcelEvent
9
+ from aoa.ocel.exceptions.ocel_resource_access_prohibited_error import (
10
+ OcelResourceAccessProhibitedError,
11
+ )
12
+ from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
13
+
14
+
15
+ @exclude_graph_model
16
+ class OcelStoreWrapper(BaseResource, OcelStoreProtocol):
17
+ """Proxy wrapper for OCEL store passed into nested actions via ``ToolsBox.run``.
18
+
19
+ Delegates ``add_event``; ``open`` and ``close`` raise
20
+ ``OcelResourceAccessProhibitedError`` (§5.27.2).
21
+ """
22
+
23
+ def __init__(self, resource: OcelStoreProtocol) -> None:
24
+ self._resource = resource
25
+
26
+ async def check_rollup_support(self) -> bool:
27
+ return await self._resource.check_rollup_support()
28
+
29
+ def get_wrapper_class(self) -> type[BaseResource] | None:
30
+ return OcelStoreWrapper
31
+
32
+ async def open(self) -> None:
33
+ raise OcelResourceAccessProhibitedError(
34
+ "Opening OCEL store is allowed only in the action that created the resource. "
35
+ "Current action received a proxy connection, so open is unavailable."
36
+ )
37
+
38
+ async def add_event(self, event: OcelEvent) -> None:
39
+ await self._resource.add_event(event)
40
+
41
+ async def close(self) -> None:
42
+ raise OcelResourceAccessProhibitedError(
43
+ "Closing OCEL store is allowed only in the action that created the resource. "
44
+ "Current action received a proxy connection, so close is unavailable."
45
+ )
@@ -0,0 +1,31 @@
1
+ # packages/aoa-ocel/src/aoa/ocel/type_id.py
2
+ """Short OCEL type prefixes and object IDs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ import xxhash
9
+
10
+ _prefix_cache: dict[str, str] = {}
11
+
12
+
13
+ def make_oid(obj: Any, original_id: str | int | None = None) -> str:
14
+ """Short OCEL type id, or object id when ``original_id`` is set.
15
+
16
+ Without ``original_id``: ``orde_a1b`` (type prefix for ``event.type`` / ``object.type``).
17
+ With ``original_id``: ``orde_a1b_123`` (full ``object.id``).
18
+ """
19
+ cls = obj if isinstance(obj, type) else type(obj)
20
+ full_name = f"{cls.__module__}.{cls.__qualname__}"
21
+
22
+ prefix = _prefix_cache.get(full_name)
23
+ if prefix is None:
24
+ base = cls.__name__[:4].lower()
25
+ suffix = xxhash.xxh32(full_name.encode()).hexdigest()[:3]
26
+ prefix = f"{base}_{suffix}"
27
+ _prefix_cache[full_name] = prefix
28
+
29
+ if original_id is None:
30
+ return prefix
31
+ return f"{prefix}_{original_id}"