Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/spec/concurrency_and_lifetimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ turns it into a set of per-key serial queues:
- `_inFlight` counts strand lambdas currently dispatched to the base executor.
The destructor waits on `_cv` until `_inFlight == 0` before destroying the
map, so no pool thread can touch `_strands` after the executor is gone.
- **The drain's own work is one handoff per task and one notification per
quiescence, whatever the host is doing.** `_cv` is signalled only where
`--_inFlight` reaches zero, and `~StrandExecutor` is its only waiter, so the
`notify_all` wakes at most one thread — a handoff between two tasks for one
key signals nothing. What a loaded host adds is therefore *latency between*
those operations, not more of them: a drain of N tasks costs N dispatches
whose wall clock is the base executor's wakeup latency under the run queue of
the moment. Measured on a 12-thread host, the same drain's per-task cost
spans three orders of magnitude with machine load while every count above
stays fixed. A task body that calls `std::this_thread::yield()` pays far more
again, because a yielding thread goes to the back of the run queue with no
sleeper credit; that is a property of the posted task, not of the strand.
- **`~StrandExecutor` is a complete-drain barrier, not only a use-after-free
guard.** The re-arm in `scheduleNext` increments `_inFlight` for the next
dispatch *before* the current dispatch decrements its own, so the count never
Expand Down
72 changes: 71 additions & 1 deletion docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,71 @@ unseeded and is omitted from the request body until the user touches it, which
is what distinguishes "not answered" from an explicit `false` for a
`std::optional<bool>` member.

### Plain number fields — `type: "number"`

glaze emits `{"type": "number"}` for a bare `double` or `float` member, under
`$defs/double` / `$defs/float` with the type's own range on the definition node:

```json
"$defs": {"float": {"type": "number",
"minimum": -3.4028234663852886e+38,
"maximum": 3.4028234663852886e+38}},
"properties": {"score": {"$ref": "#/$defs/float", "x-order": 1}}
```

Such a member carries no `x-decimalPlaces` and is not a `Quantity`, so none of
the exact-decimal machinery applies to it. The shipped `DynamicForm.qml`
renderer draws the plain `TextField` and encodes its text as a JSON **number**,
which it needs a branch of its own to do: the plain-text fall-through
(`JSON.stringify(text)`) would submit `{"ratio":"3.5"}` where the schema asks
for `{"ratio":3.5}`, and a `TextField` applies no validation of its own, so
`{"ratio":"banana"}` would be submitted just as readily and the form would
report `ready` for it.

The encoding is:

- **Locale-normalised first**, exactly as a `Quantity`'s entry is: the decimal
separator and the digit grouping are the display locale's, and neither
belongs in the JSON number (`"1,000.5"` → `1000.5` in a locale that groups on
comma).
- **Grammar `-?\d+(\.\d+)?`** on the normalised text. No exponent, no trailing
separator, no bare fraction: a spelling outside it has no literal, so the
field is not engaged and the form is not ready — it is never encoded as a
string instead.
- **Digits carried through as typed**, not round-tripped through a JS number,
which would re-spell a long entry in exponent form and round it at the
seventeenth digit. Only the leading-zero run is removed, because JSON forbids
it: `"007.50"` → `7.50`. Trailing fraction zeros are kept — re-spelling the
fraction is not the encoder's business.
- **Gated on the declared range** and on `multipleOf`, like an integer field.
For a plain member the range is the one glaze stamps on the type, so a value
a `float` cannot hold is refused by the same check that enforces a
[`FieldMeta` bound](#per-field-scalar-bounds--minimum--maximum--multipleof).
`allFieldBoundsSatisfied` does not check a `double` member — it reads
`Quantity`, bare `math::Rational` and integral members only — but `ready` is
a claim about the payload satisfying **the schema**, and the schema states
the bound.

Three neighbouring shapes are numeric too and encode differently, which is why
the renderer asks in this order — `Quantity`, then `integer`, then plain
number:

| Member | Schema | Encoding |
|---|---|---|
| `Quantity<U, Dec>` | `"type": ["object","null"]`, `x-decimalPlaces`, `ExtUnits` | exact `{num,den,dp}`, assembled from the typed digits |
| an integral member | `"type": "integer"` (+ `x-exactMinimum`/`x-exactMaximum` past 2^53) | bare integer, gated on the [exact string bounds](#exact-numeric-bounds--x-exactminimum--x-exactmaximum) |
| `double` / `float` | `"type": "number"` | bare JSON number, as above |

A generated `Quantity` property is therefore not `"number"` at all. The order
still matters, because a *decorated* schema can put `x-decimalPlaces` on a
property whose type is `"number"` — a precise field spelled the plain way. The
declared precision wins there, and the field keeps the exact encoding.

A bare `math::Rational` member is **not** in this family: its schema is an
inline object of `num`/`den`/`dp` with no `x-decimalPlaces`, so it is
[unrepresentable](#what-ready-claims) — the one object-typed member whose C++
declaration looks scalar.

### Nullable fields whose type is a `$ref` — `anyOf`

A nullable member whose underlying type is emitted as a definition rather than
Expand Down Expand Up @@ -1200,7 +1265,12 @@ two shapes:

- an **object-typed** member that no typed control claims — a
[nested aggregate](#nested-aggregates-recursive-cycle-safe), whose one scalar
control collects text where the schema asks for an object;
control collects text where the schema asks for an object. A bare
`math::Rational` member is this shape too, and is the instance least likely
to be expected: its C++ declaration is a scalar and its schema is an object
of `num`/`den`/`dp`. Wrapping it in a `Quantity` — or giving the field an
`x-decimalPlaces` — is what hands it the exact-decimal control that encodes
that shape;
- an **array whose `items` are objects** (or arrays), which takes the
[`type: "array"` control](#array-fields--type-array) and encodes each entry as
a JSON string. A control *was* drawn and the member is unrepresentable anyway,
Expand Down
8 changes: 8 additions & 0 deletions include/morph/core/strand.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,14 @@ class StrandExecutor {
{
std::scoped_lock const lock{_mapMtx};
if (--_inFlight == 0) {
// Inside the `== 0` branch, so a handoff does not signal at
// all: the re-arm above has already incremented for the
// next dispatch, so the count does not reach zero until the
// strand is quiescent. `~StrandExecutor` is the only waiter
// on this variable, so `notify_all` wakes at most one
// thread and is equivalent to `notify_one` here -- there is
// no herd to wake, and no predicate but `_inFlight == 0`
// for a wakeup to land on and be lost.
_cv.notify_all();
}
}
Expand Down
62 changes: 61 additions & 1 deletion src/qt/forms/qml/DynamicForm.qml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
// x-min/x-max/x-step -> slider track bounds + increment (Ranged fields)
// type: "array" -> comma-separated-with-validation control; encodes to
// a genuine JSON array literal, e.g. "a, b" -> ["a","b"]
// type: "number" -> plain text field encoding a JSON number, for a member
// with no x-decimalPlaces (a bare double/float)
// x-submitMode: "explicit" -> suppresses auto-submit-on-validity; renders
// an explicit Submit button (enabled only while ready)
// instead -- see "Explicit submit mode" below
Expand Down Expand Up @@ -487,6 +489,9 @@ Frame {
// `isArray` is deliberately absent: the array control claims
// the property but encodes its items as strings, so an array
// of objects is unrepresentable even though a control drew it.
// `isNumber` is absent for the same reason: a member declared
// both "number" and "object" is satisfied by neither reading,
// and the object one is the half no typed text can close.
const typedControl = dp !== undefined || optionsAction !== undefined
|| enumOptionRows.length > 0 || p.format === "date-time"
|| types.indexOf("integer") !== -1 || types.indexOf("boolean") !== -1
Expand Down Expand Up @@ -524,6 +529,16 @@ Frame {
isQuantity: dp !== undefined,
decimals: opt(dp, 0),
isInteger: types.indexOf("integer") !== -1,
// "number" -- a bare `double`/`float`. The plain text
// field draws it, but the JSON *number* encoding is its
// own: the fall-through at the end of fieldJsonLiteral
// would quote the digits. This flag says only what the
// schema's type is, so it is also true for a *precise*
// field spelled "number" with an x-decimalPlaces beside
// it; fieldJsonLiteral asks `isQuantity` first, which is
// what keeps such a field on the exact {num,den,dp}
// encoding.
isNumber: types.indexOf("number") !== -1,
// "boolean" -- a CheckBox, not the plain text field's
// fall-through (which wrapped the typed text as a JSON
// *string*: {"flag":"true"}, or {"flag":"banana"} for
Expand Down Expand Up @@ -1338,6 +1353,50 @@ Frame {
return null
return normalised
}
if (f.isNumber) {
// A bare `double`/`float` member: `"type": "number"` with no
// x-decimalPlaces and no Quantity wrapper, so none of the encoders
// above claims it. It needs one of its own -- the generic
// fall-through at the end of this function would wrap the typed
// digits as a JSON *string*, and the schema asks for a number.
// Declaring it unrepresentable instead would be worse than the
// wrong value it replaces: a number is exactly what a text field
// collects, so a form carrying one would never be submittable.
//
// Locale-normalised first, like a Quantity's entry: a decimal
// field is typed with the locale's decimal separator and
// grouping, and neither belongs in the JSON number.
const canonicalNumber = normalizeLocaleNumber(text, {
decimalSeparator: qtLocale.decimalPoint,
groupSeparator: qtLocale.groupSeparator,
negativeSign: qtLocale.negativeSign,
positiveSign: qtLocale.positiveSign,
zeroDigit: qtLocale.zeroDigit
})
// No exponent and no trailing separator: the grammar a text field
// is expected to collect, and every spelling outside it (blank
// fraction, stray sign, letters) is refused rather than encoded.
if (canonicalNumber === null || !/^-?\d+(\.\d+)?$/.test(canonicalNumber))
return null
const numberValue = parseFloat(canonicalNumber)
// The declared range, which for a plain member is the one glaze
// stamps on the type itself: a `float` field carries ±3.4e38, so
// this gate refuses a value the member cannot hold. `ready` is a
// claim about the payload satisfying the schema, so a bound the
// schema states is checked here whether or not the model's own
// bound check covers this member kind.
if (f.minimum !== undefined && numberValue < f.minimum)
return null
if (f.maximum !== undefined && numberValue > f.maximum)
return null
if (violatesMultipleOf(numberValue, f.multipleOf))
return null
// Digits carried through as typed, never through parseFloat: a
// round-trip through a JS number re-spells what the user wrote
// ("1e+41" for a long entry) and rounds at the seventeenth digit.
// Only the leading-zero run has to go, because JSON forbids it.
return canonicalNumber.replace(/^(-?)0+(?=\d)/, "$1")
}
if (f.isBoolean) {
// Emitted bare, never quoted. The CheckBox only ever stores these
// two spellings; any other retained value (a prefill from a stale
Expand Down Expand Up @@ -1796,7 +1855,8 @@ Frame {
: (fieldColumn.modelData.isQuantity
? "0." + "0".repeat(Math.max(1, fieldColumn.modelData.decimals))
: (fieldColumn.modelData.isInteger ? "0" : ""))
inputMethodHints: (fieldColumn.modelData.isQuantity || fieldColumn.modelData.isInteger)
inputMethodHints: (fieldColumn.modelData.isQuantity || fieldColumn.modelData.isInteger
|| fieldColumn.modelData.isNumber)
? Qt.ImhFormattedNumbersOnly : Qt.ImhNone
onTextChanged: form.setFieldValue(fieldColumn.modelData.name, text)
// Re-seed from the retained value whenever this delegate is
Expand Down
Loading
Loading