From 321d56f50b251b210b96cc276912e1db6f101b04 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 20 Sep 2026 08:07:04 -0700 Subject: [PATCH] Diagnose neighborhood API performance --- .../neighborhood_api_benchmark.json | 45 ++ .../performance/neighborhood_api_diagnosis.md | 270 ++++++++++++ scripts/diagnose_neighborhood_performance.py | 388 ++++++++++++++++++ 3 files changed, 703 insertions(+) create mode 100644 docs/performance/neighborhood_api_benchmark.json create mode 100644 docs/performance/neighborhood_api_diagnosis.md create mode 100644 scripts/diagnose_neighborhood_performance.py diff --git a/docs/performance/neighborhood_api_benchmark.json b/docs/performance/neighborhood_api_benchmark.json new file mode 100644 index 0000000..032bcd5 --- /dev/null +++ b/docs/performance/neighborhood_api_benchmark.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "diagnostic_commit": "918abe01c444e5346b6e895dc42d2d7037f718f7", + "method": "bounded sequential local diagnosis; no concurrency or network", + "fixture": { + "kind": "deterministic synthetic production-count fixture", + "materials": 1727, + "material_elements": 4144, + "material_applications": 128 + }, + "default_request": { + "material_id": 1, + "depth": 2, + "limit": 25, + "neighbor_service_calls": 25, + "database_query_count": 150, + "warm_median_service_ms": 1638.338, + "payload_bytes": 9657, + "payload_sha256": "50f22e6a428bd19f7569f169361e71cf5d019056c23e07d75cf2af97a2f2fd11" + }, + "scaling": [ + {"depth": 2, "limit": 1, "queries": 6, "warm_median_service_ms": 66.519, "payload_bytes": 336}, + {"depth": 2, "limit": 5, "queries": 30, "warm_median_service_ms": 298.746, "payload_bytes": 2043}, + {"depth": 2, "limit": 10, "queries": 60, "warm_median_service_ms": 649.084, "payload_bytes": 3945}, + {"depth": 2, "limit": 25, "queries": 150, "warm_median_service_ms": 1638.338, "payload_bytes": 9657}, + {"depth": 2, "limit": 50, "queries": 300, "warm_median_service_ms": 4538.827, "payload_bytes": 19262}, + {"depth": 1, "limit": 25, "queries": 6, "warm_median_service_ms": 81.794, "payload_bytes": 9454} + ], + "round_trip_sensitivity": [ + {"simulated_rtt_ms": 0, "queries": 150, "end_to_end_ms": 1697.022}, + {"simulated_rtt_ms": 25, "queries": 150, "end_to_end_ms": 5514.719}, + {"simulated_rtt_ms": 50, "queries": 150, "end_to_end_ms": 9325.920}, + {"simulated_rtt_ms": 100, "queries": 150, "end_to_end_ms": 16749.582} + ], + "semantic_stability": { + "repeated_results_identical": true, + "artificial_delay_payload_hash_unchanged": true + }, + "limitations": [ + "SQLite does not reproduce PostgreSQL execution plans or Neon network behavior.", + "The fixture matches production material count but not the exact production cohort.", + "Artificial per-query delay is a sensitivity test, not a production measurement.", + "Sequential measurements do not establish concurrency capacity." + ] +} diff --git a/docs/performance/neighborhood_api_diagnosis.md b/docs/performance/neighborhood_api_diagnosis.md new file mode 100644 index 0000000..52e7fb1 --- /dev/null +++ b/docs/performance/neighborhood_api_diagnosis.md @@ -0,0 +1,270 @@ +# Neighborhood API Performance Diagnosis + +**Status:** Diagnosis complete; no remediation implemented + +**Scope:** `GET /api/v1/materials/{material_id}/neighborhood` + +**Trigger:** MG-DE-009 production observation of `16,242.222 ms` + +**Diagnostic checkout:** `918abe01c444e5346b6e895dc42d2d7037f718f7` + +**Production access or mutation:** None + +## Decision summary + +The primary cause is a request-scoped N+1 query pattern in depth-two +neighborhood traversal, amplified by dense association materialization and +remote database round trips. + +For the default local diagnostic request (`material_id=1`, `depth=2`, +`limit=25`), the service admitted 25 nodes and called +`MaterialNeighborService.get_neighbors()` 25 times. Each call issued six SQL +queries, producing 150 queries for a 25-node response. The same fixture at +`depth=1`, `limit=25` returned a similarly sized payload after only the root +expansion: six queries and an 81.794 ms warm median rather than 150 queries and +1,638.338 ms. + +On the local fixture, adding 100 ms of artificial delay before each SQL +execution increased end-to-end latency from 1,697.022 ms to 16,749.582 ms, +close to the independent MG-DE-009 production observation. This sensitivity +test is not a production measurement, but it supports the causal conclusion: +query round-trip multiplication explains the remote-scale symptom. + +Serialization, response size, and final neighborhood graph assembly are not +material causes at the measured scale. Serialization was approximately +0.2 ms, the JSON payload was 9,657 bytes, and final graph work outside neighbor +calls was approximately 25 ms. + +## Boundaries and method + +The diagnosis used: + +- a deterministic synthetic fixture with the production material count of + 1,727; +- 4,144 material-element rows and 128 material-application rows, including a + deliberately dense common-element neighborhood; +- a disposable local SQLite database guarded by a required + `neighborhood_perf_test` filename; +- sequential requests only; +- one cold and three warm service measurements for the primary request; +- SQLAlchemy query instrumentation; +- separate measurements for service execution, database execution events, + neighbor construction/scoring, graph assembly, Pydantic serialization, + payload size, and FastAPI end-to-end latency; and +- controlled per-query delay of 0, 25, 50, and 100 ms to measure sensitivity + to remote round trips. + +The committed diagnostic runner is +`scripts/diagnose_neighborhood_performance.py`. It refuses PostgreSQL and any +SQLite database whose name does not contain `neighborhood_perf_test`. + +The fixture matches production row count, not the exact Materials Project +cohort. SQLite does not reproduce PostgreSQL execution plans, Neon transport, +or production-host capacity. The MG-DE-009 report does not retain the exact +neighborhood query parameters, so this diagnosis exercises the endpoint +defaults. No concurrency or load conclusion is made. + +## Primary benchmark + +Default request: `material_id=1`, `depth=2`, `limit=25`. + +| Measurement | Result | +|---|---:| +| Materials | 1,727 | +| Returned nodes / edges | 25 / 25 | +| Neighbor-service calls | 25 | +| SQL queries | 150 | +| Queries per expanded node | 6 | +| Warm median service latency | 1,638.338 ms | +| Median direct SQL execution time | 9.874 ms | +| Median neighbor build/scoring | 254.379 ms | +| Median query materialization/collection estimate | 1,455.240 ms | +| Median final graph assembly estimate | 24.610 ms | +| Serialization | 0.162–0.190 ms warm | +| Payload | 9,657 bytes | +| Repeated-result equality | Exact | + +`database_execute_ms` measures cursor execution callbacks and does not include +all ORM row fetching, object construction, and Python aggregation. Those costs +are represented in the materialization/collection estimate. This distinction +prevents the small cursor-execution number from being misread as the complete +database-related cost. + +### Limit and depth scaling + +| Depth | Limit | Neighbor calls | SQL queries | Warm median service ms | Payload bytes | +|---:|---:|---:|---:|---:|---:| +| 2 | 1 | 1 | 6 | 66.519 | 336 | +| 2 | 5 | 5 | 30 | 298.746 | 2,043 | +| 2 | 10 | 10 | 60 | 649.084 | 3,945 | +| 2 | 25 | 25 | 150 | 1,638.338 | 9,657 | +| 2 | 50 | 50 | 300 | 4,538.827 | 19,262 | +| 1 | 25 | 1 | 6 | 81.794 | 9,454 | + +The query count is exactly `6 × expanded nodes` in this fixture. At depth one, +only the root is expanded even though 25 nodes are returned. At depth two, +every admitted node is expanded, making latency scale with the response limit. + +### Remote-round-trip sensitivity + +| Artificial delay per query | SQL queries | End-to-end ms | Payload SHA-256 unchanged | +|---:|---:|---:|:---:| +| 0 ms | 150 | 1,697.022 | Yes | +| 25 ms | 150 | 5,514.719 | Yes | +| 50 ms | 150 | 9,325.920 | Yes | +| 100 ms | 150 | 16,749.582 | Yes | + +The added latency closely follows `query count × delay`. As an inference, the +MG-DE-009 value is consistent with roughly 97 ms of effective additional cost +per query after subtracting this local fixture's no-delay endpoint time. This +is explanatory sensitivity evidence, not a measurement of Neon latency. + +## Query and computation path + +Each expanded material currently performs: + +1. material lookup; +2. source material-element lookup; +3. source material-application lookup; +4. all matching material-element association loading; +5. all matching material-application association loading; and +6. neighbor material loading. + +The neighborhood cache prevents duplicate expansion of the same material in a +single request, but it does not batch different admitted materials. Dense +common elements therefore cause large association result sets to be loaded and +aggregated repeatedly. Deterministic sorting and bounded admission correctly +control returned membership; they do not make the underlying data access +set-oriented. + +## Root causes + +### RC-1 — Per-node SQL expansion (high confidence) + +Depth-two traversal performs six queries for every admitted node. Query count +therefore grows with `limit`, reaching 150 at the default limit and 300 at 50. +The controlled-delay experiment reproduces the observed latency class. + +### RC-2 — Repeated dense association materialization (high confidence) + +The local no-delay request still takes about 1.6 seconds. Most measured time is +inside neighbor collection and ORM materialization, not final graph assembly. +Each expansion reloads broad shared-element rows and reconstructs neighbor +scores in Python. + +### RC-3 — Payload and serialization (ruled out at this scale) + +The response is under 10 KiB at the default limit and serialization takes less +than 0.3 ms. Depth-one and depth-two payload sizes are similar while service +latencies and query counts differ sharply. + +### RC-4 — Final graph assembly and deterministic ordering (secondary only) + +Final graph assembly is approximately 25 ms at the default request. Sorting +and closure filtering must remain intact, but they do not explain 16.2 seconds. + +## Remediation options + +No option below is implemented by this diagnosis. + +### Option A — Batch neighbor inputs and adjacency by traversal level + +Add a set-oriented internal loader that fetches material metadata, +material-element memberships, material-application memberships, and matching +associations for the bounded frontier in batches. Reconstruct the existing +per-material neighbor dictionaries with the current score formula and +`neighbor_ranking_key`, then retain the existing BFS admission and response +assembly. + +This is the recommended first implementation because it addresses both query +round trips and repeated ORM work while keeping public schemas and traversal +semantics explicit. Care is required to preserve sequential BFS membership +when ties and the node limit interact; batching may prefetch data, but it must +not alter admission order. + +### Option B — Database-aggregated neighbor scoring + +Use grouped SQL to return per-source/per-neighbor shared-element and +shared-application counts with material metadata. This can reduce transferred +association rows as well as query count. It offers a stronger ceiling for +dense datasets but has higher semantic and database-portability risk. Exact +handling of duplicate associations, relationship types, missing evidence, and +tie ordering must be proven. + +### Option C — Recursive or fully composed SQL neighborhood query + +Express traversal and scoring in a recursive CTE or a small number of composed +queries. This could minimize round trips, but it couples BFS limit semantics +and deterministic ordering tightly to SQL. It is not the preferred first move +because equivalence is harder to review and maintain. + +### Option D — Cache completed neighborhoods + +A versioned result cache may improve repeated identical requests, but it does +not fix cold-request cost and creates invalidation/provenance concerns. It +should be considered only after the underlying query path is bounded. + +### Option E — Reduce application/database network distance + +Co-location can reduce the multiplier but leaves 150–300-query behavior and +local materialization cost intact. It is an operational complement, not the +code-level remediation. + +No speculative index is recommended from this SQLite diagnosis. Index changes +require PostgreSQL `EXPLAIN (ANALYZE, BUFFERS)` evidence on a disposable +production-sized database. + +## Acceptance criteria for a later fix + +The following criteria are defined before implementation: + +### Semantic and compatibility gates + +1. Existing neighbor, neighborhood, API determinism, graph-closure, limit, and + missing-material tests pass unchanged. +2. Before/after JSON is exactly equal for a matrix covering depths 1 and 2; + limits 1, 5, 10, 25, 50, and 100; dense, sparse, tied-score, unknown-value, + and missing-root fixtures. +3. Node membership, BFS depth, `best_score`, edge membership, relationship + types, score formula, deterministic node/edge ordering, null/unknown fields, + and response schema remain unchanged. +4. Repeated runs over identical state produce byte-equivalent canonical JSON. +5. Public status codes, query-parameter bounds, and response compatibility do + not change. + +### Performance gates + +1. The 1,727-material default diagnostic request uses no more than 12 SQL + queries at depth two and query count does not grow linearly with `limit`. +2. Warm median service latency on the committed local fixture is at most + 500 ms for `depth=2`, `limit=25`—at least a threefold improvement over the + 1,638.338 ms diagnostic baseline. +3. With 100 ms artificial per-query delay, the same endpoint completes within + 3,000 ms and returns the exact baseline payload hash. +4. Serialization remains below 5 ms and payload bytes are unchanged for an + equivalent response. +5. `limit=50` and `limit=100` complete within the application's 20-second + deadline on the local production-count fixture without unbounded memory or + query growth. + +### PostgreSQL and operational gates + +1. Re-run sequentially on disposable PostgreSQL with the exact 1,727-material + manifest when available; retain query counts, stage timings, payload hashes, + and relevant `EXPLAIN (ANALYZE, BUFFERS)` output. +2. No concurrency claim is made until a separately scoped concurrency test is + approved and executed. +3. Production validation requires separate authorization and begins with + bounded read-only measurement. Diagnosis does not authorize deployment, + restart, database write, index creation, provider change, or production + access. + +## Verification completed + +- Four repeated default service results were identical. +- All artificial-delay endpoint responses had the same payload SHA-256: + `50f22e6a428bd19f7569f169361e71cf5d019056c23e07d75cf2af97a2f2fd11`. +- The focused existing neighborhood service suite passed: `9 passed`. +- Ruff passed for the diagnostic runner. +- Runtime endpoint code, schemas, scoring, ordering, and database schema were + not modified. diff --git a/scripts/diagnose_neighborhood_performance.py b/scripts/diagnose_neighborhood_performance.py new file mode 100644 index 0000000..3ef483c --- /dev/null +++ b/scripts/diagnose_neighborhood_performance.py @@ -0,0 +1,388 @@ +"""Bounded local diagnosis for the material-neighborhood endpoint. + +This script intentionally supports only a disposable SQLite database whose +filename contains ``neighborhood_perf_test``. It performs no network access +and does not modify application behavior. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import statistics +import time +from collections import Counter +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import event, func, insert, select +from sqlalchemy.engine import Engine + +from app.core.database import Base, SessionLocal, engine, get_db +from app.main import app +from app.models.application import Application +from app.models.element import Element +from app.models.material import Material +from app.models.material_application import MaterialApplication +from app.models.material_element import MaterialElement +from app.schemas.material_neighborhood import MaterialNeighborhoodResponse +from app.services.material.neighbor_service import MaterialNeighborService +from app.services.material.neighborhood_service import MaterialNeighborhoodService + + +ELEMENTS = ("Li", "Fe", "P", "O", "Na", "Mn", "Co", "Ni", "Si", "S") +APPLICATIONS = ("battery", "catalyst", "structural") + + +@dataclass +class QueryRecorder: + starts: list[float] = field(default_factory=list) + durations_ms: list[float] = field(default_factory=list) + statements: list[str] = field(default_factory=list) + + def before(self, _conn, _cursor, statement, _parameters, _context, _many) -> None: + self.starts.append(time.perf_counter()) + self.statements.append(statement) + + def after(self, *_args) -> None: + self.durations_ms.append((time.perf_counter() - self.starts.pop()) * 1_000) + + +class ProfiledNeighborService(MaterialNeighborService): + def __init__(self, db): + super().__init__(db) + self.calls = 0 + self.call_ms = 0.0 + self.build_ms = 0.0 + + def get_neighbors(self, material_id: int) -> dict: + started = time.perf_counter() + try: + return super().get_neighbors(material_id) + finally: + self.calls += 1 + self.call_ms += (time.perf_counter() - started) * 1_000 + + def _build_neighbors(self, *, neighbor_scores, materials_by_id): + started = time.perf_counter() + try: + return super()._build_neighbors( + neighbor_scores=neighbor_scores, + materials_by_id=materials_by_id, + ) + finally: + self.build_ms += (time.perf_counter() - started) * 1_000 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--materials", type=int, default=1_727) + parser.add_argument("--depth", type=int, default=2, choices=(1, 2)) + parser.add_argument("--limit", type=int, default=25) + parser.add_argument("--warm-runs", type=int, default=3) + parser.add_argument( + "--rtt-ms", + type=int, + nargs="+", + default=(0, 25, 50, 100), + help="Artificial per-query round-trip delays for endpoint sensitivity.", + ) + return parser.parse_args() + + +def validate_environment( + output: Path, + materials: int, + limit: int, + warm_runs: int, + rtt_values: list[int], +) -> None: + database = engine.url.database or "" + if engine.dialect.name != "sqlite" or "neighborhood_perf_test" not in database: + raise RuntimeError( + "diagnosis requires a disposable SQLite database containing " + "'neighborhood_perf_test' in its filename" + ) + if not 25 <= materials <= 10_000: + raise ValueError("--materials must be between 25 and 10000") + if not 1 <= limit <= 100: + raise ValueError("--limit must be between 1 and 100") + if not 1 <= warm_runs <= 5: + raise ValueError("--warm-runs must be between 1 and 5") + if not rtt_values or any(value < 0 or value > 100 for value in rtt_values): + raise ValueError("--rtt-ms values must be between 0 and 100") + if output.exists(): + raise ValueError(f"refusing to overwrite {output}") + + +def seed_fixture(material_count: int) -> dict[str, int]: + Base.metadata.create_all( + engine, + tables=[ + Element.__table__, + Application.__table__, + Material.__table__, + MaterialElement.__table__, + MaterialApplication.__table__, + ], + ) + with engine.begin() as connection: + existing = connection.scalar(select(func.count()).select_from(Material)) + if existing: + if existing != material_count: + raise RuntimeError("existing fixture has an unexpected material count") + else: + connection.execute( + insert(Element), + [ + {"id": index, "symbol": symbol, "name": symbol} + for index, symbol in enumerate(ELEMENTS, start=1) + ], + ) + connection.execute( + insert(Application), + [ + {"id": index, "name": name} + for index, name in enumerate(APPLICATIONS, start=1) + ], + ) + connection.execute( + insert(Material), + [ + { + "id": material_id, + "mp_id": f"perf-{material_id:05d}", + "formula": f"M{material_id}O2", + "pretty_formula": f"M{material_id}O2", + "material_type": "synthetic_performance_fixture", + "energy_above_hull": (material_id % 21) / 100, + "is_stable": material_id % 21 == 0, + "source": "synthetic_performance_fixture", + } + for material_id in range(1, material_count + 1) + ], + ) + material_elements = [] + material_applications = [] + for material_id in range(1, material_count + 1): + # O is deliberately common, while the other memberships create + # dense and sparse sub-neighborhoods deterministically. + element_ids = {4, 1 + (material_id % len(ELEMENTS))} + if material_id % 3 == 0: + element_ids.add(2) + if material_id % 5 == 0: + element_ids.add(3) + for element_id in sorted(element_ids): + material_elements.append( + { + "material_id": material_id, + "element_id": element_id, + "fraction": 1.0 / len(element_ids), + "fraction_known": True, + } + ) + if material_id <= 28 or material_id % 17 == 0: + material_applications.append( + { + "material_id": material_id, + "application_id": 1 + (material_id % len(APPLICATIONS)), + "suitability_score": 0.5, + } + ) + connection.execute(insert(MaterialElement), material_elements) + connection.execute(insert(MaterialApplication), material_applications) + + return { + "materials": connection.scalar(select(func.count()).select_from(Material)), + "elements": connection.scalar(select(func.count()).select_from(Element)), + "material_elements": connection.scalar( + select(func.count()).select_from(MaterialElement) + ), + "material_applications": connection.scalar( + select(func.count()).select_from(MaterialApplication) + ), + } + + +def classify_statement(statement: str) -> str: + lowered = " ".join(statement.lower().split()) + for table in ( + "material_elements", + "material_applications", + "materials", + "elements", + "applications", + ): + if f" {table} " in f" {lowered} ": + return table + return "other" + + +@contextmanager +def record_queries(target_engine: Engine): + recorder = QueryRecorder() + event.listen(target_engine, "before_cursor_execute", recorder.before) + event.listen(target_engine, "after_cursor_execute", recorder.after) + try: + yield recorder + finally: + event.remove(target_engine, "before_cursor_execute", recorder.before) + event.remove(target_engine, "after_cursor_execute", recorder.after) + + +@contextmanager +def simulated_round_trip(target_engine: Engine, milliseconds: int): + def delay(*_args) -> None: + time.sleep(milliseconds / 1_000) + + if milliseconds: + event.listen(target_engine, "before_cursor_execute", delay) + try: + yield + finally: + if milliseconds: + event.remove(target_engine, "before_cursor_execute", delay) + + +def service_measurement(depth: int, limit: int) -> tuple[dict[str, Any], dict]: + with SessionLocal() as db, record_queries(engine) as queries: + service = MaterialNeighborhoodService(db) + profiled = ProfiledNeighborService(db) + service.neighbor_service = profiled + started = time.perf_counter() + result = service.get_neighborhood(material_id=1, depth=depth, limit=limit) + service_ms = (time.perf_counter() - started) * 1_000 + + serialization_started = time.perf_counter() + validated = MaterialNeighborhoodResponse.model_validate(result) + payload = validated.model_dump_json().encode("utf-8") + serialization_ms = (time.perf_counter() - serialization_started) * 1_000 + database_ms = sum(queries.durations_ms) + statement_counts = Counter(classify_statement(item) for item in queries.statements) + return ( + { + "service_ms": round(service_ms, 3), + "database_query_count": len(queries.statements), + "database_execute_ms": round(database_ms, 3), + "query_count_by_table": dict(sorted(statement_counts.items())), + "neighbor_service_calls": profiled.calls, + "neighbor_service_ms": round(profiled.call_ms, 3), + "neighbor_build_and_score_ms": round(profiled.build_ms, 3), + "neighborhood_graph_ms_estimate": round( + max(0.0, service_ms - profiled.call_ms), 3 + ), + "neighbor_query_materialization_and_collection_ms_estimate": round( + max(0.0, profiled.call_ms - database_ms - profiled.build_ms), 3 + ), + "serialization_ms": round(serialization_ms, 3), + "payload_bytes": len(payload), + "payload_sha256": hashlib.sha256(payload).hexdigest(), + "node_count": result["node_count"], + "edge_count": result["edge_count"], + }, + result, + ) + + +def endpoint_measurement(depth: int, limit: int, rtt_ms: int) -> dict[str, Any]: + def override_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + path = f"/api/v1/materials/1/neighborhood?depth={depth}&limit={limit}" + try: + with simulated_round_trip(engine, rtt_ms), record_queries(engine) as queries: + with TestClient(app) as client: + started = time.perf_counter() + response = client.get(path) + wall_ms = (time.perf_counter() - started) * 1_000 + finally: + app.dependency_overrides.clear() + if response.status_code != 200: + raise RuntimeError(f"endpoint returned {response.status_code}: {response.text}") + return { + "simulated_rtt_ms_per_query": rtt_ms, + "wall_ms": round(wall_ms, 3), + "database_query_count": len(queries.statements), + "database_execute_ms_including_simulated_rtt": round( + sum(queries.durations_ms), 3 + ), + "payload_bytes": len(response.content), + "payload_sha256": hashlib.sha256(response.content).hexdigest(), + } + + +def main() -> int: + args = parse_args() + validate_environment( + args.output, + args.materials, + args.limit, + args.warm_runs, + args.rtt_ms, + ) + counts = seed_fixture(args.materials) + + service_runs = [] + expected_result = None + for _ in range(args.warm_runs + 1): + measurement, result = service_measurement(args.depth, args.limit) + service_runs.append(measurement) + if expected_result is None: + expected_result = result + elif result != expected_result: + raise RuntimeError("repeated service result changed") + + endpoint_runs = [ + endpoint_measurement(args.depth, args.limit, rtt_ms) + for rtt_ms in args.rtt_ms + ] + warm_service_ms = [item["service_ms"] for item in service_runs[1:]] + report = { + "schema_version": 1, + "method": "bounded sequential local diagnosis; no concurrency or network", + "fixture": { + "kind": "deterministic synthetic production-count fixture", + "row_counts": counts, + }, + "request": {"material_id": 1, "depth": args.depth, "limit": args.limit}, + "service_runs": { + "cold": service_runs[0], + "warm": service_runs[1:], + "warm_median_service_ms": round(statistics.median(warm_service_ms), 3), + }, + "endpoint_latency_sensitivity": endpoint_runs, + "semantic_stability": { + "repeated_results_identical": True, + "canonical_result_sha256": hashlib.sha256( + json.dumps( + expected_result, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest(), + }, + "limitations": [ + "SQLite does not reproduce PostgreSQL execution plans or Neon network behavior.", + "The fixture matches production material count but not the exact production cohort.", + "Artificial per-query delay is a sensitivity test, not a production measurement.", + "Sequential measurements do not establish concurrency capacity.", + ], + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + print(json.dumps({"output": str(args.output), "status": "complete"})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())