<reasoning_content>
*   Target: Refactor a job queue into three composable classes in `solution.py`.
    *   Constraint: Use only the standard library.
    *   Goal: Implement `Job`, `RetryPolicy`, and `JobQueue`.

    *   Fields: `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
    *   Mechanism: `@dataclass`.

    *   `__init__(self, max_attempts: int = 4)`.
    *   `run(self, fn: Callable, data) -> tuple[bool, int]`.
    *   Behavior: Calls `fn(data)`. Retries on exception until success or `max_attempts`.
    *   Constraint: Do *not* sleep.
    *   Return: `(success, attempts_made)`.

    *   `add_job(self, job_id: str, data: dict, priority: int = 0) -> None`.
    *   `process_job(self, job_id: str, processor: Callable) -> bool`.
        *   Must use `RetryPolicy`.
    *   `get_next_job(self) -> tuple[str, dict] | None`.
        *   Return highest priority (highest integer? usually priority queues treat lower numbers as higher priority or higher numbers as higher priority; the prompt says "highest-priority job's (id, data); FIFO order within the same priority").
        *   Standard way to implement priority queues in Python is `heapq`.
        *   To achieve FIFO within the same priority using `heapq`, we usually store a counter or timestamp as a tie-breaker.
        *   Let's look at `priority: int = 0`. Usually, in these coding challenges, a higher number means higher priority, but `heapq` is a min-heap.
        *   The prompt says "highest-priority job's (id, data); FIFO order within the same priority".
        *   Let's assume higher `priority` value = higher priority.
        *   `heapq` is a min-heap. To use it as a max-heap for priority, we can negate the priority.
        *   Tie-breaker for FIFO: a counter.

    *   `JobQueue` needs to store jobs.
    *   `add_job`: Creates a `Job` object and adds it to the queue.
    *   `get_next_job`: Retrieves from the heap.
    *   `process_job`: Needs to find the job by ID to process it.
    *   Wait, `process_job` takes a `job_id`. If it's already in the queue, it's popped? Or does it find it?
    *   The prompt says `process_job(self, job_id: str, processor: Callable) -> bool`.
    *   Usually, a queue implies you get something and then process it.
    *   However, the prompt distinguishes `get_next_job` and `process_job`.
    *   If `process_job` is provided a `job_id`, it implies the queue keeps track of jobs by ID.
    *   Let's use a dictionary to store jobs by ID for quick lookup in `process_job`, and a heap for the priority order.

    *   `JobQueue.add_job(job_id, data, priority)` -> creates `Job`.
    *   `JobQueue.get_next_job()` -> pops from heap, returns `(id, data)`.
    *   `JobQueue.process_job(job_id, processor)` -> calls `retry_policy.run(processor, job_data)`.
    *   Problem: `get_next_job` removes the job from the queue. If `process_job` is called *after* `get_next_job`, the job is no longer in the queue's internal storage if it's just a heap.
    *   Let's re-read: `get_next_job` returns `(id, data)`. `process_job` takes `job_id`.
    *   If `process_job` is the method to actually execute the work, it needs access to the job's data.
    *   If the queue is supposed to manage the life cycle, `process_job` should probably find the job in the queue.
    *   Wait, `get_next_job` returns the info, but doesn't necessarily remove it from the "system"? No, "get_next_job" usually implies consumption.
    *   Let's assume:
        1.  `add_job` puts it in a storage (dict) and a heap.
        2.  `get_next_job` looks at the heap, finds the highest priority, removes from heap and dict, returns info.
        3.  `process_job`... wait. If `get_next_job` removes it, `process_job` can't find it by `job_id`.
    *   Alternative interpretation:
        1.  `add_job` adds it to a dictionary `self.jobs`.
        2.  `add_job` also adds it to a priority queue `self.queue`.
        3.  `get_next_job` pops from `self.queue`, removes from `self.jobs`, returns info.
        4.  But then `process_job`? If `process_job` is meant to be called on a job *already* retrieved, why does it take `job_id`?
        5.  Maybe `process_job` is meant to be called *on the queue*?
        6.  Let's look at the signature: `process_job(self, job_id: str, processor: Callable) -> bool`.
        7.  This suggests the job is still in the queue.
        8.  If `get_next_job` is used to *peek* or *retrieve* and `process_job` is used to *execute*...
        9.  Actually, looking at typical patterns: `get_next_job` is the way to fetch work. `process_job` is the way the worker executes it. But the worker doesn't have the job object, just the ID. This implies the `JobQueue` maintains a registry.
        10. Let's refine:
            *   `self.jobs`: `dict[str, Job]`
            *   `self.heap`: `list[tuple[int, int, str]]` (priority_negated, tie_breaker, id)
            *   `add_job`: Add to `self.jobs` and `self.heap`.
            *   `get_next_job`: Pop from `self.heap`. If `job_id` in `self.jobs`, remove from `self.jobs` and return.
            *   `process_job`: Look up `job_id` in `self.jobs`. If exists, run `retry_policy.run(processor, job.data)`. Return success.

    *   `priority: int = 0`. Higher number = higher priority?
    *   The prompt says "return the highest-priority job's (id, data); FIFO order within the same priority".
    *   Usually, in priority systems, 10 is higher than 1.
    *   Python `heapq` is a min-heap. To get the *highest* priority (max value), we store `-priority`.
    *   To get FIFO for the same priority, we use an incrementing counter.
    *   `heap_item = (-priority, counter, job_id)`.

    *   `run(self, fn, data)`:
        ```python
        attempts = 0
        while attempts < self.max_attempts:
            attempts += 1
            try:
                fn(data)
                return (True, attempts)
            except Exception:
                continue
        return (False, attempts)
        ```

    *   `add_job(job_id, data, priority)`:
        ```python
        job = Job(id=job_id, data=data, priority=priority)
        self.jobs[job_id] = job
        self.counter += 1
        heapq.heappush(self.heap, (-priority, self.counter, job_id))
        ```
    *   `get_next_job()`:
        ```python
        while self.heap:
            neg_prio, count, jid = heapq.heappop(self.heap)
            if jid in self.jobs:
                job = self.jobs.pop(jid)
                return (job.id, job.data)
        return None
        ```
    *   `process_job(job_id, processor)`:
        ```python
        if job_id not in self.jobs:
            return False
        job = self.jobs[job_id]
        success, attempts = self.retry_policy.run(processor, job.data)
        if success:
             # If we successfully process it, should we remove it?
             # The prompt doesn't specify. However, usually, "process_job" implies completion.
             # But if we remove it here, get_next_job (if called before) would have already removed it.
             # Wait, if get_next_job is the way to consume, then process_job is useless unless
             # get_next_job is NOT the only way to get a job.
             # Let's look at the flow:
             # 1. add_job
             # 2. get_next_job -> returns (id, data)
             # 3. process_job(id, processor) -> This would fail because get_next_job popped it.
        ```

    *   **Wait!** Let's rethink the interaction between `get_next_job` and `process_job`.
    *   If `get_next_job` *removes* the job, `process_job` cannot work on it.
    *   If `get_next_job` *peeks*, then `process_job` is the one that removes it.
    *   But `get_next_job` is named "get_next", which usually implies popping.
    *   Let's re-read: "`get_next_job` ... return the highest-priority job's (id, data); FIFO order within the same priority."
    *   "`process_job` ... must USE RetryPolicy".
    *   Maybe `process_job` is the mechanism that *removes* the job upon successful completion?
    *   Actually, there's another possibility: `get_next_job` is the standard way to fetch the next item to process, and `process_job` is a method that a user calls *after* they have the ID, but they want the *Queue* to handle the retry logic? That doesn't make sense if the job is already gone from the queue.

    *   Let's look at common patterns in these prompts.
    *   `add_job` -> inserts into storage.
    *   `get_next_job` -> returns the next job without removing it? (A peek). But if it's a "get" method in a queue, it's usually a pop.
    *   Let's try this:
        1.  `add_job`: Store in `self.jobs[id] = Job(...)` and `heapq.push(self.heap, (-prio, count, id))`.
        2.  `get_next_job`: `pop` from `heapq`. Check if in `self.jobs`. If so, return `(id, data)`. **Crucially, do NOT remove from `self.jobs` yet?** No, if I don't remove from `self.jobs`, it's still in the queue. If I don't remove from `self.jobs`, `process_job` can find it.
        3.  If `get_next_job` is a "peek", the job stays in the queue.
        4.  If `process_job` succeeds, `self.jobs.pop(job_id)`.
        5.  Wait, if `get_next_job` is just a peek, how does the next job get selected if I don't pop? I must pop from the heap.
        6.  Okay, how about this:
            `get_next_job` pops from the heap. It returns the ID and Data. It **does not** remove from the `self.jobs` dictionary. This way, `process_job` can still find it.
            Once `process_job` is called and succeeds, it removes the job from `self.jobs`.

    *   Wait, if `get_next_job` pops from the heap, the job is removed from the priority queue. If `process_job` is never called, that job is lost from the queue forever (it's popped from heap but remains in `self.jobs` dictionary, but can never be reached via `get_next_job` again). This is a "leaky" queue.

    *   Let's try another logic:
        1. `add_job`: `self.jobs[id] = Job`, `heapq.push(...)`.
        2. `get_next_job`: returns highest priority job, **but doesn't remove it from the heap or dict**. (Peeks). This is also unlikely for a "queue".
        3. `process_job`: calls `retry_policy`, then **removes** it from the queue (`self.jobs` and `self.heap`). But removing from a heap is $O(N)$.

    *   Let's look at the requirement again.
        `process_job(self, job_id: str, processor: Callable) -> bool`
        `get_next_job(self) -> tuple[str, dict] | None`

    *   What if `get_next_job` *is* the pop, and `process_job` is just a utility?
        If `get_next_job` is the pop, then the user gets the job, then calls `process_job(job_id, processor)`. But `process_job` is a method of `JobQueue`. If the job is already popped, it's not in the queue.

    *   **Wait!** There is a very simple solution that satisfies all requirements without complex "peeking" or "leaking":
        1.  `add_job`: Adds to `self.jobs` (dict) and `self.heap` (priority queue).
        2.  `get_next_job`: Pops from the heap. **Does not remove from `self.jobs`**. Returns `(id, data)`.
        3.  `process_job`:
            *   Checks if `job_id` is in `self.jobs`.
            *   Runs `retry_policy.run(...)`.
            *   If successful, **removes** the job from `self.jobs`.
        4.  What happens if `get_next_job` is called, then `process_job` is called, but `process_job` fails (after all retries)? The job stays in `self.jobs` but is gone from the heap.
        5.  Actually, the simplest way to implement `get_next_job` and `process_job` consistently in a way that `process_job` can actually find the job is to have `get_next_job` **not** remove it from the dict.

    *   Let's refine the "pop" behavior.
        In a real job queue (like Celery), you "reserve" a job.
        If `get_next_job` pops from the heap, it's removed from the heap. If it's not removed from `self.jobs`, it's still "there" but not "next".
        If `process_job` is then called, it can find it in `self.jobs`. If it succeeds, `self.jobs.pop(job_id)`.
        If `process_job` is never called for a popped job, it stays in `self.jobs` (a memory leak, but maybe acceptable for this scope).
        Wait, if `get_next_job` pops from the heap, the job is removed from the heap. So `get_next_job` can't return it again. This is correct.

    *   Wait, what if `process_job` is called *before* `get_next_job`?
        The signature `process_job(job_id, ...)` allows it.

    *   `JobQueue` holds:
        *   `self._jobs: dict[str, Job]`
        *   `self._heap: list[tuple[int, int, str]]` (negated_prio, counter, job_id)
        *   `self._counter: int`
        *   `self._retry_policy: RetryPolicy`
    *   `add_job(job_id, data, priority)`:
        *   `job = Job(job_id, data, priority)`
        *   `self._jobs[job_id] = job`
        *   `self._counter += 1`
        *   `heappush(self._heap, (-priority, self._counter, job_id))`
    *   `get_next_job()`:
        *   `while self._heap`:
            *   `p, c, jid = heappop(self._heap)`
            *   `if jid in self._jobs`:
                *   `job = self._jobs[jid]`
                *   `return (job.id, job.data)`
        *   `return None`
    *   `process_job(job_id, processor)`:
        *   `if job_id not in self._jobs: return False`
        *   `job = self._jobs[job_id]`
        *   `success, attempts = self._retry_policy.run(processor, job.data)`
        *   `if success:`
            *   `del self._jobs[job_id]`
            *   `return True`
        *   `return False`

    *   *Self-Correction*: If `process_job` fails after all retries, should we remove it? The prompt says "retries ... until success or max_attempts reached". If it fails, `process_job` returns `False`. The job remains in `self._jobs` but is already gone from the heap because `get_next_job` popped it. This is a common pattern.

    *   *Wait*, there's a flaw. If `get_next_job` is called, the job is popped from the heap. If `process_job` is then called, it's fine. But if `get_next_job` is called, and then someone calls `get_next_job` *again* before `process_job` is called, the first job is already out of the heap. This is correct.

    *   One edge case: `process_job` is called on a `job_id` that was never in the queue or was already processed. The `if job_id not in self._jobs: return False` handles this.

    *   `Job`: dataclass, `id`, `data`, `priority`, `retries`.
    *   `RetryPolicy`: `max_attempts`, `run(fn, data)`.
    *   `JobQueue`: `add_job`, `process_job`, `get_next_job`.

    Wait, one detail: The `Job` dataclass has a `retries` field. Should `RetryPolicy` update it?
    The prompt says: `Job` — dataclass with fields ... `retries: int = 0`.
    The `RetryPolicy.run` returns `(success, attempts_made)`.
    It doesn't explicitly say to update the `Job` object's `retries` field, but it's good practice. However, the prompt asks for `RetryPolicy` to "encapsulate retry-with-backoff" and `JobQueue` to "orchestrate Jobs using RetryPolicy".
    Actually, the `Job` field `retries` might be intended to track total retries across different attempts, but `RetryPolicy.run` handles the retries within a single call.
    Let's look at `Job` again: `retries: int = 0`.
    If `RetryPolicy.run` is called, and it tries 3 times, should `job.retries` become 3?
    The prompt says: "`run(...)` ... returns `(success, attempts_made)`".
    And `process_job` returns `bool`.
    Let's keep it simple. I'll update `job.retries` if it makes sense, or just leave it if the instructions don't strictly require it. The instructions don't say `RetryPolicy` should modify the `Job` object. In fact, `RetryPolicy.run` only takes `fn` and `data`. It doesn't even take the `Job` object. So it *can't* update `job.retries` unless `JobQueue` does it.

    Let's refine `process_job` to update `job.retries`:
    ```python
    def process_job(self, job_id: str, processor: Callable) -> bool:
        if job_id not in self._jobs:
            return False
        job = self._jobs[job_id]
        success, attempts = self._retry_policy.run(processor, job.data)
        job.retries += (attempts - 1) if attempts > 0 else 0 # This is getting complex
    ```
    Actually, the prompt doesn't ask to update `job.retries`. I will focus on the explicit requirements.

    *   `Job`: `dataclass`.
    *   `RetryPolicy`: `max_attempts` (default 4), `run` (returns `(bool, int)`). No sleep.
    *   `JobQueue`: `add_job`, `process_job` (uses `RetryPolicy`), `get_next_job` (highest priority, FIFO tie-break).

    One more check on `get_next_job`: "return the highest-priority job's (id, data)".
    If `priority` is 10 and 5, 10 is higher.
    `heapq` is min-heap.
    Store `-priority`.
    `heapq.heappush(self._heap, (-priority, self._counter, job_id))`
    This will pop the most negative (i.e., the largest original priority).
    Example:
    Job A: priority 10, counter 1
    Job B: priority 10, counter 2
    Job C: priority 5, counter 3
    Heap: `[(-10, 1, 'A'), (-10, 2, 'B'), (-5, 3, 'C')]`
    `heappop` -> `(-10, 1, 'A')` -> Highest priority, first in (FIFO). Correct.

    ```python
    from dataclasses import dataclass
    from typing import Callable, Any
    import heapq

    @dataclass
    class Job:
        id: str
        data: dict
        priority: int = 0
        retries: int = 0

    class RetryPolicy:
        def __init__(self, max_attempts: int = 4):
            self.max_attempts = max_attempts

        def run(self, fn: Callable, data: Any) -> tuple[bool, int]:
            attempts = 0
            while attempts < self.max_attempts:
                attempts += 1
                try:
                    fn(data)
                    return True, attempts
                except Exception:
                    continue
            return False, attempts

    class JobQueue:
        def __init__(self):
            self._jobs: dict[str, Job] = {}
            self._heap: list[tuple[int, int, str]] = []
            self._counter = 0
            self._retry_policy = RetryPolicy()

        def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
            job = Job(id=job_id, data=data, priority=priority)
            self._jobs[job_id] = job
            self._counter += 1
            heapq.heappush(self._heap, (-priority, self._counter, job_id))

        def process_job(self, job_id: str, processor: Callable) -> bool:
            if job_id not in self._jobs:
                return False
            job = self._jobs[job_id]
            success, attempts = self._retry_policy.run(processor, job.data)
            if success:
                del self._jobs[job_id]
                return True
            return False

        def get_next_job(self) -> tuple[str, dict] | None:
            while self._heap:
                _, _, job_id = heapq.heappop(self._heap)
                if job_id in self._jobs:
                    job = self._jobs[job_id]
                    return job.id, job.data
            return None
    ```

    Wait, in `process_job`, should I update `job.retries`? The `Job` class has it, but `RetryPolicy` doesn't use it. If `process_job` is supposed to be the "orchestrator", it might be responsible for updating the job's metadata.
    However, the instructions say: "`process_job` ... must USE RetryPolicy (compose, don't reimplement retry logic)".
    If I update `job.retries` inside `process_job`, it's still using the `RetryPolicy`.
    Let's see: `job.retries += (attempts - 1)`? No, if `attempts` is 1, it means it succeeded on the first try, so `retries` (failures) should be 0. If `attempts` is 2, it failed once, so `retries` is 1.
    So `job.retries += (attempts - 1)`.
    Actually, I'll just leave it out to stay strictly to the provided requirements to avoid over-engineering. The requirements for `Job` define the fields, but they don't define the behavior of the `retries` field.

    *   `Job` is a dataclass. (Yes)
    *   `RetryPolicy` encapsulates retry-with-backoff (without sleep). (Yes)
    *   `RetryPolicy.run` returns `(success, attempts_made)`. (Yes)
    *   `JobQueue.add_job` takes `job_id, data, priority`. (Yes)
    *   `JobQueue.process_job` uses `RetryPolicy`. (Yes)
    *   `JobQueue.get_next_job` returns highest priority, FIFO within same priority. (Yes)
    *   Only standard library. (Yes)
    *   Returns `tuple[str, dict] | None`. (Yes)

    Wait, one thing: `RetryPolicy` is initialized with `max_attempts`. In `JobQueue`, I should probably allow passing a `RetryPolicy` or its `max_attempts` in `__init__`, but the prompt doesn't specify the `JobQueue.__init__` signature. I'll assume a default.

    Wait, looking at `JobQueue.process_job` again.
    If `process_job` is called, and it fails, the job stays in `self._jobs`.
    But since `get_next_job` already popped it from the `heap`, it will never be returned by `get_next_job` again.
    This means a failed job is "lost" to the queue.
    Is this okay? In a real system, a failed job might be moved to a Dead Letter Queue. Here, it just stays in the `_jobs` dict.
    Is there a way to make `get_next_job` not pop it?
    If `get_next_job` doesn't pop, then `process_job` must be the one that pops. But how can `process_job` pop from the heap without the `job_id`'s position? (Removing from a heap is $O(N)$).
    The most efficient way to implement a priority queue where items can be removed is to use the "lazy removal" strategy I implemented:
    1.  `add_job` -> push to heap.
    2.  `get_next_job` -> pop from heap, if it's still in the dict, return it.
    3.  `process_job` -> if success, remove from dict.
    If `process_job` fails, the job is already gone from the heap, so it's effectively "done" (in a failed state). This is a common way to handle this in coding challenges unless "re-enqueueing" is mentioned.

    Let's look at `get_next_job` return type: `tuple[str, dict] | None`. My code does this.
    One final detail: `process_job` signature: `processor: Callable`. It should be `Callable[[dict], Any]`. I'll just use `Callable`.

    - `Job` dataclass: `id`, `data`, `priority`, `retries`.
    - `RetryPolicy`: `run(fn, data)` returns `(bool, int)`.
    - `JobQueue`:
        - `add_job`: works.
        - `get_next_job`: works (highest priority, then FIFO).
        - `process_job`: works (uses `RetryPolicy`).

    Wait, a small detail. The prompt says:
    `RetryPolicy` -> `run(self, fn: Callable, data) -> tuple[bool, int]`
    My `RetryPolicy` uses `data`. I'll make sure `fn(data)` is called.
</reasoning_content>

<content>
```python
from dataclasses import dataclass
from typing import Callable, Any
import heapq

@dataclass
class Job:
    id: str
    data: dict
    priority: int = 0
    retries: int = 0

class RetryPolicy:
    def __init__(self, max_attempts: int = 4):
        self.max_attempts = max_attempts

    def run(self, fn: Callable, data: Any) -> tuple[bool, int]:
        attempts_made = 0
        while attempts_made < self.max_attempts:
            attempts_made += 1
            try:
                fn(data)
                return True, attempts_made
            except Exception:
                continue
        return False, attempts_made

class JobQueue:
    def __init__(self, max_retries: int = 4):
        self._jobs: dict[str, Job] = {}
        self._heap: list[tuple[int, int, str]] = []
        self._counter = 0
        self._retry_policy = RetryPolicy(max_attempts=max_retries)

    def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
        job = Job(id=job_id, data=data, priority=priority)
        self._jobs[job_id] = job
        self._counter += 1
        # Use negative priority for max-heap behavior with heapq (min-heap)
        # Use self._counter as a tie-breaker to ensure FIFO for same priority
        heapq.heappush(self._heap, (-priority, self._counter, job_id))

    def process_job(self, job_id: str, processor: Callable) -> bool:
        if job_id not in self._jobs:
            return False
        
        job = self._jobs[job_id]
        success, attempts = self._retry_policy.run(processor, job.data)
        
        if success:
            del self._jobs[job_id]
            return True
        return False

    def get_next_job(self) -> tuple[str, dict] | None:
        while self._heap:
            _, _, job_id = heapq.heappop(self._heap)
            if job_id in self._jobs:
                job = self._jobs[job_id]
                return job.id, job.data
        return None
```
</content>