Code import - branch release/SIENTIAPDE-1646
This commit is contained in:
149
docs/E2E_TEST_REPORT.md
Normal file
149
docs/E2E_TEST_REPORT.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# E2E test run report
|
||||
|
||||
**Date:** 2026-05-08
|
||||
**Command:** `source venv/bin/activate && rtk pytest e2e/ -v --tb=short`
|
||||
**Environment:** Linux, Python 3.11.15, pytest 9.0.3
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|------:|
|
||||
| Collected | 43 |
|
||||
| **Passed** | **37** |
|
||||
| **Failed** | **6** |
|
||||
|
||||
Full pytest output (compressed by `rtk`) was written to:
|
||||
|
||||
`~/.local/share/rtk/tee/1778268193_pytest.log`
|
||||
|
||||
---
|
||||
|
||||
## Failed tests (6)
|
||||
|
||||
1. `e2e/test_drift.py::test_drift_happy_path_persists_all_columns_with_reference_data`
|
||||
2. `e2e/test_drift.py::test_drift_uses_30pct_fallback_when_reference_unavailable`
|
||||
3. `e2e/test_drift.py::test_drift_chunk_period_seconds_preserves_seconds_in_chunk_start_date`
|
||||
4. `e2e/test_predictions_batch_prediction_process.py::test_scenario_2_1_3_input_gate_triggers_repeat`
|
||||
5. `e2e/test_predictions_batch_prediction_process.py::test_scenario_2_2_3_transform_gate_triggers_repeat`
|
||||
6. `e2e/test_predictions_batch_prediction_process.py::test_scenario_2_3_3_predict_gate_triggers_repeat`
|
||||
|
||||
**Follow-up:** Jensen–Shannon NULLs for the drift happy-path scenario are fully traced (histogram out-of-range + `density=True`, vs NannyML’s leftover bin) in [DRIFT_JS_NULL_INVESTIGATION.md](./DRIFT_JS_NULL_INVESTIGATION.md).
|
||||
|
||||
---
|
||||
|
||||
## Failure group A — Drift: `drift_metrics.value` NOT NULL (2 tests)
|
||||
|
||||
### Error
|
||||
|
||||
`psycopg2.errors.NotNullViolation`: null value in column `value` of relation `sientia_data.drift_metrics` violates not-null constraint.
|
||||
|
||||
Example failing row (from logs): `feature=sensor_2`, `method=jensen_shannon`, `value=null`, with `kolmogorov_smirnov` / `wasserstein` populated for the same chunk.
|
||||
|
||||
The bulk INSERT built by `Activities.export_data_to_postgres` includes parameters such as `'value__4': None` for `jensen_shannon` on a given chunk.
|
||||
|
||||
### Root cause
|
||||
|
||||
`calculate_drift` (real `DriftAnalysis` + `ModelMetrics.get_drift_metrics`) can emit **NaN / missing** values for some metric methods (here **Jensen–Shannon**) on some features/chunks. Pandas/SQLAlchemy turns that into SQL `NULL`, while the E2E schema (mirroring production) defines:
|
||||
|
||||
```sql
|
||||
value numeric NOT NULL
|
||||
```
|
||||
|
||||
in `e2e/db_schema.sql` for `sientia_data.drift_metrics`.
|
||||
|
||||
### Recommended fixes (pick one consistent with product rules)
|
||||
|
||||
1. **Application layer (preferred if NULLs are never valid in production):** Before export, sanitize the drift dataframe — e.g. drop rows where `value` is null/NaN, or replace with a defined sentinel (only if product agrees), or skip emitting that method row when the statistic is undefined.
|
||||
2. **Analytics layer:** Harden the Jensen–Shannon (and similar) paths so they always return a finite float for the supported inputs, or explicitly map “undefined” to an agreed numeric convention.
|
||||
3. **Schema (only if product allows missing metrics):** Align DDL with reality by making `value` nullable — **only** if production and downstream consumers already expect missing metrics; the E2E comment in `test_drift.py` suggests `feature`/`timestamp` nullable cases exist, but `value` is still listed in `NON_NULL_DRIFT_COLUMNS`.
|
||||
|
||||
### Tests affected
|
||||
|
||||
- `test_drift_happy_path_persists_all_columns_with_reference_data`
|
||||
- `test_drift_uses_30pct_fallback_when_reference_unavailable`
|
||||
|
||||
---
|
||||
|
||||
## Failure group B — Drift: chunk period seconds (1 test)
|
||||
|
||||
### Error
|
||||
|
||||
`AssertionError: expected at least one drift row to be persisted` (`e2e/test_drift.py:493`).
|
||||
|
||||
The workflow run completed without failing the test via `WorkflowFailureError`, but **no rows** were found in `sientia_data.drift_metrics` for the model.
|
||||
|
||||
### Likely cause
|
||||
|
||||
In `Drift.run`, persistence runs only when `if drift_data:` is truthy (`laborious/workflows/drift.py`). An **empty** drift result skips `export_data_to_postgres`, so the table stays empty.
|
||||
|
||||
Probable reasons:
|
||||
|
||||
- With **`chunk_period='s'`** and only **three** target rows (30 s spacing), `calculate_drift` / `DriftAnalysis` may produce **no output rows** (insufficient data per chunk or internal filters).
|
||||
- Less likely here: time-window mismatch — timestamps are built from `datetime.now(UTC)` with `interval: 60` minutes from `drift_base.json`, so data should still fall in the window.
|
||||
|
||||
### Recommended fixes
|
||||
|
||||
1. **Test data:** Increase the number of second-spaced points (and/or span multiple chunk boundaries) so the analyzer reliably emits at least one chunk row.
|
||||
2. **Product code:** If sub-minute chunking is required to always produce metrics when any data exists, adjust `ModelMetrics` / `DriftAnalysis` integration for small-N second buckets.
|
||||
3. **Diagnostics:** Run the same scenario with `pytest -s` and confirm logs for “empty `drift_data`” vs export errors.
|
||||
|
||||
Solução a ser aplicada:
|
||||
Dividir em dois testes:
|
||||
|
||||
1. Teste com dados suficientes para produzir pelo menos uma linha de drift
|
||||
2. Teste com dados insuficientes para produzir pelo menos uma linha de drift, mas ja esperando os erros e validando que nao foi persistido nada
|
||||
|
||||
### Test affected
|
||||
|
||||
- `test_drift_chunk_period_seconds_preserves_seconds_in_chunk_start_date`
|
||||
|
||||
---
|
||||
|
||||
## Failure group C — Prediction REPEAT path: unique constraint on predictions (3 tests)
|
||||
|
||||
### Error
|
||||
|
||||
`psycopg2.errors.UniqueViolation`: duplicate key value violates unique constraint **`unique_model_id_timestamp`** on `sientia_data.predictions` (`model_id`, `timestamp`).
|
||||
|
||||
### Root cause
|
||||
|
||||
REPEAT is handled by `Activities.repeat_last_prediction` (registered from `sientia_do.temporal.activities.postgres_sync.Postgres`, via `Storage` inheritance in `laborious/activities/storage.py`). The E2E tests seed a prior row with a **fixed** timestamp:
|
||||
|
||||
```python
|
||||
# e2e/test_predictions_batch_prediction_process.py — insert_sample_prediction
|
||||
VALUES ({model_id}, '2024-01-01 12:00:00+00:00', ...)
|
||||
```
|
||||
|
||||
The helper `assert_repeat` expects **two** rows with the same `(model_id, prediction, prediction_confidence, prediction_status)` but compares **only** those columns — not `timestamp` (`e2e/helpers.py`). So the intended behavior is: **duplicate business payload**, not necessarily **duplicate primary unique key** `(model_id, timestamp)`.
|
||||
|
||||
If `repeat_last_prediction` **INSERT**s a copy using the **same** `timestamp` as the last prediction, Postgres correctly rejects the second insert.
|
||||
|
||||
### Recommended fixes
|
||||
|
||||
1. **Implement REPEAT as UPSERT:** Use `ON CONFLICT (model_id, timestamp) DO UPDATE` (or the project’s existing `export_data_to_postgres` `on_conflict` pattern used in `FormatAndExportPrediction`) when writing the repeated prediction — if the product definition of REPEAT is “refresh same logical slot.”
|
||||
2. **Insert with the current batch timestamp:** Copy numeric/status fields from the last row but set `timestamp` to the **new** batch instant (e.g. the workflow’s `last_timestamp` / slice timestamp). This matches `assert_repeat`, which does not assert on `timestamp`.
|
||||
3. **Test-only change (weakest):** Relax assertions or change seed data — only if production behavior is “duplicate key is expected” (unlikely).
|
||||
|
||||
Solução a ser aplicada:
|
||||
Alterar o teste para usar o timestamp da ultima execucao do batch, ao inves do timestamp do primeiro batch.
|
||||
2. **Insert with the current batch timestamp:** Copy numeric/status fields from the last row but set `timestamp` to the **new** batch instant (e.g. the workflow’s `last_timestamp` / slice timestamp). This matches `assert_repeat`, which does not assert on `timestamp`.
|
||||
|
||||
### Tests affected
|
||||
|
||||
- `test_scenario_2_1_3_input_gate_triggers_repeat`
|
||||
- `test_scenario_2_2_3_transform_gate_triggers_repeat`
|
||||
- `test_scenario_2_3_3_predict_gate_triggers_repeat`
|
||||
|
||||
---
|
||||
|
||||
## Passing areas (sanity check)
|
||||
|
||||
All scenarios in `test_child_workflows_e2e.py`, `test_minimal_retrain.py`, `test_minio_offload.py`, `test_predictions_batch_format_export.py`, `test_predictions_batch_main_workflow.py` (except the three REPEAT cases above), and `test_simple_metrics.py` **passed** in this run.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. Fix **drift `value` NULL** — unblocks two high-value drift E2Es and may clarify the chunk-seconds scenario if exports start succeeding consistently.
|
||||
2. Fix **`repeat_last_prediction` uniqueness** — unblocks three prediction-process E2Es; implementation likely lives in **`sientia_do`** Postgres activities, not in this repo.
|
||||
3. Revisit **`test_drift_chunk_period_seconds`** data volume / expectations after drift export is stable.
|
||||
164
docs/opc-communication.md
Normal file
164
docs/opc-communication.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# OPC UA communication (Laborious)
|
||||
|
||||
Laborious exports predictions to OPC UA servers through `OpcRepository` ([`laborious/utils/repository/opc_repository.py`](../laborious/utils/repository/opc_repository.py)) and the synchronous Temporal activity layer in [`laborious/activities/opc.py`](../laborious/activities/opc.py). The repository uses `asyncua.sync.Client` (asyncio on a background thread) so activities remain blocking without `async def`.
|
||||
|
||||
OPC reconnect, write error classification (`opc_error_kind`), and activity confidence/comment behavior are converted from the **async** implementation on `main` at `fcc8920a8be4` (`asyncua.Client` + `asyncio` reconnect task → `threading` reconnect thread). Re-convert with `scripts/convert_opc_async_to_sync.py` when `main` OPC files change.
|
||||
|
||||
Implementation plan for session/channel recovery on Tier-1 `Bad*` errors: [`.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md`](../.cursor/plans/opc_bad_reconnect_ac4c6045.plan.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Worker (long-lived)
|
||||
└── OpcRepository per OPC server id (from OPC_CONFIG / env)
|
||||
├── connect / disconnect / validate_connection (read-only)
|
||||
├── _connect_locked / _reconnect_locked (under _connection_lock)
|
||||
├── write_data (single attempt per call)
|
||||
└── background reconnect on Tier-1 Bad*, closed protocol, or stale session
|
||||
|
||||
Temporal activity write_opc_data
|
||||
└── OPC.manage_output_tags → write_data per tag (sequential per activity)
|
||||
```
|
||||
|
||||
One worker process holds one `OpcRepository` instance per configured server. Multiple Temporal activities can call `write_data` concurrently on the same repository.
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
| Phase | Behavior |
|
||||
|-------|----------|
|
||||
| Startup | `init_opc()` creates repositories and calls `connect()` → `_connect_locked()` |
|
||||
| Steady state | `validate_connection()` is read-only (`protocol.state` only); `_session_ready` is checked in `write_data` |
|
||||
| Tier-1 Bad* / protocol closed / session not ready | `_start_reconnect(reason)` → `_run_reconnect` (thread) → `_reconnect_locked()` (respects `reconnection_interval`) |
|
||||
| Write | `write_data()` checks in-flight reconnect thread, `_session_ready`, validates, then one `get_node` + `write_value` |
|
||||
| Shutdown | `disconnect()` sets `_allow_reconnect = False`, then tears down session |
|
||||
|
||||
### Session and channel timeouts
|
||||
|
||||
Requested session and secure-channel lifetime: **10 minutes** (`OPC_UA_SESSION_AND_CHANNEL_TIMEOUT_MS` in `opc_repository.py`). The server may revise these values; negotiated values are logged after connect and exposed as `opc_session_revised_timeout_milliseconds`.
|
||||
|
||||
### Reconnection interval
|
||||
|
||||
`OPC_RECONNECTION_INTERVAL` is in **seconds** (default `120`). It gates **background** reconnect after Tier-1 `Bad*` (`last_reconnection_time` is updated only in `_reconnect_locked()`). It limits load on the OPC server when many workflows fail at once.
|
||||
|
||||
## Concurrency: connection lock and session readiness
|
||||
|
||||
To allow **multiple concurrent writes** when the session is healthy, but **block all writes** while the connection is being torn down or re-established:
|
||||
|
||||
| Primitive | Role |
|
||||
|-----------|------|
|
||||
| `_connection_lock` (`threading.Lock`) | Held for the entire `disconnect` → `connect` path. Only one connection-maintenance task at a time. |
|
||||
| `_session_ready` (`threading.Event`) | Set when a session is ready for writes; cleared before reconnect starts and set again after a successful connect. |
|
||||
| `_allow_reconnect` | Cleared in `disconnect()` so shutdown does not spawn reconnect threads |
|
||||
|
||||
**Connection methods (caller holds `_connection_lock` for `_*_locked` helpers):**
|
||||
|
||||
| Method | Role |
|
||||
|--------|------|
|
||||
| `_create_client()` | Create asyncua `Client` + optional `set_security`; raises if `client` already exists |
|
||||
| `_open_session()` | `client.connect()` + metrics; raises if session already open or client missing |
|
||||
| `_connect_locked()` | `_create_client()` (when needed) + `_open_session()`; raises if already connected |
|
||||
| `_disconnect_locked()` | Teardown session and clear `client` |
|
||||
| `_reconnect_locked()` | `_disconnect_locked()` + `_connect_locked()`; sets `last_reconnection_time` |
|
||||
|
||||
Public `connect()` / `disconnect()` acquire the lock and call `_connect_locked()` / `_disconnect_locked()`.
|
||||
|
||||
**Write path (`write_data`):**
|
||||
|
||||
1. If a reconnect **thread** is alive → `reconnect_in_progress`.
|
||||
2. If `_session_ready` is cleared → schedule `SessionNotReady` reconnect; return `reconnect_in_progress` or `connection_lost`.
|
||||
3. If `validate_connection()` fails (protocol closed) → schedule `ProtocolClosed` reconnect; return `connection_lost`.
|
||||
4. Single `get_node` + `write_value` (no retry in the same call).
|
||||
|
||||
**Reconnect path (`_run_reconnect`):**
|
||||
|
||||
1. `_start_reconnect` clears `_session_ready` and starts a daemon thread when the interval allows and `_allow_reconnect` is true.
|
||||
2. `with _connection_lock:` → `_reconnect_locked()`.
|
||||
3. `_session_ready` is set on successful `_open_session()`.
|
||||
|
||||
A second `_connect_locked()` while a session is already open raises `OpcSessionAlreadyConnectedError` (disconnect first).
|
||||
|
||||
**asyncua note:** Concurrent `write_value` on the same session is only safe if the stack tolerates it. If production shows issues, serialize writes while keeping the connection lock semantics above.
|
||||
|
||||
## Tier-1 `Bad*` errors and reconnect
|
||||
|
||||
When the server invalidates the session (e.g. `BadSessionIdInvalid`) but the client still sees transport as open, `write_data` fails once, records the OPC status in metrics, and **schedules** reconnect if:
|
||||
|
||||
- The exception is a `UaStatusCodeError` whose name is in `RECONNECTABLE_OPC_BAD_NAMES` (see plan), and
|
||||
- `reconnection_interval` has elapsed since `last_reconnection_time`, and
|
||||
- No reconnect task is already running.
|
||||
|
||||
There is **no write retry**: the failed export is not sent again in the same activity.
|
||||
|
||||
## Prediction confidence and PostgreSQL comments
|
||||
|
||||
| `prediction_confidence` | Meaning |
|
||||
|-------------------------|---------|
|
||||
| (unchanged) | Successful OPC export |
|
||||
| **12** | Generic OPC write failure (`OPC_WRITTING_ERROR_CONFIDENCE`) |
|
||||
| **14** | Tier-1 session/channel `Bad*` on export (`OPC_SESSION_BAD_CONFIDENCE`) |
|
||||
| **14** | Write while reconnect in progress (`OPC_SESSION_BAD_CONFIDENCE`, comment `OPC UA reconnect in progress`) |
|
||||
| **13** | PI Web API write failure (separate path) |
|
||||
|
||||
Session/channel errors use a stable comment for counting:
|
||||
|
||||
```text
|
||||
OPC UA session/channel error: BadSessionIdInvalid
|
||||
```
|
||||
|
||||
Reconnect-in-progress exports use:
|
||||
|
||||
```text
|
||||
OPC UA reconnect in progress
|
||||
```
|
||||
|
||||
Example SQL:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM predictions WHERE prediction_confidence = 14;
|
||||
SELECT count(*) FROM predictions WHERE comments LIKE 'OPC UA session/channel error:%';
|
||||
```
|
||||
|
||||
## Prometheus metrics (`opc_*`)
|
||||
|
||||
Defined in [`laborious/metrics.py`](../laborious/metrics.py). Do not rename in production without a dashboard migration.
|
||||
|
||||
| Metric | Purpose |
|
||||
|--------|---------|
|
||||
| `opc_connections_initiated_total` | Connection attempts |
|
||||
| `opc_connections_failed_total` | Failed connects |
|
||||
| `opc_connection_status` | Gauge 1=connected, 0=disconnected |
|
||||
| `opc_session_created_total` | Session established after connect |
|
||||
| `opc_session_closed_total` | Disconnect initiated |
|
||||
| `opc_session_revised_timeout_milliseconds` | Negotiated session timeout (ms) |
|
||||
| `opc_write_attempts_total` | Per write; label `result` = `OK` or exception name |
|
||||
| `opc_write_inter_arrival_over_session_timeout_total` | Successful writes spaced longer than revised session timeout |
|
||||
|
||||
Legacy activity metrics: `laborious_prediction_opc_writing_count`, `laborious_prediction_opc_writing_response_time_monitor`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OPC_CONFIG` | — | JSON map of server configs (overrides single-server env) |
|
||||
| `OPC_ID` | `1` | Server id |
|
||||
| `OPC_URL` | `opc.tcp://localhost:4840` | Endpoint |
|
||||
| `OPC_SERVER_NAME` | `default_server` | Label for metrics/logs |
|
||||
| `OPC_SERVER_URI` | same as URL | Application URI / cert SAN |
|
||||
| `OPC_CERT_PATH` | — | Client certificate (secure mode) |
|
||||
| `OPC_PRIVATE_KEY_PATH` | — | Client private key |
|
||||
| `OPC_SERVER_CERT_PATH` | — | Server certificate |
|
||||
| `OPC_RECONNECTION_INTERVAL` | `120` | Minimum seconds between reconnects |
|
||||
|
||||
## Operations checklist
|
||||
|
||||
- Correlate `BadSessionIdInvalid` in `opc_write_attempts_total` with `opc_session_closed_total` / `opc_session_created_total` (reconnect may finish after the row is stored with confidence 14).
|
||||
- Use confidence **14** and comment prefix for session invalidation rates; use **12** for other OPC failures.
|
||||
- Respect `OPC_RECONNECTION_INTERVAL` under parallel load; bursts of confidence 14 are expected until the next successful cycle.
|
||||
|
||||
## Related tests
|
||||
|
||||
- Unit: [`tests/laborious/utils/repository/test_opc_repository.py`](../tests/laborious/utils/repository/test_opc_repository.py)
|
||||
- Unit: [`tests/laborious/activities/test_opc.py`](../tests/laborious/activities/test_opc.py)
|
||||
- E2E (mock OPC): [`e2e/test_predictions_batch_format_export.py`](../e2e/test_predictions_batch_format_export.py)
|
||||
- E2E (in-process asyncua server + real `OpcRepository`): [`e2e/test_opc_real_server.py`](../e2e/test_opc_real_server.py) — scenarios 3.1.2, 3.2.2, 3.2.4, 3.2.5
|
||||
- Scenarios: [`e2e/scenarios.md`](../e2e/scenarios.md)
|
||||
55
docs/sientia_model_drift_jensen_shannon_change.md
Normal file
55
docs/sientia_model_drift_jensen_shannon_change.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Specification: `sientia_model` — Jensen–Shannon drift (`DriftAnalysis`)
|
||||
|
||||
This document describes what **`sientia_model.analytics.drift_analysis.DriftAnalysis`** should change so downstream consumers (e.g. Laborious `calculate_drift` → Postgres `drift_metrics.value NOT NULL`) no longer receive **NaN** for Jensen–Shannon on valid finite data.
|
||||
|
||||
## Scope
|
||||
|
||||
- **File:** `sientia_model/analytics/drift_analysis.py`
|
||||
- **Method:** `_jensen_shannon_distance(self, ref: np.ndarray, cur: np.ndarray, bins: int = 20) -> float`
|
||||
- **Callers:** `detect_univariate_drift` uses this for the `jensen_shannon` method; results are written to `value` in the drift dataframe.
|
||||
|
||||
## Problem
|
||||
|
||||
The current implementation:
|
||||
|
||||
1. Builds bin edges from **`ref`** only: `np.histogram(ref, bins=bins, density=True)`.
|
||||
2. Builds the chunk histogram with **`density=True`** on the same edges: `np.histogram(cur, bins=edges, density=True)`.
|
||||
|
||||
When **every** value in **`cur`** falls **outside** the closed support implied by those edges (typical case: production chunk drifted above the reference max or below the reference min), NumPy yields **all-zero counts** for `cur`. With **`density=True`**, normalization does **0/0**, producing **NaN** for the whole histogram, which propagates to **`float('nan')`** in `detect_univariate_drift` → **SQL NULL** where `value` is `NOT NULL`.
|
||||
|
||||
This appears in Laborious when:
|
||||
|
||||
- `model_config.target` excludes the main target column from univariate features, so another feature (e.g. `sensor_2`) is compared chunk-by-chunk against the full reference series for that feature.
|
||||
- Reference and current ranges do not overlap for some chunks (strong drift or different scaling).
|
||||
|
||||
Other methods (`kolmogorov_smirnov`, `wasserstein`) do not use this histogram+density path, so they can stay finite while **Jensen–Shannon** alone becomes null.
|
||||
|
||||
## Required behavior
|
||||
|
||||
1. **Finite output** for finite `ref` and `cur` after removing non-finite values, whenever both sides have **at least one** usable sample.
|
||||
2. **Explicit handling of out-of-range chunk mass:** probability mass from `cur` that does not fall into any bin defined from `ref` must still be represented (so the chunk distribution sums to 1), analogous to NannyML’s continuous JS approach (tail / “leftover” mass).
|
||||
3. **Missing values:** drop `NaN` from `ref` and `cur` before computing. If either side is **empty** after that, return **`float('nan')`** (callers may filter or map; schema may still forbid null — product decision outside this spec).
|
||||
|
||||
## Recommended algorithm (replace current body)
|
||||
|
||||
1. `ref = np.asarray(ref, float); cur = np.asarray(cur, float)`.
|
||||
2. `ref = ref[~np.isnan(ref)]; cur = cur[~np.isnan(cur)]`.
|
||||
3. If `ref.size == 0` or `cur.size == 0`: return `float('nan')`.
|
||||
4. `hist_ref, edges = np.histogram(ref, bins=bins)` — **counts**, not `density=True`.
|
||||
5. `p = hist_ref.astype(float) / ref.size` (reference bin probabilities).
|
||||
6. `hist_cur, _ = np.histogram(cur, bins=edges)`; `q = hist_cur.astype(float) / cur.size`.
|
||||
7. `leftover = 1.0 - float(np.sum(q))`. If `leftover > 1e-15` (tolerance for float noise), append **`leftover`** to `q` and **`0.0`** to `p` so both remain proper discrete distributions over the same extended support.
|
||||
8. Apply small smoothing (existing module constant `EPSILON` is fine): add `EPSILON` to `p` and `q`, renormalize each to sum 1.
|
||||
9. `m = 0.5 * (p + q)`; compute symmetric JS via KL terms as today, e.g. `inner = 0.5 * (sum(p*log(p/m)) + sum(q*log(q/m)))`.
|
||||
10. Return `sqrt(max(inner, 0.0))` to guard against tiny negative `inner` from floating-point error.
|
||||
|
||||
## Non-goals / notes
|
||||
|
||||
- **Numerical parity** with the old `density=True` implementation is not required; parity with **NannyML** or **scipy** JS is desirable but optional. The priority is **finite, interpretable** drift when the chunk is outside the reference histogram range.
|
||||
- **Multivariate** drift in the same file is unchanged by this spec.
|
||||
- **Tests** in `sientia_model` should cover: (a) chunk entirely above reference max, (b) entirely below reference min, (c) overlapping range, (d) `ref` or `cur` all-NaN after cleaning.
|
||||
|
||||
## Reference (external)
|
||||
|
||||
- NannyML continuous JS uses count-based bin probabilities and a **leftover** mass bin; see `ContinuousJensenShannonDistance` in `nannyml/drift/univariate/methods.py` (`_calculate`, `leftover = 1 - np.sum(...)`).
|
||||
- Historical NaN-in-reference issue: [NannyML#339](https://github.com/NannyML/nannyml/issues/339) / [#340](https://github.com/NannyML/nannyml/pull/340) (orthogonal to out-of-range mass, but relevant for input cleaning).
|
||||
Reference in New Issue
Block a user