"""Offline, manifest-pinned replay of the three-operation ARR v2 capture.""" from __future__ import annotations import argparse import json from pathlib import Path import re import urllib.error from . import audit_arr_capture as audit from . import collect_arr_day as day from . import collect_arr_source as source from . import rate_info require = source.require def _same_json(left, right): # Python equality aliases true/1 and false/0, including inside containers. # Canonical JSON keeps those types distinct while ignoring object key order. return (json.dumps(left, sort_keys=True, allow_nan=False, separators=(",", ":")) == json.dumps(right, sort_keys=True, allow_nan=False, separators=(",", ":"))) class VerifiedArchive(audit.VerifiedArchive): version = day.VERSION file_pattern = re.compile(r"(?:capture\.json|rate-assessments\.json|(?:request|rate)-[0-9]{6}\.(?:json|meta\.json|response\.bin))") operations = day.OPERATIONS options_type = day.Options max_inventory_files = 182000 def __init__(self, directory: Path, expected_manifest_sha256: str): super().__init__(directory, expected_manifest_sha256) for document in (self.capture, self.result): require(all(document.get(key) == getattr(self.options, key) for key in ("from_date", "to_date", "rate_date")), "capture_date_mismatch") class MemorySink(audit.MemorySink): def __init__(self): super().__init__() self.assessments = None def write(self, name, value): if re.fullmatch(r"(?:request|rate|profile)-[0-9]{6}\.json", name): self.latest_request = value if name == "rate-assessments.json": self.assessments = value super().write(name, value) def replay(archive: VerifiedArchive) -> tuple[list[dict], list[dict], dict]: """Rebuild all requests and join decisions without credentials or networking.""" require(archive.version == day.VERSION, "unsupported_day_archive_version") return _replay(archive) def _replay(archive, *, named=False): sink = MemorySink() counters = {"request": 0, "rate": 0} if named: from . import collect_arr_named_day as named_day, profile_reader, profile_summary counters["profile"] = 0 consumed = {"capture.json", "rate-assessments.json"} rows, details = [], [] initial_search_done = False def transport(method, path, body): nonlocal initial_search_done operation = sink.latest_request["operation_id"] prefix = ("profile" if named and operation == profile_summary.OPERATION else "rate" if operation == rate_info.POST else "request") counters[prefix] += 1 label = f"{prefix}-{counters[prefix]:06d}" request_name, meta_name, response_name = label + ".json", label + ".meta.json", label + ".response.bin" request, meta = archive.document(request_name), archive.document(meta_name) consumed.update((request_name, meta_name)) require(request.get("operation_id") == operation and request.get("method") == method and request.get("path") == path and _same_json(request.get("body"), source.strict_json(body) if body is not None else None), "archived_request_mismatch") require(type(request.get("attempt")) is int and request["attempt"] == sink.latest_request["attempt"], "archived_attempt_mismatch") if meta.get("error") == "transport_failure": require(response_name not in archive.inventory, "unexpected_transport_response") raise urllib.error.URLError("archived_transport_failure") raw = archive.read(response_name) consumed.add(response_name) require(type(meta.get("http_status")) is int and meta.get("oversized") is False, "invalid_response_metadata") if meta["http_status"] == 200: page = source.strict_json(raw).get("data", {}).get("reservations", {}) if operation == source.SEARCH and not initial_search_done: rows.extend(page.get("reservationInfo", [])) if page.get("hasMore", False) is False: initial_search_done = True elif operation == source.DETAIL: details.extend(page.get("reservation", [])) # Historical Retry-After can be an HTTP date now in the past. Replay # validates response/request sequence; it does not claim timing replay. headers = {} if prefix == "profile" and meta.get("retry_after") is not None: require(type(meta["retry_after"]) is str, "invalid_archived_retry_after") headers["retry-after"] = meta["retry_after"] return meta["http_status"], headers, raw clients = (source.Reader(sink, archive.options.hotel_id, transport, sleep=lambda _: None), rate_info.RateInfoReader(sink, archive.options.hotel_id, transport, sleep=lambda _: None)) if named: result = named_day.collect(archive.options, sink, *clients, profile_reader.ProfileSummaryReader(sink, archive.options.hotel_id, transport, max_profiles=archive.options.max_profiles, sleep=lambda _: None)) else: result = day.collect(archive.options, sink, *clients) require(result.get("candidate_capture_complete") is True, "capture_protocol_replay_failed") require(consumed == set(archive.inventory), "unconsumed_archive_files") require([item["name"] for item in sink.files[:-1]] == list(archive.inventory), "archive_sequence_mismatch") for key, value in result.items(): if key not in {"completed_at", "manifest_sha256"}: require(_same_json(value, archive.result.get(key)), "capture_summary_mismatch") require(_same_json(sink.assessments, archive.document("rate-assessments.json")), "rate_assessment_mismatch") require(len(rows) == len(details) == len(sink.assessments["records"]) == result["search_records"], "capture_pair_count_mismatch") return rows, details, sink.assessments def analyze_fields(rows: list[dict], details: list[dict], assessments: dict) -> dict: """Reuse the source-field observations, with v2's validated rate candidate.""" records = assessments.get("records") require(isinstance(records, list) and len(records) == len(details), "rate_assessment_count_mismatch") require(all(record.get("reservation_id") == source.reservation_id(detail) for record, detail in zip(records, details)), "rate_assessment_identity_mismatch") analysis = audit.analyze_fields(rows, details) valid = sum("effective_rate" in record and "error" not in record for record in records) field = analysis["fields"]["EFFECTIVE_RATE_AMOUNT"] field.update(records_with_candidate=valid, records_without_candidate=len(records) - valid, records_with_distinct_candidates=0) analysis["field_notes"]["EFFECTIVE_RATE_AMOUNT"] = ( "searchRateInfo detail.totalRateAmount, exact reservation/day request binding; " "finite nonnegative amount and currency checked against detail rate segments; " "no response identity/date echo, arithmetic fallback or report-equivalence claim") analysis["field_notes"]["candidate_counts"] += "; v2 effective-price counts require amount/currency validity" analysis["blocked_business_decisions"][0] = "same_source_effective_rate_equivalence" return analysis def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--capture-dir", required=True, type=Path) parser.add_argument("--manifest-sha256", required=True) args = parser.parse_args(argv) try: archive = VerifiedArchive(args.capture_dir, args.manifest_sha256) rows, details, assessments = replay(archive) output = {"integrity_verified": True, "protocol_replayed": True, "finance_ready": False, "records": len(rows), "valid_rate_candidates": archive.result["valid_rate_candidates"], "rate_issues": archive.result["rate_issues"], "analysis": analyze_fields(rows, details, assessments)} except source.CollectionError as error: output = {"integrity_verified": False, "error": str(error), "finance_ready": False} except Exception: output = {"integrity_verified": False, "error": "archive_audit_failed", "finance_ready": False} print(json.dumps(output, ensure_ascii=False)) return 0 if output["integrity_verified"] else 1 if __name__ == "__main__": raise SystemExit(main())