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
.
Applied to the raw array

lower selection defines the effective symmetric matrix

whereas upper selection defines

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

Its characteristic equation is

so for the positive values used here the eigenvalues are

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:

Its characteristic polynomial is

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

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:
8a9a6be812fef7c4fbad142a34a0b358ddc801c6dc8447fe28318357252dab42That 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.jsonA 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:
Establish the authoritative full/lower/upper storage contract while retaining the original array.
Construct and record the effective matrix implied by that contract rather than inferring matrix identity from eigenvalues.
Compare the spectrum with an independent derivation or other approved oracle, not merely another solver sharing the same triangle convention.
Evaluate eigenpair residuals against the matrix whose meaning is actually being asserted.
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.

