Skip to content

Match across a chain, so a rule about two terms can find them among five (#248, #746 v1.0) - #942

Merged
Rafael-SOWNet merged 5 commits into
masterfrom
feat/nary-matching
Aug 14, 2026
Merged

Match across a chain, so a rule about two terms can find them among five (#248, #746 v1.0)#942
Rafael-SOWNet merged 5 commits into
masterfrom
feat/nary-matching

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

#746 v1.0 asks for pattern matching "with commutative and n-ary matching handled by the engine". #938 built the commutative half and said plainly that the n-ary half was missing: a + b + c could not match x + y. This is that half, and it is the last piece of #248's matching engine.

MatchPattern.Gathered<Sumf>("rest", squaredSine, squaredCosine)

Flattens the chain with the LinearChildren the library already has, finds the parts among the operands in any positions, and binds the leftovers to rest.

The empty sum is zero

Where nothing is left over, rest is bound to the operator's identity0 for a sum, 1 for a product. Not a convenience: the empty sum is zero and the empty product is one. So one rule covers both sin(x)^2 + cos(x)^2 and the same pair buried in a longer sum, and no right-hand side has to ask which case it is in.

Because the operands come from LinearChildren, the subtractive and divisive spellings arrive normalised — a - b offers a and (-1) * b. A rule needs no second arm for them, which is one of the ways the switch sets multiplied.

What it buys, stated against the code rather than in the abstract

Patterns.TrigonometricRules spends two arms on the Pythagorean identity:

Sumf(Powf(Sinf(var any1), Integer(2)), Powf(Cosf(var any1a), Integer(2))) when any1 == any1a => 1,
Sumf(Powf(Cosf(var any1), Integer(2)), Powf(Sinf(var any1a), Integer(2))) when any1 == any1a => 1,

Two, because there is no commutative matching. And both match only two children of one Sumf, so the pair is found in sin(x)^2 + cos(x)^2 and missed in a + sin(x)^2 + b + cos(x)^2.

The library's answer is to sort the operands with CanonicalOrder before the rules run, so the pair lands adjacent. That works — and it is the matcher's limitation showing through as a pipeline stage. One Gathered rule replaces both arms and handles the buried case, and MatchedRules.PythagoreanIdentity is that rule.

It is the first set in that file with no switch to be checked against, because the switch cannot express it. It is checked against the mathematics instead.

Bounded, because this is where the explosion lives

Assigning k parts to n operands is n!/(n−k)! attempts. #746 warns that combinatorial explosion is the whole difficulty of the rewrite graph, so the search is bounded by measured work rather than by a reasoned size limit — the same choice #921 settled on for the resultant. Past the ceiling the enumeration stops yielding, so the rule does not apply rather than taking unbounded time. Declining is a legitimate answer where a hang is not. Two parts stay under the bound until 100 operands.

Measured

Suite 7141 passed / 0 failed, 24 of them new.

The bound is checked by counting solutions, not by timing a run — a timing assertion is flaky in CI, and this one is exact: three holes over eight terms enumerates exactly 8·7·6 = 336, and five holes over twenty stops well short of 1,860,480.

Worth recording, because the first version of that test was worthless: it used a pattern that failed at the first hole, so the search died at depth 1 having spent 60 of its 10,000 budget. It passed, and proved nothing. The replacement builds prefixes that all match and fail only at the last hole, which is the case the bound is actually for.

Nothing runs the new rule set, so no answer changes — no BREAKING-CHANGES.md entry. Everything here is internal; PublicApi.txt is unchanged.

🤖 Generated with Claude Code

Rafael-SOWNet and others added 2 commits August 14, 2026 18:52
…ive (#248)

#746 v1.0 asks for pattern matching "with commutative and n-ary matching handled by the engine".
#938 built the commutative half and said plainly that the n-ary half was not there: a + b + c could
not match x + y. This is that half.

MatchPattern.Gathered<T>(restName, parts...) flattens an associative chain with the LinearChildren
the library already has, finds the parts among the operands in any positions, and binds what is left
to restName.

Where nothing is left, restName is bound to the operator's identity -- 0 for a sum, 1 for a product.
That is not a convenience. The empty sum is zero and the empty product is one, so one rule covers
both sin(x)^2 + cos(x)^2 and the same pair buried in a longer sum, and no right-hand side has to ask
which case it is in.

Because the operands come from LinearChildren, the subtractive and divisive spellings are already
normalised: a - b offers a and (-1) * b. A rule needs no second arm for them, which is one of the
ways the switch sets multiplied.

The search is bounded by measured work rather than by a reasoned size limit, as #921 settled for the
resultant. Assigning k parts to n operands is n!/(n-k)! attempts; past the ceiling the enumeration
stops yielding, so the rule does not apply rather than taking unbounded time. Declining is a
legitimate answer where a hang is not.

MatchedRules.PythagoreanIdentity is the demonstration, and it is the first set here with no switch
to be checked against, because the switch cannot express it. Patterns.TrigonometricRules spends two
arms on this identity -- one per operand order, since it has no commutative matching -- and both
match only two children of one Sumf. So the pair is found in sin(x)^2 + cos(x)^2 and missed in
a + sin(x)^2 + b + cos(x)^2. The library's answer is to sort the operands with CanonicalOrder before
the rules run so the pair lands adjacent; that works, and it is the matcher's limitation showing
through as a pipeline stage. One Gathered rule replaces both arms and handles the buried case.

Nothing runs the new set, so no answer changes and there is no BREAKING-CHANGES entry. Everything
here is internal; PublicApi.txt is unchanged.

Measured: suite 7141 passed / 0 failed, 24 of them new. The bound is checked by counting solutions
rather than by timing a run: three holes over eight terms enumerates exactly 8*7*6, and five holes
over twenty stops well short of 20*19*18*17*16.

#248
#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…and close the gap (#248)

#746 v2.0 says a slower Simplify for the common case is unacceptable and that the fast path must
survive as a path. Nothing had measured whether a rule expressed as data can be that path, and the
answer decides whether #248's migration is possible at all, so it is worth knowing before two
thousand lines of rules are written against it.

DotnetBenchmark.MatchingEngine asks it: the same rule as a switch arm and as data, on a node it
rewrites and on a node it does not. The miss is the case that matters, because a rewrite pass asks
every rule about every node and nearly every answer is no.

As first written, the data form was disqualifying:

  miss   2.7 ns and no allocation   ->   58.5 ns and 296 B
  hit   24.1 ns and 216 B           ->  294.3 ns and 1696 B

296 bytes per rule per node is GC pressure on every node of every tree, which is not a factor to
engineer around. Three things were responsible, and all three are fixed here.

Bindings was a Dictionary copied on every bind -- a whole map allocated per hole per attempt, in a
search that backtracks. It is a cons list sharing its tail now, so a bind is one small object.
Immutability there is a correctness requirement rather than an optimisation: a branch that fails must
leave nothing behind for the branch tried next.

Match is now a non-iterator wrapper over a virtual MatchCore, guarded by the pattern's declared root
node type. A type mismatch returns a shared empty sequence, so it allocates nothing and does not even
construct the iterator state machine. The guard must never reject what the full match would accept,
so RootType is null wherever the constraint is not exactly one type: Exact, because two entities can
be equal without sharing a runtime type, and Gathered, because a sum chain is Sumf or Minusf.

MatchedRuleSet walked its rules through the IReadOnlyList property, which boxes an enumerator. That
was the last 32 bytes. It keeps the array and walks that; Rules still exposes the list, since being
enumerable is the point.

Measured after, anchored on DivisionSwitchMiss, which is 2.737 ns in the first run and 2.757 in the
last:

  miss   58.5 ns and 296 B   ->   14.1 ns and nothing
  hit   294.3 ns and 1696 B  ->  232.4 ns and 1264 B

A type-mismatched miss now allocates nothing at all, which is the switch's own figure. It is still
about five times the switch in time, and a hit is still about twenty times and six times the
allocation.

Two things this does not claim. A miss that is *not* a type mismatch still does the work and still
allocates -- PythagorasDataLongMiss is 351 ns and 1136 B, because the expression really is a sum and
the assignments really are tried. And nothing here says Simplify is unaffected, because nothing runs
these rules yet; what fraction of a pass is rule dispatch is not known, and the way to find out is to
convert one real set, run it, and measure end to end. That experiment is worth doing now and was not
before.

The benchmark project is signed with the library's key, as UnitTests is, so it can name the internals
the question is about.

Measured: suite 7141 passed / 0 failed. No answer changes; nothing runs the data rules.

#248
#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

And then measured what it costs, because that decides whether #248 can happen

A second commit. #746 v2.0 says a slower Simplify for the common case is unacceptable and the fast path must survive as a path — and nothing had ever measured whether a rule expressed as data can be that path. Better to know before two thousand lines of rules are written against it.

DotnetBenchmark.MatchingEngine asks the question directly: the same rule as a switch arm and as data, on a node it rewrites and one it does not. The miss is the case that matters — a rewrite pass asks every rule about every node, and nearly every answer is no.

As first written, the data form was disqualifying:

switch data
miss 2.7 ns, 0 B 58.5 ns, 296 B
hit 24.1 ns, 216 B 294.3 ns, 1,696 B

296 bytes per rule per node is GC pressure on every node of every tree. That is not a factor to engineer around; it ends the design. Three causes, all fixed here:

  1. Bindings was a Dictionary copied on every bind — a whole map allocated per hole per attempt, in a search that backtracks. Now a cons list sharing its tail, so a bind is one small object. Immutability there is a correctness requirement, not an optimisation: a failed branch must leave nothing for the next one.
  2. Match allocated an iterator state machine before rejecting. It is now a non-iterator wrapper over a virtual MatchCore, guarded by the pattern's declared root node type, returning a shared empty sequence on mismatch. The guard must never reject what the full match would accept, so RootType is null wherever the constraint is not exactly one type — Exact, because two entities can be equal without sharing a runtime type, and Gathered, because a sum chain is Sumf or Minusf.
  3. MatchedRuleSet walked its rules through the IReadOnlyList property, which boxes an enumerator. That was the last 32 bytes. It walks the array now; Rules still exposes the list, since being enumerable is the point.

After

switch data, before data, after
miss 2.76 ns, 0 B 58.5 ns, 296 B 14.1 ns, 0 B
hit 10.2 ns, 216 B 294.3 ns, 1,696 B 232.4 ns, 1,264 B

A type-mismatched miss now allocates nothing at all — the switch's own figure.

On reading these numbers honestly

The DivisionSwitchHit baseline moved 24.1 → 10.2 ns between runs, on code I did not touch, so absolute times are not comparable across runs and the Pythagoras "ratio" apparently worsening from 25× to 53× is an artefact of the halved baseline, not a regression. DivisionSwitchMiss held at 2.737 / 2.757 / 2.757 ns across all three runs, so it is the honest anchor, and the miss and hit figures above are stated against it. Allocation is deterministic and comparable throughout, which is why this repo's own guidance says to read that column first.

Two things this does not claim

  • Not every miss is free. A miss that is not a type mismatch still does the work and still allocates: PythagorasDataLongMiss is 351 ns and 1,136 B, because the expression really is a sum and the assignments really are tried.
  • Nothing here says Simplify is unaffected, because nothing runs these rules. What fraction of a pass is rule dispatch is unknown. The way to find out is to convert one real set, run it in the pipeline, and measure end to end — an experiment that is worth doing now and was not before.

The data form is still ~5× the switch on a miss and ~23× on a hit. That is a candidate to engineer with, where 296 B per miss was not.

The benchmark project is signed with the library's key, as UnitTests already is, so it can name the internals the question is about.

Suite 7141 passed / 0 failed. No answer changes.

#248)

The per-node numbers said the data form was five times the switch on a miss. The unit the pipeline
actually spends is a pass -- every node of a real expression offered to a rule set -- so this
measures that, with the same rules on both sides: the switch is Patterns.DivisionPreparingRules and
the data set was proven to agree with it in #938.

  PassSwitch    135 ns    648 B
  PassData     1385 ns   4552 B

Ten times the time and seven times the allocation, for one rule set on one modest tree, where
Simplify runs about thirty of them repeatedly. That answers the question this benchmark was written
for: the hot rule sets cannot be migrated to this form as it stands, which is what #746 v2.0 means
when it says the fast path must survive as a path.

An index was the obvious reply, and it is the one thing a switch cannot do: a switch tests its arms
in order, while rules that are values can be grouped by the node type each one requires, so that a
node is only offered the rules that stand a chance. It was built -- a per-runtime-type cache keyed on
the node's type, with IsAssignableFrom so a rule requiring Number still sees an Integer, and filtering
that preserves order so first-match-wins picks the same rule.

It did not pay for itself, and it is recorded here rather than kept.

  per-node miss    0 B  ->   24 B
  per pass      4552 B  -> 5464 B

Both figures reproduced exactly on a repeat run of the same build, so they are real and not noise --
while DivisionSwitchMiss, which nothing touched, moved from 2.85 ns to 7.99 ns between those same two
runs. Allocation is the measurement here and the timings are not, which is the standing guidance in
this repository and is why the time improvement the index appeared to give could not be claimed: it
sat inside that drift.

Two attempts at it. The first used the GetOrAdd overload that takes a factory, and the lambda closed
over `this`, so it allocated a delegate on every call including every cache hit -- 64 bytes a node, to
avoid work worth less than that. Looking the type up first fixed that and left 24 bytes a node which
was never accounted for; a warm ConcurrentDictionary lookup should allocate nothing. An optimisation
that makes the deterministic column worse for a reason nobody can name is not one to ship, so it is
out, with a comment where it was so the next person measures rather than assumes.

The finding underneath is that the index was aimed at the wrong half. Dispatch is not where the cost
is -- the match attempt is, because a node that does match its type builds an iterator per child per
level of nesting. The lever that would matter is a matcher that does not allocate: an explicit stack
in place of yield return. That is a larger change and it now has a number to beat.

Measured: suite 7141 passed / 0 failed. No answer changes; nothing runs the data rules.

#248
#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

The pass-level number, which is the one that decides the migration

Per-node figures said the data form was ~5× the switch on a miss. But the unit the pipeline spends is a pass — every node of a real expression offered to a rule set. Same rules on both sides (#938 proved the two agree), one modest tree:

Mean Allocated
PassSwitch 135 ns 648 B
PassData 1,385 ns 4,552 B

10× the time and 7× the allocation, for one rule set. Simplify runs about thirty of them, repeatedly.

So the answer to the question this benchmark was written for is no: the hot rule sets cannot move to this form as it stands. That is exactly what #746 v2.0 means by "the fast path must survive as a path".

An index was the obvious reply, and it did not pay for itself

It is also the one thing a switch genuinely cannot do — a switch must test its arms in order, while rules that are values can be grouped by the node type each requires, so a node is only offered rules that stand a chance. I built it: a per-runtime-type cache, IsAssignableFrom so a rule requiring Number still sees an Integer, filtering that preserves order so first-match-wins picks the same rule.

without index with index
per-node miss 0 B 24 B
per pass 4,552 B 5,464 B

Both reproduced exactly on a repeat of the same build. Meanwhile DivisionSwitchMiss — which nothing touched — moved 2.85 → 7.99 ns between those same two runs. Allocation is the measurement here; the timings are not. That is why I could not claim the time improvement the index appeared to give: it sat inside that drift.

Two attempts. The first used the GetOrAdd overload taking a factory, and the lambda closed over this, so it allocated a delegate on every call including every cache hit — 64 B per node, to avoid work worth less than that. (The same shape as the closure-before-the-early-return that cost Simplify a fifth of its allocation once before.) Looking the type up first fixed that and left 24 B per node that I could never account for — a warm ConcurrentDictionary lookup should allocate nothing.

An optimisation that makes the deterministic column worse for a reason nobody can name is not one to ship. It is out, with a comment where it was so the next person measures instead of assuming it was never tried.

What the failure actually shows

The index was aimed at the wrong half. Dispatch is not where the cost is — the match attempt is, because a node that does match its type builds an iterator per child per level of nesting. The lever that would matter is a non-allocating matcher: an explicit stack in place of yield return. Larger change, and it now has a number to beat.

None of this blocks what #248 was needed for. Per-rule soundness, addressable rules for #825, and e-matching all work at these speeds; it is only replacing the hot switch that does not.

Suite 7141 passed / 0 failed. No answer changes.

The pass measurement said the cost is in the match attempt rather than in the dispatch, and named
the reason: MatchCore is an iterator, so a state machine is allocated for every node of the pattern
at every attempt, and a rewrite pass makes an attempt at every node of the tree.

Most rules do not need any of that. A pattern with no commutative node and no gathering in it can
fit an expression in at most one way, so there is nothing to enumerate and nothing to backtrack over.
MatchPattern.IsDeterministic says which patterns those are and TryMatchOnce matches them directly,
threading the bindings left to right.

Indeterminacy is inherited: a node containing a commutative child anywhere below it is itself a
search. A flag that only looked at the node would take the fast path on a pattern with two answers
and silently pick one of them.

Allocation, which is the figure that reproduces on this machine:

  one pass over a tree   4552 B -> 888 B,  against 648 B for the switch
  one hit                1288 B -> 296 B,  against 216 B for the switch
  one miss                  0 B ->   0 B,  as before

That is the whole of what disqualified this form. Seven times the switch's allocation on a pass is
GC pressure at every node of every tree; 1.37 times is a cost to weigh.

Time is not solved and is not claimed to be. The pass is around 1030 ns against 130 for the switch,
so roughly eight times, and what remains is virtual dispatch and type tests rather than allocation --
the switch does inline what this does through the pattern tree. Whether eight times on one rule set
matters to Simplify depends on what fraction of a pass is rule dispatch, which is still not measured.

The Gathered path is untouched at 2712 B, because it really is a search. It throws if asked for a
single match rather than guessing at one.

Two implementations of matching now exist, so DeterministicMatchingTest holds them together: every
rule in the registry over thirty expressions, requiring that a pattern claiming determinism never
matches more than one way, that both matchers agree on whether it matches, and that they bind the
same values. It asserts it checked more than a hundred pairs, because a test that silently checks
nothing passes just as loudly. That the sets still rewrite the same thing end to end is left to
MatchedRulesAgreeWithTheSwitchTest, which already ran both forms against each other and now reaches
the fast path to do it.

Measured: suite 7143 passed / 0 failed. No answer changes; nothing runs the data rules.

#248
#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

The allocation gap is closed

The pass measurement said the cost is the match attempt, not the dispatch, and named the reason: MatchCore is an iterator, so a state machine is allocated for every node of the pattern at every attempt — and a pass makes an attempt at every node of the tree.

Most rules never needed it. A pattern with no commutative node and no gathering fits in at most one way, so there is nothing to enumerate and nothing to backtrack over. IsDeterministic says which patterns those are; TryMatchOnce matches them directly, threading bindings left to right.

switch before after
pass 648 B 4,552 B (7.0×) 888 B (1.37×)
hit 216 B 1,288 B (6.0×) 296 B (1.37×)
miss 0 B 0 B 0 B

Seven times the switch's allocation on a pass is GC pressure at every node of every tree — that was the disqualifying fact. 1.37× is a cost to weigh.

What is not solved, and is not claimed to be

Time. The pass is ~1,030 ns against ~130 for the switch, so roughly 8×. What remains is virtual dispatch and type tests, not allocation: the switch does inline what this does by walking a pattern tree. Whether 8× on one rule set matters to Simplify depends on what fraction of a pass is rule dispatch, which is still not measured.

Gathered is untouched at 2,712 B, because it really is a search. It throws if asked for a single match rather than guessing at one.

Holding two matchers together

There are now two implementations of matching, which is how a matcher acquires a case where they differ. DeterministicMatchingTest runs every rule in the registry over thirty expressions and requires that a pattern claiming determinism never matches more than one way, that both matchers agree on whether it matches, and that they bind the same values.

It also asserts it checked more than a hundred pairs — a test that silently checks nothing passes just as loudly, which is a mistake I made earlier in this same PR with the budget test.

Indeterminacy is inherited: a node with a commutative child anywhere below it is itself a search. A flag that looked only at the node would take the fast path on a pattern with two answers and silently pick one. There is a test for that buried case specifically.

That the sets still rewrite the same thing end to end is left to MatchedRulesAgreeWithTheSwitchTest, which already ran both forms against each other and now reaches the fast path to do it — a second copy would have meant opening up rule right-hand sides for no gain in coverage.

Suite 7143 passed / 0 failed. No answer changes.

…nd (#248)

A pass over a tree with one rule set as data costs about ten times what the switch costs. That was
measured here and it is true, and on its own it says nothing about whether the migration is
affordable, because it does not say what fraction of simplification is rule dispatch.

So the exchange was made and measured. RewriteRules.DivisionPreparing was pointed at
MatchedRules.DivisionPreparing.ApplyHere instead of Patterns.DivisionPreparingRules -- a set
Simplificator runs twice per iteration -- and the library was measured through its own entry points.

The suite passed, 7143 of 7143. That is a stronger statement than the agreement test which already
covered these two forms: seven thousand real assertions went through the data matcher rather than
thirty generated expressions, so the exchange is a behavioural non-event and not merely an agreeing
one.

Allocation did not move.

  Simplify                       84,402 B  ->  84,402 B
  SimplifyQuotient            7,325,772 B  ->  7,328,796 B   (+0.04%)
  Expand, Factorize, Differentiate, Rationalization    identical

Time moved a little: Simplify 46,819 ns -> 49,209 ns, about five percent, while SimplifyQuotient and
Rationalization did not move at all and the isolated single pass moved eighteen percent.

The ten-times figure does not reach the caller. Dispatch is a small part of what simplification
spends, which is the thing that was unknown when the pass measurement was taken and is the reason a
microbenchmark ratio was never enough to decide this.

Five percent for one of some thirty sets is not free either. The conclusion is not "migrate
everything" but "migrate where a set's rules need to be data", which is where a rule has to carry
its own soundness, be named in a report, or be matched against an e-class.

The exchange itself is reverted: a five percent cost buys nothing until something is built on it.
What is kept is the number, in the place someone deciding this next will look.

Measured on this tree, both columns on one machine, allocation read first because the timings on it
move by more than this between runs of unchanged code.

#248
#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

The pass ratio does not reach the caller — measured, not argued

A pass with one rule set as data costs ~10× the switch. True, and on its own it says nothing about whether migration is affordable, because it does not say what fraction of simplification is dispatch. So I made the exchange and measured through the library's own entry points.

RewriteRules.DivisionPreparing was pointed at MatchedRules.DivisionPreparing.ApplyHere instead of Patterns.DivisionPreparingRules — a set Simplificator runs twice per iteration.

It is a behavioural non-event

The suite passed, 7143 of 7143. That is a stronger statement than the agreement test that already covered these two forms: seven thousand real assertions went through the data matcher, rather than thirty generated expressions.

Allocation did not move

switch data
Simplify 84,402 B 84,402 B
SimplifyQuotient 7,325,772 B 7,328,796 B (+0.04%)
Expand / Factorize / Differentiate / Rationalization identical

Time moved a little

Simplify 46,819 → 49,209 ns, ≈ +5%. SimplifyQuotient and Rationalization did not move. The isolated single pass moved +18%.

What this changes

The 10× does not reach the caller. Dispatch is a small part of what simplification spends — the thing that was unknown when I took the pass measurement, and the reason a microbenchmark ratio was never enough to decide this. I said earlier in this PR that the hot sets "cannot be migrated as it stands"; that was too strong, and this is the measurement that corrects it.

But 5% for one of some thirty sets is not free either. The conclusion is not migrate everything. It is migrate where a set's rules need to be data — where a rule must carry its own soundness, be named in a report (#825), or be matched against an e-class.

The exchange itself is reverted: 5% buys nothing until something is built on it. What is kept is the number, recorded on MatchedRules where whoever decides this next will look.

Allocation read first throughout, because the timings on this machine move by more than 5% between runs of unchanged code — DivisionSwitchMiss went 2.85 → 7.99 ns earlier today with nothing touched.

@Rafael-SOWNet
Rafael-SOWNet merged commit 12db347 into master Aug 14, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant