from __future__ import annotations import random from dataclasses import dataclass from typing import Iterable, Iterator, Sequence from dovla_cil.data.datasets import CILDataset from dovla_cil.data.schema import CILRecord from dovla_cil.data.sharding import group_records class BatchIndices(list[int]): """Batch index list with same-state ranking metadata.""" def __init__( self, indices: Iterable[int], *, group_ids: Iterable[str] = (), pair_indices: Iterable[tuple[int, int]] = (), ) -> None: super().__init__(int(index) for index in indices) self.group_ids = list(group_ids) self.pair_indices = [tuple(pair) for pair in pair_indices] class GroupAwareBatchSampler: """Yield batches that preserve CIL same-state group structure. Modes: - `full_group`: include complete groups, up to `batch_groups` groups per batch. - `pairs`: sample positive/negative same-group pairs for ranking losses. - `mixed`: sample `records_per_group` records per group while preferring reward-diverse sets. """ def __init__( self, dataset: CILDataset, *, mode: str = "full_group", batch_groups: int = 1, records_per_group: int | None = None, pair_count_per_group: int = 1, shuffle: bool = False, seed: int = 17, reward_margin: float = 1e-6, group_ids: Sequence[str] | None = None, ) -> None: if mode not in {"full_group", "pairs", "mixed"}: raise ValueError("mode must be 'full_group', 'pairs', or 'mixed'") if batch_groups <= 0: raise ValueError("batch_groups must be positive") if records_per_group is not None and records_per_group <= 0: raise ValueError("records_per_group must be positive when provided") if pair_count_per_group <= 0: raise ValueError("pair_count_per_group must be positive") if reward_margin < 0: raise ValueError("reward_margin must be non-negative") self.dataset = dataset self.mode = mode self.batch_groups = int(batch_groups) self.records_per_group = records_per_group self.pair_count_per_group = int(pair_count_per_group) self.shuffle = bool(shuffle) self.seed = int(seed) self.reward_margin = float(reward_margin) unknown = [group_id for group_id in (group_ids or []) if group_id not in dataset.group_ids] if unknown: raise KeyError(f"Unknown CIL groups for sampler: {unknown[:3]}") self.group_ids = list(group_ids) if group_ids is not None else list(dataset.group_ids) def __iter__(self) -> Iterator[BatchIndices]: rng = random.Random(self.seed) group_ids = list(self.group_ids) if self.shuffle: rng.shuffle(group_ids) for offset in range(0, len(group_ids), self.batch_groups): selected_group_ids = group_ids[offset : offset + self.batch_groups] if self.mode == "full_group": yield self._full_group_batch(selected_group_ids) elif self.mode == "pairs": yield self._pair_batch(selected_group_ids, rng) else: yield self._mixed_batch(selected_group_ids, rng) def __len__(self) -> int: if not self.group_ids: return 0 return (len(self.group_ids) + self.batch_groups - 1) // self.batch_groups def _full_group_batch(self, group_ids: Sequence[str]) -> BatchIndices: indices: list[int] = [] for group_id in group_ids: indices.extend(self.dataset.group_indices(group_id)) pair_indices = _local_pair_indices( [self.dataset[index] for index in indices], reward_margin=self.reward_margin ) return BatchIndices(indices, group_ids=group_ids, pair_indices=pair_indices) def _pair_batch(self, group_ids: Sequence[str], rng: random.Random) -> BatchIndices: indices: list[int] = [] pair_indices: list[tuple[int, int]] = [] for group_id in group_ids: group_indices = self.dataset.group_indices(group_id) records = [self.dataset[index] for index in group_indices] global_pairs = _sample_reward_ordered_pairs( group_indices, records, pair_count=self.pair_count_per_group, reward_margin=self.reward_margin, rng=rng, ) local_by_global: dict[int, int] = {} for better, worse in global_pairs: if better not in local_by_global: local_by_global[better] = len(indices) indices.append(better) if worse not in local_by_global: local_by_global[worse] = len(indices) indices.append(worse) pair_indices.append((local_by_global[better], local_by_global[worse])) return BatchIndices(indices, group_ids=group_ids, pair_indices=pair_indices) def _mixed_batch(self, group_ids: Sequence[str], rng: random.Random) -> BatchIndices: indices: list[int] = [] for group_id in group_ids: group_indices = self.dataset.group_indices(group_id) records = [self.dataset[index] for index in group_indices] selected = _sample_records_from_group( group_indices, records, count=self.records_per_group or len(group_indices), reward_margin=self.reward_margin, rng=rng, ) indices.extend(selected) pair_indices = _local_pair_indices( [self.dataset[index] for index in indices], reward_margin=self.reward_margin ) return BatchIndices(indices, group_ids=group_ids, pair_indices=pair_indices) @dataclass(frozen=True) class GroupBatchSampler: """Backward-compatible complete-group sampler over materialized records.""" records: tuple[CILRecord, ...] shuffle: bool = False seed: int = 17 @classmethod def from_records( cls, records: Iterable[CILRecord], *, shuffle: bool = False, seed: int = 17 ) -> "GroupBatchSampler": return cls(records=tuple(records), shuffle=shuffle, seed=seed) def __iter__(self) -> Iterator[list[CILRecord]]: groups = list(group_records(self.records).values()) if self.shuffle: rng = random.Random(self.seed) rng.shuffle(groups) yield from groups def _sample_reward_ordered_pairs( group_indices: list[int], records: list[CILRecord], *, pair_count: int, reward_margin: float, rng: random.Random, ) -> list[tuple[int, int]]: candidates: list[tuple[int, int]] = [] for left in range(len(records)): for right in range(len(records)): if left == right: continue reward_left = _ranking_reward(records[left]) reward_right = _ranking_reward(records[right]) if reward_left > reward_right + reward_margin: candidates.append((group_indices[left], group_indices[right])) rng.shuffle(candidates) return candidates[:pair_count] def _sample_records_from_group( group_indices: list[int], records: list[CILRecord], *, count: int, reward_margin: float, rng: random.Random, ) -> list[int]: if count >= len(group_indices): return list(group_indices) pairs = _sample_reward_ordered_pairs( group_indices, records, pair_count=max(1, count // 2), reward_margin=reward_margin, rng=rng, ) selected: list[int] = [] for better, worse in pairs: for index in (better, worse): if index not in selected: selected.append(index) if len(selected) >= count: return selected remaining = [index for index in group_indices if index not in selected] rng.shuffle(remaining) return selected + remaining[: max(0, count - len(selected))] def _local_pair_indices(records: list[CILRecord], *, reward_margin: float) -> list[tuple[int, int]]: pairs: list[tuple[int, int]] = [] by_group = group_records(records) offset_by_record_id = {record.record_id: index for index, record in enumerate(records)} for group in by_group.values(): for left in range(len(group)): for right in range(len(group)): if left == right: continue if _ranking_reward(group[left]) > _ranking_reward(group[right]) + reward_margin: pairs.append( ( offset_by_record_id[group[left].record_id], offset_by_record_id[group[right].record_id], ) ) return pairs def _ranking_reward(record: CILRecord) -> float: return record.reward.score