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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! Analytics methods: forecast, anomaly detection, and preprocessing.
use arrow::array;
use super::Chronix;
use crate::analytics::{AnomalyConfig, ForecastConfig};
use crate::error::{DbError, Result};
impl Chronix {
/// Forecast future values for a series.
///
/// Fetches historical data for the given measurement and field,
/// fits a forecast model, and returns predicted values.
pub fn forecast(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
horizon: usize,
config: Option<ForecastConfig>,
) -> Result<chronix_analytics::forecast::ForecastResult> {
use chronix_analytics::forecast::{
ArimaModel, ForecastModel, HoltLinearModel, HoltWintersModel, LinearRegressionModel,
SarimaModel, SesModel,
};
let cfg = config.unwrap_or_default();
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
if values.len() < 2 {
return Err(DbError::Internal(
"forecast: need at least 2 data points".into(),
));
}
let mut model: Box<dyn ForecastModel> = match cfg.model.as_deref() {
Some("ses") | None => Box::new(SesModel::new(None)),
Some("holt") => Box::new(HoltLinearModel::new(None, None, 0.98)),
Some("holt_winters") => {
Box::new(HoltWintersModel::new(None, None, None, cfg.period, false))
}
Some("arima") => {
let (p, d, q) = cfg.arima_order.unwrap_or((1, 1, 1));
Box::new(ArimaModel::new(p, d, q))
}
Some("sarima") => {
let (p, d, q) = cfg.arima_order.unwrap_or((1, 1, 1));
let (sp, sd, sq, m) =
cfg.sarima_order
.unwrap_or((1, 1, 1, cfg.period.unwrap_or(24)));
Box::new(SarimaModel::new(p, d, q, sp, sd, sq, m))
}
Some("linear_regression") => Box::new(LinearRegressionModel::new()),
// A name that is neither built in nor registered is an error, not
// a reason to quietly fit something else: a typo in `"holtwinters"`
// used to return a simple exponential smoothing forecast under the
// caller's belief that it was seasonal.
Some(name) => match chronix_analytics::registry::global_registry()
.try_create_model_default(name)
{
Some(Ok(model)) => model,
Some(Err(e)) => return Err(DbError::Internal(e.to_string())),
None => {
return Err(DbError::InvalidRequest(format!(
"forecast: unknown model {name:?}; expected one of \"ses\", \"holt\", \
\"holt_winters\", \"arima\", \"sarima\", \"linear_regression\", \
or a name registered with the model registry"
)));
}
},
};
model
.fit(×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))?;
let result = model
.predict(horizon)
.map_err(|e| DbError::Internal(e.to_string()))?;
// Every model builds a 95 % interval; `confidence` used to be read by
// nothing at all, so a caller asking for 99 % got 95 % and no warning.
result
.with_confidence(cfg.confidence)
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Forecast a series with the model chosen automatically.
///
/// Fetches the window, then hands it to
/// [`select_model`](chronix_analytics::forecast::select_model), which
/// detects the seasonal period, chooses a differencing order by a KPSS
/// unit-root test, searches an ARIMA order, and scores every eligible
/// candidate by rolling-origin cross-validation **at `horizon`** — the
/// only comparison that transfers across model families. The winner is
/// refitted on the whole window.
///
/// The returned [`AutoForecast`](chronix_analytics::forecast::AutoForecast)
/// carries the forecast *and* the reasoning: which model won, what every
/// candidate scored, and why anything that was not scored was left out.
///
/// # Errors
///
/// Returns [`DbError`] if the window cannot be read, if it is too short to
/// hold a training window plus a `horizon`-long test window, or if no
/// candidate could be cross-validated.
pub fn auto_forecast(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
horizon: usize,
options: Option<chronix_analytics::forecast::AutoForecastOptions>,
) -> Result<chronix_analytics::forecast::AutoForecast> {
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
chronix_analytics::forecast::auto_forecast(
×tamps,
&values,
horizon,
&options.unwrap_or_default(),
)
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Detect anomalies in a series.
///
/// Fetches historical data, fits a detector, and returns scores.
pub fn detect_anomalies(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
config: Option<AnomalyConfig>,
) -> Result<Vec<chronix_analytics::anomaly::AnomalyScore>> {
use chronix_analytics::anomaly::{
AnomalyDetector, DynamicThresholdDetector, ForecastResidualDetector, IqrDetector,
ModifiedZScoreDetector, MovingAverageResidualDetector, ZScoreDetector,
};
let cfg = config.unwrap_or_default();
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
if values.len() < 3 {
return Err(DbError::Internal(
"detect_anomalies: need at least 3 data points".into(),
));
}
// The name is refused if it is not one this database has, exactly as
// `forecast` refuses an unknown model one function above. It used to
// fall through to a z-score and say nothing, so `"modified-zscore"` —
// a plausible typo for the *median/MAD* detector, which is chosen
// precisely because it is robust to the outliers a mean and standard
// deviation are not — returned mean-based scores under
// `method: ZScore`.
//
// `"zscore"` needs a branch of its own now. It never had one: it is
// the first name in `AnomalyConfig::method`'s documented list and it
// reached its detector only by falling through the same arm that
// swallowed the typos, which is why nobody noticed the arm.
let mut detector: Box<dyn AnomalyDetector> = match cfg.method.as_deref() {
Some("zscore") | None => Box::new(ZScoreDetector::new(Some(cfg.threshold))),
Some("iqr") => Box::new(IqrDetector::new(Some(cfg.threshold))),
Some("modified_zscore") => Box::new(ModifiedZScoreDetector::new(Some(cfg.threshold))),
Some("dynamic_threshold") => Box::new(DynamicThresholdDetector::new(
cfg.window_size,
Some(cfg.threshold),
)),
Some("forecast_residual") => {
Box::new(ForecastResidualDetector::new(Some(cfg.threshold), None))
}
Some("moving_average") => Box::new(MovingAverageResidualDetector::new(
cfg.window_size,
Some(cfg.threshold),
)),
Some(name) => match chronix_analytics::registry::global_registry()
.try_create_detector_default(name)
{
Some(Ok(det)) => det,
Some(Err(e)) => return Err(DbError::Internal(e.to_string())),
None => {
return Err(DbError::InvalidRequest(format!(
"detect_anomalies: unknown detector {name:?}; expected one of \
\"zscore\", \"modified_zscore\", \"iqr\", \"dynamic_threshold\", \
\"forecast_residual\", \"moving_average\", or a name registered \
with the detector registry"
)));
}
},
};
detector
.fit(×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))?;
detector
.detect(×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Preprocess a series (interpolate, smooth, resample).
#[allow(clippy::needless_pass_by_value)] // by-value config mirrors the sibling forecast/anomaly APIs
pub fn preprocess(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
config: chronix_analytics::preprocess::PreprocessConfig,
) -> Result<chronix_analytics::preprocess::PreprocessResult> {
let (timestamps, values) =
self.fetch_series_data(measurement, field, tags, start_ns, end_ns)?;
chronix_analytics::preprocess::PreprocessPipeline::run(&config, ×tamps, &values)
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Fetch raw (timestamps, values) for a single field+tags combination.
///
/// Uses memtable scan for in-memory data. For persisted data, build
/// a query via [`Chronix::query()`] and [`Chronix::execute()`].
pub fn fetch_series_data(
&self,
measurement: &str,
field: &str,
tags: &[(&str, &str)],
start_ns: i64,
end_ns: i64,
) -> Result<(Vec<i64>, Vec<f64>)> {
// Use the full query engine so both memtable AND on-disk segments
// are scanned — not just the memtable.
let mut builder = self.query().measurement(measurement);
for &(k, v) in tags {
builder = builder.tag(k, v);
}
builder = builder.range(start_ns, end_ns);
let plan = builder.build()?;
let batches = self.execute_stream(&plan)?;
let mut timestamps = Vec::new();
let mut values = Vec::new();
for batch in &batches {
let schema = batch.schema();
// Find timestamp column
let ts_idx = schema
.fields()
.iter()
.position(|f| f.name() == chronix_core::TIME_COLUMN);
let field_idx = schema.index_of(field).ok();
if let (Some(ts_idx), Some(field_idx)) = (ts_idx, field_idx) {
let ts_arr = batch.column(ts_idx);
let val_arr = batch.column(field_idx);
if let Some(ts_i64) = ts_arr.as_any().downcast_ref::<array::Int64Array>() {
for row in 0..batch.num_rows() {
if val_arr.is_null(row) {
continue;
}
let v = if let Some(a) =
val_arr.as_any().downcast_ref::<array::Float64Array>()
{
a.value(row)
} else if let Some(a) = val_arr.as_any().downcast_ref::<array::Int64Array>()
{
a.value(row) as f64
} else if let Some(a) =
val_arr.as_any().downcast_ref::<array::UInt64Array>()
{
a.value(row) as f64
} else {
continue;
};
timestamps.push(ts_i64.value(row));
values.push(v);
}
}
}
}
Ok((timestamps, values))
}
}