1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
//! Trace embeddings — project trace variants into a low-dimensional PCA space.
//!
//! Each trace is represented as a bag-of-activities vector (activity count per trace),
//! then reduced with PCA so that similar traces cluster together in 2-D / n-D space.
//!
//! Gated on the `miniml` feature (miniml-core crate).
#![cfg(feature = "miniml")]
use wasm_bindgen::prelude::*;
use crate::state::{get_or_init_state, StoredObject};
/// Cap `requested` to the PCA-valid range `[1, min(vocab_size, n_traces - 1)]`.
///
/// PCA has at most `min(n_samples, n_features)` non-trivial components. For
/// the bag-of-activities case `n_samples = n_traces`, `n_features = vocab_size`,
/// and the strict bound is `min(vocab_size, n_traces - 1)` because one degree
/// of freedom is consumed by centring. The minimum return value is 1 so the
/// output array is never empty.
///
/// # Panics
/// Asserts that `n_traces >= 2` (caller guarantees this).
fn cap_n_components(requested: usize, vocab_size: usize, n_traces: usize) -> usize {
debug_assert!(n_traces >= 2, "cap_n_components requires n_traces >= 2");
requested.clamp(1, vocab_size.min(n_traces - 1))
}
/// Project trace variants into a reduced-dimension PCA space.
///
/// Each trace becomes a bag-of-activities vector over the vocabulary, then
/// PCA reduces that to `n_components` dimensions.
///
/// # Arguments
/// * `log_handle` — Handle returned by `load_eventlog_from_xes` / `load_eventlog_from_json`.
/// * `activity_key` — Event attribute name for activity labels (e.g. `"concept:name"`).
/// * `n_components` — Number of PCA dimensions (capped at min(vocab_size, n_traces-1)).
///
/// # Returns
/// JSON string:
/// ```json
/// {
/// "n_traces": 42,
/// "n_components": 2,
/// "vocab_size": 8,
/// "explained_variance_ratio": [0.62, 0.21],
/// "points": [
/// { "trace_index": 0, "coords": [1.2, -0.3], "trace_length": 5 },
/// ...
/// ]
/// }
/// ```
#[wasm_bindgen]
pub fn project_trace_variants(
log_handle: &str,
activity_key: &str,
n_components: usize,
) -> Result<JsValue, JsValue> {
get_or_init_state().with_object(log_handle, |obj| {
let log = match obj {
Some(StoredObject::EventLog(l)) => l,
Some(_) => {
return Err(crate::error::wasm_err(
crate::error::codes::INVALID_INPUT,
"project_trace_variants: handle is not an EventLog",
))
}
None => {
return Err(crate::error::wasm_err(
crate::error::codes::INVALID_INPUT,
format!("project_trace_variants: handle '{}' not found", log_handle),
))
}
};
// Build columnar representation (borrowed, tied to log lifetime)
let col = log.to_columnar(activity_key);
let vocab_size = col.vocab.len();
let n_traces = col.trace_offsets.len().saturating_sub(1);
if vocab_size == 0 {
return Err(crate::error::wasm_err(
crate::error::codes::INVALID_INPUT,
"project_trace_variants: event log has no activities (empty vocabulary)",
));
}
if n_traces < 2 {
return Err(crate::error::wasm_err(
crate::error::codes::INVALID_INPUT,
"project_trace_variants: need at least 2 traces for PCA",
));
}
if vocab_size > 200 {
return Err(crate::error::wasm_err(
crate::error::codes::INVALID_INPUT,
"project_trace_variants: vocabulary too large for PCA (>200 activities)",
));
}
// Cap n_components per documented invariant:
// 1 ≤ n_comp ≤ min(vocab_size, n_traces - 1)
let n_comp = cap_n_components(n_components, vocab_size, n_traces);
// Build flat bag-of-activities matrix: [n_traces × vocab_size]
let mut flat_data = vec![0.0f64; n_traces * vocab_size];
for t in 0..n_traces {
let start = col.trace_offsets[t];
let end = col.trace_offsets[t + 1];
for &ev_id in &col.events[start..end] {
flat_data[t * vocab_size + ev_id as usize] += 1.0;
}
}
// Run PCA via miniml-core
let pca_result = miniml::pca_impl(&flat_data, vocab_size, n_comp).map_err(|e| {
crate::error::wasm_err(
crate::error::codes::INTERNAL_ERROR,
format!("PCA failed: {}", e),
)
})?;
let transformed = pca_result.get_transformed(); // Vec<f64>, shape [n_traces * n_comp]
let evr = pca_result.get_explained_variance_ratio();
// Build per-trace point objects
let mut points = Vec::with_capacity(n_traces);
for t in 0..n_traces {
let coords: Vec<f64> = (0..n_comp).map(|c| transformed[t * n_comp + c]).collect();
let trace_len = col.trace_offsets[t + 1] - col.trace_offsets[t];
points.push(serde_json::json!({
"trace_index": t,
"coords": coords,
"trace_length": trace_len,
}));
}
let result = serde_json::json!({
"n_traces": n_traces,
"n_components": n_comp,
"vocab_size": vocab_size,
"explained_variance_ratio": evr,
"points": points,
});
Ok(JsValue::from_str(&result.to_string()))
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Rank-1 invariants for the PCA component cap: upper-bounded by
/// `min(vocab_size, n_traces - 1)`, never returns 0, identity-in-range,
/// and idempotent (cap(cap(x)) == cap(x)).
#[test]
fn cap_n_components_invariants() {
assert_eq!(cap_n_components(99, 5, 100), 5); // capped by vocab
assert_eq!(cap_n_components(99, 100, 5), 4); // capped by n-1
assert_eq!(cap_n_components(99, 3, 7), 3); // tighter wins (vocab)
assert_eq!(cap_n_components(99, 50, 4), 3); // tighter wins (n-1)
assert_eq!(cap_n_components(0, 5, 100), 1); // never zero
assert_eq!(cap_n_components(5, 1, 10), 1); // vocab=1 → 1
assert_eq!(cap_n_components(3, 10, 20), 3); // identity in range
let once = cap_n_components(99, 7, 30);
assert_eq!(once, cap_n_components(once, 7, 30)); // idempotent
}
}