precog-api 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
precog_api/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Precog REST API."""
2
+
3
+ from precog_api.app import create_app
4
+
5
+ __all__ = ["create_app"]
precog_api/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Package entry point: ``python -m precog_api`` / ``precog-api``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uvicorn
6
+
7
+ from precog_api.app import create_app
8
+ from precog_api.config import Settings
9
+
10
+
11
+ def main() -> None:
12
+ settings = Settings()
13
+ uvicorn.run(create_app(settings), host="0.0.0.0", port=8000)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
precog_api/app.py ADDED
@@ -0,0 +1,427 @@
1
+ """FastAPI application factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from collections.abc import AsyncIterator
10
+ from contextlib import asynccontextmanager
11
+ from typing import Annotated, Any
12
+
13
+ from fastapi import Body, Depends, FastAPI, Header, HTTPException, Request
14
+ from fastapi.exceptions import RequestValidationError
15
+ from fastapi.responses import JSONResponse, Response
16
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
17
+ from pydantic import BaseModel
18
+
19
+ from precog_api.config import Settings
20
+ from precog_api.engine import Engine, FakeEngine
21
+ from precog_api.mapping import to_execution_problem, to_forecast_response
22
+ from precog_api.observability import (
23
+ FORECAST_SERIES,
24
+ INFLIGHT,
25
+ METRICS_EXCLUDED_PATHS,
26
+ MODEL_LOAD_SECONDS,
27
+ REQUEST_COUNT,
28
+ REQUEST_LATENCY,
29
+ configure_logging,
30
+ request_id_var,
31
+ )
32
+ from precog_api.tracing import setup_tracing
33
+ from precog_schemas import (
34
+ Capabilities,
35
+ ExecutionFeatures,
36
+ ExecutionLimits,
37
+ ForecastRequest,
38
+ ForecastResponse,
39
+ ModelProvenance,
40
+ )
41
+
42
+ logger = logging.getLogger("precog.api")
43
+
44
+ PROBLEM_MEDIA_TYPE = "application/problem+json"
45
+
46
+ FORECAST_EXAMPLES: dict[str, Any] = {
47
+ "targets": {
48
+ "summary": "Single target with quantiles",
49
+ "value": {
50
+ "horizon": 4,
51
+ "targets": [{"id": "sales", "values": [100, 102, 101, 105, 107, 106, 108, 109]}],
52
+ "quantiles": [0.1, 0.5, 0.9],
53
+ },
54
+ },
55
+ "covariates": {
56
+ "summary": "Target with past-only and known-future covariates",
57
+ "value": {
58
+ "horizon": 3,
59
+ "targets": [{"id": "kiosk", "values": [50, 52, 51, 53, 55, 54, 56, 57]}],
60
+ "past_covariates": [
61
+ {"id": "footfall", "values": [0.1, 0.2, 0.15, 0.3, 0.4, 0.35, 0.5, 0.6]}
62
+ ],
63
+ "known_future_covariates": [
64
+ {
65
+ "id": "promo",
66
+ "history": [0, 1, 0, 0, 0, 1, 0, 0],
67
+ "future": [1, 0, 0],
68
+ }
69
+ ],
70
+ "quantiles": [0.1, 0.9],
71
+ },
72
+ },
73
+ "joint_targets": {
74
+ "summary": "Multiple targets forecast jointly",
75
+ "value": {
76
+ "horizon": 3,
77
+ "targets": [
78
+ {"id": "a", "values": [10, 11, 12, 13, 14]},
79
+ {"id": "b", "values": [20, 21, 22, 23, 24]},
80
+ ],
81
+ "quantiles": [0.5],
82
+ },
83
+ },
84
+ "point_only": {
85
+ "summary": "Point-only forecast (no quantiles)",
86
+ "value": {
87
+ "horizon": 3,
88
+ "targets": [{"id": "cpu", "values": [1.0, 2.0, 3.0, 4.0, 5.0]}],
89
+ "quantiles": [],
90
+ },
91
+ },
92
+ }
93
+
94
+
95
+ class ProblemDetail(BaseModel):
96
+ """RFC 7807 problem payload."""
97
+
98
+ type: str = "about:blank"
99
+ title: str
100
+ status: int
101
+ detail: str | None = None
102
+
103
+
104
+ def load_engine(settings: Settings) -> Engine:
105
+ """Build the configured engine."""
106
+ if settings.engine == "fake":
107
+ logger.info("using FakeEngine")
108
+ return FakeEngine()
109
+ from precog_api.engine_timesfm3 import TimesFM3Engine
110
+
111
+ logger.info("loading TimesFM-3 engine (device=%s)", settings.device)
112
+ return TimesFM3Engine(settings)
113
+
114
+
115
+ def create_app(settings: Settings | None = None, engine: Engine | None = None) -> FastAPI:
116
+ """Create the ASGI application."""
117
+ settings = settings or Settings()
118
+ configure_logging(json_logs=settings.log_json)
119
+ if settings.enable_docs:
120
+ docs_url: str | None = "/docs"
121
+ redoc_url: str | None = "/redoc"
122
+ openapi_url: str | None = "/openapi.json"
123
+ else:
124
+ docs_url = redoc_url = openapi_url = None
125
+
126
+ @asynccontextmanager
127
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
128
+ app.state.settings = settings
129
+ app.state.semaphore = asyncio.Semaphore(settings.max_concurrency)
130
+ app.state.rate_buckets = {}
131
+ app.state.engine = engine
132
+ if app.state.engine is None:
133
+ started = time.perf_counter()
134
+ app.state.engine = load_engine(settings)
135
+ MODEL_LOAD_SECONDS.set(time.perf_counter() - started)
136
+ logger.info(
137
+ "precog-api %s ready (engine=%s, model=%s)",
138
+ app.version,
139
+ settings.engine,
140
+ settings.model_name,
141
+ )
142
+ app.state.ready = app.state.engine.ready
143
+ yield
144
+
145
+ app = FastAPI(
146
+ title="Precog API",
147
+ version="0.1.0",
148
+ summary="Zero-shot forecasting with TimesFM-3.",
149
+ description=(
150
+ "Synchronous TimesFM-3 execution. "
151
+ f"Limits: horizon <= {settings.max_horizon}, context <= {settings.max_context}, "
152
+ f"targets <= {settings.max_series}. Errors use RFC 7807 "
153
+ "(`application/problem+json`)."
154
+ + (" Bearer authentication is required." if settings.api_key else "")
155
+ ),
156
+ lifespan=lifespan,
157
+ docs_url=docs_url,
158
+ redoc_url=redoc_url,
159
+ openapi_url=openapi_url,
160
+ )
161
+
162
+ if settings.otel_enabled:
163
+ setup_tracing(settings.otel_service_name, fastapi_app=app)
164
+
165
+ @app.middleware("http")
166
+ async def _observe(request: Request, call_next: Any) -> Response:
167
+ request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
168
+ token = request_id_var.set(request_id)
169
+ path = request.url.path
170
+ counted = path not in METRICS_EXCLUDED_PATHS
171
+ if counted:
172
+ INFLIGHT.inc()
173
+ started = time.perf_counter()
174
+ try:
175
+ if settings.rate_limit_requests > 0 and path == "/v1/forecast":
176
+ retry_after = _register_hit(app, settings, request)
177
+ if retry_after > 0:
178
+ problem = ProblemDetail(
179
+ title="Too Many Requests",
180
+ status=429,
181
+ detail=f"rate limit exceeded; retry in {retry_after}s",
182
+ )
183
+ response: Response = JSONResponse(
184
+ status_code=429,
185
+ content=problem.model_dump(),
186
+ media_type=PROBLEM_MEDIA_TYPE,
187
+ headers={"Retry-After": str(retry_after)},
188
+ )
189
+ else:
190
+ response = await call_next(request)
191
+ else:
192
+ response = await call_next(request)
193
+ finally:
194
+ duration = time.perf_counter() - started
195
+ if counted:
196
+ INFLIGHT.dec()
197
+ request_id_var.reset(token)
198
+ response.headers["x-request-id"] = request_id
199
+ if counted:
200
+ REQUEST_COUNT.labels(request.method, path, str(response.status_code)).inc()
201
+ REQUEST_LATENCY.labels(request.method, path).observe(duration)
202
+ logger.info(
203
+ "request",
204
+ extra={
205
+ "request_id": request_id,
206
+ "method": request.method,
207
+ "path": path,
208
+ "status": response.status_code,
209
+ "duration_ms": round(duration * 1000, 2),
210
+ "client": _client_key(request),
211
+ },
212
+ )
213
+ return response
214
+
215
+ @app.exception_handler(HTTPException)
216
+ async def _http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
217
+ problem = ProblemDetail(
218
+ title=_status_title(exc.status_code), status=exc.status_code, detail=str(exc.detail)
219
+ )
220
+ return JSONResponse(
221
+ status_code=exc.status_code,
222
+ content=problem.model_dump(),
223
+ media_type=PROBLEM_MEDIA_TYPE,
224
+ )
225
+
226
+ @app.exception_handler(RequestValidationError)
227
+ async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
228
+ problem = ProblemDetail(
229
+ title="Unprocessable Entity", status=422, detail=_format_errors(exc)
230
+ )
231
+ return JSONResponse(
232
+ status_code=422, content=problem.model_dump(), media_type=PROBLEM_MEDIA_TYPE
233
+ )
234
+
235
+ def require_api_key(authorization: str | None = Header(default=None)) -> None:
236
+ if not settings.api_key:
237
+ return
238
+ expected = f"Bearer {settings.api_key}"
239
+ if authorization != expected:
240
+ raise HTTPException(status_code=401, detail="invalid or missing API key")
241
+
242
+ @app.get("/healthz", tags=["ops"])
243
+ async def healthz() -> dict[str, str]:
244
+ return {"status": "ok"}
245
+
246
+ @app.get("/readyz", tags=["ops"])
247
+ async def readyz() -> JSONResponse:
248
+ ready = bool(getattr(app.state, "ready", False))
249
+ payload = {"status": "ready" if ready else "loading"}
250
+ return JSONResponse(status_code=200 if ready else 503, content=payload)
251
+
252
+ @app.get("/metrics", include_in_schema=False)
253
+ async def metrics() -> Response:
254
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
255
+
256
+ @app.get("/v1/capabilities", response_model=Capabilities, tags=["forecast"])
257
+ async def capabilities() -> Capabilities:
258
+ engine = app.state.engine
259
+ return Capabilities(
260
+ engine=settings.engine,
261
+ model=ModelProvenance(id=settings.model_id, revision=settings.model_revision),
262
+ device=settings.device,
263
+ limits=ExecutionLimits(
264
+ max_horizon=settings.max_horizon,
265
+ max_context=_min_limit(settings.max_context, engine.max_context),
266
+ max_variates=engine.max_variates,
267
+ max_targets=settings.max_series,
268
+ ),
269
+ quantile_levels=list(engine.quantile_levels),
270
+ features=ExecutionFeatures(
271
+ point_forecast=True,
272
+ probabilistic_forecast=True,
273
+ past_covariates=True,
274
+ known_future_covariates=True,
275
+ joint_targets=True,
276
+ ),
277
+ auth_required=bool(settings.api_key),
278
+ )
279
+
280
+ @app.post(
281
+ "/v1/forecast",
282
+ response_model=ForecastResponse,
283
+ dependencies=[Depends(require_api_key)],
284
+ tags=["forecast"],
285
+ summary="Forecast time series",
286
+ response_description="Point forecast and the caller-selected quantiles per target.",
287
+ responses={
288
+ 401: {"description": "Missing or invalid API key"},
289
+ 422: {"description": "Validation error or configured limit exceeded"},
290
+ 429: {"description": "Rate limit exceeded"},
291
+ 504: {"description": "Forecast timed out"},
292
+ },
293
+ )
294
+ async def forecast(
295
+ payload: Annotated[ForecastRequest, Body(openapi_examples=FORECAST_EXAMPLES)],
296
+ ) -> ForecastResponse:
297
+ _enforce_limits(payload, settings, app.state.engine)
298
+ problem = to_execution_problem(payload)
299
+ started = time.perf_counter()
300
+ async with app.state.semaphore:
301
+ try:
302
+ result = await asyncio.wait_for(
303
+ asyncio.to_thread(app.state.engine.predict, problem),
304
+ timeout=settings.request_timeout_s,
305
+ )
306
+ except TimeoutError as exc:
307
+ raise HTTPException(status_code=504, detail="forecast timed out") from exc
308
+ latency_ms = (time.perf_counter() - started) * 1000
309
+ FORECAST_SERIES.inc(len(payload.targets))
310
+ return to_forecast_response(
311
+ payload,
312
+ result,
313
+ model=settings.model_id,
314
+ revision=settings.model_revision,
315
+ latency_ms=round(latency_ms, 3),
316
+ )
317
+
318
+ return app
319
+
320
+
321
+ def _client_key(request: Request) -> str:
322
+ authorization = request.headers.get("authorization")
323
+ if authorization:
324
+ return authorization
325
+ return request.client.host if request.client else "unknown"
326
+
327
+
328
+ def _register_hit(app: FastAPI, settings: Settings, request: Request) -> int:
329
+ """Record a request and return seconds to wait (0 if allowed)."""
330
+ key = _client_key(request)
331
+ now = time.monotonic()
332
+ window = settings.rate_limit_window_s
333
+ bucket: list[float] = app.state.rate_buckets.setdefault(key, [])
334
+ while bucket and now - bucket[0] > window:
335
+ bucket.pop(0)
336
+ if len(bucket) >= settings.rate_limit_requests:
337
+ return max(1, int(window - (now - bucket[0])) + 1)
338
+ bucket.append(now)
339
+ return 0
340
+
341
+
342
+ def _min_limit(configured: int, engine_limit: int | None) -> int:
343
+ """Intersect a configured limit with the active engine's effective limit."""
344
+ return configured if engine_limit is None else min(configured, engine_limit)
345
+
346
+
347
+ def _max_unit_variates(payload: ForecastRequest) -> int:
348
+ """Variates Precog sends to the single forward pass for this problem.
349
+
350
+ Targets and covariate channels share the same execution budget.
351
+ """
352
+ return (
353
+ len(payload.targets) + len(payload.past_covariates) + len(payload.known_future_covariates)
354
+ )
355
+
356
+
357
+ def _enforce_limits(payload: ForecastRequest, settings: Settings, engine: Engine) -> None:
358
+ if payload.horizon > settings.max_horizon:
359
+ raise HTTPException(
360
+ status_code=422,
361
+ detail=f"horizon {payload.horizon} exceeds max {settings.max_horizon}",
362
+ )
363
+ if len(payload.targets) > settings.max_series:
364
+ raise HTTPException(
365
+ status_code=422,
366
+ detail=f"{len(payload.targets)} targets exceed max {settings.max_series}",
367
+ )
368
+ effective_context = _min_limit(settings.max_context, engine.max_context)
369
+ longest = max(len(target.values) for target in payload.targets)
370
+ if longest > effective_context:
371
+ raise HTTPException(
372
+ status_code=422,
373
+ detail=(
374
+ f"context length {longest} exceeds max {effective_context}; "
375
+ "Precog never truncates input to fit the model context"
376
+ ),
377
+ )
378
+ # The target policy ceiling and the backend execution budget are distinct:
379
+ # targets + covariate channels share the engine's forward-pass budget.
380
+ if engine.max_variates is not None:
381
+ variates = _max_unit_variates(payload)
382
+ if variates > engine.max_variates:
383
+ raise HTTPException(
384
+ status_code=422,
385
+ detail=(
386
+ f"{variates} variates exceed max {engine.max_variates}; "
387
+ "Precog never drops or chunks covariates/targets to fit the model"
388
+ ),
389
+ )
390
+ _enforce_supported_quantiles(payload, engine)
391
+
392
+
393
+ def _enforce_supported_quantiles(payload: ForecastRequest, engine: Engine) -> None:
394
+ """Reject requested quantile levels the active runtime cannot produce.
395
+
396
+ The runtime grid is the execution source of truth (#179); unsupported levels
397
+ fail closed before any backend column lookup.
398
+ """
399
+ supported = engine.quantile_levels
400
+ for level in payload.quantiles:
401
+ if not any(abs(level - candidate) < 1e-9 for candidate in supported):
402
+ allowed = ", ".join(f"{value:g}" for value in supported)
403
+ raise HTTPException(
404
+ status_code=422,
405
+ detail=f"unsupported quantile {level}; supported levels: {allowed}",
406
+ )
407
+
408
+
409
+ def _status_title(status_code: int) -> str:
410
+ return {
411
+ 401: "Unauthorized",
412
+ 422: "Unprocessable Entity",
413
+ 429: "Too Many Requests",
414
+ 504: "Gateway Timeout",
415
+ }.get(status_code, "Error")
416
+
417
+
418
+ def _format_errors(exc: RequestValidationError) -> str:
419
+ try:
420
+ return "; ".join(
421
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
422
+ )
423
+ except (KeyError, TypeError):
424
+ return "invalid request"
425
+
426
+
427
+ __all__ = ["ProblemDetail", "create_app", "load_engine"]
precog_api/config.py ADDED
@@ -0,0 +1,49 @@
1
+ """Runtime configuration, loaded from ``PRECOG_*`` environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ """API settings.
12
+
13
+ Every field can be overridden with a ``PRECOG_``-prefixed env var, e.g.
14
+ ``PRECOG_MAX_HORIZON=512``.
15
+ """
16
+
17
+ model_config = SettingsConfigDict(env_prefix="PRECOG_", env_file=".env", extra="ignore")
18
+
19
+ engine: Literal["fake", "timesfm3"] = "timesfm3"
20
+ device: str = "cpu"
21
+ model_path: str = "/opt/precog/models"
22
+ model_id: str = "google/timesfm-3.0-pytorch"
23
+ model_revision: str | None = None
24
+ cache_dir: str | None = None
25
+ local_files_only: bool = False
26
+ preload: Literal["auto", "always", "never"] = "auto"
27
+ preload_retries: int = 3
28
+ model_required: bool = True
29
+ prune_old_revisions: bool = False
30
+ hf_token: str | None = None
31
+ per_core_batch_size: int = 16
32
+ torch_threads: int = 0
33
+ max_concurrency: int = 1
34
+ request_timeout_s: float = 300.0
35
+ api_key: str | None = None
36
+ enable_docs: bool = True
37
+ log_json: bool = True
38
+ otel_enabled: bool = False
39
+ otel_service_name: str = "precog-api"
40
+ rate_limit_requests: int = 0
41
+ rate_limit_window_s: int = 60
42
+
43
+ max_horizon: int = 1024
44
+ max_context: int = 16384
45
+ max_series: int = 64
46
+
47
+ @property
48
+ def model_name(self) -> str:
49
+ return "timesfm-3.0"
precog_api/engine.py ADDED
@@ -0,0 +1,74 @@
1
+ """Engine abstraction for TimesFM-3 inference.
2
+
3
+ The API talks to an :class:`Engine` over the canonical execution boundary
4
+ (ADR 0006). Tests and local demos use :class:`FakeEngine`; production uses the
5
+ TimesFM-3 implementation loaded lazily so the base install does not require
6
+ torch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from precog_api.execution import (
12
+ Engine,
13
+ ExecutionProblem,
14
+ ExecutionResult,
15
+ QuantileExecutionResult,
16
+ TargetExecutionResult,
17
+ )
18
+
19
+
20
+ class FakeEngine:
21
+ """Deterministic engine that repeats the last observed value."""
22
+
23
+ _QUANTILE_LEVELS: tuple[float, ...] = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9)
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ ready: bool = True,
29
+ max_context: int | None = None,
30
+ max_variates: int | None = None,
31
+ quantile_levels: tuple[float, ...] | None = None,
32
+ ) -> None:
33
+ self._ready = ready
34
+ self._max_context = max_context
35
+ self._max_variates = max_variates
36
+ self._quantile_levels = (
37
+ tuple(quantile_levels) if quantile_levels is not None else self._QUANTILE_LEVELS
38
+ )
39
+
40
+ @property
41
+ def ready(self) -> bool:
42
+ return self._ready
43
+
44
+ @property
45
+ def max_context(self) -> int | None:
46
+ return self._max_context
47
+
48
+ @property
49
+ def max_variates(self) -> int | None:
50
+ return self._max_variates
51
+
52
+ @property
53
+ def quantile_levels(self) -> tuple[float, ...]:
54
+ return self._quantile_levels
55
+
56
+ def predict(self, problem: ExecutionProblem) -> ExecutionResult:
57
+ targets: list[TargetExecutionResult] = []
58
+ for target in problem.targets:
59
+ last = float(target.values[-1])
60
+ quantiles = [
61
+ QuantileExecutionResult(level=level, values=[last] * problem.horizon)
62
+ for level in problem.quantiles
63
+ ]
64
+ targets.append(
65
+ TargetExecutionResult(
66
+ id=target.id,
67
+ forecast=[last] * problem.horizon,
68
+ quantiles=quantiles,
69
+ )
70
+ )
71
+ return ExecutionResult(targets=targets)
72
+
73
+
74
+ __all__ = ["Engine", "FakeEngine"]
@@ -0,0 +1,211 @@
1
+ """TimesFM-3 engine.
2
+
3
+ Imported lazily so the base installation does not require torch. The mapping
4
+ follows the real TimesFM-3 API (``TimesFM3Evaluator.predict_batch``) validated
5
+ during the PREC-1 spike and contains all backend-specific shape translation
6
+ (ADR 0006): single vs joint targets, covariate stacking, known-future
7
+ history+future concatenation, quantile-column selection and evaluator options.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ from precog_api.config import Settings
18
+ from precog_api.execution import (
19
+ ExecutionKnownFutureCovariate,
20
+ ExecutionPastCovariate,
21
+ ExecutionProblem,
22
+ ExecutionResult,
23
+ QuantileExecutionResult,
24
+ TargetExecutionResult,
25
+ )
26
+
27
+ # Used only when the engine extra is unavailable; the live evaluator is the
28
+ # source of truth so capability enforcement cannot drift from the backend.
29
+ _FALLBACK_MAX_VARIATES = 32
30
+
31
+
32
+ def effective_max_variates() -> int:
33
+ """Variates the active TimesFM-3 evaluator accepts per forward pass.
34
+
35
+ Above this limit the evaluator subsamples covariates and chunks targets,
36
+ which would silently change the consumer's data; Precog rejects instead.
37
+ """
38
+ try:
39
+ from timesfm3.torch.evaluator import _MAX_VARIATES_PER_FORWARD
40
+ except ImportError: # pragma: no cover - engine extra not installed
41
+ return _FALLBACK_MAX_VARIATES
42
+ return int(_MAX_VARIATES_PER_FORWARD)
43
+
44
+
45
+ class TimesFM3Engine:
46
+ """Adapter around ``timesfm3.TimesFM3Evaluator``."""
47
+
48
+ def __init__(self, settings: Settings) -> None:
49
+ import torch # noqa: PLC0415
50
+ from timesfm3 import ModelConfig, TimesFM3Evaluator # noqa: PLC0415
51
+
52
+ if settings.torch_threads > 0:
53
+ torch.set_num_threads(settings.torch_threads)
54
+
55
+ model_dir = Path(settings.model_path)
56
+ if model_dir.is_dir():
57
+ checkpoint = settings.model_path
58
+ local_only = True
59
+ else:
60
+ checkpoint = settings.model_id
61
+ local_only = settings.local_files_only
62
+ config = ModelConfig(
63
+ checkpoint_path=checkpoint,
64
+ per_core_batch_size=settings.per_core_batch_size,
65
+ device=settings.device,
66
+ revision=settings.model_revision,
67
+ cache_dir=settings.cache_dir,
68
+ local_files_only=local_only,
69
+ )
70
+ self._evaluator = TimesFM3Evaluator(config)
71
+ # ``global_context`` is the context length the model actually honors;
72
+ # anything longer is truncated by the backend.
73
+ self._max_context = int(self._evaluator.global_context)
74
+ self._max_variates = effective_max_variates()
75
+ # The active quantile grid is the model's own, not the REST schema's.
76
+ self._quantile_levels = tuple(float(level) for level in self._evaluator.config.quantiles)
77
+ self._ready = True
78
+
79
+ @property
80
+ def ready(self) -> bool:
81
+ return self._ready
82
+
83
+ @property
84
+ def max_context(self) -> int:
85
+ return self._max_context
86
+
87
+ @property
88
+ def max_variates(self) -> int:
89
+ return self._max_variates
90
+
91
+ @property
92
+ def quantile_levels(self) -> tuple[float, ...]:
93
+ """The quantile grid the active model produces, in column order."""
94
+ return self._quantile_levels
95
+
96
+ def predict(self, problem: ExecutionProblem) -> ExecutionResult:
97
+ if len(problem.targets) == 1:
98
+ return self._predict_univariate(problem)
99
+ return self._predict_joint(problem)
100
+
101
+ def _predict_univariate(self, problem: ExecutionProblem) -> ExecutionResult:
102
+ target = problem.targets[0]
103
+ past_only = _stacked(problem.past_covariates)
104
+ past_future = _stacked_known(problem.known_future_covariates)
105
+ outputs = list(
106
+ self._evaluator.predict_batch(
107
+ contexts=[np.asarray(target.values, dtype=np.float32)],
108
+ horizon=problem.horizon,
109
+ past_only_covariates=[past_only] if past_only is not None else None,
110
+ past_future_covariates=[past_future] if past_future is not None else None,
111
+ ts_ids=[target.id],
112
+ **_EVALUATOR_OPTIONS,
113
+ return_quantiles=bool(problem.quantiles),
114
+ )
115
+ )
116
+ return ExecutionResult(
117
+ targets=[_target_result(target.id, outputs[0], problem, self._quantile_levels)]
118
+ )
119
+
120
+ def _predict_joint(self, problem: ExecutionProblem) -> ExecutionResult:
121
+ contexts = [np.asarray(target.values, dtype=np.float32) for target in problem.targets]
122
+ kwargs: dict[str, object] = {}
123
+ past_only = _stacked(problem.past_covariates)
124
+ past_future = _stacked_known(problem.known_future_covariates)
125
+ if past_only is not None:
126
+ kwargs["past_only_covariates"] = [past_only]
127
+ if past_future is not None:
128
+ kwargs["past_future_covariates"] = [past_future]
129
+ outputs = list(
130
+ self._evaluator.predict_batch(
131
+ contexts=[np.stack(contexts)],
132
+ horizon=problem.horizon,
133
+ **_EVALUATOR_OPTIONS,
134
+ return_quantiles=bool(problem.quantiles),
135
+ **kwargs,
136
+ )
137
+ )
138
+ output = outputs[0]
139
+ return ExecutionResult(
140
+ targets=[
141
+ _target_result(target.id, output, problem, self._quantile_levels, index=index)
142
+ for index, target in enumerate(problem.targets)
143
+ ]
144
+ )
145
+
146
+
147
+ # Explicit evaluator options (ADR 0007): set rather than inherited, so evaluator
148
+ # benchmark defaults cannot silently change Precog behavior.
149
+ _EVALUATOR_OPTIONS: dict[str, object] = {
150
+ "use_symmetric_averaging": False,
151
+ "make_positive": False,
152
+ "sort_quantiles": True,
153
+ "use_znorm": False,
154
+ "padding_mode": "none",
155
+ }
156
+
157
+
158
+ def _stacked(covariates: list[ExecutionPastCovariate]) -> np.ndarray | None:
159
+ """Stack covariate channels into an ``(n_channels, length)`` array."""
160
+ if not covariates:
161
+ return None
162
+ return np.stack([np.asarray(covariate.values, dtype=np.float32) for covariate in covariates])
163
+
164
+
165
+ def _stacked_known(known: list[ExecutionKnownFutureCovariate]) -> np.ndarray | None:
166
+ """Concatenate known-future ``history + future`` into the backend array."""
167
+ if not known:
168
+ return None
169
+ return np.stack(
170
+ [
171
+ np.asarray([*covariate.history, *covariate.future], dtype=np.float32)
172
+ for covariate in known
173
+ ]
174
+ )
175
+
176
+
177
+ def _target_result(
178
+ target_id: str,
179
+ output: Any,
180
+ problem: ExecutionProblem,
181
+ quantile_levels: tuple[float, ...],
182
+ *,
183
+ index: int | None = None,
184
+ ) -> TargetExecutionResult:
185
+ """Normalize one backend output into a canonical target result."""
186
+ forecast = np.asarray(output.forecast)
187
+ point = forecast.reshape(-1) if index is None else np.atleast_2d(forecast)[index].reshape(-1)
188
+
189
+ quantiles: list[QuantileExecutionResult] = []
190
+ if problem.quantiles:
191
+ raw = output.quantiles
192
+ if raw is None:
193
+ raise RuntimeError("evaluator returned no quantiles although they were requested")
194
+ matrix = np.asarray(raw)
195
+ if index is not None:
196
+ matrix = matrix[index]
197
+ for level in problem.quantiles:
198
+ column = _column_index(level, quantile_levels)
199
+ quantiles.append(
200
+ QuantileExecutionResult(level=level, values=matrix[:, column].tolist())
201
+ )
202
+
203
+ return TargetExecutionResult(id=target_id, forecast=point.tolist(), quantiles=quantiles)
204
+
205
+
206
+ def _column_index(level: float, quantile_levels: tuple[float, ...]) -> int:
207
+ """Map a requested level to its column in the active model quantile grid."""
208
+ for index, candidate in enumerate(quantile_levels):
209
+ if abs(candidate - level) < 1e-9:
210
+ return index
211
+ raise ValueError(f"unsupported quantile level {level}")
@@ -0,0 +1,114 @@
1
+ """Canonical Precog execution types and Engine protocol (ADR 0006).
2
+
3
+ These are server-internal execution types, not HTTP DTOs. The REST wire contract
4
+ lives in :mod:`precog_schemas`; :mod:`precog_api.mapping` translates between the
5
+ two. The Engine boundary only sees the canonical problem and returns normalized
6
+ predictions, never API timing or provenance.
7
+
8
+ Phase 2 standalone: the API still accepts the pre-release wire contract and maps
9
+ it onto this boundary.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Protocol
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ExecutionTarget:
20
+ """One target series, ordered oldest to newest."""
21
+
22
+ id: str
23
+ values: list[float]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ExecutionPastCovariate:
28
+ """A covariate known only during the historical context."""
29
+
30
+ id: str
31
+ values: list[float]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ExecutionKnownFutureCovariate:
36
+ """A covariate whose history and future values are both known."""
37
+
38
+ id: str
39
+ history: list[float]
40
+ future: list[float]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ExecutionProblem:
45
+ """A canonical, single-role forecasting problem.
46
+
47
+ Targets are forecast jointly, matching the semantic contract. ``quantiles``
48
+ is empty for point-only output.
49
+ """
50
+
51
+ horizon: int
52
+ targets: list[ExecutionTarget]
53
+ quantiles: list[float] = field(default_factory=list)
54
+ past_covariates: list[ExecutionPastCovariate] = field(default_factory=list)
55
+ known_future_covariates: list[ExecutionKnownFutureCovariate] = field(default_factory=list)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class QuantileExecutionResult:
60
+ """One requested quantile level and its values for a target."""
61
+
62
+ level: float
63
+ values: list[float]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class TargetExecutionResult:
68
+ """Normalized point forecast and requested quantiles for one target."""
69
+
70
+ id: str
71
+ forecast: list[float]
72
+ quantiles: list[QuantileExecutionResult] = field(default_factory=list)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ExecutionResult:
77
+ """Normalized engine predictions only: no timing, envelope or provenance."""
78
+
79
+ targets: list[TargetExecutionResult]
80
+
81
+
82
+ class Engine(Protocol):
83
+ """Minimal contract the API depends on."""
84
+
85
+ @property
86
+ def ready(self) -> bool:
87
+ """Whether the engine finished loading and can serve forecasts."""
88
+
89
+ @property
90
+ def max_context(self) -> int | None:
91
+ """Effective context length honored by the engine, or ``None`` if unbounded."""
92
+
93
+ @property
94
+ def max_variates(self) -> int | None:
95
+ """Effective variates per forward pass, or ``None`` if unbounded."""
96
+
97
+ @property
98
+ def quantile_levels(self) -> tuple[float, ...]:
99
+ """The quantile grid the active runtime can produce, in column order."""
100
+
101
+ def predict(self, problem: ExecutionProblem) -> ExecutionResult:
102
+ """Run one canonical execution problem and return a normalized result."""
103
+
104
+
105
+ __all__ = [
106
+ "Engine",
107
+ "ExecutionKnownFutureCovariate",
108
+ "ExecutionPastCovariate",
109
+ "ExecutionProblem",
110
+ "ExecutionResult",
111
+ "ExecutionTarget",
112
+ "QuantileExecutionResult",
113
+ "TargetExecutionResult",
114
+ ]
precog_api/mapping.py ADDED
@@ -0,0 +1,81 @@
1
+ """Translation between the REST wire contract and the canonical execution
2
+ boundary (ADR 0006).
3
+
4
+ The wire contract and the canonical execution problem now share the same shape
5
+ (Phase 2 cutover); this module keeps the engine boundary from depending on wire
6
+ DTOs and assembles the response envelope at the API layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from precog_api.execution import (
12
+ ExecutionKnownFutureCovariate,
13
+ ExecutionPastCovariate,
14
+ ExecutionProblem,
15
+ ExecutionResult,
16
+ ExecutionTarget,
17
+ )
18
+ from precog_schemas import (
19
+ ForecastRequest,
20
+ ForecastResponse,
21
+ ModelProvenance,
22
+ QuantileForecast,
23
+ TargetForecast,
24
+ Usage,
25
+ )
26
+
27
+
28
+ def to_execution_problem(request: ForecastRequest) -> ExecutionProblem:
29
+ """Compile the wire request into one canonical execution problem."""
30
+ return ExecutionProblem(
31
+ horizon=request.horizon,
32
+ targets=[
33
+ ExecutionTarget(id=target.id, values=list(target.values)) for target in request.targets
34
+ ],
35
+ quantiles=list(request.quantiles),
36
+ past_covariates=[
37
+ ExecutionPastCovariate(id=covariate.id, values=list(covariate.values))
38
+ for covariate in request.past_covariates
39
+ ],
40
+ known_future_covariates=[
41
+ ExecutionKnownFutureCovariate(
42
+ id=covariate.id,
43
+ history=list(covariate.history),
44
+ future=list(covariate.future),
45
+ )
46
+ for covariate in request.known_future_covariates
47
+ ],
48
+ )
49
+
50
+
51
+ def to_forecast_response(
52
+ request: ForecastRequest,
53
+ result: ExecutionResult,
54
+ *,
55
+ model: str,
56
+ revision: str | None,
57
+ latency_ms: float,
58
+ ) -> ForecastResponse:
59
+ """Assemble the wire response envelope around normalized engine output."""
60
+ return ForecastResponse(
61
+ horizon=request.horizon,
62
+ targets=[
63
+ TargetForecast(
64
+ id=target.id,
65
+ forecast=target.forecast,
66
+ quantiles=[
67
+ QuantileForecast(level=quantile.level, values=quantile.values)
68
+ for quantile in target.quantiles
69
+ ],
70
+ )
71
+ for target in result.targets
72
+ ],
73
+ model=ModelProvenance(id=model, revision=revision),
74
+ usage=Usage(
75
+ latency_ms=latency_ms,
76
+ context_len=len(request.targets[0].values),
77
+ ),
78
+ )
79
+
80
+
81
+ __all__ = ["to_execution_problem", "to_forecast_response"]
@@ -0,0 +1,65 @@
1
+ """Logging and metrics helpers for the API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from contextvars import ContextVar
8
+ from typing import Any
9
+
10
+ from prometheus_client import Counter, Gauge, Histogram
11
+
12
+ request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
13
+
14
+ REQUEST_COUNT = Counter(
15
+ "precog_requests_total", "HTTP requests processed.", ["method", "path", "status"]
16
+ )
17
+ REQUEST_LATENCY = Histogram(
18
+ "precog_request_duration_seconds", "HTTP request duration in seconds.", ["method", "path"]
19
+ )
20
+ INFLIGHT = Gauge("precog_inflight_requests", "In-flight HTTP requests.")
21
+ FORECAST_SERIES = Counter("precog_forecast_series_total", "Target series forecast.")
22
+ MODEL_LOAD_SECONDS = Gauge("precog_model_load_seconds", "Engine load time in seconds.")
23
+
24
+ # Paths excluded from request metrics (noise / scraping).
25
+ METRICS_EXCLUDED_PATHS = frozenset({"/metrics", "/healthz", "/readyz"})
26
+
27
+ _LOG_EXTRA_FIELDS = ("request_id", "method", "path", "status", "duration_ms", "client")
28
+ _configured = False
29
+
30
+
31
+ class JsonFormatter(logging.Formatter):
32
+ """Render log records as single-line JSON."""
33
+
34
+ def format(self, record: logging.LogRecord) -> str:
35
+ payload: dict[str, Any] = {
36
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
37
+ "level": record.levelname,
38
+ "logger": record.name,
39
+ "msg": record.getMessage(),
40
+ "request_id": getattr(record, "request_id", None) or request_id_var.get(),
41
+ }
42
+ for field in _LOG_EXTRA_FIELDS:
43
+ value = getattr(record, field, None)
44
+ if value is not None:
45
+ payload[field] = value
46
+ if record.exc_info:
47
+ payload["exc"] = self.formatException(record.exc_info)
48
+ return json.dumps(payload, ensure_ascii=False)
49
+
50
+
51
+ def configure_logging(*, json_logs: bool = True, level: str = "INFO") -> None:
52
+ """Configure root logging once, with JSON or plain output."""
53
+ global _configured
54
+ if _configured:
55
+ return
56
+ handler = logging.StreamHandler()
57
+ if json_logs:
58
+ handler.setFormatter(JsonFormatter())
59
+ else:
60
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
61
+ root = logging.getLogger()
62
+ root.handlers[:] = [handler]
63
+ root.setLevel(level)
64
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
65
+ _configured = True
precog_api/tracing.py ADDED
@@ -0,0 +1,44 @@
1
+ """Optional OpenTelemetry tracing.
2
+
3
+ Enabled with ``PRECOG_OTEL_ENABLED=true``. The exporter and endpoint come from
4
+ the standard ``OTEL_EXPORTER_OTLP_ENDPOINT`` / ``OTEL_SERVICE_NAME`` variables.
5
+ The OpenTelemetry packages are an optional extra (``precog-api[otel]``); when
6
+ they are missing, tracing is skipped with a warning.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from fastapi import FastAPI
16
+
17
+ logger = logging.getLogger("precog.tracing")
18
+
19
+
20
+ def setup_tracing(service_name: str = "precog-api", *, fastapi_app: FastAPI | None = None) -> bool:
21
+ """Configure a tracer provider and instrument the FastAPI app.
22
+
23
+ Returns ``True`` when tracing was configured, ``False`` when the optional
24
+ dependencies are not installed.
25
+ """
26
+ try:
27
+ from opentelemetry import trace
28
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
29
+ from opentelemetry.sdk.resources import Resource
30
+ from opentelemetry.sdk.trace import TracerProvider
31
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
32
+ except ImportError:
33
+ logger.warning("OpenTelemetry enabled but not installed; skipping tracing")
34
+ return False
35
+
36
+ provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
37
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
38
+ trace.set_tracer_provider(provider)
39
+
40
+ if fastapi_app is not None:
41
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
42
+
43
+ FastAPIInstrumentor.instrument_app(fastapi_app)
44
+ return True
precog_api/warmup.py ADDED
@@ -0,0 +1,148 @@
1
+ """Provision the TimesFM-3 weights into the model cache.
2
+
3
+ Used by the container entrypoint to download the weights into a mounted volume
4
+ when they are not already present. Works with any volume type: an ephemeral
5
+ directory, a named volume, a bind mount or a Kubernetes PVC.
6
+
7
+ The download is environment-agnostic: it only needs the Hugging Face hub layout
8
+ under ``PRECOG_CACHE_DIR`` and the pinned revision.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import shutil
15
+ import string
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from precog_api.config import Settings
20
+
21
+ logger = logging.getLogger("precog.warmup")
22
+
23
+ REQUIRED_FILES = ("config.json", "model.safetensors")
24
+
25
+
26
+ def model_cache_path(settings: Settings) -> Path:
27
+ """Return the cache directory the engine and the downloader share."""
28
+ if settings.cache_dir:
29
+ return Path(settings.cache_dir)
30
+ from huggingface_hub import constants # noqa: PLC0415
31
+
32
+ return Path(constants.HF_HUB_CACHE)
33
+
34
+
35
+ def is_model_present(cache_dir: Path, model_id: str) -> bool:
36
+ """Check for a complete snapshot of ``model_id`` in the hub cache."""
37
+ snapshots = cache_dir / f"models--{model_id.replace('/', '--')}" / "snapshots"
38
+ if not snapshots.is_dir():
39
+ return False
40
+ return any(
41
+ all((snapshot / name).is_file() for name in REQUIRED_FILES)
42
+ for snapshot in snapshots.iterdir()
43
+ if snapshot.is_dir()
44
+ )
45
+
46
+
47
+ def download_model(settings: Settings) -> None:
48
+ """Download the pinned model revision into the cache directory."""
49
+ from huggingface_hub import snapshot_download # noqa: PLC0415
50
+
51
+ cache_dir = model_cache_path(settings)
52
+ cache_dir.mkdir(parents=True, exist_ok=True)
53
+ logger.info(
54
+ "downloading %s@%s into %s",
55
+ settings.model_id,
56
+ settings.model_revision or "main",
57
+ cache_dir,
58
+ )
59
+ snapshot_download(
60
+ repo_id=settings.model_id,
61
+ revision=settings.model_revision,
62
+ cache_dir=str(cache_dir),
63
+ token=settings.hf_token,
64
+ )
65
+
66
+
67
+ def ensure_model(settings: Settings) -> bool:
68
+ """Ensure the model is available locally, downloading it if needed."""
69
+ cache_dir = model_cache_path(settings)
70
+ if settings.preload == "never":
71
+ present = is_model_present(cache_dir, settings.model_id)
72
+ logger.info("preload disabled; model present=%s in %s", present, cache_dir)
73
+ if not present and settings.model_required:
74
+ raise SystemExit(
75
+ f"model {settings.model_id} not present in {cache_dir} and PRECOG_PRELOAD=never"
76
+ )
77
+ return present
78
+ if settings.preload == "auto" and is_model_present(cache_dir, settings.model_id):
79
+ logger.info("model already present in %s", cache_dir)
80
+ return True
81
+
82
+ last_error: Exception | None = None
83
+ for attempt in range(settings.preload_retries + 1):
84
+ try:
85
+ download_model(settings)
86
+ return True
87
+ except Exception as exc: # noqa: BLE001 - report any download failure
88
+ last_error = exc
89
+ logger.warning("download attempt %d failed: %s", attempt + 1, exc)
90
+ if attempt < settings.preload_retries:
91
+ time.sleep(min(2**attempt, 30))
92
+
93
+ message = f"failed to provision model {settings.model_id}: {last_error}"
94
+ if settings.model_required:
95
+ raise SystemExit(message)
96
+ logger.error("%s (continuing; startup may fail)", message)
97
+ return False
98
+
99
+
100
+ def main() -> None:
101
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
102
+ settings = Settings()
103
+ ensure_model(settings)
104
+ if settings.prune_old_revisions:
105
+ prune_cache(settings)
106
+
107
+
108
+ def prune_cache(settings: Settings) -> int:
109
+ """Remove cached snapshots other than the pinned revision; return the count.
110
+
111
+ Only runs when ``model_revision`` is a pinned commit sha (otherwise we cannot
112
+ tell which snapshot is current).
113
+ """
114
+ revision = settings.model_revision or ""
115
+ if not (len(revision) == 40 and all(char in string.hexdigits for char in revision)):
116
+ logger.info("model revision is not a pinned commit; skipping cache prune")
117
+ return 0
118
+
119
+ cache_dir = model_cache_path(settings)
120
+ repo_dir = cache_dir / f"models--{settings.model_id.replace('/', '--')}"
121
+ snapshots = repo_dir / "snapshots"
122
+ if not snapshots.is_dir():
123
+ return 0
124
+
125
+ removed = 0
126
+ for snapshot in snapshots.iterdir():
127
+ if snapshot.is_dir() and snapshot.name != revision:
128
+ shutil.rmtree(snapshot, ignore_errors=True)
129
+ removed += 1
130
+ _remove_unreferenced_blobs(repo_dir)
131
+ logger.info("pruned %d snapshot(s) from %s", removed, snapshots)
132
+ return removed
133
+
134
+
135
+ def _remove_unreferenced_blobs(repo_dir: Path) -> None:
136
+ """Delete blobs no longer referenced by any remaining snapshot."""
137
+ blobs = repo_dir / "blobs"
138
+ snapshots = repo_dir / "snapshots"
139
+ if not blobs.is_dir():
140
+ return
141
+ referenced = {str(path.resolve()) for path in snapshots.rglob("*") if path.is_symlink()}
142
+ for blob in blobs.iterdir():
143
+ if str(blob.resolve()) not in referenced:
144
+ blob.unlink(missing_ok=True)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: precog-api
3
+ Version: 1.0.0
4
+ Summary: Precog REST API exposing TimesFM-3 forecasts.
5
+ Project-URL: Homepage, https://github.com/Albe83/precog
6
+ Project-URL: Repository, https://github.com/Albe83/precog
7
+ Project-URL: Issues, https://github.com/Albe83/precog/issues
8
+ Project-URL: Changelog, https://github.com/Albe83/precog/blob/main/CHANGELOG.md
9
+ License: MIT
10
+ Keywords: forecasting,time-series,timesfm,zero-shot
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: fastapi>=0.115
17
+ Requires-Dist: numpy>=1.26
18
+ Requires-Dist: precog-schemas<0.2.0,>=0.1.0
19
+ Requires-Dist: prometheus-client>=0.20
20
+ Requires-Dist: pydantic-settings>=2.4
21
+ Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: uvicorn[standard]>=0.53.0
23
+ Provides-Extra: engine
24
+ Requires-Dist: timesfm[torch]>=3.0.2; extra == 'engine'
25
+ Provides-Extra: otel
26
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27; extra == 'otel'
27
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == 'otel'
28
+ Requires-Dist: opentelemetry-sdk>=1.27; extra == 'otel'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # precog-api
32
+
33
+ Precog **REST Execution API**: Google
34
+ [TimesFM-3](https://research.google/blog/timesfm-3-a-zero-shot-foundation-model-for-multivariate-forecasting/)
35
+ zero-shot forecasting as a synchronous, typed HTTP service.
36
+
37
+ This is the Python distribution of the Precog API. Containers and Helm remain
38
+ the preferred production deployment path; install this package for development,
39
+ labs and Python-native environments. The distribution contains **no model
40
+ weights**.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install precog-api # base install: fake engine only, no torch
46
+ pip install "precog-api[engine]" # real TimesFM-3 engine (weights download at runtime)
47
+ ```
48
+
49
+ The base install is lightweight and runs the deterministic fake engine, which is
50
+ enough to exercise the REST contract.
51
+
52
+ ## Run
53
+
54
+ ```bash
55
+ PRECOG_ENGINE=fake precog-api # http://localhost:8000
56
+ precog-api # real engine (requires the [engine] extra)
57
+ ```
58
+
59
+ Configuration uses the `PRECOG_` prefix (`PRECOG_ENGINE`, `PRECOG_DEVICE`,
60
+ `PRECOG_MAX_*`, `PRECOG_API_KEY`, ...). See the repository documentation.
61
+
62
+ ## License
63
+
64
+ Application code is MIT. The TimesFM-3 model weights are distributed under the
65
+ TimesFM Non-Commercial License v1.0 and are **not** part of this distribution.
@@ -0,0 +1,15 @@
1
+ precog_api/__init__.py,sha256=UQksd19z7GTODzOMh3YP6t5RUPIs6l2RiXKZ11KO6pE,88
2
+ precog_api/__main__.py,sha256=iSCHyEge_Kb3RWNxbdp_mGCLFgNLUvJ7dwIyEkNkIHQ,353
3
+ precog_api/app.py,sha256=UVKekh8ca2fx2F_2KXnu0g7l5I5CKnwJUQ0ugSmRFec,15588
4
+ precog_api/config.py,sha256=6T8ebJmmY6Y2tBXzw8NvB2mOeTSO9ueU8HQGKs02fmY,1446
5
+ precog_api/engine.py,sha256=mUEERPdYw3QwyFAzzQ2HAjP52LdKi3A5scKkMJFpCOc,2169
6
+ precog_api/engine_timesfm3.py,sha256=PBx0eCnkWLD2DRZEeK-23ymLgt6vgHa24iIq68mr_eM,7856
7
+ precog_api/execution.py,sha256=SdAmnqkAE3sbKo1Gk-H6NRez1xxNgoiF6Kn4Zzon4SQ,3158
8
+ precog_api/mapping.py,sha256=1kCCFaBG7LUQr02QdDfm5W0fZwihLfH_yZfGvUbH04A,2459
9
+ precog_api/observability.py,sha256=mTijhceVPorffgtlGAfydx-5IvGoxFdSqo2h4jOQhOo,2393
10
+ precog_api/tracing.py,sha256=e1bY6BkC8Uh5taWZM_l2_N812API98OQbs6nUTqBHLY,1644
11
+ precog_api/warmup.py,sha256=aQ0qNet52S2yrLdGaXS13AqUAkq4S-UbIwaPS2Kg0jw,5171
12
+ precog_api-1.0.0.dist-info/METADATA,sha256=AMV_m5HYpZQL2Uu8M3-M7Ug9ltsmwvSFsT00EVBc-6M,2525
13
+ precog_api-1.0.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
14
+ precog_api-1.0.0.dist-info/entry_points.txt,sha256=h5BJQ_d6BKs9iSTSR5J93OjpKGLyEB3-n7FvvUdtWhA,103
15
+ precog_api-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ precog-api = precog_api.__main__:main
3
+ precog-download-model = precog_api.warmup:main