File size: 12,485 Bytes
e201ae4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""analyze_forecast — project a time series forward with exponential smoothing (§0.9).

First of the ML analytical tools. Buckets rows into periods (reusing analyze_trend's
date parsing + resampling), fits a statsmodels ETS (Holt-Winters, additive trend, no
AutoML — locked decision) OFF the event loop via `run_fit`, and returns the history +
forecast series plus an actual-vs-forecast line chart ("sepaket").

Follows docs/tools/ANALYTICAL_TOOLS_CONVENTIONS.md: pure-ish compute function (async
ONLY to `await run_fit` for the fit), `data` never self-fetched (Pattern A), never
catches its own errors (the invoker's never-throw seam wraps them), deterministic
output (ETS optimisation is deterministic — no seed needed).
"""

from __future__ import annotations

import numpy as np
import pandas as pd
from pydantic import BaseModel, Field

from src.tools.analytics.agg_vocab import normalise_agg
from src.tools.analytics.charts import (
    _LINE_STYLE,
    COLORWAY,
    LAYOUT_PRESET,
    _axis,
    envelope,
)
from src.tools.analytics.descriptive import ColumnNotFoundError
from src.tools.analytics.temporal import (
    FREQ_MAP,
    SUPPORTED_AGGS,
    InvalidFrequencyError,
    UnsupportedAggregationError,
    _clean,
    _parse_date_column,
    _period_label,
)
from src.tools.ml.executor import run_fit
from src.tools.spec_builder import build_description

# ETS with an additive trend and estimated initialisation needs a few points to
# be meaningful; below this we raise rather than emit a garbage projection.
_MIN_POINTS = 4
# A holdout backtest needs points to train on AND points to score against; below this
# the forecast ships UNVALIDATED, which is itself weak evidence (see `quality`).
_MIN_BACKTEST_POINTS = 8
# MASE >= 1 means the model did no better than "next = last" on the holdout — the
# floor the reserved `quality` block reports and the checkpoint's CK7 reads.
_MASE_FLOOR = 1.0


class NotEnoughDataError(ValueError):
    """Too few non-empty periods to fit a forecast (error_code NOT_ENOUGH_DATA)."""


class InvalidHorizonError(ValueError):
    """horizon is not a positive integer (error_code INVALID_HORIZON)."""


class ForecastInput(BaseModel):
    # `data` FIRST — property order follows field order (planner-prompt readability).
    data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).")
    date_column: str = Field(..., description="Column holding dates/timestamps to bucket on.")
    value_column: str = Field(..., description="Numeric column to project into the future.")
    horizon: int = Field(6, description="How many future periods to forecast (>= 1).")
    freq: str = Field("month", description="Period granularity: day, week, month, quarter, year.")
    agg: str = Field("sum", description="How to aggregate the value within each period.")


DESCRIPTION = build_description(
    summary="Project a time series forward N periods with an exponential-smoothing (ETS) model.",
    use_when="the user wants a projection/forecast of a metric into the FUTURE "
    "(next months, next quarter) — 'proyeksi', 'forecast', 'prediksi ke depan'.",
    dont_use_when=[
        "the user wants PAST movement/trend, not a future projection -> analyze_trend",
        "there is no date/time column to project along -> analyze_aggregate / analyze_descriptive",
        "the user wants what DRIVES the metric, not its future values -> analyze_importance",
    ],
    output="a history + forecast series (per-period points) plus an actual-vs-forecast line chart.",
    examples=[
        "proyeksi produksi 6 bulan ke depan",
        "forecast next quarter's revenue",
        "predict signups for the next 12 weeks",
    ],
)


def _ets(values: np.ndarray):
    """An additive-trend ETS fit. Imported lazily so app startup doesn't pay for
    statsmodels unless a forecast actually runs."""
    from statsmodels.tsa.holtwinters import ExponentialSmoothing

    return ExponentialSmoothing(
        values, trend="add", seasonal=None, initialization_method="estimated"
    ).fit()


def _fit_ets(values: np.ndarray, horizon: int):
    """Fit the reported model AND backtest it. Runs inside run_fit's thread.

    Both fits happen in one offloaded call so a forecast costs one thread hop and one
    timeout, not two.

    The backtest is a single holdout: train on everything but the last `h` periods,
    forecast those, and score with **MASE** — mean absolute error divided by the mean
    absolute error of the naive "next = last" forecast on the training span. MASE is
    the scale-free choice (Hyndman & Koehler 2006) and, unlike MAPE, it neither
    explodes near zero nor punishes over-prediction asymmetrically. It also reads
    plainly: **below 1 beats doing nothing, at or above 1 does not.**

    Returns (model, mase, reason). `mase` is None when no backtest was possible, and
    `reason` then says WHY in the user's terms — the two causes are different and a
    wrong explanation is worse than none.
    """
    model = _ets(values)

    n = values.shape[0]
    too_short = "too few periods to hold any out"
    if n < _MIN_BACKTEST_POINTS:
        return model, None, too_short
    # Hold out the forecast horizon, but never more than a quarter of the series and
    # never so much that training drops below the fitting floor.
    holdout = max(1, min(horizon, n // 4, n - _MIN_POINTS))
    train, test = values[:-holdout], values[-holdout:]
    if train.shape[0] < _MIN_POINTS:
        return model, None, too_short

    predicted = np.asarray(_ets(train).forecast(holdout), dtype=float)
    naive_error = float(np.mean(np.abs(np.diff(train))))
    if naive_error == 0:
        # The series never moves, so the naive baseline has zero error and MASE is
        # undefined (not infinite). A forecast of a constant is trivially "right" and
        # tells the user nothing — still weak, but for this reason, not shortness.
        return model, None, "the series never changes, so there is nothing to validate against"
    return model, float(np.mean(np.abs(test - predicted)) / naive_error), None


def _forecast_chart(
    history: list[dict[str, object]],
    forecast_pts: list[dict[str, object]],
    value_column: str,
) -> dict[str, object]:
    """Line chart: actual history + a dashed forecast, connected at the last actual."""
    hist_x = [p["period"] for p in history]
    hist_y = [p["value"] for p in history]

    # Anchor the forecast line to the last real actual so the dashed segment connects.
    anchor = next((p for p in reversed(history) if p["value"] is not None), None)
    fc_x = ([anchor["period"]] if anchor else []) + [p["period"] for p in forecast_pts]
    fc_y = ([anchor["value"]] if anchor else []) + [p["value"] for p in forecast_pts]

    actual = {
        "type": "scatter",
        "mode": "lines+markers",
        "x": hist_x,
        "y": hist_y,
        "name": "actual",
        "line": {**_LINE_STYLE, "color": COLORWAY[0]},
        "marker": {"size": 6},
    }
    forecast = {
        "type": "scatter",
        "mode": "lines+markers",
        "x": fc_x,
        "y": fc_y,
        "name": "forecast",
        "line": {**_LINE_STYLE, "color": COLORWAY[5], "dash": "dash"},
        "marker": {"size": 6},
    }
    title = f"Forecast of {value_column}"
    layout = {
        **LAYOUT_PRESET,
        "title": {"text": title},
        "showlegend": True,
        "xaxis": _axis("period"),
        "yaxis": _axis(value_column),
    }
    return envelope("line", title, [actual, forecast], layout)


async def analyze_forecast(
    df: pd.DataFrame,
    date_column: str,
    value_column: str,
    horizon: int = 6,
    freq: str = "month",
    agg: str = "sum",
) -> dict[str, object]:
    """Forecast `value_column` `horizon` periods ahead over `freq` buckets.

    Async ONLY so the ETS fit can be offloaded via `run_fit`; everything else is
    ordinary synchronous compute. `data` is materialised upstream (Pattern A) and
    arrives as `df`.

    Returns a dict with: freq, agg, horizon, model, history + forecast point lists,
    last_actual / forecast_end / change_abs / change_pct / direction, and `charts`
    (the reserved auto-chart key).

    Raises (all wrapped by the invoker's never-throw seam):
        ColumnNotFoundError, InvalidFrequencyError, UnsupportedAggregationError,
        InvalidHorizonError, NotEnoughDataError, InvalidDateColumnError.
    """
    missing = [c for c in (date_column, value_column) if c not in df.columns]
    if missing:
        raise ColumnNotFoundError(f"columns not found: {missing}")
    if freq not in FREQ_MAP:
        raise InvalidFrequencyError(f"unknown frequency '{freq}'; supported: {list(FREQ_MAP)}")
    # `avg` is the QueryIR's word for this and planner.md teaches it; the tools
    # speak pandas. Accept both, and report whatever the caller actually wrote.
    # (A23 Bug 2 -- see agg_vocab.)
    requested, agg = agg, normalise_agg(agg)
    if agg not in SUPPORTED_AGGS:
        raise UnsupportedAggregationError(
            f"unsupported aggregation '{requested}'; supported: {list(SUPPORTED_AGGS)}"
        )
    if not isinstance(horizon, int) or horizon < 1:
        raise InvalidHorizonError(f"horizon must be a positive integer, got {horizon!r}")

    # Build a datetime-indexed series, then resample into evenly-spaced periods.
    # Same positional build as analyze_trend so date_column == value_column works.
    dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date")
    values = pd.Series(df[value_column].to_numpy(), name="_value")
    frame = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date")
    resampled = frame["_value"].sort_index().resample(FREQ_MAP[freq]).agg(agg).astype(float)

    observed = resampled.dropna()
    if observed.shape[0] < _MIN_POINTS:
        raise NotEnoughDataError(
            f"need >= {_MIN_POINTS} non-empty {freq} periods to forecast, "
            f"got {observed.shape[0]}"
        )
    # ETS needs a gap-free series; interpolate the empty interior periods.
    filled = resampled.interpolate(limit_direction="both")

    model, mase, mase_reason = await run_fit(
        _fit_ets, filled.to_numpy(dtype=float), horizon, label="analyze_forecast"
    )
    fc_values = [float(v) for v in np.asarray(model.forecast(horizon), dtype=float)]

    future_index = pd.date_range(
        start=filled.index[-1], periods=horizon + 1, freq=FREQ_MAP[freq]
    )[1:]
    history = [
        {"period": _period_label(ts, freq), "value": _clean(val)} for ts, val in resampled.items()
    ]
    forecast_pts = [
        {"period": _period_label(ts, freq), "value": val}
        for ts, val in zip(future_index, fc_values, strict=True)
    ]

    last_actual = float(observed.iloc[-1])
    forecast_end = fc_values[-1] if fc_values else None
    change_abs = (forecast_end - last_actual) if forecast_end is not None else None
    change_pct = (
        (change_abs / last_actual) if (change_abs is not None and last_actual != 0) else None
    )
    if change_abs is None or change_abs == 0:
        direction = "flat"
    else:
        direction = "up" if change_abs > 0 else "down"

    return {
        "freq": freq,
        "agg": agg,
        "horizon": horizon,
        "model": "ETS(add trend)",
        "history": history,
        "forecast": forecast_pts,
        "last_actual": last_actual,
        "forecast_end": forecast_end,
        "change_abs": change_abs,
        "change_pct": change_pct,
        "direction": direction,
        # Reserved `quality` block (conventions §3): the tool reports its own score and
        # its own verdict; CK7 surfaces `weak`. An unvalidated forecast (series too
        # short to hold anything out) counts as weak — shipping a projection nobody
        # checked is exactly the case the plan's R-3 wanted flagged.
        "quality": {
            "metric": "mase",
            "value": None if mase is None else round(mase, 4),
            "floor": _MASE_FLOOR,
            "weak": mase is None or mase >= _MASE_FLOOR,
            "note": (
                f"not validated — {mase_reason}"
                if mase is None
                else "did not beat a naive 'next = last' forecast on the holdout"
                if mase >= _MASE_FLOOR
                else "beats a naive 'next = last' forecast on the holdout"
            ),
        },
        "charts": [_forecast_chart(history, forecast_pts, value_column)],
    }