# Translation Batching — Mechanism & End-to-End Flow (SPEC)

**Status:** Authoritative. Agents working on translation batching MUST conform to this
document, not to ad-hoc prompt memory. If code and spec disagree, flag it — do not
silently pick one.

**Scope:** the full path a translatable string travels:
WordPress plugin (`plugins/international-press-zone`) → backend HTTP API
(`press-zone-backend/api`) → Bull queue → worker → Gemini → webhook back to WordPress.

**Owner directive this design implements:** every batch boundary is *derived* from a
verified hard limit with a named safety factor. No bare numeric literals at call
sites — a constant with a source comment, env-overridable.

---

## 1. Verified hard limits (facts — cite, never re-derive)

Gemini Flash (`gemini-3-flash-preview`, ai.google.dev model docs, checked 2026-08-22):

| Limit | Value | Notes |
|---|---|---|
| Max input tokens | 1,048,576 | never binding in practice |
| **Max output tokens** | **65,536** | **the binding constraint** — the translated JSON must fit here |
| Inline request payload | 20 MB | documented conservative bound |

Translation output ≈ input size + JSON envelope, and Hebrew/Arabic/CJK can approach
~1 output token per character. Output, not input, is what overflows.

## 2. Derived constants (single source of truth)

All live in `press-zone-backend/api/src/config/batchLimits.ts`. Every one is
env-overridable via the same-named env var. Zod schemas and the `/health`
capabilities block import these constants — they cannot drift from each other.

| Constant | Default | Derivation |
|---|---|---|
| `GEMINI_MAX_OUTPUT_TOKENS` | 65536 | hard cap, model docs |
| `CHARS_PER_OUTPUT_TOKEN` | 1 | worst-case he/ar/CJK planning ratio |
| `OUTPUT_SAFETY_FACTOR` | 0.5 | safety window on the output cap |
| `GEMINI_CALL_CHAR_BUDGET` | 32768 | `65536 × 1 × 0.5` — max source chars per Gemini call |
| `GEMINI_CALL_MAX_STRINGS` | 100 | JSON-parse reliability cap per call |
| `GEMINI_CALL_MAX_PAYLOAD_BYTES` | 10485760 | 20 MB inline cap × 0.5 |
| `BULK_SYNC_MAX_STRINGS` | 2000 | sync bulk schema bound |
| `BULK_ASYNC_MAX_STRINGS` | 10000 | async bulk-strings schema bound |
| `BULK_MAX_CHARS_PER_STRING` | 5000 | per-string schema bound |
| `BULK_MAX_TARGET_LANGS` | 20 | per-job target-language bound |
| `BULK_REQUEST_CHAR_BUDGET` | 327680 | `10 × GEMINI_CALL_CHAR_BUDGET` per HTTP request |
| `MAX_TRUNCATION_RETRIES` | 3 | truncation-retry rounds (§7) |

Worker job concurrency: `WORKER_JOB_CONCURRENCY` (default 8, documented derivation in
`worker.ts` — the practical bound is per-process memory/webhook fan-out, not Gemini
RPM; Bull's silent default of 1 was the historical "translates one by one" bug).

## 3. End-to-end flow

```
WP admin "Generate All"
  └─ StringTranslateController::generateAll{,Async}()          (plugin)
       chunks strings by BACKEND-ADVERTISED budgets (§5)
  └─ POST https://api.press.zone/v1/jobs/bulk-strings           (async path)
     POST /v1/translate/bulk                                    (sync path, small runs)
       zod-validated against BULK_* constants (§2)
  └─ Bull queue 'translation-jobs', job type 'bulk-strings'
  └─ worker.ts handler (concurrency WORKER_JOB_CONCURRENCY)
       planBatches(strings, {maxChars: GEMINI_CALL_CHAR_BUDGET,
                             maxCount: GEMINI_CALL_MAX_STRINGS,
                             maxPayloadBytes: GEMINI_CALL_MAX_PAYLOAD_BYTES})
  └─ geminiClient bulk call per batch (responseMimeType: application/json)
       incomplete responses → truncation retry loop (§7)
  └─ per-string results + per-string failures → job result
  └─ webhook callback to WordPress; plugin stores translations
```

## 4. Batch planner (backend → Gemini)

`press-zone-backend/api/src/utils/batchPlanner.ts` — `planBatches(strings, opts)`.

Rules (all covered by unit tests in `__tests__/unit/batchPlanner.test.ts`):
- **Greedy, in-order**: strings are packed first-to-last; order is preserved.
- A batch closes when adding the next string would exceed `maxChars`, `maxCount`,
  or `maxPayloadBytes` (payload measured as actual JSON array byte length).
- A single string that alone exceeds a limit becomes a **singleton batch** — never
  dropped, never split.
- **Lossless**: union of batches == input, exactly once each.
- Used by BOTH the worker bulk-strings handler and the sync bulk path. The old fixed
  `BULK_BATCH_SIZE = 50` count-slicing is gone and must not return.

## 5. Capabilities contract (backend advertises its limits)

`GET /health` exposes:

```json
"capabilities": {
  "bulkStringTranslation": {
    "syncMaxStrings":     <BULK_SYNC_MAX_STRINGS>,
    "asyncMaxStrings":    <BULK_ASYNC_MAX_STRINGS>,
    "maxCharsPerString":  <BULK_MAX_CHARS_PER_STRING>,
    "requestCharBudget":  <BULK_REQUEST_CHAR_BUDGET>,
    "maxTargetLangs":     <BULK_MAX_TARGET_LANGS>,
    "version": 1
  }
}
```

Values are imported from `batchLimits.ts` — the same constants the zod schemas
enforce. Changing a limit means changing the constant, nothing else.

## 6. Plugin chunking (WordPress → backend)

`StringTranslateController` (route base `international-press-zone/v1/system-translate`,
bulk entry `/generate-all`):

- Fetches the capabilities block from `/health`, caches it in the
  `ipz_bulk_limits` transient for 1 hour. Cached values are validated (all five
  advertised fields present, numeric, positive) before use; invalid cache → refetch.
- Chunks the string payload greedily by **character budget** (`requestCharBudget`)
  AND **string count** (`syncMaxStrings`) — the same greedy/singleton/lossless rules
  as §4, implemented in PHP.
- Fallback when `/health` is unreachable or lacks the capability: named class
  constants `FALLBACK_SYNC_MAX_STRINGS = 2000` and
  `FALLBACK_REQUEST_CHAR_BUDGET = 327680` — matching the backend defaults, so a
  fallback never splits requests the backend would have accepted. No bare literals.
- Small runs that fit one chunk behave exactly as before (single request).

## 7. Truncation safety (retry incomplete Gemini responses)

Even budget-packed batches can hit the 65,536 output-token cap. In the
`geminiClient` bulk path, after parsing:

- A string is **incomplete** when: its id is absent from the response, its
  translation is empty, the candidate `finishReason` is `MAX_TOKENS`, or the JSON
  fails to parse on an otherwise-successful HTTP response.
- Retry up to `MAX_TRUNCATION_RETRIES = 3` rounds over **only the incomplete set**.
- A round with **no progress** splits the remaining set in half and retries each
  half (halving isolates an oversized string).
- Still incomplete after all rounds → explicit per-string failure entry
  (error "gemini output truncated") in the existing failure shape. Never silently
  dropped. Successes from earlier rounds are always kept.
- Response shape to callers is unchanged; callers already handle per-string failures.

## 8. Invariants — do NOT change without owner sign-off

- Job dispatch shapes and the plugin's 4-variant job-id extraction
  (`TranslateController.php`) stay as-is.
- Credit accounting, webhook delivery semantics, exception replacement: untouched.
- `responseMimeType: 'application/json'` constrained decoding stays.
- No Gemini-call parallelism *inside* a single job — concurrency lives at the queue
  level (`WORKER_JOB_CONCURRENCY`).
- No string may ever be dropped, reordered, or silently truncated at any hop.

## 9. Verification bar (what "done" means for changes here)

- Backend: `cd press-zone-backend/api && npm run build && npm test -- --runInBand`
  fully green (heavy commands via the local-gate wrapper).
- Plugin PHP: pre-commit PHPCS/PHPStan gate passes; `php -l` clean;
  no `array_chunk` by fixed count in bulk senders.
- Any new limit: added to `batchLimits.ts` with a source comment + env override,
  surfaced in `/health` capabilities if the plugin must know it, and covered by a
  unit test.
