From af8964378cab088fa0e3baeabd1d667040e7e3e3 Mon Sep 17 00:00:00 2001 From: David Leong <116610336+leongdl@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:22:43 -0700 Subject: [PATCH 1/3] fix: Keep the trailing zeros of a floatstring range element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #342 pointed out that the string form of a 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 literal of 2.50 is the same literal as 2.5 once parsed. #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 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 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> --- src/openjd/model/v2023_09/_model.py | 105 ++++------------ .../model_v0/test_step_param_space_iter.py | 4 +- .../model_v0/v2023_09/test_parameter_space.py | 112 +++++++----------- 3 files changed, 68 insertions(+), 153 deletions(-) diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index 56caa30..4951267 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -1245,74 +1245,29 @@ def _validate_target_runtime_seconds(cls, value: Any, info: ValidationInfo) -> A return validate_int_fmtstring_field(value, ge=0, context=context) +# '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 number an ````/```` range element denotes -- an - ``int`` for ````, and for ```` the plain-notation text - of the value, since ``str(Decimal)`` cannot render every magnitude without an - exponent. Returns the element unchanged when it does not denote a number, or - when its plain-notation form would not fit this field's character cap.""" + """The value an ````/```` range element denotes. + + An ```` becomes an ``int``. A ```` 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) - value = Decimal(elem) - if not value.is_finite(): - # Decimal accepts 'nan'/'inf', which are not s; leave - # them as text rather than changing how they render. - return elem - # Bound the expansion from the exponent before performing it. Decimal - # construction from a string is unbounded -- the context's Emax/Emin - # constrain arithmetic results, not construction, so Decimal('1e999999999') - # is finite and the guard above does not stop it -- and the plain-notation - # length is linear in the exponent, so 11 characters of template text - # would otherwise materialize ~10**9 characters. - # - # The bound is the character cap this field already enforces, because an - # expansion past it cannot be carried here as text at all: the value is - # stored in a TaskRangeList element, whose str member is - # TaskParameterStringValueAsJob, and pydantic's union falls through to - # the numeric members instead, rendering the element as an integer, or as - # inf, or as 0.0. Such an element keeps its source text -- what this - # function already does for anything it cannot normalize, and how it - # rendered before normalization was introduced. - exponent = cast(int, value.as_tuple().exponent) # finite: never 'n'/'N'/'F' - # Length of the '[-].' text returned below. Stripping - # redundant zeros can only shorten it, so this is an upper bound. - expansion_len = ( - (1 if value.is_signed() else 0) - + max(value.adjusted() + 1, 1) # integer digits, at least one - + 1 # the decimal point - + max(-exponent, 1) # fraction digits, at least one - ) - if expansion_len > _MAX_TASK_PARAM_VALUE_LEN: - return elem - # format(value, 'f') is exact and plain at every magnitude: with no - # precision in the format spec it neither rounds to getcontext().prec - # nor switches to exponent notation, so an embedding app that sets a - # different context cannot change what this library renders. Neither - # holds of the obvious alternatives -- normalize() rounds to the - # ambient precision, str() switches to exponent notation below 1e-6, - # and quantize() raises InvalidOperation once the result needs more - # digits than the ambient precision allows. - text = format(value, "f") - negative = text.startswith("-") - integer, _, fraction = text.lstrip("-").partition(".") - # A denotes a number, so redundant zeros in its source - # text are not part of the value: '02.50' is 2.5. One fractional digit - # is always kept, matching openjd-rs, which renders the float 1.0 as - # `1.0` and never as `1`. - integer = integer.lstrip("0") or "0" - fraction = fraction.rstrip("0") or "0" - if negative and integer == "0" and fraction == "0": - # The number denoted by '-0.0' is zero, which has no sign; openjd-rs - # renders it `0.0`. - negative = False - return f"{'-' if negative else ''}{integer}.{fraction}" - except (ValueError, ArithmeticError): - # Not a number at all — e.g. a format string that resolved to - # non-numeric text. A literal range is already checked against its - # element type at template parse time, so leave such a value to the - # existing behaviour rather than adding a rejection path here. + 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) # Target model for task parameters when instantiating a job. @@ -1327,22 +1282,14 @@ class RangeListTaskParameterDefinition(OpenJDModel_v2023_09): @field_validator("range", mode="before") @classmethod def _normalize_numeric_range_elements(cls, value: Any, info: ValidationInfo) -> Any: - # §3.4.1.1/§3.4.1.2: an element is ` | - # ` and a element is ` | - # `, where an / is "a string whose - # value is the string representation of" a number (§2.3, §2.4). Such an - # element therefore denotes that number, not its source text, so '02' - # is the task value 2 and '02.50' is 2.5. Python was keeping the source - # text, which reaches a task command line as `--frame 02`. - # - # Applied on the instantiation target so every inbound range path is - # covered: a literal list, a range-expression expansion, and an RFC 0006 - # typed whole-field resolution all funnel through this model. + # §7.5: a string-form range element loses redundant leading zeros and + # keeps its decimal places, so '02' is the value 2 and '02.50' renders + # `2.50`. Numeric literals carry no such request and are left as parsed; + # STRING and PATH ranges have no numeric form at all. # - # Numeric elements are left exactly as parsed. A FLOAT range of [1.0] - # renders 1.0, which openjd-rs also does and the conformance suite pins - # (base/jobs/3.4--float-parameter). STRING and PATH ranges are text by - # definition and are never touched. + # On the instantiation target so all three inbound paths are covered: a + # literal list, a range-expression expansion, and RFC 0006 whole-field + # resolution. param_type = info.data.get("type") if not isinstance(value, list) or param_type not in ( TaskParameterType.INT, diff --git a/test/openjd/model_v0/test_step_param_space_iter.py b/test/openjd/model_v0/test_step_param_space_iter.py index d11e277..5cfc2ce 100644 --- a/test/openjd/model_v0/test_step_param_space_iter.py +++ b/test/openjd/model_v0/test_step_param_space_iter.py @@ -613,8 +613,8 @@ def test_int_range_intstring_elements(self) -> None: def test_float_range_floatstring_elements(self) -> None: # WHEN a FLOAT range is written with elements - # THEN each task gets the number the element denotes - assert self._task_values("FLOAT", ["1.5", "02.50"]) == ["1.5", "2.5"] + # THEN each task gets the element's text, less redundant leading zeros + assert self._task_values("FLOAT", ["1.5", "02.50", "3.500"]) == ["1.5", "2.50", "3.500"] def test_float_range_numeric_literals_keep_their_scale(self) -> None: # A literal is not a string representation, so it keeps the scale diff --git a/test/openjd/model_v0/v2023_09/test_parameter_space.py b/test/openjd/model_v0/v2023_09/test_parameter_space.py index 25ae76a..742f8b2 100644 --- a/test/openjd/model_v0/v2023_09/test_parameter_space.py +++ b/test/openjd/model_v0/v2023_09/test_parameter_space.py @@ -441,13 +441,12 @@ def test_list_form_range_is_still_capped(self, model: Any, obj: dict[str, Any]) class TestRangeListElementNormalization: - """§3.4.1.1/§3.4.1.2: an `` element is ` | ` - and a `` element is ` | `. An - ``/`` is "a string whose value is the string - representation of" a number (§2.3, §2.4), so it denotes that number and not - its source text — a range of `['1', '02', '003']` is the task values 1, 2 and - 3. These values reach a task command line, so keeping the text renders - `--frame 02`. + """§7.5 on a range element: leading zeros go, decimal places stay. + + `['1', '02', '003']` is the task values 1, 2 and 3; keeping the text renders + `--frame 02`. But `'2.50'` renders `2.50`, because the string form is how a + template asks for a fixed number of decimal places — + `EXPR/jobs/expr1.3.4--float-passthrough` pins that for a FLOAT default. """ @pytest.mark.parametrize( @@ -471,96 +470,65 @@ def test_intstring_elements_carry_their_value(self, param_type: str) -> None: @pytest.mark.parametrize( "element,expected", ( - pytest.param("1.5", "1.5", id="no normalization needed"), - pytest.param("02.50", "2.5", id="leading and trailing zeros"), - pytest.param("3.500", "3.5", id="trailing zeros"), - pytest.param("-02.50", "-2.5", id="negative"), - # An integral float keeps one fractional digit. openjd-rs renders - # the '1.0' as `1.0` and '007' as `7.0`, so dropping - # the fraction would make the same number render two ways depending - # on whether it was written as a or a . - pytest.param("1.0", "1.0", id="integral float"), + pytest.param("1.5", "1.5", id="nothing to strip"), + # The rule, in both directions at once. + pytest.param("02.50", "2.50", id="leading zero goes, trailing zero stays"), + pytest.param("3.500", "3.500", id="trailing zeros stay"), + pytest.param("-02.50", "-2.50", id="negative"), + pytest.param("+02.50", "+2.50", id="explicit plus sign is kept"), + pytest.param("007", "7", id="leading zeros on a whole number"), + pytest.param("100", "100", id="a zero that is a significant digit"), + # Neither the zero before the point nor the last of an all-zero + # integer part is redundant. + pytest.param("0.50", "0.50", id="the necessary leading zero stays"), + pytest.param("000", "0", id="all zeros keeps one"), + pytest.param("0", "0", id="bare zero"), pytest.param("0.0", "0.0", id="zero"), - pytest.param("-0.0", "0.0", id="negative zero has no sign"), - pytest.param("007", "7.0", id="leading zeros on a whole number"), - pytest.param("100", "100.0", id="whole number"), - # Exponent notation must never reach a task command line, at any - # magnitude and in either direction. - pytest.param("1E+2", "100.0", id="exponent notation in the source"), - pytest.param( - "0.0000001", - "0.0000001", - id="below 1e-6, where Decimal.__str__ would give 1E-7", - ), - pytest.param( - "1E+30", - "1" + "0" * 30 + ".0", - id="more digits than the default decimal precision", - ), + pytest.param("0.00", "0.00", id="zero keeps its trailing zeros too"), + pytest.param("1.0", "1.0", id="integral float keeps its point"), + # Exponent notation is the author's own text and is forwarded. The + # FLOAT parameter default path does the same for `default: "1E+2"`. + pytest.param("1E+2", "1E+2", id="exponent notation"), + pytest.param("01E+2", "1E+2", id="leading zero on an exponent form"), + pytest.param("0.0000001", "0.0000001", id="below 1e-6"), pytest.param( "1.2345678901234567890123456789012345678901", "1.2345678901234567890123456789012345678901", - id="more significant digits than the default decimal precision", + id="more significant digits than a float can hold", ), ), ) - def test_floatstring_elements_carry_their_value(self, element: str, expected: str) -> None: + def test_floatstring_elements_keep_their_scale(self, element: str, expected: str) -> None: # WHEN the instantiation target parses a float range holding a model = _parse_model( model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN the element renders as the number it denotes + # THEN it renders as written, less any redundant leading zeros assert [str(v) for v in model.range] == [expected] @pytest.mark.parametrize( "element", ( - # Decimal's context bounds arithmetic results, not construction from - # a string, so these are finite: 11 characters of template text - # whose plain-notation expansion is ~10**9 characters. pytest.param("1e999999999", id="huge positive exponent"), pytest.param("1e-999999999", id="huge negative exponent"), - # One character past the cap in each direction: both expand to 1025. - pytest.param("1E+1022", id="one past the cap, positive exponent"), - pytest.param("1E-1023", id="one past the cap, negative exponent"), + pytest.param("1E+1022", id="positive exponent past the field cap"), + pytest.param("1E-1023", id="negative exponent past the field cap"), ), ) - def test_element_too_long_to_expand_keeps_its_source_text(self, element: str) -> None: - # WHEN the instantiation target parses a whose plain-notation - # form would not fit TaskParameterStringValueAsJob's 1024-character cap + def test_a_huge_exponent_costs_only_its_own_characters(self, element: str) -> None: + # WHEN 11 characters of template text denote a number needing ~10**9 + # digits in plain notation model = _parse_model( model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN it keeps its source text rather than being expanded. Expanding it - # would allocate the whole expansion, and the result could not be carried - # as text here anyway: it fails the str member of TaskRangeList's union, - # and pydantic then falls through to the numeric members, rendering the - # element as an integer, or as inf, or as 0.0. + # THEN it renders as its own text. Nothing expands it, so no exponent + # bound is needed and the value cannot exceed the field's 1024-character + # cap and fall through to a numeric member of the union. assert [str(v) for v in model.range] == [element] - @pytest.mark.parametrize( - "element,expected", - ( - pytest.param("1E+1021", "1" + "0" * 1021 + ".0", id="positive exponent"), - pytest.param("1E-1022", "0." + "0" * 1021 + "1", id="negative exponent"), - ), - ) - def test_element_at_the_cap_is_still_normalized(self, element: str, expected: str) -> None: - # GIVEN a whose plain-notation form is exactly 1024 - # characters — at the cap, not past it - assert len(expected) == 1024 - - # WHEN the instantiation target parses it - model = _parse_model( - model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} - ) - - # THEN the length bound has not cost it its normalization - assert [str(v) for v in model.range] == [expected] - - def test_floatstring_normalization_ignores_the_decimal_context(self) -> None: + def test_floatstring_rendering_ignores_the_decimal_context(self) -> None: # GIVEN an embedding application that has narrowed the process-wide # decimal context, and a with more significant digits # than that precision allows @@ -573,8 +541,8 @@ def test_floatstring_normalization_ignores_the_decimal_context(self) -> None: model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN the value is unrounded: what this library renders is a property of - # the template, not of the host application's decimal context + # THEN it is unrounded: the rendering is a property of the template, not + # of the host application's decimal context assert [str(v) for v in model.range] == [element] @pytest.mark.parametrize( From 0708da85bab49524f2f0f8fc9a89582f9118cd77 Mon Sep 17 00:00:00 2001 From: David Leong <116610336+leongdl@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:12:27 -0700 Subject: [PATCH 2/3] fix: trim a floatstring range element and unsign a zero 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> --- src/openjd/model/v2023_09/_model.py | 13 ++++++- .../model_v0/v2023_09/test_parameter_space.py | 38 ++++++++++++++----- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index 4951267..5d739a5 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -1262,12 +1262,21 @@ def _normalized_range_element(elem: str, to_int: bool) -> Any: 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) + value = 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) + # Trimmed because the text reaches a command line and float() ignores + # surrounding whitespace; openjd-rs trims here too. + text = _REDUNDANT_LEADING_ZEROS.sub(r"\1", elem.strip()) + if value == 0.0: + # Zero has no sign, so drop one without dropping the decimal places: + # '-0.00' renders `0.00`. Text that reaches zero only by underflow, like + # '1e-400', does not render the value at all and is not kept. + unsigned = text.lstrip("+-") + return unsigned if set(unsigned) <= {"0", "."} else "0.0" + return text # Target model for task parameters when instantiating a job. diff --git a/test/openjd/model_v0/v2023_09/test_parameter_space.py b/test/openjd/model_v0/v2023_09/test_parameter_space.py index 742f8b2..b71f1c2 100644 --- a/test/openjd/model_v0/v2023_09/test_parameter_space.py +++ b/test/openjd/model_v0/v2023_09/test_parameter_space.py @@ -485,6 +485,16 @@ def test_intstring_elements_carry_their_value(self, param_type: str) -> None: pytest.param("0", "0", id="bare zero"), pytest.param("0.0", "0.0", id="zero"), pytest.param("0.00", "0.00", id="zero keeps its trailing zeros too"), + # Zero has no sign, which openjd-rs and mainline both render away. + # Dropping it must not drop the decimal places with it. + pytest.param("-0.0", "0.0", id="negative zero has no sign"), + pytest.param("-0.00", "0.00", id="unsigned, but still two places"), + # Reaches zero only by underflow, so the text does not render the + # value and is not kept. + pytest.param("1e-400", "0.0", id="underflow to zero"), + # float() ignores surrounding whitespace and this text reaches a + # command line, so it is trimmed rather than forwarded. + pytest.param(" 1.5 ", "1.5", id="surrounding whitespace"), pytest.param("1.0", "1.0", id="integral float keeps its point"), # Exponent notation is the author's own text and is forwarded. The # FLOAT parameter default path does the same for `default: "1E+2"`. @@ -508,25 +518,33 @@ def test_floatstring_elements_keep_their_scale(self, element: str, expected: str assert [str(v) for v in model.range] == [expected] @pytest.mark.parametrize( - "element", + "element,expected", ( - pytest.param("1e999999999", id="huge positive exponent"), - pytest.param("1e-999999999", id="huge negative exponent"), - pytest.param("1E+1022", id="positive exponent past the field cap"), - pytest.param("1E-1023", id="negative exponent past the field cap"), + # An exponent too large for a float still costs only its own + # characters, which is the whole point: nothing expands it. + pytest.param("1e999999999", "1e999999999", id="huge positive exponent"), + pytest.param("1E+1022", "1E+1022", id="positive exponent past the field cap"), + # Too small, and the value underflows to zero, so the text no longer + # renders it. openjd-rs resolves these to zero and renders 0.0 too. + pytest.param("1e-999999999", "0.0", id="huge negative exponent"), + pytest.param("1E-1023", "0.0", id="negative exponent past the field cap"), ), ) - def test_a_huge_exponent_costs_only_its_own_characters(self, element: str) -> None: + def test_a_huge_exponent_costs_only_its_own_characters( + self, element: str, expected: str + ) -> None: # WHEN 11 characters of template text denote a number needing ~10**9 # digits in plain notation model = _parse_model( model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN it renders as its own text. Nothing expands it, so no exponent - # bound is needed and the value cannot exceed the field's 1024-character - # cap and fall through to a numeric member of the union. - assert [str(v) for v in model.range] == [element] + # THEN nothing expands it, so no bound on the exponent is needed to stop a + # template allocating ~10**9 characters. Long *literal* text can still + # exceed TaskParameterStringValueAsJob's cap and fall through to a numeric + # member of the union, as it does on mainline and in 0.11.6; only the + # expansion is gone. + assert [str(v) for v in model.range] == [expected] def test_floatstring_rendering_ignores_the_decimal_context(self) -> None: # GIVEN an embedding application that has narrowed the process-wide From 41bf56cb984f3e00256c67b5b6f8795105fcb459 Mon Sep 17 00:00:00 2001 From: David Leong <116610336+leongdl@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:07:53 -0700 Subject: [PATCH 3/3] fix: decide a zero spelling from the text, not the parse 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 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> --- src/openjd/model/v2023_09/_model.py | 22 ++++++++++++------- .../model_v0/v2023_09/test_parameter_space.py | 17 ++++++++------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index 5d739a5..423ea14 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -1249,6 +1249,14 @@ def _validate_target_runtime_seconds(cls, value: Any, info: ValidationInfo) -> A # digit, so '0.50' keeps the zero that is its integer part. _REDUNDANT_LEADING_ZEROS = re.compile(r"^([+-]?)0+(?=[0-9])") +# 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]+)?$") + + +def _spells_zero(text: str) -> bool: + return _SPELLS_ZERO.match(text) is not None + def _normalized_range_element(elem: str, to_int: bool) -> Any: """The value an ````/```` range element denotes. @@ -1262,7 +1270,7 @@ def _normalized_range_element(elem: str, to_int: bool) -> Any: 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). - value = float(elem) + 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. @@ -1270,13 +1278,11 @@ def _normalized_range_element(elem: str, to_int: bool) -> Any: # Trimmed because the text reaches a command line and float() ignores # surrounding whitespace; openjd-rs trims here too. text = _REDUNDANT_LEADING_ZEROS.sub(r"\1", elem.strip()) - if value == 0.0: - # Zero has no sign, so drop one without dropping the decimal places: - # '-0.00' renders `0.00`. Text that reaches zero only by underflow, like - # '1e-400', does not render the value at all and is not kept. - unsigned = text.lstrip("+-") - return unsigned if set(unsigned) <= {"0", "."} else "0.0" - return text + # Zero has no sign, so drop one without dropping the decimal places: '-0.00' + # renders `0.00`. Decided from the text, not from `value` -- the parse + # underflows to 0.0 below ~5e-324, and a tiny value's digits are exactly what + # a is for. + return text.lstrip("+-") if _spells_zero(text) else text # Target model for task parameters when instantiating a job. diff --git a/test/openjd/model_v0/v2023_09/test_parameter_space.py b/test/openjd/model_v0/v2023_09/test_parameter_space.py index b71f1c2..442ba36 100644 --- a/test/openjd/model_v0/v2023_09/test_parameter_space.py +++ b/test/openjd/model_v0/v2023_09/test_parameter_space.py @@ -489,9 +489,12 @@ def test_intstring_elements_carry_their_value(self, param_type: str) -> None: # Dropping it must not drop the decimal places with it. pytest.param("-0.0", "0.0", id="negative zero has no sign"), pytest.param("-0.00", "0.00", id="unsigned, but still two places"), - # Reaches zero only by underflow, so the text does not render the - # value and is not kept. - pytest.param("1e-400", "0.0", id="underflow to zero"), + # An all-zero mantissa spells zero whatever the exponent. + pytest.param("0E+2", "0E+2", id="exponent form of zero"), + pytest.param("-0e5", "0e5", id="signed exponent form of zero"), + # Underflow is not zero being spelled: the digits are the request. + pytest.param("1e-400", "1e-400", id="underflow keeps its digits"), + pytest.param("0." + "0" * 400 + "1", "0." + "0" * 400 + "1", id="tiny plain decimal"), # float() ignores surrounding whitespace and this text reaches a # command line, so it is trimmed rather than forwarded. pytest.param(" 1.5 ", "1.5", id="surrounding whitespace"), @@ -524,10 +527,10 @@ def test_floatstring_elements_keep_their_scale(self, element: str, expected: str # characters, which is the whole point: nothing expands it. pytest.param("1e999999999", "1e999999999", id="huge positive exponent"), pytest.param("1E+1022", "1E+1022", id="positive exponent past the field cap"), - # Too small, and the value underflows to zero, so the text no longer - # renders it. openjd-rs resolves these to zero and renders 0.0 too. - pytest.param("1e-999999999", "0.0", id="huge negative exponent"), - pytest.param("1E-1023", "0.0", id="negative exponent past the field cap"), + # Too small for a float either way, and the digits still stand: + # underflowing to 0.0 is a parse limit, not the author writing zero. + pytest.param("1e-999999999", "1e-999999999", id="huge negative exponent"), + pytest.param("1E-1023", "1E-1023", id="negative exponent past the field cap"), ), ) def test_a_huge_exponent_costs_only_its_own_characters(