manual-sync chart-synthesis 2026-07-02T22:45:08Z workspace/dovla_cil/generation/tangent_chart_synthesis.py
Browse files
workspace/dovla_cil/generation/tangent_chart_synthesis.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from collections.abc import Sequence
|
| 6 |
+
from dataclasses import asdict, dataclass, field
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from dovla_cil.generation.tangent_spline_cvae import (
|
| 10 |
+
SplineTangentRow,
|
| 11 |
+
_aggregate_rows,
|
| 12 |
+
_evaluate_generated_group,
|
| 13 |
+
_split_rows,
|
| 14 |
+
_validate_eval_args,
|
| 15 |
+
build_spline_tangent_rows,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class ChartSynthesisConfig:
|
| 21 |
+
obs_dim: int = 96
|
| 22 |
+
text_dim: int = 64
|
| 23 |
+
val_fraction: float = 0.2
|
| 24 |
+
seed: int = 0
|
| 25 |
+
neighbor_pool: int = 64
|
| 26 |
+
direct_count: int = 12
|
| 27 |
+
barycentric_windows: tuple[int, ...] = field(default_factory=lambda: (4, 8, 16, 32))
|
| 28 |
+
barycentric_temperature: float = 0.5
|
| 29 |
+
utility_weight: float = 0.0
|
| 30 |
+
order: str = "direct_first"
|
| 31 |
+
same_task_only: bool = True
|
| 32 |
+
|
| 33 |
+
def __post_init__(self) -> None:
|
| 34 |
+
if self.obs_dim <= 0:
|
| 35 |
+
raise ValueError("obs_dim must be positive")
|
| 36 |
+
if self.text_dim <= 0:
|
| 37 |
+
raise ValueError("text_dim must be positive")
|
| 38 |
+
if not 0.0 < self.val_fraction < 1.0:
|
| 39 |
+
raise ValueError("val_fraction must be in (0, 1)")
|
| 40 |
+
if self.neighbor_pool <= 0:
|
| 41 |
+
raise ValueError("neighbor_pool must be positive")
|
| 42 |
+
if self.direct_count < 0:
|
| 43 |
+
raise ValueError("direct_count must be non-negative")
|
| 44 |
+
if any(window <= 0 for window in self.barycentric_windows):
|
| 45 |
+
raise ValueError("barycentric_windows must contain positive values")
|
| 46 |
+
if self.barycentric_temperature <= 0.0:
|
| 47 |
+
raise ValueError("barycentric_temperature must be positive")
|
| 48 |
+
if self.utility_weight < 0.0:
|
| 49 |
+
raise ValueError("utility_weight must be non-negative")
|
| 50 |
+
if self.order not in {"direct_first", "means_first", "interleave"}:
|
| 51 |
+
raise ValueError("order must be direct_first, means_first, or interleave")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def evaluate_chart_synthesis(
|
| 55 |
+
targets: Sequence[dict[str, Any]],
|
| 56 |
+
*,
|
| 57 |
+
config: ChartSynthesisConfig | None = None,
|
| 58 |
+
k_values: Sequence[int] = (1, 2, 4, 8, 16),
|
| 59 |
+
thresholds: Sequence[float] = (0.05, 0.1, 0.2, 0.4),
|
| 60 |
+
) -> dict[str, Any]:
|
| 61 |
+
"""Evaluate local chart synthesis from train-only positive tangents.
|
| 62 |
+
|
| 63 |
+
Local-atlas retrieval asks whether a heldout positive tangent already appears
|
| 64 |
+
among nearby train positives. This diagnostic takes one step toward a
|
| 65 |
+
generator: it keeps a few nearest chart atoms, then adds barycentric means
|
| 66 |
+
over increasingly wide local neighborhoods. These means are still
|
| 67 |
+
deployment-clean because they use only train positives and the current
|
| 68 |
+
observation-language condition.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
config = config or ChartSynthesisConfig()
|
| 72 |
+
_validate_eval_args(k_values, thresholds)
|
| 73 |
+
rows, task_ids, code_dim, horizon, action_dim = build_spline_tangent_rows(
|
| 74 |
+
targets,
|
| 75 |
+
obs_dim=config.obs_dim,
|
| 76 |
+
text_dim=config.text_dim,
|
| 77 |
+
)
|
| 78 |
+
if not rows:
|
| 79 |
+
raise ValueError("no usable spline tangent rows")
|
| 80 |
+
split = _split_rows(rows, val_fraction=config.val_fraction, seed=config.seed)
|
| 81 |
+
train_positive = [
|
| 82 |
+
row for row in split["train"]
|
| 83 |
+
if row.example.tangent_label == "positive"
|
| 84 |
+
]
|
| 85 |
+
if not train_positive:
|
| 86 |
+
raise ValueError("no train positive tangents")
|
| 87 |
+
train_positive_by_task: dict[str, list[SplineTangentRow]] = {}
|
| 88 |
+
for row in train_positive:
|
| 89 |
+
train_positive_by_task.setdefault(row.example.task_id, []).append(row)
|
| 90 |
+
|
| 91 |
+
max_k = max(int(k) for k in k_values)
|
| 92 |
+
val_by_group: dict[str, list[SplineTangentRow]] = {}
|
| 93 |
+
for row in split["val"]:
|
| 94 |
+
val_by_group.setdefault(row.example.group_id, []).append(row)
|
| 95 |
+
|
| 96 |
+
groups: list[dict[str, Any]] = []
|
| 97 |
+
for group_id, group_rows in sorted(val_by_group.items()):
|
| 98 |
+
positives = [
|
| 99 |
+
row.example for row in group_rows
|
| 100 |
+
if row.example.tangent_label == "positive"
|
| 101 |
+
]
|
| 102 |
+
if not positives:
|
| 103 |
+
continue
|
| 104 |
+
negatives = [
|
| 105 |
+
row.example for row in group_rows
|
| 106 |
+
if row.example.tangent_label == "negative"
|
| 107 |
+
]
|
| 108 |
+
proposals = _select_chart_proposals(
|
| 109 |
+
group_rows[0],
|
| 110 |
+
train_positive=train_positive,
|
| 111 |
+
train_positive_by_task=train_positive_by_task,
|
| 112 |
+
config=config,
|
| 113 |
+
max_k=max_k,
|
| 114 |
+
)
|
| 115 |
+
groups.append(
|
| 116 |
+
_evaluate_generated_group(
|
| 117 |
+
group_id,
|
| 118 |
+
task_id=group_rows[0].example.task_id,
|
| 119 |
+
proposals=proposals,
|
| 120 |
+
positives=positives,
|
| 121 |
+
negatives=negatives,
|
| 122 |
+
k_values=k_values,
|
| 123 |
+
thresholds=thresholds,
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
"report_type": "positive_tangent_chart_synthesis_eval",
|
| 129 |
+
"metric_scope": "offline_support_proxy",
|
| 130 |
+
"note": (
|
| 131 |
+
"Chart synthesis proposes train-only positive tangents from local "
|
| 132 |
+
"atlas neighborhoods plus barycentric chart means. It tests whether "
|
| 133 |
+
"local positive support is better represented as chart coordinates "
|
| 134 |
+
"than as raw prototype replay."
|
| 135 |
+
),
|
| 136 |
+
"config": {
|
| 137 |
+
**asdict(config),
|
| 138 |
+
"barycentric_windows": list(config.barycentric_windows),
|
| 139 |
+
},
|
| 140 |
+
"task_ids": list(task_ids),
|
| 141 |
+
"code_dim": code_dim,
|
| 142 |
+
"horizon": horizon,
|
| 143 |
+
"action_dim": action_dim,
|
| 144 |
+
"num_examples": len(rows),
|
| 145 |
+
"num_groups": len({row.example.group_id for row in rows}),
|
| 146 |
+
"num_train_examples": len(split["train"]),
|
| 147 |
+
"num_val_examples": len(split["val"]),
|
| 148 |
+
"num_train_positive": len(train_positive),
|
| 149 |
+
"num_val_groups_with_positive": len(groups),
|
| 150 |
+
"label_counts": dict(Counter(row.example.tangent_label for row in rows)),
|
| 151 |
+
"train_positive_by_task": {
|
| 152 |
+
task_id: len(train_positive_by_task.get(task_id, []))
|
| 153 |
+
for task_id in task_ids
|
| 154 |
+
},
|
| 155 |
+
"overall": _aggregate_rows(groups, k_values=k_values, thresholds=thresholds),
|
| 156 |
+
"per_task": {
|
| 157 |
+
task_id: _aggregate_rows(
|
| 158 |
+
[group for group in groups if group["task_id"] == task_id],
|
| 159 |
+
k_values=k_values,
|
| 160 |
+
thresholds=thresholds,
|
| 161 |
+
)
|
| 162 |
+
for task_id in task_ids
|
| 163 |
+
},
|
| 164 |
+
"groups": groups,
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _select_chart_proposals(
|
| 169 |
+
query: SplineTangentRow,
|
| 170 |
+
*,
|
| 171 |
+
train_positive: Sequence[SplineTangentRow],
|
| 172 |
+
train_positive_by_task: dict[str, list[SplineTangentRow]],
|
| 173 |
+
config: ChartSynthesisConfig,
|
| 174 |
+
max_k: int,
|
| 175 |
+
) -> list[list[float]]:
|
| 176 |
+
if config.same_task_only:
|
| 177 |
+
pool = train_positive_by_task.get(query.example.task_id, []) or list(train_positive)
|
| 178 |
+
else:
|
| 179 |
+
pool = list(train_positive)
|
| 180 |
+
ranked = sorted(
|
| 181 |
+
(
|
| 182 |
+
(_condition_distance(query.condition, row.condition), row)
|
| 183 |
+
for row in pool
|
| 184 |
+
),
|
| 185 |
+
key=lambda item: (item[0], -item[1].example.delta_utility, item[1].example.group_id),
|
| 186 |
+
)[: max(int(config.neighbor_pool), max_k)]
|
| 187 |
+
direct_rows = [row for _, row in ranked[: int(config.direct_count)]]
|
| 188 |
+
direct = [_flat_delta(row) for row in direct_rows]
|
| 189 |
+
means = [
|
| 190 |
+
_weighted_barycenter(
|
| 191 |
+
ranked[: min(int(window), len(ranked))],
|
| 192 |
+
temperature=config.barycentric_temperature,
|
| 193 |
+
utility_weight=config.utility_weight,
|
| 194 |
+
)
|
| 195 |
+
for window in config.barycentric_windows
|
| 196 |
+
if ranked
|
| 197 |
+
]
|
| 198 |
+
means = [mean for mean in means if mean]
|
| 199 |
+
if config.order == "means_first":
|
| 200 |
+
proposals = means + direct
|
| 201 |
+
elif config.order == "interleave":
|
| 202 |
+
proposals = []
|
| 203 |
+
for index in range(max(len(direct), len(means))):
|
| 204 |
+
if index < len(direct):
|
| 205 |
+
proposals.append(direct[index])
|
| 206 |
+
if index < len(means):
|
| 207 |
+
proposals.append(means[index])
|
| 208 |
+
else:
|
| 209 |
+
proposals = direct + means
|
| 210 |
+
|
| 211 |
+
used_direct = len(direct)
|
| 212 |
+
while len(proposals) < max_k and used_direct < len(ranked):
|
| 213 |
+
proposals.append(_flat_delta(ranked[used_direct][1]))
|
| 214 |
+
used_direct += 1
|
| 215 |
+
return proposals[:max_k]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _weighted_barycenter(
|
| 219 |
+
ranked: Sequence[tuple[float, SplineTangentRow]],
|
| 220 |
+
*,
|
| 221 |
+
temperature: float,
|
| 222 |
+
utility_weight: float,
|
| 223 |
+
) -> list[float]:
|
| 224 |
+
if not ranked:
|
| 225 |
+
return []
|
| 226 |
+
nonzero = sorted(distance for distance, _ in ranked if distance > 1.0e-9)
|
| 227 |
+
local_scale = nonzero[len(nonzero) // 2] if nonzero else 1.0
|
| 228 |
+
scale = max(local_scale * float(temperature), 1.0e-6)
|
| 229 |
+
scores = [
|
| 230 |
+
-float(distance) / scale + float(utility_weight) * row.example.delta_utility
|
| 231 |
+
for distance, row in ranked
|
| 232 |
+
]
|
| 233 |
+
max_score = max(scores)
|
| 234 |
+
weights = [math.exp(score - max_score) for score in scores]
|
| 235 |
+
total = sum(weights) or 1.0
|
| 236 |
+
vectors = [_flat_delta(row) for _, row in ranked]
|
| 237 |
+
size = min(len(vector) for vector in vectors)
|
| 238 |
+
return [
|
| 239 |
+
sum(weight * vector[index] for weight, vector in zip(weights, vectors, strict=True))
|
| 240 |
+
/ total
|
| 241 |
+
for index in range(size)
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _condition_distance(left: Sequence[float], right: Sequence[float]) -> float:
|
| 246 |
+
return _rms_distance(left, right)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _flat_delta(row: SplineTangentRow) -> list[float]:
|
| 250 |
+
return row.example.flat_delta
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _rms_distance(left: Sequence[float], right: Sequence[float]) -> float:
|
| 254 |
+
size = min(len(left), len(right))
|
| 255 |
+
if size <= 0:
|
| 256 |
+
return 0.0
|
| 257 |
+
return math.sqrt(
|
| 258 |
+
sum((float(left[index]) - float(right[index])) ** 2 for index in range(size))
|
| 259 |
+
/ size
|
| 260 |
+
)
|