<reasoning_content>
*   Task: Refactor a job queue into three composable classes in `solution.py`.
    *   Classes required:
        1.  `Job` (dataclass): `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
        2.  `RetryPolicy`: `__init__(max_attempts)`, `run(fn, data) -> (bool, int)`. Retry logic without actual sleep.
        3.  `JobQueue`: `add_job`, `process_job` (uses `RetryPolicy`), `get_next_job` (priority + FIFO).
    *   Constraints: Standard library only, no external packages, no explanations, just Python code.

    *   `from dataclasses import dataclass`
    *   `from typing import Callable, Tuple, Optional`
    *   `import heapq` (for priority queue)

    *   `@dataclass(order=False)` (Though `order=True` helps with sorting, but I need to manage priority manually to handle FIFO within same priority).
    *   Fields: `id`, `data`, `priority`, `retries`.

    *   `__init__(self, max_attempts: int = 4)`
    *   `run(self, fn: Callable, data) -> Tuple[bool, int]`
    *   Implementation: Loop from 1 to `max_attempts`. Try calling `fn(data)`. If it succeeds, return `(True, attempts)`. If it fails, catch exception and continue. If loop ends, return `(False, max_attempts)`.

    *   Internal storage: A heap or list to handle priority.
    *   `add_job`: Create `Job` instance. To handle FIFO within priority, the heap element should be `(-priority, sequence_number, job)`. (Using negative priority because `heapq` is a min-heap and we want highest priority first).
    *   `get_next_job`: Pop from heap, return `(id, data)`.
    *   `process_job`:
        *   Find the job in the queue (Wait, the requirement says `process_job(self, job_id: str, processor: Callable)`. This implies the job might already be "out" of the queue or we need to find it. But `get_next_job` is what retrieves it. Usually, in these patterns, `process_job` takes a job object or the queue manages the lifecycle).
        *   *Correction*: The prompt says `process_job(self, job_id: str, processor: Callable) -> bool`. This implies the job is already in the queue or we are looking up a specific ID. However, `get_next_job` returns the job.
        *   Let's re-read: `add_job` adds it. `get_next_job` retrieves it. `process_job` takes a `job_id`. This is slightly ambiguous. Does `process_job` take the job from the queue or does it process a job that was already retrieved via `get_next_job`?
        *   Actually, usually, a `process_job` method in a queue class might look for a job by ID. But `get_next_job` implies a consumer pattern.
        *   Let's assume `process_job` uses the `RetryPolicy` to run a function on a job's data. But where does it get the job? If it takes `job_id`, it needs to know which job it is.
        *   Wait, if `get_next_job` removes the job from the queue, `process_job` cannot find it by ID unless it's stored elsewhere.
        *   Let's look at standard patterns. Often `process_job` is called on the object returned by `get_next_job`. But the signature is `process_job(self, job_id: str, processor: Callable)`.
        *   Let's assume the queue maintains a mapping of `id -> Job` and a heap of `id`s (or tuples).
        *   Wait, if `get_next_job` returns `(id, data)`, it implies the job is removed.
        *   If `process_job` is to be used, perhaps the jobs are stored in a dictionary, and `get_next_job` pops from the priority structure.
        *   Actually, looking at the prompt: `get_next_job` returns `(id, data)`. If I call `process_job` after `get_next_job`, I have the ID.
        *   Let's refine `JobQueue`:
            *   `self.jobs: dict[str, Job]` to store active jobs.
            *   `self.queue: list` (heap) to store `(-priority, counter, job_id)`.
            *   `self.counter = 0` for FIFO.
            *   `add_job`: Add to `self.jobs` and `self.queue`.
            *   `get_next_job`: Pop from `self.queue`, get ID from it, get Job from `self.jobs`, remove from `self.jobs`, return `(id, data)`.
            *   `process_job`: This is tricky. If the job is already removed by `get_next_job`, `process_job` can't find it in `self.jobs`.
            *   Maybe `process_job` is called *instead* of `get_next_job`? No, they are separate.
            *   Alternative interpretation: `process_job` is for jobs *already* in the queue, but it's an unusual API.
            *   Let's check the requirements again: "`process_job(self, job_id: str, processor: Callable) -> bool` — must USE RetryPolicy".
            *   If `process_job` is given an ID, it needs to find that Job. If the job is in the queue, it should process it.
            *   If `get_next_job` is "get the highest priority", then `process_job` is likely intended to be a way to process a specific job by ID.
            *   However, usually, you get a job, then process it.
            *   Let's look at the standard "Job Queue" implementation requirements.
            *   If I use `self.jobs[job_id]` inside `process_job`, I need to keep the job in `self.jobs` until it's finished or failed.
            *   But `get_next_job` returns the job. If I call `get_next_job`, the job is gone from the queue's perspective.
            *   Maybe `get_next_job` doesn't remove it? "return the highest-priority job's (id, data)". Usually, `get` implies removal in queue terminology (like `pop`).
            *   Let's assume:
                1. `add_job` puts it in the heap and a dict.
                2. `get_next_job` pops from heap, gets the job from dict, removes from dict, returns tuple.
                3. `process_job` is actually meant to be: "Take a job that was just 'gotten', and process it". But the signature is `(self, job_id, processor)`. This implies the `JobQueue` *has* the job.
                4. If `process_job` is called, and the job was already removed by `get_next_job`, it's not there.
                5. *Hypothesis*: `get_next_job` returns the info, but the job remains in the dictionary until `process_job` is called? No, that's not how queues work.
                6. *Hypothesis 2*: `get_next_job` is the "peek/pop" and `process_job` is a method that handles the execution logic for a specific job.
                7. Let's try this: `add_job` puts job in `self._all_jobs` (dict) and `self._heap`. `get_next_job` pops from heap, finds in dict, and returns the data. It *removes* the job from the heap (via pop), but maybe the `process_job` method is what actually cleans up the dictionary?
                8. Wait, if `process_job` uses `RetryPolicy`, it returns a bool. If it succeeds, the job is done.
                9. Let's look at the requirements for `process_job` again. It takes `job_id`.
                10. If I call `get_next_job`, I get the ID. Then I call `process_job(id, processor)`.
                11. This implies the job stays in the queue's dictionary *until* `process_job` is called.
                12. But `get_next_job` is supposed to return the *highest priority* job. If I call `get_next_job` multiple times, I'll get the same job every time if it's not removed.
                13. Therefore, `get_next_job` *must* remove it from the priority heap.
                14. So:
                    - `add_job(id, data, priority)`: `self.jobs[id] = Job(...)`, `heapq.heappush(self.heap, (-priority, count, id))`
                    - `get_next_job()`: `priority, count, id = heapq.heappop(self.heap)`, `job = self.jobs[id]`, `return (id, job.data)`
                    - `process_job(id, processor)`: `job = self.jobs[id]`, `success, attempts = self.retry_policy.run(processor, job.data)`, `if success: del self.jobs[id]`, `return success`.
                    - Wait, if `get_next_job` removes from the heap, but `process_job` is how we finish it... what if it fails? The job is already removed from the heap. It's "in flight".
                    - If it fails, `process_job` returns `False`. The job is still in `self.jobs`? The prompt doesn't say to put it back in the queue.
                    - Let's look at the `Job` class: it has a `retries` field. This suggests the `Job` object tracks its own state.
                    - Okay, let's re-read: `process_job` "must USE RetryPolicy".
                    - If `process_job` returns `bool`, and we want to be able to retry the job *later* if it fails, we'd need to put it back in the queue. But the prompt doesn't explicitly ask for re-queuing failed jobs, just implementing the classes.

    *   `Job` (dataclass)
    *   `RetryPolicy` (logic: loop `max_attempts` times)
    *   `JobQueue`:
        - `self._jobs`: `dict[str, Job]`
        - `self._heap`: `list[tuple[int, int, str]]` (priority, counter, id)
        - `self._counter`: `int`
        - `self._retry_policy`: `RetryPolicy`
        - `add_job`: Create `Job`, add to `_jobs` and `_heap`.
        - `get_next_job`: Pop from `_heap`, get `id` from it, get `job` from `_jobs`, return `(id, job.data)`.
        - `process_job`: Get `job` from `_jobs` (if exists), run `retry_policy.run(processor, job.data)`, then `del self._jobs[id]` if success? Or just return status?
        - Let's look at `process_job` again. If `get_next_job` removes it from the heap, the job is no longer "in the queue" for `get_next_job` to find, but it is "in flight". This is a common pattern.

    *   *Wait*, one detail: `get_next_job` returns `(id, data)`. It does *not* return the Job object.
    *   `process_job` takes `job_id`. This means the `JobQueue` *must* keep the `Job` object somewhere indexed by `id`.
    *   If `get_next_job` removes it from the priority heap, it's no longer "next".
    *   If `process_job` is called, it needs to access the `data`.
    *   The most logical flow:
        1. `add_job(id, data, priority)` -> `Job` created, stored in `self._jobs[id]`, added to `self._heap`.
        2. `get_next_job()` -> Pops from `self._heap`, returns `(id, job.data)`.
        3. `process_job(id, processor)` -> `job = self._jobs.get(id)`, if not `None`, `success, _ = self._retry_policy.run(processor, job.data)`. If `success`, `del self._jobs[id]`. Return `success`.

    *   *Refinement on `get_next_job`*: If I pop from the heap, the job is removed from the "ready" queue. If `process_job` is called and fails, the job is still in `self._jobs` (the in-flight storage).

    *   `Job` dataclass: `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
    *   `RetryPolicy.run`:
        ```python
        def run(self, fn, data):
            for attempt in range(1, self.max_attempts + 1):
                try:
                    fn(data)
                    return True, attempt
                except Exception:
                    continue
            return False, self.max_attempts
        ```
    *   `JobQueue`:
        - `add_job`: `self._jobs[id] = Job(...)`, `heappush(self._heap, (-priority, self._counter, id))`.
        - `get_next_job`: `_, _, job_id = heappop(self._heap)`, `job = self._jobs[job_id]`, `return (job_id, job.data)`.
        - `process_job`: `job = self._jobs[job_id]`, `success, _ = self.retry_policy.run(processor, job.data)`, `if success: del self._jobs[job_id]`, `return success`.

    *Self-Correction on `get_next_job`*: If `get_next_job` is called and the job is already in `self._jobs`, but `process_job` is the one that removes it, `get_next_job` should remove it from the *heap* so it's not picked up again, but it should probably stay in `self._jobs` until `process_job` is finished.

    *Wait*, the prompt says: `get_next_job(self) -> tuple[str, dict] | None`. This is a standard queue `pop`.
    If `get_next_job` removes the job from the priority structure, `process_job` will use the `job_id` to find the data.

    *Wait*, how does `process_job` know which job to process if `get_next_job` already returned the ID? The user provides the `job_id` to `process_job`. This means the `JobQueue` must maintain a record of jobs that have been "gotten" but not yet "processed" (or failed).

    Let's check the `Job` fields again. `retries: int = 0`. The `RetryPolicy` doesn't update the `Job` object's `retries` field in the `run` method description, but the `Job` class has it. Usually, the `Job` object tracks how many times it has been attempted *across* different `run` calls, or `RetryPolicy` is the internal mechanism. The prompt says: "`RetryPolicy` ... encapsulates retry-with-backoff ... returns `(success, attempts_made)`". It doesn't say to update the `Job`.

    *   `Job`: `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.
    *   `RetryPolicy`: `max_attempts`. `run(fn, data)`.
    *   `JobQueue`:
        - `_jobs`: `dict[str, Job]`
        - `_heap`: `list` (for priority)
        - `_counter`: `int` (for FIFO)
        - `_retry_policy`: `RetryPolicy`
        - `add_job`: adds to `_jobs` and `_heap`.
        - `get_next_job`: pops from `_heap`, returns `(id, data)`. *Problem*: If I pop from heap, I must keep the job in `_jobs` so `process_job` can find it by ID.
        - `process_job`: uses `_retry_policy.run`. If success, `del _jobs[job_id]`.

    *Wait*, if `get_next_job` pops from the heap, and `process_job` is called, everything works. What if `process_job` is *not* called? The job stays in `_jobs` forever? That's fine for this scope. What if `process_job` fails? The job stays in `_jobs`.

    *Wait*, there's one edge case: `get_next_job` should return the highest priority. If multiple jobs have the same priority, FIFO order is required. `heapq` with `(priority, counter, id)` handles this perfectly.

    *Implementation detail*: `priority` is usually higher = more important. In `heapq` (min-heap), we use `-priority`.

    *   `Job`: `dataclass`? Yes.
    *   `RetryPolicy`: `run` returns `(bool, int)`? Yes. No sleep? Yes.
    *   `JobQueue`: `add_job`, `process_job` (uses policy), `get_next_job` (priority + FIFO)? Yes.
    *   Standard library only? Yes.

    Let's refine `get_next_job` to handle the empty case:
    ```python
    def get_next_job(self) -> tuple[str, dict] | None:
        if not self._heap:
            return None
        _, _, job_id = heapq.heappop(self._heap)
        job = self._jobs[job_id]
        return job.id, job.data
    ```

    And `process_job`:
    ```python
    def process_job(self, job_id: str, processor: Callable) -> bool:
        job = self._jobs.get(job_id)
        if not job:
            return False
        success, attempts = self._retry_policy.run(processor, job.data)
        job.retries += attempts # Should we? The prompt doesn't require it, but the field exists.
        if success:
            del self._jobs[job_id]
        return success
    ```
    Actually, I'll skip updating `job.retries` unless it's obvious, to stay strictly to the prompt. The prompt asks to define the fields, not necessarily update them, but `process_job` is the only place where retries happen. Let's look at `retries` again. If `RetryPolicy` handles retries *within* a single `run` call, `job.retries` might be intended to track how many times the `Job` has been processed via `process_job`. But I'll keep it simple.

    *Wait*, one detail: If `get_next_job` removes the job from the heap, but `process_job` fails, the job is essentially lost from the queue (it's not in the heap anymore, and it's not being re-added). That's a reasonable interpretation of the instructions provided.

    *Final check on `get_next_job`*: `return (id, data)`.
    `add_job` parameters: `job_id`, `data`, `priority`.
    `Job` fields: `id`, `data`, `priority`, `retries`.

    One small detail: `priority` in `Job` and `add_job`.
    `add_job(self, job_id: str, data: dict, priority: int = 0)`
    The `Job` dataclass also has `priority`.

    ```python
    from dataclasses import dataclass
    from typing import Callable, Tuple, Optional, Dict, List
    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: dict) -> Tuple[bool, int]:
            attempts_made = 0
            for attempt in range(1, self.max_attempts + 1):
                attempts_made = attempt
                try:
                    fn(data)
                    return True, attempts_made
                except Exception:
                    continue
            return False, attempts_made

    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
            # Use negative priority for max-heap behavior with heapq
            heapq.heappush(self._heap, (-priority, self._counter, job_id))
            self._counter += 1

        def process_job(self, job_id: str, processor: Callable) -> bool:
            job = self._jobs.get(job_id)
            if not job:
                return False
            
            success, attempts = self._retry_policy.run(processor, job.data)
            job.retries += attempts # Track total attempts made
            
            if success:
                del self._jobs[job_id]
            return success

        def get_next_job(self) -> Optional[Tuple[str, dict]]:
            if not self._heap:
                return None
            
            _, _, job_id = heapq.heappop(self._heap)
            job = self._jobs.get(job_id)
            if not job:
                return self.get_next_job() # Should not happen in normal flow
            
            return job.id, job.data
    ```

    Wait, `RetryPolicy` is initialized in `JobQueue`. The prompt says:
    `RetryPolicy` — `__init__(self, max_attempts: int = 4)`.
    `JobQueue` — `process_job` must USE `RetryPolicy`.
    It doesn't specify how `RetryPolicy` is passed to `JobQueue`. I'll initialize it in `__init__` of `JobQueue`.

    Wait, if `get_next_job` removes it from the heap, but `process_job` is called with a `job_id` that is *not* in `_jobs` (because it's already finished or was never there), it returns `False`. This is correct.

    One potential issue: `get_next_job` popping from heap. If I have a job in the heap, but then I call `process_job` on it *before* calling `get_next_job`, `get_next_job` will eventually pop an ID that is no longer in `self._jobs`.
    My `get_next_job` code:
    ```python
    _, _, job_id = heapq.heappop(self._heap)
    job = self._jobs.get(job_id)
    if not job:
        return self.get_next_job()
    ```
    This handles the case where a job was processed and removed from `_jobs` but still exists in the `_heap`.

    Wait, `self._retry_policy = RetryPolicy()` inside `JobQueue.__init__` is fine. The prompt doesn't specify the `JobQueue` constructor.

    Final check on return types:
    - `run` -> `tuple[bool, int]`
    - `get_next_job` -> `tuple[str, dict] | None`
    - `process_job` -> `bool`

    All match.

    ```python
    from dataclasses import dataclass
    from typing import Callable, Tuple, Optional, Dict, List
    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: dict) -> Tuple[bool, int]:
            for attempt in range(1, self.max_attempts + 1):
                try:
                    fn(data)
                    return True, attempt
                except Exception:
                    pass
            return False, self.max_attempts

    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
            heapq.heappush(self._heap, (-priority, self._counter, job_id))
            self._counter += 1

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

        def get_next_job(self) -> Optional[Tuple[str, dict]]:
            while self._heap:
                _, _, job_id = heapq.heappop(self._heap)
                job = self._jobs.get(job_id)
                if job:
                    return job.id, job.data
            return None
    ```
    The `while` loop in `get_next_job` is cleaner than recursion.

    One detail: `RetryPolicy.run` return value for failures. If it fails after 4 attempts, `attempts_made` is 4. My `range(1, 5)` loop:
    - attempt 1: fails
    - attempt 2: fails
    - attempt 3: fails
    - attempt 4: fails
    Loop ends. Return `False, 4`.
    Wait, the loop: `range(1, self.max_attempts + 1)` is `1, 2, 3, 4`.
    If `max_attempts` is 4:
    - `attempt` = 1, 2, 3, 4.
    If it fails on the 4th attempt, the loop finishes. The `attempts_made` should be 4.
    In my code:
    ```python
    for attempt in range(1, self.max_attempts + 1):
        try:
            fn(data)
            return True, attempt
        except Exception:
            pass
    return False, self.max_attempts
    ```
    If `max_attempts` is 4, and it fails all 4 times, it returns `False, 4`. This is correct.

    Wait, what if `max_attempts` is 0? The loop doesn't run. `range(1, 1)` is empty. Returns `False, 0`. Correct.

    One more thing: `JobQueue` constructor. The prompt doesn't specify it, so I'll just provide a basic one.
</reasoning_content>

<content>
```python
from dataclasses import dataclass
from typing import Callable, Tuple, Optional, Dict, List
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: dict) -> Tuple[bool, int]:
        for attempt in range(1, self.max_attempts + 1):
            try:
                fn(data)
                return True, attempt
            except Exception:
                continue
        return False, self.max_attempts

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
        # Use negative priority for max-heap behavior; use counter for FIFO
        heapq.heappush(self._heap, (-priority, self._counter, job_id))
        self._counter += 1

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

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