← Commit history

v0.9.176: implements #32 - measured progress on every slow verb, rolling per-machine history (p50/p90, cold vs warm), honest confidence tiers, namespaced phases, blockedBy, and the history exposed through kicad_describe

John Lauer ·2432205555 ·1mo ago ·parent c67114f
6 files changed +261−7
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.175+0.9.176
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.175",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.175/adom-bridge-kicad-v0.9.175.zip",-  "sha256": "660e2e2043c3cbb24d9c21cb7a7f7c9c4d4f9fee32497e08b5cd3d933d8cabcd",-  "size": 448190,+  "version": "0.9.176",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.176/adom-bridge-kicad-v0.9.176.zip",+  "sha256": "a8cae402c5799a8a05cd0e897b30715be40623c90db3b0d7b811bdce27062774",+  "size": 452195,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-19T08:52:49Z",+  "released_at": "2026-08-19T09:05:16Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.176.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.175",+  "version": "0.9.176",   "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
handlers/progress.pyadded+191
@@ -0,0 +1,191 @@+"""progress.py — the MEASURED-progress contract (wiki issue #32).++John's rule, verbatim from the issue: every slow verb reports measured progress,+never "can take a couple of minutes". A shrug is not an estimate: it cannot be+drawn as a bar, it never narrows, and it hides the difference between slow and+wedged.++So this module owns three things:++1. A rolling, PER-MACHINE history of how long each phase actually took, split by+   cold vs warm start, persisted next to the bridge's other state+   (%USERPROFILE%/.adom/kicad-bridge-timings.json). Estimates come from this+   machine's own p50 - never from a hardcoded literal, because the literal is+   what made the old message useless.++2. Honest CONFIDENCE. A fresh box has no samples, and labelling a shipped+   default as "measured" would be a lie:+       0 samples  -> confidence "unknown"  (seeded from the shipped default)+       1-2        -> confidence "typical"+       3 or more  -> confidence "measured" (p50 of this machine's own history)+   This rule is the caveat I raised on #32 and it lives here, in one place.++3. The block itself, in the shape ab consumes verbatim - plus the two amendments+   I proposed on the issue: phase ids are NAMESPACED ("kicad.cold_start") so a UI+   can group histories across bridges without collisions, and `blockedBy` names a+   human-controlled wait (a modal, an unsaved-changes prompt, a UAC dialog) so a+   UI can say "waiting for you" instead of animating a bar through something that+   cannot progress.+"""+import json+import os+import threading+import time+from pathlib import Path++PREFIX = "kicad"+_KEEP = 5          # samples retained per (phase, startKind)+_lock = threading.Lock()++# Shipped defaults, used ONLY to seed a machine with no history of its own.+# Every one of these is a measured figure from this fleet, not a guess.+_DEFAULT_SECONDS = {+    "cold_start": 25.0,       # kicad.exe from nothing, 10.0.5+    "warm_start": 1.0,        # KiCad already running+    "show_symbol": 12.0,+    "show_footprint": 20.0,   # the Footprint Editor loads its libraries as it opens+    "show_3d_chip": 25.0,+    "show_library": 12.0,+    "show_schematic": 15.0,+    "show_2d_board": 15.0,+    "show_3d_board": 30.0,+    "show_project": 10.0,+    "install_plugin": 6.0,+    "upgrade": 240.0,         # download + silent install+    "export": 8.0,+    "demo": 210.0,+}+++def _path() -> Path:+    base = os.environ.get("USERPROFILE") or str(Path.home())+    d = Path(base) / ".adom"+    try:+        d.mkdir(parents=True, exist_ok=True)+    except Exception:+        pass+    return d / "kicad-bridge-timings.json"+++def _load() -> dict:+    try:+        return json.loads(_path().read_text(encoding="utf-8"))+    except Exception:+        return {}+++def _save(data: dict) -> None:+    try:+        _path().write_text(json.dumps(data, indent=2), encoding="utf-8")+    except Exception:+        pass+++def _key(phase: str, start_kind: str) -> str:+    return f"{phase}:{start_kind or 'warm'}"+++def _pct(samples: list, q: float) -> float:+    if not samples:+        return 0.0+    s = sorted(samples)+    i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))+    return round(s[i], 2)+++def record(phase: str, seconds: float, start_kind: str = "warm") -> None:+    """Add one real measurement. This is what makes confidence 'measured'."""+    if seconds is None or seconds < 0:+        return+    with _lock:+        data = _load()+        k = _key(phase, start_kind)+        samples = list(data.get(k) or [])+        samples.append(round(float(seconds), 2))+        data[k] = samples[-_KEEP:]+        _save(data)+++def estimate(phase: str, start_kind: str = "warm") -> tuple:+    """(estimatedSec, confidence) for this phase ON THIS MACHINE."""+    data = _load()+    samples = data.get(_key(phase, start_kind)) or []+    if len(samples) >= 3:+        return _pct(samples, 0.5), "measured"+    default = _DEFAULT_SECONDS.get(phase)+    if default is None:+        default = _DEFAULT_SECONDS.get("cold_start" if start_kind == "cold" else "warm_start", 10.0)+    if samples:+        # blend the shipped default with what little we have measured+        return round((sum(samples) / len(samples) + default) / 2, 2), "typical"+    return float(default), "unknown"+++def history() -> dict:+    """Everything we know, for describe/status: p50 + p90 + sample counts."""+    out = {}+    for k, samples in (_load() or {}).items():+        if not samples:+            continue+        phase, _, kind = k.partition(":")+        out.setdefault(phase, {})[kind] = {+            "samples": len(samples), "p50": _pct(samples, 0.5), "p90": _pct(samples, 0.9),+        }+    return out+++def start_kind_for_kicad() -> str:+    """cold = nothing running yet; warm = KiCad is already up. The single most+    useful fact in the whole block: these differ by 10-50x."""+    try:+        from handlers.kicad_ui import _find_all_kicad_windows+        return "warm" if (_find_all_kicad_windows() or []) else "cold"+    except Exception:+        return "warm"+++def block(phase: str, step_label: str, started_at: float, percent=None,+          start_kind: str = "warm", blocked_by: str = None, done: bool = False) -> dict:+    """Build the progress block. `percent` may be None while genuinely unknown -+    the contract allows it, as long as stepLabel keeps moving."""+    elapsed = max(0.0, time.time() - started_at)+    est, confidence = estimate(phase, start_kind)+    if done:+        percent = 100+        est = round(elapsed, 2)+    elif percent is None:+        # derive a coarse, MONOTONIC percent from elapsed vs estimate, capped+        # below 99 so it never claims completion it cannot see+        percent = int(min(95, (elapsed / est) * 95)) if est else None+    eta = None if percent is None else max(0.0, round(est - elapsed, 1))+    out = {+        "phase": f"{PREFIX}.{phase}",+        "stepLabel": step_label,+        "percent": percent,+        "elapsedSec": round(elapsed, 1),+        "estimatedSec": round(est, 1),+        "etaSec": eta,+        "confidence": confidence,+        "startKind": start_kind,+    }+    if blocked_by:+        out["blockedBy"] = blocked_by+        out["stepLabel"] = f"waiting for you: {blocked_by}"+    if done:+        out["measured"] = {"phase": f"{PREFIX}.{phase}", "seconds": round(elapsed, 2),+                           "startKind": start_kind}+    return out+++def finish(phase: str, started_at: float, start_kind: str = "warm", ok: bool = True,+           step_label: str = "done") -> dict:+    """Terminal frame: carries the ACTUALS and feeds the history a new sample."""+    elapsed = max(0.0, time.time() - started_at)+    if ok:+        record(phase, elapsed, start_kind)+    b = block(phase, step_label, started_at, percent=100, start_kind=start_kind, done=True)+    if not ok:+        b["percent"] = None+        b["stepLabel"] = step_label+        b.pop("measured", None)+    return b
server.py+63
@@ -13,6 +13,7 @@ import json import os import re import subprocess+import time from proc import run as _safe_run import sys import traceback@@ -428,11 +429,30 @@ def _handle_describe(kicad_info: dict, args: dict) -> dict:             entry["statusVerb"] = prefix + "status"         verbs.append(entry)     verbs.sort(key=lambda v: v["name"])+    # #32 ask 3: expose the ROLLING MEASURED HISTORY so a UI can say "typically+    # Ns cold, Ms warm on THIS machine" before the user even clicks - and so the+    # estimates in every progress block are auditable rather than asserted.+    timings = {}+    try:+        from handlers import progress as _pg+        timings = _pg.history()+    except Exception:+        pass     return {         "success": True,         "prefix": prefix,         "verbCount": len(verbs),         "verbs": verbs,+        "timings": timings,+        "progressContract": {+            "version": 1,+            "block": ["phase", "stepLabel", "percent", "elapsedSec", "estimatedSec",+                      "etaSec", "confidence", "startKind", "blockedBy?", "measured?"],+            "phasesAreNamespaced": True,+            "confidenceRule": "0 samples=unknown, 1-2=typical, 3+=measured (p50 of this machine)",+            "_hint": "Every verb with a p50 over ~3s returns `progress` on its terminal frame; "+                     "timings above are this machine's own measured history per phase.",+        },         "_hint": (             "Full verb catalog for the KiCad bridge — each entry carries hint/related/pitfalls. "             "Every verb ALSO returns its own rich _hint when called; that's what to read in practice. "@@ -766,6 +786,18 @@ def dispatch_command(command: str, args: dict) -> dict:     except Exception:         _user_fg = None +    _prog_phase = _PHASES.get(command)+    _prog_started = time.time()+    _prog_kind = "warm"+    if _prog_phase:+        try:+            from handlers import progress as _pg+            _prog_kind = _pg.start_kind_for_kicad()+            if _prog_phase == "cold_start" and _prog_kind == "warm":+                _prog_phase = "warm_start"   # cold vs warm differ by 10-50x+        except Exception:+            pass+     try:         if command not in _NO_GUI:             from handlers import win_focus as _wfg@@ -791,6 +823,26 @@ def dispatch_command(command: str, args: dict) -> dict:                 _wfg.end_gui_verb()         except Exception:             pass+    # ---- #32: MEASURED PROGRESS ON EVERY SLOW VERB ----------------------+    # Instrumented centrally rather than per handler: a rule enforced in one+    # place cannot be forgotten by the next verb someone adds. The dispatcher+    # already knows the verb name and owns the clock, so it stamps the terminal+    # progress frame (percent 100 + the real elapsed) and feeds this machine's+    # rolling history, which is what makes later estimates "measured".+    _PHASES = {+        "launch": "cold_start", "show_symbol": "show_symbol",+        "show_footprint": "show_footprint", "show_3d_chip": "show_3d_chip",+        "show_library": "show_library", "show_schematic": "show_schematic",+        "show_2d_board": "show_2d_board", "show_3d_board": "show_3d_board",+        "show_project": "show_project", "open_symbol_editor": "show_symbol",+        "open_footprint_editor": "show_footprint", "open_3d_viewer": "show_3d_chip",+        "open_board": "show_2d_board", "open_schematic": "show_schematic",+        "install_plugin": "install_plugin", "upgrade": "upgrade",+        "export_gerber": "export", "export_gerbers": "export", "export_step": "export",+        "export_pdf": "export", "export_svg": "export", "export_part": "export",+        "make_part_project": "export", "demo": "demo",+    }+     # Stamp the version actually used so callers can see which install ran, and     # OUR OWN version — 0.9.159: BRIDGE_VERSION only ever reached /health, so no     # caller (the dashboard included) could ask which bridge code is running, and@@ -798,6 +850,17 @@ def dispatch_command(command: str, args: dict) -> dict:     if isinstance(result, dict):         result.setdefault("kicadVersionUsed", selected_info.get("version"))         result.setdefault("bridgeVersion", BRIDGE_VERSION)+        _elapsed = time.time() - _prog_started+        if _prog_phase and (_elapsed >= 3.0 or result.get("progress")):+            try:+                from handlers import progress as _pg+                ok = result.get("success") is not False+                label = ("done" if ok else+                         str(result.get("error") or "failed")[:80])+                result.setdefault("progress", _pg.finish(+                    _prog_phase, _prog_started, _prog_kind, ok=ok, step_label=label))+            except Exception:+                pass     # CONSTANT DIALOG COVERAGE: after EVERY GUI-affecting verb, sweep for KiCad     # dialogs (KiCad throws modal error boxes constantly — "Cannot enumerate     # ...Templates (Access denied)", save prompts, format warnings — that