"""ADR-0169 — CompositionClaim ratification handler tests.

Mirrors :mod:`tests.test_math_frame_ratification` (F1) and pins:

1.  SAFE_COMPOSITION_CATEGORIES is exactly the ADR-0169 allowlist (3 entries).
2.  apply_composition_claim writes an entry for a safe category.
3.  receipt records before/after sha + evidence_hash.
4.  idempotent same-evidence → AlreadyRatified.
5.  rejects non-composition sub_type.
6.  rejects categories outside SAFE_COMPOSITION_CATEGORIES → WrongCompositionCategory.
7.  rejects invalid polarity.
8.  rejects evidence tampering.
9.  rejects evidence laundering (source='corpus' is forbidden).
10. case 0050 hazard pin — after ratification, case 0050 still refuses.
11. polarity=falsifies branch records non-composing pattern without admitting.
12. duplicate evidence on second apply appends evidence_hash, not new row.
13. manifest.json checksum is unchanged by composition ratification.
14. alphabetical sort by surface_pattern preserved across writes.
15. claim signature canonicalization deterministic.
16. claim signature replay equivalence cross-process (subprocess).
17. queue-order independence (A→B == B→A ratify).
18. partition: cognition TeachingChainProposal records not seen by handler.
19. audit evidence not laundered as source="corpus" at proposal layer.
20. workbench dispatch routes composition_reclassification → CompositionClaim.
21. proposed_change_kind Literal accepts composition_reclassification.
22. JSONL round-trip preserves composition_reclassification change_kind.

ADR-0169 §"Decision" + ADR-0169.1 §"Evidence floor" + hazard pins.
"""

from __future__ import annotations

import functools
import hashlib
import json
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path

import pytest

from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.comprehension import lexicon as comprehension_lexicon
from generate.comprehension import lifecycle
from generate.comprehension.audit import AuditRow, audit_problem
from generate.comprehension.state import ReaderRefusal
from teaching.math_composition_proposal import (
    build_composition_claim_proposal,
    build_evidence_pointer,
    compute_claim_signature,
)
from teaching.math_composition_ratification import (
    AlreadyRatified,
    EvidenceLaundering,
    EvidenceTampering,
    InvalidPolarity,
    SAFE_COMPOSITION_CATEGORIES,
    WrongClaimSubType,
    WrongCompositionCategory,
    apply_composition_claim,
)
from teaching.math_contemplation_proposal import (
    _VALID_CHANGE_KINDS,
    ChangeKind,
    build_proposal,
    from_jsonl_record,
    to_jsonl_record,
)
from teaching.math_evidence import from_audit_row
from teaching.math_reasoning_trace import ReasoningStep, build_trace


REPO_ROOT = Path(__file__).resolve().parents[1]
PACK_ROOT = REPO_ROOT / "language_packs" / "data" / "en_core_math_v1"
CASES_PATH = REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
AUDIT_BRIEF_PATH = (
    REPO_ROOT
    / "evals"
    / "gsm8k_math"
    / "train_sample"
    / "v1"
    / "audit_brief_11.json"
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def pack_copy(tmp_path: Path) -> Path:
    target = tmp_path / "en_core_math_v1"
    shutil.copytree(PACK_ROOT, target)
    (target / "compositions").mkdir(exist_ok=True)
    comprehension_lexicon._CACHE.clear()
    lifecycle._get_lexicon.cache_clear()
    yield target
    comprehension_lexicon._CACHE.clear()
    lifecycle._get_lexicon.cache_clear()


def _row(
    surface: str,
    *,
    missing_operator: str = "quantity_extraction",
    refusal_reason: str = "incomplete_operation",
    case_id: str | None = None,
) -> AuditRow:
    return AuditRow(
        case_id=case_id or f"case-{surface}",
        sentence_index=0,
        token_index=2,
        token_text=surface,
        recognized_terms=("Mark", "buys"),
        skipped_frame=None,
        missing_operator=missing_operator,
        refusal_reason=refusal_reason,
        refusal_detail=f"composition gap for '{surface}'",
    )


def _claim(surface: str, *, sub_type: str = "composition", case_id: str | None = None):
    return from_audit_row(_row(surface, case_id=case_id), sub_type)  # type: ignore[arg-type]


def _entries(path: Path) -> list[dict]:
    if not path.exists():
        return []
    return [
        json.loads(line)
        for line in path.read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]


def _manifest_sha(pack_root: Path) -> str:
    return hashlib.sha256((pack_root / "manifest.json").read_bytes()).hexdigest()


def _use_pack_for_reader(monkeypatch: pytest.MonkeyPatch, pack_root: Path) -> None:
    comprehension_lexicon._CACHE.clear()

    @functools.cache
    def _tmp_lexicon():
        return comprehension_lexicon.load_lexicon(pack_root)

    monkeypatch.setattr(lifecycle, "_get_lexicon", _tmp_lexicon)


# ---------------------------------------------------------------------------
# 1 — SAFE_COMPOSITION_CATEGORIES allowlist pinned
# ---------------------------------------------------------------------------


def test_safe_composition_categories_is_adr_0169_allowlist() -> None:
    """The three ADR-0169 categories, no more, no less."""
    assert SAFE_COMPOSITION_CATEGORIES == frozenset(
        {
            "multiplicative_composition",
            "additive_composition",
            "subtractive_composition",
        }
    )


# ---------------------------------------------------------------------------
# 2 — apply writes an entry for a safe category
# ---------------------------------------------------------------------------


def test_apply_composition_claim_writes_entry(pack_copy: Path) -> None:
    receipt = apply_composition_claim(
        claim=_claim("each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        surface_pattern="bound(count) bound(unit_cost)",
        pack_root=pack_copy,
    )

    assert receipt.surface_pattern == "bound(count) bound(unit_cost)"
    assert receipt.composition_category == "multiplicative_composition"
    assert receipt.polarity == "affirms"
    assert receipt.is_duplicate_evidence is False
    entry = next(
        e
        for e in _entries(
            pack_copy / "compositions" / "multiplicative_composition.jsonl"
        )
        if e["surface_pattern"] == "bound(count) bound(unit_cost)"
    )
    assert entry["composition_category"] == "multiplicative_composition"
    assert entry["polarity"] == "affirms"
    assert receipt.evidence_hash in entry["evidence_hashes"]


# ---------------------------------------------------------------------------
# 3 — receipt records before/after sha
# ---------------------------------------------------------------------------


def test_receipt_records_before_after_sha(pack_copy: Path) -> None:
    receipt = apply_composition_claim(
        claim=_claim("each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )

    assert len(receipt.file_sha256_before) == 64
    assert len(receipt.file_sha256_after) == 64
    assert receipt.file_sha256_before != receipt.file_sha256_after
    assert receipt.evidence_hash == _claim("each").evidence_hash


# ---------------------------------------------------------------------------
# 4 — idempotent same-evidence raises AlreadyRatified
# ---------------------------------------------------------------------------


def test_idempotent_same_evidence_raises_already_ratified(pack_copy: Path) -> None:
    claim = _claim("each")
    apply_composition_claim(
        claim=claim,
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )

    with pytest.raises(AlreadyRatified, match="already ratified"):
        apply_composition_claim(
            claim=claim,
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )


# ---------------------------------------------------------------------------
# 5 — rejects non-composition sub_type
# ---------------------------------------------------------------------------


def test_rejects_non_composition_sub_type(pack_copy: Path) -> None:
    with pytest.raises(WrongClaimSubType):
        apply_composition_claim(
            claim=_claim("each", sub_type="lexical"),
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )


# ---------------------------------------------------------------------------
# 6 — rejects categories outside SAFE_COMPOSITION_CATEGORIES (wrong=0 hazard)
# ---------------------------------------------------------------------------


def test_rejects_unsafe_composition_category(pack_copy: Path) -> None:
    with pytest.raises(
        WrongCompositionCategory, match="SAFE_COMPOSITION_CATEGORIES"
    ):
        apply_composition_claim(
            claim=_claim("each"),
            composition_category="distributive_composition",  # not in allowlist
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )


# ---------------------------------------------------------------------------
# 7 — rejects invalid polarity
# ---------------------------------------------------------------------------


def test_rejects_invalid_polarity(pack_copy: Path) -> None:
    with pytest.raises(InvalidPolarity, match="polarity must be one of"):
        apply_composition_claim(
            claim=_claim("each"),
            composition_category="multiplicative_composition",
            polarity="maybe",  # not affirms/falsifies
            reviewer="Ada",
            pack_root=pack_copy,
        )


# ---------------------------------------------------------------------------
# 8 — rejects evidence tampering
# ---------------------------------------------------------------------------


def test_rejects_evidence_tampering(pack_copy: Path) -> None:
    claim = _claim("each")
    object.__setattr__(claim, "evidence_hash", "0" * 64)

    with pytest.raises(EvidenceTampering):
        apply_composition_claim(
            claim=claim,
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )


# ---------------------------------------------------------------------------
# 9 — rejects evidence laundering as source='corpus'
# ---------------------------------------------------------------------------


def test_rejects_evidence_laundered_as_corpus(pack_copy: Path) -> None:
    """ADR-0169.1 §'Evidence floor': source='corpus' MUST be rejected."""
    with pytest.raises(EvidenceLaundering, match="MUST NOT be laundered"):
        apply_composition_claim(
            claim=_claim("each"),
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
            evidence_source="corpus",  # forbidden
        )


# ---------------------------------------------------------------------------
# 10 — case 0050 hazard pin: still refused after ratification
# ---------------------------------------------------------------------------


def test_case_0050_remains_refused_after_composition_ratification(
    pack_copy: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """After ratifying a safe composition pattern, case 0050 must still refuse.

    Case 0050 ("Mark does a gig every other day for 2 weeks. ..." — the
    period token at sentence_index=0 with missing_operator=
    pre_frame_filler_sentence) is the prototype wrong=0 hazard.  A
    speculative composition admission here would feed a wrong operand to
    the solver.  The new compositions/ entries must not open that
    admission path.
    """
    apply_composition_claim(
        claim=_claim("each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )
    _use_pack_for_reader(monkeypatch, pack_copy)
    cases = [json.loads(line) for line in CASES_PATH.read_text().splitlines()]
    case = next(c for c in cases if c["case_id"] == "gsm8k-train-sample-v1-0050")

    result, _rows = audit_problem(case["question"], case_id=case["case_id"])

    assert isinstance(result, ReaderRefusal), (
        "case 0050 must remain refused after CompositionClaim ratification"
    )
    assert result.sentence_index == 0


# ---------------------------------------------------------------------------
# 11 — polarity=falsifies branch records non-composing pattern
# ---------------------------------------------------------------------------


def test_polarity_falsifies_records_non_composing(pack_copy: Path) -> None:
    """A 'falsifies' ratification records the pattern as NOT composing.

    This is the negative-evidence path: an operator reviewed the audit row
    and decided the pattern does not in fact compose under the category.
    The entry is written to the same source file but with polarity=falsifies
    so downstream registry builds can mark the pattern as a known
    non-composer (refusal-stable).
    """
    receipt = apply_composition_claim(
        claim=_claim("along"),
        composition_category="multiplicative_composition",
        polarity="falsifies",
        reviewer="Ada",
        pack_root=pack_copy,
    )

    assert receipt.polarity == "falsifies"
    entry = next(
        e
        for e in _entries(
            pack_copy / "compositions" / "multiplicative_composition.jsonl"
        )
        if e["surface_pattern"] == "along"
    )
    assert entry["polarity"] == "falsifies"

    # affirms + falsifies of the same pattern produce distinct entries
    receipt2 = apply_composition_claim(
        claim=_claim("each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )
    assert receipt2.polarity == "affirms"
    polarities = {
        e["polarity"]
        for e in _entries(
            pack_copy / "compositions" / "multiplicative_composition.jsonl"
        )
    }
    assert {"affirms", "falsifies"} <= polarities


# ---------------------------------------------------------------------------
# 12 — duplicate evidence appends to existing row
# ---------------------------------------------------------------------------


def test_duplicate_pattern_polarity_with_new_evidence_appends_hash(
    pack_copy: Path,
) -> None:
    """ADR-0169.1 §'Idempotency': same claim + new evidence appends hash, not row."""
    apply_composition_claim(
        claim=_claim("each", case_id="case-each-1"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )

    # Second evidence for the same claim — different case_id, so the
    # evidence_hash differs even though pattern+polarity+category match.
    second_row = AuditRow(
        case_id="case-each-2",
        sentence_index=1,
        token_index=4,
        token_text="each",
        recognized_terms=("Bob", "buys"),
        skipped_frame=None,
        missing_operator="quantity_extraction",
        refusal_reason="incomplete_operation",
        refusal_detail="duplicate composition evidence",
    )
    second_claim = from_audit_row(second_row, "composition")
    receipt = apply_composition_claim(
        claim=second_claim,
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )

    assert receipt.is_duplicate_evidence is True
    rows = [
        e
        for e in _entries(
            pack_copy / "compositions" / "multiplicative_composition.jsonl"
        )
        if e["surface_pattern"] == "each"
    ]
    assert len(rows) == 1, "duplicate evidence must not create a second row"
    assert len(rows[0]["evidence_hashes"]) == 2


# ---------------------------------------------------------------------------
# 13 — manifest.json checksum unchanged
# ---------------------------------------------------------------------------


def test_lexicon_checksum_preserved_by_composition_ratification(
    pack_copy: Path,
) -> None:
    """RAT-1 — the lexicon ``checksum`` field is preserved across
    composition ratification. The manifest may gain a
    ``composition_checksum`` field (auto-compile per RAT-1) but the
    pre-existing lexicon checksum bytes are untouched.
    """
    manifest_before = json.loads((pack_copy / "manifest.json").read_bytes())
    declared_before = manifest_before["checksum"]

    apply_composition_claim(
        claim=_claim("each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=pack_copy,
    )

    manifest_after = json.loads((pack_copy / "manifest.json").read_bytes())
    assert manifest_after["checksum"] == declared_before, (
        "lexicon checksum must not change during composition ratification"
    )
    # RAT-1: composition_checksum may now be present (auto-compile).
    if "composition_checksum" in manifest_after:
        assert isinstance(manifest_after["composition_checksum"], str)


# ---------------------------------------------------------------------------
# 14 — alphabetical sort preserved across multiple writes
# ---------------------------------------------------------------------------


def test_alphabetical_sort_preserved(pack_copy: Path) -> None:
    for surface in ("times", "each", "apiece"):
        apply_composition_claim(
            claim=_claim(surface),
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )

    patterns = [
        e["surface_pattern"]
        for e in _entries(
            pack_copy / "compositions" / "multiplicative_composition.jsonl"
        )
    ]
    assert patterns == sorted(patterns)


# ---------------------------------------------------------------------------
# 15 — claim signature canonicalization deterministic
# ---------------------------------------------------------------------------


def test_claim_signature_canonicalization_deterministic() -> None:
    """ADR-0169 §'Replay obligations' #1: equivalent claims → identical signatures."""
    ev1 = _claim("each", case_id="case-1")
    ev2 = _claim("each", case_id="case-1")  # identical evidence
    p1 = build_evidence_pointer(ev1)
    p2 = build_evidence_pointer(ev2)

    sig_a = compute_claim_signature(
        surface_pattern="bound(count) bound(unit_cost)",
        composition_category="multiplicative_composition",
        polarity="affirms",
        evidence=(p1,),
    )
    sig_b = compute_claim_signature(
        surface_pattern="bound(count) bound(unit_cost)",
        composition_category="multiplicative_composition",
        polarity="affirms",
        evidence=(p2,),
    )
    assert sig_a == sig_b
    assert len(sig_a) == 64

    # Different polarity → different signature
    sig_c = compute_claim_signature(
        surface_pattern="bound(count) bound(unit_cost)",
        composition_category="multiplicative_composition",
        polarity="falsifies",
        evidence=(p1,),
    )
    assert sig_a != sig_c


# ---------------------------------------------------------------------------
# 16 — claim signature replay equivalence cross-process (subprocess)
# ---------------------------------------------------------------------------


def test_claim_signature_replay_equivalence_cross_process() -> None:
    """ADR-0169 §'Replay obligations' #2: equivalent across processes."""
    script = textwrap.dedent(
        """
        import json, sys
        from teaching.math_composition_proposal import (
            build_evidence_pointer, compute_claim_signature,
        )
        from teaching.math_evidence import from_audit_row
        from generate.comprehension.audit import AuditRow
        row = AuditRow(
            case_id="case-xprocess", sentence_index=0, token_index=2,
            token_text="each", recognized_terms=("Mark", "buys"),
            skipped_frame=None, missing_operator="quantity_extraction",
            refusal_reason="incomplete_operation",
            refusal_detail="composition gap for 'each'",
        )
        ev = from_audit_row(row, "composition")
        p = build_evidence_pointer(ev)
        sig = compute_claim_signature(
            surface_pattern="bound(count) bound(unit_cost)",
            composition_category="multiplicative_composition",
            polarity="affirms",
            evidence=(p,),
        )
        sys.stdout.write(sig)
        """
    )
    proc = subprocess.run(
        [sys.executable, "-c", script],
        capture_output=True,
        text=True,
        cwd=str(REPO_ROOT),
        check=True,
    )
    subprocess_sig = proc.stdout.strip()

    # In-process compute
    row = AuditRow(
        case_id="case-xprocess",
        sentence_index=0,
        token_index=2,
        token_text="each",
        recognized_terms=("Mark", "buys"),
        skipped_frame=None,
        missing_operator="quantity_extraction",
        refusal_reason="incomplete_operation",
        refusal_detail="composition gap for 'each'",
    )
    ev = from_audit_row(row, "composition")
    p = build_evidence_pointer(ev)
    inproc_sig = compute_claim_signature(
        surface_pattern="bound(count) bound(unit_cost)",
        composition_category="multiplicative_composition",
        polarity="affirms",
        evidence=(p,),
    )

    assert subprocess_sig == inproc_sig
    assert len(subprocess_sig) == 64


# ---------------------------------------------------------------------------
# 17 — queue-order independence (A→B == B→A)
# ---------------------------------------------------------------------------


def test_queue_order_independence(pack_copy: Path, tmp_path: Path) -> None:
    """ADR-0169 §'Replay obligations' #2: queue-order independence.

    Ratifying claim A then claim B produces the same target file as
    ratifying B then A.  Sort-by-surface_pattern write discipline carries
    most of the load; this test pins it.
    """
    # Order A → B
    target_a = tmp_path / "en_core_math_v1_a"
    shutil.copytree(PACK_ROOT, target_a)
    (target_a / "compositions").mkdir(exist_ok=True)
    apply_composition_claim(
        claim=_claim("each", case_id="case-each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=target_a,
    )
    apply_composition_claim(
        claim=_claim("apiece", case_id="case-apiece"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=target_a,
    )
    out_a = (
        target_a / "compositions" / "multiplicative_composition.jsonl"
    ).read_bytes()

    # Order B → A
    target_b = tmp_path / "en_core_math_v1_b"
    shutil.copytree(PACK_ROOT, target_b)
    (target_b / "compositions").mkdir(exist_ok=True)
    apply_composition_claim(
        claim=_claim("apiece", case_id="case-apiece"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=target_b,
    )
    apply_composition_claim(
        claim=_claim("each", case_id="case-each"),
        composition_category="multiplicative_composition",
        polarity="affirms",
        reviewer="Ada",
        pack_root=target_b,
    )
    out_b = (
        target_b / "compositions" / "multiplicative_composition.jsonl"
    ).read_bytes()

    # Provenance carries the date but not the order, so byte-equality holds.
    assert out_a == out_b


# ---------------------------------------------------------------------------
# 18 — partition: cognition TeachingChainProposal records not seen
# ---------------------------------------------------------------------------


def test_partition_cognition_proposals_not_seen(pack_copy: Path) -> None:
    """ADR-0169 §'Partition guarantees': handler is math-only.

    A cognition-style proposal (a TeachingChainProposal-like dict, or any
    object that is not a MathReaderRefusalEvidence with
    sub_type='composition') must be rejected by the handler — not silently
    accepted as if it were math evidence.
    """
    # A non-composition sub_type is the cognition-flow analog: rejected.
    with pytest.raises(WrongClaimSubType):
        apply_composition_claim(
            claim=_claim("each", sub_type="lexical"),
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )

    # A non-evidence object (the cognition TeachingChainProposal would not
    # carry sub_type at all) must also fail loudly — AttributeError or
    # TypeError, not a silent admit.
    class _NotEvidence:
        pass

    with pytest.raises((AttributeError, TypeError)):
        apply_composition_claim(
            claim=_NotEvidence(),  # type: ignore[arg-type]
            composition_category="multiplicative_composition",
            polarity="affirms",
            reviewer="Ada",
            pack_root=pack_copy,
        )


# ---------------------------------------------------------------------------
# 19 — audit evidence not laundered as source="corpus" at proposal layer
# ---------------------------------------------------------------------------


def test_audit_evidence_not_laundered_as_corpus_at_proposal_layer() -> None:  # noqa: D401
    """ADR-0169.1 §'Trip-wires' #1: proposal layer rejects corpus pointers.

    Defense-in-depth: the handler rejects source='corpus' on apply, and the
    proposal-build layer (build_composition_claim_proposal) also rejects
    pointers whose source drifted from 'math_audit'.
    """
    ev = _claim("each")
    pointer = build_evidence_pointer(ev)

    # Forge a pointer with corpus source — direct dataclass construction
    # since the factory pins source='math_audit'.
    from teaching.math_composition_proposal import MathReaderRefusalEvidencePointer

    laundered = MathReaderRefusalEvidencePointer(
        source="corpus",  # type: ignore[arg-type]
        case_id=pointer.case_id,
        sentence_index=pointer.sentence_index,
        token_index=pointer.token_index,
        missing_operator=pointer.missing_operator,
        refusal_reason=pointer.refusal_reason,
        evidence_hash=pointer.evidence_hash,
        audit_row_digest=pointer.audit_row_digest,
    )
    with pytest.raises(ValueError, match="math_audit"):
        build_composition_claim_proposal(
            surface_pattern="bound(count) bound(unit_cost)",
            composition_category="multiplicative_composition",
            polarity="affirms",
            evidence=(laundered,),
        )


# ---------------------------------------------------------------------------
# 20 — workbench dispatch routes composition_reclassification
# ---------------------------------------------------------------------------


def test_workbench_dispatch_composition_reclassification() -> None:
    """Workbench routes composition_reclassification → CompositionClaim, not 501."""
    from workbench.readers import _HANDLER_DISPATCH

    assert _HANDLER_DISPATCH["composition_reclassification"] == "CompositionClaim"
    # Sibling routes still exist
    assert _HANDLER_DISPATCH["vocabulary_addition"] == "LexicalClaim"
    assert _HANDLER_DISPATCH["frame_reclassification"] == "FrameClaim"


# ---------------------------------------------------------------------------
# 21 — proposed_change_kind Literal accepts composition_reclassification
# ---------------------------------------------------------------------------


def test_proposal_change_kind_literal_accepts_composition_reclassification() -> None:
    """Literal extension permits composition_reclassification as a valid ChangeKind."""
    from typing import get_args

    assert "composition_reclassification" in _VALID_CHANGE_KINDS
    assert "composition_reclassification" in get_args(ChangeKind)


# ---------------------------------------------------------------------------
# 22 — JSONL round-trip preserves composition_reclassification change_kind
# ---------------------------------------------------------------------------


def test_jsonl_round_trip_with_composition_reclassification() -> None:
    """W1 round-trip test extended for the new change_kind (ADR-0169 §'Acceptance gates')."""
    ev1 = _claim("each", case_id="case-1")
    ev2 = _claim("each", case_id="case-2")
    steps = (
        ReasoningStep(
            step_index=0,
            step_kind="observation",
            input_pointers=("case-1", "case-2"),
            claim="2 refusal rows share composition gap",
            justification="grouped by (refusal_reason, missing_operator)",
            output_payload={"evidence_count": 2},
        ),
        ReasoningStep(
            step_index=1,
            step_kind="grouping",
            input_pointers=("case-1", "case-2"),
            claim="group key",
            justification="exact pair equality",
            output_payload={"k": "v"},
        ),
        ReasoningStep(
            step_index=2,
            step_kind="hypothesis",
            input_pointers=("case-1", "case-2"),
            claim="composition_reclassification fits",
            justification="dispatched via pair table to composition_reclassification",
            output_payload={"proposed_change_kind": "composition_reclassification"},
        ),
        ReasoningStep(
            step_index=3,
            step_kind="conclusion",
            input_pointers=("case-1", "case-2"),
            claim="propose composition_reclassification",
            justification="evidence-only proposal",
            output_payload={"proposed_change_kind": "composition_reclassification"},
        ),
    )
    trace = build_trace(steps)
    proposal = build_proposal(
        shape_category=ShapeCategory.UNCATEGORIZED,
        structural_commonality="2 refusals share composition gap",
        evidence_pointers=(ev1, ev2),
        proposed_change_kind="composition_reclassification",
        proposed_change_payload={"k": "v"},
        wrong_zero_assertion=(
            "Proposal is evidence-only; ratification handler is the wrong=0 surface."
        ),
        replay_equivalence_hash="0" * 64,
        reasoning_trace=trace,
    )

    record = to_jsonl_record(proposal)
    assert record["proposed_change_kind"] == "composition_reclassification"
    round_tripped = from_jsonl_record(record)
    assert round_tripped.proposed_change_kind == "composition_reclassification"
    assert round_tripped.proposal_id == proposal.proposal_id
