From fb859231eef6e0ff1048a86ebb2017b2395983d7 Mon Sep 17 00:00:00 2001
From: "Olof Lagerkvist (LTRData)"
Date: Thu, 3 Sep 2026 17:37:37 +0200
Subject: [PATCH 01/12] Document math expression redesign decisions
---
docs/math-expression-redesign/README.md | 84 +++++++++
docs/math-expression-redesign/architecture.md | 173 ++++++++++++++++++
.../language-specification.md | 166 +++++++++++++++++
.../legacy-behavior-classification.md | 88 +++++++++
docs/math-expression-redesign/usage-corpus.md | 125 +++++++++++++
5 files changed, 636 insertions(+)
create mode 100644 docs/math-expression-redesign/README.md
create mode 100644 docs/math-expression-redesign/architecture.md
create mode 100644 docs/math-expression-redesign/language-specification.md
create mode 100644 docs/math-expression-redesign/legacy-behavior-classification.md
create mode 100644 docs/math-expression-redesign/usage-corpus.md
diff --git a/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md
new file mode 100644
index 0000000..dfadbba
--- /dev/null
+++ b/docs/math-expression-redesign/README.md
@@ -0,0 +1,84 @@
+# Math expression and function plotting redesign
+
+This directory records the decisions behind the experimental replacement APIs on
+`experimental/math-expression-redesign`.
+
+The historical implementation is evidence, not the specification. Observed behavior is
+classified before it becomes a test or a public contract:
+
+- **A — desired modern behavior:** normative for the new API.
+- **B — legacy compatibility behavior:** relevant only to explicit compatibility code.
+- **C — historical implementation artifact or bug:** intentionally not preserved.
+
+## Documents
+
+- [First-party usage corpus](usage-corpus.md)
+- [Legacy behavior classification](legacy-behavior-classification.md)
+- [Modern expression language specification](language-specification.md)
+- [Architecture, package boundaries, and proposed APIs](architecture.md)
+
+## Scope of this experimental slice
+
+The branch validates only these boundaries:
+
+- an immutable math-specific syntax model with source spans;
+- a real lexer and parser with diagnostics;
+- an explicit immutable symbol catalog;
+- binding to constants, functions, and arbitrary variable slots;
+- interpreted evaluation without runtime code generation;
+- convenient allocation-free unary evaluation for plotting;
+- parser-independent fixed-count function sampling;
+- explicit sample status for non-finite values and evaluation failures;
+- backend-neutral Cartesian polyline geometry with viewport clipping.
+
+It deliberately does **not** implement SkiaSharp, PNG output, expression-diagram
+rendering, System.Drawing migration, derivative/integral calculation, recurrence,
+adaptive sampling, or LINQ-expression compilation.
+
+The existing `MathExpressionParser`, `IMathExpressionParser`, `MathFunctions`, and
+`ScriptControl` remain temporarily so the experimental API can be reviewed without a
+broad consumer migration. They do not define the new language.
+
+## Decisions made for the experiment
+
+- The existing package name `LTRData.MathExpression` remains. It is a reasonable domain
+ name and changing it would not isolate a dependency.
+- Function plotting is a separate package because it is independently useful, must not
+ reference the parser, and lets arbitrary numerical callers avoid the expression package
+ and its current `LTRData.Extensions` dependency.
+- Expression-diagram content and layout are planned inside `LTRData.MathExpression` unless
+ a concrete dependency or independent-consumption reason emerges.
+- A future SkiaSharp edge package is justified because it isolates an external native
+ rendering dependency and different deployment requirements.
+- System.Drawing compatibility remains in `LTRLib.Windows` initially.
+- The modern expression language is invariant-culture, source-preserving, and
+ case-insensitive for names.
+- Power is right-associative and binds more strongly than unary sign, so `2^3^2` is
+ `2^(3^2)` and `-2^2` is `-(2^2)`.
+- The ordinary plotting contract is unary `f(x) -> y`. Previous-`y` recurrence is not in
+ the new primary API because no first-party formula using it was found.
+
+## Remaining design questions
+
+- Whether the first SkiaSharp package should contain both plot and expression-diagram
+ renderers or use two edge packages. One combined package is currently preferred because
+ the dependency and deployment boundary is the same.
+- Whether legacy function aliases should later be offered by a separately named symbol
+ catalog in Library or only by an LTRLib adapter.
+- Whether plotting needs adaptive sampling after fixed-count sampling and correct clipping
+ have been evaluated against the web and Windows applications.
+- Whether syntax diagrams need both source-oriented and semantic modes. Both remain the
+ likely design, but rendering is outside this slice.
+- Whether new projects should eventually drop .NET Framework 3.5/4.0. This branch keeps
+ the repository target matrix while compatibility policy is decided.
+
+## Recommended next slice
+
+After this branch is reviewed and the language decisions are accepted:
+
+1. add expression-diagram content and measured-size tree layout inside
+ `LTRData.MathExpression`;
+2. add one SkiaSharp edge package containing plot and diagram renderers plus PNG encoding;
+3. integrate both PNG paths into `ltr-data.se` with input/resource limits and Linux tests;
+4. only then evaluate derivative, integral, adaptive sampling, and Windows migration.
+
diff --git a/docs/math-expression-redesign/architecture.md b/docs/math-expression-redesign/architecture.md
new file mode 100644
index 0000000..918f967
--- /dev/null
+++ b/docs/math-expression-redesign/architecture.md
@@ -0,0 +1,173 @@
+# Architecture, dependency boundaries, and proposed public APIs
+
+## Intended dependency graph
+
+```mermaid
+flowchart TD
+ Apps["Web / Windows / netexpr"] --> Math["LTRData.MathExpression"]
+ Apps --> Plot["LTRData.FunctionPlotting"]
+ Skia["Future SkiaSharp edge package"] --> Math
+ Skia --> Plot
+ Legacy["LTRLib.Windows adapters"] --> Math
+ Legacy --> Plot
+```
+
+There is no dependency from plotting to expressions. An expression-bound unary function
+is merely one producer of `double f(double x)`.
+
+Within `LTRData.MathExpression`, dependencies flow in one direction:
+
+```text
+source -> lexer/parser -> immutable syntax -> binder/catalog -> interpreted evaluator
+ |
+ +-> future diagram content/layout
+```
+
+`System.Linq.Expressions` may later be an export target. It is not syntax.
+
+## Package boundary decisions
+
+| Package/project | Boundary decision | Dependency isolated | Known consumers | What other consumers avoid | Different TFM/version need? |
+|---|---|---|---|---|---|
+| `LTRData.MathExpression` | Keep existing package; add modern APIs beside legacy types during experiment | Keeps parsing/evaluation free of graphics and future SkiaSharp | `netexpr`, GraphViewer, web | `netexpr` avoids plotting/rendering dependencies | No new version boundary; existing name remains adequate |
+| `LTRData.FunctionPlotting` | Add one portable project/package | Prevents plotting from depending on parser and its current `LTRData.Extensions` reference | GraphViewer, web, arbitrary numerical callers | Expression-only consumers avoid plotting; plotting-only consumers avoid parser/extensions | Same broad repository TFMs initially; independently useful API justifies consumption boundary |
+| expression diagrams | Namespace/types in `LTRData.MathExpression`, not a separate package | No external dependency to isolate in content/layout | Web first; possible educational/debug consumers | Nothing meaningful would be avoided by another package | No independent target/version need found |
+| future SkiaSharp edge | One package for both plot and expression-diagram raster rendering | SkiaSharp managed/native assets, font/raster deployment, PNG encoding | Unix ASP.NET application | Windows and calculation-only consumers avoid SkiaSharp entirely | Yes: external/native dependency and modern server TFMs |
+| System.Drawing edge | Keep in `LTRLib.Windows` initially | Windows-only API and legacy TFMs | Windows GraphViewer | All portable/server consumers avoid System.Drawing | Yes: Windows-specific targets; compatibility release lifecycle |
+
+No separate packages are proposed for lexer, syntax, binding, evaluation, layout, rendering,
+or PNG encoding merely because they are conceptual stages.
+
+## Proposed expression API
+
+Signatures are reviewable proposals. The experimental slice implements the central forms.
+
+```csharp
+MathParseResult parse = MathParser.Default.Parse("sin(x) + x^2");
+
+if (!parse.Success)
+{
+ foreach (MathDiagnostic diagnostic in parse.Diagnostics)
+ {
+ Console.Error.WriteLine($"{diagnostic.Span}: {diagnostic.Message}");
+ }
+}
+
+MathBindingResult binding = MathBinder.Bind(
+ parse.Root!,
+ MathSymbolCatalog.Standard);
+
+BoundMathExpression expression = binding.Expression!;
+UnaryMathFunction function = expression.BindUnary("x");
+double y = function.Evaluate(1.25);
+```
+
+Arbitrary variables use stable slots:
+
+```csharp
+foreach (MathVariable variable in expression.Variables)
+{
+ Console.WriteLine($"{variable.Slot}: {variable.Name}");
+}
+
+double result = expression.Evaluate(valuesBySlot);
+```
+
+Custom symbols are explicit:
+
+```csharp
+MathSymbolCatalog symbols = MathSymbolCatalog.CreateBuilder()
+ .AddConstant("g", 9.80665)
+ .AddFunction("square", x => x * x)
+ .Build();
+```
+
+The builder is mutable construction state; the resulting catalog is immutable and safe to
+share. No provider assembly/type scan occurs during parsing or evaluation.
+
+## Proposed plotting API
+
+```csharp
+SampleSeries samples = FunctionSampler.Sample(
+ function.Evaluate,
+ new NumericRange(-10, 10),
+ sampleCount: 1200);
+
+var viewport = new PlotViewport(
+ new NumericRange(-10, 10),
+ new NumericRange(-4, 4),
+ new CanvasSize(1200, 720));
+
+CurveGeometry geometry = CurveGeometryBuilder.Build(samples, viewport);
+```
+
+`SampleSeries` retains the original data coordinates and a status per sample. Geometry is
+a collection of continuous-coordinate polylines; it contains no pixels, colors, pens,
+paths, fonts, or renderer objects.
+
+The validation slice deliberately uses fixed-count endpoint-inclusive sampling. The count
+is explicit and unrelated to a rectangle's pixel-loop convention. Geometry breaks at
+invalid samples and performs proper rectangular clipping. It does not yet claim to detect
+finite-valued asymptotes.
+
+## Future expression-diagram boundary
+
+Planned flow inside the existing math package:
+
+```text
+MathSyntax or semantic expression
+ -> ExpressionDiagram (node IDs, labels, roles, ordered edges)
+ -> backend-measured node sizes
+ -> TreeDiagramLayout (rectangles and connectors)
+```
+
+The Skia/System.Drawing adapter measures labels using the actual font backend and supplies
+sizes to portable layout. This avoids both renderer-owned layout and a general-purpose
+`IGraphics.MeasureText/DrawString` abstraction.
+
+## Future renderer integration
+
+Backend-specific APIs should directly accept backend objects:
+
+```csharp
+skiaPlotRenderer.Render(SKCanvas canvas, CurveGeometry geometry, SkiaPlotTheme theme);
+skiaDiagramRenderer.Render(SKCanvas canvas, ExpressionDiagramLayout layout,
+ SkiaDiagramTheme theme);
+skiaPngEncoder.Encode(SKImage image, Stream destination);
+
+systemDrawingPlotRenderer.Render(Graphics graphics, CurveGeometry geometry,
+ SystemDrawingPlotTheme theme);
+```
+
+Rendering and encoding remain separate concepts inside the edge package, but do not need
+separate packages.
+
+## Intentional compatibility breaks
+
+| Legacy behavior/API | Modern decision | Reason |
+|---|---|---|
+| Parser returns LINQ `Expression` | Parser returns math syntax and diagnostics | Source spans, diagrams, AOT interpretation, and language ownership |
+| `ScriptControl.Expression` setter compiles and mutates state | Explicit parse, bind, and optional unary binding | Predictable immutable stages |
+| Only `x` and `y` can be compiled | Arbitrary bound variable slots; plotting explicitly binds unary `x` | Required by `netexpr`; no hidden special variables |
+| Current culture configurable grammar | Invariant formula language | Stable storage/server semantics and unambiguous comma |
+| Current power grouping | Conventional right-associative power above unary sign | Existing results are artifacts without consumer evidence |
+| Empty input is zero | Parse diagnostic | Avoid silently graphing missing input |
+| Reflection provider types | Explicit symbol catalog | AOT/trimming safety and deterministic public contract |
+| Exceptions become nullable values | Direct evaluation propagates; sampling classifies | Distinguishes evaluation, non-finite result, and missing expression |
+| Previous `y` always fed back | Unary ordinary function | No first-party use; recurrence can be explicit legacy functionality |
+| Sampling tied to inclusive pixel coordinates | Explicit numerical sample count | Calculation independent of renderer |
+| Integral wraps at viewport | Never alter numerical state for visibility | Mathematical correctness and separation of clipping |
+| Refresh appends paths | Explicit overlay composition | Preserve the feature without accidental state mutation |
+
+## Compatibility placement
+
+The existing public classes remain untouched in the experimental slice. Later migration can:
+
+- implement LTRLib `ScriptControl` over the new parser/binder while retaining its nullable
+ result and source-normalization behavior where required;
+- implement an LTRLib Windows `Surface` adapter with characterized legacy algorithms;
+- leave old .NET Framework targets on the legacy implementation while modern GraphViewer
+ targets migrate first.
+
+No types named `ScriptControl`, `Surface`, or `Refresh` are added to the modern API.
+
diff --git a/docs/math-expression-redesign/language-specification.md b/docs/math-expression-redesign/language-specification.md
new file mode 100644
index 0000000..cec13b2
--- /dev/null
+++ b/docs/math-expression-redesign/language-specification.md
@@ -0,0 +1,166 @@
+# Modern mathematical expression language — experimental specification
+
+This document is normative for the experimental `MathParser`/`MathBinder` API. It is not a
+description of `MathExpressionParser`.
+
+## 1. Source and names
+
+- Source text is preserved exactly in `MathParseResult.SourceText`.
+- Every syntax node and diagnostic has a zero-based source span.
+- Whitespace separates or surrounds tokens and is otherwise insignificant.
+- Identifiers use a Unicode letter or `_` as the first character, followed by Unicode
+ letters, digits, or `_`.
+- Identifier lookup is ordinal case-insensitive. Original spelling remains in syntax.
+- A bare identifier resolves first as a catalog constant and otherwise becomes a variable.
+- An identifier followed immediately by `(` is a function call; unknown functions and
+ wrong arities are binding diagnostics.
+
+## 2. Numeric literals and culture
+
+The language is culture-invariant:
+
+- decimal separator is `.`;
+- function arguments are separated by `,`;
+- group/thousands separators are not accepted;
+- literals may contain digits before and/or after `.`, but at least one digit is required;
+- scientific notation uses `e` or `E` and an optional `+`/`-` exponent sign.
+
+Examples: `12`, `12.`, `.5`, `1.25`, `1e-3`, `2.5E+6`.
+
+User interfaces may localize entry separately, but thread culture never changes the
+formula language.
+
+## 3. Primary expressions and calls
+
+Primary expressions are:
+
+- numeric literals;
+- names;
+- function calls `name(argument, ...)`;
+- parenthesized expressions `(expression)`.
+
+Zero-argument calls are syntactically valid so a catalog may define them. The standard
+catalog currently defines unary and binary functions only.
+
+## 4. Operators
+
+From highest to lowest binding strength:
+
+| Level | Operators | Associativity |
+|---:|---|---|
+| 1 | postfix factorial `!` | left/repeated |
+| 2 | power `^`, `**` | right |
+| 3 | prefix identity/negation `+`, `-` | right |
+| 4 | multiplication `*`, division `/`, remainder `%`/`mod`, implicit multiplication | left |
+| 5 | addition `+`, subtraction `-` | left |
+
+Power binds more strongly than unary sign:
+
+- `-2^2` is `-(2^2)` and evaluates to `-4`;
+- `2^-2` is `2^(-2)` and evaluates to `0.25`;
+- `2^3^2` is `2^(3^2)` and evaluates to `512`;
+- `2*3^2` is `2*(3^2)` and evaluates to `18`.
+
+`**` is an alias for `^`. `mod` is a case-insensitive alias for `%`.
+
+### Implicit multiplication
+
+Implicit multiplication is supported when an already parsed operand is followed by an
+identifier or `(` without an explicit binary operator. It has exactly the precedence and
+left associativity of `*`.
+
+Examples:
+
+- `2x` means `2*x`;
+- `2(x+1)` means `2*(x+1)`;
+- `(x+1)(x-1)` means `(x+1)*(x-1)`;
+- `2sin(x)` means `2*sin(x)`.
+
+An identifier directly followed by `(` is always a function call. Write `x*(y)` rather
+than `x(y)` when multiplication is intended. Adjacent numeric literals are rejected rather
+than multiplied.
+
+## 5. Factorial
+
+Postfix factorial accepts a finite, non-negative integer-valued `double`:
+
+- `0!` and `1!` are `1`;
+- a negative, fractional, or `NaN` operand produces `NaN`;
+- an operand greater than `170` produces positive infinity because the exact factorial
+ exceeds the finite `double` range.
+
+This intentionally differs from the legacy loop, which returned one for negative inputs
+and effectively truncated some fractional inputs.
+
+## 6. Standard symbol catalog
+
+The experimental standard catalog contains constants:
+
+- `e`;
+- `pi`.
+
+It contains conventional functions available consistently for `double`:
+
+- unary: `abs`, `acos`, `asin`, `atan`, `ceiling`, `cos`, `cosh`, `exp`, `floor`, `ln`,
+ `log`, `log10`, `round`, `sign`, `sin`, `sinh`, `sqrt`, `tan`, `tanh`, `truncate`;
+- binary: `atan2`, `log`, `max`, `min`, `pow`.
+
+`log(x)` and `ln(x)` are natural logarithms. `log(x, b)` uses base `b`.
+
+Additional constants and unary/binary functions can be supplied through an explicit
+catalog builder. Catalogs are immutable after construction. Reflection is not part of the
+standard lookup process.
+
+Historical aliases and bitwise/shift operations are not in the modern standard catalog.
+They may be offered later by an explicitly named legacy catalog.
+
+## 7. Binding and variables
+
+Parsing does not decide whether a name is a constant or variable. Binding resolves names
+against a supplied symbol catalog:
+
+1. a bare name matching a constant binds to that constant;
+2. another bare name becomes a variable;
+3. a call must match a function name and arity or binding fails.
+
+Variables receive stable zero-based slots in first-occurrence traversal order. Evaluation
+can supply values by slot or by case-insensitive name.
+
+Unary function binding succeeds when the expression has no variables or has exactly one
+variable matching the requested name. It fails explicitly when other variables remain.
+
+## 8. Errors and floating-point semantics
+
+- Lexical and syntactic errors are returned as source-span diagnostics; invalid or empty
+ input does not become zero.
+- Binding errors such as an unknown function or wrong arity are diagnostics.
+- Direct evaluation uses normal IEEE `double` arithmetic.
+- Division by zero and domain behavior from `System.Math` therefore produce infinity or
+ `NaN` where .NET does so.
+- Exceptions thrown by explicitly registered custom functions propagate from direct
+ evaluation.
+- Function sampling classifies `NaN`, positive infinity, negative infinity, and evaluation
+ exceptions as distinct sample statuses. It does not confuse those states with zero.
+
+## 9. Formatting and diagrams
+
+The exact source and the syntax tree are distinct from a future canonical formatter.
+`Expression.ToString()` output is not formula identity.
+
+Parenthesized syntax is preserved, allowing a future source-oriented diagram. Binding
+removes syntactic parentheses, allowing a semantic operation diagram. Formatting and
+diagram rendering are not implemented in this experimental slice.
+
+## 10. Intentional omissions
+
+The initial modern language omits:
+
+- shifts `<<`/`>>`;
+- bitwise `&`, `|`, `xor`, and prefix `!`;
+- prefix `neg`;
+- textual infix `pow`;
+- reflection-discovered methods/fields;
+- assignments, comparisons, booleans, conditionals, and statements.
+
+These can be reconsidered as language features only with a use case and explicit semantics.
+
diff --git a/docs/math-expression-redesign/legacy-behavior-classification.md b/docs/math-expression-redesign/legacy-behavior-classification.md
new file mode 100644
index 0000000..0afb5f5
--- /dev/null
+++ b/docs/math-expression-redesign/legacy-behavior-classification.md
@@ -0,0 +1,88 @@
+# Legacy behavior classification
+
+Each row is deliberately classified as exactly one of:
+
+- **A:** desired modern behavior and eligible for normative new-API tests;
+- **B:** legacy compatibility only;
+- **C:** discarded artifact or bug.
+
+“Feature” and “mechanism” are separate rows when the feature is wanted but its historical
+implementation is not.
+
+## Expression language and evaluation
+
+| Observed behavior | Class | Evidence and decision |
+|---|:---:|---|
+| Parse ordinary arithmetic, parentheses, functions, constants, and variables | A | Required by all expression consumers. |
+| Arbitrary variables | A | `netexpr` discovers and prompts for arbitrary parameters. |
+| Case-insensitive names | A | Long-standing behavior and convenient for interactive formula input; source spelling is still preserved. |
+| Invariant `.` decimal syntax and comma argument separator | A | Both current applications construct the parser with invariant culture; a stable language must not change with server/thread culture. |
+| Leading decimal point such as `.1` | A | Explicit current test and common calculator syntax. |
+| Scientific notation with signed exponent | A | Normal numeric syntax; replacement tokenization currently breaks it. |
+| `+`, `-`, `*`, `/`, `%`/`mod`, `^`/`**` | A | Ordinary mathematical/calculator operations; `**` and implicit multiplication have direct tests. |
+| Left associativity for addition, subtraction, multiplication, division, and remainder | A | Conventional and tested for subtraction/arithmetic. |
+| Right-associative power | A | Conventional modern decision: `2^3^2` means `2^(3^2)`. |
+| Power binds more strongly than unary sign | A | Conventional modern decision: `-2^2` means `-(2^2)` while `2^-2` remains valid. |
+| Implicit multiplication | A | Explicit existing test. It has the same precedence/associativity as `*`; ambiguous forms should use parentheses. |
+| Postfix factorial | A | Fits the mathematical domain. Modern semantics require a finite non-negative integer; invalid input yields `NaN`, and overflow yields positive infinity. |
+| Preserve original source text and node spans | A | Needed for diagnostics and expression diagrams. |
+| Explicit immutable constant/function catalog | A | Supports extension without reflection, trimming surprises, or mutable global/provider state. |
+| IEEE `NaN` and infinity from valid floating-point operations | A | Evaluation remains `double`; sampling classifies non-finite results separately. |
+| LINQ `Expression` as the parsed representation | C | Runtime-code-generation model leaks into parsing and loses lexical/source structure. |
+| Runtime compilation as the baseline evaluator | C | Unnecessary for correctness and unsuitable as the AOT baseline. An optional exporter/compiler may be added later. |
+| Binding every expression to exactly `x` and `y` | C | Contradicted by `netexpr`; plotting can explicitly request unary binding. |
+| Previous-`y` recurrence in ordinary plotting | B | Implemented historically, but no first-party formula using it was found. If retained, it must be explicitly named. |
+| Lower-case the complete input | C | Original spelling is useful source data; comparison can be case-insensitive without mutation. |
+| Store `Expression.ToString()` as formula identity | C | Generated representation is neither source nor a language-level canonical format. |
+| Reflection scan of mutable `ProviderTypes` including non-public members | C | Accidental extensibility mechanism; explicit catalogs are predictable and AOT-compatible. |
+| Reflection-based provider import | B | May be useful as a separately named migration adapter, never as the default catalog. |
+| Mutate thread culture while formatting generated expression text | C | Hidden global/thread state with unsafe restoration on failure. |
+| Convert every evaluation exception to `null` | B | Legacy graph gaps depend on it, but modern evaluation and sampling have explicit error/status contracts. |
+| Current equal-precedence left-to-right power/multiply scan | C | Produces `(2*3)^2` for `2*3^2`; no consumer evidence supports it. |
+| Replacement-based tokenization | C | Breaks scientific notation and identifiers containing operator substrings. |
+| Empty input becomes numeric zero | C | Hides missing user input; modern parser reports an expected-expression diagnostic. |
+| Prefix `neg` and prefix `!` | C | No usage evidence; `!` is especially misleading alongside postfix factorial. |
+| Infix textual `pow` | C | No usage evidence; `pow(a,b)`, `^`, and `**` are clear alternatives. |
+| Shifts and bitwise operators | B | A shift has a parser test but no application formula; it does not belong in the initial mathematical plotting language. |
+| Historical aliases such as `atn`, `sqr`, `sgn`, `cosec`, `cotan`, and `harcsin` | B | Historically exposed but absent from checked-in formulas. A legacy catalog can provide them later. |
+| Private `System.Math` methods accepted as symbols | C | Implementation leakage with trimming and versioning risk. |
+| Legacy factorial truncation/negative-input result of one | C | Surprising numerical artifact rather than defensible factorial semantics. |
+| Apparent integer-operand reflection call bug for bitwise operations | C | A bug must not become a compatibility contract. |
+
+## Plot calculation and rendering
+
+| Observed behavior | Class | Evidence and decision |
+|---|:---:|---|
+| Plot an arbitrary scalar function independently of its origin | A | Required architectural boundary and useful beyond expression parsing. |
+| Explicit finite/non-finite/evaluation-error samples | A | Necessary for robust geometry and server rendering. |
+| Explicit overlay composition | A | GraphViewer intentionally lets users retain old graphs. |
+| Derivative display as a feature | A | Enabled by default in GraphViewer, but deferred from the validation slice. |
+| Numerical integral display as a feature | A | Exposed by GraphViewer, but deferred and disabled by default. |
+| Backend-neutral continuous plot geometry | A | Required by both System.Drawing and Unix PNG rendering. |
+| Exactly `width + 1` samples tied to rectangle pixels | C | Rectangle-loop artifact. New sampling takes an explicit count independent of rendering. |
+| Pass the prior result as the next `y` automatically | B | Same recurrence decision as above. |
+| Draw a segment only when an endpoint is strictly within Y bounds | C | Omits crossing segments and conflates visibility with numerical continuity. |
+| Clamp coordinates to one pixel outside the viewport | C | Renderer/path workaround. New geometry uses continuous transforms and clipping. |
+| Treat exceptions as gaps but let `NaN`/infinity reach conversion/path code | C | Inconsistent accidental behavior; statuses are explicit. |
+| Silently swallow path-construction exceptions | C | Hides defects and mixes rendering failures with calculation. |
+| Forward-difference derivative between adjacent samples | B | Exact output may matter only to legacy rendering. A future modern derivative should be specified independently. |
+| Carry stale derivative state across an invalid gap | C | Clear bug. |
+| Right-rectangle cumulative integration | B | Possible compatibility mode if exact old output matters; not selected as modern default. |
+| Wrap the integral to the opposite Y boundary | C | Viewport changes mathematical state; clipping must never alter the numerical integral. |
+| Reset integral to zero after invalid samples | B | Legacy behavior only; future modern integration needs explicit segment/initial-value semantics. |
+| Append paths on `Refresh` until `Clear` | C | Mutation artifact. The desired overlay feature is represented explicitly instead. |
+| Always draw axes and clamp off-range axes outside the area | C | Visibility decision should be explicit in plot composition/rendering. |
+| `Surface` owns disposable backend paths and a finalizer | C | Portable samples and geometry require no disposal; backend resources are short-lived at the edge. |
+| System.Drawing in the Windows adapter | B | Valid compatibility/backend choice, but not part of portable APIs. |
+| SkiaSharp in a Unix PNG edge package | A | Selected first backend direction; it must not shape core models. |
+
+## Test policy
+
+New tests use explicit naming/categories:
+
+- `ModernMathExpressionTests` and plotting tests are normative category A tests.
+- `LegacyMathExpressionCharacterizationTests` document B/C observations against the
+ existing parser only.
+- A legacy characterization failure is evidence to revisit documentation; it must not be
+ “fixed” by changing the new parser to reproduce the old result.
+
diff --git a/docs/math-expression-redesign/usage-corpus.md b/docs/math-expression-redesign/usage-corpus.md
new file mode 100644
index 0000000..54fc4fa
--- /dev/null
+++ b/docs/math-expression-redesign/usage-corpus.md
@@ -0,0 +1,125 @@
+# First-party expression and graph usage corpus
+
+## Search scope
+
+The default branches and available history were searched in:
+
+- `LTRData/Library`;
+- `LTRData/LTRLib`;
+- `LTRData/WindowsTools`, especially `GraphViewer`;
+- `LTRData/MathTools`, especially `netexpr`;
+- `LTRData/ltr-data.se` through the GitHub repository connection;
+- the historical `richardneish/ltrdata` GraphViewer sources.
+
+Searches covered parser and graph type names, formula/expression settings and assignments,
+common mathematical call syntax, documentation, tests, project files, resources, and
+historical GraphViewer implementations.
+
+## Checked-in formula corpus
+
+### Application defaults
+
+| Expression | Source | What it demonstrates |
+|---|---|---|
+| `sin(x)` | Current `WindowsTools/GraphViewer/My Project/Settings.settings` | Unary variable, standard function call, case-insensitive historical input |
+| `sin(x)` | Historical GraphViewer settings and application configuration | Long-lived default, unchanged across the scripting, generated-VB, and current parser eras |
+| `sin(x)` | Disabled `ltr-data.se/coreweb/Pages/GraphViewer.cshtml.cs` | Same server-side plotting use case with invariant parsing |
+
+No other persisted end-user formula is checked into the first-party applications. The
+GraphViewer combo box accepts arbitrary text and stores the most recent value in per-user
+settings, but those user values are not repository evidence.
+
+### Current Library tests
+
+| Expression or family | Demonstrated intent |
+|---|---|
+| `sin(0.4) * 2` | Standard unary function and multiplication |
+| `atan2(312,2)` | Two-argument function and comma separator |
+| `e ** 2` | Named constant and `**` power spelling |
+| `169 - 5 - 3 - 1` | Left-associated subtraction |
+| `169 - (5 - 3 - 1)` | Parentheses |
+| `169 (5 - 3 - 1)` | Implicit multiplication before parentheses |
+| mixed `+`, `-`, `*`, `/` examples | Ordinary arithmetic precedence |
+| `.1` and `.01` | Leading-decimal-point numeric literals |
+| `1 << 10` | Shift operator was intentionally added to the legacy parser |
+| unary sign chains such as `--35`, `+-35`, and `-+35` | Legacy prefix-sign handling |
+
+Tests prove that somebody deliberately added support, but they do not by themselves prove
+that a feature belongs in the modern graph-oriented expression language. The shift case is
+therefore classified as legacy-only pending a real consumer.
+
+## API and feature usage
+
+### Windows GraphViewer
+
+`GraphView.vb`:
+
+- owns one mutable `ScriptControl` and one retained `Surface`;
+- assigns formula text and calls `Surface.Refresh` on recalculation;
+- paints the retained function, derivative, integral, and axes;
+- creates another surface for printing;
+- exports BMP, GIF, JPEG, PNG, and TIFF through System.Drawing;
+- lets the user retain old graphs by disabling clear-before-redraw.
+
+The persisted defaults are X `-10..10`, Y `-5..5`, derivative enabled, integral disabled,
+and clear-before-redraw enabled. Overlaying prior plots is an intentional UI feature, but
+retained `GraphicsPath` mutation is only its historical implementation.
+
+The project targets modern Windows TFMs and .NET Framework 3.5/4.0 from the same VB project.
+That is evidence for keeping old-target compatibility in LTRLib until target support is
+explicitly reconsidered; it is not a reason to constrain all new APIs indefinitely.
+
+### `netexpr`
+
+`MathTools/netexpr` accepts arbitrary formula text, discovers arbitrary parameters, asks
+the user for invariant-culture values, dynamically compiles a LINQ lambda, and invokes it.
+Its actual requirements are:
+
+- arbitrary variables;
+- stable variable discovery;
+- invariant input/output;
+- direct evaluation.
+
+It does not require graphics, `x`/`y` special treatment, or a LINQ expression as the public
+syntax model. Dynamic compilation is an implementation choice rather than a requirement.
+
+### `ltr-data.se`
+
+The disabled GraphViewer page caches compiled `ScriptControl` objects by formula, builds an
+ImageSharp-era `Surface`, optionally draws derivative/integral curves, and encodes a raster
+response. The current practical requirement is two server-generated PNGs: a function plot
+and an expression diagram.
+
+The unbounded compiled-expression cache and unbounded image dimensions are application
+risks to correct during integration, not behavior for a library API to preserve.
+
+## Symbol vocabulary evidence
+
+The current `MathFunctions` and historical `Functions.vb`/`Functions.vbs` provide aliases
+such as `sqr`, `sgn`, `atn`, `sec`, `cosec`, `cotan`, arc variants, hyperbolic variants,
+`ln`, `logn`, and `fac`. This proves historical availability, not checked-in formula use.
+The modern standard catalog therefore starts with conventional names. A separately named
+legacy catalog can restore aliases if migration data shows a need.
+
+## Previous-`y` recurrence search
+
+No checked-in formula intentionally reads `y`. The relevant uses of `y` are inside
+`Surface.Refresh` and its VB ancestors, where the previous result is passed into the next
+evaluation. The only checked-in application formula is unary `sin(x)`.
+
+Conclusion: the modern plotting input is a unary scalar function. Recurrence is category B
+at most and should be a separately named compatibility concept if external evidence later
+requires it.
+
+## Expression-tree renderer search
+
+No expression-tree renderer was found under names including `ExpressionTree`,
+`ExpressionNode`, `DrawExpression`, or “expression tree” in the inspected first-party
+default branches or the historical GraphViewer repository. The current parser's LINQ
+expression output is a tree representation, but no observed first-party code lays it out or
+renders it as an image.
+
+Expression-diagram design is therefore a new requirement, not a renderer port. If another
+historical repository is identified, it should be reviewed as additional evidence and
+classified rather than adopted automatically.
+
From 354622667e861be3aefa33e7f7d2a752ff84daf3 Mon Sep 17 00:00:00 2001
From: "Olof Lagerkvist (LTRData)"
Date: Thu, 3 Sep 2026 18:01:33 +0200
Subject: [PATCH 02/12] Prototype immutable math parsing and evaluation
---
.../MathExpression/MathBinding.cs | 459 ++++++++++++++++
.../MathExpression/MathDiagnostics.cs | 44 ++
.../MathExpression/MathParser.cs | 496 ++++++++++++++++++
.../MathExpression/MathSymbolCatalog.cs | 233 ++++++++
.../MathExpression/MathSyntax.cs | 232 ++++++++
...gacyMathExpressionCharacterizationTests.cs | 48 ++
Tests/ModernMathExpressionTests.cs | 187 +++++++
docs/math-expression-redesign/architecture.md | 3 +-
8 files changed, 1700 insertions(+), 2 deletions(-)
create mode 100644 LTRData.MathExpression/MathExpression/MathBinding.cs
create mode 100644 LTRData.MathExpression/MathExpression/MathDiagnostics.cs
create mode 100644 LTRData.MathExpression/MathExpression/MathParser.cs
create mode 100644 LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs
create mode 100644 LTRData.MathExpression/MathExpression/MathSyntax.cs
create mode 100644 Tests/LegacyMathExpressionCharacterizationTests.cs
create mode 100644 Tests/ModernMathExpressionTests.cs
diff --git a/LTRData.MathExpression/MathExpression/MathBinding.cs b/LTRData.MathExpression/MathExpression/MathBinding.cs
new file mode 100644
index 0000000..4fe5373
--- /dev/null
+++ b/LTRData.MathExpression/MathExpression/MathBinding.cs
@@ -0,0 +1,459 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.MathExpression;
+
+#pragma warning disable CS1591
+
+public sealed class MathVariable
+{
+ internal MathVariable(string name, int slot)
+ {
+ Name = name;
+ Slot = slot;
+ }
+
+ public string Name { get; }
+
+ public int Slot { get; }
+}
+
+public sealed class MathBindingResult
+{
+ internal MathBindingResult(BoundMathExpression? expression,
+ IEnumerable diagnostics)
+ {
+ Expression = expression;
+ Diagnostics = new List(diagnostics).AsReadOnly();
+ }
+
+ public BoundMathExpression? Expression { get; }
+
+ public ReadOnlyCollection Diagnostics { get; }
+
+ public bool Success => Expression is not null && Diagnostics.Count == 0;
+}
+
+public static class MathBinder
+{
+ public static MathBindingResult Bind(MathSyntax syntax,
+ MathSymbolCatalog symbolCatalog)
+ {
+ if (syntax is null)
+ {
+ throw new ArgumentNullException(nameof(syntax));
+ }
+
+ if (symbolCatalog is null)
+ {
+ throw new ArgumentNullException(nameof(symbolCatalog));
+ }
+
+ var binder = new Binder(symbolCatalog);
+ var root = binder.Bind(syntax);
+
+ if (binder.Diagnostics.Count != 0)
+ {
+ return new MathBindingResult(null, binder.Diagnostics);
+ }
+
+ return new MathBindingResult(
+ new BoundMathExpression(root, binder.Variables), binder.Diagnostics);
+ }
+
+ private sealed class Binder
+ {
+ private readonly MathSymbolCatalog symbolCatalog;
+ private readonly Dictionary variablesByName =
+ new(StringComparer.OrdinalIgnoreCase);
+ private readonly List variables = new();
+ private readonly List diagnostics = new();
+
+ public Binder(MathSymbolCatalog symbolCatalog)
+ {
+ this.symbolCatalog = symbolCatalog;
+ }
+
+ public List Variables => variables;
+
+ public List Diagnostics => diagnostics;
+
+ public BoundNode Bind(MathSyntax syntax)
+ {
+ if (syntax is NumberSyntax number)
+ {
+ return new BoundConstantNode(number.Value);
+ }
+
+ if (syntax is NameSyntax name)
+ {
+ if (symbolCatalog.TryGetConstant(name.Name, out var value))
+ {
+ return new BoundConstantNode(value);
+ }
+
+ if (!variablesByName.TryGetValue(name.Name, out var variable))
+ {
+ variable = new MathVariable(name.Name, variables.Count);
+ variablesByName.Add(name.Name, variable);
+ variables.Add(variable);
+ }
+
+ return new BoundVariableNode(variable.Slot);
+ }
+
+ if (syntax is ParenthesizedSyntax parenthesized)
+ {
+ return Bind(parenthesized.Expression);
+ }
+
+ if (syntax is PrefixSyntax prefix)
+ {
+ return new BoundPrefixNode(prefix.Operator, Bind(prefix.Operand));
+ }
+
+ if (syntax is PostfixSyntax postfix)
+ {
+ return new BoundPostfixNode(postfix.Operator, Bind(postfix.Operand));
+ }
+
+ if (syntax is BinarySyntax binary)
+ {
+ return new BoundBinaryNode(binary.Operator,
+ Bind(binary.Left), Bind(binary.Right));
+ }
+
+ if (syntax is CallSyntax call)
+ {
+ var arguments = new BoundNode[call.Arguments.Count];
+
+ for (var index = 0; index < arguments.Length; index++)
+ {
+ arguments[index] = Bind(call.Arguments[index]);
+ }
+
+ if (!symbolCatalog.TryGetFunction(call.Name, arguments.Length,
+ out var function) || function is null)
+ {
+ diagnostics.Add(new MathDiagnostic("MATH300",
+ $"Unknown function or unsupported arity: '{call.Name}' with " +
+ $"{arguments.Length} argument(s).", call.NameSpan));
+ return new BoundConstantNode(double.NaN);
+ }
+
+ return new BoundCallNode(function, arguments);
+ }
+
+ throw new NotSupportedException(
+ $"Unsupported syntax node '{syntax.GetType().FullName}'.");
+ }
+ }
+}
+
+public sealed class BoundMathExpression
+{
+ private readonly BoundNode root;
+
+ internal BoundMathExpression(BoundNode root, IEnumerable variables)
+ {
+ this.root = root;
+ Variables = new List(variables).AsReadOnly();
+ }
+
+ public ReadOnlyCollection Variables { get; }
+
+ public double Evaluate(params double[] values)
+ {
+ if (values is null)
+ {
+ throw new ArgumentNullException(nameof(values));
+ }
+
+ if (values.Length != Variables.Count)
+ {
+ throw new ArgumentException(
+ $"Expected {Variables.Count} variable value(s), but received {values.Length}.",
+ nameof(values));
+ }
+
+ return root.Evaluate(MathEvaluationContext.ForValues(values));
+ }
+
+ public double Evaluate(IDictionary values)
+ {
+ if (values is null)
+ {
+ throw new ArgumentNullException(nameof(values));
+ }
+
+ var valuesBySlot = new double[Variables.Count];
+
+ foreach (var variable in Variables)
+ {
+ if (!TryGetValue(values, variable.Name, out valuesBySlot[variable.Slot]))
+ {
+ throw new KeyNotFoundException(
+ $"No value was supplied for variable '{variable.Name}'.");
+ }
+ }
+
+ return root.Evaluate(MathEvaluationContext.ForValues(valuesBySlot));
+ }
+
+ public UnaryMathFunction BindUnary(string variableName)
+ {
+ if (variableName is null)
+ {
+ throw new ArgumentNullException(nameof(variableName));
+ }
+
+ if (Variables.Count == 0)
+ {
+ return new UnaryMathFunction(root, -1);
+ }
+
+ if (Variables.Count != 1 ||
+ !string.Equals(Variables[0].Name, variableName,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"Expression cannot be bound as a unary function of '{variableName}'.");
+ }
+
+ return new UnaryMathFunction(root, Variables[0].Slot);
+ }
+
+ private static bool TryGetValue(IDictionary values,
+ string name, out double value)
+ {
+ if (values.TryGetValue(name, out value))
+ {
+ return true;
+ }
+
+ foreach (var entry in values)
+ {
+ if (string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
+ {
+ value = entry.Value;
+ return true;
+ }
+ }
+
+ value = 0d;
+ return false;
+ }
+}
+
+public sealed class UnaryMathFunction
+{
+ private readonly BoundNode root;
+ private readonly int variableSlot;
+
+ internal UnaryMathFunction(BoundNode root, int variableSlot)
+ {
+ this.root = root;
+ this.variableSlot = variableSlot;
+ }
+
+ public double Evaluate(double value) =>
+ root.Evaluate(MathEvaluationContext.ForUnary(variableSlot, value));
+}
+
+internal struct MathEvaluationContext
+{
+ private readonly double[]? values;
+ private readonly int unarySlot;
+ private readonly double unaryValue;
+
+ private MathEvaluationContext(double[]? values, int unarySlot, double unaryValue)
+ {
+ this.values = values;
+ this.unarySlot = unarySlot;
+ this.unaryValue = unaryValue;
+ }
+
+ public static MathEvaluationContext ForValues(double[] values) =>
+ new(values, -1, 0d);
+
+ public static MathEvaluationContext ForUnary(int variableSlot, double value) =>
+ new(null, variableSlot, value);
+
+ public double GetVariable(int slot)
+ {
+ if (slot == unarySlot)
+ {
+ return unaryValue;
+ }
+
+ if (values is null)
+ {
+ throw new InvalidOperationException("No value is available for this variable.");
+ }
+
+ return values[slot];
+ }
+}
+
+internal abstract class BoundNode
+{
+ public abstract double Evaluate(MathEvaluationContext context);
+}
+
+internal sealed class BoundConstantNode : BoundNode
+{
+ private readonly double value;
+
+ public BoundConstantNode(double value)
+ {
+ this.value = value;
+ }
+
+ public override double Evaluate(MathEvaluationContext context) => value;
+}
+
+internal sealed class BoundVariableNode : BoundNode
+{
+ private readonly int slot;
+
+ public BoundVariableNode(int slot)
+ {
+ this.slot = slot;
+ }
+
+ public override double Evaluate(MathEvaluationContext context) =>
+ context.GetVariable(slot);
+}
+
+internal sealed class BoundPrefixNode : BoundNode
+{
+ private readonly MathPrefixOperator @operator;
+ private readonly BoundNode operand;
+
+ public BoundPrefixNode(MathPrefixOperator @operator, BoundNode operand)
+ {
+ this.@operator = @operator;
+ this.operand = operand;
+ }
+
+ public override double Evaluate(MathEvaluationContext context)
+ {
+ var value = operand.Evaluate(context);
+
+ return @operator == MathPrefixOperator.Negate ? -value : value;
+ }
+}
+
+internal sealed class BoundPostfixNode : BoundNode
+{
+ private readonly MathPostfixOperator @operator;
+ private readonly BoundNode operand;
+
+ public BoundPostfixNode(MathPostfixOperator @operator, BoundNode operand)
+ {
+ this.@operator = @operator;
+ this.operand = operand;
+ }
+
+ public override double Evaluate(MathEvaluationContext context)
+ {
+ var value = operand.Evaluate(context);
+
+ switch (@operator)
+ {
+ case MathPostfixOperator.Factorial:
+ return Factorial(value);
+ default:
+ throw new NotSupportedException($"Unsupported postfix operator '{@operator}'.");
+ }
+ }
+
+ private static double Factorial(double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value) ||
+ value < 0d || value != Math.Floor(value))
+ {
+ return double.NaN;
+ }
+
+ if (value > 170d)
+ {
+ return double.PositiveInfinity;
+ }
+
+ var result = 1d;
+
+ for (var factor = 2; factor <= (int)value; factor++)
+ {
+ result *= factor;
+ }
+
+ return result;
+ }
+}
+
+internal sealed class BoundBinaryNode : BoundNode
+{
+ private readonly MathBinaryOperator @operator;
+ private readonly BoundNode left;
+ private readonly BoundNode right;
+
+ public BoundBinaryNode(MathBinaryOperator @operator, BoundNode left, BoundNode right)
+ {
+ this.@operator = @operator;
+ this.left = left;
+ this.right = right;
+ }
+
+ public override double Evaluate(MathEvaluationContext context)
+ {
+ var leftValue = left.Evaluate(context);
+ var rightValue = right.Evaluate(context);
+
+ switch (@operator)
+ {
+ case MathBinaryOperator.Add:
+ return leftValue + rightValue;
+ case MathBinaryOperator.Subtract:
+ return leftValue - rightValue;
+ case MathBinaryOperator.Multiply:
+ return leftValue * rightValue;
+ case MathBinaryOperator.Divide:
+ return leftValue / rightValue;
+ case MathBinaryOperator.Remainder:
+ return leftValue % rightValue;
+ case MathBinaryOperator.Power:
+ return Math.Pow(leftValue, rightValue);
+ default:
+ throw new NotSupportedException($"Unsupported binary operator '{@operator}'.");
+ }
+ }
+}
+
+internal sealed class BoundCallNode : BoundNode
+{
+ private readonly MathFunctionDefinition function;
+ private readonly BoundNode[] arguments;
+
+ public BoundCallNode(MathFunctionDefinition function, BoundNode[] arguments)
+ {
+ this.function = function;
+ this.arguments = arguments;
+ }
+
+ public override double Evaluate(MathEvaluationContext context)
+ {
+ switch (function.Arity)
+ {
+ case 1:
+ return function.Invoke(arguments[0].Evaluate(context));
+ case 2:
+ return function.Invoke(arguments[0].Evaluate(context),
+ arguments[1].Evaluate(context));
+ default:
+ throw new NotSupportedException(
+ $"Unsupported function arity {function.Arity}.");
+ }
+ }
+}
diff --git a/LTRData.MathExpression/MathExpression/MathDiagnostics.cs b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs
new file mode 100644
index 0000000..15da225
--- /dev/null
+++ b/LTRData.MathExpression/MathExpression/MathDiagnostics.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.MathExpression;
+
+#pragma warning disable CS1591
+
+public sealed class MathDiagnostic
+{
+ public MathDiagnostic(string code, string message, SourceSpan span)
+ {
+ Code = code ?? throw new ArgumentNullException(nameof(code));
+ Message = message ?? throw new ArgumentNullException(nameof(message));
+ Span = span;
+ }
+
+ public string Code { get; }
+
+ public string Message { get; }
+
+ public SourceSpan Span { get; }
+
+ public override string ToString() => $"{Code} at {Span}: {Message}";
+}
+
+public sealed class MathParseResult
+{
+ internal MathParseResult(string sourceText, MathSyntax? root,
+ IEnumerable diagnostics)
+ {
+ SourceText = sourceText ?? throw new ArgumentNullException(nameof(sourceText));
+ Root = root;
+ Diagnostics = new List(diagnostics).AsReadOnly();
+ }
+
+ public string SourceText { get; }
+
+ public MathSyntax? Root { get; }
+
+ public ReadOnlyCollection Diagnostics { get; }
+
+ public bool Success => Root is not null && Diagnostics.Count == 0;
+}
diff --git a/LTRData.MathExpression/MathExpression/MathParser.cs b/LTRData.MathExpression/MathExpression/MathParser.cs
new file mode 100644
index 0000000..3e8f426
--- /dev/null
+++ b/LTRData.MathExpression/MathExpression/MathParser.cs
@@ -0,0 +1,496 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace LTRData.MathExpression;
+
+#pragma warning disable CS1591
+
+public sealed class MathParser
+{
+ public static MathParser Default { get; } = new();
+
+ public MathParseResult Parse(string sourceText)
+ {
+ if (sourceText is null)
+ {
+ throw new ArgumentNullException(nameof(sourceText));
+ }
+
+ var diagnostics = new List();
+ var lexer = new MathLexer(sourceText, diagnostics);
+ var tokens = lexer.Lex();
+
+ if (diagnostics.Count != 0)
+ {
+ return new MathParseResult(sourceText, null, diagnostics);
+ }
+
+ var parser = new SyntaxParser(tokens, diagnostics);
+ var root = parser.ParseRoot();
+
+ return new MathParseResult(sourceText, diagnostics.Count == 0 ? root : null,
+ diagnostics);
+ }
+
+ private sealed class SyntaxParser
+ {
+ private readonly List tokens;
+ private readonly List diagnostics;
+ private int position;
+
+ public SyntaxParser(List tokens, List diagnostics)
+ {
+ this.tokens = tokens;
+ this.diagnostics = diagnostics;
+ }
+
+ private MathToken Current => tokens[position];
+
+ private MathToken NextToken()
+ {
+ var current = Current;
+
+ if (current.Kind != MathTokenKind.End)
+ {
+ position++;
+ }
+
+ return current;
+ }
+
+ public MathSyntax? ParseRoot()
+ {
+ try
+ {
+ var expression = ParseAdditive();
+
+ if (Current.Kind != MathTokenKind.End)
+ {
+ Report("MATH203", $"Unexpected token '{Current.Text}'.", Current.Span);
+ return null;
+ }
+
+ return expression;
+ }
+ catch (ParseAbortException)
+ {
+ return null;
+ }
+ }
+
+ private MathSyntax ParseAdditive()
+ {
+ var left = ParseMultiplicative();
+
+ while (Current.Kind == MathTokenKind.Plus ||
+ Current.Kind == MathTokenKind.Minus)
+ {
+ var operatorToken = NextToken();
+ var right = ParseMultiplicative();
+ var @operator = operatorToken.Kind == MathTokenKind.Plus
+ ? MathBinaryOperator.Add
+ : MathBinaryOperator.Subtract;
+
+ left = new BinarySyntax(left, @operator, operatorToken.Span, right,
+ false, SourceSpan.FromBounds(left.Span.Start, right.Span.End));
+ }
+
+ return left;
+ }
+
+ private MathSyntax ParseMultiplicative()
+ {
+ var left = ParseUnary();
+
+ for (; ; )
+ {
+ MathBinaryOperator @operator;
+ MathToken operatorToken;
+ var isImplicit = false;
+
+ switch (Current.Kind)
+ {
+ case MathTokenKind.Star:
+ operatorToken = NextToken();
+ @operator = MathBinaryOperator.Multiply;
+ break;
+
+ case MathTokenKind.Slash:
+ operatorToken = NextToken();
+ @operator = MathBinaryOperator.Divide;
+ break;
+
+ case MathTokenKind.Percent:
+ operatorToken = NextToken();
+ @operator = MathBinaryOperator.Remainder;
+ break;
+
+ case MathTokenKind.Identifier when
+ string.Equals(Current.Text, "mod", StringComparison.OrdinalIgnoreCase):
+ operatorToken = NextToken();
+ @operator = MathBinaryOperator.Remainder;
+ break;
+
+ case MathTokenKind.Identifier:
+ case MathTokenKind.OpenParenthesis:
+ operatorToken = new MathToken(MathTokenKind.ImplicitMultiply,
+ string.Empty,
+ SourceSpan.FromBounds(left.Span.End, Current.Span.Start), 0d);
+ @operator = MathBinaryOperator.Multiply;
+ isImplicit = true;
+ break;
+
+ default:
+ return left;
+ }
+
+ var right = ParseUnary();
+ left = new BinarySyntax(left, @operator, operatorToken.Span, right,
+ isImplicit, SourceSpan.FromBounds(left.Span.Start, right.Span.End));
+ }
+ }
+
+ private MathSyntax ParseUnary()
+ {
+ if (Current.Kind == MathTokenKind.Plus ||
+ Current.Kind == MathTokenKind.Minus)
+ {
+ var operatorToken = NextToken();
+ var operand = ParseUnary();
+ var @operator = operatorToken.Kind == MathTokenKind.Plus
+ ? MathPrefixOperator.Identity
+ : MathPrefixOperator.Negate;
+
+ return new PrefixSyntax(@operator, operatorToken.Span, operand,
+ SourceSpan.FromBounds(operatorToken.Span.Start, operand.Span.End));
+ }
+
+ return ParsePower();
+ }
+
+ private MathSyntax ParsePower()
+ {
+ var left = ParsePostfix();
+
+ if (Current.Kind == MathTokenKind.Caret ||
+ Current.Kind == MathTokenKind.DoubleStar)
+ {
+ var operatorToken = NextToken();
+ var right = ParseUnary();
+
+ return new BinarySyntax(left, MathBinaryOperator.Power,
+ operatorToken.Span, right, false,
+ SourceSpan.FromBounds(left.Span.Start, right.Span.End));
+ }
+
+ return left;
+ }
+
+ private MathSyntax ParsePostfix()
+ {
+ var expression = ParsePrimary();
+
+ while (Current.Kind == MathTokenKind.Bang)
+ {
+ var operatorToken = NextToken();
+ expression = new PostfixSyntax(expression,
+ MathPostfixOperator.Factorial, operatorToken.Span,
+ SourceSpan.FromBounds(expression.Span.Start, operatorToken.Span.End));
+ }
+
+ return expression;
+ }
+
+ private MathSyntax ParsePrimary()
+ {
+ if (Current.Kind == MathTokenKind.Number)
+ {
+ var number = NextToken();
+ return new NumberSyntax(number.Number, number.Text, number.Span);
+ }
+
+ if (Current.Kind == MathTokenKind.Identifier)
+ {
+ var name = NextToken();
+
+ if (Current.Kind != MathTokenKind.OpenParenthesis)
+ {
+ return new NameSyntax(name.Text, name.Span);
+ }
+
+ NextToken();
+ var arguments = new List();
+
+ if (Current.Kind != MathTokenKind.CloseParenthesis)
+ {
+ for (; ; )
+ {
+ arguments.Add(ParseAdditive());
+
+ if (Current.Kind != MathTokenKind.Comma)
+ {
+ break;
+ }
+
+ NextToken();
+ }
+ }
+
+ var close = Expect(MathTokenKind.CloseParenthesis,
+ "MATH202", "Expected ',' or ')' after function argument.");
+
+ return new CallSyntax(name.Text, name.Span, arguments,
+ SourceSpan.FromBounds(name.Span.Start, close.Span.End));
+ }
+
+ if (Current.Kind == MathTokenKind.OpenParenthesis)
+ {
+ var open = NextToken();
+ var expression = ParseAdditive();
+ var close = Expect(MathTokenKind.CloseParenthesis,
+ "MATH201", "Expected ')' to close parenthesized expression.");
+
+ return new ParenthesizedSyntax(expression,
+ SourceSpan.FromBounds(open.Span.Start, close.Span.End));
+ }
+
+ Report("MATH200", "Expected an expression.", Current.Span);
+ throw new ParseAbortException();
+ }
+
+ private MathToken Expect(MathTokenKind kind, string code, string message)
+ {
+ if (Current.Kind == kind)
+ {
+ return NextToken();
+ }
+
+ Report(code, message, Current.Span);
+ throw new ParseAbortException();
+ }
+
+ private void Report(string code, string message, SourceSpan span)
+ {
+ diagnostics.Add(new MathDiagnostic(code, message, span));
+ }
+ }
+
+ private sealed class ParseAbortException : Exception
+ {
+ }
+
+ private sealed class MathLexer
+ {
+ private readonly string sourceText;
+ private readonly List diagnostics;
+ private int position;
+
+ public MathLexer(string sourceText, List diagnostics)
+ {
+ this.sourceText = sourceText;
+ this.diagnostics = diagnostics;
+ }
+
+ public List Lex()
+ {
+ var tokens = new List();
+
+ while (position < sourceText.Length)
+ {
+ if (char.IsWhiteSpace(sourceText[position]))
+ {
+ position++;
+ continue;
+ }
+
+ var start = position;
+ var current = sourceText[position];
+
+ if (char.IsDigit(current) ||
+ (current == '.' && position + 1 < sourceText.Length &&
+ char.IsDigit(sourceText[position + 1])))
+ {
+ tokens.Add(LexNumber());
+ continue;
+ }
+
+ if (char.IsLetter(current) || current == '_')
+ {
+ position++;
+
+ while (position < sourceText.Length &&
+ (char.IsLetterOrDigit(sourceText[position]) ||
+ sourceText[position] == '_'))
+ {
+ position++;
+ }
+
+ tokens.Add(CreateToken(MathTokenKind.Identifier, start, position));
+ continue;
+ }
+
+ position++;
+
+ switch (current)
+ {
+ case '+':
+ tokens.Add(CreateToken(MathTokenKind.Plus, start, position));
+ break;
+ case '-':
+ tokens.Add(CreateToken(MathTokenKind.Minus, start, position));
+ break;
+ case '*':
+ if (position < sourceText.Length && sourceText[position] == '*')
+ {
+ position++;
+ tokens.Add(CreateToken(MathTokenKind.DoubleStar, start, position));
+ }
+ else
+ {
+ tokens.Add(CreateToken(MathTokenKind.Star, start, position));
+ }
+ break;
+ case '/':
+ tokens.Add(CreateToken(MathTokenKind.Slash, start, position));
+ break;
+ case '%':
+ tokens.Add(CreateToken(MathTokenKind.Percent, start, position));
+ break;
+ case '^':
+ tokens.Add(CreateToken(MathTokenKind.Caret, start, position));
+ break;
+ case '!':
+ tokens.Add(CreateToken(MathTokenKind.Bang, start, position));
+ break;
+ case '(':
+ tokens.Add(CreateToken(MathTokenKind.OpenParenthesis, start, position));
+ break;
+ case ')':
+ tokens.Add(CreateToken(MathTokenKind.CloseParenthesis, start, position));
+ break;
+ case ',':
+ tokens.Add(CreateToken(MathTokenKind.Comma, start, position));
+ break;
+ default:
+ diagnostics.Add(new MathDiagnostic("MATH100",
+ $"Invalid character '{current}'.",
+ new SourceSpan(start, 1)));
+ tokens.Add(CreateToken(MathTokenKind.Invalid, start, position));
+ break;
+ }
+ }
+
+ tokens.Add(new MathToken(MathTokenKind.End, string.Empty,
+ new SourceSpan(sourceText.Length, 0), 0d));
+
+ return tokens;
+ }
+
+ private MathToken LexNumber()
+ {
+ var start = position;
+
+ while (position < sourceText.Length && char.IsDigit(sourceText[position]))
+ {
+ position++;
+ }
+
+ if (position < sourceText.Length && sourceText[position] == '.')
+ {
+ position++;
+
+ while (position < sourceText.Length && char.IsDigit(sourceText[position]))
+ {
+ position++;
+ }
+ }
+
+ if (position < sourceText.Length &&
+ (sourceText[position] == 'e' || sourceText[position] == 'E'))
+ {
+ position++;
+
+ if (position < sourceText.Length &&
+ (sourceText[position] == '+' || sourceText[position] == '-'))
+ {
+ position++;
+ }
+
+ var exponentStart = position;
+
+ while (position < sourceText.Length && char.IsDigit(sourceText[position]))
+ {
+ position++;
+ }
+
+ if (position == exponentStart)
+ {
+ var invalidText = sourceText.Substring(start, position - start);
+ var invalidSpan = new SourceSpan(start, position - start);
+ diagnostics.Add(new MathDiagnostic("MATH101",
+ $"Invalid numeric literal '{invalidText}'.", invalidSpan));
+ return new MathToken(MathTokenKind.Invalid, invalidText,
+ invalidSpan, 0d);
+ }
+ }
+
+ var text = sourceText.Substring(start, position - start);
+
+ if (!double.TryParse(text, NumberStyles.Float,
+ CultureInfo.InvariantCulture, out var number))
+ {
+ var invalidSpan = new SourceSpan(start, position - start);
+ diagnostics.Add(new MathDiagnostic("MATH101",
+ $"Invalid numeric literal '{text}'.", invalidSpan));
+ return new MathToken(MathTokenKind.Invalid, text, invalidSpan, 0d);
+ }
+
+ return new MathToken(MathTokenKind.Number, text,
+ new SourceSpan(start, position - start), number);
+ }
+
+ private MathToken CreateToken(MathTokenKind kind, int start, int end) =>
+ new(kind, sourceText.Substring(start, end - start),
+ new SourceSpan(start, end - start), 0d);
+ }
+
+ private enum MathTokenKind
+ {
+ Invalid,
+ End,
+ Number,
+ Identifier,
+ Plus,
+ Minus,
+ Star,
+ Slash,
+ Percent,
+ Caret,
+ DoubleStar,
+ Bang,
+ OpenParenthesis,
+ CloseParenthesis,
+ Comma,
+ ImplicitMultiply
+ }
+
+ private struct MathToken
+ {
+ public MathToken(MathTokenKind kind, string text, SourceSpan span, double number)
+ {
+ Kind = kind;
+ Text = text;
+ Span = span;
+ Number = number;
+ }
+
+ public MathTokenKind Kind { get; }
+
+ public string Text { get; }
+
+ public SourceSpan Span { get; }
+
+ public double Number { get; }
+ }
+}
diff --git a/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs
new file mode 100644
index 0000000..ce8efb4
--- /dev/null
+++ b/LTRData.MathExpression/MathExpression/MathSymbolCatalog.cs
@@ -0,0 +1,233 @@
+using System;
+using System.Collections.Generic;
+
+namespace LTRData.MathExpression;
+
+#pragma warning disable CS1591
+
+public sealed class MathSymbolCatalog
+{
+ private static readonly MathSymbolCatalog standard = CreateStandard();
+
+ private readonly Dictionary constants;
+ private readonly Dictionary> functions;
+
+ internal MathSymbolCatalog(Dictionary constants,
+ Dictionary> functions)
+ {
+ this.constants = new Dictionary(constants,
+ StringComparer.OrdinalIgnoreCase);
+ this.functions = new Dictionary>(
+ StringComparer.OrdinalIgnoreCase);
+
+ foreach (var entry in functions)
+ {
+ this.functions.Add(entry.Key,
+ new Dictionary(entry.Value));
+ }
+ }
+
+ public static MathSymbolCatalog Standard => standard;
+
+ public static MathSymbolCatalogBuilder CreateBuilder() => new();
+
+ public MathSymbolCatalogBuilder ToBuilder() => new(constants, functions);
+
+ internal bool TryGetConstant(string name, out double value) =>
+ constants.TryGetValue(name, out value);
+
+ internal bool TryGetFunction(string name, int arity,
+ out MathFunctionDefinition? function)
+ {
+ if (functions.TryGetValue(name, out var overloads) &&
+ overloads.TryGetValue(arity, out var result))
+ {
+ function = result;
+ return true;
+ }
+
+ function = null;
+ return false;
+ }
+
+ private static MathSymbolCatalog CreateStandard()
+ {
+ return CreateBuilder()
+ .AddConstant("e", Math.E)
+ .AddConstant("pi", Math.PI)
+ .AddFunction("abs", x => Math.Abs(x))
+ .AddFunction("acos", x => Math.Acos(x))
+ .AddFunction("asin", x => Math.Asin(x))
+ .AddFunction("atan", x => Math.Atan(x))
+ .AddFunction("atan2", (y, x) => Math.Atan2(y, x))
+ .AddFunction("ceiling", x => Math.Ceiling(x))
+ .AddFunction("cos", x => Math.Cos(x))
+ .AddFunction("cosh", x => Math.Cosh(x))
+ .AddFunction("exp", x => Math.Exp(x))
+ .AddFunction("floor", x => Math.Floor(x))
+ .AddFunction("ln", x => Math.Log(x))
+ .AddFunction("log", x => Math.Log(x))
+ .AddFunction("log", (x, @base) => Math.Log(x, @base))
+ .AddFunction("log10", x => Math.Log10(x))
+ .AddFunction("max", (left, right) => Math.Max(left, right))
+ .AddFunction("min", (left, right) => Math.Min(left, right))
+ .AddFunction("pow", (left, right) => Math.Pow(left, right))
+ .AddFunction("round", x => Math.Round(x))
+ .AddFunction("sign", x => Math.Sign(x))
+ .AddFunction("sin", x => Math.Sin(x))
+ .AddFunction("sinh", x => Math.Sinh(x))
+ .AddFunction("sqrt", x => Math.Sqrt(x))
+ .AddFunction("tan", x => Math.Tan(x))
+ .AddFunction("tanh", x => Math.Tanh(x))
+ .AddFunction("truncate", x => Math.Truncate(x))
+ .Build();
+ }
+}
+
+public sealed class MathSymbolCatalogBuilder
+{
+ private readonly Dictionary constants =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly Dictionary> functions =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public MathSymbolCatalogBuilder()
+ {
+ }
+
+ internal MathSymbolCatalogBuilder(Dictionary constants,
+ Dictionary> functions)
+ {
+ foreach (var constant in constants)
+ {
+ this.constants.Add(constant.Key, constant.Value);
+ }
+
+ foreach (var function in functions)
+ {
+ this.functions.Add(function.Key,
+ new Dictionary(function.Value));
+ }
+ }
+
+ public MathSymbolCatalogBuilder AddConstant(string name, double value)
+ {
+ ValidateName(name);
+
+ if (constants.ContainsKey(name))
+ {
+ throw new ArgumentException($"A constant named '{name}' is already registered.",
+ nameof(name));
+ }
+
+ constants.Add(name, value);
+ return this;
+ }
+
+ public MathSymbolCatalogBuilder AddFunction(string name, Func function)
+ {
+ if (function is null)
+ {
+ throw new ArgumentNullException(nameof(function));
+ }
+
+ AddFunction(name, new MathFunctionDefinition(function));
+ return this;
+ }
+
+ public MathSymbolCatalogBuilder AddFunction(string name,
+ Func function)
+ {
+ if (function is null)
+ {
+ throw new ArgumentNullException(nameof(function));
+ }
+
+ AddFunction(name, new MathFunctionDefinition(function));
+ return this;
+ }
+
+ public MathSymbolCatalog Build() => new(constants, functions);
+
+ private void AddFunction(string name, MathFunctionDefinition function)
+ {
+ ValidateName(name);
+
+ if (!functions.TryGetValue(name, out var overloads))
+ {
+ overloads = new Dictionary();
+ functions.Add(name, overloads);
+ }
+
+ if (overloads.ContainsKey(function.Arity))
+ {
+ throw new ArgumentException(
+ $"A function named '{name}' with arity {function.Arity} is already registered.",
+ nameof(name));
+ }
+
+ overloads.Add(function.Arity, function);
+ }
+
+ private static void ValidateName(string name)
+ {
+ if (name is null || name.Trim().Length == 0)
+ {
+ throw new ArgumentException("A symbol name is required.", nameof(name));
+ }
+
+ if (!(char.IsLetter(name[0]) || name[0] == '_'))
+ {
+ throw new ArgumentException($"Invalid symbol name '{name}'.", nameof(name));
+ }
+
+ for (var index = 1; index < name.Length; index++)
+ {
+ if (!(char.IsLetterOrDigit(name[index]) || name[index] == '_'))
+ {
+ throw new ArgumentException($"Invalid symbol name '{name}'.", nameof(name));
+ }
+ }
+ }
+}
+
+internal sealed class MathFunctionDefinition
+{
+ private readonly Func? unary;
+ private readonly Func? binary;
+
+ public MathFunctionDefinition(Func unary)
+ {
+ this.unary = unary;
+ Arity = 1;
+ }
+
+ public MathFunctionDefinition(Func binary)
+ {
+ this.binary = binary;
+ Arity = 2;
+ }
+
+ public int Arity { get; }
+
+ public double Invoke(double argument)
+ {
+ if (unary is null)
+ {
+ throw new InvalidOperationException("Function is not unary.");
+ }
+
+ return unary(argument);
+ }
+
+ public double Invoke(double left, double right)
+ {
+ if (binary is null)
+ {
+ throw new InvalidOperationException("Function is not binary.");
+ }
+
+ return binary(left, right);
+ }
+}
diff --git a/LTRData.MathExpression/MathExpression/MathSyntax.cs b/LTRData.MathExpression/MathExpression/MathSyntax.cs
new file mode 100644
index 0000000..1b70ada
--- /dev/null
+++ b/LTRData.MathExpression/MathExpression/MathSyntax.cs
@@ -0,0 +1,232 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.MathExpression;
+
+#pragma warning disable CS1591
+
+public struct SourceSpan : IEquatable
+{
+ public SourceSpan(int start, int length)
+ {
+ if (start < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(start));
+ }
+
+ if (length < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(length));
+ }
+
+ Start = start;
+ Length = length;
+ }
+
+ public int Start { get; }
+
+ public int Length { get; }
+
+ public int End => Start + Length;
+
+ public static SourceSpan FromBounds(int start, int end)
+ {
+ if (end < start)
+ {
+ throw new ArgumentOutOfRangeException(nameof(end));
+ }
+
+ return new SourceSpan(start, end - start);
+ }
+
+ public bool Equals(SourceSpan other) => Start == other.Start && Length == other.Length;
+
+ public override bool Equals(object? obj) => obj is SourceSpan other && Equals(other);
+
+ public override int GetHashCode() => unchecked((Start * 397) ^ Length);
+
+ public override string ToString() => $"{Start}..{End}";
+
+ public static bool operator ==(SourceSpan left, SourceSpan right) => left.Equals(right);
+
+ public static bool operator !=(SourceSpan left, SourceSpan right) => !left.Equals(right);
+}
+
+public enum MathSyntaxKind
+{
+ Number,
+ Name,
+ Parenthesized,
+ Prefix,
+ Postfix,
+ Binary,
+ Call
+}
+
+public enum MathPrefixOperator
+{
+ Identity,
+ Negate
+}
+
+public enum MathPostfixOperator
+{
+ Factorial
+}
+
+public enum MathBinaryOperator
+{
+ Add,
+ Subtract,
+ Multiply,
+ Divide,
+ Remainder,
+ Power
+}
+
+public abstract class MathSyntax
+{
+ internal MathSyntax(SourceSpan span)
+ {
+ Span = span;
+ }
+
+ public SourceSpan Span { get; }
+
+ public abstract MathSyntaxKind Kind { get; }
+}
+
+public sealed class NumberSyntax : MathSyntax
+{
+ public NumberSyntax(double value, string lexeme, SourceSpan span)
+ : base(span)
+ {
+ Value = value;
+ Lexeme = lexeme ?? throw new ArgumentNullException(nameof(lexeme));
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Number;
+
+ public double Value { get; }
+
+ public string Lexeme { get; }
+}
+
+public sealed class NameSyntax : MathSyntax
+{
+ public NameSyntax(string name, SourceSpan span)
+ : base(span)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Name;
+
+ public string Name { get; }
+}
+
+public sealed class ParenthesizedSyntax : MathSyntax
+{
+ public ParenthesizedSyntax(MathSyntax expression, SourceSpan span)
+ : base(span)
+ {
+ Expression = expression ?? throw new ArgumentNullException(nameof(expression));
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Parenthesized;
+
+ public MathSyntax Expression { get; }
+}
+
+public sealed class PrefixSyntax : MathSyntax
+{
+ public PrefixSyntax(MathPrefixOperator @operator, SourceSpan operatorSpan,
+ MathSyntax operand, SourceSpan span)
+ : base(span)
+ {
+ Operator = @operator;
+ OperatorSpan = operatorSpan;
+ Operand = operand ?? throw new ArgumentNullException(nameof(operand));
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Prefix;
+
+ public MathPrefixOperator Operator { get; }
+
+ public SourceSpan OperatorSpan { get; }
+
+ public MathSyntax Operand { get; }
+}
+
+public sealed class PostfixSyntax : MathSyntax
+{
+ public PostfixSyntax(MathSyntax operand, MathPostfixOperator @operator,
+ SourceSpan operatorSpan, SourceSpan span)
+ : base(span)
+ {
+ Operand = operand ?? throw new ArgumentNullException(nameof(operand));
+ Operator = @operator;
+ OperatorSpan = operatorSpan;
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Postfix;
+
+ public MathSyntax Operand { get; }
+
+ public MathPostfixOperator Operator { get; }
+
+ public SourceSpan OperatorSpan { get; }
+}
+
+public sealed class BinarySyntax : MathSyntax
+{
+ public BinarySyntax(MathSyntax left, MathBinaryOperator @operator,
+ SourceSpan operatorSpan, MathSyntax right, bool isImplicit, SourceSpan span)
+ : base(span)
+ {
+ Left = left ?? throw new ArgumentNullException(nameof(left));
+ Operator = @operator;
+ OperatorSpan = operatorSpan;
+ Right = right ?? throw new ArgumentNullException(nameof(right));
+ IsImplicit = isImplicit;
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Binary;
+
+ public MathSyntax Left { get; }
+
+ public MathBinaryOperator Operator { get; }
+
+ public SourceSpan OperatorSpan { get; }
+
+ public MathSyntax Right { get; }
+
+ public bool IsImplicit { get; }
+}
+
+public sealed class CallSyntax : MathSyntax
+{
+ public CallSyntax(string name, SourceSpan nameSpan,
+ IEnumerable arguments, SourceSpan span)
+ : base(span)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ NameSpan = nameSpan;
+
+ if (arguments is null)
+ {
+ throw new ArgumentNullException(nameof(arguments));
+ }
+
+ Arguments = new List(arguments).AsReadOnly();
+ }
+
+ public override MathSyntaxKind Kind => MathSyntaxKind.Call;
+
+ public string Name { get; }
+
+ public SourceSpan NameSpan { get; }
+
+ public ReadOnlyCollection Arguments { get; }
+}
diff --git a/Tests/LegacyMathExpressionCharacterizationTests.cs b/Tests/LegacyMathExpressionCharacterizationTests.cs
new file mode 100644
index 0000000..aa55cda
--- /dev/null
+++ b/Tests/LegacyMathExpressionCharacterizationTests.cs
@@ -0,0 +1,48 @@
+using LTRData.MathExpression;
+using System;
+using System.Globalization;
+using Xunit;
+
+namespace LTRData.Extensions.Tests;
+
+///
+/// Documents selected behavior of the historical parser. These are evidence for migration,
+/// not normative tests for .
+///
+[Trait("Contract", "LegacyCharacterization")]
+public class LegacyMathExpressionCharacterizationTests
+{
+ private readonly MathExpressionParser parser = new(CultureInfo.InvariantCulture);
+
+ [Fact]
+ public void PowerAndMultiplicationCurrentlyGroupLeftToRightAtOnePrecedence()
+ {
+ var expression = parser.ParseExpression>("2*3^2");
+
+ Assert.Equal(36d, expression());
+ }
+
+ [Fact]
+ public void RepeatedPowerIsCurrentlyLeftAssociative()
+ {
+ var expression = parser.ParseExpression>("2^3^2");
+
+ Assert.Equal(64d, expression());
+ }
+
+ [Fact]
+ public void EmptyInputCurrentlyBecomesZero()
+ {
+ var expression = parser.ParseExpression>(string.Empty);
+
+ Assert.Equal(0d, expression());
+ }
+
+ [Fact]
+ public void ScriptControlTreatsYAsASecondCallerSuppliedVariable()
+ {
+ var control = new ScriptControl(parser) { Expression = "x+y" };
+
+ Assert.Equal((double?)5d, control.Eval(2d, 3d));
+ }
+}
diff --git a/Tests/ModernMathExpressionTests.cs b/Tests/ModernMathExpressionTests.cs
new file mode 100644
index 0000000..3ff9ea3
--- /dev/null
+++ b/Tests/ModernMathExpressionTests.cs
@@ -0,0 +1,187 @@
+using LTRData.MathExpression;
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace LTRData.Extensions.Tests;
+
+[Trait("Contract", "Modern")]
+public class ModernMathExpressionTests
+{
+ [Fact]
+ public void ParserPreservesSourceTextAndNodeSpan()
+ {
+ const string source = " SIN(x) ";
+
+ var parse = MathParser.Default.Parse(source);
+
+ Assert.True(parse.Success, FormatDiagnostics(parse.Diagnostics));
+ Assert.Equal(source, parse.SourceText);
+ Assert.Equal(new SourceSpan(1, 6), parse.Root!.Span);
+
+ var call = Assert.IsType(parse.Root);
+ Assert.Equal("SIN", call.Name);
+ Assert.Equal(new SourceSpan(1, 3), call.NameSpan);
+ }
+
+ [Theory]
+ [InlineData("2*3^2", 18d)]
+ [InlineData("2^3^2", 512d)]
+ [InlineData("-2^2", -4d)]
+ [InlineData("2^-2", 0.25d)]
+ [InlineData("20 mod 6", 2d)]
+ [InlineData("2**3", 8d)]
+ public void ModernOperatorRulesAreExplicit(string source, double expected)
+ {
+ Assert.Equal(expected, Bind(source).Evaluate());
+ }
+
+ [Fact]
+ public void ScientificNotationWithSignedExponentsIsOneNumberToken()
+ {
+ var expression = Bind("1e-3 + 2.5E+2");
+
+ Assert.Equal(250.001d, expression.Evaluate(), 12);
+ }
+
+ [Fact]
+ public void ImplicitMultiplicationUsesOrdinaryMultiplicationPrecedence()
+ {
+ var expression = Bind("2x + 3(x+1)");
+ var function = expression.BindUnary("x");
+
+ Assert.Equal(13d, function.Evaluate(2d));
+ }
+
+ [Fact]
+ public void VariablesUseCaseInsensitiveStableFirstOccurrenceSlots()
+ {
+ var expression = Bind("b + A*b + a");
+
+ Assert.Equal(2, expression.Variables.Count);
+ Assert.Equal("b", expression.Variables[0].Name);
+ Assert.Equal(0, expression.Variables[0].Slot);
+ Assert.Equal("A", expression.Variables[1].Name);
+ Assert.Equal(1, expression.Variables[1].Slot);
+ Assert.Equal(11d, expression.Evaluate(2d, 3d));
+ Assert.Equal(11d, expression.Evaluate(new Dictionary
+ {
+ ["B"] = 2d,
+ ["a"] = 3d
+ }));
+ }
+
+ [Fact]
+ public void StandardSymbolsAreCaseInsensitiveAndExplicit()
+ {
+ var expression = Bind("SIN(PI/2) + log(e)");
+
+ Assert.Equal(2d, expression.Evaluate(), 12);
+ }
+
+ [Fact]
+ public void StandardCatalogCanBeExtendedWithoutReflection()
+ {
+ var symbols = MathSymbolCatalog.Standard.ToBuilder()
+ .AddConstant("g", 9.80665d)
+ .AddFunction("square", x => x * x)
+ .Build();
+
+ var expression = Bind("square(g)", symbols);
+
+ Assert.Equal(9.80665d * 9.80665d, expression.Evaluate(), 12);
+ }
+
+ [Fact]
+ public void UnknownFunctionIsABindingDiagnosticNotAVariable()
+ {
+ var parse = MathParser.Default.Parse("missing(2)");
+ Assert.True(parse.Success, FormatDiagnostics(parse.Diagnostics));
+
+ var binding = MathBinder.Bind(parse.Root!, MathSymbolCatalog.Standard);
+
+ Assert.False(binding.Success);
+ var diagnostic = Assert.Single(binding.Diagnostics);
+ Assert.Equal("MATH300", diagnostic.Code);
+ Assert.Equal(new SourceSpan(0, 7), diagnostic.Span);
+ }
+
+ [Theory]
+ [InlineData(0d, 1d)]
+ [InlineData(1d, 1d)]
+ [InlineData(5d, 120d)]
+ public void FactorialHasSpecifiedIntegerSemantics(double value, double expected)
+ {
+ var expression = Bind(value.ToString(System.Globalization.CultureInfo.InvariantCulture) + "!");
+
+ Assert.Equal(expected, expression.Evaluate());
+ }
+
+ [Fact]
+ public void InvalidFactorialInputProducesNotANumber()
+ {
+ Assert.True(double.IsNaN(Bind("(-1)!").Evaluate()));
+ Assert.True(double.IsNaN(Bind("2.5!").Evaluate()));
+ Assert.Equal(double.PositiveInfinity, Bind("171!").Evaluate());
+ }
+
+ [Fact]
+ public void EmptyInputIsAParseDiagnostic()
+ {
+ var parse = MathParser.Default.Parse(string.Empty);
+
+ Assert.False(parse.Success);
+ var diagnostic = Assert.Single(parse.Diagnostics);
+ Assert.Equal("MATH200", diagnostic.Code);
+ Assert.Equal(new SourceSpan(0, 0), diagnostic.Span);
+ }
+
+ [Fact]
+ public void BitwiseOperatorsAreNotPartOfTheModernLanguage()
+ {
+ var parse = MathParser.Default.Parse("1 << 10");
+
+ Assert.False(parse.Success);
+ Assert.Contains(parse.Diagnostics, diagnostic => diagnostic.Code == "MATH100");
+ }
+
+ [Fact]
+ public void TextualOperatorSubstringsRemainPartOfIdentifiers()
+ {
+ var expression = Bind("model + power_level + negative");
+
+ Assert.Equal(6d, expression.Evaluate(1d, 2d, 3d));
+ }
+
+ [Fact]
+ public void UnaryBindingRejectsOtherVariables()
+ {
+ var expression = Bind("x+y");
+
+ Assert.Throws(() => expression.BindUnary("x"));
+ }
+
+ [Fact]
+ public void ConstantExpressionCanBeUsedAsAUnaryFunction()
+ {
+ var function = Bind("pi").BindUnary("x");
+
+ Assert.Equal(Math.PI, function.Evaluate(123d));
+ }
+
+ private static BoundMathExpression Bind(string source,
+ MathSymbolCatalog? symbols = null)
+ {
+ var parse = MathParser.Default.Parse(source);
+ Assert.True(parse.Success, FormatDiagnostics(parse.Diagnostics));
+
+ var binding = MathBinder.Bind(parse.Root!,
+ symbols ?? MathSymbolCatalog.Standard);
+ Assert.True(binding.Success, FormatDiagnostics(binding.Diagnostics));
+
+ return binding.Expression!;
+ }
+
+ private static string FormatDiagnostics(IEnumerable diagnostics) =>
+ string.Join(Environment.NewLine, diagnostics);
+}
diff --git a/docs/math-expression-redesign/architecture.md b/docs/math-expression-redesign/architecture.md
index 918f967..39ad2cd 100644
--- a/docs/math-expression-redesign/architecture.md
+++ b/docs/math-expression-redesign/architecture.md
@@ -76,7 +76,7 @@ double result = expression.Evaluate(valuesBySlot);
Custom symbols are explicit:
```csharp
-MathSymbolCatalog symbols = MathSymbolCatalog.CreateBuilder()
+MathSymbolCatalog symbols = MathSymbolCatalog.Standard.ToBuilder()
.AddConstant("g", 9.80665)
.AddFunction("square", x => x * x)
.Build();
@@ -170,4 +170,3 @@ The existing public classes remain untouched in the experimental slice. Later mi
targets migrate first.
No types named `ScriptControl`, `Surface`, or `Refresh` are added to the modern API.
-
From 2844e9489a35bee9cb2383902fdd3dffbb11978a Mon Sep 17 00:00:00 2001
From: "Olof Lagerkvist (LTRData)"
Date: Thu, 3 Sep 2026 18:02:48 +0200
Subject: [PATCH 03/12] Prototype parser-independent function plotting
---
.../FunctionPlotting/Geometry.cs | 290 ++++++++++++++++++
.../FunctionPlotting/NumericRange.cs | 59 ++++
.../FunctionPlotting/Sampling.cs | 161 ++++++++++
.../LTRData.FunctionPlotting.csproj | 8 +
Library.slnx | 1 +
Tests/FunctionPlottingTests.cs | 141 +++++++++
Tests/Tests.csproj | 1 +
docs/math-expression-redesign/README.md | 1 -
.../language-specification.md | 1 -
.../legacy-behavior-classification.md | 1 -
docs/math-expression-redesign/usage-corpus.md | 1 -
11 files changed, 661 insertions(+), 4 deletions(-)
create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs
create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs
create mode 100644 LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs
create mode 100644 LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj
create mode 100644 Tests/FunctionPlottingTests.cs
diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs
new file mode 100644
index 0000000..f3dbdaa
--- /dev/null
+++ b/LTRData.FunctionPlotting/FunctionPlotting/Geometry.cs
@@ -0,0 +1,290 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.FunctionPlotting;
+
+#pragma warning disable CS1591
+
+public struct CanvasSize : IEquatable
+{
+ public CanvasSize(double width, double height)
+ {
+ if (!NumericRange.IsFinite(width) || width <= 0d)
+ {
+ throw new ArgumentOutOfRangeException(nameof(width),
+ "Canvas width must be finite and positive.");
+ }
+
+ if (!NumericRange.IsFinite(height) || height <= 0d)
+ {
+ throw new ArgumentOutOfRangeException(nameof(height),
+ "Canvas height must be finite and positive.");
+ }
+
+ Width = width;
+ Height = height;
+ }
+
+ public double Width { get; }
+
+ public double Height { get; }
+
+ internal bool IsValid => NumericRange.IsFinite(Width) && Width > 0d &&
+ NumericRange.IsFinite(Height) && Height > 0d;
+
+ public bool Equals(CanvasSize other) =>
+ Width.Equals(other.Width) && Height.Equals(other.Height);
+
+ public override bool Equals(object? obj) => obj is CanvasSize other && Equals(other);
+
+ public override int GetHashCode() =>
+ unchecked((Width.GetHashCode() * 397) ^ Height.GetHashCode());
+}
+
+public sealed class PlotViewport
+{
+ public PlotViewport(NumericRange xRange, NumericRange yRange, CanvasSize canvas)
+ {
+ if (!xRange.IsValid)
+ {
+ throw new ArgumentException("A valid X range is required.", nameof(xRange));
+ }
+
+ if (!yRange.IsValid)
+ {
+ throw new ArgumentException("A valid Y range is required.", nameof(yRange));
+ }
+
+ if (!canvas.IsValid)
+ {
+ throw new ArgumentException("A valid canvas size is required.", nameof(canvas));
+ }
+
+ XRange = xRange;
+ YRange = yRange;
+ Canvas = canvas;
+ }
+
+ public NumericRange XRange { get; }
+
+ public NumericRange YRange { get; }
+
+ public CanvasSize Canvas { get; }
+}
+
+public struct CanvasPoint : IEquatable
+{
+ public CanvasPoint(double x, double y)
+ {
+ X = x;
+ Y = y;
+ }
+
+ public double X { get; }
+
+ public double Y { get; }
+
+ public bool Equals(CanvasPoint other) => X.Equals(other.X) && Y.Equals(other.Y);
+
+ public override bool Equals(object? obj) => obj is CanvasPoint other && Equals(other);
+
+ public override int GetHashCode() =>
+ unchecked((X.GetHashCode() * 397) ^ Y.GetHashCode());
+}
+
+public sealed class Polyline
+{
+ internal Polyline(IEnumerable points)
+ {
+ Points = new List(points).AsReadOnly();
+ }
+
+ public ReadOnlyCollection Points { get; }
+}
+
+public sealed class CurveGeometry
+{
+ internal CurveGeometry(IEnumerable polylines)
+ {
+ Polylines = new List(polylines).AsReadOnly();
+ }
+
+ public ReadOnlyCollection Polylines { get; }
+}
+
+public static class CartesianTransform
+{
+ public static CanvasPoint ToCanvas(double x, double y, PlotViewport viewport)
+ {
+ if (viewport is null)
+ {
+ throw new ArgumentNullException(nameof(viewport));
+ }
+
+ return new CanvasPoint(
+ (x - viewport.XRange.Minimum) * viewport.Canvas.Width /
+ viewport.XRange.Length,
+ viewport.Canvas.Height -
+ (y - viewport.YRange.Minimum) * viewport.Canvas.Height /
+ viewport.YRange.Length);
+ }
+
+ public static CanvasPoint ToData(double canvasX, double canvasY,
+ PlotViewport viewport)
+ {
+ if (viewport is null)
+ {
+ throw new ArgumentNullException(nameof(viewport));
+ }
+
+ return new CanvasPoint(
+ canvasX * viewport.XRange.Length / viewport.Canvas.Width +
+ viewport.XRange.Minimum,
+ (viewport.Canvas.Height - canvasY) * viewport.YRange.Length /
+ viewport.Canvas.Height + viewport.YRange.Minimum);
+ }
+}
+
+public static class CurveGeometryBuilder
+{
+ public static CurveGeometry Build(SampleSeries series, PlotViewport viewport)
+ {
+ if (series is null)
+ {
+ throw new ArgumentNullException(nameof(series));
+ }
+
+ if (viewport is null)
+ {
+ throw new ArgumentNullException(nameof(viewport));
+ }
+
+ var pointLists = new List>();
+ List? current = null;
+
+ for (var index = 1; index < series.Count; index++)
+ {
+ var first = series[index - 1];
+ var second = series[index];
+
+ if (first.Status != FunctionSampleStatus.Finite ||
+ second.Status != FunctionSampleStatus.Finite)
+ {
+ current = null;
+ continue;
+ }
+
+ var x0 = first.X;
+ var y0 = first.Y;
+ var x1 = second.X;
+ var y1 = second.Y;
+
+ if (!TryClip(viewport.XRange, viewport.YRange,
+ ref x0, ref y0, ref x1, ref y1))
+ {
+ current = null;
+ continue;
+ }
+
+ var start = CartesianTransform.ToCanvas(x0, y0, viewport);
+ var end = CartesianTransform.ToCanvas(x1, y1, viewport);
+
+ if (current is null || !NearlyEqual(current[current.Count - 1], start))
+ {
+ current = new List { start };
+ pointLists.Add(current);
+ }
+
+ if (!NearlyEqual(current[current.Count - 1], end))
+ {
+ current.Add(end);
+ }
+ }
+
+ var polylines = new List(pointLists.Count);
+
+ foreach (var points in pointLists)
+ {
+ if (points.Count >= 2)
+ {
+ polylines.Add(new Polyline(points));
+ }
+ }
+
+ return new CurveGeometry(polylines);
+ }
+
+ private static bool TryClip(NumericRange xRange, NumericRange yRange,
+ ref double x0, ref double y0, ref double x1, ref double y1)
+ {
+ var deltaX = x1 - x0;
+ var deltaY = y1 - y0;
+ var start = 0d;
+ var end = 1d;
+
+ if (!ClipTest(-deltaX, x0 - xRange.Minimum, ref start, ref end) ||
+ !ClipTest(deltaX, xRange.Maximum - x0, ref start, ref end) ||
+ !ClipTest(-deltaY, y0 - yRange.Minimum, ref start, ref end) ||
+ !ClipTest(deltaY, yRange.Maximum - y0, ref start, ref end))
+ {
+ return false;
+ }
+
+ var originalX = x0;
+ var originalY = y0;
+
+ x1 = originalX + end * deltaX;
+ y1 = originalY + end * deltaY;
+ x0 = originalX + start * deltaX;
+ y0 = originalY + start * deltaY;
+
+ return true;
+ }
+
+ private static bool ClipTest(double direction, double distance,
+ ref double start, ref double end)
+ {
+ if (direction == 0d)
+ {
+ return distance >= 0d;
+ }
+
+ var ratio = distance / direction;
+
+ if (direction < 0d)
+ {
+ if (ratio > end)
+ {
+ return false;
+ }
+
+ if (ratio > start)
+ {
+ start = ratio;
+ }
+ }
+ else
+ {
+ if (ratio < start)
+ {
+ return false;
+ }
+
+ if (ratio < end)
+ {
+ end = ratio;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool NearlyEqual(CanvasPoint left, CanvasPoint right)
+ {
+ const double tolerance = 1e-10;
+
+ return Math.Abs(left.X - right.X) <= tolerance &&
+ Math.Abs(left.Y - right.Y) <= tolerance;
+ }
+}
diff --git a/LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs b/LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs
new file mode 100644
index 0000000..2d0da0a
--- /dev/null
+++ b/LTRData.FunctionPlotting/FunctionPlotting/NumericRange.cs
@@ -0,0 +1,59 @@
+using System;
+
+namespace LTRData.FunctionPlotting;
+
+#pragma warning disable CS1591
+
+public struct NumericRange : IEquatable
+{
+ public NumericRange(double minimum, double maximum)
+ {
+ if (!IsFinite(minimum))
+ {
+ throw new ArgumentOutOfRangeException(nameof(minimum),
+ "Range minimum must be finite.");
+ }
+
+ if (!IsFinite(maximum))
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximum),
+ "Range maximum must be finite.");
+ }
+
+ if (maximum <= minimum)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximum),
+ "Range maximum must be greater than its minimum.");
+ }
+
+ Minimum = minimum;
+ Maximum = maximum;
+ }
+
+ public double Minimum { get; }
+
+ public double Maximum { get; }
+
+ public double Length => Maximum - Minimum;
+
+ internal bool IsValid => IsFinite(Minimum) && IsFinite(Maximum) && Maximum > Minimum;
+
+ public bool Contains(double value) => value >= Minimum && value <= Maximum;
+
+ public bool Equals(NumericRange other) =>
+ Minimum.Equals(other.Minimum) && Maximum.Equals(other.Maximum);
+
+ public override bool Equals(object? obj) => obj is NumericRange other && Equals(other);
+
+ public override int GetHashCode() =>
+ unchecked((Minimum.GetHashCode() * 397) ^ Maximum.GetHashCode());
+
+ public override string ToString() => $"{Minimum}..{Maximum}";
+
+ public static bool operator ==(NumericRange left, NumericRange right) => left.Equals(right);
+
+ public static bool operator !=(NumericRange left, NumericRange right) => !left.Equals(right);
+
+ internal static bool IsFinite(double value) =>
+ !double.IsNaN(value) && !double.IsInfinity(value);
+}
diff --git a/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs
new file mode 100644
index 0000000..9523653
--- /dev/null
+++ b/LTRData.FunctionPlotting/FunctionPlotting/Sampling.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.FunctionPlotting;
+
+#pragma warning disable CS1591
+
+public delegate double ScalarFunction(double x);
+
+public enum FunctionSampleStatus
+{
+ Finite,
+ NotANumber,
+ PositiveInfinity,
+ NegativeInfinity,
+ EvaluationError
+}
+
+public struct FunctionSample : IEquatable
+{
+ public FunctionSample(double x, double y, FunctionSampleStatus status)
+ {
+ X = x;
+ Y = y;
+ Status = status;
+ }
+
+ public double X { get; }
+
+ public double Y { get; }
+
+ public FunctionSampleStatus Status { get; }
+
+ public bool Equals(FunctionSample other) =>
+ X.Equals(other.X) && Y.Equals(other.Y) && Status == other.Status;
+
+ public override bool Equals(object? obj) => obj is FunctionSample other && Equals(other);
+
+ public override int GetHashCode() =>
+ unchecked((((X.GetHashCode() * 397) ^ Y.GetHashCode()) * 397) ^
+ (int)Status);
+
+ public static bool operator ==(FunctionSample left, FunctionSample right) =>
+ left.Equals(right);
+
+ public static bool operator !=(FunctionSample left, FunctionSample right) =>
+ !left.Equals(right);
+}
+
+public sealed class SampleSeries
+{
+ private readonly ReadOnlyCollection samples;
+
+ public SampleSeries(IEnumerable samples)
+ {
+ if (samples is null)
+ {
+ throw new ArgumentNullException(nameof(samples));
+ }
+
+ this.samples = new List(samples).AsReadOnly();
+ }
+
+ internal SampleSeries(FunctionSample[] samples)
+ {
+ this.samples = Array.AsReadOnly(samples);
+ }
+
+ public ReadOnlyCollection Samples => samples;
+
+ public int Count => samples.Count;
+
+ public FunctionSample this[int index] => samples[index];
+}
+
+public static class FunctionSampler
+{
+ public static SampleSeries Sample(ScalarFunction function, NumericRange domain,
+ int sampleCount)
+ {
+ if (function is null)
+ {
+ throw new ArgumentNullException(nameof(function));
+ }
+
+ if (!domain.IsValid)
+ {
+ throw new ArgumentException("A valid sampling domain is required.",
+ nameof(domain));
+ }
+
+ if (sampleCount < 2)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sampleCount),
+ "At least two samples are required.");
+ }
+
+ var samples = new FunctionSample[sampleCount];
+ var step = domain.Length / (sampleCount - 1);
+
+ for (var index = 0; index < samples.Length; index++)
+ {
+ var x = index == samples.Length - 1
+ ? domain.Maximum
+ : domain.Minimum + step * index;
+
+ double y;
+ FunctionSampleStatus status;
+
+ try
+ {
+ y = function(x);
+ status = Classify(y);
+ }
+ catch (Exception exception)
+ {
+ if (IsFatal(exception))
+ {
+ throw;
+ }
+
+ y = double.NaN;
+ status = FunctionSampleStatus.EvaluationError;
+ }
+
+ samples[index] = new FunctionSample(x, y, status);
+ }
+
+ return new SampleSeries(samples);
+ }
+
+ private static FunctionSampleStatus Classify(double value)
+ {
+ if (double.IsNaN(value))
+ {
+ return FunctionSampleStatus.NotANumber;
+ }
+
+ if (double.IsPositiveInfinity(value))
+ {
+ return FunctionSampleStatus.PositiveInfinity;
+ }
+
+ if (double.IsNegativeInfinity(value))
+ {
+ return FunctionSampleStatus.NegativeInfinity;
+ }
+
+ return FunctionSampleStatus.Finite;
+ }
+
+ private static bool IsFatal(Exception exception) =>
+ exception is OutOfMemoryException ||
+ exception is StackOverflowException ||
+ exception is AccessViolationException ||
+ exception is AppDomainUnloadedException ||
+ exception is BadImageFormatException ||
+ exception is CannotUnloadAppDomainException ||
+ exception is InvalidProgramException;
+}
diff --git a/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj
new file mode 100644
index 0000000..4f619fc
--- /dev/null
+++ b/LTRData.FunctionPlotting/LTRData.FunctionPlotting.csproj
@@ -0,0 +1,8 @@
+
+
+
+ 0.1.0
+ $(Version)
+
+
+
diff --git a/Library.slnx b/Library.slnx
index 12cb74a..ca238f3 100644
--- a/Library.slnx
+++ b/Library.slnx
@@ -7,6 +7,7 @@
+
diff --git a/Tests/FunctionPlottingTests.cs b/Tests/FunctionPlottingTests.cs
new file mode 100644
index 0000000..8d1510d
--- /dev/null
+++ b/Tests/FunctionPlottingTests.cs
@@ -0,0 +1,141 @@
+using LTRData.FunctionPlotting;
+using LTRData.MathExpression;
+using System;
+using Xunit;
+
+namespace LTRData.Extensions.Tests;
+
+[Trait("Contract", "Modern")]
+public class FunctionPlottingTests
+{
+ [Fact]
+ public void FixedCountSamplingIncludesBothDomainEndpoints()
+ {
+ var samples = FunctionSampler.Sample(x => x * 2d,
+ new NumericRange(-1d, 1d), 5);
+
+ Assert.Equal(5, samples.Count);
+ Assert.Equal(-1d, samples[0].X);
+ Assert.Equal(-0.5d, samples[1].X);
+ Assert.Equal(0d, samples[2].X);
+ Assert.Equal(0.5d, samples[3].X);
+ Assert.Equal(1d, samples[4].X);
+ Assert.Equal(2d, samples[4].Y);
+ }
+
+ [Fact]
+ public void SamplingClassifiesEveryNonFiniteAndErrorState()
+ {
+ var samples = FunctionSampler.Sample(x =>
+ {
+ if (x == -2d)
+ {
+ return double.NaN;
+ }
+
+ if (x == -1d)
+ {
+ return double.NegativeInfinity;
+ }
+
+ if (x == 0d)
+ {
+ throw new ArithmeticException("Deliberate sample failure.");
+ }
+
+ if (x == 1d)
+ {
+ return double.PositiveInfinity;
+ }
+
+ return x;
+ }, new NumericRange(-2d, 2d), 5);
+
+ Assert.Equal(FunctionSampleStatus.NotANumber, samples[0].Status);
+ Assert.Equal(FunctionSampleStatus.NegativeInfinity, samples[1].Status);
+ Assert.Equal(FunctionSampleStatus.EvaluationError, samples[2].Status);
+ Assert.Equal(FunctionSampleStatus.PositiveInfinity, samples[3].Status);
+ Assert.Equal(FunctionSampleStatus.Finite, samples[4].Status);
+ }
+
+ [Fact]
+ public void InvalidSamplesSplitCurveGeometry()
+ {
+ var samples = new SampleSeries(new[]
+ {
+ new FunctionSample(-2d, -2d, FunctionSampleStatus.Finite),
+ new FunctionSample(-1d, -1d, FunctionSampleStatus.Finite),
+ new FunctionSample(0d, double.NaN, FunctionSampleStatus.NotANumber),
+ new FunctionSample(1d, 1d, FunctionSampleStatus.Finite),
+ new FunctionSample(2d, 2d, FunctionSampleStatus.Finite)
+ });
+
+ var geometry = CurveGeometryBuilder.Build(samples,
+ new PlotViewport(new NumericRange(-2d, 2d),
+ new NumericRange(-2d, 2d), new CanvasSize(400d, 200d)));
+
+ Assert.Equal(2, geometry.Polylines.Count);
+ Assert.Equal(2, geometry.Polylines[0].Points.Count);
+ Assert.Equal(2, geometry.Polylines[1].Points.Count);
+ }
+
+ [Fact]
+ public void ClippingKeepsCrossingSegmentWithBothEndpointsOutside()
+ {
+ var samples = new SampleSeries(new[]
+ {
+ new FunctionSample(-1d, -2d, FunctionSampleStatus.Finite),
+ new FunctionSample(1d, 2d, FunctionSampleStatus.Finite)
+ });
+
+ var geometry = CurveGeometryBuilder.Build(samples,
+ new PlotViewport(new NumericRange(-1d, 1d),
+ new NumericRange(-1d, 1d), new CanvasSize(200d, 100d)));
+
+ var line = Assert.Single(geometry.Polylines);
+ Assert.Equal(2, line.Points.Count);
+ Assert.Equal(50d, line.Points[0].X, 12);
+ Assert.Equal(100d, line.Points[0].Y, 12);
+ Assert.Equal(150d, line.Points[1].X, 12);
+ Assert.Equal(0d, line.Points[1].Y, 12);
+ }
+
+ [Fact]
+ public void CartesianTransformRoundTripsContinuousCoordinates()
+ {
+ var viewport = new PlotViewport(new NumericRange(-10d, 10d),
+ new NumericRange(-4d, 4d), new CanvasSize(1200d, 720d));
+
+ var canvas = CartesianTransform.ToCanvas(2.5d, -1.25d, viewport);
+ var data = CartesianTransform.ToData(canvas.X, canvas.Y, viewport);
+
+ Assert.Equal(2.5d, data.X, 12);
+ Assert.Equal(-1.25d, data.Y, 12);
+ }
+
+ [Fact]
+ public void ExpressionEvaluationComposesWithParserIndependentSampler()
+ {
+ var parse = MathParser.Default.Parse("sin(x)");
+ Assert.True(parse.Success);
+
+ var binding = MathBinder.Bind(parse.Root!, MathSymbolCatalog.Standard);
+ Assert.True(binding.Success);
+
+ var function = binding.Expression!.BindUnary("x");
+ var samples = FunctionSampler.Sample(function.Evaluate,
+ new NumericRange(0d, Math.PI), 3);
+
+ Assert.Equal(0d, samples[0].Y, 12);
+ Assert.Equal(1d, samples[1].Y, 12);
+ Assert.Equal(0d, samples[2].Y, 12);
+ }
+
+ [Fact]
+ public void InvalidRangesAndSampleCountsAreRejected()
+ {
+ Assert.Throws(() => new NumericRange(1d, 1d));
+ Assert.Throws(() =>
+ FunctionSampler.Sample(x => x, new NumericRange(0d, 1d), 1));
+ }
+}
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 71803a6..9672e30 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -37,6 +37,7 @@
+
diff --git a/docs/math-expression-redesign/README.md b/docs/math-expression-redesign/README.md
index dfadbba..a30786b 100644
--- a/docs/math-expression-redesign/README.md
+++ b/docs/math-expression-redesign/README.md
@@ -81,4 +81,3 @@ After this branch is reviewed and the language decisions are accepted:
2. add one SkiaSharp edge package containing plot and diagram renderers plus PNG encoding;
3. integrate both PNG paths into `ltr-data.se` with input/resource limits and Linux tests;
4. only then evaluate derivative, integral, adaptive sampling, and Windows migration.
-
diff --git a/docs/math-expression-redesign/language-specification.md b/docs/math-expression-redesign/language-specification.md
index cec13b2..8ae34d3 100644
--- a/docs/math-expression-redesign/language-specification.md
+++ b/docs/math-expression-redesign/language-specification.md
@@ -163,4 +163,3 @@ The initial modern language omits:
- assignments, comparisons, booleans, conditionals, and statements.
These can be reconsidered as language features only with a use case and explicit semantics.
-
diff --git a/docs/math-expression-redesign/legacy-behavior-classification.md b/docs/math-expression-redesign/legacy-behavior-classification.md
index 0afb5f5..f19e565 100644
--- a/docs/math-expression-redesign/legacy-behavior-classification.md
+++ b/docs/math-expression-redesign/legacy-behavior-classification.md
@@ -85,4 +85,3 @@ New tests use explicit naming/categories:
existing parser only.
- A legacy characterization failure is evidence to revisit documentation; it must not be
“fixed” by changing the new parser to reproduce the old result.
-
diff --git a/docs/math-expression-redesign/usage-corpus.md b/docs/math-expression-redesign/usage-corpus.md
index 54fc4fa..aea4572 100644
--- a/docs/math-expression-redesign/usage-corpus.md
+++ b/docs/math-expression-redesign/usage-corpus.md
@@ -122,4 +122,3 @@ renders it as an image.
Expression-diagram design is therefore a new requirement, not a renderer port. If another
historical repository is identified, it should be reviewed as additional evidence and
classified rather than adopted automatically.
-
From d178c3782607f4d05774ec6c0f24e0e470857b1e Mon Sep 17 00:00:00 2001
From: "Olof Lagerkvist (LTRData)"
Date: Tue, 8 Sep 2026 12:47:29 +0200
Subject: [PATCH 04/12] Add portable expression diagram content and measured
tree layout
---
.../Diagrams/ExpressionDiagram.cs | 166 +++++++++++++++++
.../Diagrams/TreeDiagramLayout.cs | 175 ++++++++++++++++++
.../LTRData.MathExpression.csproj | 5 +-
Tests/ExpressionDiagramTests.cs | 96 ++++++++++
Tests/ModernExpressionCorpusTests.cs | 43 +++++
5 files changed, 483 insertions(+), 2 deletions(-)
create mode 100644 LTRData.MathExpression/Diagrams/ExpressionDiagram.cs
create mode 100644 LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs
create mode 100644 Tests/ExpressionDiagramTests.cs
create mode 100644 Tests/ModernExpressionCorpusTests.cs
diff --git a/LTRData.MathExpression/Diagrams/ExpressionDiagram.cs b/LTRData.MathExpression/Diagrams/ExpressionDiagram.cs
new file mode 100644
index 0000000..2506076
--- /dev/null
+++ b/LTRData.MathExpression/Diagrams/ExpressionDiagram.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.MathExpression.Diagrams;
+
+/// An ordered child occurrence in an expression diagram.
+public sealed class ExpressionDiagramChild
+{
+ internal ExpressionDiagramChild(int nodeId, string role)
+ {
+ NodeId = nodeId;
+ Role = role;
+ }
+
+ /// Index of the child in .
+ public int NodeId { get; }
+
+ /// The operand or argument's role, independent of its visual position.
+ public string Role { get; }
+}
+
+/// Backend-independent content for one occurrence of a syntax node.
+public sealed class ExpressionDiagramNode
+{
+ internal ExpressionDiagramNode(int id, MathSyntax syntax, string label,
+ IList children)
+ {
+ Id = id;
+ Kind = syntax.Kind;
+ Span = syntax.Span;
+ Label = label;
+ Children = new List(children).AsReadOnly();
+ }
+
+ /// Stable zero-based preorder index within this diagram.
+ public int Id { get; }
+ /// Original syntax category.
+ public MathSyntaxKind Kind { get; }
+ /// Original source location.
+ public SourceSpan Span { get; }
+ /// Node label, without renderer markup.
+ public string Label { get; }
+ /// Children in mathematical operand/argument order.
+ public ReadOnlyCollection Children { get; }
+}
+
+/// Immutable expression-tree content, independent of layout and graphics.
+public sealed class ExpressionDiagram
+{
+ private ExpressionDiagram(List nodes) => Nodes = nodes.AsReadOnly();
+
+ /// Nodes in preorder; the root is always node zero.
+ public ReadOnlyCollection Nodes { get; }
+
+ ///
+ /// Projects syntax into a tree. Names and numeric spelling are preserved; operators
+ /// use conventional labels. Parenthesis nodes may be retained explicitly. This is
+ /// a syntax projection, not algebraic simplification or a bound semantic diagram.
+ ///
+ /// The root syntax.
+ /// Whether to show grouping as separate nodes.
+ /// Maximum visited syntax occurrences, including omitted grouping.
+ public static ExpressionDiagram FromSyntax(MathSyntax syntax,
+ bool includeParentheses = false, int maxNodes = 4096)
+ {
+#if NET6_0_OR_GREATER
+ ArgumentNullException.ThrowIfNull(syntax);
+#else
+ if (syntax is null) throw new ArgumentNullException(nameof(syntax));
+#endif
+ if (maxNodes < 1) throw new ArgumentOutOfRangeException(nameof(maxNodes));
+
+ var syntaxNodes = new List();
+ var labels = new List();
+ var children = new List>();
+ var pending = new Stack();
+ pending.Push(new PendingNode(syntax, -1, string.Empty));
+ var visited = 0;
+
+ // Iterative traversal also handles deeply nested, manually constructed syntax.
+ while (pending.Count != 0)
+ {
+ var next = pending.Pop();
+ var current = next.Syntax;
+ if (++visited > maxNodes)
+ throw new ArgumentException("The expression exceeds the diagram node limit.", nameof(syntax));
+
+ if (!includeParentheses && current is ParenthesizedSyntax grouping)
+ {
+ pending.Push(new PendingNode(grouping.Expression, next.Parent, next.Role));
+ continue;
+ }
+
+ var id = syntaxNodes.Count;
+ syntaxNodes.Add(current);
+ children.Add(new List());
+ if (next.Parent >= 0)
+ children[next.Parent].Add(new ExpressionDiagramChild(id, next.Role));
+
+ switch (current)
+ {
+ case NumberSyntax number:
+ labels.Add(number.Lexeme);
+ break;
+ case NameSyntax name:
+ labels.Add(name.Name);
+ break;
+ case ParenthesizedSyntax parenthesized:
+ labels.Add("( )");
+ pending.Push(new PendingNode(parenthesized.Expression, id, "expression"));
+ break;
+ case PrefixSyntax prefix:
+ labels.Add(prefix.Operator == MathPrefixOperator.Negate ? "−" : "+");
+ pending.Push(new PendingNode(prefix.Operand, id, "operand"));
+ break;
+ case PostfixSyntax postfix:
+ labels.Add("!");
+ pending.Push(new PendingNode(postfix.Operand, id, "operand"));
+ break;
+ case BinarySyntax binary:
+ labels.Add(BinaryLabel(binary.Operator));
+ pending.Push(new PendingNode(binary.Right, id, "right"));
+ pending.Push(new PendingNode(binary.Left, id, "left"));
+ break;
+ case CallSyntax call:
+ labels.Add(call.Name);
+ for (var i = call.Arguments.Count - 1; i >= 0; i--)
+ pending.Push(new PendingNode(call.Arguments[i], id,
+ "argument " + (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)));
+ break;
+ default:
+ throw new ArgumentException("Unsupported syntax node.", nameof(syntax));
+ }
+ }
+
+ var nodes = new List(syntaxNodes.Count);
+ for (var i = 0; i < syntaxNodes.Count; i++)
+ nodes.Add(new ExpressionDiagramNode(i, syntaxNodes[i], labels[i], children[i]));
+ return new ExpressionDiagram(nodes);
+ }
+
+ private static string BinaryLabel(MathBinaryOperator op) => op switch
+ {
+ MathBinaryOperator.Add => "+",
+ MathBinaryOperator.Subtract => "−",
+ MathBinaryOperator.Multiply => "×",
+ MathBinaryOperator.Divide => "÷",
+ MathBinaryOperator.Remainder => "mod",
+ MathBinaryOperator.Power => "^",
+ _ => throw new ArgumentOutOfRangeException(nameof(op))
+ };
+
+ private sealed class PendingNode
+ {
+ public PendingNode(MathSyntax syntax, int parent, string role)
+ {
+ Syntax = syntax;
+ Parent = parent;
+ Role = role;
+ }
+ public MathSyntax Syntax { get; }
+ public int Parent { get; }
+ public string Role { get; }
+ }
+}
diff --git a/LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs b/LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs
new file mode 100644
index 0000000..f3dc5ee
--- /dev/null
+++ b/LTRData.MathExpression/Diagrams/TreeDiagramLayout.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace LTRData.MathExpression.Diagrams;
+
+/// A finite positive measured node or diagram size, in continuous canvas units.
+public struct DiagramSize
+{
+ /// Creates a validated measured size.
+ public DiagramSize(double width, double height)
+ {
+ if (!IsPositiveFinite(width)) throw new ArgumentOutOfRangeException(nameof(width));
+ if (!IsPositiveFinite(height)) throw new ArgumentOutOfRangeException(nameof(height));
+ Width = width;
+ Height = height;
+ }
+ /// Width in canvas units.
+ public double Width { get; }
+ /// Height in canvas units.
+ public double Height { get; }
+ internal static bool IsPositiveFinite(double value) => value > 0 && !double.IsInfinity(value);
+}
+
+/// A point in a diagram's continuous coordinate space.
+public struct DiagramPoint
+{
+ /// Creates a point.
+ public DiagramPoint(double x, double y) { X = x; Y = y; }
+ /// Horizontal coordinate.
+ public double X { get; }
+ /// Vertical coordinate, increasing downward.
+ public double Y { get; }
+}
+
+/// The rectangle reserved for a measured node.
+public sealed class DiagramNodeLayout
+{
+ internal DiagramNodeLayout(ExpressionDiagramNode node, double x, double y, DiagramSize size)
+ { Node = node; X = x; Y = y; Size = size; }
+ /// Content being positioned.
+ public ExpressionDiagramNode Node { get; }
+ /// Left edge.
+ public double X { get; }
+ /// Top edge.
+ public double Y { get; }
+ /// Measured rectangle size.
+ public DiagramSize Size { get; }
+}
+
+/// A connector from a parent's bottom center to a child's top center.
+public sealed class DiagramConnector
+{
+ internal DiagramConnector(int parentId, ExpressionDiagramChild child,
+ DiagramPoint start, DiagramPoint end)
+ { ParentId = parentId; Child = child; Start = start; End = end; }
+ /// Parent node ID.
+ public int ParentId { get; }
+ /// Child node ID and ordered operand role.
+ public ExpressionDiagramChild Child { get; }
+ /// Start point on the parent.
+ public DiagramPoint Start { get; }
+ /// End point on the child.
+ public DiagramPoint End { get; }
+}
+
+/// Immutable, backend-independent rectangles and connectors for a tree.
+public sealed class TreeDiagramLayout
+{
+ private TreeDiagramLayout(DiagramSize size, List nodes,
+ List connectors)
+ { Size = size; Nodes = nodes.AsReadOnly(); Connectors = connectors.AsReadOnly(); }
+ /// Total extent, including margins.
+ public DiagramSize Size { get; }
+ /// Positioned nodes indexed by node ID.
+ public ReadOnlyCollection Nodes { get; }
+ /// Connectors in parent and child order.
+ public ReadOnlyCollection Connectors { get; }
+
+ ///
+ /// Lays out measured nodes in non-overlapping subtrees. Measurement is supplied by
+ /// the caller's font backend. Runs iteratively in linear time and preserves child order.
+ ///
+ /// Tree content.
+ /// Measured sizes indexed by node ID; copied during layout.
+ /// Space between adjacent subtree extents.
+ /// Space between levels.
+ /// Space around the diagram.
+ public static TreeDiagramLayout Create(ExpressionDiagram diagram, IList nodeSizes,
+ double siblingSpacing = 24, double levelSpacing = 40, double margin = 16)
+ {
+#if NET6_0_OR_GREATER
+ ArgumentNullException.ThrowIfNull(diagram);
+ ArgumentNullException.ThrowIfNull(nodeSizes);
+#else
+ if (diagram is null) throw new ArgumentNullException(nameof(diagram));
+ if (nodeSizes is null) throw new ArgumentNullException(nameof(nodeSizes));
+#endif
+ ValidateSpacing(siblingSpacing, nameof(siblingSpacing));
+ ValidateSpacing(levelSpacing, nameof(levelSpacing));
+ ValidateSpacing(margin, nameof(margin));
+ var count = diagram.Nodes.Count;
+ if (nodeSizes.Count != count)
+ throw new ArgumentException("One measured size per node is required.", nameof(nodeSizes));
+
+ var sizes = new DiagramSize[count];
+ var widths = new double[count];
+ var depth = new int[count];
+ var levelHeights = new double[count];
+ var levelTops = new double[count];
+ var lefts = new double[count];
+ var maxDepth = 0;
+ for (var i = 0; i < count; i++)
+ {
+ var size = nodeSizes[i];
+ if (!DiagramSize.IsPositiveFinite(size.Width) || !DiagramSize.IsPositiveFinite(size.Height))
+ throw new ArgumentException("Node sizes must be finite and positive.", nameof(nodeSizes));
+ sizes[i] = size;
+ levelHeights[depth[i]] = Math.Max(levelHeights[depth[i]], size.Height);
+ maxDepth = Math.Max(maxDepth, depth[i]);
+ foreach (var child in diagram.Nodes[i].Children) depth[child.NodeId] = depth[i] + 1;
+ }
+
+ for (var i = count - 1; i >= 0; i--)
+ {
+ var children = diagram.Nodes[i].Children;
+ var childWidth = 0d;
+ foreach (var child in children) childWidth += widths[child.NodeId];
+ if (children.Count > 1) childWidth += siblingSpacing * (children.Count - 1);
+ widths[i] = Math.Max(sizes[i].Width, childWidth);
+ }
+
+ levelTops[0] = margin;
+ for (var i = 1; i <= maxDepth; i++)
+ levelTops[i] = levelTops[i - 1] + levelHeights[i - 1] + levelSpacing;
+
+ // Constructor detects overflow before any coordinates are given to a backend.
+ var extent = new DiagramSize(widths[0] + 2 * margin,
+ levelTops[maxDepth] + levelHeights[maxDepth] + margin);
+ lefts[0] = margin;
+ var placed = new List(count);
+ for (var i = 0; i < count; i++)
+ {
+ placed.Add(new DiagramNodeLayout(diagram.Nodes[i],
+ lefts[i] + (widths[i] - sizes[i].Width) / 2, levelTops[depth[i]], sizes[i]));
+ var children = diagram.Nodes[i].Children;
+ var total = 0d;
+ foreach (var child in children) total += widths[child.NodeId];
+ if (children.Count > 1) total += siblingSpacing * (children.Count - 1);
+ var nextLeft = lefts[i] + (widths[i] - total) / 2;
+ foreach (var child in children)
+ {
+ lefts[child.NodeId] = nextLeft;
+ nextLeft += widths[child.NodeId] + siblingSpacing;
+ }
+ }
+
+ var connectors = new List(count - 1);
+ foreach (var parent in placed)
+ foreach (var child in parent.Node.Children)
+ {
+ var target = placed[child.NodeId];
+ connectors.Add(new DiagramConnector(parent.Node.Id, child,
+ new DiagramPoint(parent.X + parent.Size.Width / 2, parent.Y + parent.Size.Height),
+ new DiagramPoint(target.X + target.Size.Width / 2, target.Y)));
+ }
+ return new TreeDiagramLayout(extent, placed, connectors);
+ }
+
+ private static void ValidateSpacing(double value, string name)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value) || value < 0)
+ throw new ArgumentOutOfRangeException(name);
+ }
+}
diff --git a/LTRData.MathExpression/LTRData.MathExpression.csproj b/LTRData.MathExpression/LTRData.MathExpression.csproj
index 5e62963..8f9d2fd 100644
--- a/LTRData.MathExpression/LTRData.MathExpression.csproj
+++ b/LTRData.MathExpression/LTRData.MathExpression.csproj
@@ -1,8 +1,9 @@
- 1.0.23
- $(Version)
+ 1.1.0-preview.1
+ 1.1.0
+ $(Version)
diff --git a/Tests/ExpressionDiagramTests.cs b/Tests/ExpressionDiagramTests.cs
new file mode 100644
index 0000000..0e55122
--- /dev/null
+++ b/Tests/ExpressionDiagramTests.cs
@@ -0,0 +1,96 @@
+using LTRData.MathExpression;
+using LTRData.MathExpression.Diagrams;
+using System;
+using System.Linq;
+using Xunit;
+
+namespace LTRData.Extensions.Tests;
+
+public class ExpressionDiagramTests
+{
+ [Fact]
+ public void DiagramPreservesOperandOrderSpellingAndSourceLocations()
+ {
+ var diagram = Diagram("atan2(X, .50) - 2x");
+ Assert.Equal(new[] { "−", "atan2", "X", ".50", "×", "2", "x" },
+ diagram.Nodes.Select(n => n.Label));
+ Assert.Equal(new[] { "left", "right" }, diagram.Nodes[0].Children.Select(c => c.Role));
+ Assert.Equal(new[] { "argument 1", "argument 2" }, diagram.Nodes[1].Children.Select(c => c.Role));
+ Assert.Equal(new SourceSpan(6, 1), diagram.Nodes[2].Span);
+ Assert.Equal(Enumerable.Range(0, diagram.Nodes.Count), diagram.Nodes.Select(n => n.Id));
+ }
+
+ [Fact]
+ public void ParenthesesAreAnExplicitPresentationChoice()
+ {
+ var syntax = MathParser.Default.Parse("((x))").Root!;
+ Assert.Single(ExpressionDiagram.FromSyntax(syntax).Nodes);
+ Assert.Equal(new[] { "( )", "( )", "x" },
+ ExpressionDiagram.FromSyntax(syntax, true).Nodes.Select(n => n.Label));
+ Assert.Throws(() => ExpressionDiagram.FromSyntax(syntax, maxNodes: 2));
+ }
+
+ [Fact]
+ public void LayoutRespectsMeasuredSizesAndKeepsUnbalancedSubtreesApart()
+ {
+ var diagram = Diagram("long_variable + sin(x^2 + max(y, 100))");
+ var sizes = diagram.Nodes.Select((n, i) => new DiagramSize(30 + n.Label.Length * 12, 30 + i % 3 * 10)).ToArray();
+ var layout = TreeDiagramLayout.Create(diagram, sizes);
+ Assert.Equal(diagram.Nodes.Count - 1, layout.Connectors.Count);
+ foreach (var node in layout.Nodes)
+ {
+ Assert.Equal(sizes[node.Node.Id].Width, node.Size.Width);
+ Assert.InRange(node.X, 0, layout.Size.Width - node.Size.Width);
+ Assert.InRange(node.Y, 0, layout.Size.Height - node.Size.Height);
+ foreach (var other in layout.Nodes.Where(n => n.Node.Id > node.Node.Id))
+ Assert.False(node.X < other.X + other.Size.Width && other.X < node.X + node.Size.Width &&
+ node.Y < other.Y + other.Size.Height && other.Y < node.Y + node.Size.Height);
+ var childNodes = node.Node.Children.Select(c => layout.Nodes[c.NodeId]).ToArray();
+ for (var i = 1; i < childNodes.Length; i++) Assert.True(childNodes[i - 1].X < childNodes[i].X);
+ }
+ foreach (var connector in layout.Connectors)
+ {
+ var parent = layout.Nodes[connector.ParentId];
+ var child = layout.Nodes[connector.Child.NodeId];
+ Assert.Equal(parent.Y + parent.Size.Height, connector.Start.Y);
+ Assert.Equal(child.Y, connector.End.Y);
+ Assert.True(connector.End.Y > connector.Start.Y);
+ }
+ sizes[0] = new DiagramSize(1, 1);
+ Assert.NotEqual(1, layout.Nodes[0].Size.Width);
+ }
+
+ [Fact]
+ public void SingleNodeExtentIncludesMargins()
+ {
+ var layout = TreeDiagramLayout.Create(Diagram("x"), new[] { new DiagramSize(40, 30) }, margin: 10);
+ Assert.Equal(60, layout.Size.Width);
+ Assert.Equal(50, layout.Size.Height);
+ Assert.Empty(layout.Connectors);
+ }
+
+ [Fact]
+ public void DeepManualSyntaxDoesNotRequireRecursiveTraversalOrLayout()
+ {
+ MathSyntax syntax = new NameSyntax("x", new SourceSpan(0, 1));
+ for (var i = 0; i < 2000; i++)
+ syntax = new PrefixSyntax(MathPrefixOperator.Negate, new SourceSpan(0, 1), syntax, syntax.Span);
+ var diagram = ExpressionDiagram.FromSyntax(syntax);
+ var layout = TreeDiagramLayout.Create(diagram, Enumerable.Repeat(new DiagramSize(20, 20), diagram.Nodes.Count).ToArray());
+ Assert.Equal(2001, layout.Nodes.Count);
+ Assert.True(layout.Size.Height > 40000);
+ Assert.Throws(() => ExpressionDiagram.FromSyntax(syntax, maxNodes: 2000));
+ }
+
+ [Fact]
+ public void InvalidMeasurementsAndOverflowAreRejectedBeforeRendering()
+ {
+ var diagram = Diagram("x+x");
+ Assert.Throws(() => TreeDiagramLayout.Create(diagram, new DiagramSize[3]));
+ Assert.Throws(() => TreeDiagramLayout.Create(diagram, new[] { new DiagramSize(1, 1) }));
+ Assert.Throws(() => TreeDiagramLayout.Create(diagram,
+ Enumerable.Repeat(new DiagramSize(double.MaxValue, 20), 3).ToArray()));
+ }
+
+ private static ExpressionDiagram Diagram(string source) => ExpressionDiagram.FromSyntax(MathParser.Default.Parse(source).Root!);
+}
diff --git a/Tests/ModernExpressionCorpusTests.cs b/Tests/ModernExpressionCorpusTests.cs
new file mode 100644
index 0000000..38eabd2
--- /dev/null
+++ b/Tests/ModernExpressionCorpusTests.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using LTRData.MathExpression;
+using Xunit;
+
+namespace LTRData.Extensions.Tests;
+
+/// The applicable expressions from the legacy tests, through the modern pipeline.
+public class ModernExpressionCorpusTests
+{
+ public static IEnumerable