Skip to content

Add exact Rational/Quantity value types and schema-driven form generation#6

Merged
Yaraslaut merged 10 commits into
masterfrom
feature/quantity-forms
Jul 6, 2026
Merged

Add exact Rational/Quantity value types and schema-driven form generation#6
Yaraslaut merged 10 commits into
masterfrom
feature/quantity-forms

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

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.hppmorph::math::Rational, adapted from LASTRADA JPMath with a bundled DecimalPlaces strong type (no boxed.hpp dependency). 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-range dp) instead of asserting.
  • morph/quantity.hppmorph::units::Quantity<U>: unit-tagged, optionally-empty values.
    • One kind of empty: the blank state lives inside (optional<Rational>); action structs never wrap a Quantity in std::optional.
    • Units are types, defined by the application: enum NTTPs via the UnitTraits customisation point + a consteval algebra — Mass / Volume deduces Density at compile time, kg + m³ does not compile.
    • Units never travel: wire payload is the nullable Rational; schemas get ExtUnits automatically.
  • morph/forms.hppschemaJson<A>(): glaze write_json_schema plus the gaps a renderer needs: derived required array (std::optional / optionalFields opt-out rule), x-decimalPlaces, x-order. allRequiredEngaged() plugs into the existing ActionValidator resolution via validate(), 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 through ActionDispatcher; plus gui_qml/ (-DMORPH_BUILD_FORMS_QML=ON): a Qt Quick DynamicForm renderer submitting real wire envelopes to an in-process RemoteServer.
  • CMakefind_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.
  • Docs — README section with runnable snippet; ARCHITECTURE namespace/header maps, design section, "validators do not run server-side" known limitation, design-decision rows.

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)
  • Full build incl. VERIFY_INTERFACE_HEADER_SETS and strict warnings (-Wconversion -Wsign-conversion -Wold-style-cast -Wshadow …) — clean
  • qmllint clean; QML app smoke-tested headless (offscreen)
  • HTML demo: schemas embed as valid JSON; REPL round-trip verified incl. error paths (missing required field → domain error, unknown action, empty-propagation → null)

🤖 Generated with Claude Code

…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

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 9 commits July 5, 2026 16:34
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>
@Yaraslaut Yaraslaut merged commit 21a73ef into master Jul 6, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant