feat: add guarded production Flow v2 authoritative simulation
This commit is contained in:
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from contextlib import nullcontext
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -26,7 +27,7 @@ from app.canonical_operations import (
|
|||||||
opportunity_ids_from_work_seeds,
|
opportunity_ids_from_work_seeds,
|
||||||
partition_canonical_items,
|
partition_canonical_items,
|
||||||
)
|
)
|
||||||
from app.opportunity_next_action_service import get_opportunity_next_actions
|
from app.opportunity_next_action_service import get_opportunity_next_actions, get_opportunity_next_actions_for_mode
|
||||||
from app.document_reconciliation_service import active_document_link_exclusion_sql
|
from app.document_reconciliation_service import active_document_link_exclusion_sql
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ def _norm_identity(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _identity_tokens(value: Any) -> set[str]:
|
def _identity_tokens(value: Any) -> set[str]:
|
||||||
return {
|
result = {
|
||||||
token
|
token
|
||||||
for token in _norm_identity(value).split()
|
for token in _norm_identity(value).split()
|
||||||
if len(token) >= 3 and token not in _IDENTITY_STOPWORDS
|
if len(token) >= 3 and token not in _IDENTITY_STOPWORDS
|
||||||
@@ -302,7 +303,9 @@ def _attach_operation_urls(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|||||||
return _sanitize_operation_identities(cleaned)
|
return _sanitize_operation_identities(cleaned)
|
||||||
|
|
||||||
|
|
||||||
def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
def get_operations_summary(
|
||||||
|
limit: int = 24, *, flow_mode: str | None = None, connection: Any = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Build the /operations work queue summary.
|
"""Build the /operations work queue summary.
|
||||||
|
|
||||||
/operations is intentionally not a mini-dashboard. It returns a compact
|
/operations is intentionally not a mini-dashboard. It returns a compact
|
||||||
@@ -315,7 +318,8 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
|||||||
# after canonicalization. This prevents duplicate rows from consuming the
|
# after canonicalization. This prevents duplicate rows from consuming the
|
||||||
# display limit while still avoiding an unbounded history scan.
|
# display limit while still avoiding an unbounded history scan.
|
||||||
candidate_limit = max(200, min(display_limit * 20, 1000))
|
candidate_limit = max(200, min(display_limit * 20, 1000))
|
||||||
with engine.begin() as conn:
|
active_flow_mode = str(flow_mode if flow_mode is not None else settings.blif_flow_v2_mode or "off").strip().lower()
|
||||||
|
with (nullcontext(connection) if connection is not None else engine.begin()) as conn:
|
||||||
counts = conn.execute(text("""
|
counts = conn.execute(text("""
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM opportunities WHERE status = 'open')::int AS open_opportunities,
|
(SELECT COUNT(*) FROM opportunities WHERE status = 'open')::int AS open_opportunities,
|
||||||
@@ -675,9 +679,9 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
|||||||
# In authoritative mode the factual projection, not a legacy task/stage,
|
# In authoritative mode the factual projection, not a legacy task/stage,
|
||||||
# selects material processes that have current business work. Synthetic
|
# selects material processes that have current business work. Synthetic
|
||||||
# rows are read-model seeds only and are never persisted.
|
# rows are read-model seeds only and are never persisted.
|
||||||
if str(settings.blif_flow_v2_mode or "off").strip().lower() == "authoritative":
|
if active_flow_mode == "authoritative":
|
||||||
seeded = {str(row.get("opportunity_id") or "") for row in seed_rows}
|
seeded = {str(row.get("opportunity_id") or "") for row in seed_rows}
|
||||||
with engine.connect() as conn:
|
with (nullcontext(connection) if connection is not None else engine.connect()) as conn:
|
||||||
v2_seeds = conn.execute(text("""
|
v2_seeds = conn.execute(text("""
|
||||||
SELECT p.opportunity_id::text, p.business_next_action, p.derived_at,
|
SELECT p.opportunity_id::text, p.business_next_action, p.derived_at,
|
||||||
o.title, o.value_amount, o.currency, o.lifecycle_state,
|
o.title, o.value_amount, o.currency, o.lifecycle_state,
|
||||||
@@ -713,7 +717,9 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
|||||||
row.update({key: timeline.get(key) for key in ("latest_public_inbound", "latest_public_outbound")})
|
row.update({key: timeline.get(key) for key in ("latest_public_inbound", "latest_public_outbound")})
|
||||||
normalized_rows = _normalise_work_item_intent(seed_rows)
|
normalized_rows = _normalise_work_item_intent(seed_rows)
|
||||||
opportunity_ids = opportunity_ids_from_work_seeds(normalized_rows)
|
opportunity_ids = opportunity_ids_from_work_seeds(normalized_rows)
|
||||||
decisions = get_opportunity_next_actions(opportunity_ids) if opportunity_ids else {}
|
decisions = (get_opportunity_next_actions_for_mode(
|
||||||
|
opportunity_ids, flow_mode=active_flow_mode, connection=connection,
|
||||||
|
) if opportunity_ids else {})
|
||||||
projection = canonicalize_operations(normalized_rows, decisions, evidence_rows=[dict(row) for row in evidence_rows])
|
projection = canonicalize_operations(normalized_rows, decisions, evidence_rows=[dict(row) for row in evidence_rows])
|
||||||
canonical_items = _attach_operation_urls(list(projection["items"]))
|
canonical_items = _attach_operation_urls(list(projection["items"]))
|
||||||
partition = partition_canonical_items(canonical_items, display_limit=display_limit)
|
partition = partition_canonical_items(canonical_items, display_limit=display_limit)
|
||||||
@@ -758,6 +764,11 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
|||||||
**diagnostics,
|
**diagnostics,
|
||||||
"projection_metrics": diagnostics,
|
"projection_metrics": diagnostics,
|
||||||
}
|
}
|
||||||
|
if flow_mode is not None:
|
||||||
|
# Complete, untruncated read-model population for offline simulation;
|
||||||
|
# normal API/GET callers do not receive this audit-only field.
|
||||||
|
result["simulation_all_items"] = canonical_items
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_system_health_summary() -> Dict[str, Any]:
|
def get_system_health_summary() -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -376,13 +376,24 @@ def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Di
|
|||||||
if not ids:
|
if not ids:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if _flow_v2_mode() == "authoritative":
|
return get_opportunity_next_actions_for_mode(ids, flow_mode=_flow_v2_mode())
|
||||||
return _get_authoritative_opportunity_decisions(ids)
|
|
||||||
|
|
||||||
|
def get_opportunity_next_actions_for_mode(
|
||||||
|
opportunity_ids: Iterable[str], *, flow_mode: str, connection: Any = None,
|
||||||
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Explicit-mode read boundary used by the read-only cutover simulation."""
|
||||||
|
ids = list(dict.fromkeys(str(value).strip() for value in opportunity_ids if str(value).strip()))
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
if flow_mode == "authoritative":
|
||||||
|
return _get_authoritative_opportunity_decisions(ids, connection=connection)
|
||||||
|
|
||||||
# Read path only: schema creation belongs to startup/migrations. In
|
# Read path only: schema creation belongs to startup/migrations. In
|
||||||
# particular, GET /operations must never perform DDL while calculating its
|
# particular, GET /operations must never perform DDL while calculating its
|
||||||
# canonical projection.
|
# canonical projection.
|
||||||
with engine.begin() as conn:
|
from contextlib import nullcontext
|
||||||
|
with (nullcontext(connection) if connection is not None else engine.begin()) as conn:
|
||||||
opportunities = _bulk_rows(conn, """
|
opportunities = _bulk_rows(conn, """
|
||||||
SELECT id::text, stage, status, title,
|
SELECT id::text, stage, status, title,
|
||||||
local_customer_id::text AS fiscal_customer_id,
|
local_customer_id::text AS fiscal_customer_id,
|
||||||
@@ -453,13 +464,15 @@ def get_opportunity_next_actions(opportunity_ids: Iterable[str]) -> Dict[str, Di
|
|||||||
company_profile="blif",
|
company_profile="blif",
|
||||||
)
|
)
|
||||||
decisions[oid] = decide_opportunity_next_action(evidence, profile).to_dict()
|
decisions[oid] = decide_opportunity_next_action(evidence, profile).to_dict()
|
||||||
|
if flow_mode == "compare":
|
||||||
_observe_flow_v2(decisions)
|
_observe_flow_v2(decisions)
|
||||||
return decisions
|
return decisions
|
||||||
|
|
||||||
|
|
||||||
def _get_authoritative_opportunity_decisions(ids: list[str]) -> Dict[str, Dict[str, Any]]:
|
def _get_authoritative_opportunity_decisions(ids: list[str], *, connection: Any = None) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Read-only set-oriented adapter input loader; never derives or persists V2."""
|
"""Read-only set-oriented adapter input loader; never derives or persists V2."""
|
||||||
with engine.connect() as conn:
|
from contextlib import nullcontext
|
||||||
|
with (nullcontext(connection) if connection is not None else engine.connect()) as conn:
|
||||||
projections = _bulk_rows(conn, """
|
projections = _bulk_rows(conn, """
|
||||||
SELECT opportunity_id::text, material_process_key,
|
SELECT opportunity_id::text, material_process_key,
|
||||||
canonical_opportunity_id::text, is_duplicate_representation,
|
canonical_opportunity_id::text, is_duplicate_representation,
|
||||||
|
|||||||
@@ -1,95 +1,226 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Read-only audit of the exact Operations boundary in V1 and authoritative V2."""
|
"""Simulate authoritative BLIF Flow v2 without changing application mode."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
if str(ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.operations_service import get_operations_summary
|
|
||||||
|
|
||||||
JSON_PATH = Path("/tmp/blif_flow_v2_authoritative_simulation.json")
|
JSON_PATH = Path("/tmp/blif_flow_v2_authoritative_simulation.json")
|
||||||
TEXT_PATH = Path("/tmp/blif_flow_v2_authoritative_operations.txt")
|
TEXT_PATH = Path("/tmp/blif_flow_v2_authoritative_operations.txt")
|
||||||
CURRENT = {"do_now", "review", "blocked", "exception"}
|
CURRENT = {"do_now", "review", "blocked", "exception"}
|
||||||
|
CLASSIFICATIONS = {
|
||||||
|
"SATISFIED", "SUPERSEDED", "DUPLICATE_SUPPRESSED", "PREMATURE_REMOVED",
|
||||||
def _all(summary):
|
"REPLACED_BY_CORRECT_ACTION", "LEGACY_ONLY", "UNSAFE_FALSE_NEGATIVE",
|
||||||
return list(summary.get("work_items") or []) + list(summary.get("waiting_items") or []) + list(summary.get("backlog_items") or []) + list(summary.get("not_current_items") or [])
|
}
|
||||||
|
NAMED = {
|
||||||
|
"Instalbeira": ("5c33db95-fab8-477a-bddd-0b9cc8f91302", "CREATE_PROFORMA", "do_now"),
|
||||||
def _metrics(summary):
|
"Panoramic": ("fd79b9a1-07e6-4f61-95e8-09eab89c155e", "FOLLOW_UP_CUSTOMER_REVIEW", "do_now"),
|
||||||
items = _all(summary)
|
"ENGEXICON": ("61f1c955-a372-4ea7-b9b0-b8528d74a141", "PREPARE_ORDER", "do_now"),
|
||||||
queues = [str(item.get("operational_queue") or "") for item in items]
|
"CONSTRURECUP": ("e3b23ac5-84db-4763-8a31-a684e873032c", "PREPARE_ORDER", "do_now"),
|
||||||
return {
|
"X MAT canonical": ("dc89a466-db24-401b-bfe9-d47644b2d0c8", None, "not_current"),
|
||||||
"current": sum(queue in CURRENT for queue in queues),
|
"X MAT duplicate": ("1816a06e-9a69-4a9b-9279-1263156892d3", None, "suppressed"),
|
||||||
"do_now": queues.count("do_now"), "review": queues.count("review"),
|
"RZSOLAR canonical": ("fd221608-e007-4043-a23d-07e0c119a345", "REVIEW_REQUIRED", "review"),
|
||||||
"waiting": queues.count("waiting"), "backlog": queues.count("backlog"),
|
"RZSOLAR duplicate": ("434124fb-ac19-4d78-909a-55761d7e8daa", None, "suppressed"),
|
||||||
"not_current": queues.count("not_current"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _identity(item):
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
return str(item.get("canonical_opportunity_id") or item.get("opportunity_id") or item.get("process_key") or item.get("work_item_key"))
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Read-only V1 versus simulated authoritative BLIF Flow v2 Operations audit."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--production-readonly-simulation", action="store_true",
|
||||||
|
help="Required opt-in when current_database() is clientflow; requires compare mode and a read-only transaction.",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _classification(old, new):
|
def _load_runtime():
|
||||||
|
"""Application imports are intentionally delayed until after argparse."""
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
from app.db import engine
|
||||||
|
from app.operations_service import get_operations_summary
|
||||||
|
from app.opportunity_next_action_service import get_opportunity_next_actions_for_mode
|
||||||
|
return engine, get_operations_summary, get_opportunity_next_actions_for_mode
|
||||||
|
|
||||||
|
|
||||||
|
def _all(summary: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
if "simulation_all_items" in summary:
|
||||||
|
return list(summary.get("simulation_all_items") or [])
|
||||||
|
return sum((list(summary.get(key) or []) for key in
|
||||||
|
("work_items", "waiting_items", "backlog_items", "not_current_items")), [])
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(summary: dict[str, Any]) -> dict[str, int]:
|
||||||
|
queues = [str(item.get("operational_queue") or "") for item in _all(summary)]
|
||||||
|
return {"current": sum(q in CURRENT for q in queues), "do_now": queues.count("do_now"),
|
||||||
|
"review": queues.count("review"), "waiting": queues.count("waiting"),
|
||||||
|
"backlog": queues.count("backlog"), "blocked": queues.count("blocked"),
|
||||||
|
"not_current": queues.count("not_current")}
|
||||||
|
|
||||||
|
|
||||||
|
def _key(item: dict[str, Any]) -> str:
|
||||||
|
return str(item.get("work_item_key") or item.get("process_key") or item.get("opportunity_id") or item.get("id"))
|
||||||
|
|
||||||
|
|
||||||
|
def _classification(old: dict[str, Any], new: dict[str, Any] | None) -> tuple[str, str]:
|
||||||
if new is not None:
|
if new is not None:
|
||||||
return "REPLACED_BY_CORRECT_ACTION"
|
return "REPLACED_BY_CORRECT_ACTION", "Flow v2 selected a different current action for the same work item."
|
||||||
decision = old.get("decision") or {}
|
decision = old.get("decision") if isinstance(old.get("decision"), dict) else {}
|
||||||
code = str(decision.get("reason_code") or old.get("eligibility_reason_code") or "").upper()
|
code = str(decision.get("reason_code") or old.get("eligibility_reason_code") or "").upper()
|
||||||
if "DUPLICATE" in code:
|
if "DUPLICATE" in code:
|
||||||
return "DUPLICATE_SUPPRESSED"
|
return "DUPLICATE_SUPPRESSED", "The local opportunity is a duplicate material representation."
|
||||||
if "SATISF" in code or "ANSWERED" in code:
|
if "SATISF" in code or "ANSWERED" in code:
|
||||||
return "SATISFIED"
|
return "SATISFIED", "Later factual evidence satisfies the old obligation."
|
||||||
if "SUPERSE" in code or "TERMINAL" in code:
|
if "SUPERSE" in code or "TERMINAL" in code:
|
||||||
return "SUPERSEDED"
|
return "SUPERSEDED", "A later factual state supersedes the legacy card."
|
||||||
if "PREMATURE" in code:
|
if "PREMATURE" in code:
|
||||||
return "PREMATURE_REMOVED"
|
return "PREMATURE_REMOVED", "The legacy action is premature for the factual state."
|
||||||
if old.get("source") in {"task", "opportunity"}:
|
if old.get("source") in {"task", "opportunity"}:
|
||||||
return "LEGACY_ONLY"
|
return "LEGACY_ONLY", "The card is supported only by legacy operational representation."
|
||||||
return "UNSAFE_FALSE_NEGATIVE"
|
return "UNSAFE_FALSE_NEGATIVE", "No safe factual explanation was found for removing this current card."
|
||||||
|
|
||||||
|
|
||||||
def simulate():
|
def build_report(
|
||||||
original = settings.blif_flow_v2_mode
|
*, identity: dict[str, Any], configured_mode: str, v1: dict[str, Any], v2: dict[str, Any],
|
||||||
try:
|
named_decisions: dict[str, dict[str, Any]], missing_projections: int,
|
||||||
settings.blif_flow_v2_mode = "off"
|
) -> dict[str, Any]:
|
||||||
v1 = get_operations_summary(limit=200)
|
v1_current = {_key(item): item for item in _all(v1) if item.get("operational_queue") in CURRENT}
|
||||||
settings.blif_flow_v2_mode = "authoritative"
|
v2_current = {_key(item): item for item in _all(v2) if item.get("operational_queue") in CURRENT}
|
||||||
v2 = get_operations_summary(limit=200)
|
removed, changed = [], []
|
||||||
finally:
|
|
||||||
settings.blif_flow_v2_mode = original
|
|
||||||
v1_current = {_identity(item): item for item in _all(v1) if item.get("operational_queue") in CURRENT}
|
|
||||||
v2_current = {_identity(item): item for item in _all(v2) if item.get("operational_queue") in CURRENT}
|
|
||||||
removed = []
|
|
||||||
for key, old in v1_current.items():
|
for key, old in v1_current.items():
|
||||||
new = v2_current.get(key)
|
new = v2_current.get(key)
|
||||||
if new is None or new.get("action_code") != old.get("action_code"):
|
if new is not None and new.get("action_code") == old.get("action_code"):
|
||||||
removed.append({"identity": key, "v1_action": old.get("action_code"),
|
continue
|
||||||
"v2_action": (new or {}).get("action_code"),
|
classification, reason = _classification(old, new)
|
||||||
"classification": _classification(old, new)})
|
row = {"work_item_key": key, "opportunity_id": old.get("opportunity_id"),
|
||||||
added = [{"identity": key, "action": item.get("action_code")}
|
"v1_action": old.get("action_code"), "simulated_v2_action": (new or {}).get("action_code"),
|
||||||
for key, item in v2_current.items()
|
"classification": classification, "reason": reason,
|
||||||
if key not in v1_current or v1_current[key].get("action_code") != item.get("action_code")]
|
"source_refs": old.get("source_refs") or []}
|
||||||
result = {"v1": _metrics(v1), "authoritative_v2": _metrics(v2),
|
(changed if new is not None else removed).append(row)
|
||||||
"cards_removed": removed, "cards_added_or_replaced": added,
|
added = [{"work_item_key": key, "opportunity_id": item.get("opportunity_id"),
|
||||||
"unsafe_false_negatives": sum(row["classification"] == "UNSAFE_FALSE_NEGATIVE" for row in removed)}
|
"action": item.get("action_code"), "source_refs": item.get("source_refs") or []}
|
||||||
JSON_PATH.write_text(json.dumps(result, indent=2, ensure_ascii=False, default=str) + "\n")
|
for key, item in v2_current.items() if key not in v1_current]
|
||||||
lines = ["BLIF Flow v2 authoritative Operations simulation", "", f"V1: {result['v1']}",
|
|
||||||
f"Authoritative V2: {result['authoritative_v2']}",
|
material_counts: dict[str, int] = {}
|
||||||
f"Cards removed: {len(removed)}", f"Cards added/replaced: {len(added)}",
|
for item in v2_current.values():
|
||||||
f"UNSAFE_FALSE_NEGATIVE: {result['unsafe_false_negatives']}"]
|
material = str(item.get("canonical_opportunity_id") or item.get("opportunity_id") or item.get("process_key"))
|
||||||
|
material_counts[material] = material_counts.get(material, 0) + 1
|
||||||
|
duplicates = [{"material_process": key, "count": count} for key, count in material_counts.items() if count > 1]
|
||||||
|
|
||||||
|
named_cases = {}
|
||||||
|
for name, (oid, expected_action, expected_queue) in NAMED.items():
|
||||||
|
decision = named_decisions.get(oid) or {}
|
||||||
|
suppressed = decision.get("suppress_current_card") is True
|
||||||
|
actual_queue = "suppressed" if suppressed else decision.get("operational_queue")
|
||||||
|
actual_action = decision.get("effective_action")
|
||||||
|
passed = actual_action == expected_action and actual_queue == expected_queue
|
||||||
|
named_cases[name] = {"opportunity_id": oid, "expected_action": expected_action,
|
||||||
|
"expected_queue": expected_queue, "actual_action": actual_action,
|
||||||
|
"actual_queue": actual_queue, "passed": passed}
|
||||||
|
|
||||||
|
fiscal = []
|
||||||
|
for item in v2_current.values():
|
||||||
|
decision = item.get("decision") if isinstance(item.get("decision"), dict) else {}
|
||||||
|
if item.get("action_code") == "VALIDATE_FISCAL_CUSTOMER":
|
||||||
|
fiscal.append({"opportunity_id": item.get("opportunity_id"),
|
||||||
|
"business_action": decision.get("business_next_action"),
|
||||||
|
"reason_code": decision.get("reason_code"),
|
||||||
|
"reason": decision.get("description")})
|
||||||
|
unsafe = sum(row["classification"] == "UNSAFE_FALSE_NEGATIVE" for row in removed)
|
||||||
|
return {
|
||||||
|
"identity": identity, "configured_mode": configured_mode,
|
||||||
|
"v1_metrics": _metrics(v1), "authoritative_metrics": _metrics(v2),
|
||||||
|
"semantic_changes": len(removed) + len(changed) + len(added),
|
||||||
|
"removed_cards": removed, "added_cards": added, "changed_actions": changed,
|
||||||
|
"duplicate_cards": duplicates, "duplicate_current_cards": len(duplicates),
|
||||||
|
"missing_projections": missing_projections, "unsafe_false_negatives": unsafe,
|
||||||
|
"named_cases": named_cases, "fiscal_validation_cases": fiscal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_reports(report: dict[str, Any]) -> None:
|
||||||
|
JSON_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False, default=str) + "\n")
|
||||||
|
lines = ["BLIF Flow v2 authoritative Operations simulation", "",
|
||||||
|
f"Identity: {report['identity']}", f"Configured mode: {report['configured_mode']}",
|
||||||
|
f"V1: {report['v1_metrics']}", f"Simulated authoritative V2: {report['authoritative_metrics']}",
|
||||||
|
f"Removed: {len(report['removed_cards'])}", f"Added: {len(report['added_cards'])}",
|
||||||
|
f"Changed actions: {len(report['changed_actions'])}",
|
||||||
|
f"Duplicate current cards: {report['duplicate_current_cards']}",
|
||||||
|
f"Missing projections: {report['missing_projections']}",
|
||||||
|
f"UNSAFE_FALSE_NEGATIVE: {report['unsafe_false_negatives']}", "", "Named cases:"]
|
||||||
|
lines.extend(f"- {name}: {case}" for name, case in report["named_cases"].items())
|
||||||
|
lines.extend(["", f"Fiscal validation cases: {len(report['fiscal_validation_cases'])}"])
|
||||||
TEXT_PATH.write_text("\n".join(lines) + "\n")
|
TEXT_PATH.write_text("\n".join(lines) + "\n")
|
||||||
return result
|
|
||||||
|
|
||||||
|
def exit_code(report: dict[str, Any]) -> int:
|
||||||
|
failed_named = any(not case.get("passed") for case in report.get("named_cases", {}).values())
|
||||||
|
unsafe = int(report.get("unsafe_false_negatives") or 0)
|
||||||
|
missing = int(report.get("missing_projections") or 0)
|
||||||
|
duplicates = int(report.get("duplicate_current_cards") or 0)
|
||||||
|
return 2 if unsafe or missing or duplicates or failed_named else 0
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace, *, runtime_loader=_load_runtime) -> dict[str, Any]:
|
||||||
|
engine, get_operations, get_decisions = runtime_loader()
|
||||||
|
configured_mode = os.environ.get("BLIF_FLOW_V2_MODE")
|
||||||
|
conn = engine.connect()
|
||||||
|
try:
|
||||||
|
# One explicit transaction and one factual snapshot for identity, V1,
|
||||||
|
# V2, projection completeness, and named-case decisions.
|
||||||
|
conn.exec_driver_sql("BEGIN READ ONLY")
|
||||||
|
identity = dict(conn.exec_driver_sql("""
|
||||||
|
SELECT current_database() AS database, current_user AS user,
|
||||||
|
current_setting('transaction_read_only') AS transaction_read_only
|
||||||
|
""").mappings().one())
|
||||||
|
production = identity.get("database") == "clientflow"
|
||||||
|
if production and not args.production_readonly_simulation:
|
||||||
|
raise RuntimeError("production simulation requires --production-readonly-simulation")
|
||||||
|
if args.production_readonly_simulation:
|
||||||
|
if identity.get("database") != "clientflow":
|
||||||
|
raise RuntimeError("production simulation requires current_database() = clientflow")
|
||||||
|
if identity.get("transaction_read_only") != "on":
|
||||||
|
raise RuntimeError("production simulation requires transaction_read_only = on")
|
||||||
|
if configured_mode is None:
|
||||||
|
raise RuntimeError("production simulation requires explicit BLIF_FLOW_V2_MODE=compare")
|
||||||
|
if configured_mode.strip().lower() != "compare":
|
||||||
|
raise RuntimeError("production simulation requires BLIF_FLOW_V2_MODE=compare")
|
||||||
|
|
||||||
|
v1 = get_operations(limit=200, flow_mode="off", connection=conn)
|
||||||
|
v2 = get_operations(limit=200, flow_mode="authoritative", connection=conn)
|
||||||
|
projection_counts = conn.exec_driver_sql("""
|
||||||
|
SELECT (SELECT count(*) FROM opportunities) AS opportunities,
|
||||||
|
(SELECT count(*) FROM opportunity_flow_state_v2) AS projections
|
||||||
|
""").mappings().one()
|
||||||
|
named_ids = [value[0] for value in NAMED.values()]
|
||||||
|
named_decisions = get_decisions(named_ids, flow_mode="authoritative", connection=conn)
|
||||||
|
if production and os.environ.get("BLIF_FLOW_V2_MODE") != configured_mode:
|
||||||
|
raise RuntimeError("configured BLIF_FLOW_V2_MODE changed during simulation")
|
||||||
|
report = build_report(
|
||||||
|
identity=identity, configured_mode=configured_mode or "unset", v1=v1, v2=v2,
|
||||||
|
named_decisions=named_decisions,
|
||||||
|
missing_projections=max(0, int(projection_counts["opportunities"]) - int(projection_counts["projections"])),
|
||||||
|
)
|
||||||
|
_write_reports(report)
|
||||||
|
return report
|
||||||
|
finally:
|
||||||
|
conn.exec_driver_sql("ROLLBACK")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None, *, runtime_loader=_load_runtime) -> int:
|
||||||
|
args = parse_args(argv) # --help exits before runtime_loader/application imports.
|
||||||
|
report = run(args, runtime_loader=runtime_loader)
|
||||||
|
print(json.dumps(report, indent=2, ensure_ascii=False, default=str))
|
||||||
|
return exit_code(report)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
audit = simulate()
|
raise SystemExit(main())
|
||||||
print(json.dumps(audit, indent=2, ensure_ascii=False, default=str))
|
|
||||||
if audit["unsafe_false_negatives"]:
|
|
||||||
raise SystemExit(2)
|
|
||||||
|
|||||||
138
tests/test_blif_flow_v2_authoritative_simulation.py
Normal file
138
tests/test_blif_flow_v2_authoritative_simulation.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from argparse import Namespace
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
PATH = Path("scripts/simulate_blif_flow_v2_authoritative.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("authoritative_simulation", PATH)
|
||||||
|
sim = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(sim)
|
||||||
|
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
def __init__(self, row): self.row = row
|
||||||
|
def mappings(self): return self
|
||||||
|
def one(self): return self.row
|
||||||
|
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def __init__(self, database="clientflow", readonly="on"):
|
||||||
|
self.database, self.readonly, self.sql = database, readonly, []
|
||||||
|
def exec_driver_sql(self, sql):
|
||||||
|
self.sql.append(sql.strip())
|
||||||
|
if "current_database" in sql:
|
||||||
|
return Result({"database": self.database, "user": "audit", "transaction_read_only": self.readonly})
|
||||||
|
if "count(*) FROM opportunities" in sql:
|
||||||
|
return Result({"opportunities": 328, "projections": 328})
|
||||||
|
return Result({})
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
|
||||||
|
class Engine:
|
||||||
|
def __init__(self, connection): self.connection = connection
|
||||||
|
def connect(self): return self.connection
|
||||||
|
|
||||||
|
|
||||||
|
def named_decisions(fail=False):
|
||||||
|
result = {}
|
||||||
|
for name, (oid, action, queue) in sim.NAMED.items():
|
||||||
|
result[oid] = {"effective_action": action,
|
||||||
|
"operational_queue": "not_current" if queue == "suppressed" else queue,
|
||||||
|
"suppress_current_card": queue == "suppressed"}
|
||||||
|
if fail:
|
||||||
|
result[sim.NAMED["Instalbeira"][0]]["effective_action"] = "SEND_INVOICE"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def runtime(connection, *, fail_named=False, modes=None):
|
||||||
|
empty = {"work_items": [], "waiting_items": [], "backlog_items": [], "not_current_items": []}
|
||||||
|
def operations(**kwargs):
|
||||||
|
assert kwargs["connection"] is connection
|
||||||
|
if modes is not None:
|
||||||
|
modes.append(kwargs["flow_mode"])
|
||||||
|
return empty
|
||||||
|
def decisions(ids, **kwargs):
|
||||||
|
assert kwargs == {"flow_mode": "authoritative", "connection": connection}
|
||||||
|
return named_decisions(fail_named)
|
||||||
|
return lambda: (Engine(connection), operations, decisions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_performs_zero_db_work():
|
||||||
|
called = False
|
||||||
|
def loader():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
raise AssertionError("DB/application runtime must not load")
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
sim.main(["--help"], runtime_loader=loader)
|
||||||
|
assert exc.value.code == 0 and called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_requires_explicit_flag(monkeypatch):
|
||||||
|
monkeypatch.setenv("BLIF_FLOW_V2_MODE", "compare")
|
||||||
|
with pytest.raises(RuntimeError, match="requires --production"):
|
||||||
|
sim.run(Namespace(production_readonly_simulation=False), runtime_loader=runtime(Connection()))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("database,readonly,mode,message", [
|
||||||
|
("test_db", "on", "compare", "current_database"),
|
||||||
|
("clientflow", "off", "compare", "transaction_read_only"),
|
||||||
|
("clientflow", "on", None, "explicit BLIF_FLOW"),
|
||||||
|
("clientflow", "on", "off", "BLIF_FLOW_V2_MODE=compare"),
|
||||||
|
("clientflow", "on", "shadow", "BLIF_FLOW_V2_MODE=compare"),
|
||||||
|
("clientflow", "on", "authoritative", "BLIF_FLOW_V2_MODE=compare"),
|
||||||
|
])
|
||||||
|
def test_production_guards(monkeypatch, database, readonly, mode, message):
|
||||||
|
if mode is None:
|
||||||
|
monkeypatch.delenv("BLIF_FLOW_V2_MODE", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("BLIF_FLOW_V2_MODE", mode)
|
||||||
|
with pytest.raises(RuntimeError, match=message):
|
||||||
|
sim.run(Namespace(production_readonly_simulation=True),
|
||||||
|
runtime_loader=runtime(Connection(database, readonly)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_simulation_uses_explicit_modes_without_changing_config_and_has_no_db_writes(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("BLIF_FLOW_V2_MODE", "compare")
|
||||||
|
monkeypatch.setattr(sim, "JSON_PATH", tmp_path / "report.json")
|
||||||
|
monkeypatch.setattr(sim, "TEXT_PATH", tmp_path / "report.txt")
|
||||||
|
connection = Connection()
|
||||||
|
modes = []
|
||||||
|
report = sim.run(Namespace(production_readonly_simulation=True), runtime_loader=runtime(connection, modes=modes))
|
||||||
|
assert report["configured_mode"] == "compare"
|
||||||
|
assert sim.exit_code(report) == 0
|
||||||
|
assert [sql for sql in connection.sql if sql.split(None, 1)[0].upper() in {"INSERT", "UPDATE", "DELETE", "MERGE"}] == []
|
||||||
|
assert connection.sql[0] == "BEGIN READ ONLY" and connection.sql[-1] == "ROLLBACK"
|
||||||
|
assert modes == ["off", "authoritative"]
|
||||||
|
|
||||||
|
|
||||||
|
def safe_report():
|
||||||
|
return sim.build_report(identity={}, configured_mode="compare",
|
||||||
|
v1={"work_items": []}, v2={"work_items": []},
|
||||||
|
named_decisions=named_decisions(), missing_projections=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsafe_false_negative_is_nonzero():
|
||||||
|
report = safe_report()
|
||||||
|
report["unsafe_false_negatives"] = 1
|
||||||
|
assert sim.exit_code(report) != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_projection_is_nonzero():
|
||||||
|
report = safe_report()
|
||||||
|
report["missing_projections"] = 1
|
||||||
|
assert sim.exit_code(report) != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_current_card_is_nonzero():
|
||||||
|
report = safe_report()
|
||||||
|
report["duplicate_current_cards"] = 1
|
||||||
|
assert sim.exit_code(report) != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_case_failure_is_nonzero():
|
||||||
|
report = sim.build_report(identity={}, configured_mode="compare", v1={"work_items": []},
|
||||||
|
v2={"work_items": []}, named_decisions=named_decisions(True), missing_projections=0)
|
||||||
|
assert sim.exit_code(report) != 0
|
||||||
Reference in New Issue
Block a user