v0.14.0
Added
-
Pairedfills the second key of a many-to-many, and the edge count stays
exact. Two fan-outs on one through table are refused because a fan-out is a
partition computed from the row index alone, so two of them partition the same
rows without either seeing the other -- nothing enumerates the pairs and a
collision is a matter of the seed.person=Paired("company", Zipf())is the
half that looks:companypartitions the rows as any fan-out does, and within
each of its groupspersontakes that many distinct partners. A duplicate
pair is impossible by construction rather than removed afterwards, so nothing
is deduplicated and the build never reports an achieved count against a
requested one. The design notes for this milestone had given that up; they
were wrong, and the rule that cardinality is declared survives M2M.- What binds is the busiest group, not the product. Every row of one
group needs a different partner, so the constraint is
largest group <= partnersrather thanrows <= groups x partners-- and a
heavy tail puts a large share of every edge on one group,Zipf(1.2)over
five thousand of them putting 21% on the top one. A declaration that looks
sparse against the product can still be impossible, and it cannot be known
until the partition is resolved: this is the one structural refusal that
waits for the build rather than firing where the shape is written. - The second side is derived and approximate, and the numbers are
published. Both marginals plus the edge count is over-determined, and
fixing all three is a constraint satisfaction problem that cannot stream
intoCOPY. Measured against exact weighted sampling over 200,000 edges,
the busiest partner comes out 1.09 times as busy, the 99th percentile about
30% high, and about 7% fewer partners are touched. - Partners are allocated across weight bands and strided within one, so
nothing is ever drawn and asked whether it is taken. Rejection sampling
degenerates exactly where the shape is most interesting -- 245 probes per
row on that world -- and the usual fix, sampling the ones to leave out when
a group wants more than half, is ten times faster and a different sampling
rule, so a group one row over the halfway mark would get a materially
different membership from one row under. The band count is derived from
the declared weights rather than chosen, which systematic allocation is
what makes possible: it converges as bands get finer where largest-remainder
runs away, so "enough" is a limit rather than a number somebody tuned.
- What binds is the busiest group, not the product. Every row of one
-
FanOutPlan.group_ofanswers which parent a row belongs to, which a
pairing needs to choose partners inside this row's group. -
Projection(..., through=Model)says which model a derived join runs on.
An abstract base carryingcreated_by/updated_bytoUser-- a pattern most
Django schemas have -- makes any two models in the schema joinable, so the
derivation had candidates everywhere and could settle nothing: not for one
pair but for every pair, which put the derived form out of reach for a whole
application and leftsql=as the only route to this feature's own motivating
example. The refusal now namesthrough=first, and names a model that can
actually resolve the join rather than the first one alphabetically -- an audit
model is reached by two edges from each side, so it is a real candidate and
never a usable answer. Where every candidate is reached more than once,
through=cannot narrow anything and the refusal says so instead of
suggesting it.
Changed
- The two-fan-out refusal names the declaration that replaces it. Its remedy
used to be a statement of your own, because nothing else could keep the
constraint. It now namesPairedfirst and keepsProjection(columns=, sql=)
as the escape hatch for an edge set that has to be a particular one.
Fixed
-
A rounded
Uniformcan size a fan-out.
FanOut(Uniform(1, 10, places=0))was refused with "needs numeric fan-out
sizes, but producedDecimal('3')" -- which reads as a contradiction, and was
one keyword away fromFanOut(Uniform(1, 10)), the spelling this package's own
docstring recommends and two of its refusals suggest.places=0is the natural
way to say that a fan-out size is a count. The guard was
isinstance(weight, (int, float))while the next line already did
float(weight), so it was stricter than the arithmetic it protected. It is now
the numeric tower plusDecimal, whichnumbers.Realdeliberately excludes,
andFractionand numpy scalars come along with it.boolstays refused. The
message also names a remedy now, which it was alone in not doing. -
columns=accepts the column anINSERTactually lists. The refusal that
sends a reader there says "the select's columns are what the insert lists", and
what an insert lists for a foreign key isevent_id-- which was then refused
as "no field named event_id". That is a contradiction rather than a correction,
and the surrounding documentation reinforced the wrong reading. Both spellings
are accepted and resolve to the same column, the messages say so, and the same
dict is read forstatistics=so the two cannot disagree about what a column
is called.