fix(smart): routing coherence + enrichment latency (nearestHeading quadratic blowup) - #50
Conversation
…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
left a comment
There was a problem hiding this comment.
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
rawfrom 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 forraw. - 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
| deadline = time.monotonic() + timeout | ||
| for name, method, call_kw in specs: | ||
| remaining = max(0.5, deadline - time.monotonic()) | ||
| try: |
There was a problem hiding this comment.
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.
| fut = self._smart_executor.submit(self.call, method, **kw) | ||
| try: | ||
| return fut.result(timeout=tm) | ||
| except _cf.TimeoutError: |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| b = {"status": 200, "blockmap": {}, "discover_timeout": True, "raw": {}} | ||
| esc = _escalation_for_bundle(b) | ||
| assert esc and esc["reason"] == "timeout" and esc["retryable"] is True | ||
|
|
There was a problem hiding this comment.
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.
Moved from PR #50 where it landed without the fix it exercises.
|
All four addressed in bb8aed2:
Re-verified after changes: pytest 8/8 here, 6/6 on #51. |
protostatis
left a comment
There was a problem hiding this comment.
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_shadowedis 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.
… 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
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()insrc/js/page_model.jsranquerySelector('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).discoverswallowed the error and returned empty after exactly 30s; the Python layer's 6–8s budget then reportedtimeoutescalation. 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_discover30.0s → 0.04s; matrix timeout escalations 7/24 → 0; BBC end-to-end 25.9s → 1.7s.Routing-coherence fixes
extractwhileavoid[]forbade it. Now gated onjson_scripts > 0, else suggestsquery_debug._apply_coherence(): nonext_tools/micro_hintentry may contradictavoid[]; defense in depth against any future branch drift.challenge@0.55); kept aschallenge_shadowedevidence.Efficiency / robustness
Client.callis synchronous request/response — true parallelism would cross-read responses).bundle.rawslimmed to the six fields drivers read (~2× token cut peropen()).controls[0]IndexError on forms with no controls (eBay error page).Verification
cargo test --release: 128/128.