Skip to content

Private research: agent-aware recompute batching (do not flip /v1/run default) #5

Open

Description

@jkbennitt

Why

Agent /v1/run defaults to recompute=True after every script. Holding rebuilds across independent property edits is several× faster in-process, but FreeCAD already has SkipRecompute / freeze knobs — the open question is when an LLM agent path can use them safely.

What we measured (2026-09-18, live window, no checkout change)

Fixtures under %LOCALAPPDATA%\Temp\vibecad-recompute-bench\ (EngineBlock from tree examples; ShawnHymel public FCStds in public-cad\).

Fixture Edit In-process hold→recompute HTTP recompute:false then true
fuse / PD chains (Temp) leaf dims ~6–9×, volumes match (earlier hardened runner)
EngineBlock.FCStd Cylinder+Cylinder001 Radius ~8.7× median when recomputes run; volumes match —
ShawnHymel propeller.FCStd Pad001.Length +0.25×8 (Quantity) ~8.3×, volumes match flaky (stale Body)
ShawnHymel fem_bracket.FCStd Pad.Length 2→4 mm (Quantity) ~8.7×, volumes match flaky (stale Body)

Reports: ab-engineblock-report.json, ab-propeller-*, ab-fem_bracket-inproc.json, ab-fem_bracket-http.txt.

Working conclusions

  • Do not flip /v1/run default recompute=True.
  • Safe pattern so far: batch inside one script, one doc.recompute() at end; measure Body/tip solid volume (not null fillet tips).
  • Unsafe so far: relying on per-call recompute:false across HTTP for Part Design dirtying.
  • Digital_caliper (~7MB library) hung the live GUI; BIMExample Arch restore blocked — not good benches.
  • No usable public turbofan FCStd/STEP for this work.

Not for 10-X-eng yet

No upstream issue/PR until: failing automated test, proposed behavior, and approved wording.

Next

  • Root-cause HTTP stale Body after recompute:false bursts (PD dirty marks / host recompute path)
  • Decide burst API vs document sticky freeze vs document-only guidance
  • Optional: harden Temp runner into a private script in this fork (still no default flip)

Activity

  1. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Live RCA (2026-09-18, DESKTOP-ELN7SGD)

    Fixture: ShawnHymel fem_bracket.FCStd. Edit: Pad.Length 2.0→4.0 mm (App.Units.Quantity), measure Body.Shape.Volume (tip Mirrored).

    Log: %LOCALAPPDATA%\Temp\vibecad-recompute-bench\http-flake-instrumentation.jsonl

    Host code

    VibeCADAgentControl.run_script: after exec, if recompute then ActiveDocument.recompute(). With recompute:false, nothing after exec.

    What the instrumented HTTP burst showed

    Step host recompute http ms pad Body vol Pad state RecomputePending CooperativeMutationActive
    reset 2 mm true ~1314 2.0 3625.9 Up-to-date false false
    defer bump ×5 false ~7–63 2.25…3.25 3625.9 Touched false false
    defer bump 6–7 false ~23 3.5…3.75 3625.9 Touched true true
    final bump true 8.3 4.0 3625.9 (stale) Touched true true
    measure-only true 15.5 4.0 3625.9 (stale) Touched true true
    touch() + in-script recompute() false 17 4.0 3625.9 Touched true true (rc=0 in 0.004 ms)

    Smoking gun: once CooperativeMutationActive / RecomputePending latch mid-burst, later document.recompute() early-returns (~0–15 ms instead of ~1.2 s) and Body stays at baseline even though Pad length is already 4.0.

    Recovery / control

    • ~2 s later (poll): gates cleared; Body volume became 4844.6 without another Length edit (GUI follow-up drained).
    • Fresh reopen + in-process hold→doc.recompute(): rc=13, ~1289 ms, volume 3625.9→4844.6, coop=false.

    Working conclusion

    Cross-call recompute:false is unsafe for Part Design not because Length fails to touch, but because VibeCAD’s GUI Document::recompute can defer under cooperative-mutation / pending-recompute gates; the next /v1/run with recompute:true does not wait for that rebuild. In-process batching on a quiet doc still works.

    Fix direction (still private — no default flip)

    1. After host recompute:true, wait until not RecomputePending and cooperative/presentation gates clear; re-issue if Pad/Body still Touched.
    2. Serialize agent /v1/run with GUI recompute follow-ups so follow-up drains before the next call.
    3. Do not treat script-side Shape reads as post-recompute unless that wait is guaranteed.

    Still not for 10-X-eng until proposed behavior + approved wording.

  2. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Sketch: wait/drain after host recompute:true (fork-only, no code yet)

    Goal

    When /v1/run asks for recompute:true, the HTTP response must not return until Feature rebuild has actually finished (or we hard-fail with a clear error). Today the host calls ActiveDocument.recompute(), but GUI-thread Document::recompute can return 0 and queue a follow-up while CooperativeMutationActive / mutation-blocking presentation / coordinator is active — so Pad can already show the new Length while Body.Shape is still baseline.

    Where

    VibeCADAgentControl.run_script (after exec, only when recompute is true). Still marshalled via on_document_thread (already on the document/GUI owner thread).

    Why a plain sleep is wrong

    Follow-ups are queued on the GUI thread (scheduleGuiRecomputeFollowUp). We are already on that thread for /v1/run, so we must pump Qt events (same idea as the QEventLoop inside Document::recompute) until the follow-up drains — not sleep on the HTTP worker.

    Proposed helper (pseudocode)

    def _drain_recompute(document, *, timeout_s: float = 30.0) -> dict:
        """Call recompute and wait until rebuild finished or timeout.
    
        Returns {"ok": bool, "recompute_calls": int, "ms": float, "error": str|None}.
        """
        deadline = time.monotonic() + timeout_s
        calls = 0
    
        def gates_block() -> bool:
            # Prefer the same predicates Document::recompute uses.
            coop = bool(getattr(document, "CooperativeMutationActive", False))
            # Python may expose PresentationUpdateActive; C++ early-return also
            # checks mutation-blocking presentation depth + guiRecomputeCoordinatorActive.
            pres = bool(getattr(document, "PresentationUpdateActive", False))
            pending = bool(getattr(document, "RecomputePending", False))
            return coop or pending  # + presentation/coordinator if exposed
    
        def any_touched() -> bool:
            return any(
                "Touched" in list(getattr(obj, "State", []) or [])
                for obj in document.Objects
            )
    
        # Initial request (mirrors today’s host call).
        document.recompute()
        calls += 1
    
        gui = FreeCADGui  # if available
        while time.monotonic() < deadline:
            # Pump so scheduleGuiRecomputeFollowUp can run.
            if gui is not None:
                try:
                    gui.updateGui()  # or a short QEventLoop.processEvents
                except Exception:
                    pass
    
            if gates_block():
                continue
    
            if any_touched():
                document.recompute()
                calls += 1
                continue
    
            return {"ok": True, "recompute_calls": calls, "ms": ..., "error": None}
    
        return {
            "ok": False,
            "recompute_calls": calls,
            "ms": ...,
            "error": "RECOMPUTE_DRAIN_TIMEOUT",
        }

    Wire-in:

    exec(...)
    if recompute:
        drain = _drain_recompute(App.ActiveDocument)
        if not drain["ok"]:
            return failure("RECOMPUTE_DRAIN_TIMEOUT", ..., stage="recompute")

    Optional response fields (debug): recompute_drain_ms, recompute_calls.

    Acceptance (private, against fem_bracket)

    Same HTTP burst as #5 RCA comment:

    1. Reset Pad.Length to 2.0 mm, recompute:true → Body ~3625.9
    2. Seven calls +0.25 with recompute:false
    3. One call +0.25 with recompute:true (pad → 4.0)
    4. Same response (or immediate measure-only) must see Body ~4844.6
    5. Must not require a multi-second poll afterward
    6. Quiet in-process hold→recompute still ~8× and volumes match

    Non-goals

    • Do not flip default recompute to false
    • Do not make recompute:false “safe” for Shape reads across calls
    • Do not file on 10-X-eng until behavior + wording approved

    Agent guidance (docs only, after fix)

    • Preferred: batch independent property edits inside one /v1/run script, one recompute at the end (already correct today on a quiet doc).
    • recompute:false remains an advanced knob: do not read Shape/volume until a later recompute:true call that has drained.

    Open questions before coding

    1. Exact Python surface for coordinator / mutation-blocking presentation (may need a tiny App getter if not already bound).
    2. Timeout default (30s?) and whether drain failure is hard-fail vs warning + stale flag.
    3. Whether measure-only recompute:true with no edits should also drain (yes — RCA needed it).

    Ready to implement on AppSprout-dev only when you say go; still no upstream PR.

  3. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Live retest on DESKTOP-ELN7SGD (drain branch)

    Checkout: cursor/recompute-drain-2105 @ 0ef0140d
    Installed Mod copy updated; relaunched with -SkipRebuild.

    Same fem_bracket HTTP burst as the RCA (Pad 2→4 mm, seven recompute:false, one recompute:true):

    Before drain After drain
    final /v1/run http ms ~8 1213 (recompute_drain_ms ~1204, calls=1)
    Body vol immediately after final 3625.9 (stale) 4844.6
    Pad/Body state Touched / pending Up-to-date
    Needed ~2s poll later? yes no

    Drain fields present on success. Default still recompute:true. Still not for 10-X-eng until wording approved.

  4. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    More live benches (drain branch 0ef0140d)

    Same machine, -SkipRebuild install.

    Propeller (ShawnHymel) — Pad001 6→8 mm

    Check Result
    HTTP false×7 + true×1 final ~946 ms, drain ~938, Body 3925.2 immediate, Up-to-date
    HTTP ×3 repeats 3/3 pad=8.0 vol=3925.2
    In-process hold→recompute volumes match, ~8.5× (eager ~7209 ms vs batch ~853 ms)

    EngineBlock — Cylinder+Cylinder001 Radius 25→27.5 (+0.25×10)

    Check Result
    HTTP false×9 + true×1 final ~553 ms, drain ~532, tip Cut003 Up-to-date (not ~8 ms stale)
    In-process hold→recompute volumes match eager/batch, ~9.0× (eager ~3563 ms vs batch ~395 ms)

    Combined with fem_bracket

    Drain holds across Part Design (propeller, fem_bracket) and Part boolean chain (EngineBlock). In-process batching speedup still ~8–9×. Default still recompute=True.

    Log: %LOCALAPPDATA%\Temp\vibecad-recompute-bench\live-benches-after-drain.json

  5. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Hard matrix after drain (cursor/recompute-drain-2105 @ 0ef0140d)

    Live HTTP on DESKTOP-ELN7SGD. Drain is present (recompute_drain_ms ~0.9–1.4s on finals). Default remains recompute=true.

    Suite results

    Suite Result
    propeller false×7 + true ×8 7/8 exact (~3925.23). t1 miss: ~3881 (not the old ~8 ms / permanently stale path; drain still ~900 ms)
    fem_bracket ×8 8/8 under volume gate; one early trial showed intermediate ~4692 then settled to 4844.6
    EngineBlock r=27.5 ×8 8/8
    propeller back-to-back ×4 all passed loose gate; one soft ~3837 then 3925
    measure-only (recompute:true noop after false burst) works: pre Shape stale, drain ~1.2s, post measure correct
    switch propeller→fem pass

    First-after-open dig (propeller PolarPattern)

    Drain clears CooperativeMutationActive / RecomputePending and objects report Up-to-date, but Body/Tip volume can still be wrong on the first false-burst+drain after openDocument:

    • Seen wrong volumes: ~3837, ~3881, ~3793 (Pad Length already 8.0)
    • Reopen ×4 after fix probe: 3/4 exact 3925, 1/4 wrong (~3793)
    • Second burst on the same open doc usually lands exact
    • Idle second recompute:true after a wrong result does not reliably fix (gates already clear; nothing left to drain)

    So: drain fixed the catastrophic “~8 ms early-return + stale Shape until GUI poll” failure. Remaining flake looks like PartDesign PolarPattern / first rebuild after open reporting Up-to-date with an incomplete solid, not the latch the drain targets.

    Still not for 10-X-eng

    Do not file upstream until:

    1. first-after-open propeller is understood or mitigated, and
    2. Jason approves issue wording.

    Artifacts: %LOCALAPPDATA%\Temp\vibecad-recompute-bench\hard-matrix-dig.json

  6. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Live retest after harden (f17f5152)

    Dev window on DESKTOP-ELN7SGD: VibeCAD DEV · f17f5152. AgentControl copied into pixi Mod; relaunch -SkipRebuild.

    Propeller first-after-open (the flake)

    Suite Before harden After harden
    reopen ×8 6/8 (wrongs ~3837–3903, Up-to-date) 8/8 exact 3925.23
    seek wrong for probes easy 0/12 additional opens — no wrong to probe
    drain_ms on final ~0.9–1.0s ~3.2–3.3s (forced dirty rebuild)
    same-doc second burst usually ok ok

    Pre-harden recovery (for the record): noop / double recompute did not fix; same-Length reassign or touch() + recompute did. Matches the forced rebuild in this commit.

    Other matrix

    Suite Result
    fem_bracket false×7+true ×8 8/8 vol 4844.56, drain ~1.3s
    EngineBlock Cylinder.Radius edit ×8 8/8 Up-to-date (drain ~0.45s; no PartDesign tip path)
    fem measure-only after false×8 pass pre 3625.9 → post 4844.56, drain ~1.28s

    Artifacts: %LOCALAPPDATA%\Temp\vibecad-recompute-bench\polar-first-open-dig.json, hard-matrix-after-harden.json.

    Still not filing 10-X-eng until Jason wants wording. Cost note: patterned Body docs pay ~+2s on recompute:true vs gate-only drain.

  7. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Retest ce89f450 (touch-then-rebuild drain)

    Pulled over f17f5152, pixi Mod copy, -SkipRebuild. Window: ce89f450.

    Check Result
    propeller reopen ×8 8/8 vol 3925.23, drain ~2.9–3.2s, recompute_calls=2
    fem_bracket spot false×7+true pass 4844.56, drain ~2.5s
    fem measure-only pass 3625.9 → 4844.56, drain ~2.5s

    Still private-fork only; no 10-X-eng issue yet.

  8. jkbennitt commented on Sep 18, 2026

    @jkbennitt
    MemberAuthor

    Status (2026-09-18)

    The honesty half of this research is upstream now:

    /v1/run with recompute: true waits out GUI follow-up and, for Part Design Body/pattern tips, dirties Tip/Pad/originals and rebuilds until Shape volume is stable. Default stays recompute: true. Live matrix on Windows 10 DEV stayed green (propeller first-after-open, fem_bracket, measure-only, EngineBlock cylinder radius, recompute: false still fast).

    Still open on this private tracker (performance track)

    • Do not flip /v1/run default to recompute: false.
    • Safe speedup so far remains: batch edits inside one script, one doc.recompute() at end.
    • Decide later: burst API vs document sticky freeze vs docs-only guidance for agents.
    • HTTP recompute: false across calls is still the wrong default for Part Design; the drain makes the true path trustworthy so batching-inside-one-script stays the story.

    Fork PR #6 is the same branch lineage and is being closed as superseded by 10-X-eng 10-X-eng#246 (not merged into AppSprout main as a separate landing).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions