notes · · 5 min

Two negations, one meaning

Inverting a boolean predicate requires changing both its body and its uses. Three direct-call shapes explain the negations and parentheses; a September prod-code implementation also shows the limits of reference discovery and textual rewriting.

On this page · 4 sections
  1. The body
  2. The calls
  3. The reference that is not a call
  4. Tests and later scope

Some predicates are named the wrong way round. A codebase full of !is_disabled(x) reads better as is_enabled(x), and a check that is nearly always negated at its call sites wants to be the other check. The refactoring is on this project’s catalog as invert_boolean: give the function the opposite name and meaning, with the aim of preserving the meaning of its uses. Doing that for a few direct calls is simpler than establishing it for an entire program.

This post follows the Rust implementation merged on September 23, 2026 in PR #116, pinned at 7129be17. The command output, timings and development-test outcomes below are historical PR reports, not executions independently repeated for this post. Later versions changed the tool.

The probe recorded in PR #108 did not offer this refactoring at the tested positions. It offered convert_bool_to_enum at a boolean binding - a different change. This is a result for those probes, not every analyzer version or possible trigger. code_invert_boolean implemented the operation in two halves.

The body

The function must now return the opposite of what it returned. A body that is one expression is negated in place:

-pub fn is_small(n: u32) -> bool {
-    n < 10
+pub fn is_large(n: u32) -> bool {
+    !(n < 10)

A body with statements is negated as a block - !{ … } negates the block’s value, which is its tail - and every return in it is negated too, because a return leaves before the tail is reached. Every return of the function, that is: a return inside a closure or an async block returns from that closure or block, and one inside a nested fn returns from that function, so the implementation tries to leave those alone. Its selection of return sites scans text, using braces and nearby |, async and fn spellings. The unit test writes out specific closure, async-block and nested-function shapes; that does not establish correct handling of every valid Rust form.

The calls

The implementation processes references returned by the analyzer. For a reference it can read as a call, the proposed edit depends on what is around it:

is_small becomes is_large and its body n < 10 becomes !(n < 10). Three call sites: if is_small(n) becomes if !is_large(n), a call that gains a negation; else if !is_small(n + 1) becomes else if is_large(n + 1), where two negations cancel; is_small(n).then(|| n) becomes (!is_large(n)).then(|| n), a call that goes on is wrapped in parentheses. For these shown boolean examples, the added or removed negation preserves the expression value; this is not proof of complete reference discovery or arbitrary program equivalence
For the three shown boolean expressions, the negations preserve their values. These examples do not establish completeness of the tool’s rewrite.

A plain call gains a !. A call that already had a ! loses it instead: !!is_large(n) would be correct and nobody would write it. And a call that goes on - is_small(n).then(|| n) - cannot just gain a ! in front, because method calls bind more tightly than unary !: !is_large(n).then(|| n) negates whatever .then returns, which here is an Option and does not compile, and in other cases would compile and mean something else. The proposed edit is (!is_large(n)).then(|| n). This revision uses the next non-whitespace character (., ? or [) to select parentheses; it is a textual rule rather than a complete expression-tree transformation.

PR #116 reports this scratch-crate result. The excerpt is abridged: it omits the changed-line count, file headers and final apply instruction. The body diff is included here:

`is_small` → `is_large` (src/lib.rs)

- the body returns the negation of what it returned
- 2 call(s) gain a `!`, 1 lose the `!` they had

-    n < 10
+    !(n < 10)
-    if is_small(n) {
+    if !is_large(n) {
-    } else if !is_small(n + 1) {
+    } else if is_large(n + 1) {
-    is_small(n).then(|| n)
+    (!is_large(n)).then(|| n)

the analyzer accepts the result: 0 errors

The PR reports a warm dry-run duration of 0.68 seconds for this example. It is a single reported observation, not a general latency guarantee or evidence of an apply run.

The reference that is not a call

One reference shape this implementation leaves unchanged is the function used as a value, filter(is_small) or a fn(u32) -> bool returned from somewhere. Under the new name it would mean the opposite, and there is no call to put a ! in front of - one possible adapter is a closure, |n| !is_large(n), and whether the place that holds the function wants the old meaning or the new one is not something the text says. This revision lists the reference as unmatched and leaves it untouched, including its old name. If that name no longer resolves after the declaration is renamed, the remaining use needs repair; merely listing it does not make the result valid.

The code validates the rewritten files in an analyzer overlay. An apply request is refused when that check returns error diagnostics, unless force is set. There is no separate unmatched gate. A file containing only an unmatched use need not be in the rewritten set, so this path does not guarantee that the unresolved use will be diagnosed. A dry run writes nothing regardless of those results. verify: "compile" was an additional option; an empty analyzer report alone is not compilation or behaviour-preservation proof.

When a returned reference points inside the predicate’s own body, this implementation refuses the operation. The test includes such a recursive case. This still depends on receiving the reference: the historical code turns a failed reference query into an empty list. Neither that empty list nor the unmatched list proves that all uses have been accounted for.

Tests and later scope

The EVEN fixture in the PR’s orchestration test contains an early return, direct and negated calls, a continued .then_some(n) call and a function-value reference. It uses a scripted gateway that supplies references and no analyzer diagnostics. The test asserts the rewritten strings, an unmatched count of one, and a successful apply. That checks orchestration under those answers; it does not demonstrate that a real analyzer accepts the leftover old name. A second fixture checks refusal when a recursive reference is supplied.

PR #116 reports that the first run failed because a supplied reference column pointed at s_even rather than is_even. It also reports that disabling negation cancellation made the counts (3, 0) instead of (2, 1). These development and mutation-check outcomes are attributed to the PR; the available feature/test commit and source assertions are not independent run receipts. We have not rerun those checks.

The following boundaries belong to that historical implementation:

  • Fields and variables came later. This revision targeted functions. PR #135 added boolean fields and locals at 06:38 UTC on September 23, 2026, later the same morning.
  • Names. The new name is given, not derived; is_valid does not become is_invalid by itself.
  • Rust-only is historical. PR #707 added other languages on September 29; 080971c9 later corrected declaration matching in that path. This post does not assess the correctness of those later implementations.
Cite this article
Citation
Alexander Panasenko (2026-10-17). Two negations, one meaning. https://prod.codes/blog/two-negations-one-meaning/