"""Exact Model Artifact and runtime recipe identity (DGR-003).

A route is a chain of Shards that together compute one forward pass. If two
Shards disagree about *anything* that moves the numbers — the weights, the
quantization, the dtype the seam is serialized in, the dtype the kernels
accumulate in, the KV layout, the tokenizer, the architecture adapter, the
backend, or the runtime build — the route still runs and still emits tokens.
They are simply the wrong tokens, and nothing upstream notices. Identity is
therefore a digest over the executable semantics, not a model name.

Four decisions shape this module.

**The axes are separate, and each one is separately fatal.** `Q4_K_M` weights
are not a `bfloat16` seam, which is not an `fp32` accumulate, which is not a
`q8_0` KV cache. Collapsing them into one "precision" label is how a route ends
up numerically split while every node reports agreement. :data:`RECIPE_AXES` is
the full list; the digest commits to each under its own key, so swapping two
axis *values* changes the digest even though the multiset of values did not.

**The fingerprint deliberately excludes the Shard range.** Shards on one route
own *different* ranges; if the range were digested, no two Shards on a route
could ever agree. Range compatibility is a route-level coverage question
(:func:`check_route`), not an identity question.

**A split artifact routes under its source's identity.** A node holding layers
32–63 of a GGUF holds a *different file* with a *different content hash* than a
node holding layers 0–31, and neither equals the whole model. So the digest
binds :attr:`ArtifactIdentity.source_digest` — the whole-model artifact every
Shard descends from — and the derivative's own hash and range are bound to that
source locally (:class:`DerivativeBinding`). A derivative that cannot name the
exact artifact it was cut from is not admissible: it is an anonymous blob that
happens to have the right layer count.

**Labels are not digested.** `recipe_id`, `recipe_version` and
`catalogue_version` ride alongside the digests for diagnosis, mirroring the
capability report an admitted node already registers with (ADR-0023). Renaming
a recipe does not change a single number, so a rename must not partition a
route; changing `kv_dtype` changes every number, so it must. The digest commits
to what changes the numbers.

**Certification is not here.** Recipes are registered-but-dark on arrival, and a
recipe leaves the dark only when a real distributed forward over at least two
distinct physical nodes has emitted real tokens. That ledger lives in the
Tracker (:class:`meshnet_tracker.recipe.CertificationLedger`) and nowhere else —
a node that could decide it was certified would be marking its own homework, and
a second copy of the policy here would be a second thing to keep in step with it.
This module builds and compares identity; the Tracker decides what may serve.

The tracker re-derives the digests independently in ``meshnet_tracker.recipe`` —
it does not import this package, because an admission gate that shares an
implementation with the thing it admits is not an independent check. The two
implementations are pinned together by a committed conformance vector
(``tests/data/recipe_fingerprint_vectors.json``); if they ever drift, a test
fails rather than a route silently forming.
"""

from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass
from typing import Any, Iterable, Mapping, Sequence

from .native_protocol import BUNDLE_VERSION, SCHEMA_VERSION, pb

# Layout of the serialized identity block. Bump when the JSON shape changes.
RECIPE_IDENTITY_SCHEMA_VERSION = 1

# Domain separation. A digest means nothing outside the structure it commits to;
# the prefix guarantees an artifact digest can never be mistaken for — or collide
# with — a recipe digest, even if their canonical JSON were somehow identical.
ARTIFACT_DIGEST_DOMAIN = "meshnet.model-artifact.v1"
RECIPE_DIGEST_DOMAIN = "meshnet.runtime-recipe.v1"
SHARD_BINDING_DIGEST_DOMAIN = "meshnet.shard-binding.v1"

# The axes of a runtime recipe. Every one of these changes the numbers a Shard
# produces, so every one of them is part of identity and none of them may be
# folded into another.
RECIPE_AXES: tuple[str, ...] = (
    "weight_quantization",
    "activation_dtype",
    "compute_dtype",
    "kv_dtype",
    "kv_layout",
    "tokenizer_revision",
    "architecture_adapter",
    "backend_id",
    "runtime_version",
    "boundary_schema_version",
    "protocol_schema_version",
)

# --- Why two identities failed to match. These are the fail-closed reasons. ---
MISMATCH_ARTIFACT = "artifact"
MISMATCH_WEIGHT_QUANTIZATION = "weight-quantization"
MISMATCH_ACTIVATION_RECIPE = "activation-recipe"
MISMATCH_CACHE_LAYOUT = "cache-layout"
MISMATCH_TOKENIZER = "tokenizer"
MISMATCH_ARCHITECTURE = "architecture"
MISMATCH_BACKEND = "backend"
MISMATCH_RUNTIME = "runtime"
MISMATCH_BOUNDARY_SCHEMA = "boundary-schema"
MISMATCH_RANGE = "range"
MISMATCH_FINGERPRINT = "fingerprint"
MISMATCH_ROUTE_SESSION = "route-session"
MISMATCH_ROUTE_EPOCH = "route-epoch"

# Which fail-closed reason each axis reports. Several axes share a reason
# because they fail for the same operational cause: `activation_dtype` and
# `compute_dtype` are both "the activation recipe disagrees", and `kv_dtype`
# and `kv_layout` are both "the cache layout disagrees".
_AXIS_MISMATCH: Mapping[str, str] = {
    "weight_quantization": MISMATCH_WEIGHT_QUANTIZATION,
    "activation_dtype": MISMATCH_ACTIVATION_RECIPE,
    "compute_dtype": MISMATCH_ACTIVATION_RECIPE,
    "kv_dtype": MISMATCH_CACHE_LAYOUT,
    "kv_layout": MISMATCH_CACHE_LAYOUT,
    "tokenizer_revision": MISMATCH_TOKENIZER,
    "architecture_adapter": MISMATCH_ARCHITECTURE,
    "backend_id": MISMATCH_BACKEND,
    "runtime_version": MISMATCH_RUNTIME,
    "boundary_schema_version": MISMATCH_BOUNDARY_SCHEMA,
    "protocol_schema_version": MISMATCH_BOUNDARY_SCHEMA,
}

_HEX64 = re.compile(r"^[0-9a-f]{64}$")
_LLAMA_CPP_RUNTIME_PIN = re.compile(
    r"^llama\.cpp@[0-9a-f]{40}\+patchstack\.[0-9a-f]{64}$"
)
_LLAMA_CPP_BACKEND_IDS = frozenset({"llama.cpp", "llama-cpp"})

# A revision that can move is not a pin. DGR-017 learned this on the artifact;
# it is just as true of a tokenizer.
_MOVING_REFS = frozenset({"main", "master", "head", "latest", "dev", "trunk"})


class RecipeIdentityError(ValueError):
    """Malformed identity input. Messages name the field, never echo a payload."""


class RouteIncompatible(RecipeIdentityError):
    """Two Shards cannot form an Inference Route.

    Carries the structured reasons, so a caller can map them to a gRPC status
    rather than re-parsing a sentence.
    """

    def __init__(self, mismatches: Sequence["RouteMismatch"]) -> None:
        self.mismatches = tuple(mismatches)
        super().__init__("; ".join(m.describe() for m in self.mismatches))


def canonical_sha256(value: Any) -> str:
    """SHA-256 over canonical JSON — the repository's digest convention."""
    payload = json.dumps(
        value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def _digest(domain: str, body: Mapping[str, Any]) -> str:
    return canonical_sha256({"domain": domain, "body": dict(body)})


def _require_text(value: Any, what: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise RecipeIdentityError(f"{what!r} must be a non-empty string")
    return value


def _require_hex64(value: Any, what: str) -> str:
    text = _require_text(value, what)
    if not _HEX64.match(text):
        raise RecipeIdentityError(
            f"{what!r} must be a lowercase 64-character SHA-256 hex digest"
        )
    return text


def _require_int(value: Any, what: str, minimum: int) -> int:
    if isinstance(value, bool) or not isinstance(value, int):
        raise RecipeIdentityError(f"{what!r} must be an integer")
    if value < minimum:
        raise RecipeIdentityError(f"{what!r} must be >= {minimum}, got {value}")
    return value


def _require_pin(value: Any, what: str) -> str:
    """A revision must identify one immutable thing, not a ref that moves."""
    text = _require_text(value, what)
    lowered = text.strip().lower()
    if lowered in _MOVING_REFS or lowered.startswith("refs/"):
        raise RecipeIdentityError(
            f"{what!r} is {text!r}, which is a moving reference, not a pin; "
            "identity requires an exact immutable revision"
        )
    return text


def _require_runtime_pin(value: Any, backend_id: Any) -> str:
    text = _require_pin(value, "recipe.runtime_version")
    backend = _require_text(backend_id, "recipe.backend_id").strip().lower()
    if backend in _LLAMA_CPP_BACKEND_IDS and not _LLAMA_CPP_RUNTIME_PIN.fullmatch(text):
        raise RecipeIdentityError(
            "'recipe.runtime_version' for llama.cpp must be "
            "'llama.cpp@<40-hex commit>+patchstack.<64-hex digest>'"
        )
    return text


def _as_mapping(value: Any, what: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise RecipeIdentityError(
            f"{what!r} must be a JSON object, got {type(value).__name__}"
        )
    return value


@dataclass(frozen=True)
class DerivativeBinding:
    """A split or derived artifact, bound to the exact artifact it was cut from.

    Without this binding a split GGUF is an anonymous blob with a plausible
    layer count. With it, the Shard's own bytes are checkable *and* its identity
    on the route is the source model's, which is what lets a whole-model oracle
    and a five-way split agree on a fingerprint.

    The range is inclusive start, exclusive end, matching the protocol
    (``ShardRange`` in ``shard_runtime.proto``).
    """

    source_artifact_digest: str
    shard_start: int
    shard_end: int

    def __post_init__(self) -> None:
        _require_hex64(
            self.source_artifact_digest, "derived_from.source_artifact_digest"
        )
        _require_int(self.shard_start, "derived_from.shard_start", 0)
        _require_int(self.shard_end, "derived_from.shard_end", 1)
        if self.shard_end <= self.shard_start:
            raise RecipeIdentityError(
                f"'derived_from.shard_end' ({self.shard_end}) must be greater than "
                f"'derived_from.shard_start' ({self.shard_start}); an empty split "
                "artifact covers no layers and can prove nothing"
            )

    def covers(self, start: int, end: int) -> bool:
        return self.shard_start <= start and end <= self.shard_end

    def to_dict(self) -> dict:
        return {
            "source_artifact_digest": self.source_artifact_digest,
            "shard_start": self.shard_start,
            "shard_end": self.shard_end,
        }

    @classmethod
    def from_dict(cls, data: Any) -> DerivativeBinding:
        doc = _as_mapping(data, "derived_from")
        return cls(
            source_artifact_digest=_require_hex64(
                doc.get("source_artifact_digest"),
                "derived_from.source_artifact_digest",
            ),
            shard_start=_require_int(
                doc.get("shard_start"), "derived_from.shard_start", 0
            ),
            shard_end=_require_int(doc.get("shard_end"), "derived_from.shard_end", 1),
        )


@dataclass(frozen=True)
class ArtifactIdentity:
    """Exactly which weights, config and architecture a Shard loaded.

    `content_digest` is *this* file's hash — the whole model for an undivided
    artifact, one split's bytes for a derivative. `source_digest` is what the
    route compares. For an undivided artifact they are the same value; that
    identity is the whole point, because it is what makes the DGR-018
    whole-model oracle and the DGR-020 distributed route the same artifact.
    """

    artifact_id: str
    revision: str
    content_digest: str
    architecture: str
    architecture_digest: str
    layer_count: int
    derived_from: DerivativeBinding | None = None

    def __post_init__(self) -> None:
        _require_text(self.artifact_id, "artifact.artifact_id")
        _require_pin(self.revision, "artifact.revision")
        _require_hex64(self.content_digest, "artifact.content_digest")
        _require_text(self.architecture, "artifact.architecture")
        _require_hex64(self.architecture_digest, "artifact.architecture_digest")
        _require_int(self.layer_count, "artifact.layer_count", 1)

        binding = self.derived_from
        if binding is None:
            return
        if binding.source_artifact_digest == self.content_digest:
            raise RecipeIdentityError(
                "'artifact.derived_from.source_artifact_digest' equals this "
                "artifact's own content digest; an artifact is not a split of itself"
            )
        if binding.shard_end > self.layer_count:
            raise RecipeIdentityError(
                f"'artifact.derived_from' claims layers "
                f"{binding.shard_start}–{binding.shard_end}, but the source model "
                f"has only {self.layer_count} layers"
            )

    @property
    def is_derivative(self) -> bool:
        return self.derived_from is not None

    @property
    def source_digest(self) -> str:
        """The whole-model artifact this Shard descends from — what a route compares."""
        if self.derived_from is None:
            return self.content_digest
        return self.derived_from.source_artifact_digest

    @property
    def model_artifact_digest(self) -> str:
        """The artifact half of the compatibility fingerprint.

        Commits to the source weights, the architecture and its config/tokenizer
        snapshot, and the layer count — and deliberately *not* to the repository
        the bytes were fetched from. A mirror of identical bytes is the same
        artifact; the repo name buys diagnosis, not numerical identity.
        """
        return _digest(
            ARTIFACT_DIGEST_DOMAIN,
            {
                "source_digest": self.source_digest,
                "architecture": self.architecture,
                "architecture_digest": self.architecture_digest,
                "layer_count": self.layer_count,
            },
        )

    def to_dict(self) -> dict:
        return {
            "artifact_id": self.artifact_id,
            "revision": self.revision,
            "content_digest": self.content_digest,
            "architecture": self.architecture,
            "architecture_digest": self.architecture_digest,
            "layer_count": self.layer_count,
            "derived_from": (
                self.derived_from.to_dict() if self.derived_from else None
            ),
        }

    @classmethod
    def from_dict(cls, data: Any) -> ArtifactIdentity:
        doc = _as_mapping(data, "artifact")
        raw_binding = doc.get("derived_from")
        return cls(
            artifact_id=_require_text(doc.get("artifact_id"), "artifact.artifact_id"),
            revision=_require_pin(doc.get("revision"), "artifact.revision"),
            content_digest=_require_hex64(
                doc.get("content_digest"), "artifact.content_digest"
            ),
            architecture=_require_text(
                doc.get("architecture"), "artifact.architecture"
            ),
            architecture_digest=_require_hex64(
                doc.get("architecture_digest"), "artifact.architecture_digest"
            ),
            layer_count=_require_int(doc.get("layer_count"), "artifact.layer_count", 1),
            derived_from=(
                None if raw_binding is None else DerivativeBinding.from_dict(raw_binding)
            ),
        )


@dataclass(frozen=True)
class RuntimeRecipe:
    """How a Shard executes the artifact — one field per numerically live axis.

    Weight quantization is not activation dtype is not compute dtype is not KV
    dtype. Two nodes running `UD-IQ1_S` weights, one accumulating in fp32 and
    one in fp16, produce different logits from the same bytes. Keeping the axes
    apart is the entire safety property; see :data:`RECIPE_AXES`.

    `tokenizer_revision` and `runtime_version` must be exact pins, never moving
    references. For the native runtime the canonical `runtime_version` value —
    committing to the exact upstream commit *and* the ordered patch stack — is
    derived from the DGR-027 lock manifest by :mod:`meshnet_node.runtime_pin`.

    The three label fields are diagnosis only and are not digested.
    """

    weight_quantization: str
    activation_dtype: str
    compute_dtype: str
    kv_dtype: str
    kv_layout: str
    tokenizer_revision: str
    architecture_adapter: str
    backend_id: str
    runtime_version: str
    boundary_schema_version: int = BUNDLE_VERSION
    protocol_schema_version: int = int(SCHEMA_VERSION)
    recipe_id: str = "unnamed"
    recipe_version: str = "0"
    catalogue_version: str = "0"

    def __post_init__(self) -> None:
        for axis in RECIPE_AXES:
            value = getattr(self, axis)
            if axis.endswith("_schema_version"):
                _require_int(value, f"recipe.{axis}", 1)
            else:
                _require_text(value, f"recipe.{axis}")
        _require_pin(self.tokenizer_revision, "recipe.tokenizer_revision")
        _require_runtime_pin(self.runtime_version, self.backend_id)
        _require_text(self.recipe_id, "recipe.recipe_id")
        _require_text(self.recipe_version, "recipe.recipe_version")
        _require_text(self.catalogue_version, "recipe.catalogue_version")

    @property
    def axes(self) -> dict[str, Any]:
        """The digested fields, and only those."""
        return {axis: getattr(self, axis) for axis in RECIPE_AXES}

    @property
    def runtime_recipe_digest(self) -> str:
        """The recipe half of the compatibility fingerprint."""
        return _digest(RECIPE_DIGEST_DOMAIN, self.axes)

    def to_dict(self) -> dict:
        doc = self.axes
        doc.update(
            {
                "recipe_id": self.recipe_id,
                "recipe_version": self.recipe_version,
                "catalogue_version": self.catalogue_version,
            }
        )
        return doc

    @classmethod
    def from_dict(cls, data: Any) -> RuntimeRecipe:
        doc = _as_mapping(data, "recipe")
        missing = [axis for axis in RECIPE_AXES if axis not in doc]
        if missing:
            raise RecipeIdentityError(
                "recipe is missing required axes: "
                + ", ".join(missing)
                + "; an unstated axis is an unproven one, so it cannot default"
            )
        kwargs: dict[str, Any] = {}
        for axis in RECIPE_AXES:
            value = doc[axis]
            if axis.endswith("_schema_version"):
                kwargs[axis] = _require_int(value, f"recipe.{axis}", 1)
            else:
                kwargs[axis] = _require_text(value, f"recipe.{axis}")
        return cls(
            **kwargs,
            recipe_id=_require_text(doc.get("recipe_id"), "recipe.recipe_id"),
            recipe_version=_require_text(
                doc.get("recipe_version"), "recipe.recipe_version"
            ),
            catalogue_version=_require_text(
                doc.get("catalogue_version"), "recipe.catalogue_version"
            ),
        )


@dataclass(frozen=True)
class CompatibilityFingerprint:
    """The stable identity two peers actually compare.

    This is the Python face of the protocol's ``Fingerprint`` message
    (DGR-002). Do not invent a second identity struct: capability admission and
    the gRPC handshake compare *this*, and they must compare the same thing or
    a route can pass one gate and fail the other.
    """

    model_artifact_digest: str
    runtime_recipe_digest: str
    recipe_id: str
    recipe_version: str
    catalogue_version: str

    def __post_init__(self) -> None:
        _require_hex64(self.model_artifact_digest, "fingerprint.model_artifact_digest")
        _require_hex64(
            self.runtime_recipe_digest, "fingerprint.runtime_recipe_digest"
        )

    @property
    def key(self) -> tuple[str, str]:
        """What equality means: the digests, never the labels."""
        return (self.model_artifact_digest, self.runtime_recipe_digest)

    def matches(self, other: CompatibilityFingerprint) -> bool:
        return self.key == other.key

    def to_dict(self) -> dict:
        return {
            "model_artifact_digest": self.model_artifact_digest,
            "runtime_recipe_digest": self.runtime_recipe_digest,
            "recipe_id": self.recipe_id,
            "recipe_version": self.recipe_version,
            "catalogue_version": self.catalogue_version,
        }

    @classmethod
    def from_dict(cls, data: Any) -> CompatibilityFingerprint:
        doc = _as_mapping(data, "fingerprint")
        return cls(
            model_artifact_digest=_require_hex64(
                doc.get("model_artifact_digest"), "fingerprint.model_artifact_digest"
            ),
            runtime_recipe_digest=_require_hex64(
                doc.get("runtime_recipe_digest"), "fingerprint.runtime_recipe_digest"
            ),
            recipe_id=_require_text(doc.get("recipe_id"), "fingerprint.recipe_id"),
            recipe_version=_require_text(
                doc.get("recipe_version"), "fingerprint.recipe_version"
            ),
            catalogue_version=_require_text(
                doc.get("catalogue_version"), "fingerprint.catalogue_version"
            ),
        )

    def to_proto(self) -> "pb.Fingerprint":
        """Populate the DGR-002 ``Fingerprint`` message for the gRPC handshake."""
        return pb.Fingerprint(
            model_artifact_digest=self.model_artifact_digest,
            runtime_recipe_digest=self.runtime_recipe_digest,
            recipe_id=self.recipe_id,
            recipe_version=self.recipe_version,
            catalogue_version=self.catalogue_version,
        )

    @classmethod
    def from_proto(cls, message: "pb.Fingerprint") -> CompatibilityFingerprint:
        return cls(
            model_artifact_digest=_require_hex64(
                message.model_artifact_digest, "fingerprint.model_artifact_digest"
            ),
            runtime_recipe_digest=_require_hex64(
                message.runtime_recipe_digest, "fingerprint.runtime_recipe_digest"
            ),
            recipe_id=_require_text(message.recipe_id, "fingerprint.recipe_id"),
            recipe_version=_require_text(
                message.recipe_version, "fingerprint.recipe_version"
            ),
            catalogue_version=_require_text(
                message.catalogue_version, "fingerprint.catalogue_version"
            ),
        )


@dataclass(frozen=True)
class RouteMismatch:
    """One structured, fail-closed reason two identities do not compose."""

    reason: str
    detail: str
    shard_index: int | None = None

    def describe(self) -> str:
        where = "" if self.shard_index is None else f"shard {self.shard_index}: "
        return f"{where}{self.reason}: {self.detail}"

    def to_dict(self) -> dict:
        return {
            "reason": self.reason,
            "detail": self.detail,
            "shard_index": self.shard_index,
        }


@dataclass(frozen=True)
class ShardIdentity:
    """What one Shard is: an artifact, a recipe, and the layers it owns.

    The owned range is inclusive start, exclusive end (protocol convention). It
    is *not* part of the fingerprint — see the module docstring — but it is
    validated against the artifact, because a Shard cannot serve layers its
    artifact does not contain.
    """

    artifact: ArtifactIdentity
    recipe: RuntimeRecipe
    shard_start: int
    shard_end: int

    def __post_init__(self) -> None:
        _require_int(self.shard_start, "shard_start", 0)
        _require_int(self.shard_end, "shard_end", 1)
        if self.shard_end <= self.shard_start:
            raise RecipeIdentityError(
                f"'shard_end' ({self.shard_end}) must be greater than 'shard_start' "
                f"({self.shard_start}); a Shard owning no layer computes nothing"
            )
        if self.shard_end > self.artifact.layer_count:
            raise RecipeIdentityError(
                f"Shard owns layers {self.shard_start}–{self.shard_end}, but the "
                f"artifact has only {self.artifact.layer_count} layers"
            )
        binding = self.artifact.derived_from
        if binding is not None and not binding.covers(self.shard_start, self.shard_end):
            raise RecipeIdentityError(
                f"Shard advertises layers {self.shard_start}–{self.shard_end}, but its "
                f"split artifact only contains layers {binding.shard_start}–"
                f"{binding.shard_end}; it cannot serve weights it does not hold"
            )

    @property
    def fingerprint(self) -> CompatibilityFingerprint:
        """The derived identity. Never stored, never trusted from the wire — derived."""
        return CompatibilityFingerprint(
            model_artifact_digest=self.artifact.model_artifact_digest,
            runtime_recipe_digest=self.recipe.runtime_recipe_digest,
            recipe_id=self.recipe.recipe_id,
            recipe_version=self.recipe.recipe_version,
            catalogue_version=self.recipe.catalogue_version,
        )

    @property
    def shard_binding_digest(self) -> str:
        """This Shard's own bytes and exact range, bound to the source artifact.

        The fingerprint is range-independent on purpose — see the module
        docstring — which means it cannot distinguish two *different splits of
        the same source*. Both are the same recipe on the same model, and for
        route compatibility that is exactly right. It is not right for
        certification: a derivative could otherwise assert the correct source and
        inherit a certification earned by bytes it does not hold.

        So the derivative's own content hash and exact end-exclusive range get a
        second digest, outside the fingerprint. The Tracker re-derives it at
        admission and requires certification evidence to name the same binding
        for every serving node.
        """
        binding = self.artifact.derived_from
        return _digest(
            SHARD_BINDING_DIGEST_DOMAIN,
            {
                "source_digest": self.artifact.source_digest,
                "content_digest": self.artifact.content_digest,
                "architecture_digest": self.artifact.architecture_digest,
                "derivative_range": (
                    None
                    if binding is None
                    else [binding.shard_start, binding.shard_end]
                ),
                "shard_start": self.shard_start,
                "shard_end": self.shard_end,
            },
        )

    def to_dict(self) -> dict:
        return {
            "schema_version": RECIPE_IDENTITY_SCHEMA_VERSION,
            "artifact": self.artifact.to_dict(),
            "recipe": self.recipe.to_dict(),
            "shard_start": self.shard_start,
            "shard_end": self.shard_end,
            "fingerprint": self.fingerprint.to_dict(),
        }

    @classmethod
    def from_dict(cls, data: Any) -> ShardIdentity:
        """Parse an identity block, re-deriving — never trusting — its fingerprint.

        A declared fingerprint is a compatibility claim, not authentication.
        Recomputing it catches an inconsistent declaration; only tracker-owned
        distributed certification decides whether a recipe may serve.
        """
        doc = _as_mapping(data, "identity")

        declared_schema = doc.get("schema_version")
        if declared_schema != RECIPE_IDENTITY_SCHEMA_VERSION:
            raise RecipeIdentityError(
                f"identity block declares schema version {declared_schema!r}, but this "
                f"node reads version {RECIPE_IDENTITY_SCHEMA_VERSION}"
            )

        identity = cls(
            artifact=ArtifactIdentity.from_dict(doc.get("artifact")),
            recipe=RuntimeRecipe.from_dict(doc.get("recipe")),
            shard_start=_require_int(doc.get("shard_start"), "shard_start", 0),
            shard_end=_require_int(doc.get("shard_end"), "shard_end", 1),
        )

        declared = doc.get("fingerprint")
        if declared is not None:
            claim = CompatibilityFingerprint.from_dict(declared)
            derived = identity.fingerprint
            if not claim.matches(derived):
                raise RouteIncompatible(
                    [
                        RouteMismatch(
                            MISMATCH_FINGERPRINT,
                            "declared fingerprint does not match the identity it "
                            "claims to describe; the digest is derived from the "
                            "artifact and recipe, it is not an assertion",
                        )
                    ]
                )
        return identity


def explain_mismatch(
    left: ShardIdentity,
    right: ShardIdentity,
    *,
    shard_index: int | None = None,
) -> tuple[RouteMismatch, ...]:
    """Name every axis on which two Shards disagree.

    The fingerprint comparison already answers *whether* they compose. This
    answers *why not*, which is the difference between an operator fixing their
    node in a minute and filing a bug.
    """
    mismatches: list[RouteMismatch] = []

    if left.artifact.source_digest != right.artifact.source_digest:
        mismatches.append(
            RouteMismatch(
                MISMATCH_ARTIFACT,
                f"source Model Artifact {right.artifact.source_digest[:12]}… does not "
                f"match the route's {left.artifact.source_digest[:12]}…",
                shard_index,
            )
        )
    if left.artifact.architecture != right.artifact.architecture:
        mismatches.append(
            RouteMismatch(
                MISMATCH_ARCHITECTURE,
                f"artifact architecture {right.artifact.architecture!r} does not match "
                f"the route's {left.artifact.architecture!r}",
                shard_index,
            )
        )
    if left.artifact.architecture_digest != right.artifact.architecture_digest:
        mismatches.append(
            RouteMismatch(
                MISMATCH_ARCHITECTURE,
                "architecture/config snapshot differs from the route's; the same "
                "architecture name with different config is a different model",
                shard_index,
            )
        )
    if left.artifact.layer_count != right.artifact.layer_count:
        mismatches.append(
            RouteMismatch(
                MISMATCH_ARTIFACT,
                f"artifact has {right.artifact.layer_count} layers, but the route's "
                f"has {left.artifact.layer_count}",
                shard_index,
            )
        )

    for axis in RECIPE_AXES:
        mine = getattr(left.recipe, axis)
        theirs = getattr(right.recipe, axis)
        if mine != theirs:
            mismatches.append(
                RouteMismatch(
                    _AXIS_MISMATCH[axis],
                    f"{axis} is {theirs!r}, but the route runs {mine!r}",
                    shard_index,
                )
            )

    if not mismatches and not left.fingerprint.matches(right.fingerprint):
        # Unreachable via the fields above; if it ever fires, identity has grown a
        # digested field that `explain_mismatch` does not know about, and silently
        # reporting "compatible" would be far worse than an unhelpful message.
        mismatches.append(
            RouteMismatch(
                MISMATCH_FINGERPRINT,
                "fingerprints differ on a field this comparison does not cover",
                shard_index,
            )
        )
    return tuple(mismatches)


def _coverage_gap(
    ranges: Iterable[tuple[int, int]], layer_count: int
) -> str | None:
    """Return why `ranges` fail to tile ``[0, layer_count)``, or None if they do.

    Ranges may overlap: ADR-0012 lets the Tracker resolve an overlap by telling a
    hop where the previous hop stopped, so an overlap is a routing decision, not
    a defect. A *hole* is a defect — the layers in it are never computed, and the
    route would silently emit tokens from a truncated model.
    """
    ordered = sorted(ranges)
    if not ordered:
        return "the certifying route owns no layers"
    if ordered[0][0] != 0:
        return f"layers 0–{ordered[0][0]} are owned by no Shard on the route"

    covered = 0
    for start, end in ordered:
        if start > covered:
            return f"layers {covered}–{start} are owned by no Shard on the route"
        covered = max(covered, end)

    if covered < layer_count:
        return f"layers {covered}–{layer_count} are owned by no Shard on the route"
    if covered > layer_count:
        return (
            f"the route claims to cover {covered} layers, but the model has only "
            f"{layer_count}"
        )
    return None


def check_route(
    shards: Sequence[ShardIdentity],
) -> tuple[RouteMismatch, ...]:
    """Why these Shards may not form one Inference Route; empty when they may.

    This answers the *numerical* question only — do these Shards agree on every
    axis that moves the numbers, and do they tile the model without a hole. It
    deliberately does not answer "may this recipe carry user traffic": that is
    certification, the Tracker owns it (`meshnet_tracker.recipe`), and a node
    asking itself whether it is certified would be marking its own homework.

    Fail-closed by construction: an empty route, a hole in the coverage and a
    single differing dtype each produce a reason, so a caller that proceeds only
    on an empty tuple cannot admit any of them.
    """
    if not shards:
        return (
            RouteMismatch(
                MISMATCH_RANGE, "a route needs at least one Shard; this one has none"
            ),
        )

    mismatches: list[RouteMismatch] = []

    head = shards[0]
    for index, shard in enumerate(shards[1:], start=1):
        if not head.fingerprint.matches(shard.fingerprint):
            mismatches.extend(explain_mismatch(head, shard, shard_index=index))

    gap = _coverage_gap(
        [(shard.shard_start, shard.shard_end) for shard in shards],
        head.artifact.layer_count,
    )
    if gap is not None:
        mismatches.append(RouteMismatch(MISMATCH_RANGE, gap))

    return tuple(mismatches)


def require_route(shards: Sequence[ShardIdentity]) -> CompatibilityFingerprint:
    """`check_route`, raising the structured reasons instead of returning them."""
    mismatches = check_route(shards)
    if mismatches:
        raise RouteIncompatible(mismatches)
    return shards[0].fingerprint


# ---------------------------------------------------------------------------
# The alpha target (DGR-017)
# ---------------------------------------------------------------------------


def glm_alpha_artifact(
    derived_from: DerivativeBinding | None = None,
    *,
    content_digest: str | None = None,
) -> ArtifactIdentity:
    """Artifact identity for the pinned GLM-5.2 ``UD-IQ1_S`` alpha target.

    Reads the locked manifest and architecture snapshot DGR-017 sealed, rather
    than restating their digests here — a second copy of a pinned digest is a
    second thing that can drift from the target it claims to pin.

    `layer_count` is `num_hidden_layers` (78), *not* `total_artifact_layers`
    (79). The 79th is the MTP/next-token-prediction layer, which is not part of
    the transformer stack a route tiles and which alpha explicitly defers
    (roadmap decision 17). Tiling 79 layers would leave every route one layer
    short of "covered" forever.

    Pass `derived_from` when this node holds a split of the target, and
    `content_digest` for that split's own bytes.
    """
    from .glm_alpha import load_locked_target

    _contract, manifest, snapshot = load_locked_target()
    return ArtifactIdentity(
        artifact_id=manifest.gguf_repo_id,
        revision=manifest.gguf_revision,
        content_digest=content_digest or manifest.digest,
        architecture=str(snapshot["model_type"]),
        architecture_digest=snapshot.digest,
        layer_count=int(snapshot["num_hidden_layers"]),
        derived_from=derived_from,
    )


# ---------------------------------------------------------------------------
# gRPC handshake (DGR-002 `Fingerprint`, `ERROR_CODE_FINGERPRINT_MISMATCH`)
# ---------------------------------------------------------------------------


def check_handshake(
    local: ShardIdentity,
    remote: "pb.Fingerprint",
) -> tuple[RouteMismatch, ...]:
    """Compare the fingerprint a peer opened the stream with against our own.

    An incompatible peer must fail at `SessionOpen`, not mid-generation: by the
    time a wrong activation has been folded into a KV cache, the session is
    already producing wrong tokens and there is nothing to salvage.
    """
    try:
        presented = CompatibilityFingerprint.from_proto(remote)
    except RecipeIdentityError as exc:
        return (
            RouteMismatch(
                MISMATCH_FINGERPRINT,
                f"peer presented an unusable fingerprint: {exc}",
            ),
        )

    mine = local.fingerprint
    if presented.matches(mine):
        return ()

    reasons: list[RouteMismatch] = []
    if presented.model_artifact_digest != mine.model_artifact_digest:
        reasons.append(
            RouteMismatch(
                MISMATCH_ARTIFACT,
                f"peer serves Model Artifact {presented.model_artifact_digest[:12]}…, "
                f"this Shard serves {mine.model_artifact_digest[:12]}…",
            )
        )
    if presented.runtime_recipe_digest != mine.runtime_recipe_digest:
        reasons.append(
            RouteMismatch(
                MISMATCH_FINGERPRINT,
                f"peer runs recipe {presented.runtime_recipe_digest[:12]}… "
                f"({presented.recipe_id} v{presented.recipe_version}), this Shard runs "
                f"{mine.runtime_recipe_digest[:12]}… ({mine.recipe_id} "
                f"v{mine.recipe_version})",
            )
        )
    return tuple(reasons)


def check_session_open(
    local: ShardIdentity,
    remote: "pb.SessionOpen",
    *,
    expected_route_session_id: str | None = None,
    expected_route_epoch: int | None = None,
) -> tuple[RouteMismatch, ...]:
    """Fail closed on the complete immutable portion of a ``SessionOpen``.

    ``SessionOpen`` is not just a fingerprint carrier: its route session and
    epoch select the tracker-issued route whose identity was checked.  A worker
    that accepts a stale epoch can receive activations for a route that the
    tracker has already replaced.  Callers that have the tracker assignment
    therefore pass both expected values; callers without it still reject the
    protobuf defaults, which cannot identify a live route.
    """
    reasons = list(check_handshake(local, remote.fingerprint))
    if remote.schema_version != SCHEMA_VERSION:
        reasons.append(
            RouteMismatch(
                MISMATCH_BOUNDARY_SCHEMA,
                f"peer schema version {remote.schema_version} does not match "
                f"{SCHEMA_VERSION}",
            )
        )

    if not remote.route_session_id:
        reasons.append(
            RouteMismatch(
                MISMATCH_ROUTE_SESSION,
                "peer omitted the tracker-issued route session id",
            )
        )
    elif (
        expected_route_session_id is not None
        and remote.route_session_id != expected_route_session_id
    ):
        reasons.append(
            RouteMismatch(
                MISMATCH_ROUTE_SESSION,
                "peer route session does not match the tracker assignment",
            )
        )

    if remote.route_epoch < 1:
        reasons.append(
            RouteMismatch(
                MISMATCH_ROUTE_EPOCH,
                "peer omitted a positive tracker-issued route epoch",
            )
        )
    elif expected_route_epoch is not None and remote.route_epoch != expected_route_epoch:
        reasons.append(
            RouteMismatch(
                MISMATCH_ROUTE_EPOCH,
                "peer route epoch does not match the tracker assignment",
            )
        )

    advertised = (remote.shard_range.start_layer, remote.shard_range.end_layer)
    expected = (local.shard_start, local.shard_end)
    effective = remote.shard_range.effective_start_layer
    if advertised != expected:
        reasons.append(
            RouteMismatch(
                MISMATCH_RANGE,
                f"peer advertised Shard range {advertised}, expected {expected}",
            )
        )
    elif not local.shard_start <= effective < local.shard_end:
        reasons.append(
            RouteMismatch(
                MISMATCH_RANGE,
                f"effective start {effective} is outside Shard range {expected}",
            )
        )
    return tuple(reasons)


def handshake_error(
    mismatches: Sequence[RouteMismatch],
) -> "pb.ShardError | None":
    """The protocol status a rejected handshake closes the stream with.

    Each rejection maps to the most specific DGR-002 code the reasons allow:
    a digest disagreement dominates (the peer is the wrong artifact or recipe,
    whatever else is also wrong), then a schema disagreement (a peer on a
    foreign schema cannot be trusted to have encoded anything else correctly),
    and only a pure range disagreement reports the range code. Collapsing all
    of these to one code would leave the initiator unable to tell "re-route me"
    from "renegotiate the schema" from "wrong peer entirely".
    """
    if not mismatches:
        return None
    reasons = {mismatch.reason for mismatch in mismatches}
    if reasons == {MISMATCH_RANGE}:
        code = pb.ERROR_CODE_SHARD_RANGE_MISMATCH
    elif reasons <= {MISMATCH_ROUTE_SESSION, MISMATCH_ROUTE_EPOCH}:
        code = pb.ERROR_CODE_EPOCH_STALE
    elif reasons <= {MISMATCH_BOUNDARY_SCHEMA, MISMATCH_RANGE}:
        code = pb.ERROR_CODE_SCHEMA_UNSUPPORTED
    else:
        code = pb.ERROR_CODE_FINGERPRINT_MISMATCH
    return pb.ShardError(
        code=code,
        detail="; ".join(m.describe() for m in mismatches),
        retryable=False,
    )
