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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: CI
on:
push:
branches: [main]
tags: ['v*'] # a release tag builds the versioned docs
pull_request:

jobs:
Expand All @@ -23,3 +24,27 @@ jobs:
# pyparity/gen_fixtures.py against the Python reference package), so the
# test job needs no Python toolchain.
- uses: julia-actions/julia-runtest@v1

docs:
runs-on: ubuntu-latest
# `deploydocs` pushes the built site to the gh-pages branch; on a fork PR the
# token is read-only and it skips the push and just builds (and doctests).
permissions:
contents: write
pull-requests: read
statuses: write
steps:
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: '1'
- uses: julia-actions/cache@v2
- name: Build docs (doctests included)
run: |
julia --project=docs -e '
using Pkg
Pkg.develop(PackageSpec(path=pwd()))
Pkg.instantiate()'
julia --project=docs docs/make.jl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Manifest.toml
.DS_Store
__pycache__/
*.pyc
docs/Manifest.toml
2 changes: 1 addition & 1 deletion PORTING_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ sets `self.xp = numpy`) is excluded.

**Upstream bugs.** Four defects found in the Python reference over the course of the
port are catalogued — with reproductions, root causes and patches — in
[`docs/upstream-bugs.md`](docs/upstream-bugs.md).
[`docs/src/upstream-bugs.md`](docs/src/upstream-bugs.md).

**Harness conventions established** (also in `README.md`): 1-based indexing with
the `v .+ 1` parity convention; `NPZ.jl` cannot read zero-element arrays, so the
Expand Down
3 changes: 2 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ julia = "1.10"

[extras]
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
NPZ = "15e1cf62-19b3-5cfa-8e77-841668bca605"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test", "NPZ", "CairoMakie"]
test = ["Test", "NPZ", "CairoMakie", "Documenter"]
69 changes: 67 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ the heat diffusion of the data — no mesh, no charts, no prescribed metric.

```julia
using Pkg
Pkg.add(url="https://github.com/wardjm/DiffusionGeometryJ.jl")
Pkg.add(url="https://github.com/wardjm/DiffusionGeometryJ")
```

Plotting is provided through a package extension that loads only when a
Expand Down Expand Up @@ -119,6 +119,25 @@ wedge(α, β) # wedge product, degree k₁ + k₂ — also spelled α ^
f * α # pointwise product with a function (so is wedge(f, α))
```

Read one back with the accessors — a tensor stores *coefficients* in the diffusion
basis, not pointwise values, and `to_pointwise_basis` is what evaluates it:

```julia
coeffs(X) # the coefficient array
space(X) # the tensor space it lives in
geometry(X) # the DiffusionGeometry behind it
to_pointwise_basis(X) # values at the sample points, (batch…, n·C) — the inverse
# of the dg_* factories, up to the basis truncation
```

Tensors, spaces and the geometry itself print as a one-line summary rather than
dumping the eigenbasis:

```
VectorField(space=VectorFieldSpace(dim=48), shape=(48,), batch_shape=())
DiffusionGeometry(n=120, ambient_dim=3, n_function_basis=16, n_coefficients=16)
```

The learned metric gives you geometry:

```julia
Expand All @@ -128,6 +147,24 @@ l2_norm(X) # global L² norm (or l2_norm(dg, X))
pointwise_norm(X) # pointwise norm (n,) (or pointwise_norm(dg, X))
```

### Batches

Leading axes are *batch* dimensions — many fields carried in one tensor:

```julia
F = dg_function(dg, ones(3, 60)) # three functions at once
batch_shape(F) # (3,)
```

A plain numeric array multiplying a tensor is a bag of **batch-wise scalars**, never a
coefficient vector, and `.*` / `./` are exact synonyms for `*` / `/` (no other
broadcast is defined). That is what makes a spectral filter a one-liner:

```julia
evals, evecs = spectrum(laplacian(dg, 0))
smoothed = exp.(-evals) .* evecs # damp each mode by e^(-λ)
```

## Differential operators

Operator accessors take the geometry and return a `LinearOperator` or
Expand Down Expand Up @@ -257,14 +294,20 @@ dgplot(T) # ellipsoids for a (0,2)-tensor
`dgplot!` adds to an existing axis. Specialised recipes (`dgscatter`, `dgquiver`,
`dg2form`, `dg3form`, `dgellipsoids`, `dgeiglines`, `dgtangentplanes`) are
available directly. Animate a time-evolving field with `dganimate`. See
[`docs/plotting.md`](docs/plotting.md).
[`docs/src/plotting.md`](docs/src/plotting.md).

## Conventions

- **1-based indexing** throughout: index values and positions range over `1:d`,
in keeping with Julia. Point-cloud data is `n × d` (rows are points).
- Arrays keep a leading point axis `p`, so a field's coefficients are laid out
per point.
- **Batch axes broadcast from the right** (numpy's rule, *not* Julia's): they are
matched from the trailing axis, with length-1 axes stretching. A `(3, 4)` batch and
a `(4,)` batch combine to `(3, 4)`.
- A tensor holds **coefficients in the diffusion basis, not pointwise values** —
`to_pointwise_basis` evaluates it, and `dg_function` and friends project the other
way. This is why a numeric array scaling a tensor is read as batch-wise scalars.
- **`*` on two `Form`s is the tensor product**, following Python; the wedge is
`wedge(α, β)` or `α ^ β`. Julia's `^` binds *tighter* than `+` and `*` where
Python's binds looser than both, so a Python expression that leans on that
Expand Down Expand Up @@ -302,6 +345,28 @@ One caveat: a fresh Julia process pays a one-time JIT compilation cost (~10 s) o
the first `from_point_cloud` call, which the interpreted Python does not; every
call after that is warm.

## Documentation

Every exported function carries a docstring with runnable examples. Build the
[Documenter](https://documenter.juliadocs.org) site with:

```bash
julia --project=docs -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()'
julia --project=docs docs/make.jl # writes docs/build, and runs every doctest
```

The examples are doctests: they assert real values (the Laplacian spectrum of the
circle, the adjointness of `δ` against `d`, the antisymmetry of the wedge) and run as
part of the test suite — both the docstring examples and those in the guide pages — so
they cannot drift from the code. They share a preamble — `dg` (60 points on a circle),
`dg3` (200 on a sphere), and `f = cos θ` — defined in `docs/doctest_setup.jl`.

The site also carries a [Conventions](docs/src/conventions.md) page (coefficients vs
values, batch broadcasting, weak vs strong forms) and a catalogue of the
[upstream bugs](docs/src/upstream-bugs.md) found in the Python reference during the
port. CI builds the site with `checkdocs=:exports`, so an undocumented export or a
broken example fails the build.

## Testing

```bash
Expand Down
6 changes: 6 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[deps]
DiffusionGeometryJ = "f8140dce-05b1-4e22-80fe-cf341a3061cb"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"

[compat]
Documenter = "1"
36 changes: 36 additions & 0 deletions docs/doctest_setup.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# The doctest preamble, shared by `docs/make.jl` and `test/doctests.jl`.
#
# Every `jldoctest` block in the package runs with this code already evaluated, so
# examples can reach for the running example geometries without rebuilding them.
# Keep it small: whatever appears here is invisible in the rendered docs, so an
# example that leans on more than `dg`, `dg3`, `f` and their point clouds becomes
# hard to read. It is documented for the reader in `docs/src/index.md`.

const DOCTEST_SETUP = quote
using DiffusionGeometryJ
using LinearAlgebra

# Nothing unexported is imported here on purpose. The internal helpers documented on
# the "Utilities" page (`np_reshape`, the batch helpers, `_from_pointwise_basis`) are
# *not* exported, so their examples spell them `DiffusionGeometryJ.np_reshape(...)` —
# as a reader would have to.

# 60 points on the unit circle in ℝ² — the running example. Its intrinsic
# dimension is 1, but the carré du champ lives in the d = 2 ambient basis.
θ = range(0, 2π; length=61)[1:60]
circle = [cos.(θ) sin.(θ)]
dg = from_point_cloud(circle; knn_kernel=16, n_function_basis=8)

# The coordinate function x₁ = cos θ on that circle.
f = dg_function(dg, cos.(θ))

# 200 points on the unit 2-sphere in ℝ³ (Fibonacci lattice), for the examples
# that need a surface: 2-forms, curvature, the Hodge star.
sphere = let n = 200, ϕ = (1 + sqrt(5)) / 2
z = [1 - 2 * (i - 0.5) / n for i in 1:n]
r = sqrt.(max.(1 .- z .^ 2, 0.0))
ψ = [2π * (i - 1) / ϕ for i in 1:n]
[r .* cos.(ψ) r .* sin.(ψ) z]
end
dg3 = from_point_cloud(sphere; knn_kernel=20, n_function_basis=12)
end
35 changes: 35 additions & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Documenter
using DiffusionGeometryJ

include(joinpath(@__DIR__, "doctest_setup.jl"))
DocMeta.setdocmeta!(DiffusionGeometryJ, :DocTestSetup, DOCTEST_SETUP; recursive=true)

makedocs(;
sitename="DiffusionGeometryJ",
authors="Jeffrey Ward",
modules=[DiffusionGeometryJ],
doctest=true,
checkdocs=:exports, # every exported name must appear in an @docs block
# No `warnonly`: an undocumented export, a broken doctest or a dead cross-reference
# must fail the build, not warn.
format=Documenter.HTML(;
canonical="https://wardjm.github.io/DiffusionGeometryJ",
prettyurls=get(ENV, "CI", "false") == "true",
),
pages=[
"Home" => "index.md",
"Conventions" => "conventions.md",
"API" => [
"Building a geometry" => "api/geometry.md",
"Tensor fields" => "api/tensors.md",
"Operators" => "api/operators.md",
"Numerical methods" => "api/methods.md",
"Diffusion core" => "api/diffusion.md",
"Utilities" => "api/utils.md",
],
"Plotting" => "plotting.md",
"Upstream bugs" => "upstream-bugs.md",
],
)

deploydocs(; repo="github.com/wardjm/DiffusionGeometryJ", devbranch="main")
50 changes: 50 additions & 0 deletions docs/src/api/diffusion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
```@meta
CurrentModule = DiffusionGeometryJ
```

# Diffusion core

The pipeline that turns a point cloud into a diffusion: kNN graph → Markov chain →
symmetric kernel → eigenfunction basis → carré du champ. [`from_point_cloud`](@ref)
runs all of it; these are the stages, for when you want to intervene.

## The diffusion process

```@docs
knn_graph
compute_local_bandwidths
tune_kernel
markov_chain
build_symmetric_kernel_matrix
compute_eigenfunction_basis
```

## Carré du champ

```@docs
carre_du_champ_knn
carre_du_champ_graph
gamma_compound
gamma_02
gamma_02_sym
```

## Markov triples

```@docs
MarkovTriple
ImmersedMarkovTriple
cdc
regularise
immersed_triple_from_point_cloud
immersed_triple_from_knn_kernel
immersed_triple_from_graph_kernel
immersed_triple_from_edges
```

## Regularisation

```@docs
regularise_diffusion
regularise_bandlimit
```
91 changes: 91 additions & 0 deletions docs/src/api/geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
```@meta
CurrentModule = DiffusionGeometryJ
DocTestSetup = Main.DOCTEST_SETUP
```

# Building a geometry

A `DiffusionGeometry` is built from a point cloud (or a kernel, or a graph), and from
then on it is the thing everything else hangs off: it owns the eigenfunction basis, the
measure, the carré du champ cache, the tensor spaces, and every differential operator.

```jldoctest
julia> dg = from_point_cloud(circle; knn_kernel=16, n_function_basis=8)
DiffusionGeometry(n=60, ambient_dim=2, n_function_basis=8, n_coefficients=8)
```

## The type

```@docs
DiffusionGeometry
```

## Constructors

Six ways in, from the most convenient to the most raw.

```@docs
from_point_cloud
from_knn_graph
from_knn_kernel
from_graph_kernel
from_edges
from_sparse_matrix
```

## Accessors

```@docs
npoints
ambient_dim
n_coefficients
n_function_basis
function_basis
measure
immersion_coords
```

## Tensor spaces

Each is cached on the geometry, so repeated calls return the same object.

```@docs
function_space
vector_field_space
form_space
tensor02_space
tensor02sym_space
```

## Building tensors

```@docs
dg_function
dg_vector_field
dg_form
dg_tensor02
dg_tensor02sym
```

## The metric

```@docs
g
pointwise_norm
inner
l2_norm
```

## The γ cache

The carré du champ contractions are the expensive part of the package, so they are
memoised on the geometry as `dg.cache`.

```@docs
GammaCache
gamma_coords
gamma_functions
gamma_mixed
gamma_ambient
gamma_coords_compound
```
Loading
Loading