Skip to content

Commit 20ec65f

Browse files
authored
Merge pull request #411 from Integration-Automation/feat/run-diff-batch
Add run_diff: LCS-aligned diff of two run step-traces
2 parents 2660cc8 + aee77c4 commit 20ec65f

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)