Skip to content

Commit 4d870f0

Browse files
fix: predict output quality — stray y, index-keyed intervals, horizon cap (#536)
Fixes #536. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f0de28c commit 4d870f0

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

src/sktime_mcp/runtime/executor.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,29 @@ def _to_period_index_if_possible(obj: Any) -> Any:
8282
return obj
8383

8484

85+
# Max forecast rows returned inline before truncation (NB-22). Normal horizons
86+
# (<= a few dozen) are never affected; a 1000-step forecast would otherwise
87+
# flood the client with ~30KB+ of inline JSON.
88+
_MAX_PREDICTION_ROWS = 500
89+
90+
91+
def _cap_prediction_rows(result: dict) -> tuple[dict, dict | None]:
92+
"""Cap an index-keyed prediction dict, returning (capped, truncation_note)."""
93+
if not isinstance(result, dict) or len(result) <= _MAX_PREDICTION_ROWS:
94+
return result, None
95+
total = len(result)
96+
kept = dict(list(result.items())[:_MAX_PREDICTION_ROWS])
97+
note = {
98+
"shown": _MAX_PREDICTION_ROWS,
99+
"total": total,
100+
"note": (
101+
"forecast truncated; request a smaller horizon or use save_data to write "
102+
"the full series to a file"
103+
),
104+
}
105+
return kept, note
106+
107+
85108
def _get_index_frequency_metadata(
86109
index: pd.Index,
87110
fallback: str | None = None,
@@ -492,6 +515,7 @@ def predict(
492515
elif obj_type in ("transformer", "clusterer"):
493516
is_transformer = True
494517

518+
dropped_y_warning = None
495519
try:
496520
if fh is None and not (is_classifier_or_regressor or is_transformer):
497521
fh = list(range(1, 13))
@@ -500,7 +524,21 @@ def predict(
500524
if X is not None:
501525
kwargs["X"] = X
502526
if y is not None:
503-
kwargs["y"] = y
527+
# y at predict is only for annotators; forwarding it to a
528+
# forecaster raised a raw "unexpected keyword argument 'y'"
529+
# TypeError (NB-18). Only pass it when predict accepts it.
530+
accepts_y = False
531+
try:
532+
accepts_y = "y" in inspect.signature(instance.predict).parameters
533+
except (ValueError, TypeError):
534+
accepts_y = False
535+
if accepts_y:
536+
kwargs["y"] = y
537+
else:
538+
dropped_y_warning = (
539+
f"y was ignored: {obj_type or 'this estimator'}.predict() does not "
540+
"accept y (it is only used by annotators/detectors)."
541+
)
504542

505543
if is_classifier_or_regressor:
506544
# Classifiers take X in predict (X is the feature matrix)
@@ -542,20 +580,25 @@ def predict(
542580

543581
from sktime_mcp.server import sanitize_for_json
544582

583+
truncated_note = None
545584
if isinstance(predictions, pd.Series):
546585
predictions_copy = predictions.copy()
547586
predictions_copy.index = predictions_copy.index.astype(str)
548-
result = predictions_copy.to_dict()
587+
result, truncated_note = _cap_prediction_rows(predictions_copy.to_dict())
549588
elif isinstance(predictions, pd.DataFrame):
550589
predictions_copy = predictions.copy()
551590
predictions_copy.index = predictions_copy.index.astype(str)
552-
# Need to handle multiindex columns if they exist (like in predict_interval)
591+
# Flatten multiindex columns (predict_interval/quantiles) for JSON.
553592
if isinstance(predictions_copy.columns, pd.MultiIndex):
554-
# Flatten multiindex for JSON serialization
555593
predictions_copy.columns = [
556594
"_".join(map(str, col)) for col in predictions_copy.columns.values
557595
]
558-
result = predictions_copy.to_dict(orient="list")
596+
# orient="index" keeps the time index as the key so interval /
597+
# variance values map to time points, consistent with predict
598+
# (NB-21). orient="list" dropped the index entirely.
599+
result, truncated_note = _cap_prediction_rows(
600+
predictions_copy.to_dict(orient="index")
601+
)
559602
else:
560603
result = sanitize_for_json(predictions)
561604

@@ -574,6 +617,10 @@ def predict(
574617
out["alpha"] = alpha
575618
else:
576619
out["predictions"] = result
620+
if truncated_note:
621+
out["predictions_truncated"] = truncated_note
622+
if dropped_y_warning:
623+
out["warnings"] = [dropped_y_warning]
577624
return out
578625
except Exception as e:
579626
return {"success": False, "error": str(e)}

tests/test_predict_output.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""predict output-quality fixes (#536).
2+
3+
- NB-18: y forwarded to a forecaster raised a raw TypeError; now dropped with
4+
a warning.
5+
- NB-21: predict_interval / predict_var dropped the time index (bare arrays);
6+
now index-keyed like predict.
7+
- NB-22: an unbounded horizon flooded the response; now capped with a marker.
8+
"""
9+
10+
import contextlib
11+
12+
import pytest
13+
14+
from sktime_mcp.runtime.executor import get_executor
15+
from sktime_mcp.runtime.handles import get_handle_manager
16+
from sktime_mcp.tools.fit_predict import fit_tool, predict_tool
17+
from sktime_mcp.tools.instantiate import instantiate_tool
18+
19+
20+
@pytest.fixture
21+
def fitted_forecaster():
22+
res = instantiate_tool(spec="NaiveForecaster(sp=12)")
23+
handle = res["handle"]
24+
fit_tool(estimator_handle=handle, y_dataset="airline")
25+
yield handle
26+
with contextlib.suppress(KeyError):
27+
get_handle_manager().release_handle(handle)
28+
29+
30+
def test_stray_y_is_dropped_with_warning(fitted_forecaster):
31+
# y_dataset on a forecaster used to raise "unexpected keyword argument 'y'"
32+
res = predict_tool(estimator_handle=fitted_forecaster, horizon=3, y_dataset="airline")
33+
assert res["success"], res
34+
assert len(res["predictions"]) == 3
35+
assert "warnings" in res
36+
assert any("y was ignored" in w for w in res["warnings"])
37+
38+
39+
def test_predict_interval_is_index_keyed(fitted_forecaster):
40+
res = predict_tool(
41+
estimator_handle=fitted_forecaster, horizon=3, mode="predict_interval", coverage=0.8
42+
)
43+
assert res["success"], res
44+
intervals = res["intervals"]
45+
# keys are time periods, each mapping to a dict of bound -> value
46+
assert len(intervals) == 3
47+
first_key = next(iter(intervals))
48+
assert "-" in first_key or first_key.isdigit() # a period/timestamp label
49+
assert isinstance(intervals[first_key], dict)
50+
assert any("lower" in col for col in intervals[first_key])
51+
assert any("upper" in col for col in intervals[first_key])
52+
53+
54+
def test_predict_var_is_index_keyed(fitted_forecaster):
55+
res = predict_tool(estimator_handle=fitted_forecaster, horizon=2, mode="predict_var")
56+
assert res["success"], res
57+
preds = res["predictions"]
58+
assert len(preds) == 2
59+
assert all(isinstance(v, dict) for v in preds.values())
60+
61+
62+
def test_large_horizon_is_capped(fitted_forecaster):
63+
res = predict_tool(estimator_handle=fitted_forecaster, horizon=1000)
64+
assert res["success"], res
65+
assert len(res["predictions"]) <= 500
66+
assert res["predictions_truncated"]["total"] == 1000
67+
assert res["predictions_truncated"]["shown"] == 500
68+
69+
70+
def test_normal_horizon_not_capped(fitted_forecaster):
71+
res = predict_tool(estimator_handle=fitted_forecaster, horizon=12)
72+
assert res["success"]
73+
assert "predictions_truncated" not in res
74+
assert len(res["predictions"]) == 12

0 commit comments

Comments
 (0)