Repository navigation
Hold a rewrite recording per flow instead of per thread - #863
Merged
Merged
Conversation
Rafael-SOWNet
force-pushed
the
fix/rewriterecording-asynclocal
branch
from
August 10, 2026 02:22
c402607 to
48160f2
Compare
Closes #859. RewriteRecording kept its ambient scope in a [ThreadStatic] field and documented the consequence rather than fixing it -- "A synchronous scope, and it has to be. Do not await inside one." It no longer has to be. The scope is an AsyncLocal, as MathS.Settings now is and as the cancellation token in MathS.Multithreading already was. The trap #857 flagged applies here and is why this is more than a field swap. An AsyncLocal flows the reference, so once the pointer reaches child tasks two of them can report to one recording at once, and the steps were accumulating into a List. That is a torn write, not a merged list. The store is a ConcurrentQueue, `closed` is volatile since it is now read from flows other than the one that set it, and Steps copies out rather than handing back a live view. One existing test encoded the old semantics and is rewritten rather than deleted: ARecordingOnOneThreadDoesNotSeeAnother started a thread inside an open recording and asserted its work was not collected. ExecutionContext flows to a manually started thread, so that work is now collected -- which is the point of the change, not a regression of it. It becomes WorkStartedUnderARecordingIsCollectedWhereverItRuns, and the isolation it was really reaching for is covered by SiblingRecordingsDoNotSeeEachOther. RewriteAllocationTest still passes untouched, so being off is still free: one ambient read per rule set, nothing allocated, which is what #746 asks of every layer above the tree. Verified: 6064 C# tests and 130 F# tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rafael-SOWNet
force-pushed
the
fix/rewriterecording-asynclocal
branch
from
August 10, 2026 02:48
48160f2 to
5a5ef43
Compare
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 23, 2026
…1012) * Make the derivation a path from the input to the answer (#28, #273) A recording held every rewrite that fired, across every candidate the simplifier generated including the ones that lost, each on the subexpression it matched. Read in order that does not walk from the input to the answer, and the type's own documentation said so in three places. `RewriteRecording.PathFrom(input, result)` walks it. Every step is a whole expression turning into another: `Steps[i].After` is `Steps[i + 1].Before`, the first starts at the input, and the last lands on what `Simplify` returned. `DerivationPath.OfSimplifying(expression)` is the same in one call, and prints as the stages with the identity that produced each one beside it. Two grains in two types, because a rewrite pass walks bottom-up and no partly-rewritten whole expression exists inside one: `DerivationStep` is the pass, and its `Rewrites` are the `RewriteStep`s that fired in it, each naming the rule that did it. What was missing was attribution. A rule-set pass now records what it turned into as well as what it matched, and `Simplificator` records the stages that are not rewrite passes -- inner simplification, the boolean minimiser, the polynomial rearrangement, expansion, factoring -- since the chain otherwise has a hole wherever the simplifier tidies up. Losing candidates are absent rather than marked: nothing leads from one to the answer, so none can be on a path to it, and what the search produced and did not keep is reported as `ExpressionsExplored`. Free when nobody is recording, which is what #746 requires of anything above the tree: one ambient read per stage against a tree walk per stage, and no allocation. `RewriteAllocationTest` guards the new fast path the way it already guarded `ApplyOnce`. `Simplify` returns what it returned before, and the raw recording is unchanged -- 270 rewrites on `x^(-1)/(y/z)`, 251 of them normalisation, measured on both builds. Corrected in passing, each re-measured: `Common` has 100 arms and not 103; `Derivation` on that expression is 5 rewrites and not 6; and `Transformations.md` said a source generator over the switch bodies was still for #746 item 50 to decide, which #951 shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e * Say per flow and ambient read, which is what the recording has been since #863 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #859.
RewriteRecordingheld its ambient scope in a[ThreadStatic]field, and documented the consequence rather than fixing it:It no longer has to be. The scope is an
AsyncLocal, asMathS.Settingsnow is (#857) and as the cancellation token inMathS.Multithreadingalready was.Why this is more than a field swap
The trap #857 ran into applies directly here. An
AsyncLocalflows the reference, so once the pointer reaches child tasks, two of them can report to one recording at the same time — and the steps were accumulating into aList<RewriteStep>. That is a torn write, not a merged list.So alongside the field:
ConcurrentQueue<RewriteStep>;closedisvolatile, since it is now read from flows other than the one that set it;Stepscopies out rather than handing back a live view.Behaviour
awaitOne existing test is rewritten, not deleted
ARecordingOnOneThreadDoesNotSeeAnotherstarted a thread inside an open recording and asserted its work was not collected.ExecutionContextflows to a manually started thread, so that work is now collected — which is the point of the change rather than a regression of it.It becomes
WorkStartedUnderARecordingIsCollectedWhereverItRuns, and the isolation it was really reaching for — that a parallel caller records its own work and nobody else's — is covered properly bySiblingRecordingsDoNotSeeEachOther, with a barrier forcing both recordings open before either does any work.Also new:
ARecordingSurvivesAnAwait(the regression) andARecordingOpenedInsideATaskDoesNotEscapeIt.AClosedRecordingIgnoresWhateverItIsStillHandedpasses unchanged.Off is still free
RewriteAllocationTestpasses untouched: no recording open still costs one ambient read per rule set and allocates nothing, which is what #746 asks of every layer above the tree.Verification
Breaking, so it wants the 2.0 window; recorded in
BREAKING-CHANGES.mdwith the migration, theSteps-is-now-a-snapshot note, and the fact that step order across parallel work is not defined.Note on merge order: this touches
BREAKING-CHANGES.mdat the same anchors as #853 and #857. Whichever merges later needs a trivial "keep both" resolution in that file.🤖 Generated with Claude Code