Spaces:
Sleeping
Sleeping
File size: 8,282 Bytes
1607c63 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """
Phase 3 β backward-compatibility fix validation.
Five strategies are accepted, each grading rule is independent so the
reward layer can compose a rubric:
field_alias β keep the old field name as an alias to the new one
version_bump β expose the change behind a new API version
deprecation_window β keep the old field, mark it deprecated, document removal
dual_write β emit both the old and new field for a transition period
consumer_patch β coordinate consumer updates (only valid when the
producer cannot retain backward compat, e.g. enum
narrowing where the old values are illegal upstream)
A fix is graded against every consumer in the scenario's service graph.
The result includes per-consumer pass/fail so the reward layer can
penalise partial fixes (breaks β₯1 consumer) without false-rewarding
fixes that happen to satisfy the easy consumer but break the hard one.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List
from .service_graph import CascadeScenario, ConsumerDeclaration
VALID_STRATEGIES = {
"field_alias",
"version_bump",
"deprecation_window",
"dual_write",
"consumer_patch",
}
@dataclass
class FixValidationResult:
"""Outcome of validating one fix proposal against all consumers."""
strategy: str
is_well_formed: bool
is_strategy_acceptable: bool
consumers_passing: List[str] = field(default_factory=list)
consumers_failing: List[str] = field(default_factory=list)
failure_reasons: Dict[str, str] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
@property
def all_consumers_pass(self) -> bool:
return (
self.is_well_formed
and self.is_strategy_acceptable
and len(self.consumers_failing) == 0
and len(self.consumers_passing) > 0
)
# ββ Strategy-specific consumer checks ββββββββββββββββββββββββββββββββββββ
def _check_field_alias(
scenario: CascadeScenario,
spec_patch: Dict[str, Any],
consumer: ConsumerDeclaration,
) -> tuple[bool, str]:
"""Pass if the patch reintroduces every consumed field as an alias."""
aliases = spec_patch.get("aliases") or spec_patch.get("field_aliases") or {}
if not isinstance(aliases, dict) or not aliases:
return False, "patch missing 'aliases' map"
if scenario.scenario_id == "user_email_rename":
# Patch must alias the old name back to the new name
if "email" not in aliases:
return False, "no alias for 'email'"
target = aliases["email"]
if target != "email_address":
return False, f"alias points to '{target}', expected 'email_address'"
# Consumer passes if it still consumes 'email' (alias covers it)
if "email" in consumer.fields_consumed:
return True, ""
return True, "consumer not affected"
if scenario.scenario_id == "orders_status_narrowed":
# Aliasing doesn't help an enum narrowing β reject for affected consumers
affected = consumer.name in scenario.ground_truth_affected
if affected:
return False, "field_alias cannot restore removed enum values"
return True, "consumer not affected"
return False, "unknown scenario for field_alias"
def _check_version_bump(
scenario: CascadeScenario,
spec_patch: Dict[str, Any],
consumer: ConsumerDeclaration,
) -> tuple[bool, str]:
"""Pass if the patch declares both v1 (legacy) and v2 endpoints."""
versions = spec_patch.get("versions") or []
if not isinstance(versions, list) or len(versions) < 2:
return False, "patch missing two-version declaration"
has_legacy = any("v1" in str(v).lower() or "1.0" in str(v) for v in versions)
has_new = any("v2" in str(v).lower() or "2.0" in str(v) for v in versions)
if not (has_legacy and has_new):
return False, "patch must keep v1 alongside v2"
return True, ""
def _check_deprecation_window(
scenario: CascadeScenario,
spec_patch: Dict[str, Any],
consumer: ConsumerDeclaration,
) -> tuple[bool, str]:
"""Pass if the patch keeps the old field/enum and marks it deprecated."""
if scenario.scenario_id == "user_email_rename":
deprecated = spec_patch.get("deprecated_fields") or []
if "email" not in deprecated:
return False, "must list 'email' under deprecated_fields"
return True, ""
if scenario.scenario_id == "orders_status_narrowed":
deprecated_values = spec_patch.get("deprecated_enum_values") or []
for value in ("cancelled", "refunded"):
if value not in deprecated_values:
return False, f"must keep '{value}' as deprecated enum"
return True, ""
return False, "unknown scenario for deprecation_window"
def _check_dual_write(
scenario: CascadeScenario,
spec_patch: Dict[str, Any],
consumer: ConsumerDeclaration,
) -> tuple[bool, str]:
"""Pass if the patch emits both old and new field names simultaneously."""
fields = spec_patch.get("emit_fields") or []
if scenario.scenario_id == "user_email_rename":
if "email" not in fields or "email_address" not in fields:
return False, "must emit both 'email' and 'email_address'"
return True, ""
return False, "dual_write not supported for this scenario"
def _check_consumer_patch(
scenario: CascadeScenario,
spec_patch: Dict[str, Any],
consumer: ConsumerDeclaration,
) -> tuple[bool, str]:
"""Pass if the patch lists every truly-affected consumer to migrate."""
migrate = spec_patch.get("consumers_to_migrate") or []
if not isinstance(migrate, list):
return False, "consumers_to_migrate must be a list"
if consumer.name in scenario.ground_truth_affected:
if consumer.name not in migrate:
return False, "affected consumer missing from migration list"
return True, ""
return True, ""
_STRATEGY_CHECKERS = {
"field_alias": _check_field_alias,
"version_bump": _check_version_bump,
"deprecation_window": _check_deprecation_window,
"dual_write": _check_dual_write,
"consumer_patch": _check_consumer_patch,
}
# ββ Public entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def validate_fix(
scenario: CascadeScenario,
strategy: str,
spec_patch: Dict[str, Any],
) -> FixValidationResult:
"""Validate a fix proposal against all consumers in the scenario."""
notes: List[str] = []
if not isinstance(spec_patch, dict):
return FixValidationResult(
strategy=strategy,
is_well_formed=False,
is_strategy_acceptable=False,
notes=["spec_patch must be a JSON object"],
)
if not strategy or strategy not in VALID_STRATEGIES:
return FixValidationResult(
strategy=strategy,
is_well_formed=False,
is_strategy_acceptable=False,
notes=[
f"strategy '{strategy}' not in {sorted(VALID_STRATEGIES)}"
],
)
is_acceptable = strategy in scenario.acceptable_fix_strategies
if not is_acceptable:
notes.append(
f"'{strategy}' not in acceptable strategies "
f"{scenario.acceptable_fix_strategies} for this scenario"
)
checker = _STRATEGY_CHECKERS[strategy]
passing: List[str] = []
failing: List[str] = []
reasons: Dict[str, str] = {}
for consumer in scenario.consumers:
ok, reason = checker(scenario, spec_patch, consumer)
if ok:
passing.append(consumer.name)
else:
failing.append(consumer.name)
reasons[consumer.name] = reason
return FixValidationResult(
strategy=strategy,
is_well_formed=True,
is_strategy_acceptable=is_acceptable,
consumers_passing=passing,
consumers_failing=failing,
failure_reasons=reasons,
notes=notes,
)
|