Skip to content

fix: refuse a deck with no frequency, instead of succeeding in silence - #451

Merged
dc0sk merged 1 commit into
mainfrom
fix/refuse-deck-with-no-frequency
Sep 8, 2026
Merged

fix: refuse a deck with no frequency, instead of succeeding in silence#451
dc0sk merged 1 commit into
mainfrom
fix/refuse-deck-with-no-frequency

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 8, 2026

Copy link
Copy Markdown
Owner

The defect

fnec deck.nec on a deck with no FR card exited 0 having written zero bytes to stdout and stderr. A silent success is indistinguishable from a run that worked — and it was one of four different answers to the same deck (FND-070):

frontend before
CLI exit 0, nothing at all
GUI (two seams) refused — "deck has no FR card"
fnec_py::solve_deck_str raised — "deck has no FR card"
fnec_py::sweep_deck_str returned [], at success
CLI sweep --resonance refused — "resonance search: deck must have an FR card"

That fourth row my own site enumeration missed; the design review found it. It is the defect inside the frontend FND-070 credits with refusing — one module disagreeing with itself, its sweep half returning the empty-result-standing-for-an-error shape that docs/json-output-schema.md now tells consumers not to read that way.

Why the predicate is typed on the resolved list

validate::no_frequency_error(freqs_hz, remedy) takes the resolved frequency list, not the deck. That is the load-bearing choice, not a detail.

Frequencies do not have to come from an FR card: --sweep-config supplies them for the CLI, the sweep tab's own range widgets for the GUI, the wire for the worker. So this cannot live in pre_solve_error — the gate every frontend already calls, and the obvious home — because that sees only the deck and would refuse a working, documented capability.

$ fnec --sweep-config sweep.toml no-fr.nec     # measured: exit 0, both points solved

No test covered that capability until this change added one. docs/cli-guide.md said --sweep-config "overrides the FR card", which is true and incomplete; it also supplies one.

Sabotage — this is the improvement over FND-145

Forcing the helper to return None:

gate result
CLI sweep_contract FAILED
GUI gui_smoke FAILED
pytest, freshly built wheel FAILED
controls (--sweep-config solve, FR-deck sweep) green

FND-145's equivalent sabotage left every CLI test passing, because the CLI had a second route to that refusal. Here there is none, so every frontend notices — which is what sharing the predicate buys over sharing only the sentence.

FND-084 is closed by the opposite of the fix it proposed

That row asked for "one line: emit [] before the early return". Rejected, for reasons the review supplied and I had not considered:

  • [] is printed only under --output-format json, so text mode would have stayed silent and FND-070 would have survived intact — it fixes the symptom, not the defect;
  • [] + exit 0 already means solved, and there was no feedpoint to price (measured on corpus/dipole-ex1-freesp-51seg.nec). Spending it here makes "never solved" indistinguishable from a real result — in the very document where, one PR ago, I wrote that an empty array had been silently standing for an error and that consumers must branch on the exit code;
  • there is no contract to honour: json.loads('') raises, so every consumer in the tree already crashed on this deck and now gets a clean non-zero exit instead.

FND-070 and FND-084 have sat adjacent in the ledger since the 2026-08-28 audit proposing opposite fixes, and nobody noticed. Closing either as written would have entrenched the other.

Breaking

Breaking for anyone scripting fnec on a frequency-less deck and reading exit 0. Deliberately divergent from nec2c, which defaults such a deck to 299.8 MHz (λ = 1 m) and answers it — on a 10.5 m dipole it reports 133.18 + j280.36 Ω, pricing the wire as 10.5 λ. A plausible number for a deck the user did not write.

This is the second breaking change to this contract paragraph, and it follows the maintainer's "refuse everywhere" decision on the identical no-EX case rather than a fresh instruction — flagged rather than assumed.

Recorded, not fixed

  • FND-150 — a bad --hosts path was never reported on this deck, because the early return preceded reading the file.
  • FND-151 — the frequency checks now live at two seams plus the worker's wire check; folding them into a pre_solve_error that takes the resolved list is a four-frontend signature change, deferred deliberately.

Gate

scripts/check-all.shEXIT=0, 15/15, on the committed tree. Python gates run against a freshly built wheel: 30 pytest tests pass, and both new ones fail under the sabotage.

🤖 Generated with Claude Code

https://claude.ai/code/session_018p7FxX7QMWNJVNp9LbaLkB

`fnec deck.nec` on a deck with no `FR` card exited **0 having written zero bytes
to stdout AND stderr**. A silent success is indistinguishable from a run that
worked, and it was one of four different answers to the same deck (FND-070):

  - CLI: exit 0, nothing at all;
  - GUI, two seams: refused, "deck has no FR card";
  - `fnec_py::solve_deck_str`: raised, "deck has no FR card";
  - `fnec_py::sweep_deck_str`: returned `[]`, at success.

That last one my own site enumeration missed; fable's design review found it.
It is the defect *inside* the frontend FND-070 credits with refusing — one
module disagreeing with itself, its sweep half returning the empty-result-
standing-for-an-error shape that `docs/json-output-schema.md` now tells
consumers not to read that way.

The predicate is `validate::no_frequency_error(freqs_hz, remedy)`, typed on the
**resolved list rather than on the deck**, and that is the load-bearing choice.
Frequencies do not have to come from an `FR` card: `--sweep-config` supplies
them for the CLI, the sweep tab's own range widgets for the GUI, the wire for
the worker. So this cannot live in `pre_solve_error` — the gate every frontend
already calls — because that sees only the deck and would refuse the working
`--sweep-config` case. Measured: an FR-less deck with `--sweep-config` solves at
both configured points, and **no test covered that** until this change added
one.

Sharing the predicate and not merely the sentence is what makes the sabotage
bite. Forcing the helper to return `None` fails the CLI gate, the GUI gate and
the pytest gate, while both controls stay green. FND-145's equivalent sabotage
left every CLI test passing, because the CLI had a second route; here there is
none, so every frontend notices.

**FND-084 is closed by the opposite of the fix it proposed.** That row asked for
"one line: emit `[]` before the early return". Rejected, for reasons the review
supplied and I had not considered: `[]` is printed only under `--output-format
json`, so text mode would have stayed silent and FND-070 survived intact; and
`[]` + exit 0 already means *solved, and there was no feedpoint to price*, so
spending it here would make "never solved" indistinguishable from a real result
— in the very document where, one PR ago, I wrote that an empty array had been
silently standing for an error and that consumers must branch on the exit code.
There was no contract to honour either: `json.loads('')` raises, so every
consumer in the tree already crashed on this deck and now gets a clean non-zero
exit.

BREAKING for anyone scripting `fnec` on a frequency-less deck and reading exit
0. Deliberately divergent from nec2c, which defaults such a deck to 299.8 MHz
(λ = 1 m) and answers it: on a 10.5 m dipole it reports 133.18 + j280.36 Ω,
pricing the wire as 10.5 λ — a plausible number for a deck the user did not
write.

Recorded rather than fixed: FND-150 (a bad `--hosts` path was never reported on
this deck, because the early return preceded reading the file) and FND-151 (the
frequency checks now live at two seams plus the worker's wire check; folding
them into a `pre_solve_error` that takes the resolved list is a four-frontend
signature change, deferred deliberately).

Gate: scripts/check-all.sh, EXIT=0, 15/15. Python gates run against a freshly
built wheel: 30 pytest tests pass, and both new ones fail under the sabotage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018p7FxX7QMWNJVNp9LbaLkB
@dc0sk
dc0sk merged commit 7e70257 into main Sep 8, 2026
8 checks passed
@dc0sk
dc0sk deleted the fix/refuse-deck-with-no-frequency branch September 8, 2026 08:46
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