sofhiaazzhr Claude Opus 4.8 commited on
Commit
ae50194
·
1 Parent(s): 7078816

[NOTICKET] anomaly: name the anomalous rows + a flag-rate quality verdict (D-3)

Browse files

analyze_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>

docs/plans/PR0_PR1_TOOL_FIXES.md CHANGED
@@ -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
src/tools/analytics/anomaly.py CHANGED
@@ -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 + a scatter with "
 
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), and `charts` (the reserved auto-chart key).
 
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
- missing = [c for c in column_ids if c not in df.columns]
 
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
- record = {c: _clean(scored.at[idx, c]) for c in column_ids}
 
 
 
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": float(flags.mean()) if scored.shape[0] else 0.0,
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
  }