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 +5 -0
- precog_api/__main__.py +17 -0
- precog_api/app.py +427 -0
- precog_api/config.py +49 -0
- precog_api/engine.py +74 -0
- precog_api/engine_timesfm3.py +211 -0
- precog_api/execution.py +114 -0
- precog_api/mapping.py +81 -0
- precog_api/observability.py +65 -0
- precog_api/tracing.py +44 -0
- precog_api/warmup.py +148 -0
- precog_api-1.0.0.dist-info/METADATA +65 -0
- precog_api-1.0.0.dist-info/RECORD +15 -0
- precog_api-1.0.0.dist-info/WHEEL +4 -0
- precog_api-1.0.0.dist-info/entry_points.txt +3 -0
precog_api/__init__.py
ADDED
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}")
|
precog_api/execution.py
ADDED
|
@@ -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,,
|