Skip to content

Decide whether a node type may be defined outside the kernel (#1026) - #1040

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
docs/node-contract-extensibility
Aug 23, 2026
Merged

Rafael-SOWNet merged 1 commit into
masterfrom
docs/node-contract-extensibility

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Closes the design half of #1026 and unblocks #746 item 56.

What was wrong

Entity is a public abstract partial record with a protected constructor. Five of its eleven abstract members are internal or private protected, so a node type cannot be defined outside AngouriMath.dll. Measured, not remembered — a class library referencing the built kernel and implementing all six reachable members:

GeoPoint.cs(7,30): error CS0534: 'GeoPointf' does not implement inherited abstract member 'Entity.IntrinsicCondition.get'
GeoPoint.cs(7,30): error CS0534: 'GeoPointf' does not implement inherited abstract member 'Entity.InvertNode(Entity, Entity)'
GeoPoint.cs(7,30): error CS0534: 'GeoPointf' does not implement inherited abstract member 'Entity.Priority.get'
GeoPoint.cs(7,30): error CS0534: 'GeoPointf' does not implement inherited abstract member 'Entity.SortHashName(TreeAnalyzer.SortLevel)'
GeoPoint.cs(7,30): error CS0534: 'GeoPointf' does not implement inherited abstract member 'Entity.ToSymPy()'

Exactly five, and no other error. InternalsVisibleTo is not an escape — a published extension package cannot be on that list.

What the document decides

Sources/AngouriMath/Docs/Contributing/NodeContract.md. Open the contract without publishing anything.

Traced, per member, from what reads it rather than from what it looks like:

member verdict why
Priority engine, default Priority.Func every read is under Functions/Output/; it is bracketing precedence, and 27 node types already inherit Func from Entity.Function
SortHashName engine, default a name distinctive to the type read only from Functions/Simplification/Patterns/; the values are a deduplication table over kernel node kinds (Sumf/Minusf → summinus_), and an external node has no partner in it
ToSymPy engine, default NotSufficientlySupportedException a printer for a foreign system, and Entity.Set.SpecialSet already throws exactly that from a base class
InvertNode engine, default Enumerable.Empty<Entity>() 17 of the 60 implementations already return it; "cannot invert" is the honest no-answer
IntrinsicCondition contract, protected abstract the only one read by a public member (DomainCondition). Boolean.True — 39 of 60 implementations — is the positive claim that an operation is total, and for an unknown node that is the unsafe direction

Nothing becomes public. That is the whole argument against the obvious reading: publishing the five drags internal enum Priority (30 named values, |-composed with deliberate ties) and internal enum SortLevel into the public API, so every later change to bracketing or like-term grouping becomes a BREAKING-CHANGES.md entry — for a capability the defaults deliver without it.

The alternative — keep Entity closed, let a domain package contribute data — is costed rather than dismissed: it keeps exhaustive matching and the reflection sweep sound, but InnerSimplify is a function rather than a table row, so the "data" acquires delegates immediately, the indirection lands on Simplify's default path, and a domain package's own rules stop matching on types.

Two things measured that were not known

The hierarchy is already open. Number.Complex, Number.Real, Number.Rational and Variable are concrete and unsealed by three SealedOrAbstract exemptions, so a subclass inherits every implementation and compiles from outside today:

public sealed record MyReal(EDecimal D) : Entity.Number.Real(D);

Stringize gives 3, 3 + x simplifies to 3 + x, SolveEquation("x") gives { -3 }. So "closed, therefore exhaustive matching is sound" is already only true by convention, and EveryNodeSurvivesEveryPipelineTest — which enumerates typeof(Entity).Assembly.GetTypes() — cannot see such a type.

There is a twelfth obligation nobody has written down. All 68 concrete node types carry public override string ToString() => Stringize(); (69 occurrences, one on an abstract base). AddingNode.cs does not mention it and it is not optional: without it the record compiler synthesizes ToString, which calls the synthesized Entity.PrintMembers, which appends Entity's own Entity-valued public properties, each calling ToString again:

at RuntimeHelpers.EnsureSufficientExecutionStack()
at AngouriMath.Entity.PrintMembers(StringBuilder)
at DomainPack.GeoPointf.PrintMembers(StringBuilder)
at DomainPack.GeoPointf.ToString()
at System.Text.StringBuilder.Append(Object)          [repeats]

Entity should declare public sealed override string ToString() => Stringize(); once — 69 lines deleted, and the trap gone for every future node.

What was measured

The five proposed defaults were given to a node kind the kernel has never seen (GeoPointf(X, Y), printing point(x, y)) and put through the seventeen pipelines EveryNodeSurvivesEveryPipelineTest uses, on five shapes. Compiled against a local build of this tree with InternalsVisibleTo added and signing off — the only way such a type compiles today; not a build of master.

Sixteen of seventeen held. point(x, x) + point(x, x) → 2 * point(x, x) (the pattern layer grouped an unknown node through the default sort hash); point(x, x) / point(x, x) → 1 provided not point(x, x) = 0 (the provided machinery reached it through DomainCondition); Solve refused with the library's own UncompilableNodeException. The seventeenth is the ToString recursion above.

Trimming

Any design needing runtime assembly scanning is disqualified — the kernel declares IsAotCompatible and AotSmokeTest gates it. That bites #338 as written ("look up the types, inherited from FunctionEntity"); the capability behind it needs explicit registration or a generated table. Option A itself is virtual dispatch and needs no reflection at all.

Scope

Documentation only — no code changes. Where the document concludes code must change, that is its recommendation, not this diff. Docs/Contributing/README.md gains one index entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd

Entity is a public abstract record with a protected constructor, which reads
as an invitation to derive from it, and five of its eleven abstract members
are internal or private protected, so a subclass in another assembly fails to
compile with CS0534 naming exactly those five. That is the blocker under #746
items 53 and 56 and under Packaging.md's "blocked, not decided" for domain
packages -- item 56 attributes the seam to #338, looking a type up so it can be
parsed, which is the second half of it.

The decision is to open the contract without publishing anything: four of the
five are engine machinery with a default the kernel can supply and be right
about -- Priority is read only by the printers, SortHashName is a deduplication
table over kernel node kinds, ToSymPy already throws from a base class for a
node SymPy has no name for, and an empty inversion is what a third of the node
types already return. Only IntrinsicCondition has no safe default: Boolean.True
is the positive claim that an operation is total, and only the node's author
knows whether it holds, so it becomes protected abstract. Publishing the five
instead would drag two internal enums and 30 frozen enum values into the public
API for the same capability.

The document costs the alternative -- a closed hierarchy extended by data --
against that, and records why it is not the status quo either: Number.Real,
Rational, Complex and Variable are concrete and unsealed, and a subclass of one
compiles against the nupkg today and goes through Simplify and Solve intact.

The defaults were given to a node kind the kernel has never seen and run
through the pipelines EveryNodeSurvivesEveryPipelineTest uses. Sixteen of the
seventeen held, including like-term grouping and the provided machinery. The
one that did not is a twelfth obligation written nowhere: every node type
carries `ToString() => Stringize()`, and without it the record-synthesized
ToString recurses through Entity.PrintMembers until the stack guard fires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
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