fix: Keep the trailing zeros of a floatstring range element - #345
fix: Keep the trailing zeros of a floatstring range element#345leongdl wants to merge 3 commits into
Conversation
Answering "does this revert half of #342?", with the function it turns onShort 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.
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 itWhole 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:
The regex leaves the last digit, via the lookahead.
The comments above were about twice this long a moment ago. The |
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>
0d692a7 to
af89643
Compare
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>
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) |
There was a problem hiding this comment.
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 renders1_000.50."٠٠٧"(Arabic-Indic) parses as 7, but0+and the(?=[0-9])lookahead only see ASCII, so the leading zeros survive and the element renders٠٠٧."1."and".5"parse, and render1.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]+)?$") |
There was a problem hiding this comment.
_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 to0.0for large n),_REDUNDANT_LEADING_ZEROSdoes not shorten it (its(?=[0-9])lookahead sees the., so it does not match at all), and then_spells_zeroruns 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.
What
The string form of a
<FloatRangeList>element keeps the trailing zeros it was written with. Leading zeros still go.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 for2.50on a command line; a renderer handed2.5has 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:
'02'on an INT range is the task value2; forwarding the text renders--frame 02'02.50'renders2.50This is also what lets two conformance fixtures both be right at once. The already-landed
EXPR/jobs/expr1.3.4--float-passthroughassertsPARAM:3.500from a FLOATdefault: "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 aDecimal:normalize()andquantize()round togetcontext().prec, so an embedding application's decimal context changed what this library rendered.str(Decimal)switches to exponent notation below1e-6, so'0.0000001'rendered1E-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 exceedTaskParameterStringValueAsJob'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,mypyclean at the CI-pinned versions.Conformance, full
2023-09/*suite (1162 fixtures) run from source —openjd-cliat mainline7c7ece4,openjd-sessionsat0.10.14, which is the newest combination the CLI supports — against openjd-specifications#180's tree:e7a17b3(mainline)base/jobs/7.5--numeric-string-zeros-in-range-elementsThe 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 rendering1.0) andEXPR/jobs/expr1.3.4--float-passthrough(a FLOATdefault: "3.500"rendering3.500) both pass.Mutation-checked. Three mutants confirm the tests pin both halves of the rule rather than just asserting the current output:
int()conversion removedCross-implementation agreement
openjd-rs, which also backs this package's
openjd.model._v1API, needed the matching change: it stored a resolved FLOAT range asVec<f64>, so'02.50'rendered2.5. That is OpenJobDescription/openjd-rs#354. With both branches in place the two implementations render every range-element case tested identically:'1.5''02.50''3.500''007'1.52.503.5007'0.50''000''0.00'0.5000.00'1E+2''1e-3''+2.50'1E+21e-3+2.50Before 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 in0708da8; three do not hold.float()forwards' 1.5 'with its spaces1.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.0renders-0.0, diverging from openjd-rs-0.0→0.0and-0.00→0.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.50too, measured.float1.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'→7reintroduces a divergence9a9998drendered7.0, openjd-rs#354 renders7. The suggested fix (append.0when no point) would create the divergence.1E+2, and so does openjd-rs on both sides of its fix.100.0existed 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'renders007there and7here, anddefault: '01.250'renders01.250there and1.250here. 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'renders0.001here,1e-3there). §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:
f5da1b0e7a17b3'02'02222'003'003333'1.5'1.51.51.51.5'02.50'02.502.52.52.50'3.500'3.5003.53.53.500'007'00777.07'0.50'0.500.50.50.50'0.00'0.0000.00.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
'02'→2is fix: Normalize intstring/floatstring task parameter range elements #342's behaviour, untouched.'02.50'→2.50still loses the0;'007'→7.git diff f5da1b0..HEAD -- src/openjd/model/_step_param_space_iter.pyis empty. Therange_set={str(v) for v in parameter.range}repair and the test that pins it are exactly as fix: Normalize intstring/floatstring task parameter range elements #342 left them._normalize_numeric_range_elementsis still amode="before"validator onRangeListTaskParameterDefinition, in the same place, keying off the validatedtype, so it still covers all three inbound range paths.What is reverted in full is
d6d5540ande7a17b3, not #342. Those existed only to make re-rendering aDecimalsafe: context-sensitive rounding, exponent notation below1e-6, and the unboundedformat(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'renders7rather than mainline's7.0— the "always keep one fractional digit" rule was a follow-up invention, not #342's.Proportions, from the diffs
Source only, excluding tests:
v2023_09/_model.py).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.