Skip to content

Replace OrdF64 type in the compiler with a first-class PositiveF64 type#988

Open
apoelstra wants to merge 13 commits into
rust-bitcoin:masterfrom
apoelstra:2026-06/ord-f64
Open

Replace OrdF64 type in the compiler with a first-class PositiveF64 type#988
apoelstra wants to merge 13 commits into
rust-bitcoin:masterfrom
apoelstra:2026-06/ord-f64

Conversation

@apoelstra

Copy link
Copy Markdown
Member

Supercedes #972.

The OrdF64 type in the compiler is weaker than it needs to be. Nominally it exists just to force an ordering on f64, with a code comment that it "will panic if given NaN". But it doesn't actually enforce that you can't put NaN into it, or any other value. There are also lots of places where we do reckless division that could plausibly turn into 0.0 / 0.0, and where we add values as u32s (which might overflow and panic) before converting to f64 or OrdF64. Plus there are 'as f64` casts all over the place, which is code smell.

In fact, all the probabilities used by the compiler, and parsed from policies, are required to be positive numbers (the policy values are even integers). So we can greatly improve this situation by renaming OrdF64 to PostiveF64, moving it to its own module, and enforcing that the contained value is always positive. This lets us:

  • freely add these values without worrying about cancellation
  • freely multiply and divide these values without worrying about zeroing them out or dividing by zero (although Inf is a permissible value for PositiveF64)
  • eliminate tons of casts

However, to do this we need to significantly refactor the compiler logic, which currently uses Option<f64> to represent "branch probabilities" and does so in an incoherent way, where None represents "not yet set or unneeded" and Some(0.0) roughly represents "required to be set but unneeded" and there are .expects all over the place. The compiler also stores these branch probabilities in the AstElemExt structure, which is totally unnecessary and results in a ton of mutation and arc-cloning.

@apoelstra apoelstra force-pushed the 2026-06/ord-f64 branch 2 times, most recently from dae7ba4 to 424f23b Compare June 15, 2026 14:37

@Abeeujah Abeeujah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is well executed, I identified a few nits which are optional or could be done in a follow up.

Comment thread src/primitives/positive_f64.rs Outdated
/// Attempts to create a [`PositiveF64`] from an ordinary `f64`.
pub fn new(f: f64) -> Option<Self> {
// Can likely make this const in Rust 1.83
if f > 0.0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Pending the MSRV upgrade for this to be made const, this can be rewritten to use the then_some method available on bool.

(f > 0.0).then_some(Self(f))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to use then rather than then_some, I suppose, because strictly speaking what you wrote results in a temporarily-existing invalid Self in the case that f <= 0. But good idea.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe, then_some returns Some(T) if the bool is true , or None otherwise; which looks very much like what the function is doing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clippy prefers then_some to then which I don't like, but okay, I'll use then_some..

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😅 then is almost equivalent to just wriiting the if else block, whereas then_some is more intuitive for this usecase and fits everything nicely in one line.

Comment thread src/primitives/positive_f64.rs Outdated
/// Returns the sum (or original value). Does not modify in-place.
#[must_use]
pub fn conditional_add(self, other: Option<Self>) -> Self {
if let Some(other) = other {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This can also be written to make use of the map_or method available on Option

other.map_or(self, |i| i + self)

{
// Compute the sum of all the items in the iterator. Because all items in
// the iterator are positive, this will be 0 iff the iterator is empty.
let sum = iter.clone().map(|x| x.0).sum::<f64>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: iff -> if

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, iff is correct.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah true, today's years old when I learned that.

/// Returns `None` if the return value would be 0, which is impermissible for the
/// [`PositiveF64`] type.
pub fn one_minus_k_over_n<const MAX: usize, T>(t: &Threshold<T, MAX>) -> Option<Self> {
if t.is_and() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This could also use the then_some method, if adopted, the tradeoffs here would be conciseness over clarity

(!t.is_and()).then_some(Self(1.0 - (t.k() as f64 / t.n() as f64)))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, I think this is too noisy.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought as much

Comment thread src/policy/compiler.rs
let rw = PositiveF64::from(subs[1].0) / total;

//and-or
if let (Concrete::And(x), _) = (subs[0].1.as_ref(), subs[1].1.as_ref()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this pattern here, could be simplified to just take the reference being used in this if block, and the one unused should not be evaluated at all, same for Line 158 below

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes we want to execute both sides of this. So there is no way to combine the two clauses into one.

Comment thread src/policy/compiler.rs
}
}
};
if let (_, Concrete::And(x)) = (&subs[0].1.as_ref(), subs[1].1.as_ref()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as stated above, the 0th value should not be evaluated since it is unused.

Comment thread src/policy/compiler.rs
}

Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function could benefit from Macros, it would greatly reduce the repetition.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole compiler needs to be rewritten to be non-recursive. So I don't want to make the code harder to modify ahead of doing that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great planning ahead, hopefully we get to eliminate a couple of the double nested loops too in the planned rewrite.

@Abeeujah Abeeujah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@Abeeujah Abeeujah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 424f23b

@yancyribbens

Copy link
Copy Markdown
Contributor

@Abeeujah can you stand on your head?

@Abeeujah

Copy link
Copy Markdown

@Abeeujah can you stand on your head?

I try, I try @yancyribbens

@yancyribbens

Copy link
Copy Markdown
Contributor

@Abeeujah

How many digits of pi can you calculate?

@Abeeujah

Copy link
Copy Markdown

@Abeeujah

How many digits of pi can you calculate?

I usually just end up with a massive headache after 3.14 🤣

@yancyribbens

Copy link
Copy Markdown
Contributor

I usually just end up with a massive headache after 3.14 🤣

Ha! ok, you pass the Turing test :P

@apoelstra

Copy link
Copy Markdown
Member Author

@Abeeujah is not a LLM and has been contributing usefully to rust-bitcoin for quite some time.

@yancyribbens

Copy link
Copy Markdown
Contributor

@Abeeujah is not a LLM and has been contributing usefully to rust-bitcoin for quite some time.

Yes, that is what I had meant by "passes Turing test", although maybe that was confusing.

My apologies @Abeeujah, we have an ongoing discussion in IRC to try to identify bot activity, and we thought your coding patterns might have been that of a bot/LLM.

Abeeujah and others added 4 commits June 20, 2026 15:45
Update the type name to better reflect its domain invariants. The
underlying floats are guaranteed to be both positive and not-NaN,
making `PositiveF64` a more accurate and descriptive name than
`OrdF64`, which only highlighted the `Ord` trait implementation.
Code move only. Retain the public constructor. We will eliminate its use
over the following commits.

Also, publicly expose the type in src/lib.rs, since we are going to start
enforcing invariants on it and making it generally useful.
Our parser only allows nonzero u32s, so just use the type. This adds a
bit of noise to the current code, but when we move the probability
computations from f64 to PositiveF64, it will pay dividends.

Also eliminates a couple panic paths where we were using .sum or bare
addition to add up probabilities; now we always convert to f64 before
doing the addition, which will lose precision but not panic if the
numbers get too big.
…values

Once we remove the unchecked constructor(s) from PositiveF64, we won't be
able to do these "sum everything then divide by sum" constructions. Instead,
encapsulate them into PositiveF64::normalized_iter.

This is mildly annoying to use, but I wasn't able to come up with a nicer
solution that avoided gratuitous clones, gave sane compiler errors if you
misuse them, and had readable code.
@apoelstra

Copy link
Copy Markdown
Member Author

Rebased on master.

@apoelstra

Copy link
Copy Markdown
Member Author

Addressed @Abeeujah's comments except the "should use a macro" one which I want to play with a bit.

@Abeeujah

Copy link
Copy Markdown

Addressed @Abeeujah's comments except the "should use a macro" one which I want to play with a bit.

You'd most likely have a lot of fun doing this, all the loop bodies are the same, only the provided arguments they evaluate differs.

By changing this one function and chasing compiler errors, I found I
was able to (and required to) replace a *ton* of f64s with PositiveF64s.
Amazingly, this did not require adding any new functionality to the
PositiveF64 type, or any conversions. Instead, I was able to *delete*
a ton of conversions, wrappers, unwrapping, etc.

Now concrete.rs has literally no instances of 'f64' which is awesome.
The next step is compiler.rs.
There is a ton of over-abstraction in AstElemeExt. Our original goal was
to save on code by having one function, AstElemExt::type_check_common,
which would match on a Terminal and do all the "fragment-specific" logic.
But the consequence is that this needs to be really general to handle all
the data that each fragment might need.

In particular, disjunctions need "weights" which are provided by the user
to indicate which branches are likely to be taken. No other kind of
astelem needs extra data, but all the wrappers need infrastructure to
carry this stuff around.

By eliminating the terminal() function in place of direct constructors
for each terminal type, we simplify the code and are able to also get
rid of the type_check function. The next commits will eventually get
rid of type_check_common, the branch_prob field of AstElemExt, a bunch
of mutability and unwrap paths, and possibly more.
… functions

Conjunctions don't need to set 'branch_prob', but we do set them, leading
to confusing logic. By getting rid of these, we can make the conjunction
logic simpler.

Relatedly, we add an AstElemExt::and_n function rather than trying to force
AstElem::and_or to produce an and_n. They're fundamentally different even
though they compile to the same sort of script; and_n is a conjunction, can
not be dissatisfied, and has no weights associated with it. By separating
the two concepts, we eliminate a place where we were setting probabilities
to 0.0, which is incorrect.
Had to move the logic into a separate function to avoid stack overflows,
because we are recursing very large/complex functions. Other than that I
tried to preserve the existing logic (and ran some local fuzztests quite
extensively to verify).

I removed all the unused code from AstElemExt::type_check, since it
would no longer compile -- I changed the disjunction code to take the
left/right weights as parameters rather than storing them in the
AstElemExt object. The next commit will cause even more code to become
dead; where possible, I have tried to prune dead code in separate commits
to make the diffs easier to read.

The goal of these commits is to remove the `branch_prob` field entirely,
which will then let us make AstElemExt immutable, which will unlock some
further refactorings. But until I'm done this, I have to just shuffle around
the existing logic and the result is pretty big/ugly.
Update the CompExtData::and_or function to take weights, as for the other
disjunctions, eliminating the last place where we read the branch_prob
field.

This totally eliminates the type_check method, and stops using the branch_prob
field of AstElemExt. The next commit will delete a ton of unused stuff, which
I didn't do here to minimize the amount of noise in this commit.

Then I will start converting to use PositiveF64 rather than f64 everywhere.
Costs remain using f64, since they may be zero (e.g. the satisfactio
cost of a timelock). But all probilities in the compiler should be
positive: if they are zero, the corresponding path is impossible and
we represent that with an option.
It is now impossible to create an invalid PositiveF64. This eliminates
any possibility of division by 0 in the compiler.
@apoelstra

Copy link
Copy Markdown
Member Author

Okay, used closures in place of macros and significantly reduced the code repetition. Also added the missing ops impls for PositiveF64, renamed and_or_0 to and_n which is the name used throughout the codebase, and cleaned up the history a bit.

I think this is ready to go now.

@apoelstra

Copy link
Copy Markdown
Member Author

On 347c334 successfully ran local tests

@Abeeujah Abeeujah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 347c334 Successfully ran local tests

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.

3 participants