captchakraken 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +126 -16
  2. package/dist/cli-invocation.d.ts +2 -0
  3. package/dist/cli-invocation.js +5 -1
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/solver.d.ts +115 -5
  11. package/dist/solver.js +583 -190
  12. package/dist/timing.d.ts +48 -0
  13. package/dist/timing.js +103 -0
  14. package/dist/trajectory.d.ts +27 -0
  15. package/dist/trajectory.js +104 -0
  16. package/dist/types.d.ts +129 -0
  17. package/package.json +11 -3
  18. package/python/README.md +76 -5
  19. package/python/pyproject.toml +21 -4
  20. package/python/src/captchakraken/__init__.py +36 -1
  21. package/python/src/captchakraken/cli.py +62 -4
  22. package/python/src/captchakraken/config.py +28 -2
  23. package/python/src/captchakraken/errors.py +24 -0
  24. package/python/src/captchakraken/humanize.py +929 -0
  25. package/python/src/captchakraken/keyframes.py +99 -0
  26. package/python/src/captchakraken/models.json +149 -13
  27. package/python/src/captchakraken/page_solver.py +588 -291
  28. package/python/src/captchakraken/pinned_model.json +3 -2
  29. package/python/src/captchakraken/planner.py +118 -9
  30. package/python/src/captchakraken/prompts.py +182 -0
  31. package/python/src/captchakraken/server_manager.py +9 -4
  32. package/python/src/captchakraken/solver.py +97 -57
  33. package/python/src/captchakraken/timing.py +8 -3
  34. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  35. package/python/src/captchakraken/trajectory.py +117 -0
  36. package/python/src/captchakraken/updater.py +62 -1
@@ -12,8 +12,9 @@
12
12
  "serving prompt fails CI until someone consciously re-pins \u2014 which forces",
13
13
  "the question 'does the pinned adapter still expect this prompt?'.",
14
14
  "",
15
- "To ship a new adapter: update lora_adapter/lora_revision, re-run the Tier 2",
16
- "static-image gate against it, and update prompt_sha256 if the prompts moved."
15
+ "To ship a new adapter: update lora_adapter/lora_revision, re-run the",
16
+ "static-image accuracy gate against it, and update prompt_sha256 if the",
17
+ "prompts moved."
17
18
  ],
18
19
  "base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
19
20
  "lora_adapter": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
@@ -124,6 +124,14 @@ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
124
124
  _SESSION_HEADER = "X-CK-Session"
125
125
  _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
126
126
 
127
+ # Report whether the widget accepted, once per solve. "0" turns it off.
128
+ #
129
+ # ON by default because the report is what makes a failed solve improvable, and
130
+ # because it is the only way a hosted account's failures can be found at all.
131
+ # Opting out here stops the CLIENT sending it; `captureOptOut` on the account is
132
+ # the server-side half, and either one alone is enough to keep nothing.
133
+ _REPORT_OUTCOME_ENV = "CAPTCHA_REPORT_OUTCOME"
134
+
127
135
  # Arbitrary extra headers, as "Name: value" pairs separated by newlines or
128
136
  # commas. Empty and absent by default.
129
137
  #
@@ -284,6 +292,7 @@ class ActionPlanner:
284
292
  debug_callback: Optional[Any] = None,
285
293
  base_url: Optional[str] = None,
286
294
  api_key: Optional[str] = None,
295
+ expert: Optional[str] = None,
287
296
  **_: Any,
288
297
  ):
289
298
  self.debug_callback = debug_callback
@@ -309,9 +318,102 @@ class ActionPlanner:
309
318
  # the same key: an adapter reads a puzzle at the pixel band it trained
310
319
  # under. Resolved once per planner — see the note above.
311
320
  self.pixel_budget = prompts.pixel_budget(_prompt_key)
321
+ # WHICH ADAPTER answers each prompt family. Empty for every model that
322
+ # is not routed, which is every model published so far — `_model_for`
323
+ # then returns `self.model` for every request and the wire is unchanged.
324
+ #
325
+ # Resolved once, like the two above, and for a stronger reason: the pin
326
+ # is validated here, so `--expert grid` against a single-adapter model
327
+ # fails when the solver is BUILT rather than on the first grid it meets.
328
+ self.expert = (expert if expert is not None
329
+ else prompts.expert_pin())
330
+ self.experts = prompts.experts(_prompt_key)
331
+ if self.expert:
332
+ # Raises on an unknown family, and on a pin against an unrouted
333
+ # model. Both are configuration errors, and a benchmark that
334
+ # quietly measured the generalist while reporting an expert is the
335
+ # exact class of silent mispairing models.json exists to prevent.
336
+ prompts.route(_prompt_key, None, pin=self.expert)
312
337
  # Auto-start a local vLLM server on the first request if one isn't up
313
338
  # (no-op for a healthy or remote endpoint). Guarded so we only try once.
314
339
  self._server_ensured = False
340
+ # ONE connection, reused for every round of every solve this planner
341
+ # serves. `requests.post` opens a fresh TCP connection each call — and
342
+ # to a hosted endpoint a TLS handshake with it. Measured against the
343
+ # gate's own endpoint: 258ms per request connectionless vs 144ms
344
+ # pooled, so every inference was paying ~110ms to re-dial a server it
345
+ # had just finished talking to. A multi-round grid pays it per round.
346
+ #
347
+ # The planner is per-PageSolver and a PageSolver is reusable across
348
+ # solves, so a caller who keeps one keeps the connection warm too.
349
+ self._http = requests.Session()
350
+ # Latched off by the first 404 — see `report_outcome`.
351
+ self._outcome_supported = True
352
+
353
+ def _model_for(self, family: Optional[str]) -> str:
354
+ """The `model` string for a request in `family`.
355
+
356
+ `self.model` whenever the model is not routed, so a caller may pass a
357
+ family unconditionally. A routed model with no expert for this family
358
+ degrades to the generalist — never to another expert, never to an
359
+ error (docs/MOE_LORA_DESIGN.md §11).
360
+ """
361
+ if not self.experts:
362
+ return self.model
363
+ return prompts.route(self.model, family, pin=self.expert) or self.model
364
+
365
+ # ── the solve's own verdict, sent back ─────────────────────────────────
366
+ #
367
+ # THE ONE THING THE SERVER CANNOT SEE. A wrong answer reaches the gateway as
368
+ # a 200 with well-formed JSON in it; only this driver watched the widget.
369
+ # Without this report the hosted API cannot tell a solved captcha from a
370
+ # failed one, and the failures — the exact boards the model is worst at, on
371
+ # real sites — are the one dataset that cannot be collected any other way.
372
+ #
373
+ # BEST-EFFORT, ALWAYS. It runs after the solve is over and its result is
374
+ # already decided, so nothing it does can change the answer the caller gets.
375
+ # Every failure is swallowed: a self-hosted vLLM has no such route, a
376
+ # network blip is not the caller's problem, and an exception here would turn
377
+ # a successful solve into a raised one.
378
+ #
379
+ # ONE 404 IS ENOUGH. A self-hosted endpoint answers 404 forever, so the
380
+ # first one latches this off for the life of the planner rather than paying
381
+ # a round trip per solve to be told the same thing again.
382
+ _OUTCOME_PATH = "/solve-outcome"
383
+ _OUTCOME_TIMEOUT_S = 3.0
384
+
385
+ def report_outcome(self, session_id: Optional[str], solved: bool) -> bool:
386
+ """Tell the API whether the widget accepted. True if it was delivered.
387
+
388
+ Never raises. The return value is for tests and for `--debug`; no caller
389
+ should branch on it.
390
+ """
391
+ if not session_id or not self._outcome_supported:
392
+ return False
393
+ if os.getenv(_REPORT_OUTCOME_ENV, "1") == "0":
394
+ return False
395
+ url = f"{self.base_url}{self._OUTCOME_PATH}"
396
+ try:
397
+ resp = self._http.post(
398
+ url,
399
+ headers={
400
+ "Authorization": f"Bearer {self.api_key}",
401
+ "Content-Type": "application/json",
402
+ },
403
+ json={"session": session_id, "solved": bool(solved)},
404
+ timeout=self._OUTCOME_TIMEOUT_S,
405
+ )
406
+ except Exception as exc: # noqa: BLE001 — see the note above
407
+ self._log(f"outcome report failed: {exc}")
408
+ return False
409
+ if resp.status_code == 404:
410
+ # Not an error: this endpoint does not serve the route. Stop asking.
411
+ self._outcome_supported = False
412
+ self._log("outcome reporting: endpoint has no /solve-outcome; disabled")
413
+ return False
414
+ ok = 200 <= resp.status_code < 300
415
+ self._log(f"outcome report {session_id} solved={solved} -> {resp.status_code}")
416
+ return ok
315
417
 
316
418
  def _log(self, message: str) -> None:
317
419
  if DEBUG:
@@ -319,11 +421,14 @@ class ActionPlanner:
319
421
  if self.debug_callback:
320
422
  self.debug_callback(f"[Planner] {message}")
321
423
 
322
- def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
323
- return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens)
424
+ def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512,
425
+ family: Optional[str] = None) -> str:
426
+ return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens,
427
+ family=family)
324
428
 
325
429
  def _chat_with_images(
326
- self, prompt: str, image_paths: List[str], max_tokens: int = 512
430
+ self, prompt: str, image_paths: List[str], max_tokens: int = 512,
431
+ family: Optional[str] = None,
327
432
  ) -> str:
328
433
  """One request carrying N images followed by the prompt.
329
434
 
@@ -355,8 +460,9 @@ class ActionPlanner:
355
460
  },
356
461
  ]
357
462
 
463
+ model = self._model_for(family)
358
464
  payload = {
359
- "model": self.model,
465
+ "model": model,
360
466
  "messages": messages,
361
467
  "temperature": 0,
362
468
  "max_tokens": max_tokens,
@@ -387,11 +493,11 @@ class ActionPlanner:
387
493
  self._server_ensured = True
388
494
 
389
495
  url = f"{self.base_url}/chat/completions"
390
- self._log(f"POST {url} model={self.model} max_tokens={max_tokens} "
496
+ self._log(f"POST {url} model={model} max_tokens={max_tokens} "
391
497
  f"images={len(parts)}")
392
498
 
393
499
  with timed("planner.chat"):
394
- resp = requests.post(url, headers=headers, json=payload, timeout=120)
500
+ resp = self._http.post(url, headers=headers, json=payload, timeout=120)
395
501
 
396
502
  # Surface auth / billing / server errors as something the reader can act
397
503
  # on, instead of letting resp.json() blow up with a cryptic "Expecting
@@ -531,7 +637,8 @@ class ActionPlanner:
531
637
  "Return the complete list of cell numbers that match the description, "
532
638
  "including any matches you may have overlooked."
533
639
  )
534
- raw = self._chat_with_image(prompt, image_path, max_tokens=128)
640
+ raw = self._chat_with_image(prompt, image_path, max_tokens=128,
641
+ family="grid")
535
642
  out = self._normalize_grid(self._parse_json(raw), total)
536
643
  self._log(f"grid selection -> {out}")
537
644
  return out
@@ -592,7 +699,8 @@ class ActionPlanner:
592
699
  into ClickAction / DragAction / TypeAction.
593
700
  """
594
701
  prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
595
- raw = self._chat_with_image(prompt, image_path, max_tokens=512)
702
+ raw = self._chat_with_image(prompt, image_path, max_tokens=512,
703
+ family="text" if text_mode else "pixel")
596
704
  data = self._parse_json(raw)
597
705
  actions = self._normalize_pixel(data)
598
706
  self._log(f"pixel actions -> {actions}")
@@ -618,7 +726,8 @@ class ActionPlanner:
618
726
  # so this raises a clear error there instead of sending a v1 model a
619
727
  # keyframe request it was never trained to read.
620
728
  prompt = self.prompts.video_prompt(len(keyframe_paths))
621
- raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512)
729
+ raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512,
730
+ family="video")
622
731
  data = self._parse_json(raw)
623
732
  actions = self._normalize_pixel(data)
624
733
  frame = self._normalize_frame(data, len(keyframe_paths))
@@ -247,6 +247,80 @@ def canonical_model_id(model: Optional[str]) -> Optional[str]:
247
247
  return alias if isinstance(alias, str) else None
248
248
 
249
249
 
250
+ # ── availability: can these weights be obtained at all? ─────────────────────
251
+ #
252
+ # Same registry, same reason as prompt_version and pixel_budget: it is a fact
253
+ # about the MODEL, and the one place that already decides what an unpinned
254
+ # client downloads. See models.json's header comment.
255
+
256
+ PUBLIC = "public"
257
+ PRIVATE = "private"
258
+ LICENSED = "licensed"
259
+ #: Anything outside this set is a typo, and a typo must not read as "public".
260
+ #: The whole class of bug this file exists to prevent is a field that fails
261
+ #: open — `check_prompt_parity.py` rejects an unknown value at release time,
262
+ #: and `is_licensed` below treats one as licensed rather than guessing.
263
+ #:
264
+ #: Spelled out as literals rather than `(PUBLIC, PRIVATE, LICENSED)`: the
265
+ #: release gate reads this file by AST so it can run in a training venv with no
266
+ #: client installed, and `ast.literal_eval` cannot follow a name. A tuple of
267
+ #: names reads as "unset" to the gate, which is the check silently not running.
268
+ #: `tests/test_prompt_registry.py` pins that all four constants agree.
269
+ AVAILABILITIES = ("public", "private", "licensed")
270
+
271
+
272
+ def availability(model: Optional[str]) -> str:
273
+ """`"public"`, `"private"` or `"licensed"` for `model`. Absent means public.
274
+
275
+ The three answer ONE question — can these weights be obtained, and by whom:
276
+
277
+ public on the Hub, by anyone.
278
+ private on the Hub in a PRIVATE repo. The bytes exist and `fetch`
279
+ pulls them, but only for a token this account has authorised.
280
+ Everyone else gets 401/404 from the Hub, which is the right
281
+ answer and not something to pre-empt with a refusal.
282
+ licensed no Hub repo at all. Hosted API or a licence we issue.
283
+
284
+ `private` is the newest and it is NOT a softer `licensed`. They differ in
285
+ what an authorised holder can do, which is the whole reason the field is
286
+ not a boolean: a licensed model has nothing to download however good your
287
+ credentials are, so refusing early is a KINDNESS — it replaces a
288
+ `RepositoryNotFoundError` that reads as "you are not logged in" with the
289
+ truth. Doing the same to a private model would be a lie, and would make our
290
+ own admin token unable to pull weights it is entitled to.
291
+
292
+ An unregistered model is public too: it is not ours, and refusing to
293
+ download a stranger's adapter because we have never heard of it would break
294
+ every self-hoster who trained their own.
295
+ """
296
+ entry = registered_models().get(canonical_model_id(model) or "") or {}
297
+ value = entry.get("availability")
298
+ return value if isinstance(value, str) and value else PUBLIC
299
+
300
+
301
+ def is_licensed(model: Optional[str]) -> bool:
302
+ """True if these weights are NOT OBTAINABLE AT ALL — hosted API or licence.
303
+
304
+ Fails CLOSED on an unrecognised value. A registry that says `"licenced"`
305
+ must not hand out a proprietary model because of a spelling.
306
+
307
+ False for `private`: those weights are downloadable, by the holder of a
308
+ token we authorised. Proprietary and unobtainable are different facts, and
309
+ this function answers the second one — `requires_auth` answers the first.
310
+ """
311
+ return availability(model) not in (PUBLIC, PRIVATE)
312
+
313
+
314
+ def requires_auth(model: Optional[str]) -> bool:
315
+ """True if pulling these weights needs a token we authorised.
316
+
317
+ Separate from `is_licensed` because the two diverge exactly where it
318
+ matters: a `private` model needs a token AND can be fetched with one, a
319
+ `licensed` model needs no token because there is nothing to fetch.
320
+ """
321
+ return availability(model) == PRIVATE
322
+
323
+
250
324
  # ── prompt sets ─────────────────────────────────────────────────────────────
251
325
 
252
326
  @dataclass
@@ -479,6 +553,114 @@ def pixel_budget(model: Optional[str]) -> PixelBudget:
479
553
  return DEFAULT_PIXEL_BUDGET
480
554
 
481
555
 
556
+ # ─── expert routing: WHICH ADAPTER answers this prompt family ───────────────
557
+ #
558
+ # Same registry, same reason again, one more axis. A routed model is not one
559
+ # adapter: `Abyss` is four LoRAs behind one endpoint, and the thing that picks
560
+ # between them is the prompt family the request is about to send — grid, pixel,
561
+ # video, text. See docs/MOE_LORA_DESIGN.md §11 ("the router is the prompt
562
+ # family, and it already exists").
563
+ #
564
+ # It lives HERE rather than in the solver for the reason prompt_version does:
565
+ # which experts exist is a fact about the MODEL. A client that hardcoded four
566
+ # names would send them to v1.2, which serves one adapter under one name, and
567
+ # every request would 404 on a model the endpoint has never heard of.
568
+ #
569
+ # ABSENT MEANS NOT ROUTED, and that is the whole backwards-compatibility story:
570
+ # every published model today declares no `experts`, so `experts()` returns {},
571
+ # `route()` returns the name the caller already had, and the bytes on the wire
572
+ # are identical.
573
+
574
+ EXPERT_ENV = "CAPTCHA_EXPERT"
575
+
576
+ #: The prompt families, which are also the only keys an `experts` map may
577
+ #: carry. `pixel` is the click/drag family — `PromptSet.action_prompt` — and is
578
+ #: spelled that way rather than `action_pixel` because it is what ckgate's
579
+ #: PROMPT_MARKERS already calls it, and two spellings of one router is how a
580
+ #: family ends up unrouted on one side.
581
+ PROMPT_FAMILIES = ("pixel", "grid", "video", "text")
582
+
583
+
584
+ def experts(model: Optional[str]) -> Dict[str, str]:
585
+ """Prompt family -> served adapter name, for a ROUTED model.
586
+
587
+ `{}` for anything that is not routed, which is every model published so
588
+ far. Callers apply it unconditionally; an empty map is a no-op.
589
+
590
+ Unknown family keys are DROPPED rather than passed through. A typo in the
591
+ registry would otherwise be a name nothing ever selects — invisible,
592
+ because the fallback below is a working solve at a slightly worse score,
593
+ which is the exact failure mode this whole module exists to prevent.
594
+ """
595
+ repo_id = canonical_model_id(model)
596
+ entry = (registered_models().get(repo_id) or {}) if repo_id else {}
597
+ mapping = entry.get("experts")
598
+ if not isinstance(mapping, dict):
599
+ return {}
600
+ out = {}
601
+ for family in PROMPT_FAMILIES:
602
+ name = mapping.get(family)
603
+ if isinstance(name, str) and name:
604
+ out[family] = name
605
+ for key in mapping:
606
+ if key not in PROMPT_FAMILIES and not str(key).startswith("_"):
607
+ _warn(f"model {repo_id!r} declares an expert for unknown prompt "
608
+ f"family {key!r}; have {', '.join(PROMPT_FAMILIES)}. It will "
609
+ "never be selected.")
610
+ return out
611
+
612
+
613
+ def route(model: Optional[str], family: Optional[str], *,
614
+ pin: Optional[str] = None) -> str:
615
+ """The `model` string to put on the wire for one request.
616
+
617
+ `model` is what the caller would have sent — a served name or a repo id —
618
+ and is returned unchanged whenever the model is not routed, which is what
619
+ keeps every existing deployment byte-identical.
620
+
621
+ `pin` forces one family for every request, whatever `family` says. That is
622
+ what a gate wants: serve one arm, grade the types it owns, serve the next.
623
+ An unknown `pin` RAISES, because a benchmark that silently measured the
624
+ generalist and reported it as the expert is a number nobody can catch.
625
+
626
+ An unrecognised or unmapped `family` degrades to `model` — the generalist —
627
+ never to another expert and never to an error. A caller prompting in their
628
+ own words is the expected case for a shipped model, and the day the
629
+ generation-2 `text` family reached ckgate without a marker, refusing it cost
630
+ every distorted-text solve in production for a day.
631
+ """
632
+ if pin is not None:
633
+ pin = pin.strip()
634
+ if pin:
635
+ if pin not in PROMPT_FAMILIES:
636
+ raise ValueError(
637
+ f"unknown expert {pin!r}; have {', '.join(PROMPT_FAMILIES)}")
638
+ family = pin
639
+ mapping = experts(model)
640
+ if not mapping:
641
+ if pin:
642
+ raise ValueError(
643
+ f"expert {pin!r} was requested but model {model!r} declares no "
644
+ "experts — it serves one adapter under one name. Drop the "
645
+ "expert, or point the client at a routed model.")
646
+ return model or ""
647
+ return mapping.get(family or "") or model or ""
648
+
649
+
650
+ def _env_str(name: str) -> Optional[str]:
651
+ """Like `_env_int`, and read through the same indirection for the same
652
+ second reason: `test_public_contract.py` finds environment variables by
653
+ grepping for `os.environ.get(<NAME>)`, so naming the CONSTANT there records
654
+ `EXPERT_ENV` in the published env list as though it were a variable anyone
655
+ could set. The literal is picked up from the assignment above instead."""
656
+ return (os.environ.get(name) or "").strip() or None
657
+
658
+
659
+ def expert_pin() -> Optional[str]:
660
+ """`CAPTCHA_EXPERT`, or None. Empty and unset are the same thing."""
661
+ return _env_str(EXPERT_ENV)
662
+
663
+
482
664
  def clear_cache() -> None:
483
665
  _cache.clear()
484
666
  global _REGISTRY
@@ -170,11 +170,16 @@ def ensure_server(base_url: "str | None" = None) -> None:
170
170
  """
171
171
  base_url = base_url or config.base_url()
172
172
 
173
- if is_healthy(base_url):
174
- return
173
+ # REMOTE FIRST. There is nothing to ensure about a server we do not manage,
174
+ # so asking it for /health before deciding that is a round trip spent to
175
+ # reach a `return` — and up to `timeout=2.0` of it against a hosted gateway
176
+ # that serves no /health at all. Paid once per ActionPlanner, which for a
177
+ # caller using `solve_captcha_on_page` is once per solve.
175
178
  if not is_local(base_url):
176
- # Remote server we don't manage: let the actual request raise a clear
177
- # connection error rather than silently trying to boot vLLM locally.
179
+ # Let the actual request raise a clear connection error rather than
180
+ # silently trying to boot vLLM locally.
181
+ return
182
+ if is_healthy(base_url):
178
183
  return
179
184
  if not config.autostart_enabled():
180
185
  return
@@ -24,7 +24,6 @@ v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
24
24
  lives on the `v1-old-architecture` branch.
25
25
  """
26
26
 
27
- import math
28
27
  import os
29
28
  import shutil
30
29
  import sys
@@ -112,6 +111,47 @@ class DebugManager:
112
111
  return None
113
112
 
114
113
 
114
+ #: The only cell counts a registered grid puzzle has. `find_grid` is a lattice
115
+ #: detector, not a catalogue — it allows rectangles and any dimension from
116
+ #: MIN_GRID_DIM up — so it will happily report 12 or 20 cells on a click puzzle's
117
+ #: header/footer bands. Anything not in here is a false positive by construction,
118
+ #: and it is also a board the model has never seen: the training pipeline's own
119
+ #: `grid_overlay.detect_cells` refuses every count but these two.
120
+ GRID_CELL_SHAPES = {9: (3, 3), 16: (4, 4)}
121
+
122
+ #: Cell counts each vendor actually ships, for the vendors the driver can name.
123
+ #: Only reCAPTCHA has a 4x4; hCaptcha's single grid puzzle is 3x3, so 16 cells on
124
+ #: an hCaptcha board is a contradiction rather than a close call. Measured on real
125
+ #: captures, that one line removes four of the five non-grid boards that clear
126
+ #: both find_grid and _is_real_grid today.
127
+ #:
128
+ #: A vendor NOT in this table — `unknown`, or anything new — gets every shape.
129
+ #: `unknown` is what the driver reports for GeeTest and Prosopo, both of which
130
+ #: ship real 3x3 grids, and it is what the one-shot image API defaults to, which
131
+ #: is the path the offline grader uses to score reCAPTCHA 4x4. Narrowing it would
132
+ #: stop scoring a whole puzzle type while looking like a tightening.
133
+ VENDOR_GRID_CELLS = {
134
+ "hcaptcha": frozenset({9}),
135
+ "recaptcha": frozenset({9, 16}),
136
+ }
137
+
138
+
139
+ def _grid_dims(n_cells, puzzle_source="unknown"):
140
+ """(rows, cols) if this detection could be a grid we ship, else None.
141
+
142
+ None means "do not solve this as a grid" — the caller falls through to the
143
+ click/drag path, which is what the board actually is. It is deliberately not
144
+ an exception: a false positive here is an ordinary event, not an error.
145
+ """
146
+ shape = GRID_CELL_SHAPES.get(n_cells)
147
+ if shape is None:
148
+ return None
149
+ allowed = VENDOR_GRID_CELLS.get(puzzle_source)
150
+ if allowed is not None and n_cells not in allowed:
151
+ return None
152
+ return shape
153
+
154
+
115
155
  class CaptchaSolver:
116
156
  """v2 solver: OpenCV grid detection + vLLM `captcha` LoRA."""
117
157
 
@@ -120,13 +160,15 @@ class CaptchaSolver:
120
160
  model: Optional[str] = None,
121
161
  provider: str = "captchaKrakenApi",
122
162
  api_key: Optional[str] = None,
163
+ expert: Optional[str] = None,
123
164
  ):
124
165
  self.debug = DebugManager(DEBUG)
125
166
  # `provider` kept for argv compatibility with the v1 CLI signature.
126
167
  if provider not in {"captchaKrakenApi"}:
127
168
  self.debug.log(f"Provider {provider!r} ignored; v2 only supports captchaKrakenApi.")
128
169
  self.planner = ActionPlanner(
129
- model=model, api_key=api_key, debug_callback=self.debug.log
170
+ model=model, api_key=api_key, debug_callback=self.debug.log,
171
+ expert=expert,
130
172
  )
131
173
  self.image_processor = ImageProcessor(None, self.planner, self.debug)
132
174
  self._image_size: Optional[Tuple[int, int]] = None
@@ -183,9 +225,22 @@ class CaptchaSolver:
183
225
 
184
226
  with timed("solver.find_grid"):
185
227
  grid_boxes = find_grid(cv_image_path)
186
- if grid_boxes and self._is_real_grid(cv_image_path, grid_boxes):
228
+ # The SHAPE gate runs before the content gate: a lattice no vendor ships
229
+ # is not a grid whatever its cells look like, and saying so here keeps
230
+ # `_solve_grid` from ever being handed dimensions it would have to invent.
231
+ dims = _grid_dims(len(grid_boxes), puzzle_source) if grid_boxes else None
232
+ if grid_boxes and dims and self._is_real_grid(cv_image_path, grid_boxes):
187
233
  self.debug.log(f"Detected grid with {len(grid_boxes)} cells")
188
- return self._solve_grid(cv_image_path, grid_boxes, retry_mode=retry_mode)
234
+ return self._solve_grid(cv_image_path, grid_boxes, dims[0], dims[1],
235
+ retry_mode=retry_mode)
236
+ elif grid_boxes and not dims:
237
+ # e.g. a 16-cell lattice on an hCaptcha board (it ships no 4x4), or a
238
+ # 12-cell one on anything. Fall through to the click path, which is
239
+ # what this puzzle is.
240
+ self.debug.log(
241
+ f"find_grid returned {len(grid_boxes)} cells, which is not a shape "
242
+ f"{puzzle_source} ships — not solving as a grid."
243
+ )
189
244
  elif grid_boxes:
190
245
  # find_grid latched onto e.g. an hCaptcha click-puzzle's
191
246
  # header/footer bands. Reject — only true grids are supported.
@@ -403,11 +458,6 @@ class CaptchaSolver:
403
458
  if not stds:
404
459
  return False
405
460
 
406
- n = len(grid_boxes)
407
- side = 3 if n == 9 else 4 if n == 16 else int(round(n ** 0.5))
408
- rows = [stds[i * side:(i + 1) * side] for i in range(side)]
409
- cols = [stds[i::side] for i in range(side)]
410
-
411
461
  self.debug.log(
412
462
  f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
413
463
  )
@@ -459,46 +509,35 @@ class CaptchaSolver:
459
509
  )
460
510
  return False
461
511
 
462
- FLAT = 20.0
463
- # Build a side×side flat-mask (1 = flat cell, 0 = rich cell).
464
- flat_mask = [[1 if stds[r * side + c] < FLAT else 0
465
- for c in range(side)] for r in range(side)]
466
- flat_count = sum(sum(row) for row in flat_mask)
467
-
468
- # Heuristic: in a *real* captcha grid, flat tiles (sky, asphalt)
469
- # always form a spatially contiguous blob (top-left sky stack,
470
- # bottom road row, etc.) because the underlying image is a single
471
- # photo split into tiles. In a click-puzzle false grid, the flat
472
- # cells are the **left + right margins** of the middle row,
473
- # separated by the rich photo cell — non-contiguous.
512
+ # THE FLAT-CELL CONTIGUITY HEURISTIC IS GONE, and it was the only
513
+ # thing rejecting real grids.
474
514
  #
475
- # Use 4-connected components on flat cells. If we get more than
476
- # one component with >=1 cell each, it's the click-puzzle pattern.
477
- def components(mask):
478
- seen = [[False] * side for _ in range(side)]
479
- comps = 0
480
- for r in range(side):
481
- for c in range(side):
482
- if mask[r][c] and not seen[r][c]:
483
- comps += 1
484
- stack = [(r, c)]
485
- while stack:
486
- y, x = stack.pop()
487
- if seen[y][x] or not mask[y][x]:
488
- continue
489
- seen[y][x] = True
490
- for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
491
- ny, nx = y + dy, x + dx
492
- if 0 <= ny < side and 0 <= nx < side:
493
- stack.append((ny, nx))
494
- return comps
495
-
496
- if flat_count >= 2 and components(flat_mask) >= 2:
497
- self.debug.log(
498
- f"_is_real_grid: flat cells form >=2 disjoint components "
499
- "→ reject (click-puzzle pattern)."
500
- )
501
- return False
515
+ # It assumed flat tiles in a genuine photo grid always form ONE
516
+ # contiguous blob (a top-left sky stack, a bottom road row), because
517
+ # the board is a single photo cut up; a click-puzzle false grid puts
518
+ # its flat cells in the left and right margins of the middle row,
519
+ # which is two components. True of the boards it was written for,
520
+ # and false of plenty of photographs — a subject centred against
521
+ # plain sky leaves two flat corners and nothing between them.
522
+ #
523
+ # MEASURED over every real capture we hold, restricted to lattices
524
+ # `_grid_dims` actually accepts (9 or 16 cells — a 25-cell gobang
525
+ # board never reaches this function as a grid decision):
526
+ #
527
+ # rule grid false neg board false pos
528
+ # spread + mean-stddev 2/1748 0.11% 0/269 0.00%
529
+ # ...plus contiguity 40/1748 2.29% 0/269 0.00%
530
+ #
531
+ # It cost 38 real grids and caught nothing the two checks above did
532
+ # not already catch — 19 of those were recaptcha_grid_4x4 alone,
533
+ # 5.5% of that family, every one of them sent to the click path to
534
+ # be asked for coordinates on a board answered with cell numbers.
535
+ # Nothing errors when that happens; the reply is simply graded
536
+ # against a gold it cannot match.
537
+ #
538
+ # The median-colour spread check above supersedes it and is a much
539
+ # cleaner separation (an 18x gap rather than a topological guess),
540
+ # which is why this is a deletion and not a retuning.
502
541
  return True
503
542
  except Exception as e:
504
543
  self.debug.log(f"_is_real_grid check errored ({e}); accepting grid.")
@@ -508,17 +547,18 @@ class CaptchaSolver:
508
547
  self,
509
548
  image_path: str,
510
549
  grid_boxes: List[Tuple[int, int, int, int]],
550
+ rows: int,
551
+ cols: int,
511
552
  retry_mode: Optional[str] = None,
512
553
  ) -> Union[ClickAction, DoneAction, WaitAction]:
513
- n = len(grid_boxes)
514
- if n == 9:
515
- rows, cols = 3, 3
516
- elif n == 16:
517
- rows, cols = 4, 4
518
- else:
519
- cols = int(math.sqrt(n))
520
- rows = math.ceil(n / cols)
521
- self.debug.log(f"grid {rows}x{cols} ({n} cells)")
554
+ # The shape is PASSED IN, already cleared by `_grid_dims`. It used to be
555
+ # derived here, and the derivation had an `else` branch that turned any
556
+ # unrecognised count into `cols = int(sqrt(n)); rows = ceil(n / cols)` —
557
+ # so a 12-cell false positive became a 4x3 board, got stamped with twelve
558
+ # numbers, and was answered with cell ids. No such board can exist in the
559
+ # training corpus, because the generator's own detector refuses every
560
+ # count but 9 and 16.
561
+ self.debug.log(f"grid {rows}x{cols} ({len(grid_boxes)} cells)")
522
562
 
523
563
  cv_selected: List[int] = []
524
564
  cv_loading: List[int] = []
@@ -56,9 +56,14 @@ class PhaseBudget:
56
56
  monitor spent 31s of it" is. Always on — it is a few dict updates per phase
57
57
  against multi-second waits — but only PRINTED under CAPTCHA_TIMINGS=1.
58
58
 
59
- Phases may nest (a burst contains its screenshots); only the outermost of a
60
- given name accumulates, so the totals stay a partition of the solve rather
61
- than double-counting.
59
+ Phases may nest (a burst contains its screenshots). Only the outermost of a
60
+ GIVEN NAME accumulates, so re-entering one cannot double-count it — but a
61
+ phase nested inside a differently-named one counts under both, deliberately:
62
+ the cursor drifting over the widget WHILE the model generates is genuinely
63
+ both `mouse` and `inference`, and hiding either would misreport what the
64
+ solve was doing. The totals are therefore an attribution, not a partition,
65
+ and can exceed the elapsed time. `report()` prints the residual as
66
+ `(unattributed)` and it can go negative when they do.
62
67
  """
63
68
 
64
69
  def __init__(self) -> None: