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/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/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..9d7861a --- /dev/null +++ b/Schema/Models/Metadata/QuantityRegistry.cs @@ -0,0 +1,242 @@ +// 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. + /// + /// + /// 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); + + if (property is null || property.PropertyType != typeof(DimensionInfo)) + { + return null; + } + + return property.GetValue(Activator.CreateInstance(closed)) as DimensionInfo; + } +} 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/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/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": [ { 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": [],