diff --git a/apps/nec-cli/src/main.rs b/apps/nec-cli/src/main.rs index a52c46d..5d48083 100644 --- a/apps/nec-cli/src/main.rs +++ b/apps/nec-cli/src/main.rs @@ -359,8 +359,26 @@ fn main() -> ExitCode { } else { frequencies_from_fr(deck) }; - if freqs_hz.is_empty() { - return ExitCode::SUCCESS; + // This used to be `return ExitCode::SUCCESS` — a deck with no `FR` and no + // `--sweep-config` exited 0 having written zero bytes to stdout AND stderr, + // while the GUI and `fnec_py` refused the same deck (FND-070). A silent + // success is the worst of the three answers: it is indistinguishable from a + // run that worked. + // + // The check is over the RESOLVED list, so `--sweep-config` on a deck with no + // `FR` still solves — frequencies do not have to come from the deck, which is + // exactly why this cannot live in `pre_solve_error`. + // + // Reached only when both sources are absent: a `--sweep-config` that parsed + // but yielded nothing is already refused by `SweepConfig::from_file`, and a + // file that failed to parse exits above. + if let Some(err) = nec_solver::validate::no_frequency_error( + &freqs_hz, + "Add an `FR` card to the deck, or pass `--sweep-config ` to \ + supply the frequencies yourself.", + ) { + eprintln!("error: {err}"); + return ExitCode::FAILURE; } if !exec_flag_explicitly_set && profile == CompatibilityProfile::Native { @@ -1211,10 +1229,16 @@ fn run_sweep_subcommand(args: &[String]) -> ExitCode { // Find the single FR frequency from the deck. let freqs = frequencies_from_fr(deck); - let freq_hz = freqs - .first() - .copied() - .ok_or_else(|| "resonance search: deck must have an FR card".to_string())?; + let freq_hz = freqs.first().copied().ok_or_else(|| { + // Same sentence as every other frontend (FND-070). The remedy is + // narrower here: a resonance search substitutes into a template + // deck, so `--sweep-config` is not the answer. + nec_solver::validate::no_frequency_error( + &freqs, + "Add an `FR` card to the template deck.", + ) + .unwrap_or_else(|| "resonance search: no frequency".to_string()) + })?; let solve_result = solve_frequency_point( deck, diff --git a/apps/nec-cli/tests/sweep_contract.rs b/apps/nec-cli/tests/sweep_contract.rs index 9a5c132..2c566bb 100644 --- a/apps/nec-cli/tests/sweep_contract.rs +++ b/apps/nec-cli/tests/sweep_contract.rs @@ -272,3 +272,89 @@ fn sweep_output_is_machine_parseable() { "expected 2 FEEDPOINTS sections (one per frequency), got:\n{stdout}" ); } + +/// The deck-free half of the flag: `--sweep-config` does not merely *override* an +/// `FR` card, it **supplies** the frequencies when the deck has none. +/// +/// That capability was gated by nothing until now, which mattered the moment a +/// refusal for frequency-less decks was added (FND-070): the obvious placement +/// for that refusal — `validate::pre_solve_error`, which every frontend already +/// calls — sees only the deck and would have refused this working case. The +/// refusal is typed on the *resolved* frequency list instead, and this test is +/// what stops a future author from "simplifying" it back onto the deck. +/// +/// `docs/cli-guide.md` said "overrides the `FR` card frequency list", which is +/// true and incomplete; it now says it also supplies one. +#[test] +fn sweep_config_supplies_the_frequencies_for_a_deck_with_no_fr_card() { + const NO_FR_DECK: &str = "GW 1 51 0 0 -5.282 0 0 5.282 0.001\nGE\nEX 0 1 26 0 1.0 0.0\nEN\n"; + assert!( + !NO_FR_DECK.contains("FR"), + "the fixture must have no FR card, or this test proves nothing" + ); + + let deck = write_temp("no-fr-deck", NO_FR_DECK); + let cfg = write_temp("no-fr-cfg", "[frequency]\npoints_mhz = [14.0, 14.2]\n"); + let output = Command::new(env!("CARGO_BIN_EXE_fnec")) + .arg("--sweep-config") + .arg(&cfg) + .arg(&deck) + .output() + .expect("failed to run fnec"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "a deck with no FR must still solve when --sweep-config supplies the \ + frequencies: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + freq_values_mhz(&stdout), + vec![14.0, 14.2], + "both configured points must be solved: {stdout}" + ); +} + +/// The other half: with neither source, the run is refused rather than silently +/// succeeding. +/// +/// It used to exit **0 having written zero bytes to stdout AND stderr** — a +/// silent success, indistinguishable from a run that worked — while the GUI and +/// `fnec_py` refused the same deck (FND-070). Both output formats are checked +/// because the `[]` that JSON mode prints for a *solved* deck with no priceable +/// feedpoint must not be reused for a deck that was never solved at all. +#[test] +fn a_deck_with_no_frequency_at_all_is_refused_in_both_output_formats() { + const NO_FR_DECK: &str = "GW 1 51 0 0 -5.282 0 0 5.282 0.001\nGE\nEX 0 1 26 0 1.0 0.0\nEN\n"; + let deck = write_temp("no-freq-deck", NO_FR_DECK); + + for format in ["text", "json"] { + let output = Command::new(env!("CARGO_BIN_EXE_fnec")) + .args(["--output-format", format]) + .arg(&deck) + .output() + .expect("failed to run fnec"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "a deck with no frequency must not report success (--output-format \ + {format}): {stderr}" + ); + assert!( + output.stdout.is_empty(), + "the refusal must not also emit a report (--output-format {format}): \ + {} byte(s)", + output.stdout.len() + ); + assert!( + stderr.contains("no frequency to solve at"), + "the refusal must name its reason (--output-format {format}): {stderr}" + ); + assert!( + stderr.contains("--sweep-config"), + "the CLI's remedy must name the second frequency source \ + (--output-format {format}): {stderr}" + ); + } +} diff --git a/apps/nec-gui/src/solve.rs b/apps/nec-gui/src/solve.rs index b930690..a21379b 100644 --- a/apps/nec-gui/src/solve.rs +++ b/apps/nec-gui/src/solve.rs @@ -415,7 +415,15 @@ pub fn solve_deck_str(deck_text: &str, solver: SolverKind) -> Result Result, deck: &str, solver: &str) -> PyResult, deck: &str, solver: &str) -> PyResult Option { ) } +/// A solve with no frequencies to run, if that is the case. +/// +/// **Typed on the resolved list, not on the deck**, and that is the whole point. +/// A frequency does not have to come from an `FR` card: the CLI takes one from +/// `--sweep-config`, the GUI's sweep tab from its own range widgets, and the +/// worker from the wire. A deck-typed predicate would refuse all three — so this +/// cannot live in [`pre_solve_error`], which sees only the deck, and it is why +/// [`frequency_error`] (which validates the values of an `FR` card that exists) +/// does not cover the case where none does. +/// +/// Sharing only the *sentence* would have left the predicate written out at every +/// call site, where the next one is one missing `is_empty()` away from the defect +/// this closes. Sharing the predicate means deleting this check fails every +/// frontend at once. +/// +/// `remedy` is the caller's, because the honest advice differs: the CLI has a +/// second way to supply frequencies and the other frontends do not, so only the +/// CLI may mention `--sweep-config`. +/// +/// The defect it closes (FND-070): `fnec deck.nec` on a deck with no `FR` exited +/// **0 with zero bytes on stdout and stderr** — a silent success — while the GUI +/// and `fnec_py`'s `solve_deck_str` refused the same deck, and `fnec_py`'s +/// `sweep_deck_str` returned `[]`. +/// +/// Deliberately divergent from nec2c, which defaults an `FR`-less deck to +/// 299.8 MHz (λ = 1 m) and answers it: measured on a 10.5 m dipole it reports +/// 133.18 + j280.36 Ω, pricing the wire as 10.5 λ. That is a plausible number for +/// a deck the user did not write. +pub fn no_frequency_error(freqs_hz: &[f64], remedy: &str) -> Option { + if !freqs_hz.is_empty() { + return None; + } + Some(format!( + "FR: this deck has no frequency to solve at, so there is nothing to \ + compute — an antenna's impedance and pattern are properties at a \ + frequency, not of the geometry alone. {remedy}" + )) +} + /// Every reason a deck must not be solved at all, geometry or otherwise. /// /// This is the gate a frontend calls before solving; [`geometry_error`] is one diff --git a/docs/changelog.md b/docs/changelog.md index 0b4d912..d184d6d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,35 @@ from 0.13.0 and earlier predate the Keep a Changelog headings and are left as wr ## [Unreleased] +### Changed + +- **A deck with no frequency at all is refused instead of silently succeeding.** + `fnec deck.nec` on a deck with no `FR` card exited **0 having written zero + bytes to stdout *and* stderr** — a silent success, indistinguishable from a run + that worked — while the GUI and `fnec_py`'s `solve_deck_str` refused the same + deck and `fnec_py`'s `sweep_deck_str` returned `[]` (FND-070). Four frontends, + three wordings and one silence; now one sentence, via + `validate::no_frequency_error`. + + The check is typed on the **resolved** frequency list, not on the deck, which + is what keeps `--sweep-config` working: that flag *supplies* frequencies for a + deck that has no `FR` card, so a deck-typed check in `pre_solve_error` — the + gate every frontend already calls — would have refused a working, documented + capability. That capability was gated by no test until now. + + BREAKING for anyone scripting `fnec` on a frequency-less deck and reading exit + 0. `docs/json-output-schema.md`'s claim that such a deck yields `[]` was never + true (it yielded zero bytes, so `json.loads` raised) and is now withdrawn + rather than honoured — FND-084's proposed one-line fix, "emit `[]` before the + early return", was **not** taken: it applies only to JSON mode and would have + spent `[]`, which already means *solved, with no feedpoint to price*, on a deck + that was never solved at all. + + Deliberately divergent from nec2c, which defaults an `FR`-less deck to + 299.8 MHz (λ = 1 m) and answers it — measured 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. + ## [0.18.0] — 2026-09-08 — Nothing drives it, so there is no solve Eighteen changes since v0.17.0, in three clusters. **Remediation of the diff --git a/docs/cli-guide.md b/docs/cli-guide.md index 5d38fb3..992a6c8 100644 --- a/docs/cli-guide.md +++ b/docs/cli-guide.md @@ -47,7 +47,7 @@ Compatibility profile note: | `--bench` | flag | off | Enable benchmark instrumentation plumbing (also used by the GPU benchmark timing gates) | | `--bench-format` | `human` \| `csv` \| `json` | `human` | Emit machine-readable benchmark records to stderr as `bench_csv:` or `bench_json:` lines while keeping the normal human-readable report on stdout | | `--output-format` | `text` \| `json` | `text` | Report format on stdout. `json` writes one JSON record per solved frequency point instead of the text report; diagnostics stay on stderr either way. Schema: `docs/json-output-schema.md` | -| `--sweep-config` | `` | — | Load a TOML frequency-sweep spec (range or explicit list); overrides the `FR` card frequency list for a batch solve. See `examples/sweep-spec.toml`. | +| `--sweep-config` | `` | — | Load a TOML frequency-sweep spec (range or explicit list). Replaces the `FR` card's frequency list for a batch solve — and **supplies** one when the deck has no `FR` card at all, which is the only way to solve such a deck (without it the run is refused). See `examples/sweep-spec.toml`. | | `--vars` | `` | — | Load a flat key→value map and substitute `$VAR` tokens in the deck before parsing. TOML (any extension except `.json`) and JSON flat-object files are both accepted. An undefined token causes a non-zero exit with a diagnostic. | | `--loads-config` | `` | — | Load fnec-specific extended loads (Laplace-domain `Z(s) = N(s)/D(s)`) from a TOML file and stamp them on the solve alongside any `LD` cards. Hallén/pulse paths only — rejected with `--solver mpie`. See **Laplace-domain loads** below. | | `--hosts` | `` | — | Distribute the frequency points of a sweep across SSH worker nodes listed in a TOML file (`[[worker]]` entries with `hostname` / `ssh_user` / optional `binary_path`). There is no local fallback: a missing file, no `[[worker]]` entries, or no reachable worker exits **1** with a diagnostic. See `docs/worker-deployment.md` | diff --git a/docs/json-output-schema.md b/docs/json-output-schema.md index 11f4095..52b0685 100644 --- a/docs/json-output-schema.md +++ b/docs/json-output-schema.md @@ -2,7 +2,7 @@ project: fnec-rust doc: docs/json-output-schema.md status: living -last_updated: 2026-09-07 +last_updated: 2026-09-08 --- # fnec JSON Output Schema (v1) @@ -21,13 +21,23 @@ fnec --output-format json --sweep-config sweep.toml ## Top-level structure The output is a JSON **array** — one element per frequency point solved, in -the same order as the deck's FR card defines them. - -A deck with no FR card is documented here as producing `[]`, and **does not**: -it produces zero bytes, so `json.loads(...)` on the output raises. That is -FND-084 in `docs/project/findings-ledger.md`, open and deliberately not fixed -in the change that rewrote the section below — the two cases were separated on -purpose, and re-measured on 2026-09-07 to confirm the no-FR one is unchanged. +the same order the frequencies were resolved in — from the deck's `FR` card, or +from `--sweep-config` when that flag is given. + +A deck with **no frequency at all** — no `FR` card and no `--sweep-config` — is +refused with exit 1 and writes nothing to stdout. It is not an empty array: `[]` +means *solved, and there was no feedpoint to price*, which is a different +outcome and must stay distinguishable from *never solved*. + +Until v0.18.0 such a deck exited 0 with zero bytes, which `json.loads('')` turns +into an exception rather than an empty list (FND-084). The ledger's proposed +one-line fix — emit `[]` before the early return — was **not** taken: it applies +only to JSON mode, leaving text mode silent, and it would have spent the one +signal that already means something else. + +Note that `--sweep-config` **supplies** the frequency list, so a deck with no +`FR` card solves normally when that flag is given; the refusal is over the +resolved list, not over the deck. ```json [ diff --git a/docs/project/findings-ledger.md b/docs/project/findings-ledger.md index ba85735..7b67ce0 100644 --- a/docs/project/findings-ledger.md +++ b/docs/project/findings-ledger.md @@ -42,6 +42,8 @@ An `open` row is not a failure — it is the point. What the process forbids is | ID | Found | State | Finding | Evidence / owner | |:---|:------|:------|:--------|:-----------------| +| FND-151 | 2026-09-08 | open | **[low] `pre_solve_error` cannot see the resolved frequency list, so the frequency checks are split across two seams.** `validate::frequency_error` validates the values of an `FR` card that exists, `validate::no_frequency_error` (added by #451) covers the case where no frequency was resolved from any source, and the worker has a third check on the wire frequency (`is_usable_frequency_mhz`, FND-098). All four frontends hold their resolved list before they call `pre_solve_error`, so the gate could take `freqs_hz: &[f64]` and fold all three into one. | Proposed by fable's design review of #451, 2026-09-08, and deliberately deferred out of that change: it is a four-frontend signature change, `diagnose` gains a parameter, and the GUI's `deck_warnings` placeholder (`solve.rs:396`, `unwrap_or(0.0)`) has to decide whether to pass an empty slice or `[0.0]`. Recorded so the split is a known state rather than an accident. | +| FND-150 | 2026-09-08 | open | **[low] `fnec --hosts /nonexistent.toml` on a frequency-less deck exited 0 without reporting the missing file.** The early return for an empty frequency list preceded the hosts file being read, so a bad `--hosts` path was never diagnosed — the run simply succeeded in silence. | Found by fable's design review of #451, 2026-09-08, while enumerating what the early return was hiding. #451 makes that path exit 1 with the frequency reason, so the silent success is gone; whether a missing `--hosts` file should be reported *in preference to* the missing frequency is a separate ordering question, unexamined. | | FND-149 | 2026-09-07 | open | **[low] The toolchain is pinned in CI and nowhere else, so the pre-commit hook lints against whatever clippy the host has.** There is no `rust-toolchain.toml`; `.github/workflows/ci.yml` names `dtolnay/rust-toolchain@1.97.1` in seven places, and `.githooks/pre-commit` runs the workspace `cargo clippy -- -D warnings` against the installed toolchain. On a host one minor version ahead, three pre-existing `needless_late_init` sites in `crates/nec_solver/src/linear.rs` (and a fourth in `apps/nec-cli/src/solve_session.rs`) became hard errors, so **every** commit in the repo required `--no-verify` — a gate that has to be bypassed in order to commit. | Found 2026-09-07 on clippy 0.1.98 while committing #448. The four sites were fixed there, which clears the symptom and not the cause: the next clippy release does it again, and CI cannot see it because CI pins. The fix is a `rust-toolchain.toml` at 1.97.1 with CI reading it instead of naming a version seven times — one pin, one file, which is what `reproducible-builds` asks for. | | FND-148 | 2026-09-07 | open | **[low] The CLI's solver axis has two closed enumerations with different counts, and neither is reachable from a test.** `apps/nec-cli/src/solve_session.rs:9` `SolverMode` has five variants (`hallen`, `pulse`, `continuity`, `sinusoidal`, `mpie`), is `pub(super)`, and has no `ALL`. `crates/nec_solver/src/validate.rs:267` `SolverKind::ALL` has two members, because it names the two solver *bases* rather than the CLI's five modes. An integration test can see neither, so until #449 nothing in the tree could sweep "every `--solver` value" — and the one axis that a whole class of routing bugs hides behind was untestable by construction. | Found by fable's review of this session's lessons, 2026-09-07, and verified: `grep` confirms `ALL: [SolverKind; 2]` against five `SolverMode` variants, and no test in `apps/nec-cli/tests/` swept the solver axis. #449 works around it by parsing the alternation out of the binary's own usage line (the `--solver` alternation it prints for an invalid value), which is derived-from-execution and self-updating, but the underlying split remains: two names for one axis, disagreeing on what the axis contains. | | FND-147 | 2026-09-07 | open | **[low] The worker's GPU-eligibility gate omits the drive check its CLI twin has.** `crates/nec_worker/src/solve.rs:493-517` gates the GPU path on `!route.paths` and `!driven_by_current`, where the CLI's gate at `apps/nec-cli/src/solve_session.rs:1140` also requires `route.drive == HallenDrive::DeltaGap`. The deck class the worker's gate lets through is a **plane-wave** deck, which carries an `EX` card and so is untouched by #449; what keeps it harmless is step 7, where no priceable feedpoint yields `NoFeedpoint` after a wasted zero-RHS GPU solve. (An earlier version of this row said #449's RHS build refuses it first. That was wrong twice: `build_hallen_rhs` never refuses — it returns `Ok` with an all-zero RHS — and the post-#449 refusal is step 2b's `pre_solve_error`, which a plane-wave deck passes.) It remains the "N copies, one diverged" shape on a gate. | Found by fable's design review of #449, 2026-09-07; read, not executed. Recorded rather than fixed because the fix belongs with a decision about whether the worker's step-3 RHS build should be made lazy at all (see FND-146's neighbour discussion in that review). | @@ -107,7 +109,7 @@ An `open` row is not a failure — it is the point. What the process forbids is | FND-087 | 2026-08-28 | fixed | **[medium] Unbounded input-controlled allocation/compute from GW segment count and GR repeat count (no cap analogous to MAX_FR_POINTS)** | Found by the 2026-08-28 whole-project audit (R09); confirmed by adversarial verification. expand_wire `for i in 0..gw.segments` and apply_gr `for copy_idx in 1..=gr.count` both unbounded; no MAX_SEG anywhere. Downstream O(N^2) sites real (validate.rs:45 double loop, matrix.rs:90 vec![_; n*n]). Reachable from worker solve.rs:371 and fnec_py lib.rs:69/359. Corrections: the finder's own 4e6 repro hangs in the intersection loop and never reaches the n*n fill; short segments trip an unrelated tiny-segment rejection first. Verifier demonstrated the alloc abort separately with long segments (exit 134, 6.4 GB). Downgrade: availability only, self-inflicted on operator-supplied decks. Detail: `docs/dev/reviews/review-260828.md`. — fixed -- ALREADY FIXED by FND-125, and closed as a duplicate rather than re-fixed. Same defect from a second finder: unbounded allocation from GW segment count and GR repeat count. Verified rather than assumed: `GW 1 4000000000` and `GR 1 4000000000` both exit 1 naming the card, the requested total and the 10000 cap. | | FND-086 | 2026-08-28 | fixed | **[medium] cli-guide is behind the shipped binary: no `fnec project convert` anywhere in the guide, and the emitted SWEEP_POINTS section is absent from the report contract** | Found by the 2026-08-28 whole-project audit (R07); confirmed by adversarial verification. All three sub-claims verified. `project convert` appears 0 times in cli-guide despite the binary printing it in its own usage and roadmap GAP-015 citing it as the entry point. SWEEP_POINTS appears 0 times in the guide though the 'Output format' section declares the report contract 'stable, versioned' and enumerates every other section; a run confirmed SWEEP_POINTS is emitted on every multi-frequency text run and report_contract.rs locks it -- so the guide is the one diverged copy. `fnec worker` absent from the guide AND from the binary's usage. CORRECTION: the guide is not wholly silent on workers (it covers --hosts and a distributed-sweep section); what is missing is the worker --stdio subcommand. Detail: `docs/dev/reviews/review-260828.md`. Fixed in #447: `fnec project convert` and `fnec worker --stdio` are documented, `SWEEP_POINTS` is in the report contract, and the binary's usage now lists every subcommand it dispatches. That claim was itself false on the first pass: I added `taper` and `worker` but not `sweep`, and the gate could not catch it because the gate reads the usage text — it is blind to exactly what the usage omits. `sweep` is in both now. Gated so it cannot recur: `the_cli_guide_documents_every_report_section_the_binary_emits` (unioned across four deck classes, because no single deck emits more than four of the seven sections) and `the_cli_guide_documents_every_subcommand_the_usage_advertises`, both sabotage-verified. | | FND-085 | 2026-08-28 | fixed | **[medium] python-bindings.md claims 'Hallen solver only (no ... selection from Python yet)' — fnec_py has had a solver= kwarg (including "mpie") and current-source solving since #413** | Found by the 2026-08-28 whole-project audit (R05); confirmed by adversarial verification. python-bindings.md:123 'Hallen solver only (no ... selection from Python yet)' is false: lib.rs:287/319 both carry #[pyo3(signature = (deck, solver = "hallen"))], solver_from_name accepts 'mpie', and MPIE decks route to solve_mpie_session. Documented signatures are stale (no solver parameter shown). Corrections: the parenthetical about pulse/continuity is about BASIS choice not solver kind, so only the leading clause is false; the current-source half is an omission not a falsehood. Detail: `docs/dev/reviews/review-260828.md`. Fixed in #447. Only the leading clause was false: `solver="mpie"` is accepted, while pulse/continuity/sinusoidal are BASIS choices that genuinely are not exposed, so the parenthetical stays. Both documented signatures gained the missing `solver` parameter. | -| FND-084 | 2026-08-28 | open | **[medium] json-output-schema.md promises an empty JSON array `[]` for a no-FR deck; the binary emits zero bytes, so the doc's own Python example crashes** | Found by the 2026-08-28 whole-project audit (R02); confirmed by adversarial verification. json-output-schema.md promises [] for a no-FR deck; main.rs:254-256 early-returns SUCCESS ~270 lines before the only JSON emitter, so stdout is 0 bytes and json.loads('') raises. Control holds: FR-but-no-EX emits [] (that control is void from #449 onward — an FR-but-no-EX deck is now refused; the no-FR case this row is about is unchanged and re-measured 2026-09-07 at exit 0, 0 bytes). Text mode also prints nothing at all. One-line fix (emit [] before the early return). Detail: `docs/dev/reviews/review-260828.md`. | +| FND-084 | 2026-08-28 | fixed | **[medium] json-output-schema.md promises an empty JSON array `[]` for a no-FR deck; the binary emits zero bytes, so the doc's own Python example crashes** | Found by the 2026-08-28 whole-project audit (R02); confirmed by adversarial verification. json-output-schema.md promises [] for a no-FR deck; main.rs:254-256 early-returns SUCCESS ~270 lines before the only JSON emitter, so stdout is 0 bytes and json.loads('') raises. Control holds: FR-but-no-EX emits [] (that control is void from #449 onward — an FR-but-no-EX deck is now refused; the no-FR case this row is about is unchanged and re-measured 2026-09-07 at exit 0, 0 bytes). Text mode also prints nothing at all. One-line fix (emit [] before the early return). Detail: `docs/dev/reviews/review-260828.md`. **Fixed in #451 by the opposite of the remedy this row proposed** — the defect (a documented promise the binary never kept) is gone because the paragraph was withdrawn and the deck refused, not because `[]` started being emitted. Emitting `[]` is not one line and not one axis: `[]` is printed only under `--output-format json` (`apps/nec-cli/src/main.rs`), so text mode would still have written zero bytes at exit 0 and FND-070 would have survived intact. It also spends a signal that already means something else — `[]` + exit 0 is *solved, no priceable feedpoint*, measured on `corpus/dipole-ex1-freesp-51seg.nec`, and a consumer could no longer tell that from *never solved*. There was no contract to honour either: `json.loads('')` raises, so every consumer in the tree (`examples/optimize_swr.py`, `docs/automation-guide.md`) already crashed on this deck and now gets a clean non-zero exit instead. The deck is refused; the doc paragraph is withdrawn rather than made true. | | FND-083 | 2026-08-28 | fixed | **[medium] README feature list describes EX 1/2/4/5, PT, and NT as 'semantics pending / runs as EX-0 with a warning' — all six shipped over a year of releases ago** | Found by the 2026-08-28 whole-project audit (R01); confirmed by adversarial verification. README claims EX1/2/4/5,PT,NT are deferred/warn-only; all four actually solve (EX4 -> hallen-current-source Z=74.23+j13.90; NT moved Z to 6545+j1119; PT -1 suppressed CURRENTS). card-support-matrix table is right but its own intro prose is ALSO stale -> three-way divergence. README has zero occurrences of 'mpie' or 'sommerfeld'. Staleness is ~2 months / 7 releases, not 'over a year'. Docs-only, errs toward understating capability. Detail: `docs/dev/reviews/review-260828.md`. Fixed in #447, each claim re-verified by running: EX 1/2/3 emit RECEIVE_PATTERN, EX 4 gives Z = V_port/i0, EX 5 equals type 0, `PT -1` suppresses CURRENTS while `PT 0` prints all 51 segments, and a well-formed NT moves the feedpoint 74.24 + j13.90 -> 70.63 + j14.01. The evidence cell's second observation -- zero occurrences of "mpie" or "sommerfeld" -- is fixed too. The third sub-claim (card-support-matrix intro prose also stale) does NOT reproduce: that file changed in #432/#435/#437 and its intro is a legend consistent with its table. | | FND-082 | 2026-08-28 | open | **[low] Any wire touching z = 0 is refused as 'buried' for PEC/finite ground — the canonical ground-mounted quarter-wave monopole cannot be solved** | Found by the 2026-08-28 whole-project audit (R62); confirmed by adversarial verification. buried_wire_geometry_error rejects min(z)<=1e-9 for PEC/SimpleFiniteGround as cited; a ground-mounted monopole (GW base at z=0, GN 1) hard-errors 'unsupported buried-wire geometry ... deferred' while nec2c solved the identical deck without complaint. CORRECTION: the finder's '~36+j21 ohm' figure was not reproduced (the verifier's decks were not precisely resonant) -- illustrative, not load-bearing. IMPORTANT CONTEXT THE FINDER MISSED: this is a KNOWN, deliberately scoped, already-tracked limitation (PH2-CHK-002, docs/nec4-support.md, docs/corpus-validation-strategy.md) with a dedicated regression case (dipole-gn2-buried-unsupported) locking the fail-fast behaviour in BY DESIGN. A documented non-goal that fails loud with an actionable message -- belongs in the report as a scope note, not a defect. Detail: `docs/dev/reviews/review-260828.md`. | | FND-081 | 2026-08-28 | open | **[low] Negative-resistance caveat is suppressed for Pulse, Continuity AND Sinusoidal solver modes — including the mode the CLI's own warning recommends as accurate** | Found by the 2026-08-28 whole-project audit (R61); confirmed by adversarial verification. Reproduced with a real sinusoidal-basis solve (forced via --sin-fallback-rel-max 1.0 so SOLVER_MODE really was sinusoidal, not a hallen fallback) on a degree-3 T/Y deck: Z_RE = -37.67 ohm with ZERO stderr lines matching 'negative'. Same deck under --solver pulse: Z_RE = -665.8, also zero. So sinusoidal really is exempt from the negative-R caveat while warnings.rs:30-38 names it as the ACCURATE alternative to pulse/continuity -- a genuine inconsistency with validate.rs:249's internal 'sinusoidal is equally experimental' rationale. CORRECTION: the finder's 'no signal at all' is overstated for this repro -- the T/Y-junction and on-a-junction warnings still fire under every solver mode, so a junctioned deck does get caveats, just not the negative-R one. The literal claim holds only for a NON-junctioned deck going negative purely from basis divergence. Deliberate documented exemption on a ... Detail: `docs/dev/reviews/review-260828.md`. | @@ -121,7 +123,7 @@ An `open` row is not a failure — it is the point. What the process forbids is | FND-073 | 2026-08-28 | open | **[low] WorkerPool::new_ssh (fail-fast pool constructor) has no callers anywhere, including tests** | Found by the 2026-08-28 whole-project audit (R40); confirmed by adversarial verification. grep -rnw new_ssh returns exactly two hits: pool.rs:61 (doc comment) and pool.rs:92 (definition). Zero call sites in shipped code, tests, examples or benches; the shipped CLI uses only new_ssh_skip_failures. Fail-fast error path is dead and untested. Detail: `docs/dev/reviews/review-260828.md`. | | FND-072 | 2026-08-28 | open | **[low] Deck caveats and the solve/sweep result come from two independent reads of the deck file, and successive DeckWarnings tasks race with no ordering guard** | Found by the 2026-08-28 whole-project audit (R35); confirmed by adversarial verification. Two genuinely independent file reads with no shared snapshot: solve_deck_path does its own read_to_string while the refresh_warnings block spawns a second read_deck_text. The sweep path by contrast reads once and passes the string down. DeckWarnings applies unconditionally with no generation guard, in explicit asymmetry with SolveComplete's awaiting_solve() guard in the same file. Since SolverSelected is in the refresh set, toggling the picker spawns two concurrent futures with no ordering guarantee. UI-only staleness. Detail: `docs/dev/reviews/review-260828.md`. | | FND-071 | 2026-08-28 | open | **[low] Blocking native file dialogs (and synchronous file writes) inside update() freeze the iced event loop** | Found by the 2026-08-28 whole-project audit (R33); confirmed by adversarial verification. rfd 0.14 sync FileDialog blocks the winit event-loop thread at main.rs:299/309/319, plus std::fs::write at 326 and a Session save per keystroke at 139. Sweep freeze real (channel(64) backpressure, no message loss). CORRECTIONS: the extra citation 338-353 is WRONG -- that block is already Task::perform, i.e. correctly off-thread; and there is no documented responsiveness convention, only a de facto pattern. Downgrade: user-initiated modal dialogs during which the app is unusable anyway; no wrong result, no data loss. Detail: `docs/dev/reviews/review-260828.md`. | -| FND-070 | 2026-08-28 | open | **[low] A deck with no FR card: CLI exits 0 printing nothing at all, while the GUI and fnec_py refuse it** | Found by the 2026-08-28 whole-project audit (R28); confirmed by adversarial verification. main.rs:254 `if freqs_hz.is_empty() { return ExitCode::SUCCESS; }` -- reproduced: no-FR deck gives exit 0 with stdout+stderr = 0 bytes (wc -c). GUI solve.rs:410 and fnec_py lib.rs:296 independently construct 'deck has no FR card' as an ERROR. Three-frontend divergence verified as stated. Detail: `docs/dev/reviews/review-260828.md`. | +| FND-070 | 2026-08-28 | fixed | **[low] A deck with no FR card: CLI exits 0 printing nothing at all, while the GUI and fnec_py refuse it** | Found by the 2026-08-28 whole-project audit (R28); confirmed by adversarial verification. main.rs:254 `if freqs_hz.is_empty() { return ExitCode::SUCCESS; }` -- reproduced: no-FR deck gives exit 0 with stdout+stderr = 0 bytes (wc -c). GUI solve.rs:410 and fnec_py lib.rs:296 independently construct 'deck has no FR card' as an ERROR. Three-frontend divergence verified as stated. Detail: `docs/dev/reviews/review-260828.md`. **Worse than recorded, and a fourth site.** Re-measured 2026-09-08: the CLI exits 0 with zero bytes on stdout AND stderr — a silent success. And `fnec_py` does not simply "refuse": `solve_deck_str` raises while `sweep_deck_str` (`lib.rs:315`) returned `[]`, so that module disagreed with itself and its sweep half returned the empty-result-standing-for-an-error shape. Found by fable's design review, which my own site enumeration had missed. Fixed in #451 by `validate::no_frequency_error(freqs_hz, remedy)` — typed on the **resolved list**, not the deck, because `--sweep-config` supplies frequencies for a deck with no `FR` and a deck-typed check in `pre_solve_error` would have refused that working capability (which no test covered until this change added one). Sharing the predicate rather than only the sentence is what makes the sabotage bite: forcing the helper to return `None` fails the CLI, GUI **and** pytest gates, where FND-145's equivalent sabotage left every CLI test green. | | FND-069 | 2026-08-28 | open | **[low] CLI FR sweeps still emit one negative-resistance warning per point — the swept aggregate producer is wired to GUI and fnec_py only** | Found by the 2026-08-28 whole-project audit (R27); confirmed by adversarial verification. swept_negative_resistance_caveat is called only from nec-gui solve.rs:845 and fnec_py lib.rs:361, never from nec-cli, whose per-point warn runs inside the per-frequency solve. MEASURED: the BENT_NEGATIVE_R inverted-V over a 50-point sweep produced exactly 50 distinct 'has negative resistance' lines on stderr, each with a different Re Z. Aggregate-caveat seam with one diverged frontend; stderr noise, not wrong physics. Detail: `docs/dev/reviews/review-260828.md`. | | FND-068 | 2026-08-28 | open | **[low] Version-bump docs gate detects the bump with the same banned sed/head grep, and is PR-only + not a required status check** | Found by the 2026-08-28 whole-project audit (R16); confirmed by adversarial verification. check-version-bump-docs.sh:22 uses the same banned sed/head first-match extraction its sibling documents as 'exactly how a check earns false confidence'; dormant today (only one version line). CI step is `if: github.event_name == 'pull_request'`. Verifier checked the live repo config: branch protection required_status_checks.contexts = [] and the only ruleset targets TAG refs, so 'docs contract' is not required anywhere and a PR can merge with it red or skipped. release-tag.yml greps changelog/releasenotes at mint time but NEVER SBOM.spdx.json, so the SBOM requirement is enforced in exactly one bypassable place. Detail: `docs/dev/reviews/review-260828.md`. | | FND-067 | 2026-08-28 | open | **[low] Corpus provenance derives the historical version with the exact naive first-line grep its sibling checker documents as a trap — and stamps + checks with the same code, so an error would self-validate** | Found by the 2026-08-28 whole-project audit (R15); confirmed by adversarial verification. derive-corpus-provenance.py:46 takes the first line starting with 'version' and splits on quotes, while its sibling check-release-tags.py:95 uses tomllib and its docstring explicitly says 'Not a grep... exactly how a check earns false confidence'. --check recomputes with the SAME naive derivation it used to stamp, so a wrong derivation would write wrong provenance and certify it fresh. Verifier replayed all 76 commits touching reference-results.json comparing naive grep vs tomllib: 0 divergences today. Latent shape, not an active bug. Detail: `docs/dev/reviews/review-260828.md`. | diff --git a/docs/project/test-catalog.md b/docs/project/test-catalog.md index 220ce37..cba14b3 100644 --- a/docs/project/test-catalog.md +++ b/docs/project/test-catalog.md @@ -2,7 +2,7 @@ project: fnec-rust doc: docs/project/test-catalog.md status: living -last_updated: 2026-09-07 +last_updated: 2026-09-08 --- # Test catalog @@ -37,7 +37,7 @@ counts (measured, not estimated). Aggregate pass/fail is recorded separately in | `apps/nec-cli/tests/result_cache_contract.rs` | 5 | Distributed result cache hit/miss/invalidation + sweep reuse | PH6-CHK-007 | | `apps/nec-cli/tests/scriptability_contract.rs` | 25 | Scripting/drop-in alias contract; temp-file & path handling | NFR-005, GAP-011, PH2-CHK-008 | | `apps/nec-cli/tests/sinusoidal_a2_regression.rs` | 2 | Sinusoidal solver tracks Hallén on dipole + sweep | DEC-011, PH6-CHK-003 | -| `apps/nec-cli/tests/sweep_contract.rs` | 5 | Sweep point/list/linear produce correct frequency blocks | FR-007, PH3-CHK-006 | +| `apps/nec-cli/tests/sweep_contract.rs` | 7 | Sweep point/list/linear produce correct frequency blocks; `--sweep-config` **supplies** frequencies for an FR-less deck, and a deck with no frequency from any source is refused (FND-070) | FR-007, PH3-CHK-006 | | `apps/nec-cli/tests/template_contract.rs` | 5 | TOML/JSON var substitution; undefined-token error | PH3-CHK-007 | | `apps/nec-cli/tests/tl_cards.rs` | 3 | `TL` card changes feedpoint Z across nseg | PRT-002, PH2-CHK-003 | | `apps/nec-cli/tests/topology_fallback.rs` | 13 | Non-single-chain fallback across solver/pulse/exec/sinusoidal/loaded | DEC-010/011 | @@ -57,7 +57,7 @@ counts (measured, not estimated). Aggregate pass/fail is recorded separately in | `apps/nec-cli/tests/current_source_junction.rs` | 1 | CLI junctioned current source: split-dipole EX-4 feedpoint Z=V/i0 matches voltage-source Z (~2e-4) | PH9-CHK-002 | | `crates/nec_worker/tests/gpu_exec.rs` | 2 | Worker-level GPU execution vs CPU parity | PH7-CHK-004 | -Integration subtotal: **519** test +Integration subtotal: **522** test functions across the `tests/` binaries listed above. ## Unit tests (in `src/`) @@ -82,8 +82,8 @@ Unit subtotal: **567** `#[test]` functions. ## Totals -- **Test functions**: **1093** = 567 unit + 519 integration + **7 doctests**. -- **`cargo test --workspace` aggregate**: **1091 passing, 0 failed, 2 ignored**, +- **Test functions**: **1096** = 567 unit + 522 integration + **7 doctests**. +- **`cargo test --workspace` aggregate**: **1094 passing, 0 failed, 2 ignored**, measured 2026-09-07 — the authoritative pass count in [test-results.md](test-results.md). Doctests are counted separately on purpose. `cargo test --workspace -- --list` diff --git a/docs/python-bindings.md b/docs/python-bindings.md index 0b2613e..ed0a362 100644 --- a/docs/python-bindings.md +++ b/docs/python-bindings.md @@ -2,7 +2,7 @@ project: fnec-rust doc: docs/python-bindings.md status: living -last_updated: 2026-08-31 +last_updated: 2026-09-08 --- # fnec Python Bindings (`fnec_py`) @@ -92,6 +92,12 @@ Solve all frequency points defined by the deck's `FR` card(s) and return a list of dicts (one per frequency point), each with the same fields as `solve_deck_str`. +Raises `RuntimeError` for a deck with no `FR` card. It used to return an empty +list for that deck, at success, while `solve_deck_str` raised — one module +disagreeing with itself, and an empty result standing in for an error (FND-070). +These bindings read frequencies from the deck only; there is no `--sweep-config` +equivalent here. + ```python sweep_deck = """ CM Dipole sweep 14–16 MHz