# Object-Centric Process Mining (OCPM) on ClickHouse

**Last updated**: 2026-03-22
**Author**: Joe Lanzone
**Status**: Implementation complete (v1)

---

## What is OCPM?

Traditional process mining is **case-centric**: one case ID (e.g., `order.id`) defines the unit of analysis. Every event belongs to exactly one case, and variants are computed per case.

**Object-Centric Process Mining** (OCPM) recognizes that real business processes involve multiple interacting objects simultaneously. In ServiceLink's appraisal workflow:

- An **Order** triggers an **Appraisal**, which requires a **Vendor**, produces a **Document**, may involve an **Inspector**, and uses **Calendar** scheduling slots.
- A single vendor handles multiple orders concurrently — creating contention invisible to case-centric analysis.
- A document review can block multiple downstream orders.

OCPM lets events link to multiple object types simultaneously, revealing cross-object bottlenecks that case-centric analysis misses entirely.

---

## Why OCPM for ServiceLink?

ServiceLink's appraisal workflow is inherently multi-object. The current case-centric approach (one case = one `order.id`) misses:

| Hidden Pattern | What Case-Centric Misses | What OCPM Reveals |
|---------------|-------------------------|-------------------|
| Vendor contention | "Order X took 3 days" | "Vendor V had 12 concurrent orders — the bottleneck is vendor capacity, not order complexity" |
| Document dependencies | "Review took 2 hours" | "3 orders were blocked waiting for the same comp report" |
| Portal-to-action transitions | "Operator clicked 15 pages" | "73% of portal browsing precedes a system action — that's information gathering, not waste" |
| Cross-category rework | "Order had 2 revision loops" | "Quality Control → Document transitions average 4.2 hours — that's the QC-to-fix handoff delay" |

---

## ClickHouse-Native Implementation

This is the **first OCPM implementation built entirely on ClickHouse** using standard SQL. Celonis requires their proprietary PQL engine and OCPM add-on module ($100K+/yr). Our approach uses ClickHouse's array functions and window functions to achieve equivalent analysis at zero incremental cost.

### Object Category Derivation

Instead of requiring schema changes or new instrumentation, we derive object types from existing `SpanName` prefixes:

```sql
CASE
  WHEN SpanName LIKE 'email.%'      THEN 'Communication'
  WHEN SpanName LIKE 'page.%'       THEN 'Portal'
  WHEN SpanName LIKE 'action.%'     THEN 'System Action'
  WHEN SpanName LIKE 'order.%'      THEN 'Order Lifecycle'
  WHEN SpanName LIKE 'review.%'     THEN 'Quality Control'
  WHEN SpanName LIKE 'report.%'     THEN 'Document'
  WHEN SpanName LIKE 'inspection.%' THEN 'Field Work'
  WHEN SpanName LIKE 'appraisal.%'  THEN 'Valuation'
  ELSE 'Other'
END
```

This maps directly to the SpanName taxonomy already in `otel_traces` — no new instrumentation needed.

### Key Attributes

| Attribute | Role in OCPM |
|-----------|-------------|
| `SpanAttributes['order.id']` | Case key (groups events into orders) |
| `SpanAttributes['operator.id']` | Actor (who performed the action) |
| `SpanName` prefix | Object category (what type of object was involved) |
| `Timestamp` | Event ordering within and across objects |

---

## SQL Queries

### 1. Object Interaction Matrix

**Question**: "Which object categories co-occur most within the same order?"

**ClickHouse primitives**: `arrayDistinct()`, `groupArray()`, `arrayJoin()`

```sql
SELECT c1 AS category_a, c2 AS category_b,
  count() AS co_occurrence_count,
  uniqExact(oid) AS shared_orders
FROM (
  SELECT oid,
    arrayJoin(categories) AS c1,
    arrayJoin(categories) AS c2
  FROM (
    SELECT SpanAttributes['order.id'] AS oid,
      arrayDistinct(groupArray(
        CASE WHEN SpanName LIKE 'email.%' THEN 'Communication'
             WHEN SpanName LIKE 'page.%' THEN 'Portal'
             ... END
      )) AS categories
    FROM otel_traces
    WHERE SpanAttributes['order.id'] != ''
    GROUP BY oid
    HAVING length(categories) >= 2
  )
) WHERE c1 < c2
GROUP BY c1, c2
ORDER BY co_occurrence_count DESC
```

**Key insight**: `arrayJoin()` explodes the per-order category array into a cross product, then `WHERE c1 < c2` ensures we count each pair once.

### 2. Cross-Category Variant Analysis

**Question**: "How many unique path patterns exist within each object category?"

**ClickHouse primitives**: `groupArray()`, `cityHash64()`, `topK()`

```sql
SELECT category,
  count() AS total_orders,
  uniqExact(path_hash) AS unique_variants,
  topK(5)(path_signature) AS top_paths
FROM (
  SELECT oid, category,
    cityHash64(arrayStringConcat(steps, '→')) AS path_hash,
    arrayStringConcat(steps, ' → ') AS path_signature
  FROM (
    SELECT SpanAttributes['order.id'] AS oid,
      CASE...category...END AS category,
      groupArray(SpanName) AS steps
    FROM otel_traces
    WHERE SpanAttributes['order.id'] != ''
    ORDER BY oid, Timestamp
    GROUP BY oid, category
    HAVING length(steps) >= 2
  )
) GROUP BY category
```

**Key insight**: `cityHash64()` provides fast variant hashing without materializing the full string. `topK()` is a ClickHouse approximate aggregation that returns the most frequent paths without sorting the full dataset.

### 3. Cross-Category Bottlenecks

**Question**: "Which category transitions take the longest?"

**ClickHouse primitives**: `lagInFrame()`, `dateDiff()`, `quantile()`

```sql
SELECT prev_category, current_category,
  prev_step, current_step,
  count() AS transition_count,
  round(avg(gap_minutes), 1) AS avg_minutes,
  round(quantile(0.95)(gap_minutes), 1) AS p95_minutes
FROM (
  SELECT
    CASE...END AS current_category,
    lagInFrame(CASE...END) OVER (PARTITION BY order.id ORDER BY Timestamp) AS prev_category,
    dateDiff('minute', lagInFrame(Timestamp) OVER (...), Timestamp) AS gap_minutes
  FROM otel_traces
  WHERE SpanAttributes['order.id'] != ''
)
WHERE prev_category != current_category
GROUP BY prev_category, current_category, prev_step, current_step
ORDER BY avg_minutes DESC
```

**Key insight**: The `lagInFrame()` window function computes the previous event's category *within the same order partition*, enabling cross-category transition timing without self-joins.

### 4. Actor-Object Affinity

**Question**: "Which operators work most with which object categories?"

```sql
SELECT operator_id, category,
  count() AS event_count,
  uniqExact(SpanAttributes['order.id']) AS order_count
FROM (
  SELECT SpanAttributes['operator.id'] AS operator_id,
    CASE...END AS category
  FROM otel_traces
  WHERE SpanAttributes['operator.id'] != ''
)
GROUP BY operator_id, category
```

### 5. Object Lifecycle Duration

**Question**: "How long does each object category's involvement last per order?"

```sql
SELECT category,
  count() AS order_count,
  round(avg(lifecycle_hours), 1) AS avg_hours,
  round(quantile(0.5)(lifecycle_hours), 1) AS median_hours,
  round(quantile(0.95)(lifecycle_hours), 1) AS p95_hours
FROM (
  SELECT SpanAttributes['order.id'] AS oid,
    CASE...END AS category,
    dateDiff('hour', min(Timestamp), max(Timestamp)) AS lifecycle_hours
  FROM otel_traces
  GROUP BY oid, category
  HAVING lifecycle_hours > 0
)
GROUP BY category
```

---

## Comparison with Celonis OCPM

| Capability | Celonis OCPM | EXOS OCPM (ClickHouse) |
|-----------|-------------|----------------------|
| Object type definition | Manual configuration in EMS | Automatic from SpanName prefixes |
| Interaction graph | Built-in visualization | SQL + frontend rendering |
| Cross-object bottlenecks | PQL queries | `lagInFrame()` window functions |
| Variant analysis by object | PQL `VARIANT()` function | `groupArray()` + `cityHash64()` |
| Actor-object affinity | Task Mining (separate license) | `GROUP BY operator × category` |
| Object lifecycle timing | Built-in KPI | `dateDiff(min, max)` + `quantile()` |
| Query language | PQL (proprietary) | Standard SQL |
| Incremental cost | $100K+/yr add-on | $0 (same ClickHouse instance) |
| Setup time | Weeks (data model + connectors) | Zero (uses existing OTel spans) |

### What Celonis Does Better

- **Visual object interaction graph** with drag-and-drop exploration
- **Object-centric conformance checking** with formal process models
- **Simulation mode** for what-if analysis across object types
- **Enterprise connectors** (SAP, Salesforce, ServiceNow)

### What EXOS Does Better

- **Zero setup**: derives object types from existing span names
- **Standard SQL**: no proprietary query language to learn
- **Unified platform**: OCPM + session replay + product analytics in one tool
- **Cost**: $0 incremental vs. $100K+/yr Celonis OCPM module
- **Transparency**: every query is visible and auditable

---

## API Reference

### `GET /api/ocpm`

**Parameters**:
- `hours` (optional, default 720): Lookback window in hours (1-2160)

**Response structure**:
```json
{
  "interaction_matrix": {
    "pairs": [{"category_a": "Communication", "category_b": "Portal", "co_occurrences": 1234, "shared_orders": 456}],
    "clickhouse_function": "arrayJoin() + arrayDistinct() + groupArray()"
  },
  "category_variants": {
    "categories": [{"category": "Communication", "total_orders": 500, "unique_variants": 23, "top_paths": [...]}]
  },
  "category_bottlenecks": {
    "transitions": [{"from_category": "Quality Control", "to_category": "Document", "avg_minutes": 252, "p95_minutes": 840}]
  },
  "actor_affinity": {
    "operators": [{"operator_id": "op-437", "categories": {"Communication": 120, "Portal": 80}, "total_events": 200}]
  },
  "object_lifecycle": {
    "categories": [{"category": "Communication", "avg_hours": 48.2, "median_hours": 36.0, "p95_hours": 120.5}]
  },
  "primitives_used": [...]
}
```

---

## Novel Contribution

This implementation represents the first open-source, ClickHouse-native OCPM system. Key innovations:

1. **Prefix-based object typing**: Instead of requiring explicit object type annotations, we derive types from SpanName prefixes that are already part of the OTel instrumentation standard. This means any organization with OTel traces can use OCPM without changing their instrumentation.

2. **Array function co-occurrence**: Using `arrayJoin()` on `arrayDistinct(groupArray(...))` to compute object co-occurrence matrices is a novel pattern that avoids the self-joins typically needed for interaction analysis.

3. **Window function category transitions**: Combining `lagInFrame()` with inline `CASE` expressions for category mapping allows cross-object bottleneck detection in a single pass — no materialized views or pre-computation required.

4. **Approximate variant counting**: Using `cityHash64()` for path hashing combined with `topK()` for approximate top-K provides O(1) memory variant analysis regardless of path cardinality.

---

## Future Work

- **Materialized Views**: Pre-compute interaction matrices and lifecycle metrics for sub-second dashboard loads
- **Object-centric conformance**: Define expected multi-object sequences and measure conformance rates
- **Shared-object contention**: Detect when multiple orders compete for the same vendor/inspector
- **Temporal object graphs**: Visualize how object interactions change over time (weekly/monthly trends)
