Skip to content

Commit f61de00

Browse files
committed
refactor(engine): a fragment loses rows for two reasons; carry them apart
Prerequisite for adopting v1's absence semantics, and inert on its own — no behaviour changes, 415 tests unmoved. A term fragment can lose rows at a coordinate for two unrelated reasons, and a constraint row has to react to exactly one of them. A **masked variable** is genuinely absent there. A **sparse parameter** is a compressed dense array whose missing rows mean a zero coefficient — SPEC §8's "sparse data gives sparse variables", and what a generated binder emits by the tableful. Once the two are multiplied into one frame the distinction is gone, which is why "drop the row where a variable is absent" could not be written as an anti-join against the term stream. So the variable's own coordinates ride alongside as `TermFragment.presence`, rewritten by the same shape operators that rewrite the term. The propagation rule follows from the convention rather than from convenience: variable leaf the variable's frame parameter, constant none — sparsity is an encoding, not absence a * b, a / b, -a the variable side's, unchanged: a sparse coefficient zeroes a term, it does not unmake the variable under it sum, group_sum cleared — §13 has reductions *skip* absent slots rather than propagate them, which is what keeps a cross-module accounting equation summing over a partly-masked dim roll, shift still to do: the coordinate map has to be applied to presence too, and shift's vacated edge unioned back in, since SPEC §7 declares it contributes zero Refs #8
1 parent 403fa33 commit f61de00

1 file changed

Lines changed: 25 additions & 3 deletions

File tree

src/farkas/relational/compiler.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ class TermFragment:
6262
6363
A term's other dims arrived by broadcast. See :meth:`survives_dropping`.
6464
"""
65+
presence: pl.LazyFrame | None = None
66+
"""Where the *variable* under this fragment exists, keyed by :attr:`dims`.
67+
68+
Not the same question as which rows :attr:`frame` has. A fragment loses rows
69+
for two unrelated reasons, and a constraint row must react to only one of
70+
them: a **masked variable** is genuinely absent there, while a **sparse
71+
parameter** is a compressed dense array whose missing rows mean a zero
72+
coefficient (SPEC §8). Once the two are multiplied together the frame cannot
73+
tell them apart, so the variable's own coordinates are carried alongside.
74+
75+
``None`` means "nothing to report" — a constant fragment has no variable, and
76+
a reduction clears it, because ``sum`` skips absent slots rather than
77+
propagating them (v1 ``convention.rst`` §13).
78+
"""
6579

6680
@property
6781
def value_column(self) -> str:
@@ -299,7 +313,8 @@ def _variable_fragment(self, name: str) -> TermFragment:
299313

300314
dims = self.program.variable(name).dims
301315
frame = self.variables[name].select(*dims, 'var_label', pl.lit(1.0, dtype=pl.Float64).alias('coeff'))
302-
return TermFragment(dims, frame, True, label_dims=frozenset(dims))
316+
presence = self.variables[name].select(*dims)
317+
return TermFragment(dims, frame, True, label_dims=frozenset(dims), presence=presence)
303318

304319
def _product(self, a: CompiledExpression, b: CompiledExpression, context: str) -> CompiledExpression:
305320
"""``a * b``, with the variable-carrying side normalised to the left."""
@@ -348,6 +363,8 @@ def _sum_fragment(self, p: TermFragment, over: tuple[str, ...], context: str) ->
348363
frame = p.frame.select(*keep, *p.carried)
349364
if scale != 1:
350365
frame = frame.with_columns(pl.col(p.value_column) * scale)
366+
# §13: a reduction *skips* absent slots rather than propagating them, so
367+
# summing over a partly-masked dim is well defined and reports nothing.
351368
return TermFragment(keep, frame, p.is_term, p.survives_dropping(dropped), p.label_dims - dropped)
352369

353370
def _group_fragment(self, p: TermFragment, g: plan.GroupSum, context: str) -> TermFragment:
@@ -372,6 +389,7 @@ def _group_fragment(self, p: TermFragment, g: plan.GroupSum, context: str) -> Te
372389
mapping = self.dimensions[g.over].select(pl.col('val').alias(g.over), pl.col(g.coordinate).alias(g.into))
373390
frame = p.frame.join(mapping, on=g.over, how='inner').select(*keep, g.into, *p.carried)
374391
keyed = p.keyed and g.over in p.label_dims
392+
# a group is a sum, so §13 applies here as well: absence does not escape it
375393
return TermFragment((*keep, g.into), frame, p.is_term, keyed, _relabel(p.label_dims, g.over, g.into))
376394

377395
def _translate_fragment(self, p: TermFragment, s: plan.Translate, context: str) -> TermFragment:
@@ -477,7 +495,9 @@ def _map_fragments(
477495

478496
def _negate(p: TermFragment) -> TermFragment:
479497

480-
return TermFragment(p.dims, p.frame.with_columns(-pl.col(p.value_column)), p.is_term, p.keyed, p.label_dims)
498+
return TermFragment(
499+
p.dims, p.frame.with_columns(-pl.col(p.value_column)), p.is_term, p.keyed, p.label_dims, p.presence
500+
)
481501

482502

483503
def _join_mul(a: TermFragment, c: TermFragment, is_term: bool, divide: bool = False) -> TermFragment:
@@ -498,4 +518,6 @@ def _join_mul(a: TermFragment, c: TermFragment, is_term: bool, divide: bool = Fa
498518
out = 'coeff' if is_term else 'cval'
499519
carried = ['var_label', out] if is_term else [out]
500520
frame = joined.with_columns(combined.alias(out)).select(*out_dims, *carried)
501-
return TermFragment(out_dims, frame, is_term, a.keyed and c.keyed, a.label_dims)
521+
# *c* is variable-free, so it contributes no absence: a sparse coefficient
522+
# zeroes a term, it does not unmake the variable underneath it.
523+
return TermFragment(out_dims, frame, is_term, a.keyed and c.keyed, a.label_dims, a.presence)

0 commit comments

Comments
 (0)