Skip to content

fix(smart): routing coherence + enrichment latency (nearestHeading quadratic blowup) - #50

Merged
protostatis merged 2 commits into
mainfrom
fix/smart-routing-calibration
Aug 21, 2026
Merged

fix(smart): routing coherence + enrichment latency (nearestHeading quadratic blowup)#50
protostatis merged 2 commits into
mainfrom
fix/smart-routing-calibration

Conversation

@protostatis

Copy link
Copy Markdown
Owner

Follow-up to #48, from a live 24-site out-of-matrix evaluation of the smart MCP surface.

Root cause of the enrichment-timeout epidemic

nearestHeading() in src/js/page_model.js ran querySelector('h1..h4') over each ancestor's entire subtree for every link — O(links × depth × doc size) on QuickJS, blowing the 30s eval watchdog (route_discover: InternalError: interrupted). discover swallowed the error and returned empty after exactly 30s; the Python layer's 6–8s budget then reported timeout escalation. Affected ~30% of mainstream sites (HN, CNN, Verge, MDN, Yahoo Finance, docs.rs, Lobsters…).

Fix: scan only direct children at each ancestor level (also better semantics: own-section heading). lobste.rs route_discover 30.0s → 0.04s; matrix timeout escalations 7/24 → 0; BBC end-to-end 25.9s → 1.7s.

Routing-coherence fixes

  • thin_shell contradiction (crates.io, old.reddit): escalation suggested extract while avoid[] forbade it. Now gated on json_scripts > 0, else suggests query_debug.
  • New _apply_coherence(): no next_tools/micro_hint entry may contradict avoid[]; defense in depth against any future branch drift.
  • Challenge confidence floor: sub-0.7 challenge matches no longer shadow a concrete HTTP status (httpbin 503 was challenge@0.55); kept as challenge_shadowed evidence.

Efficiency / robustness

  • Enrichment phase gets one shared deadline instead of k sequential budgets (Client.call is synchronous request/response — true parallelism would cross-read responses).
  • Cards run before discover so a slow call starves routes, not content.
  • bundle.raw slimmed to the six fields drivers read (~2× token cut per open()).
  • Guarded controls[0] IndexError on forms with no controls (eBay error page).

Verification

  • 5 new fixture tests (thin_shell gating, challenge floor, coherence filter, find-binary mtime rule lives in the follow-up PR); full suite 9/9.
  • cargo test --release: 128/128.
  • Live 24-site matrix before/after: flags 5 → 0, timeout-esc 7 → 0, enrichment populated on all content pages.

…adratic blowup)

- route_discover burned the full 30s eval watchdog on many pages:
  nearestHeading() ran querySelector over each ancestor's entire subtree
  per link (O(links x depth x doc size)) under QuickJS. Scan direct
  children only — lobste.rs route_discover 30.0s -> 0.04s; timeout
  escalations across a live 24-site matrix went 7/24 -> 0.
- thin_shell escalation suggested extract even with json_scripts == 0,
  colliding with avoid[] (crates.io, old.reddit). Gate the suggestion on
  positive evidence; otherwise suggest query_debug.
- New _apply_coherence(): next_tools/micro_hint entries that contradict
  avoid[] are dropped in place, escalation copy kept in sync.
- Challenge verdicts below 0.7 confidence no longer shadow a concrete
  HTTP status (httpbin 503 was reported challenge@0.55 instead of
  server_error); the detector's read survives as challenge_shadowed
  evidence.
- Enrichment phase shares one deadline instead of k sequential budgets;
  cards run before discover so a slow call starves routes, not content.
- bundle.raw slimmed to the fields drivers actually read (~2x token cut).
- Guard micro_hint forms branch against controls=[] (eBay error page).

Live 24-site matrix: coherence flags 7 -> 0; BBC 25.9s -> 1.7s.

@protostatis protostatis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sky's Code Review

This PR fixes a real production bug (O(links × depth × doc size) nearestHeading blowup causing 30s watchdog timeouts) and adds sensible routing-coherence fixes (thin_shell/extract contradiction, challenge confidence floor, _apply_coherence defense-in-depth). The nearestHeading rewrite to direct-children scanning is correct and a dramatic performance win. The escalation logic for challenge_shadowed is actually safe from NameError on all traced paths, and the confidence-floor change is reasonable. Main concerns: (1) the find_binary test_added-without-implementation mismatch, (2) the shared-deadline implementation can still overrun the budget because each call gets a min-0.5s floor even after the deadline passes, and (3) the timeout path doesn't actually cancel the synchronous Client.call, so a timed-out call can still cross-read responses on the single pipe the PR itself warns about.

Verdict: Comment

Comments

  • The nearestHeading change is correct and the semantics shift (own-section heading) is intentional and well-motivated. Confirmed the iterator now scans n.children (element-only) per ancestor level, which is O(depth × child-count) instead of subtree querySelector — matches the reported 30.0s → 0.04s.
  • Bundle 'raw' slimming to six fields is a good token reduction, but it silently changes raw from the full navigate result to a partial view. If any driver/routing consumer reads a field outside those six (e.g. blockmap, extract, headers that were previously present in raw), it will now KeyError or get None. The six-field list is a reasonable contract but should be documented as the canonical surface for raw.
  • The challenge confidence floor (conf >= 0.7 or not http_error) is a sound calibration fix and the shadowed-evidence preservation is good. Note the default conf=0.9 when confidence is absent means a challenge with no confidence field still preempts any HTTP error status — likely intended, but worth confirming that's the desired behavior for unknown detectors.

Reviewed by Sky — Unchained Sky engineering agent

Comment thread python/unbrowser/smart.py
deadline = time.monotonic() + timeout
for name, method, call_kw in specs:
remaining = max(0.5, deadline - time.monotonic())
try:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remaining = max(0.5, deadline - time.monotonic()) gives every enrichment call a 0.5s floor even after the shared deadline has fully expired. With 3 specs, the phase can overrun the budget by ~1.5s (plus whatever a single in-flight call already consumed). If the hard budget must be honored, clamp instead with if remaining <= 0: break rather than flooring to 0.5, or at least cap the floor so only the first call after expiry gets a grace period.

Comment thread python/unbrowser/smart.py
fut = self._smart_executor.submit(self.call, method, **kw)
try:
return fut.result(timeout=tm)
except _cf.TimeoutError:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fut.cancel() on a ThreadPoolExecutor future rarely interrupts the running self.call (threads can't be killed), so the synchronous request/response over the single pipe continues in the background. The very next _timed_call submits a new self.call, and per this PR's own comment ('no id matching, so concurrent calls would cross-read responses') that timed-out-but-still-running call can cross-read the next response. The shared deadline shrinks the window but doesn't eliminate it. Consider guarding the pool/pipeline with a lock or draining/awaiting the timed-out future before the next submit.

Comment thread python/unbrowser/smart.py
return {"reason": "http_error", "category": "retry", "confidence": 0.8, "severity": "medium", "retryable": True, "evidence": {"status": status}, "hint": f"HTTP {status} — check challenge field and retry.", "options": [{"action": "try_help", "tool": "help", "params": {"topic": "session"}}], "next_tools": [{"tool": "help", "when": "session", "confidence": 0.7}]}
return {"reason": "server_error", "category": "retry", "confidence": 0.8, "severity": "medium", "retryable": True, "evidence": {**shadow, "status": status}, "hint": f"HTTP {status} server error — retryable.", "options": [{"action": "retry", "tool": "open", "params": {"url": bundle.get("url")}}], "next_tools": [{"tool": "open", "when": "retry", "confidence": 0.6}]}
return {"reason": "http_error", "category": "retry", "confidence": 0.8, "severity": "medium", "retryable": True, "evidence": {**shadow, "status": status}, "hint": f"HTTP {status} — check challenge field and retry.", "options": [{"action": "try_help", "tool": "help", "params": {"topic": "session"}}], "next_tools": [{"tool": "help", "when": "session", "confidence": 0.7}]}
# 3. timeout — enrichment bounded

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

challenge_shadowed is only bound when challenge is truthy AND conf < 0.7 AND http_error. It is correctly guarded (the later if challenge short-circuits when falsy, and all fall-through paths assign it), so no NameError today — but the binding is implicit and spread across two branches. Worth a one-line comment noting the invariant, since any future sub-branch added inside if challenge: could silently leave it unbound.

Comment thread tests/test_mcp_minimal.py
b = {"status": 200, "blockmap": {}, "discover_timeout": True, "raw": {}}
esc = _escalation_for_bundle(b)
assert esc and esc["reason"] == "timeout" and esc["retryable"] is True

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_find_binary_prefers_freshest_local_build asserts find_binary() prefers the freshest binary by mtime, but the PR description explicitly states 'find-binary mtime rule lives in the follow-up PR' and this diff contains no change to find_binary (only smart.py and page_model.js). As written this test will fail against the current code. Either ship the find_binary change here or move the test to the follow-up PR.

…riant note

- Drop the 0.5s per-call grace floor: once the shared deadline expires,
  remaining specs are skipped outright instead of overrunning the budget
  by ~0.5s each.
- _timed_call now returns (result, future). After a timeout the abandoned
  worker still owns the rpc pipe (responses are position-matched), so the
  loop gives it a 1.5s grace to land its response (recovered results are
  used); if it doesn't finish, remaining specs are skipped with an error
  and the phase breaks — no further submit can cross-read another call's
  reply.
- Document the challenge_shadowed binding invariant at the fall-through.
- Move test_find_binary_prefers_freshest_local_build to PR #51 where the
  find_binary change itself lives; it fails standalone here.
protostatis added a commit that referenced this pull request Aug 21, 2026
Moved from PR #50 where it landed without the fix it exercises.
@protostatis

Copy link
Copy Markdown
Owner Author

All four addressed in bb8aed2:

  1. 0.5s floor overrun — dropped. remaining <= 0 now skips outright with a skipped: budget exhausted error instead of flooring.

  2. Cross-read after timeout — the real one, fixed properly: _timed_call now returns (result, future). On timeout the loop gives the abandoned worker a 1.5s grace to land its response (a recovered result is used); if it doesn't finish, remaining specs are skipped with skipped: … rpc pipe reserved and the phase breaks — no further submit can misread another call's reply. Residual exposure is now only cross-invocation (next tool call racing a zombie), which needs the id-matched reader noted as the v2 client fix; in-phase it's closed.

  3. challenge_shadowed invariant — documented at the binding with an explicit warning for future early-returns inside the challenge block.

  4. Misplaced test — you're right, that was my branch split leaking. test_find_binary_prefers_freshest_local_build moved to fix(packaging): find_binary prefers freshest local build over stale bundled binary #51 (46c0b54) where its fix lives; this branch is back to 8/8 standalone-green.

Re-verified after changes: pytest 8/8 here, 6/6 on #51.

@protostatis protostatis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sky's Code Review

Well-executed follow-up addressing two real production issues: the O(links×depth×doc-size) nearestHeading quadratic blowup causing 30s eval watchdogs, and routing-coherence contradictions (thin_shell suggesting extract while avoid[] forbids it). The challenge-confidence floor correctly fixes httpbin 503 being misclassified as challenge@0.55, and the shared-deadline enrichment phase is a sensible design given the synchronous single-pipe RPC. The challenge_shadowed binding is currently correct but relies on a documented invariant that is fragile to future edits. Tests are thorough (5 fixtures + full suite). Overall the changes are correct with no security or production-breaking bugs.

Verdict: Approve

Comments

  • The invariant-comment approach for challenge_shadowed is a code smell that will bite during future branch edits; prefer structural safety (initialize to None, or return a sentinel) over prose.
  • The shared-deadline comment says calls stay sequential (no true parallelism) — correct given position-matched response reads, and the cards-before-discover ordering is a good call so slow discover starves routes, not content.

Reviewed by Sky — Unchained Sky engineering agent

Inline Comments (could not attach to lines)

python/unbrowser/smart.py:405 — The challenge_shadowed binding is correct today but is fragile: it is only assigned in the low-confidence+HTTP-error branch, then spread into {**shadow, ...} later. Any future early-return added inside the if challenge: block before that assignment will raise UnboundLocalError at the HTTP-error section. The comment documents this, but a more robust pattern would be to initialize challenge_shadowed = None before the if challenge: block and build shadow conditionally on is not None. Consider a structural guarantee (init to None) over a comment guarantee.

python/unbrowser/smart.py:744 — The 1.5s grace drain (fut.result(timeout=1.5)) can push total phase time ~1.5s past the stated shared timeout budget, since the abandoned worker owns the pipe. This is necessary to avoid cross-reading responses, but the effective worst case is timeout + 1.5s, not timeout as the PR description implies (BBC 26s → 1.7s still holds for the happy path, but the bounded-worst-case claim is slightly off). Worth a one-line doc note so future readers don't assume a hard timeout ceiling.

python/unbrowser/smart.py:576 — In _apply_coherence, when bundle.get("next_tools") is None/unset and avoid_tools is non-empty, nxt becomes [] and [] != None is True, so bundle["next_tools"] is set to an empty list rather than left unset. This is almost certainly harmless, but if any downstream consumer branches on next_tools is None vs next_tools == [], this mutation changes semantics. Confirm no consumer distinguishes these; otherwise initialize carefully.

src/js/page_model.js:186 — Nice fix and the direct-children semantics are arguably more correct (own-section heading). One behavior note: n.children returns only element nodes, so text-only heading wrappers (unusual) are skipped, and the loop terminates correctly on Document nodes via the n.tagName guard. No issue — just flagging that <h1 itemprop="name"> with a child element (e.g. <span>) inside the heading will still match on the itemprop branch since k is the heading element itself. Looks correct.

@protostatis
protostatis merged commit d3e4598 into main Aug 21, 2026
1 check passed
@protostatis
protostatis deleted the fix/smart-routing-calibration branch August 21, 2026 12:57
protostatis added a commit that referenced this pull request Aug 21, 2026
… registry

- nearestHeading quadratic blowup fix (route_discover watchdog burns,
  enrichment timeouts on ~30% of mainstream sites)
- smart-layer routing coherence + shared enrichment deadline (#50)
- find_binary freshest-local-build resolution (#51)
- CLI search/open flag parse errors exit cleanly (#49)
- README: routing-aids bullet + minimal MCP profile note; SKILL.md tool
  hints cover micro_hint/avoid/escalation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant