Source code for nvalchemi.distributed.validate

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Single-call distributed-spec inference + validation.

`trace_and_validate(model_factory, sample_batch, ...)` does:

1. Reference run — single-process forward on the sample batch, captured
   under :func:`~nvalchemi.distributed._core.dispatch_trace.dispatch_trace` so
   we observe which custom ops fire and what shapes they produce. This
   is also the ground-truth output the multi-rank run is compared
   against.
2. Spec inference — translate the trace's observed firings into a
   candidate :class:`MLIPSpec`. The wrapper's existing
   ``distribution_spec`` (if defined) is treated as a strong prior:
   missing fields are inferred; provided fields are kept verbatim.
3. Validation — spawn ``world_size`` processes on the same GPU device,
   each running the wrapper through :class:`DistributedModel` with the
   inferred spec. Compare per-output tensors against the reference and
   produce a per-output diff.
4. Auto-fix — if the validation diff exceeds tolerance, run a small
   rule engine that proposes spec mutations from a corpus of patterns
   we've encountered (UMA halo-correction double-count,
   under/over-reduction). Each rule is tried in turn; the first that
   clears tolerance wins. The returned ``spec`` is the working one.

The returned :class:`TraceReport` is *actionable*: it gives a single
``next_action`` string and a serializable spec, so the user can either
paste the spec into their wrapper or save it to disk for cache reuse.

Module layout
-------------

``trace_and_validate`` (the public entry) lives here. The internals
are split across siblings:

* :mod:`.types` — :class:`Attempt`, :class:`TraceReport` dataclasses.
* :mod:`.payloads` — Batch ↔ tensor-dict wire format + diff metric.
* :mod:`.reference` — single-process reference run + NL summary.
* :mod:`.halo_diagnostics` — halo-completeness check.
* :mod:`.worker` — per-rank ``mp.spawn`` target.
* :mod:`.inference` — spawn orchestration + spec inference.
* :mod:`.autofix` — rule engine + spec signatures.

Each sibling is independently testable; ``trace_and_validate`` is the
glue that wires them in the documented order.
"""

from __future__ import annotations

import os
from typing import Any, Callable, Sequence

import torch

from nvalchemi.distributed.spec import MLIPSpec
from nvalchemi.distributed.validate.autofix import (
    _next_fix_candidate,
    _suspect_op_summary,
)
from nvalchemi.distributed.validate.inference import (
    _infer_spec_from_trace,
    _validate_spec,
)
from nvalchemi.distributed.validate.reference import _reference_run
from nvalchemi.distributed.validate.scripted_diagnostics import (
    ScriptedOpReport,
    detect_scripted_ops,
)
from nvalchemi.distributed.validate.types import Attempt, TraceReport

__all__ = [
    "Attempt",
    "ScriptedOpReport",
    "TraceReport",
    "detect_scripted_ops",
    "trace_and_validate",
]


_DEFAULT_WATCHED_HELPER_PACKAGES: tuple[str, ...] = ("aimnet.nbops",)


# ----- Worker-error translators ------------------------------------
#
# Generic torch / autograd errors are cryptic when the underlying cause
# is a framework-specific dispatch interaction. We pattern-match on the
# error string + the partial dispatch trace shipped from the worker and
# rewrite the ``next_action`` to point at the actual root cause. New
# patterns get appended here as we encounter them in the wild.


def _detect_dropped_inplace_dispatch_return(
    error_str: str, handler_counts: dict[str, int]
) -> str | None:
    """Detect the "I dropped my ``scatter_add_`` return" footgun.

    Symptom (worker-side): ``torch.autograd.grad`` raises
    ``RuntimeError: One of the differentiated Tensors appears to not
    have been used in the graph``.

    Mechanism: the wrapper called ``t.scatter_add_(0, idx, src)``
    (or ``index_add_`` / ``index_copy_``) with a ShardTensor source.
    Under domain decomposition the dispatch handler
    (:func:`_halo_scatter_correction`) computes the cross-rank-corrected
    output as a *new* tensor — there is no in-place primitive that also
    does halo_reverse + halo_forward. The handler returns the new
    tensor; the caller's ``t.scatter_add_(...)`` (no rebind) drops it.
    ``t`` stays at its pre-scatter zero, the model output detaches from
    its inputs, and ``autograd.grad`` finds no path back to
    ``positions``.

    Single-process: ``scatter_add_`` returns ``self``, the rebind is a
    no-op, the bug is silent until the wrapper meets the validator.

    Detection: marker is "appears to not have been used in the graph"
    AND ``halo_scatter_correction`` (or its index_add / index_copy
    siblings) fired at least once before the crash.

    Returns the suggested-fix string, or ``None`` if the pattern
    doesn't match.
    """
    if "appears to not have been used in the graph" not in error_str:
        return None
    inplace_handlers = (
        "halo_scatter_correction[scatter_add_]",
        "halo_scatter_correction[index_add_]",
        "halo_scatter_correction[index_copy_]",
        "halo_scatter_correction",
    )
    fired = [
        (h, n)
        for h, n in handler_counts.items()
        if any(h.startswith(p) for p in inplace_handlers) and n > 0
    ]
    if not fired:
        return None
    fired_summary = ", ".join(f"{h}×{n}" for h, n in fired)
    return (
        "Likely cause: an in-place ``scatter_add_`` / ``index_add_`` / "
        "``index_copy_`` inside the wrapper or model returned a *new* "
        "tensor (cross-rank halo correction can't preserve in-place "
        "semantics) and the caller dropped the return. Search the "
        "model code for ``t.scatter_add_(...)`` / ``t.index_add_(...)`` "
        "/ ``t.index_copy_(...)`` patterns where the return value is "
        "discarded, and rebind:\n"
        "    G = G.scatter_add_(0, idx, src)\n"
        "Single-process: the rebind is a no-op (``self`` is returned). "
        "Distributed: the rebind is mandatory for the autograd graph "
        f"to thread through the corrected accumulator. (Observed "
        f"firings: {fired_summary}.)"
    )


def _detect_severed_autograd_graph(
    error_str: str, handler_counts: dict[str, int]
) -> str | None:
    """Detect "the distributed forward severed the positions→energy graph".

    Symptom (worker-side): ``torch.autograd.grad`` raises
    ``RuntimeError: element 0 of tensors does not require grad and does not
    have a grad_fn`` — element 0 being the *output* (energy), which means the
    forward graph from ``positions`` to ``energy`` is broken.

    Mechanism: many MLIPs compute conservative forces *internally* via
    ``autograd.grad(energy, positions)`` (MACE's ``compute_forces``,
    AIMNet2's force head). Under domain decomposition the energy must stay
    connected to the input positions through every ShardTensor op. If a
    dispatch handler or a wrap-back returns a tensor detached from autograd,
    the energy loses its ``grad_fn`` and the internal ``autograd.grad`` finds
    no path back — even though single-process works (the graph is intact
    there).

    Returns the suggested-fix string, or ``None`` if the pattern doesn't
    match.
    """
    markers = (
        "does not require grad and does not have a grad_fn",
        "does not have a grad_fn",
    )
    if not any(m in error_str for m in markers):
        return None
    fired = ", ".join(f"{h}×{n}" for h, n in sorted(handler_counts.items())) or "none"
    return (
        "Likely cause: the model computes conservative forces *internally* "
        "via ``torch.autograd.grad(energy, positions)``, but the distributed "
        "forward severed the autograd graph between ``positions`` and "
        "``energy`` — the energy output reached ``autograd.grad`` without a "
        "``grad_fn``. Single-process works because the graph is intact there; "
        "under distribution one of the ShardTensor dispatch handlers or a "
        "wrap-back returned a tensor detached from autograd. Check that every "
        "op on the energy path preserves autograd (the per-system reduction / "
        "halo-correction handlers are autograd.Functions; a plain re-wrap that "
        "drops ``grad_fn`` is the usual culprit). "
        f"(Dispatch handlers that fired before the crash: {fired}.)"
    )


def _detect_scripted_op_shardtensor_ima(
    error_str: str, handler_counts: dict[str, int]
) -> str | None:
    """Detect the ``@torch.jit.script`` + ShardTensor CUDA illegal-memory-access.

    Symptom (worker-side): a ``RuntimeError`` from "the TorchScript interpreter"
    whose payload is an "illegal memory access" / "CUDA driver error" — often
    surfacing as a Warp ``wp_free_device_async`` fault on the next allocation.

    Mechanism: a scripted op (e.g. e3nn's ``_spherical_harmonics``) received a
    requires-grad ShardTensor on the halo path. TorchScript bypasses
    ``__torch_function__``, so the storage-less wrapper enters the JIT graph
    raw; its TensorExpr-fused kernel reads the near-null ``data_ptr`` → IMA.
    Marshalling the op across the boundary (Route C) fixes it.
    """
    markers = ("illegal memory access", "cuda driver error", "wp_free_device_async")
    lowered = error_str.lower()
    if not any(m in lowered for m in markers):
        return None
    scripted_context = (
        "torchscript" in lowered
        or "jit interpreter" in lowered
        or "wp_free_device_async" in lowered
        or "spherical_harmonics" in lowered
    )
    if not scripted_context:
        return None
    return (
        "Likely cause: a ``@torch.jit.script`` op received a requires-grad "
        "ShardTensor on the distributed halo path. TorchScript bypasses "
        "``__torch_function__``, so the storage-less ShardTensor enters the JIT "
        "graph raw and its TensorExpr-fused CUDA kernel reads a near-null "
        "``data_ptr`` → illegal memory access (the Warp ``wp_free_device_async`` "
        "fault is usually the *next* allocation tripping over the corrupted "
        "context, not the real site). Fix by MARSHALLING the scripted op across "
        "the boundary (Route C): unwrap ShardTensor→local, run the still-scripted "
        "op, re-wrap. Scripted *submodules* are auto-marshalled by the default "
        '``DomainConfig.scripted_marshal="auto"``; a module-level scripted '
        "*function* (the usual culprit, e.g. ``e3nn.o3._spherical_harmonics``) "
        'must be DECLARED — add ``JitAdapter(module_path, attr, mode="marshal")`` '
        "to the spec's ``distribution.third_party_helpers``. Run the pre-flight "
        "``detect_scripted_ops(model, spec)`` to list undeclared scripted "
        "functions and get a paste-able delta."
    )


def _translate_worker_error(
    error_str: str, handler_counts: dict[str, int]
) -> str | None:
    """Run all worker-error translators in priority order; return the
    first match, or ``None`` if no translator fires."""
    for translator in (
        _detect_scripted_op_shardtensor_ima,
        _detect_dropped_inplace_dispatch_return,
        _detect_severed_autograd_graph,
    ):
        hint = translator(error_str, handler_counts)
        if hint is not None:
            return hint
    return None


def _format_partition_health(
    ph: dict[str, Any] | None, *, degenerate_only: bool = False
) -> str:
    """Render the partition-health verdict for ``next_action``.

    ``degenerate_only`` (used on the success path) returns text ONLY when the
    partition is degenerate — a clean pass on a healthy partition needs no
    note. On the failure path it always appends the per-rank composition as
    diagnostic context.
    """
    if not ph:
        return ""
    if degenerate_only and not ph.get("degenerate"):
        return ""
    lines: list[str] = []
    if ph.get("degenerate"):
        lines.append(
            "Partition is DEGENERATE — this run did not meaningfully exercise "
            "domain decomposition, so the result is not evidence the spec is "
            "correct. Pick a larger system / different world_size so every rank "
            "has non-trivial owned + halo + remote atoms:"
        )
        lines.extend("  - " + m for m in ph["degenerate"])
        lines.append(
            "  Rule of thumb: a partitioned axis only develops remote atoms once "
            "its per-rank domain exceeds two ghost widths, i.e. "
            "box_axis / ranks_on_axis > 2 * ghost_width  (ghost_width ~= "
            "cutoff + skin). Below that every rank ghosts its neighbour's entire "
            "domain (remote == 0). E.g. ghost_width 6 Ang with a 2-way split "
            "needs box > 24 Ang on that axis."
        )
    comp = ", ".join(
        f"rank{r}(owned={d['owned']}, halo={d['halo']}, remote={d['remote']})"
        for r, d in sorted(ph.get("per_rank", {}).items())
    )
    if comp:
        lines.append(f"Partition composition: {comp}.")
    return "\n".join(lines)


[docs] def trace_and_validate( model_factory: Callable[[], Any], sample_batch: Any, *, world_size: int = 2, device: str | torch.device = "cuda:0", atol: float = 1e-5, rtol: float = 1e-4, auto_fix: bool = True, max_fix_attempts: int = 8, backend: str = "auto", timeout_sec: float = 120.0, watched_helper_packages: Sequence[str] | None = None, helper_sample_every: int = 8, layer_diagnostic: bool = True, ) -> TraceReport: """Infer a distribution spec, validate it on a single-GPU multi-process run, and (optionally) auto-fix when validation fails. Parameters ---------- model_factory Callable returning a freshly-constructed wrapper. Called once in the launcher process for the reference run, and once per rank in each spawned worker. Pristine state every time — no shared module graph between processes. sample_batch A :class:`~nvalchemi.data.Batch` (or compatible) carrying positions / cell / pbc on the target ``device``. Small enough that ``world_size`` copies fit in memory at once. world_size Virtual ranks to spawn on the same GPU. The default (2) is sufficient to flush the dispatch logic; larger values catch partition-dependent bugs but cost spawn overhead linearly. device CUDA device all ranks bind to. Default ``"cuda:0"``. CPU validation is *not* supported by this entry point — CPU/GPU numerical drift makes it unreliable; if you need it, call the harness in ``test_dispatch_trace_gloo.py`` directly. atol Per-output absolute tolerance. Pass criterion (per output) is ``abs_diff <= atol OR rel_diff <= rtol`` — same convention :func:`torch.testing.assert_close` uses, so extensive quantities (energy scales linearly with atom count) compare correctly across system sizes. rtol Per-output relative tolerance. Default ``1e-4`` covers fp32 round-off accumulation across collective reductions on the ``cpu:gloo,cuda:gloo`` backend; tighten to e.g. ``1e-5`` when running NCCL or fp64. auto_fix When the initial inferred spec fails validation, try rule-based mutations. Disable to get a single-attempt report. max_fix_attempts Cap on the number of distinct specs auto-fix will try. backend ``"nccl"``, ``"gloo"``, or ``"auto"`` (NCCL when CUDA is available, else Gloo). Both correctly route over CUDA tensors; NCCL is faster. timeout_sec Per-spawn join timeout. watched_helper_packages Fully-qualified module paths whose top-level Python helpers get instrumented during the reference and per-rank runs. The :mod:`~nvalchemi.distributed._core.helper_trace` proxy records each call's input / output shapes + sums; the :mod:`~nvalchemi.distributed._core.helper_diagnosis` classifier then flags helpers that look like distribution gaps (per-system reductions whose per-rank outputs sum to the reference output but aren't declared in ``spec.distribution.third_party_helpers``). Defaults to ``("aimnet.nbops",)``. Pass an explicit empty tuple to disable. Unimportable packages are skipped silently. helper_sample_every Record every Nth call after the first call per ``(module, function)``. Default 8 keeps overhead bounded for hot helpers (``mol_sum`` runs multiple times per layer); set to 1 for exhaustive recording (debug only). Returns ------- TraceReport Carries the working (or best-guess) spec, every attempt's diff/handler-counts, and a one-line ``next_action``. """ if not torch.cuda.is_available(): raise RuntimeError( "trace_and_validate requires CUDA — single-GPU multi-process " "spawn is the validation primitive (CPU validation is " "explicitly out of scope)." ) # Warp kernel cache: ensure both the launcher's reference run and # spawned workers can write JIT artefacts. Default # ``~/.cache/warp/`` may be read-only (sandboxed dev envs); route # to a writable temp location. Set at the launcher level so spawned # children inherit it before any nvalchemiops/warp import runs. if "WARP_CACHE_PATH" not in os.environ: import tempfile # noqa: PLC0415 os.environ["WARP_CACHE_PATH"] = os.path.join( tempfile.gettempdir(), "nvalchemi-validate-warp-cache" ) # Suppress noisy external warnings during the validator run: # Gloo connection messages, Warp's ``warp.context`` deprecation, # and the ``.grad attribute of a non-leaf`` warning fired inside # ``warp/_src/torch.py``. None are actionable from user code. import warnings as _warnings # noqa: PLC0415 os.environ.setdefault("GLOO_LOG_LEVEL", "ERROR") os.environ.setdefault("GLOG_minloglevel", "2") # Silences PyTorch's C++-side ``ProcessGroup`` teardown warnings # (``No backend of type 0 found``). Gloo's C++ ``Pair::connect`` # rank-connect log line still leaks through — those come from # ``transport/tcp/pair.cc`` which doesn't honour any of these env # vars; they're cosmetic-only and harmless. os.environ.setdefault("TORCH_CPP_LOG_LEVEL", "ERROR") os.environ.setdefault("TORCH_DISTRIBUTED_DEBUG", "OFF") _warnings.filterwarnings( "ignore", message=".*warp\\.context.*", category=DeprecationWarning, ) _warnings.filterwarnings( "ignore", message=".*\\.grad attribute of a Tensor that is not a leaf.*", category=UserWarning, ) # Warp's deprecation warnings bypass the Python ``warnings`` machinery # (they call ``sys.stdout.write`` directly via ``warp_showwarning``) # but DO consult the package-internal ``warnings_seen`` dedupe set # — pre-populating that set is the only reliable way to keep the # noise out of the validator's output. Both the launcher and each # spawned worker pre-populate independently (the workers inherit # ``warnings_seen`` clean since it's process-local state). try: import warp._src.utils as _wu # noqa: PLC0415 _warnings_seen = getattr(_wu, "warnings_seen", None) if _warnings_seen is not None: for _msg in ( "The namespace `warp.context` will soon be removed from the " "public API. It can still be accessed from `warp._src.context` " "but might be changed or removed without notice.", "The symbol `warp.context.Device` will soon be removed from " "the public API. Use `warp.Device` instead.", ): _warnings_seen.add((DeprecationWarning, _msg)) except ImportError: pass # The validator spawns every virtual rank on the SAME device, so NCCL is # off the table (it rejects multiple ranks sharing one device, "Duplicate # GPU detected"). A plain ``backend="gloo"`` still routes cuda-tensor # collectives through NCCL (PyTorch's default ``cpu:gloo,cuda:nccl`` map), so # pin BOTH device classes to gloo. Real multi-GPU NCCL runs go through the # benchmark scripts' torchrun launchers, not this debugging harness. if backend == "auto": backend_resolved = "cpu:gloo,cuda:gloo" else: backend_resolved = backend if watched_helper_packages is None: watched_helper_packages = _DEFAULT_WATCHED_HELPER_PACKAGES watched_helper_packages = tuple(watched_helper_packages) # Reference run + initial inference. ( ref_outputs, initial_trace, ref_helper_calls, ref_nl_summary, ref_layer_records, ) = _reference_run( model_factory, sample_batch, watched_helper_packages=watched_helper_packages, helper_sample_every=helper_sample_every, layer_diagnostic=layer_diagnostic, ) initial_spec = _infer_spec_from_trace(model_factory(), initial_trace) # Scripted-op pre-flight (static; no GPU). Flag module-level # ``@torch.jit.script`` functions auto-discovery can't wrap — the # @torch.jit.script + ShardTensor illegal-memory-access vector — and, when # auto-fixing, declare a marshalling JitAdapter for each so the spawn run # doesn't IMA. from nvalchemi.distributed.validate.scripted_diagnostics import ( # noqa: PLC0415 apply_marshal_adapters, detect_scripted_ops, ) scripted_report = detect_scripted_ops(model_factory(), initial_spec) preflight_hint = scripted_report.format_hint() rationale = "initial inference from single-rank trace" if scripted_report.has_risk and auto_fix: initial_spec = apply_marshal_adapters( initial_spec, scripted_report.undeclared_functions ) _injected = ", ".join( f"{mp}.{attr}" for mp, attr in scripted_report.undeclared_functions ) rationale = ( "initial inference + auto-marshalled undeclared scripted " f"function(s): {_injected}" ) attempts: list[Attempt] = [] spec = initial_spec for attempt_idx in range(max_fix_attempts): result = _validate_spec( model_factory, sample_batch, spec=spec, world_size=world_size, device=device, backend=backend_resolved, timeout_sec=timeout_sec, ref_outputs=ref_outputs, atol=atol, rtol=rtol, watched_helper_packages=watched_helper_packages, helper_sample_every=helper_sample_every, ref_helper_calls=ref_helper_calls, ref_nl_summary=ref_nl_summary, ref_layer_records=ref_layer_records, layer_diagnostic=layer_diagnostic, ) attempts.append(Attempt(spec=spec, rationale=rationale, **result)) if attempts[-1].passed: ok_action = ( f"OK — paste spec from report.spec (passed at attempt " f"#{attempt_idx + 1})" ) if preflight_hint: # The run passed; if undeclared scripted functions were # auto-marshalled, tell the user to make it permanent. ok_action += ( "\n\nScripted-op pre-flight (auto-marshalled for this run " "— declare these on your wrapper's spec to make it " "permanent):\n" + preflight_hint ) # A green result on a degenerate partition is a trap — surface it. degen = _format_partition_health( attempts[-1].partition_health, degenerate_only=True ) if degen: ok_action += "\nWARNING: " + degen return TraceReport( ok=True, spec=spec, attempts=attempts, next_action=ok_action, ) if not auto_fix: break # Pick the next rule to try. Returns ``None`` when no rule's # predicate matches — we've exhausted what the engine knows. candidate = _next_fix_candidate(spec, attempts) if candidate is None: break spec, rationale = candidate # Failed. Best guess + actionable next step. last = attempts[-1] if last.error is not None: next_action = f"FAIL — worker raised before completing forward.\n{last.error}" # Translate generic torch errors into framework-specific hints # using the partial dispatch trace shipped from the worker. # When this fires it usually pinpoints the root cause directly, # so it goes BEFORE the helper-gap branch. translated = _translate_worker_error(last.error, last.handler_counts or {}) if translated is not None: next_action += "\n\nDiagnosis: " + translated if last.handler_counts: counts_summary = ", ".join( f"{h}×{n}" for h, n in sorted(last.handler_counts.items()) ) next_action += ( f"\n\nDispatch trace before the crash: {counts_summary}. " "An empty trace usually means the wrapper crashed in " "construction or before any ShardTensor reached a " "registered op; a non-empty trace tells you which " "dispatch paths the wrapper exercised." ) helper_gaps = [ d for d in last.helper_diagnostics if d.suspected_gap is not None ] if helper_gaps: next_action += ( "\nPartial helper-trace before the crash flagged " f"{len(helper_gaps)} suspected gap(s): " + ", ".join(f"{d.module}.{d.function}" for d in helper_gaps) + ". An unwrapped third-party helper that should have " "been distributed is the typical root cause of this " "failure mode. Inspect " "``report.attempts[-1].helper_diagnostics`` for details." ) else: suspect = _suspect_op_summary(last.handler_counts) next_action = ( f"FAIL — auto-fix exhausted after {len(attempts)} attempts. " f"Closest variant in report.spec; " f"ΔE_max={max(last.max_abs_diff.values(), default=0.0):.3e}. " f"{suspect}" ) helper_gaps = [ d for d in last.helper_diagnostics if d.suspected_gap is not None ] if helper_gaps: next_action += ( "\nSuspected third-party helper gaps " f"({len(helper_gaps)}): " + ", ".join(f"{d.module}.{d.function}" for d in helper_gaps) + ". Inspect ``report.attempts[-1].helper_diagnostics`` " "for per-helper details (pattern, consistency check, " "suggested remedy template)." ) # Surface free-form divergence notes too — these fire even # when the formal classifier can't reach a verdict, giving the # user a starting point ("rank values agree but disagree with # ref by 12% — likely local-edge-graph computation"). Filter # to helpers without a formal gap so we don't double-report. helper_notes: list[str] = [] for d in last.helper_diagnostics: if d.suspected_gap is not None: continue # already mentioned above helper_notes.extend( f" - {d.module}.{d.function}: {note}" for note in d.divergence_notes ) if helper_notes: next_action += ( "\nWatched-helper divergences (no formal classifier " f"verdict; informational, {len(helper_notes)} total):\n" + "\n".join(helper_notes) ) # Partition health is the FIRST thing to rule out: a degenerate # partition (no halo / no remote / empty shard) makes every # downstream verdict suspect, and a too-small system is a common # cause of "halo coverage incomplete". ph_note = _format_partition_health(last.partition_health) if ph_note: next_action += "\n" + ph_note # Halo-completeness verdict comes BEFORE helper-trace # interpretation in causal order — if halo is missing edges, # downstream output divergences trace to that, not to the # combine rule. Surface it prominently so the reader sees the # root-cause line first; surface the *positive* case too so # readers know halo has been ruled out as a cause. hc = last.halo_completeness if hc: if not hc.get("matches", True): next_action += "\nHalo coverage check: " + hc.get( "interpretation", "halo coverage incomplete" ) else: ref_total = hc.get("ref_total_valid_edges", "?") next_action += ( f"\nHalo coverage check: VERIFIED — every owned atom " f"on every rank sees the same neighbor count as " f"single-process ({ref_total} edges total, owned " f"sums match per-rank). Halo construction is " f"correct; output divergences originate elsewhere " f"(combine rule, autograd graph topology, or " f"non-decomposable computation)." ) ld = last.layer_divergence if ld is not None: fd = ld.get("first_divergent") checked = ld.get("checked", 0) if fd is not None: next_action += ( f"\nLayer-by-layer diagnostic: first divergent module " f"is ``{fd['module']}`` at rel_diff=" f"{fd['rel_diff']:.2e} (sum-of-ranks " f"{fd['ranks_sum']:.4e} vs ref {fd['ref_sum']:.4e}). " f"Look for a missing distribution wrapper here, or " f"upstream input plumbing — checked {checked} modules " f"in execution order." ) elif checked > 0: next_action += ( f"\nLayer-by-layer diagnostic: every module's " f"sum-of-ranks matched ref to within tolerance " f"({checked} modules checked, max rel_diff=" f"{ld.get('max_rel_diff', 0.0):.2e}). The divergence " f"is in a non-Module computation (autograd-derived " f"output like forces/stress, post-model consolidation, " f"or a kernel that bypasses sub-module hooks)." ) if preflight_hint: next_action += "\n\nScripted-op pre-flight: " + preflight_hint return TraceReport( ok=False, spec=attempts[-1].spec, attempts=attempts, next_action=next_action, )