Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions benches/pachner_stress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use criterion::{
};
use delaunay::prelude::construction::{Vertex, vertex};
use delaunay::prelude::pachner::{
EdgeKey, FacetHandle, PachnerMove, PachnerMoves, RidgeHandle, SimplexKey, TriangleHandle,
VertexKey,
EdgeKey, FacetHandle, PachnerMove, PachnerMoveResult, PachnerMoves, RidgeHandle, SimplexKey,
TriangleHandle, VertexKey,
};

/// Shared benchmark setup error helpers.
Expand Down Expand Up @@ -118,12 +118,13 @@ fn k1_remove_fixture(
) -> (FlipTriangulation<4>, VertexKey) {
let mut dt = base_dt.clone();
let vertex_uuid = vertex.uuid();
let inserted = dt
.attempt_pachner(PachnerMove::K1Insert {
let inserted = attempt_pachner_move(
&mut dt,
PachnerMove::K1Insert {
simplex_key,
vertex,
})
.or_abort();
},
);
let vertex_key = dt
.tds()
.vertex_key_from_uuid(&vertex_uuid)
Expand All @@ -140,7 +141,7 @@ fn k2_inverse_fixture(
facet: FacetHandle,
) -> (FlipTriangulation<4>, EdgeKey) {
let mut dt = base_dt.clone();
let info = dt.attempt_pachner(PachnerMove::K2 { facet }).or_abort();
let info = attempt_pachner_move(&mut dt, PachnerMove::K2 { facet });
let edge = inserted_edge(&dt, &info.inserted_face_vertices);

(dt, edge)
Expand All @@ -152,12 +153,23 @@ fn k3_inverse_fixture(
ridge: RidgeHandle,
) -> (FlipTriangulation<4>, TriangleHandle) {
let mut dt = base_dt.clone();
let info = dt.attempt_pachner(PachnerMove::K3 { ridge }).or_abort();
let info = attempt_pachner_move(&mut dt, PachnerMove::K3 { ridge });
let triangle = inserted_triangle(&info.inserted_face_vertices);

(dt, triangle)
}

/// Parses and commits one Pachner request on the same topology owner.
fn attempt_pachner_move(
dt: &mut FlipTriangulation<4>,
pachner_move: PachnerMove<(), 4>,
) -> PachnerMoveResult<4> {
dt.propose_pachner(pachner_move)
.or_abort()
.attempt_on(dt)
.or_abort()
}

/// Converts a reported inserted face into an inverse k=2 edge handle.
fn inserted_edge(dt: &FlipTriangulation<4>, vertices: &[VertexKey]) -> EdgeKey {
let [a, b] = vertices else {
Expand Down Expand Up @@ -185,7 +197,7 @@ fn clone_batch(base_dt: &FlipTriangulation<4>) -> Vec<FlipTriangulation<4>> {
vec![base_dt.clone(); MOVES_PER_SAMPLE]
}

/// Registers one stress case that repeats the same detached Pachner proposal.
/// Registers one stress case that repeats the same raw Pachner request.
fn bench_pachner_move(
group: &mut BenchmarkGroup<'_, WallTime>,
name: &'static str,
Expand All @@ -197,7 +209,7 @@ fn bench_pachner_move(
|| clone_batch(base_dt),
|mut triangulations| {
for dt in &mut triangulations {
let result = dt.attempt_pachner(pachner_move).or_abort();
let result = attempt_pachner_move(dt, pachner_move);
black_box(&result);
}
black_box(triangulations);
Expand Down
71 changes: 61 additions & 10 deletions docs/api_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ for topology guarantee and validation policy details.
The local edit API is exposed through the `PachnerMoves` trait in
`prelude::pachner`:

The canonical public workflow is fluent and staged: parse a raw
`PachnerMove` into a provenanced `PachnerProposal`, then dry-run or attempt the
proposal through the proposal object. This keeps mutation explicit while
preserving owner/generation evidence between stages.

```rust
use delaunay::prelude::construction::{
DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex,
Expand Down Expand Up @@ -204,34 +209,43 @@ fn main() -> Result<(), ExampleError> {
let Some((simplex_key, _)) = dt.simplices().next() else {
return Ok(());
};
let info = dt.attempt_pachner(PachnerMove::K1Insert {
simplex_key,
vertex: vertex![0.25, 0.25, 0.25]?,
})?;
let info = dt
.propose_pachner(PachnerMove::K1Insert {
simplex_key,
vertex: vertex![0.25, 0.25, 0.25]?,
})?
.attempt_on(&mut dt)?;

// k=1 inverse: Remove a vertex (collapses its star)
let vertex_key = info.inserted_face_vertices[0];
dt.attempt_pachner(PachnerMove::K1Remove { vertex_key })?;
dt.propose_pachner(PachnerMove::K1Remove { vertex_key })?
.attempt_on(&mut dt)?;

// k=2 move: Flip a facet (2 simplices ↔ D simplices)
let facet = /* FacetHandle */;
let info = dt.attempt_pachner(PachnerMove::K2 { facet })?;
let info = dt
.propose_pachner(PachnerMove::K2 { facet })?
.attempt_on(&mut dt)?;

// k=2 inverse: Flip from an edge star (D simplices ↔ 2 simplices)
let edge = EdgeKey::try_new(info.inserted_face_vertices[0], info.inserted_face_vertices[1])?;
dt.attempt_pachner(PachnerMove::K2Inverse { edge })?;
dt.propose_pachner(PachnerMove::K2Inverse { edge })?
.attempt_on(&mut dt)?;

// k=3 move: Flip a ridge (3 simplices ↔ D-1 simplices, requires D ≥ 3)
let ridge = /* RidgeHandle */;
let info = dt.attempt_pachner(PachnerMove::K3 { ridge })?;
let info = dt
.propose_pachner(PachnerMove::K3 { ridge })?
.attempt_on(&mut dt)?;

// k=3 inverse: Flip from a triangle star (D-1 simplices ↔ 3 simplices)
let triangle = TriangleHandle::try_new(
info.inserted_face_vertices[0],
info.inserted_face_vertices[1],
info.inserted_face_vertices[2],
)?;
dt.attempt_pachner(PachnerMove::K3Inverse { triangle })?;
dt.propose_pachner(PachnerMove::K3Inverse { triangle })?
.attempt_on(&mut dt)?;
Ok(())
}
```
Expand Down Expand Up @@ -277,11 +291,47 @@ fn main() -> Result<(), ExampleError> {
### Key Characteristics

- **Explicit control**: You specify exactly which flip to perform
- **Provenanced proposals**: Raw `PachnerMove` values are parsed into
`PachnerProposal` values before dry-run or mutation
- **No automatic property preservation**: The Delaunay property is **not** maintained automatically
- **Reversible**: Each forward move has a corresponding inverse
- **Geometric validation**: Flips check for degeneracy and manifold preservation
- **Flexible**: Can be used to build custom repair or optimization algorithms

### Proposal Provenance

`PachnerMove` is a raw detached request. It can be stored, randomized, or queued,
but it is not proof that its handles are still live or that they came from the
target triangulation. `propose_pachner(...)` is the raw-to-provenanced
boundary: it validates the local move preconditions, then stamps the resulting
`PachnerProposal` with the current topology owner and structural generation
while carrying the proven feasibility report inward.

Two runtime-only TDS primitives provide that provenance:

- `TopologyOwnerId` is an opaque identity for one live topology owner. Ordinary
clones and deserialization get fresh identities, while internal rollback
snapshots preserve identity so failure-atomic mutation paths can restore the
same owner.
- The topology generation increments on structural mutation. It is an
invalidation stamp for caches, proposals, and detached topology artifacts; it
is not serialized.

`PachnerProposal::can_attempt_on(...)` and `PachnerProposal::attempt_on(...)`
are the dry-run and mutation paths. They reject proposals from another owner
with `FlipError::WrongTopologyOwner` and proposals from an older generation with
`FlipError::StaleTopologyProposal` before interpreting runtime-local keys.
`can_attempt_on(...)` returns the feasibility proof stored in the proposal after
that provenance check; `attempt_on(...)` still revalidates through the selected
primitive mutation path before changing topology.

This design supports future concurrent proposal workflows: worker threads can
compute or filter candidate moves against an immutable snapshot, then a
coordinator can attempt selected proposals against the canonical owner and treat
losing stale proposals as typed, expected failures. It does not by itself make
topology mutation concurrent; shared mutable access still needs an explicit
synchronization or transaction design.

### Important Caveats

⚠️ **The Pachner Move API does not preserve the Delaunay property automatically.**
Expand Down Expand Up @@ -338,7 +388,8 @@ fn main() -> Result<(), ExampleError> {

// 3. Make custom topology edits (Pachner Move API)
let facet = /* ... */;
dt.attempt_pachner(PachnerMove::K2 { facet })?;
dt.propose_pachner(PachnerMove::K2 { facet })?
.attempt_on(&mut dt)?;

// 4. Verify Delaunay property if needed
if let Err(e) = dt.is_valid_delaunay() {
Expand Down
38 changes: 38 additions & 0 deletions docs/dev/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Agents must follow these rules when modifying or adding Rust code.
- [Numeric Conversions](#numeric-conversions)
- [Borrowing and Ownership](#borrowing-and-ownership)
- [Error Handling](#error-handling)
- [Fluent Workflow APIs](#fluent-workflow-apis)
- [Constructor Naming](#constructor-naming)
- [Panic Policy](#panic-policy)
- [Error Types](#error-types)
Expand Down Expand Up @@ -338,6 +339,43 @@ builder

---

## Fluent Workflow APIs

Fluent APIs are a reviewed design preference for public workflows, not a
repository-wide requirement. Prefer staged method chains when the operation
naturally proceeds through configuration, proposal, transaction, dry-run,
commit, execution, or report phases.

Good fluent APIs make the valid sequence obvious and keep fallibility visible:

```rust
let result = owner
.propose_change(raw_request)?
.attempt_on(&mut owner)?;
```

Use fluent stages when they preserve useful evidence, such as a builder that
stores validated options, a proposal that carries owner/generation provenance,
or a transaction guard that owns rollback state. Coordinate this with
parse-don't-validate design: once raw input has been parsed into a
proof-bearing value, later stages should consume or borrow that value rather
than reaccepting the raw input.

Keep mutation explicit at the terminal method. Prefer names such as `build`,
`attempt_on`, `apply_to`, `commit`, `execute`, or `finish` when that method is
the point where side effects happen. Public samples should not hide mutation in
closures such as `and_then`, `map`, `inspect`, or `for_each` when a named stage
would be clearer.

Do not force fluent style onto accessors, iterators, simple queries, passive
reports, primitive/expert APIs, standard trait implementations, or one-step
operations with no meaningful intermediate state. Keep non-fluent functions
when they provide real orthogonality, such as trait dispatch hooks or low-level
primitive operations; remove or hide them when they only duplicate the fluent
workflow and broaden public surface without adding capability.

---

## Constructor Naming

Constructor names must show where raw input is parsed into proof-bearing domain
Expand Down
4 changes: 4 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ These are not currently implemented:
- Constrained Delaunay triangulations.
- Voronoi diagram extraction.
- Built-in visualization.
- Multi-threaded construction, proposal coordination, or concurrent topology
mutation APIs. Runtime owner/generation provenance exists for caches and
detached Pachner proposals, but parallel execution still requires a dedicated
synchronization and transaction design.
- Massively parallel, GPU, or out-of-core construction.
- Full spherical or hyperbolic triangulation semantics.

Expand Down
11 changes: 8 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ Key takeaways from v0.7.8:
### v0.8.0 paper-facing API and topology push

v0.8.0 is the next feature-bearing release and is expected to carry the larger
work intentionally deferred from v0.7.8 cleanup:
work intentionally deferred from v0.7.8 cleanup. It will require Rust 1.97.0;
the final release gate is an explicit audit of the 1.97.0 toolchain surface
before shipping.

- **Pachner/Edit API shape (#252/#253/#350/#337):** unify the Pachner move API,
expand public flip benchmark coverage, add Monte-Carlo stress benchmarks, and
Expand All @@ -51,8 +53,11 @@ work intentionally deferred from v0.7.8 cleanup:
`SphericalSpace::canonicalize_point()`.
- **Iterator cleanup (#353):** prefer iterator-based collection-building paths
where that improves clarity and allocation behavior.
- **Rust test cleanup (#329):** adopt stable `assert_matches!` in tests now
that the MSRV supports it.
- **Rust 1.97.0 release gate (#329/#496):** raise the v0.8.0 MSRV to Rust
1.97.0, finish the baseline `assert_matches!` cleanup, audit the new
integer/`NonZero` bit helpers against Hilbert bit-depth/index invariants,
review `RepeatN::default` and Cargo 1.97 tooling changes for useful adoption,
and re-benchmark predicate `cold_path` decisions under the 1.97.0 compiler.

### v0.9.0 and later horizon

Expand Down
18 changes: 11 additions & 7 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,16 +502,20 @@ fn main() -> Result<(), FlipExampleError> {
let Some((simplex_key, _)) = dt.simplices().next() else {
return Ok(());
};
let info = dt.attempt_pachner(PachnerMove::K1Insert {
simplex_key,
vertex: vertex![0.1, 0.1, 0.1]?,
})?;
let info = dt
.propose_pachner(PachnerMove::K1Insert {
simplex_key,
vertex: vertex![0.1, 0.1, 0.1]?,
})?
.attempt_on(&mut dt)?;
let inserted_vertex = info.inserted_face_vertices[0];

// k=1 inverse: remove the inserted vertex (collapse its star).
let removed = dt.attempt_pachner(PachnerMove::K1Remove {
vertex_key: inserted_vertex,
})?;
let removed = dt
.propose_pachner(PachnerMove::K1Remove {
vertex_key: inserted_vertex,
})?
.attempt_on(&mut dt)?;
assert!(!removed.removed_simplices.is_empty());

// Validate the stack (Levels 1–3) after topological edits.
Expand Down
11 changes: 7 additions & 4 deletions examples/delaunayize_repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,10 @@ fn flip_then_repair_2d() -> Result<(), DelaunayizeRepairExampleError> {
let mut violating_facet = None;
for facet in facets {
let mut trial = dt.clone();
if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok()
&& trial.is_valid_delaunay().is_err()
{
let Ok(proposal) = trial.propose_pachner(PachnerMove::K2 { facet }) else {
continue;
};
if proposal.attempt_on(&mut trial).is_ok() && trial.is_valid_delaunay().is_err() {
violating_facet = Some(facet);
break;
}
Expand All @@ -190,7 +191,9 @@ fn flip_then_repair_2d() -> Result<(), DelaunayizeRepairExampleError> {
return Ok(());
};

let selected_flip = dt.attempt_pachner(PachnerMove::K2 { facet })?;
let selected_flip = dt
.propose_pachner(PachnerMove::K2 { facet })?
.attempt_on(&mut dt)?;
assert!(!selected_flip.new_simplices.is_empty());
match dt.is_valid_delaunay() {
Ok(()) => {
Expand Down
5 changes: 4 additions & 1 deletion examples/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ fn build_non_delaunay_triangulation_2d()
};
let facet = FacetHandle::try_new(dt.tds(), simplex_key, facet_index)?;
let mut trial = dt.clone();
if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok()
let Ok(proposal) = trial.propose_pachner(PachnerMove::K2 { facet }) else {
continue;
};
if proposal.attempt_on(&mut trial).is_ok()
&& trial.as_triangulation().validate().is_ok()
&& matches!(
trial.is_valid_delaunay(),
Expand Down
Loading
Loading