Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ input; `Derivation` means it is a different object. A derivative and an antideri
verified — so promoting one means changing that test, and saying in the same change why the rewrite
needs no assumptions. Loosening a tier needs nothing.

**A recording is a scope, not a setting.** `RewriteRecording.Start()` collects the rewrites that
fire while it is open, and costs one thread-static read per rule set — not per node — when nobody
opened one. Anything else added to this layer has to keep that shape: the common path may not pay
for machinery it is not using, and a switch a caller can leave on is a way of making it pay.

**Say "no answer" with `null`, here too.** `ApplyCore` returning `null` is the layer's way of saying
"I could not settle this", and it is the one place where the new layer is *more* honest than the 1.x
method it backs: `Transformation.Integration` has no answer where `Entity.Integrate` returns an
Expand Down
117 changes: 117 additions & 0 deletions Sources/AngouriMath/Core/Transformations/RewriteRecording.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//
// Copyright (c) 2019-2026 Angouri.
// AngouriMath is licensed under MIT.
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
// Website: https://am.angouri.org.
//

using System;

namespace AngouriMath.Core.Transformations
{
/// <summary>
/// Collects the rewrites that fire while it is open, so that an answer can be asked how
/// it was reached rather than only what it is.
/// </summary>
/// <remarks>
/// <para>
/// Off unless asked for, and off is free: with no recording open, applying a rule set
/// costs one thread-static read more than it did before — per rule set, not per node —
/// and allocates nothing. That is the condition
/// <a href="https://github.com/asc-community/AngouriMath/issues/746">#746</a> puts on
/// every layer above the tree, and it is why this is a scope rather than a setting that
/// something might leave on.
/// </para>
/// <para>
/// Per thread, like <see cref="MathS.Settings"/>: a recording opened on one thread does
/// not see rewrites on another, so a parallel caller records its own work and nobody
/// else's.
/// </para>
/// <para>
/// <b>A synchronous scope, and it has to be.</b> Do not <see langword="await"/> inside
/// one. The recording follows the thread rather than the call, so yielding lets whatever
/// else that thread picks up be recorded as if it were yours, and the continuation may
/// come back on a different thread than the one holding it. Closing is written to
/// survive both — a recording closed elsewhere leaves the opening thread pointing at
/// something that ignores what it is handed rather than at a list that keeps growing —
/// but what gets collected in between is not something this can make meaningful. Record
/// around synchronous work, and await outside the scope.
/// </para>
/// <para>
/// <b>What this is not.</b> It records rewrites — which is what
/// <a href="https://github.com/asc-community/AngouriMath/issues/28">#28</a> asks for —
/// and not everything <see cref="Entity.Simplify(int)"/> does. Simplification also
/// expands, factorises, divides polynomials, minimises boolean expressions and then
/// *chooses* among the candidates by a complexity metric; the steps below are the
/// rewrites, in the order they fired, across every candidate that was generated,
/// including the ones that lost. Reading them as a route from the input to the returned
/// answer would be reading in something that is not there.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// using AngouriMath;
/// using AngouriMath.Core.Transformations;
///
/// using var recording = RewriteRecording.Start();
/// var simplified = ((Entity)"a / (b / c)").Simplify();
/// foreach (var step in recording.Steps)
/// Console.WriteLine(step);
/// </code>
/// </example>
public sealed class RewriteRecording : IDisposable
{
[ThreadStatic]
private static RewriteRecording? current;

private readonly RewriteRecording? enclosing;
private readonly List<RewriteStep> steps = new();
private bool closed;

private RewriteRecording(RewriteRecording? enclosing) => this.enclosing = enclosing;

/// <summary>
/// Opens a recording on this thread. Dispose it to close it — the value is meant to
/// be held in a <see langword="using"/>, as <see cref="MathS.Settings"/> values are.
/// </summary>
/// <remarks>
/// Recordings nest: opening one inside another hides the outer one until the inner
/// is disposed, so a caller who records a subcomputation does not silently add its
/// steps to somebody else's list.
/// </remarks>
public static RewriteRecording Start() => current = new RewriteRecording(current);

/// <summary>
/// The rewrites that fired while this recording was open, in the order they fired.
/// </summary>
public IReadOnlyList<RewriteStep> Steps => steps;

/// <summary>Closes the recording. <see cref="Steps"/> stays readable afterwards.</summary>
public void Dispose()
{
if (closed)
return;
closed = true;
// Only this thread's chain is ours to put back. Disposing on a thread other than
// the one that opened it -- which is what awaiting inside a recording leads to --
// would otherwise clear whatever that thread was recording into, and leave the
// opening thread pointing at a closed recording. Add ignores that case, so the
// worst a stray reference can do is nothing.
if (ReferenceEquals(current, this))
current = enclosing;
}

/// <summary>
/// The recording to report to, or <see langword="null"/> where nobody is listening —
/// which is the case this has to stay free for.
/// </summary>
internal static RewriteRecording? Current => current;

internal void Add(RewriteRuleSet ruleSet, Entity before, Entity after)
{
if (closed)
return;
steps.Add(new RewriteStep(ruleSet, before, after));
}
}
}
31 changes: 28 additions & 3 deletions Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,34 @@ internal RewriteRuleSet(string name, string description, TransformationRelation
/// </summary>
/// <param name="expression">The expression to rewrite.</param>
public Entity ApplyOnce(Entity expression)
=> expression is null
? throw new ArgumentNullException(nameof(expression))
: expression.Replace(rules);
{
if (expression is null)
throw new ArgumentNullException(nameof(expression));

// One thread-static read, per application rather than per node, and nothing
// allocated: the ordinary path must not pay for a recording nobody opened.
var recording = RewriteRecording.Current;
return recording is null
? expression.Replace(rules)
: ApplyOnceRecording(expression, recording);
}

/// <remarks>
/// A method of its own, and that is the whole reason for it. The closure below
/// captures the rule set and the recording, and the compiler allocates the object
/// holding them where they come into scope -- so writing this inline in
/// <see cref="ApplyOnce(Entity)"/> put one allocation on every rewrite in the
/// library whether or not anybody was recording. Measured: it cost `Simplify` a
/// fifth of its allocation on the benchmark expressions.
/// </remarks>
private Entity ApplyOnceRecording(Entity expression, RewriteRecording recording)
=> expression.Replace(node =>
{
var rewritten = rules(node);
if (rewritten != node)
recording.Add(this, node, rewritten);
return rewritten;
});

/// <summary>
/// This set as a <see cref="Transformation"/>, so that it composes with the rest of
Expand Down
43 changes: 43 additions & 0 deletions Sources/AngouriMath/Core/Transformations/RewriteStep.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// Copyright (c) 2019-2026 Angouri.
// AngouriMath is licensed under MIT.
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
// Website: https://am.angouri.org.
//

namespace AngouriMath.Core.Transformations
{
/// <summary>
/// One rewrite that actually fired: which rule set did it, the subexpression it matched,
/// and what it put there instead.
/// </summary>
/// <remarks>
/// The subexpression, not the whole expression. A rewrite pass walks the tree bottom-up
/// and rewrites nodes as it goes, so there is no moment at which a partly-rewritten
/// whole expression exists to be photographed — reporting one would mean building it,
/// and it would be a picture of something the engine never held.
/// </remarks>
public readonly struct RewriteStep
{
internal RewriteStep(RewriteRuleSet ruleSet, Entity before, Entity after)
=> (RuleSet, Before, After) = (ruleSet, before, after);

/// <summary>Which rule set rewrote it.</summary>
public RewriteRuleSet RuleSet { get; }

/// <summary>The subexpression as it was matched.</summary>
public Entity Before { get; }

/// <summary>What replaced it. Never equal to <see cref="Before"/> — a rule set that changed nothing records nothing.</summary>
public Entity After { get; }

/// <summary>What the rule set claims about the rewrite. See <see cref="RewriteRuleSet.Relation"/>.</summary>
public TransformationRelation Relation => RuleSet.Relation;

/// <summary>How well justified that claim is. See <see cref="Soundness"/> on what a tier is and is not.</summary>
public Soundness Soundness => RuleSet.Soundness;

/// <inheritdoc/>
public override string ToString() => $"{RuleSet.Name}: {Before.Stringize()} -> {After.Stringize()}";
}
}
34 changes: 34 additions & 0 deletions Sources/AngouriMath/Docs/Contributing/Transformations.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ same claim in the shape its callers expect, by handing back an unevaluated `Inte
| `TransformationResult` | input, output-or-nothing, and which transformation ran. A struct, so routing an ordinary call through this layer allocates nothing |
| `RewriteRuleSet` | a named, attributed group of rewrites — `Name`, `Description`, `Relation`, `Soundness`, `ApplyOnce` |
| `RewriteRules` | the registry: every rule set the simplifier applies, explicitly listed, enumerable through `All` in a fixed order |
| `RewriteRecording` / `RewriteStep` | a scope that collects the rewrites which fired while it was open — off unless asked for, and free when off |

Composition is `Then`, `Repeat(n)` and `UntilStable(max)`. All three take their bound from the
caller: there is no unbounded rewrite loop anywhere in the layer, and `UntilStable` reports hitting
Expand Down Expand Up @@ -68,6 +69,39 @@ built out of the registry. `SimplifyChildren` — which every stage of the simpl
Everything else is a thin adapter over the algorithm that was already there. **Nothing that worked
was rewritten to make the architecture tidier.**

## Recording what fired

```csharp
using var recording = RewriteRecording.Start();
var simplified = ((Entity)"a / (b / c)").Simplify();
foreach (var step in recording.Steps)
Console.WriteLine(step); // Common: a / (b / c) -> a * c / b
```

This is [#28](https://github.com/asc-community/AngouriMath/issues/28). Three things about it
are deliberate and worth keeping:

**Free when off.** With no recording open, applying a rule set costs one thread-static read
more than it did before — per rule set, not per node — and allocates nothing. That is why it
is a scope rather than a setting: a setting is something a caller can leave on.

**Per thread**, like `MathS.Settings`, so a parallel caller records its own work and nobody
else's. Recordings nest, and an inner one hides the outer until it closes.

**A step is a subexpression, not a snapshot.** A rewrite pass walks bottom-up and rewrites
nodes as it goes, so there is no moment at which a partly-rewritten whole expression exists
to photograph. #28's example shows whole-expression snapshots; reporting those would mean
constructing something the engine never held.

And the honest limit, which the type's own documentation states: **these are the rewrites,
not everything `Simplify` did.** Simplification also expands, factorises, divides
polynomials, minimises boolean expressions, and then *chooses* among candidates by a
complexity metric. The steps are every rewrite that fired across every candidate generated —
including candidates that lost. Reading them as a route from the input to the returned answer
would be reading in something that is not there. Making that route available is the
derivation work in #746's v5.0 tier, and it needs the candidate search to be attributable
first, not just the rewrites.

## What is deliberately not here

**`Solve` is not a transformation.** It consumes a *goal* — an equation, with a variable to solve for
Expand Down
3 changes: 3 additions & 0 deletions Sources/Tests/DotnetBenchmark/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ public static void Main(string[] args)
{
"RAMUsageTest" => GetReportByBenchmark(typeof(RAMUsageTest), "Gen 0", "Gen 1", "Gen 2", "Allocated"),
"CommonFunctionsInterVersion" => GetReportByBenchmark(typeof(CommonFunctionsInterVersion), "Mean", "Error", "StdDev"),
// Allocated as well as Mean: the regressions this one exists to catch
// show up in allocation and are invisible in the timings.
"TransformationLayer" => GetReportByBenchmark(typeof(TransformationLayer), "Mean", "Error", "StdDev", "Allocated"),
"CompiledFuncTest" => GetReportByBenchmark(typeof(CompiledFuncTest), "Mean", "Error", "StdDev"),
"NumbersBenchmark" => GetReportByBenchmark(typeof(NumbersBenchmark), "Mean", "Error", "StdDev"),
_ => throw new($"Unexpected benchmark {arg}")
Expand Down
75 changes: 75 additions & 0 deletions Sources/Tests/DotnetBenchmark/TransformationLayer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// Copyright (c) 2019-2026 Angouri.
// AngouriMath is licensed under MIT.
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
// Website: https://am.angouri.org.
//

using AngouriMath;
using AngouriMath.Core.Transformations;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Exporters.Csv;

namespace DotnetBenchmark
{
/// <summary>
/// The transformation layer, measured for time and allocation.
/// </summary>
/// <remarks>
/// <para>
/// Two claims are made about this layer and neither is safe without something that
/// measures them: that routing the 1.x entry points through it costs nothing, and that
/// recording rewrites costs nothing when nobody is recording. Both have already been
/// broken once during development -- the second by a closure the compiler allocated at
/// method entry regardless of the early return, which cost <c>Simplify</c> a fifth of
/// its allocation with the feature switched off, and which no test would have noticed.
/// </para>
/// <para>
/// Read the allocation column first. It is deterministic and reproduces to the tenth of
/// a kilobyte, where the timings on an ordinary machine vary by ten percent run to run
/// and hide exactly this kind of regression.
/// </para>
/// </remarks>
[ArtifactsPath(@"./benchmark_results.csv")]
[CsvExporter(CsvSeparator.Semicolon)]
[MemoryDiagnoser]
public class TransformationLayer
{
private static readonly Entity simplifyInput = "x + 3 / 3 + x ^ 0 - log(e, e2)";
private static readonly Entity quotientInput = "(x ^ 3 + 3 * x ^ 2 * y + 3 * x * y ^ 2 + y ^ 3) / (x + y)";
private static readonly Entity expandInput = "(x + y) ^ 6";
private static readonly Entity factorizeInput = "x * y + y + 1 + x";
private static readonly Entity surdInput = "1 / (sqrt(3) + 5)";
private static readonly Entity unorderedInput = "z + y + x + sin(b) + a";
private static readonly Entity derivativeInput = "x + 3 + arccos(x + 2) / sqrt(x2 + 1)";

// The 1.x surface, which now reaches its algorithm through the layer. These are the
// numbers to compare against a build from before the layer existed.
[Benchmark] public void Simplify() => simplifyInput.Simplify();
[Benchmark] public void SimplifyQuotient() => quotientInput.Simplify();
[Benchmark] public void Expand() => expandInput.Expand();
[Benchmark] public void Factorize() => factorizeInput.Factorize();
[Benchmark] public void Differentiate() => derivativeInput.Differentiate("x");

// The layer reached directly.
[Benchmark] public void SimplificationTransformation() => Transformation.Simplification.Apply(simplifyInput);
[Benchmark] public void Normalization() => Transformation.Normalization.Apply(unorderedInput);
[Benchmark] public void Rationalisation() => Transformation.Rationalisation.Apply(surdInput);
[Benchmark] public void Substitution() => Transformation.Substitution("x", 3).Apply(quotientInput);

// A single rewrite pass, the unit everything above is built out of.
[Benchmark] public void OneRewritePass() => RewriteRules.Common.ApplyOnce(quotientInput);
[Benchmark] public void OneRewritePassThatMatchesNothing() => RewriteRules.PhiFunction.ApplyOnce(quotientInput);

// The pair that matters. SimplifyWhileRecording is expected to cost more -- it
// collects a step per rewrite. Simplify above is the one that must not move: it is
// the same call with nobody listening, and if the two ever converge, the recording
// machinery has leaked into the ordinary path.
[Benchmark]
public void SimplifyWhileRecording()
{
using var recording = RewriteRecording.Start();
simplifyInput.Simplify();
}
}
}
Loading
Loading