Optimize cutover compare/monitor per the 2026-10-12 plan.

Correctness (A): normalize port-map names with the template rules (snapshot v2,
v1 replays unchanged); protect mappings/templates that are referenced; stop
dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated;
validate baseline ownership on project create; one canonical key form; fix
built-in template tolerances and upgrade old packaged values in place.

Cross-vendor identity (B): single identity pipeline for every path, per-side and
per-vendor interface rules, value maps, filters after normalization, mapping
pre-flight for compare and cutover projects, SQL eligibility fixes.

Data quality (V): no_data / suspect_parse / not_collected sheet states, command
slice coverage, directional compare modes, value-sorted duplicate pairing,
before-side raw names in results and CSV.

Robustness (H): bounded auto-run history, background evaluate/finish with
polling, manual route tables out of the acceptance gate, baseline row cache,
scoped duplicate-key failures, merged HF tasks with a per-NE session cap,
same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red
tickets.

Frontend: pre-flight report, mapping table view, template editor support for the
new rules, data-state banners. Docs: canonical field contract.

Not included: Huawei status parsers (no real samples), SQL-path route compare.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 11:51:55 +08:00
parent fff7d63b9b
commit 315539e0fd
59 changed files with 6483 additions and 487 deletions

View file

@ -137,6 +137,8 @@ def field_rule_map(rules: Sequence[Mapping[str, Any]] | None) -> dict[str, dict[
name = str(raw.get("field") or "").strip()
if not name:
continue
if str(raw.get("side") or "").strip():
continue # side-scoped rules feed the identity pipeline, not the compare
out[name] = dict(raw)
return out
@ -210,6 +212,50 @@ def _parse_float(text: str) -> float | None:
return None
DIRECTIONAL_MODES = ("not_worse", "numeric_min", "numeric_max")
def _rank_index(value: str, rank: Sequence[Any]) -> int | None:
wanted = value.strip().lower()
for i, item in enumerate(rank or []):
if scalar_text(item).strip().lower() == wanted:
return i
return None
def _tolerance(rule: Mapping[str, Any]) -> float:
try:
return float(rule.get("tolerance") or 0)
except (TypeError, ValueError):
return 0.0
def _directional_verdict(bv: str, av: str, rule: Mapping[str, Any]) -> tuple[bool, str]:
"""``(equal_or_better, reason)`` for not_worse / numeric_min / numeric_max."""
mode = str(rule.get("compare") or "").strip().lower()
if mode == "not_worse":
rank = rule.get("rank") or []
bi, ai = _rank_index(bv, rank), _rank_index(av, rank)
if bi is None or ai is None:
return bv == av, "neq"
return ai <= bi, (f"worse: {bv}→{av}" if ai > bi else "")
bn, an = _parse_float(bv), _parse_float(av)
if bn is None or an is None:
return bv == av, "neq"
tol = _tolerance(rule)
if mode == "numeric_min":
floor = bn * (1 - tol / 100.0)
if an >= floor:
return True, ""
drop = (bn - an) / abs(bn) * 100.0 if bn else 0.0
return False, f"drop {drop:.0f}% > {tol:g}%"
ceil = bn * (1 + tol / 100.0)
if an <= ceil:
return True, ""
rise = (an - bn) / abs(bn) * 100.0 if bn else 0.0
return False, f"rise {rise:.0f}% > {tol:g}%"
def explain_diff(
before: Any,
after: Any,
@ -222,6 +268,8 @@ def explain_diff(
bv = normalize_value(before, norm)
av = normalize_value(after, norm)
mode = str(rule.get("compare") or "eq").strip().lower() or "eq"
if mode in DIRECTIONAL_MODES:
return _directional_verdict(bv, av, rule)[1]
if mode in ("percent", "pct", "rel"):
bn = _parse_float(bv)
an = _parse_float(av)
@ -262,6 +310,8 @@ def values_equal(
mode = str(rule.get("compare") or "eq").strip().lower() or "eq"
if mode in ("ignore", "skip", "off"):
return True
if mode in DIRECTIONAL_MODES:
return _directional_verdict(bv, av, rule)[0]
if mode in ("numeric", "number", "int", "float", "percent", "pct", "rel"):
bn = _parse_float(bv)
an = _parse_float(av)