Add exact Rational/Quantity value types and schema-driven form generation#6
Merged
Conversation
…tion
Actions can now carry exact, unit-tagged values and describe themselves as
JSON Schemas that clients render forms from at runtime.
- morph/rational.hpp: morph::math::Rational, adapted from LASTRADA JPMath
with a bundled DecimalPlaces strong type (no boxed.hpp dependency). Exact
int64 arithmetic with decimal-precision propagation, expected-based error
handling, std::formatter, and a Glaze wire codec ({"num","den","dp"}) that
canonicalises on read and clamps hostile input instead of asserting.
- morph/quantity.hpp: morph::units::Quantity<U> — unit-tagged, optionally-
empty values. One kind of empty (optional<Rational> inside; never wrap a
Quantity in std::optional). Units are application enum NTTPs via the
UnitTraits customisation point plus a consteval algebra: result units are
deduced at compile time and illegal combinations do not compile. The wire
payload is the nullable Rational — units never travel; generated schemas
get ExtUnits automatically.
- morph/forms.hpp: schemaJson<A>() = glaze write_json_schema plus a derived
required array (std::optional / optionalFields opt-out rule),
x-decimalPlaces on quantity properties, and x-order on every property;
allRequiredEngaged() readiness helper that the existing ActionValidator
resolution picks up via validate().
- tests: full port of the LASTRADA Rational Catch2 suite plus wire-codec
cases; quantity/forms coverage including dispatch through the registry.
- examples/forms: demo with three surfaces — --schemas, --emit-html (self-
contained vanilla-JS form renderer), and a REPL dispatching pasted action
lines through ActionDispatcher; examples/forms/gui_qml
(MORPH_BUILD_FORMS_QML=ON) renders the same schemas in Qt Quick and
submits wire envelopes to an in-process RemoteServer.
- CMake: find_package(glaze 7.4 ...) version bound so a stale system/user
glaze install can no longer shadow the pinned FetchContent version.
- docs: README feature section; ARCHITECTURE namespace/header maps, a design
section for the new layer, a "validators do not run server-side" known
limitation, and new design-decision rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Coverage (codecov/patch): every previously-missing line in the three new headers is now exercised or gone — - tests: IsNegative, ToDouble(>18) fallback, mixed-operator error propagation on both sides of every operator, exact comparison at int64 extremes, negative-operand multiplication, INT64_MIN wire clamping, empty cross-unit products, mergeSchemaExtras fallback, schema memoization. - dead branches removed instead of tested: clampDecimalPlaces now asserts and delegates to clampWireDecimalPlaces (one clamp, one range); fromFloatImpl's unreachable powerOfTen sentinel check dropped; Quantity division relies on DividedBy as the single division-by-zero authority. Correctness fixes found by the review pass, all test-covered: - Rational::operator<=> now compares via exact 128-bit cross products (__int128 or portable limbs), so ordering can no longer disagree with operator== at large magnitudes (previously long double rounding could declare distinct values equal — strong_ordering violation). - FromFloat overflow guard compares against 2^63 exactly; casting INT64_MAX rounds up to 2^63 where long double == double, letting a boundary value slip through to UB in llround. - setWire clamps INT64_MIN components to -INT64_MAX: negation of wire values is otherwise UB, contradicting the hostile-input guarantee. - forms: `required` is always assigned (an explicit empty array beats inheriting whatever the writer emitted); schemaJson memoized per type; reflection walk builds the member tie once (was O(N^2)). - HTML renderer emits rational/integer JSON from exact digit strings (Number(BigInt) was silently lossy above 2^53); honest clipboard status; schemas embedded with `<` escaped so descriptions can never close the <script>. - QML renderer: same exact digit-string payloads (Math.round(parseFloat) mis-rounded over-precise input), rejects more decimals than the field declares; FormsController's pool is declared last so its destructor drains callbacks while the members they touch are still alive; formsController id no longer shadowed by DynamicForm's property. Doxygen gate: full @param/@return on the public surface; the three new detail namespaces and third-party shims (glz, std::formatter) join the existing EXCLUDE_SYMBOLS convention. `ninja doc` is warning-clean. Also: warn when MORPH_BUILD_FORMS_QML is set but unreachable (EXAMPLES=OFF / Emscripten). 335/335 tests pass; the three headers have zero uncovered lines under llvm-cov. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s, REPL parsing - FromFloat: values scaling into [2^63 - 0.5, 2^63) passed the overflow guard but llround rounds them up to 2^63, wrapping to a poisoned INT64_MIN numerator (empirically reproduced on x86 80-bit long double with 922337203685477580.75L at dp=1). Guard now rejects the half-ulp window; regression test added (holds on double-long-double platforms too, where the literal rounds past the plain 2^63 bound). - Both form renderers emitted integer input digit-for-digit, so "007" produced JSON with a leading-zero number that glaze rejects; the digit string is now normalised. - REPL: split action/payload on space or tab, trim trailing whitespace and CRLF (a pasted "exit\r" now quits; tab-separated lines dispatch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quantity gains a second template argument — the field's *declared* decimal count — defaulting from the unit's UnitTraits metadata and overridable per field (Quantity<Unit::m3, 4>). Two precisions, deliberately distinct: - declared (type-level): what the field is specified to hold; feeds the schema's x-decimalPlaces (per field now, not just per unit), FromDouble (convert a reading at the field's precision), and declaredPrecision(). - actual (runtime): the Rational's DecimalPlaces tag, unchanged semantics — max-propagates through arithmetic and is adjustable at run time via withDecimalPlaces() / atDeclaredPrecision(). Same-unit quantities convert freely across declared precisions (the value and its runtime tag carry over); binary arithmetic results carry the unit's default declared precision (a computed temporary is not a declared field), with the converting constructor handling storage into overridden fields. Comparisons work across declared precisions. Wire format unchanged — neither the unit nor the declared precision travels. The demo's ComputeDryDensity.volume showcases an override (m3 at 4 decimals vs unit default 3); the schema advertises x-decimalPlaces:4 and both renderers adapt without changes. 337 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tidy gate (--warnings-as-errors='*') had never completed on this branch; running it locally surfaced 83 errors. The one substantive change: the Rational API ported from LASTRADA kept PascalCase methods, which both the repo's naming convention and the rest of morph's public API (including Quantity) contradict — renamed to camelBack (from, fromFloat, zero, one, isZero, isInteger, isNegative, toDouble, getDecimalPlaces, reciprocal, dividedBy, fromDouble; is_quantity_v -> isQuantity), with docs updated. Mechanical rest: drop redundant `inline` on constexpr functions; designated initializers for U128/Wire/UnitMeta; std::is_lt/is_gt instead of ordering- vs-0 comparisons (also kills a use-nullptr false positive and a nested ternary); parentheses in the +=/-= cross terms; static_cast<int> for the sign arithmetic; braces and string::contains in tests; Loggable gets a uint8_t base (pre-existing, matches LogLevel); one NOLINT on Quantity's deliberately-unchecked operator* with the rationale in its doc comment. clang-tidy on the new headers + registry.hpp + both new test files: 0 errors. Build warning-free, docs gate clean, 337/337 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…into value_or The clang-tidy rework replaced value_or(std::move(...)) with an explicit if/return, reintroducing a line that cannot execute (glz::write_json of a DOM we just built does not fail) — the exact class of uncoverable branch removed earlier. value_or without the move satisfies both gates: the copy is irrelevant (schemaJson memoises per type) and the fallback branch lives inside glaze's expected, not in morph. llvm-cov: 0 uncovered lines across rational.hpp/quantity.hpp/forms.hpp; 337/337 tests pass; tidy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new field types, both following the established one-kind-of-empty pattern (blank state inside; required-ness derived; nothing extra travels): - morph/datetime.hpp: morph::time::DateTime — a UTC instant (millisecond precision over std::chrono::sys_time, adapted from LASTRADA Toolbox/Chrono.hpp) — and morph::time::Timestamp, the optionally-empty field wrapper. The wire format is strict ISO-8601 UTC with a hand-rolled parser (no locale, no chrono::parse — identical behaviour on libstdc++, libc++/WASM, MSVC). Unlike Rational's clamping codec, a malformed timestamp is a JSON read *error*: a mistyped instant has no meaningful clamp. Schemas carry the standard "format": "date-time". - morph/choice.hpp: morph::forms::Choice<T, "ListSamples", "id", "name"> declares in the type that a field is not free input — its options are the rows returned by executing the named action (registered like any other, typically Loggable::No). Schemas carry x-optionsAction / x-optionValue / x-optionLabel; on the wire a Choice is its bare nullable value. FixedString provides the structural NTTP for the names. forms.hpp generalises engagement: allRequiredEngaged now covers every EmptyCapableField (anything with hasValue() — Quantity, Choice, Timestamp), and schemaJson stamps the x-options* keys. Demo: RecordMeasurement.sampleId became a Choice served by the new ListSamples action, and gained a required measuredAt Timestamp. The HTML renderer builds a <select> (options resolved at emit time by dispatching the options action — a static page cannot fetch) and a datetime-local input; the QML client fetches options live through FormsController::fetchOptions over the same in-process wire and renders a ComboBox, plus an ISO-validated date-time field. REPL examples updated; a malformed measuredAt now shows the strict codec rejecting it with a caret. 10 new test cases (347 total): ISO round-trips and every rejection path, strict wire codec, Timestamp/Choice empty-state + wire + schema stamps + readiness, and options-action dispatch through the registry. All local gates green: zero uncovered lines across the five field-type headers, clang-tidy clean, doxygen clean, qmllint clean. Docs updated (ARCHITECTURE namespace/ header maps + design section + decision rows, README, example README). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Date-time picker: the QML client gains DateTimePicker.qml — manual ISO-8601
entry plus a calendar popup (MonthGrid + day-of-week header + zero-padded
time spinboxes, month/year navigation, Now/Clear/OK), all in UTC to match
the Timestamp wire contract. The HTML client already had the browser's
native datetime-local picker; it gains a "now" button filling the current
UTC time.
Unit switching: a unit system may now declare convertible entry units per
canonical unit with exact rational ratios —
UnitTraits<Unit>::alternatives(Unit::kg) -> { {g, 1, 1000}, {t, 1000, 1} }
morph::units::UnitAlternative<E> + the HasUnitAlternatives customisation are
surfaced through Quantity::unitAlternatives() and stamped into schemas as
x-unitAlternatives ({id, display, decimals, num, den} per entry). Both
renderers grow a unit selector on such fields: switching recalculates the
entered value exactly (BigInt in the HTML client; digit-string long
arithmetic in QML, which lacks reliable BigInt) rounded to the target
unit's decimals, and the submitted payload is always the canonical unit —
value * num/den rides into the exact rational, so entering 2650500 g
produces the same wire value as 2650.5 kg and the model never sees display
units. Client-side bounds apply in the canonical unit only.
Demo: kg fields convert to g/t, the m3 field to L. Tests: alternative
declaration statics, schema surface, and absence for units without
alternatives (348 total). All gates green: zero uncovered lines, clang-tidy
clean, doxygen clean, qmllint clean. Docs updated (ARCHITECTURE, README,
example README).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…REPL e2e The forms core was fully covered, but the renderers' logic — where the exactness claims actually live — was only manually verified. Three new test vectors close that, all registered with ctest: - forms_qml_logic: a Qt Quick Test suite (QUICK_TEST_MAIN) driving the real DynamicForm with a synthetic schema: schema -> field-descriptor mapping (x-order, kinds, unit options, required), the digit-string long arithmetic (mulDigits/divRoundDigits/incDigits/scaledDigits incl. half-up rounding), exact unit conversion (kg<->g<->t round trips, negatives, malformed input), payload composition (ratio folding, dp stays canonical, digit-exact beyond 2^53), the readiness gate (over-precision and malformed datetimes rejected), and option-row extraction. The QML module became a static library (morph_forms_module + Q_IMPORT_QML_PLUGIN) so the app and the test runner both link and import it; DynamicForm now tolerates a null controller so the logic is instantiable standalone. - forms_html_math (needs Node, present on all CI images): emits the page and extracts the marked pure-helper block *verbatim*, then drives the shipped BigInt math: payload assembly, conversion with half-up rounding, formatScaled/divRoundBig/scaledBig primitives, $ref resolution, and the embedded OPTIONS/SCHEMAS data. - forms_repl_roundtrip (Unix): pipes canonical and converted-unit submits, a tab-separated line, a malformed datetime, and a missing required field through the real binary and checks every reply. enable_testing() is hoisted above the subdirectories so demo-level add_test calls register. Docs: unit-alternative symbols in the ARCHITECTURE namespace map, the alternatives hook in quantity.hpp's usage example, and a Tests section in the example README. 351 tests total, all green; doxygen and qmllint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codecov flagged one partial line in datetime.hpp. Root causes, all closed:
- Every rejection path of fromIso8601 now has a dedicated malformed input:
each separator position individually wrong, a from_chars hard failure at
the start of every field (year/month/day/hour/minute/second), a below-'0'
character ending the fraction scan, and seconds > 59 — alongside the
existing too-short/trailing-junk/impossible-date cases.
- The formatter's empty-spec range check was runtime-dead: the format
machinery's contract guarantees the terminating '}' is always in the
parse range (both "{}" and "{:}" spellings), so the defensive
begin()==end() arm could never be false-tested. Removed with the contract
documented; a "{:}" round-trip test pins the empty-spec spelling.
llvm-cov: zero uncovered lines and zero one-sided branches across all five
field-type headers; 351 tests pass; tidy clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Actions can now carry exact, unit-tagged values and describe themselves as JSON Schemas that clients render forms from at runtime — no per-form GUI code.
morph/rational.hpp—morph::math::Rational, adapted from LASTRADA JPMath with a bundledDecimalPlacesstrong type (noboxed.hppdependency). Exact int64 arithmetic with decimal-precision propagation,std::expected-based error handling,std::formatter, and a Glaze wire codec ({"num","den","dp"}) that canonicalises on read and clamps hostile input (den:0, out-of-rangedp) instead of asserting.morph/quantity.hpp—morph::units::Quantity<U>: unit-tagged, optionally-empty values.optional<Rational>); action structs never wrap aQuantityinstd::optional.UnitTraitscustomisation point + a consteval algebra —Mass / VolumededucesDensityat compile time,kg + m³does not compile.ExtUnitsautomatically.morph/forms.hpp—schemaJson<A>(): glazewrite_json_schemaplus the gaps a renderer needs: derivedrequiredarray (std::optional/optionalFieldsopt-out rule),x-decimalPlaces,x-order.allRequiredEngaged()plugs into the existingActionValidatorresolution viavalidate(), so one declaration drives schema, submit gate, and readiness.examples/forms— the full loop, locally:--schemas,--emit-html(self-contained vanilla-JS renderer page), REPL throughActionDispatcher; plusgui_qml/(-DMORPH_BUILD_FORMS_QML=ON): a Qt QuickDynamicFormrenderer submitting real wire envelopes to an in-processRemoteServer.find_package(glaze 7.4 ...)version bound: a stale system/user glaze (e.g. a 4.x in~/.local) can no longer silently shadow the pinned v7.4.0.Test plan
ctest: 328/328 pass (Debug, clang 22), including the ported 681-line LASTRADA Rational suite + new wire-codec cases and quantity/forms tests (algebra static_asserts, empty propagation, schema content, registry dispatch round-trip)VERIFY_INTERFACE_HEADER_SETSand strict warnings (-Wconversion -Wsign-conversion -Wold-style-cast -Wshadow …) — cleanqmllintclean; QML app smoke-tested headless (offscreen)null)🤖 Generated with Claude Code