Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,118 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `RECOTEM_ALLOW_IRSPACK_VERSION_SKEW` — truthy downgrades the skew check to a
warning, for operators who know their artifact's algorithm is unaffected.
- `recotem_artifact_load_failures_total` gained a `version_skew` reason label.
- **Feature-aware iALS.** A new optional `features:` recipe block (sibling to
`source:` / `item_metadata:`) declares item- and/or user-side attribute
tables — `categorical` (one-hot), `numerical` (standardized), and
`multi_label` (multi-hot) encodings, plus an implicit bias column — that
are encoded and fed to `IALSRecommender` during Optuna search and the
final refit. The mere presence of `features:` turns this on; there is no
separate flag. `lambda_item_feature` / `lambda_user_feature` are tuned by
Optuna over a new recotem-owned range (`5e-2`–`1e6`, log-scale) rather than
irspack's own `default_suggest_parameter`, because irspack ships no default
range for them and their `0.0` constructor default is a hard error whenever
the matching feature matrix is non-empty. See
`docs/recipe-reference.md#features`.
- **Cold-start serving from side features.** `POST
/v1/recipes/{name}:recommend` accepts `user_features` to score an unknown
user from their profile alone; `POST /v1/recipes/{name}:recommend-related`
accepts `user_features` (profile prior added to an ad-hoc seed history) and
`item_features` (keyed by seed id, for seed items absent from training).
Both single and batch verbs support this. A known `user_id`'s supplied
`user_features` are deliberately **ignored**, not rejected — the learned
embedding from real interactions strictly dominates a profile prior. A
request that supplies feature values against a model with no matching
feature state gets a new `400 FEATURES_NOT_SUPPORTED` rather than a guess.
Separately, a supplied `numerical` value whose *standardized* magnitude
(raw value standardized against the column's training mean/std — not the
raw value itself) is large enough to make irspack's per-request cold-start
solve itself fail gets a new `400 FEATURE_VALUE_UNUSABLE` rather than an
unhandled `500` — distinct from `FEATURES_NOT_SUPPORTED` because the model
and feature side both support cold start here; only this particular value
does not. The `detail` message describes the standardized value as
numerically unusable, not the client's raw one, because a column with a
small enough training std can make an entirely ordinary raw value (e.g.
`10000`) standardize to an unusable magnitude just as easily as an
actually-extreme raw value against a normal-sized std. A `numerical` value
large enough to be meaningless but not large enough to break the solver is
**not** caught by this and degrades silently as `200` instead — clamping
that range was a deliberate, deferred modelling decision, not an
oversight. A non-finite supplied value (`Infinity`/`-Infinity`, or a
string like `"nan"`) increments `recotem_v1_feature_unknown_value_total`
rather than degrading invisibly; a missing or otherwise unparseable value
still degrades silently with no signal, unchanged. See
`docs/api-reference.md#feature-aware-cold-start`.
- `RECOTEM_MAX_FEATURE_DIM` (default 5000, clamped [16, 100000]) — caps the
encoded feature dimension per side. The vocabulary is built from the whole
fetched feature table (so cold-start entities are representable), which
means encoded dimension scales with **catalog size, not interaction
count**; `min_frequency` on high-cardinality columns is the only
recipe-level lever. Cost is cubic in this number and multiplies with
`training.parallelism`. See `docs/operations.md#feature-aware-ials-sizing`.
- Artifact headers for feature-aware models gain a `features` block
(`{"version": 1, "item": {...}, "user": {...}}`), inspectable via `recotem
inspect`. Serve checks this version before deserializing the payload:
absent → loads (old artifact or non-feature model); present but
unrecognized → refused (`ArtifactError`, reason `feature_version`) rather
than risk silently mis-encoding a request's features into the wrong vector
space.
- New metrics: `recotem_v1_feature_unknown_value_total` (a request's
categorical/multi_label value was absent from the training vocabulary, or
a numerical value was non-finite — degrades to an all-zero segment /
contributes nothing rather than failing the request) and
`recotem_v1_cold_start_requests_total` (cold-start traffic by case).
- New example: `examples/feature-aware/` — a small interactions CSV, an item
feature table exercising all three encodings, and a README walking
train → serve → cold-start `:recommend-related`.
- **Request-body size cap.** `serve` now bounds the raw HTTP request body via a
`BodySizeLimitMiddleware` before Starlette buffers and JSON-parses it: a
declared `Content-Length` over the cap is rejected outright, and bodies with
no `Content-Length` (chunked / streamed) are counted as they arrive so the
header cannot be omitted to bypass the limit. Over-cap requests get a
`413 PAYLOAD_TOO_LARGE` in the standard error envelope. Previously an
authenticated client could make the process buffer and parse a multi-GB body.
- `RECOTEM_MAX_BODY_BYTES` (default 128 MiB, clamped [1 MiB, 2 GiB]) tunes the
cap. The default clears the largest well-formed request `serve` already
accepts (~72 MiB) with headroom while blocking GB-scale bodies. A new
`PAYLOAD_TOO_LARGE` error code is added to the v1 API's `ErrorCode` union.
- **Cold-start feature-dict key-length caps.** Every feature-mapping KEY is now
bounded to 1–256 characters (parity with other identifier fields):
`user_features` column names, the `item_features` outer seed-id keys, and the
nested per-seed feature keys. Previously only string VALUES were capped and
`Field(max_length=64)` bounded only the key COUNT, leaving key length
unbounded. Over-length or empty keys now get a `422`; an over-length key
reports only its length, never its (possibly huge) text.

### Changed

- **A numerical `features:` column with a tiny-but-nonzero training std is
now treated as zero-variance, like an exactly-constant column.** Previously
only an exact `std == 0.0` was floored; a column whose values differ only
by floating-point rounding noise (e.g. `std ≈ 1e-15`) passed that check but
still divided serve-time standardization by a near-zero denominator,
turning an ordinary request value into an astronomically large
standardized one and a false `400 FEATURE_VALUE_UNUSABLE`.
`build_encoder_state` now floors any std no larger than `1e-8 ×
max(abs(mean), 1.0)` (relative to the column's own scale) to zero. A
column caught by this floor degrades exactly like a missing value
(`feature_zero_variance_column` warning, unchanged) instead of ever
reaching the standardization divide. This changes training-time encoding
for any feature table containing such a column; retrain to pick it up. See
`docs/api-reference.md#feature-aware-cold-start`.
- **Every recipe's `recipe_hash` changes on upgrade, features or not.** The
hash is computed by JSON-dumping the whole recipe with no `exclude_none`,
so adding the new optional `features` field emits `{"features": null}` for
every existing recipe and changes its hash — the same effect
`item_metadata` already has when absent. Nothing in Recotem compares or
gates on `recipe_hash` today; it is carried through to the artifact header
(`recotem inspect`), the `train_done` log event, and the
`GET /v1/recipes/{name}` response purely for operators' own SIEM/audit
rules. The inference verbs do not echo it: `:recommend` returns
`request_id` / `recipe` / `model_version` / `items` only. If you pin or
diff `recipe_hash` in external tooling, expect every recipe to show a
changed hash on this upgrade even though nothing about the recipe's
behavior changed.

- **irspack upgraded from 0.4.2 to 0.5.0.** irspack 0.5.0 adds feature-aware
iALS, cache/Eigen performance work, and a reworked tuning API. Recotem drives
Optuna itself and does not call `BaseRecommender.tune`, so none of irspack's
Expand All @@ -46,6 +155,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
sklearn axis at all. If you need TruncatedSVD artifacts to be reproducible
bit-exact, pin sklearn exactly or build train and serve from the same lock
file.
- **`recotem validate` labels each probed data source.** Because a recipe may
now declare feature-side sources (`features.item.source` /
`features.user.source`) alongside the top-level `source:`, the probe output
tags which one it is (`DataSource: probe OK (csv) [source]`, `DataSource probe
failed [features.item.source]: ...`) and the missing-discriminator message
reads `source is missing the 'type' discriminator.` rather than `Recipe
source is missing the 'type' discriminator.`. Exit codes are unchanged;
tooling that greps the exact `validate` output lines should update.

### Fixed

- **Feature-aware iALS: an all-dead-numerical `features:` block is now
refused.** The whole-block-dead guard keyed on `n_features == 1`, which a
block whose only column is a zero-variance (or all-null) `numerical` column
escaped — a numerical column always reserves width 1, so `n_features` stayed
2 even though it emits nothing. Such a block would sign an artifact
advertising `features` while serving bias-only (== plain iALS). The guard now
refuses a block when no column can emit a non-bias feature, matching the
existing all-categorical-dead and zero-id-overlap refusals.
- **Feature-aware iALS: a finite-but-huge cold-start `numerical` value no
longer injects `inf`.** The non-finite check tested the raw parsed value, but
the matrix stores the value standardized and cast to `float32`; a value
finite in float64 whose standardized magnitude exceeds float32's max became
`±inf` in the matrix and was not counted. It is now counted as an unknown
value (`recotem_v1_feature_unknown_value_total`) and contributes nothing,
like any other unusable value.
- **Cold-start feature request values are now length-capped.** Each string
value in `user_features` / `item_features` is capped at 8192 characters
(`422` on violation, like every other request-schema cap). Previously only
the key count was capped, leaving a single string value unbounded — a
memory-amplification vector via
`multi_label` tokenization, reachable with one API key and multiplied by
batch/related fan-out. The cap covers the batch verbs too.
- **Feature-aware iALS training: an unrepresentable `numerical` column fails
with a training-domain error, not exit 1.** A `numerical` column carrying a
Python int too large for float64 (`>= 309` digits) raised an unmapped
`OverflowError` (exit 1) from the fit's own parser; it now raises a
`TrainingError` (exit 4) naming the column. A complex-valued column, which
previously trained silently on its real part, is now rejected explicitly.
- **Recipe load rejects a `features.<side>.id_column` that also names a feature
column.** The collision is guaranteed to fail at train time (the id column is
consumed as the index); it is now caught at recipe load with a clear message.
- **Feature-aware iALS: a non-finite value no longer silently kills an
otherwise-usable `numerical` column.** `pd.to_numeric` maps an overflow token
like `1e400` to `+inf`, and pandas `mean` / `std` do not skip `±inf`, so a
single such cell made the column's `std` non-finite and routed the whole
column to the zero-variance path — silently dropping a column that still held
usable finite values (like `[1, 2, 3]`) while the artifact continued to
advertise `features`, and emitting a `feature_zero_variance_column` warning
that misattributed the cause as "divide by zero." `build_encoder_state` now
computes mean/std over the finite values only, so a stray overflow cell
degrades to `unknown` at encode time — exactly as it already did per request —
instead of killing the column at fit time. A column that parses to no finite
value at all is still dropped, now with a distinct, accurate warning detail.
This changes training-time encoding for any feature table with such a column;
retrain to pick it up.
- **`:recommend-related` cold-start paths now return `404 NO_CANDIDATES`
consistently.** The pre-existing all-seeds-known path raised `NO_CANDIDATES`
when the ranker produced no survivors, but the two cold-start branches (the
`user_features` profile prior, and `item_features` for a seed absent from
training) returned `200` with an empty `items` list for the identical
condition. Both branches now raise the same `NO_CANDIDATES`, so every path of
the verb — single and batch — reports an empty result the same way.

### Migrating to irspack 0.5.0

Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ src/recotem/
├── recipe/ pydantic v2 Recipe + YAML loader + env expansion
├── datasource/ DataSource Protocol + entry_points discovery (csv / parquet / bigquery / sql)
├── training/ Optuna search + irspack train; per-recipe file lock
│ └── features.py fetch feature tables, build encoder state, encode per phase (search vs. final refit)
├── artifact/ HMAC-signed binary container with FQCN allow-list
├── metadata/ item metadata loader (CSV/Parquet via fsspec)
├── serving/ FastAPI app, ModelRegistry, ArtifactWatcher
├── _idmap.py Neutral home for IDMappedRecommender (canonical FQCN)
├── _features.py Neutral home for side-feature encoder state + pure encode logic (shared by training/ and serving/)
├── _irspack_compat.py Verified-compatible allow-list guarding irspack pickle skew
├── _http_fetch.py SSRF-guarded HTTP/HTTPS fetcher with sha256 verify
├── _size_cap.py Shared download-size cap helper (used by csv source + metadata loader)
Expand Down Expand Up @@ -120,6 +122,12 @@ See `docs/recipe-reference.md` for the full schema. Highlights:
- Cleansing block: `drop_null_ids`, `dedup` policy, `min_rows / min_users /
min_items` data preconditions.
- Multi-algorithm Optuna search with optional per-algorithm trial budgets.
- Optional `features:` block (sibling to `source:` / `item_metadata:`) turns
on feature-aware iALS — no separate flag. `features.item` / `features.user`
each declare a `source` (same datasource registry as the top-level
`source`), an `id_column`, and a `columns` list of `{name, encoding,
delimiter?, min_frequency?}` (`categorical` | `numerical` | `multi_label`).
See `docs/recipe-reference.md#features`.

## Artifact format

Expand Down Expand Up @@ -211,6 +219,7 @@ uv run ruff format --check src tests
| `RECOTEM_DRAIN_SECONDS` | 30 | SIGTERM grace window. Clamped [1, 300]. |
| `RECOTEM_LOG_FORMAT` | auto | `auto` / `json` / `console`. |
| `RECOTEM_MAX_PAYLOAD_BYTES` | 512 MiB | Per-payload cap (post-HMAC-verify) for serve-side deserialization. Clamped [1 MiB, 16 GiB]. Smaller than `RECOTEM_MAX_ARTIFACT_BYTES` to bound deserialization memory expansion. |
| `RECOTEM_MAX_BODY_BYTES` | 128 MiB | Max serve-side HTTP **request** body size. Clamped [1 MiB, 2 GiB]. A `BodySizeLimitMiddleware` returns `413 PAYLOAD_TOO_LARGE` when the declared `Content-Length` exceeds the cap, and enforces a running byte count on chunked/streamed bodies with no `Content-Length` so the header cannot be omitted to bypass it. Default preserves the entire legitimate request space (largest well-formed body main accepts is ~72 MiB) while blocking GB-scale bodies that Starlette would buffer and parse before validation. |
| `RECOTEM_ARTIFACT_ROOT` | (empty) | If set, local `output.path` must lie under it. |
| `RECOTEM_RECIPE_*` | — | Allow-listed for `${...}` recipe expansion. |
| `RECOTEM_METADATA_FIELD_DENY` | (empty) | Comma-separated columns stripped from `/v1/recipes/{name}:recommend` and `:recommend-related` responses. |
Expand All @@ -221,6 +230,7 @@ uv run ruff format --check src tests
| `RECOTEM_STARTUP_PARALLELISM` | (empty = auto) | Number of parallel threads used to load artifacts at `recotem serve` startup. Leave unset (default) for auto-sizing (`min(len(recipes), 8)`). Setting to `0` is NOT a sentinel — it clamps to 1 and emits an `env_var_clamped` warning. Clamped [1, 32]. Set to `1` to force sequential loading for debugging. |
| `RECOTEM_MAX_SQL_ROWS` | 50_000_000 | Hard cap on rows returned by the SQL data source. Clamped [1_000, 500_000_000]. Caps **row count**, not DataFrame resident memory — see `docs/data-sources/sql.md` for the memory-bound caveat. |
| `RECOTEM_SQL_ALLOW_PRIVATE` | (empty) | Truthy opts the SQL source into private/loopback DSN hosts (default deny, for SSRF). Covers every driver-routing form — netloc, `?host=`, `?hostaddr=`, `?service=`, `?unix_socket=`, absolute-path host, and network DSNs with no host info — all default-deny without this flag. Also disables the DNS-rebinding re-check before each probe/fetch — opting in means trusting the host end-to-end. |
| `RECOTEM_MAX_FEATURE_DIM` | 5000 | Cap on the encoded feature dimension per side (item and user checked independently) for feature-aware iALS. Clamped [16, 100000]. Vocabulary is built from the whole fetched feature table (not just interaction-covered rows), so dimension scales with **catalog size, not interaction count**; `min_frequency` on high-cardinality `categorical`/`multi_label` columns is the only recipe-level lever. Cost is cubic in this number (dense `Fᵀ F` Cholesky) and multiplies with `training.parallelism`. See `docs/operations.md#feature-aware-ials-sizing`. |

## CI

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ moving parts to a recipe file and a binary artifact:

- Recipe-driven: 1 YAML = 1 model = 1 `/v1/recipes/{name}:recommend` endpoint (with related/batch verbs)
- Hyperparameter search across irspack algorithms via Optuna
- Feature-aware iALS: attach item/user side features (categorical / numerical / multi_label) via a `features:` recipe block
- Cold-start serving: recommend for unknown users and unseen seed items from their attributes alone via `user_features` / `item_features`
- Pluggable data sources (built-in: CSV / Parquet / BigQuery / SQL; extend via Python entry points)
- HMAC-signed artifacts with multi-key rotation and a deterministic
FQCN allow-list at deserialization time
Expand Down
Loading