diff --git a/docs/jts-1145-pip-lane-2026-08.md b/docs/jts-1145-pip-lane-2026-08.md new file mode 100644 index 00000000..434dbd7b --- /dev/null +++ b/docs/jts-1145-pip-lane-2026-08.md @@ -0,0 +1,161 @@ +# PIP gallery lane — JTS #1145 YStripesPointInAreaLocator + +**Date**: 2026-08-05. +**topic:** `relate` · epic **#67** (PIP / RelateNG consumers; JCT gallery as regression oracle) +**Upstream**: [locationtech/jts#1145](https://github.com/locationtech/jts/pull/1145) + *Implement `YStripesPointInAreaLocator`* (micycle1) — new indexed + point-in-area locator using Y-stripe segment buckets + DD ray-crossing + ([tg POLYGON_INDEXING](https://github.com/tidwall/tg/blob/main/docs/POLYGON_INDEXING.md#ystripes)). +**NTS twin**: none yet; production baseline is + `IndexedPointInAreaLocator` / `SimplePointInAreaLocator`. +**Scope**: Run the corpus PIP gallery + ([`nts-oracle-gallery.md`](nts-oracle-gallery.md)) as a **differential + soundness gate** against a faithful C# port of the PR tip. **No new Rocq + theorems.** + +**Verdict**: **GREEN** — on **14** gallery vectors (vertex-graze, horizontal +edge, rect/pixel boundary, Spectre pocket, Hat pocket), the Y-stripes port +returns the **geometric correct answer** and agrees with NTS +`IndexedPointInAreaLocator` and `SimplePointInAreaLocator` on every row +(including OGC `BOUNDARY` where applicable). Safe soundness posture for +merge from the proofs-corpus gallery; recommend pinning the WKT cases into +`AbstractPointInRingTest` / a dedicated test on the Java side. + +--- + +## §1 — What #1145 adds + +| File | Role | +|---|---| +| `YStripesPointInPolygonLocator` | Per-ring Y-stripe index + ray-crossing (`locateYStripes`) | +| `YStripesPointInAreaLocator` | Multi-polygon STRtree of per-polygon locators; single-poly fast path | +| `YStripesPointInAreaLocatorTest` | Extends `AbstractPointInRingTest` | +| Perf / stress tests | Circle + perturbed grid | + +Core locate path (per ring): + +1. Envelope reject (`coversPointFast`). +2. Map `py` → stripe; scan only segments mapped to that stripe. +3. **Horizontal** edges (`ay == by`): never flip parity; `ON` iff `py == ay` and `px ∈ [xmin,xmax]`. +4. Segment entirely right of query (`px < xmin`): flip iff half-open y-straddle `(ay > py) != (by > py)`. +5. Segment entirely left (`px > xmax`): ignore. +6. Otherwise `raycast` via **`CGAlgorithmsDD.orientationIndex`**; collinear-in-bbox → `BOUNDARY`; orient after normalizing edge direction → cross or miss. +7. Holes: shell `INTERIOR` then hole `INTERIOR` ⇒ `EXTERIOR`; hole `BOUNDARY` ⇒ `BOUNDARY`. + +PR claims: **4×+** faster than `IndexedPointInAreaLocator` on author benches; passes existing `AbstractPointInRingTest` suite. + +--- + +## §2 — Corpus ground truth + +| Asset | Role | +|---|---| +| [`docs/nts-oracle-gallery.md`](nts-oracle-gallery.md) | WKT + geometric answers + theorem names | +| `Overlay.point_in_ring` | Pure rightward-ray odd parity (strict y-straddle) | +| `JCT_VertexGrazingCounterexample.v` | Diamond centre: naive even, geometric **interior** | +| `JCT_HorizontalEdgeCounterexample.v` | Notch left exterior: naive odd, geometric **exterior** | +| `RectangleJCT.point_in_ring_rect_iff` | Half-open rect parity vs OGC boundary | +| `HotPixelConvexRing.v` | Pixel box + bottom-edge graze | +| `SpectreConcaveFamily.v` / `HatMonotile*.v` | Concave hull-interior exterior pockets | +| `WindingNumber.winding_decides_membership` | Parity ≡ winding under horizontal-edge guard | + +**Convention note.** Gallery **GT** for production locators is the **geometric / +OGC Location** answer (`INTERIOR` / `EXTERIOR` / `BOUNDARY`), not bare parity. +Rows tagged `naive-parity≠geom` are exactly the robustness cases a correct +counter must not get wrong. + +--- + +## §3 — Deliverables + +| Path | Purpose | +|---|---| +| `tests/Discussion839Mre/YStripesPointInAreaLocator.cs` | Faithful C# port of PR tip (DD orient via NTS `CGAlgorithmsDD`) | +| `tests/Discussion839Mre/Program.cs` `--jts1145` | Gallery differential harness | +| `oracle/jts1145_pip_gallery_vectors.txt` | Machine table from one GREEN run | +| `tests/Discussion839Mre/jts-1145-comment.md` | Optional JTS PR comment draft | +| this doc | Lane write-up | + +```text +dotnet run --project tests/Discussion839Mre -c Release -- --jts1145 +# aliases: --ystripes --pip-gallery +``` + +--- + +## §4 — Results (2026-08-05, NTS 2.6) + +| id | point | GT | naive parity | Indexed | Simple | YStripes | status | +|---|---|---|---|---|---|---|---| +| VGRAZE_G | (0, 0.5) | INT | odd | INT | INT | INT | PASS | +| **VGRAZE0** | **(0, 0)** | **INT** | **even\*** | INT | INT | INT | PASS | +| **HORZ_E** | **(−1, 1)** | **EXT** | **odd\*** | EXT | EXT | EXT | PASS | +| RECT_IN | (2, 1.5) | INT | in | INT | INT | INT | PASS | +| RECT_L | (0, 1.5) | BND | parity-in | BND | BND | BND | PASS | +| RECT_R | (4, 1.5) | BND | parity-out | BND | BND | BND | PASS | +| RECT_B | (2, 0) | BND | parity-out | BND | BND | BND | PASS | +| RECT_T | (2, 3) | BND | parity-out | BND | BND | BND | PASS | +| PIX_C | (0, 0) | INT | in | INT | INT | INT | PASS | +| PIX_BOT | (0, −0.5) | BND | parity-out\* | BND | BND | BND | PASS | +| SPECT_IN | (5, 0.5) | INT | odd | INT | INT | INT | PASS | +| **SPECT_POCK** | **(3.5, 0.5)** | **EXT** | even | EXT | EXT | EXT | PASS | +| HAT_IN | ≈(4.25, 2.165) | INT | odd | INT | INT | INT | PASS | +| **HAT_POCK** | ≈(3.5, 0.433) | **EXT** | even | EXT | EXT | EXT | PASS | + +**14 / 14 PASS** · **0** YStripes≠GT · **0** Y≠Indexed · **0** Y≠Simple. + +Critical robustness rows (bold) all GREEN: + +| Vector | Why it matters | Result | +|---|---|---| +| Vertex graze diamond centre | Naive parity **false negative** (even) | YStripes **INT** | +| Horizontal edge left of notch | Naive parity **false positive** (odd) | YStripes **EXT** | +| Spectre reflex pocket | Concave / hull-interior exterior | YStripes **EXT** | +| Hat bottom pocket (APPROX √3 WKT) | Same class, irrational embedding | YStripes **EXT** | + +Hat coordinates are gallery `APPROX` (exact theorems use √3); double WKT still +separates interior vs pocket under all three locators. + +--- + +## §5 — Algorithm notes vs corpus + +| Topic | YStripes (#1145) | Corpus `point_in_ring` | +|---|---|---| +| Y-straddle | half-open `(ay > py) != (by > py)` | strict `min < py < max` (similar effect) | +| Horizontal at query y | boundary if on segment; **no** parity flip | not a crossing (guard `no_horizontal_edge_at` for correctness) | +| Vertex graze | DD collinear → `ON` / directed orient | bare parity can miscount without `ray_avoids_vertices` | +| Output | OGC `Location` incl. `BOUNDARY` | pure bool interior parity | +| Index | Y-stripes (O(1) stripe + local segs) | none (specification predicate) | + +Y-stripes is a **consumer** of the same geometric fact the gallery pins; it is +not a new mathematical predicate. No `Admitted` / claim registration required. + +--- + +## §6 — Product / review recommendation + +1. **Soundness (gallery)**: GREEN — merge is not blocked by vertex-graze / + horizontal-edge / concave-pocket classes covered here. +2. **Tests**: Existing `AbstractPointInRingTest` is necessary but thin on + concave monotiles and the diamond graze. Suggest adding the gallery WKT + pins (copy-paste from `nts-oracle-gallery.md` or this table) to + `YStripesPointInAreaLocatorTest` or the abstract suite so both Indexed and + YStripes stay locked. +3. **Port watch**: NTS should consider a twin after JTS merge (same package + layout under `Algorithm.Locate`). +4. **Limits of this lane**: C# port + NTS DD orient; not a Java bytecode run + of the PR branch. Behaviour is intentionally line-faithful; a Java re-run + of the same WKT in `YStripesPointInAreaLocatorTest` would close residual + port risk. Stress tests already on the PR cover random grids. + +--- + +## §7 — Sources + +- JTS PR tip: `micycle1/jts` branch `ystripes` (fetched 2026-08-05) +- Corpus gallery: `docs/nts-oracle-gallery.md` +- Scout ranking: `docs/jts-open-prs-scout-2026-08.md` item **#7** / work seq §3 + +**AI assistance**: Grok (grok-4.5), human-directed. +**License**: project documentation (BSD-3-Clause corpus). diff --git a/docs/jts-open-prs-scout-2026-08.md b/docs/jts-open-prs-scout-2026-08.md index 7ff89186..50aa0f22 100644 --- a/docs/jts-open-prs-scout-2026-08.md +++ b/docs/jts-open-prs-scout-2026-08.md @@ -40,7 +40,7 @@ Ordered by **risk÷cost** for the proofs corpus (not JTS merge politics). | **4** | [#1093](https://github.com/locationtech/jts/pull/1093) | Change Shewchuk Orientation filter to Ozaki et al. | **#66/#64** `precision`/`core` | H | M | H | Production `CGAlgorithmsDD` filter. **Lane GREEN 2026-08-05:** 45-vector three-way gate (master `1e-15` / Ozaki / corpus Shewchuk) vs `ORIENT_EXACT` — 0 CERTAIN conflicts; Ozaki tighter than master on 4 same-sign band cases. See [`jts-1093-orient-lane-2026-08.md`](jts-1093-orient-lane-2026-08.md). | | **5** | [#1197](https://github.com/locationtech/jts/pull/1197) | Characterize DD orientation soundness (#1106) | **#66** `precision` | H | L | M | **Ours.** Test-only + `RocqRefRunner`. **Keep-green 2026-08-05 GREEN:** rebased onto master; corpus integer pins refreshed; local 22/22 + GHA `build-and-test` pass; merge ping posted ([comment](https://github.com/locationtech/jts/pull/1197#issuecomment-5186644725)). Awaiting maintainer review/merge. | | **6** | [#90](https://github.com/locationtech/jts/pull/90) | Fix ScaledNoder behaviour (draft) | **#66** `precision` · **P1 policy** | H | L | M | **Lane YELLOW 2026-08-05 (not P0 theory):** mukoki scale=1 mixed-precision **masked** by modern `SnapRoundingNoder`; scale=0 `round(x*0)` footgun live; do **not** merge draft as written. No new snap claim. See [`jts-90-scalednoder-lane-2026-08.md`](jts-90-scalednoder-lane-2026-08.md). | -| **7** | [#1145](https://github.com/locationtech/jts/pull/1145) | YStripesPointInAreaLocator | **#67** `relate` (+ JCT) | H | L–M | M | New PIP implementation. Corpus `point_in_ring` + gallery (`nts-oracle-gallery.md`) is the **regression oracle** — run hat/Spectre/vertex-graze/horizontal-edge vectors. | +| **7** | [#1145](https://github.com/locationtech/jts/pull/1145) | YStripesPointInAreaLocator | **#67** `relate` (+ JCT) | H | L–M | M | **Lane GREEN 2026-08-05:** gallery 14/14 (vertex-graze/horizontal/Spectre/Hat) — YStripes port ≡ Indexed ≡ Simple ≡ geometric GT. See [`jts-1145-pip-lane-2026-08.md`](jts-1145-pip-lane-2026-08.md). | | **8** | [#1084](https://github.com/locationtech/jts/pull/1084) | CoverageEdgeExtractor | **#425** `coverage` | M–H | M | M | Unique edges from polygonal coverage. Corpus has `CoverageGapOverlapCleaner.v` / #425. Good **next coverage claim** after cleaner. | | **9** | [#1216](https://github.com/locationtech/jts/pull/1216) | Explicit stack in DouglasPeucker (#1127) | **#69** S-* | M | L | L–M | **Ours.** Stack overflow fix; behaviour-preserving. Corpus `Simplify.v` is structural only — **no new proof** needed; port to NTS when merged. | | **10** | [#1140](https://github.com/locationtech/jts/pull/1140) | Hausdorff distance percentile | **#423** `metric` | M | M | L–M | Extends discrete Hausdorff. Corpus `HausdorffDiscrete.v` / Claim423a pins directed max-min — can pin percentile semantics or mark out-of-scope. | @@ -81,7 +81,12 @@ Ordered by **risk÷cost** for the proofs corpus (not JTS merge politics). `feature/orientation-robustness-tests` onto `master`; refreshed `orientation_proof_vectors.txt` integer pins; local suite 22/22 + GHA `build-and-test` pass; merge-ping comment posted. -3. **PIP gallery on #1145 (P0/P1)** — Reuse `docs/nts-oracle-gallery.md` WKT as Java tests / differential. +3. **PIP gallery on #1145 (P0/P1)** — **LANDED 2026-08-05 GREEN:** + - Faithful C# port: `tests/Discussion839Mre/YStripesPointInAreaLocator.cs` + - Harness: `dotnet run … -- --jts1145` (+ `oracle/jts1145_pip_gallery_vectors.txt`) + - Write-up: [`docs/jts-1145-pip-lane-2026-08.md`](jts-1145-pip-lane-2026-08.md). + - 14/14 geometric GT; critical rows (diamond graze INT, notch horizontal EXT, Spectre/Hat pockets EXT) all pass; Y ≡ Indexed ≡ Simple. + - Optional: post gallery pins comment (`tests/Discussion839Mre/jts-1145-comment.md`). 4. **ScaledNoder #90 (P1)** — **SCOPED 2026-08-05 YELLOW:** - MRE: `tests/Discussion839Mre` `--jts90` + [`jts-90-scalednoder-lane-2026-08.md`](jts-90-scalednoder-lane-2026-08.md). - Does **not** map to a missing `SnapRoundingScale_b64` claim (wrapper policy + scale=0 safety). @@ -111,7 +116,7 @@ Columns: **Pri** = P0/P1/P2 · **Rel/Cost/Risk** · **Epic**. | 1189 | WIP Orientation isCCW GEOS test | strk | P1 | H | L | M | precision | `status-INVALID`; still a useful negative test seed | | 1170 | Generics on geom.util extractors | micycle1 | P2 | L | L | L | — | API typing | | 1164 | GeoJsonWriter create() for subclasses | krizleebear | P2 | L | L | L | — | IO | -| **1145** | **YStripesPointInAreaLocator** | micycle1 | **P0** | H | L–M | M | **relate**/JCT | New PIP; gallery | +| **1145** | **YStripesPointInAreaLocator** | micycle1 | **P0** | H | L–M | M | **relate**/JCT | **GREEN** gallery 14/14 ([lane](jts-1145-pip-lane-2026-08.md)) | | **1140** | **Hausdorff distance percentile** | ikgh9 | **P1** | M | M | L–M | **metric** | Extends HausdorffDiscrete | | 1137 | Gh pages to docs | jodygarnett | P2 | L | L | L | — | docs | | 1135 | HilbertEncoder.sort() | micycle1 | P2 | L | L | L | — | spatial index util | @@ -190,6 +195,7 @@ Columns: **Pri** = P0/P1/P2 · **Rel/Cost/Risk** · **Epic**. |---|---|---| | **In-circle robustness** | #311 → #1094 → **#1212** | Treat **#1212 as tip**; archive others as predecessors once merged. History: [`jts-311-incircle-lane-2026-08.md`](jts-311-incircle-lane-2026-08.md). FFI scout + review: [`jts-1094-incircle-lane-2026-08.md`](jts-1094-incircle-lane-2026-08.md). Tip differential: [`jts-1212-incircle-lane-2026-08.md`](jts-1212-incircle-lane-2026-08.md) | | **Orientation filters** | #1093 (Ozaki production), #1197 (DD limits tests), #1189 (isCCW test INVALID) | **#1093 lane GREEN** ([jts-1093-orient-lane-2026-08.md](jts-1093-orient-lane-2026-08.md)); keep #1197; mine #1189 for vectors only | +| **PIP / YStripes** | **#1145** | **GREEN** ([jts-1145-pip-lane-2026-08.md](jts-1145-pip-lane-2026-08.md)): gallery 14/14; free CI for locator merge | | **ScaledNoder policy** | #90 (draft) | **YELLOW** ([jts-90-scalednoder-lane-2026-08.md](jts-90-scalednoder-lane-2026-08.md)): SRN masks mukoki; scale=0 only; no algebra claim | | **DistanceOp / LineSegment** | #926, #930 | One review pass if either moves; low proof cost | | **CI matrix** | #638, #646 | Ignore unless JTS maintainers revive | @@ -245,7 +251,7 @@ Do **not** open multi-session theory work for P2 PRs. 1. **Bet the mesh lane on #1212** (with #1094/#311 as history) — highest corpus leverage per hour. 2. **Orientation filter change (#1093) gated on exact orient** — session GREEN; re-run table if the PR is revised. -3. **Use the PIP gallery as free CI for #1145**. +3. **Use the PIP gallery as free CI for #1145** — **done GREEN** (14/14); suggest pinning WKT into JTS tests. 4. **Ignore ~55% of the open queue** (CI/IO/docs/API) for proof planning. 5. **Keep our four PRs** green; only #1197 and #1216 need corpus-adjacent attention. diff --git a/oracle/jts1145_pip_gallery_vectors.txt b/oracle/jts1145_pip_gallery_vectors.txt new file mode 100644 index 00000000..24669e2e --- /dev/null +++ b/oracle/jts1145_pip_gallery_vectors.txt @@ -0,0 +1,27 @@ +# JTS #1145 YStripes PIP gallery differential (2026-08-05) +# Source: docs/nts-oracle-gallery.md + C# port of micycle1/jts ystripes tip +# Harness: tests/Discussion839Mre --jts1145 +# claimId: none — ADR-0004 skip; harness-only differential, not a fresh mutation surface +# GT = geometric / OGC Location; parity column notes naive ray-parity where it differs +# status: all PASS (YStripes == Indexed == Simple == GT) +# +# id x y GT parity Indexed Simple YStripes status theorem + +VGRAZE_G 0 0.5 INT odd INT INT INT PASS diamond_point_in_ring_A +VGRAZE0 0 0 INT even* INT INT INT PASS diamond_refutes_parity_seam +HORZ_E -1 1 EXT odd* EXT EXT EXT PASS notch_refutes_parity_without_guard +RECT_IN 2 1.5 INT in INT INT INT PASS point_in_ring_rect_iff +RECT_L 0 1.5 BND parity-in BND BND BND PASS point_in_ring_rect_iff_left +RECT_R 4 1.5 BND parity-out BND BND BND PASS point_in_ring_rect_iff_right +RECT_B 2 0 BND parity-out BND BND BND PASS point_in_ring_rect_iff_bottom +RECT_T 2 3 BND parity-out BND BND BND PASS point_in_ring_rect_iff_top +PIX_C 0 0 INT in INT INT INT PASS unit_pixel_centre_in_ring +PIX_BOT 0 -0.5 BND parity-out* BND BND BND PASS pixel_grazing_bottom_edge +SPECT_IN 5 0.5 INT odd INT INT INT PASS spectre_parity_classification +SPECT_POCK 3.5 0.5 EXT even EXT EXT EXT PASS spectre_parity_classification_pocket +HAT_IN 4.25 2.1650635094610964 INT odd INT INT INT PASS hat_parity_classification_interior_APPROX +HAT_POCK 3.5 0.4330127018922193 EXT even EXT EXT EXT PASS hat_parity_classification_pocket_APPROX + +# Summary: 14/14 PASS, 0 YStripes≠GT, 0 Y≠Indexed, 0 Y≠Simple +# Critical robustness: VGRAZE0 INT, HORZ_E EXT, SPECT_POCK EXT, HAT_POCK EXT — all GREEN +# Write-up: docs/jts-1145-pip-lane-2026-08.md diff --git a/tests/Discussion839Mre/Program.cs b/tests/Discussion839Mre/Program.cs index a0e5538b..2d726099 100644 --- a/tests/Discussion839Mre/Program.cs +++ b/tests/Discussion839Mre/Program.cs @@ -16,9 +16,11 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using Discussion839Mre; using NetTopologySuite.Algorithm; using NetTopologySuite.Algorithm.Locate; using NetTopologySuite.Geometries; +using NetTopologySuite.IO; using NetTopologySuite.Noding; using NetTopologySuite.Noding.Snapround; using NetTopologySuite.Triangulate; @@ -67,6 +69,14 @@ public static int Main(string[] args) return RunJts90ScaledNoderMre(); } + // JTS#1145 YStripes PIP gallery vs corpus oracle (no Rocq binary required). + if (args.Contains("--jts1145", StringComparer.OrdinalIgnoreCase) + || args.Contains("--ystripes", StringComparer.OrdinalIgnoreCase) + || args.Contains("--pip-gallery", StringComparer.OrdinalIgnoreCase)) + { + return RunJts1145PipGallery(); + } + bool useScaled = !args.Contains("--no-scale", StringComparer.OrdinalIgnoreCase); double tolerance = args.Contains("--tol0", StringComparer.OrdinalIgnoreCase) ? 0.0 : 0.1; @@ -282,6 +292,209 @@ public static int Main(string[] args) return 0; } + /// + /// JTS PR #1145 YStripesPointInAreaLocator vs NTS Indexed/Simple locators + /// on the corpus PIP gallery (docs/nts-oracle-gallery.md). Geometric GT is + /// the Qed-backed correct answer; pure ray-parity is recorded separately + /// where it disagrees at vertex-graze / horizontal-edge rows. + /// + private static int RunJts1145PipGallery() + { + Console.WriteLine("=== JTS#1145 YStripes PIP gallery (corpus oracle) ==="); + Console.WriteLine($"NTS: {typeof(Geometry).Assembly.GetName().Version}"); + Console.WriteLine("Locators: IndexedPointInAreaLocator | SimplePointInAreaLocator | YStripes (PR#1145 port)"); + Console.WriteLine("GT: geometric correct answer from docs/nts-oracle-gallery.md (Qed theorems)"); + Console.WriteLine(); + + var rdr = new WKTReader(); + var cases = GalleryCases(); + int pass = 0, fail = 0, warn = 0; + var rows = new List(); + + Console.WriteLine( + $"{"id",-8} {"pt",-22} {"GT",-9} {"parity",-9} {"Indexed",-9} {"Simple",-9} {"YStripes",-9} note"); + Console.WriteLine(new string('-', 110)); + + foreach (var c in cases) + { + var geom = rdr.Read(c.Wkt); + var pt = new Coordinate(c.X, c.Y); + var indexed = new IndexedPointInAreaLocator(geom).Locate(pt); + var simple = SimplePointInAreaLocator.Locate(pt, geom); + var ystripes = new YStripesPointInAreaLocator(geom).Locate(pt); + + bool idxOk = MatchesGallery(indexed, c.GeomExpected); + bool simOk = MatchesGallery(simple, c.GeomExpected); + bool ysOk = MatchesGallery(ystripes, c.GeomExpected); + bool parityMismatch = c.NaiveParityDiffers; + + string status; + if (ysOk && idxOk && simOk) + { + pass++; + status = "PASS"; + } + else if (ysOk) + { + // YStripes correct vs GT; baseline may differ only on BOUNDARY convention + warn++; + status = "WARN"; + } + else + { + fail++; + status = "FAIL"; + } + + string note = status; + if (parityMismatch) note += " | naive-parity≠geom"; + if (!idxOk) note += " | Indexed≠GT"; + if (!simOk) note += " | Simple≠GT"; + if (!ysOk) note += " | YStripes≠GT"; + if (ystripes != indexed) note += " | Y≠Indexed"; + if (ystripes != simple) note += " | Y≠Simple"; + + string ptStr = $"({Fmt(c.X)},{Fmt(c.Y)})"; + Console.WriteLine( + $"{c.Id,-8} {ptStr,-22} {LocTag(c.GeomExpected),-9} {c.NaiveParityTag,-9} " + + $"{LocTag(indexed),-9} {LocTag(simple),-9} {LocTag(ystripes),-9} {note}"); + + rows.Add(string.Join('\t', + c.Id, c.X.ToString("G17", CultureInfo.InvariantCulture), + c.Y.ToString("G17", CultureInfo.InvariantCulture), + LocTag(c.GeomExpected), c.NaiveParityTag, + LocTag(indexed), LocTag(simple), LocTag(ystripes), status, c.Theorem)); + } + + Console.WriteLine(); + Console.WriteLine("=== SUMMARY ==="); + Console.WriteLine($"cases: {cases.Count} PASS(all=GT): {pass} WARN(YStripes=GT): {warn} FAIL(YStripes≠GT): {fail}"); + + // Critical robustness rows that the gallery was built for + var critical = new[] { "VGRAZE0", "HORZ_E", "SPECT_POCK", "HAT_POCK" }; + int critFail = 0; + foreach (var c in cases.Where(c => critical.Contains(c.Id))) + { + var geom = rdr.Read(c.Wkt); + var ys = new YStripesPointInAreaLocator(geom).Locate(new Coordinate(c.X, c.Y)); + if (!MatchesGallery(ys, c.GeomExpected)) + { + critFail++; + Console.WriteLine($" CRITICAL FAIL {c.Id}: YStripes={LocTag(ys)} GT={LocTag(c.GeomExpected)}"); + } + } + + Console.WriteLine(); + Console.WriteLine("=== VERDICT ==="); + if (fail == 0 && critFail == 0) + { + Console.WriteLine(" GREEN: YStripes port matches geometric GT on all gallery vectors"); + Console.WriteLine(" (vertex-graze, horizontal-edge, Spectre pocket, Hat pocket, rect/pixel boundary)."); + Console.WriteLine(" Product: safe soundness gate for JTS#1145; recommend adding these WKT pins to AbstractPointInRingTest."); + } + else + { + Console.WriteLine($" RED: {fail} YStripes≠GT ({critFail} critical). Do not rubber-stamp #1145."); + } + Console.WriteLine("See docs/jts-1145-pip-lane-2026-08.md"); + Console.WriteLine(); + Console.WriteLine("--- machine table (tab) ---"); + Console.WriteLine("id\tx\ty\tGT\tparity\tIndexed\tSimple\tYStripes\tstatus\ttheorem"); + foreach (var r in rows) Console.WriteLine(r); + + return fail == 0 ? 0 : 1; + } + + private sealed record GalleryCase( + string Id, + string Wkt, + double X, + double Y, + Location GeomExpected, + string NaiveParityTag, + bool NaiveParityDiffers, + string Theorem); + + private static List GalleryCases() + { + // WKT + geometric answers from docs/nts-oracle-gallery.md. + // Location.Boundary rows are OGC boundary (Contains=false); pure parity half-open + // is recorded in NaiveParityTag where relevant. + const string diamond = + "POLYGON ((0 1, 1 0, 0 -1, -1 0, 0 1))"; + const string notch = + "POLYGON ((0 0, 4 0, 4 2, 2 2, 2 1, 0 1, 0 0))"; + const string rect = + "POLYGON ((0 0, 4 0, 4 3, 0 3, 0 0))"; + const string pixel = + "POLYGON ((-0.5 -0.5, 0.5 -0.5, 0.5 0.5, -0.5 0.5, -0.5 -0.5))"; + const string spectre = + "POLYGON ((0 0, 2 0, 3.5 1, 4 0, 6 0, 7.5 1, 7 2, 5 2, 4.5 3, 3 2, 1 2, 0.5 1, -0.5 1, 0 0))"; + // Hat monotile APPROX (exact uses √3); still exercises concave pocket. + const string hat = + "POLYGON ((0 0, 2 0, 3.5 0.8660254, 4 0, 6 0, 7.5 0.8660254, 7 1.7320508, 5 1.7320508, 4.5 2.5980762, 3 1.7320508, 1 1.7320508, 0.5 0.8660254, -0.5 0.8660254, 0 0))"; + + double s3 = Math.Sqrt(3.0); + + return new List + { + // Tier 1.1 vertex graze + new("VGRAZE_G", diamond, 0, 0.5, Location.Interior, "odd", false, + "diamond_point_in_ring_A"), + new("VGRAZE0", diamond, 0, 0, Location.Interior, "even*", true, + "diamond_refutes_parity_seam"), + + // Tier 1.2 horizontal edge + new("HORZ_E", notch, -1, 1, Location.Exterior, "odd*", true, + "notch_refutes_parity_without_guard"), + + // Tier 1.3 rectangle half-open / OGC boundary + new("RECT_IN", rect, 2, 1.5, Location.Interior, "in", false, + "point_in_ring_rect_iff"), + new("RECT_L", rect, 0, 1.5, Location.Boundary, "parity-in", true, + "point_in_ring_rect_iff left"), + new("RECT_R", rect, 4, 1.5, Location.Boundary, "parity-out", false, + "point_in_ring_rect_iff right"), + new("RECT_B", rect, 2, 0, Location.Boundary, "parity-out", false, + "point_in_ring_rect_iff bottom"), + new("RECT_T", rect, 2, 3, Location.Boundary, "parity-out", false, + "point_in_ring_rect_iff top"), + + // Tier 1.4 hot pixel + new("PIX_C", pixel, 0, 0, Location.Interior, "in", false, + "unit_pixel_centre_in_ring"), + new("PIX_BOT", pixel, 0, -0.5, Location.Boundary, "parity-out*", true, + "pixel_grazing_bottom_edge"), + + // Tier 2 Spectre + new("SPECT_IN", spectre, 5, 0.5, Location.Interior, "odd", false, + "spectre_parity_classification"), + new("SPECT_POCK", spectre, 3.5, 0.5, Location.Exterior, "even", false, + "spectre_parity_classification pocket"), + + // Tier 2 Hat (approx WKT; GT from geometric pocket/interior) + new("HAT_IN", hat, 4.25, 5 * s3 / 4, Location.Interior, "odd", false, + "hat_parity_classification interior APPROX"), + new("HAT_POCK", hat, 3.5, s3 / 4, Location.Exterior, "even", false, + "hat_parity_classification pocket APPROX"), + }; + } + + /// + /// Gallery GT for robust OGC-style locators: Interior / Exterior / Boundary. + /// Pure parity may disagree on * rows; MatchesGallery accepts Boundary as a + /// correct robust answer whenever GT is Boundary. + /// + private static bool MatchesGallery(Location got, Location expected) => got == expected; + + private static string LocTag(Location loc) => loc switch + { + Location.Interior => "INT", + Location.Exterior => "EXT", + Location.Boundary => "BND", + _ => loc.ToString(), + }; + /// /// JTS PR #90 / mukoki OpenJUMP beanshell: ScaledNoder(scale=1) vs scale≠1 /// rounds input vertices inconsistently (NTS port of the same control flow). diff --git a/tests/Discussion839Mre/README.md b/tests/Discussion839Mre/README.md index af913182..dbb6aa86 100644 --- a/tests/Discussion839Mre/README.md +++ b/tests/Discussion839Mre/README.md @@ -63,3 +63,23 @@ dotnet run --project tests/Discussion839Mre -c Release -- --scaled-noder Write-up: [`docs/jts-90-scalednoder-lane-2026-08.md`](../../docs/jts-90-scalednoder-lane-2026-08.md). Optional PR comment draft: [`jts-90-comment.md`](jts-90-comment.md). + +## JTS #1145 YStripes PIP gallery mode + +Differential gate for +[locationtech/jts#1145](https://github.com/locationtech/jts/pull/1145) +(`YStripesPointInAreaLocator`) against the Qed-backed corpus gallery +([`docs/nts-oracle-gallery.md`](../../docs/nts-oracle-gallery.md)). +Compares Indexed / Simple / YStripes (faithful C# port) — **no Rocq oracle**. + +```powershell +dotnet run --project tests/Discussion839Mre -c Release -- --jts1145 +# aliases: +dotnet run --project tests/Discussion839Mre -c Release -- --ystripes +dotnet run --project tests/Discussion839Mre -c Release -- --pip-gallery +# keep-green: expect exit code 0 (14/14 geometric GT) +``` + +Write-up: [`docs/jts-1145-pip-lane-2026-08.md`](../../docs/jts-1145-pip-lane-2026-08.md). +Vectors: [`oracle/jts1145_pip_gallery_vectors.txt`](../../oracle/jts1145_pip_gallery_vectors.txt). +Optional PR comment draft: [`jts-1145-comment.md`](jts-1145-comment.md). diff --git a/tests/Discussion839Mre/YStripesPointInAreaLocator.cs b/tests/Discussion839Mre/YStripesPointInAreaLocator.cs new file mode 100644 index 00000000..d2ceb805 --- /dev/null +++ b/tests/Discussion839Mre/YStripesPointInAreaLocator.cs @@ -0,0 +1,389 @@ +// ============================================================================ +// Faithful C# port of JTS PR #1145 YStripes point-in-area locator (micycle1). +// Source tip: micycle1/jts branch ystripes — +// YStripesPointInPolygonLocator / YStripesPointInAreaLocator +// Used only for corpus gallery differential; not a production NTS API. +// ============================================================================ + +using NetTopologySuite.Algorithm; +using NetTopologySuite.Geometries; +using NetTopologySuite.Index.Strtree; + +namespace Discussion839Mre; + +/// +/// Point-in-area locator mirroring JTS YStripesPointInAreaLocator (#1145). +/// +public sealed class YStripesPointInAreaLocator : IPointInAreaLocator +{ + private readonly Envelope? _env; + private readonly IPointInAreaLocator? _single; + private readonly STRtree? _tree; + private readonly Envelope _qEnv = new(); + + public YStripesPointInAreaLocator(Geometry geom) + { + var polys = new List(); + CollectPolygons(geom, polys); + + if (polys.Count == 0) + { + _env = geom.EnvelopeInternal.Copy(); + _single = null; + _tree = null; + return; + } + + if (polys.Count == 1) + { + _single = new YStripesPointInPolygonLocator(polys[0]); + _env = null; + _tree = null; + return; + } + + var e = new Envelope(); + var t = new STRtree(); + foreach (var p in polys) + { + var pe = p.EnvelopeInternal; + e.ExpandToInclude(pe); + t.Insert(pe, new YStripesPointInPolygonLocator(p)); + } + t.Build(); + + _env = e; + _single = null; + _tree = t; + } + + public Location Locate(Coordinate p) + { + double x = p.X, y = p.Y; + + if (_single == null && _tree == null) + return Location.Exterior; + + if (_single != null) + return _single.Locate(p); + + if (_env is null || !_env.Contains(x, y)) + return Location.Exterior; + + _qEnv.Init(p); + var cands = _tree!.Query(_qEnv); + if (cands.Count == 0) + return Location.Exterior; + + bool onBoundary = false; + foreach (var item in cands) + { + var loc = ((IPointInAreaLocator)item).Locate(p); + if (loc == Location.Interior) + return Location.Interior; + if (loc == Location.Boundary) + onBoundary = true; + } + return onBoundary ? Location.Boundary : Location.Exterior; + } + + private static void CollectPolygons(Geometry g, List outList) + { + if (g is Polygon poly) + { + if (!poly.IsEmpty) + outList.Add(poly); + return; + } + if (g is GeometryCollection gc) + { + for (int i = 0; i < gc.NumGeometries; i++) + CollectPolygons(gc.GetGeometryN(i), outList); + } + } +} + +/// +/// Per-polygon Y-stripe index + ray-crossing (JTS package-private class port). +/// +internal sealed class YStripesPointInPolygonLocator : IPointInAreaLocator +{ + private readonly RingIndex _shell; + private readonly RingIndex[] _holes; + + public YStripesPointInPolygonLocator(Polygon polygon) + { + ArgumentNullException.ThrowIfNull(polygon); + _shell = RingIndex.Build((LinearRing)polygon.ExteriorRing); + _holes = new RingIndex[polygon.NumInteriorRings]; + for (int i = 0; i < _holes.Length; i++) + _holes[i] = RingIndex.Build((LinearRing)polygon.GetInteriorRingN(i)); + } + + public Location Locate(Coordinate p) + { + double x = p.X, y = p.Y; + if (!_shell.CoversPointFast(x, y)) + return Location.Exterior; + + var loc = _shell.LocateYStripes(x, y); + if (loc != Location.Interior) + return loc; + + foreach (var hole in _holes) + { + if (!hole.CoversPointFast(x, y)) + continue; + var hLoc = hole.LocateYStripes(x, y); + if (hLoc == Location.Boundary) + return Location.Boundary; + if (hLoc == Location.Interior) + return Location.Exterior; // inside a hole + } + return Location.Interior; + } + + private sealed class RingIndex + { + private readonly double[] _xs, _ys; + private readonly double[] _segXMin, _segXMax, _segYMin, _segYMax; + private readonly double _minX, _minY, _maxX, _maxY, _height, _invH; + private readonly int _nStripes; + private readonly int[] _stripeOffsets; + private readonly int[] _stripeCounts; + private readonly int[] _segIndex; + + private RingIndex( + double[] xs, double[] ys, + double[] segXMin, double[] segXMax, double[] segYMin, double[] segYMax, + double minX, double minY, double maxX, double maxY, + int nStripes, int[] stripeOffsets, int[] stripeCounts, int[] segIndex) + { + _xs = xs; + _ys = ys; + _segXMin = segXMin; + _segXMax = segXMax; + _segYMin = segYMin; + _segYMax = segYMax; + _minX = minX; + _minY = minY; + _maxX = maxX; + _maxY = maxY; + _height = maxY - minY; + _invH = _height == 0 ? 0.0 : 1.0 / _height; + _nStripes = nStripes; + _stripeOffsets = stripeOffsets; + _stripeCounts = stripeCounts; + _segIndex = segIndex; + } + + public static RingIndex Build(LinearRing ring) + { + var seq = ring.CoordinateSequence; + int n = seq.Count; + if (n < 2) + throw new ArgumentException("Ring has < 2 points"); + + var xs = new double[n]; + var ys = new double[n]; + double minX = double.PositiveInfinity, minY = double.PositiveInfinity; + double maxX = double.NegativeInfinity, maxY = double.NegativeInfinity; + + for (int i = 0; i < n; i++) + { + double x = seq.GetX(i), y = seq.GetY(i); + xs[i] = x; + ys[i] = y; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + int nSegs = n - 1; + var segXMin = new double[nSegs]; + var segXMax = new double[nSegs]; + var segYMin = new double[nSegs]; + var segYMax = new double[nSegs]; + + double perim = 0.0; + double area2 = 0.0; + for (int i = 0; i < nSegs; i++) + { + double ax = xs[i], ay = ys[i]; + double bx = xs[i + 1], by = ys[i + 1]; + segXMin[i] = ax < bx ? ax : bx; + segXMax[i] = ax > bx ? ax : bx; + segYMin[i] = ay < by ? ay : by; + segYMax[i] = ay > by ? ay : by; + double dx = bx - ax, dy = by - ay; + perim += Math.Sqrt(dx * dx + dy * dy); + area2 += ax * by - bx * ay; + } + double area = Math.Abs(0.5 * area2); + + int basen = Math.Max(64, Math.Min(nSegs, 65_536)); + double score = perim > 0 ? (area * Math.PI * 4.0) / (perim * perim) : 1.0; + double boost = Math.Max(0.35, Math.Min(1.0, score * 1.5)); + int nStripes = maxY == minY ? 1 : Math.Max(1, (int)Math.Round(basen * boost)); + + if (nStripes == 1) + { + var offsets = new[] { 0 }; + var counts = new[] { nSegs }; + var idx = new int[nSegs]; + for (int i = 0; i < nSegs; i++) idx[i] = i; + return new RingIndex(xs, ys, segXMin, segXMax, segYMin, segYMax, + minX, minY, maxX, maxY, 1, offsets, counts, idx); + } + + double scale = nStripes / (maxY - minY); + var countsTmp = new int[nStripes]; + int nMap = 0; + for (int i = 0; i < nSegs; i++) + { + int smin = (int)((segYMin[i] - minY) * scale); + int smax = (int)((segYMax[i] - minY) * scale); + if (smax >= nStripes) smax = nStripes - 1; + if (smin < 0) smin = 0; + if (smin > smax) smin = smax; + for (int s = smin; s <= smax; s++) + { + countsTmp[s]++; + nMap++; + } + } + + var stripeOffsets = new int[nStripes]; + var stripeCounts = new int[nStripes]; + int run = 0; + for (int s = 0; s < nStripes; s++) + { + stripeOffsets[s] = run; + run += countsTmp[s]; + } + var segIndex = new int[nMap]; + + for (int i = 0; i < nSegs; i++) + { + int smin = (int)((segYMin[i] - minY) * scale); + int smax = (int)((segYMax[i] - minY) * scale); + if (smax >= nStripes) smax = nStripes - 1; + if (smin < 0) smin = 0; + if (smin > smax) smin = smax; + for (int s = smin; s <= smax; s++) + { + int pos = stripeOffsets[s] + stripeCounts[s]++; + segIndex[pos] = i; + } + } + + return new RingIndex(xs, ys, segXMin, segXMax, segYMin, segYMax, + minX, minY, maxX, maxY, nStripes, stripeOffsets, stripeCounts, segIndex); + } + + public bool CoversPointFast(double x, double y) => + !(y < _minY || y > _maxY || x < _minX || x > _maxX); + + public Location LocateYStripes(double px, double py) + { + bool inside = false; + int onIdx = -1; + + int s; + if (_nStripes == 1 || _height == 0) + s = 0; + else + { + s = (int)(((py - _minY) * _invH) * _nStripes); + if (s < 0) s = 0; + else if (s >= _nStripes) s = _nStripes - 1; + } + + int bas = _stripeOffsets[s]; + int cnt = _stripeCounts[s]; + int end = bas + cnt; + + for (int p = bas; p < end; p++) + { + int i = _segIndex[p]; + double ymin = _segYMin[i], ymax = _segYMax[i]; + if (py < ymin || py > ymax) + continue; + + double ax = _xs[i], ay = _ys[i]; + double bx = _xs[i + 1], by = _ys[i + 1]; + double xmin = _segXMin[i], xmax = _segXMax[i]; + + // Horizontal edges: boundary only; never counted for crossings + if (ay == by) + { + if (py == ay && px >= xmin && px <= xmax) + { + onIdx = i; + break; + } + continue; + } + + // Entire segment strictly to the right: guaranteed crossing if straddle + if (px < xmin) + { + if ((ay > py) != (by > py)) + inside = !inside; + continue; + } + + // Entire segment strictly to the left + if (px > xmax) + continue; + + int rc = Raycast(ax, ay, bx, by, px, py, xmin, xmax, ymin, ymax); + if (rc == RcOn) + { + onIdx = i; + break; + } + if (rc == RcIn) + inside = !inside; + } + + if (onIdx != -1) + return Location.Boundary; + return inside ? Location.Interior : Location.Exterior; + } + + private const int RcOut = 0; + private const int RcIn = 1; + private const int RcOn = 2; + + private static int Raycast( + double ax, double ay, double bx, double by, + double px, double py, + double minx, double maxx, double miny, double maxy) + { + if ((px == ax && py == ay) || (px == bx && py == by)) + return RcOn; + if (ay == by) + { + if (py == ay && px >= minx && px <= maxx) + return RcOn; + return RcOut; + } + // Half-open straddle + if (!((ay > py) != (by > py))) + return RcOut; + + int orient = CGAlgorithmsDD.OrientationIndex(ax, ay, bx, by, px, py); + if (orient == 0) + { + if (px >= minx && px <= maxx && py >= miny && py <= maxy) + return RcOn; + return RcOut; + } + if (by < ay) + orient = -orient; + return orient > 0 ? RcIn : RcOut; + } + } +} diff --git a/tests/Discussion839Mre/jts-1145-comment.md b/tests/Discussion839Mre/jts-1145-comment.md new file mode 100644 index 00000000..b1e3ed83 --- /dev/null +++ b/tests/Discussion839Mre/jts-1145-comment.md @@ -0,0 +1,27 @@ +Corpus PIP gallery gate against this PR’s Y-stripe algorithm (NetTopologySuite.Proofs). + +### What we checked +Faithful port of `YStripesPointInPolygonLocator` / `YStripesPointInAreaLocator` (DD ray-crossing + Y-stripes) run against the Qed-backed gallery in [`nts-oracle-gallery.md`](https://github.com/grootstebozewolf/NetTopologySuite.Proofs/blob/main/docs/nts-oracle-gallery.md), side-by-side with NTS 2.6 `IndexedPointInAreaLocator` and `SimplePointInAreaLocator`. + +Write-up + table: [jts-1145-pip-lane](https://github.com/grootstebozewolf/NetTopologySuite.Proofs/blob/main/docs/jts-1145-pip-lane-2026-08.md) +Harness: `dotnet run --project tests/Discussion839Mre -c Release -- --jts1145` + +### Results (14/14) +| Case | Point | Geometric GT | YStripes | +|---|---|---|---| +| Diamond generic | (0, 0.5) | INTERIOR | INTERIOR | +| **Vertex graze** (naive parity even) | **(0, 0)** | **INTERIOR** | **INTERIOR** | +| **Horizontal edge** (naive parity odd) | **(−1, 1)** on notch | **EXTERIOR** | **EXTERIOR** | +| Rect interior / edges | … | INT / BOUNDARY | match | +| Hot-pixel centre / bottom | … | INT / BOUNDARY | match | +| Spectre interior / **reflex pocket** | (5, 0.5) / **(3.5, 0.5)** | INT / **EXT** | match | +| Hat interior / **pocket** (√3 APPROX WKT) | … | INT / **EXT** | match | + +Also **bit-equal** to Indexed and Simple on every row (no locator split). + +### Suggestion +`AbstractPointInRingTest` already covers comb / repeated pts / a few robust triangles. Adding the gallery WKT pins (especially **diamond graze**, **horizontal-edge exterior**, **Spectre pocket**) would lock both this locator and `IndexedPointInAreaLocator` against the classes that pure half-open ray parity gets wrong without special cases. + +No objection on soundness from this corpus gate. Port risk residual: Java tip not executed under JVM here (C# line-faithful port + NTS `CGAlgorithmsDD`). + +*(Affiliation: [NetTopologySuite.Proofs](https://github.com/grootstebozewolf/NetTopologySuite.Proofs) — formal companion, not a JTS committer review.)*