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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
use serde_json::json;
/// Next-Activity Prediction — Van der Aalst perspective
///
/// In process mining, "next activity prediction" answers the question:
/// *Given the activities observed so far in a running case (the prefix),
/// which activity is most likely to occur next?*
///
/// This module consolidates all WASM-exported functions for next-activity
/// prediction, including:
///
/// - **Top-k prediction** (`predict_next_k`): returns the k most probable
/// successor activities with probabilities and a confidence/entropy score.
/// - **Beam-search paths** (`predict_beam_paths`): explores the most likely
/// future continuations of a case prefix using beam search over the n-gram
/// transition model.
///
/// Both functions operate on an `NGramPredictor` handle that was previously
/// built via `build_ngram_predictor`. The predictor encodes an n-gram Markov
/// chain learned from completed traces — a lightweight but effective baseline
/// that Van der Aalst and colleagues use as a reference in predictive process
/// monitoring research.
use wasm_bindgen::prelude::*;
use crate::state::{get_or_init_state, StoredObject};
/// Return the top-k most likely next activities for a given prefix.
///
/// `model_handle` — handle returned by `build_ngram_predictor`.
/// `prefix_json` — JSON array of activity name strings, e.g. `["A","B"]`.
/// `k` — how many candidates to return.
///
/// Returns a JSON object:
/// ```json
/// {
/// "activities": ["C", "D"],
/// "probabilities": [0.75, 0.25],
/// "confidence": 0.75,
/// "entropy": 0.56
/// }
/// ```
/// `confidence` is the probability of the top-1 prediction.
/// `entropy` is the normalised Shannon entropy of the distribution (0 = certain,
/// 1 = uniform).
#[wasm_bindgen]
pub fn predict_next_k(model_handle: &str, prefix_json: &str, k: usize) -> Result<JsValue, JsValue> {
let prefix: Vec<String> = serde_json::from_str(prefix_json)
.map_err(|e| crate::error::js_val(&format!("Invalid prefix JSON: {}", e)))?;
get_or_init_state().with_object(model_handle, |obj| match obj {
Some(StoredObject::NGramPredictor(predictor)) => {
// Get full ranked predictions from the predictor
let all_preds = predictor.predict(&prefix);
let top_k: Vec<_> = all_preds.into_iter().take(k).collect();
let activities: Vec<&str> = top_k.iter().map(|(a, _)| a.as_str()).collect();
let probabilities: Vec<f64> = top_k.iter().map(|(_, p)| *p).collect();
let confidence = probabilities.first().copied().unwrap_or(0.0);
let entropy_val = normalised_entropy(&probabilities);
let result = json!({
"activities": activities,
"probabilities": probabilities,
"confidence": confidence,
"entropy": entropy_val,
});
serde_json::to_string(&result)
.map(|s| crate::error::js_val(&s))
.map_err(|e| crate::error::js_val(&e.to_string()))
}
Some(_) => Err(crate::error::js_val("Handle is not an NGramPredictor")),
None => Err(crate::error::js_val("NGramPredictor handle not found")),
})
}
/// Beam-search future paths from a case prefix.
///
/// `model_handle` — handle returned by `build_ngram_predictor`.
/// `prefix_json` — JSON array of activity name strings.
/// `beam_width` — number of beams (candidate paths) to keep at each step.
/// `max_steps` — maximum number of future activities to predict.
///
/// Returns a JSON array of paths:
/// ```json
/// [
/// { "sequence": ["C","D","E"], "probability": 0.42, "length": 3 },
/// { "sequence": ["C","F"], "probability": 0.18, "length": 2 }
/// ]
/// ```
/// Paths are sorted descending by probability.
#[wasm_bindgen]
pub fn predict_beam_paths(
model_handle: &str,
prefix_json: &str,
beam_width: usize,
max_steps: usize,
) -> Result<JsValue, JsValue> {
let prefix: Vec<String> = serde_json::from_str(prefix_json)
.map_err(|e| crate::error::js_val(&format!("Invalid prefix JSON: {}", e)))?;
get_or_init_state().with_object(model_handle, |obj| match obj {
Some(StoredObject::NGramPredictor(predictor)) => {
let paths = beam_search_on_ngram(predictor, &prefix, beam_width, max_steps);
serde_json::to_string(&paths)
.map(|s| crate::error::js_val(&s))
.map_err(|e| crate::error::js_val(&e.to_string()))
}
Some(_) => Err(crate::error::js_val("Handle is not an NGramPredictor")),
None => Err(crate::error::js_val("NGramPredictor handle not found")),
})
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Shannon entropy normalised to [0, 1].
fn normalised_entropy(probs: &[f64]) -> f64 {
if probs.is_empty() {
return 0.0;
}
let ent: f64 = probs
.iter()
.filter(|&&p| p > 0.0)
.map(|&p| -p * p.ln())
.sum();
let max_ent = (probs.len() as f64).ln();
if max_ent > 0.0 {
ent / max_ent
} else {
0.0
}
}
/// Beam search over a string-keyed `NGramPredictor`.
///
/// Each beam is a `(Vec<String>, f64)` — the full activity sequence (prefix +
/// predicted suffix) and its cumulative probability. At every step we expand
/// each beam by querying `predictor.predict(...)` and keep the top
/// `beam_width` candidates.
fn beam_search_on_ngram(
predictor: &crate::models::NGramPredictor,
prefix: &[String],
beam_width: usize,
max_steps: usize,
) -> Vec<serde_json::Value> {
// Each beam: (full_sequence, cumulative_probability)
let mut beams: Vec<(Vec<String>, f64)> = vec![(prefix.to_vec(), 1.0)];
for _ in 0..max_steps {
let mut next_beams: Vec<(Vec<String>, f64)> = Vec::new();
for (seq, prob) in &beams {
let preds = predictor.predict(seq);
if preds.is_empty() {
// Dead end — keep beam as-is so it appears in results
next_beams.push((seq.clone(), *prob));
continue;
}
for (act, trans_prob) in &preds {
let new_prob = prob * trans_prob;
let mut new_seq = seq.clone();
new_seq.push(act.clone());
next_beams.push((new_seq, new_prob));
}
}
if next_beams.is_empty() {
break;
}
next_beams.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
// Deduplicate beams that are identical (keep highest prob)
beams = next_beams.into_iter().take(beam_width).collect();
}
// Build output — only include the predicted suffix (after the original prefix)
let prefix_len = prefix.len();
beams
.into_iter()
.filter(|(seq, _)| seq.len() > prefix_len) // exclude empty extensions
.map(|(seq, prob)| {
let suffix: Vec<&str> = seq[prefix_len..].iter().map(|s| s.as_str()).collect();
json!({
"sequence": suffix,
"probability": prob,
"length": suffix.len(),
})
})
.collect()
}