#!/usr/bin/env python3
"""Partial scorer for ordinary mate-in-three lines, based on Iqbal (2008).
Use UCI moves such as f7h6. Castling and en passant are excluded.
No external packages required.
"""
import argparse
from collections import Counter, defaultdict
from fractions import Fraction
from functools import lru_cache
from itertools import combinations

FEN = "5rk1/5Npp/4Q3/8/8/8/8/K7 w - - 0 1"
MOVES = (("f7", "h6"), ("g8", "h8"), ("e6", "g8"),
         ("f8", "g8"), ("h6", "f7"))
VALUE = {"K": 10, "Q": 9, "R": 5, "N": 3, "B": 3, "P": 1}
POWER = {"K": 8, "Q": 27, "R": 14, "N": 8, "B": 13, "P": 4}
# Iqbal et al. (2012), TCIAIG 4(3), p. 180: P2 = hierarchy / 5.
HIERARCHY_2012 = {"Q": 1, "R": 2, "B": 3, "N": 4, "P": 5}
# Probability of using the top five features (else six).
# 70-30: Iqbal (2010) Part II, ICGA J. 33(4). 20-80: Iqbal et al. (2012).
SPLITS = {"70-30": Fraction(7, 10), "20-80": Fraction(1, 5)}
MODELS = {"2008": {}, "2010": {"split": "70-30"},
          "2012": {"split": "20-80", "cap": True, "p2_2012": True}}
SQUARES = [(file, rank) for file in range(8) for rank in range(8)]


def square(name):
    return ord(name[0]) - ord("a"), int(name[1]) - 1


def read_position(fen):
    board = {}
    for rank, row in enumerate(reversed(fen.split()[0].split("/"))):
        file = 0
        for char in row:
            if char.isdigit():
                file += int(char)
            else:
                board[file, rank] = char
                file += 1
    return board


def distance(source, target):
    return max(abs(a - b) for a, b in zip(source, target))


def between(start, point, end):
    """Whether point is strictly inside the straight segment start–end."""
    if point in (start, end):
        return False
    dx, dy = (b - a for a, b in zip(start, end))
    px, py = (b - a for a, b in zip(start, point))
    return dx * py == dy * px and 0 < dx * px + dy * py < dx * dx + dy * dy


def attacks(board, source, target):
    piece = board[source]
    dx, dy = (b - a for a, b in zip(source, target))
    if not (dx or dy):
        return False
    if piece.upper() == "K":
        return distance(source, target) == 1
    if piece.upper() == "N":
        return sorted((abs(dx), abs(dy))) == [1, 2]
    if piece.upper() == "P":
        return abs(dx) == 1 and dy == (1 if piece.isupper() else -1)
    if piece.upper() == "B" and abs(dx) != abs(dy):
        return False
    if dx and dy and (piece.upper() not in {"Q", "B"} or abs(dx) != abs(dy)):
        return False
    step_x = (dx > 0) - (dx < 0)
    step_y = (dy > 0) - (dy < 0)
    return all((source[0] + step_x * i, source[1] + step_y * i) not in board
               for i in range(1, distance(source, target)))


def play(board, source, target, promotion=None):
    result = board.copy()
    piece = result.pop(source)
    result[target] = promotion or piece
    return result


def in_check(board, white):
    king = next(pos for pos, piece in board.items()
                if piece == ("K" if white else "k"))
    return any(attacks(board, pos, king) for pos, piece in board.items()
               if piece.isupper() != white)


@lru_cache(maxsize=None)
def candidate_squares(source, piece):
    empty_board = {source: piece}
    direction = 1 if piece.isupper() else -1
    return tuple(
        target for target in SQUARES
        if attacks(empty_board, source, target)
        or (piece.upper() == "P" and target[0] == source[0]
            and target[1] - source[1] in (direction, 2 * direction))
    )


def legal_moves(board, white):
    for source, piece in board.items():
        if piece.isupper() != white:
            continue
        for target in candidate_squares(source, piece):
            occupant = board.get(target)
            if occupant and (occupant.isupper() == white or occupant.upper() == "K"):
                continue
            if piece.upper() == "P" and not occupant:
                direction = 1 if white else -1
                one_step = (source[0], source[1] + direction)
                two_step = (source[0], source[1] + 2 * direction)
                reachable = target == one_step or (
                    source[1] == (1 if white else 6) and target == two_step and one_step not in board
                )
            else:
                reachable = attacks(board, source, target)
            if reachable and not in_check(play(board, source, target), white):
                yield source, target


def material(board, white):
    return sum(VALUE[piece.upper()] for piece in board.values()
               if piece.isupper() == white and piece.upper() != "K")


MAX_CONTROL = {"K": 3, "Q": 6, "R": 4, "B": 3, "N": 2, "P": 2}


def is_mate(board):
    return in_check(board, False) and not any(legal_moves(board, False))


def economy(board, king, overlap_rule, *, published_p6=False):
    active = board.copy()
    white = [pos for pos, piece in board.items() if piece.isupper()]
    # §4.5.4 uses FEN order for ties in both material-order passes.
    for direction in (-1, 1):
        sequence = sorted(white, key=lambda pos: (direction * VALUE[board[pos]],
                                                  -pos[1], pos[0]))
        for pos in sequence:
            if pos not in active:
                continue
            trial = active.copy()
            trial.pop(pos)
            if is_mate(trial):
                active = trial
    field = [pos for pos in SQUARES if distance(pos, king) <= 1]
    useful = [pos for pos in active if active[pos].isupper()]
    # Section 4.5.3: a ray still controls the square beyond the black king.
    transparent = active.copy()
    transparent.pop(king)

    def controls(pos, target):
        return attacks(transparent, pos, target)
    # Optional reconstruction of the numeric examples in Figure 6.5.
    control_field = [pos for pos in field
                     if not published_p6 or pos == king
                     or not active.get(pos, "").islower()]
    utilization = sum(
        Fraction(sum(controls(pos, target) for target in control_field),
                 MAX_CONTROL[active[pos]])
        for pos in useful
    )
    control_counts = [
        sum(controls(pos, target) for pos in useful)
        for target in field if target not in active
    ]
    # Appendix E calls overlapping_squares without defining its internals.
    # Pair counting reproduces the Loyd example; square counting follows §4.5.3.
    if overlap_rule == "pairs":
        overlap = sum(count * (count - 1) // 2 for count in control_counts)
    else:
        overlap = sum(count > 1 for count in control_counts)
    passive = sum(MAX_CONTROL[board[pos]] for pos in white
                  if pos not in active and (not published_p6 or board[pos] != "K"))
    return (utilization - Fraction(overlap + passive, 9)) / len(white)


def capture_gain(board, source, target):
    """One capture and the opponent's immediate legal recapture."""
    white = board[source].isupper()
    captured = VALUE[board[target].upper()]
    after = play(board, source, target)
    if any(dest == target for _, dest in legal_moves(after, not white)):
        captured -= VALUE[board[source].upper()]
    return captured


def heuristics(start, after, source, target, dissertation):
    before_moves = list(legal_moves(start, True))
    after_moves = list(legal_moves(after, True))
    mobility = Fraction(max(0, len(before_moves) - len(after_moves)), len(before_moves))
    if not dissertation:
        return mobility
    black_moves = list(legal_moves(after, False))
    king_risk = Fraction()
    if start[source] == "K" and any(
        in_check(play(after, origin, dest), True) for origin, dest in black_moves
    ):
        ring = min(target[0], 7-target[0], target[1], 7-target[1])
        king_risk = Fraction(ring + 1, 4)
    captures = {dest for origin, dest in before_moves
                if start.get(dest, "").islower() and start[dest] != "p"
                and capture_gain(start, origin, dest) > 0}
    taken = VALUE[start[target].upper()] if target in start else 0
    # Appendix E sums every favourable capture above the capture threshold.
    missed = sum(VALUE[start[pos].upper()] for pos in captures
                 if VALUE[start[pos].upper()] > taken)
    # The appendix explicitly undoes the key before scanning for en prise pieces.
    exposed = {dest for origin, dest in legal_moves(start, False)
               if start.get(dest, "") in {"Q", "R", "B", "N"}
               and VALUE[start[dest]] > taken
               and capture_gain(start, origin, dest) > 0}
    return (mobility + king_risk + Fraction(missed, 9)
            + Fraction(sum(VALUE[start[pos]] for pos in exposed), 9)) / 4


def describe_piece(board, pos):
    return f"{board.get(pos, 'empty')}@{chr(97 + pos[0])}{pos[1] + 1}"


def record_motif_role(roles, theme, board, attackers, targets, score):
    if roles is not None and score:
        roles[theme].add((tuple(board[pos].upper() for pos in attackers), tuple(targets)))


def trace_motif(trace, theme, board, attackers, targets, score, penalties):
    if trace is not None:
        attack_text = ", ".join(describe_piece(board, pos) for pos in attackers)
        target_text = ", ".join(describe_piece(board, pos) for pos in targets)
        penalty_text = ", ".join(f"{name}={value}" for name, value in penalties.items())
        trace.append(f"  {theme}: {float(score):.8f}; attackers: {attack_text}; "
                     f"targets: {target_text}; penalties before normalization: {penalty_text}")


def tactical_themes(board, attacker, *, before=None, source=None, strict_skewer=False,
                    no_material_fork_at_mate=False, trace=None, motif_roles=None):
    replies = list(legal_moves(board, False))
    if any(dest == attacker and capture_gain(board, origin, dest) > 0
           for origin, dest in replies):
        return Fraction(), Fraction(), Fraction()
    piece = board[attacker]
    targets = [pos for pos, enemy in board.items()
               if enemy.islower() and enemy != "p" and attacks(board, attacker, pos)
               and (enemy == "k" or ((attacker, pos) in legal_moves(board, True)
                    and capture_gain(board, attacker, pos) > 0))]
    checking = [(origin, dest) for origin, dest in replies
                if in_check(play(board, origin, dest), True)]
    terminal_fork = no_material_fork_at_mate and is_mate(board)
    if terminal_fork:
        targets = [pos for pos in targets if board[pos] == "k"]
    fork_targets = {pos: VALUE[board[pos].upper()] for pos in targets}
    if not terminal_fork and "k" not in (board[pos] for pos in targets):
        mating_rays = set()
        for source, dest in sorted(legal_moves(board, True),
                                   key=lambda move: distance(attacker, move[1])):
            if source != attacker or (dest in board and dest not in targets):
                continue
            if not is_mate(play(board, source, dest)):
                continue
            if piece in "QRB" and dest not in board:
                ray = tuple((b > a) - (b < a) for a, b in zip(attacker, dest))
                if ray in mating_rays:
                    continue
                mating_rays.add(ray)
            fork_targets[dest] = VALUE["K"]
    fork = Fraction()
    if len(fork_targets) >= 2:
        fork = sum(value + 1 + Fraction(distance(attacker, pos), POWER[piece])
                   for pos, value in fork_targets.items())
        counterchecks = sum(origin in targets for origin, _ in checking)
        fork = (fork - counterchecks) / 37
        target_roles = sorted(
            "mate-square" if value == VALUE["K"] and board.get(pos) != "k"
            else board[pos].upper()
            for pos, value in fork_targets.items()
        )
        record_motif_role(motif_roles, "T1", board, [attacker], target_roles, fork)
        trace_motif(trace, "T1 fork", board, [attacker], fork_targets, fork,
                    {"counterchecks": counterchecks})
        if trace is not None:
            for pos, value in fork_targets.items():
                trace.append(f"    {describe_piece(board, pos)} target value: {value}")
    pin = skewer = Fraction()
    if piece in "QRB":
        fronts = [pos for pos, enemy in board.items()
                  if enemy.islower() and enemy != "p" and attacks(board, attacker, pos)]
        for front in fronts:
            dx, dy = (b - a for a, b in zip(attacker, front))
            step = ((dx > 0) - (dx < 0), (dy > 0) - (dy < 0))
            rear = (front[0] + step[0], front[1] + step[1])
            while rear in SQUARES and rear not in board:
                rear = (rear[0] + step[0], rear[1] + step[1])
            enemy = board.get(rear, "")
            if not enemy.islower() or enemy == "p":
                continue
            if (before is not None and before.get(source) == piece
                    and before.get(front) == board[front] and before.get(rear) == enemy
                    and attacks(before, source, front)):
                without_front = before.copy()
                without_front.pop(front)
                if attacks(without_front, source, rear):
                    continue
            front_value = VALUE[board[front].upper()]
            rear_value = VALUE[enemy.upper()]
            if strict_skewer and front_value >= rear_value:
                opens_target = any(
                    origin == front and dest != attacker
                    and attacks(play(board, origin, dest), attacker, rear)
                    for origin, dest in replies
                )
                if not opens_target:
                    continue
            # Appendix E's pinning_line counts only moves along the
            # attack line that leave the rear target screened.
            front_moves = sum(origin == front and between(attacker, dest, rear)
                              for origin, dest in replies)
            blocks = [(origin, dest) for origin, dest in replies
                      if origin != front and between(attacker, dest, front)]
            liability = (len(blocks) + Fraction(VALUE[piece],
                         sum(VALUE[board[origin].upper()] for origin, _ in blocks))
                         if blocks else 0)
            mobility = Fraction(front_moves, POWER[board[front].upper()])
            counterchecks = sum(origin in (front, rear) for origin, _ in checking)
            score = (front_value + rear_value
                       + Fraction(distance(attacker, rear), POWER[piece])
                       - mobility - counterchecks
                       - liability) / 19
            theme = "T2 pin" if front_value < rear_value else "T3 skewer"
            record_motif_role(motif_roles, theme.split()[0], board, [attacker],
                              [board[front].upper(), board[rear].upper()], score)
            trace_motif(trace, theme, board, [attacker], [front, rear], score,
                        {"mobility": mobility, "counterchecks": counterchecks,
                         "interpositions": liability})
            if front_value < rear_value:
                pin += score
            else:
                skewer += score
    return fork, pin, skewer


def xray_score(before, board, source, moved, *, motif_roles=None):
    if board[moved] not in "QRB":
        return Fraction()
    score = Fraction()
    for middle, enemy in board.items():
        if enemy not in "qrb" or not attacks(board, moved, middle):
            continue
        dx, dy = (b - a for a, b in zip(moved, middle))
        step = ((dx > 0) - (dx < 0), (dy > 0) - (dy < 0))
        other = (middle[0] + step[0], middle[1] + step[1])
        while other in SQUARES and other not in board:
            other = (other[0] + step[0], other[1] + step[1])
        partner = board.get(other)
        if partner not in {"Q", "R", "B"} or before.get(middle) != enemy:
            continue
        if not any(pos in before and attacks(before, pos, middle)
                   for pos in (other, source)):
            continue
        transparent = board.copy()
        transparent.pop(middle)
        if not (attacks(transparent, other, moved)
                and attacks(board, middle, moved)
                and attacks(board, middle, other)):
            continue
        average_value = Fraction(VALUE[board[moved]] + VALUE[partner], 2)
        contribution = (1 + VALUE[enemy.upper()] - average_value
                  + Fraction(distance(moved, other),
                             min(POWER[board[moved]], POWER[partner]))) / 7
        score += contribution
        record_motif_role(motif_roles, "T4", board, [moved, other],
                          [enemy.upper()], contribution)
    return score


def smothered_mate_score(board, *, motif_roles=None):
    """Score mate with every adjacent king square occupied (§5.7)."""
    if not is_mate(board):
        return Fraction()
    king, checkers = checkers_against(board, False)
    neighbors = [pos for pos in SQUARES if distance(pos, king) == 1]
    if any(pos not in board for pos in neighbors):
        return Fraction()
    # In a verified mate the king cannot legally capture any White blocker.
    score = Fraction(sum(POWER[board[pos].upper()] for pos in neighbors), 101)
    record_motif_role(motif_roles, "T7", board,
                      sorted(checkers, key=lambda pos: board[pos]),
                      sorted(board[pos] for pos in neighbors), score)
    return score


def promotion_theme(promoted):
    return Fraction(3, VALUE[promoted.upper()]) if promoted else Fraction()


def named_moves(board, white):
    for source, target in legal_moves(board, white):
        piece = board[source]
        promotions = (None,)
        if piece.upper() == "P" and target[1] in (0, 7):
            promotions = "QRBN" if white else "qrbn"
        uci = "".join(f"{chr(97 + file)}{rank + 1}" for file, rank in (source, target))
        for promotion in promotions:
            yield uci + (promotion.lower() if promotion else ""), play(
                board, source, target, promotion
            )


def move_positions(board, white):
    for _, position in named_moves(board, white):
        yield position


def mate_tree(start, mate_moves=3, *, exact=False, single_line=False,
              key=None, node_limit=None, line_limit=None):
    """Return the shortest forced mate and winning lines within the selected depth."""
    nodes = 0

    def visit():
        nonlocal nodes
        nodes += 1
        if node_limit is not None and nodes > node_limit:
            raise ValueError("The search limit was reached before the result could be confirmed. Enter a UCI line to score it directly.")
    def can_force(board, white, remaining):
        return solve(tuple(sorted(board.items())), white, remaining)

    @lru_cache(maxsize=100_000)
    def solve(position, white, remaining):
        visit()
        board = dict(position)
        if white:
            return remaining > 0 and any(
                can_force(child, False, remaining - 1)
                for child in move_positions(board, True)
            )
        if remaining == 0:
            return is_mate(board)
        replies = move_positions(board, False)
        first = next(replies, None)
        if first is None:
            return in_check(board, False)
        return can_force(first, True, remaining) and all(
            can_force(child, True, remaining) for child in replies
        )

    lines = []
    duals = {}

    def collect(board, white, remaining, path):
        visit()
        if not white and is_mate(board):
            if not single_line or remaining == 0:
                lines.append(path)
                if line_limit is not None and len(lines) > line_limit:
                    raise ValueError("The solution tree exceeds the maximum number of variations.")
                return single_line
            return False
        choices = list(named_moves(board, white))
        if not path and key is not None:
            choices = [(move, child) for move, child in choices if move == key]
        if single_line:
            choices.sort(key=lambda choice: choice[0])
        if white:
            choices = [(move, child) for move, child in choices
                       if can_force(child, False, remaining - 1)]
            if path and len(choices) > 1:
                duals[path] = tuple(move for move, _ in choices)
        for move, child in choices:
            if collect(child, not white, remaining - int(white), path + (move,)):
                return True
        return False

    minimum = next((n for n in range(1, mate_moves + 1) if can_force(start, True, n)), None)
    if minimum is not None and (not exact or minimum == mate_moves):
        collect(start, True, mate_moves, ())
    return minimum, lines, duals


def motif_combination_distance(left, right):
    union = left | right
    return Fraction(len(left ^ right), len(union)) if union else Fraction()


def partial_profile_distance(left, right):
    """Mean of both directions of nearest-combination Jaccard distances."""
    def directed(source, target):
        return sum(min(motif_combination_distance(a, b) for b in target)
                   for a in source) / len(source)

    return (directed(left, right) + directed(right, left)) / 2


def print_distance_matrix(label, key, defenses, distances):
    print(f"  {label} distance matrix for {key} (UCI):")
    print("        " + " ".join(f"{move:>6}" for move in defenses))
    for row in defenses:
        cells = [Fraction() if row == col else distances[tuple(sorted((row, col)))]
                 for col in defenses]
        print(f"  {row:>5} " + " ".join(f"{float(cell):6.3f}" for cell in cells))
    if distances:
        maximum = max(distances.values())
        furthest = [f"{a}/{b}" for (a, b), value in distances.items() if value == maximum]
        print(f"  Most different ({label}): {float(maximum):.6f}; {len(furthest)} pair(s)")
        print("    " + ", ".join(furthest))


def weighted_tree_index(diversity, novelty, dual_density, weights):
    if len(weights) != 3 or any(weight < 0 for weight in weights) or not sum(weights):
        raise ValueError("Tree weights must be three nonnegative numbers with a positive sum")
    return sum(weight * value for weight, value in
               zip(weights, (diversity, novelty, 1 - dual_density))) / sum(weights)


def tree_weight(value):
    try:
        weight = Fraction(value)
    except (ValueError, ZeroDivisionError):
        raise argparse.ArgumentTypeError(f"Invalid tree weight: {value}") from None
    if weight < 0:
        raise argparse.ArgumentTypeError("Tree weights must be nonnegative")
    return weight


def print_solution_tree(fen, evaluate_line, *, tree_weights=(1, 1, 1), tree=None, summaries=None, mate_moves=3):
    minimum, lines, duals = tree if tree is not None else mate_tree(scoring_position(fen), mate_moves)
    print(f"\nSolution tree (UCI; White to mate within {mate_moves}):")
    if minimum is None:
        print(f"No forced mate within {mate_moves}.")
        return
    print("Tree index weights (diversity, new roles, dual avoidance): "
          + ", ".join(map(str, tree_weights)))
    keys = sorted({line[0] for line in lines})
    print(f"Shortest forced mate: {minimum}; keys: {', '.join(keys)}")
    print(f"Mate lines: {len(lines)}; dual nodes: {len(duals)}")
    components = {}
    dual_timing = defaultdict(set)
    branch_roles = defaultdict(lambda: defaultdict(set))
    for line in lines:
        roles = defaultdict(set)
        timeline = []
        components[line] = evaluate_line(line, motif_roles=roles, motif_timeline=timeline)
        seen = set()
        events = [{(theme, role) for theme, variants in step.items() for role in variants}
                  for step in timeline]
        for ply, event in enumerate(events):
            path = line[:ply]
            if path in duals:
                later = set().union(*events[ply:])
                if seen:
                    dual_timing[path].add("prior")
                if later:
                    dual_timing[path].add("post")
                if later - seen:
                    dual_timing[path].add("new")
            seen.update(event)
        for theme, variants in roles.items():
            branch_roles[line[:2]][theme].update(variants)
    scores = {line: sum(values.values()) for line, values in components.items()
              if len(line) == 2 * mate_moves - 1}
    branches = defaultdict(list)
    for line in lines:
        branches[line[:2]].append(line)
    dual_counts = Counter(path[:2] for path in duals)
    choice_nodes = {
        line[:ply] for line in lines for ply in range(2, 2 * mate_moves, 2) if len(line) > ply
    }
    choices_by_key = Counter(path[0] for path in choice_nodes)
    line_signatures = {
        line: frozenset(name.split()[0] for name, score in values.items()
                        if name.startswith("T") and score != 0)
        for line, values in components.items()
    }
    signatures = {branch: {line_signatures[line] for line in branch_lines}
                  for branch, branch_lines in branches.items()}

    # Group descendant profiles by the White move chosen at each dual.
    continuations = {path: defaultdict(set) for path in duals}
    for line, signature in line_signatures.items():
        for ply in range(2, 2 * mate_moves, 2):
            path = line[:ply]
            if len(line) > ply and path in continuations:
                continuations[path][line[ply]].add(signature)
    thematic_duals = Counter()
    divergent_duals = Counter()
    for path, profiles in continuations.items():
        if any(any(profile) for profile in profiles.values()):
            thematic_duals[path[:2]] += 1
            if len({frozenset(profile) for profile in profiles.values()}) > 1:
                divergent_duals[path[:2]] += 1
    print(f"Thematic duals: {sum(thematic_duals.values())}/{len(duals)}; "
          f"different motif profiles: {sum(divergent_duals.values())}")
    inherited_only = Counter(path[:2] for path, flags in dual_timing.items()
                             if flags == {"prior"})
    post_duals = Counter(path[:2] for path, flags in dual_timing.items() if "post" in flags)
    novel_duals = Counter(path[:2] for path, flags in dual_timing.items() if "new" in flags)
    print(f"Dual motif timing: inherited-only={sum(inherited_only.values())}; "
          f"post-branch={sum(post_duals.values())}; new roles={sum(novel_duals.values())}")
    print("First-defense summary (UCI):")
    score_label = "three-move" if mate_moves == 3 else "full-length"
    for branch, branch_lines in sorted(branches.items()):
        branch_scores = [scores[line] for line in branch_lines if line in scores]
        score_range = (f"{float(min(branch_scores)):.8f} to "
                       f"{float(max(branch_scores)):.8f}"
                       if branch_scores else "n/a")
        defense = branch[1] if len(branch) == 2 else "(immediate mate)"
        print(f"  {branch[0]} {defense}: mate lines={len(branch_lines)}; "
              f"dual nodes={dual_counts[branch]}; scored={len(branch_scores)}; "
              f"score range={score_range}")
        print(f"    thematic duals={thematic_duals[branch]}/{dual_counts[branch]}; "
              f"different motif profiles={divergent_duals[branch]}")
        print(f"    dual motif timing: inherited-only={inherited_only[branch]}; "
              f"post-branch={post_duals[branch]}; new roles={novel_duals[branch]}")
        motif_counts = Counter(theme for line in branch_lines for theme in line_signatures[line])
        motifs = ", ".join(f"T{i}={motif_counts[f'T{i}']}" for i in range(1, 11))
        print(f"    motifs (all mate lines with nonzero component): {motifs}")
        print(f"    distinct motif combinations: {len(signatures[branch])}")
        roles = branch_roles[branch]
        if roles:
            counts = ", ".join(f"T{i}={len(roles[f'T{i}'])}" for i in range(1, 11)
                               if f"T{i}" in roles)
            print(f"    distinct motif roles: {counts}")
    print("Motif diversity by key (mean pairwise Jaccard distance):")
    for key in keys:
        profiles = {branch[1]: profile for branch, profile in sorted(signatures.items())
                    if branch[0] == key and len(branch) == 2 and profile}
        legal_defenses = [branch for branch in branches
                          if len(branch) == 2 and branch[0] == key]
        scored_defenses = sum(any(line in scores for line in branches[branch])
                              for branch in legal_defenses)
        print(f"  {key}: defense coverage: motifs={len(profiles)}/{len(legal_defenses)}; "
              f"{score_label} scores={scored_defenses}/{len(legal_defenses)}")
        distances = {
            (left, right): Fraction(len(profiles[left] ^ profiles[right]),
                                    len(profiles[left] | profiles[right]))
            for left, right in combinations(profiles, 2)
        }
        diversity = (f"{float(sum(distances.values()) / len(distances)):.6f}"
                     if distances else "n/a")
        print(f"  {key}: diversity={diversity}; defenses={len(profiles)}; "
              f"pairs={len(distances)}; combinations={len(set().union(*profiles.values()))}")
        partial_distances = {
            (left, right): partial_profile_distance(profiles[left], profiles[right])
            for left, right in distances
        }
        if distances:
            partial_mean = sum(partial_distances.values()) / len(partial_distances)
            print(f"  {key}: partial motif diversity={float(partial_mean):.6f}")
            largest_drop = max(distances, key=lambda pair: distances[pair] - partial_distances[pair])
            left, right = largest_drop
            print(f"    largest partial-match reduction: {left}/{right}; "
                  f"exact={float(distances[largest_drop]):.6f}; "
                  f"partial={float(partial_distances[largest_drop]):.6f}")
        else:
            print(f"  {key}: partial motif diversity=n/a")
        if partial_distances:
            novel_defenses = sum(bool(novel_duals[branch]) for branch in legal_defenses)
            thematic_nodes = sum(thematic_duals[branch] for branch in legal_defenses)
            choice_count = choices_by_key[key]
            novelty = Fraction(novel_defenses, len(legal_defenses))
            dual_density = Fraction(thematic_nodes, choice_count)
            tree_index = weighted_tree_index(partial_mean, novelty, dual_density, tree_weights)
            if summaries is not None:
                summaries[key] = {
                    "index": float(tree_index), "diversity": float(partial_mean),
                    "novel_defenses": novel_defenses, "defenses": len(legal_defenses),
                    "thematic_duals": thematic_nodes, "choice_nodes": choice_count,
                }
            print(f"  {key}: experimental tree index={float(tree_index):.6f}; "
                  f"partial diversity={float(partial_mean):.6f}; "
                  f"new-role defenses={novel_defenses}/{len(legal_defenses)}; "
                  f"thematic dual density={thematic_nodes}/{choice_count}")
        else:
            if summaries is not None:
                summaries[key] = {"index": None, "defenses": len(legal_defenses)}
            print(f"  {key}: experimental tree index=n/a (fewer than two motif profiles)")
        if summaries is not None:
            continue
        repeated = Counter()
        changed = Counter()
        for left, right in combinations(profiles, 2):
            left_roles = branch_roles[key, left]
            right_roles = branch_roles[key, right]
            for theme in left_roles.keys() & right_roles.keys():
                repeated[theme] += 1
                changed[theme] += left_roles[theme] != right_roles[theme]
        shared = sum(repeated.values())
        different = sum(changed.values())
        rate = f"{different / shared:.6f}" if shared else "n/a"
        print(f"  {key}: functional recurrence={rate}; "
              f"different/shared pair-themes={different}/{shared}")
        print("    by theme: " + ", ".join(
            f"{theme}={changed[theme]}/{repeated[theme]}" for theme in (f"T{i}" for i in range(1, 11))
        ))
        if profiles:
            print_distance_matrix("Jaccard", key, profiles, distances)
            print_distance_matrix("Partial motif", key, profiles, partial_distances)
    if summaries is not None:
        return
    for path, moves in duals.items():
        print(f"  Dual after {' '.join(path)}: {', '.join(moves)}")
    for line in lines:
        label = " ".join(line)
        if line in scores:
            print(f"  {label}: {float(scores[line]):.8f}")
        else:
            print(f"  {label}: early mate (motifs only)")


def zugzwang_score(board, *, motif_roles=None):
    if in_check(board, False):
        return Fraction()
    replies = list(named_moves(board, False))
    if not replies or any(is_mate(position) for position in move_positions(board, True)):
        return Fraction()
    roles = set()
    for reply_move, reply in replies:
        mates = (position for position in move_positions(reply, True) if is_mate(position))
        first_mate = next(mates, None)
        if first_mate is None:
            return Fraction()
        if motif_roles is not None:
            reply_type = board[square(reply_move[:2])].upper()
            reply_kind = "capture" if square(reply_move[2:4]) in board else "quiet"
            for mate in (first_mate, *mates):
                _, checkers = checkers_against(mate, False)
                roles.add((reply_type, reply_kind, reply_move[4:],
                           tuple(sorted(mate[pos].upper() for pos in checkers))))
    if motif_roles is not None:
        motif_roles["T6"].update(roles)
    return Fraction(len(replies), 30)


def checkers_against(board, white):
    king = next(pos for pos, piece in board.items() if piece == ("K" if white else "k"))
    return king, [pos for pos, piece in board.items()
                  if piece.isupper() != white and attacks(board, pos, king)]


def crosscheck_score(checks, *, motif_timeline=None, check_roles=None):
    contributions = []
    run = []
    for index, (distance_ratio, captures_checker) in enumerate(checks + [(None, False)]):
        if distance_ratio is None or captures_checker:
            if len(run) >= 2:
                contributions.extend(1 + checks[ply][0] for ply in run)
                if motif_timeline is not None:
                    motif_timeline[run[-1]]["T8"].add(tuple(check_roles[ply] for ply in run))
            run = []
        if distance_ratio is not None:
            run.append(index)
    return sum(contributions, Fraction()) / len(checks)


def discovered_attack(before, board, source, moved, *, trace=None, motif_roles=None):
    """Score a newly opened attack by a stationary White slider (§5.5)."""
    replies = list(legal_moves(board, False))
    if any(dest == moved and capture_gain(board, origin, dest) > 0
           for origin, dest in replies):
        return Fraction()
    targets = {pos: piece.upper() for pos, piece in board.items()
               if piece.islower() and piece != "p"}
    white_moves = set(legal_moves(board, True))

    def threatened(attacker, target):
        return attacks(board, attacker, target) and (
            targets[target] == "K" or (
                (attacker, target) in white_moves
                and capture_gain(board, attacker, target) > 0
            )
        )

    moving_targets = [pos for pos in targets if threatened(moved, pos)]
    moving_target = max(moving_targets, key=lambda pos: VALUE[targets[pos]],
                        default=None)
    revealed = []
    for slider, piece in board.items():
        if slider == moved or piece not in "QRB":
            continue
        for target in targets:
            if (not threatened(slider, target)
                    or attacks(before, slider, target)
                    or not attacks(board, slider, source)
                    or not between(slider, source, target)):
                continue
            revealed.append((slider, target))
    # Figure C.15 credits the moving attacker only once, with priority
    # to a newly revealed attack on the king, then to FEN target order.
    revealed.sort(key=lambda pair: (targets[pair[1]] != "K",
                                    -pair[1][1], pair[1][0],
                                    -pair[0][1], pair[0][0]))
    scores = []
    for index, (slider, target) in enumerate(revealed):
        piece = board[slider]
        value = VALUE[targets[target]] + Fraction(distance(slider, target), POWER[piece])
        attacked = {target}
        liability = Fraction()
        if index == 0 and moving_target is not None:
            attacked.add(moving_target)
            value += VALUE[targets[moving_target]] + Fraction(
                distance(moved, moving_target), POWER[board[moved]])
        else:
            blockers = [
                origin for origin, dest in replies
                if between(slider, dest, target)
            ]
            if blockers:
                liability = len(blockers) + Fraction(
                    VALUE[piece], sum(VALUE[board[pos].upper()] for pos in blockers))
        checks = sum(origin in attacked and in_check(play(board, origin, dest), True)
                     for origin, dest in replies)
        score = (value - checks - liability) / 20
        scores.append(score)
        attackers = [slider]
        scored_targets = [target]
        if index == 0 and moving_target is not None:
            attackers.append(moved)
            scored_targets.append(moving_target)
        record_motif_role(motif_roles, "T5", board, attackers,
                          [board[pos].upper() for pos in scored_targets], score)
        trace_motif(trace, "T5 discovered/double attack", board, attackers,
                    scored_targets, score,
                    {"counterchecks": checks, "interpositions": liability})
    return sum(scores, Fraction())


def record_visit(visits, source, target):
    """Track the squares visited by the same piece, including its origin."""
    visited = visits.pop(source)
    revisit = target in visited
    visited.add(target)
    visits[target] = visited
    return revisit


def scoring_position(fen):
    fields = fen.split()
    if len(fields) != 6 or fields[1:4] != ["w", "-", "-"]:
        raise ValueError("Expected White to move, with no castling or en passant rights")
    start = read_position(fen)
    if set(start.values()) - set("KQRBNPkqrbnp") or any(
        list(start.values()).count(king) != 1 for king in ("K", "k")
    ):
        raise ValueError("Expected ordinary pieces and one king of each color")
    return start


def evaluate(fen, moves, knight_distance, dissertation=False,
             overlap_rule="squares", round_terms=False, *, published_p6=False,
             strict_skewer=False, no_material_fork_at_mate=False, trace=None, motif_roles=None, motif_timeline=None, mate_moves=3, p2_2012=False):
    start = scoring_position(fen)
    board = start
    travel = []
    discovered_scores = []
    fork = pin = skewer = xray = zugzwang = promotion_score = Fraction()
    checks = []
    check_roles = []
    previous_checkers = []
    visits = {pos: {pos} for pos, piece in start.items() if piece.isupper()}
    switchbacks = 0
    timeline = motif_timeline
    if timeline is None and motif_roles is not None:
        timeline = []
    step_roles = None
    for index, move in enumerate(moves):
        if timeline is not None:
            step_roles = defaultdict(set)
            timeline.append(step_roles)
        if (len(move) not in (4, 5)
                or any(move[i] not in allowed for i, allowed in
                       enumerate(("abcdefgh", "12345678", "abcdefgh", "12345678")))):
            raise ValueError("Use UCI moves, including the promotion suffix if needed")
        source, target = square(move[:2]), square(move[2:4])
        white = index % 2 == 0
        if (source, target) not in legal_moves(board, white):
            raise ValueError(f"Illegal move: {move}")
        piece = board[source]
        must_promote = piece.upper() == "P" and target[1] in (0, 7)
        suffix = move[4:]
        if bool(suffix) != must_promote or (suffix and suffix not in "qrbn"):
            raise ValueError(f"Invalid promotion suffix: {move}")
        promoted = (suffix.upper() if white else suffix) or None
        before = board
        board = play(board, source, target, promoted)
        checked_king, checking_pieces = checkers_against(board, not white)
        check_distance = None
        if checking_pieces:
            eligible = checking_pieces
            if target in checking_pieces and board[target].upper() != "K":
                without_mover = board.copy()
                without_mover.pop(target)
                if not in_check(without_mover, white):
                    eligible = [target]
                elif len(checking_pieces) > 1:
                    eligible = [pos for pos in checking_pieces if pos != target]
            check_distance = max(
                Fraction(distance(pos, checked_king), POWER[board[pos].upper()])
                for pos in eligible
            )
        checks.append((check_distance, target in previous_checkers))
        if step_roles is not None:
            check_roles.append(("white" if white else "black",
                                tuple(sorted(board[pos].upper() for pos in eligible))
                                if checking_pieces else ()))
            if promoted:
                step_roles["T9"].add(("white" if white else "black", promoted.upper(),
                                      "capture" if target in before else "quiet",
                                      bool(checking_pieces)))
        previous_checkers = checking_pieces
        promotion_score += promotion_theme(promoted)
        if not white:
            visits.pop(target, None)
            continue
        if trace is not None:
            trace.append(f"{index // 2 + 1}. {move} (White)")
        revisit = record_visit(visits, source, target)
        switchbacks += revisit
        if step_roles is not None and dissertation and revisit:
            step_roles["T10"].add((board[target].upper(),
                                   "capture" if target in before else "quiet",
                                   bool(checking_pieces)))
        if trace is not None and dissertation and revisit:
            trace.append(f"  T10 switchback: 0.50000000; piece: {describe_piece(board, target)}")
        if index == 0:
            p1 = heuristics(start, board, source, target, dissertation)
        fork_score, pin_score, skewer_score = tactical_themes(
            board, target, before=before, source=source, strict_skewer=strict_skewer,
            no_material_fork_at_mate=no_material_fork_at_mate, trace=trace, motif_roles=step_roles)
        fork += fork_score
        pin += pin_score
        xray += xray_score(before, board, source, target, motif_roles=step_roles)
        if not dissertation or index == 2 * (mate_moves - 2):
            zugzwang += zugzwang_score(board, motif_roles=step_roles)
        skewer += skewer_score
        travel.append(Fraction(knight_distance if piece == "N" else distance(source, target),
                               POWER[piece]))
        king = next(pos for pos, occupant in board.items() if occupant == "k")
        checkers = [pos for pos, occupant in board.items()
                    if occupant.isupper() and attacks(board, pos, king)]
        discovered_scores.append(discovered_attack(
            before, board, source, target, trace=trace, motif_roles=step_roles))
        if trace is not None:
            trace.append(f"  P3 movement: {float(travel[-1]):.8f}; "
                         f"T1: {float(fork_score):.8f}; T2: {float(pin_score):.8f}; "
                         f"T3: {float(skewer_score):.8f}; T5: {float(discovered_scores[-1]):.8f}")
            if index == 0:
                trace.append(f"  P1 key move: {float(p1):.8f}")
    if not is_mate(board):
        raise ValueError("The final position is not checkmate")
    # §4.2 uses the moving checker in a double mate. §4.3 uses the
    # first checker in FEN order for the final-distance contribution.
    mating_square = target if target in checkers else checkers[0]
    mating_piece = board[mating_square]
    distance_piece = (min(checkers, key=lambda pos: (-pos[1], pos[0]))
                      if dissertation else mating_square)
    distance_kind = board[distance_piece]
    neighbors = sum(distance(a, b) == 1 for a in start for b in start)
    white_start, black_start = material(start, True), material(start, False)
    white_loss = white_start - material(board, True)
    black_loss = black_start - material(board, False)
    travel.append(Fraction(knight_distance if distance_kind == "N"
                           else distance(distance_piece, king), POWER[distance_kind]))
    # Figure 6.4 adds displayed three-decimal contributions at each ply.
    def sum_terms(terms):
        return sum(round(term, 3) if round_terms else term for term in terms)

    scores = {
        "P1": p1,
        "P2": (Fraction(HIERARCHY_2012[mating_piece.upper()], 5) if p2_2012
               else Fraction(4, POWER[mating_piece])),
        "P3": sum_terms(travel),
        "P4": Fraction(max(0, black_start - white_start), 38),
        "P5": Fraction(max(0, white_loss - black_loss), 5 * mate_moves - 1),
        "P6": economy(board, king, overlap_rule, published_p6=published_p6),
        "P7": 1 / (1 + Fraction(neighbors, len(start))),
        "T1 (direct forks)": fork,
        "T2 (direct pins)": pin,
        "T3 (direct skewers)": skewer,
        "T4": xray,
        "T6": zugzwang,
        "T8": crosscheck_score(checks, motif_timeline=timeline, check_roles=check_roles),
        "T9": promotion_score,
        "T10 (dissertation)": Fraction(switchbacks, 2) if dissertation else Fraction(),
        "T5 (discovered/double attack)": sum_terms(discovered_scores),
        "T7": smothered_mate_score(board, motif_roles=step_roles),
    }

    if motif_roles is not None:
        for step in timeline:
            for theme, roles in step.items():
                motif_roles[theme].update(roles)
    return {name: round(score, 3) if round_terms else score
            for name, score in scores.items()}


def top_k_score(scores, k, *, cap=False):
    """Sum the k highest features, then subtract the remaining positive ones.

    Iqbal (2010, Part II): null and negative remaining features are ignored.
    Iqbal et al. (2012): with five or fewer scoring features, all are summed.
    """
    values = sorted((min(value, 1) if cap else value for value in scores.values()),
                    reverse=True)
    if sum(value != 0 for value in values) <= 5:
        return sum(values)
    return sum(values[:k]) - sum(value for value in values[k:] if value > 0)


def expected_stochastic(scores, p_five, *, cap=False):
    """Exact expectation of the random five-or-six selection."""
    return (p_five * top_k_score(scores, 5, cap=cap)
            + (1 - p_five) * top_k_score(scores, 6, cap=cap))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--fen", default=FEN)
    parser.add_argument("--moves", nargs=5)
    parser.add_argument("--knight-distance", type=int, choices=(2, 3), default=3)
    parser.add_argument("--dissertation", action="store_true",
                        help="Use dissertation heuristics and switchback scoring")
    parser.add_argument("--overlap", choices=("squares", "pairs"), default="squares",
                        help="Count unique squares or pairs of controllers")
    parser.add_argument("--round-terms", action="store_true",
                        help="Sum contributions rounded to three decimals")
    parser.add_argument("--published-p6", action="store_true",
                        help="Use the inferred P6 exceptions for Figure 6.5")
    parser.add_argument("--strict-skewer", action="store_true",
                        help="Require a legal move by the front piece that reveals the rear target")
    parser.add_argument("--no-material-fork-at-mate", action="store_true",
                        help="Ignore hypothetical material fork targets after checkmate")
    parser.add_argument("--solution-tree", action="store_true",
                        help="Enumerate forced mate lines and duals within three moves")
    parser.add_argument("--tree-weights", type=tree_weight, nargs=3, default=(1, 1, 1),
                        metavar=("D", "N", "A"),
                        help="Tree index weights for diversity, new roles and dual avoidance")
    parser.add_argument("--trace", action="store_true",
                        help="Show per-move scoring and tactical motif details")
    parser.add_argument("--reference", type=float, help="Published score to compare")
    args = parser.parse_args()
    if not sum(args.tree_weights):
        parser.error("--tree-weights requires at least one positive weight")
    trace = [] if args.trace else None
    def evaluate_line(moves, line_trace=None, *, motif_roles=None, motif_timeline=None):
        return evaluate(args.fen, moves, args.knight_distance,
                        args.dissertation, args.overlap, args.round_terms,
                        published_p6=args.published_p6, strict_skewer=args.strict_skewer,
                        no_material_fork_at_mate=args.no_material_fork_at_mate,
                        trace=line_trace, motif_roles=motif_roles, motif_timeline=motif_timeline)

    try:
        if args.solution_tree and args.moves is None:
            print_solution_tree(args.fen, evaluate_line, tree_weights=args.tree_weights)
            return
        moves = args.moves or ["".join(move) for move in MOVES]
        scores = evaluate_line(moves, trace)
    except ValueError as error:
        parser.error(str(error))
    if trace is not None:
        print("Trace (per-move contributions before rounding):")
        print("\n".join(trace))
        print("\nFinal component scores:")
    for name, score in scores.items():
        print(f"{name:29s} {float(score): .8f}")
    total = float(sum(scores.values()))
    print(f"Implemented subtotal: {total:.8f}")
    print("Partial model: direct forks, pins and skewers; simplified tactical liabilities.")
    print("T5 covers ordinary revealed lines.")
    if not args.dissertation:
        print("2008 example mode: mobility only for P1; no separate switchback score.")
    if args.reference is not None:
        print(f"Published total:      {args.reference:.8f}")
        print(f"Subtotal minus published total: {total - args.reference:+.8f}")

    if args.solution_tree:
        print_solution_tree(args.fen, evaluate_line, tree_weights=args.tree_weights)


if __name__ == "__main__":
    main()
