Skip to content

fix: refuse a deck that nothing drives, instead of reporting its zeros - #449

Merged
dc0sk merged 1 commit into
mainfrom
fix/refuse-undriven-deck
Sep 7, 2026
Merged

dc0sk merged 1 commit into
mainfrom
fix/refuse-undriven-deck

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 7, 2026

Copy link
Copy Markdown
Owner

The defect

With no EX card the Hallén system is homogeneous and solves to the zero vector. All four frontends solved it; they split on what they did with the answer, and that split is why the check belongs in none of them.

$ fnec no-ex.nec        # geometry + FR, no EX
warning: [validator] deck has no EX card — no feedpoint impedance will be computed
...
CURRENTS
TAG SEG I_RE I_IM I_MAG I_PHASE
1 1 0.000000e0 0.000000e0 0.000000e0 0.0000
...                                              (51 rows)
diag: ... abs_res=0.000000e0 rel_res=0.000000e0
$ echo $?
0

A flat response, advertised as perfect convergence. With an RP card it also printed a RADIATION_PATTERN of -999.9900 rows.

frontend before
CLI printed the zeros, exit 0
GUI (currents / pattern) drew the zeros as a coloured overlay, no caveat at all
worker, fnec_py refused — but after the wasted solve, and with the wrong reason: "no driven feedpoint (EX voltage source) found in deck", the sentence a plane-wave receive deck gets

The GUI had nothing to check with: the only no-EX test in the tree was a NoExCardValidator declared inside the CLI's own main (FND-145).

Why the check is where it is

In validate::pre_solve_error — the gate all four frontends call. That placement is the fix, not an implementation detail.

The obvious spot is the Hallén RHS builders, since that is where the zero vector is born. That guard would have been inert for --solver pulse and --solver continuity, which drive off build_excitation instead:

mode before after
hallen exit 0, 51 zero rows exit 1, 0 bytes
pulse exit 0, 51 zero rows exit 1, 0 bytes
continuity exit 0, 51 zero rows exit 1, 0 bytes
sinusoidal exit 0, 51 zero rows exit 1, 0 bytes
mpie exit 1 exit 1

The "after" column is now a standing gate, not a hand measurement: the CLI test parses the --solver <a|b|c> alternation out of the binary's own usage line and sweeps it against both output formats. There was no enum to sweep — SolverMode is pub(super) with no ALL, and SolverKind::ALL has two members rather than five. That split is logged as FND-148.

Breaking, deliberately

docs/json-output-schema.md documented [] and exit 0 for this deck under "Absence of feedpoint data"not under that file's "Stability guarantee" section, which covers the field set. Documented rather than guaranteed; consumers relied on it either way. stdout now carries nothing at all — not [].

Measured consequence: a deck with neither EX nor FR also goes exit 0 → exit 1, since validators run ahead of the FR check. A deck with EX but no FR is unchanged at exit 0 / 0 bytes — that is FND-084, still open.

fnec diverges from nec2c conditionally: given an undriven deck with an execute card, nec2c exits 0 and prints a zero currents table; with an RP it prints -nan gains rather than a floor value; with neither it never executes.

Sabotage, in three parts

sabotage result
drop the check from pre_solve_error GUI, worker and nec_solver tests fail — every CLI test still passes
demote the CLI validator to a warning the CLI's own test fails
both deck_validator.rs fails; all three controls stay green

The first row is the interesting one. The CLI keeps a second route through its own validator, so a CLI-only suite would certify a fix that left three frontends broken — the same shape as the original bug. That blind spot is written into the test's doc comment rather than left for the next reader to rediscover.

Ledger

  • FND-145 new, fixed here.
  • FND-112 closed by measurement, credited to fix: remediate the 2026-08-28 audit — 10 findings closed, 8 opened #432 (4e7170f) which actually fixed it — re-measured at max_ma=1.232144e0 and +2.147 dBi against the audit's 0e0 and −999.99 floor.
  • FND-108 re-scoped — its central grep claim is now false.
  • FND-146 / 147 / 148 / 149 recorded, not fixed.
  • FND-070 / FND-084 NOT closed — the no-FR case, re-measured today at exit 0 / 0 bytes.

Review

Design reviewed before any code was written, which caught the inert-guard placement above. The diff was then reviewed and returned 14 defects, several of them false claims in my own prose — including "every frontend reported that zero" (false for two), a comment asserting the new fnec_py Rust test runs in CI (it does not; that crate is outside the workspace), an "O(N²) fill" the validator never saves, and a reversed account of why a fixture changed. All corrected here, and the rewrite of json-output-schema.md no longer re-asserts the false general claim that FND-084 exists to correct.

Also carries the ### Fixed changelog entry for #448's FND-142, which that PR landed without.

🤖 Generated with Claude Code

https://claude.ai/code/session_018p7FxX7QMWNJVNp9LbaLkB

@dc0sk
dc0sk force-pushed the fix/refuse-undriven-deck branch from 8d6b605 to 6f079f0 Compare September 7, 2026 19:48
With no `EX` card the Hallén system is homogeneous and solves to the zero
vector. All four frontends solved it; they then split on what they did with the
answer, and that split is why the check belongs in none of them:

  - the CLI printed a full `CURRENTS` table of exactly `0.000000e0`, a
    `RADIATION_PATTERN` of `-999.9900`, and `diag: ... abs_res=0.000000e0
    rel_res=0.000000e0` — a flat response advertised as perfect convergence —
    and exited 0;
  - the GUI's currents and pattern views drew the same zeros as a coloured
    overlay, with no caveat at all;
  - the worker and `fnec_py` did refuse, but only after the wasted solve and
    with the wrong reason: "no driven feedpoint (EX voltage source) found in
    deck", which is the sentence a plane-wave receive deck gets — a feedpoint
    problem, not a missing-card one.

The GUI had nothing to check with. The only no-`EX` test in the tree was a
`NoExCardValidator` declared *inside* the CLI's own `main` (FND-145).

The check now lives in `validate::pre_solve_error`, the gate all four frontends
call. That placement is the fix, not an implementation detail: a guard in the
Hallén RHS builders — the obvious spot, since that is where the zero vector is
born — would have been INERT for `--solver pulse` and `--solver continuity`,
which drive off `build_excitation` instead. Measured before: hallen, pulse,
continuity and sinusoidal each exit 0 with 51 zero rows; only mpie refused.
Measured after: all five exit 1 with zero bytes on stdout, swept by a test that
takes the mode list from the binary's own usage line rather than typing it.

The CLI's validator stays, promoted to error level and taking its sentence from
the shared predicate. It runs before the geometry build, so it skips the FR and
sweep-config parsing, the exec probe, `build_geometry` and the pairwise crossing
scan — it does NOT save a matrix fill, as an earlier draft of this message
claimed; `pre_solve_error` already refuses ahead of any assembly. It keeps EP-4's
`DeckValidator` integration demonstrated. Its cost is recorded rather than
glossed: the CLI now has two independent routes to this refusal, so the CLI's own
tests cannot discriminate a regression in the shared one.

BREAKING, deliberately, and stated more carefully than the first draft did.
`docs/json-output-schema.md` documented `[]` and exit 0 for this deck under
"Absence of feedpoint data" — NOT under that file's "Stability guarantee"
section, which covers the field set. Documented, not guaranteed; consumers
relied on it either way. That paragraph is rewritten here with a changelog
entry. A related consequence, measured: a deck with neither `EX` nor `FR` also
goes exit 0 -> exit 1, because validators run ahead of the FR check. A deck with
`EX` but no `FR` is unchanged at exit 0 and zero bytes — that is FND-084, still
open, and the rewrite no longer re-asserts the false general claim it corrects.

fnec diverges from nec2c here, conditionally. Measured first-hand against this
host's nec2c on a 21-segment dipole with FR and no EX: with an `XQ`, exit 0 and
a `CURRENTS AND LOCATION` table of `0.0000E+00`; with an `RP`, exit 0 with
`-nan` gains and `EFFICIENCY = -nan` rather than a floor value; with neither,
exit 0 and no currents or radiation section at all — it never executes. The
review reported this first and I took it second-hand into a code comment; the
comment now carries my own measurement instead, because a project that leans on
nec2c parity should not cite the oracle on someone else's word. (Getting there
needed a short path: nec2c aborts with "Input file name too long" on the
scratchpad, exiting 255 with no output — which reads exactly like "nec2c refuses
undriven decks", the opposite of the truth.)

Sabotage-verified in two halves:
  - drop the check from `pre_solve_error`: the GUI, worker and `nec_solver`
    tests fail — and every CLI test still PASSES, because of that second route.
    That is why each frontend gets its own gate rather than trusting the CLI's;
  - demote the CLI validator back to a warning: the CLI's own test fails.
Both routes must go before `deck_validator.rs` notices, which is the third
sabotage that was actually run.

Two test fixtures changed. The GUI's `a_sweep_with_no_solvable_points_...` used
a no-`EX` deck as its "prepares but fails to solve" case; that class now fails
at prepare, so the test FAILS loudly — it was replaced because it broke, not to
head off a silent pass, which is what an earlier draft said. A plane-wave
receive deck replaces it: a real class rather than a degenerate one, and it
still exercises the per-point failure this pins. The worker's test expected
`NoFeedpoint`, reached by solving to zeros and then failing to price; it now
gets `UnsupportedConfig` naming the missing drive, distinguishing "nothing
drives this" from "the drive is receive-only". Both already crossed the wire as
`unsupported_config`, so only `error_message` changed.

Ledger: FND-145 new and fixed. FND-112 closed by measurement, and credited to
#432 (`4e7170f`) which actually fixed it, not to this PR — re-measured at
max_ma=1.232144e0 and +2.147 dBi against the audit's 0e0 and -999.99 floor.
FND-108 re-scoped, its central grep claim being false now. FND-146/147/148/149
recorded, not fixed. FND-070/FND-084 explicitly NOT closed.

Also carries the `### Fixed` changelog entry for #448's FND-142, which that PR
landed without one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018p7FxX7QMWNJVNp9LbaLkB
@dc0sk
dc0sk force-pushed the fix/refuse-undriven-deck branch from 6f079f0 to ea0bfdd Compare September 7, 2026 19:54
@dc0sk
dc0sk merged commit 8272839 into main Sep 7, 2026
8 checks passed
@dc0sk
dc0sk deleted the fix/refuse-undriven-deck branch September 7, 2026 19:58
dc0sk added a commit that referenced this pull request Sep 8, 2026
Eighteen changes since v0.17.0: remediation of the 2026-08-28 whole-project
audit (#432-#442), a documentation-honesty cluster (#443-#447), and two solver
fixes (#448, #449). Both criticals and every high from that audit are closed.
The ledger went 137 findings / 35 open -> 149 / 33 open, zero critical, zero
high; it grew because fixing things found things.

Versions: workspace 0.17.0 -> 0.18.0, fnec_py 0.8.0 -> 0.9.0 in both its
`Cargo.toml` and its `pyproject.toml` (the lockstep every release since v0.14.0
has used). Both lockfiles refreshed, SBOM regenerated and committed.

**The changelog covered 2 of the 18 PRs.** `[Unreleased]` held entries for #448
and #449 only; the entire audit-remediation cluster -- both criticals among it --
had merged without one. Keep a Changelog's own warning is that a partial record
"can be as dangerous as not having a changelog", because it reads as complete.
Entries written for all eighteen and each verified cited by number, which is a
check worth keeping: `for n in 432..449; do grep -q "#$n" docs/changelog.md; done`.

Breaking, and the release notes carry a migration for each: a deck with no `EX`
is refused (stdout now empty rather than `[]`, exit 1 rather than 0); a deck with
neither `EX` nor `FR` likewise; `GM` decks change geometry, because fnec had been
losing wires in silence against NEC-2 semantics; and `EX 4` impedances shift,
since a current source is now the unit-voltage solve rescaled exactly.

Claims swept rather than carried forward. Two were wrong and are corrected:

  - the plane-wave split/whole agreement was quoted at 8.1e-12, which came from
    a different incidence angle; re-measured at this commit on the matched pair
    it is 1.1e-12, against the test's 1e-9 gate;
  - "1655 items checked" by the doc-attachment gate was true at #441 and is 1681
    today, so both documents now date the figure instead of letting a stale
    number read as current.

Re-measured at this commit: no-`EX` exits 1 with 0 bytes on stdout in both text
and JSON; `EX`-but-no-`FR` still exits 0 with 0 bytes (FND-084, open, and
deliberately untouched); neither-`EX`-nor-`FR` exits 1; the oversized `RP` is
refused; corpus 50 cases; 1093 tests; ledger 149/33.

No dependency changed between v0.17.0 and v0.18.0: 526 packages before and after,
and the only entries that moved are the nine workspace members' own versions.
Established by set difference over `(name, versionInfo)` pairs -- not by hashing
the sets, which is randomised per process and fooled an earlier release.

Removed-feature sweep for the one `### Removed` entry: `FNEC_ACCEL_STUB_GPU`
survives only in the retirement records that document its removal and in a schema
note that calls it retired -- no live advertising.

Gate: scripts/check-all.sh, EXIT=0, all 15 gates passed. The version-consistency
check was sabotage-verified once for this repo: `check-binding-version.py` passes
at 0.9.0/0.9.0, fails naming both files when pyproject alone says 0.9.1, and
passes again restored.


Claude-Session: https://claude.ai/code/session_018p7FxX7QMWNJVNp9LbaLkB

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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