Skip to content

Structural analysis: infer the coupling graph from the IR (separable dims, linking constraints) #39

Closed as not planned

Description

@FBumann

Where this comes from

Prior-art scan of declarative optimisation languages surfaced GBOML (U. Liège, JOSS 2022) — a stand-alone MILP language whose organising abstraction is a hierarchical hypergraph: #NODE blocks own internal variables and local constraints, #HYPEREDGE blocks carry the coupling constraints, and coupling may only reference the external: variables of incident nodes. Their JOSS paper diagnoses a gap we recognise — algebraic modeling languages "rarely provide language constructs to represent the block structure in a model and typically fail to take advantage of it", while framework-style tools (they name PyPSA, Calliope, Balmorel) "lack expressiveness and adding components is often cumbersome". They claim four payoffs from carrying that structure: simpler encoding, model re-use, parallelised model generation, and structure-exploiting solvers.

The observation that motivates this issue: we have the same structural information, and we do not have to ask the user for it. GBOML makes the modeller draw the hypergraph by hand. In our IR the coupling is already marked syntactically, and can be recovered by a static pass over Program.

This issue is only the analysis-and-reporting half. Consuming the structure (block-annotated sinks, decomposition solvers) is explicitly out of scope — see the bottom.

The claim

relational/plan.py Program is a declaration-level object: a handful of ConstraintDeclarations, each with its dims and an affine Expression tree. Nothing is flattened, so the coupling pattern survives — and the expression nodes that couple coords are exactly the ones ARCHITECTURE.md already classifies for executor locality:

plan node coupling
Add, Multiply, Divide, Negate, Variable, Parameter, Predicate masks none — pointwise, row (g1, t5) touches only variables at (g1, t5)
Translate(dimension=d, by=k) banded along d with bandwidth |k| (bounded halo); wrap=True makes the band cyclic
Sum(over=d) collapses d — a linking row across all of d
GroupSum(over, coordinate, into) — and At, the same mapping walked the other way collapses the mapped dim into into — linking, but partitioned by the mapping data

From which the rule: a dim that appears in some dims: but never inside a Sum.over, a GroupSum/At, or a Translate.dimension anywhere in the program is separable — the model is block-diagonal along it. This is sound by construction (every coupling route is one of the nodes above) and conservative in the right direction: it can under-report separability, never over-report it.

Caveat worth encoding in the output: Sum and Translate are statically decidable, but GroupSum/At coupling is data-dependent. Whether gen_bus links everything into one component or partitions into five islands lives in the mapping table. So the pass has two tiers — a static verdict from the Program alone, and an optional refinement that runs connected components over the mapping table(s). The refinement is a query over one small parameter, not over the constraint matrix.

What to build

  1. A pure function over Program — relational/structure.py, no engine imports, mirroring plan.py's "frozen dataclasses, no execution logic" discipline — returning:
    • separable dims (block-diagonal axes) with the block count where statically known,
    • linking constraint declarations, and which dim each one collapses,
    • time-coupling bandwidth per dim (max |by| over Translate nodes on that dim),
    • the declaration-level bipartite incidence: which constraint families touch which variable families.
  2. Optional data-tier refinement for GroupSum: connected components over the mapping parameter, giving the real block count.
  3. Surface it. check() is the natural home — it is already the "tell me about this file without building it" entry point, and this needs no data. Overlaps with Diagnostics: a slow build cannot say where the time went #709 (build/solve observability, refiled from Build/solve observability: executor stats #34); structure is a pre-build property, so it should be reportable from check() alone and echoed in the diagnostics when a build happens.

Output should read as a diagnostic a user acts on, e.g. "block-diagonal along region (14 blocks); linking constraints: co2_budget (sums region), transmission_balance (group_sum line_region → region); snapshot coupled with bandwidth 1 (storage_balance)."

Why this is worth it independent of decomposition

Diagnostics alone justify it — knowing your 14 regions are independent except for one CO₂ budget row is a real modelling fact, and today nothing tells you.

But the reason to build it now is that separability is the prerequisite for sequential per-block build-and-solve. If a dim is provably separable, the engine can build and solve one slice at a time, holding a single block in memory. That is an out-of-core scaling story that falls straight out of hard rule 4 rather than straining it, needs no external solver, and is a strictly better use of the structure than exporting it.

Explicitly out of scope

  • Block-annotated sinks (SMPS-with-blocks, DSP format). Structurally this is just another sink and the COO batches are already there — but it bets on a small, mostly academic structure-exploiting-solver ecosystem. Separate issue if ever.
  • Implementing decomposition (Benders, Dantzig-Wolfe). These are solve algorithms — cut management, convergence, subproblem lifecycle — requiring a Python driver loop around highspy. That is Python-side algorithmic machinery of exactly the kind hard rule 6 exists to keep out. Not a goal.

Definition of done

check() reports the coupling structure of a model with no data bound; tests cover a block-diagonal program, a program with one linking Sum, a Translate-banded program, and a GroupSum program where the static verdict says "coupled" and the data-tier refinement finds N components. ARCHITECTURE.md's locality section notes that the same classification also yields the coupling graph.

🤖 Generated with Claude Code

Activity

  1. added
    featureNew capability
    area:engineLowering, IR, relational executor, the sink contract
    and removed on Jul 24, 2026
  2. FBumann commented on Jul 26, 2026

    @FBumann
    CollaboratorAuthor

    Parking this rather than scheduling it:

    Deprioritisation is expressed by the absence of the roadmap label rather than by a new one, so this stays open as backlog. The GBOML prior-art reasoning is the part worth keeping.

  3. FBumann commented on Jul 31, 2026

    @FBumann
    CollaboratorAuthor

    ROADMAP Track 2d (#378) names this as the block-detection step for decomposition: the master/sub split is a GROUP BY over the A frame, which is what this analysis produces. Sequenced with #204.

  4. FBumann commented on Jul 31, 2026

    @FBumann
    CollaboratorAuthor

    Path fix: this issue is written against relational/ir.py and Program. The module is relational/plan.py and the type is plan.Program; the frozen-dataclass, no-execution-logic discipline it cites is intact under the new name.

    The node table also needs one substitution — Shift(dim, n) is now plan.Translate, one node for both surface spellings differing only in wrap. That strengthens the claim rather than weakening it: bandwidth is |by| off a single node kind instead of two, and wrap=True is the case where the band is cyclic, which the coupling report would want to say.

  5. FBumann commented on Aug 16, 2026

    @FBumann
    CollaboratorAuthor

    Prompt: "close 39"

    Note

    The following content was generated by AI.

    Closing as not planned: this is an abstraction with no caller.

    The finding holds — relational/plan.py's Program is declaration-level, nothing
    is flattened, and the coupling pattern is recoverable by a static pass, so GBOML's
    hypergraph is information we have without asking the modeller to draw it. What the
    issue does not have is a consumer: it scopes out block-annotated sinks and
    decomposition solvers explicitly, which leaves an analysis whose only output is a
    report nobody reads.

    Since it was filed, the decomposition question moved elsewhere and answered
    differently — #584 ran Benders on lpspec unmodified with an eleven-line driver, and
    #596 asks whether the package owns an algorithm surface at all. If the answer there
    is no, the structure has no in-repo consumer by construction.

    Re-file the day something asks for it, against whatever wants to read it — the
    analysis is cheap to rebuild and the body would be written to a consumer that
    exists rather than to a hypothetical one.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineLowering, IR, relational executor, the sink contractfeatureNew capability

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions