A schema can declare behaviour and semantic types, not just data - #155
Merged
Conversation
A class says what data is. There was no way to say what a program can do, so
an engine API declared in a schema stopped at its components and every
function was written by hand -- which is exactly where a fact about a type
gets stated somewhere other than the schema.
Interfaces close that. A SchemaInterface holds SchemaFunctions, each holding
ordered SchemaParameters and a return type, and a generator turns one into the
header an implementation is written against.
The design question is not how to express a signature; it is how to stop
expressing one. Every interface language that let a signature answer
ownership, lifetime and error handling individually grew annotations until it
was a worse version of the language it described. So four conventions answer
them globally and no signature restates them:
- Can this call fail? The return type is Result. There is no throws.
- May the callee keep this? Handle yes, Span no.
- Who frees it? Nobody: a handle is an identifier, a span is a
borrow valid for the call.
- Is this argument const? direction, which every language can spell.
Validation enforces the corollaries rather than trusting them: an Array
parameter is refused because an array is an owned collection and a borrowed
sequence is a Span; a Result parameter is refused because fallibility
describes a call and not an argument to one; None is refused anywhere
generatable, distinctly from Void, which is a decision rather than an
unfinished edit.
Direction on a Span describes the elements, not the view, which is what makes
the ECS case fall out: `In Span<Velocity>` is `std::span<const Velocity>` and
`Out Span<Position>` is `std::span<Position>`, so a system that reads one
component and writes another is an ordinary signature.
One structural change was needed. A type resolved its class by walking
ParentMember to the schema, and a type in a signature has no member, so every
class reference in an API would have silently resolved to nothing. BaseType
now carries the schema association directly and reference resolution reads
that; the member association still sets it, so ISchemaType.ParentMember and
every existing caller are untouched.
formatVersion moves to 3. Additive: a version 2 file loads as a schema with no
interfaces, so the migration is the existing no-op stamp.
346 tests pass in Schema.Test (334 before, 12 new) and 201 in
SchemaEditor.Test, with 0 warnings across the solution. The SonarCloud
analyzers were run locally against every changed file: 0 findings on any line
this change adds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
An entity id is a number. So is a texture id. Adding one to the other is
nonsense that compiles, and no schema could say otherwise: a member was an
Int or it was not, and every Int was every other Int.
SchemaSemanticType is how the schema says they are different things. Both are
stored as a Long; neither is interchangeable with the other or with a bare
number. A Semantic type refers to one, alongside Object for a class and
Interface for an interface.
What it refuses is the point, and the refusals are conventions the schema
holds rather than settings each declaration restates:
- Crossing into or out of the underlying type is always explicit. A bare
number never becomes an EntityId by accident, and getting the number back
out is a named call.
- A semantic type may refine another, widening implicitly and narrowing
explicitly. A Weight is a ForceMagnitude; not every force is a weight.
Only something whose values would otherwise be interchangeable may be
shimmed. An Object, Interface or Enum is already a distinct type -- naming one
again buys nothing and leaves it ambiguous which conversions apply. An Array,
Span, Handle, Result or Optional describes how a value is carried rather than
what it is, so shimming one crosses two unrelated axes. Both are refused, as
is a refinement cycle, which is represented as nothing.
Some of the six metadata properties belong to a type rather than to a use of
it: a Metres is metres everywhere it appears, so declaring the unit once is
what stops every member restating it. A range is more often contextual and
usually stays on the member. Rather than a second copy of the rules for each,
ISchemaMetadataCarrier is the shape validation reads, so a unit on something
that measures nothing is refused identically wherever it was declared.
formatVersion stays at 3, which this branch introduced and has not shipped.
357 tests pass in Schema.Test (346 before, 11 new) and 201 in
SchemaEditor.Test, with 0 warnings across the solution. The SonarCloud
analyzers were run locally against every changed file: 0 findings on any line
this change adds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
SonarCloud reported fourteen new issues across the two test files this branch adds: MSTEST0037 and MSTEST0068, the newer MSTest analyzer's preference for assertions that name what they check. `Assert.Contains(predicate, issues)` reports which collection did not contain a match; `Assert.IsTrue(issues.Any( predicate))` reports that false was not true. Likewise `Assert.HasCount` and `Assert.AreSequenceEqual`. My local verification missed all fourteen. The filter was `(warning|error) S[0-9]+`, which matches SonarAnalyzer's own rules and nothing else -- and these arrive as `external_roslyn:MSTEST*`. So the claim in the previous two commits that there were no findings on any added line was true only of S-rules, and overstated as written. The filter is not the whole of it: these rules are not reported by a local build at any severity in this repository, so no local run would have shown them. Querying the analysis on the pull request is the check that works, which is what CLAUDE.md already says. This leaves the two new files using assertions the rest of the suite does not. That inconsistency is real, and the alternative -- knowingly adding fourteen more findings of a kind already open twenty times over -- is worse. Converting the existing suite remains a separate change. 357 tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk
|
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.



Two extensions to what a schema can say. Both exist so that a fact about a type stops being stated somewhere other than the schema.
1. Semantic types
An entity id is a number. So is a texture id. Adding one to the other is nonsense that compiles, and no schema could say otherwise — a member was an
Intor it was not, and everyIntwas every otherInt.SchemaSemanticTypesays they are different things: both stored as aLong, neither interchangeable with the other or with a bare number. ASemantictype refers to one, alongsideObjectfor a class.{ "underlyingType": { "TypeName": "Float" }, "unit": "m", "name": "Metres" }What it refuses is the point, and the refusals are conventions the schema holds rather than settings each declaration restates:
EntityIdby accident, and getting the number back out is a named call.Weightis aForceMagnitude; not every force is a weight.Only something whose values would otherwise be interchangeable may be shimmed:
Object,Interface,EnumArray,Span,Handle,Result,Optional,VoidRefinement cycles are reported, because a cycle is represented as nothing.
On metadata: some of the six properties belong to a type rather than a use of it — a
Metresis metres everywhere it appears, so declaring the unit once stops every member restating it. A range is more often contextual and usually stays on the member. Rather than a second copy of the rules for each,ISchemaMetadataCarrieris the shape validation reads, so a unit on something that measures nothing is refused identically wherever it was declared.2. Interfaces, functions and parameters
A class says what data is. There was no way to say what a program can do, so an API declared in a schema stopped at its components and every function was written by hand.
SchemaInterfaceholdsSchemaFunctions, each holding orderedSchemaParameters and a return type, and a generator turns one into the header an implementation is written against.The design question is how to stop expressing things
Every interface language that let a signature answer ownership, lifetime and error handling individually grew annotations until it was a worse version of the language it described. Four conventions answer them globally instead:
ResultthrowsclauseHandleyes,SpannodirectionconstThe cost is real and deliberate: a program wanting two error conventions cannot express the second.
Validation enforces the corollaries rather than trusting them. An
Arrayparameter is refused (an array is owned; a borrowed sequence is aSpan). AResultparameter is refused (fallibility describes a call, not an argument).Noneis refused anywhere generatable, distinctly fromVoid, which is a decision rather than an unfinished edit.Direction on a Span describes the elements, not the view
So an ECS system that reads one component and writes another is an ordinary signature needing no special concept.
One structural change
A type resolved its class by walking
ParentMemberto the schema — and a type in a signature has no member, so every class reference in an API would have silently resolved to nothing.BaseTypenow carries the schema association directly. Additive:ISchemaType.ParentMemberand every existing caller are untouched, and tests pin the new path.New types
Void(returns nothing — a decision, unlikeNone),Interface,Semantic, and four wrappers sharing aWrapperTypebase:Span(a borrow),Handle(an opaque generation-counted reference),Result(a call that can fail),Optional(absence is not failure).Versioning
formatVersionmoves to 3. Additive — a version 2 file loads as a schema with neither interfaces nor semantic types — so the migration is the existing no-op stamp.Verification
dotnet build -c Release— 0 warnings, 0 errors across the solutionSchema.Test(334 before, 23 new), 201 inSchemaEditor.Test, 0 failuresA correction to what the first two commits claimed. They said there were "0 findings on any line this change adds". That was true only of SonarAnalyzer's own
S-rules — the filter used locally was(warning|error) S[0-9]+, and SonarCloud also ingests the MSTest analyzer asexternal_roslyn:MSTEST*. Fourteen of those were reported on the two new test files, and the third commit fixes them:Assert.Contains/Assert.HasCount/Assert.AreSequenceEqualin place ofAssert.IsTrue(…Any(…))and friends, which say what is actually being asserted.Worth recording that these rules are not reported by a local build at any severity in this repository, so no local run would have surfaced them regardless of the filter. Querying the analysis on the PR is the check that works, as
CLAUDE.mdalready says.That leaves the two new files using assertions the rest of the suite does not. That inconsistency is real; knowingly adding fourteen more findings of a kind already open twenty times over seemed worse. Converting the existing suite is a separate change.
Docs
docs/schema-format.mdgains both halves — semantic types with their refusal table, and interfaces with the four conventions — plus the new types and version rows.CLAUDE.mdmatches.What this does not include
Systems (component reads/writes and phase) and events; and the code generation that turns an interface into a C++ header, which belongs in Coder and CodeBlocker rather than here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk