Skip to content

Commit aee77c4

Browse files
committed
Add run_diff: LCS-aligned diff of two run step-traces
A run history says a run failed but not what changed from the run that passed. Align two step sequences with an LCS walk (so an inserted or removed step shifts the rest into place instead of mis-pairing) and classify the differences: added/removed steps, status flips (with the new failure's signature), and timing regressions. summarize_run_diff renders a one-line summary. Pure stdlib over step dicts.
1 parent 2660cc8 commit aee77c4

11 files changed

Lines changed: 367 additions & 0 deletions

File tree

‎WHATS_NEW.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-25) — Run-Trace Diff (what changed between two executions)
4+
5+
See exactly what changed between a passing run and a failing one. Full reference: [`docs/source/Eng/doc/new_features/v192_features_doc.rst`](docs/source/Eng/doc/new_features/v192_features_doc.rst).
6+
7+
- **`diff_runs` / `summarize_run_diff`** (`AC_diff_runs`): a run history says a run *failed* but not *what changed* from the run that passed. This aligns two step sequences with a longest-common-subsequence walk (so an inserted/removed step shifts the rest into place instead of mis-pairing everything) and classifies the differences: **added**/**removed** steps, **status_flips** (an aligned step that changed status — with the new failure's `failure_signature` when it carries an error), and **timing_regressions** (a step that got `regress_factor`× slower). `summarize_run_diff` renders a one-line summary. Pure stdlib over lists of `{name,status,duration,error}` step dicts. No `PySide6`.
8+
39
## What's new (2026-06-25) — Stable Failure Signatures
410

511
Match the *same kind* of failure across runs, despite differing paths and ids. Full reference: [`docs/source/Eng/doc/new_features/v191_features_doc.rst`](docs/source/Eng/doc/new_features/v191_features_doc.rst).
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
Run-Trace Diff (what changed between two executions)
2+
====================================================
3+
4+
A run history tells you a run *failed*, but not *what changed* from the run that
5+
passed: which step was added or dropped, which step flipped pass→fail, which step
6+
got slower. ``run_diff`` aligns the two step sequences with a longest-common-
7+
subsequence walk — so an inserted or removed step shifts the rest into place
8+
instead of mis-pairing everything — and classifies the differences:
9+
10+
* **added** / **removed** — steps present in only one run,
11+
* **status_flips** — an aligned step whose status changed, with the new failure's
12+
:func:`failure_signature` when it carries an ``error``,
13+
* **timing_regressions** — an aligned step that got ``regress_factor`` x slower.
14+
15+
A step is any dict with a name key (default ``"name"``) and optional ``status`` /
16+
``duration`` / ``error``. Pure standard library; no device, no ``PySide6``.
17+
18+
Headless API
19+
------------
20+
21+
.. code-block:: python
22+
23+
from je_auto_control import diff_runs, summarize_run_diff
24+
25+
before = [{"name": "login", "status": "ok", "duration": 1.0},
26+
{"name": "submit", "status": "ok", "duration": 1.0}]
27+
after = [{"name": "login", "status": "ok", "duration": 1.1},
28+
{"name": "accept_cookies", "status": "ok"}, # inserted
29+
{"name": "submit", "status": "error", "error": "Timeout ..."}]
30+
31+
diff = diff_runs(before, after)
32+
# {"added": [accept_cookies], "removed": [],
33+
# "status_flips": [{"name": "submit", "from": "ok", "to": "error",
34+
# "signature": "..."}],
35+
# "timing_regressions": [], "aligned": 2, "identical": False}
36+
37+
summarize_run_diff(diff) # "+1 added, 1 status flip(s)"
38+
39+
``regress_factor`` (default ``1.5``) is the slowdown ratio that counts as a
40+
regression; ``key`` selects the field steps are aligned on. ``summarize_run_diff``
41+
renders a one-line summary (``"no change"`` when identical).
42+
43+
Executor commands
44+
-----------------
45+
46+
``AC_diff_runs`` (``before`` / ``after`` / ``key`` / ``regress_factor``) returns
47+
the diff plus a ``summary`` field. It is exposed as the read-only ``ac_diff_runs``
48+
MCP tool and as a Script Builder command under **Testing**.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
執行軌跡比較(兩次執行之間改變了什麼)
2+
======================================
3+
4+
執行歷史告訴你某次執行*失敗*了,卻不告訴你相較於通過的那次*改變了什麼*:哪個步驟被加入或移除、
5+
哪個步驟由通過翻轉成失敗、哪個步驟變慢了。``run_diff`` 以最長共同子序列(LCS)走訪對齊兩個步驟
6+
序列——這樣插入或移除一個步驟會把其餘步驟順移到位,而非整個錯位配對——並將差異分類:
7+
8+
* **added** / **removed** ——只存在於其中一次執行的步驟,
9+
* **status_flips** ——某個已對齊步驟的狀態改變,若帶有 ``error`` 則附上新失敗的
10+
:func:`failure_signature`,
11+
* **timing_regressions** ——某個已對齊步驟變慢了 ``regress_factor`` 倍。
12+
13+
步驟可為任何帶有名稱鍵(預設 ``"name"``)與選填 ``status`` / ``duration`` / ``error`` 的字典。
14+
純標準庫;不涉及裝置,不匯入 ``PySide6``。
15+
16+
無頭 API
17+
--------
18+
19+
.. code-block:: python
20+
21+
from je_auto_control import diff_runs, summarize_run_diff
22+
23+
before = [{"name": "login", "status": "ok", "duration": 1.0},
24+
{"name": "submit", "status": "ok", "duration": 1.0}]
25+
after = [{"name": "login", "status": "ok", "duration": 1.1},
26+
{"name": "accept_cookies", "status": "ok"}, # 插入
27+
{"name": "submit", "status": "error", "error": "Timeout ..."}]
28+
29+
diff = diff_runs(before, after)
30+
# {"added": [accept_cookies], "removed": [],
31+
# "status_flips": [{"name": "submit", "from": "ok", "to": "error",
32+
# "signature": "..."}],
33+
# "timing_regressions": [], "aligned": 2, "identical": False}
34+
35+
summarize_run_diff(diff) # "+1 added, 1 status flip(s)"
36+
37+
``regress_factor``(預設 ``1.5``)是算作退化的變慢比率;``key`` 選擇步驟對齊所依據的欄位。
38+
``summarize_run_diff`` 產生一行摘要(相同時為 ``"no change"``)。
39+
40+
執行器指令
41+
----------
42+
43+
``AC_diff_runs``(``before`` / ``after`` / ``key`` / ``regress_factor``)回傳該差異並附帶
44+
``summary`` 欄位。以唯讀 ``ac_diff_runs`` MCP 工具及 Script Builder 指令(位於 **Testing**
45+
分類下)形式提供。

‎je_auto_control/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
from je_auto_control.utils.failure_signature import (
9393
failure_signature, group_failures, normalize_error,
9494
)
95+
# Run-trace diff (LCS-aligned: added/removed steps, status flips, regressions)
96+
from je_auto_control.utils.run_diff import diff_runs, summarize_run_diff
9597
# VLM element locator (headless)
9698
from je_auto_control.utils.vision import (
9799
VLMNotAvailableError, click_by_description, locate_by_description,
@@ -1670,6 +1672,7 @@ def start_autocontrol_gui(*args, **kwargs):
16701672
"detect_scale", "scale_sweep",
16711673
"saliency_map", "salient_regions", "most_salient",
16721674
"normalize_error", "failure_signature", "group_failures",
1675+
"diff_runs", "summarize_run_diff",
16731676
# VLM locator
16741677
"VLMNotAvailableError", "locate_by_description", "click_by_description",
16751678
"verify_description",

‎je_auto_control/gui/script_builder/command_schema.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2723,6 +2723,19 @@ def _add_audit_specs(specs: List[CommandSpec]) -> None:
27232723
placeholder='["err one", "err two"]'),),
27242724
description="Group error messages by failure signature (most frequent).",
27252725
))
2726+
specs.append(CommandSpec(
2727+
"AC_diff_runs", "Testing", "Diff Two Run Traces",
2728+
fields=(
2729+
FieldSpec("before", FieldType.STRING,
2730+
placeholder='[{"name": "login", "status": "ok"}]'),
2731+
FieldSpec("after", FieldType.STRING,
2732+
placeholder='[{"name": "login", "status": "error"}]'),
2733+
FieldSpec("key", FieldType.STRING, optional=True, default="name"),
2734+
FieldSpec("regress_factor", FieldType.FLOAT, optional=True,
2735+
default=1.5),
2736+
),
2737+
description="LCS-align two run step-traces: added/removed/flips/regress.",
2738+
))
27262739
specs.append(CommandSpec(
27272740
"AC_scan_secrets", "Tools", "Scan for Hardcoded Secrets",
27282741
description="Scan 'data' (JSON view) for hardcoded secrets that "

‎je_auto_control/utils/executor/action_executor.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4366,6 +4366,20 @@ def _group_failures(errors: Any) -> Dict[str, Any]:
43664366
return {"groups": groups, "count": len(groups)}
43674367

43684368

4369+
def _diff_runs(before: Any, after: Any, key: str = "name",
4370+
regress_factor: Any = 1.5) -> Dict[str, Any]:
4371+
"""Adapter: diff two run step-traces (added/removed/flips/regressions)."""
4372+
import json
4373+
from je_auto_control.utils.run_diff import diff_runs, summarize_run_diff
4374+
if isinstance(before, str):
4375+
before = json.loads(before)
4376+
if isinstance(after, str):
4377+
after = json.loads(after)
4378+
diff = diff_runs(before, after, key=str(key),
4379+
regress_factor=float(regress_factor))
4380+
return {**diff, "summary": summarize_run_diff(diff)}
4381+
4382+
43694383
def _image_histogram(source: Any = None, bins: Any = 32, space: str = "hsv",
43704384
region: Any = None) -> Dict[str, Any]:
43714385
"""Adapter: per-channel colour histogram of an image / the screen."""
@@ -6596,6 +6610,7 @@ def __init__(self):
65966610
"AC_most_salient": _most_salient,
65976611
"AC_failure_signature": _failure_signature,
65986612
"AC_group_failures": _group_failures,
6613+
"AC_diff_runs": _diff_runs,
65996614
"AC_image_histogram": _image_histogram,
66006615
"AC_histogram_changed": _histogram_changed,
66016616
"AC_changed_regions": _changed_regions,

‎je_auto_control/utils/mcp_server/tools/_factories.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7675,6 +7675,22 @@ def flakiness_tools() -> List[MCPTool]:
76757675
handler=h.group_failures,
76767676
annotations=READ_ONLY,
76777677
),
7678+
MCPTool(
7679+
name="ac_diff_runs",
7680+
description=("Diff two run step-traces (lists of {name,status,"
7681+
"duration,error}) — LCS-aligned so inserts shift rather "
7682+
"than mis-pair. Returns {added, removed, status_flips "
7683+
"(with failure signature), timing_regressions, aligned, "
7684+
"identical, summary}. 'regress_factor' = slowdown ratio."),
7685+
input_schema=schema({
7686+
"before": {"type": "array", "items": {"type": "object"}},
7687+
"after": {"type": "array", "items": {"type": "object"}},
7688+
"key": {"type": "string"},
7689+
"regress_factor": {"type": "number"}},
7690+
required=["before", "after"]),
7691+
handler=h.diff_runs,
7692+
annotations=READ_ONLY,
7693+
),
76787694
]
76797695

76807696

‎je_auto_control/utils/mcp_server/tools/_handlers.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2553,6 +2553,11 @@ def group_failures(errors):
25532553
return _group_failures(errors)
25542554

25552555

2556+
def diff_runs(before, after, key="name", regress_factor=1.5):
2557+
from je_auto_control.utils.executor.action_executor import _diff_runs
2558+
return _diff_runs(before, after, key, regress_factor)
2559+
2560+
25562561
def image_histogram(source=None, bins=32, space="hsv", region=None):
25572562
from je_auto_control.utils.executor.action_executor import _image_histogram
25582563
return _image_histogram(source, bins, space, region)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Diff two run traces (LCS-aligned step diff: added/removed/flips/regressions)."""
2+
from je_auto_control.utils.run_diff.run_diff import diff_runs, summarize_run_diff
3+
4+
__all__ = ["diff_runs", "summarize_run_diff"]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Diff two run traces — what changed between two executions of a flow.
2+
3+
A run history tells you a run *failed*, but not *what changed* from the run that
4+
passed: which step was added or dropped, which step flipped pass→fail, which step
5+
got slower. ``run_diff`` aligns the two step sequences with a longest-common-
6+
subsequence walk (so an inserted / removed step shifts the rest into place instead
7+
of mis-pairing everything) and classifies the differences:
8+
9+
* **added** / **removed** steps (present in only one run),
10+
* **status flips** (an aligned step whose status changed — with the new failure's
11+
:func:`failure_signature` when it carries an ``error``),
12+
* **timing regressions** (an aligned step that got ``regress_factor`` x slower).
13+
14+
A step is any dict with a name key (default ``"name"``) and optional ``status`` /
15+
``duration`` / ``error``. Pure standard library; no device, no ``PySide6``.
16+
"""
17+
from typing import Any, Dict, List, Sequence
18+
19+
Step = Dict[str, Any]
20+
21+
22+
def _lcs_pairs(left: Sequence[str], right: Sequence[str]) -> List[tuple]:
23+
"""Return aligned ``(i, j)`` index pairs of the longest common subsequence."""
24+
n, m = len(left), len(right)
25+
table = [[0] * (m + 1) for _ in range(n + 1)]
26+
for i in range(n - 1, -1, -1):
27+
for j in range(m - 1, -1, -1):
28+
if left[i] == right[j]:
29+
table[i][j] = table[i + 1][j + 1] + 1
30+
else:
31+
table[i][j] = max(table[i + 1][j], table[i][j + 1])
32+
pairs, i, j = [], 0, 0
33+
while i < n and j < m:
34+
if left[i] == right[j]:
35+
pairs.append((i, j))
36+
i, j = i + 1, j + 1
37+
elif table[i + 1][j] >= table[i][j + 1]:
38+
i += 1
39+
else:
40+
j += 1
41+
return pairs
42+
43+
44+
def _status_flip(before: Step, after: Step, name: str) -> Dict[str, Any]:
45+
"""Build a status-flip record, attaching a signature for a new error."""
46+
flip = {"name": name, "from": before.get("status"),
47+
"to": after.get("status")}
48+
error = after.get("error")
49+
if error:
50+
from je_auto_control.utils.failure_signature import failure_signature
51+
flip["signature"] = failure_signature(str(error))
52+
return flip
53+
54+
55+
def _regression(before: Step, after: Step, name: str,
56+
factor: float) -> Dict[str, Any]:
57+
"""Return a timing-regression record, or ``{}`` if not a regression."""
58+
prev, curr = before.get("duration"), after.get("duration")
59+
if not isinstance(prev, (int, float)) or not isinstance(curr, (int, float)):
60+
return {}
61+
if prev > 0 and curr >= prev * float(factor):
62+
return {"name": name, "before": float(prev), "after": float(curr),
63+
"ratio": round(curr / prev, 3)}
64+
return {}
65+
66+
67+
def _aligned_changes(before: Sequence[Step], after: Sequence[Step],
68+
names: Sequence[str], pairs: List[tuple],
69+
factor: float) -> tuple:
70+
"""Classify the LCS-aligned pairs into (status flips, timing regressions)."""
71+
flips, regressions = [], []
72+
for i, j in pairs:
73+
if before[i].get("status") != after[j].get("status"):
74+
flips.append(_status_flip(before[i], after[j], names[i]))
75+
regression = _regression(before[i], after[j], names[i], factor)
76+
if regression:
77+
regressions.append(regression)
78+
return flips, regressions
79+
80+
81+
def _keys(steps: Sequence[Step], key: str) -> List[str]:
82+
return [str(step.get(key, "")) for step in steps]
83+
84+
85+
def _unmatched(steps: Sequence[Step], matched: set) -> List[Step]:
86+
return [steps[k] for k in range(len(steps)) if k not in matched]
87+
88+
89+
def diff_runs(before: Sequence[Step], after: Sequence[Step], *,
90+
key: str = "name", regress_factor: float = 1.5) -> Dict[str, Any]:
91+
"""Diff two step sequences into ``{added, removed, status_flips,
92+
timing_regressions, aligned, identical}``.
93+
94+
Steps are aligned by their ``key`` value via LCS; ``regress_factor`` is the
95+
slowdown ratio that counts as a timing regression.
96+
"""
97+
left, right = _keys(before, key), _keys(after, key)
98+
pairs = _lcs_pairs(left, right)
99+
flips, regressions = _aligned_changes(before, after, left, pairs,
100+
regress_factor)
101+
added = _unmatched(after, {j for _, j in pairs})
102+
removed = _unmatched(before, {i for i, _ in pairs})
103+
return {"added": added, "removed": removed, "status_flips": flips,
104+
"timing_regressions": regressions, "aligned": len(pairs),
105+
"identical": not any((added, removed, flips, regressions))}
106+
107+
108+
def summarize_run_diff(diff: Dict[str, Any]) -> str:
109+
"""Render a one-line human summary of a :func:`diff_runs` result."""
110+
if diff.get("identical"):
111+
return "no change"
112+
parts = []
113+
for label, field in (("+{} added", "added"), ("-{} removed", "removed"),
114+
("{} status flip(s)", "status_flips"),
115+
("{} regression(s)", "timing_regressions")):
116+
count = len(diff.get(field, []))
117+
if count:
118+
parts.append(label.format(count))
119+
return ", ".join(parts)

0 commit comments

Comments
 (0)