earthsci-astdiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ """Analytical sparse Jacobians for EarthSciAST (.esm) models.
2
+
3
+ Python binding of the EarthSciASTDiff band calculus — see
4
+ ``esm-jacobian-spec.md`` at the repository root for the normative derivative
5
+ semantics (§3) and the band-entry format (§4). Gated by the same
6
+ deterministic-JSON goldens as the Julia reference.
7
+ """
8
+ from .bands import Band, BandError, Ctx, bands, merge_bands, normalize_band
9
+ from .clip import clip_regions
10
+ from .cse import cse_templates, expand_templates
11
+ from .emit import (jacobian_block, jacobian_pattern, parse_band,
12
+ parse_jacobian_block, scatter_pairs, serialize_band)
13
+ from .factorization import (BlockDiagonalPlan, lu_fill,
14
+ markowitz_ordering, plan_factorization,
15
+ serialize_plan)
16
+ from .structure import detect_structure
17
+ from .expr_helpers import Site, ckey, skey, stable_json
18
+ from .inline import InlineError, inline_observed
19
+ from .jacobian import JacEntry, jacobian_bands
20
+ from .numeric import EvalError, assemble_jacobian
21
+ from .scalar_rules import DerivativeRuleError, dscalar
22
+ from .simplify_branches import canon_lits, simp, simplify_branches
23
+ from .system import SysView, ctx_of, sysview
24
+
25
+ __all__ = [
26
+ "Band", "BandError", "Ctx", "bands", "merge_bands", "normalize_band",
27
+ "clip_regions", "jacobian_pattern", "parse_band", "scatter_pairs",
28
+ "jacobian_block", "parse_jacobian_block", "cse_templates",
29
+ "BlockDiagonalPlan", "plan_factorization", "serialize_plan",
30
+ "lu_fill", "markowitz_ordering",
31
+ "expand_templates", "detect_structure",
32
+ "serialize_band", "Site", "ckey", "skey", "stable_json",
33
+ "InlineError", "inline_observed", "JacEntry", "jacobian_bands",
34
+ "EvalError", "assemble_jacobian",
35
+ "DerivativeRuleError", "dscalar", "canon_lits", "simp",
36
+ "simplify_branches", "SysView", "ctx_of", "sysview",
37
+ ]
@@ -0,0 +1,465 @@
1
+ """The array-level band calculus (bands.jl): differentiate an array-valued
2
+ RHS w.r.t. one variable, producing structured bands."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from earthsci_ast.esm_types import Expr, ExprNode
8
+ from earthsci_ast.expr_walk import map_children
9
+ from earthsci_ast.expression import free_variables
10
+ from earthsci_ast.substitute import substitute
11
+
12
+ from .clip import clip_regions
13
+ from .expr_helpers import Site, add, ckey, is_zero, mul, neg, op, region_bound, skey
14
+ from .inline import all_names, binders
15
+ from .scalar_rules import dscalar, plain_sum, reduction
16
+ from .simplify_branches import canon_lits, simp
17
+ from .simplify_port import simplify_jl as simplify
18
+
19
+
20
+ @dataclass
21
+ class Band:
22
+ """One structured block of ∂(rhs of the equation for u)/∂v — see the
23
+ Julia reference docstring and esm-jacobian-spec.md §4. ``contracted``
24
+ holds free contracted column dimensions ``(name, lo, hi)``."""
25
+
26
+ rows: list[tuple[int, int]]
27
+ ridx: list # str names or int singletons
28
+ cidx: list # column index expressions
29
+ coef: Expr
30
+ contracted: list[tuple[str, int, int]] = field(default_factory=list)
31
+
32
+
33
+ @dataclass
34
+ class Ctx:
35
+ index_sets: dict[str, int]
36
+ shapes: dict[str, list[int]]
37
+ params: set[str]
38
+
39
+
40
+ class BandError(Exception):
41
+ """Raised when an expression has a form the band calculus cannot differentiate
42
+ (for example a filtered ``faq`` node or a non-``"+"`` semiring)."""
43
+
44
+
45
+ def _set_size(name: str, ctx: Ctx) -> int:
46
+ if name not in ctx.index_sets:
47
+ raise BandError(f"range over undeclared index set `{name}`")
48
+ return ctx.index_sets[name]
49
+
50
+
51
+ def _range_of(r, ctx: Ctx) -> tuple[int, int]:
52
+ if isinstance(r, dict):
53
+ return (1, _set_size(str(r["from"]), ctx))
54
+ if isinstance(r, (list, tuple)):
55
+ return (region_bound(r[0]), region_bound(r[-1]))
56
+ # typed IndexSetRef-like object with a `from`/`from_` attribute
57
+ frm = getattr(r, "from_", None) or getattr(r, "from_set", None)
58
+ if frm is not None:
59
+ return (1, _set_size(str(frm), ctx))
60
+ raise BandError(f"unsupported range spec {r!r}")
61
+
62
+
63
+ def is_array(ctx: Ctx, name) -> bool:
64
+ return isinstance(name, str) and name in ctx.shapes
65
+
66
+
67
+ def sites_of(e: Expr, v: str, acc: dict[str, Site] | None = None) -> dict[str, Site]:
68
+ if acc is None:
69
+ acc = {}
70
+ if isinstance(e, str) and e == v:
71
+ s = Site.of(e)
72
+ acc[s.key] = s
73
+ elif isinstance(e, ExprNode) and e.op == "index" and e.args[0] == v:
74
+ s = Site.of(e)
75
+ acc[s.key] = s
76
+ elif isinstance(e, ExprNode):
77
+ map_children(e, lambda x: (sites_of(x, v, acc), x)[1])
78
+ return acc
79
+
80
+
81
+ def sorted_sites(e: Expr, v: str) -> list[Site]:
82
+ d = sites_of(e, v)
83
+ return [d[k] for k in sorted(d)]
84
+
85
+
86
+ _AMBIGUOUS = object()
87
+
88
+
89
+ def _nested_sums(e: Expr, v: str, outer) -> dict:
90
+ """For each site of ``v`` under a nested scalar faq sum, the indices the
91
+ sums ENCLOSING it bind (bands.jl ``_nested_sums``): site key → {name →
92
+ range spec, or ``_AMBIGUOUS`` for a name also bound by ``outer``, bound
93
+ twice on the way down, or over different ranges at different
94
+ occurrences}. Position-aware: a sum elsewhere in the body does not
95
+ affect a site outside it."""
96
+ acc: dict = {}
97
+ _nested_walk(acc, e, v, set(outer), [])
98
+ return acc
99
+
100
+
101
+ def _nested_walk(acc: dict, e: Expr, v: str, outer: set, stack: list) -> None:
102
+ if (isinstance(e, str) and e == v) or (
103
+ isinstance(e, ExprNode) and e.op == "index" and e.args[0] == v):
104
+ if not stack:
105
+ return
106
+ d = acc.setdefault(Site.of(e).key, {})
107
+ seen: set = set()
108
+ for n, r in stack:
109
+ amb = n in outer or n in seen or (n in d and d[n] != r)
110
+ d[n] = _AMBIGUOUS if amb else r
111
+ seen.add(n)
112
+ return
113
+ if not isinstance(e, ExprNode):
114
+ return
115
+ pushed = 0
116
+ if e.op == "faq" and not (e.output_idx or []) and e.ranges:
117
+ for n, r in e.ranges.items():
118
+ stack.append((str(n), r))
119
+ pushed += 1
120
+ map_children(e, lambda x: (_nested_walk(acc, x, v, outer, stack), x)[1])
121
+ del stack[len(stack) - pushed:]
122
+
123
+
124
+ def _subst_free(e: Expr, m: dict) -> Expr:
125
+ """Substitution that does not descend into a faq for the names it binds."""
126
+ if isinstance(e, str):
127
+ return m.get(e, e)
128
+ if not isinstance(e, ExprNode):
129
+ return e
130
+ if e.op == "faq":
131
+ bound = binders(e)
132
+ if any(n in m for n in bound):
133
+ m = {k: v for k, v in m.items() if k not in bound}
134
+ if not m:
135
+ return e
136
+ return map_children(e, lambda x: _subst_free(x, m))
137
+
138
+
139
+ def _lift_nested(nested: dict, s: Site, body: Expr, cidx: list, coef: Expr,
140
+ ctx: Ctx, outer, taken: list):
141
+ """A site under a nested sum whose column reads the sum's index: that
142
+ index becomes a CONTRACTED column dimension of the band, renamed apart in
143
+ ``cidx`` and in the coefficient's free occurrences (bands.jl
144
+ ``_lift_nested``). ``taken`` is a per-body lazy cache (``[None]``) of the
145
+ body's names."""
146
+ d = nested.get(s.key)
147
+ if not d:
148
+ return cidx, coef, []
149
+ read: set[str] = set()
150
+ for c in cidx:
151
+ read |= set(free_variables(c))
152
+ names = sorted(n for n in d if n in read)
153
+ if not names:
154
+ return cidx, coef, []
155
+ if taken[0] is None:
156
+ taken[0] = all_names(body, set(outer))
157
+ ren: dict = {}
158
+ extra = []
159
+ for n in names:
160
+ if d[n] is _AMBIGUOUS:
161
+ raise BandError(
162
+ f"a column reads `{n}`, which the sums around it bind "
163
+ "ambiguously (shadowing an enclosing index, or over two "
164
+ "different ranges)")
165
+ f = n + "_j"
166
+ while f in taken[0] or f in ren.values():
167
+ f += "_j"
168
+ ren[n] = f
169
+ extra.append((f, *_range_of(d[n], ctx)))
170
+ return ([substitute(c, ren) for c in cidx], _subst_free(coef, ren), extra)
171
+
172
+
173
+ def is_array_expr(e: Expr, ctx: Ctx) -> bool:
174
+ if isinstance(e, str):
175
+ return is_array(ctx, e)
176
+ if not isinstance(e, ExprNode):
177
+ return False
178
+ if e.op in ("faq", "makearray", "broadcast",
179
+ "reshape", "transpose", "concat"):
180
+ return True
181
+ if e.op == "index":
182
+ return False
183
+ return any(is_array_expr(x, ctx) for x in e.args)
184
+
185
+
186
+ def occurs_var(e: Expr, v: str) -> bool:
187
+ return v in free_variables(e)
188
+
189
+
190
+ def bands(rhs: Expr, v: str, ctx: Ctx, shape_u: list[int] | None = None) -> list[Band]:
191
+ """Differentiate an equation RHS (already observed-inlined) w.r.t. ``v``."""
192
+ shape_u = shape_u or []
193
+ out: list[Band] = []
194
+ if not shape_u:
195
+ nested = _nested_sums(rhs, v, [])
196
+ taken: list = [None]
197
+ for s in sorted_sites(rhs, v):
198
+ cidx, d, extra = _lift_nested(nested, s, rhs, _site_cidx(s),
199
+ dscalar(rhs, s), ctx, [], taken)
200
+ coef = simp(d)
201
+ if is_zero(coef):
202
+ continue
203
+ out.append(Band([], [], cidx, coef, extra))
204
+ return out
205
+ _bands_array(out, rhs, v, ctx, shape_u, [1])
206
+ return out
207
+
208
+
209
+ def _site_cidx(s: Site) -> list:
210
+ return [] if isinstance(s.expr, str) else list(s.expr.args[1:])
211
+
212
+
213
+ def _scalar_bands(out, e: Expr, v: str, ctx: Ctx, rows, ridx, scale) -> None:
214
+ outer = [r for r in ridx if isinstance(r, str)]
215
+ for rows2, body in clip_regions(e, ridx, rows):
216
+ nested = _nested_sums(body, v, outer)
217
+ taken: list = [None]
218
+ for s in sorted_sites(body, v):
219
+ cidx, d, extra = _lift_nested(nested, s, body, _site_cidx(s),
220
+ dscalar(body, s), ctx, outer, taken)
221
+ coef = simp(mul(d, *scale))
222
+ if is_zero(coef):
223
+ continue
224
+ out.append(Band(rows2, list(ridx), cidx, coef, extra))
225
+
226
+
227
+ def _bands_array(out, e: Expr, v: str, ctx: Ctx, shape_u, scale) -> None:
228
+ if isinstance(e, str):
229
+ if e != v:
230
+ return
231
+ names = [f"_r{k + 1}" for k in range(len(shape_u))]
232
+ out.append(Band([(1, n) for n in shape_u], list(names),
233
+ [n for n in names], mul(*scale)))
234
+ return
235
+ if not isinstance(e, ExprNode):
236
+ return
237
+ o = e.op
238
+ if o == "+":
239
+ for x in e.args:
240
+ _bands_array(out, x, v, ctx, shape_u, scale)
241
+ elif o == "-":
242
+ _bands_array(out, e.args[0], v, ctx, shape_u, scale)
243
+ if len(e.args) == 2:
244
+ _bands_array(out, e.args[1], v, ctx, shape_u, scale + [-1])
245
+ elif o == "neg":
246
+ _bands_array(out, e.args[0], v, ctx, shape_u, scale + [-1])
247
+ elif o == "*":
248
+ arr = [x for x in e.args if is_array_expr(x, ctx)]
249
+ sc = [x for x in e.args if not is_array_expr(x, ctx)]
250
+ if len(arr) != 1:
251
+ raise BandError(
252
+ f"elementwise product of {len(arr)} array-valued factors at "
253
+ "array level; wrap in a faq node")
254
+ if any(occurs_var(x, v) for x in sc):
255
+ raise BandError(
256
+ f"scalar factor depends on `{v}` in an array-level product")
257
+ _bands_array(out, arr[0], v, ctx, shape_u, scale + sc)
258
+ elif o == "/":
259
+ if is_array_expr(e.args[1], ctx):
260
+ raise BandError("array-valued divisor at array level")
261
+ _bands_array(out, e.args[0], v, ctx, shape_u,
262
+ scale + [op("/", 1, e.args[1])])
263
+ elif o == "makearray":
264
+ _bands_makearray(out, e, v, ctx, scale)
265
+ elif o == "faq":
266
+ _bands_faq(out, e, v, ctx, shape_u, scale)
267
+ elif o == "index":
268
+ names = [f"_r{k + 1}" for k in range(len(shape_u))]
269
+ _scalar_bands(out, e, v, ctx, [(1, n) for n in shape_u], names, scale)
270
+ else:
271
+ if is_array_expr(e, ctx):
272
+ names = [f"_r{k + 1}" for k in range(len(shape_u))]
273
+ idx = [n for n in names]
274
+ body = _push_index(e, idx, ctx)
275
+ agg = ExprNode(op="faq", args=[],
276
+ output_idx=list(names), expr=body,
277
+ ranges={n: [1, shape_u[k]]
278
+ for k, n in enumerate(names)})
279
+ _bands_array(out, agg, v, ctx, shape_u, scale)
280
+ else:
281
+ names = [f"_r{k + 1}" for k in range(len(shape_u))]
282
+ _scalar_bands(out, e, v, ctx, [(1, n) for n in shape_u], names, scale)
283
+
284
+
285
+ def _bands_faq(out, e: ExprNode, v: str, ctx: Ctx, shape_u, scale) -> None:
286
+ oidx = [str(x) for x in e.output_idx if isinstance(x, str)]
287
+ if len(oidx) != len(e.output_idx):
288
+ raise BandError("singleton `1` output_idx entries not supported yet")
289
+ if e.filter is not None:
290
+ raise BandError("filtered faq nodes not supported (the filter gate "
291
+ "would be dropped from the derivative)")
292
+ ranges = e.ranges or {}
293
+ cnames = sorted(str(n) for n in ranges if str(n) not in oidx)
294
+ if not plain_sum(e):
295
+ raise BandError(f"{reduction(e)} (non-smooth semiring reductions "
296
+ "have no bands)")
297
+ crange = [(n, *_range_of(ranges[n], ctx)) for n in cnames]
298
+ rows = []
299
+ for k, n in enumerate(oidx):
300
+ rows.append(_range_of(ranges[n], ctx) if n in ranges
301
+ else (1, shape_u[k]))
302
+ body = e.expr
303
+ outer = oidx + cnames
304
+ for rows2, body2 in clip_regions(body, oidx, rows):
305
+ nested = _nested_sums(body2, v, outer)
306
+ taken: list = [None]
307
+ for s in sorted_sites(body2, v):
308
+ if isinstance(s.expr, str) and is_array(ctx, v):
309
+ raise BandError(
310
+ f"whole-array reference to `{v}` inside a faq body")
311
+ cidx, d, extra = _lift_nested(nested, s, body2, _site_cidx(s),
312
+ dscalar(body2, s), ctx, outer, taken)
313
+ if not crange:
314
+ coef = simp(mul(d, *scale))
315
+ if not is_zero(coef):
316
+ out.append(Band(rows2, list(oidx), cidx, coef, extra))
317
+ elif not any(any(n in free_variables(c) for c in cidx)
318
+ for n, _, _ in crange):
319
+ red = ExprNode(op="faq", args=[], output_idx=[],
320
+ reduce="+", expr=d,
321
+ ranges={n: [lo, hi] for n, lo, hi in crange})
322
+ coef = simp(mul(red, *scale))
323
+ if not is_zero(coef):
324
+ out.append(Band(rows2, list(oidx), cidx, coef, extra))
325
+ else:
326
+ coef = simp(mul(d, *scale))
327
+ if not is_zero(coef):
328
+ out.append(Band(rows2, list(oidx), cidx, coef,
329
+ list(crange) + extra))
330
+
331
+
332
+ def _bands_makearray(out, e: ExprNode, v: str, ctx: Ctx, scale) -> None:
333
+ for region, val in zip(e.regions, e.values):
334
+ rows = [(region_bound(r[0]), region_bound(r[1])) for r in region]
335
+ if any(hi < lo for lo, hi in rows):
336
+ continue # folded-empty region
337
+ nonsing = [k for k, (lo, hi) in enumerate(rows) if hi > lo]
338
+ if is_array_expr(val, ctx):
339
+ sub: list[Band] = []
340
+ val_fullrank = (isinstance(val, ExprNode) and val.op == "faq"
341
+ and len(val.output_idx) == len(rows))
342
+ if val_fullrank:
343
+ sub_shape = [hi - lo + 1 for lo, hi in rows]
344
+ else:
345
+ sub_shape = [rows[k][1] - rows[k][0] + 1 for k in nonsing]
346
+ _bands_array(sub, val, v, ctx, sub_shape, scale)
347
+ if isinstance(val, ExprNode) and val.op == "faq":
348
+ vr = val.ranges or {}
349
+ origins = [_range_of(vr[str(n)], ctx)[0] if str(n) in vr else 1
350
+ for n in val.output_idx]
351
+ else:
352
+ origins = [1] * len(nonsing)
353
+ for b in sub:
354
+ ridx: list = []
355
+ subst: dict[str, Expr] = {}
356
+ rows2: list[tuple[int, int]] = []
357
+ fullrank = len(b.ridx) == len(rows)
358
+ kk = -1
359
+ for k, (lo, hi) in enumerate(rows):
360
+ if fullrank or hi > lo:
361
+ kk += 1
362
+ r = b.ridx[kk]
363
+ rlo, rhi = b.rows[kk]
364
+ shift = lo - origins[kk]
365
+ if isinstance(r, str):
366
+ ridx.append(r)
367
+ if shift != 0:
368
+ subst[r] = add(r, -shift)
369
+ rows2.append((rlo + shift, rhi + shift))
370
+ else:
371
+ ridx.append(r + shift)
372
+ rows2.append((r + shift, r + shift))
373
+ else:
374
+ ridx.append(lo)
375
+ rows2.append((lo, lo))
376
+ coef = b.coef if not subst else substitute(b.coef, subst)
377
+ cidx = b.cidx if not subst else [substitute(c, subst)
378
+ for c in b.cidx]
379
+ out.append(Band(rows2, ridx, cidx, coef, b.contracted))
380
+ else:
381
+ _scalar_bands(out, val, v, ctx, rows, [lo for lo, _ in rows], scale)
382
+
383
+
384
+ def _push_index(e: Expr, idx: list, ctx: Ctx) -> Expr:
385
+ if isinstance(e, str):
386
+ return op("index", e, *idx) if is_array(ctx, e) else e
387
+ if isinstance(e, ExprNode):
388
+ if e.op in ("faq", "makearray", "index"):
389
+ raise BandError(f"cannot push an index into `{e.op}` nested "
390
+ "under an elementwise op")
391
+ return map_children(e, lambda x: _push_index(x, idx, ctx))
392
+ return e
393
+
394
+
395
+ def normalize_band(b: Band) -> Band:
396
+ """Pin singleton named row dims (and one-value contractions) to constants."""
397
+ subst: dict[str, Expr] = {}
398
+ ridx: list = []
399
+ for k, r in enumerate(b.ridx):
400
+ lo, hi = b.rows[k]
401
+ if isinstance(r, str) and lo == hi:
402
+ subst[r] = lo
403
+ ridx.append(lo)
404
+ else:
405
+ ridx.append(r)
406
+ contracted = []
407
+ for n, lo, hi in b.contracted:
408
+ if lo == hi:
409
+ subst[n] = lo
410
+ else:
411
+ contracted.append((n, lo, hi))
412
+ if not subst:
413
+ return b
414
+ return Band(b.rows, ridx,
415
+ [canon_lits(simplify(substitute(c, subst))) for c in b.cidx],
416
+ simp(substitute(b.coef, subst)), contracted)
417
+
418
+
419
+ def merge_bands(bs: list[Band]) -> list[Band]:
420
+ """Coalesce bands identical in (ridx, cidx, coef, contracted) whose row
421
+ boxes are adjacent along exactly one dimension. Order-preserving."""
422
+ if len(bs) < 2:
423
+ return bs
424
+
425
+ def key(b: Band):
426
+ return (tuple(b.ridx), tuple(skey(c) for c in b.cidx), skey(b.coef),
427
+ tuple(b.contracted))
428
+
429
+ ks = [key(b) for b in bs]
430
+ out = list(bs)
431
+ alive = [True] * len(out)
432
+ changed = True
433
+ while changed:
434
+ changed = False
435
+ for a in range(len(out)):
436
+ if not alive[a]:
437
+ continue
438
+ for b in range(a + 1, len(out)):
439
+ if not alive[b] or ks[a] != ks[b]:
440
+ continue
441
+ ra, rb = out[a].rows, out[b].rows
442
+ if len(ra) != len(rb):
443
+ continue
444
+ d = -1
445
+ ok = True
446
+ for k in range(len(ra)):
447
+ if ra[k] == rb[k]:
448
+ continue
449
+ if d < 0 and (ra[k][1] + 1 == rb[k][0]
450
+ or rb[k][1] + 1 == ra[k][0]):
451
+ d = k
452
+ else:
453
+ ok = False
454
+ break
455
+ if not ok or d < 0:
456
+ continue
457
+ lo = min(ra[d][0], rb[d][0])
458
+ hi = max(ra[d][1], rb[d][1])
459
+ rows = list(ra)
460
+ rows[d] = (lo, hi)
461
+ out[a] = Band(rows, out[a].ridx, out[a].cidx, out[a].coef,
462
+ out[a].contracted)
463
+ alive[b] = False
464
+ changed = True
465
+ return [b for k, b in enumerate(out) if alive[k]]