Files
ecm_sync_system/scripts/analyze_state_signatures.py
T
strepsiades 4a9c444b10 first commit
2026-03-09 16:31:42 +08:00

212 lines
7.2 KiB
Python

"""Analyze whether state definitions are uniquely identifiable by field signatures.
This script is intentionally placed under scripts/ (but is NOT a pytest test module)
so it can be used as a repeatable, versioned check.
It expands each state's possible value set for:
(binding_status, action, status) and optionally data_id_exist.
Wildcard semantics:
- '*' means "any enum value" for enum fields.
- list means "any of these values".
For data_id_exist (optional):
- If present: bool | '*' | [bool, ...]
- If missing: treated as unknown by default, or as wildcard with --data-id-missing-wildcard.
Usage:
python scripts/analyze_state_signatures.py
python scripts/analyze_state_signatures.py --with-data-id
python scripts/analyze_state_signatures.py --fail-on-overlap
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CFG = REPO_ROOT / "sync_state_machine" / "config" / "node_state_machine.yaml"
# Ensure repository root is importable when running as a script.
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
@dataclass(frozen=True)
class Collision:
signature: Tuple[Any, ...]
states: Tuple[str, ...]
def _load_yaml(path: Path) -> Dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise TypeError(f"Top-level YAML must be a mapping: {path}")
return data
def _import_enum_names() -> Tuple[Set[str], Set[str], Set[str]]:
# Validate enum NAMES; values are implementation details.
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
binding = {e.name for e in BindingStatus}
action = {e.name for e in SyncAction}
status = {e.name for e in SyncStatus}
return binding, action, status
def _expand_enum_field(value: Any, *, all_values: Set[str], field_path: str) -> Set[str]:
if isinstance(value, str):
if value == "*":
return set(all_values)
return {value}
if isinstance(value, list):
expanded: Set[str] = set()
for i, item in enumerate(value):
if not isinstance(item, str):
raise TypeError(f"{field_path}[{i}] must be a string")
if item == "*":
expanded |= set(all_values)
else:
expanded.add(item)
return expanded
raise TypeError(f"{field_path} must be str | list[str]")
def _expand_data_id_exist(
value: Any,
*,
field_path: str,
missing_as_wildcard: bool,
) -> Set[Optional[bool]]:
if value is None:
return {True, False} if missing_as_wildcard else {None}
if value == "*":
return {True, False}
if isinstance(value, bool):
return {value}
if isinstance(value, list):
out: Set[Optional[bool]] = set()
for i, item in enumerate(value):
if item == "*":
out |= {True, False}
elif isinstance(item, bool):
out.add(item)
else:
raise TypeError(f"{field_path}[{i}] must be bool or '*'")
return out
raise TypeError(f"{field_path} must be bool | '*' | list[bool]")
def _cartesian_product(*sets: Sequence[Any]) -> Iterable[Tuple[Any, ...]]:
if not sets:
return []
acc: List[Tuple[Any, ...]] = [()]
for s in sets:
nxt: List[Tuple[Any, ...]] = []
for prefix in acc:
for v in s:
nxt.append(prefix + (v,))
acc = nxt
return acc
def _collect_state_signature_sets(
cfg: Mapping[str, Any],
*,
with_data_id: bool,
data_id_missing_as_wildcard: bool,
) -> Dict[str, Set[Tuple[Any, ...]]]:
states = cfg.get("states")
if not isinstance(states, dict):
raise TypeError("cfg.states must be a mapping")
binding_all, action_all, status_all = _import_enum_names()
out: Dict[str, Set[Tuple[Any, ...]]] = {}
for sid, sdef in states.items():
if not isinstance(sdef, dict):
continue
binding = _expand_enum_field(sdef.get("binding_status"), all_values=binding_all, field_path=f"states.{sid}.binding_status")
action = _expand_enum_field(sdef.get("action"), all_values=action_all, field_path=f"states.{sid}.action")
status = _expand_enum_field(sdef.get("status"), all_values=status_all, field_path=f"states.{sid}.status")
if with_data_id:
data_id = _expand_data_id_exist(
sdef.get("data_id_exist"),
field_path=f"states.{sid}.data_id_exist",
missing_as_wildcard=data_id_missing_as_wildcard,
)
signatures = set(_cartesian_product(sorted(binding), sorted(action), sorted(status), sorted(data_id, key=lambda x: str(x))))
else:
signatures = set(_cartesian_product(sorted(binding), sorted(action), sorted(status)))
out[str(sid)] = signatures
return out
def _find_collisions(state_signatures: Mapping[str, Set[Tuple[Any, ...]]]) -> List[Collision]:
index: Dict[Tuple[Any, ...], List[str]] = {}
for sid, sigs in state_signatures.items():
for sig in sigs:
index.setdefault(sig, []).append(sid)
collisions: List[Collision] = []
for sig, sids in index.items():
if len(sids) > 1:
collisions.append(Collision(signature=sig, states=tuple(sorted(sids))))
collisions.sort(key=lambda c: (-len(c.states), c.signature))
return collisions
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Analyze state signature overlaps")
parser.add_argument("--config", default=str(DEFAULT_CFG), help="Path to node_state_machine.yaml")
parser.add_argument("--with-data-id", action="store_true", help="Include data_id_exist in signature")
parser.add_argument(
"--data-id-missing-wildcard",
action="store_true",
help="If data_id_exist is missing, treat it as wildcard (True/False) instead of unknown",
)
parser.add_argument("--fail-on-overlap", action="store_true", help="Exit non-zero if collisions exist")
parser.add_argument("--show", type=int, default=30, help="Max collisions to print")
args = parser.parse_args(list(argv) if argv is not None else None)
cfg = _load_yaml(Path(args.config))
state_sigs = _collect_state_signature_sets(
cfg,
with_data_id=bool(args.with_data_id),
data_id_missing_as_wildcard=bool(args.data_id_missing_wildcard),
)
collisions = _find_collisions(state_sigs)
dim = 4 if args.with_data_id else 3
print(f"Config: {args.config}")
print(f"Signature dims: {dim} ({'binding,action,status,data_id' if args.with_data_id else 'binding,action,status'})")
print(f"States analyzed: {len(state_sigs)}")
print(f"Collisions: {len(collisions)}")
if collisions:
print("\nTop collisions:")
for c in collisions[: args.show]:
print(f" {c.signature} -> {', '.join(c.states)}")
if args.fail_on_overlap and collisions:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())