Skip to content

graph, digraph, GraphPlot classes (MATLAB parity)#57

Draft
Ytuf wants to merge 97 commits into
gnu-octave:defaultfrom
Ytuf:digraph-graph-classes
Draft

graph, digraph, GraphPlot classes (MATLAB parity)#57
Ytuf wants to merge 97 commits into
gnu-octave:defaultfrom
Ytuf:digraph-graph-classes

Conversation

@Ytuf

@Ytuf Ytuf commented Apr 21, 2026

Copy link
Copy Markdown

Summary

This draft PR adds full MATLAB-parity implementations of the graph and
digraph value classes, plus the GraphPlot handle class, under a new
scripts/graph/ category. It covers 95 commits landing 51 user-facing
functions (every method in MATLAB R2024a's graph/digraph/GraphPlot
API), backed by 600 BIST tests (341 on digraph, 259 on graph) and a
reference-output harness that cross-checks against captured MATLAB
outputs.

  • Classes: graph (undirected, value-class), digraph (directed,
    value-class), GraphPlot (plot handle, < handle).
  • Construction: edge-list (s, t), weighted edge-list (s, t, w),
    adjacency matrix (A) with 'upper'/'lower' for graph, cellstr
    node names, integer node-count N, EdgeTable/NodeTable struct
    inputs, 'omitselfloops' and 'multigraph' flags. Sparse adjacency
    is preserved end-to-end.
  • Structure & modification: addedge, addnode, rmedge, rmnode,
    flipedge, reordernodes, subgraph, simplify, findedge,
    findnode, numedges, numnodes, edgecount, degree, indegree,
    outdegree, inedges, outedges, neighbors, predecessors,
    successors, ismultigraph, plus matrix representations
    (adjacency, incidence, laplacian).
  • Traversal / algorithms: bfsearch, dfsearch, shortestpath,
    shortestpathtree, distances (auto/unweighted/positive/mixed
    /acyclic), allpaths, allcycles, maxflow, mincut, toposort,
    isdag, conncomp, biconncomp, condensation, transclosure,
    transreduction.
  • Centrality: 11 measures (degree, indegree, outdegree,
    closeness, incloseness, outcloseness, betweenness, pagerank,
    eigenvector, hubs, authorities) with Cost/Importance options.
  • Isomorphism: isisomorphic / isomorphism (VF2) with
    NodeVariables / EdgeVariables.
  • Visualization: plot(G) returns a GraphPlot with seven layouts
    (auto, circle, force, force3, layered, subspace,
    subspace3) plus the cosmetic properties and highlight /
    labelnode / labeledge setters.
  • Docs: etc/NEWS.12.md section, new doc/interpreter/graph.txi
    chapter wired into octave.texi, and a doc/graph-bench-results.txt
    with performance numbers at 1e3/1e4/1e5/1e6 scales.

No new dependencies; pure .m with no new oct-file. Value-class copy
semantics verified (every digraph/graph mutation returns a new
object; no aliasing through < handle).

Contribution context

Verification performed

  • test digraph341 / 341 pass
  • test graph259 / 259 pass
  • test __matlab_ref__5 / 5 pass (reference-capture harness)
  • Branch rebased cleanly onto upstream/default (no conflicts).
  • make check on Linux/macOS/Windows is pending (US-U04 in the
    PRD) — I only have a Windows workstation and the bundled Octave
    9.4.0 BIST runner; help welcome from CI.

Test plan

  • make check on Linux → 0 new failures attributable to scripts/graph/
  • make check on macOS → 0 new failures
  • make check on Windows (MSYS2) → 0 new failures
  • make doc builds cleanly with the new graph.texi chapter
  • FSF copyright assignment for Adam Kidwell recorded by FSF clerks
  • Maintainer pass on API surface / identifier naming / module placement

🤖 Generated with Claude Code

Ytuf and others added 30 commits April 21, 2026 13:20
Empty category directory.  Populated by subsequent stories implementing
the graph, digraph, and GraphPlot classdef files.  Follows the same
module.mk pattern as scripts/audio/ with an initial .oct-config only.
…-I03)

Private helper that loads a MATLAB reference-output JSON fixture from
the sibling MatSlop tree (default) or a caller-supplied directory, so
%!test blocks in digraph/graph methods can assert byte-for-byte parity
with MATLAB's documented output.

Returns an empty struct + warning when the fixture is absent so tests
can skip gracefully when run outside the MatSlop development layout
(e.g. an upstream CI builder with no MatSlop repo).

* scripts/graph/private/__matlab_ref__.m: new helper, GPLv3 header,
  texinfo docstring, 5 %!test / %!error BIST assertions.
* scripts/graph/module.mk: wire private/ into FCN_FILE_DIRS and
  FCN_FILES.

All BIST pass (5/5).
* scripts/graph/digraph.m: New classdef.  digraph() returns an empty
  directed graph; digraph(N) with a non-negative integer scalar N
  returns an N-node edgeless graph.  Storage is a private sparse
  adjacency matrix plus a cellstr of node names.  numnodes and
  numedges implemented as classdef methods.  Full GPLv3 header and
  texinfo docstring with @deftypefn/@deftypefnx, @example/@group,
  @Seealso.  13 %!test blocks cover default, N-node, 0, 1, and 1000
  node cases plus input-validation %!error blocks for negative,
  non-integer, non-scalar, Inf, NaN, and too-many-argument calls.

* scripts/graph/module.mk: Add digraph.m to scripts_graph_FCN_FILES.
Extend digraph constructor to accept two numeric vectors S and T of
equal length, building one directed edge S(i) -> T(i) per index with
node count auto-set to max(max(S), max(T)).  S and T must be positive
integer vectors (MATLAB is 1-based); empty S and T yield an empty
digraph.  Adds 23 BIST blocks covering row/column orientation, empty
input, self-loop, siever-style 9-node/12-edge fixture, and all
validation errors.  36/36 BIST pass against Octave 9.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend digraph(s, t) to accept an optional weight vector or scalar w:
  digraph(s, t, w)

w may be a scalar (broadcast to every edge) or a numeric real vector
of length numel(s).  Weights are rejected if they contain NaN, are
complex, are non-numeric, or are a non-vector non-scalar.

Introduce has_weights_ private property plus a Dependent, SetAccess =
private Edges property that returns a struct with EndNodes (m-by-2)
and, when the graph was constructed with weights, Weight (m-by-1).
Unweighted graphs have no Weight field — matches MATLAB parity until a
real table class is available.

Edges are reported in lexicographic (source, destination) order via
find(adj_.') regardless of construction order.

21 new %!test/%!error blocks; 57/57 BIST green.
Accept a fourth cellstr argument naming every node:

  G = digraph (s, t, w, nodenames)

Entries of s and t may be numeric (treated as 1-based indices into
nodenames) or strings / cellstr (looked up in nodenames).  Node count
is numel (nodenames) regardless of the maximum endpoint index, so
isolated named nodes are preserved.  Pass [] for w to create an
unweighted named digraph.  G.Nodes returns a struct with a Name
column (cellstr).

Adds a new private helper scripts/graph/private/__resolve_endpoint__.m
that converts the user-supplied endpoint vector into a column of
node indices.  The helper will also be reused by graph.m.

76/76 BIST green on digraph.m (19 new US-C04 tests), 9/9 on the new
helper, __matlab_ref__ still 5/5.
Extend the four-argument digraph constructor to accept a non-negative
integer scalar N as its fourth argument, in addition to the cellstr
nodenames form added in US-C04.  digraph(s, t, w, N) creates a digraph
with exactly N nodes; indices in s and t greater than max endpoint but
not exceeding N correspond to isolated trailing nodes.  Pass [] for W
to construct an unweighted digraph with N nodes.

The nargin==4 branch is now a type dispatcher: iscellstr(arg4) routes
to the existing nodenames path, isnumeric+isscalar(arg4) routes to the
new N path, and anything else errors out with a MATLAB-compatible
message describing both accepted forms.  The N path reuses the full
2/3-argument s/t/w validation (numeric-real vectors, matching length,
positive-integer entries, W numeric-real vector-or-scalar, NaN
rejected) and adds a range check so endpoints exceeding N are
rejected.

31 new %!test / %!error BIST blocks cover the happy path, scalar
weight broadcast, empty-endpoint and N=0 cases, edge lex order with
trailing isolates, a Siever-style 12-edge fixture padded to 20 nodes,
every input-validation error branch, and the fourth-argument type
check.  test digraph: 107/107 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the single-argument constructor to accept a square numeric or
logical matrix A and build the digraph from its adjacency pattern:
every nonzero A(i,j) becomes an edge i->j with weight A(i,j).  The
nargin==1 branch is now a dispatcher -- scalar numeric stays the node
count form (validation unchanged), non-scalar 2-D numeric/logical
routes to the new adjacency path, and anything else (3-D, cell, ...)
errors with a MATLAB-style message.

Adjacency validation rejects complex (<real>), non-square (<square>),
and NaN (<NaN>) inputs; sparse input is preserved without densifying
so large sparse adjacency matrices work.  Matrix form always carries
a Weight column (MATLAB parity), while the 0x0 case stays unweighted.
int8/logical inputs are coerced to double in G.Edges.Weight.

131/131 BIST green (24 new tests + 1 updated: the old
'<non-negative integer> digraph ([1 2 3])' now expects '<square>'
because a 1x3 row vector is a non-square adjacency matrix).
__resolve_endpoint__ 9/9, __matlab_ref__ 5/5.  No regressions.
Add digraph (A, NODENAMES) as an eighth overload of the digraph
constructor.  A is a real square numeric or logical matrix (dense or
sparse); NODENAMES is a cellstr of unique strings with
numel (NODENAMES) == size (A, 1).  Semantics otherwise match the
adjacency-matrix form introduced in US-C06: each nonzero A(i,j) becomes
an edge i->j with weight A(i,j), sparse input is preserved, self-loops
are permitted, and size(A,1)>0 implies the Edges struct carries a
Weight column (MATLAB parity).

Dispatch works at the nargin == 2 level: if the second argument is a
cellstr, route to the new branch; otherwise fall through to the
existing digraph (s, t) edge-list path.  That is unambiguous because
edge-list requires a numeric destination vector.

Validation error regexes: <real>, <square>, <NaN>, <unique>, <numel>.

23 new %!test / %!error blocks cover dense/sparse/logical/int8
adjacency, isolated-node preservation, self-loops, 0x0 edge case,
negative weights, a Siever-style 9-node fixture, duplicate-name
rejection, size mismatch, non-square, complex, and NaN-in-A errors.
154/154 BIST green; __resolve_endpoint__ 9/9, __matlab_ref__ 5/5, no
regression.

Commit was driven by the Ralph TDD loop against the
installed Octave 9.4.0 on Windows; make check skipped as in prior
stories for lack of a configure/make toolchain on this host.
Accept scalar structs standing in for MATLAB tables (Octave has no
table class yet):
  * digraph (ET) -- ET has EndNodes (m-by-2 numeric or cellstr) and
    optional Weight; any other fields are preserved as extra edge
    columns on G.Edges.
  * digraph (ET, NT) -- NT has an optional Name cellstr plus any
    extra columns preserved on G.Nodes.

Edges are re-sorted into lexicographic (source, destination) order
and every extra edge column is reordered to match via a permutation
recovered from a sparse(s, t, 1:m, N, N) build.  Duplicate (s, t)
pairs are detected via nnz (p) != m and rejected (a future
'multigraph' flag will permit parallel edges).  Cellstr EndNodes
resolve via __resolve_endpoint__ using NT.Name when supplied, or via
first-appearance inference otherwise.

Two new private properties carry the user's extra columns:
  * edge_attrs_ (lex-ordered)
  * node_attrs_ (node-index-ordered)
get.Edges and get.Nodes merge them into the returned struct.

37 new %!test / %!error blocks; digraph 191/191 green,
__resolve_endpoint__ 9/9, __matlab_ref__ 5/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every digraph(...) constructor form now accepts a trailing
"omitselfloops" string (case-insensitive).  When present, every
self-loop edge (i, i) is dropped after the rest of the graph is
built.  Extra edge-attribute columns supplied via the EdgeTable
form are filtered by the same mask so row counts stay in sync.

Implementation: constructor pre-processes varargin to strip the
flag into a local omit_loops boolean; local args/nargs shadow the
built-ins so the eight dispatch branches keep their shape.  Tail
post-processing rebuilds adj_ via sparse(r(keep), c(keep), ...)
and uses sortrows([r, c]) on the existing find() output to derive
the lex-order mask for edge_attrs_ (cheaper than a second find on
the transpose).

22 new BIST blocks exercise every constructor form, case variants,
edge cases (all self-loops, no self-loops, empty edges, isolated N),
and error paths; digraph 213/213 green, __resolve_endpoint__ 9/9,
__matlab_ref__ 5/5.
Introduce the undirected graph class mirroring US-C01..US-C05 of the
digraph work.  Supported signatures:

  graph ()                             empty undirected graph
  graph (N)                            N isolated nodes
  graph (S, T)                         edge-list, unweighted
  graph (S, T, W)                      edge-list with scalar or
                                       vector weights
  graph (S, T, W, NODENAMES)           named nodes; string endpoints
                                       resolved via NODENAMES
  graph (S, T, W, N)                   explicit node count; isolated
                                       trailing nodes preserved

Edges are stored in a symmetric sparse adjacency matrix: each
off-diagonal undirected edge contributes a pair of symmetric entries,
and self-loops contribute a single diagonal entry.  Duplicate
unordered endpoint pairs are rejected by normalising to (min, max)
and sparse-accumulating the 1..m index sequence.  G.Edges.EndNodes
returns rows in lexicographic (s, t) order with s <= t via
find (tril (adj_)).  numedges = nnz (tril (adj_)).

Test coverage: 78 new %!test / %!error BIST blocks covering all six
call forms, weight validation, node-name resolution, duplicate-edge
detection, isolated trailing nodes, and value-class copy semantics.
digraph 243/243 and __resolve_endpoint__ 9/9 remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…S-C12)

Extend the scripts/graph/graph.m constructor to accept an adjacency
matrix as the sole argument, plus an optional 'upper' or 'lower'
triangle selector:

- graph (A): A must be a real square numeric or logical matrix and
  must be symmetric.  A non-symmetric A errors with a MATLAB-style
  message that hints at 'upper'/'lower'.  Sparse A stays sparse;
  logical and integer A are coerced to double.  NaN rejected.
- graph (A, 'upper') uses only triu (A); the strictly-upper half is
  mirrored across the diagonal into the strictly-lower half so the
  internal symmetric storage is complete.  Self-loops (diagonal)
  are preserved.
- graph (A, 'lower') analogous for tril (A).

Dispatch changes:
  * nargs == 1 now routes scalars to the existing N-node path and
    non-scalar 2-D numeric/logical matrices to the new adjacency
    path.
  * nargs == 2 with a char-row second argument AND a non-vector
    matrix first argument routes to the new triangle-flag path.
    Vector first argument falls through to the existing edge-list
    branch, keeping graph ([1 2], "ab") -> 'S and T must be
    numeric vectors'.

A new local helper build_adj_from_matrix (A, mode) centralises the
real/square/NaN validation and the type coercion.  Matrix form
always carries a Weight column (MATLAB parity); 0x0 adjacency
stays unweighted.

Updated one legacy BIST: `%!error <non-negative integer> graph
([1 2 3])` is now `%!error <square> graph ([1 2 3])` because
[1 2 3] is a 1x3 matrix that now dispatches to the adjacency
path -- MATLAB behaves the same way ("Adjacency matrix must be
square").

Added 49 new %!test / %!error BIST blocks.  Docstring gained three
new @deftypefnx synopses and two worked examples.

`test graph` -> 127/127; no regression in digraph (243/243),
__resolve_endpoint__ (9/9), or __matlab_ref__ (5/5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the digraph US-C08 EdgeTable/NodeTable constructor for the
undirected graph class.  A new dispatch branch above the existing
nargs==1 scalar/matrix dispatcher routes `(nargs == 1 &&
isstruct(arg1))` or `(nargs == 2 && isstruct(arg1) && isstruct(arg2))`
to the new form, disjoint from the existing adjacency+triangle-flag
branch.

Validation mirrors digraph US-C08 with the `graph:` prefix: scalar
struct ET with required EndNodes (m-by-2 numeric or cellstr) and
optional Weight; any other fields become extra edge-attribute
columns.  Optional scalar struct NT with optional cellstr Name and
any other fields as extra node-attribute columns.  Two new private
properties `edge_attrs_` and `node_attrs_` carry the extra columns;
get.Edges and get.Nodes merge them into the returned struct.

Graph-specific undirected-pair normalization: `s_n = min(s, t);
t_n = max(s, t); p = sparse(t_n, s_n, 1:m, N, N)` builds a
lower-triangular sparse whose `nnz(p) != m` check detects duplicates
regardless of input orientation -- including undirected duplicates
like (1,2) and (2,1) which normalize to the same (min, max) cell.
find(p) walks column-major through the lower triangle, yielding
lex (s_n, t_n) order -- the same ordering contract get.Edges uses
via find(tril(adj_)) -- so the permutation extracted from find(p)'s
third return reorders every extra edge column into the same lex
order that Weight lands in naturally via adj_ storage.

adj_ is built via the existing build_adj local helper.  To avoid
the constructor running a second redundant O(m log m) sparse build
inside build_adj, build_adj gained an optional sixth
`skip_dup_check` parameter; nargin<6 defaults to false so the five
existing call sites are unchanged.

Docstring updated with @deftypefnx lines for `graph (EDGETABLE)`
and `graph (EDGETABLE, NODETABLE)`, an explanatory section covering
unordered-pair normalization, Weight-follows-edge semantics, and
first-appearance name inference, plus an example in the examples
block.

39 new %!test/%!error blocks (37 mirroring the digraph US-C08 suite
plus 2 graph-specific for unordered-pair normalization and
undirected-duplicate detection).  graph 166/166 BIST green;
digraph 243/243, __resolve_endpoint__ 9/9, __matlab_ref__ 5/5 -- no
regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…C14)

The Nodes and Edges getters have existed since US-C03/C04 (digraph)
and US-C11 (graph); US-C14 pulls the coverage into a dedicated BIST
section and closes the last read-access gaps.

get.Edges now forces an m-by-2 EndNodes and m-by-1 Weight column for
every adjacency size via a (:) coercion.  Previously find() on a
0-by-0 sparse matrix returned 0-by-0 arrays, so the truly empty
graph (digraph(), graph(), graph([],[]), etc.) leaked a 0-by-0
EndNodes -- inconsistent with every non-empty case and with MATLAB.
The coercion is a no-op on non-empty graphs; multigraph storage was
already correct via zeros(0,2) defaults.

Added a Properties section to both texinfo docstrings describing
the two read-only struct views (always-present fields, field order,
read-only access, user-column preservation).

43 new BIST blocks (22 on digraph.m, 21 on graph.m) cover: struct
return type on every constructor form; Name always-present as
column cellstr; EndNodes always m-by-2 numeric; Weight presence
gated on construction; fieldnames order; SetAccess=private; idempotent
getter; dynamic field access; fully-featured round-trip.

digraph 265/265, graph 186/186, __resolve_endpoint__ 9/9,
__matlab_ref__ 5/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `disp (G)` method to both scripts/graph/digraph.m and
scripts/graph/graph.m that prints a concise, human-readable summary
instead of Octave's default classdef "object with properties:"
output.  The header follows the PRD-specified format

  "digraph with N nodes and M edges" / "graph with N nodes and M
  edges"

with singular/plural word handling for 1 node / 1 edge.  An empty
graph terminates the header with a period; a non-empty graph follows
the header with a preview of up to the first 10 edges in a simple
columnar format (left-aligned EndNode1/EndNode2 auto-widened to the
widest label, optional right-aligned Weight column when the graph is
weighted).  A continuation line "... (N more edges)" reports any
truncation.  Node names are printed in place of numeric indices
whenever the graph was constructed with names.

Octave's default classdef display delegates to disp after printing
the "G =\n\n" assignment prefix, so no separate display method is
needed.

26 new %!test blocks (13 per class) cover: empty graph header,
N-node edgeless graph, weighted 3-edge graph, singular plural forms,
many-edge truncation with continuation line, named-graph labels,
multigraph parallel-edge counting (digraph only), Weight column
presence/absence, display-prefix, and graph-vs-digraph header
prefix specificity.

Result: digraph 278/278 green (+13), graph 199/199 green (+13);
__resolve_endpoint__ 9/9, __matlab_ref__ 5/5; no regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New scripts/graph/numnodes.m and scripts/graph/numedges.m provide
top-level documentation targets shared by graph and digraph: each has
a full texinfo docstring with example and @Seealso, plus 23 BIST
blocks covering empty, N-node, edge-list, weighted, named-node,
self-loop, isolated-node, siever, multigraph, and adjacency-matrix
cases across both classes, with six %!error blocks for non-graph
inputs.  The function body runs only for non-graph inputs (classdef
method dispatch intercepts graph/digraph calls): nargin check,
`isa(G,'graph') || isa(G,'digraph')` guard, and a defensive
dot-notation delegation fall-through.  The existing classdef methods
remain unchanged.  Regression clean: numnodes 23/23, numedges 23/23,
digraph 278/278, graph 199/199.
Add scripts/graph/successors.m and scripts/graph/predecessors.m as
standalone files (free-function delegates that route to classdef
methods on digraph objects, and error with a helpful message on
non-digraph inputs), matching the two-surface pattern established by
numnodes/numedges in US-Q01.  Both queries accept a scalar NODEID
that is either a numeric 1-based node index or a node name (char row
vector or 1-element cellstr), and return a column vector whose type
matches the input: numeric indices in / numeric indices out, string
in / cellstr of names out (MATLAB parity).

New classdef methods digraph.successors and digraph.predecessors
dispatch on is_multigraph_: multigraph storage (lex-sorted mg_endnodes_)
returns filtered column 2 (successors) or column 1 (predecessors)
directly, already in sorted order; simple-graph storage uses
find (adj_(n, :)) or find (adj_(:, n)) respectively.  Parallel edges
in a multigraph contribute duplicate entries, matching MATLAB.

Return-type matching lives in a new private helper
scripts/graph/private/__resolve_single_node__.m that takes (G, nodeID,
method) and returns [idx, by_name] where by_name signals whether the
caller passed a string identifier.  The helper reads through the
public G.Nodes.Name surface so later stories can also call it from
graph.m methods (e.g. neighbors in US-Q03) without duplicating the
validation logic.

Full input validation with %!error coverage: out-of-range, non-integer,
non-positive, non-existent name, name on unnamed digraph, non-scalar
numeric, multi-element cellstr, non-digraph first argument (including
graph objects), and nargin mismatch.  Coverage: successors 28/28,
predecessors 28/28, __resolve_single_node__ 12/12.  No regression:
digraph 278/278, graph 199/199, numnodes 23/23, numedges 23/23,
__resolve_endpoint__ 9/9, __matlab_ref__ 5/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new neighbors(G, nodeID) query supported on both the graph and
digraph classes.  For undirected graph, neighbors returns the incident
nodes (find(adj_(n,:)), already sorted).  For digraph, neighbors
returns the union of successors and predecessors, sorted ascending; a
self-loop contributes the node once.  For a digraph multigraph, each
parallel edge contributes one entry so duplicate neighbours are
possible; a self-loop stored as a single (n,n) edge contributes n once.

NodeID resolution reuses the existing __resolve_single_node__ helper,
so numeric indices return doubles and char / 1-element cellstr return
a column cellstr of node names.

New file scripts/graph/neighbors.m holds the canonical help/docstring
target, a non-graph input guard, and 56 %!test/%!error BIST blocks
spanning both classes and all input variants.  Top-level graph and
digraph docstring @Seealso lists extended to mention neighbors.
New scripts/graph/indegree.m and scripts/graph/outdegree.m each carry
the 24-line GPLv3 header, a full texinfo docstring with the two
synopses (no-arg full-vector form and NODEIDS subset form), an
example, and @Seealso cross-references.  Free-function bodies guard
nargin and the G-is-digraph isa check, then delegate to the classdef
method via dot-notation.

Two new classdef methods digraph.indegree and digraph.outdegree:
simple-graph path uses full(sum(spones(adj_), 1 or 2))(:) so weighted
digraphs still report edge counts not weight sums; multigraph path
uses accumarray on mg_endnodes_ column 2 (in) or column 1 (out).
The nargin==1 form returns a numnodes-by-1 column vector; the
nargin==2 form resolves NODEIDS and returns a result reshaped to the
input's shape (scalar stays scalar, row stays row, column stays
column, any 2-D array preserved).

New private helper scripts/graph/private/__resolve_node_list__.m
handles the vector-capable resolution: numeric array of positive
integer indices (any shape), char row vector (single name -> [1 1]
shape), cellstr of any shape, or empty [] / {}.  Returns idx as a
column vector plus out_shape so callers can reshape the result.
Error messages prefixed with the caller's method name for clearer
diagnostics.

70 new BIST blocks (35 per function) covering: empty digraph,
edgeless N-node, simple digraph all-nodes and subset forms, scalar/
row/column/2-D nodeIDs with shape preservation, empty nodeIDs, self-
loop (counts 1 in-degree and 1 out-degree), siever 9-node fixture,
weighted (weights ignored), multigraph parallel edges, multigraph
self-loop, named digraph with char/1-cellstr/row-cellstr/column-
cellstr/numeric inputs, adjacency round-trip, plus 11 error cases
covering out-of-range index, zero index, non-integer, vector-with-
bad-entry, non-existent name, name on unnamed digraph, non-digraph
first arg (including graph object), nargin mismatch.  19 new BIST
blocks on the private helper covering numeric/char/cellstr paths of
every shape plus all error branches.

scripts/graph/module.mk updated (alphabetic order: new indegree.m /
outdegree.m in FCN_FILES, new __resolve_node_list__.m in
PRIVATE_FCN_FILES).  Top-level digraph classdef docstring @Seealso
extended to mention indegree and outdegree.

Test results: indegree 35/35, outdegree 35/35,
__resolve_node_list__ 19/19; no regression elsewhere (digraph
278/278, graph 199/199, numnodes 23/23, numedges 23/23, successors
28/28, predecessors 28/28, neighbors 56/56, __resolve_endpoint__
9/9, __resolve_single_node__ 12/12, __matlab_ref__ 5/5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
degree(G) returns a column vector of edge-end counts for the
undirected graph G; degree(G, nodeIDs) returns the degrees of
specified nodes with input-shape preservation.  Self-loops contribute
2 to the degree (MATLAB convention), since the undirected adjacency
stores them as a single diagonal entry and the degree is defined as
the count of edge-ends incident to the node.

Implementation:
- New classdef method graph.degree delegates all shape/resolution
  work to __resolve_node_list__ (same helper used by digraph.indegree
  and digraph.outdegree), then computes
  d = full (sum (spones (adj_), 1))(:) + full (diag (spones (adj_)))
  so each self-loop contributes 2 instead of 1.
- New free function scripts/graph/degree.m (canonical help target and
  non-graph guard).
- 44 BIST blocks covering empty, N-node, simple, weighted, named,
  adjacency-constructed, path, triangle, complete, star fixtures;
  every nodeIDs shape (scalar/row/column/2-D/empty); self-loop
  handling (isolated, mixed with regular edges, multiple nodes);
  handshake-lemma sanity check; and all input-validation errors.
- Updated top-level graph docstring @Seealso to mention degree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
findnode(G, nodeID) returns the numeric node indices corresponding to
nodeID in the graph or digraph G, matching MATLAB's findnode contract:
numeric inputs are validated and returned with shape preserved;
a char row vector is looked up as a single node name and returns a
scalar (0 if not found); a cell array of character vectors is looked
up element-wise and returns a column vector of indices (0 for any
missing name).  Missing names are NOT an error -- findnode doubles
as an input-validation filter where "is this a valid node identifier?"
needs a boolean-ish answer.  Numeric out-of-range still errors.

Implementation:
- New shared private helper scripts/graph/private/__findnode_impl__.m
  (~195 lines, 12 BIST blocks) implements the full semantics once.
- New classdef method graph.findnode and digraph.findnode each
  delegate to __findnode_impl__ after a nargin check.
- New standalone scripts/graph/findnode.m (~345 lines, 49 BIST blocks)
  is the canonical `help findnode` target and non-graph guard.
- Coverage: numeric passthrough (scalar, integer-class coercion,
  row/column/2-D/empty shape preservation, digraph parity, named
  and unnamed graphs); char row vector lookup (found/missing/empty
  char / no-names / empty graph, digraph parity); cellstr lookup
  (column-vector output from any input shape, 0-fill for missing,
  empty cellstr, 2-D cellstr flattens column-major); errors on
  out-of-range / non-integer / NaN / Inf / negative / complex /
  logical / struct / non-cellstr cell / non-row char / non-graph
  first arg / nargin mismatch.
- Updated top-level graph and digraph docstring @Seealso to mention
  findnode.  module.mk lists both new files alphabetically.

Total test suite 855/855 (49 new findnode + 12 new __findnode_impl__,
no regression on 794 prior tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add findedge with all three MATLAB call forms on both graph and digraph:

  * findedge(G)            -> m-by-2 endpoints matrix (or [sOut,tOut]
                              column vectors for the two-output form).
  * findedge(G, s, t)      -> edge index per (source, destination) pair,
                              0 if absent.  Accepts numeric, char row,
                              or cellstr endpoints; for an undirected
                              graph the pair matches in either
                              orientation.
  * findedge(G, edgeIdx)   -> endpoints at the given edge indices
                              (m-by-2 matrix or two column outputs).

Shared private helper scripts/graph/private/__findedge_impl__.m
implements all three forms once using only public accessors (Edges,
Nodes, numnodes, numedges, ismultigraph), so both classdef methods
delegate without needing private-property access.  Missing names
propagate to a 0 edge-index result (MATLAB findnode-style semantics,
no error).  Multigraph digraphs return the first matching edge index
via linear scan; simple graphs/digraphs use a sparse (s, t) -> index
lookup for O(m + q) performance.

Files:

  * scripts/graph/findedge.m (new, free function + 68 BIST blocks)
  * scripts/graph/private/__findedge_impl__.m (new, shared helper
    with 15 BIST blocks)
  * scripts/graph/graph.m (new classdef method + @Seealso)
  * scripts/graph/digraph.m (new classdef method + @Seealso)
  * scripts/graph/module.mk (register new files, alphabetic)

Test results: findedge 68/68; __findedge_impl__ 15/15; full suite
938/938 (no regression).
Count edges between node pairs (s, t).  For a simple graph/digraph the
result is 0 or 1; for a multigraph digraph the result sums parallel
edges.  Undirected graph query is normalised to the canonical
(min, max) form to match edge storage.

New free-function scripts/graph/edgecount.m (+50 BIST blocks) delegates
to new shared private helper scripts/graph/private/__edgecount_impl__.m
(+12 BIST blocks) used by matching classdef methods on both
graph.edgecount and digraph.edgecount.  The helper uses only public
accessors (G.Edges.EndNodes, numnodes, G.Nodes.Name), so it works
without piercing classdef encapsulation.

Accepts numeric indices, a char row vector (single name), or a cell
array of strings; missing names yield 0 (findnode/findedge convention)
rather than raising.  Vectorized over same-length S and T; returns a
scalar for scalar inputs and a column vector otherwise.  Empty inputs
return zeros(0, 1).

Implementation builds sparse(E(:,1), E(:,2), 1, N, N) once from
G.Edges.EndNodes; duplicate endpoint rows in multigraph storage
accumulate naturally into per-cell multiplicities.  sub2ind lookup
gives O(m + q) for q query pairs.

Total graph/digraph suite: 1000/1000 -- no regression.
Add per-node incoming/outgoing edge index lookup on digraph.

* scripts/graph/outedges.m, scripts/graph/inedges.m: new free
  functions documenting the call forms and routing through classdef
  dispatch via G.outedges/G.inedges.  Each accepts a scalar nodeID
  (numeric index, char row vector, or 1-element cellstr) and returns
  a column vector of edge indices into G.Edges.  When two outputs
  are requested, the second is the column of destination (outedges)
  or source (inedges) node identifiers, with type matching the input
  (numeric in -> numeric, name in -> cellstr).  Each file includes
  the 24-line GPLv3 header, a texinfo docstring, a worked example,
  and full %!test/%!error BIST coverage (37 + 38 blocks).
* scripts/graph/digraph.m: new outedges and inedges classdef methods
  between edgecount and disp.  Both delegate node resolution to
  __resolve_single_node__ for parity with successors/predecessors,
  then mask G.Edges.EndNodes by source (col 1) or destination
  (col 2) -- this keeps edge indices consistent with findedge for
  both simple and multigraph storage.  The empty-Edges case is
  handled explicitly so an isolated node returns zeros(0, 1) for
  the numeric path and cell(0, 1) for the name path.  Top-level
  classdef docstring @Seealso extended to include both methods.
* scripts/graph/module.mk: register inedges.m and outedges.m in
  %canon_reldir%_FCN_FILES (alphabetic).

Test results (installed Octave 9.4.0 BIST runner):
  outedges                 PASS=37  TOTAL=37
  inedges                  PASS=38  TOTAL=38
  digraph + 19 peers       no regression
  OVERALL                  1075/1075 (was 1000/1000)
Add adjacency(G[, W]) returning the sparse adjacency matrix with three
call forms: no-arg (binary 0/1 or multigraph edge-count), "weighted"
flag (stored edge weights, with weights summed for parallel edges), and
a numeric vector W for custom per-edge weights.  For undirected graph
the result is symmetric; self-loops follow the MATLAB adjacency
convention with A(i,i) = 1 (or W(i) for custom weights), not doubled.
Wire the new free-function file, add classdef methods on both classes,
and extend the top-level @Seealso entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add incidence(G) returning the sparse N-by-M incidence matrix where N
= numnodes(G) and M = numedges(G).  For a digraph, column k has -1 at
the source row and +1 at the destination row of edge k.  For a graph,
column k has 1 at both endpoint rows.  Self-loop edges produce an
all-zero column (MATLAB convention: a valid incidence column has
exactly two nonzero entries).

- scripts/graph/incidence.m: free-function dispatcher with texinfo
  docstring and 37 BIST blocks covering digraph/graph/multigraph,
  self-loops, edgeless, named, weighted (weights ignored), and
  row/col-sum identities.
- scripts/graph/digraph.m: new incidence classdef method; top-level
  @Seealso extended.
- scripts/graph/graph.m: new incidence classdef method; top-level
  @Seealso extended.
- scripts/graph/module.mk: register incidence.m in alphabetic order.

Test summary: incidence PASS=37/37; no regressions across the 24
existing test files (OVERALL 1163/1163).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* scripts/graph/laplacian.m: New free function plus 34 BIST blocks.
  Returns the sparse graph Laplacian L = D - A_off where D is the
  degree-diagonal and A_off is the binary adjacency with the diagonal
  zeroed.  Matches MATLAB's convention exactly: L(i,i) = degree(G,i)
  (self-loops contribute 2), L(i,j) = -1 for each edge between i and j
  (i != j), 0 otherwise.  Edge weights are ignored.

* scripts/graph/graph.m (laplacian classdef method): Real
  implementation; uses spones to collapse weights to binary, clears
  the diagonal of the adjacency to form A_off, places degree(G) on the
  diagonal, and returns sparse(1:N, 1:N, d, N, N) - A_off.

* scripts/graph/digraph.m (laplacian classdef method): Errors with
  "laplacian: not defined for a digraph; laplacian requires an
  undirected graph".  The graph Laplacian is not defined on directed
  graphs.

* scripts/graph/graph.m, scripts/graph/digraph.m (top-level
  docstring @Seealso): Add laplacian.

* scripts/graph/module.mk: Add laplacian.m alphabetic between
  inedges.m and neighbors.m.
* scripts/graph/ismultigraph.m: new free function (canonical help
target plus a helpful-error fallback for non-graph inputs) that
delegates to the class method via dot notation.  44 BIST blocks
cover digraph true/false cases (parallel edges, weighted parallel,
named parallel, EdgeTable duplicates, parallel self-loops,
anti-parallel is not parallel), digraph false cases (plain, empty,
edgeless, multigraph-flag-without-dupes, adjacency built, EdgeTable
without dupes), graph always-false cases, return-type and
dot-notation dispatch, Siever 9-node fixture, and %!error blocks
for non-graph scalar, vector, string, cell, struct, sparse, and
empty inputs plus nargin==0.

* scripts/graph/graph.m: new ismultigraph classdef method that
returns constant false -- the undirected graph class in this build
does not accept a 'multigraph' constructor flag (see US-C10, which
only added multigraph storage to digraph), so parallel edges cannot
be stored.  Top-level @Seealso extended to list ismultigraph.

* scripts/graph/module.mk: ismultigraph.m added to
%canon_reldir%_FCN_FILES in alphabetic order between inedges.m and
laplacian.m.

digraph.ismultigraph was already implemented as a classdef method
in US-C10 and is unchanged.  ismultigraph 44/44, full graph suite
1241/1241 -- no regression.
Ytuf and others added 29 commits April 21, 2026 13:20
Extend the centrality() method in graph.m and digraph.m to accept
MATLAB's 'Cost' and 'Importance' per-edge weight vector options.

'Cost' is accepted for closeness/incloseness/outcloseness/betweenness
and supplies strictly positive per-edge costs of length numedges(G)
that override any stored edge weights when computing shortest paths.
Weighted betweenness runs a Dijkstra Brandes variant (O(N^2) per
source, linear-scan extract-min) in place of the BFS default.

'Importance' is accepted for pagerank/eigenvector/hubs/authorities
and supplies non-negative per-edge importances of length numedges(G)
that override any stored edge weights in the iterative computation.

Each helper gains its own Name-Value parser with consistent
validation (length == numedges(G), numeric real, finite, sign-
appropriate for the option).  opts_accepting_types in both class
methods grows from {"pagerank"} to the full eight-entry whitelist so
the other seven TYPE cases now forward varargin{:} to their helpers.
Degree-based variants (degree/indegree/outdegree) still reject every
Name-Value option with the familiar "no name-value options are
supported" error.

Tests: +20 closeness BIST, +19 betweenness BIST (including a Dijkstra
Brandes rerouting test where Cost = [10;1;1;1] shifts the central
node's betweenness from 2 to 3), +12 eigenvector BIST, +12 hits BIST,
+13 pagerank BIST, +34 centrality.m BIST (incl. cross-type option
rejection, case-insensitivity, dot-notation dispatch), and two legacy
"no name-value" rejection regexes updated to "unknown TYPE option"
since eigenvector/hubs/authorities now accept Importance.

Full scripts/graph suite: 3595/3595 PASS (prev 3489 + 106 net new).
NO REGRESSIONS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add isisomorphic(G1, G2) as both a free function in
scripts/graph/ and a method on the graph and digraph classes.
Returns a scalar logical: true iff the two graphs have the same
structural adjacency (node names and edge weights are ignored;
edge multiplicities for multigraphs and self-loops are honoured).

The search uses the VF2 algorithm (Cordella, Foggia, Sansone,
Vento, 2004) via a new private helper __isomorphism_vf2__ that
accepts N-by-N adjacency matrices plus a directed flag and
returns either a column permutation vector (future use by
isomorphism()) or []/false.  Pruning: node and edge count,
sorted row/column sums (degree sequences), sorted diagonal
(self-loop multiset) before recursion; per-candidate degree
match, self-loop parity, and mapped-neighbour consistency
during recursion; VF2 terminal-set candidate selection to
eliminate symmetric branches.

test __isomorphism_vf2__ -> 17/17 PASS
test isisomorphic        -> 29/29 PASS
test digraph             -> 278/278 PASS (no regression)
test graph               -> 199/199 PASS (no regression)
isomorphism(G1, G2) returns a column permutation vector P such that
reordernodes(G2, P) has the same structure as G1, or [] when no
isomorphism exists (matching MATLAB's convention: adjacency(G2)(P, P)
== adjacency(G1)).

Delegates to the existing __isomorphism_vf2__ private helper from
US-IS01 and inverts its f21-style perm (G2-to-G1 direction) to the
MATLAB f12-style P (G1-to-G2 direction).  Node names and edge weights
are ignored -- structure only.

Files:
- scripts/graph/isomorphism.m (new, free-function wrapper with 32
  BIST blocks covering digraph/graph iso + non-iso cases, empty
  graphs, single/edgeless, named graphs, self-loops, dot-notation
  dispatch, cross-class errors, print_usage).
- scripts/graph/graph.m (+55 lines: new isomorphism method).
- scripts/graph/digraph.m (+55 lines: same, directed variant).
- scripts/graph/module.mk (+1: register isomorphism.m).

Verification:
- test isomorphism -> 32/32 PASS
- test isisomorphic -> 29/29 PASS (no regression)
- test __isomorphism_vf2__ -> 17/17 PASS (no regression)
- test graph / digraph / __matlab_ref__ all PASS
- Full scripts/graph public+private suite: 2985+688 = 3673/3673 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the isomorphism class methods (and the free function) to
accept trailing name-value pairs that restrict the VF2 search to
mappings preserving per-node or per-edge labels.

- NodeVariables (char or cellstr): names of fields in G.Nodes that
  must agree between a G1 node and its mapped G2 partner.
- EdgeVariables (char or cellstr): names of fields in G.Edges that
  must agree between corresponding edges under the mapping.
  Rejected for multigraphs with a clear error message.

The VF2 helper gained two optional color-vector arguments (nc1/nc2)
and two optional color-matrix arguments (ec1/ec2) that are checked
inside the feasibility test, so incompatible candidate pairs prune
early rather than being rejected only after a full mapping is found.
Node-color multiset and edge-color multiset quick-rejects are added
ahead of the search.

Two new private helpers host the option parsing and label-to-color
serialization: __isomorphism_parse_opts__ validates the name-value
pairs, extracts the requested table columns, and builds the per-edge
color adjacency matrix (symmetric for graph, asymmetric for digraph);
__combine_labels__ serializes tuples of cellstr / numeric / logical
columns through unique() to produce integer color codes shared across
G1 and G2.

Help for isomorphism, the private helpers, and the VF2 engine now
documents the optional argument forms.

Files changed:
- scripts/graph/isomorphism.m (docstring, varargin delegation,
  27 new BIST blocks covering NodeVariables/EdgeVariables success
  and error paths, dot-notation dispatch).
- scripts/graph/graph.m (varargin, docstring, call into parser).
- scripts/graph/digraph.m (same, directed).
- scripts/graph/private/__isomorphism_vf2__.m (nc/ec args, feasibility
  changes, quick rejects, 9 new BIST blocks).
- scripts/graph/private/__isomorphism_parse_opts__.m (new, ~290 lines
  including 12 BIST blocks).
- scripts/graph/private/__combine_labels__.m (new, ~130 lines
  including 9 BIST blocks).
- scripts/graph/module.mk (register both new private helpers).

Verification:
- test isomorphism                               -> 59/59 PASS
- test __isomorphism_vf2__                       -> 26/26 PASS
- test __isomorphism_parse_opts__                -> 12/12 PASS
- test __combine_labels__                        ->  9/9  PASS
- test graph                                     -> 199/199 PASS
- test digraph                                   -> 278/278 PASS
- test isisomorphic                              ->  29/29 PASS
- Full scripts/graph public                      -> 3012/3012 PASS
- Full scripts/graph private                     -> 718/718 PASS
- help isomorphism renders (NodeVariables / EdgeVariables visible).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New files:

 * scripts/graph/GraphPlot.m --- classdef GraphPlot < handle
   with XData/YData/ZData, NodeColor/EdgeColor/Marker/MarkerSize/
   LineStyle/LineWidth, read-only NumNodes/NumEdges, and private
   axes/edge/node handle caches.  Constructor accepts 0 args (empty
   object) or a graph/digraph plus optional Layout/XData/YData
   name-value pairs.  Rendering wrapped in try/catch so the object is
   still returned cleanly in non-interactive graphics environments.

 * scripts/graph/plot.m --- free function that dispatches plot(G) to
   the classdef plot method on the graph / digraph instance.

 * scripts/graph/private/__graph_plot_auto_layout__.m --- layout
   helper.  'auto' dispatches to 'subspace' (<100 nodes) or 'force'
   (>=100 nodes); both branches currently route to a deterministic
   unit-circle placeholder that will be replaced by the real
   algorithms in US-GP03 (force) and US-GP06 (subspace).

Modified files:

 * scripts/graph/digraph.m --- added plot (G, varargin) method that
   delegates to the GraphPlot constructor.
 * scripts/graph/graph.m --- parallel plot method for undirected
   graphs.
 * scripts/graph/module.mk --- registered the new files in
   _FCN_FILES / _PRIVATE_FCN_FILES in alphabetic order.

BIST:
 * GraphPlot                          13/13
 * plot                                8/8
 * __graph_plot_auto_layout__         12/12
 * digraph                           278/278 (no regression)
 * graph                             199/199 (no regression)

Includes a %!demo block on plot.m that draws a 4-cycle digraph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'circle' layout was already wired through
__graph_plot_auto_layout__.m's 'circle' case since US-GP01 as the
deterministic fallback shared by the 'auto', 'subspace' and 'force'
branches.  Promote it to a first-class user-facing layout with full
BIST coverage and docstring updates:

  * scripts/graph/private/__graph_plot_auto_layout__.m: rewrite the
    @table bullet for 'circle' from a one-line note to a full
    paragraph declaring the layout production-quality, documenting
    the start-at-(1, 0) CCW convention (theta = 2*pi*(k-1)/N), and
    calling out the N==0 and N==1 special cases; +10 BIST blocks
    covering N==0/1/2 edge cases, the unit-circle property, uniform
    chord length 2*sin(pi/N), the start-at-(1, 0) CCW direction,
    determinism, graph/digraph parity, independence from edge weights
    and node names, isolated trailing nodes, and the column-vector
    return contract.

  * scripts/graph/plot.m: +9 BIST blocks covering
    plot (G, 'Layout', 'circle') on both graph and digraph
    (unit-circle placement, uniform 5-cycle chord length,
    case-insensitive layout name, N==0/1/100 sanity, named and
    weighted digraph independence, isolated trailing nodes,
    determinism) plus a second %!demo rendering a 6-cycle on the
    unit circle.  Docstring gains a paragraph enumerating the
    recognised layout names with a case-insensitivity note.

  * scripts/graph/GraphPlot.m: +4 BIST blocks covering the
    constructor path directly (select circle layout, graph/digraph
    parity, XData/YData overrides win, explicit
    X=[1;0;-1;0] Y=[0;1;0;-1] for N=4 verifying the start-at-(1, 0)
    CCW direction).

No implementation change was required.

Verification:
  * test __graph_plot_auto_layout__  22/22 PASS (prev 12/12, +10)
  * test plot                        17/17 PASS (prev 8/8,   +9)
  * test GraphPlot                   17/17 PASS (prev 13/13, +4)
  * scripts/graph public suite       3046/3046 PASS, no regressions
  * scripts/graph private suite       740/740  PASS, no regressions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the 'force' / 'auto' (N>=100) layout placeholder with a real
Fruchterman-Reingold 2-D implementation.  New private helper
__graph_plot_force__.m runs the classical vectorised F-R recipe:
pairwise repulsive forces (k^2/d) plus attractive forces on
edges (d^2/k * W_eff) for 100 iterations with linear temperature
cool-down from t0=0.1 to 0; k = sqrt(1/N) as the ideal spring
length.  Weighted adjacency is symmetrised (A + A.') so directed
and undirected graphs yield the same layout when the edge set is
symmetric.

New 'WeightEffect' option on GraphPlot/plot() (case-insensitive):
 - 'none'   (default): weights ignored
 - 'direct': attractive force multiplied by edge weight
 - 'inverse': attractive force divided by edge weight

Determinism: the algorithm seeds the global RNG internally with
rand('state', 42) and restores the caller's state on exit via
unwind_protect.  Repeat calls return identical coordinates; the
layout is independent of the caller's RNG state.

__graph_plot_auto_layout__ gained an optional opts-struct argument
whose WeightEffect field is forwarded to the force branch.
GraphPlot.m parses 'WeightEffect' as a name-value pair and forwards
it via opts.WeightEffect.  plot.m docstring updated to document the
new layout and option, plus a new %!demo.

Test summary:
  __graph_plot_force__        22/22 PASS
  __graph_plot_auto_layout__  34/34 PASS (prev 22/22)
  GraphPlot                   26/26 PASS (prev 17/17)
  plot                        23/23 PASS (prev 17/17)
  scripts/graph public      3061/3061 PASS (prev 3046)
  scripts/graph private      774/774  PASS (prev 740)

No regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New private helper scripts/graph/private/__graph_plot_force3__.m
implements the 3-D Fruchterman-Reingold algorithm: vectorised
pairwise repulsive and attractive force sums with k = (1/N)^(1/3)
as the unit-volume ideal spring length, 100 iterations with linear
cool-down, internally-seeded RNG (rand('state', 42)) and
unwind_protect restoration of the caller's global RNG state for
determinism independent of caller state.  Same WeightEffect
semantics as the 2-D force helper (none / direct / inverse).

__graph_plot_auto_layout__ gains an optional third output Z
(zeros(0,1) for 2-D layouts, populated by the new 'force3' branch)
and a new 'force3' layout case that delegates to the helper.

GraphPlot picks up the optional Z, stores h.ZData as a column vector
(empty for 2-D), and dispatches edge and node rendering through
plot3 when ZData is non-empty, preserving all 2-D behaviour.

plot.m docstring documents 'force3' alongside 'force', explains the
3-D ZData contract, and adds a new %!demo.

Test coverage:
- __graph_plot_force3__:      25/25 PASS (new file)
- __graph_plot_auto_layout__: 42/42 PASS (+8)
- GraphPlot:                  35/35 PASS (+9)
- plot:                       29/29 PASS (+6)
- Full scripts/graph public:  3076/3076 PASS (+15)
- Full scripts/graph private:  807/807  PASS (+33)
- __matlab_ref__:             5/5 PASS (no regression)
New private helper scripts/graph/private/__graph_plot_layered__.m implements a Sugiyama-style hierarchical layout: break cycles via DFS back-edge reversal (for digraphs) or BFS orientation (for undirected graphs), compute longest-path ranks (ASAP) or latest-path ranks (ALAP) using Kahn's algorithm on the working adjacency, apply user-declared Sources/Sinks constraints by detaching them from their predecessors/successors, then reduce crossings with 8 iterations of barycenter sweeps using a stable sort for full determinism. Direction={down,up,left,right} transforms the final coordinates.

auto_layout forwards Direction/Sources/Sinks/AssignLayers fields from opts; GraphPlot adds the four matching name-value options with char-row validation; plot docstring documents the layered layout and its options; module.mk adds the helper.

Tests: __graph_plot_layered__ 44/44, __graph_plot_auto_layout__ 55/55 (+13), GraphPlot 52/52 (+17), plot 40/40 (+11); full graph public suite 3104/3104 (+28), private 864/864 (+57), zero regressions.
Replace the circle-based subspace placeholder with real spectral
layouts driven by Laplacian eigendecomposition (Hall-style embedding).

* New private helpers:
  - __graph_plot_subspace_embedding__.m: builds the symmetric
    unweighted, self-loop-free Laplacian and returns the
    sign-normalised eigenvectors of the DIMENSION smallest non-trivial
    eigenvalues (kernel eigenvectors skipped; padded with zero columns
    when fewer than DIMENSION non-trivial modes exist).
  - __graph_plot_subspace__.m: 2-D spectral layout (X = v2, Y = v3).
    Accepts DIMENSION >= 2; default min(100, numnodes(G)).
  - __graph_plot_subspace3__.m: 3-D spectral layout (X, Y, Z from
    v2, v3, v4).  Accepts DIMENSION >= 3.
* __graph_plot_auto_layout__.m dispatches 'subspace' and 'subspace3'
  to the new helpers, forwards a new Dimension option, and the 'auto'
  branch (N<100) now uses real spectral layout instead of the circle
  placeholder.
* GraphPlot.m accepts a new 'Dimension' name-value option and routes
  it through layout_opts.
* plot.m docstring lists subspace/subspace3 and the Dimension option;
  new BIST coverage and %!demo blocks.
* module.mk registers the three new private .m files.

Layouts are fully deterministic (no RNG; eigenvector signs normalised
so the first max-magnitude entry is positive).  Edge weights and
self-loops are ignored, matching laplacian()'s convention.

Test counts (scripts/graph only):
- __graph_plot_subspace_embedding__  22/22 PASS
- __graph_plot_subspace__            24/24 PASS
- __graph_plot_subspace3__           26/26 PASS
- __graph_plot_auto_layout__         76/76 PASS (was 55)
- GraphPlot                          65/65 PASS (was 52)
- plot                               49/49 PASS (was 40)
- Full scripts/graph suite         4083/4083 PASS, 0 failures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the following cosmetic name-value options to GraphPlot and make
them available for round-trip set/get (both via constructor and direct
assignment on the handle):

  NodeColor, Marker, MarkerSize, NodeLabel, NodeFontSize, NodeFontName,
  NodeFontAngle, NodeFontWeight, NodeLabelMode, NodeLabelColor

NodeLabel and NodeLabelMode cooperate as in MATLAB: setting NodeLabel
flips NodeLabelMode to 'manual'; re-assigning NodeLabelMode = 'auto'
regenerates the label from the cached graph's Nodes.Name (or "1","2",
...  for unnamed nodes).  Labels are rendered next to each marker as
text objects inside the same best-effort try/catch that wraps the rest
of the draw.

Three new private helpers:
  private/__graph_plot_default_labels__.m
  private/__graph_plot_validate_colorspec__.m
  private/__graph_plot_validate_nodelabel__.m

Tests: 113/113 GraphPlot (was 65), 25/25 colorspec, 14/14 nodelabel,
5/5 default_labels, 3174/3174 scripts/graph public, 1001/1001 private.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add ten edge-appearance cosmetic properties to GraphPlot and the
validation / rendering plumbing behind them:

* Plain properties: ArrowSize (default 7), ArrowPosition (default
  0.5, strictly in (0, 1)), EdgeAlpha (default 0.5, in [0, 1]),
  EdgeFontSize (default 8), EdgeFontName (default "Helvetica").
* Dependent properties: EdgeLabel and EdgeLabelMode, backed by
  private edge_label_ / edge_label_mode_, mirroring the
  NodeLabel / NodeLabelMode pattern.  Auto-default is weight
  strings on a weighted graph and empty on an unweighted graph.
  Assigning EdgeLabel flips the mode to "manual"; assigning
  EdgeLabelMode = "auto" regenerates labels from the cached graph.
* Validated setters added for LineWidth (positive real scalar),
  LineStyle (MATLAB style list), ArrowSize, ArrowPosition,
  EdgeAlpha, EdgeFontSize, EdgeFontName.
* Constructor name-value loop now accepts EdgeColor, LineWidth,
  LineStyle, ArrowSize, ArrowPosition, EdgeAlpha, EdgeLabel,
  EdgeLabelMode, EdgeFontSize, EdgeFontName (case-insensitive).
* Edge labels render as text objects at ArrowPosition along each
  edge, best-effort (failures do not invalidate property state).

New private helpers __graph_plot_validate_edgelabel__.m and
__graph_plot_default_edge_labels__.m implement the edge-label
validator and auto-default generator, with their own BIST suites
(15 and 7 tests respectively).

37 new BIST blocks in GraphPlot.m (all pass):
  test GraphPlot                              172/172
  test __graph_plot_validate_edgelabel__       15/15
  test __graph_plot_default_edge_labels__       7/7
  scripts/graph + scripts/graph/private      4256/4256
  __matlab_ref__                                 5/5
  plot                                          49/49

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-GP09)

Add the highlight function for GraphPlot objects, allowing users to
visually distinguish specific nodes by changing their color, marker,
or marker size.

New: scripts/graph/highlight.m
- Free-function entry point dispatching to GraphPlot.highlight method.
- Texinfo docstring with synopsis, options table, and example.
- 6 BIST tests (smoke + free-function dispatch + error cases).

Extended: scripts/graph/GraphPlot.m
- New highlight (h, nodes, varargin) method inside the classdef.
- Accepts numeric indices, bare char node name, or cellstr of names.
- Default action: set NodeColor to red ([1 0 0]) for specified nodes.
- Name/Value overrides: NodeColor, Marker, MarkerSize (case-insensitive).
- Expands properties to per-node form (NodeColor Nx3 / Marker Nx1 cellstr
  / MarkerSize Nx1 vector) lazily, only when needed.  Nodes not in the
  selection keep their current cosmetic values.  Empty NODES is a silent
  no-op that does not expand any property.
- Extended set.NodeColor to accept Nx3 matrices (per-node).
- Extended set.Marker to accept cellstr of length NumNodes.
- Extended set.MarkerSize to accept a positive real vector of length
  NumNodes.
- 30 new BIST blocks covering the acceptance criteria, case-insensitive
  dispatch, handle-class sharing, two-call composition, undirected
  graphs, and error regex matches for every validation path.

Module.mk registers scripts/graph/highlight.m.

Verification:
- test GraphPlot                    -> 202/202 PASS (was 172)
- test highlight                    -> 6/6 PASS
- scripts/graph public (all .m)     -> 3269/3269 PASS
- scripts/graph private (all .m)    -> 1023/1023 PASS
- test plot                         -> 49/49 PASS (no regression)
- test __matlab_ref__               -> 5/5 PASS (no regression)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…GP10)

Extend GraphPlot.highlight to accept the edge-endpoint form
highlight(h, s, t[, Name, Value, ...]).  Dispatch between node and
edge form is decided by the 3rd positional argument: if it is a
char row matching a known option name (NodeColor / Marker /
MarkerSize / EdgeColor / LineWidth / LineStyle) the call stays in
the node form, otherwise it is treated as edge form with s=arg2,
t=arg3.  This matches MATLAB's dispatch behaviour and makes
highlight(h, 'alpha', 'beta') resolve as an edge highlight when
'alpha' and 'beta' are node names in the graph.

The edge form reuses __findedge_impl__ for endpoint resolution,
so it handles undirected graphs (normalising to min/max),
multigraphs, and name-based lookups consistently with findedge.
Missing edges raise a clear error distinguishing "node name not
found" from "no edge connects S, T".

EdgeColor expands to Mx3 on first use; LineWidth to Mx1 and
LineStyle to Mx1 cellstr when a corresponding override is
supplied.  Empty (s, t) is a silent no-op that leaves the scalar
properties untouched, paralleling the empty-nodes no-op in US-GP09.

Setters for EdgeColor, LineWidth, and LineStyle were extended to
accept the per-edge forms used by highlight while preserving
scalar assignment.  31 new BIST blocks and the updated
help highlight render both deftypefn synopses and the full option
table; the free-function highlight.m docstring was expanded to
mirror the method docstring.  All 235 GraphPlot tests and the full
scripts/graph suite (4325/4325) pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hlighting (US-GP11)

Adds the MATLAB-style edge-index form of highlight() where the literal
keyword 'Edges' followed by a numeric vector of 1-based edge indices
selects those edges by their row position in G.Edges.EndNodes.

* scripts/graph/GraphPlot.m: new dispatch branch at the top of the
  highlight method that detects the 'Edges' keyword (case-insensitive
  char row) BEFORE the existing node-vs-(s, t) dispatch.  Validates idx
  as a positive-integer vector within 1..NumEdges, parses trailing
  EdgeColor / LineWidth / LineStyle name-value overrides, and expands
  scalar cosmetic properties to per-edge form on first highlight.
  Empty idx is a silent no-op.  Adds two new @deftypefnx synopses plus
  descriptive paragraph to the texinfo docstring.  29 new BIST blocks.
* scripts/graph/highlight.m: adds matching @deftypefnx synopses and an
  example to the free-function docstring.  One new BIST block verifies
  dispatch through the free-function entry point.

Test results:
- test GraphPlot                -> 264/264 PASS (was 235)
- test highlight                -> 7/7 PASS (was 6)
- test digraph                  -> 278/278 PASS (no regression)
- test graph                    -> 199/199 PASS (no regression)
- test plot                     -> 49/49 PASS (no regression)
- test __matlab_ref__           -> 5/5 PASS (no regression)
- scripts/graph public (all .m) -> 3332/3332 PASS
- scripts/graph private         -> 1023/1023 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the labeledge method to the GraphPlot handle class so users can set
per-edge text labels post-construction.  Both the edge-index form
labeledge(h, idx, labels) and the (s, t) endpoint form labeledge(h, s,
t, labels) are supported, matching MATLAB's API.  Labels may be a
cellstr, a numeric vector (converted via num2str), or a scalar string /
numeric / single-cell value that is broadcast to all selected edges.
The method flips EdgeLabelMode to 'manual' and preserves existing
labels on edges outside the selection.

Dispatch is purely positional (numel(varargin)==2 vs 3), which avoids
the option-name ambiguity that the highlight method had to resolve with
a keyword lookup.  Endpoint resolution reuses __findedge_impl__ and
label validation reuses __graph_plot_validate_edgelabel__, keeping the
error wording consistent with the existing highlight and EdgeLabel
setter code paths.

A free-function entry point scripts/graph/labeledge.m delegates to the
classdef method and renders 'help labeledge' cleanly.

Tests: +41 new BIST blocks (33 in GraphPlot.m, 8 in labeledge.m)
covering scalar/vector/column idx, numeric vs cellstr labels, scalar
broadcast (char / numeric / single-cell), composition, (s,t) form with
numeric and cellstr endpoints, undirected (s,t)==(t,s), handle-class
semantics, and 14 error regexes.

Verification:
- test GraphPlot            -> 297/297 PASS (was 264)
- test labeledge            -> 8/8 PASS (new)
- test highlight            -> 7/7 PASS (no regression)
- test digraph              -> 278/278 PASS (no regression)
- test graph                -> 199/199 PASS (no regression)
- test plot                 -> 49/49 PASS (no regression)
- test __matlab_ref__       -> 5/5 PASS (no regression)
- Full scripts/graph public -> 3373/3373 PASS
- scripts/graph private     -> 1023/1023 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add labelnode method on GraphPlot and matching free-function entry.
Node identifier resolution reuses __resolve_node_list__ (numeric any
shape, char row = single node name, cellstr of names, or empty = no
op); label validation reuses __graph_plot_validate_nodelabel__.  Label
broadcast mirrors labeledge: char row, scalar numeric (via num2str),
and single-cell cellstr broadcast to the resolved node count.  Labels
are overlaid at resolved indices into a full NumNodes-length column
cellstr and pushed back through the Dependent NodeLabel setter so
NodeLabelMode flips to 'manual'.

* scripts/graph/GraphPlot.m: new labelnode method plus 29 new BIST
  blocks (scalar/vector/column/2-D-matrix indices, numeric vs cellstr
  vs char-row vs single-cell-cellstr labels, scalar-string and
  scalar-numeric and single-cell broadcast, char-row single-name,
  cellstr names, 1-element cellstr name, undirected-graph parity,
  composition, empty-nodes and empty-cellstr no-ops, handle-class
  aliasing, post-manual overlay, plus 10 error cases).  labeledge
  method @Seealso extended with labelnode.
* scripts/graph/labelnode.m: new free-function entry point with full
  texinfo docstring and 8 BIST blocks for the free-function dispatch.
* scripts/graph/labeledge.m: @Seealso extended with labelnode.
* scripts/graph/module.mk: labelnode.m registered alphabetically.

test GraphPlot 326/326, test labelnode 8/8, test labeledge 8/8, full
scripts/graph public 3410/3410 (+37 net new), private 1023/1023; no
regressions.
…P14)

Adds ZData name-value option to the GraphPlot constructor and plot(G)
wrapper so callers can supply explicit 3-D node coordinates that bypass
the layout step.

- GraphPlot (G, 'XData', x, 'YData', y, 'ZData', z) stores z as a
  column double of length numnodes (G) and is_3d becomes true, so the
  existing plot3 render path is used.
- GraphPlot (G, 'XData', x, 'YData', y) continues to produce a 2-D
  bypass with h.ZData empty (regression-safe).
- GraphPlot (G, ..., 'ZData', []) is treated as no ZData (2-D bypass).
- ZData without XData AND YData is rejected with error
  'ZData requires XData and YData to also be supplied'.
- ZData length mismatch errors with
  'ZData length must equal numnodes (G)'.

17 new BIST blocks in scripts/graph/GraphPlot.m (3-D bypass; bypass
overrides 'circle' and 'force3' layouts; 2-D with explicit Z=[];
column / row / int32 Z forms; undirected parity; N>0 isolated trailing
nodes; 5 error cases covering length mismatches and Z-alone rejection)
and 5 new BIST blocks in scripts/graph/plot.m (3-D round-trip via the
free function, Z=[] = 2-D, length and Z-alone errors).  No other files
changed.

GraphPlot 343/343 (prev 326), plot 53/53 (prev 49), full scripts/graph
public 3431/3431 (+21 net), private 1023/1023 unchanged.  digraph,
graph, highlight, labeledge, labelnode regressions all green.
Add BIST coverage that reads the MATLAB-parity default cosmetic
properties on the GraphPlot returned by plot(G) on real graph and
digraph objects.

Defaults exercised (all already in place from earlier stories, this
story pins them under test):
- NodeColor / EdgeColor = [0 0.4470 0.7410] (MATLAB default series
  color 1 blue)
- Marker = "o"; MarkerSize = 4
- LineWidth = 0.5; LineStyle = "-"
- ArrowSize = 7; ArrowPosition = 0.5; EdgeAlpha = 0.5

13 new BIST blocks in scripts/graph/GraphPlot.m and 3 new blocks in
scripts/graph/plot.m cover: unweighted and weighted digraph/graph,
named-node graph, isolated-nodes-only digraph, empty digraph (N=0),
GraphPlot() empty constructor, survival across set-then-restore,
and invariance across Layout selection ("auto", "circle", "force",
"subspace", "layered").

Verification:
- test GraphPlot               -> 356/356 PASS (was 343, +13)
- test plot                    -> 56/56 PASS (was 53, +3)
- test digraph / test graph    -> 278/278 and 199/199 PASS (no
                                   regression)
- Full scripts/graph public    -> 3447/3447 PASS (was 3431, +16)
- Full scripts/graph private   -> 1023/1023 PASS (no regression)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PS01)

Add MATLAB-compatible `saveobj(G)` instance method and `static loadobj(s)`
method to both digraph and graph classes so objects survive a -v7
MAT-file round-trip.

`saveobj` returns a scalar struct containing the internal state
(adj_, nodenames_, has_weights_, edge_attrs_, node_attrs_, plus
is_multigraph_ / mg_endnodes_ / mg_weights_ for digraph). Field
names mirror the private property names so the same struct flows
out of Octave's default classdef-to-struct save path.

`static loadobj(s)` reconstructs a digraph / graph from either that
struct or an existing object of the target class (idempotent path
for future Octave versions that preserve classdef through load).

Because Octave's MAT5 load currently converts classdef elements to
plain structs (Octave:load:classdef-to-struct warning) and does not
auto-invoke loadobj, callers finish the round-trip with:
  S = load('G.mat');  G = digraph.loadobj(S.G);
MATLAB calls loadobj automatically, so the same code is a no-op
there.

21 new BIST blocks cover saveobj struct shape, loadobj(struct) and
loadobj(object) paths, save -v7 round-trip across unweighted /
weighted / named / empty / isolated-only / multigraph cases, and
input-validation errors.

test digraph 289/289 (+11); test graph 209/209 (+10); full
scripts/graph public 3468/3468 (+21); private 1023/1023 unchanged.
…-PS02)

The `Nodes` and `Edges` properties are declared `Dependent, SetAccess =
private`, so reading them via chained subsref (G.Edges.Weight,
G.Edges.EndNodes(i,:), G.Nodes.Name{i}, ...) works as a natural
consequence of the getters returning a plain struct, and any form of
assignment (direct, chained, indexed, brace, or dynamic-field) errors
with "private access and cannot be set" -- Octave's MATLAB-parity
equivalent of "you cannot set the read-only property 'Edges' of
digraph/graph".

This change locks both directions in with 47 new BIST blocks across
the two classdef files so that a future refactor cannot silently break
the read-only contract.  The underlying infrastructure was built in
US-C04 / US-C14; US-PS02 is the test-lock-in layer.

* scripts/graph/digraph.m (US-PS02 section): shared fixture
  Gd_ps02 / Gdn_ps02 / Gdm_ps02 (plain / named / multigraph);
  12 %!test <*PS02> read-path blocks covering full-field,
  indexed, brace, end-index, dynamic-field, and multigraph
  chained reads plus the numel(Weight)==numedges and
  cell(0,1) unnamed-default invariants;
  12 %!error <private access> write-path blocks covering
  direct, chained, indexed, brace, new-field, empty-rhs,
  and dynamic-field assignments.
* scripts/graph/graph.m (US-PS02 section): parallel content
  with shared fixture Gu_ps02 / Gun_ps02 and no multigraph
  case -- undirected graph has no 'multigraph' constructor
  flag (scripts/graph/graph.m:737).  The lex-(s<=t) edge order
  is reflected in the expected Weight = [10;30;20] and
  EndNodes = [1 2; 1 3; 2 3] for the test fixture.

test digraph -> 313/313 (was 289, +24); test graph -> 232/232
(was 209, +23).  Full scripts/graph public suite -> 3515/3515
(was 3468, +47); private suite -> 1023/1023 unchanged;
test __matlab_ref__ -> 5/5 unchanged.  No regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Override horzcat, vertcat, and cat on both classdef types to throw
a MATLAB-compatible "Concatenation of <graph|digraph> objects is
not allowed. Use addnode or addedge to modify an existing
<graph|digraph>." error.  The error fires for explicit
horzcat/vertcat/cat calls as well as bracket-concat [G1, G2] /
[G1; G2] (through Octave classdef dispatch).  Single-element [G]
remains a no-op per MATLAB parity.

14 new BIST blocks per file (+28 total) verify: bracket horzcat,
bracket vertcat, explicit horzcat, explicit vertcat, cat(1,...)
and cat(2,...), three-operand varargin path, named/unnamed and
weighted-mixed pairs, the lasterror inner-message preservation,
and the single-element no-op.  Bracket-dispatch wraps the error
with "<class>/<method> method failed"; explicit calls preserve
the full message -- tests cover both surfaces.

Verification:
- test scripts/graph/digraph.m -> 327/327 (prev 313, +14)
- test scripts/graph/graph.m   -> 246/246 (prev 232, +14)
- Full scripts/graph public    -> 3543/3543 (prev 3515, +28)
- Full scripts/graph private   -> 1023/1023 (unchanged)
- test __matlab_ref__          -> 5/5 (unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both `classdef digraph` and `classdef graph` are declared without
`< handle`, so Octave's classdef machinery already gives every
assignment `G2 = G1` full value-copy semantics and every mutator
method (addnode, addedge, rmnode, rmedge, reordernodes, subgraph,
digraph-only flipedge) returns a new object rather than modifying
its argument in place -- MATLAB parity.  This commit locks that
contract in with 27 BIST blocks (14 digraph, 13 graph -- graph has
no flipedge) that each follow the pattern:

    G1 = <fixture>;
    G2 = G1;
    G2 = <mutator> (G2, ...);
    assert (<source unchanged>);

covering unweighted / weighted / named fixtures and chained
mutations.  Also covered: a three-level G1 -> G2 -> G3 chain, a
`local_hammer` nested function (pass-by-value through function
boundary), and the return-value-doesn't-alias-source case
`H = addedge (G1, ...)`.  Every block carries the `<*PS04>` test
ID for easy grouping under `test digraph verbose` / `test graph
verbose`.

A future refactor to `classdef digraph < handle` (reference
semantics) would trip every block in this section -- that is the
intent of the lock.
Add test/graph/ with 29 MATLAB-doc-example scripts, a runner, a
%!test wrapper, and a module.mk.  Each doc-example script ends
with assert() calls so a wrong result fails the suite rather than
passing silently.

  - test/graph/doc-examples/*.m  (29 files):
    digraph/graph constructors (numeric edge list, weighted, node
    names, adjacency matrix, omitselfloops, multigraph), addedge /
    addnode / rmedge / rmnode, neighbors / successors / predecessors,
    shortestpath / distances, bfsearch / dfsearch, centrality,
    conncomp, toposort / isdag, subgraph, flipedge, reordernodes,
    findnode / findedge, degree / indegree / outdegree,
    adjacency / incidence / laplacian.
  - test/graph/run_doc_examples.m: walks doc-examples/ in stable
    alphabetic order and runs each script inside a private
    subfunction's isolated workspace.  Returns a struct with
    fields total, npass, nfail, ran, failures.  4 own %!test
    BIST blocks cover discovery, failure accounting, non-existent
    directory, and non-string EXAMPLES_DIR.
  - test/graph/doc-examples.tst: 4 %!test <*US-R02> blocks that
    probe pwd+test/graph, pwd+graph, and pwd for the runner so
    the tst works whether make check runs it from the source
    root, from test/, or from test/graph/.
  - test/graph/module.mk: lists the runner, the .tst file, and
    all 29 example scripts.
  - test/Makefile.am: add include graph/module.mk alphabetically
    between file-encoding/ and help/.

All 29 examples pass under run_doc_examples; the 4 .tst blocks
pass from three different working directories.  No regressions:
test digraph 341/341, test graph 259/259, test __matlab_ref__ 5/5,
test addedge / rmnode / shortestpath / plot.m all unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add test/graph-bench.m, a caller-parameterisable script (the dash in
its filename precludes a function) that times distances, shortestpath,
centrality, and plot on random uniform-Erdos-Renyi digraphs at a
configurable sequence of node counts.  Parallel-edge deduplication is
done driver-side so the graph ctor's simple-graph constraint is
respected without needing the "multigraph" flag.

Results land in a bench_results struct; passing results_file also
writes a human-readable text table.  Seven %!test <*US-R03> blocks
cover smoke, multi-size rectangular timings, plot-skip above
max_plot_size, results_file round-trip, reproducibility, and input
validation.  Reference timings at 1e3/1e4/1e5 committed to
doc/graph-bench-results.txt with reproduction instructions; 1e6 row
documented as unmeasured on this host.
Add a new 'Graph and network analysis' section to NEWS.12.md describing
the graph, digraph, and GraphPlot classes along with the construction,
query, modification, traversal, algorithm, centrality, isomorphism, and
visualization APIs.  Also list every new top-level name in the
alphabetical list of new functions.
Create doc/interpreter/graph.txi with an overview, a short runnable
example, and nine sections covering object construction, structure
queries, modification, matrix representations, traversal and shortest
paths, connectivity and flow, centrality, isomorphism, and
visualization.  Each section pulls in DOCSTRING entries for the
relevant scripts/graph/ functions (49 total).

Wire the new chapter into the book by adding
* Graph and Network Analysis::
to the top-level menu and detailed menu of doc/interpreter/octave.texi
(placed after Sparse Matrices, reflecting the sparse-adjacency
storage used by the classes) and by appending
%reldir%/graph.texi to MUNGED_TEXI_SRC in doc/interpreter/module.mk.

Syntax validated with makeinfo via the Octave macros.texi include
(RC=0, zero warnings) after stubbing the @Docstring expansions and
the Sparse Matrices cross-reference target.

test digraph, test graph, and test __matlab_ref__ all unchanged
(341/341, 259/259, 5/5).
macOS make-check CI surfaced 11 BIST failures in scripts/graph/
(GraphPlot.m:6, distances.m:3, plot.m:2) where the tests cross-check
public-API output against the private helper (__graph_plot_force3__,
__graph_plot_layered__, __graph_plot_subspace__, __graph_plot_subspace3__,
__distances_bellman_ford__).  Under `make check`, BIST blocks execute
outside the parent directory's private-function scope, so direct
calls to those __helpers__ fail with 'undefined'.  The Windows local
driver hid this by explicitly adding scripts/graph/private to the
path via `--path scripts/graph/private`; upstream CI has no such
workaround.

Wrap each affected BIST in a path-snapshot / addpath / restore idiom:

    old_path = path ();
    addpath (fullfile (fileparts (which ("digraph")), "private"));
    unwind_protect
      ...cross-check against private helper...
    unwind_protect_cleanup
      ...; path (old_path);
    end_unwind_protect

The snapshot preserves existing entries (so the Windows driver's
pre-added scripts/graph/private stays on path after each test) while
CI without the pre-add gets private/ added just for the duration of
the test and cleanly removed.  All 11 previously-failing tests now
pass whether private/ is on the path at entry or not:

  test GraphPlot  -> 356/356 (was 350/356 on macOS)
  test distances  -> 133/133 (was 130/133 on macOS)
  test plot       ->  56/56  (was  54/56 on macOS)

No regression in test digraph (341/341), test graph (259/259), or in
the private helpers' own BIST blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three %!error blocks in scripts/graph/GraphPlot.m leaked an invisible
figure + axes each because they constructed a GraphPlot successfully
(which calls newplot()) and then triggered the error via a property
setter.  The %!error macro expects a single expression, so it has no
unwind_protect wrapper to close the leaked figure.  Over the course
of the full test suite, the leaked figure persisted into later plot
tests and caused scripts/plot/util/findobj.m to see 4 root-level
tag="" objects instead of the expected 2 ([root, figure(3)]).  The
analyze-test-suite-results CI step then tripped the `FAIL 0` gate
on Ubuntu and macOS (upstream 73bd1ab same job on 9.4.0 with no
graph/ code passes 15/15).  MinGW jobs were unaffected because the
mingw workflow's analyze step only emits warnings, not failures.

Fix: rewrite the three %!error blocks as explicit %!test blocks with
figure("visible", "off") + unwind_protect + close(hf), using an
inner try/catch that captures the expected error message regex
(NodeLabel / EdgeLabel) so the error-assertion semantics are
preserved.

Test count is unchanged at 356/356.  After the fix, running
test digraph; test graph; test GraphPlot; test plot; test highlight;
test labelnode; test labeledge leaves findobj(0,"tag","") at exactly
2 handles (root + the just-created figure(3)) as upstream expects.

Files changed:
- scripts/graph/GraphPlot.m (+57/-7): 3 %!error -> 3 %!test blocks
  at lines 2589, 3039, 3058 (post-edit numbering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Ytuf

Ytuf commented Apr 21, 2026

Copy link
Copy Markdown
Author

CI update — make check green across all 3 target platforms (HEAD 73c671f9e0)

Summary of the April 21 CI matrix for commit 73c671f9e0: every one of the 3 platforms called out in the test plan reports 0 new failures attributable to scripts/graph/, and the full 52-file scripts/graph/ BIST matrix passes 100% on each. Remaining failures are pre-existing Octave issues that predate this branch and that do not appear in its diff.

Linux — make-ubuntu run 24744390167 ✅ 6/6 cells analyze green

6 cells: ubuntu-22.04 gcc/clang, ubuntu-24.04 gcc/clang, ubuntu-24.04-arm gcc/clang. All reached analyze test suite results with conclusion=success.

Ubuntu-24.04 gcc test-suite.log: PASS 26157, FAIL 0, REGRESSION 0. All 52 scripts/graph/*.m files pass 100% (e.g. digraph.m 341/341, graph.m 259/259, GraphPlot.m 356/356, centrality.m 250/250, distances.m 133/133).

Cross-build armhf (run 24744390195) also landed analyze=success, providing an independent non-x86 Linux confirmation.

macOS — make-macos run 24744390207check green on both arm64 cells

macos-15 Qt6 and macos-14 Qt6 both report:

  • check step conclusion=success
  • test-suite.log: PASS 26130, FAIL 0, REGRESSION 1
  • All 52 scripts/graph/*.m files pass 100% (same pass counts as Linux).

The single REGRESSION is linear-algebra/linsolve.m — the long-standing pre-existing arm64 BLAS/LAPACK flake tracked as Savannah bug #68238. It reproduces on default at the rebase parent and is not introduced by this branch. The CI workflow's analyze test suite results step exits non-zero on any REGRESSION match, which is why the job overall shows red, but the make check itself completed without new failures.

Windows (MSYS2) — make-mingw run 24744390161mingw-w64 CLANG64 full-pipeline green

mingw-w64 CLANG64 concluded end-to-end with conclusion=success, including the analyze test suite results step. All 52 scripts/graph/*.m files pass 100% on Windows (same pass counts as Linux and macOS).

Non-graph failures observed on CLANG64 are all pre-existing Windows-specific Octave issues that do not touch scripts/graph/:

  • libinterp\corefcn\numeric\qr.cc-tst — FAIL 6 (QR Windows flake)
  • libinterp\corefcn\graphics.cc-tst — FAIL 9 / REGRESSION 3 (C++ plotting backend)
  • plot/util/*.m — FAIL tallies across ~14 files (pre-existing Windows plot testutil flakes)
  • plot/appearance/{axis,datetick,legend,rticklabels,thetaticklabels}.m — pre-existing
  • geometry/griddata.m, miscellaneous/delete.m, sparse/gmres.m — pre-existing
  • test/sparse.tst — pre-existing SEGV that truncates the suite summary on clang64

Spot-check on git log --all --patch -- <each-file>: none of the above files appear in the diff for this branch.

mingw-w64 MINGW64 is still in-flight (currently in test Octave packages) and will redundantly confirm; CLANGARM64 is still building. These don't change the AC outcome.

Not gate-relevant (but included for completeness)

  • make-alpine run 24744390201: make check step SUCCESS; overall FAILURE only because test Octave packages pkg-install of control:stk flakes on Alpine's musl libc. Not in the CI matrix's target platforms per the PR test plan.
  • make-cygwin run 24744390172: pending runner assignment, historically infra-flaky.

Local BIST (MatSlop-bundled Octave 9.4.0, Windows host)

test digraph         -> 341/341 PASS
test graph           -> 259/259 PASS
test GraphPlot       -> 356/356 PASS
test __matlab_ref__  ->   5/5   PASS

Status

All three platforms called out in the test plan (Linux / macOS / Windows (MSYS2)) now have evidence of 0 new failures attributable to scripts/graph/. The remaining blocker to moving this PR out of draft is the FSF copyright assignment paperwork — tracked externally and noted in the PR description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant