* It's pretty vague from the wording what the actual representation is.
The reference implementation is sign-magnitude, but is that mandated?
The only place I see where it's completely unambiguous is unary operator-().

Yes, it is mandated.
 
Especially, the definition of "effective width" seems to imply two's
complement. Is the effective width of "3" 2 bits, and of "-3" 3 bits?
(And what about "0"?)

I thought the effective width definition actually implies that the sign bit is separated because for two's complement would have different widths for positive and negative numbers, and unsigned positive numbers need fewer bits. The wording certainly may not be perfect yet.
 
* Should width_mag() return `floor(log2(x))` or `floor(log2(x)) + 1`?
It's defined (and implemented) as the former, but the name implies the
latter. The way representation() is defined in terms of width_mag()
definitely implies the latter. (Also, it's technically undefined for
negative values right now.)

Hmm yeah, I guess there needs to be an abs() somewhere.
 

* Regarding the from_range constructor, is determining the signedness of
the encoded value based on the signedness of the range's value type the
right API? I mean, it sort of makes sense, but I'm still on the fence.
The individual digits themselves are technically not signed integers
(their MSB is not a sign bit, except for the last), and are probably not
stored as such.

I don't know if the design of that one is good. I think it makes a bit of sense since it lets you construct from any other integer type, and communicating whether it's signed or unsigned seems pretty natural. This is useful when e.g. bit-casting a signed _BitInt(4096) to some limbs and constructing big_int from that, although to be fair, that is already provided directly via another constructor.
 
Should there be a way to treat the range as big-endian (in term of the
element order)? The range might not be reversible, but the big_int storage
is.

That sounds like a general problem that applies to all from_range constructors in the standard library. I don't really know how you would find yourself in a situation where you can't reverse the range anyway. In multiprecision, you basically always work with contiguous ranges, so it's always possible to run one through std::views::reverse.
 
In the "throws" section, you don't know ahead of time if the value
requires an allocation or not, so you can't give that promise (plus, the
iterator itself can throw).

I didn't think of the iterator throwing, but you don't need to know in advance if the value will fit for unsigned integers at least. You can just insert limbs into inplace storage as long as they fit, and if you run out of space, start allocating. However, maybe that doesn't work for negative integers passed via input range because you get an ever-increasing sequence of 1-bits, and once you hit the sign bit, you may realize "OOPS, I only need one limb to store this" because the number turns out to be minus one or something.

Anyway, thanks for all the feedback, I'll see about fixing it :)

If you find anything else, it makes things a lot easier for us if you create a GitHub issue in the repo; that allows us to immediately track the problem.