Skip to content

Characterize orientation-predicate robustness over R² (tests for #1106)#2

Merged
grootstebozewolf merged 5 commits into
masterfrom
claude/artifact-download-jts-oracle-wBMFn
Jun 1, 2026
Merged

Characterize orientation-predicate robustness over R² (tests for #1106)#2
grootstebozewolf merged 5 commits into
masterfrom
claude/artifact-download-jts-oracle-wBMFn

Conversation

@grootstebozewolf

Copy link
Copy Markdown
Owner

Description

Adds a test-only harness that characterizes the robustness of the orientation
predicate (Orientation.indexCGAlgorithmsDD) over arbitrary double
coordinates, which is the property the point-line orientation robustness issues
ask for. The headline tool is a counterexample hunter that compares a
predicate against a self-contained exact reference (BigDecimal, exact for
dyadic doubles) and searches adversarial inputs for sign disagreements.

Key findings (all backed by passing tests):

  • In-band soundness. Orientation.index agrees with the exact sign on
    every one of millions of adversarial cases — uniform-random across
    magnitudes, near-collinear at ULP-level perpendicular offsets, and the
    double-precision-hard cases from OrientationIndexFailureTest — for
    coordinates roughly in [2^-511, 2^511].
  • The hunter is not vacuous. Over the same inputs it finds 10^4–10^6
    counterexamples for the predicates DD replaced (RobustDeterminant,
    NonRobustCGAlgorithms), while DD yields zero.
  • Range limit (real counterexamples). Outside that band DD fails trivially:
    for |coord| ≥ 2^512 the products overflow (Inf − Inf = NaN) and for
    |coord| ≲ 2^-512 they underflow, so orientationIndex returns 0
    (collinear) for points that are not collinear. e.g.
    (0,0),(1e200,1e200),(2e200,2.0000001e200) is CCW but DD reports collinear.
    A robust predicate would scale coordinates to avoid this.
  • Latent defect found (test-source only). While property-testing the
    Shewchuk expansion primitives, ShewchuksDeterminant.fast_expansion_sum_zeroelim
    was found to double-count the first component and drop the largest for inputs
    of length ≥ 2 (post-increment e[eindex++] where Shewchuk's reference uses
    pre-increment e[++eindex]): e.g. [1,16,256,4096]+[2,32,512,8192] → 822
    (should be 13107). This is masked in orientationIndex by the Stage-A float
    filter (verified exact over 2M near-collinear cases) and lives in test
    sources only
    ; it is documented in code, not auto-fixed.

Relevant issues

Contribution type / module

  • Type: enhancement (tests / characterization)
  • Module: jts-core (test sources only — no production code changed)

What's included (all under modules/core/src/test/.../algorithm/)

  • RocqRefRunner + RocqRefRunnerTest — exact orientation reference and
    soundness tests: exhaustive over integer coords |c| ≤ 4, plus large
    randomized / near-collinear / boundary samples over |c| ≤ 2^25 (exact in
    64-bit), and the R² exact-BigDecimal reference; an optional loader for
    externally-exported reference vectors.
  • DDCounterexampleHunter + OrientationDDRobustnessTest — the hunter,
    adversarial generators, and the overflow/underflow characterization tests.
  • ShewchukExpansionExactnessTest — exactness of Two_Sum / Fast_Two_Sum /
    Two_Product, end-to-end exactness of ShewchuksDeterminant.orientationIndex,
    and the documented fast_expansion_sum_zeroelim defect.

All new files carry the EPL/EDL license header and are JUnit tests.

Notes

  • This PR targets the fork's master, not upstream; commits are not ECA
    signed-off.
  • The exact reference is self-contained. As an aside, its verdicts (including
    the overflow/underflow counterexamples) were independently spot-checked
    against an external GMP-backed exact-integer orientation oracle and agreed in
    all cases; that oracle is corroboration only and is not vendored or required.

Generated by Claude Code

grootstebozewolf and others added 5 commits June 1, 2026 15:56
Adds a bespoke Java reference runner (RocqRefRunner) and JUnit suite that
exercise the orientation predicate (Orientation.index / CGAlgorithmsDD)
against an exact reference, addressing the point-line orientation
robustness requirement tracked in locationtech#1106.

R^2 coverage (the property the issue actually asks for): compares
Orientation.index against an exact BigDecimal determinant sign over
arbitrary double coordinates -- uniform random (moderate and large
magnitude), adversarial near-collinear (ULP-level perpendicular offsets),
and the double-precision-hard cases from OrientationIndexFailureTest.
JTS's double-double predicate agrees with the exact reference across the
full ~1.6M-case sample. This is strong empirical evidence, not a proof:
DD is ~106-bit, not adaptive-exact, so soundness over all of R^2 is not
claimed.

Bounded-integer floor: exhaustive (|c| <= 4) plus large randomized,
near-collinear and domain-boundary samples over integer coords |c| <= 2^25,
where the determinant is exact in 64-bit arithmetic. A loader consumes
externally exported reference vectors (e.g. from a Rocq development) and
cross-checks every supplied sign against the in-code reference.

https://claude.ai/code/session_019x4KgiMf2qvbYV7c81D6Z3
Co-authored-by: Claude <noreply@anthropic.com>
Adds DDCounterexampleHunter, which searches for inputs where an orientation
predicate disagrees with the exact BigDecimal reference, and a regression
test (OrientationDDRobustnessTest) that runs it.

The hunter evaluates three predicates against the same exact oracle using
adversarial generators (near-collinear across magnitudes up to ~2^52,
minimal-determinant product collisions, uniform random):

  strategy / magnitude        DD   RobustDeterminant   NonRobustCGAlgorithms
  near-collinear 1e7           0       36014               107214
  near-collinear 1e12          0       35261               107522
  near-collinear 4.5e15        0       34398               106623
  product-collision            0        6295               395614

The current DD predicate (Orientation.index) yields zero counterexamples
across millions of adversarial cases, while the naive and legacy predicates
it replaced fail tens to hundreds of thousands of times. The test asserts
both that the search is effective (legacy predicates do fail) and that DD
does not. This is strong empirical evidence, not a proof: DD is ~106-bit,
not adaptive-exact, but for double inputs the smallest non-zero orientation
determinant is ~2^-104 relative to the product scale, giving DD about two
bits of margin.

https://claude.ai/code/session_019x4KgiMf2qvbYV7c81D6Z3
Co-authored-by: Claude <noreply@anthropic.com>
The previous commit overstated the result. Its searches were bounded to
|coord| <= ~1e15, and concluded DD orientation had no discoverable
counterexamples. That conclusion was wrong: it implicitly assumed the DD
products neither overflow nor underflow.

Searching the full IEEE-754 double range finds counterexamples immediately:

  (0,0),(1e200,1e200),(2e200,2.0000001e200)  ->  DD=0, exact=+1

Mechanism and sharp thresholds:
  - Overflow: for |coord| >= 2^512 (~1.34e154) a DD product exceeds
    Double.MAX_VALUE -> Infinity, the determinant is Infinity-Infinity=NaN,
    and orientationIndex returns 0 (collinear) for non-collinear points.
  - Underflow: for |coord| <~ 2^-512 the products flush to zero and the
    determinant is reported as 0.
  DD is sound only within roughly [2^-511, 2^511]; the ~2-bit margin argument
  holds only inside that band.

Adds DDCounterexampleHunter.extremeMagnitude generator, KNOWN_COUNTEREXAMPLES,
and OVERFLOW/UNDERFLOW magnitude constants. OrientationDDRobustnessTest now
characterizes the limitation (testDDFailsOnOverflow, testDDFailsOnUnderflow,
testKnownCounterexamples) and scopes the soundness assertions to the safe band.

https://claude.ai/code/session_019x4KgiMf2qvbYV7c81D6Z3
Co-authored-by: Claude <noreply@anthropic.com>
…efect

Property-tests the Shewchuk expansion primitives in ShewchuksDeterminant
(test-source) against an exact BigDecimal reference, mirroring refSignExact:
  - Two_Sum / Fast_Two_Sum / Two_Product are exact (head+tail == a+b / a*b);
  - ShewchuksDeterminant.orientationIndex is exact end-to-end within the safe
    magnitude band (transitively exercising the expansion stack);
  - Two_Product overflows for products beyond Double.MAX_VALUE (range limit;
    adaptive precision does not grant overflow immunity).

Documents a real defect found while probing fast_expansion_sum_zeroelim: lines
713/717 use post-increment (e[eindex++]) where Shewchuk's reference uses
pre-increment (e[++eindex]), so it re-reads the first component instead of
advancing -- double-counting the first and dropping the largest for inputs of
length >= 2 (e.g. [1,16,256,4096]+[2,32,512,8192] -> 822, should be 13107).
This is test-source code (production orientation uses CGAlgorithmsDD), and the
defect is masked in orientationIndex by the Stage-A float filter, so no sign
errors occur over 2M near-collinear cases. Not auto-fixed: a correct Java port
must also guard the one-past-end read Shewchuk's C relies on.

https://claude.ai/code/session_019x4KgiMf2qvbYV7c81D6Z3
Co-authored-by: Claude <noreply@anthropic.com>
Adds a brief prose note in DDCounterexampleHunter: the self-contained
BigDecimal reference (RocqRefRunner.refSignExact) remains the sole ground
truth for the tests, and its verdicts -- including the overflow/underflow
counterexamples -- were independently spot-checked against an external
GMP-backed exact-integer orientation oracle and agreed in all cases. The
oracle is corroboration only, not a test dependency; no binary or harness is
vendored.

https://claude.ai/code/session_019x4KgiMf2qvbYV7c81D6Z3
Co-authored-by: Claude <noreply@anthropic.com>
@grootstebozewolf
grootstebozewolf merged commit 5112467 into master Jun 1, 2026
2 checks passed
github-actions Bot pushed a commit that referenced this pull request Jun 2, 2026
§7 risk #2 in issue locationtech#1195 says today's
CIRCULARSTRING(p0,p1,p2).equalsExact(LINESTRING(p0,p1,p2)) returns
true, against OGC SFA intent. Root cause: LineString overrides
Geometry.isEquivalentClass with a lenient `other instanceof
LineString` check (intentional, supports LinearRing ↔ LineString
equivalence); CircularString and CompoundCurve extend LineString and
inherit the lenient check.

Branching options in SPEC_R_EQ.md:

  A — override `isEquivalentClass` on each curve-side LineString
      subclass to be strict. Asymmetric: cs.eE(ls) returns false,
      ls.eE(cs) stays true (LineString's lenient check is preserved).
      One method per curve type, no jts-core change.

  B — tighten LineString.isEquivalentClass to strict everywhere.
      Symmetric, but breaks LinearRing ↔ LineString equivalence which
      is widely relied on. Much bigger change.

  C — add a parallel `equalsExactCurveAware` method. Surface-area
      cost, doesn't fix the bug for existing callers.

Lean: Option A. Implemented in this commit:

  - CircularString.isEquivalentClass overridden to
    `other instanceof CircularString`
  - CompoundCurve.isEquivalentClass overridden to
    `other instanceof CompoundCurve`

Asymmetry contract is part of the spike; documented in SPEC_R_EQ.md
"asymmetry trap" and exercised by
test_R_EQ_asymmetryIsDocumentedAndAccepted in
EqualsExactSemanticSpec.

Measurement on this branch:

  BEFORE override (probe):
    cs.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(cs)        true        -- WRONG (R-EQ target)
    cs.equalsExact(cc)        true        -- WRONG (R-EQ target)
    ls.equalsExact(cs)        true        -- intentional (asymmetry)
    cs.equalsExact(cs)        true        -- correct
    poly.equalsExact(ls)      false       -- already correct

  AFTER override (probe):
    cs.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(cs)        false       -- FIXED
    cs.equalsExact(cc)        false       -- FIXED
    ls.equalsExact(cs)        true        -- unchanged (asymmetry)
    cs.equalsExact(cs)        true        -- unchanged
    poly.equalsExact(ls)      false       -- unchanged

  Spec class: 3 reds flipped green, 3 sanity tests stayed green => 6/6
  Default curved-module suite: 55 / 55 still green (zero regressions)
  Full reactor with checkstyle: BUILD SUCCESS

Conclusion: Option A is a clean, two-method change that fixes the §7
violation on the side where the type information lives, with no
regressions in jts-core or jts-curved. The asymmetric dual is a
release-note item, not a correctness issue.

Smallest concrete next step (SPEC_R_EQ.md): maintainer A/B/C ack,
then this branch becomes the implementation PR (cherry-pick the two
overrides, add release-note bullet, add Javadoc on each curve type
calling out the asymmetric contract).

Files:
  - modules/curved/SPEC_R_EQ.md
  - modules/curved/src/main/java/.../CircularString.java
  - modules/curved/src/main/java/.../CompoundCurve.java
  - modules/curved/src/test/java/.../EqualsExactSemanticSpec.java
  - modules/curved/src/test/java/.../EqualsExactCurrentBehaviourProbe.java
  - modules/curved/pom.xml (Surefire exclude per epic §11)

Assisted-by: Claude (Opus-4.7)
Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
github-actions Bot pushed a commit that referenced this pull request Jun 14, 2026
§7 risk #2 in issue locationtech#1195 says today's
CIRCULARSTRING(p0,p1,p2).equalsExact(LINESTRING(p0,p1,p2)) returns
true, against OGC SFA intent. Root cause: LineString overrides
Geometry.isEquivalentClass with a lenient `other instanceof
LineString` check (intentional, supports LinearRing ↔ LineString
equivalence); CircularString and CompoundCurve extend LineString and
inherit the lenient check.

Branching options in SPEC_R_EQ.md:

  A — override `isEquivalentClass` on each curve-side LineString
      subclass to be strict. Asymmetric: cs.eE(ls) returns false,
      ls.eE(cs) stays true (LineString's lenient check is preserved).
      One method per curve type, no jts-core change.

  B — tighten LineString.isEquivalentClass to strict everywhere.
      Symmetric, but breaks LinearRing ↔ LineString equivalence which
      is widely relied on. Much bigger change.

  C — add a parallel `equalsExactCurveAware` method. Surface-area
      cost, doesn't fix the bug for existing callers.

Lean: Option A. Implemented in this commit:

  - CircularString.isEquivalentClass overridden to
    `other instanceof CircularString`
  - CompoundCurve.isEquivalentClass overridden to
    `other instanceof CompoundCurve`

Asymmetry contract is part of the spike; documented in SPEC_R_EQ.md
"asymmetry trap" and exercised by
test_R_EQ_asymmetryIsDocumentedAndAccepted in
EqualsExactSemanticSpec.

Measurement on this branch:

  BEFORE override (probe):
    cs.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(cs)        true        -- WRONG (R-EQ target)
    cs.equalsExact(cc)        true        -- WRONG (R-EQ target)
    ls.equalsExact(cs)        true        -- intentional (asymmetry)
    cs.equalsExact(cs)        true        -- correct
    poly.equalsExact(ls)      false       -- already correct

  AFTER override (probe):
    cs.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(cs)        false       -- FIXED
    cs.equalsExact(cc)        false       -- FIXED
    ls.equalsExact(cs)        true        -- unchanged (asymmetry)
    cs.equalsExact(cs)        true        -- unchanged
    poly.equalsExact(ls)      false       -- unchanged

  Spec class: 3 reds flipped green, 3 sanity tests stayed green => 6/6
  Default curved-module suite: 55 / 55 still green (zero regressions)
  Full reactor with checkstyle: BUILD SUCCESS

Conclusion: Option A is a clean, two-method change that fixes the §7
violation on the side where the type information lives, with no
regressions in jts-core or jts-curved. The asymmetric dual is a
release-note item, not a correctness issue.

Smallest concrete next step (SPEC_R_EQ.md): maintainer A/B/C ack,
then this branch becomes the implementation PR (cherry-pick the two
overrides, add release-note bullet, add Javadoc on each curve type
calling out the asymmetric contract).

Files:
  - modules/curved/SPEC_R_EQ.md
  - modules/curved/src/main/java/.../CircularString.java
  - modules/curved/src/main/java/.../CompoundCurve.java
  - modules/curved/src/test/java/.../EqualsExactSemanticSpec.java
  - modules/curved/src/test/java/.../EqualsExactCurrentBehaviourProbe.java
  - modules/curved/pom.xml (Surefire exclude per epic §11)

Assisted-by: Claude (Opus-4.7)
Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
github-actions Bot pushed a commit that referenced this pull request Jun 18, 2026
§7 risk #2 in issue locationtech#1195 says today's
CIRCULARSTRING(p0,p1,p2).equalsExact(LINESTRING(p0,p1,p2)) returns
true, against OGC SFA intent. Root cause: LineString overrides
Geometry.isEquivalentClass with a lenient `other instanceof
LineString` check (intentional, supports LinearRing ↔ LineString
equivalence); CircularString and CompoundCurve extend LineString and
inherit the lenient check.

Branching options in SPEC_R_EQ.md:

  A — override `isEquivalentClass` on each curve-side LineString
      subclass to be strict. Asymmetric: cs.eE(ls) returns false,
      ls.eE(cs) stays true (LineString's lenient check is preserved).
      One method per curve type, no jts-core change.

  B — tighten LineString.isEquivalentClass to strict everywhere.
      Symmetric, but breaks LinearRing ↔ LineString equivalence which
      is widely relied on. Much bigger change.

  C — add a parallel `equalsExactCurveAware` method. Surface-area
      cost, doesn't fix the bug for existing callers.

Lean: Option A. Implemented in this commit:

  - CircularString.isEquivalentClass overridden to
    `other instanceof CircularString`
  - CompoundCurve.isEquivalentClass overridden to
    `other instanceof CompoundCurve`

Asymmetry contract is part of the spike; documented in SPEC_R_EQ.md
"asymmetry trap" and exercised by
test_R_EQ_asymmetryIsDocumentedAndAccepted in
EqualsExactSemanticSpec.

Measurement on this branch:

  BEFORE override (probe):
    cs.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(cs)        true        -- WRONG (R-EQ target)
    cs.equalsExact(cc)        true        -- WRONG (R-EQ target)
    ls.equalsExact(cs)        true        -- intentional (asymmetry)
    cs.equalsExact(cs)        true        -- correct
    poly.equalsExact(ls)      false       -- already correct

  AFTER override (probe):
    cs.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(cs)        false       -- FIXED
    cs.equalsExact(cc)        false       -- FIXED
    ls.equalsExact(cs)        true        -- unchanged (asymmetry)
    cs.equalsExact(cs)        true        -- unchanged
    poly.equalsExact(ls)      false       -- unchanged

  Spec class: 3 reds flipped green, 3 sanity tests stayed green => 6/6
  Default curved-module suite: 55 / 55 still green (zero regressions)
  Full reactor with checkstyle: BUILD SUCCESS

Conclusion: Option A is a clean, two-method change that fixes the §7
violation on the side where the type information lives, with no
regressions in jts-core or jts-curved. The asymmetric dual is a
release-note item, not a correctness issue.

Smallest concrete next step (SPEC_R_EQ.md): maintainer A/B/C ack,
then this branch becomes the implementation PR (cherry-pick the two
overrides, add release-note bullet, add Javadoc on each curve type
calling out the asymmetric contract).

Files:
  - modules/curved/SPEC_R_EQ.md
  - modules/curved/src/main/java/.../CircularString.java
  - modules/curved/src/main/java/.../CompoundCurve.java
  - modules/curved/src/test/java/.../EqualsExactSemanticSpec.java
  - modules/curved/src/test/java/.../EqualsExactCurrentBehaviourProbe.java
  - modules/curved/pom.xml (Surefire exclude per epic §11)

Assisted-by: Claude (Opus-4.7)
Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
github-actions Bot pushed a commit that referenced this pull request Jun 20, 2026
§7 risk #2 in issue locationtech#1195 says today's
CIRCULARSTRING(p0,p1,p2).equalsExact(LINESTRING(p0,p1,p2)) returns
true, against OGC SFA intent. Root cause: LineString overrides
Geometry.isEquivalentClass with a lenient `other instanceof
LineString` check (intentional, supports LinearRing ↔ LineString
equivalence); CircularString and CompoundCurve extend LineString and
inherit the lenient check.

Branching options in SPEC_R_EQ.md:

  A — override `isEquivalentClass` on each curve-side LineString
      subclass to be strict. Asymmetric: cs.eE(ls) returns false,
      ls.eE(cs) stays true (LineString's lenient check is preserved).
      One method per curve type, no jts-core change.

  B — tighten LineString.isEquivalentClass to strict everywhere.
      Symmetric, but breaks LinearRing ↔ LineString equivalence which
      is widely relied on. Much bigger change.

  C — add a parallel `equalsExactCurveAware` method. Surface-area
      cost, doesn't fix the bug for existing callers.

Lean: Option A. Implemented in this commit:

  - CircularString.isEquivalentClass overridden to
    `other instanceof CircularString`
  - CompoundCurve.isEquivalentClass overridden to
    `other instanceof CompoundCurve`

Asymmetry contract is part of the spike; documented in SPEC_R_EQ.md
"asymmetry trap" and exercised by
test_R_EQ_asymmetryIsDocumentedAndAccepted in
EqualsExactSemanticSpec.

Measurement on this branch:

  BEFORE override (probe):
    cs.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(cs)        true        -- WRONG (R-EQ target)
    cs.equalsExact(cc)        true        -- WRONG (R-EQ target)
    ls.equalsExact(cs)        true        -- intentional (asymmetry)
    cs.equalsExact(cs)        true        -- correct
    poly.equalsExact(ls)      false       -- already correct

  AFTER override (probe):
    cs.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(cs)        false       -- FIXED
    cs.equalsExact(cc)        false       -- FIXED
    ls.equalsExact(cs)        true        -- unchanged (asymmetry)
    cs.equalsExact(cs)        true        -- unchanged
    poly.equalsExact(ls)      false       -- unchanged

  Spec class: 3 reds flipped green, 3 sanity tests stayed green => 6/6
  Default curved-module suite: 55 / 55 still green (zero regressions)
  Full reactor with checkstyle: BUILD SUCCESS

Conclusion: Option A is a clean, two-method change that fixes the §7
violation on the side where the type information lives, with no
regressions in jts-core or jts-curved. The asymmetric dual is a
release-note item, not a correctness issue.

Smallest concrete next step (SPEC_R_EQ.md): maintainer A/B/C ack,
then this branch becomes the implementation PR (cherry-pick the two
overrides, add release-note bullet, add Javadoc on each curve type
calling out the asymmetric contract).

Files:
  - modules/curved/SPEC_R_EQ.md
  - modules/curved/src/main/java/.../CircularString.java
  - modules/curved/src/main/java/.../CompoundCurve.java
  - modules/curved/src/test/java/.../EqualsExactSemanticSpec.java
  - modules/curved/src/test/java/.../EqualsExactCurrentBehaviourProbe.java
  - modules/curved/pom.xml (Surefire exclude per epic §11)

Assisted-by: Claude (Opus-4.7)
Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
github-actions Bot pushed a commit that referenced this pull request Jul 1, 2026
§7 risk #2 in issue locationtech#1195 says today's
CIRCULARSTRING(p0,p1,p2).equalsExact(LINESTRING(p0,p1,p2)) returns
true, against OGC SFA intent. Root cause: LineString overrides
Geometry.isEquivalentClass with a lenient `other instanceof
LineString` check (intentional, supports LinearRing ↔ LineString
equivalence); CircularString and CompoundCurve extend LineString and
inherit the lenient check.

Branching options in SPEC_R_EQ.md:

  A — override `isEquivalentClass` on each curve-side LineString
      subclass to be strict. Asymmetric: cs.eE(ls) returns false,
      ls.eE(cs) stays true (LineString's lenient check is preserved).
      One method per curve type, no jts-core change.

  B — tighten LineString.isEquivalentClass to strict everywhere.
      Symmetric, but breaks LinearRing ↔ LineString equivalence which
      is widely relied on. Much bigger change.

  C — add a parallel `equalsExactCurveAware` method. Surface-area
      cost, doesn't fix the bug for existing callers.

Lean: Option A. Implemented in this commit:

  - CircularString.isEquivalentClass overridden to
    `other instanceof CircularString`
  - CompoundCurve.isEquivalentClass overridden to
    `other instanceof CompoundCurve`

Asymmetry contract is part of the spike; documented in SPEC_R_EQ.md
"asymmetry trap" and exercised by
test_R_EQ_asymmetryIsDocumentedAndAccepted in
EqualsExactSemanticSpec.

Measurement on this branch:

  BEFORE override (probe):
    cs.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(cs)        true        -- WRONG (R-EQ target)
    cs.equalsExact(cc)        true        -- WRONG (R-EQ target)
    ls.equalsExact(cs)        true        -- intentional (asymmetry)
    cs.equalsExact(cs)        true        -- correct
    poly.equalsExact(ls)      false       -- already correct

  AFTER override (probe):
    cs.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(cs)        false       -- FIXED
    cs.equalsExact(cc)        false       -- FIXED
    ls.equalsExact(cs)        true        -- unchanged (asymmetry)
    cs.equalsExact(cs)        true        -- unchanged
    poly.equalsExact(ls)      false       -- unchanged

  Spec class: 3 reds flipped green, 3 sanity tests stayed green => 6/6
  Default curved-module suite: 55 / 55 still green (zero regressions)
  Full reactor with checkstyle: BUILD SUCCESS

Conclusion: Option A is a clean, two-method change that fixes the §7
violation on the side where the type information lives, with no
regressions in jts-core or jts-curved. The asymmetric dual is a
release-note item, not a correctness issue.

Smallest concrete next step (SPEC_R_EQ.md): maintainer A/B/C ack,
then this branch becomes the implementation PR (cherry-pick the two
overrides, add release-note bullet, add Javadoc on each curve type
calling out the asymmetric contract).

Files:
  - modules/curved/SPEC_R_EQ.md
  - modules/curved/src/main/java/.../CircularString.java
  - modules/curved/src/main/java/.../CompoundCurve.java
  - modules/curved/src/test/java/.../EqualsExactSemanticSpec.java
  - modules/curved/src/test/java/.../EqualsExactCurrentBehaviourProbe.java
  - modules/curved/pom.xml (Surefire exclude per epic §11)

Assisted-by: Claude (Opus-4.7)
Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
github-actions Bot pushed a commit that referenced this pull request Jul 2, 2026
§7 risk #2 in issue locationtech#1195 says today's
CIRCULARSTRING(p0,p1,p2).equalsExact(LINESTRING(p0,p1,p2)) returns
true, against OGC SFA intent. Root cause: LineString overrides
Geometry.isEquivalentClass with a lenient `other instanceof
LineString` check (intentional, supports LinearRing ↔ LineString
equivalence); CircularString and CompoundCurve extend LineString and
inherit the lenient check.

Branching options in SPEC_R_EQ.md:

  A — override `isEquivalentClass` on each curve-side LineString
      subclass to be strict. Asymmetric: cs.eE(ls) returns false,
      ls.eE(cs) stays true (LineString's lenient check is preserved).
      One method per curve type, no jts-core change.

  B — tighten LineString.isEquivalentClass to strict everywhere.
      Symmetric, but breaks LinearRing ↔ LineString equivalence which
      is widely relied on. Much bigger change.

  C — add a parallel `equalsExactCurveAware` method. Surface-area
      cost, doesn't fix the bug for existing callers.

Lean: Option A. Implemented in this commit:

  - CircularString.isEquivalentClass overridden to
    `other instanceof CircularString`
  - CompoundCurve.isEquivalentClass overridden to
    `other instanceof CompoundCurve`

Asymmetry contract is part of the spike; documented in SPEC_R_EQ.md
"asymmetry trap" and exercised by
test_R_EQ_asymmetryIsDocumentedAndAccepted in
EqualsExactSemanticSpec.

Measurement on this branch:

  BEFORE override (probe):
    cs.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(ls)        true        -- WRONG (R-EQ target)
    cc.equalsExact(cs)        true        -- WRONG (R-EQ target)
    cs.equalsExact(cc)        true        -- WRONG (R-EQ target)
    ls.equalsExact(cs)        true        -- intentional (asymmetry)
    cs.equalsExact(cs)        true        -- correct
    poly.equalsExact(ls)      false       -- already correct

  AFTER override (probe):
    cs.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(ls)        false       -- FIXED
    cc.equalsExact(cs)        false       -- FIXED
    cs.equalsExact(cc)        false       -- FIXED
    ls.equalsExact(cs)        true        -- unchanged (asymmetry)
    cs.equalsExact(cs)        true        -- unchanged
    poly.equalsExact(ls)      false       -- unchanged

  Spec class: 3 reds flipped green, 3 sanity tests stayed green => 6/6
  Default curved-module suite: 55 / 55 still green (zero regressions)
  Full reactor with checkstyle: BUILD SUCCESS

Conclusion: Option A is a clean, two-method change that fixes the §7
violation on the side where the type information lives, with no
regressions in jts-core or jts-curved. The asymmetric dual is a
release-note item, not a correctness issue.

Smallest concrete next step (SPEC_R_EQ.md): maintainer A/B/C ack,
then this branch becomes the implementation PR (cherry-pick the two
overrides, add release-note bullet, add Javadoc on each curve type
calling out the asymmetric contract).

Files:
  - modules/curved/SPEC_R_EQ.md
  - modules/curved/src/main/java/.../CircularString.java
  - modules/curved/src/main/java/.../CompoundCurve.java
  - modules/curved/src/test/java/.../EqualsExactSemanticSpec.java
  - modules/curved/src/test/java/.../EqualsExactCurrentBehaviourProbe.java
  - modules/curved/pom.xml (Surefire exclude per epic §11)

Assisted-by: Claude (Opus-4.7)
Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant