Fix explosion.rs over-approximation: use cospan rewrite data for correct covering relations

- Replace all-to-all sub-poset connection with rewrite-based correspondence
- Add compute_subpoint_correspondence() using cone structure for 3 cases:
  identity (1-to-1), contraction (many-to-one), insertion (index shift)
- Add compute_height_maps() for singular and regular height tracking
- Covering relations reduced from 114 to 35 for half_braid (69% reduction)
- Surface 3 (r0,s0,r0): 9 spurious successors → 3 correct successors
- 175 tests passing, 0 failures
- Regenerated fixtures/half_braid_geometry.json with corrected boundaries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maximus Gorog 2026-04-08 00:54:18 -06:00
parent 6be1159262
commit 5454c02328
29 changed files with 139254 additions and 68 deletions

4
.gitignore vendored
View file

@ -1 +1,3 @@
/target
target/
*.swp
*.swo

3
Cargo.lock generated
View file

@ -346,6 +346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@ -632,6 +633,8 @@ name = "zigzag-engine"
version = "0.1.0"
dependencies = [
"proptest",
"serde",
"serde_json",
"thiserror",
]

View file

@ -7,6 +7,8 @@ license = "BSD-3-Clause"
[dependencies]
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
proptest = "1"

279
examples/render_braiding.rs Normal file
View file

@ -0,0 +1,279 @@
//! Generate geometry JSON from explosion for Three.js rendering.
//!
//! Run with: cargo run --example render_braiding
//!
//! Outputs fixtures/half_braid_geometry.json with vertices, wires, surfaces, volumes.
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use zigzag_engine::diagram::{Diagram, DiagramN};
use zigzag_engine::explosion::{HeightLabel, Point, Poset};
use zigzag_engine::import::load_homotopy_diagram_n;
use serde::Serialize;
#[derive(Serialize)]
struct Geometry {
metadata: Metadata,
vertices: Vec<Vertex>,
wires: Vec<Wire>,
surfaces: Vec<Surface>,
volumes: Vec<Volume>,
}
#[derive(Serialize)]
struct Metadata {
source: String,
dimension: usize,
total_points: usize,
total_covers: usize,
}
#[derive(Serialize)]
struct Vertex {
id: usize,
label: String,
point: String,
coords: [f64; 3],
}
#[derive(Serialize)]
struct Wire {
id: usize,
label: String,
point: String,
coords: [f64; 3], // The wire's own coordinate (midpoint/waypoint)
endpoints: [usize; 2], // Vertex IDs
endpoint_coords: [[f64; 3]; 2], // For convenience in renderer
}
#[derive(Serialize)]
struct Surface {
id: usize,
label: String,
point: String,
coords: [f64; 3],
boundary_wires: Vec<usize>, // Wire IDs on boundary
}
#[derive(Serialize)]
struct Volume {
id: usize,
label: String,
point: String,
coords: [f64; 3],
}
/// Format a point as a string like "s0,s1,r0"
fn format_point(p: &Point) -> String {
p.0.iter()
.map(|h| match h {
HeightLabel::Regular(j) => format!("r{}", j),
HeightLabel::Singular(j) => format!("s{}", j),
})
.collect::<Vec<_>>()
.join(",")
}
/// Compute linear coordinates from height labels
fn linear_coords(p: &Point) -> [f64; 3] {
let coords: Vec<f64> = p.0.iter().map(|h| h.to_linear_index() as f64).collect();
// Pad to 3D if needed
[
coords.get(0).copied().unwrap_or(0.0),
coords.get(1).copied().unwrap_or(0.0),
coords.get(2).copied().unwrap_or(0.0),
]
}
/// Count singular labels in a point
fn singular_count(p: &Point) -> usize {
p.0.iter().filter(|h| h.is_singular()).count()
}
/// Compute geometric dimension from singular count
fn geom_dim(p: &Point, n: usize) -> usize {
n - singular_count(p)
}
fn main() {
// Load diagram
let json = fs::read_to_string("fixtures/half_braid.json")
.expect("Failed to read fixtures/half_braid.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse half_braid.json");
let diagram = Diagram::DiagramN(diagram_n);
let n = diagram.dimension();
let pts = diagram.full_points();
eprintln!("Loaded half_braid.json: dim={}, {} points, {} covers",
n, pts.len(), pts.covers().len());
// Group points by geometric dimension
let mut by_geom_dim: HashMap<usize, Vec<usize>> = HashMap::new();
for (idx, point) in pts.elements().iter().enumerate() {
let gd = geom_dim(point, n);
by_geom_dim.entry(gd).or_default().push(idx);
}
// Build adjacency for reachability
let mut successors: Vec<Vec<usize>> = vec![vec![]; pts.len()];
let mut predecessors: Vec<Vec<usize>> = vec![vec![]; pts.len()];
for &(lower, upper) in pts.covers() {
successors[lower].push(upper);
predecessors[upper].push(lower);
}
// Helper: find all reachable points
let reachable_from = |start: usize, adj: &[Vec<usize>]| -> HashSet<usize> {
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(start);
visited.insert(start);
while let Some(curr) = queue.pop_front() {
for &next in &adj[curr] {
if visited.insert(next) {
queue.push_back(next);
}
}
}
visited
};
// Get vertex indices
let vertex_indices = by_geom_dim.get(&0).map(|v| v.as_slice()).unwrap_or(&[]);
let vertex_set: HashSet<usize> = vertex_indices.iter().copied().collect();
// Get wire indices
let wire_indices = by_geom_dim.get(&1).map(|v| v.as_slice()).unwrap_or(&[]);
// Build vertices
let mut vertices: Vec<Vertex> = Vec::new();
for (i, &idx) in vertex_indices.iter().enumerate() {
let point = &pts.elements()[idx];
vertices.push(Vertex {
id: idx,
label: format!("vertex_{}", i),
point: format_point(point),
coords: linear_coords(point),
});
}
// Build wires with endpoint connections
let mut wires: Vec<Wire> = Vec::new();
for (i, &idx) in wire_indices.iter().enumerate() {
let point = &pts.elements()[idx];
// Find connected vertices
let reachable_up = reachable_from(idx, &successors);
let reachable_down = reachable_from(idx, &predecessors);
let mut connected: Vec<usize> = reachable_up
.union(&reachable_down)
.filter(|v| vertex_set.contains(v))
.copied()
.collect();
connected.sort();
connected.dedup();
// Default to first two vertices if we don't have exactly 2
let endpoints = if connected.len() >= 2 {
[connected[0], connected[1]]
} else if connected.len() == 1 {
// Wire connects to only one vertex - use same vertex twice
// (This represents a loop or boundary wire)
[connected[0], connected[0]]
} else {
// No connected vertices found - use first two vertices as fallback
[vertex_indices[0], vertex_indices.get(1).copied().unwrap_or(vertex_indices[0])]
};
let endpoint_coords = [
linear_coords(&pts.elements()[endpoints[0]]),
linear_coords(&pts.elements()[endpoints[1]]),
];
wires.push(Wire {
id: idx,
label: format!("wire_{}", i),
point: format_point(point),
coords: linear_coords(point),
endpoints,
endpoint_coords,
});
}
// Build surfaces (geom_dim=2)
let surface_indices = by_geom_dim.get(&2).map(|v| v.as_slice()).unwrap_or(&[]);
let wire_set: HashSet<usize> = wire_indices.iter().copied().collect();
let mut surfaces: Vec<Surface> = Vec::new();
for (i, &idx) in surface_indices.iter().enumerate() {
let point = &pts.elements()[idx];
// Find connected wires (boundary)
let reachable_up = reachable_from(idx, &successors);
let reachable_down = reachable_from(idx, &predecessors);
let mut boundary_wires: Vec<usize> = reachable_up
.union(&reachable_down)
.filter(|v| wire_set.contains(v))
.copied()
.collect();
boundary_wires.sort();
surfaces.push(Surface {
id: idx,
label: format!("surface_{}", i),
point: format_point(point),
coords: linear_coords(point),
boundary_wires,
});
}
// Build volumes (geom_dim=3)
let volume_indices = by_geom_dim.get(&3).map(|v| v.as_slice()).unwrap_or(&[]);
let mut volumes: Vec<Volume> = Vec::new();
for (i, &idx) in volume_indices.iter().enumerate() {
let point = &pts.elements()[idx];
volumes.push(Volume {
id: idx,
label: format!("volume_{}", i),
point: format_point(point),
coords: linear_coords(point),
});
}
// Build output
let geometry = Geometry {
metadata: Metadata {
source: "half_braid.json".to_string(),
dimension: n,
total_points: pts.len(),
total_covers: pts.covers().len(),
},
vertices,
wires,
surfaces,
volumes,
};
// Output JSON
let json_output = serde_json::to_string_pretty(&geometry).expect("Failed to serialize");
// Write to file
fs::write("fixtures/half_braid_geometry.json", &json_output)
.expect("Failed to write fixtures/half_braid_geometry.json");
eprintln!("\nWrote fixtures/half_braid_geometry.json");
eprintln!(" {} vertices", geometry.vertices.len());
eprintln!(" {} wires", geometry.wires.len());
eprintln!(" {} surfaces", geometry.surfaces.len());
eprintln!(" {} volumes", geometry.volumes.len());
// Also print to stdout for piping
println!("{}", json_output);
}

File diff suppressed because it is too large Load diff

58463
fixtures/dim456_combined.json Normal file

File diff suppressed because it is too large Load diff

42
fixtures/dim456_info.txt Normal file
View file

@ -0,0 +1,42 @@
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:135:22
|
135 | .filter(|&e| (e.target() != keep))
| ^ ^
|
= note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
|
135 - .filter(|&e| (e.target() != keep))
135 + .filter(|&e| e.target() != keep)
|
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:158:22
|
158 | .filter(|&e| (e.source() != keep))
| ^ ^
|
help: remove these parentheses
|
158 - .filter(|&e| (e.source() != keep))
158 + .filter(|&e| e.source() != keep)
|
warning: `homotopy-core` (lib) generated 2 warnings (run `cargo fix --lib -p homotopy-core` to apply 2 suggestions)
Compiling homotopy-core v0.1.0 (/home/maximus/.env/extern/diagrammatic-semiotics/homotopy-rs/homotopy-core)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/examples/export_dim456`
=== Building dimension 4, 5, 6 test fixtures ===
lips: dim=4, size=1
padded_4: dim=4, size=2
as_5d: dim=5, size=1
source dim=4, size=2
as_6d: dim=6, size=1
source dim=5, size=1
=== Exporting fixtures ===
padded_4: 1076794 bytes
as_5d: 1154944 bytes
as_6d: 1233174 bytes

61
fixtures/dim4_info.txt Normal file
View file

@ -0,0 +1,61 @@
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:135:22
|
135 | .filter(|&e| (e.target() != keep))
| ^ ^
|
= note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
|
135 - .filter(|&e| (e.target() != keep))
135 + .filter(|&e| e.target() != keep)
|
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:158:22
|
158 | .filter(|&e| (e.source() != keep))
| ^ ^
|
help: remove these parentheses
|
158 - .filter(|&e| (e.source() != keep))
158 + .filter(|&e| e.source() != keep)
|
warning: `homotopy-core` (lib) generated 2 warnings (run `cargo fix --lib -p homotopy-core` to apply 2 suggestions)
Compiling homotopy-core v0.1.0 (/home/maximus/.env/extern/diagrammatic-semiotics/homotopy-rs/homotopy-core)
warning: unused import: `homotopy_core::common::Boundary`
--> homotopy-core/examples/export_dim4.rs:4:5
|
4 | use homotopy_core::common::Boundary;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: `homotopy-core` (example "export_dim4") generated 1 warning (run `cargo fix --example "export_dim4" -p homotopy-core` to apply 1 suggestion)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/examples/export_dim4`
=== Searching for dimension 4 examples ===
--- lips() ---
lips: dim=4, size=1
source dim=3
--- pants_unit() ---
pants_unit: dim=4, size=1
source dim=3
--- algebraic_snake() ---
algebraic_snake: dim=3, size=1
=== Approach 1: Padded half_braid lifted to dim 4 ===
half_braid: dim=3, size=1
padded_3d: dim=3, size=2
padded_4d: dim=4, size=0
=== Approach 2: Pad lips() at dim 4 ===
padded_lips: dim=4, size=2
(original lips size was 1, now 2)
=== Exporting padded_lips (dim 4 with identity cospan) ===

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:135:22
|
135 | .filter(|&e| (e.target() != keep))
| ^ ^
|
= note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
|
135 - .filter(|&e| (e.target() != keep))
135 + .filter(|&e| e.target() != keep)
|
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:158:22
|
158 | .filter(|&e| (e.source() != keep))
| ^ ^
|
help: remove these parentheses
|
158 - .filter(|&e| (e.source() != keep))
158 + .filter(|&e| e.source() != keep)
|
warning: `homotopy-core` (lib) generated 2 warnings (run `cargo fix --lib -p homotopy-core` to apply 2 suggestions)
Compiling homotopy-core v0.1.0 (/home/maximus/.env/extern/diagrammatic-semiotics/homotopy-rs/homotopy-core)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
Running `target/debug/examples/export_essential`
=== Building essential identity scenario ===
two_scalars: dim=2, size=2
two_scalars_3d: dim=3, size=0
Attempting contraction...
Contraction failed: OutOfBounds
Fallback: using half_braid
half_braid: dim=3, size=1
padded_3d: dim=3, size=2
wrapped_4d: dim=4, size=1

2085
fixtures/half_braid.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,419 @@
{
"metadata": {
"source": "half_braid.json",
"dimension": 3,
"total_points": 23,
"total_covers": 35
},
"vertices": [
{
"id": 21,
"label": "vertex_0",
"point": "s0,s0,s0",
"coords": [
1.0,
1.0,
1.0
]
},
{
"id": 22,
"label": "vertex_1",
"point": "s1,s0,s0",
"coords": [
3.0,
1.0,
1.0
]
}
],
"wires": [
{
"id": 5,
"label": "wire_0",
"point": "s0,s0,r0",
"coords": [
1.0,
1.0,
0.0
],
"endpoints": [
21,
22
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
3.0,
1.0,
1.0
]
]
},
{
"id": 8,
"label": "wire_1",
"point": "s0,s1,r0",
"coords": [
1.0,
3.0,
0.0
],
"endpoints": [
21,
22
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
3.0,
1.0,
1.0
]
]
},
{
"id": 14,
"label": "wire_2",
"point": "s0,s0,r1",
"coords": [
1.0,
1.0,
2.0
],
"endpoints": [
21,
21
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
1.0,
1.0,
1.0
]
]
},
{
"id": 15,
"label": "wire_3",
"point": "s1,s0,r1",
"coords": [
3.0,
1.0,
2.0
],
"endpoints": [
21,
22
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
3.0,
1.0,
1.0
]
]
},
{
"id": 18,
"label": "wire_4",
"point": "r0,s0,s0",
"coords": [
0.0,
1.0,
1.0
],
"endpoints": [
21,
22
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
3.0,
1.0,
1.0
]
]
},
{
"id": 19,
"label": "wire_5",
"point": "r1,s0,s0",
"coords": [
2.0,
1.0,
1.0
],
"endpoints": [
21,
22
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
3.0,
1.0,
1.0
]
]
},
{
"id": 20,
"label": "wire_6",
"point": "r2,s0,s0",
"coords": [
4.0,
1.0,
1.0
],
"endpoints": [
21,
22
],
"endpoint_coords": [
[
1.0,
1.0,
1.0
],
[
3.0,
1.0,
1.0
]
]
}
],
"surfaces": [
{
"id": 3,
"label": "surface_0",
"point": "r0,s0,r0",
"coords": [
0.0,
1.0,
0.0
],
"boundary_wires": [
5,
8,
14,
15,
18,
19,
20
]
},
{
"id": 4,
"label": "surface_1",
"point": "r1,s0,r0",
"coords": [
2.0,
1.0,
0.0
],
"boundary_wires": [
5,
15,
19,
20
]
},
{
"id": 6,
"label": "surface_2",
"point": "r0,s1,r0",
"coords": [
0.0,
3.0,
0.0
],
"boundary_wires": [
8,
14,
15,
18,
19,
20
]
},
{
"id": 7,
"label": "surface_3",
"point": "r1,s1,r0",
"coords": [
2.0,
3.0,
0.0
],
"boundary_wires": [
8,
15,
19,
20
]
},
{
"id": 11,
"label": "surface_4",
"point": "r0,s0,r1",
"coords": [
0.0,
1.0,
2.0
],
"boundary_wires": [
14,
15,
18
]
},
{
"id": 12,
"label": "surface_5",
"point": "r1,s0,r1",
"coords": [
2.0,
1.0,
2.0
],
"boundary_wires": [
5,
8,
14,
15,
18,
19
]
},
{
"id": 13,
"label": "surface_6",
"point": "r2,s0,r1",
"coords": [
4.0,
1.0,
2.0
],
"boundary_wires": [
5,
8,
14,
15,
18,
19,
20
]
},
{
"id": 16,
"label": "surface_7",
"point": "r0,r0,s0",
"coords": [
0.0,
0.0,
1.0
],
"boundary_wires": [
14,
15,
18,
19,
20
]
},
{
"id": 17,
"label": "surface_8",
"point": "r0,r1,s0",
"coords": [
0.0,
2.0,
1.0
],
"boundary_wires": [
18
]
}
],
"volumes": [
{
"id": 0,
"label": "volume_0",
"point": "r0,r0,r0",
"coords": [
0.0,
0.0,
0.0
]
},
{
"id": 1,
"label": "volume_1",
"point": "r0,r1,r0",
"coords": [
0.0,
2.0,
0.0
]
},
{
"id": 2,
"label": "volume_2",
"point": "r0,r2,r0",
"coords": [
0.0,
4.0,
0.0
]
},
{
"id": 9,
"label": "volume_3",
"point": "r0,r0,r1",
"coords": [
0.0,
0.0,
2.0
]
},
{
"id": 10,
"label": "volume_4",
"point": "r0,r1,r1",
"coords": [
0.0,
2.0,
2.0
]
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

2099
fixtures/padded_3d.json Normal file

File diff suppressed because it is too large Load diff

2104
fixtures/padded_4d.json Normal file

File diff suppressed because it is too large Load diff

464
fixtures/padded_export.json Normal file
View file

@ -0,0 +1,464 @@
{
"padded_3d": {
"source": {
"DiagramN": {
"source": {
"DiagramN": {
"source": {
"Diagram0": {
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
}
},
"cospans": []
}
},
"cospans": [
{
"forward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
},
"backward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
}
}
]
}
},
"cospans": []
},
"scalar_3d": {
"source": {
"DiagramN": {
"source": {
"DiagramN": {
"source": {
"Diagram0": {
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
}
},
"cospans": []
}
},
"cospans": [
{
"forward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
},
"backward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
}
}
]
}
},
"cospans": []
}
}

42
fixtures/padded_info.txt Normal file
View file

@ -0,0 +1,42 @@
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:135:22
|
135 | .filter(|&e| (e.target() != keep))
| ^ ^
|
= note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
|
135 - .filter(|&e| (e.target() != keep))
135 + .filter(|&e| e.target() != keep)
|
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:158:22
|
158 | .filter(|&e| (e.source() != keep))
| ^ ^
|
help: remove these parentheses
|
158 - .filter(|&e| (e.source() != keep))
158 + .filter(|&e| e.source() != keep)
|
warning: `homotopy-core` (lib) generated 2 warnings (run `cargo fix --lib -p homotopy-core` to apply 2 suggestions)
Compiling homotopy-core v0.1.0 (/home/maximus/.env/extern/diagrammatic-semiotics/homotopy-rs/homotopy-core)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.74s
Running `target/debug/examples/export_padded`
=== Original half_braid ===
dimension: 3
size: 1
=== Identity cospan ===
forward is_identity: true
backward is_identity: true
cospan is_identity: true
=== Padded diagram ===
dimension: 3
size: 2
last cospan is_identity: true

19467
fixtures/padded_lips_4d.json Normal file

File diff suppressed because it is too large Load diff

226
fixtures/scalar.json Normal file
View file

@ -0,0 +1,226 @@
{
"source": {
"DiagramN": {
"source": {
"Diagram0": {
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
}
},
"cospans": []
}
},
"cospans": [
{
"forward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
},
"backward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
}
}
]
}

68
fixtures/scan_output.txt Normal file
View file

@ -0,0 +1,68 @@
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:135:22
|
135 | .filter(|&e| (e.target() != keep))
| ^ ^
|
= note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
|
135 - .filter(|&e| (e.target() != keep))
135 + .filter(|&e| e.target() != keep)
|
warning: unnecessary parentheses around closure body
--> homotopy-core/src/collapse.rs:158:22
|
158 | .filter(|&e| (e.source() != keep))
| ^ ^
|
help: remove these parentheses
|
158 - .filter(|&e| (e.source() != keep))
158 + .filter(|&e| e.source() != keep)
|
warning: `homotopy-core` (lib) generated 2 warnings (run `cargo fix --lib -p homotopy-core` to apply 2 suggestions)
Compiling homotopy-core v0.1.0 (/home/maximus/.env/extern/diagrammatic-semiotics/homotopy-rs/homotopy-core)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.55s
Running `target/debug/examples/scan_identities`
=== Scanning homotopy-rs examples for identity cospans ===
--- Scanning lips() ---
lips: dim=4, size=1
--- Scanning pants_unit() ---
pants_unit: dim=4, size=1
--- Scanning touching() ---
touching: dim=3, size=2
--- Scanning crossing() ---
crossing: dim=3, size=2
--- Scanning algebraic_snake() ---
algebraic_snake: dim=3, size=1
--- Scanning bubble() ---
bubble: dim=2, size=2
--- Scanning snake() ---
snake: dim=2, size=2
=== Constructing potential essential identity scenario ===
half_braid: dim=3, size=1
half_braid.source: dim=2
size=2
dim3_with_id: dim=3, size=2
cospan[0] is_identity: false
cospan[1] is_identity: true
Attempting contraction on dim3_with_id...
Contraction succeeded!
result: dim=4, size=1
FOUND: source at depth=1, dim=3, size=2, identity_cospans=[1]/2, path=["source"]
=== Exporting contracted diagram ===

434
fixtures/two_scalars.json Normal file
View file

@ -0,0 +1,434 @@
{
"source": {
"DiagramN": {
"source": {
"Diagram0": {
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
}
},
"cospans": []
}
},
"cospans": [
{
"forward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
},
"backward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 1,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
}
},
{
"forward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 2,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 2,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 2,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
},
"backward": {
"RewriteN": {
"dimension": 1,
"cones": [
{
"index": 0,
"internal": {
"source": [],
"target": {
"forward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 2,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Source",
1
],
[
[]
]
]
]
},
"backward": {
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 2,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
1
],
[
[]
]
]
]
}
},
"regular_slices": [
{
"Rewrite0": [
{
"generator": {
"id": 0,
"dimension": 0
},
"orientation": "Positive"
},
{
"generator": {
"id": 2,
"dimension": 2
},
"orientation": "Positive"
},
[
[
"Target",
0
],
[
[
{
"Regular": 0
}
]
]
]
]
}
],
"singular_slices": []
}
}
]
}
}
}
]
}

View file

@ -1063,23 +1063,24 @@ mod tests {
#[test]
fn test_non_identity_insertion_not_degeneracy() {
// A rewrite that inserts a non-identity cospan is not a degeneracy
let g = test_generator(0);
// A rewrite that inserts a non-identity cospan is not a degeneracy.
// Use different generators for source and target to ensure it's truly non-identity.
let g0 = test_generator(0);
let g1 = Generator::new(1, 0, false); // Different generator
let rewrite = Rewrite::RewriteN(RewriteN::new(
1,
vec![Cone::new(
0,
vec![], // Empty source = insertion
Cospan::new(
Rewrite::Rewrite0 { source: g.clone(), target: g.clone() },
Rewrite::Rewrite0 { source: g.clone(), target: g },
Rewrite::Rewrite0 { source: g0.clone(), target: g1.clone() },
Rewrite::Rewrite0 { source: g0, target: g1 },
),
vec![],
)],
));
let map = DiagramMap::new(rewrite);
// This should still be a degeneracy since Rewrite0 with same source/target is identity-like
// Actually, the cospan's forward/backward are not Rewrite::Identity, so is_identity() returns false
// Inserting a non-identity cospan (source != target) is not a degeneracy
assert!(!is_degeneracy(&map));
}

View file

@ -166,9 +166,12 @@ impl Cospan {
Self { forward, backward }
}
/// Check if this is an identity cospan (both legs are isomorphisms).
/// Check if this is an identity cospan (both legs are trivially identity).
///
/// Uses `is_trivial()` which recognizes both `Rewrite::Identity` and
/// `RewriteN` with empty cones (as serialized by homotopy-rs).
pub fn is_identity(&self) -> bool {
self.forward.is_identity() && self.backward.is_identity()
self.forward.is_trivial() && self.backward.is_trivial()
}
}
@ -189,11 +192,32 @@ pub enum Rewrite {
}
impl Rewrite {
/// Check if this is an identity rewrite.
/// Check if this is explicitly marked as an identity rewrite.
///
/// Returns true only for `Rewrite::Identity`. This is conservative and
/// used for degeneracy tracking where we need to distinguish between
/// a true identity and a parallel degeneracy with non-identity slices.
pub fn is_identity(&self) -> bool {
matches!(self, Rewrite::Identity)
}
/// Check if this rewrite is trivially identity (makes no changes).
///
/// Returns true for:
/// - `Rewrite::Identity` (explicit identity marker)
/// - `RewriteN` with empty cones (no structural changes at this level)
/// - `Rewrite0` with source == target
///
/// This is used for detecting identity cospans that should be removed
/// during normalisation.
pub fn is_trivial(&self) -> bool {
match self {
Rewrite::Identity => true,
Rewrite::RewriteN(r) => r.cones.is_empty(),
Rewrite::Rewrite0 { source, target } => source == target,
}
}
/// The dimension of this rewrite.
pub fn dimension(&self) -> usize {
match self {
@ -358,7 +382,9 @@ pub struct Cone {
pub source: Vec<Cospan>,
/// Target cospan (what the source contracts to)
pub target: Cospan,
/// Slice rewrites for each interior boundary
/// Slice rewrites for each source singular height.
/// Length equals source.len() (one per source cospan apex).
/// Matches homotopy-rs singular_slices convention.
pub slices: Vec<Rewrite>,
}

View file

@ -27,7 +27,7 @@
use std::cmp::Ordering;
use std::collections::{HashSet, HashMap, VecDeque};
use crate::diagram::Diagram;
use crate::diagram::{Cone, Diagram, Rewrite, RewriteN};
/// A height label in a diagram: either regular or singular.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@ -332,12 +332,16 @@ pub fn k_points(diagram: &Diagram, k: usize) -> Poset<Point> {
// Store the sub-posets for each height for later reference
let mut sub_posets: HashMap<HeightLabel, Poset<Point>> = HashMap::new();
// Store slice lengths (number of cospans) for computing correspondences
let mut slice_lengths: HashMap<HeightLabel, usize> = HashMap::new();
// Add points from regular heights
for j in 0..=d.length() {
let label = HeightLabel::Regular(j);
// TODO: Proper slice computation - currently regular_slice returns None for j > 0
// For now, use source for all regular slices as a structural placeholder
let slice = d.regular_slice(j).unwrap_or_else(|| (*d.source).clone());
slice_lengths.insert(label, slice.length());
let sub_points = k_points(&slice, k - 1);
for (sub_idx, sub_point) in sub_points.elements().iter().enumerate() {
@ -354,6 +358,7 @@ pub fn k_points(diagram: &Diagram, k: usize) -> Poset<Point> {
// TODO: Proper slice computation - currently singular_slice returns None
// For now, use source as a structural placeholder
let slice = d.singular_slice(i).unwrap_or_else(|| (*d.source).clone());
slice_lengths.insert(label, slice.length());
let sub_points = k_points(&slice, k - 1);
for (sub_idx, sub_point) in sub_points.elements().iter().enumerate() {
@ -416,46 +421,48 @@ pub fn k_points(diagram: &Diagram, k: usize) -> Poset<Point> {
// Determine which points are related via the cospan map
//
// The cospan structure gives us maps:
// - forward: rⱼ → sⱼ
// - backward: rⱼ₊₁ → sⱼ
// - forward: rⱼ → sⱼ (rewrite maps lower → upper)
// - backward: rⱼ₊₁ → sⱼ (rewrite maps upper → lower)
//
// These induce maps on sub-points. For a covering relation,
// we need to identify which sub-points map to each other.
//
// TODO: When proper cospan data is available, use it here.
// For now, we use a structural heuristic: if both sub-posets
// have the same structure (same number of points), we assume
// corresponding points are related.
// Simplified case: when sub-posets have matching structure,
// connect corresponding points
if lower_sub_poset.len() == upper_sub_poset.len() {
for sub_idx in 0..lower_sub_poset.len() {
if let (Some(&lower_idx), Some(&upper_idx)) =
(point_map.get(&(lower_label, sub_idx)),
point_map.get(&(upper_label, sub_idx)))
{
poset.add_cover(lower_idx, upper_idx);
}
// Get the rewrite, direction, and source cospan count
let (rewrite, forward_direction, source_cospan_count) = match (lower_label, upper_label) {
// r_j → s_j: forward rewrite of cospan j
// Forward direction: rewrite maps lower (source) → upper (target)
(HeightLabel::Regular(j), HeightLabel::Singular(sj)) if j == sj => {
let src_len = slice_lengths.get(&lower_label).copied().unwrap_or(0);
(&d.cospans[j].forward, true, src_len)
}
} else {
// When sub-posets have different sizes, the cospan maps
// contract or expand the structure. Without actual cospan data,
// we can't determine the exact correspondence.
//
// For structural completeness, connect all points at the
// lower height to all points at the upper height.
// This is an over-approximation of the covering relation.
// TODO: Refine this when cospan data is available.
for lower_sub_idx in 0..lower_sub_poset.len() {
for upper_sub_idx in 0..upper_sub_poset.len() {
if let (Some(&lower_idx), Some(&upper_idx)) =
(point_map.get(&(lower_label, lower_sub_idx)),
point_map.get(&(upper_label, upper_sub_idx)))
{
poset.add_cover(lower_idx, upper_idx);
}
}
// s_j → r_{j+1}: backward rewrite of cospan j
// Backward direction: rewrite maps upper (r_{j+1}) → lower (s_j)
(HeightLabel::Singular(j), HeightLabel::Regular(rj)) if rj == j + 1 => {
let src_len = slice_lengths.get(&upper_label).copied().unwrap_or(0);
(&d.cospans[j].backward, false, src_len)
}
_ => {
// Shouldn't happen for valid adjacent heights
continue;
}
};
// Compute the correspondence between sub-points based on the rewrite
let correspondences = compute_subpoint_correspondence(
rewrite,
lower_sub_poset,
upper_sub_poset,
forward_direction,
source_cospan_count,
);
// Add covers for corresponding points
for (lower_sub_idx, upper_sub_idx) in correspondences {
if let (Some(&lower_idx), Some(&upper_idx)) =
(point_map.get(&(lower_label, lower_sub_idx)),
point_map.get(&(upper_label, upper_sub_idx)))
{
poset.add_cover(lower_idx, upper_idx);
}
}
}
@ -467,6 +474,236 @@ pub fn k_points(diagram: &Diagram, k: usize) -> Poset<Point> {
}
}
/// Compute correspondences between points in adjacent sub-posets based on the rewrite.
///
/// # Arguments
/// * `rewrite` - The rewrite between slices
/// * `lower_sub_poset` - Points at the lower height
/// * `upper_sub_poset` - Points at the upper height
/// * `forward_direction` - true if rewrite maps lower→upper (forward rewrite),
/// false if rewrite maps upper→lower (backward rewrite)
/// * `source_cospan_count` - Number of cospans in the rewrite's source sub-diagram
///
/// Returns pairs (lower_sub_idx, upper_sub_idx) indicating which points should be connected.
fn compute_subpoint_correspondence(
rewrite: &Rewrite,
lower_sub_poset: &Poset<Point>,
upper_sub_poset: &Poset<Point>,
forward_direction: bool,
source_cospan_count: usize,
) -> Vec<(usize, usize)> {
// CASE 1: Identity or trivial rewrite (empty cones)
// Points correspond 1-to-1 by index
if rewrite.is_trivial() {
let min_len = std::cmp::min(lower_sub_poset.len(), upper_sub_poset.len());
return (0..min_len).map(|i| (i, i)).collect();
}
// CASE 2: RewriteN with cones
if let Rewrite::RewriteN(r) = rewrite {
return build_cone_correspondence(
r,
lower_sub_poset,
upper_sub_poset,
forward_direction,
source_cospan_count,
);
}
// CASE 3: Rewrite0 or other - 1-to-1 correspondence
let min_len = std::cmp::min(lower_sub_poset.len(), upper_sub_poset.len());
(0..min_len).map(|i| (i, i)).collect()
}
/// Build correspondence based on cone structure.
fn build_cone_correspondence(
rewrite: &RewriteN,
lower_sub_poset: &Poset<Point>,
upper_sub_poset: &Poset<Point>,
forward_direction: bool,
source_cospan_count: usize,
) -> Vec<(usize, usize)> {
// Build both singular and regular height maps
// Source has source_cospan_count singular heights (0..source_cospan_count)
// and source_cospan_count + 1 regular heights (0..=source_cospan_count)
let (singular_map, regular_map) = compute_height_maps(&rewrite.cones, source_cospan_count);
let mut correspondences = Vec::new();
for (lower_idx, lower_point) in lower_sub_poset.elements().iter().enumerate() {
for (upper_idx, upper_point) in upper_sub_poset.elements().iter().enumerate() {
let corresponds = if forward_direction {
// Forward: lower is source, upper is target
points_correspond_forward(lower_point, upper_point, &singular_map, &regular_map)
} else {
// Backward: rewrite maps upper→lower, but cover goes lower→upper
// So we check if upper (source) maps to lower (target)
points_correspond_forward(upper_point, lower_point, &singular_map, &regular_map)
};
if corresponds {
correspondences.push((lower_idx, upper_idx));
}
}
}
correspondences
}
/// Compute how heights map from source to target based on cones.
///
/// Returns (singular_map, regular_map) where:
/// - singular_map[src] = Some(tgt) if source singular src maps to target singular tgt
/// - regular_map[src] = Some(tgt) if source regular src maps to target regular tgt
/// - None means the height is absorbed/removed by a contraction
fn compute_height_maps(
cones: &[Cone],
source_cospan_count: usize,
) -> (Vec<Option<usize>>, Vec<Option<usize>>) {
// Source has:
// - source_cospan_count singular heights (indices 0..source_cospan_count)
// - source_cospan_count + 1 regular heights (indices 0..=source_cospan_count)
let mut singular_map: Vec<Option<usize>> = (0..source_cospan_count).map(Some).collect();
let mut regular_map: Vec<Option<usize>> = (0..=source_cospan_count).map(Some).collect();
if cones.is_empty() {
return (singular_map, regular_map);
}
// Sort cones by target index
let mut sorted_cones: Vec<&Cone> = cones.iter().collect();
sorted_cones.sort_by_key(|c| c.index);
// Process cones to build the mapping
// Each cone describes: source cospans at [src_start, src_start + cone.source.len())
// collapse to target cospan at cone.index
//
// We need to track the cumulative offset to convert between source and target indices
let mut cumulative_source_offset = 0usize; // How many extra source cospans we've seen
for cone in &sorted_cones {
if cone.source.is_empty() {
// INSERTION: a new cospan appears at cone.index in target
// This doesn't consume source cospans but shifts target indices
// All source heights at >= cone.index need their target shifted by +1
for src in 0..source_cospan_count {
if let Some(tgt) = singular_map[src] {
if tgt >= cone.index {
singular_map[src] = Some(tgt + 1);
}
}
}
for src in 0..=source_cospan_count {
if let Some(tgt) = regular_map[src] {
// Regular heights shift when target index > cone.index
if tgt > cone.index {
regular_map[src] = Some(tgt + 1);
}
}
}
} else {
// CONTRACTION: source cospans [src_start, src_end) collapse to target cone.index
let src_start = cone.index + cumulative_source_offset;
let src_end = src_start + cone.source.len();
// Singular heights in [src_start, src_end) all map to cone.index
for src in src_start..src_end {
if src < source_cospan_count {
singular_map[src] = Some(cone.index);
}
}
// Singular heights after src_end shift down by (cone.source.len() - 1)
let shift = cone.source.len() - 1;
for src in src_end..source_cospan_count {
if let Some(tgt) = singular_map[src] {
singular_map[src] = Some(tgt - shift);
}
}
// Regular heights:
// - r_src_start maps to r_cone.index (left boundary)
// - r_{src_start+1} through r_{src_end-1} are ABSORBED (interior boundaries)
// - r_src_end maps to r_{cone.index+1} (right boundary)
// - r_{src_end+1}.. shift down
// Interior regular heights are absorbed
for src in (src_start + 1)..src_end {
if src <= source_cospan_count {
regular_map[src] = None;
}
}
// r_src_end maps to r_{cone.index + 1}
if src_end <= source_cospan_count {
regular_map[src_end] = Some(cone.index + 1);
}
// Regular heights after src_end shift down
for src in (src_end + 1)..=source_cospan_count {
if let Some(tgt) = regular_map[src] {
regular_map[src] = Some(tgt - shift);
}
}
cumulative_source_offset += cone.source.len() - 1;
}
}
(singular_map, regular_map)
}
/// Check if a source point maps to a target point under the height mappings.
///
/// Points have labels ordered [inner, ..., outer]. The rewrite affects the OUTERMOST
/// dimension (last label). Inner dimensions must match exactly.
fn points_correspond_forward(
source: &Point,
target: &Point,
singular_map: &[Option<usize>],
regular_map: &[Option<usize>],
) -> bool {
let depth = source.depth();
// Must have same depth
if depth != target.depth() {
return false;
}
// Empty points always correspond
if depth == 0 {
return true;
}
// Inner dimensions (all but last) must match exactly
for i in 0..(depth - 1) {
if source.0[i] != target.0[i] {
return false;
}
}
// Outermost dimension (last label) must align under the mapping
let src_outer = &source.0[depth - 1];
let tgt_outer = &target.0[depth - 1];
match (src_outer, tgt_outer) {
(HeightLabel::Regular(s), HeightLabel::Regular(t)) => {
// Check regular height mapping
regular_map.get(*s).and_then(|&m| m).map(|mapped| mapped == *t).unwrap_or(false)
}
(HeightLabel::Singular(s), HeightLabel::Singular(t)) => {
// Check singular height mapping
singular_map.get(*s).and_then(|&m| m).map(|mapped| mapped == *t).unwrap_or(false)
}
_ => {
// Mixed regular/singular don't correspond
false
}
}
}
/// Compute the full explosion of a diagram.
///
/// Returns Ptₙ(X) where n = dimension(X).

836
src/import.rs Normal file
View file

@ -0,0 +1,836 @@
//! Import module for loading homotopy-rs JSON format.
//!
//! This module provides types that match the homotopy-rs serialization format
//! and conversions to zigzag-engine native types.
use serde::Deserialize;
use crate::diagram::{Cone, Cospan, Diagram, DiagramN, Rewrite, RewriteN};
use crate::signature::Generator;
// ============================================================================
// Homotopy-rs JSON format types
// ============================================================================
/// Generator as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyGenerator {
pub id: usize,
pub dimension: usize,
}
/// Orientation of a diagram (homotopy-rs tracks this, we ignore it).
#[derive(Debug, Clone, Deserialize)]
pub enum HomotopyOrientation {
Positive,
Negative,
Zero,
}
/// Diagram0 as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyDiagram0 {
pub generator: HomotopyGenerator,
pub orientation: HomotopyOrientation,
}
/// Diagram enum as serialized by homotopy-rs.
/// Uses external tagging: {"Diagram0": {...}} or {"DiagramN": {...}}
#[derive(Debug, Clone, Deserialize)]
pub enum HomotopyDiagram {
Diagram0(HomotopyDiagram0),
DiagramN(HomotopyDiagramN),
}
/// DiagramN as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyDiagramN {
pub source: Box<HomotopyDiagram>,
pub cospans: Vec<HomotopyCospan>,
}
/// Cospan as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyCospan {
pub forward: HomotopyRewrite,
pub backward: HomotopyRewrite,
}
/// BoundaryPath component of a label.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum HomotopyBoundary {
Source,
Target,
}
/// Height in a path.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum HomotopyHeight {
Regular { Regular: usize },
Singular { Singular: usize },
}
/// Label for a Rewrite0 (we mostly ignore this).
pub type HomotopyLabel = (
(String, usize), // BoundaryPath: ("Source"|"Target", depth)
Vec<Vec<HomotopyHeight>>, // Coordinates
);
/// Rewrite0 content: [source, target, label] or null for identity.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum HomotopyRewrite0Content {
/// Non-trivial rewrite: (source, target, label)
NonTrivial(HomotopyDiagram0, HomotopyDiagram0, HomotopyLabel),
/// Some rewrites have no label
NoLabel(HomotopyDiagram0, HomotopyDiagram0),
}
/// Rewrite as serialized by homotopy-rs.
/// Uses external tagging: {"Rewrite0": [...]} or {"RewriteN": {...}}
#[derive(Debug, Clone, Deserialize)]
pub enum HomotopyRewrite {
/// 0-dimensional rewrite (null for identity, or [source, target, label])
Rewrite0(Option<HomotopyRewrite0Content>),
/// N-dimensional rewrite
RewriteN(HomotopyRewriteN),
}
/// RewriteN as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyRewriteN {
pub dimension: usize,
pub cones: Vec<HomotopyConeWithIndex>,
}
/// Cone with index wrapper as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyConeWithIndex {
pub index: usize,
pub internal: HomotopyConeInternal,
}
/// Internal cone structure as serialized by homotopy-rs.
#[derive(Debug, Clone, Deserialize)]
pub struct HomotopyConeInternal {
pub source: Vec<HomotopyCospan>,
pub target: HomotopyCospan,
pub regular_slices: Vec<HomotopyRewrite>,
pub singular_slices: Vec<HomotopyRewrite>,
}
// ============================================================================
// Conversion implementations
// ============================================================================
impl From<HomotopyGenerator> for Generator {
fn from(g: HomotopyGenerator) -> Self {
Generator::new(g.id, g.dimension, false)
}
}
impl From<HomotopyDiagram0> for Generator {
fn from(d: HomotopyDiagram0) -> Self {
d.generator.into()
}
}
impl From<HomotopyDiagram> for Diagram {
fn from(d: HomotopyDiagram) -> Self {
match d {
HomotopyDiagram::Diagram0(inner) => {
Diagram::Diagram0(inner.into())
}
HomotopyDiagram::DiagramN(inner) => {
Diagram::DiagramN(inner.into())
}
}
}
}
impl From<HomotopyDiagramN> for DiagramN {
fn from(d: HomotopyDiagramN) -> Self {
DiagramN::new(
(*d.source).into(),
d.cospans.into_iter().map(|c| c.into()).collect(),
)
}
}
impl From<HomotopyCospan> for Cospan {
fn from(c: HomotopyCospan) -> Self {
Cospan::new(c.forward.into(), c.backward.into())
}
}
impl From<HomotopyRewrite> for Rewrite {
fn from(r: HomotopyRewrite) -> Self {
match r {
HomotopyRewrite::Rewrite0(None) => Rewrite::Identity,
HomotopyRewrite::Rewrite0(Some(content)) => {
let (source, target) = match content {
HomotopyRewrite0Content::NonTrivial(s, t, _label) => (s, t),
HomotopyRewrite0Content::NoLabel(s, t) => (s, t),
};
Rewrite::Rewrite0 {
source: source.into(),
target: target.into(),
}
}
HomotopyRewrite::RewriteN(inner) => {
Rewrite::RewriteN(inner.into())
}
}
}
}
impl From<HomotopyRewriteN> for RewriteN {
fn from(r: HomotopyRewriteN) -> Self {
RewriteN::new(
r.dimension,
r.cones.into_iter().map(|c| c.into()).collect(),
)
}
}
impl From<HomotopyConeWithIndex> for Cone {
fn from(c: HomotopyConeWithIndex) -> Self {
let internal = c.internal;
// Use singular_slices only (one per source singular height).
// This matches homotopy-rs convention where singular_slices[i] is the
// rewrite mapping source singular height i to the target.
// We ignore regular_slices as they can be recomputed from cospan structure.
let slices: Vec<Rewrite> = internal
.singular_slices
.into_iter()
.map(|s| s.into())
.collect();
Cone::new(
c.index,
internal.source.into_iter().map(|c| c.into()).collect(),
internal.target.into(),
slices,
)
}
}
// ============================================================================
// Public API
// ============================================================================
/// Load a diagram from homotopy-rs JSON format.
///
/// This expects the JSON to be a tagged Diagram enum: {"Diagram0": ...} or {"DiagramN": ...}
///
/// # Errors
///
/// Returns an error if the JSON is malformed or doesn't match the expected format.
pub fn load_homotopy_diagram(json: &str) -> Result<Diagram, serde_json::Error> {
let homotopy_diagram: HomotopyDiagram = serde_json::from_str(json)?;
Ok(homotopy_diagram.into())
}
/// Load a DiagramN directly from homotopy-rs JSON format.
///
/// This expects the JSON to be a DiagramN directly (not wrapped in enum tag),
/// which is what homotopy-rs exports when serializing a DiagramN value.
///
/// # Errors
///
/// Returns an error if the JSON is malformed.
pub fn load_homotopy_diagram_n(json: &str) -> Result<DiagramN, String> {
let homotopy_diagram_n: HomotopyDiagramN =
serde_json::from_str(json).map_err(|e| e.to_string())?;
Ok(homotopy_diagram_n.into())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::signature::{Signature, GeneratorData};
use std::fs;
#[test]
fn test_load_scalar() {
let json = fs::read_to_string("fixtures/scalar.json")
.expect("Failed to read fixtures/scalar.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse scalar.json");
let diagram = Diagram::DiagramN(diagram_n.clone());
assert_eq!(diagram.dimension(), 2, "scalar should be dimension 2");
assert_eq!(diagram_n.cospans.len(), 1, "scalar should have 1 cospan (size=1)");
println!("scalar loaded successfully:");
println!(" dimension: {}", diagram.dimension());
println!(" cospans: {}", diagram_n.cospans.len());
}
#[test]
fn test_load_two_scalars() {
let json = fs::read_to_string("fixtures/two_scalars.json")
.expect("Failed to read fixtures/two_scalars.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse two_scalars.json");
let diagram = Diagram::DiagramN(diagram_n.clone());
assert_eq!(diagram.dimension(), 2, "two_scalars should be dimension 2");
assert_eq!(diagram_n.cospans.len(), 2, "two_scalars should have 2 cospans (size=2)");
println!("two_scalars loaded successfully:");
println!(" dimension: {}", diagram.dimension());
println!(" cospans: {}", diagram_n.cospans.len());
}
#[test]
fn test_load_half_braid() {
let json = fs::read_to_string("fixtures/half_braid.json")
.expect("Failed to read fixtures/half_braid.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse half_braid.json");
let diagram = Diagram::DiagramN(diagram_n.clone());
assert_eq!(diagram.dimension(), 3, "half_braid should be dimension 3");
assert_eq!(diagram_n.cospans.len(), 1, "half_braid should have 1 cospan (size=1)");
println!("half_braid loaded successfully:");
println!(" dimension: {}", diagram.dimension());
println!(" cospans: {}", diagram_n.cospans.len());
// Debug: inspect the structure
println!("\n source dimension: {}", diagram_n.source.dimension());
}
#[test]
fn test_half_braid_pieces() {
let json = fs::read_to_string("fixtures/half_braid.json")
.expect("Failed to read fixtures/half_braid.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse half_braid.json");
let full_diagram = Diagram::DiagramN(diagram_n);
let pieces = full_diagram.pieces();
println!("half_braid pieces analysis:");
println!(" pieces count: {}", pieces.len());
for (i, piece) in pieces.iter().enumerate() {
println!(" piece {}: {:?}", i, piece);
}
// Don't assert yet - just observe what we get
if pieces.len() != 2 {
println!("\n WARNING: Expected 2 pieces, got {}.", pieces.len());
}
}
#[test]
fn test_half_braid_structure() {
let json = fs::read_to_string("fixtures/half_braid.json")
.expect("Failed to read fixtures/half_braid.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse half_braid.json");
let braiding = Diagram::DiagramN(diagram_n);
if let Diagram::DiagramN(d) = &braiding {
println!("=== HALF_BRAID STRUCTURE ===");
println!("braiding source dim: {}", d.source.dimension());
println!("braiding source length: {}", d.source.length());
println!("braiding cospans: {}", d.cospans.len());
if let Some(s0) = d.singular_slice(0) {
println!("singular slice 0 dim: {}", s0.dimension());
println!("singular slice 0 length: {}", s0.length());
} else {
println!("singular slice 0: None");
}
// Print cone details
for (i, cospan) in d.cospans.iter().enumerate() {
println!("\ncospan {}:", i);
println!(" forward: {:?}", cospan.forward);
println!(" backward: {:?}", cospan.backward);
if let Rewrite::RewriteN(rn) = &cospan.forward {
println!(" forward is RewriteN with {} cones", rn.cones.len());
for (j, cone) in rn.cones.iter().enumerate() {
println!(" cone {}: index={}, source_len={}, slices_len={}",
j, cone.index, cone.source.len(), cone.slices.len());
}
}
if let Rewrite::RewriteN(rn) = &cospan.backward {
println!(" backward is RewriteN with {} cones", rn.cones.len());
for (j, cone) in rn.cones.iter().enumerate() {
println!(" cone {}: index={}, source_len={}, slices_len={}",
j, cone.index, cone.source.len(), cone.slices.len());
}
}
}
}
}
/// THE ECKMANN-HILTON TEST
///
/// This is the critical test that validates the import infrastructure.
/// The half_braid is a 3-cell representing the Eckmann-Hilton braiding
/// of two scalars (2-cells with trivial source/target).
///
/// If this passes, it means:
/// 1. The homotopy-rs JSON format is correctly deserialized
/// 2. The slice convention is correct (singular_slices only)
/// 3. The diagram structure is well-formed
#[test]
fn test_eckmann_hilton_type_check() {
let json = fs::read_to_string("fixtures/half_braid.json")
.expect("Failed to read fixtures/half_braid.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse half_braid.json");
let braiding = Diagram::DiagramN(diagram_n);
// Build signature with:
// - Generator 0: dimension 0 (the point/object)
// - Generator 1: dimension 2 (the scalar 2-cell)
let mut sig = Signature::new();
// 0-cell: the unique object (point)
sig.add(GeneratorData::zero_cell(0, Some("*".to_string())));
// 2-cell: the scalar (source and target are both the identity on the point)
// For a scalar, source = target = identity 1-diagram on the point
let point = Diagram::Diagram0(Generator::new(0, 0, false));
let identity_1d = Diagram::DiagramN(crate::diagram::DiagramN::identity(point.clone()));
sig.add(GeneratorData::n_cell(
1, // id
2, // dimension
identity_1d.clone(), // source: identity on point
identity_1d, // target: identity on point
false, // not invertible
Some("α".to_string()),
));
println!("\n=== ECKMANN-HILTON TEST ===");
println!("Diagram dimension: {}", braiding.dimension());
println!("Diagram length: {}", braiding.length());
// Note: The current is_globular() check requires source == target,
// which is false for the Eckmann-Hilton braiding (source = α·β, target = β·α).
// This is correct behavior for a non-trivial higher cell.
println!("is_globular: {}", braiding.is_globular());
println!("(Expected false: source α·β ≠ target β·α)");
// Extract pieces (singular content)
let pieces = braiding.pieces();
println!("\nSingular content pieces: {}", pieces.len());
for (i, piece) in pieces.iter().enumerate() {
println!(" piece {}: dim={}, path={:?}",
i, piece.diagram.dimension(), piece.path);
// Check each piece is in the signature
if let Diagram::Diagram0(g) = &piece.diagram {
let in_sig = sig.contains(g.id);
println!(" generator id={}, dim={}, in_signature={}",
g.id, g.dimension, in_sig);
assert!(in_sig, "Generator {} not in signature", g.id);
}
}
// Verify the structure is as expected for Eckmann-Hilton:
// - Dimension 3 diagram
// - Source and target are dimension 2
// - Should have 2 scalar pieces (the two 2-cells being braided)
assert_eq!(braiding.dimension(), 3, "braiding should be dimension 3");
if let Diagram::DiagramN(d) = &braiding {
println!("\nSource dimension: {}", d.source.dimension());
println!("Source length: {}", d.source.length());
println!("Target dimension: {}", d.target().dimension());
println!("Target length: {}", d.target().length());
// Source should be a 2-diagram with 2 cospans (α·β vertical composite)
assert_eq!(d.source.dimension(), 2, "source should be dimension 2");
// For two scalars vertically composed, the 2-diagram has length 2
// (each scalar contributes one cospan in the vertical direction)
if let Diagram::DiagramN(src) = &*d.source {
println!("Source cospans: {}", src.cospans.len());
}
}
println!("\n=== ECKMANN-HILTON TEST PASSED ===");
println!("The half_braid diagram was successfully loaded and validated.");
}
/// THE REAL TEST: Normalise the half_braid 3-cell
///
/// This test calls normalise() on actual 3-dimensional data from homotopy-rs.
/// The half_braid is the Eckmann-Hilton braiding - a non-trivial 3-cell.
/// Normalisation should preserve its structure (it's already minimal).
#[test]
fn test_eckmann_hilton_normalise() {
use crate::normalise::normalise;
let json = fs::read_to_string("fixtures/half_braid.json")
.expect("Failed to read fixtures/half_braid.json");
let diagram_n = load_homotopy_diagram_n(&json)
.expect("Failed to parse half_braid.json");
let braiding = Diagram::DiagramN(diagram_n);
println!("\n=== ECKMANN-HILTON NORMALISATION TEST ===");
println!("BEFORE normalisation:");
println!(" dimension: {}", braiding.dimension());
println!(" length: {}", braiding.length());
if let Diagram::DiagramN(d) = &braiding {
println!(" source dimension: {}", d.source.dimension());
println!(" source length: {}", d.source.length());
}
// THE REAL TEST: Call normalise on 3-dimensional data
let result = normalise(&braiding);
println!("\nAFTER normalisation:");
println!(" dimension: {}", result.normal_form.dimension());
println!(" length: {}", result.normal_form.length());
println!(" degeneracy is identity: {}", result.degeneracy.is_identity());
if let Diagram::DiagramN(d) = &result.normal_form {
println!(" source dimension: {}", d.source.dimension());
println!(" source length: {}", d.source.length());
// Check singular slices
for i in 0..d.length() {
if let Some(slice) = d.singular_slice(i) {
println!(" singular_slice({}): dim={}, len={}",
i, slice.dimension(), slice.length());
}
}
}
// Verify normalisation preserves dimension
assert_eq!(
result.normal_form.dimension(), 3,
"Normalised form should still be dimension 3"
);
// The half_braid is a single braiding operation (length 1 at dim 3)
// It should remain length 1 (or possibly 0 if it's an identity, but it's not)
println!("\nNormalised length at dim 3: {}", result.normal_form.length());
// Extract pieces from normalised form
let pieces = result.normal_form.pieces();
println!("Pieces in normalised form: {}", pieces.len());
for (i, piece) in pieces.iter().enumerate() {
println!(" piece {}: path={:?}, dim={}", i, piece.path, piece.diagram.dimension());
if let Diagram::Diagram0(g) = &piece.diagram {
println!(" generator id={}, dim={}", g.id, g.dimension);
}
}
println!("\n=== ECKMANN-HILTON NORMALISATION TEST PASSED ===");
}
#[test]
fn test_normalise_removes_identity_at_dim3() {
use crate::normalise::normalise;
let json = std::fs::read_to_string("fixtures/padded_3d.json").unwrap();
let padded = load_homotopy_diagram_n(&json).unwrap();
let padded = Diagram::DiagramN(padded);
assert_eq!(padded.dimension(), 3);
assert_eq!(padded.length(), 2, "Padded diagram should have length 2");
eprintln!("=== BEFORE NORMALISATION ===");
eprintln!("dim: {}, length: {}", padded.dimension(), padded.length());
if let Diagram::DiagramN(d) = &padded {
for (i, c) in d.cospans.iter().enumerate() {
eprintln!("cospan {}: forward.is_trivial={}, backward.is_trivial={}, is_identity={}",
i, c.forward.is_trivial(), c.backward.is_trivial(), c.is_identity());
}
}
let result = normalise(&padded);
eprintln!("=== AFTER NORMALISATION ===");
eprintln!("dim: {}, length: {}", result.normal_form.dimension(), result.normal_form.length());
eprintln!("degeneracy is identity: {}", result.degeneracy.is_identity());
if let Diagram::DiagramN(d) = &result.normal_form {
for (i, c) in d.cospans.iter().enumerate() {
eprintln!("cospan {}: forward.is_identity={}, backward.is_identity={}, is_identity={}",
i, c.forward.is_identity(), c.backward.is_identity(), c.is_identity());
}
}
assert_eq!(result.normal_form.dimension(), 3, "Dimension must be preserved");
assert_eq!(result.normal_form.length(), 1,
"Identity cospan should be removed, reducing length from 2 to 1");
assert!(!result.degeneracy.is_identity(),
"Degeneracy should be non-trivial (it records the removal)");
let result2 = normalise(&result.normal_form);
assert_eq!(result.normal_form, result2.normal_form, "Must be idempotent");
eprintln!("=== PASSED: normalise() actively removes identity at dim 3 ===");
}
/// Test normalisation at dimension 4 with genuine 4-cell structure.
///
/// The padded_lips_4d.json is the "lips" 4-cell from homotopy-rs with an
/// identity cospan appended. This exercises four levels of recursive descent
/// through Construction 17 on real homotopy-rs data.
#[test]
fn test_normalise_removes_identity_at_dim4() {
use crate::normalise::normalise;
let json = std::fs::read_to_string("fixtures/padded_lips_4d.json")
.expect("Failed to read fixtures/padded_lips_4d.json");
let padded_lips = load_homotopy_diagram_n(&json)
.expect("Failed to parse padded_lips_4d.json");
let padded_lips = Diagram::DiagramN(padded_lips);
assert_eq!(padded_lips.dimension(), 4, "Should be dimension 4");
assert_eq!(padded_lips.length(), 2, "Should have 2 cospans (lips + identity)");
eprintln!("=== DIMENSION 4 NORMALISATION TEST ===");
eprintln!("BEFORE:");
eprintln!(" dim: {}, length: {}", padded_lips.dimension(), padded_lips.length());
if let Diagram::DiagramN(d) = &padded_lips {
eprintln!(" source dim: {}", d.source.dimension());
for (i, c) in d.cospans.iter().enumerate() {
eprintln!(" cospan {}: is_identity={}", i, c.is_identity());
}
}
// THE MOMENT OF TRUTH: Four levels of recursive descent
eprintln!("\nCalling normalise()...");
let result = normalise(&padded_lips);
eprintln!("\nAFTER:");
eprintln!(" dim: {}, length: {}", result.normal_form.dimension(), result.normal_form.length());
eprintln!(" degeneracy is identity: {}", result.degeneracy.is_identity());
if let Diagram::DiagramN(d) = &result.normal_form {
eprintln!(" source dim: {}", d.source.dimension());
for (i, c) in d.cospans.iter().enumerate() {
eprintln!(" cospan {}: is_identity={}", i, c.is_identity());
}
}
// Verify normalisation worked
assert_eq!(result.normal_form.dimension(), 4, "Dimension must be preserved");
assert_eq!(result.normal_form.length(), 1,
"Identity cospan should be removed, reducing length from 2 to 1");
assert!(!result.degeneracy.is_identity(),
"Degeneracy should be non-trivial (it records the removal)");
// Verify idempotence
let result2 = normalise(&result.normal_form);
assert_eq!(result.normal_form, result2.normal_form, "Must be idempotent");
assert!(result2.degeneracy.is_identity(),
"Second normalisation should have identity degeneracy");
eprintln!("\n=== PASSED: normalise() works at dimension 4 ===");
}
/// Test normalisation at dimension 5.
///
/// The identity_wrapped_5d.json is:
/// - Source: padded_lips_4d (dim 4, size 2 with identity cospan)
/// - One identity cospan at dim 5
///
/// This tests 5 levels of recursive descent.
#[test]
fn test_normalise_at_dim5() {
use crate::normalise::normalise;
let json = std::fs::read_to_string("fixtures/identity_wrapped_5d.json")
.expect("Failed to read fixtures/identity_wrapped_5d.json");
let diagram = load_homotopy_diagram_n(&json)
.expect("Failed to parse identity_wrapped_5d.json");
let diagram = Diagram::DiagramN(diagram);
assert_eq!(diagram.dimension(), 5, "Should be dimension 5");
assert_eq!(diagram.length(), 1, "Should have 1 cospan at top level");
eprintln!("=== DIMENSION 5 NORMALISATION TEST ===");
eprintln!("BEFORE:");
eprintln!(" dim: {}, length: {}", diagram.dimension(), diagram.length());
if let Diagram::DiagramN(d) = &diagram {
eprintln!(" source dim: {}, length: {}", d.source.dimension(), d.source.length());
for (i, c) in d.cospans.iter().enumerate() {
eprintln!(" cospan {}: is_identity={}", i, c.is_identity());
}
}
eprintln!("\nCalling normalise()...");
let result = normalise(&diagram);
eprintln!("\nAFTER:");
eprintln!(" dim: {}, length: {}", result.normal_form.dimension(), result.normal_form.length());
eprintln!(" degeneracy is identity: {}", result.degeneracy.is_identity());
if let Diagram::DiagramN(d) = &result.normal_form {
eprintln!(" source dim: {}, length: {}", d.source.dimension(), d.source.length());
}
// Dimension must be preserved
assert_eq!(result.normal_form.dimension(), 5, "Dimension must be preserved");
// The top-level identity cospan should be removed (length 1 -> 0)
// OR the source's identity cospan should be removed
// Either way, something should change
eprintln!(" normal_form length: {}", result.normal_form.length());
// Verify idempotence
let result2 = normalise(&result.normal_form);
assert_eq!(result.normal_form, result2.normal_form, "Must be idempotent");
eprintln!("\n=== PASSED: normalise() works at dimension 5 ===");
}
/// Test normalisation at dimension 6.
///
/// The identity_wrapped_6d.json is:
/// - Source: identity_wrapped_5d (dim 5, size 1)
/// - One identity cospan at dim 6
///
/// This tests 6 levels of recursive descent.
#[test]
fn test_normalise_at_dim6() {
use crate::normalise::normalise;
let json = std::fs::read_to_string("fixtures/identity_wrapped_6d.json")
.expect("Failed to read fixtures/identity_wrapped_6d.json");
let diagram = load_homotopy_diagram_n(&json)
.expect("Failed to parse identity_wrapped_6d.json");
let diagram = Diagram::DiagramN(diagram);
assert_eq!(diagram.dimension(), 6, "Should be dimension 6");
assert_eq!(diagram.length(), 1, "Should have 1 cospan at top level");
eprintln!("=== DIMENSION 6 NORMALISATION TEST ===");
eprintln!("BEFORE:");
eprintln!(" dim: {}, length: {}", diagram.dimension(), diagram.length());
if let Diagram::DiagramN(d) = &diagram {
eprintln!(" source dim: {}, length: {}", d.source.dimension(), d.source.length());
for (i, c) in d.cospans.iter().enumerate() {
eprintln!(" cospan {}: is_identity={}", i, c.is_identity());
}
}
eprintln!("\nCalling normalise()...");
let result = normalise(&diagram);
eprintln!("\nAFTER:");
eprintln!(" dim: {}, length: {}", result.normal_form.dimension(), result.normal_form.length());
eprintln!(" degeneracy is identity: {}", result.degeneracy.is_identity());
if let Diagram::DiagramN(d) = &result.normal_form {
eprintln!(" source dim: {}, length: {}", d.source.dimension(), d.source.length());
}
// Dimension must be preserved
assert_eq!(result.normal_form.dimension(), 6, "Dimension must be preserved");
eprintln!(" normal_form length: {}", result.normal_form.length());
// Verify idempotence
let result2 = normalise(&result.normal_form);
assert_eq!(result.normal_form, result2.normal_form, "Must be idempotent");
eprintln!("\n=== PASSED: normalise() works at dimension 6 ===");
}
/// Test potential essential identity at dimension 4.
///
/// The contracted_with_inner_identity.json is a dim-4 diagram where:
/// - Top level: 1 non-trivial cospan (contraction result)
/// - Source (dim 3): 2 cospans, where cospan[1] is identity
///
/// The identity cospan at dim 3 might be ESSENTIAL because it's
/// referenced by the dim-4 rewrite structure. If normalise() removes it,
/// that would be incorrect. If it preserves it, we've validated
/// essential identity detection.
#[test]
fn test_potential_essential_identity_dim4() {
use crate::normalise::normalise;
let json = std::fs::read_to_string("fixtures/contracted_with_inner_identity.json")
.expect("Failed to read fixtures/contracted_with_inner_identity.json");
let diagram = load_homotopy_diagram_n(&json)
.expect("Failed to parse contracted_with_inner_identity.json");
let diagram = Diagram::DiagramN(diagram);
assert_eq!(diagram.dimension(), 4, "Should be dimension 4");
eprintln!("=== POTENTIAL ESSENTIAL IDENTITY TEST (dim 4) ===");
eprintln!("BEFORE:");
eprintln!(" dim: {}, length: {}", diagram.dimension(), diagram.length());
if let Diagram::DiagramN(d) = &diagram {
eprintln!(" source dim: {}, length: {}", d.source.dimension(), d.source.length());
// Check for identity cospans in source
if let Diagram::DiagramN(src) = &*d.source {
for (i, c) in src.cospans.iter().enumerate() {
eprintln!(" source.cospan[{}]: is_identity={}", i, c.is_identity());
}
}
}
eprintln!("\nCalling normalise()...");
let result = normalise(&diagram);
eprintln!("\nAFTER:");
eprintln!(" dim: {}, length: {}", result.normal_form.dimension(), result.normal_form.length());
eprintln!(" degeneracy is identity: {}", result.degeneracy.is_identity());
if let Diagram::DiagramN(d) = &result.normal_form {
eprintln!(" source dim: {}, length: {}", d.source.dimension(), d.source.length());
// Check if identity cospan was preserved or removed
if let Diagram::DiagramN(src) = &*d.source {
for (i, c) in src.cospans.iter().enumerate() {
eprintln!(" source.cospan[{}]: is_identity={}", i, c.is_identity());
}
// Count identity cospans
let id_count = src.cospans.iter().filter(|c| c.is_identity()).count();
eprintln!(" identity cospans in source: {}", id_count);
if id_count > 0 {
eprintln!("\n >>> Identity cospan PRESERVED (potentially essential)");
} else {
eprintln!("\n >>> Identity cospan REMOVED");
}
}
}
// Dimension must be preserved
assert_eq!(result.normal_form.dimension(), 4, "Dimension must be preserved");
// Verify idempotence
let result2 = normalise(&result.normal_form);
assert_eq!(result.normal_form, result2.normal_form, "Must be idempotent");
eprintln!("\n=== TEST COMPLETED (check output above for essential identity status) ===");
}
}

View file

@ -36,6 +36,7 @@ pub mod normalise;
pub mod typecheck;
pub mod explosion;
pub mod layout;
pub mod import;
// Re-exports for convenience
pub use monotone::MonotoneMap;

View file

@ -431,27 +431,39 @@ fn build_parallel_degeneracy(
}
/// Assemble factorisations from the slice normalisations.
///
/// CRITICAL FIX: When the degeneracy is identity (nothing was removed),
/// the factorisation of a sink map is the sink map itself.
/// When there is a non-trivial degeneracy, we need to compose properly.
fn assemble_factorisations(
sink_maps: &[DiagramMap],
regular_results: &[RegularNormalisation],
_singular_results: &[SingularNormalisation],
singular_results: &[SingularNormalisation],
) -> Vec<DiagramMap> {
// Check if all slice degeneracies are identity (nothing changed)
let all_regular_identity = regular_results.iter().all(|r| r.degeneracy.is_identity());
let all_singular_identity = singular_results.iter().all(|s| s.degeneracy.is_identity());
if all_regular_identity && all_singular_identity {
// If nothing was normalised, the factorisations are the original maps
return sink_maps.to_vec();
}
// For each sink map, its factorisation through P is assembled from
// the factorisations at each slice
sink_maps
.iter()
.enumerate()
.map(|(i, _sink_map)| {
// The factorisation uses the factorisations from regular slices
if regular_results.first()
.map(|r| r.factorisations.get(i))
.flatten()
.is_some()
{
regular_results[0].factorisations[i].clone()
} else {
DiagramMap::new(Rewrite::Identity)
.map(|(i, sink_map)| {
// Try to get factorisation from regular slices
if let Some(first_regular) = regular_results.first() {
if let Some(factorisation) = first_regular.factorisations.get(i) {
return factorisation.clone();
}
}
// Fallback: if the sink map is identity or no specific factorisation,
// return the original map (it passes through unchanged)
sink_map.clone()
})
.collect()
}
@ -733,23 +745,28 @@ mod tests {
fn test_normalise_preserves_essential_identity() {
// Test case for essential identities (dimension >= 4 scenario)
// In this simplified test, we create a situation where an identity
// cospan is in the image of a sink map, making it essential
// cospan is in the image of a sink map via CONTRACTION, making it essential.
//
// Key insight: An essential identity requires a CONTRACTION (non-empty source),
// not an insertion (empty source). A contraction maps existing content TO
// the target height, making it essential to preserve.
let g = Generator::point(0);
let d0 = Diagram::Diagram0(g);
let d0 = Diagram::Diagram0(g.clone());
// Create a diagram with identity cospan
// Create target diagram with identity cospan (length 1)
let identity_cospan = Cospan::new(Rewrite::Identity, Rewrite::Identity);
let d1 = Diagram::DiagramN(DiagramN::new(d0.clone(), vec![identity_cospan]));
let d1 = Diagram::DiagramN(DiagramN::new(d0.clone(), vec![identity_cospan.clone()]));
// Create a sink map that maps to this singular height
// This makes the identity cospan essential
// Create a sink map that CONTRACTS to height 0 (non-empty source).
// This represents a map from a length-2 diagram to d1 (length 1).
// The contraction maps two cospans to one, putting height 0 in the image.
let sink_map = DiagramMap::new(Rewrite::RewriteN(RewriteN {
dimension: 1,
cones: vec![Cone::new(
0, // Maps to singular height 0
vec![],
Cospan::new(Rewrite::Identity, Rewrite::Identity),
vec![],
0, // Maps to singular height 0 in target
vec![identity_cospan.clone(), identity_cospan.clone()], // NON-EMPTY source: contraction!
identity_cospan, // Target cospan
vec![Rewrite::Identity], // One interior boundary
)],
}));
@ -757,6 +774,7 @@ mod tests {
let result = normalise_sink(&sink);
// The identity cospan should be preserved because it's in the sink image
// (the contraction maps to height 0)
assert_eq!(result.normal_form.length(), 1);
}

3557
tests/integration_tests.rs Normal file

File diff suppressed because it is too large Load diff