Source: src/mcss/context_subset_consistency.py · Plain text

src/mcss/context_subset_consistency.py

"""Training-only context-subset geometry consistency for the unchanged V5 state."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass

import torch
import torch.nn.functional as functional
from torch import Tensor

from mcss.measurements import FixedMeasurementRenderer
from mcss.types import Cameras, SceneState


@dataclass(frozen=True)
class ContextSubset:
    """One context tensor/camera set with a single view removed in place."""

    rgb: Tensor
    cameras: Cameras
    dropped_index: int
    retained_indices: tuple[int, ...]


@dataclass(frozen=True)
class ContextSubsetGeometryResult:
    """Differentiable consistency loss plus detached audit telemetry."""

    loss: Tensor
    terms: dict[str, Tensor]
    diagnostics: dict[str, float]
    audit_diagnostics: dict[str, float]
    per_sample_diagnostics: tuple[dict[str, float], ...]


def drop_context_view(
    context_rgb: Tensor,
    cameras: Cameras,
    *,
    dropped_index: int,
) -> ContextSubset:
    """Remove one view without changing the established coordinate frame or order."""

    if context_rgb.ndim != 5 or context_rgb.shape[2] != 3:
        raise ValueError("context_rgb must have shape [B, V, 3, H, W]")
    batch_size, view_count = context_rgb.shape[:2]
    if cameras.leading_shape != (batch_size, view_count):
        raise ValueError("context cameras must align with context_rgb")
    if cameras.device != context_rgb.device:
        raise ValueError("context cameras and RGB must be on the same device")
    if view_count < 2:
        raise ValueError("context view deletion requires at least two views")
    if not 0 <= dropped_index < view_count:
        raise ValueError("dropped_index must select an existing context view")

    retained_indices = tuple(index for index in range(view_count) if index != dropped_index)
    indices = torch.tensor(retained_indices, device=context_rgb.device, dtype=torch.long)
    return ContextSubset(
        rgb=context_rgb.index_select(1, indices),
        cameras=cameras.select_views(indices),
        dropped_index=dropped_index,
        retained_indices=retained_indices,
    )


def ramped_context_subset_geometry_weight(
    max_weight: float,
    *,
    optimizer_step: int,
    warmup_steps: int,
) -> float:
    """Ramp against optimizer updates, not gradient-accumulation micro-steps."""

    if max_weight < 0.0:
        raise ValueError("max_weight must be non-negative")
    if optimizer_step < 1:
        raise ValueError("optimizer_step must be positive")
    if warmup_steps < 0:
        raise ValueError("warmup_steps must be non-negative")
    if max_weight == 0.0 or warmup_steps == 0:
        return float(max_weight)
    return float(max_weight) * min(optimizer_step / warmup_steps, 1.0)


def compute_context_subset_geometry(
    full_state: SceneState,
    subset_state: SceneState,
    full_predictions: Mapping[str, Tensor],
    target_cameras: Cameras,
    renderer: FixedMeasurementRenderer,
    *,
    residual_saturation_threshold: float,
    collect_audit_diagnostics: bool = True,
) -> ContextSubsetGeometryResult:
    """Compare subset completion geometry against a detached full-context teacher."""

    if full_state.evidence is None or subset_state.evidence is None:
        raise ValueError("context-subset geometry requires V5 StateEvidence on both paths")
    if residual_saturation_threshold <= 0.0:
        raise ValueError("residual_saturation_threshold must be positive")
    if "depth" not in full_predictions or "visibility" not in full_predictions:
        raise ValueError("full_predictions must contain depth and visibility")

    full_evidence = full_state.evidence
    subset_evidence = subset_state.evidence
    reference_shape = full_state.density_logits.shape
    if subset_state.density_logits.shape != reference_shape:
        raise ValueError("full and subset geometry grids must have identical shapes")
    if full_evidence.density_residual.shape != subset_evidence.density_residual.shape:
        raise ValueError("full and subset density residuals must have identical shapes")

    shared_support = (
        subset_evidence.provenance.detach().gt(1e-6).sum(dim=1, keepdim=True) >= 2
    )
    stable_evidence = (
        1.0
        - (
            full_evidence.confidence.detach()
            - subset_evidence.confidence.detach()
        ).abs()
    ).clamp(0.0, 1.0)
    overlap_weight = (
        shared_support.to(dtype=full_state.density_logits.dtype) * stable_evidence
    ).detach()

    teacher_density = full_state.density_logits.detach()
    subset_on_full_evidence = (
        full_evidence.base_density_logits.detach()
        + full_evidence.completion_gate.detach() * subset_evidence.density_residual
    )
    counterfactual_density = (
        overlap_weight * subset_on_full_evidence
        + (1.0 - overlap_weight) * teacher_density
    )
    counterfactual_state = SceneState(
        density_logits=counterfactual_density,
        color=full_state.color.detach(),
        log_variance=full_state.log_variance.detach(),
        bounds=full_state.bounds.detach(),
    )
    counterfactual_predictions = renderer(
        counterfactual_state,
        target_cameras,
        {"depth", "visibility"},
    )

    teacher_depth = full_predictions["depth"].detach().float()
    teacher_visibility = full_predictions["visibility"].detach().float()
    counterfactual_depth = counterfactual_predictions["depth"].float()
    counterfactual_visibility = counterfactual_predictions["visibility"].float()
    voxel_spacing = _mean_metric_voxel_spacing(
        full_state.bounds.detach(),
        full_state.spatial_shape,
    )
    depth_error = functional.smooth_l1_loss(
        counterfactual_depth / voxel_spacing,
        teacher_depth / voxel_spacing,
        reduction="none",
    )
    visible_mass = teacher_visibility.sum()
    depth_loss = (depth_error * teacher_visibility).sum() / visible_mass.clamp_min(1.0)
    visibility_loss = functional.smooth_l1_loss(
        counterfactual_visibility,
        teacher_visibility,
    )
    loss = depth_loss + visibility_loss

    residual_difference = (
        full_evidence.density_residual.detach()
        - subset_evidence.density_residual.detach()
    ).abs()
    diagnostics = {
        "full_evidence_mean": _detached_mean(full_evidence.confidence),
        "subset_evidence_mean": _detached_mean(subset_evidence.confidence),
        "evidence_abs_diff": _detached_mean(
            (full_evidence.confidence - subset_evidence.confidence).abs()
        ),
        "full_completion_gate_mean": _detached_mean(full_evidence.completion_gate),
        "subset_completion_gate_mean": _detached_mean(subset_evidence.completion_gate),
        "shared_coverage": _detached_mean(shared_support),
        "overlap_weight_mean": _detached_mean(overlap_weight),
        "residual_abs_diff": _detached_weighted_mean(
            residual_difference,
            overlap_weight,
        ),
        "counterfactual_depth_drift_voxels": _detached_weighted_mean(
            (counterfactual_depth.detach() - teacher_depth).abs() / voxel_spacing,
            teacher_visibility,
        ),
        "visibility_drift": _detached_mean(
            (counterfactual_visibility.detach() - teacher_visibility).abs()
        ),
    }
    audit_diagnostics: dict[str, float] = {}
    per_sample_diagnostics: tuple[dict[str, float], ...] = ()
    if collect_audit_diagnostics:
        audit_diagnostics = {
            "full_residual_saturation_fraction": _detached_mean(
                full_evidence.density_residual.detach().abs()
                >= residual_saturation_threshold
            ),
            "subset_residual_saturation_fraction": _detached_mean(
                subset_evidence.density_residual.detach().abs()
                >= residual_saturation_threshold
            ),
            **_residual_quantile_diagnostics(
                "full_residual_abs",
                full_evidence.density_residual,
            ),
            **_residual_quantile_diagnostics(
                "subset_residual_abs",
                subset_evidence.density_residual,
            ),
        }
        per_sample_diagnostics = tuple(
            {
                "full_evidence_mean": _detached_mean(full_evidence.confidence[index]),
                "subset_evidence_mean": _detached_mean(subset_evidence.confidence[index]),
                "full_completion_gate_mean": _detached_mean(
                    full_evidence.completion_gate[index]
                ),
                "subset_completion_gate_mean": _detached_mean(
                    subset_evidence.completion_gate[index]
                ),
                "full_residual_abs": _detached_mean(
                    full_evidence.density_residual[index].abs()
                ),
                "subset_residual_abs": _detached_mean(
                    subset_evidence.density_residual[index].abs()
                ),
                "residual_abs_diff": _detached_weighted_mean(
                    residual_difference[index],
                    overlap_weight[index],
                ),
                "shared_coverage": _detached_mean(shared_support[index]),
                "overlap_weight_mean": _detached_mean(overlap_weight[index]),
            }
            for index in range(reference_shape[0])
        )
    return ContextSubsetGeometryResult(
        loss=loss,
        terms={"depth": depth_loss, "visibility": visibility_loss},
        diagnostics=diagnostics,
        audit_diagnostics=audit_diagnostics,
        per_sample_diagnostics=per_sample_diagnostics,
    )


def _mean_metric_voxel_spacing(
    bounds: Tensor,
    spatial_shape: tuple[int, int, int],
) -> Tensor:
    depth, height, width = spatial_shape
    xyz_counts = bounds.new_tensor([width, height, depth])
    spacing = ((bounds[:, 1] - bounds[:, 0]) / xyz_counts).mean(dim=-1)
    return spacing.float().view(-1, 1, 1, 1, 1).clamp_min(1e-6)


def _detached_mean(values: Tensor) -> float:
    return float(values.detach().float().mean().item())


def _detached_weighted_mean(values: Tensor, weights: Tensor) -> float:
    values_float = values.detach().float()
    weights_float = weights.detach().float()
    weight_sum = weights_float.sum()
    if weight_sum.item() == 0.0:
        return 0.0
    return float((values_float * weights_float).sum().div(weight_sum).item())


def _residual_quantile_diagnostics(prefix: str, residual: Tensor) -> dict[str, float]:
    probabilities = torch.arange(0.1, 1.0, 0.1, device=residual.device)
    quantiles = torch.quantile(residual.detach().float().abs().reshape(-1), probabilities)
    return {
        f"{prefix}_p{index * 10:02d}": float(value.item())
        for index, value in enumerate(quantiles, start=1)
    }