R-734: the update test ignores an app's listed marker files, each with its reason

`09` §3 decision 145. MARKER_IGNORE in upgrade-test.py: per app, the files the
files_may_change mark does not count — first immich's six 13-byte
{encoded-video,library,backups,profile,thumbs,upload}/.immich folder markers,
rewritten at every start (bench measurement 2026-09-30). A listed file is
ignored only when changed/added and still <= 64 bytes; a removed or grown
marker, any unlisted file, and a moved tree the file walk cannot name still
mark the step. The verdict records files_ignored with the reasons.
HARNESS_VERSION 5. Tests: test_upgrade_bench.py MarkerIgnore (red-proved).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 11:20:09 +02:00
parent ec72c9dd5a
commit b0939cf309
2 changed files with 131 additions and 3 deletions
+66 -3
View File
@@ -51,8 +51,9 @@ import importlib.util, json, os, re, shutil, subprocess, sys, time
from datetime import datetime, timezone
from pathlib import Path
HARNESS_VERSION = 4 # 2: the memory watch (R-635); 3: box fixtures on the bench + files_may_change (2026-09-23 night);
HARNESS_VERSION = 5 # 2: the memory watch (R-635); 3: box fixtures on the bench + files_may_change (2026-09-23 night);
# 4: a PostgreSQL major is CONVERTED on the bench, and the ladder carries the mark (`09` §6.4 part 10)
# 5: files_may_change no longer counts an app's listed MARKER files (MARKER_IGNORE, R-734)
ROOT = Path("/opt/upg")
TEMPLATES = ROOT / "templates"
EVIDENCE = ROOT / "evidence"
@@ -628,6 +629,62 @@ def bind_tree_files(project: str, workdir: Path) -> dict:
return out
# R-734 (`09` §3 decision 145): app-owned MARKER files the `files_may_change` mark does not count. Each entry is
# (bind-source suffix, path inside that bind, largest size in bytes it may have, the reason). An entry ignores a file
# only when it was CHANGED or ADDED and is still that small — a removed marker, a grown one, or any file not listed
# here still counts, so the list can only take away what was measured to be noise. Pinned by
# scripts/test_upgrade_bench.py (MarkerIgnore: an unlisted changed file still marks the step).
_IMMICH_MARKER_REASON = ("immich rewrites this 13-byte folder marker at every start (its storage check writes a new "
"random token); measured on the bench 2026-09-30, v3.2.2 -> v3.2.4 and the 0b82 re-proof: "
"the only changed files were these six markers, no household file (R-734)")
MARKER_IGNORE = {
"immich": [("appdata/immich", "%s/.immich" % d, 64, _IMMICH_MARKER_REASON)
for d in ("encoded-video", "library", "backups", "profile", "thumbs", "upload")],
}
def _size_of(entry) -> int:
"""The size from a bind_tree_files value ("<size>:<digest>"); -1 when it cannot be read."""
try:
return int(str(entry).split(":", 1)[0])
except ValueError:
return -1
def classify_changes(app: str, before: dict, after: dict):
"""(counted, ignored) between two bind_tree_files results. `counted` is changed_files' list without the
files MARKER_IGNORE lists for `app`; `ignored` is [{"file", "reason"}] for the ones it took away."""
rules = MARKER_IGNORE.get(app) or []
counted, ignored = [], []
for src in sorted(set(before) | set(after)):
b, a = before.get(src) or {}, after.get(src) or {}
for rel in sorted(set(b) | set(a)):
if b.get(rel) == a.get(rel):
continue
kind = "added" if rel not in b else "removed" if rel not in a else "changed"
line = f"{src}/{rel} ({kind})"
hit = None
if kind != "removed":
for suffix, path, max_size, reason in rules:
if (src.rstrip("/") == suffix or src.rstrip("/").endswith("/" + suffix)) and rel == path \
and 0 <= _size_of(a.get(rel)) <= max_size:
hit = reason
break
if hit:
ignored.append({"file": line, "reason": hit})
else:
counted.append(line)
return counted, ignored
def files_may_change(tree_changed: list, counted: list, ignored: list) -> bool:
"""The mark. The tree hash moved and at least one changed file is NOT on the app's ignore list — or the tree
hash moved and the per-file walk could not name any file (the two walks disagree: keep the honest mark)."""
if not tree_changed:
return False
return bool(counted) or not ignored
def changed_files(before: dict, after: dict) -> list:
"""["<src>/<relpath> (added|removed|changed)"] between two bind_tree_files results."""
out = []
@@ -927,10 +984,16 @@ def run_edge(edge_id: str) -> dict:
detail_after = bind_tree_files(project, workdir)
(ev / "files-after-detail.json").write_text(json.dumps(detail_after, indent=2))
rec["files_changed_detail"] = changed_files(detail_before, detail_after)[:200]
if rec["files_changed"]:
counted, ignored = classify_changes(app, detail_before, detail_after)
rec["files_ignored"] = ignored[:200] # R-734: app-owned markers taken away, each with its reason
if ignored:
say(f"files ignored as app markers (MARKER_IGNORE, R-734): {[i['file'] for i in ignored][:20]}")
if files_may_change(rec["files_changed"], counted, ignored):
rec["marks"] = sorted(set(rec["marks"]) | {"files_may_change"})
say(f"files_may_change — the files: {rec['files_changed_detail'][:20]}")
say(f"files_may_change — the files: {(counted or rec['files_changed_detail'])[:20]}")
say(f"files_may_change: the bind-mounted tree changed under {rec['files_changed']}")
elif rec["files_changed"]:
say("files_may_change NOT set: every changed file is an app marker on the ignore list")
# --- 5b. the MEMORY WATCH — only for an edge that just read back; a failed one is decided ---
if rec["verdict"] == "proven" and SOAK_SECONDS > 0: