Skip to content

Render full symbolic expressions for Deterministics and Potentials (plain text and LaTeX) - #8408

Open
drbenvincent wants to merge 11 commits into
pymc-devs:mainfrom
drbenvincent:deterministic-expression-repr
Open

Render full symbolic expressions for Deterministics and Potentials (plain text and LaTeX)#8408
drbenvincent wants to merge 11 commits into
pymc-devs:mainfrom
drbenvincent:deterministic-expression-repr

Conversation

@drbenvincent

@drbenvincent drbenvincent commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #8407. Implements the unimplemented half of #7538: rendering the actual symbolic expression of Deterministics and Potentials instead of the opaque f(inputs) placeholder, in both plain text and LaTeX.

with pm.Model() as m:
    x = pm.Data("x", 2.0)
    sigma = pm.HalfNormal("sigma", sigma=1)
    alpha_a = pm.Normal("alpha_a", 0, 1)
    mu = pm.Deterministic("mu", alpha_a * x + pt.log(sigma**2) + 3)
    eta = pm.Deterministic("eta", mu / (1 + mu))
    pot = pm.Potential("pot", sigma * 2)
    y = pm.Normal("y", mu=mu, sigma=sigma)

print(str_for_model(m, deterministic_exprs=True))
      x = Data(2)
  sigma ~ HalfNormal(0, 1)
alpha_a ~ Normal(0, 1)
      y ~ Normal(mu, sigma)
     mu = ((alpha_a * x) + Log((sigma ** 2))) + 3
    eta = mu / (1 + mu)
    pot ~ sigma * 2

LaTeX output

The same model rendered as LaTeX — a full aligned equation block, ready for notebooks (_repr_latex_), docs, or papers:

print(str_for_model(m, formatting="latex", deterministic_exprs=True))
$$
\begin{array}{rcl}
    \text{x} &= &\operatorname{Data}(2)\\
    \text{sigma} &\sim & \operatorname{HalfNormal}(0,~1)\\
    \text{alpha\_a} &\sim & \operatorname{Normal}(0,~1)\\
    \text{y} &\sim & \operatorname{Normal}(\text{mu},~\text{sigma})\\
    \text{mu} &= &((\text{alpha\_a} \cdot \text{x}) + \log({\text{sigma}}^{2})) + 3\\
    \text{eta} &= &\frac{\text{mu}}{(1 + \text{mu})}\\
    \text{pot} &\sim & \text{sigma} \cdot 2
\end{array}
$$

Deterministic bodies render as proper math (\frac, \log, \cdot, exponentiation); names are escaped consistently with the rest of the model repr; unknown ops degrade gracefully to \operatorname{name}(args) instead of crashing.

Design decisions (from #8407 discussion)

  • Opt-in keyword only (deterministic_exprs=False default): print(m) output is byte-identical to before; no global config.
  • Nested deterministics stop at named variables: eta renders as mu / (1 + mu) since mu's definition appears on its own line.
  • Potentials included: same shared code path, and LaTeX model exports would otherwise have holes.
  • Uniform name escaping, no greek magic: all names go through the same escaping as the rest of the repr (pytensor's leaf printer silently converts only some greek names, which would be inconsistent).
  • Structural fidelity: no rewriting at print time. x.mean() shows as its true graph form (sum / shape), matching what is actually computed. This keeps the feature a pure graph traversal — no folding/compilation, avoiding the cost concern raised in the Richer textual representation: show Data variables, fold constants, and use semantic separators #8205 review.
  • model_to_graphviz untouched: node labels have tight space constraints and their own truncation machinery; can build on this later.
  • Never truncate: users who pass the flag asked to see the expression.

Implementation notes

  • Plain text clones pytensor's global printer with a named-variable leaf rule (the trick already used inside PPrinter.process_graph), inheriting its broad op coverage for free.
  • LaTeX uses a fresh PPrinter: registrations for arithmetic, exp/log/sqrt, trig, dot, sum via PatternPrinter/OperatorPrinter/FunctionPrinter (precedence handling comes free), plus a graceful \operatorname{name}(args) fallback so unknown ops never crash.
  • Two graph subtleties handled explicitly: named Deterministics are themselves unary ViewOp wrappers (view_op(var, name=...)), so identity-unwrapping must stop at named nodes; and DimShuffle axis manipulation is rendered transparently (its dict registration in the global printer outranks condition-based rules, so it is overridden by type key). Anonymous distributions (including PyMC's SymbolicRandomVariables) are detected via pytensor's RNGConsumerOp base class.
  • Constants render through the existing _str_for_constant_value formatting for consistency with the rest of the repr.

Validation

  • New tests in tests/test_printing.py::TestDeterministicExprs cover default-output invariance, plain bodies, nested-deterministic stopping, potentials, LaTeX bodies with \frac/\log, underscore escaping inside bodies, an unnamed ViewOp wrapper around a named leaf (plain and LaTeX), the unregistered-Elemwise \operatorname fallback with memoized shared subexpressions, and standalone str_for_potential_or_deterministic.
  • New table-driven tests in tests/test_printing.py::TestDeterministicExprsParametric parametrize over seven model families — linear regression, nonlinear scalar ops (exp/sqrt/tanh), matrix ops (dot, sum), indexing/slicing subtensors, potentials only, a non-centered hierarchical model (partial pooling across groups), and fixed-value vs prior-valued parameters/hyperparameters (including an inline anonymous prior) — asserting per-family exact anchors plus general invariants: no leftover placeholders, no graph-internals leakage (DimShuffle{, ViewOp, RNG(), and no unescaped underscores anywhere in the LaTeX.
  • Anonymous distributions inside deterministic/potential bodies (e.g. pm.Normal.dist(0, 1)) render as distribution calls — like their named counterparts on RV lines — instead of leaking RNG internals; previously this crashed LaTeX rendering outright.
  • pytest tests/test_printing.py: 46 passed.
  • pre-commit run --files pymc/printing.py tests/test_printing.py: passes.
  • python scripts/run_mypy.py: pymc/printing.py clean.

Add a `deterministic_exprs` flag to str_for_model and
str_for_potential_or_deterministic. When enabled, Deterministics and
Potentials render their full symbolic expression body instead of an
opaque f(inputs) placeholder, in both plain text and LaTeX.

The rendering is a pure traversal of the existing PyTensor graph (no
rewrites, compilation, or evaluation):

- Plain text clones the global pytensor printer with a named-variable
  leaf rule, so nested deterministics reference each other by name.
- LaTeX uses a fresh printer registering LaTeX operators for common ops
  (arithmetic, exp/log/sqrt, trig, dot, sum) with graceful
  \\operatorname{...} fallback for unregistered ops.
- Variable names are escaped consistently with the rest of the model
  repr; no implicit greek conversion.

Default output is unchanged. Addresses the unimplemented half of pymc-devs#7538;
closes pymc-devs#8407.
@read-the-docs-community

read-the-docs-community Bot commented Aug 25, 2026

Copy link
Copy Markdown

Documentation build overview

📚 pymc | 🛠️ Build #34239736 | 📁 Comparing 1cb318e against latest (da8fc47)

  🔍 Preview build  

1 file changed
± glossary.html

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.94%. Comparing base (da8fc47) to head (1cb318e).

Files with missing lines Patch % Lines
pymc/printing.py 96.42% 11 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #8408      +/-   ##
==========================================
+ Coverage   91.87%   91.94%   +0.06%     
==========================================
  Files         128      128              
  Lines       21256    21560     +304     
==========================================
+ Hits        19530    19824     +294     
- Misses       1726     1736      +10     
Files with missing lines Coverage Δ
pymc/printing.py 93.29% <96.42%> (+3.73%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- Table-driven TestDeterministicExprsParametric covering five model
  families (linear regression, nonlinear ops, matrix ops, indexing,
  potentials) with output invariants plus exact anchors per family.
- _BodyLeafPrinter now unwraps unnamed ViewOp wrappers before reading
  the leaf name; previously a named variable referenced through an
  unary view_op crashed with AttributeError on None.name.
- Regression test for the view-op path; sqrt LaTeX uses PatternPrinter.
Shared subexpressions (bound to Python variables) exercise the printers'
memo paths; pt.sigmoid covers the unregistered-Elemwise operatorname
fallback. Patch coverage for pymc/printing.py is now complete.
named_names.discard(var.name) requires str; guard against None names.
Cramer-von Mises p-values at n=5 are too coarse for a 0.001 threshold:
even a perfect sampler (drawing from the reference truncnorm directly)
fails ~0.1% of runs. Use 1000 draws so the comparison is meaningful.
Unrelated to the printing changes; fixes the flaky ubuntu-numba CI leg.
Non-centered partial-pooling model: verifies the pooled deterministic
stops at named parents (mu, tau, z) in both plain text and LaTeX.
@drbenvincent

Copy link
Copy Markdown
Contributor Author

Note on __repr__ methods: this PR does not change any default rendering path.

There are no traditional __repr__ overrides in play here — Model.str_repr is printing.str_for_model bound at Model.__init__, and Model._repr_latex_ is the same function partially applied with formatting="latex". Because of that, the new deterministic_exprs=False keyword automatically became available on model.str_repr(...) without touching pymc/model/core.py.

What is deliberately unchanged (opt-in keyword only):

  • print(model) / IPython pretty display → byte-identical to before
  • _repr_latex_ (Jupyter LaTeX display) → still shows the opaque f(inputs) placeholders, since it binds only formatting="latex"

What we could do later, if desired:

  1. Richer Jupyter LaTeX by default: pass deterministic_exprs=True in the partial at core.py:462, so notebooks show full symbolic expressions instead of placeholders. One line; a design decision rather than an implementation effort.
  2. A user-facing toggle for the default (e.g. pm.config option) if opt-in-per-call proves too hidden.
  3. Build on this in model_to_graphviz: node labels have tighter space constraints and their own truncation machinery, but they could reuse the body printers added here.

None of these are included — happy to take (1) forward here or in a follow-up depending on maintainer preference.

…alls

An inline prior such as pm.Normal.dist(0, 1) inside a Deterministic or
Potential body previously leaked graph internals in plain text (including
a nondeterministic RNG memory address) and crashed the LaTeX printer on
ownerless rng/size inputs. Both body printers now delegate to
str_for_dist, so anonymous dists render like their named counterparts
(e.g. Normal(0, 1)), with parameters that reference model variables
rendered by name. Ownerless non-constant leaves (unnamed shared
variables) terminate LaTeX rendering gracefully as their type.

Adds a fixed_vs_prior_params parametric case covering scalar constants,
named priors, an inline anonymous prior, and unnamed shared leaves;
parametric tests now also assert no RNG internals leak into either format.
Comment thread pymc/printing.py Outdated
Comment thread pymc/printing.py Outdated
- _is_random_op reduces to a single isinstance against pytensor's
  RNGConsumerOp, which covers both RandomVariable and PyMC's
  SymbolicRandomVariable; drops the lazy-import cycle workaround.
- DimShuffle is not mere 'broadcasting noise' (axis manipulation can be
  semantically meaningful, e.g. elementwise vs outer product); reworded
  to 'adds clutter without changing the expression'.
@drbenvincent

Copy link
Copy Markdown
Contributor Author

Adversarial review of #8408

Tested at d49f018 against a battery of stress models. The feature holds up on the things you'd expect to break it: MvNormal / Mixture / Censored / CustomDist bodies, advanced indexing, a 10000x50 observed array, a 3000-term sum in 0.4s. Nothing in the list below is a sampling correctness issue; it's all repr behaviour.

Two themes: a small number of outright bugs where the printed expression is wrong, and a design gap around unbounded verbosity. The second is the bigger one.


The design gap: no length budget on the string reprs

model_table has truncate_deterministic. str_for_model / str_for_potential_or_deterministic have no equivalent, and the docs frame "never truncate" as a feature. Two consequences:

Shared subexpressions expand exponentially. Nothing memoizes a node used more than once, so it is re-walked and re-printed each time:

v = s
for _ in range(20):
    v = v + v

6.3M chars plain / 13.6M LaTeX in ~1.2s. 25 iterations is gigabytes. This is not just an adversarial toy. A realistic adstock + saturation block:

acc = x
for l in range(1, 8):
    acc = acc + (alpha ** l) * shifted(x, l)
sat = pm.Deterministic("sat", acc / (acc + lam))

prints the entire 8-term adstock chain twice, once in the numerator and once in the denominator, because acc is unnamed. Any unnamed lin = a + b*x reused in four places prints four times.

Deep chains hit the recursion limit. Printing is recursive, so the flag turns a working repr into a crash:

v = s
for _ in range(6000):
    v = v + 1.0
pm.Deterministic("d", v)
m.str_repr()                          # fine
m.str_repr(deterministic_exprs=True)  # RecursionError

The cliff is around 4500-5000 chained ops. Graphs built in Python loops (unrolled AR, adstock, iterative link functions) get there.

The mitigating factor, and I think the lever for the fix: recursion stops at named variables. A model that names its intermediates prints beautifully (50 chained named Deterministics gives 50 tidy one-liners). It is unnamed intermediate graph that blows up. Pointers, roughly in order of value:

  • Honour a length / node-count budget in the string reprs too, falling back to the existing f(...) placeholder past it. Same knob as the table.
  • Memoize repeated non-trivial subgraphs and emit them as auxiliary where ... lines rather than inlining each occurrence.
  • Iterative traversal, or catch the depth and degrade gracefully instead of propagating RecursionError.

Related design question you raised: array constants. Every one renders <constant>, so a 3-element vector, a 20x20 matrix and a 100k vector are indistinguishable (d = s * <constant>). Agreed that printing a 20x20 matrix inline is not the answer, but <constant> for all of them is barely above f(s) for a feature whose point is showing the real expression. Suggestion: inline genuinely small arrays (say <= 4 elements, 1-D) and otherwise emit shape and dtype, <constant float64 (20,20)>. Note pm.Data already does the right thing, printing the name, which is a good argument for documenting "name your data containers" alongside this feature.


Correctness bugs

1. Transposes are silently deleted. Rendering DimShuffle as its first input means a genuine permutation disappears:

dT  = X          # pm.Deterministic("dT", X.T)
dTX = X \dot X   # X.T @ X
dXX = X \dot X   # X @ X

X.T @ X and X @ X print identically, and X.T prints as X. That's not clutter removal, it's the wrong expression, and the X.T @ X case is asserted in the new tests. Pointer: treat a DimShuffle as transparent only when new_order is a sorted subsequence plus 'x' insertions (pure broadcast); render real permutations as ^{T} / \operatorname{transpose}.

2. The LaTeX fallback collapses distinct ops onto the same output.

  • X.sum(axis=0), X.sum(axis=1) and X.sum() all render \sum(X). Plain text correctly keeps axis=.
  • eigh, cholesky and solve all render \operatorname{Blockwise}(...) with the inner op discarded.
  • cast drops the dtype.

Pointer: carry the distinguishing op parameter into the fallback name, and unwrap Blockwise / Elemwise to type(op.core_op).__name__.

3. LaTeX escaping is incomplete, and & / % are fatal. _latex_escape handles _ only. A variable named a&b injects a column separator into \begin{array}{rcl}; c%d comments out the rest of the line. # and bare \ are also unhandled. The escaper is pre-existing, but this PR multiplies the exposure because names now appear throughout expression bodies, and the test invariant only checks underscores. Pointer: escape & % # $ { } ~ ^ \, or at least verify the assumption the tests encode.

4. scan and Blockwise leak graph internals. A 5-step scan renders:

path = Scan{scan_fn, while_loop=False, inplace=none}(5, set_subtensor(AllocEmpty{dtype='float64'}((5 + Shape(1)[0]))[:ScalarFromTensor(Shape(1)[0])], 1), r)[1:]

Allocation machinery on display, inner function body never shown. Worth noting Blockwise{Eigh{lower=True, overwrite_a=False, ...}} passes the new leakage invariant, which only greps for DimShuffle{, ViewOp and RNG(. Pointer: fall back to the opaque placeholder for ops carrying inner graphs.


Minor

  • Plain-text matmul emits a LaTeX escape into plain output: d = X \dot b.
  • str_for_potential_or_deterministic(d, deterministic_exprs=True) with the default named_vars=None inlines the distribution: d = s ~ HalfNormal(0, 1) * 2, a ~ nested inside an expression. Public function, reachable directly.
  • include_params=False wins and the body becomes d = Deterministic, silently ignoring deterministic_exprs=True. Probably intended, but worth a docstring line.
  • Unnamed leaves render inconsistently across formats: plain gives X, LaTeX gives \text{Matrix(float64, shape=(?, ?))}.
  • Leaf matching is by name string rather than identity, so a non-model variable that happens to carry the name mu renders as mu. Conversely printers_dict in pytensor's PPrinter.process outranks every condition rule, so the named-leaf rule never fires for ops with a dict registration. Matching on r in named_vars would make both directions consistent.

Correctness:
- DimShuffle no longer rendered transparently when it permutes axes;
  full reversals render as transpose (X.T / ^{T}), other permutations
  show their axis order, so transposed expressions print faithfully
- LaTeX fallback carries distinguishing op parameters: Blockwise/Elemwise
  unwrap to their core/scalar op, casts show dtype, Sum shows reduction
  axes (\sum_{0}), so eigh/cholesky/solve/sum-axis no longer collapse
- scan/OpFromGraph bodies (and slices thereof) render as opaque
  f(named inputs) placeholders instead of leaking allocation machinery
- plain-text matmul renders @ instead of pytensor's \dot LaTeX escape

Minor:
- named leaves matched by identity rather than name string; dict-keyed
  registrations shared with a named leaf's owner are shielded so leaves
  still win over condition rules
- str_for_potential_or_deterministic defaults named_vars to all named
  variables reachable from the graph, so standalone calls stop at named
  intermediates instead of inlining distributions
- ownerless leaves render consistently across formats (name if present,
  otherwise the type)
- docstrings document include_params=False precedence
@drbenvincent

Copy link
Copy Markdown
Contributor Author

Response to the adversarial review — correctness bugs and minor points

Addressed in d8efa89. All scenarios below were re-run against the review's own reproductions, and tests/test_printing.py now has regression tests for each (57 passing; pre-commit and run_mypy.py clean for the touched files).

Correctness bugs

1. Transposes silently deleted — fixed. DimShuffle is now only transparent when it is pure broadcasting (inserted 'x' axes / dropped broadcastable axes with survivors in order). A full axis reversal renders as a transpose (X.T / {X}^{T}); any other permutation renders its order explicitly (transpose(w, order=(2, 0, 1))). The review's example now prints:

dT  = X.T
dTX = X.T @ X
dXX = X @ X

2. LaTeX fallback collapsing distinct ops — fixed.

  • Blockwise/Elemwise unwrap to their core/scalar op, so eigh, cholesky, solve render as \operatorname{Eigh} / \operatorname{Cholesky} / \operatorname{Solve}.
  • Sum carries its reduction axes into LaTeX: \sum_{0}(X), \sum_{1}(X), \sum(X) are now distinct (plain text already kept axis=).
  • Casts show their target dtype: \operatorname{cast}\left(s,\ \text{int32}\right).

3. Incomplete LaTeX escaping (&, %, #, …) — not addressed, by design decision. Variable names like a&b or c%d remain rare edge cases we're willing to live with for now; the existing escaper intentionally only escapes $ for MathJax. If a real model ever hits this, the fix is localized to _latex_escape.

4. scan / inner-graph leakage — fixed. Ops carrying an inner graph (Scan, OpFromGraph) — including results sliced through Subtensor, which previously leaked the slice's index machinery too — now render as the opaque placeholder over named inputs:

path = f(seq)[1:]

No AllocEmpty, set_subtensor, or Scan{...} internals appear.

Minor points

  • Plain-text matmul: renders @ instead of pytensor's inherited \dot LaTeX escape (d = X @ b). This required overriding both pytensor's type-keyed and instance-keyed Dot registrations.
  • Standalone str_for_potential_or_deterministic(deterministic_exprs=True): when named_vars is omitted it now defaults to every named variable reachable from the graph, so the call stops at named intermediates instead of inlining distributions: d = s * 2, no stray ~.
  • Docstrings: str_for_model and str_for_potential_or_deterministic now state that include_params=False takes precedence over deterministic_exprs=True. Locked in by a test.
  • Unnamed leaves across formats: consistent in both formats — name if present (e.g. an external shared container), otherwise the variable type, mirroring plain text.
  • Leaf matching by identity: leaves match on variable identity rather than name string, so a same-named non-model variable expands instead of masquerading as the leaf. Conversely, since PPrinter.process checks dict-keyed registrations before all condition rules, any dict entry whose op instance/class is shared with a named leaf's owner is wrapped in a guard so the named-leaf rule still wins.

Not yet addressed

  • The unbounded-verbosity design gap (exponential expansion of shared unnamed subexpressions, recursion-depth cliff on deep chains, no length budget, <constant> for all array constants): deliberately not touched here. These are design decisions rather than local fixes — happy to take them up in a follow-up discussion/proposal.

The deterministic_exprs rendering had no length budget: shared unnamed
subexpressions duplicated their text once per occurrence (exponential
for v = v + v loops or adstock chains reused in numerator and
denominator), and deep unrolled chains crashed the recursive printer
with RecursionError around 4500 ops.

_print_plan now walks the leaf-pruned graph breadth-first and computes,
iteratively, each node's depth and its number of occurrences in the
rendered tree. Nodes one level deeper than _MAX_EXPR_DEPTH are rendered
as the existing opaque f(inputs) placeholder by pre-seeding the print
memo, bounding recursion depth; if more than _MAX_EXPR_NODES nodes would
still be rendered, the whole expression degrades to a single placeholder.
Naming intermediates (or using pt.scan instead of Python loops) keeps
large models fully expanded.

Array constants in bodies now describe their geometry: scalars keep
their value, short 1-D arrays inline their values, and larger arrays
render as <constant float64 (20, 20)> in plain text and
<constant float64> \in \mathbb{R}^{20 x 20} in LaTeX. This is opt-in
only; default str_for_model output stays unchanged.
@drbenvincent

Copy link
Copy Markdown
Contributor Author

The design gap — addressed in 1cb318e

Both halves of the unbounded-verbosity gap are now implemented.

Length / depth budget

pymc/printing.py now has a _print_plan pre-pass that walks the leaf-pruned graph breadth-first and, iteratively, computes each node's depth and its number of occurrences in the fully expanded render tree. Two bounds come out of that single pass:

  • Depth: unnamed nodes one level below _MAX_EXPR_DEPTH = 64 are rendered as the existing opaque f(inputs) placeholder by pre-seeding pytensor's print memo (every Printer.process checks the memo first, so this works uniformly across all printers without touching dispatch priority). Recursion depth is therefore bounded at ~64 levels — far under the ~4500-op frame cliff.
  • Size: if more than _MAX_EXPR_NODES = 1000 nodes would still be rendered after those cuts (counting only paths through visible parents), the whole expression degrades to a single placeholder. Occurrence counting is cut-aware so a heavily-shared node whose parents sit mostly below the depth cut doesn't trigger a needless fallback.

Re-running the review's own reproductions:

Case Before After
v = v + v × 25 6.3M chars plain / 13.6M LaTeX 29 chars, d = f(s), instant
6000-op chain RecursionError 417 chars in 0.02s, prints 64 levels then f(s)
Adstock chain reused twice fully duplicated still fully expanded (788 chars) — moderate sharing is unaffected

The "never truncate" framing is amended: expressions are bounded, and naming intermediates (or using pt.scan rather than Python loops) keeps large models fully expanded. Docstrings state the bounds; new tests in TestExpressionBounds cover all three regimes plus LaTeX boundedness. 65 tests pass; pre-commit clean; mypy outcome for printing.py unchanged (remaining repo-wide failures are pre-existing environment drift, identical with and without this change).

Array constants

_BodyConstantPrinter now renders geometry instead of a bare <constant>:

  • scalars and size-1 arrays: value, as before
  • short 1-D arrays (≤ 4 elements): inlined — d = [1.5, 2.5, 3.5] * x
  • larger arrays: <constant float64 (20, 20)> @ x in plain text, and math notation in LaTeX — \text{<constant float64>} \in \mathbb{R}^{20 \times 20} (\mathbb{Z} for integer kinds)

This lives only in the opt-in body printer; _str_for_constant_value is untouched, so default print(model) output remains byte-identical.

Is the adversarial review fully addressed?

Correctness bugs (1, 2, 4): yes — fixed in d8efa89 with regression tests each.

Design gap: yes — length/depth budget, graceful degradation, recursion safety, and shape-aware constants as proposed above.

Two deliberate remainders, for the record:

  1. LaTeX escaping of &, %, #, … — still open by design decision (rare names; localized fix in _latex_escape if ever needed).
  2. Shared-subgraph expansion above budget degrades opaquely rather than emitting auxiliary let-binding lines (CSE-style printing). That would give linear output with zero information loss for heavily-shared graphs, at the cost of invented temporary names and real layout complexity in str_for_model. Given that idiomatic models put iteration in scan (rendered opaquely anyway) and named intermediates print beautifully, the budget + guidance approach was chosen as the proportionate fix. Happy to explore it as a follow-up if reviewers want full-fidelity rendering of shared graphs.

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.

ENH: Render full symbolic expressions for Deterministics (and Potentials) in model representations — plain text and LaTeX

2 participants