Skip to content

Deepen algebraic-loop diagnostics with implicated signals and conditioning #167

Description

@jamestjsp

Context

ErrAlgebraicLoop is a bare sentinel:

// errors.go:20
ErrAlgebraicLoop = errors.New("controlsys: algebraic loop: (I-D22) singular")

It is raised from solveIdentityMinusScaledProduct after an LU factorization of
I - scale·(left·right) fails luNearSingular (wellposed.go:22-26), and reaches
callers through ConnectByName, LFT, SafeFeedback, and DiscretizeWithOpts.

At that point the failure is a rank property of the assembled feedthrough matrix.
The function receives no signal metadata, so nothing downstream can attribute the
loop to particular blocks or channels.

Problem

A consumer that compiles a user-authored block diagram through ConnectByName
can only report the failure generically. Process Lab currently surfaces:

flowsheet contains an unsolvable algebraic loop; add dynamics or change a
direct-feedthrough gain

which is accurate but names nothing, leaving the user to find the offending path
by hand on a sheet that may have dozens of blocks.

The obvious application-side workaround is wrong. Reporting the strongly
connected component of the wiring graph names a strict superset of the culprits:
the loop is over Q·D, so any cycle passing through an integrator or lag has a
zero block in D and is perfectly well-posed. Blaming the SCC would flag valid
dynamic feedback. Attributing correctly requires walking the cycle in D-space
with the same per-block feedthrough the library already assembled — which is
library-side knowledge, not caller-side.

Proposal

Introduce a typed error carrying the implicated signals, wrapping the existing
sentinel so errors.Is(err, ErrAlgebraicLoop) keeps working for every current
caller:

type AlgebraicLoopError struct {
    Signals   []string // names of signals participating in the feedthrough cycle
    Condition float64  // reciprocal condition estimate of (I - Q·D)
}

func (e *AlgebraicLoopError) Error() string { ... }
func (e *AlgebraicLoopError) Unwrap() error { return ErrAlgebraicLoop }

ConnectByName holds the names; solveIdentityMinusScaledProduct holds the
matrix. Threading a name slice (or an index→name resolver) into the solve helper
is what connects them.

Conditioning

Related, and cheap once the above is in place: the guard is luNearSingular, not
exact singularity. A near-singular feedthrough loop — unity feedback with a gain
of 0.999, say — fails identically to a structurally impossible one, even though
the user's real problem is conditioning rather than topology. And a loop just on
the passing side of the threshold compiles and returns a numerically poor E
with no signal at all.

Reporting the condition estimate on the error covers both cases with the same
machinery, since the factorization is already in hand. It would also let callers
distinguish "this cannot work" from "this barely works".

Acceptance criteria

  • AlgebraicLoopError carries implicated signal names and a condition estimate
  • errors.Is(err, ErrAlgebraicLoop) holds for every path that returns it today
    (connect.go:1367, connect.go:1521, lft.go:164, safe_feedback.go:111,
    delay.go:2155, delay.go:2165, convert.go:481)
  • Existing assertions in connect_test.go:2309, lft_test.go:355,
    delay_test.go:3298, architecture_backlog_test.go:202 pass unchanged
  • A test asserts the reported signal set for a known direct-feedthrough loop,
    and asserts a dynamic feedback loop through an integrator is not reported
  • A test covers the near-singular case and asserts the condition estimate
    distinguishes it from the structurally singular one

Non-goals

  • Automatically breaking loops or inserting unit delays.
  • Changing what is accepted or rejected. This is diagnostics only; the
    well-posedness decision stays exactly where it is.

Found while reviewing consumer-side handling in jamestjsp/go-htmx PR #4, which
deliberately keeps the generic message until this exists.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfoundationCore solver that other features depend onmodelModel types and conversionsready-for-agentFully specified and ready for an agent to implement

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions