Scientific Python engineer validating NumPy eigh lower- and upper-triangle matrix results at a workstation

Check the Matrix NumPy eigh Actually Solves

Thu, Oct 29, 2026

Consider the unchanged real array [[2, 9], [1, 2]]. With numpy.linalg.eigh(a, UPLO="L"), it produces eigenvalues [1, 3]. With UPLO="U", the same stored array produces approximately [-7, 11]. Both results are perfectly intelligible once the input contract is stated: NumPy’s symmetric eigensolver selects one triangle, with lower as the default, and returns ascending eigenvalues with eigenvectors stored by column. It is not using the disagreement between 9 and 1 as a request to validate a complete nonsymmetric matrix. NumPy 2.3 documents that UPLO selects the lower or upper triangular part used in the calculation.

That behavior is useful when one triangle deliberately carries a symmetric matrix. It is dangerous when a caller assumes a successful eigh call certifies every entry of a dense array. The operational question is therefore not merely “did the solver converge?” It is: did it decompose the matrix the caller intended, or the symmetric matrix implied by the selected triangle?

This playbook keeps that distinction explicit. It uses ordinary real float64 2×2 arrays, fixed coordinates, exact symmetry as the laboratory admission rule, and a fixed absolute 1e-12 allowance only for the authored numerical diagnostics. It does not claim that rule or tolerance is suitable for arbitrary scientific matrices. The goal is a reviewable decision: retain the raw array, state its storage contract, construct the effective symmetric matrix, independently derive the expected spectrum, check residuals against the matrix that actually matters, then choose ACCEPT, REJECT, or HOLD. Any proposed matrix transformation receives a separate approval decision.

Declare whether the array stores a full matrix or one triangle

Before selecting numpy.linalg.eigh UPLO, write down what each stored coordinate means.

For this laboratory, three storage contracts are admissible concepts:

Storage declaration

Meaning of [[2,9],[1,2]]

full

All four stored entries are meaningful; therefore the off-diagonal disagreement makes the array invalid under this article’s exact-symmetry policy.

lower

The diagonal and lower entry a[1,0]=1 define the symmetric matrix [[2,1],[1,2]]; a[0,1]=9 is not matrix data for the decomposition.

upper

The diagonal and upper entry a[0,1]=9 define [[2,9],[9,2]]; a[1,0]=1 is not matrix data for the decomposition.

The coordinate identities are fixed. This article does not repair feature order, infer labels, or permute axes. That prerequisite belongs to a different class of review, such as preserving named feature coordinates. Refonte’s feature-order example likewise emphasizes that shape alone does not establish semantic identity.

The essential evidence is therefore the pair stored array + declared meaning. The spectrum cannot retroactively decide the declaration. If a pipeline says “this is a complete symmetric matrix,” the fact that treating it as lower-triangle storage makes eigh return attractive values does not authorize that reinterpretation.

The storage declaration is part of the mathematical input

A deliberately unused triangle and a conflicting complete matrix can have identical bytes. Their difference lies in the interface contract that produced and consumed those bytes.

That makes provenance important but also exposes a limit of the fixture. The helper below accepts a label such as "lower" because the label is trusted test metadata. It does not authenticate that some upstream scientific producer really intended lower-triangle storage. Residuals cannot authenticate that provenance either. A reviewer must obtain the storage declaration from an authoritative interface, file-format specification, assembly routine, or responsible owner rather than infer it from successful numerical output.

Read what the symmetric solver actually consumes

For the real symmetric problem in scope, NumPy documents numpy.linalg.eigh(a, UPLO='L') as operating on a symmetric matrix and states that UPLO specifies whether calculation uses the lower triangular part, the default, or the upper triangular part. It returns eigenvalues in ascending order and normalized eigenvectors in columns. The documented eigenpair relationship is first-eq.

Applied to the raw array

second-eq

lower selection defines the effective symmetric matrix

third-eq

whereas upper selection defines

fourth-eq

This is the central distinction behind a NumPy eigh non symmetric matrix audit. eigh is the symmetric/Hermitian interface; triangle selection is part of that interface. A successful result therefore establishes neither that R[0,1] == R[1,0] nor that the caller intended the full stored array.

SciPy makes the motivation especially explicit. Its scipy.linalg.eigh documentation says lower=True selects the pertinent lower-triangle data and explains that the routine does not check symmetry because upper/lower triangular representations are supported. This is established library behavior, not a newly discovered NumPy defect.

A finite check does not establish symmetry

scipy.linalg.eigh(..., check_finite=True) answers a different question. SciPy documents check_finite as checking inputs for finite values, and separately notes that the finiteness check applies to the whole array even though triangle selection determines the pertinent matrix data.

Accordingly, do not simplify the rule to “unused entries are never inspected.” They may be irrelevant to the eigendecomposition while still being examined by an admission check. In E10, SciPy receives a finite asymmetric array with lower=True, check_finite=True, and driver="evd"; passing the finite check does not make the two off-diagonal entries equal.

The laboratory helper likewise checks finiteness over its entire 2×2 input before applying a storage declaration. That is an authored policy, separate from NumPy’s UPLO semantics.

Derive the expected spectra before running a solver

The laboratory deliberately makes the algebra independent of NumPy and SciPy.

Every effective symmetric example has the form

fiveth-eq

Its characteristic equation is

sixth-eq

so for the positive values used here the eigenvalues are

seventh-eq

That immediately gives:

Matrix interpretation

Expected eigenvalues

Lower-triangle interpretation

1

[1, 3]

Upper-triangle interpretation

9

[-7, 11]

Averaged symmetric matrix

5

[-3, 7]

Symmetric selected-entry control

3

[-1, 5]

The unchanged nonsymmetric raw matrix is a different problem:

eight-eq

Its characteristic polynomial is

nineth-eq

whose roots are -1 and 5.

Those expectations precede any solver call. Agreement between eigh, eigvalsh, or SciPy is therefore not being used as the oracle. For readers who want broader prerequisite context around arrays, NumPy, and SciPy, Refonte’s overview of scientific Python library foundations identifies NumPy for array/matrix computation and SciPy as a numerical-computing layer built on NumPy.

For an eigenpair (λⱼ, νⱼ), the diagnostic residual used here is the componentwise maximum of

eigenpair

The authored fixture requires this quantity to be below 1e-12 for its effective symmetric matrices. That bound is a laboratory diagnostic allowance, not a universal scientific tolerance.

Run the complete twelve-case matrix laboratory

The supplied laboratory records a baseline run under Python 3.12.14, NumPy 2.3.5, and SciPy 1.17.0. Execution started at 2026-10-29T11:43:14.823884+00:00 and finished at 2026-10-29T11:43:14.834459+00:00, returning process exit code 0 and status PASS_12_CASES. Those timestamps identify the supplied run; they are not a benchmark. No BLAS/LAPACK build or thread-count qualification is asserted.

Save the following as eigh_matrix_lab.py. The stated SHA-256 of the source bytes is:

8a9a6be812fef7c4fbad142a34a0b358ddc801c6dc8447fe28318357252dab42

That hash identifies source bytes. It does not prove that any claimed historical execution occurred.

"""Owned real 2x2 eigensolver experiment; no scientific dataset or benchmark."""
import hashlib
import json
import platform
import sys
from datetime import datetime, timezone
from pathlib import Path

import numpy as np
import scipy
from scipy.linalg import eigh as scipy_eigh

TOL = 1e-12
RAW = [[2.0, 9.0], [1.0, 2.0]]
LOWER_FULL = [[2.0, 1.0], [1.0, 2.0]]
UPPER_FULL = [[2.0, 9.0], [9.0, 2.0]]
MEAN_FULL = [[2.0, 5.0], [5.0, 2.0]]
INDEFINITE = [[2.0, 3.0], [3.0, 2.0]]
QUALIFIED_CALLS = 0


class ContractError(ValueError):
    pass


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def matrix(values):
    return np.array(values, dtype=np.float64)


def spectral_error(values, expected):
    return max(abs(float(a) - b) for a, b in zip(sorted(values), expected, strict=True))


def residual(a, values, vectors):
    # Scalar products avoid relying on a broadcasted residual expression.
    return max(abs(sum(float(a[i][k]) float(vectors[k, j]) for k in range(2))
                   - float(values[j]) float(vectors[i, j]))
               for i in range(2) for j in range(2))


def reconstruction_error(a, values, vectors):
    return max(abs(float(a[i][j]) - sum(float(vectors[i, k]) float(values[k])
                                      float(vectors[j, k]) for k in range(2)))
               for i in range(2) for j in range(2))


def evidence(raw, effective, values, vectors, expected, raw_matches):
    error = spectral_error(values, expected)
    raw_error = residual(raw, values, vectors)
    effective_error = residual(effective, values, vectors)
    orthogonality = max(abs(sum(float(vectors[k, i]) float(vectors[k, j]) for k in range(2))
                           - (1.0 if i == j else 0.0)) for i in range(2) for j in range(2))
    require(error < TOL and effective_error < TOL and orthogonality < TOL, "Bad eigensystem")
    require((raw_error < TOL) == raw_matches, "Unexpected raw-matrix residual")
    require(reconstruction_error(effective, values, vectors) < TOL, "Bad effective reconstruction")
    return {"input": np.asarray(raw).tolist(), "effective_matrix": effective,
            "eigenvalues": values.tolist(), "expected_eigenvalues": expected,
            "eigenvalue_max_error": error, "raw_residual_max": raw_error,
            "effective_residual_max": effective_error, "orthogonality_max_error": orthogonality,
            "raw_reconstruction_max_error": reconstruction_error(raw, values, vectors)}


def qualified_eigh(a, storage):
    global QUALIFIED_CALLS
    if type(a) is not np.ndarray or a.dtype != np.dtype("float64") or a.shape != (2, 2):
        raise ContractError("Expected an ordinary 2x2 float64 array")
    if not np.isfinite(a).all() or np.max(np.abs(a)) > 100:
        raise ContractError("Expected finite entries with magnitude at most 100")
    if storage not in ("full", "lower", "upper"):
        raise ContractError("Unknown storage contract")
    if storage == "full" and a[0, 1] != a[1, 0]:
        raise ContractError("Full-matrix policy requires exact symmetry")
    effective = a.copy()
    if storage == "lower":
        effective[0, 1] = effective[1, 0]
    if storage == "upper":
        effective[1, 0] = effective[0, 1]
    QUALIFIED_CALLS += 1
    values, vectors = np.linalg.eigh(effective, UPLO="L")
    return values, vectors, effective.tolist()


def run(report):
    require(np.__version__ == "2.3.5" and scipy.__version__ == "1.17.0", "Version baseline mismatch")
    cases = report["cases"]

    def record(case, decision, data):
        cases.append({"case": case, "decision": decision, "observed": data, "pass": True})

    full = matrix(LOWER_FULL)
    values, vectors, effective = qualified_eigh(full, "full")
    first = evidence(full, effective, values, vectors, [1.0, 3.0], True)
    upper_values, = np.linalg.eigh(full, UPLO="U")
    require(spectralerror(upper_values, [1.0, 3.0]) < TOL, "Symmetric UPLO comparison")
    first["upper_values"] = upper_values.tolist()
    record("E01_full_symmetric", "ACCEPT_FULL_MATRIX", first)

    raw = matrix(RAW)
    values, vectors = np.linalg.eigh(raw, UPLO="L")
    record("E02_raw_lower", "REJECT_AS_FULL_MATRIX_RESULT",
           evidence(raw, LOWER_FULL, values, vectors, [1.0, 3.0], False))
    values, vectors = np.linalg.eigh(raw, UPLO="U")
    record("E03_raw_upper", "REJECT_AS_FULL_MATRIX_RESULT",
           evidence(raw, UPPER_FULL, values, vectors, [-7.0, 11.0], False))

    unused_changed = matrix([[2.0, 41.0], [1.0, 2.0]])
    values, vectors = np.linalg.eigh(unused_changed, UPLO="L")
    record("E04_unused_upper_changed", "UNCHANGED_LOWER_RESULT_IS_NOT_INPUT_VALIDATION",
           evidence(unused_changed, LOWER_FULL, values, vectors, [1.0, 3.0], False))
    used_changed = matrix([[2.0, 9.0], [3.0, 2.0]])
    values, vectors = np.linalg.eigh(used_changed, UPLO="L")
    record("E05_selected_lower_changed", "SELECTED_TRIANGLE_CHANGED_THE_PROBLEM",
           evidence(used_changed, INDEFINITE, values, vectors, [-1.0, 5.0], False))

    indefinite = matrix(INDEFINITE)
    values, vectors, effective = qualified_eigh(indefinite, "full")
    record("E06_symmetric_indefinite", "ACCEPT_SYMMETRIC_NOT_PSD_CLAIM",
           evidence(indefinite, effective, values, vectors, [-1.0, 5.0], True))

    before = QUALIFIED_CALLS
    try:
        qualified_eigh(raw, "full")
    except ContractError as error:
        require(str(error) == "Full-matrix policy requires exact symmetry", "E07 wrong hold")
    else:
        raise AssertionError("E07 failed to hold asymmetric full input")
    require(QUALIFIED_CALLS == before, "E07 reached solver")
    record("E07_full_contract_hold", "HOLD_BEFORE_SOLVER", {"solver_called": False})

    original = raw.copy()
    values, vectors, effective = qualified_eigh(raw, "lower")
    require(effective == LOWER_FULL and np.array_equal(raw, original), "E08 representation mismatch")
    accepted = evidence(raw, effective, values, vectors, [1.0, 3.0], False)
    accepted.update(storage_contract="lower", raw_preserved=True)
    record("E08_declared_lower_storage", "ACCEPT_EXPLICIT_TRIANGULAR_CONTRACT", accepted)

    mean = (raw + raw.T) / 2
    require(mean.tolist() == MEAN_FULL and not np.array_equal(mean, raw), "E09 altered input control")
    values, vectors = np.linalg.eigh(mean)
    altered = evidence(mean, MEAN_FULL, values, vectors, [-3.0, 7.0], True)
    altered.update(original_full_input=RAW, original_input_changed=True, transformation_authorized=False)
    record("E09_unapproved_average", "HOLD_UNAPPROVED_MATRIX_CHANGE", altered)

    only_values = np.linalg.eigvalsh(raw, UPLO="L")
    scipy_values, scipy_vectors = scipy_eigh(raw, lower=True, check_finite=True, driver="evd")
    require(spectral_error(only_values, [1.0, 3.0]) < TOL, "E10 eigvalsh mismatch")
    comparison = evidence(raw, LOWER_FULL, scipy_values, scipy_vectors, [1.0, 3.0], False)
    comparison.update(numpy_eigvalsh=only_values.tolist(), scipy_check_finite=True, scipy_driver="evd")
    record("E10_companion_solvers", "AGREEMENT_DOES_NOT_VALIDATE_BOTH_TRIANGLES", comparison)

    invalid = [("wrong_shape", np.ones((2, 3)), "full"),
               ("integer_dtype", np.array(LOWER_FULL, dtype=np.int64), "full"),
               ("nonfinite_unused_entry", matrix([[2.0, float("nan")], [1.0, 2.0]]), "lower"),
               ("unknown_contract", full, "guess")]
    holds = []
    for name, candidate, storage in invalid:
        before = QUALIFIED_CALLS
        try:
            qualified_eigh(candidate, storage)
        except ContractError:
            holds.append(name)
        else:
            raise AssertionError("E11 missing hold: " + name)
        require(QUALIFIED_CALLS == before, "E11 reached solver")
    require(holds == ["wrong_shape", "integer_dtype", "nonfinite_unused_entry", "unknown_contract"],
            "E11 invalid-input inventory")
    record("E11_admission_holds", "HOLD_BEFORE_SOLVER", {"rejected": holds, "solver_called": False})

    general_values, general_vectors = np.linalg.eig(raw)
    require(not np.iscomplexobj(general_values) and not np.iscomplexobj(general_vectors), "E12 unexpected type")
    require(spectral_error(general_values, [-1.0, 5.0]) < TOL, "E12 characteristic-polynomial oracle")
    general_residual = residual(RAW, general_values, general_vectors)
    require(general_residual < TOL, "E12 raw eigenpair residual")
    record("E12_general_matrix_comparator", "GENERAL_MATRIX_COMPARISON_ONLY",
           {"input": RAW, "eigenvalues": sorted(general_values.tolist()),
            "expected_eigenvalues": [-1.0, 5.0], "characteristic_polynomial": "lambda^2-4lambda-5",
            "raw_residual_max": general_residual})
    require(np.array_equal(raw, matrix(RAW)), "Raw input was mutated")
    require(len(cases) == 12 and QUALIFIED_CALLS == 3, "Incomplete case/call inventory")
    report.update(status="PASS_12_CASES", qualified_solver_calls=QUALIFIED_CALLS, raw_input_preserved=True)


if name == "__main__":
    report = {"status": "FAIL", "cases": [], "started_utc": datetime.now(timezone.utc).isoformat(),
              "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
              "environment": {"python": sys.version, "platform": platform.platform(),
                              "numpy": np.__version__, "scipy": scipy.__version__},
              "tolerance": {"absolute_diagnostic_max": TOL, "scope": "authored real 2x2 examples only"}}
    try:
        run(report)
    except Exception as error:
        report["failure"] = {"type": type(error).__name__, "message": str(error)}
    report["finished_utc"] = datetime.now(timezone.utc).isoformat()
    print(json.dumps(report, indent=2, allow_nan=False))
    sys.exit(0 if report["status"] == "PASS_12_CASES" else 1)

Assuming Python 3.12.14 is available as python3.12, the standalone reproduction commands are:

python3.12 -m venv .venv-eigh
.venv-eigh/bin/python -m pip install numpy==2.3.5 scipy==1.17.0
.venv-eigh/bin/python eigh_matrix_lab.py > eigh-matrix-evidence.json

A rerun is acceptable evidence only as its own identified execution. Inspect the process exit status and JSON rather than assuming that creating an output file proves success. Require the expected source hash, NumPy 2.3.5, SciPy 1.17.0, all twelve case identifiers, successful assertions, and status: "PASS_12_CASES". A missing case, version mismatch, failed assertion, truncated JSON, unexpected hash, or nonzero exit is not the recorded baseline.

The source’s version assertions deliberately prevent silently treating another dependency set as the same run. The recorded Python platform string was Linux-6.18.55-x86_64-with-glibc2.39, with Python 3.12.14 built using Clang 22.1.3. Those details identify the recorded environment but do not qualify LAPACK vendor, CPU dispatch, threads, or numerical reproducibility across every system.

One further distinction matters: a fixture case may pass because it correctly demonstrates a result that policy rejects. PASS_12_CASES means the test inventory behaved as authored. It does not mean every decomposed array is an acceptable full symmetric matrix.

Compare lower and upper results against the unchanged array

E01 establishes the benign control. The truly symmetric matrix [[2,1],[1,2]] gives [1,3] whether NumPy selects UPLO="L" or "U", and its eigenpairs satisfy the raw matrix because raw and effective matrices are identical.

E02 keeps the asymmetric raw array [[2,9],[1,2]] unchanged and selects the lower triangle. The result is [1,3], exactly the spectrum independently derived for [[2,1],[1,2]]. Its effective-matrix residual is zero in the recorded run, while the maximum residual against the raw matrix is approximately 5.65685.

E03 selects the upper triangle of those same stored bytes. The spectrum is approximately [-7,11], matching [[2,9],[9,2]]; again the effective residual is below the fixture allowance while the raw residual is approximately 5.65685.

NumPy’s documentation explains why the outputs look orderly: eigh returns ascending eigenvalues for its symmetric problem. A real, sorted, numerically stable-looking spectrum is therefore not evidence that the complete asymmetric input was faithfully decomposed.

Change selected and unselected entries independently

E04 is the most useful sensitivity control in the fixture. Under UPLO="L", change only the conflicting upper entry from 9 to 41:

The lower-selected spectrum remains [1,3]. Its raw residual grows to approximately 28.28427, while the effective matrix remains [[2,1],[1,2]].

That result demonstrates something precise: for this decomposition, changing the unselected off-diagonal entry does not change the lower-triangle symmetric problem. It does not establish that the complete source array is valid.

E05 performs the converse comparison. Keep the upper 9, but change the selected lower entry from 1 to 3. With lower selection, the effective symmetric matrix becomes [[2,3],[3,2]], and its independently expected spectrum is [-1,5].

Together these controls distinguish an ignored conflict from an input that actually changes the selected mathematical operator. They are more informative than merely calling the same routine twice.

Check the eigenpair equation against the intended matrix

For every symmetric solver result, keep the matrix identity next to the residual. Do not record a bare value such as “residual = 0” without saying whether it was computed against the raw array, the lower-materialized matrix, an averaged matrix, or some other representation.

The scalar-loop check in the fixture evaluates each component of

For symmetric cases it additionally checks eigenvector orthogonality and reconstruction of the effective matrix from its eigenpairs. Those checks are appropriate to the accepted symmetric interpretation being tested; they are not blindly imposed on the general-matrix comparator.

The fixed 1e-12 allowance is intentionally local. Teams should approve numerical tolerance roles explicitly rather than copy a fixture’s threshold into unrelated workloads. Refonte’s tolerance article likewise treats acceptance semantics as something that must be declared rather than inferred from a convenient API call.

A small residual against the wrong matrix is insufficient

E02’s eigenpairs have essentially zero residual against [[2,1],[1,2]] but a residual near 5.65685 against [[2,9],[1,2]]. That is not contradictory. They are good eigenpairs for the effective lower-derived matrix and bad eigenpairs for the complete raw array.

E03 makes the same point with the upper-derived matrix.

Checking only eigenvalues is weaker still. Different matrices can share eigenvalues, so a spectrum does not uniquely identify the matrix that generated it. Eigenvector byte comparisons are also unsuitable as a rigid oracle because eigenvector signs can legitimately differ, and final floating-point bits can depend on solver details. SciPy itself notes that different driver paths and eigenvalues-only versus eigenvector-producing calculations can differ slightly at floating-point scale.

The evidentiary unit is therefore: raw input, declared contract, effective matrix, independently expected spectrum, and residual against the matrix whose meaning is under review.

Hold asymmetric full inputs before the qualified call

E07 answers the core validation question for a caller that declares "full". The helper tests exact equality of a[0,1] and a[1,0]. For [[2,9],[1,2]], it raises ContractError("Full-matrix policy requires exact symmetry") before incrementing its qualified-call counter. The solver is not invoked.

A square shape is necessary but not sufficient. That is the same structural principle behind the separate NumPy shape-contract audit: a NumPy operation may be executable while violating the application contract. The earlier article concerns paired shapes and broadcasting; here the array is already 2×2, and the failure is matrix meaning rather than dimensional compatibility.

E11 exercises four additional holds: wrong (2,3) shape, integer dtype rather than the fixture’s required float64, a nonfinite entry, and an unknown storage label. Each is rejected before a qualified helper call.

E06 prevents another category error. [[2,3],[3,2]] is exactly symmetric and therefore acceptable under the symmetry gate, but it has eigenvalue -1. Acceptance here is not a positive-semidefinite, covariance-validity, conditioning, or model-quality claim.

Keep the two-by-two guard within its tested domain

The helper is intentionally narrow: exact shape (2,2), exact float64, finite values, magnitudes at most 100, recognized storage metadata, and exact off-diagonal equality when storage is "full".

Do not generalize its exact-equality rule or 1e-12 diagnostic allowance into a production validator. Coordinate order is fixed. Storage metadata is trusted rather than authenticated. The accepted full and lower paths are exercised, but the helper’s "upper" materialization branch is implemented without an accepted helper-call case. Direct NumPy UPLO="U" behavior is exercised separately in E01 and E03.

qualified_solver_calls=3 counts only successful helper calls in E01, E06, and E08. It does not count every direct NumPy or SciPy call elsewhere in the experiment.

Materialize deliberately declared triangular storage

E08 demonstrates the legitimate counterpart to E07.

The raw bytes remain:

But this time the authoritative fixture metadata says "lower". The helper first copies the array. It then materializes the declared representation by copying the selected lower off-diagonal value into the upper position:

It retains both pieces of evidence: the untouched raw storage array and the full effective symmetric matrix. The original input is checked with np.array_equal after materialization to establish that the source was not overwritten.

The independently derived expectation for  is [1,3]. The solver result matches that expectation, the effective-matrix residual is below the laboratory allowance, eigenvectors pass the orthogonality check, and the reconstruction matches the effective matrix.

The raw-array residual remains large. Under this explicit triangular-storage representation, that does not invalidate E08, because the declaration says R[0,1]=9 is not an element of the represented symmetric operator. The acceptance evidence is therefore not “the residual against the stored 2×2 bytes is tiny.” It is “the storage declaration is lower; the effective matrix decoded from that declaration is recorded; its algebra and eigensystem agree; and the original representation remains available for audit.”

That contrasts directly with E07. The bytes are the same, but "full" means the conflicting entries are both claimed as matrix data, so policy holds the input before decomposition.

This is why full matrix versus triangular storage must be established before numerical interpretation. No post hoc spectral test can decide which contract the producer meant. Also note the coverage limit: the helper contains an upper-materialization branch, but the supplied accepted-helper inventory does not exercise it. Direct upper selection is evidence about NumPy behavior, not extra helper-branch coverage.

Require approval before averaging conflicting triangles

A common reaction to an asymmetric input intended to be symmetric is to replace it by

For this fixture,

The new matrix is symmetric. Its independently derived eigenvalues are [-3,7], and E09 obtains an accurate eigensystem for that new matrix.

That does not make averaging an approved repair.

The transformation discards the distinction between the original values 9 and 1 and substitutes 5 for both. If their disagreement arose from a defective assembly path, stale buffer, unit error, transposition mistake, or upstream interface violation, averaging conceals evidence while changing the mathematical object.

This is the operational meaning of symmetrization changes eigenvalues in the example: lower interpretation gives [1,3], upper gives [-7,11], the arithmetic mean gives [-3,7], and the unchanged general matrix has [-1,5]. A small residual for one of those matrices cannot authorize choosing it.

Preserve the original before evaluating a proposed transformation

Review a proposed transformation as a separate decision. Retain at least four items together: the original entries, the transformation identity, the transformed matrix, and its authorization state.

In E09, original_input_changed=True and transformation_authorized=False are explicit. The transformed matrix passes its own numerical checks, yet the decision remains HOLD_UNAPPROVED_MATRIX_CHANGE.

Do not overwrite the source and then test the overwritten array, because that destroys the evidence needed to ask which values originally conflicted. Do not loosen a symmetry tolerance merely to convert a contract failure into acceptance. If a scientific method genuinely requires projection onto a symmetric matrix, that is a different declared operation and needs its own justification, error model, and review.

Interpret companion solvers and the general-matrix comparison

E10 checks two companion interfaces under the same lower-triangle interpretation.

NumPy documents numpy.linalg.eigvalsh as computing eigenvalues for the symmetric/Hermitian problem and retaining the same UPLO selection, with lower as the default. SciPy’s eigh likewise selects lower versus upper data, and explicitly avoids checking symmetry so triangular representations remain possible.

Accordingly, NumPy eigvalsh(raw, UPLO="L") and SciPy eigh(raw, lower=True, check_finite=True, driver="evd") both return the lower-derived spectrum [1,3] in the supplied laboratory. That agreement is useful for reproducing interface behavior, but it is not independent validation of both triangles. Both computations are being asked essentially the same representation question.

E12 asks a genuinely different mathematical question. NumPy documents numpy.linalg.eig for eigenvalues and right eigenvectors of a general square array; unlike eigh, its eigenvalues need not be ordered and can be complex depending on the matrix. Applied intentionally to the complete raw matrix, the fixture gets roots [-1,5] after sorting only for reporting, with raw residual approximately 2.22e-16. That agrees with the independently derived polynomial .

Changing the solver does not approve a changed requirement

eig is appropriate when the actual requirement is “solve the complete nonsymmetric matrix.”

It is not a repair strategy when the requirement says the matrix must be symmetric but assembly produced conflicting off-diagonals. Switching to a general solver changes the accepted problem instead of resolving the contract failure.

Likewise, do not apply the symmetric reconstruction assumptions from eigh indiscriminately to eig. NumPy defines eig in terms of general right eigenvectors satisfying ; it does not promise the orthogonal eigenvector basis supplied by the real-symmetric problem.

Reconcile the twelve cases into acceptance decisions

The result ledger should distinguish “fixture assertion passed” from “full-matrix result accepted.”

Case

Discriminating evidence

Decision

E01

Symmetric [[2,1],[1,2]]; L and U both give [1,3]; raw residual 0.

ACCEPT_FULL_MATRIX

E02

Raw asymmetric input, L gives [1,3]; raw residual ≈5.65685, effective residual 0.

REJECT_AS_FULL_MATRIX_RESULT

E03

Same raw input, U gives ≈[-7,11]; raw residual ≈5.65685, effective residual <1e-12.

REJECT_AS_FULL_MATRIX_RESULT

E04

Unselected upper value 9→41 leaves lower spectrum [1,3]; raw residual ≈28.28427.

UNCHANGED_LOWER_RESULT_IS_NOT_INPUT_VALIDATION

E05

Selected lower value 1→3 changes effective matrix to [[2,3],[3,2]] and spectrum to ≈[-1,5].

SELECTED_TRIANGLE_CHANGED_THE_PROBLEM

E06

Exact symmetric [[2,3],[3,2]] includes negative eigenvalue -1.

ACCEPT_SYMMETRIC_NOT_PSD_CLAIM

E07

Asymmetric array under full contract held before helper solver call.

HOLD_BEFORE_SOLVER

E08

Explicit lower-storage declaration materializes [[2,1],[1,2]]; raw preserved.

ACCEPT_EXPLICIT_TRIANGULAR_CONTRACT

E09

Averaging creates [[2,5],[5,2]], spectrum [-3,7]; residual is against changed matrix and transformation lacks approval.

HOLD_UNAPPROVED_MATRIX_CHANGE

E10

NumPy eigvalsh and SciPy eigh agree under lower selection; agreement does not inspect both triangles as matrix data.

AGREEMENT_DOES_NOT_VALIDATE_BOTH_TRIANGLES

E11

Wrong shape, integer dtype, nonfinite input, and unknown contract all held before qualified calls.

HOLD_BEFORE_SOLVER

E12

General eig solves unchanged raw matrix; expected roots [-1,5]; raw residual ≈2.22e-16.

GENERAL_MATRIX_COMPARISON_ONLY

A practical eigenvalue decomposition input audit can therefore use five gates:

  1. Establish the authoritative full/lower/upper storage contract while retaining the original array.

  2. Construct and record the effective matrix implied by that contract rather than inferring matrix identity from eigenvalues.

  3. Compare the spectrum with an independent derivation or other approved oracle, not merely another solver sharing the same triangle convention.

  4. Evaluate eigenpair residuals against the matrix whose meaning is actually being asserted.

  5. Record unresolved scope limits and keep any matrix transformation under a separate authorization decision.

PASS_12_CASES says this authored inventory behaved as expected in the supplied run. It does not promote E02, E03, E04, E09, or E10 into acceptable full-matrix results.

Recover affected results and assign review ownership

When this ambiguity is discovered in an existing analytical pipeline, preserve evidence before recomputing anything. Capture the old raw arrays, any storage declarations, UPLO or lower arguments, package versions, configuration, outputs, and enough surrounding provenance to determine what matrix the producer intended.

Then classify old results by contract, not by whether their spectra look plausible.

If authoritative evidence establishes lower-triangle storage, a lower-derived result may be reproducible and acceptable even when the unused upper entries disagree. If the interface promised a complete symmetric matrix, the same bytes should be held or rejected under the stated policy until the asymmetry’s cause is resolved. If provenance is missing, prefer HOLD. Do not infer lower or upper intent because one spectrum matches an expected narrative.

Ownership should also remain separate. The domain or data owner establishes matrix meaning. The scientific-software maintainer owns the assembly or representation adapter. The reviewer owns the numerical evidence and acceptance record. One person may hold multiple roles, but the questions should not collapse into one another.

After changing matrix assembly, representation contracts, triangle selection, solver configuration, or numerical dependencies, requalify the affected path. Preserve both the pre-change evidence and the new run identity. Exact eigenvector signs or last-bit equality need not be release criteria; meaningful equations and declared contracts are stronger evidence.

Most importantly, do not let recovery silently authorize transformation. Rebuilding an intended symmetric matrix from trustworthy source data is different from averaging contradictory stored entries because decomposition succeeds afterward.

Strengthen numerical review skills with Refonte Learning

This laboratory is deliberately narrower than the wider data-science toolkit: its concern is one matrix-interface contract, independent algebra, residual evidence, and disciplined acceptance decisions rather than a survey of modeling or deployment practice. Refonte’s broader toolkit article places Python, statistical reasoning, and technical problem-solving within a larger data-science workflow.

For learners developing those foundations, Refonte Learning’s Data Science & AI program lists Python Data Science, statistical modelling, model optimization/problem solving, exploratory analysis and visualization, machine learning, and industry projects among its competencies. Its program-specific section currently states three months and 12 to 14 hours per week, while the FAQ gives broader duration language; its admissions wording also varies between a degree-oriented requirement and an FAQ saying there are no strict prerequisites. The FAQ explicitly names Python, Jupyter Notebook, Pandas, NumPy, Matplotlib, scikit-learn, and TensorFlow, and the page says successful completion provides a Training Certificate and Certificate of Internship.

NumPy is therefore confirmed in the current published program information. SciPy, eigh, eigenpair-residual validation, specific package versions, and this exact laboratory are not confirmed syllabus items. Readers interested in specialized numerical-linear-algebra review should check the current syllabus and program terms directly rather than assume that this particular workflow is included.