From a4affc259b55b24ff3c8a9325bbc2edfa0b5b731 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 05:37:52 +0000 Subject: [PATCH 1/3] Name the quantity, not the unit [minor] A schema that wanted a mass had to declare one. It wrote a semantic type called Kilograms, put "kg" on it as metadata, and every member of it said the unit twice - once as a name nothing reads and once as text something does. The kilograms are how the stored number is read, which is a fact about the field; the mass is what the value is. ktsu.Semantics already names all of it. 212 physical quantities in C#, the same 212 as C++ classes from ktsu.Semantics.Cpp, and both are things a target already has. So Quantity names one rather than asking for a copy of it - Quantity(Mass), Quantity(Velocity3D), Quantity(Ratio) - and nothing is generated for it. That is the whole difference from Semantic, and both are wanted: a semantic type is the schema's own, a quantity is everybody's. QuantityRegistry reads the vocabulary out of the assembly, the same arrangement UnitRegistry has. A quantity's dimension takes three routes, in order: a magnitude declares one through IPhysicalQuantity; a vector form does not, so its dimension is what Magnitude() answers with; and a named overload of a vector form answers neither, so what is followed is the implicit widening onto what it is an overload of. Six of the 212 are reachable only by that third route, and 212 is not a number chosen here - it is what the C++ projection emits, so the two counts agreeing is what says a schema can name every quantity a C++ target has. The ten it leaves out are the logarithmic scales and two hand-written audio types: a decibel does not add and a pH does not scale, so neither has a dimensional formula for a reflection table to carry. Three things follow from a quantity knowing its own dimension. A unit on one has to agree with it - the check the semantic type could never make, since a unit is text and Kilograms was a name. The exponents are compared and not the names, because 72 of the vocabulary's dimensions share 63 exponent vectors: a joule and a newton metre are the same eight numbers, and refusing what the physics allows is worse than not checking. The reflection table asks the type first. Its eight exponents used to come from the member's unit text and nowhere else, so a member that measured something but named no unit was written down as dimensionless, indistinguishable from a flag. Storage is the rest of the type, since every quantity is generic over one. It defaults to Float and is omitted from the file when it is, exactly as a vector's ElementType is, and it is held to a bare numeric: a generated semantic type is a record struct over a float implementing no INumber, so Mass is not a type anything could write. C# spells it ktsu.Semantics.Quantities.Mass and ClrTypeImporter reads it straight back off the closed type - the one thing generated C# carries that needs no attribute recording what it is. Everything else does, because a sequential struct or a record struct over a float is a shape a hand-written type may have for its own reasons; a Mass is not something a target happened to write, it is the quantity. C++ spells it wherever the target's vocabulary went, which CppGeneratorOptions.Quantities says in one entry for all 212 - the target named none of them, it ran the generator. That entry also says what the vocabulary is stored in, because a C++ quantity is a class rather than a template and the storage was fixed when it was generated: a member the schema keeps in a double is refused on a float target rather than emitted as two languages quietly disagreeing about the bytes. Format version 7. Unlike every step since version 1 this is not something an older reader drops - it fails to deserialize the member at all, on a discriminator it has never heard of - so the version moves to say so instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- CLAUDE.md | 86 ++++- Schema.Cpp.Test/LegacySampleCppTests.cs | 13 + Schema.Cpp.Test/QuantityCppTests.cs | 317 +++++++++++++++++++ Schema.Cpp/CppGeneratorOptions.cs | 11 + Schema.Cpp/CppGeneratorOptionsFile.cs | 21 ++ Schema.Cpp/CppQuantitySpelling.cs | 48 +++ Schema.Cpp/CppReflection.cs | 15 +- Schema.Cpp/CppReflectionBuilder.cs | 34 +- Schema.Cpp/CppTypeMapper.cs | 35 +++ Schema.Cpp/README.md | 8 + Schema.Test/QuantityTypeTests.cs | 348 +++++++++++++++++++++ Schema/Generation/CSharpCodeGenerator.cs | 6 + Schema/Models/ClrTypeImporter.cs | 34 ++ Schema/Models/Metadata/QuantityRegistry.cs | 233 ++++++++++++++ Schema/Models/Names/QuantityName.cs | 16 + Schema/Models/Schema.Validation.cs | 134 +++++++- Schema/Models/Schema.cs | 9 +- Schema/Models/Types/BaseType.cs | 1 + Schema/Models/Types/Quantity.cs | 94 ++++++ Schema/SchemaSerializer.cs | 27 +- docs/schema-format.md | 58 +++- samples/carbonmonoxide.schema.json | 2 +- samples/dungeoneer.schema.json | 2 +- 23 files changed, 1516 insertions(+), 36 deletions(-) create mode 100644 Schema.Cpp.Test/QuantityCppTests.cs create mode 100644 Schema.Cpp/CppQuantitySpelling.cs create mode 100644 Schema.Test/QuantityTypeTests.cs create mode 100644 Schema/Models/Metadata/QuantityRegistry.cs create mode 100644 Schema/Models/Names/QuantityName.cs create mode 100644 Schema/Models/Types/Quantity.cs diff --git a/CLAUDE.md b/CLAUDE.md index 73848b4..d17aee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ SchemaChild (base for named elements) BaseType (types, in ktsu.Schema.Models.Types) ├── Primitives: Int, Long, Float, Double, String, Bool, DateTime, TimeSpan ├── Vectors: Vector2, Vector3, Vector4, ColorRGB, ColorRGBA -├── Complex: Array, Object, Enum, Interface, Semantic, None, Void +├── Complex: Array, Object, Enum, Interface, Semantic, Quantity, None, Void └── Wrappers: Span, Handle, Result, Optional ``` @@ -60,16 +60,18 @@ three metres per second and a position is three metres, and a schema that says o leaves the one fact worth knowing about either of them to a comment - which is the argument `Semantic` makes for a single value, applied to three of them. -**That argument is being overtaken, and by the thing it appeals to.** A consumer generating against -`ktsu.Semantics.Quantities` finds that library already names the 3D forms - `Velocity3D`, -`Displacement3D`, `Force3D` - so a member holding one says `Semantic` naming that type rather than -`Vector3` of a component. Stating it both ways is two spellings of one fact, and the second is the -one both generators can already map, and now map the same way: `CSharpCodeGenerator` writes -`System.Numerics.Vector3` for `Vector3 { ElementType: Float }` and `Runtime.Vector3` for -a `Semantic` element, so a dimensional vector spelled either way reaches C# as a type rather than as -an `object?`. Expect `ElementType` to narrow to "which numeric type", with `Vector2/3/4` meaning -untyped geometry, once that lands. It is documented as it stands rather than as it is heading, -because the schema has not changed. +**That argument has been overtaken, and by the thing it appeals to.** `ktsu.Semantics.Quantities` +already names the 3D forms - `Velocity3D`, `Displacement3D`, `Force3D` - so a member holding one +says `Quantity` naming that type rather than `Vector3` of a component. Saying it both ways is two +spellings of one fact, and the first is the one that needs the schema to describe a shape the +vocabulary has already given a name. So a dimensional vector is a quantity, and `Vector2/3/4` is +untyped geometry: a position on a screen, a pair of texture coordinates, three channels of +something that is not a colour. + +`ElementType` stays as it is rather than narrowing to "which numeric type", because a vector of a +schema's own semantic type is still a thing a schema may want - `Vec3` is not a quantity +and never will be - and both generators already spell it. What changed is which way a *dimensional* +vector is written, not what the property can hold. The component must be a number or a `Semantic` over one; anything else is a collection of things rather than one value with components, which is what `Array` is for. It defaults to `Float` and is @@ -117,6 +119,65 @@ reported elsewhere" rather than walking further. That is what keeps a cycle a re of a recursion with no bottom, and it is why an unresolved semantic type is one message rather than one per property hanging off it. +### Physical quantities + +An entity id is a number and so is a texture id, and `SchemaSemanticType` is how the schema says +they are different things. A *mass* is a different problem: the vocabulary for it already exists. +`ktsu.Semantics.Quantities` declares 212 physical quantities, `ktsu.Semantics.Cpp` emits the same +212 as C++ classes, and both are things a target already has. So the `Quantity` type names one +rather than asking for a copy of it - `Quantity(Mass)`, `Quantity(Velocity3D)`, `Quantity(Ratio)` - +and nothing is generated for it. That is the whole difference from `Semantic`, and both are wanted: +a semantic type is the schema's own, a quantity is everybody's. + +**Name the quantity, not the unit.** What this replaces is a schema declaring a semantic type +called `Kilograms` and writing `"unit": "kg"` beside every member of it - the presentation said +twice, once as a name nothing reads and once as text something does. The kilograms are how the +stored number is read, which is a fact about the field; the mass is what the value *is*. + +`QuantityRegistry` reads the vocabulary out of the assembly, the same arrangement `UnitRegistry` +has and for the same reason. A quantity is a generic `readonly record struct` implementing one of +the five `IVectorN` interfaces, and the arity of that interface says how many components a value +has. Its dimension takes three routes, in order: a magnitude declares one through +`IPhysicalQuantity`; a vector form does not, so its dimension is what `Magnitude()` answers with; +and a *named overload* of a vector form answers neither, so what is followed is the implicit +widening onto what it is an overload of. Six of the 212 are reachable only by that third route, and +212 is not a number chosen here - it is what the C++ projection emits, so the two counts agreeing +is what says a schema can name every quantity a C++ target has. + +The ten it leaves out are the logarithmic scales and two hand-written audio types. A decibel does +not add and a pH does not scale, which is why `ktsu.Semantics` emits them from `logarithmic.json` +rather than as dimensions; they have no dimensional formula and no vector form, so accepting one +would put a type in the schema whose arithmetic means nothing and whose eight exponents a +reflection table would have to invent. + +Three things follow from a quantity knowing its own dimension. + +- **A unit on one has to agree with it.** The check the semantic type could never make: a unit is + text and `Kilograms` was a name, so the two had no way to disagree. The *exponents* are compared + rather than the names, because 72 of the vocabulary's dimensions share 63 exponent vectors - a + joule and a newton metre are the same eight numbers, and refusing what the physics allows is + worse than not checking. +- **The reflection table asks the type first.** The eight exponents used to come from the member's + unit text and nowhere else, so a member that measured something but named no unit was written + down as dimensionless - indistinguishable from a flag. A `Velocity3D` is a length over a time + because of what it is. +- **`Storage` is the rest of the type.** Every quantity is generic over its storage, so a name + alone is not yet something a generator can write. It defaults to `Float` and is omitted from the + file when it is, exactly as a vector's `ElementType` is, and it is held to a bare numeric: a + generated semantic type is a record struct over a float implementing no `INumber`, so + `Mass` is not a type anything could write. + +C# spells it `ktsu.Semantics.Quantities.Mass` and `ClrTypeImporter` reads it straight back +off the closed type - **the one thing generated C# carries that needs no attribute recording what +it is**. Everything else does, because a sequential struct or a record struct over a float is a +shape a hand-written type may have for its own reasons; a `Mass` is not something a target +happened to write, it is the quantity. C++ spells it wherever the target's vocabulary went, which +`CppGeneratorOptions.Quantities` says in one entry for all 212 - the target named none of them, it +ran the generator. That entry also says what the vocabulary is stored in, because a C++ quantity is +a class rather than a template and the storage was fixed when it was generated: a member the schema +keeps in a double is refused on a float target rather than emitted as two languages quietly +disagreeing about the bytes. + ### What a class promises about its representation `SchemaClass.TravelsAsBytes` says an instance is copied whole - across a language boundary, into a @@ -440,6 +501,7 @@ as the property initialiser as well, so a generated instance starts at it. - `Schema/Models/Types/BaseType.cs` - Abstract base with `[JsonDerivedType]` attributes for polymorphic serialization - `Schema/Models/SchemaClass.cs` - Class definitions containing `SchemaMember` collections - `Schema/Models/ClrTypeImporter.cs` - Reads a .NET type into a schema; the exact inverse of the C# generator +- `Schema/Models/Metadata/QuantityRegistry.cs` - The 212 quantities a schema may name, read out of the assembly that declares them rather than listed here - `Schema/Runtime/SchemaMetadataAttributes.cs` - What generated code carries that its C# types cannot say - `Schema.Editor/SchemaEditor.cs` - Main editor application using `ktsu.ImGui.App` - `Schema.Editor/MemberGridPanel.cs` - The grid of member rows: add, reorder, retype, remove, and the two folds each row opens @@ -470,7 +532,7 @@ own delegate does. ## Dependencies - **ktsu.Semantics.Strings/Paths** - Type-safe string and path wrappers -- **ktsu.Semantics.Quantities** - The units a member's values can be measured in +- **ktsu.Semantics.Quantities** - The quantities a member can hold, and the units its values can be measured in - **ktsu.ImGui.App/Widgets/Popups** - ImGui application framework (editor only) - **ktsu.AppDataStorage** - Persistent settings storage (editor only) - **Polyfill** - .NET compatibility shims for multi-targeting diff --git a/Schema.Cpp.Test/LegacySampleCppTests.cs b/Schema.Cpp.Test/LegacySampleCppTests.cs index 82b4059..285bccb 100644 --- a/Schema.Cpp.Test/LegacySampleCppTests.cs +++ b/Schema.Cpp.Test/LegacySampleCppTests.cs @@ -109,6 +109,7 @@ public void EveryElementGetsItsOwnHeader() Vector4 = new CppTypeSpelling("sample::Vector4", LocalHeader), ColorRgb = new CppTypeSpelling("sample::ColorRgb", LocalHeader), ColorRgba = new CppTypeSpelling("sample::ColorRgba", LocalHeader), + Quantities = new CppQuantitySpelling("sample", LocalHeader), }; /// @@ -120,6 +121,11 @@ public void EveryElementGetsItsOwnHeader() /// class holds these, so if any of them were not trivially copyable the assertion the /// generator writes beside that class would fail to compile -- which is the point of compiling /// this at all. + /// + /// The four quantities are here for the same reason and stand in for the same thing a real + /// target would have: ktsu.Semantics.Cpp emits all 212 of them, and a schema that names + /// one is naming a class the program already has rather than asking for one. + /// /// private const string SampleTypes = """ #pragma once @@ -132,6 +138,13 @@ namespace sample struct ColorRgb { float r, g, b; }; struct ColorRgba { float r, g, b, a; }; + + // Standing in for what ktsu.Semantics.Cpp emits: a class per quantity, holding one + // float for a magnitude or a signed scalar and nothing else. + class Mass { public: float value; }; + class Radius { public: float value; }; + class Speed { public: float value; }; + class Acceleration1D { public: float value; }; } """; diff --git a/Schema.Cpp.Test/QuantityCppTests.cs b/Schema.Cpp.Test/QuantityCppTests.cs new file mode 100644 index 0000000..3b6f7af --- /dev/null +++ b/Schema.Cpp.Test/QuantityCppTests.cs @@ -0,0 +1,317 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Cpp.Test; + +using System.Diagnostics; + +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Schema.Models.Types; +using ktsu.Semantics.Strings; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// A member holding one of ktsu.Semantics.Quantities' physical quantities, in C++. +/// +/// +/// +/// The C++ side of a quantity is the side where it stops resembling a semantic type. A semantic +/// type is a class this generator writes; a quantity is a class the target already has, because +/// ktsu.Semantics.Cpp emitted all 212 of them into the target's tree. So what a target says +/// is where it put them - one entry for the whole vocabulary, since it named none of them. +/// +/// +/// And the storage is the one thing the two languages can disagree about. C# closes +/// Mass<T> per member; a C++ Mass is a class rather than a template, fixed at +/// whatever the vocabulary was generated over. A member the schema stores in a double therefore +/// has no C++ spelling on a target whose quantities are floats, and saying so is the difference +/// between a refusal and two languages quietly disagreeing about the bytes. +/// +/// +[TestClass] +public sealed class QuantityCppTests +{ + private const string VocabularyHeader = "\"quantities.hpp\""; + + /// + /// What a target that ran the vocabulary generator says for itself. + /// + private static CppGeneratorOptions TargetOptions { get; } = new() + { + Quantities = new CppQuantitySpelling("sample", VocabularyHeader), + }; + + /// + /// The smallest thing that can stand in for a generated vocabulary. + /// + /// + /// Three classes over a float and one over three, each an aggregate and nothing else - which + /// is what the real ones are, and what has to be true for a class promising to travel as bytes + /// to hold one. If any of these were not trivially copyable the assertion the generator writes + /// beside that class would fail to compile, which is what makes compiling this worth doing. + /// + private const string Vocabulary = """ + #pragma once + + namespace sample + { + class Mass { public: float value; }; + class Ratio { public: float value; }; + class Heading { public: float value; }; + class Velocity3D { public: float x, y, z; }; + } + """; + + /// + /// A quantity is written where the target's vocabulary put it, and nothing is emitted for it. + /// + [TestMethod] + public void ItIsNamedWhereTheTargetPutIt() + { + IReadOnlyDictionary files = Generate(Body(), TargetOptions); + + Assert.ContainsSingle(files, "a quantity is named, not generated, so there is one header and it is the class's"); + Assert.Contains("sample::Mass", files.Values.Single(), StringComparison.Ordinal); + Assert.Contains($"#include {VocabularyHeader}", files.Values.Single(), StringComparison.Ordinal); + } + + /// + /// A target with no quantities is refused them by name, with the option to set. + /// + /// + /// The same treatment a missing vector gets, and for the same reason: a header naming a type + /// the target does not have is worse than being told which option would give it one. + /// + [TestMethod] + public void ATargetWithNoVocabularyIsRefused() + { + CppGenerationException refusal = Assert.ThrowsExactly( + () => Generate(Body(), new CppGeneratorOptions())); + + Assert.Contains("Mass", refusal.Message, StringComparison.Ordinal); + Assert.Contains("quantities", refusal.Message, StringComparison.Ordinal); + } + + /// + /// A storage the target's vocabulary was not generated over is refused. + /// + /// + /// The failure this prevents is the quiet one. Generated C# would say + /// Mass<double>, generated C++ would say sample::Mass, both would compile, + /// and a class promising to travel as bytes would be eight bytes in one language and four in + /// the other - which is the whole class of bug the enum width and the bool marshalling were + /// already spelled out to prevent. + /// + [TestMethod] + public void AStorageTheVocabularyDoesNotHaveIsRefused() + { + Schema schema = Body(new Quantity + { + QuantityName = "Mass".As(), + Storage = new Models.Types.Double(), + }); + + CppGenerationException refusal = Assert.ThrowsExactly( + () => Generate(schema, TargetOptions)); + + Assert.Contains("Double", refusal.Message, StringComparison.Ordinal); + Assert.Contains("Float", refusal.Message, StringComparison.Ordinal); + } + + /// + /// A target whose vocabulary is doubles takes the member the float target refused. + /// + [TestMethod] + public void AVocabularyOfDoublesTakesADoubleMember() + { + Schema schema = Body(new Quantity + { + QuantityName = "Mass".As(), + Storage = new Models.Types.Double(), + }); + + IReadOnlyDictionary files = Generate( + schema, + new CppGeneratorOptions { Quantities = new CppQuantitySpelling("sample", VocabularyHeader, "Double") }); + + Assert.Contains("sample::Mass", files.Values.Single(), StringComparison.Ordinal); + } + + /// + /// A storage that is not a number at all is refused when the file is read, not when a schema + /// is generated. + /// + [TestMethod] + public void AVocabularyStoredInNothingIsRefusedByTheOptionsFile() + { + Assert.IsFalse( + CppGeneratorOptionsFile.TryParse( + """{ "quantities": { "namespace": "sample", "include": "", "storage": "flaot" } }""", + out CppGeneratorOptions? _, + out string? message)); + + Assert.Contains("flaot", message!, StringComparison.Ordinal); + } + + /// + /// The options file carries the whole vocabulary in one entry. + /// + [TestMethod] + public void TheOptionsFileSaysWhereTheVocabularyIs() + { + Assert.IsTrue( + CppGeneratorOptionsFile.TryParse( + """{ "quantities": { "namespace": "holo", "include": "" } }""", + out CppGeneratorOptions? options, + out string? _)); + + Assert.AreEqual("holo::Mass", options!.Quantities!.Qualified("Mass")); + Assert.AreEqual("Float", options.Quantities.Storage); + } + + /// + /// A class of quantities that promises to travel as bytes compiles, assertion and all. + /// + /// + /// The static_assert(std::is_trivially_copyable_v<T>) the generator writes beside + /// a promising class is what checks the claim, and it is checked by the compiler rather than + /// by this test - which is the whole reason the generated headers are compiled instead of + /// merely inspected. + /// + [TestMethod] + public void AClassOfQuantitiesTravelsAsBytes() + { + Schema schema = new(); + SchemaClass body = schema.AddClass("Body".As())!; + body.TravelsAsBytes = true; + + foreach (string quantity in new[] { "Mass", "Ratio", "Heading", "Velocity3D" }) + { + body.AddMember(quantity.As())! + .SetType(new Quantity { QuantityName = quantity.As() }); + } + + Assert.IsEmpty(schema.Validate()); + + AssertCompiles(Configured(schema), TargetOptions with { Reflection = true }); + } + + /// + /// The reflection table takes a quantity's dimension from the quantity, with no unit written. + /// + /// + /// This is what the quantity adds that a unit never could. The table's eight exponents used to + /// come from the member's unit text and from nowhere else, so a member that measured something + /// but named no unit was written down as dimensionless - indistinguishable from a flag. A + /// Velocity3D is a length over a time because of what it is, so the table says so + /// whether or not anyone wrote m/s beside it. + /// + [TestMethod] + public void TheTableTakesTheDimensionFromTheQuantity() + { + Schema schema = new(); + schema.AddClass("Body".As())!.AddMember("Velocity".As())! + .SetType(new Quantity { QuantityName = "Velocity3D".As() }); + + IReadOnlyDictionary files = Generate(Configured(schema), TargetOptions with { Reflection = true }); + string table = files.Single(file => file.Key.Contains("reflection", StringComparison.Ordinal)).Value; + + // length 1, time -1, and nothing on the other six axes. + Assert.Contains("{ 1, 0, -1, 0, 0, 0, 0, 0 }", table, StringComparison.Ordinal); + } + + /// + /// A schema of one class holding one quantity. + /// + private static Schema Body(Quantity? quantity = null) + { + Schema schema = new(); + + schema.AddClass("Body".As())!.AddMember("Value".As())! + .SetType(quantity ?? new Quantity { QuantityName = "Mass".As() }); + + return Configured(schema); + } + + private static Schema Configured(Schema schema) + { + schema.AddCodeGenerator("Cpp".As())!.Namespace = "sample".As(); + + return schema; + } + + private static IReadOnlyDictionary Generate(Schema schema, CppGeneratorOptions options) => + new CppCodeGenerator(options).Generate(schema, schema.GetCodeGenerator("Cpp".As())!); + + /// + /// Emits a schema and compiles every header it wrote as one translation unit. + /// + private static void AssertCompiles(Schema schema, CppGeneratorOptions options) + { + IReadOnlyDictionary files = Generate(schema, options); + string directory = Path.Join(Path.GetTempPath(), $"schema-quantity-{Guid.NewGuid():N}"); + + Directory.CreateDirectory(directory); + + try + { + foreach ((string name, string text) in files) + { + File.WriteAllText(Path.Join(directory, name), text); + } + + File.WriteAllText(Path.Join(directory, "quantities.hpp"), Vocabulary); + + string includes = string.Concat(files.Keys.Order(StringComparer.Ordinal) + .Select(name => $"#include \"{name}\"\n")); + + File.WriteAllText(Path.Join(directory, "all.cpp"), $"{includes}\nint main() {{ return 0; }}\n"); + + (int exitCode, string output) = Compile(directory, "all.cpp"); + + Assert.AreEqual(0, exitCode, $"the generated C++ should compile:\n{output}"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static (int ExitCode, string Output) Compile(string directory, string file) + { + string? compiler = Find("g++") ?? Find("clang++"); + + if (compiler is null) + { + Assert.Inconclusive("no C++ compiler on PATH, so the generated headers were not compiled."); + } + + using Process process = new() + { + StartInfo = new ProcessStartInfo(compiler!) + { + WorkingDirectory = directory, + RedirectStandardError = true, + RedirectStandardOutput = true, + }, + }; + + foreach (string argument in (string[])["-std=c++20", "-Wall", "-Wextra", "-fsyntax-only", "-I.", file]) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.Start(); + string output = process.StandardError.ReadToEnd() + process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + return (process.ExitCode, output); + } + + private static string? Find(string executable) => + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(directory => Path.Join(directory, Path.GetFileName(executable))) + .FirstOrDefault(File.Exists); +} diff --git a/Schema.Cpp/CppGeneratorOptions.cs b/Schema.Cpp/CppGeneratorOptions.cs index 9fc100b..13ecb30 100644 --- a/Schema.Cpp/CppGeneratorOptions.cs +++ b/Schema.Cpp/CppGeneratorOptions.cs @@ -87,6 +87,17 @@ public sealed record CppGeneratorOptions /// public CppTypeSpelling? DateTime { get; init; } + /// + /// Gets where the target's physical quantities live, and what they are stored in. + /// + /// + /// Null when the target has none, which refuses a by name + /// the way a missing vector refuses a . One entry covers the + /// whole vocabulary rather than one per quantity, because the target did not name them: it ran + /// ktsu.Semantics.Cpp, which names them what the schema does. + /// + public CppQuantitySpelling? Quantities { get; init; } + /// /// Gets the semantic types the target already declares, keyed by the name the schema gives /// them. diff --git a/Schema.Cpp/CppGeneratorOptionsFile.cs b/Schema.Cpp/CppGeneratorOptionsFile.cs index da3f876..2d55de5 100644 --- a/Schema.Cpp/CppGeneratorOptionsFile.cs +++ b/Schema.Cpp/CppGeneratorOptionsFile.cs @@ -139,10 +139,31 @@ private static bool IsSpelled(CppGeneratorOptions options, [NotNullWhen(false)] } } + if (options.Quantities is CppQuantitySpelling quantities && + !QuantityStorages.Contains(quantities.Storage)) + { + // Left to the generator this would refuse every quantity in the schema with a message + // about the schema, which is not where the mistake is. + message = + $"'{NameOf(nameof(CppGeneratorOptions.Quantities))}.storage' is '{quantities.Storage}'. " + + $"A quantity is stored in a number: {string.Join(", ", QuantityStorages)}."; + return false; + } + message = null; return true; } + /// + /// The schema types a target's quantities may be stored in. + /// + /// + /// Named as the schema names them rather than as C++ spells them, because what this is + /// compared against is Quantity.Storage, and comparing the two through a translation + /// table would be a third place for them to disagree. + /// + private static readonly string[] QuantityStorages = ["Float", "Double", "Int", "Long"]; + /// /// Takes out of a host's arguments, reading the file it names. /// diff --git a/Schema.Cpp/CppQuantitySpelling.cs b/Schema.Cpp/CppQuantitySpelling.cs new file mode 100644 index 0000000..be9df2e --- /dev/null +++ b/Schema.Cpp/CppQuantitySpelling.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Cpp; + +/// +/// Where a target's physical quantities live, and what they are stored in. +/// +/// +/// +/// One entry for 212 types, which is what makes a quantity different from every other spelling +/// this generator is handed. A names one type because the target +/// wrote one type; the quantities are a vocabulary ktsu.Semantics.Cpp emits whole, so what +/// a target has to say is where it put them, not what it called each one. It called each one what +/// the schema calls it. +/// +/// +/// is the fact the schema and the target can disagree about. C# spells a +/// quantity Mass<float> and closes it per member; the C++ vocabulary is generated +/// over one numeric type and holo::Mass is a class rather than a template, so a member the +/// schema says is a double has no C++ spelling on a target whose quantities are floats. Saying +/// which one the target generated is what lets that be refused rather than emitted as two +/// languages quietly disagreeing about the bytes. +/// +/// +/// +/// The namespace the quantities were generated into, unqualified and without trailing colons; +/// empty for a target that generated them at global scope. +/// +/// +/// The header that declares them. The vocabulary ships an umbrella header, so this is one include +/// rather than one per quantity; written with angle brackets for a system header and quoted +/// otherwise, as every other include here is. +/// +/// +/// The schema type the target's quantities are stored in, named as the schema names it - +/// Float, Double, Int or Long. Defaults to Float, which is what +/// ktsu.Semantics.Cpp generates unless told otherwise. +/// +public sealed record CppQuantitySpelling(string Namespace, string Include, string Storage = "Float") +{ + /// + /// Gets how a quantity of this vocabulary is written. + /// + /// The quantity's name, as the schema writes it. + /// The qualified C++ type name. + public string Qualified(string name) => + string.IsNullOrEmpty(Namespace) ? name : $"{Namespace}::{name}"; +} diff --git a/Schema.Cpp/CppReflection.cs b/Schema.Cpp/CppReflection.cs index d52397c..be77cfc 100644 --- a/Schema.Cpp/CppReflection.cs +++ b/Schema.Cpp/CppReflection.cs @@ -7,6 +7,7 @@ namespace ktsu.Schema.Cpp; using System.Reflection; using System.Text.Json.Serialization; +using ktsu.Schema.Models; using ktsu.Schema.Models.Metadata; using ktsu.Schema.Models.Types; @@ -125,10 +126,16 @@ internal static string Kind(BaseType type) => /// /// The type. /// The enumerator, qualified by the enumeration. - internal static string Representation(BaseType type) => - Ensure.NotNull(type) is Semantic semantic && semantic.Declaration?.Representation() is BaseType stored - ? Kind(stored) - : Kind(type); + internal static string Representation(BaseType type) => Ensure.NotNull(type) switch + { + Semantic { Declaration: SchemaSemanticType declaration } => Kind(declaration.Representation()), + + // A quantity says what it is stored in directly, so there is no chain to walk: a + // Velocity3D over a float is three floats, and the table's reader needs the float. + Quantity quantity => Kind(quantity.Storage), + + _ => Kind(type), + }; /// /// How a member's interpolation is named in the table. diff --git a/Schema.Cpp/CppReflectionBuilder.cs b/Schema.Cpp/CppReflectionBuilder.cs index 58069c2..002c459 100644 --- a/Schema.Cpp/CppReflectionBuilder.cs +++ b/Schema.Cpp/CppReflectionBuilder.cs @@ -7,6 +7,7 @@ namespace ktsu.Schema.Cpp; using ktsu.Schema.Models.Metadata; using ktsu.Semantics.Quantities; +using Quantity = ktsu.Schema.Models.Types.Quantity; using SchemaEnumType = ktsu.Schema.Models.Types.Enum; /// @@ -229,20 +230,26 @@ private ConstructionExpression Described(SchemaClass schemaClass, SchemaMember m } /// - /// The exponents of the unit the member names, or all zeroes when it names none. + /// The exponents the member measures in, or all zeroes when it measures nothing. /// /// - /// A member that measures nothing — an identifier, a name, a flag — is dimensionless, and so is - /// a member whose unit does not resolve. The second is not silently the same as the first: - /// Schema.Validate reports an unresolvable unit, so reaching here with one means the - /// schema was generated without being validated first. + /// + /// The member's declared type answers first, and only a can: a + /// Velocity3D is a length over a time whether or not anyone wrote a unit beside it. So + /// this is the one kind of member whose dimension is not a restatement of its unit, and + /// Schema.Validate is what keeps the two from disagreeing when both are there. + /// + /// + /// Otherwise the unit answers. A member that measures nothing — an identifier, a name, a flag — + /// is dimensionless, and so is a member whose unit does not resolve. The second is not silently + /// the same as the first: Schema.Validate reports an unresolvable unit, so reaching here + /// with one means the schema was generated without being validated first. + /// /// private static ConstructionExpression Dimension(SchemaMember member) { ConstructionExpression dimension = new(); - Dictionary formula = member.TryResolveUnit(out IUnit? unit, out _) && unit is not null - ? unit.Dimension.DimensionalFormula - : new Dictionary(StringComparer.Ordinal); + Dictionary formula = Measured(member); foreach (string axis in Axes) { @@ -252,6 +259,17 @@ private static ConstructionExpression Dimension(SchemaMember member) return dimension; } + /// + /// The dimensional formula a member's values have, from its type where its type knows and from + /// its unit otherwise. + /// + private static Dictionary Measured(SchemaMember member) => + member.Type is Quantity { Resolved: QuantityRegistry.QuantityInfo quantity } + ? quantity.Dimension.DimensionalFormula + : member.TryResolveUnit(out IUnit? unit, out _) && unit is not null + ? unit.Dimension.DimensionalFormula + : new Dictionary(StringComparer.Ordinal); + private static ConstructionExpression Range(MemberRange? range) { ConstructionExpression written = new(); diff --git a/Schema.Cpp/CppTypeMapper.cs b/Schema.Cpp/CppTypeMapper.cs index dbef795..7e1088a 100644 --- a/Schema.Cpp/CppTypeMapper.cs +++ b/Schema.Cpp/CppTypeMapper.cs @@ -58,6 +58,7 @@ internal sealed class CppTypeMapper(Models.Schema schema, CppGeneratorOptions op SchemaTypes.Object objectType => Generated(objectType.ClassName.ToString()), SchemaTypes.Interface interfaceType => Generated(interfaceType.InterfaceName.ToString()), Semantic semantic => MapSemantic(semantic), + SchemaTypes.Quantity quantity => MapQuantity(quantity), SchemaTypes.Array arrayType => Generic(Fixed("std::vector", "vector"), arrayType.ElementType), Span span => Generic(Fixed("std::span", "span"), span.ElementType), @@ -135,11 +136,45 @@ public void Require(string include) { Bool or Int or Long or Float or Double => true, SchemaTypes.Enum or SchemaTypes.Handle => true, + + // A magnitude or a signed scalar is one number under a name; a vector form is two to four, + // which is where borrowing starts to pay, so this reads the shape rather than assuming. + SchemaTypes.Quantity quantity => quantity.Resolved?.Components <= 1, Semantic { Declaration: SchemaSemanticType declaration } => declaration.Representation() is not Semantic && IsPassedByValue(declaration.Representation()), _ => false, }; + /// + /// Names a quantity where the target's vocabulary put it. + /// + /// + /// Nothing is generated for it, which is the whole of what separates it from the semantic type + /// below: a semantic type is a class this generator writes, and a quantity is one the target + /// already has because it ran the same vocabulary generator this schema reads its names from. + /// + private TypeReference MapQuantity(SchemaTypes.Quantity quantity) + { + if (options.Quantities is not CppQuantitySpelling spelling) + { + throw new CppGenerationException( + $"The schema holds a {quantity.QuantityName}, and this target declares no physical quantities. Set '{CppGeneratorOptionsFile.NameOf(nameof(CppGeneratorOptions.Quantities))}' in the {CppGeneratorOptionsFile.Option} file, or {nameof(CppGeneratorOptions)}.{nameof(CppGeneratorOptions.Quantities)} in a host, to where ktsu.Semantics.Cpp generated them."); + } + + // A C++ quantity is a class and not a template, so the storage was fixed when the + // vocabulary was generated. C# closes one per member, so the two can disagree - and two + // languages disagreeing about the width of a member is exactly what a class promising to + // travel as bytes cannot survive. + if (!string.Equals(quantity.Storage.TypeName, spelling.Storage, StringComparison.Ordinal)) + { + throw new CppGenerationException( + $"The schema stores a {quantity.QuantityName} in a {quantity.Storage.TypeName}, and this target's quantities are generated over {spelling.Storage}. A C++ quantity is a class rather than a template, so there is no {spelling.Storage} vocabulary and a {quantity.Storage.TypeName} one to choose between."); + } + + Require(spelling.Include); + return new TypeReference(spelling.Qualified(quantity.QuantityName.ToString())); + } + private TypeReference MapSemantic(Semantic semantic) { string name = semantic.SemanticTypeName.ToString(); diff --git a/Schema.Cpp/README.md b/Schema.Cpp/README.md index 886c386..b573514 100644 --- a/Schema.Cpp/README.md +++ b/Schema.Cpp/README.md @@ -71,6 +71,14 @@ ExistingTypes = new Dictionary(StringComparer.Ordinal) } ``` +`Quantities` is the third kind of answer, and the one that covers a whole vocabulary in a line. A schema member may hold one of `ktsu.Semantics.Quantities`' 212 physical quantities, and if your program ran `ktsu.Semantics.Cpp` it already has all of them - so what you say is where they went, not what each one is called. It called each one what the schema calls it: + +```csharp +Quantities = new CppQuantitySpelling("holo", ""), +``` + +A third argument says what your vocabulary is stored in, `Float` by default. C# closes `Mass` per member and a C++ `Mass` is a class rather than a template, so a member the schema keeps in a double has no spelling on a target whose quantities are floats. Saying which one you generated is what makes that a refusal rather than two languages quietly disagreeing about the bytes. + The rest of `CppGeneratorOptions` is cosmetic: `HeaderExtension` (`.gen.hpp` by default), `MemberNaming` (`snake_case` by default), and `GeneratedBy`, the name a generated file's banner opens with - which a target that vendors this behind its own build step should set to the thing a reader would run again. ## Conventions the generated headers keep diff --git a/Schema.Test/QuantityTypeTests.cs b/Schema.Test/QuantityTypeTests.cs new file mode 100644 index 0000000..727bdd1 --- /dev/null +++ b/Schema.Test/QuantityTypeTests.cs @@ -0,0 +1,348 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Tests; + +using System.Reflection; + +using ktsu.Schema.Generation; +using ktsu.Schema.Models; +using ktsu.Schema.Models.Metadata; +using ktsu.Schema.Models.Names; +using ktsu.Schema.Models.Types; +using ktsu.Semantics.Quantities; +using ktsu.Semantics.Strings; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// A member holding one of ktsu.Semantics.Quantities' physical quantities. +/// +/// +/// +/// The difference from is whose vocabulary it is. A semantic type is how a +/// schema says that its own two numbers are different things, and nothing outside the schema has +/// heard of either; a quantity names something both generators already emit, so a member saying +/// Quantity(Mass) reaches a type the target already has rather than asking for a copy of +/// one. +/// +/// +/// What that replaces is a schema declaring a semantic type called Kilograms and writing +/// kg beside every member of it - the unit said twice, once as a name nothing reads and +/// once as text something does. A quantity says the mass and leaves the kilograms to the member, +/// which is the one place the presentation is a fact. +/// +/// +[TestClass] +public sealed class QuantityTypeTests +{ + /// + /// The whole vocabulary is nameable, and it is the vocabulary the C++ projection emits. + /// + /// + /// 212 is not a number this repository chose: it is what ktsu.Semantics.Cpp writes - + /// 148 magnitudes, 27 signed scalars and 37 vectors - and the two counts agreeing is what says + /// a schema can name every quantity a C++ target has. It was 206 while six named vector + /// overloads went unregistered, which is the case + /// pins directly. + /// + [TestMethod] + public void TheVocabularyIsTheOneBothGeneratorsHave() => + Assert.HasCount(212, QuantityRegistry.All); + + /// + /// A magnitude, a signed scalar and a vector each report their own shape. + /// + [TestMethod] + public void AQuantityKnowsHowManyComponentsItHas() + { + Assert.AreEqual(0, Resolve("Mass").Components); + Assert.AreEqual(1, Resolve("Heading").Components); + Assert.AreEqual(3, Resolve("Velocity3D").Components); + } + + /// + /// A vector form's dimension comes from the magnitude it answers with. + /// + /// + /// IVectorN above zero declares components and no dimension, so a Velocity3D + /// carries none of its own. What it does carry is Magnitude(), and the magnitude of a + /// velocity is a length over a time - which is not an inference but the relationship the + /// vocabulary is built on, since the sum of the squares of the components has twice a + /// component's dimension and the square root halves it again. + /// + [TestMethod] + public void AVectorFormTakesItsDimensionFromItsMagnitude() + { + DimensionInfo velocity = Resolve("Velocity3D").Dimension; + + Assert.AreEqual("Velocity", velocity.Name); + Assert.AreEqual(1, velocity.DimensionalFormula["length"]); + Assert.AreEqual(-1, velocity.DimensionalFormula["time"]); + } + + /// + /// A named overload of a vector form is a quantity, reached through the widening it declares. + /// + /// + /// Position3D is a Displacement3D under another name and the vocabulary gives it + /// no Magnitude() of its own, so the only thing left to follow is the implicit + /// conversion onto what it is an overload of. Six quantities are reachable only this way, and + /// without it a schema naming any of them would be told its own vocabulary does not have it. + /// + [TestMethod] + public void ANamedVectorOverloadIsAQuantity() + { + foreach (string name in new[] + { "Position3D", "Translation3D", "WindVelocity3D", "GravitationalField3D", "WeightVector", "ThrustVector" }) + { + Assert.AreEqual(3, Resolve(name).Components, name); + Assert.IsNotNull(Resolve(name).Dimension, name); + } + } + + /// + /// A logarithmic scale is not a quantity, and is not nameable as one. + /// + /// + /// A decibel does not add and a pH does not scale, which is why ktsu.Semantics emits + /// them from logarithmic.json rather than as dimensions: they have no dimensional + /// formula and no vector form. Accepting one here would put a type in the schema whose + /// arithmetic means nothing, and whose eight exponents a reflection table would have to invent. + /// + [TestMethod] + public void ALogarithmicScaleIsNotOne() + { + foreach (string name in new[] { "Decibels", "SoundPressureLevel", "Cents", "Semitones", "PH" }) + { + Assert.IsFalse(QuantityRegistry.TryResolve(name, out _), name); + } + } + + /// + /// A name the vocabulary does not have is refused, and said to be. + /// + [TestMethod] + public void AnUnknownQuantityIsReported() + { + Schema schema = WithMember(new Quantity { QuantityName = "Squiggles".As() }); + + Assert.ContainsSingle( + schema.Validate().Where(issue => + issue.Severity == SchemaValidationSeverity.Error && + issue.Message.Contains("Squiggles", StringComparison.Ordinal))); + } + + /// + /// A quantity is stored in a number, and nothing else. + /// + /// + /// Tighter than the rule a vector's components get, which accept a semantic type over a + /// number. A quantity is generic under where T : struct, INumber<T> and a + /// generated semantic type is a record struct over a float implementing no such thing, so + /// Mass<Kilograms> is not a type anything could write. Refusing it is the schema + /// saying so rather than the generator emitting C# that will not compile. + /// + [TestMethod] + public void AQuantityIsStoredInANumber() + { + Schema schema = WithMember(new Quantity + { + QuantityName = "Mass".As(), + Storage = new Models.Types.String(), + }); + + Assert.ContainsSingle( + schema.Validate().Where(issue => + issue.Severity == SchemaValidationSeverity.Error && + issue.Message.Contains("stored in a", StringComparison.Ordinal))); + } + + /// + /// A unit that measures something else is a contradiction, and is reported as one. + /// + /// + /// The check the semantic type this replaces could never make. A unit is text resolved through + /// UnitRegistry and a type called Kilograms is a name nothing reads, so the two + /// had no way to disagree; a quantity knows its own eight exponents and so does the unit, so + /// the contradiction is arithmetic. + /// + [TestMethod] + public void AUnitHasToMeasureWhatTheQuantityMeasures() + { + Schema schema = WithMember( + new Quantity { QuantityName = "Mass".As() }, + member => member.Unit = "m".As()); + + Assert.ContainsSingle( + schema.Validate().Where(issue => + issue.Severity == SchemaValidationSeverity.Error && + issue.Message.Contains("Mass", StringComparison.Ordinal))); + } + + /// + /// A unit that measures what the quantity measures is accepted. + /// + [TestMethod] + public void AUnitOfTheRightDimensionIsAccepted() + { + Schema schema = WithMember( + new Quantity { QuantityName = "Mass".As() }, + member => member.Unit = "kg".As()); + + Assert.IsEmpty(schema.Validate()); + } + + /// + /// Two names for one set of exponents are both good units for either. + /// + /// + /// 72 of the vocabulary's dimensions share 63 exponent vectors - Torque and + /// Energy are one vector between two names - so the check compares the exponents and + /// not the names. A joule and a newton metre are the same eight numbers, and a schema holding + /// a torque in joules is saying nothing the physics refuses. + /// + [TestMethod] + public void ADimensionSharedBetweenTwoNamesIsNotAContradiction() + { + Schema schema = WithMember( + new Quantity { QuantityName = "TorqueMagnitude".As() }, + member => member.Unit = "J".As()); + + Assert.IsEmpty(schema.Validate()); + } + + /// + /// A quantity travels as bytes, because its storage does. + /// + [TestMethod] + public void AClassThatTravelsAsBytesMayHoldOne() + { + Schema schema = new(); + SchemaClass promising = schema.AddClass("Body".As())!; + promising.TravelsAsBytes = true; + promising.AddMember("Mass".As())! + .SetType(new Quantity { QuantityName = "Mass".As() }); + promising.AddMember("Velocity".As())! + .SetType(new Quantity { QuantityName = "Velocity3D".As() }); + + Assert.IsEmpty(schema.Validate()); + } + + /// + /// The storage is written only when it is not the default, so a file gains nothing by saying + /// what it already meant. + /// + [TestMethod] + public void TheDefaultStorageIsOmittedFromTheFile() + { + string floats = SchemaSerializer.Serialize( + WithMember(new Quantity { QuantityName = "Mass".As() })); + + string doubles = SchemaSerializer.Serialize(WithMember(new Quantity + { + QuantityName = "Mass".As(), + Storage = new Models.Types.Double(), + })); + + Assert.DoesNotContain("storage", floats, StringComparison.OrdinalIgnoreCase); + Assert.Contains("storage", doubles, StringComparison.OrdinalIgnoreCase); + } + + /// + /// A quantity survives being written and read back. + /// + [TestMethod] + public void ItRoundTripsThroughTheFile() + { + Schema original = WithMember(new Quantity + { + QuantityName = "Velocity3D".As(), + Storage = new Models.Types.Double(), + }); + + Assert.IsTrue(SchemaSerializer.TryDeserialize(SchemaSerializer.Serialize(original), out Schema? reloaded)); + + BaseType reloadedType = reloaded!.GetClass("Body".As())! + .GetMember("Value".As())!.Type; + + Assert.IsInstanceOfType(reloadedType); + Assert.AreEqual("Velocity3D", ((Quantity)reloadedType).QuantityName.ToString()); + Assert.IsInstanceOfType(((Quantity)reloadedType).Storage); + } + + /// + /// It reaches C# as the quantity, closed over its storage, with nothing emitted for it. + /// + /// + /// This is the whole of what makes it different from a semantic type on the generated side. A + /// semantic type is a file the generator writes; a quantity is a name it references, because + /// the target already has the type. + /// + [TestMethod] + public void ItGeneratesAsTheQuantityItNames() + { + Schema schema = WithMember(new Quantity + { + QuantityName = "Velocity3D".As(), + Storage = new Models.Types.Double(), + }); + + IReadOnlyDictionary files = + new CSharpCodeGenerator().Generate(schema, CodeGenerationTests.ConfigureGenerator(schema)); + + Assert.ContainsSingle(files); + Assert.Contains("ktsu.Semantics.Quantities.Velocity3D", files.Values.Single(), StringComparison.Ordinal); + } + + /// + /// Generated, compiled and reimported, it is the quantity it started as. + /// + /// + /// The one type here that reads back with no attribute recording what it is. Everything else + /// generated C# carries needs one, because a sequential struct or a record struct over a float + /// is a shape a hand-written type may have for its own reasons - but a Mass<float> + /// is not something a target happened to write, it is the quantity. That is what it means for + /// the vocabulary to be shared rather than copied. + /// + [TestMethod] + public void ItReimportsFromTheCompiledTypeWithNothingRecordingWhatItIs() + { + Schema original = WithMember(new Quantity { QuantityName = "Mass".As() }); + SchemaGenerationResult result = SchemaGenerator.Generate(original, CodeGenerationTests.ConfigureGenerator(original)); + + Assert.IsTrue(result.IsSuccess, result.Message); + + Assembly assembly = GeneratedSourceCompiler.Compile(result.Files); + + Schema reimported = new(); + reimported.AddClass(assembly.GetType("Generated.Body", throwOnError: true)!); + + BaseType imported = reimported.GetClass("Body".As())! + .GetMember("Value".As())!.Type; + + Assert.IsInstanceOfType(imported); + Assert.AreEqual("Mass", ((Quantity)imported).QuantityName.ToString()); + Assert.IsInstanceOfType(((Quantity)imported).Storage); + } + + private static QuantityRegistry.QuantityInfo Resolve(string name) + { + Assert.IsTrue(QuantityRegistry.TryResolve(name, out QuantityRegistry.QuantityInfo? quantity), name); + + return quantity!; + } + + /// + /// A schema of one class holding one member of the given type. + /// + private static Schema WithMember(BaseType type, Action? configure = null) + { + Schema schema = new(); + SchemaMember member = schema.AddClass("Body".As())!.AddMember("Value".As())!; + + member.SetType(type); + configure?.Invoke(member); + + return schema; + } +} diff --git a/Schema/Generation/CSharpCodeGenerator.cs b/Schema/Generation/CSharpCodeGenerator.cs index 6055db9..7edfe7b 100644 --- a/Schema/Generation/CSharpCodeGenerator.cs +++ b/Schema/Generation/CSharpCodeGenerator.cs @@ -633,6 +633,12 @@ private static string Escape(string text) => // The struct emitted for the semantic type, named the same way a class or an enum is. Semantic semanticType => CSharpKeywords.Identifier(semanticType.SemanticTypeName), + // The one type here that names something neither this schema nor this library declares. + // A quantity is written where it lives, closed over the number it is stored in, and + // nothing is emitted for it - which is the whole of what makes it different from the + // semantic type above. + Quantity quantity => $"ktsu.Semantics.Quantities.{quantity.QuantityName}<{MapType(quantity.Storage)}>", + Models.Types.Enum enumType => CSharpKeywords.Identifier(enumType.EnumName), Models.Types.Object objectType => CSharpKeywords.Identifier(objectType.ClassName), Models.Types.Interface interfaceType => CSharpKeywords.Identifier(interfaceType.InterfaceName), diff --git a/Schema/Models/ClrTypeImporter.cs b/Schema/Models/ClrTypeImporter.cs index 14e770d..85231a9 100644 --- a/Schema/Models/ClrTypeImporter.cs +++ b/Schema/Models/ClrTypeImporter.cs @@ -146,6 +146,11 @@ private static void ImportMember(Schema schema, SchemaClass schemaClass, MemberI return ImportSemanticType(schema, type, semantic); } + if (TryReadQuantity(schema, type) is Quantity quantity) + { + return quantity; + } + if (type.IsGenericType && GenericTypeMappings.TryGetValue(type.GetGenericTypeDefinition(), out Func? build)) { @@ -160,6 +165,35 @@ private static void ImportMember(Schema schema, SchemaClass schemaClass, MemberI return IsSchemaClass(type) ? ImportClass(schema, type) : new None(); } + /// + /// Reads a closed quantity back as the quantity it is, or nothing when it is not one. + /// + /// + /// The one thing here that needs no attribute to be recognised. Everything else generated C# + /// carries has to record what it is, because a struct with sequential layout or a record + /// struct over a float is a shape a hand-written type may have for its own reasons - but a + /// Mass<float> from ktsu.Semantics.Quantities is not something a target + /// happened to write, it is the quantity. The type is its own evidence, which is what it + /// means for the vocabulary to be shared. + /// + private static Quantity? TryReadQuantity(Schema schema, Type type) + { + if (!type.IsGenericType || type.GetGenericArguments() is not [Type storage]) + { + return null; + } + + string name = type.GetGenericTypeDefinition().Name; + int arity = name.IndexOf('`', StringComparison.Ordinal); + + return arity > 0 && + QuantityRegistry.TryResolve(name[..arity], out QuantityRegistry.QuantityInfo? resolved) && + resolved!.Definition == type.GetGenericTypeDefinition() && + GetOrCreateSchemaType(schema, storage) is BaseType stored + ? new Quantity { QuantityName = resolved.Name.As(), Storage = stored } + : null; + } + /// /// Reads a CLR type as whatever it is the element of, which is None when it is nothing /// this recognises. diff --git a/Schema/Models/Metadata/QuantityRegistry.cs b/Schema/Models/Metadata/QuantityRegistry.cs new file mode 100644 index 0000000..abcfbf6 --- /dev/null +++ b/Schema/Models/Metadata/QuantityRegistry.cs @@ -0,0 +1,233 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Models.Metadata; + +using System.Collections.ObjectModel; +using System.Reflection; + +using ktsu.Semantics.Quantities; + +/// +/// The physical quantities a schema may name, read out of ktsu.Semantics.Quantities. +/// +/// +/// +/// The same arrangement as , and for the same reason: the vocabulary is +/// that library's and keeping a list here would fall behind it. A quantity added there is nameable +/// here with no edit in this repository. +/// +/// +/// A quantity is not a unit, and that distinction is the point. A member holds a +/// Mass; the kilograms are how its stored number is read. Naming the type for the unit says +/// the presentation twice and makes the wrong copy load-bearing - which is what +/// Semantic(Kilograms) did before this existed, and why a schema that wanted a mass had to +/// declare one. +/// +/// +/// Every quantity is generic over its storage, so what is registered is the open type +/// Mass<>. closes it over a storage type when a generator needs +/// a name to write. +/// +/// +public static class QuantityRegistry +{ + /// + /// What the registry knows about one quantity. + /// + /// The quantity's name, without its arity. + /// The open generic type, Mass<>. + /// How many components a value has: 0 for a magnitude, 1 for a signed + /// scalar, 2 to 4 for a vector. + /// The eight exponents, from the quantity itself. + public sealed record QuantityInfo(string Name, Type Definition, int Components, DimensionInfo Dimension); + + /// + /// How far a chain of widenings is followed before it is treated as one that does not end. + /// + private const int WideningLimit = 8; + + private static readonly Lazy> Registry = new(Build); + + /// + /// Every quantity that can be named, in name order. + /// + public static ReadOnlyCollection All => + new([.. Registry.Value.Values.OrderBy(quantity => quantity.Name, StringComparer.Ordinal)]); + + /// + /// Resolves a name to the quantity it refers to. + /// + /// The quantity's name, such as Mass or Velocity3D. + /// The quantity, when the name resolves. + /// when the name is one this vocabulary has. + public static bool TryResolve(string? name, out QuantityInfo? quantity) + { + quantity = null; + + return !string.IsNullOrWhiteSpace(name) && Registry.Value.TryGetValue(name, out quantity); + } + + /// + /// Closes a quantity over the storage its values are kept in. + /// + /// The quantity. + /// The storage type, such as . + /// The closed type, Mass<float>. + public static Type Closed(QuantityInfo quantity, Type storage) + { + Ensure.NotNull(quantity); + + return quantity.Definition.MakeGenericType(storage); + } + + /// + /// Reads the vocabulary out of the assembly that defines it. + /// + /// + /// A quantity is a generic readonly record struct implementing one of the five vector + /// interfaces. The arity of that interface is what says how many components a value has, which + /// is the one thing a name alone does not. + /// + private static ReadOnlyDictionary Build() + { + Dictionary registry = new(StringComparer.Ordinal); + + foreach (Type type in typeof(Mass<>).Assembly.GetTypes()) + { + if (!type.IsValueType || !type.IsGenericTypeDefinition || type.GetGenericArguments().Length != 1) + { + continue; + } + + if (Components(type) is not int components) + { + continue; + } + + string name = type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]; + + if (DimensionOf(type) is DimensionInfo dimension) + { + registry[name] = new QuantityInfo(name, type, components, dimension); + } + } + + return new ReadOnlyDictionary(registry); + } + + /// + /// How many components a value of this quantity has, or null when it is not a quantity. + /// + private static int? Components(Type definition) + { + foreach (Type contract in definition.GetInterfaces()) + { + if (!contract.IsGenericType) + { + continue; + } + + string name = contract.GetGenericTypeDefinition().Name; + + if (name.StartsWith("IVector", StringComparison.Ordinal) && + int.TryParse(name.AsSpan(7, 1), out int components)) + { + return components; + } + } + + return null; + } + + /// + /// The eight exponents a quantity carries. + /// + /// + /// + /// A magnitude carries them directly, through IPhysicalQuantity. A vector form does + /// not - IVectorN for N above zero declares components and no dimension - so its + /// dimension is read off what Magnitude() answers with, which is the magnitude form of + /// the same dimension. That is the relationship the vocabulary is built on rather than an + /// inference: the sum of the squares of the components has twice a component's dimension and + /// the square root halves it again. + /// + /// + /// A named overload of a vector form answers neither. Position3D is a + /// Displacement3D under another name and the vocabulary gives it no Magnitude() + /// of its own, so the thing to follow is the one relationship it does declare: an overload + /// widens implicitly to what it is an overload of. Six of the vocabulary's vector forms are + /// reached only this way, which is the difference between 206 quantities and 212. + /// + /// + private static DimensionInfo? DimensionOf(Type definition) => DimensionOfClosed(definition.MakeGenericType(typeof(double)), 0); + + /// + /// The dimension of a closed quantity, following the widening chain when it has one. + /// + /// The closed quantity type. + /// How many widenings have been followed, which bounds a chain that loops. + private static DimensionInfo? DimensionOfClosed(Type closed, int depth) + { + if (depth > WideningLimit) + { + return null; + } + + if (Declared(closed) is DimensionInfo declared) + { + return declared; + } + + if (closed.GetMethod("Magnitude", BindingFlags.Public | BindingFlags.Instance)?.ReturnType is Type magnitude && + Declared(magnitude) is DimensionInfo measured) + { + return measured; + } + + return Widened(closed) is Type wider ? DimensionOfClosed(wider, depth + 1) : null; + } + + /// + /// What this quantity widens implicitly to, when it is an overload of another. + /// + /// + /// Only a conversion onto another quantity counts. A quantity converts to plenty of things that + /// are not one, and following a conversion to its storage type would answer with the dimension + /// of a bare number - which is the one wrong answer that looks like a right one. + /// + private static Type? Widened(Type closed) + { + foreach (MethodInfo conversion in closed.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (conversion.Name != "op_Implicit" || + conversion.GetParameters() is not [ParameterInfo source] || + source.ParameterType != closed) + { + continue; + } + + Type target = conversion.ReturnType; + + if (target.IsGenericType && + target.Assembly == closed.Assembly && + Components(target.GetGenericTypeDefinition()) is not null) + { + return target; + } + } + + return null; + } + + /// + /// The dimension a closed quantity declares, when it declares one. + /// + private static DimensionInfo? Declared(Type closed) + { + PropertyInfo? property = closed.GetProperty("Dimension", BindingFlags.Public | BindingFlags.Instance); + + return property?.PropertyType == typeof(DimensionInfo) + ? property.GetValue(Activator.CreateInstance(closed)) as DimensionInfo + : null; + } +} diff --git a/Schema/Models/Names/QuantityName.cs b/Schema/Models/Names/QuantityName.cs new file mode 100644 index 0000000..21809d6 --- /dev/null +++ b/Schema/Models/Names/QuantityName.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Models.Names; + +using ktsu.Semantics.Strings; + +/// +/// Represents the name of a physical quantity as a strong string type. +/// +/// +/// Not an ISchemaRootName, and that is the difference from every other name here: a +/// quantity is not something the schema declares, so there is nothing in the document for the +/// name to be unique against. It names one of ktsu.Semantics.Quantities' 212 quantities, +/// and QuantityRegistry is what resolves it. +/// +public sealed record class QuantityName : SemanticString { } diff --git a/Schema/Models/Schema.Validation.cs b/Schema/Models/Schema.Validation.cs index baed938..8935a2b 100644 --- a/Schema/Models/Schema.Validation.cs +++ b/Schema/Models/Schema.Validation.cs @@ -172,6 +172,12 @@ private void ValidateTravelsAsBytes(Collection issues, Sc // travels as bytes whatever it identifies. Handle => null, + // A quantity is so many of its storage and nothing else - a Mass is a float, a + // Velocity3D is three - so what decides is what it is stored in. Said rather than + // left to the arm below, because a type added to the vocabulary that quietly travelled as + // bytes because nobody wrote its case down is the failure this whole switch is against. + Quantity quantity => WhyItCannotTravelAsBytes(quantity.Storage), + Vector vectorType => WhyItCannotTravelAsBytes(vectorType.ElementType), Semantic { Declaration: SchemaSemanticType declaration } => WhyARepresentationCannotTravelAsBytes(declaration.Representation()), @@ -226,6 +232,10 @@ private void ValidateTravelsAsBytes(Collection issues, Sc /// private static void ValidateMetadata(Collection issues, ISchemaMetadataCarrier carrier, BaseType type, string path, ISchemaElement element, string kind) { + // Before the reduction below, which is what loses the quantity: a unit disagreeing with + // what the member holds is a fact about the declared type, not about the number under it. + ValidateQuantityUnit(issues, carrier, type, path, element); + BaseType represented = Represented(type); // A chain that never reached anything real - a name that does not resolve, or a cycle - @@ -258,10 +268,17 @@ private static void ValidateMetadata(Collection issues, I /// anything real. Callers treat that as "already reported elsewhere" rather than recursing: /// it is what keeps a refinement cycle a reported error and not a walk with no bottom. /// - private static BaseType Represented(BaseType type) => - type is Semantic { Declaration: SchemaSemanticType declaration } - ? declaration.Representation() - : type; + private static BaseType Represented(BaseType type) => type switch + { + Semantic { Declaration: SchemaSemanticType declaration } => declaration.Representation(), + + // A quantity is the number it is stored in, under a name that says what the number + // measures. That is why its storage may only be a bare numeric and never a semantic type + // or another quantity: this reduces in one step because there is nothing to walk. + Quantity quantity => quantity.Storage, + + _ => type, + }; /// /// A unit has to resolve, and has to be on something that can carry one. @@ -716,6 +733,10 @@ private void ValidateType(Collection issues, BaseType typ ValidateSemanticReference(issues, semanticReference, path, element); break; + case Quantity quantity: + ValidateQuantity(issues, quantity, path, element); + break; + // A vector's components are a type in their own right, checked as one. case Vector vectorType: ValidateVectorElement(issues, vectorType, path, element); @@ -794,6 +815,111 @@ private void ValidateSemanticReference(Collection issues, } } + /// + /// A named quantity has to be one the shared vocabulary has, and has to be stored in a number. + /// + /// + /// + /// Unlike a class, an interface or a semantic type, the name is not resolved against this + /// schema - there is nothing here to declare it. It is resolved against + /// ktsu.Semantics.Quantities, which is the point of the type: the vocabulary is shared, + /// so naming one reaches a type the target already has rather than a copy this schema asked + /// for. + /// + /// + /// The storage is held to a bare numeric rather than to "a number or a semantic type over + /// one", which is the looser rule a vector's components get. A quantity is generic over its + /// storage under a where T : struct, INumber<T> constraint, and a generated + /// semantic type is a record struct over a float that implements no such thing - so + /// Mass<Kilograms> is not a type that exists to be written. Refusing it here is + /// the schema saying so rather than a generator emitting C# that will not compile. + /// + /// + private static void ValidateQuantity(Collection issues, Quantity quantity, string path, ISchemaElement? element) + { + if (string.IsNullOrEmpty(quantity.QuantityName)) + { + Report(issues, path, element!, "Quantity type does not name a quantity."); + } + else if (quantity.Resolved is null) + { + Report(issues, path, element!, $"Quantity type names '{quantity.QuantityName}', which is not a quantity ktsu.Semantics.Quantities declares."); + } + + if (!quantity.Storage.IsNumeric) + { + Report(issues, path, element!, $"Quantity '{quantity.QuantityName}' is stored in a {quantity.Storage.DisplayName}. A quantity is stored in a number."); + } + } + + /// + /// A unit on a quantity has to measure what the quantity measures. + /// + /// + /// + /// This is the one check the type makes possible and the + /// it replaces never could. A unit is text the schema resolves through + /// , and a semantic type called Kilograms is a name nothing + /// reads - so Semantic(Kilograms) carrying unit: "m" was two unrelated strings + /// and no way to tell they disagreed. A quantity knows its own eight exponents and so does + /// the unit, so the contradiction is arithmetic. + /// + /// + /// It reads the declared type rather than the representation, which is why it is called from + /// instead of living beside the other unit check: by the time + /// that one runs the quantity has already been reduced to the number it is stored in, which + /// is exactly the fact this check is not about. + /// + /// + private static void ValidateQuantityUnit(Collection issues, ISchemaMetadataCarrier carrier, BaseType type, string path, ISchemaElement element) + { + // An unresolved quantity or unit is already reported, and saying they disagree as well + // would be two messages for one mistake. + if (type is not Quantity quantity || + quantity.Resolved is not QuantityRegistry.QuantityInfo resolved || + carrier.Unit is null || + !UnitRegistry.TryResolve(carrier.Unit, out IUnit? unit, out _) || + unit is null) + { + return; + } + + if (!SameDimension(resolved.Dimension, unit.Dimension)) + { + Report( + issues, + path, + element, + $"'{carrier.Unit}' measures {unit.Dimension.Name}, and a {quantity.QuantityName} is {resolved.Dimension.Name}."); + } + } + + /// + /// Whether two dimensions are the same eight exponents. + /// + /// + /// The exponents rather than the name, because 72 of the vocabulary's dimensions share 63 + /// exponent vectors: Torque and Energy are one vector between two names, and a + /// newton metre is as good a unit for either. Comparing names would refuse that, and refusing + /// what the physics allows is worse than not checking. + /// + private static bool SameDimension(DimensionInfo left, DimensionInfo right) + { + Dictionary here = left.DimensionalFormula; + Dictionary there = right.DimensionalFormula; + + return DimensionAxes.All(axis => + (here.TryGetValue(axis, out int mine) ? mine : 0) == (there.TryGetValue(axis, out int theirs) ? theirs : 0)); + } + + /// + /// The axes a dimensional formula is written over. + /// + private static readonly string[] DimensionAxes = + [ + "length", "mass", "time", "angle", "electricCurrent", "temperature", "amountOfSubstance", "luminousIntensity", + ]; + /// /// A vector is so many of something, and that something has to be a number. /// diff --git a/Schema/Models/Schema.cs b/Schema/Models/Schema.cs index 4f5af26..ca669f8 100644 --- a/Schema/Models/Schema.cs +++ b/Schema/Models/Schema.cs @@ -54,8 +54,15 @@ public partial class Schema : ISchema /// when false, and the version moves for the reason the others did: a version 5 reader would /// drop it and generate a signature that promises less than the schema does. /// + /// + /// Version 7 added , which names one of + /// ktsu.Semantics.Quantities' physical quantities. It is a new member of the type + /// vocabulary rather than a property, so unlike every step since version 1 a version 6 reader + /// does not quietly drop it - it fails to deserialize the member at all, on a discriminator + /// it has never heard of. The version moves so that it says so instead. + /// /// - public const int CurrentFormatVersion = 6; + public const int CurrentFormatVersion = 7; /// /// The version attributed to a file written before the format carried a version field. diff --git a/Schema/Models/Types/BaseType.cs b/Schema/Models/Types/BaseType.cs index 4bd8d42..470dc31 100644 --- a/Schema/Models/Types/BaseType.cs +++ b/Schema/Models/Types/BaseType.cs @@ -32,6 +32,7 @@ namespace ktsu.Schema.Models.Types; [JsonDerivedType(typeof(Object), nameof(Object))] [JsonDerivedType(typeof(Interface), nameof(Interface))] [JsonDerivedType(typeof(Semantic), nameof(Semantic))] +[JsonDerivedType(typeof(Quantity), nameof(Quantity))] [JsonDerivedType(typeof(Void), nameof(Void))] [JsonDerivedType(typeof(Span), nameof(Span))] [JsonDerivedType(typeof(Handle), nameof(Handle))] diff --git a/Schema/Models/Types/Quantity.cs b/Schema/Models/Types/Quantity.cs new file mode 100644 index 0000000..05bafca --- /dev/null +++ b/Schema/Models/Types/Quantity.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Models.Types; + +using System.Text.Json.Serialization; + +using ktsu.Schema.Models.Metadata; +using ktsu.Schema.Models.Names; + +/// +/// A physical quantity from ktsu.Semantics.Quantities. +/// +/// +/// +/// A member that holds a mass says Quantity(Mass). It does not declare a semantic type to +/// say it, and it does not name a unit to say it either: the kilograms are how the stored number +/// is read, which is a fact about the field, while the mass is what the value is. +/// +/// +/// This is the difference from , and both are wanted. A semantic type is +/// how a schema says that its own two numbers are different things - an entity id is not a texture +/// id - and nothing outside the schema has heard of either. A quantity is the opposite: the +/// vocabulary is shared, 212 names that both generators already emit, so naming one here reaches +/// a type the target already has rather than a copy this schema asked for. +/// +/// +/// It carries no elementType and no component count, because the name already fixes them. +/// Velocity3D is three components of a velocity; there is no Velocity3D of anything +/// else, which is what makes it a name rather than a shape. +/// +/// +public class Quantity : BaseType +{ + /// + /// Gets or sets the quantity's name. + /// + public QuantityName QuantityName { get; set; } = new(); + + /// + /// Gets or sets the number a value of this quantity is kept in. + /// + /// + /// Every quantity in the vocabulary is generic over its storage - Mass<float>, + /// Mass<double> - so a name alone is not yet a type a generator can write. This + /// is the rest of it, and it is a property of the member rather than of the program because + /// one field being a double where the rest are floats is an ordinary thing for a schema to + /// say. + /// + /// It defaults to and is omitted from the file when it is, the same + /// arrangement has and for the same reason: a non-nullable + /// property has no ignore condition that means "the same as saying nothing". + /// + /// + public BaseType Storage { get; set; } = new Float(); + + /// + /// Gets what the registry knows about this quantity, or when the name + /// resolves to nothing. + /// + /// + /// Null is how an unresolved name reaches a caller, the same way an unresolved semantic type + /// does: reported once by validation, and treated everywhere else as already reported. + /// + [JsonIgnore] + public QuantityRegistry.QuantityInfo? Resolved => + QuantityRegistry.TryResolve(QuantityName, out QuantityRegistry.QuantityInfo? quantity) ? quantity : null; + + /// + public override void AssociateWith(SchemaMember schemaMember) + { + base.AssociateWith(schemaMember); + Storage.AssociateWith(schemaMember); + } + + /// + public override void AssociateWith(Schema? schema) + { + base.AssociateWith(schema); + Storage.AssociateWith(schema); + } + + /// + public override string ToString() => QuantityName; + + /// + protected override bool EqualsCore(BaseType other) => + other is Quantity otherQuantity + && string.Equals(QuantityName, otherQuantity.QuantityName, StringComparison.Ordinal) + && Storage.Equals(otherQuantity.Storage); + + /// + protected override int GetHashCodeCore() => + HashCode.Combine(StringComparer.Ordinal.GetHashCode(QuantityName.ToString()), Storage); +} diff --git a/Schema/SchemaSerializer.cs b/Schema/SchemaSerializer.cs index 7c7be2d..addcecf 100644 --- a/Schema/SchemaSerializer.cs +++ b/Schema/SchemaSerializer.cs @@ -21,7 +21,7 @@ public static class SchemaSerializer Converters = { new RoundTripStringJsonConverterFactory() }, TypeInfoResolver = new DefaultJsonTypeInfoResolver { - Modifiers = { OmitDefaultVectorElement }, + Modifiers = { OmitDefaultVectorElement, OmitDefaultQuantityStorage }, }, }; @@ -51,6 +51,31 @@ private static void OmitDefaultVectorElement(JsonTypeInfo typeInfo) } } + /// + /// Leaves a quantity's storage out of the file when it is the default. + /// + /// + /// The same arrangement as and for the same reason: + /// is not nullable, so no ignore condition describes + /// "the same as saying nothing", and writing it regardless would put four lines of + /// { "TypeName": "Float" } under every quantity in every file. + /// + private static void OmitDefaultQuantityStorage(JsonTypeInfo typeInfo) + { + if (typeInfo.Type != typeof(Types.Quantity)) + { + return; + } + + IEnumerable storageProperties = typeInfo.Properties + .Where(property => string.Equals(property.Name, "storage", StringComparison.OrdinalIgnoreCase)); + + foreach (JsonPropertyInfo property in storageProperties) + { + property.ShouldSerialize = static (_, value) => value is not Types.Float; + } + } + /// /// Serializes a Schema to a JSON string. /// diff --git a/docs/schema-format.md b/docs/schema-format.md index d944851..6a47b03 100644 --- a/docs/schema-format.md +++ b/docs/schema-format.md @@ -469,13 +469,19 @@ A vector says how many components it has. `elementType` says what each one is: ```json { "TypeName": "Vector3", - "elementType": { "TypeName": "Semantic", "semanticTypeName": "MetresPerSecond" } + "elementType": { "TypeName": "Semantic", "semanticTypeName": "EntityId" } } ``` -A velocity is three metres per second and a position is three metres, and a schema that says only -"three floats" leaves the one fact worth knowing about either of them to a comment. This is the -argument `Semantic` makes for a single value, applied to three of them. +A schema that says only "three floats" leaves the one fact worth knowing about the value to a +comment. This is the argument `Semantic` makes for a single value, applied to three of them. + +**For a quantity, use [`Quantity`](#quantity---a-physical-quantity-from-ktsusemanticsquantities) +instead.** A velocity of three components is `Quantity(Velocity3D)`, not a `Vector3` of a semantic +type standing in for metres per second: the vocabulary already names the 3D forms, and saying it +both ways is two spellings of one fact. What is left for `elementType` is a vector of something the +vocabulary has no name for, which is where a schema's own semantic type belongs — a `Vector3` of an +`EntityId` is not a quantity and never will be. `elementType` is optional and omitted when it is `Float`, which is what a vector has always been - so a file whose vectors are vectors of floats is written exactly as it was before the property @@ -503,6 +509,49 @@ other than `Float` on one is refused. `semanticTypeName` must name a semantic type in `semanticTypes`. +### `Quantity` - a physical quantity from `ktsu.Semantics.Quantities` + +```json +{ "TypeName": "Quantity", "quantityName": "Velocity3D" } +{ "TypeName": "Quantity", "quantityName": "Mass", "storage": { "TypeName": "Double" } } +``` + +The one named type here whose name is not resolved against this schema. `quantityName` must be one +of the 212 quantities `ktsu.Semantics.Quantities` declares — `Mass`, `Length`, `Speed`, `Ratio`, +`Heading`, `Radius`, `Velocity3D`, `Force3D`, and the rest. Nothing in `semanticTypes` declares it +and nothing is generated for it: both generators already emit the vocabulary, so naming one +reaches a type the target already has. + +That is the difference from `Semantic`, and both are wanted. A semantic type is how a schema says +that its own two numbers are different things — an entity id is not a texture id, and nothing +outside the schema has heard of either. A quantity is the opposite: the vocabulary is shared. + +**Name the quantity, not the unit.** A member holding a mass says `Quantity(Mass)` and, if it +matters, `"unit": "kg"` beside it. It does not declare a semantic type called `Kilograms`: the unit +is how the stored number is read, which is a fact about the field, and stating it as the type's +name as well makes the wrong copy load-bearing. + +`storage` is the number a value is kept in, because every quantity is generic over one — +`Mass`, `Mass`. It defaults to `Float` and is omitted from the file when it is, the +same arrangement a vector's `elementType` has. It must be `Int`, `Long`, `Float` or `Double`: a +quantity is generic under `where T : struct, INumber`, which a semantic type over a float does +not satisfy, so `Mass` is not a type anything could write. + +A `unit` on a quantity must **measure what the quantity measures**. This is the one check the +semantic type it replaces could never make: a unit is text resolved through `UnitRegistry` and a +type called `Kilograms` is a name nothing reads, so the two had no way to disagree. A quantity +knows its own eight exponents and so does the unit, so the contradiction is arithmetic. The +exponents are compared and not the names, because 72 of the vocabulary's dimensions share 63 +exponent vectors — a joule and a newton metre are the same eight numbers, and a torque held in +joules is not something the schema is entitled to refuse. + +A logarithmic scale is **not** a quantity and cannot be named as one. A decibel does not add and a +pH does not scale, which is why `ktsu.Semantics` emits those from `logarithmic.json` rather than as +dimensions; they have no dimensional formula and no vector form. + +A class that `travelsAsBytes` may hold a quantity, because a quantity is so many of its storage and +nothing else. + ### `Interface` - a reference to an interface in this schema ```json @@ -639,6 +688,7 @@ needs to know about. | `4` | Vector component types | `Vector2`, `Vector3` and `Vector4` gain an `elementType`. Omitted when it is `Float`, so a file whose vectors are vectors of floats is unchanged. | | `5` | Layout promises and the error type | A class may declare that it `travelsAsBytes`, and the root may name the `errorType` a failed `Result` carries. Additive; the class flag is omitted when false. | | `6` | Query functions | A function may declare `isQuery`, saying a call leaves the receiver unchanged. Omitted when false. | +| `7` | Physical quantities | The type vocabulary gains `Quantity`, which names one of `ktsu.Semantics.Quantities`' quantities. Unlike every step since version 1 this is not something an older reader drops: it fails to deserialize the member at all, on a discriminator it has never heard of. | Version 2 is purely additive: a file that uses none of the new properties is byte-identical to the version 1 file it would have been. The version still moves, because a version 1 reader diff --git a/samples/carbonmonoxide.schema.json b/samples/carbonmonoxide.schema.json index 5f5502c..566fdec 100644 --- a/samples/carbonmonoxide.schema.json +++ b/samples/carbonmonoxide.schema.json @@ -1,5 +1,5 @@ { - "formatVersion": 6, + "formatVersion": 7, "errorType": "", "classes": [ { diff --git a/samples/dungeoneer.schema.json b/samples/dungeoneer.schema.json index dae6042..96029f9 100644 --- a/samples/dungeoneer.schema.json +++ b/samples/dungeoneer.schema.json @@ -1,5 +1,5 @@ { - "formatVersion": 6, + "formatVersion": 7, "errorType": "", "classes": [ { From 25b447db47a7179c55d3140b555029cbd43f7b47 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 05:38:01 +0000 Subject: [PATCH 2/3] Say the modernised sample's measurements as quantities The sample declared Kilograms, Metres, MetresPerSecond and MetresPerSecondSquared: four semantic types named after units, each also carrying the unit as metadata. That states the presentation twice and makes the wrong copy load-bearing. A weight is a mass and the kilograms are how its number is read, so the member now says Quantity(Mass) with "kg" beside it - each thing once, and validation notices when the two disagree. Coin stays a semantic type, and that is the point of keeping it. A price is this schema's own idea and nothing outside it has heard of a coin, so the schema declares one and a generator emits it; a mass is everybody's, so the schema names it and nothing is emitted at all. The two ways of saying "this number is not just a number" now sit beside each other in one file, which is what a sample is for. lightRadius, hitboxRadius and influenceRadius are Radius rather than Length, and gravity is Acceleration1D rather than a magnitude, because its default is -9.81 and a magnitude form refuses a negative value at construction. The vocabulary has names for both, which is the argument for using it rather than restating it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- Schema.Test/ModernisedSampleTests.cs | 20 ++++++- samples/README.md | 18 +++++- samples/modernised.schema.json | 89 +++++++++++----------------- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/Schema.Test/ModernisedSampleTests.cs b/Schema.Test/ModernisedSampleTests.cs index 582762b..e50c648 100644 --- a/Schema.Test/ModernisedSampleTests.cs +++ b/Schema.Test/ModernisedSampleTests.cs @@ -28,6 +28,15 @@ namespace ktsu.Schema.Tests; /// only fixed-size numbers says so. SameDefinitionsAsTheLegacySet is what keeps the first /// half of that sentence true while the second half changes. /// +/// +/// It is also where the two ways of saying "this number is not just a number" sit beside each +/// other. Coin is a semantic type, because a price is this schema's own idea and nothing +/// outside it has heard of one; a weight is a Quantity(Mass) with kg on the member, +/// because a mass is everybody's. This file declared Kilograms, Metres, +/// MetresPerSecond and MetresPerSecondSquared before the schema could name a +/// quantity, which said the unit twice - once as a name nothing read and once as text something +/// did. +/// /// [TestClass] public sealed class ModernisedSampleTests @@ -115,10 +124,17 @@ public void UsesWhatTheOldFormatCouldNotSay() List members = [.. modern.Classes.SelectMany(c => c.Members)]; List types = [.. members.Select(m => m.Type)]; - Assert.IsNotEmpty(modern.SemanticTypes, "a weight and a price are not bare numbers"); - Assert.IsNotEmpty(modern.SemanticTypes.Where(s => !string.IsNullOrEmpty(s.Unit)), "a unit lives on the type"); + // The two ways of saying "this number is not just a number", each where it belongs. A price + // is this schema's own idea and nothing outside it has heard of a Coin; a weight is a mass, + // which is a name both generators already emit. + Assert.IsNotEmpty(modern.SemanticTypes, "a price is not a count of anything else"); Assert.IsNotEmpty(types.OfType(), "and a member names it"); + Assert.IsNotEmpty(types.OfType(), "a weight is a mass"); + Assert.IsNotEmpty( + members.Where(m => m.Type is Quantity && !string.IsNullOrEmpty(m.Unit)), + "and the kilograms are how its number is read"); + Assert.IsNotEmpty(types.OfType().Concat(types.OfType()), "a colour was a string"); Assert.IsNotEmpty(types.OfType(), "a vector was a class with an x and a y"); Assert.IsNotEmpty( diff --git a/samples/README.md b/samples/README.md index 6615431..95299a3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -61,8 +61,9 @@ representation. | --- | --- | --- | | `Vector2`, `Vector3`, `IntVector2`, `IntVector3` as classes | the built-in vectors, `elementType` `Float` or `Int` | four classes existed to say "two floats" | | `color: string` | `ColorRGBA`, `lightColor: ColorRGB` | the old format had no colour | -| `weight: float`, `cost: int` | `Semantic(Kilograms)`, `Semantic(Coin)` | a weight is kilograms everywhere, and a price is not a count of anything else | -| `lightRadius`, `moveSpeedPerSec`, `gravity` | `Metres`, `MetresPerSecond`, `MetresPerSecondSquared` | the unit lives on the type, stated once | +| `weight: float` | `Quantity(Mass)`, `unit: kg` | a weight is a mass, which is a name both generators already emit | +| `cost: int` | `Semantic(Coin)` | a price is this schema's own idea, and nothing outside it has heard of a coin | +| `lightRadius`, `moveSpeedPerSec`, `gravity` | `Quantity(Radius)`, `Quantity(Speed)`, `Quantity(Acceleration1D)` | what the value *is*, with the unit beside it saying how its number is read | | `probability: int`, `friction: float` | the same, with ranges and defaults | a probability is not any integer | | `points: array` | a `map` keyed by `id` | the id was already there | | `Rect`, `Line2D`, `CRXP`, … | `travelsAsBytes` | they are only fixed-size numbers | @@ -75,6 +76,19 @@ the second half, so the file cannot decay back into a transliteration one edit a It is authored rather than derived — a mechanical rewrite could not decide that a `string` called `color` is a colour — which is why the correspondence is a test rather than a regeneration. +**The two ways of saying "this number is not just a number" sit beside each other here**, which is +the point of keeping `Coin`. A semantic type is the schema's own: nothing outside it has heard of a +coin, so the schema declares one and a generator emits it. A quantity is everybody's: 212 names +`ktsu.Semantics` already declares in both languages, so naming one reaches a type the target +already has. + +This file used to say the second thing the first way, and it was wrong in a way worth recording. It +declared `Kilograms`, `Metres`, `MetresPerSecond` and `MetresPerSecondSquared` — semantic types +named after units, each carrying the unit as metadata as well. That states the presentation twice +and makes the wrong copy load-bearing: the unit is how the stored number is read, a fact about the +field, while the mass is what the value *is*. Naming the quantity and putting `kg` on the member +says each thing once, and lets validation notice when the two disagree. + **One thing the format cannot say.** `MemberRange.Minimum` and `Maximum` are both non-nullable, so a range is always two-sided and there is no way to write "at least zero". Where only one end is real — a weight, a price, a radius — this schema states no range rather than inventing a ceiling, diff --git a/samples/modernised.schema.json b/samples/modernised.schema.json index 68d01f9..625593a 100644 --- a/samples/modernised.schema.json +++ b/samples/modernised.schema.json @@ -1,5 +1,5 @@ { - "formatVersion": 6, + "formatVersion": 7, "errorType": "", "classes": [ { @@ -213,9 +213,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Kilograms" + "TypeName": "Quantity", + "quantityName": "Mass" }, + "unit": "kg", "name": "weight", "description": "" }, @@ -249,9 +250,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Kilograms" + "TypeName": "Quantity", + "quantityName": "Mass" }, + "unit": "kg", "name": "weight", "description": "" } @@ -278,9 +280,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Kilograms" + "TypeName": "Quantity", + "quantityName": "Mass" }, + "unit": "kg", "name": "weight", "description": "" } @@ -377,9 +380,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Metres" + "TypeName": "Quantity", + "quantityName": "Radius" }, + "unit": "m", "name": "lightRadius", "description": "" }, @@ -479,9 +483,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Metres" + "TypeName": "Quantity", + "quantityName": "Radius" }, + "unit": "m", "name": "hitboxRadius", "description": "" }, @@ -495,9 +500,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "MetresPerSecond" + "TypeName": "Quantity", + "quantityName": "Speed" }, + "unit": "m/s", "network": { "quantise": 0.01, "delta": true @@ -522,9 +528,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "MetresPerSecondSquared" + "TypeName": "Quantity", + "quantityName": "Acceleration1D" }, + "unit": "m/s²", "defaultValue": { "DefaultKind": "NumberDefault", "value": -9.81 @@ -549,9 +556,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Metres" + "TypeName": "Quantity", + "quantityName": "Radius" }, + "unit": "m", "name": "influenceRadius", "description": "" } @@ -1170,9 +1178,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Kilograms" + "TypeName": "Quantity", + "quantityName": "Mass" }, + "unit": "kg", "name": "weight", "description": "" }, @@ -1387,9 +1396,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Kilograms" + "TypeName": "Quantity", + "quantityName": "Mass" }, + "unit": "kg", "name": "weight", "description": "" }, @@ -1466,9 +1476,10 @@ }, { "type": { - "TypeName": "Semantic", - "semanticTypeName": "Kilograms" + "TypeName": "Quantity", + "quantityName": "Mass" }, + "unit": "kg", "name": "weight", "description": "" } @@ -1616,38 +1627,6 @@ }, "name": "Coin", "description": "A price in the smallest coin. A number of coins is not a number of anything else." - }, - { - "underlyingType": { - "TypeName": "Float" - }, - "unit": "kg", - "name": "Kilograms", - "description": "A mass in kilograms." - }, - { - "underlyingType": { - "TypeName": "Float" - }, - "unit": "m", - "name": "Metres", - "description": "A distance in metres." - }, - { - "underlyingType": { - "TypeName": "Float" - }, - "unit": "m/s", - "name": "MetresPerSecond", - "description": "A speed in metres per second." - }, - { - "underlyingType": { - "TypeName": "Float" - }, - "unit": "m/s\u00b2", - "name": "MetresPerSecondSquared", - "description": "An acceleration in metres per second squared." } ], "interfaces": [], From da39a815e16bcd0948de2dc8d7853873d8890a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 05:41:45 +0000 Subject: [PATCH 3/3] Guard the dimension property explicitly `property?.PropertyType == typeof(DimensionInfo)` is false when the property is absent, so the conditional never dereferenced a null. Neither the compiler's null-state analysis nor CodeQL can follow that, though, and a warning that has to be reasoned about every time it is read is worth two lines to remove. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- Schema/Models/Metadata/QuantityRegistry.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Schema/Models/Metadata/QuantityRegistry.cs b/Schema/Models/Metadata/QuantityRegistry.cs index abcfbf6..9d7861a 100644 --- a/Schema/Models/Metadata/QuantityRegistry.cs +++ b/Schema/Models/Metadata/QuantityRegistry.cs @@ -222,12 +222,21 @@ private static ReadOnlyDictionary Build() /// /// The dimension a closed quantity declares, when it declares one. /// + /// + /// Written as two guards rather than one conditional. property?.PropertyType == typeof(…) + /// is false when the property is absent, so the compact form never dereferenced a null - but + /// neither the compiler's null-state analysis nor CodeQL can follow that, and a warning that + /// has to be reasoned about every time it is read is worth two lines to remove. + /// private static DimensionInfo? Declared(Type closed) { PropertyInfo? property = closed.GetProperty("Dimension", BindingFlags.Public | BindingFlags.Instance); - return property?.PropertyType == typeof(DimensionInfo) - ? property.GetValue(Activator.CreateInstance(closed)) as DimensionInfo - : null; + if (property is null || property.PropertyType != typeof(DimensionInfo)) + { + return null; + } + + return property.GetValue(Activator.CreateInstance(closed)) as DimensionInfo; } }