[NOTICKET] anomaly: name the anomalous rows + a flag-rate quality verdict (D-3)
Browse filesanalyze_anomaly's records carried only the scored numeric columns plus a score, so
"which units look unusual?" could never name a unit; and it was the only ML tool
with no quality block, so an 86%-flagged run reached the assembler as silence.
- Add optional label_columns: identifier columns carried into each record
(identifiers first), never fed to the model. Planner guidance to pass the id
column follows on Rifqi's side (decided shape (a), tool-side).
- Add the reserved quality block: metric anomaly_rate, weak only ABOVE a 0.5
ceiling. A20's flag-rate finding was re-measured, not re-assumed -- clean noise
flags 0.14-0.22 (mean 0.17, p95 0.22), a genuine 30%-outlier frame 0.30, the
demo's degenerate run 0.86 -- so 0.5 clears the noise floor and a real finding
yet still catches "most rows flagged"; the note states the rate plainly either
way. Logic in _flag_rate_quality() so both branches are unit-testable (the fit
can't be pushed past 0.5 via contamination).
PR-0 from docs/plans/PR0_PR1_TOOL_FIXES.md; plan doc marks D-1 + D-3 done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# PR-0 + PR-1 — the tools do what they claim
|
| 2 |
|
| 3 |
🔄 **LIVE PLAN** · opened 2026-09-21 · branch `pr/22` · **owner: Sofhia**
|
|
|
|
| 4 |
|
| 5 |
Six fixes, all inside `src/tools/analytics/`. They come from the 2026-09-18 design review
|
| 6 |
([docs/reviews/AGENTIC_DESIGN_REVIEW_2026-09-18.md](../reviews/AGENTIC_DESIGN_REVIEW_2026-09-18.md))
|
|
@@ -43,6 +44,12 @@ analytical correctness would mostly rediscover them.
|
|
| 43 |
|
| 44 |
## D-1 · `analyze_data_quality` advertises duplicate detection and computes none
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
**Verified** `src/tools/analytics/data_quality.py:103-104`
|
| 47 |
|
| 48 |
```
|
|
@@ -77,6 +84,17 @@ but a narrower honest tool beats a broad dishonest one.
|
|
| 77 |
|
| 78 |
## D-3 · `analyze_anomaly` cannot name what is anomalous
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
**Verified** `src/tools/analytics/anomaly.py:157-159`
|
| 81 |
|
| 82 |
```python
|
|
|
|
| 1 |
# PR-0 + PR-1 — the tools do what they claim
|
| 2 |
|
| 3 |
🔄 **LIVE PLAN** · opened 2026-09-21 · branch `pr/22` · **owner: Sofhia**
|
| 4 |
+
· **PR-0 (D-1, D-3) done 2026-09-21 — awaiting commit; PR-1 (D-4, D-6, D-7, D-10) not started**
|
| 5 |
|
| 6 |
Six fixes, all inside `src/tools/analytics/`. They come from the 2026-09-18 design review
|
| 7 |
([docs/reviews/AGENTIC_DESIGN_REVIEW_2026-09-18.md](../reviews/AGENTIC_DESIGN_REVIEW_2026-09-18.md))
|
|
|
|
| 44 |
|
| 45 |
## D-1 · `analyze_data_quality` advertises duplicate detection and computes none
|
| 46 |
|
| 47 |
+
✅ **Done 2026-09-21.** Chose *compute it*: added `duplicate_rows {count, rate}` over the
|
| 48 |
+
profiled columns (`df[cols].duplicated()`, `keep="first"` = redundant copies; a column
|
| 49 |
+
subset is a candidate-key check) plus `issues.duplicate_row_count`. Always present (0 when
|
| 50 |
+
none, never `None`/absent), so `use_when`'s `duplikat`/`duplicates` claim is now backed.
|
| 51 |
+
3 local fixtures (zero-when-none, planted 3-identical→count 2, subset candidate-key).
|
| 52 |
+
|
| 53 |
**Verified** `src/tools/analytics/data_quality.py:103-104`
|
| 54 |
|
| 55 |
```
|
|
|
|
| 84 |
|
| 85 |
## D-3 · `analyze_anomaly` cannot name what is anomalous
|
| 86 |
|
| 87 |
+
✅ **Done 2026-09-21.** Shape **(a) `label_columns`** (tool-side, decided with Rifqi):
|
| 88 |
+
optional id columns carried into each record (identifiers first), never fed to the model —
|
| 89 |
+
Rifqi updates planner guidance to pass the id column. `quality` block added: metric
|
| 90 |
+
`anomaly_rate`, `weak` only ABOVE ceiling **0.5**. The A20 finding was **re-measured**
|
| 91 |
+
(2026-09-21): clean gaussian noise flags 0.14–0.22 (mean 0.17, p95 0.22), a genuine
|
| 92 |
+
30%-outlier frame flags 0.30, the demo's degenerate run flagged 0.86 — so 0.5 clears the
|
| 93 |
+
noise floor and a real high-outlier finding yet still catches "most rows flagged"; the
|
| 94 |
+
`note` reports the rate plainly either way. Logic split into `_flag_rate_quality()` so both
|
| 95 |
+
branches are unit-tested (the fit can't be pushed past 0.5 via `contamination`). 4 local
|
| 96 |
+
fixtures (names rows, missing label raises, quality present/not-weak, both branches).
|
| 97 |
+
|
| 98 |
**Verified** `src/tools/analytics/anomaly.py:157-159`
|
| 99 |
|
| 100 |
```python
|
|
@@ -33,6 +33,14 @@ _MIN_ROWS = 10
|
|
| 33 |
_MAX_ANOMALY_ROWS = 50
|
| 34 |
# Fixed so the same data always flags the same rows (family-B determinism promise).
|
| 35 |
_RANDOM_STATE = 42
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
# Internal column names for the highlighted scatter (never collide with user data).
|
| 37 |
_FLAG_COL = "__is_anomaly__"
|
| 38 |
_INDEX_COL = "__row__"
|
|
@@ -52,6 +60,11 @@ class AnomalyInput(BaseModel):
|
|
| 52 |
column_ids: list[str] = Field(
|
| 53 |
..., description="One or more NUMERIC columns to score rows for anomalies."
|
| 54 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
contamination: float | None = Field(
|
| 56 |
None, description="Expected fraction of anomalies (0-0.5); omit for automatic."
|
| 57 |
)
|
|
@@ -66,7 +79,8 @@ DESCRIPTION = build_description(
|
|
| 66 |
"the user wants movement over time or a projection -> analyze_trend / analyze_forecast",
|
| 67 |
"the user wants what DRIVES a metric -> analyze_importance",
|
| 68 |
],
|
| 69 |
-
output="a summary (row/anomaly counts, rate) + the flagged rows
|
|
|
|
| 70 |
"anomaly points highlighted.",
|
| 71 |
examples=[
|
| 72 |
"ada anomali nggak di data bulan ini?",
|
|
@@ -86,6 +100,28 @@ def _fit_iforest(x: np.ndarray, contamination, random_state: int):
|
|
| 86 |
return model
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def _anomaly_chart(
|
| 90 |
df: pd.DataFrame, column_ids: list[str], is_anomaly: pd.Series
|
| 91 |
) -> dict[str, object]:
|
|
@@ -105,6 +141,7 @@ async def analyze_anomaly(
|
|
| 105 |
df: pd.DataFrame,
|
| 106 |
column_ids: list[str],
|
| 107 |
contamination: float | None = None,
|
|
|
|
| 108 |
) -> dict[str, object]:
|
| 109 |
"""Flag anomalous rows across `column_ids` with an Isolation Forest.
|
| 110 |
|
|
@@ -112,17 +149,23 @@ async def analyze_anomaly(
|
|
| 112 |
synchronous compute. `data` is materialised upstream (Pattern A) and arrives as
|
| 113 |
`df`.
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
Returns a dict with: method, column_ids, contamination, n_rows, n_anomalies,
|
| 116 |
anomaly_rate, anomalies_total (= n_anomalies) / anomalies_shown (how many the
|
| 117 |
capped sample actually carries), `anomalies` (the flagged rows, top by score,
|
| 118 |
-
capped at _MAX_ANOMALY_ROWS),
|
|
|
|
| 119 |
|
| 120 |
Raises (all wrapped by the invoker's never-throw seam):
|
| 121 |
ColumnNotFoundError, EmptyColumnSelectionError, NotEnoughDataError.
|
| 122 |
"""
|
| 123 |
if not column_ids:
|
| 124 |
raise EmptyColumnSelectionError("column_ids must name at least one numeric column")
|
| 125 |
-
|
|
|
|
| 126 |
if missing:
|
| 127 |
raise ColumnNotFoundError(f"columns not found: {missing}")
|
| 128 |
|
|
@@ -153,22 +196,33 @@ async def analyze_anomaly(
|
|
| 153 |
anomalies: list[dict[str, object]] = []
|
| 154 |
for pos in order[:_MAX_ANOMALY_ROWS]:
|
| 155 |
idx = flagged_idx[pos]
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
| 157 |
record["anomaly_score"] = round(float(flagged_scores[pos]), 4)
|
| 158 |
anomalies.append(record)
|
| 159 |
|
|
|
|
| 160 |
return {
|
| 161 |
"method": "isolation_forest",
|
| 162 |
"column_ids": column_ids,
|
| 163 |
"contamination": contam,
|
| 164 |
"n_rows": int(scored.shape[0]),
|
| 165 |
"n_anomalies": int(flags.sum()),
|
| 166 |
-
"anomaly_rate":
|
| 167 |
# `anomalies` is a bounded top-by-score SAMPLE, not the full set. These two
|
| 168 |
# keep it honest: total says how many there really are, shown says how many
|
| 169 |
# the sample carries. When total > shown the caller knows it's truncated.
|
| 170 |
"anomalies_total": int(flags.sum()),
|
| 171 |
"anomalies_shown": len(anomalies),
|
| 172 |
"anomalies": anomalies,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
"charts": [_anomaly_chart(df, column_ids, is_anomaly)],
|
| 174 |
}
|
|
|
|
| 33 |
_MAX_ANOMALY_ROWS = 50
|
| 34 |
# Fixed so the same data always flags the same rows (family-B determinism promise).
|
| 35 |
_RANDOM_STATE = 42
|
| 36 |
+
# Above this share of rows flagged, the result is not "a few anomalies" — the notion
|
| 37 |
+
# has collapsed and the answer is noise. The reserved `quality` block reads `weak` off
|
| 38 |
+
# it. NOT a naive low ceiling: A20 reverted one because `contamination="auto"` flags a
|
| 39 |
+
# large slice of CLEAN data. Re-measured 2026-09-21 — clean gaussian noise flags 0.14–0.22
|
| 40 |
+
# (mean 0.17, p95 0.22), a genuine 30%-outlier frame flags 0.30; the demo's degenerate
|
| 41 |
+
# run flagged 0.86. 0.5 clears both the noise floor and a real high-outlier finding while
|
| 42 |
+
# still catching "most rows flagged".
|
| 43 |
+
_ANOMALY_RATE_CEILING = 0.5
|
| 44 |
# Internal column names for the highlighted scatter (never collide with user data).
|
| 45 |
_FLAG_COL = "__is_anomaly__"
|
| 46 |
_INDEX_COL = "__row__"
|
|
|
|
| 60 |
column_ids: list[str] = Field(
|
| 61 |
..., description="One or more NUMERIC columns to score rows for anomalies."
|
| 62 |
)
|
| 63 |
+
label_columns: list[str] | None = Field(
|
| 64 |
+
None, description="Identifier columns (e.g. unit/equipment no.) to carry into each "
|
| 65 |
+
"flagged record so the anomaly can be NAMED. Never fed to the model — pass the id "
|
| 66 |
+
"columns whenever the question asks 'which X is unusual'."
|
| 67 |
+
)
|
| 68 |
contamination: float | None = Field(
|
| 69 |
None, description="Expected fraction of anomalies (0-0.5); omit for automatic."
|
| 70 |
)
|
|
|
|
| 79 |
"the user wants movement over time or a projection -> analyze_trend / analyze_forecast",
|
| 80 |
"the user wants what DRIVES a metric -> analyze_importance",
|
| 81 |
],
|
| 82 |
+
output="a summary (row/anomaly counts, rate) + the flagged rows (named by any "
|
| 83 |
+
"label_columns given) + a quality verdict on the flag rate + a scatter with "
|
| 84 |
"anomaly points highlighted.",
|
| 85 |
examples=[
|
| 86 |
"ada anomali nggak di data bulan ini?",
|
|
|
|
| 100 |
return model
|
| 101 |
|
| 102 |
|
| 103 |
+
def _flag_rate_quality(anomaly_rate: float) -> dict[str, object]:
|
| 104 |
+
"""Reserved `quality` block from the flag rate (conventions §3).
|
| 105 |
+
|
| 106 |
+
Split out so both branches are unit-testable without a model run — the fit can't be
|
| 107 |
+
coaxed above a 0.5 flag rate through `contamination` (sklearn caps it there), so the
|
| 108 |
+
weak branch has no organic fixture.
|
| 109 |
+
"""
|
| 110 |
+
weak = anomaly_rate > _ANOMALY_RATE_CEILING
|
| 111 |
+
return {
|
| 112 |
+
"metric": "anomaly_rate",
|
| 113 |
+
"value": round(anomaly_rate, 4),
|
| 114 |
+
"floor": _ANOMALY_RATE_CEILING,
|
| 115 |
+
"weak": weak,
|
| 116 |
+
"note": (
|
| 117 |
+
f"{anomaly_rate:.0%} of rows flagged — above half the data, so this is "
|
| 118 |
+
"noise, not a few outliers"
|
| 119 |
+
if weak
|
| 120 |
+
else f"{anomaly_rate:.0%} of rows flagged as anomalous"
|
| 121 |
+
),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
def _anomaly_chart(
|
| 126 |
df: pd.DataFrame, column_ids: list[str], is_anomaly: pd.Series
|
| 127 |
) -> dict[str, object]:
|
|
|
|
| 141 |
df: pd.DataFrame,
|
| 142 |
column_ids: list[str],
|
| 143 |
contamination: float | None = None,
|
| 144 |
+
label_columns: list[str] | None = None,
|
| 145 |
) -> dict[str, object]:
|
| 146 |
"""Flag anomalous rows across `column_ids` with an Isolation Forest.
|
| 147 |
|
|
|
|
| 149 |
synchronous compute. `data` is materialised upstream (Pattern A) and arrives as
|
| 150 |
`df`.
|
| 151 |
|
| 152 |
+
`label_columns` are identifier columns carried into each flagged record so the
|
| 153 |
+
anomaly can be NAMED (e.g. `eqm_no`). They are NEVER fed to the model — only the
|
| 154 |
+
numeric `column_ids` are scored.
|
| 155 |
+
|
| 156 |
Returns a dict with: method, column_ids, contamination, n_rows, n_anomalies,
|
| 157 |
anomaly_rate, anomalies_total (= n_anomalies) / anomalies_shown (how many the
|
| 158 |
capped sample actually carries), `anomalies` (the flagged rows, top by score,
|
| 159 |
+
capped at _MAX_ANOMALY_ROWS), a reserved `quality` block (flag-rate verdict), and
|
| 160 |
+
`charts` (the reserved auto-chart key).
|
| 161 |
|
| 162 |
Raises (all wrapped by the invoker's never-throw seam):
|
| 163 |
ColumnNotFoundError, EmptyColumnSelectionError, NotEnoughDataError.
|
| 164 |
"""
|
| 165 |
if not column_ids:
|
| 166 |
raise EmptyColumnSelectionError("column_ids must name at least one numeric column")
|
| 167 |
+
labels = list(label_columns) if label_columns else []
|
| 168 |
+
missing = [c for c in column_ids + labels if c not in df.columns]
|
| 169 |
if missing:
|
| 170 |
raise ColumnNotFoundError(f"columns not found: {missing}")
|
| 171 |
|
|
|
|
| 196 |
anomalies: list[dict[str, object]] = []
|
| 197 |
for pos in order[:_MAX_ANOMALY_ROWS]:
|
| 198 |
idx = flagged_idx[pos]
|
| 199 |
+
# Identifiers FIRST so a reader sees WHICH row before its numbers. Pulled from
|
| 200 |
+
# the original df (labels are non-numeric and never entered `scored`).
|
| 201 |
+
record: dict[str, object] = {c: _clean(df.at[idx, c]) for c in labels}
|
| 202 |
+
record.update({c: _clean(scored.at[idx, c]) for c in column_ids})
|
| 203 |
record["anomaly_score"] = round(float(flagged_scores[pos]), 4)
|
| 204 |
anomalies.append(record)
|
| 205 |
|
| 206 |
+
anomaly_rate = float(flags.mean()) if scored.shape[0] else 0.0
|
| 207 |
return {
|
| 208 |
"method": "isolation_forest",
|
| 209 |
"column_ids": column_ids,
|
| 210 |
"contamination": contam,
|
| 211 |
"n_rows": int(scored.shape[0]),
|
| 212 |
"n_anomalies": int(flags.sum()),
|
| 213 |
+
"anomaly_rate": anomaly_rate,
|
| 214 |
# `anomalies` is a bounded top-by-score SAMPLE, not the full set. These two
|
| 215 |
# keep it honest: total says how many there really are, shown says how many
|
| 216 |
# the sample carries. When total > shown the caller knows it's truncated.
|
| 217 |
"anomalies_total": int(flags.sum()),
|
| 218 |
"anomalies_shown": len(anomalies),
|
| 219 |
"anomalies": anomalies,
|
| 220 |
+
# Reserved `quality` block (conventions §3): the tool owns its own verdict; CK7
|
| 221 |
+
# surfaces `weak`. Isolation Forest has no accuracy score, so the honest signal
|
| 222 |
+
# is the flag RATE — an unsupervised detector that flags most of the data found
|
| 223 |
+
# nothing. `weak` fires only ABOVE _ANOMALY_RATE_CEILING (0.5), clear of the
|
| 224 |
+
# ~0.22 clean-data floor A20 measured, so a normal run is never marked weak; the
|
| 225 |
+
# note reports the rate plainly either way (A20's fallback, kept as the message).
|
| 226 |
+
"quality": _flag_rate_quality(anomaly_rate),
|
| 227 |
"charts": [_anomaly_chart(df, column_ids, is_anomaly)],
|
| 228 |
}
|