```python
#!/usr/bin/env python3
"""Branch and Bound Resource Scheduler

A safe, vendor-neutral Python 3 example that demonstrates how to use
branch and bound to solve a small resource scheduling problem exactly.

The script schedules jobs onto identical resources (machines/nodes) while
minimizing makespan, subject to:
- job durations
- optional job precedence constraints
- optional job-to-resource incompatibility constraints

This is intentionally compact and practical for learning, testing, and
adapting to your own scheduling model.

Usage examples:
  python branch_and_bound_scheduler.py --demo
  python branch_and_bound_scheduler.py --jobs jobs.json --resources 3

Input JSON format example:
{
  "jobs": [
    {"id": "A", "duration": 4},
    {"id": "B", "duration": 6},
    {"id": "C", "duration": 3}
  ],
  "precedence": [["A", "B"]],
  "incompatibilities": [["B", 1]]
}

Notes:
- This solver is exact but best suited to small or medium-sized instances.
- It is designed to be easy to audit and extend, not to maximize raw speed.
"""

from __future__ import annotations

import argparse
import json
import math
import sys
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple


@dataclass(frozen=True)
class Job:
    job_id: str
    duration: float


@dataclass
class Solution:
    makespan: float
    assignment: Dict[str, int]
    resource_loads: List[float]


def validate_jobs(jobs: Sequence[Job]) -> None:
    if not jobs:
        raise ValueError("At least one job is required.")
    seen = set()
    for job in jobs:
        if not isinstance(job.job_id, str) or not job.job_id.strip():
            raise ValueError("Each job must have a non-empty string id.")
        if job.job_id in seen:
            raise ValueError(f"Duplicate job id: {job.job_id}")
        seen.add(job.job_id)
        if not isinstance(job.duration, (int, float)) or job.duration <= 0:
            raise ValueError(f"Job duration must be positive for job {job.job_id}.")


def validate_resources(resource_count: int) -> None:
    if not isinstance(resource_count, int) or resource_count <= 0:
        raise ValueError("Resource count must be a positive integer.")


def parse_input_file(path: str) -> Tuple[List[Job], List[Tuple[str, str]], List[Tuple[str, int]]]:
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)

    jobs = [Job(job_id=str(item["id"]), duration=float(item["duration"])) for item in data["jobs"]]
    precedence = [(str(a), str(b)) for a, b in data.get("precedence", [])]
    incompatibilities = [(str(job_id), int(resource_idx)) for job_id, resource_idx in data.get("incompatibilities", [])]
    return jobs, precedence, incompatibilities


def build_job_index(jobs: Sequence[Job]) -> Dict[str, int]:
    return {job.job_id: i for i, job in enumerate(jobs)}


def check_precedence_satisfied(
    assigned: Dict[str, int],
    precedence: Sequence[Tuple[str, str]],
    current_job_id: str,
) -> bool:
    """Return True if all predecessors of current_job_id are already assigned."""
    predecessors = [a for a, b in precedence if b == current_job_id]
    return all(pred in assigned for pred in predecessors)


def violates_incompatibility(job_id: str, resource_idx: int, incompatibilities: Sequence[Tuple[str, int]]) -> bool:
    return (job_id, resource_idx) in set(incompatibilities)


def lower_bound(
    loads: Sequence[float],
    remaining_durations: Sequence[float],
) -> float:
    """Optimistic makespan lower bound.

    Combines:
    - current maximum load
    - average remaining workload evenly spread across resources
    """
    current_max = max(loads) if loads else 0.0
    total_remaining = sum(remaining_durations)
    if not loads:
        return total_remaining
    avg_bound = (sum(loads) + total_remaining) / len(loads)
    return max(current_max, avg_bound)


def branch_and_bound_schedule(
    jobs: Sequence[Job],
    resource_count: int,
    precedence: Optional[Sequence[Tuple[str, str]]] = None,
    incompatibilities: Optional[Sequence[Tuple[str, int]]] = None,
) -> Solution:
    precedence = precedence or []
    incompatibilities = incompatibilities or []

    validate_jobs(jobs)
    validate_resources(resource_count)

    job_index = build_job_index(jobs)

    # Validate precedence references.
    for pred, succ in precedence:
        if pred not in job_index or succ not in job_index:
            raise ValueError(f"Invalid precedence pair: ({pred}, {succ})")

    for job_id, resource_idx in incompatibilities:
        if job_id not in job_index:
            raise ValueError(f"Invalid incompatibility job id: {job_id}")
        if resource_idx < 0 or resource_idx >= resource_count:
            raise ValueError(f"Invalid resource index in incompatibilities: {resource_idx}")

    # Heuristic ordering: schedule longer jobs first to improve pruning.
    ordered_jobs = sorted(jobs, key=lambda j: j.duration, reverse=True)
    remaining_durations_map = {job.job_id: job.duration for job in ordered_jobs}

    best_solution: Optional[Solution] = None
    loads = [0.0 for _ in range(resource_count)]
    assignment: Dict[str, int] = {}

    def can_place(job_id: str, resource_idx: int) -> bool:
        return not violates_incompatibility(job_id, resource_idx, incompatibilities)

    def search(position: int) -> None:
        nonlocal best_solution

        if position == len(ordered_jobs):
            makespan = max(loads) if loads else 0.0
            candidate = Solution(makespan=makespan, assignment=dict(assignment), resource_loads=list(loads))
            if best_solution is None or candidate.makespan < best_solution.makespan:
                best_solution = candidate
            return

        job = ordered_jobs[position]

        if not check_precedence_satisfied(assignment, precedence, job.job_id):
            # Skip branching until prerequisites are assigned in this ordering.
            # In a more advanced solver, you would use topological ordering.
            return

        remaining = [remaining_durations_map[j.job_id] for j in ordered_jobs[position:]]
        bound = lower_bound(loads, remaining)
        if best_solution is not None and bound >= best_solution.makespan:
            return

        # Branch on each resource, preferring the least loaded first.
        resource_order = sorted(range(resource_count), key=lambda idx: loads[idx])
        for resource_idx in resource_order:
            if not can_place(job.job_id, resource_idx):
                continue

            # Place job.
            assignment[job.job_id] = resource_idx
            loads[resource_idx] += job.duration

            # Bounding after branching can prune deeper states.
            next_remaining = [remaining_durations_map[j.job_id] for j in ordered_jobs[position + 1 :]]
            next_bound = lower_bound(loads, next_remaining)
            if best_solution is None or next_bound < best_solution.makespan:
                search(position + 1)

            # Backtrack.
            loads[resource_idx] -= job.duration
            del assignment[job.job_id]

    search(0)

    if best_solution is None:
        raise RuntimeError("No feasible schedule found for the given constraints.")

    return best_solution


def pretty_print_solution(solution: Solution, jobs: Sequence[Job]) -> None:
    print("Optimal schedule found")
    print(f"Makespan: {solution.makespan:.2f}")
    print("Assignments:")
    for job in sorted(jobs, key=lambda j: j.job_id):
        print(f"  Job {job.job_id}: resource {solution.assignment[job.job_id]}")
    print("Resource loads:")
    for idx, load in enumerate(solution.resource_loads):
        print(f"  Resource {idx}: {load:.2f}")


def demo_data() -> Tuple[List[Job], List[Tuple[str, str]], List[Tuple[str, int]], int]:
    jobs = [
        Job("A", 4),
        Job("B", 6),
        Job("C", 3),
        Job("D", 5),
        Job("E", 2),
    ]
    precedence = [("A", "D"), ("C", "E")]
    incompatibilities = [("B", 1)]
    resource_count = 2
    return jobs, precedence, incompatibilities, resource_count


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="Branch and bound scheduler for small resource allocation problems.")
    parser.add_argument("--demo", action="store_true", help="Run a built-in demonstration problem.")
    parser.add_argument("--jobs", help="Path to JSON input file containing jobs and optional constraints.")
    parser.add_argument("--resources", type=int, help="Number of identical resources to schedule onto.")
    args = parser.parse_args(argv)

    if args.demo:
        jobs, precedence, incompatibilities, resource_count = demo_data()
    elif args.jobs and args.resources:
        jobs, precedence, incompatibilities = parse_input_file(args.jobs)
        resource_count = args.resources
    else:
        parser.error("Provide either --demo or both --jobs and --resources.")
        return 2

    try:
        solution = branch_and_bound_schedule(
            jobs=jobs,
            resource_count=resource_count,
            precedence=precedence,
            incompatibilities=incompatibilities,
        )
    except (ValueError, RuntimeError) as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    pretty_print_solution(solution, jobs)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```