feat(canary): auto-rebase pe layout change (2↔1 ferestre)

Când canary drift coincide cu schimbare strip-count pe același frame
(ex: TS comută 2→1 chart-uri și mută menu-ul peste care e ancorat ROI),
sistemul rescrie automat baseline_phash în TOML, commit layout change
și trimite o singură alertă combinată — fără pauză, fără /rebase manual.

Drift fără strip-count change rămâne pauză ca azi (drift real).
Gate pe două semnale independente previne fals-pozitive.

Canary.check() despărțit în măsurare pură + commit_pause/rebase
explicit; tick-loop-ul orchestrează decizia.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 18:30:57 +03:00
parent 839caacc95
commit bb241bf050
4 changed files with 576 additions and 36 deletions

View File

@@ -91,24 +91,37 @@ def test_no_drift() -> None:
assert canary.is_paused is False
def test_drift_triggers_pause() -> None:
"""Drastically different canary ROI → drifted=True, paused=True."""
def test_check_does_not_auto_pause() -> None:
"""check() is pure measurement — never transitions to paused on its own."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
result = canary.check(DRIFTED_FRAME)
assert result.drifted is True
assert result.paused is True
assert result.paused is False # not committed
assert canary.is_paused is False
def test_drift_triggers_pause() -> None:
"""check() detects drift; commit_pause() transitions state."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
result = canary.check(DRIFTED_FRAME)
assert result.drifted is True
canary.commit_pause(result.distance)
assert canary.is_paused is True
def test_persists_paused() -> None:
"""After drift, feeding back a clean frame keeps paused=True."""
"""After commit_pause, feeding back a clean frame keeps paused=True."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
canary.check(DRIFTED_FRAME) # trigger pause
r1 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r1.distance)
result = canary.check(BASELINE_FRAME) # clean frame, but still paused
assert result.paused is True
@@ -120,7 +133,8 @@ def test_resume_clears() -> None:
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
canary.check(DRIFTED_FRAME) # pause
r = canary.check(DRIFTED_FRAME)
canary.commit_pause(r.distance)
canary.resume()
assert canary.is_paused is False
@@ -130,13 +144,15 @@ def test_resume_clears() -> None:
def test_pause_file_written(tmp_path: Path) -> None:
"""When pause_flag_path is provided, the file is created on drift."""
"""When pause_flag_path is provided, the file is created on commit_pause."""
flag = tmp_path / "paused.flag"
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg, pause_flag_path=flag)
assert not flag.exists()
canary.check(DRIFTED_FRAME)
r = canary.check(DRIFTED_FRAME)
assert not flag.exists() # check() alone does NOT write the flag
canary.commit_pause(r.distance)
assert flag.exists()
@@ -147,14 +163,32 @@ def test_canary_pause_callback_fires_once() -> None:
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
canary.check(DRIFTED_FRAME) # transition → callback fires
canary.check(DRIFTED_FRAME) # still paused → no new callback
canary.check(BASELINE_FRAME) # clean but still paused → no new callback
r1 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r1.distance) # transition → callback fires
canary.commit_pause(r1.distance) # idempotent → no new callback
r2 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r2.distance) # still paused → no new callback
canary.check(BASELINE_FRAME) # clean but still paused → no new callback
assert len(calls) == 1
assert calls[0] > 0 # distance should be positive
def test_commit_pause_idempotent() -> None:
"""commit_pause is no-op when already paused — no flag re-write, no callback."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
calls: list[int] = []
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
canary.commit_pause(100)
canary.commit_pause(200)
canary.commit_pause(300)
assert len(calls) == 1
assert calls[0] == 100
def test_canary_resume_allows_new_pause_notification() -> None:
"""After resume, a fresh drift must re-fire the callback."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
@@ -162,17 +196,19 @@ def test_canary_resume_allows_new_pause_notification() -> None:
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
canary.check(DRIFTED_FRAME)
r1 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r1.distance)
assert len(calls) == 1
canary.resume()
canary.check(DRIFTED_FRAME) # new pause transition
r2 = canary.check(DRIFTED_FRAME)
canary.commit_pause(r2.distance) # new pause transition
assert len(calls) == 2
def test_canary_pause_callback_exception_does_not_crash_check() -> None:
"""A failing callback must not break canary.check (detection cycle safety)."""
def test_canary_pause_callback_exception_does_not_crash_commit_pause() -> None:
"""A failing callback must not break commit_pause (detection cycle safety)."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
def _boom(_d: int) -> None:
@@ -181,8 +217,8 @@ def test_canary_pause_callback_exception_does_not_crash_check() -> None:
canary = Canary(cfg, on_pause_callback=_boom)
# Must not raise — exception is swallowed + logged.
result = canary.check(DRIFTED_FRAME)
assert result.paused is True
r = canary.check(DRIFTED_FRAME)
canary.commit_pause(r.distance)
assert canary.is_paused is True
@@ -192,7 +228,26 @@ def test_resume_deletes_pause_file(tmp_path: Path) -> None:
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg, pause_flag_path=flag)
canary.check(DRIFTED_FRAME)
r = canary.check(DRIFTED_FRAME)
canary.commit_pause(r.distance)
assert flag.exists()
canary.resume()
assert not flag.exists()
def test_rebase_updates_baseline_in_memory() -> None:
"""rebase(new_h) mirrors hash into cfg.canary; subsequent check uses it."""
cfg = _cfg_with_baseline(BASELINE_FRAME)
canary = Canary(cfg)
# Compute the phash of the drifted frame; rebase to it.
drifted_hash = phash(crop_roi(DRIFTED_FRAME, CANARY_ROI))
assert cfg.canary.baseline_phash != drifted_hash
canary.rebase(drifted_hash)
assert cfg.canary.baseline_phash == drifted_hash
# Now the drifted frame reads as clean.
result = canary.check(DRIFTED_FRAME)
assert result.drifted is False
assert result.paused is False