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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
29 changes: 26 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,36 @@ concurrency:

jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
DAGRO_DAGRE_JS: ${{ github.workspace }}/testdata/differential/node_modules/@dagrejs/dagre/dist/dagre.cjs
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.19.0
cache: npm
cache-dependency-path: testdata/differential/package-lock.json
- run: npm ci --ignore-scripts
working-directory: testdata/differential
- name: Build source-pinned D2 compatibility oracle
run: ./testdata/differential/build-dagre-3.1.1-d2-compat.sh /tmp/dagre-3.1.1-d2-compat.cjs
- name: Compare deterministic D2-profile layouts to compatibility oracle
env:
DAGRO_DAGRE_JS_COMPAT: /tmp/dagre-3.1.1-d2-compat.cjs
run: go test -run '^TestD2ProfileRandomLayoutsMatchCompatibilityOracle$' -count=1 -timeout=5m .
- run: go test ./...
- run: go test -race ./...
- run: go vet ./...
- run: go build ./...
- run: GOOS=js GOARCH=wasm go build ./...
- name: Execute deterministic D2-profile layouts in WASM
run: >-
GOOS=js GOARCH=wasm go test
-exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec"
-run '^TestD2ProfileRandomLayoutsAreFinite$'
-count=1 -timeout=5m .
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
coverage.out
testdata/differential/node_modules/
43 changes: 33 additions & 10 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
Dagro is a Go port of Dagre 0.8.5 and the subset of Graphlib 2.1.8 required by
Dagre's layout implementation.
Dagro is a native Go implementation of the D2-used default layout profile from
Dagre 3.1.1 and the subset of Graphlib 4.0.5 required by that profile.

Compatibility sources:
Behavioral sources:

Dagre 0.8.5
@dagrejs/dagre 3.1.1
https://github.com/dagrejs/dagre
commit f56edb1abbb8530e532158f7cbd403228f5b0018
commit c3ed0802cd98de74c21cff1f754689ebbb0f8dae
npm integrity sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==

Graphlib 2.1.8
@dagrejs/graphlib 4.0.5
https://github.com/dagrejs/graphlib
commit 64375bb8d96bce0d906d238853c2b5afa2f2c231
commit d3a0cf36f55ebd75f28b6acf7a436a54e1b990dc
npm integrity sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==

Both upstream projects are licensed under the MIT License and carry the
copyright notice "Copyright (c) 2012-2014 Chris Pettitt". The repository's
LICENSE preserves that notice and license text.

Dagre 0.8.5 also depends on Lodash 4.17.15. Dagro does not include Lodash
source; Go helpers reproduce only the observable collection and numeric
semantics required by the port.
Dagro does not bundle the JavaScript packages. Official packages are installed
only for tests, and the patched compatibility oracle is reproducibly generated
from the pinned source commit during tests. Dagro intentionally covers the
D2-used surface rather than all features or APIs of either upstream project.

The JSON under testdata/differential/d2-corpus and the first three named
compatibility cases is derived from D2 E2E fixtures captured at commit
1a60d69e4df9b9557923e61bf10f9aa3aa5422e1 and retains D2's copyright and
Mozilla Public License 2.0 coverage. D2 is Copyright 2022 Terrastruct Inc. See
testdata/differential/D2-LICENSE.txt and D2-CORPUS-NOTICE.md. Their expected
JSON additionally contains generated layout geometry from the pinned MIT
oracle or documented compatibility patch. The synthetic fourth compatibility
case is generated under Dagro's MIT license.

Three narrow source-level compatibility corrections are represented by
testdata/differential/dagre-3.1.1-d2-compat.patch (SHA-256
d510c474e1f291c38c14c276f6bc498dbbd0dc7132e3b9694e31b47650356d19):
parallel-edge special handling requires exactly one reversed edge, all
opposite-direction partners for the same forward dummy are retained, and
zero-size rectangle intersections remain finite. The generated test-only
oracle has SHA-256
8e34c25ed53dbccca2fa206780b0b46974b285c74e0cd7b34d0d1fafa5506cab.
Dagro also collision-checks generated reversed-edge names to preserve
caller-owned named multiedges.
92 changes: 64 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
# Dagro

Dagro is a native Go port of [Dagre](https://github.com/dagrejs/dagre), the
directed graph layout engine. It intentionally replicates Dagre **0.8.5** and
the Graphlib **2.1.8** behavior used by that release, including layout order,
compound graphs, named multiedges, self-loops, and edge-label routing.
Dagro is a native Go implementation of the [Dagre](https://github.com/dagrejs/dagre)
layout behavior used by D2. Its behavioral source target is Dagre **3.1.1** and
the D2-used subset of Graphlib **4.0.5**. Dagro has no JavaScript runtime or
third-party Go dependency in production.

Dagro targets the exact Dagre 0.8.5 behavior embedded by D2. Later Dagre
releases retain the same broad layout pipeline but include behavior-changing
ordering, positioning, compound-layout, API, and dependency changes, so
compatibility is measured against 0.8.5.
The compatibility boundary is D2's default Dagre adapter profile:

- directed, compound, named-multiedge graphs;
- graph `rankdir`, `nodesep`, `edgesep`, and `ranksep` attributes;
- node IDs, parent relationships, width, and height;
- named edge endpoints, width, height, and `labelpos`;
- graph bounds, node coordinates, edge-label coordinates, and ordered routes.

The public graph operations already exposed by Dagro remain available. This is
not a claim to implement every Dagre 3.1.1 or Graphlib 4.0.5 feature. In
particular, dynamic remembered layout state, per-cluster direction, manual or
custom ranking and ordering, constraints, and the `rankalign` option are
outside the verified D2 profile.

## Usage

Expand Down Expand Up @@ -45,38 +54,65 @@ func main() {

The API uses `Attrs` maps because Dagre and Graphlib labels are open JavaScript
objects. Recognized numeric layout attributes accept Go numeric types and are
coerced to `float64` in the internal layout graph for JavaScript `Number`
compatibility; arbitrary label values are preserved as supplied.
coerced to `float64` for JavaScript `Number` compatibility; arbitrary label
values are preserved.

## Compatibility and tests

The Go source follows the Dagre 0.8.5 module boundaries:
The checked-in D2 corpus contains 311 unique layouts captured at D2 commit
`1a60d69e4df9b9557923e61bf10f9aa3aa5422e1`:

- 308 finite outputs match official Dagre 3.1.1 bit-for-bit after the D2 JSON
bridge normalization;
- three named layouts use a pinned compatibility result because official
Dagre 3.1.1 either emits non-finite geometry or throws.

- cycle removal and greedy feedback-arc selection;
- compound nesting, normalization, and dummy-chain parenting;
- longest-path, tight-tree, and network-simplex ranking;
- weighted crossing minimization;
- Brandes-Köpf coordinate assignment;
- self-edge, label, border, direction, and translation passes.
The compatibility correction only pairs parallel dummy nodes when exactly one
edge is reversed, preserves every reversed partner when several map to the
same forward dummy, and keeps degenerate zero-size rectangle intersections
finite. Dagro also collision-checks generated reversed-edge names so a caller
edge such as `rev1` cannot be overwritten. The first three changes are
recorded as a reviewable source patch in
`testdata/differential/dagre-3.1.1-d2-compat.patch`.

The normal suite contains direct Go ports of the upstream tests. An optional
differential suite replays ordered fixtures through both implementations and
compares topology, point order, attribute presence, and all numeric output:
A fourth synthetic compatibility case minimizes the multiple-reversed-partner
failure using only D2-profile inputs. It is separate from the 311 captured
layouts and pins the finite result of the source patch.

The normal Go suite runs the complete 311-input corpus. CI additionally
installs the exact JavaScript oracle from `package-lock.json`, checks ordinary
fixtures against official Dagre 3.1.1 using exact `float64` bits, and verifies
the three captured upstream failures plus the minimized synthetic divergence:

```sh
DAGRO_DAGRE_JS=/absolute/path/to/dagre-0.8.5.js go test ./...
cd testdata/differential
npm ci --ignore-scripts
cd ../..
DAGRO_DAGRE_JS="$PWD/testdata/differential/node_modules/@dagrejs/dagre/dist/dagre.cjs" go test ./...

./testdata/differential/build-dagre-3.1.1-d2-compat.sh /tmp/dagre-3.1.1-d2-compat.cjs
DAGRO_DAGRE_JS_COMPAT=/tmp/dagre-3.1.1-d2-compat.cjs \
go test -run '^TestD2ProfileRandomLayoutsMatchCompatibilityOracle$' -count=1 .
```

The differential test uses `node` only as a test oracle. Dagro itself has no
JavaScript runtime or third-party Go dependencies.
The compatibility builder fetches the exact upstream commit, verifies its
lockfile and the source patch, builds with the upstream-pinned toolchain, and
checks the final CommonJS SHA-256 before writing it. No JavaScript bundle is
checked into Dagro or used in production.

See [UPSTREAM.md](UPSTREAM.md) for source pins, hashes, the port map, and the
precise compatibility boundary.

## Versioning

`Version` reports the replicated Dagre version (`0.8.5`). Until the first
tagged release, consumers developing Dagro and D2 together can use a Go
workspace or a temporary local `replace` directive.
`Version` identifies the Dagre behavioral source (`3.1.1`) and
`GraphlibVersion` identifies the Graphlib behavioral source (`4.0.5`). They do
not expand the verified API surface beyond the D2 profile above.

## License

MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE) for upstream attribution and
the exact compatibility revisions.
Dagro source is MIT. D2-derived corpus JSON retains D2's MPL-2.0 coverage;
expected files additionally contain generated layout geometry from the MIT
oracle.
See [LICENSE](LICENSE), [NOTICE](NOTICE), and
[`testdata/differential/D2-CORPUS-NOTICE.md`](testdata/differential/D2-CORPUS-NOTICE.md).
118 changes: 118 additions & 0 deletions UPSTREAM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Upstream provenance and compatibility boundary

Dagro implements the default Dagre layout path used by D2. The version
constants identify behavioral source revisions, not complete API coverage.

## Pinned sources

| Source | Revision | Test artifact |
| --- | --- | --- |
| `@dagrejs/dagre` 3.1.1 | `c3ed0802cd98de74c21cff1f754689ebbb0f8dae` | npm integrity `sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==`; `dist/dagre.cjs` SHA-256 `70b9a4367932dd436075d98892a7968d65cf66ae83263f995e0531823b59b671` |
| `@dagrejs/graphlib` 4.0.5 | `d3a0cf36f55ebd75f28b6acf7a436a54e1b990dc` | npm integrity `sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==`; `dist/graphlib.cjs` SHA-256 `271f39d50dbcf2f795808cb4f5b90fb42a096b5f84b4dd6bb672487b454011e7` |

The test-only `package.json` and lockfile pin those exact packages. CI pins
Node 24.19.0 and uses `npm ci --ignore-scripts`; neither JavaScript package is
a production dependency.

The compatibility oracle is rebuilt from the exact Dagre commit by
`testdata/differential/build-dagre-3.1.1-d2-compat.sh`. The builder verifies
upstream `package-lock.json` SHA-256
`9f5e1e7a40667dcffc12e35ea5d4db96f346dadf943e97c1ecc2b4dc21afbb2d`,
applies the pinned source patch, runs the upstream build with esbuild 0.27.3,
tsx 4.21.0, TypeScript 5.9.3, and Graphlib 4.0.5, and verifies patched
`dist/dagre.js` SHA-256
`9b91fccee8e70a74299cf47eaf8100c46a900fb1f334a11424ff3682c1019585`.
The generated test-only CommonJS oracle has SHA-256
`8e34c25ed53dbccca2fa206780b0b46974b285c74e0cd7b34d0d1fafa5506cab`;
it is generated in CI and is not checked in or shipped.

## Source map

| Upstream source | Dagro implementation |
| --- | --- |
| `lib/layout.ts`, `lib/coordinate-system.ts`, `lib/position/index.ts` | `layout.go`, `coordinate_system.go`, `position.go` |
| `lib/acyclic.ts`, `lib/greedy-fas.ts` | `acyclic.go`, `greedy_fas.go` |
| `lib/nesting-graph.ts`, `lib/normalize.ts`, `lib/parent-dummy-chains.ts`, `lib/add-border-segments.ts` | `nesting_graph.go`, `normalize.go`, `parent_dummy_chains.go`, `add_border_segments.go` |
| `lib/rank/*.ts` | `rank.go`, `rank_util.go`, `feasible_tree.go`, `network_simplex.go` |
| `lib/order/*.ts` | `order.go` and `order_*.go` |
| `lib/position/bk.ts` | `position_bk.go` |
| `lib/util.ts`, `lib/data/list.ts` | `util.go`, `numeric_semantics.go`, `list.go` |
| Graphlib graph operations used by D2 and Dagre's default path | `graph.go`, `callback.go`, and ordered collection helpers |

## Verified D2 profile

The release contract is the directed, compound, named-multiedge profile used
by D2: graph `rankdir`, `nodesep`, `edgesep`, and `ranksep`; node IDs,
parentage, width, and height; and named edges with width, height, and
`labelpos`. The observable outputs are graph bounds, node and edge-label
coordinates, and ordered edge points.

The checked corpus was captured from D2 commit
`1a60d69e4df9b9557923e61bf10f9aa3aa5422e1`. Across 313 D2 Dagre E2E cases,
270 invoked the adapter, producing 349 calls and 311 unique layout inputs.
Official Dagre 3.1.1 returned for 309 inputs, but only 308 results were fully
finite. The exact expected-output partition is therefore:

- 308 bit-for-bit official 3.1.1 outputs after D2's JSON bridge
normalization;
- three named compatibility outputs: one replaces a successful but
non-finite official result, and two replace official errors.

The 311-input corpus and the first three named compatibility cases are derived
from D2's MPL-2.0 E2E fixtures and retain D2's copyright and license coverage;
see `testdata/differential/D2-CORPUS-NOTICE.md` and `D2-LICENSE.txt`. Their
expected JSON additionally contains generated layout geometry from the pinned
MIT oracle or documented compatibility patch. The synthetic fourth case is
generated under Dagro's MIT license. Sources and hashes are recorded in the
manifests.

The compatibility cases are:

| D2 case | Content-addressed input ID | Official behavior |
| --- | --- | --- |
| `regression/overlapping-edge-label/dagre` | `7cfd90e29056db3a1a4d2b45690869ff537734eb5d809ab5f1bb832e59a0bc67` | returns non-finite geometry |
| `txtar/theme-overrides/dagre` | `e052b4c21cba3edb2df9b001d3f64058bf66eb4b30aeec321afa063278915d88` | throws during rectangle intersection |
| `stable/us_map/dagre` | `e2cfd977b7a3bf293fced2080851d9ce8e6bf5425153799b0f3efed58ac27853` | throws during rectangle intersection |
| `d2-profile/multiple-reversed-parallel-partners` | `297220aa20dc2b11460f16bd2c6387f96a4fba40497a7ce71296b45bae600cc4` | returns a finite but different layout; synthetic MIT-licensed regression that pins the composition of the two ordering corrections, not a captured D2 fixture |

`TestLayoutMatchesD2Corpus` verifies the partition, every input and expected
output hash, finiteness, and exact `float64` bits. The pinned JavaScript oracle
also checks ordinary fixtures directly and confirms all four documented
upstream behaviors rather than silently skipping them.

## Compatibility corrections

The reviewable patch
`testdata/differential/dagre-3.1.1-d2-compat.patch` has SHA-256
`d510c474e1f291c38c14c276f6bc498dbbd0dc7132e3b9694e31b47650356d19`
and applies to the pinned Dagre commit. It makes three changes:

1. The reversed-parallel-edge ordering special case applies only when exactly
one dummy represents a reversed edge. Official 3.1.1 can otherwise drop a
same-direction dummy from a group of three and propagate non-finite
geometry.
2. Every reversed partner for the same forward dummy is retained in encounter
order. With correction 1 applied, upstream's single-valued object table can
overwrite an earlier opposite-direction partner and leave a valid
D2-profile graph without coordinates.
3. A rectangle intersection at the exact center of a zero-width or
zero-height node returns the center, and axis-aligned intersections avoid
division by zero.

Dagro has one additional public-API safety correction: generated `revN` names
are collision-checked before an internal reversed edge is inserted. This
prevents a caller's named multiedge from being overwritten and removes that
collision's dependence on global dummy-ID history. Ordinary non-colliding
names preserve the official first candidate.

The raw seven-point self-loop route intentionally remains identical to Dagre
3.1.1. Any D2 curve or endpoint-chopping normalization belongs in the D2
adapter and is not hidden in Dagro.

## Out of scope

No parity claim is made for dynamic remembered layout state, per-cluster
direction, `customOrder`, ordering constraints, manual rank APIs, `rankalign`,
or other Dagre 3.x surfaces D2 does not use. Graphlib coverage is likewise the
D2-used public graph operations plus the operations required internally by
the verified layout path, not all of Graphlib 4.0.5.
17 changes: 16 additions & 1 deletion acyclic.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ func runAcyclic(g *Graph) {
g.RemoveEdge(e)
label["forwardName"] = edgeNameState{name: e.Name, present: e.HasName}
label["reversed"] = true
g.SetEdge(e.W, e.V, label, g.uniqueID("rev"))
g.SetEdge(e.W, e.V, label, nextReversedEdgeName(g, e.W, e.V))
}
}

func nextReversedEdgeName(g *Graph, v, w string) string {
for {
name := g.uniqueID("rev")
if !g.HasEdge(v, w, name) {
return name
}
}
}

Expand All @@ -39,6 +48,12 @@ func dfsFAS(g *Graph) []Edge {
}
delete(stack, v)
}
// Modern Dagre starts DFS from sources before visiting any remaining
// components. This makes cycle breaking deterministic when a source feeds
// into a strongly connected component whose nodes were inserted earlier.
for _, v := range g.Sources() {
dfs(v)
}
for _, v := range g.Nodes() {
dfs(v)
}
Expand Down
Loading