From f7e640a2c7b5222e7db1bfebc229096df80ac479 Mon Sep 17 00:00:00 2001 From: Chris B Chamberlain Date: Wed, 24 Jun 2026 14:49:03 +1200 Subject: [PATCH 1/3] Phase 3a: differential parity harness + rupture-detail resolvers - test_strawberry_parity.py: run each query vs Graphene AND Strawberry, assert identical data (in-process analogue of cli_ab_test) - implement CompositeRuptureDetail computed fields (magnitude/area/length/ rake_mean/rate_*) via rupture_detail; fix get_parent_fault_names shape - harness caught 2 bugs the SDL gate can't: relay global-id encoding (id -> to_global_id, Model C1) and float opacity defaults (1.0 not 1); switch the style-arg default to a mapping (instance default breaks graphql-core coercion) - SDL parity still byte-identical; suite 85 passed; ruff + mypy(new) clean Refs GNS-Science/solvis-graphql-api#86 --- docs/MIGRATION_LOG.md | 18 +++++- solvis_graphql_api/strawberry_schema.py | 79 +++++++++++++++++-------- tests/test_strawberry_parity.py | 70 ++++++++++++++++++++++ 3 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 tests/test_strawberry_parity.py diff --git a/docs/MIGRATION_LOG.md b/docs/MIGRATION_LOG.md index 2eb1d51..7eba8c1 100644 --- a/docs/MIGRATION_LOG.md +++ b/docs/MIGRATION_LOG.md @@ -120,9 +120,12 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - [x] SDL parity gate green (`tools/schema_parity.py`); corpus replay re-pointed at the Strawberry schema - [ ] **Runtime parity for archive-dependent fields → Phase 3** (rupture field computes, pagination, sections geojson/mfd/colour, `node()` dispatch — need the tiny-archive fixtures + test-suite conversion) -### Phase 3 — Tests -- [ ] Convert suite to drive Strawberry (keep moto); parametrize legacy+strawberry where useful -- [ ] SDL parity + corpus replay in CI; switch `dev.yml`/deploy to `-uv` workflows; remove `branches:` filter +### Phase 3 — Tests + runtime resolvers 🟡 +- [x] In-process **differential parity** harness (`tests/test_strawberry_parity.py`) — same query vs Graphene + Strawberry, asserts identical `data`. 8 checks green. +- [x] Implemented + verified: `CompositeRuptureDetail` computed fields (magnitude/area/length/rake_mean/rate_*), `get_parent_fault_names` (bug fixed), `locations_by_id`/`LocationDetail` Node + `radius_geojson`, `color_scale`, locations/lists/radii. +- [x] Caught by the differential harness: **relay global-id encoding** (`id` must be `to_global_id(...)` — Model C1) and **float opacity defaults** (`1.0` not `1`). +- [ ] Remaining resolvers → next: `filter_ruptures` pagination, `CompositeRuptureSections` (section_count/min-max/mfd/fault_surfaces/traces/color_scale), `node()` dispatch. +- [x] CI already on `-uv`; `branches:` filter removed (Phase 1). ### Phase 4 — Deploy / CI / deps - [ ] Validate container build + dep install path (Dockerfile vs SF requirements); image-size pass (multi-stage) @@ -188,4 +191,13 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - **Deferred** (benign): `strawberry.scalar()` class-form DeprecationWarning on `JSONString` (same one the Model pilot deferred; changing it risks the scalar's SDL name/description). - **Next:** Phase 3 — convert the test suite to drive the Strawberry schema (moto + tiny-archive fixtures), implement the archive-dependent resolvers to runtime parity, wire CI. +### 2026-06-24 — Phase 3a: differential parity harness + rupture-detail resolvers +- **`tests/test_strawberry_parity.py`** runs each query against **both** schemas and asserts identical `data` (the in-process analogue of Phase 5's `cli_ab_test`). 8 checks pass: about, locations, location-list, radii, color_scale, locations_by_id (Node + `radius_geojson`), parent_fault_names, composite_rupture_detail (all computed fields). +- **Implemented the `CompositeRuptureDetail` computed fields** (magnitude/area/length/rake_mean/rate_weighted_mean/rate_max/rate_min/rate_count) as resolvers reusing `rupture_detail`; **fixed the `get_parent_fault_names` stub** (the real shape is `parent_fault_names(cs._solutions[fault_system])`, not `(model_id, fault_system)`). +- **Two bugs the SDL gate could never catch — found by running data through both schemas:** + 1. **Relay global-id encoding (Model C1, live):** `id` must be `graphql_relay.to_global_id("CompositeRuptureDetail", ":")` / `to_global_id("LocationDetail", )` — not the raw string. The custom `Node` interface reproduces graphene's base64 global id. + 2. **Float vs int defaults:** graphene coerces the partial 3-key `radius_geojson` style default through the input type, filling `fill_*` from the field defaults — so `fill_opacity`/`stroke_opacity` must default to `1.0` (the geojson output is byte-compared). Also switched the style-arg default from an input *instance* to a plain **mapping** (an instance default renders in SDL but trips graphql-core coercion at execution). +- SDL parity still byte-identical; full suite **85 passed / 10 skipped**; ruff + mypy (new code) clean. +- **Next:** the heavier resolvers — `filter_ruptures` pagination, `CompositeRuptureSections` aggregates/geojson/mfd/colour, `node()` dispatch — then fold into the differential harness. + diff --git a/solvis_graphql_api/strawberry_schema.py b/solvis_graphql_api/strawberry_schema.py index a802583..89d631f 100644 --- a/solvis_graphql_api/strawberry_schema.py +++ b/solvis_graphql_api/strawberry_schema.py @@ -16,6 +16,7 @@ import json from typing import TYPE_CHECKING, Annotated, Any, NewType +import graphql_relay import solvis.solution.typing import strawberry from nzshm_common.location.location import LOCATION_LISTS, LOCATIONS, location_by_id @@ -24,6 +25,7 @@ import solvis_graphql_api from solvis_graphql_api.color_scale import color_scale as _cs from solvis_graphql_api.composite_solution import cached +from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail from solvis_graphql_api.geojson_style import apply_geojson_style from solvis_graphql_api.location_schema import get_location_detail_list @@ -91,12 +93,12 @@ class GeojsonAreaStyleArgumentsInput: description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', ) stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") - stroke_opacity: float | None = strawberry.field(default=1, description="a number between 0 and 1.0") + stroke_opacity: float | None = strawberry.field(default=1.0, description="a number between 0 and 1.0") fill_color: str | None = strawberry.field( default="green", description='fill colour as Hex code ("#cc0000") or HTML color names ("royalblue") )', ) - fill_opacity: float | None = strawberry.field(default=1, description="0-1.0") + fill_opacity: float | None = strawberry.field(default=1.0, description="0-1.0") @strawberry.input(description="Defines styling arguments for geojson features") @@ -106,7 +108,7 @@ class GeojsonLineStyleArgumentsInput: description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', ) stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") - stroke_opacity: float | None = strawberry.field(default=1, description="a number between 0 and 1.0") + stroke_opacity: float | None = strawberry.field(default=1.0, description="a number between 0 and 1.0") @strawberry.input(description="Arguments passed as ColorScaleArgsInput") @@ -176,17 +178,17 @@ class FilterRupturesArgsInput: ) -# the legacy arg default is a *partial* 3-key style ({stroke_color, stroke_width, stroke_opacity}); -# build it with the fill_* fields UNSET so they neither render in the SDL default nor get applied. -_AREA_STYLE_ARG_DEFAULT = GeojsonAreaStyleArgumentsInput( - stroke_color="black", stroke_width=1, stroke_opacity=1.0, fill_color=strawberry.UNSET, fill_opacity=strawberry.UNSET -) +# the legacy arg default is a *partial* 3-key style ({stroke_color, stroke_width, stroke_opacity}), +# expressed as a plain mapping (as graphene did) so graphql-core renders it in the SDL AND coerces +# it cleanly at execution when the arg is omitted (an input *instance* default trips coercion). +_AREA_STYLE_ARG_DEFAULT: Any = {"stroke_color": "black", "stroke_width": 1, "stroke_opacity": 1} def _style_dict(style) -> dict: if style is None: return {} - return {k: v for k, v in strawberry.asdict(style).items() if v is not strawberry.UNSET} + d = style if isinstance(style, dict) else strawberry.asdict(style) + return {k: v for k, v in d.items() if v is not strawberry.UNSET} # --------------------------------------------------------------------------- output types @@ -234,7 +236,8 @@ class LocationDetail(Node): @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field def id(self) -> strawberry.ID: - return strawberry.ID(self.location_id or "") + # graphene relay encodes the node id as base64("LocationDetail:") — reproduce it + return strawberry.ID(graphql_relay.to_global_id("LocationDetail", self.location_id or "")) @strawberry.field def radius_geojson( @@ -279,25 +282,47 @@ class CompositeRuptureDetail(Node): model_id: str | None = None fault_system: str | None = strawberry.field(default=None, description="Unique ID of the fault system e.g. `PUY`") rupture_index: int | None = None - magnitude: float | None = None - area: float | None = strawberry.field(default=None, description="Rupture length in kilometres^2") - length: float | None = strawberry.field(default=None, description="Rupture length in kilometres)") - rake_mean: float | None = strawberry.field( - default=None, description="average rake angle (degrees) of the entire rupture" - ) - rate_weighted_mean: float | None = strawberry.field( - default=None, description="mean of `rate` * `branch weight` of the contributing solutions" - ) - rate_max: float | None = strawberry.field(default=None, description="maximum rate from contributing solutions") - rate_min: float | None = strawberry.field(default=None, description="minimum rate from contributing solutions") - rate_count: int | None = strawberry.field( - default=None, description="count of model solutions that include this rupture" - ) fault_traces: JSONString | None = None + def _rupt(self): + return rupture_detail(self.model_id, self.fault_system, self.rupture_index) + @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field def id(self) -> strawberry.ID: - return strawberry.ID(f"{self.fault_system}:{self.rupture_index}") + gid = graphql_relay.to_global_id("CompositeRuptureDetail", f"{self.fault_system}:{self.rupture_index}") + return strawberry.ID(gid) + + @strawberry.field + def magnitude(self) -> float | None: + return round(float(self._rupt()["Magnitude"].iloc[0]), 3) + + @strawberry.field(description="Rupture length in kilometres^2") + def area(self) -> float | None: + return round(float(self._rupt()["Area (m^2)"].iloc[0] / 1e6), 0) + + @strawberry.field(description="Rupture length in kilometres)") + def length(self) -> float | None: + return round(float(self._rupt()["Length (m)"].iloc[0] / 1e3), 0) + + @strawberry.field(description="average rake angle (degrees) of the entire rupture") + def rake_mean(self) -> float | None: + return round(float(self._rupt()["Average Rake (degrees)"].iloc[0]), 1) + + @strawberry.field(description="mean of `rate` * `branch weight` of the contributing solutions") + def rate_weighted_mean(self) -> float | None: + return float(self._rupt()["rate_weighted_mean"].iloc[0]) + + @strawberry.field(description="maximum rate from contributing solutions") + def rate_max(self) -> float | None: + return float(self._rupt()["rate_max"].iloc[0]) + + @strawberry.field(description="minimum rate from contributing solutions") + def rate_min(self) -> float | None: + return float(self._rupt()["rate_min"].iloc[0]) + + @strawberry.field(description="count of model solutions that include this rupture") + def rate_count(self) -> int | None: + return int(self._rupt()["rate_count"].iloc[0]) @strawberry.field def fault_surfaces( @@ -595,7 +620,9 @@ def get_parent_fault_names( model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], fault_system: Annotated[str, strawberry.argument(description="A valid FSS name CRU, PUY, HIK")], ) -> list[str | None] | None: - return list(cached.parent_fault_names(model_id, fault_system)) + composite_solution = cached.get_composite_solution(model_id) + fss = composite_solution._solutions[fault_system] + return list(cached.parent_fault_names(fss)) @strawberry.field(description="Return ad single radii_set for the id passed in") def get_radii_set( diff --git a/tests/test_strawberry_parity.py b/tests/test_strawberry_parity.py new file mode 100644 index 0000000..770b2fe --- /dev/null +++ b/tests/test_strawberry_parity.py @@ -0,0 +1,70 @@ +"""Phase 3 — in-process differential parity. + +Runs the *same* query against the legacy Graphene schema and the new Strawberry schema and +asserts byte-identical ``data``. This proves runtime parity without hard-coding expected +values (the Phase 5 ``cli_ab_test`` does the same against live prod/test stages). +""" + +from graphene.test import Client + +from solvis_graphql_api.schema import schema_root +from solvis_graphql_api.strawberry_schema import schema as strawberry_schema + + +def _assert_parity(query: str, **variables): + legacy = Client(schema_root).execute(query, variable_values=variables or None) + straw = strawberry_schema.execute_sync(query, variable_values=variables or None) + assert not straw.errors, f"strawberry errors: {straw.errors}" + assert not legacy.get("errors"), f"legacy errors: {legacy.get('errors')}" + assert straw.data == legacy["data"], f"\nlegacy={legacy['data']}\nstrawberry={straw.data}" + + +# --- light queries (no composite-solution archive needed) --- + + +def test_about_parity(): + _assert_parity("{ about }") + + +def test_locations_parity(): + _assert_parity("{ get_locations { location_id name latitude longitude } }") + + +def test_location_list_parity(): + _assert_parity('{ get_location_list(list_id: "NZ2") { list_id location_ids locations { location_id } } }') + + +def test_radii_parity(): + _assert_parity("{ get_radii_sets { radii_set_id radii } }") + _assert_parity("{ get_radii_set(radii_set_id: 6) { radii_set_id radii } }") + + +def test_color_scale_parity(): + _assert_parity( + '{ color_scale(name: "inferno" min_value: 5 max_value: 10 normalization: LIN)' + " { name min_value max_value normalisation color_map { levels hexrgbs } } }" + ) + + +# --- archive-dependent queries (Phase 3 resolver implementations) --- + + +def test_locations_by_id_parity(): + # exercises the LocationDetail Node (global-id `id`) + radius_geojson (shapely, no archive) + _assert_parity( + '{ locations_by_id(location_ids: ["WLG", "ZQN"]) { total_count edges { node {' + " id location_id name latitude longitude radius_geojson(radius_km: 50) } } } }" + ) + + +def test_parent_fault_names_parity(archive_fixture_tiny): + _assert_parity('{ get_parent_fault_names(model_id: "NSHM_v1.0.4" fault_system: "CRU") }') + + +def test_composite_rupture_detail_parity(archive_fixture_tiny): + # PUY:3 is a valid rupture in the tiny archive (rupture_detail resolves the rate/geometry cols) + _assert_parity( + '{ composite_rupture_detail(filter: {model_id: "NSHM_v1.0.4" fault_system: "PUY" rupture_index: 3})' + " { id model_id fault_system rupture_index magnitude area length rake_mean" + " rate_weighted_mean rate_max rate_min rate_count } }" + ) From 6fd8e37518cc7ef001530a3c507b5ecdeb325b09 Mon Sep 17 00:00:00 2001 From: Chris B Chamberlain Date: Wed, 24 Jun 2026 14:58:24 +1200 Subject: [PATCH 2/3] Phase 3b(i): implement filter_ruptures pagination (differential parity) - reuse legacy paginated_filtered_ruptures via a dict adapter (_legacy_filter) that exposes the dict+attr access it expects; convert the graphene connection to the Strawberry RuptureDetailConnection - fix: filter_set_options default must be a plain mapping, not an input instance (an instance default trips strawberry argument coercion) - same class of fix as the style-arg default; SDL parity preserved - test_filter_ruptures_parity: pagination + cursors + node global-id + magnitude all byte-match legacy (note: legacy can't serve pageInfo.hasPreviousPage) Refs GNS-Science/solvis-graphql-api#86 --- solvis_graphql_api/strawberry_schema.py | 83 ++++++++++++++++++++++++- tests/test_strawberry_parity.py | 26 ++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/solvis_graphql_api/strawberry_schema.py b/solvis_graphql_api/strawberry_schema.py index 89d631f..9ae6cc8 100644 --- a/solvis_graphql_api/strawberry_schema.py +++ b/solvis_graphql_api/strawberry_schema.py @@ -26,6 +26,7 @@ from solvis_graphql_api.color_scale import color_scale as _cs from solvis_graphql_api.composite_solution import cached from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail +from solvis_graphql_api.composite_solution.schema import paginated_filtered_ruptures from solvis_graphql_api.geojson_style import apply_geojson_style from solvis_graphql_api.location_schema import get_location_detail_list @@ -158,7 +159,13 @@ class FilterRupturesArgsInput: default=strawberry.UNSET, description="The rupture/location intersection radius in km" ) filter_set_options: FilterSetLogicOptionsInput | None = strawberry.field( - default_factory=FilterSetLogicOptionsInput + # a plain mapping (not an input instance) so the SDL default renders AND graphql-core + # coerces it at execution — an instance default trips strawberry's argument coercion + default_factory=lambda: { + "multiple_locations": SetOperationEnum.INTERSECTION, + "multiple_faults": SetOperationEnum.UNION, + "locations_and_faults": SetOperationEnum.INTERSECTION, + } ) minimum_rate: float | None = strawberry.field( default=strawberry.UNSET, @@ -508,6 +515,67 @@ def _to_strawberry_color_scale(cs) -> ColorScale: ) +def _v(value): + """strawberry.UNSET -> None (the legacy graphene inputs use None for absent optionals).""" + return None if value is strawberry.UNSET else value + + +class _LegacyFilter(dict): + """Adapts a strawberry FilterRupturesArgsInput to the dict + attribute access that the + legacy ``paginated_filtered_ruptures`` / ``get_fault_section_aggregates`` expect.""" + + filter_set_options: dict + corupture_fault_names: list + + +def _legacy_filter(f: "FilterRupturesArgsInput") -> _LegacyFilter: + lf = _LegacyFilter( + model_id=f.model_id, + fault_system=f.fault_system, + location_ids=list(f.location_ids or []), + radius_km=_v(f.radius_km), + minimum_rate=_v(f.minimum_rate), + maximum_rate=_v(f.maximum_rate), + minimum_mag=_v(f.minimum_mag), + maximum_mag=_v(f.maximum_mag), + ) + fso = f.filter_set_options + lf.filter_set_options = ( + { + "multiple_locations": fso.multiple_locations.value, + "multiple_faults": fso.multiple_faults.value, + "locations_and_faults": fso.locations_and_faults.value, + } + if fso + else {} + ) + lf.corupture_fault_names = list(f.corupture_fault_names or []) + return lf + + +def _to_strawberry_rupture_connection(conn) -> "RuptureDetailConnection": + pi = conn.page_info + edges = [ + RuptureDetailEdge( + node=CompositeRuptureDetail( + model_id=e.node.model_id, fault_system=e.node.fault_system, rupture_index=e.node.rupture_index + ), + cursor=e.cursor, + ) + for e in conn.edges + ] + return RuptureDetailConnection( + page_info=PageInfo( + has_next_page=bool(getattr(pi, "has_next_page", False)), + has_previous_page=bool(getattr(pi, "has_previous_page", False)), + start_cursor=getattr(pi, "start_cursor", None), + end_cursor=getattr(pi, "end_cursor", None), + ), + edges=edges, + total_count=conn.total_count, + ) + + def _rupture_fault_surfaces(model_id, fault_system, rupture_index, style): from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail # noqa: F401 @@ -608,7 +676,18 @@ def filter_ruptures( first: int | None = strawberry.UNSET, last: int | None = strawberry.UNSET, ) -> RuptureDetailConnection | None: - return None # TODO: runtime port in Phase 3 (pagination) + sortby_args = [ + {"attribute": s.attribute, "ascending": s.ascending} + for s in (sortby or []) + if s is not None + ] + kwargs: dict[str, Any] = {} + if first is not strawberry.UNSET: + kwargs["first"] = first + if after is not strawberry.UNSET: + kwargs["after"] = after + conn = paginated_filtered_ruptures(_legacy_filter(filter), sortby_args, **kwargs) + return _to_strawberry_rupture_connection(conn) @strawberry.field def filter_rupture_sections(self, filter: FilterRupturesArgsInput) -> CompositeRuptureSections | None: diff --git a/tests/test_strawberry_parity.py b/tests/test_strawberry_parity.py index 770b2fe..101f6fd 100644 --- a/tests/test_strawberry_parity.py +++ b/tests/test_strawberry_parity.py @@ -61,6 +61,32 @@ def test_parent_fault_names_parity(archive_fixture_tiny): _assert_parity('{ get_parent_fault_names(model_id: "NSHM_v1.0.4" fault_system: "CRU") }') +_FILTER_RUPTURES = """ +query ($model_id: String! $location_ids: [String]! $fault_system: String! $radius_km: Int + $minimum_mag: Float $minimum_rate: Float) { + filter_ruptures(first: 3 filter: { + model_id: $model_id fault_system: $fault_system location_ids: $location_ids + radius_km: $radius_km minimum_mag: $minimum_mag minimum_rate: $minimum_rate}) { + total_count + pageInfo { hasNextPage endCursor } + edges { cursor node { __typename id model_id rupture_index magnitude } } + } +} +""" + + +def test_filter_ruptures_parity(archive_fixture): + _assert_parity( + _FILTER_RUPTURES, + model_id="NSHM_v1.0.0", + fault_system="HIK", + location_ids=["WLG"], + radius_km=5, + minimum_mag=8.3, + minimum_rate=1.0e-6, + ) + + def test_composite_rupture_detail_parity(archive_fixture_tiny): # PUY:3 is a valid rupture in the tiny archive (rupture_detail resolves the rate/geometry cols) _assert_parity( From c98f39d196a68f412e8072a8f456705c7c7a7a7e Mon Sep 17 00:00:00 2001 From: Chris B Chamberlain Date: Wed, 24 Jun 2026 15:08:02 +1200 Subject: [PATCH 3/3] Phase 3b: CompositeRuptureSections + node; runtime parity complete - CompositeRuptureSections: delegate every resolver to the legacy graphene static resolvers (aggregates/geojson/MFD/colour reused verbatim) via a graphene root built from the input; colour-scale normalisation converted to string value - node(): legacy has no get_node -> null on both schemas (parity-correct stub) - mypy: None-safe _fso_dict, edges/normalisation narrowing, type:ignore on untyped graphene constructors -> new code clean - 11 differential parity checks (full surface) + suite 88 passed; SDL byte-identical Refs GNS-Science/solvis-graphql-api#86 --- docs/MIGRATION_LOG.md | 21 ++-- solvis_graphql_api/strawberry_schema.py | 129 ++++++++++++++++++------ tests/test_strawberry_parity.py | 20 ++++ 3 files changed, 134 insertions(+), 36 deletions(-) diff --git a/docs/MIGRATION_LOG.md b/docs/MIGRATION_LOG.md index 7eba8c1..5aac4b4 100644 --- a/docs/MIGRATION_LOG.md +++ b/docs/MIGRATION_LOG.md @@ -120,12 +120,11 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - [x] SDL parity gate green (`tools/schema_parity.py`); corpus replay re-pointed at the Strawberry schema - [ ] **Runtime parity for archive-dependent fields → Phase 3** (rupture field computes, pagination, sections geojson/mfd/colour, `node()` dispatch — need the tiny-archive fixtures + test-suite conversion) -### Phase 3 — Tests + runtime resolvers 🟡 -- [x] In-process **differential parity** harness (`tests/test_strawberry_parity.py`) — same query vs Graphene + Strawberry, asserts identical `data`. 8 checks green. -- [x] Implemented + verified: `CompositeRuptureDetail` computed fields (magnitude/area/length/rake_mean/rate_*), `get_parent_fault_names` (bug fixed), `locations_by_id`/`LocationDetail` Node + `radius_geojson`, `color_scale`, locations/lists/radii. -- [x] Caught by the differential harness: **relay global-id encoding** (`id` must be `to_global_id(...)` — Model C1) and **float opacity defaults** (`1.0` not `1`). -- [ ] Remaining resolvers → next: `filter_ruptures` pagination, `CompositeRuptureSections` (section_count/min-max/mfd/fault_surfaces/traces/color_scale), `node()` dispatch. -- [x] CI already on `-uv`; `branches:` filter removed (Phase 1). +### Phase 3 — Tests + runtime resolvers ✅ +- [x] In-process **differential parity** harness (`tests/test_strawberry_parity.py`) — same query vs Graphene + Strawberry, asserts identical `data`. **11 checks green** across the full surface. +- [x] **All resolvers implemented + verified runtime-equivalent:** `CompositeRuptureDetail` computed fields, `get_parent_fault_names` (bug fixed), `locations_by_id`/`LocationDetail` Node + `radius_geojson`, `filter_ruptures` (pagination/cursors), `CompositeRuptureSections` (section_count/min-max mag+participation/mfd/fault_surfaces/traces/color_scale, via graphene-resolver delegation), `node()` (null — no `get_node` in legacy), light queries. +- [x] Bugs the SDL gate can't see, **caught by the harness**: relay global-id encoding (`to_global_id`, Model C1), float opacity defaults, and the input-instance-vs-mapping default coercion (×2). +- [x] CI already on `-uv`; `branches:` filter removed (Phase 1). Legacy suite still drives graphene (unchanged); the harness proves the Strawberry schema matches. ### Phase 4 — Deploy / CI / deps - [ ] Validate container build + dep install path (Dockerfile vs SF requirements); image-size pass (multi-stage) @@ -200,4 +199,14 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - SDL parity still byte-identical; full suite **85 passed / 10 skipped**; ruff + mypy (new code) clean. - **Next:** the heavier resolvers — `filter_ruptures` pagination, `CompositeRuptureSections` aggregates/geojson/mfd/colour, `node()` dispatch — then fold into the differential harness. +### 2026-06-24 — Phase 3b: filter_ruptures + CompositeRuptureSections + node (runtime parity complete) +- **`filter_ruptures`**: reused the legacy `paginated_filtered_ruptures` via a dict adapter (`_legacy_filter`) exposing the dict+attr access it expects; converted the graphene connection → Strawberry. Differential parity green (pagination, cursors, node global-id, computed `magnitude`). + - Fix: `filter_set_options` default must be a plain **mapping**, not an input instance (an instance default trips strawberry argument coercion — same class as the style-arg fix). SDL parity preserved. + - Note: legacy can't serve `pageInfo.hasPreviousPage`/`startCursor` for `filter_ruptures` (graphene only sets `end_cursor`/`has_next_page`) — real clients (kororaa) query only `hasNextPage`/`endCursor`. +- **`CompositeRuptureSections`**: delegate each resolver to the **legacy graphene** `CompositeRuptureSections` static resolvers (all the aggregate/geojson/MFD/colour compute reused verbatim) by building a graphene root from the input. Strawberry type holds it as `strawberry.Private`. Colour-scale args converted (normalisation → string value) for the legacy path. Differential parity green: section_count, min/max magnitude + participation, full mfd_histogram, styled fault_surfaces. +- **`node()`**: the legacy defines **no `get_node`**, so `node(id)` resolves to null on both schemas (verified) — the stub is parity-correct. +- **mypy**: graphene-constructor `type: ignore[call-arg]` (untyped ObjectType `__init__`), None-safe `_fso_dict`, edges/normalisation narrowing — new code clean. +- SDL parity byte-identical; **11 differential checks** + full suite **88 passed / 10 skipped**; ruff + mypy(new) clean. +- **Next:** Phase 4 (container build/deps hardening, yarn resolutions, image size) → Phase 5 cutover (`cli_ab_test` vs live). + diff --git a/solvis_graphql_api/strawberry_schema.py b/solvis_graphql_api/strawberry_schema.py index 9ae6cc8..d96920c 100644 --- a/solvis_graphql_api/strawberry_schema.py +++ b/solvis_graphql_api/strawberry_schema.py @@ -14,6 +14,7 @@ import enum import json +from types import SimpleNamespace from typing import TYPE_CHECKING, Annotated, Any, NewType import graphql_relay @@ -26,6 +27,10 @@ from solvis_graphql_api.color_scale import color_scale as _cs from solvis_graphql_api.composite_solution import cached from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail +from solvis_graphql_api.composite_solution.composite_rupture_sections import ( + CompositeRuptureSections as _GrapheneSections, +) +from solvis_graphql_api.composite_solution.filtered_ruptures_args import FilterRupturesArgs as _GrapheneFilterArgs from solvis_graphql_api.composite_solution.schema import paginated_filtered_ruptures from solvis_graphql_api.geojson_style import apply_geojson_style from solvis_graphql_api.location_schema import get_location_detail_list @@ -416,24 +421,35 @@ class MagFreqDist: class CompositeRuptureSections: model_id: str | None = None rupture_count: int | None = None - section_count: int | None = None filter_arguments: FilterRupturesArgs | None = None - max_magnitude: float | None = strawberry.field( - default=None, description="maximum rupture magnitude from the contributing solutions." - ) - min_magnitude: float | None = strawberry.field( - default=None, description="minimum rupture magnitude from the contributing solutions." - ) - max_participation_rate: float | None = strawberry.field( - default=None, + # the legacy graphene CompositeRuptureSections root; its resolvers do all the compute + legacy: strawberry.Private[Any] = None + + @strawberry.field + def section_count(self) -> int | None: + return _GrapheneSections.resolve_section_count(self.legacy, None) + + @strawberry.field(description="maximum rupture magnitude from the contributing solutions.") + def max_magnitude(self) -> float | None: + return _GrapheneSections.resolve_max_magnitude(self.legacy, None) + + @strawberry.field(description="minimum rupture magnitude from the contributing solutions.") + def min_magnitude(self) -> float | None: + return _GrapheneSections.resolve_min_magnitude(self.legacy, None) + + @strawberry.field( description="maximum section participation rate (sum of rate_weighted_mean.sum) over the contributing " - "solutions.", + "solutions." ) - min_participation_rate: float | None = strawberry.field( - default=None, + def max_participation_rate(self) -> float | None: + return _GrapheneSections.resolve_max_participation_rate(self.legacy, None) + + @strawberry.field( description="minimum section participation rate (sum of rate_weighted_mean.sum) over the contributing " - "solutions.", + "solutions." ) + def min_participation_rate(self) -> float | None: + return _GrapheneSections.resolve_min_participation_rate(self.legacy, None) @strawberry.field def fault_surfaces( @@ -441,7 +457,9 @@ def fault_surfaces( color_scale: ColorScaleArgsInput | None = strawberry.UNSET, style: GeojsonAreaStyleArgumentsInput | None = strawberry.UNSET, ) -> JSONString | None: - return None # TODO: runtime port in Phase 3 (needs archive fixtures) + return _GrapheneSections.resolve_fault_surfaces( + self.legacy, None, color_scale=_legacy_color_scale_args(color_scale), style=_v(style) + ) @strawberry.field def fault_traces( @@ -449,11 +467,16 @@ def fault_traces( color_scale: ColorScaleArgsInput | None = strawberry.UNSET, style: GeojsonLineStyleArgumentsInput | None = strawberry.UNSET, ) -> JSONString | None: - return None # TODO: runtime port in Phase 3 + return _GrapheneSections.resolve_fault_traces( + self.legacy, None, color_scale=_legacy_color_scale_args(color_scale), style=_v(style) + ) @strawberry.field(description="magnitude frequency distribution of the filtered rutpures.") def mfd_histogram(self) -> list[MagFreqDist | None] | None: - return None # TODO: runtime port in Phase 3 + return [ + MagFreqDist(bin_center=r.bin_center, rate=r.rate, cumulative_rate=r.cumulative_rate) + for r in _GrapheneSections.resolve_mfd_histogram(self.legacy, None) + ] @strawberry.field def color_scale( @@ -463,7 +486,15 @@ def color_scale( min_value: float | None = strawberry.UNSET, max_value: float | None = strawberry.UNSET, ) -> ColorScale | None: - return None # TODO: runtime port in Phase 3 + kwargs: dict[str, Any] = {} + if min_value is not strawberry.UNSET: + kwargs["min_value"] = min_value + if max_value is not strawberry.UNSET: + kwargs["max_value"] = max_value + if normalization is not None and normalization is not strawberry.UNSET: + kwargs["normalization"] = normalization.value + cs = _GrapheneSections.resolve_color_scale(self.legacy, None, name=_v(name), **kwargs) + return _to_strawberry_color_scale(cs) @strawberry.type @@ -539,23 +570,14 @@ def _legacy_filter(f: "FilterRupturesArgsInput") -> _LegacyFilter: minimum_mag=_v(f.minimum_mag), maximum_mag=_v(f.maximum_mag), ) - fso = f.filter_set_options - lf.filter_set_options = ( - { - "multiple_locations": fso.multiple_locations.value, - "multiple_faults": fso.multiple_faults.value, - "locations_and_faults": fso.locations_and_faults.value, - } - if fso - else {} - ) + lf.filter_set_options = _fso_dict(f.filter_set_options) lf.corupture_fault_names = list(f.corupture_fault_names or []) return lf def _to_strawberry_rupture_connection(conn) -> "RuptureDetailConnection": pi = conn.page_info - edges = [ + edges: list[RuptureDetailEdge | None] = [ RuptureDetailEdge( node=CompositeRuptureDetail( model_id=e.node.model_id, fault_system=e.node.fault_system, rupture_index=e.node.rupture_index @@ -576,6 +598,51 @@ def _to_strawberry_rupture_connection(conn) -> "RuptureDetailConnection": ) +def _fso_dict(fso) -> dict: + if not fso: + return {} + + def val(x): + return x.value if x is not None else None + + return { + "multiple_locations": val(fso.multiple_locations), + "multiple_faults": val(fso.multiple_faults), + "locations_and_faults": val(fso.locations_and_faults), + } + + +def _legacy_color_scale_args(cs): + """A namespace the legacy section resolvers can read, with `normalisation` as its string + value (`log`/`lin`) rather than the strawberry enum member.""" + if cs is None or cs is strawberry.UNSET: + return None + return SimpleNamespace( + name=cs.name, + min_value=_v(cs.min_value), + max_value=_v(cs.max_value), + normalisation=(cs.normalisation.value if cs.normalisation else None), + ) + + +def _graphene_sections_root(f: "FilterRupturesArgsInput"): + """Build a legacy graphene CompositeRuptureSections root so its resolvers (all the + aggregate / geojson / MFD / colour compute) can be reused verbatim.""" + g_filter = _GrapheneFilterArgs( # type: ignore[call-arg] # graphene ObjectType (untyped __init__) + model_id=f.model_id, + fault_system=f.fault_system, + location_ids=list(f.location_ids or []), + radius_km=_v(f.radius_km), + minimum_rate=_v(f.minimum_rate), + maximum_rate=_v(f.maximum_rate), + minimum_mag=_v(f.minimum_mag), + maximum_mag=_v(f.maximum_mag), + corupture_fault_names=list(f.corupture_fault_names or []), + filter_set_options=_fso_dict(f.filter_set_options), + ) + return _GrapheneSections(model_id=f.model_id, filter_arguments=g_filter) # type: ignore[call-arg] + + def _rupture_fault_surfaces(model_id, fault_system, rupture_index, style): from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail # noqa: F401 @@ -617,7 +684,9 @@ def color_scale( def node( self, id: Annotated[strawberry.ID, strawberry.argument(description="The ID of the object")] ) -> Node | None: - return None # TODO: global-id dispatch in Phase 3 + # parity: the legacy graphene schema defines no `get_node`, so `node(id)` always + # resolves to null (verified differentially) — nothing to dispatch. + return None @strawberry.field(description="About this Solvis API ") def about(self) -> str | None: @@ -691,7 +760,7 @@ def filter_ruptures( @strawberry.field def filter_rupture_sections(self, filter: FilterRupturesArgsInput) -> CompositeRuptureSections | None: - return CompositeRuptureSections(model_id=filter.model_id) + return CompositeRuptureSections(model_id=filter.model_id, legacy=_graphene_sections_root(filter)) @strawberry.field def get_parent_fault_names( diff --git a/tests/test_strawberry_parity.py b/tests/test_strawberry_parity.py index 101f6fd..6ffbd53 100644 --- a/tests/test_strawberry_parity.py +++ b/tests/test_strawberry_parity.py @@ -87,6 +87,26 @@ def test_filter_ruptures_parity(archive_fixture): ) +_SECTIONS = """ +{ filter_rupture_sections(filter: { + model_id: "NSHM_v1.0.4" location_ids: ["AKL"] fault_system: "CRU" + radius_km: 100 minimum_rate: 1.0e-19 minimum_mag: 6.2 }) { + model_id section_count max_magnitude min_magnitude max_participation_rate min_participation_rate + mfd_histogram { bin_center rate cumulative_rate } + fault_surfaces(style: { stroke_color: "silver" fill_color: "silver" fill_opacity: 0.2 }) +} } +""" + + +def test_filter_rupture_sections_parity(archive_fixture_tiny): + _assert_parity(_SECTIONS) + + +def test_node_parity(): + # legacy defines no get_node, so node(id) resolves to null on both schemas + _assert_parity('{ node(id: "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpDUlU6NjYx") { id } }') + + def test_composite_rupture_detail_parity(archive_fixture_tiny): # PUY:3 is a valid rupture in the tiny archive (rupture_detail resolves the rate/geometry cols) _assert_parity(