forms(qml): encode a plain "number" member as a number, and attribute the strand drain's slowdown (fixes #802, refs #780) - #803
Merged
Conversation
…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
This was referenced Sep 24, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Two tickets, one commit each.
1.
fixes #802— a plain"number"member is submitted as a JSON stringOutcome: the encoding branch, not the
static_assert. A baredouble/floatis 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 thatworks 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
doubleunsubmittable, 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:Note the second one: because a
TextFieldvalidates nothing and thefall-through was
JSON.stringify(text), a plain-number field accepted"banana"and reported the form ready for it. The defect is not only awrong 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: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
Quantityentry does: the decimal separatorand the digit grouping are the display locale's and neither belongs in a JSON
number (
"1,000.5"→1000.5).-?\d+(\.\d+)?. No exponent, no trailing separator, no barefraction: outside that grammar the field has no literal and the form is not
ready, rather than being encoded as a string.
multipleOf. For a plain member the range isthe one glaze stamps on the type, so
1e41in afloatfield is refused bythe same check that enforces a
FieldMetabound.it) instead of round-tripping through a JS number, which would re-spell a long
entry as
1e+41and round at the seventeenth digit.The three number-ish shapes, surveyed rather than assumed
Measured by printing what
schemaJson<A>()actually emits:x-decimalPlaces/units::Quantity"type": ["object","null"]withx-decimalPlacesandExtUnitsbeside it{num,den,dp}; now pinned against that measured schema instead of an idealised onemath::Rationalnum/den/dp, nox-decimalPlacesreadyA generated
Quantityproperty is therefore not"number"at all, which theprevious fixture in this area got wrong. The ordering in
fieldJsonLiteralstill matters, because a decorated schema can put
x-decimalPlaceson aproperty whose type is
"number"; a new case pins that the declared precisionwins there.
The bare-
Rationalfinding is recorded as a comment on #802 rather than foldedin here — different member shape, different fix. Short version:
SetBudgetLimitin the ledger rung has a bare
morph::math::Rational limitmember, so it is areal shape and not a hypothetical; ledger's QML is hand-built (no
MorphFormsdependency), 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 eventualfix is a recognition rule, not a new encoder.
2.
refs #780—StrandExecutor's drain slows superlinearlyComment and spec only. The attribution is the scheduler, via the posted task's
own
yield()— notStrandExecutor, and thenotify_allhypothesis is deadtwice over.
By construction
The notify sits inside the
--_inFlight == 0branch, and the re-arm incrementsfor the next dispatch before the current one decrements, so the count does not
reach zero across a handoff — a handoff signals nothing at all.
~StrandExecutoris the only waiter on
_cv, sonotify_allwakes at most one thread and isequivalent to
notify_onehere. There is no herd to wake and no secondpredicate for a wakeup to land on and be lost.
git log -S notify_all --follow -- include/morph/core/strand.hppreturns onecommit —
00d480d5 [morph] Init. It has never beennotify_one; no deliberatefix 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
_mapMtxacquisition) driving thesaturation 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:_mapMtxEvery 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
_mapMtxacquisitions fall as loadrises, 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 elsechanged, the same 9600 tasks under 36 spinners:
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 callyield(), so application dispatch throughStrandExecutordoes not degrade this way — the no-yield column is theevidence.
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:
invalidas aStrandExecutordefect, with thenumbers on record. Left open with
refsrather thanfixesso the assessmentis 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.mdstating that the drain's work is onehandoff 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 oninclude/morph/core/strand.hpp, the only C++ in the diff..qmlis notclang-formatted in this repo, so for the DynamicForm change this gate
measured nothing.
clang-tidy-diff.pyoverorigin/master...HEADreportsNo relevant changes found— the diff's only C++ is a header, which is whatCI'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-filterrestricted tostrand.hpp373–381. Exit 0,23151 warnings generated,1 due to line filter— so one file really was analysed and the filter was live, rather thana gate reporting success over zero files.
--target docwithWARN_AS_ERROR = FAIL_ON_WARNINGS(
build/docs/docs/Doxyfile.doc:111): exit 0, zero warning lines. Run becausea 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.
ctest:100% tests passed out of 1665, exit 0. Includes#1221 StrandExecutor serialises tasks for the same keyand#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