Skip to content

forms(qml): encode a plain "number" member as a number, and attribute the strand drain's slowdown (fixes #802, refs #780) - #803

Merged
Yaraslaut merged 2 commits into
masterfrom
forms-plain-number-and-strand-drain
Sep 24, 2026
Merged

Yaraslaut merged 2 commits into
masterfrom
forms-plain-number-and-strand-drain

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Two tickets, one commit each.

1. fixes #802 — a plain "number" member is submitted as a JSON string

Outcome: the encoding branch, not the static_assert. A bare double/float
is an ordinary member type, glaze emits a perfectly honest {"type":"number"}
for it, and a text field is exactly the control that collects one — so the
schema is not something the renderer cannot honour, it is something the renderer
had no encoder for. static_asserting on it would remove a member type that
works end to end everywhere except in fifteen lines of QML.

And explicitly not the readiness mechanism from #801: routing "number"
through the unrepresentable path would leave every form with a double
unsubmittable, which is worse than a wrong value — a wrong value is caught at
the action boundary, an unready form cannot be sent at all.

Reproduced first, against the renderer as it was

src/qt/forms/tests/tst_DynamicFormPlainNumber.qml, run before the fix:

FAIL!  : DynamicFormPlainNumber::test_a_decimal_is_submitted_as_a_json_number() 'verify()' returned FALSE.
FAIL!  : DynamicFormPlainNumber::test_text_that_is_not_a_json_number_is_refused() accepted abc
FAIL!  : DynamicFormPlainNumber::test_declared_bounds_gate_a_plain_number() Compared values are not the same
... Totals: 306 passed, 9 failed

Note the second one: because a TextField validates nothing and the
fall-through was JSON.stringify(text), a plain-number field accepted
"banana" and reported the form ready for it. The defect is not only a
wrong JSON type.

After: Totals: 317 passed, 0 failed (301 before this branch, 16 new cases).

Mutation

Left the branch in place with all its validation and changed only its return to
JSON.stringify(...) — i.e. kept the gate, removed the number-ness:

Totals: 308 passed, 7 failed

So the number-vs-string assertions are load-bearing on their own and are not
being carried by the syntax/bounds checks that came with the branch.

The branch

  • Locale-normalises first, as the Quantity entry does: the decimal separator
    and the digit grouping are the display locale's and neither belongs in a JSON
    number ("1,000.5" → 1000.5).
  • Requires -?\d+(\.\d+)?. No exponent, no trailing separator, no bare
    fraction: outside that grammar the field has no literal and the form is not
    ready, rather than being encoded as a string.
  • Gates on the declared range and multipleOf. For a plain member the range is
    the one glaze stamps on the type, so 1e41 in a float field is refused by
    the same check that enforces a FieldMeta bound.
  • Emits the typed digits verbatim (leading-zero run stripped, since JSON forbids
    it) instead of round-tripping through a JS number, which would re-spell a long
    entry as 1e+41 and round at the seventeenth digit.

The three number-ish shapes, surveyed rather than assumed

Measured by printing what schemaJson<A>() actually emits:

shape schema status
x-decimalPlaces / units::Quantity one shape, not two: "type": ["object","null"] with x-decimalPlaces and ExtUnits beside it works — exact {num,den,dp}; now pinned against that measured schema instead of an idealised one
bare math::Rational inline object of num/den/dp, no x-decimalPlaces does not work — reported unrepresentable, so the form never reaches ready

A generated Quantity property is therefore not "number" at all, which the
previous fixture in this area got wrong. The ordering in fieldJsonLiteral
still matters, because a decorated schema can put x-decimalPlaces on a
property whose type is "number"; a new case pins that the declared precision
wins there.

The bare-Rational finding is recorded as a comment on #802 rather than folded
in here — different member shape, different fix. Short version: SetBudgetLimit
in the ledger rung has a bare morph::math::Rational limit member, so it is a
real shape and not a hypothetical; ledger's QML is hand-built (no MorphForms
dependency), so no shipped form is broken today. It is also encodable — a
declared precision on that property hands it the exact-decimal control whose
literal is precisely {num,den,dp}, which the new suite pins — so the eventual
fix is a recognition rule, not a new encoder.

2. refs #780 — StrandExecutor's drain slows superlinearly

Comment and spec only. The attribution is the scheduler, via the posted task's
own yield() — not StrandExecutor
, and the notify_all hypothesis is dead
twice over.

By construction

The notify sits inside the --_inFlight == 0 branch, and the re-arm increments
for the next dispatch before the current one decrements, so the count does not
reach zero across a handoff — a handoff signals nothing at all. ~StrandExecutor
is the only waiter on _cv, so notify_all wakes at most one thread and is
equivalent to notify_one here. There is no herd to wake and no second
predicate for a wakeup to land on and be lost.

git log -S notify_all --follow -- include/morph/core/strand.hpp returns one
commit — 00d480d5 [morph] Init. It has never been notify_one; no deliberate
fix exists to undo in either direction.

By measurement

An instrumented copy of the executor (counters around the notify, the drain's
wait predicate, each dispatch and each _mapMtx acquisition) driving the
saturation case's exact shape — 8 producers × 400 posts on one key, 3 iterations
= 9600 tasks — against synthetic spinner load. 12-thread host (Ryzen 5 7600X),
clang 22.1.8, -O2:

spinners wall_s per run µs/task handoffs notifies wakeups that found nothing contended _mapMtx
0 0.019 0.019 0.020 2.0 9600 3 3 2216–2712
12 2.11 3.53 3.60 4.71 220–490 9600 3 3 777–960
24 30.6 32.3 36.5 3186–3803 9600 3 3 0–303
36 58.9 59.2 60.4 6140–6290 9600 3 3 0–81

Every strand-side count is load-invariant — one handoff per task, one
notification per drain, one wakeup per drain — while per-task wall clock moves
by three orders of magnitude. Contended _mapMtx acquisitions fall as load
rises, since the producers get less CPU with which to contend.

Scaled to the case's 20 iterations these are 24 s / 214 s / 394 s against the
23.8 / 170.5 / 396.4 s on the issue, so the instrument reproduces the reported
curve rather than a different one.

Runs per point and spread: 3–4 runs per point, not one. The spread at 12
spinners is 2.11–4.71 s — a factor of 2.2 within one point — so the reported
exponent cannot be supported by one run per point. No curve is fitted, and the
idle point is reported beside the loaded ones rather than on the same axis.

What the cost actually is

With the task body's std::this_thread::yield() removed and nothing else
changed, the same 9600 tasks under 36 spinners:

spinners=36 iterations=3 (tasks/iteration=3200)
     run     wall_s    us/task   handoffs   notifies    predChk    predFalse
       0      0.025       2.62       9600          9          5            2
       1      0.018       1.87       9600         10          6            3
       2      0.035       3.60       9600         15          5            2

2.6 µs/task at 4× oversubscription — the idle figure, ~2300× faster than the
same run with the yield. A yielding thread goes to the back of the run queue
with no sleeper credit and waits a full queue round; a thread woken from a
condition variable has sleeper credit and preempts a CPU-bound spinner
promptly, which is why the strand's own handoff path is flat in load.

That also answers the triage comment's "why it matters beyond a slow test": a
model's execute() does not call yield(), so application dispatch through
StrandExecutor does not degrade this way
— the no-yield column is the
evidence.

The yield is how the saturation case maximises drain/re-arm interleaving, so it
is load-bearing for what the case detects. It is not something to remove, and
the iteration count is not something to cut.

Suggested verdict on #780: invalid as a StrandExecutor defect, with the
numbers on record. Left open with refs rather than fixes so the assessment
is the reviewer's.

What landed for it

A comment at the notify saying why it is not a herd, and a spec bullet in
docs/spec/concurrency_and_lifetimes.md stating that the drain's work is one
handoff per task and one notification per quiescence whatever the host is doing.
Both exist because reading the line invites exactly the inference the triage
comment made.

Gates

  • clang-format --dry-run -Werror, clang-format 22.1.8: clean on
    include/morph/core/strand.hpp, the only C++ in the diff. .qml is not
    clang-formatted in this repo, so for the DynamicForm change this gate
    measured nothing
    .
  • clang-tidy: clang-tidy-diff.py over origin/master...HEAD reports
    No relevant changes found — the diff's only C++ is a header, which is what
    CI's own filter step exists to pin to a donor compile command. Reproduced that
    locally instead: clang-tidy -p build/qmlforms tests/test_strand_race.cpp
    (a TU that includes the header) with --line-filter restricted to
    strand.hpp 373–381. Exit 0, 23151 warnings generated, 1 due to line filter — so one file really was analysed and the filter was live, rather than
    a gate reporting success over zero files.
  • Doxygen --target doc with WARN_AS_ERROR = FAIL_ON_WARNINGS
    (build/docs/docs/Doxyfile.doc:111): exit 0, zero warning lines. Run because
    a public header is touched.
  • morph_forms_qml_tests -input src/qt/forms/tests: 317 passed, 0 failed,
    exit 0 — the whole suite, not the new file.
  • Full ctest: 100% tests passed out of 1665, exit 0. Includes
    #1221 StrandExecutor serialises tasks for the same key and #1585 forms_qml_logic.

Configuration measured on: Release, clang 22.1.8, MORPH_BUILD_QT=ON,
MORPH_BUILD_FORMS_QML=ON, MORPH_BUILD_TESTS=ON, examples and ladder OFF.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut and others added 2 commits September 24, 2026 07:05
…oted string (fixes #802)

`fieldJsonLiteral` had no branch for a schema property declared plain
`"number"` -- a bare `double`/`float`, no `x-decimalPlaces`, no `Quantity`
wrapper. Such a member fell through to `JSON.stringify(text)`, so the body
carried `"3.5"` where the schema asks for `3.5`; and because a `TextField`
validates nothing, `{"ratio":"banana"}` was submitted just as readily with the
form reporting `ready` for it. Measured, against the renderer before this
change:

    FAIL!  : DynamicFormPlainNumber::test_a_decimal_is_submitted_as_a_json_number()
    FAIL!  : DynamicFormPlainNumber::test_text_that_is_not_a_json_number_is_refused() accepted abc
    ... 9 failed of 315

The fix is an encoding branch, deliberately *not* the readiness mechanism that
landed for nested aggregates: a bare number is representable -- a text field is
exactly the control for it -- and routing it through the unrepresentable path
would leave every form with a `double` unsubmittable, which is worse than a
wrong value. The branch normalises the locale's separators as the `Quantity`
entry does, requires `-?\d+(\.\d+)?`, gates on the declared range (which for a
plain member is the one glaze stamps on the type: a value past a `float`'s
3.4e38 is refused) and on `multipleOf`, and emits the typed digits verbatim
rather than round-tripping them through a JS number.

The three neighbouring number-ish shapes were surveyed rather than assumed, by
printing what `schemaJson<A>()` actually emits:

  - `x-decimalPlaces` / `units::Quantity` -- one shape, not two: a generated
    `Quantity` property is `"type": ["object","null"]` with `x-decimalPlaces`
    beside it, and the exact `{num,den,dp}` branch handles it. Working, now
    pinned with that measured schema instead of an idealised one.
  - a bare `math::Rational` member -- **not** working, and not a missing
    encoding branch either: its schema is an inline object of `num`/`den`/`dp`,
    which the renderer reports *unrepresentable*, so a form over
    `SetBudgetLimit`-shaped action cannot be submitted at all. It is encodable
    -- `x-decimalPlaces` on that property hands it the exact-decimal control
    whose literal is precisely that shape, which the new suite pins. Recorded
    on the issue rather than folded in here.

`src/qt/forms/tests/tst_DynamicFormPlainNumber.qml` covers the encoding, the
wire grammar, the range gate, the branch order against a declared precision,
and the two neighbours as regression guards. Mutating the branch to quote its
literal while keeping its validation reddens 7 of the 16 new cases, so the
number-vs-string assertions are load-bearing and not carried by the validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…in costs under load (refs #780)

Comment and spec only; no behaviour change. The reported superlinear drain was
attributed to `notify_all` under contention, which reading the line invites and
measuring refutes twice over.

**By construction.** The notify sits inside the `--_inFlight == 0` branch, and
because the re-arm increments for the next dispatch before the current one
decrements, the count does not reach zero across a handoff -- so a handoff
signals nothing at all. `~StrandExecutor` is the only waiter on `_cv`, so
`notify_all` wakes at most one thread and is equivalent to `notify_one` here.
`git log -S notify_all --follow` on the file returns one commit, the initial
import: it has never been `notify_one`, so there is no deliberate fix to undo
in either direction.

**By measurement.** An instrumented copy of the executor (counters around the
notify, the drain's wait predicate, each dispatch and each `_mapMtx`
acquisition) driving the saturation case's exact shape -- 8 producers x 400
posts on one key, 3 iterations = 9600 tasks -- against synthetic spinner load on
a 12-thread host, clang 22.1.8 -O2:

    spinners  wall_s per run              us/task   handoffs  notifies  wakeups-that-found-nothing
        0     0.019 0.019 0.020              2.0      9600        3        3
       12     2.11 3.53 3.60 4.71          220-490    9600        3        3
       24     30.6 32.3 36.5              3186-3803   9600        3        3
       36     58.9 59.2 60.4              6140-6290   9600        3        3

Every strand-side count is load-invariant -- one handoff per task, one
notification per drain, one wakeup per drain -- while per-task wall clock moves
by three orders of magnitude. Contended `_mapMtx` acquisitions *fall* as load
rises (2477 idle, 0-81 at 36 spinners), since the producers get less CPU to
contend with.

Scaled to the case's 20 iterations these give 24 s / 214 s / 394 s against the
23.8 / 170.5 / 396.4 on record, so the instrument reproduces the reported curve
rather than a different one.

What the cost actually is: with the task body's `std::this_thread::yield()`
removed and nothing else changed, the same 9600 tasks under 36 spinners run in
0.018-0.035 s -- 2.6 us/task, the idle figure. A yielding thread goes to the
back of the run queue with no sleeper credit, so it waits a full queue round per
task; the strand's own wakeup path does not, which is why the no-yield figure is
flat in load. That yield is the saturation case's way of maximising
drain/re-arm interleaving, so it is load-bearing for what the case detects and
is not something to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@codecov

codecov Bot commented Sep 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 05222af into master Sep 24, 2026
39 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