Repository navigation
A node type cannot be defined outside the kernel assembly: five of Entity's abstract members are internal #1026
Description
What is wrong
Entity is public abstract partial record, and its constructor is protected, which reads as an
invitation to derive from it. But a subclass in another assembly does not compile. Five of its
abstract members are internal or private protected, so an external type can never satisfy them:
| member | accessibility | where |
|---|---|---|
Priority |
internal abstract |
Core/Entity/Entity.Definition.cs:378 |
SortHashName(SortLevel) |
private protected abstract |
Functions/TreeAnalyzer/Sort.Definition.cs:19 |
IntrinsicCondition |
private protected abstract |
Functions/Evaluation/Evaluation.Definition.cs:72 |
ToSymPy() |
internal abstract |
Functions/Output/ToSympy.Definition.cs:13 |
InvertNode(Entity, Entity) |
private protected abstract |
Functions/Continuous/Solvers/InvertNode.Definition.cs:30 |
Deriving from Entity in a separate assembly fails with CS0534, naming each of those five among
the members it cannot implement. This is not a policy that could be relaxed by an
InternalsVisibleTo: a published extension package cannot be on that list.
Why it matters, and what is behind it
This is the concrete blocker under several roadmap items, and it is not the one they name.
#746 item 56 says the extensibility seam
is #338 — looking a type up so it can be
parsed from a string. That is the second half. The first half is being able to write the type at
all, and today you cannot. The same wall sits under item 53 (groups, rings and fields as
first-class, #440) and under the whole
tier-9 domain-package idea, which assumes a geometry or statistics pack can contribute node types.
It also means one thing the packaging decision cannot decide. Docs/Contributing/Packaging.md
(#746 item 78) works out which capabilities belong in the kernel and which ship separately, and its
answer for domain packages is blocked, not decided — because a package boundary is not what is
in the way here. Moving the LaTeX printer or the SymPy exporter out of the kernel runs into the same
five members from the other side: they are abstract members of Entity, so moving them means either
this contract becoming extensible or a visitor over all 68 node types.
Scope
This is a design decision before it is an implementation, and it should be argued before it is
built. The shape of the question:
- Which of the five are genuinely part of a node's public contract, and which are engine internals
that a visitor or a registry should own instead?ToSymPyandSortHashNamelook like the second
kind;PriorityandIntrinsicConditionlook like the first. - If a member becomes
publicorprotected, it becomes a compatibility surface — every future
change toSortLevel,Priorityor theInvertNodesignature is then breaking. That is the real
cost and it should be stated before the change, not discovered after. - Is the answer instead a sealed node hierarchy plus an explicit extension point — a node kind
that carries a name and an operation table — so that a domain package contributes data rather than
a subclass? That keepsEntityclosed, which is what makes exhaustive matching and the
EveryNodeSurvivesEveryPipelineTestreflection sweep work today. - Whatever is chosen must be trimming- and NativeAOT-safe: explicit registration, no runtime assembly
scanning. See #363,
#552 and
Docs/Contributing/Trimming.md.
Acceptance criteria
- A written decision in
Docs/Contributing/, saying what a node type outside the kernel may and may
not do, and which of the five members are part of the contract. - A test that compiles a node type from a separate assembly and puts it through the pipeline —
parse is not required, butToString,Latexize,InnerSimplified, substitution and structural
equality are. A test that only asserts the type exists proves nothing; this has to build. EveryNodeSurvivesEveryPipelineTeststill passes, and it is stated whether an external node is
in scope for it.- No new reflection on a hot path, and the trimming/AOT gate stays green.
Dependencies
Independent of everything currently open. It does not depend on
#338 — it is what #338 needs, not the
reverse. It is a prerequisite for #746 items 53 and 56 and for tier 9.
Found while measuring package boundaries for #746 item 78.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
Rafael-SOWNet commentedon Aug 23, 2026
Written up as
Docs/Contributing/NodeContract.mdin #1040, and the CS0534 is reproduced there from an actual build rather than asserted — exactly five errors, naming those five members, from a class library referencing the builtAngouriMath.dlland implementing all six reachable members.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 —
Priorityis read only underFunctions/Output/and 27 node types already inheritPriority.Func;SortHashNameis a deduplication table over kernel node kinds (Sumf/Minusfboth hashsumminus_) that an external node has no partner in;ToSymPyalready throwsNotSufficientlySupportedExceptionfrom a base class; an empty inversion is what 17 of the 60 implementations already return. OnlyIntrinsicConditionis genuine contract: it is the one read by apublicmember, andBoolean.Trueis the positive claim that an operation is total, which for an unknown node is the unsafe direction. So it becomesprotected abstractand the other four getvirtualdefaults — publishing them instead would draginternal enum Priority(30|-composed values) andinternal enum SortLevelinto the public API for the same capability.Two things the issue could not have known. The hierarchy is already open:
Number.Complex,Real,RationalandVariableare concrete and unsealed, andrecord MyReal(EDecimal D) : Entity.Number.Real(D)compiles against the nupkg today and goes throughSimplifyandSolveintact — so closedness is already only a convention, which is most of Option B's case. And there is a twelfth obligation written nowhere: all 68 node types carryToString() => Stringize(), and the five defaults run through theEveryNodeSurvivesEveryPipelineTestpipeline list on a node kind the kernel has never seen held on sixteen of seventeen — the seventeenth beingToStringrecursing through the record-synthesizedEntity.PrintMembersuntil the stack guard fires.Entityshould declare itsealedonce.https://github.com/asc-community/AngouriMath/blob/1f3e47724e913b7b95e1a5e82a23c370f1c82ff4/Sources/AngouriMath/Docs/Contributing/NodeContract.md
Rafael-SOWNet commentedon Aug 23, 2026
Correcting my own framing above, from a measurement. The title says a node type cannot be
defined outside the kernel assembly. That is true for deriving directly from
Entity, and false asa blanket claim.
Four types in the hierarchy are concrete and unsealed, by exemption from the
SealedOrAbstractanalyzer —
Entity.Number.Complex,Entity.Number.Real,Entity.Number.RationalandEntity.Variable, eachpublic partial recordwith nosealedand noabstract:So an external assembly can already derive from one of those and get a working node — the five
blocked members are inherited as implemented. The seam is narrower than the issue claims: what is
blocked is a node that is not a kind of number or variable, which is most of what a domain package
would want, but not all of it.
Two consequences worth recording:
EveryNodeSurvivesEveryPipelineTestcannot see such a node. It enumeratestypeof(Entity).Assembly.GetTypes(), so an externally-derived type is outside its sweep, and theguarantee that every node survives every pipeline does not extend to it. Whether it should is an
open question, not an answered one.
an analyzer, which is a decision someone made about this assembly's internals; it may never have
been read as a public extension point. That wants a maintainer's answer.
The five non-public abstract members are still exactly as listed, verified again on current
master,and a sixth sits below them:
Set.SpecialSet.ToDomain()isinternal abstract, so a new special setis blocked the same way.
The design document answering this is #1040.
Decide whether a node type may be defined outside the kernel (#1026) (#…
Name the ToString override a node has to carry (#1042)
Rafael-SOWNet commentedon Aug 24, 2026
#1022's fix (PR #1047) adds two more to the table, taking it from five to seven:
StringizeNode()private protected abstractFunctions/Output/ToString.Definition.csLatexizeNode()private protected abstractFunctions/Output/ToString.Definition.csBoth are a direct consequence of the fix being right: the
domain(...)wrapper is one decision, soit is taken once on
Entityand each node renders only itself. That is the correct shape, and it isthe shape this issue is about — the per-node rendering that was
public overrideand could inprinciple have been supplied from outside is now a member no external type can implement.
Recording it here because it moves the wall rather than merely touching it, and because it is worth
knowing that this seam is still being narrowed while the issue is open. Nothing in #1047 is worse for
an external node type than the existing five made it — a type that cannot implement
Priorityisalready not compiling — but a fix for this issue now has seven members to answer, not five.
One measured detail that may matter to the fix: it was because of those five that removing the 130
per-node
Stringize()/Latexize()overrides in #1047 was safe to do at all. No assembly outside thekernel can have derived a node, so no outside override could be orphaned. Whatever opens this seam
will remove that argument along with the wall.
Rafael-SOWNet commentedon Sep 5, 2026
The count in the title is wrong: it is eight, not five. By reflection over
typeof(Entity)(
Instance | Public | NonPublic | DeclaredOnly,IsAbstract) there are 12 abstract members, ofwhich eight are unreachable from another assembly:
PriorityinternalSortHashName(SortLevel)private protectedIntrinsicConditionprivate protectedToSymPy()internalInvertNode(Entity, Entity)private protectedStringizeNode()private protectedLatexizeNode()private protectedDefaultCodomaininternalPlus
Set.SpecialSet.ToDomain(), which blocks a new special set only and is separable.NodeContract.md's own table already listsDefaultCodomain; its prose still says five.Option A, member by member
Nothing of it is implemented — all eight are abstract at the accessibilities above, and 70
override string ToString() => Stringize();lines remain. Shipping it:Priority→internal virtual, defaulting toPriority.Func. Safe: nothing reads it outsideFunctions/Output/.SortHashName→private protected virtual, the type name at every level.ToSymPy→internal virtual, throwingNotSufficientlySupportedException.InvertNode→private protected virtual, empty.IntrinsicCondition,StringizeNode,LatexizeNode,DefaultCodomain→protected abstract.No correct default exists for a node's domain or its printed form, and
Domainis already public.So: four
protected abstractmembers, four virtual defaults, one sealedToString, and the compiletest from the acceptance criteria. The public surface gains four protected names.
What it does and does not unblock
Does: an external node type — tier 9, #440, the first half of #338.
Does not: moving the LaTeX printer or the SymPy exporter out of the kernel. Those stay per-node
members consumed by
Entity.Latexize()andToSymPy(); making them overridable does not relocatethe 68 kernel implementations. That needs a type-keyed registry or a visitor, which §4 rightly keeps
out of A. Worth saying because it is the reason #1008 was closed without moving any boundary.
Also unsettled by A: 34
switchsites across 10 kernel files whose default arm throws, eachneeding a decision about what an unknown node kind gets; and
EveryNodeSurvivesEveryPipelineTest, which sweeps one assembly and would not see a foreign node.Recommendation
Implement Option A amended to eight members, in one PR with the compile test — and enumerate
those 34 throwing defaults first, stating per site what an unknown node gets. That enumeration is the
measurement §7 says is missing, and without it "a node type can be defined outside the kernel" is
true at compile time and false in use.
A smaller step worth taking first, on its own:
public sealed override string ToString() => Stringize();onEntity. It deletes 70 lines and an undocumented obligation, and it is independentof everything above.
Happypig375 commentedon Sep 18, 2026
A library can either be extensible in its data (classes) or operations (methods/functions). The former encourages inheritance or interface implementation against a fixed set of operations, the latter encourages matching on a fixed data hierarchy. To do both needs a beast of its own in the language like multiple dispatching (Julia) that .NET does not do.
Do we really want an extensible node that our solvers and simplifiers cannot handle effectively? Just tell anyone who needs a new node to PR upstream.