Skip to content

fix: Keep the trailing zeros of a floatstring range element - #345

Open
leongdl wants to merge 3 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/floatstring-preserve-trailing-zeros
Open

fix: Keep the trailing zeros of a floatstring range element#345
leongdl wants to merge 3 commits into
OpenJobDescription:mainlinefrom
leongdl:fix/floatstring-preserve-trailing-zeros

Conversation

@leongdl

@leongdl leongdl commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

The string form of a <FloatRangeList> element keeps the trailing zeros it was written with. Leading zeros still go.

- name: Weight
  type: FLOAT
  range: ['1.5', '02.50', '3.500']   # now W:1.5 W:2.50 W:3.500  (was W:1.5 W:2.5 W:3.5)

- name: Frame
  type: INT
  range: ['1', '02', '003']          # unchanged: FRAME:1 FRAME:2 FRAME:3

Why

Review on #342 made the point that the string form exists in order to carry the scale it was written with. A range element written '2.50' is asking for 2.50 on a command line; a renderer handed 2.5 has been given a different string. #342 normalized the element to the number it denotes and threw that away.

The two kinds of zero are not the same thing, and this splits them:

Leading zeros not part of the number — dropped '02' on an INT range is the task value 2; forwarding the text renders --frame 02
Trailing zeros the author's chosen scale — kept '02.50' renders 2.50

This is also what lets two conformance fixtures both be right at once. The already-landed EXPR/jobs/expr1.3.4--float-passthrough asserts PARAM:3.500 from a FLOAT default: "3.500". base/jobs/3.4.1.2--float-range-floatstring-elements-normalized (in openjd-specifications#179) was written to state the opposite reading for a range element, and its own comment flagged that the two could not coexist and that a spec ruling was needed. The ruling is trailing-keep, so the range-element fixture is the one that moves.

What this deletes, and why

A <floatstring> now renders as its own text less any redundant leading zeros. That is a substring operation, not a numeric one, which removes the machinery d6d5540 and e7a17b3 added — every problem those commits were solving came from having to pick a notation in which to re-render a Decimal:

  • normalize() and quantize() round to getcontext().prec, so an embedding application's decimal context changed what this library rendered.
  • str(Decimal) switches to exponent notation below 1e-6, so '0.0000001' rendered 1E-7.
  • format(value, 'f') is exact and plain at every magnitude but unbounded in the exponent, so '1e999999999' — 11 characters of template text — expanded to ~109 characters and needed a length bound to stay safe.

Text needs none of those decisions. '1e999999999' now costs its own 11 characters, so the exponent bound and its four boundary cases go with it, and an element can no longer silently exceed TaskParameterStringValueAsJob's 1024-character cap and fall through to a numeric member of the union. Net 68 insertions, 153 deletions, one commit.

An <intstring> is unaffected: int() already discards leading zeros, and there are no fractional digits to preserve.

Verification

Model suite 5527 → 5529 passed, 24 skipped, 3 xfailed. ruff, black, mypy clean at the CI-pinned versions.

Conformance, full 2023-09/* suite (1162 fixtures) run from source — openjd-cli at mainline 7c7ece4, openjd-sessions at 0.10.14, which is the newest combination the CLI supports — against openjd-specifications#180's tree:

Model Result
baseline @ e7a17b3 (mainline) 1161 passed, 1 failedbase/jobs/7.5--numeric-string-zeros-in-range-elements
this branch 1162 passed, 0 failed

The one fixture that pins this rule goes green and nothing else moves. That includes both controls: base/jobs/3.4--float-parameter (a <float> numeric literal rendering 1.0) and EXPR/jobs/expr1.3.4--float-passthrough (a FLOAT default: "3.500" rendering 3.500) both pass.

Mutation-checked. Three mutants confirm the tests pin both halves of the rule rather than just asserting the current output:

Mutant Result
leading-zero strip removed (forward text verbatim) 7 tests fail
trailing-zero strip restored (the #342 behaviour) 7 tests fail
int() conversion removed 3 tests fail

Cross-implementation agreement

openjd-rs, which also backs this package's openjd.model._v1 API, needed the matching change: it stored a resolved FLOAT range as Vec<f64>, so '02.50' rendered 2.5. That is OpenJobDescription/openjd-rs#354. With both branches in place the two implementations render every range-element case tested identically:

Source Both render
'1.5' '02.50' '3.500' '007' 1.5 2.50 3.500 7
'0.50' '000' '0.00' 0.50 0 0.00
'1E+2' '1e-3' '+2.50' 1E+2 1e-3 +2.50

Before the two changes they disagreed on eight of those ten.

Review round

Five findings, all measured against 0.11.6, mainline e7a17b3, and this branch before acting. Two were correct and are fixed in 0708da8; three do not hold.

Finding Verdict
float() forwards ' 1.5 ' with its spaces Correct, fixed. Mainline rendered 1.5; openjd-rs trims the resolved element, so this does too. The underscore case ('1_0.5') is real but pre-existing — released 0.11.6 renders it the same way and openjd-rs rejects the template outright, so it is a template-layer parity issue, not this change's.
-0.0 renders -0.0, diverging from openjd-rs Correct, fixed. Zero has no sign; the sign is dropped without the decimal places, so -0.00.0 and -0.000.00. Text that reaches zero only by underflow (1e-400) no longer renders a string that disagrees with the value. The + case needed no change: openjd-rs renders +2.50 too, measured.
Dropping the length guard lets an over-cap element round through float Wrong. Measured identical on all three checkouts (1.2345678901234567). The old bound ran before the strip, so it returned the element unchanged at 1126 characters — the strip was never reached. Pre-existing since 0.11.6. The comment that over-claimed a guarantee was reworded.
'007'7 reintroduces a divergence Stale premise. That described openjd-rs before its matching fix: 9a9998d rendered 7.0, openjd-rs#354 renders 7. The suggested fix (append .0 when no point) would create the divergence.
Exponent notation now reaches a command line Not fixing. A restoration, not a new behaviour: released 0.11.6 renders 1E+2, and so does openjd-rs on both sides of its fix. 100.0 existed only in the two unreleased commits this PR revises, and forcing plain notation is what produced the unbounded-expansion bug. §7.5 marks it unspecified.

Known remaining divergence

On the FLOAT job parameter default surface, openjd-rs forwards the text verbatim and does not strip leading zeros: default: '007' renders 007 there and 7 here, and default: '01.250' renders 01.250 there and 1.250 here. Template Schemas §7.5 rule 1 says they should be stripped, so openjd-rs is the side that is wrong, but no conformance fixture pins it and it is a pre-existing difference on a surface this change does not touch. Left for a follow-up rather than widened into either PR.

Exponent notation and an explicit leading + also still differ between the two on the default surface ('1e-3' renders 0.001 here, 1e-3 there). §7.5 calls both out as unspecified in this revision.

Related


Does this revert half of #342?

Asked in review. No — it reverts one axis of #342's FLOAT behaviour, and fully reverts the two follow-up commits, but #342's INT behaviour, its bug fix, and its architecture all survive.

Rendered range-element values, probed against four checkouts, each built and run rather than read:

Source 0.11.6 (pre-#342) #342 f5da1b0 mainline e7a17b3 this branch
INT '02' 02 2 2 2
INT '003' 003 3 3 3
FLOAT '1.5' 1.5 1.5 1.5 1.5
FLOAT '02.50' 02.50 2.5 2.5 2.50
FLOAT '3.500' 3.500 3.5 3.5 3.500
FLOAT '007' 007 7 7.0 7
FLOAT '0.50' 0.50 0.5 0.5 0.50
FLOAT '0.00' 0.00 0 0.0 0.00

#342 collapsed a <floatstring> to the number it denotes, which dropped leading and trailing zeros in one action. Those are separable, and only the second is reverted.

What survives from #342

What is reverted in full is d6d5540 and e7a17b3, not #342. Those existed only to make re-rendering a Decimal safe: context-sensitive rounding, exponent notation below 1e-6, and the unbounded format(v, 'f') expansion that needed a 1024-character bound. Forwarding text needs none of it. That is where the −132 lines come from, and it is also why '007' renders 7 rather than mainline's 7.0 — the "always keep one fractional digit" rule was a follow-up invention, not #342's.

Proportions, from the diffs

Source only, excluding tests:

The sharpest statement: relative to the last release, the only behaviour change left on this branch is the leading-zero trim. Every cell where this branch differs from 0.11.6 is a removed leading zero. That trim is #342's contribution, kept. So #342 nets out as retained-and-narrowed rather than half-reverted; the machinery being reverted belongs to the commits that came after it.

@leongdl
leongdl requested a review from a team as a code owner September 1, 2026 02:45
Comment thread src/openjd/model/v2023_09/_model.py Outdated
Comment thread src/openjd/model/v2023_09/_model.py
Comment thread src/openjd/model/v2023_09/_model.py
@leongdl

leongdl commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Answering "does this revert half of #342?", with the function it turns on

Short answer: it reverts one axis of #342's FLOAT behaviour and fully reverts the two follow-up commits. #342's INT behaviour, its containment bug fix, and its architecture all survive. The full evidence table is now in the PR description; the four-checkout probe is repeated here for the thread.

Source 0.11.6 (pre-#342) #342 f5da1b0 mainline e7a17b3 this branch
INT '02' 02 2 2 2
INT '003' 003 3 3 3
FLOAT '1.5' 1.5 1.5 1.5 1.5
FLOAT '02.50' 02.50 2.5 2.5 2.50
FLOAT '3.500' 3.500 3.5 3.5 3.500
FLOAT '007' 007 7 7.0 7
FLOAT '0.50' 0.50 0.5 0.5 0.50
FLOAT '0.00' 0.00 0 0.0 0.00

Relative to released 0.11.6, every cell that moved is a removed leading zero — which is #342's contribution, kept.

The function that decides all of it

Whole thing, as of the latest push:

# '02.50' -> '2.50', '007' -> '7', '000' -> '0'. The lookahead leaves the last
# digit, so '0.50' keeps the zero that is its integer part.
_REDUNDANT_LEADING_ZEROS = re.compile(r"^([+-]?)0+(?=[0-9])")


def _normalized_range_element(elem: str, to_int: bool) -> Any:
    """The value an ``<intstring>``/``<floatstring>`` range element denotes.

    An ``<intstring>`` becomes an ``int``. A ``<floatstring>`` keeps its text less
    redundant leading zeros, so the decimal places it was written with survive
    (§7.5). Returns the element unchanged when it does not denote a number.
    """
    try:
        if to_int:
            return int(elem)  # int() already drops leading zeros
        # Parsed only to check it is a number; the text is what renders. Not a
        # Decimal -- re-rendering one is context-sensitive and unbounded (§7.5).
        float(elem)
    except ValueError:
        # A resolved format string can be non-numeric. Literals are checked at
        # template parse time, so carry it through rather than rejecting here.
        return elem
    return _REDUNDANT_LEADING_ZEROS.sub(r"\1", elem)

Three things worth a reviewer's attention:

float(elem) is a check, not a conversion. Its result is discarded. The text is what renders, so the parse only establishes that the element denotes a number. That is also why nothing here can be affected by getcontext(), and why '1e999999999' costs 11 characters instead of ~109.

The regex leaves the last digit, via the lookahead. 0+(?=[0-9]) cannot consume the final zero of '000', and declines to fire at all on '0.50' where the next character is .. That is the difference between "strip leading zeros" and "strip redundant leading zeros", and it is the case a naive lstrip('0') gets wrong. openjd-rs states the same rule imperatively in strip_redundant_leading_zeros, and both assert the same case list.

nan/inf fall through unchanged. float('nan') succeeds, then the regex is a no-op, so they forward their text exactly as mainline did before normalization existed. No separate is_finite guard is needed, and there is no new rejection path.

The comments above were about twice this long a moment ago. The normalize / quantize / str / format(v, 'f') comparison that justified not using Decimal moved into the commit message, which is where someone asking "why not Decimal?" will look, and where it will not go stale the next time this function is edited.

Review on OpenJobDescription#342 pointed out that the string form of a <FloatRangeList> element
exists in order to carry the scale it was written with: a range element written
'2.50' is asking for `2.50` on a command line, and a renderer handed `2.5`
instead has been given a different string. It is also the only way a template
can ask for a fixed number of decimal places, because a <float> literal of 2.50
is the same literal as 2.5 once parsed. OpenJobDescription#342 normalized the element to the number
it denotes and threw that away.

Split the two kinds of zero, which are not the same thing:

  - Leading zeros are not part of the number and still go. '02' on an INT range
    is the task value 2, and forwarding the text renders `--frame 02`.
  - Trailing zeros are the author's chosen scale and are kept. '02.50' renders
    `2.50`, not `2.5`.

The already-landed conformance fixture EXPR/jobs/expr1.3.4--float-passthrough
pins the same rule for a FLOAT parameter default, so both it and
base/jobs/3.4.1.2 can now be right at once. openjd-specifications#180 states the
rule as Template Schemas §7.5.

A <floatstring> therefore renders as its own text less any redundant leading
zeros, which is a substring operation rather than a numeric one. That is what
removes the machinery the two preceding commits added, because every problem
they were solving came from having to choose a notation in which to re-render a
Decimal:

  - normalize() and quantize() round to getcontext().prec, so an embedding
    application's decimal context changed what this library rendered: measured,
    '1.2345678901234567890123456789012345678901' came back as '1.2346' under
    getcontext().prec = 5.
  - str(Decimal) switches to exponent notation once the adjusted exponent falls
    below -6, so '0.0000001' rendered 1E-7.
  - format(value, 'f') is exact and plain at every magnitude but unbounded in the
    exponent, so '1e999999999' -- 11 characters of template text -- expanded to
    ~10**9 characters and needed a length bound to stay safe.

Text needs none of those decisions. '1e999999999' now costs its own 11
characters, so the exponent bound and its boundary cases are gone with it, and an
element can no longer silently exceed TaskParameterStringValueAsJob's
1024-character cap and fall through to a numeric member of the union.

An <intstring> is unaffected: int() already discards leading zeros and there are
no fractional digits to preserve.

Verification. Model suite 5527 -> 5529 passed, 24 skipped, 3 xfailed; ruff, black
and mypy clean at the CI-pinned versions. Three mutants confirm the tests pin
both halves of the rule: dropping the leading-zero strip fails 7 tests,
restoring the trailing-zero strip fails 7, and dropping the int() conversion
fails 3.

Full 2023-09 conformance suite, run from source against openjd-cli mainline
7c7ece4 with openjd-sessions 0.10.14, on openjd-specifications#180's tree: 1162
passed, 0 failed. The baseline at e7a17b3 passes 1161 and fails only
base/jobs/7.5--numeric-string-zeros-in-range-elements, so this closes that one
fixture and moves nothing else. Both controls hold --
base/jobs/3.4--float-parameter and EXPR/jobs/expr1.3.4--float-passthrough.

Relative to released 0.11.6 the only behaviour change here is the leading-zero
trim: every element whose rendering differs from 0.11.6 differs by a removed
leading zero.

openjd-rs, which backs this package's `openjd.model._v1` API, needs the matching
change; that is OpenJobDescription/openjd-rs#354. With both in place the two
implementations render every range-element case tested identically, including
the zero and exponent forms.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl force-pushed the fix/floatstring-preserve-trailing-zeros branch from 0d692a7 to af89643 Compare September 1, 2026 04:24
Comment thread src/openjd/model/v2023_09/_model.py Outdated
Comment thread test/openjd/model_v0/v2023_09/test_parameter_space.py
Two review findings, both cases where the forwarded text did not match what
openjd-rs renders for the same element.

float() ignores surrounding whitespace but the regex does not, so '  1.5  ' was
forwarded with its spaces and reached a command line as --frame '  1.5  '.
openjd-rs trims the resolved element before keeping it; do the same here.

Zero has no sign. '-0.0' rendered -0.0 here and 0.0 on mainline and in openjd-rs,
which is the reading the deleted test pinned by name. Drop the sign without
dropping the decimal places, so '-0.00' renders 0.00. Text that reaches zero only
by underflow, like '1e-400', does not render the value and is not kept -- which
also covers the huge negative exponents, since '1e-999999999' underflows to zero.

Both now agree with openjd-rs#354 on every case measured: '  1.5  '->1.5,
'-0.0'->0.0, '-0.00'->0.00, '1e-400'->0.0, '5.'->5., '.5'->.5, '+2.50'->+2.50,
'1E+2'->1E+2.

Also soften a test comment that claimed an element can no longer exceed
TaskParameterStringValueAsJob's cap and fall through to a numeric member of the
union. That holds for the huge-exponent inputs the test parametrizes, but long
literal text still can, on this branch and on mainline and 0.11.6 alike -- only
the expansion is gone.

Verification. Model suite 5533 passed, 24 skipped, 3 xfailed; ruff, black and
mypy clean. Full 2023-09 conformance 1162 passed, 0 failed.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Comment thread src/openjd/model/v2023_09/_model.py Outdated
Comment thread src/openjd/model/v2023_09/_model.py Outdated
Comment thread test/openjd/model_v0/v2023_09/test_parameter_space.py
Review found that testing `value == 0.0` discarded the digits of any value too
small for a float. '0.' followed by 400 zeros and a 1 -- plain decimal text,
exactly what a <floatstring> is for -- underflowed and rendered 0.0, silently a
different number. The cutoff was an artifact of binary64 rather than anything in
the template: 1e-320 is subnormal and survived, 1e-400 did not.

Ask the text instead. An all-zero mantissa spells zero whatever the exponent, so
'0.00', '-0.0' and '0e5' lose their sign and keep their digits, while '1e-400'
and '0.000...1' keep both. Mirrors openjd_expr::value::text_spells_zero, so
openjd-rs#354 renders every one of these identically.

Verification. Model suite 5536 passed, 24 skipped, 3 xfailed; ruff, black and
mypy clean. Full 2023-09 conformance 1162 passed, 0 failed.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
return int(elem) # int() already drops leading zeros
# Parsed only to check it is a number; the text is what renders. Not a
# Decimal -- re-rendering one is context-sensitive and unbounded (§7.5).
float(elem)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

float() accepts a wider grammar than _REDUNDANT_LEADING_ZEROS normalizes, so for several inputs the §7.5 rule this function exists to implement silently does not apply and the raw text is forwarded to a task command line.

float() (like Decimal(), per PEP 515 and the float() grammar in the language reference) accepts underscore separators and any Unicode Nd digit, not just [0-9]:

  • "1_000.50" parses, the regex matches nothing, and the element renders 1_000.50.
  • "٠٠٧" (Arabic-Indic) parses as 7, but 0+ and the (?=[0-9]) lookahead only see ASCII, so the leading zeros survive and the element renders ٠٠٧.
  • "1." and ".5" parse, and render 1. and .5.

The removed code did not have this gap: format(Decimal(elem), "f") re-rendered in canonical ASCII regardless of how the source was spelled, so "1_000.50" became 1000.5, "٠٠٧" became 7.0, and "1." became 1.0.

These reach here only via a resolved format string — a literal is coerced by the template model's FloatRangeList, whose Decimal member matches first — so the blast radius is limited to range: "{{Param.X}}"-style resolution. But that is exactly the path the validator was moved to the instantiation target to cover, and the values do reach --flag {{Task.Param.P}}.

If the intent is "keep the author's text", then the leading-zero rule should still apply uniformly to it; the simplest way to keep parse and normalize in the same grammar is to gate on an explicit <floatstring> regex (sign, ASCII digits, optional point/exponent) rather than on float() not raising, and reject/pass-through anything else. As written, whether zeros are stripped depends on which script the digits are written in.


# An all-zero mantissa, whatever the exponent: '0.00', '-0.0' and '0e5' spell zero,
# '1e-400' does not. Mirrors openjd_expr::value::text_spells_zero.
_SPELLS_ZERO = re.compile(r"^[+-]?[0.]*[0][0.]*(?:[eE][+-]?[0-9]+)?$")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_SPELLS_ZERO backtracks quadratically on unbounded input, which reintroduces — in a different form — the resource bound the PR removes.

The mantissa is written as three overlapping pieces, [0.]*[0][0.]*, so for an input the pattern fails on, the engine tries every split point: first star gives back one char, second star re-tries every remaining length, $ fails, repeat. That is O(n²) with no early exit.

A failing input is easy to construct and is exactly the shape the PR now accepts:

  • "0." + "0"*n + "1"float() parses it (underflows to 0.0 for large n), _REDUNDANT_LEADING_ZEROS does not shorten it (its (?=[0-9]) lookahead sees the ., so it does not match at all), and then _spells_zero runs the quadratic scan and ultimately returns "no".

The input is not length-bounded when this runs. _normalized_range_element is called from a mode="before" validator, so it sees the raw element before TaskRangeList's TaskParameterStringValueAsJob 1024-char constraint is applied — and on the template side the element type is TaskParameterStringValue, whose own comment says "No maximum length. The max string length is enforced as a TaskParameterStringValueAsJob type after the template has been instantiated into a Job." So a 100 KB element reaches the regex intact, at ~5·10⁹ steps, and a range may hold 1024 elements.

The old code could not do this: Decimal(elem) and format(value, "f") are linear in the text, and the exponent guard bounded the one super-linear step. The new test "0." + "0"*400 + "1" exercises this path but at n=400 the cost is invisible, so CI will not surface it.

The check does not need a backtracking pattern — it is "the mantissa is made only of 0 and ., and contains at least one 0", which a linear scan states directly:

def _spells_zero(text: str) -> bool:
    mantissa = re.sub(r"[eE][+-]?[0-9]+$", "", text).lstrip("+-")
    return "0" in mantissa and not mantissa.strip("0.")

Worth double-checking against openjd_expr::value::text_spells_zero — Rust's regex crate is linear-time by construction, so a pattern that is safe there is not necessarily safe once transliterated to Python re.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant