fix: correct Long hyperparameter sampling, Float seed forwarding, form validation, and Jaccard similarity - #2625
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
/azp run |
|
Hey Rana Singh (@ranadeepsingh) 👋! We use semantic commit messages to streamline the release process. Examples of commit messages with semantic prefixes:
To test your commit locally, please follow our guild on building from source. |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
This PR fixes several correctness issues in SynapseML core utilities and adds targeted regression tests to ensure the corrected behavior is exercised (notably around AutoML hyperparameter sampling, Unicode normalization parameter validation, and string similarity logic in ModelEquality).
Changes:
- Fixes
LongRangeHyperParamsampling to avoid overflow-driven out-of-range values; ensuresFloatRangeHyperParamforwards its seed correctly. - Corrects
UnicodeNormalize.setFormto validate the incoming form value at set-time (failing fast on invalid forms). - Re-implements
ModelEquality.jaccardSimilarityas a Jaccard index over character bigrams and adds tests for expected similarity behavior.
Show a summary per file
| File | Description |
|---|---|
| core/src/main/scala/com/microsoft/azure/synapse/ml/automl/HyperparamBuilder.scala | Fixes LongRangeHyperParam overflow behavior and forwards seed in FloatRangeHyperParam. |
| core/src/test/scala/com/microsoft/azure/synapse/ml/automl/HyperparamRangeSuite.scala | Adds regression tests for range bounds and seed reproducibility across numeric hyperparams. |
| core/src/main/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalize.scala | Fixes setForm to validate the passed-in value, preventing invalid forms from being stored. |
| core/src/test/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalizeSuite.scala | Adds tests for setForm validation behavior and supplies valid fuzzer examples for form. |
| core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala | Replaces the incorrect “singleton-set” similarity with bigram Jaccard similarity and adds an empty-input convention. |
| cognitive/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/utils/ModelEqualitySuite.scala | Adds tests covering similarity semantics (identity/orthogonality/partial overlap/case-insensitivity/symmetry). |
Review details
Tip
Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Lite
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2625 +/- ##
==========================================
+ Coverage 87.05% 87.11% +0.06%
==========================================
Files 338 338
Lines 18843 18858 +15
Branches 1805 1764 -41
==========================================
+ Hits 16403 16429 +26
+ Misses 2440 2429 -11 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Removed the modulo bias from
Written tail-recursively because scalastyle bans Local: scalastyle clean, 19/19 pass. /azp run |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala:23
- For strings shorter than 2 characters,
sliding(2).toSetis empty, so this returns 1.0 for any pair of 0/1-length strings (e.g., "a" vs "b"), which is incorrect for a similarity measure.
val a = s1.toLowerCase.sliding(2).toSet
val b = s2.toLowerCase.sliding(2).toSet
if (a.isEmpty && b.isEmpty) 1.0 else a.intersect(b).size.toDouble / (a | b).size.toDouble
core/src/main/scala/com/microsoft/azure/synapse/ml/automl/HyperparamBuilder.scala:37
range = max - mincan overflow for very wide intervals (e.g., min = Long.MinValue, max = Long.MaxValue). In that caserangebecomes non-positive andgetNext()returnsminfor every draw, which breaks the distribution even thoughmax > min.
def getNext(): Long = {
val range = max - min
// nextLong() spans the full 64-bit range, so multiplying it by the range overflows and
// escapes [min, max) entirely. Reduce into the range first, as nextInt(range) does.
if (range <= 0) min else boundedNextLong(range) + min
}
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Addressed all three review threads (details in each). Summary: Local: scalastyle clean across core and cognitive, 12/12 automl and 6/6 ModelEqualitySuite. /azp run |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala:17
- The scaladoc says the Jaccard index is computed over character bigrams, but the implementation intentionally keeps partial windows so 0/1-length strings become empty-set/unigram sets. Updating the doc helps prevent confusion for future callers.
/** Similarity of two strings as the Jaccard index over their character bigrams. */
core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala:22
jaccardSimilarityusesString.toLowerCasewithout an explicit locale, making results dependent on the JVM default locale (e.g., Turkish locale casing can change character mappings). UseLocale.ROOTto keep similarity deterministic across environments.
val a = s1.toLowerCase.sliding(2).toSet
val b = s2.toLowerCase.sliding(2).toSet
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
da865cd to
1c98a94
Compare
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala:22
toLowerCasewithout an explicit locale is locale-dependent (e.g., Turkish locale casing) and can make similarity results non-deterministic across environments/executors. The codebase already usesLocale.ROOTfor case-folding in similar string-normalization logic (e.g., SpeechToTextSDK.scala:55).
val a = s1.toLowerCase.sliding(2).toSet
val b = s2.toLowerCase.sliding(2).toSet
core/src/test/scala/com/microsoft/azure/synapse/ml/automl/HyperparamRangeSuite.scala:40
- Local
val drawsshadows the suite-levelprivate val draws, which makes the test harder to read and can be confusing when adjusting the draw counts. Rename the local collection to avoid shadowing.
val hp = new LongRangeHyperParam(Long.MinValue + 1, Long.MaxValue, seed = 7)
val draws = (1 to 200).map(_ => hp.getNext())
assert(draws.forall(v => v >= Long.MinValue + 1 && v < Long.MaxValue))
assert(draws.distinct.length > 1)
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
…idation Three defects surfaced while reviewing test coverage for these utilities. Each fix ships with a test that fails against the current code and passes after. LongRangeHyperParam.getNext() computed `(random.nextLong() * range) + min`. nextLong() spans the full 64-bit range, so multiplying it by the range overflows and escapes [min, max) entirely: with min=0, max=100 it produced values such as -4494195807357089668. The Int and Double variants already reduce into the range first (nextInt(range), nextDouble() * range). Reduce the Long draw with floorMod, and return min for an empty range rather than dividing by zero. FloatRangeHyperParam built its inner DoubleRangeHyperParam without passing `seed`, so every instance drew from seed 0 and two Float params configured with different seeds produced identical sequences. Reproducibility and seed independence were both silently broken. UnicodeNormalize.setForm validated `getForm` -- the value already set -- rather than the incoming `value`. Since getForm falls back to "NFKD", validation always passed and an invalid form was accepted, only failing later inside the UDF on the executors where the cause is hard to attribute. ModelEquality.jaccardSimilarity built Set(s1)/Set(s2), singleton sets of the whole strings, so it could only ever return 1.0 or 0.0 -- a strict equality check, not a similarity measure. Use character bigrams, matching the working implementation already used in the speech test suites, and define the both-empty case as 1.0 instead of NaN. UnicodeNormalizeSuite now supplies a valid `form` example through the GetterSetterFuzzing hook, since the generic fuzzer probes String setters with "foo" and setForm legitimately rejects it now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Math.floorMod(nextLong(), range) is non-uniform: the residues below 2^64 mod range are drawn one extra time. Replace it with the standard rejection-sampling loop so Long ranges are exactly as uniform as Random.nextInt(bound) already is for Int. Adds coverage tests for both branches (power-of-two mask and non-power-of-two rejection) which also prove the recursion terminates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
require(max >= min) instead of silently returning min for an inverted range, and draw with rejection when the span is too wide to represent as a Long rather than treating the wrapped negative as an empty range. Adds explicit assertions for 0- and 1-character jaccard inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1c98a94 to
6f62f71
Compare
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala:22
toLowerCasewithout an explicit locale is locale-dependent (e.g., Turkish locale casing rules) and can makejaccardSimilarityproduce different results depending on the JVM’s default locale. UseLocale.ROOTfor deterministic, locale-invariant case folding.
val a = s1.toLowerCase.sliding(2).toSet
val b = s2.toLowerCase.sliding(2).toSet
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
Brendan Walsh (BrendanWalsh)
left a comment
There was a problem hiding this comment.
Approved — automated multi-model review cycle.
Reviewed by Claude Opus 5 and GPT-5.6 Sol (both at maximum reasoning effort) against a blocking-issues-only rubric, followed by an independent adjudication pass. Final result: 0 blocking findings on commit 6f62f71b. All 30 checks are green.
Two findings were raised and both were overturned on verification:
1. "Float sampling can emit the exclusive maximum" (HyperparamBuilder.scala:74) — not blocking.
The doubleRange.getNext().toFloat narrowing is byte-identical on origin/master; the same 1.0f result reproduces on the base branch with seed 0 over the range [nextDown(1.0f), 1.0f). Seed forwarding therefore does not introduce the behaviour — it is pre-existing, and no hyperparameter range in the repo comes within a float ULP of its maximum. (Python cannot construct a FloatRangeHyperParam at all, since py4j marshals to double.)
2. "Form validation is bypassed by the Python API" (UnicodeNormalize.scala:37) — not blocking.
The codegen claim is correct: Wrappable.scala:189 does emit self._set(form=value), so the generated Python setter bypasses the JVM setter. However, the pre-PR Scala setter validated getForm — reading the field before assigning it — which made it a no-op that rejected nothing on any path. Python was never protected, so this PR is a strict improvement rather than a regression, and the residual failure mode is a loud IllegalArgumentException naming the offending value, not a silent wrong result.
Additional verification performed on the core sampling change:
boundedNextLong's power-of-two branch was checked against the JDK's ownnextLong(origin, bound)and is a line-for-line match (if ((n & m) == 0L) r = (r & m) + origin;). Measured uniform: at bound 8, 464–536 per bucket against an expected 500, with ≤1.73% deviation over 100k draws at bounds 2/4/16. The rejection loopu + m - r < 0correctly mirrorsjava.util.Random.nextInt.- The exact
java.util.RandomLCG plus this PR's algorithm were simulated end to end; all 11 new deterministic assertions pass, including the[Long.MinValue+1, Long.MaxValue)overflow branch and seed reproducibility across all four numeric types. ModelEquality.jaccardSimilarityhas no in-repo callers (the two speech suites hold private copies), andTuneHyperparameters.fitdraws sequentially on the driver, so there is no shared-Randomthread exposure.
Non-blocking observations, recorded but not gating:
range == 0returnsminwhereasIntRangeHyperParamthrows — pre-existing, and now pinned by a new test.jaccardSimilarityis public with unspecified null/empty-set semantics; worth documenting if it gains callers.
ffe123a
into
microsoft:master
…ublish path, vacuous asserts
Addresses the four blocking items in BrendanWalsh's round-2 review.
1. Register 7 orphaned suites in the UnitTests matrix (pipeline.yaml).
PipelineTestCoverageSuite fails on any concrete suite no matrix leg claims.
Reproduced locally: it listed exactly those 7, so the `core` leg would have
gone red and none of the 7 would ever have run. Added them to `misc`.
2. Reset GlobalParams between tests (VerifyGlobalParams).
resetGlobalState() ran only inside test 1, which then set TestStringKey and
never cleared it, so "getGlobalParam returns None for unset key" failed
deterministically. Moved the reset into beforeEach and added afterEach so the
suite cannot perturb others sharing the forked JVM.
3. Point the ADO coverage publisher at the directory scoverage actually writes.
Verified empirically by generating a report: cobertura.xml lands in
target/scala-2.12/coverage-report/, while scoverage-report/ holds only
scoverage.xml. The old '**/scoverage-report/cobertura.xml' glob could never
match, and failIfCoverageEmpty: false made it fail silently in all 4 jobs.
Fixed the glob and set failIfCoverageEmpty: true so a future path regression
is visible rather than silent.
4. Replace vacuous assertions with contract assertions.
assert(x.isInstanceOf[T]) on a statically-T expression can never fail, so
those lines reported coverage while providing no regression protection.
- VerifyHyperparamBuilder: assert LongRangeHyperParam samples land in
[min, max) over 100 draws, plus a span wider than Long.MaxValue and a
reproducibility check. The overflow this would have exposed was already
fixed by microsoft#2625, so the real assertion now passes and guards that fix.
- VerifyDefaultHyperparams: match each default Dist to its concrete type and
assert sampled values stay in range, failing on unknown types.
- VerifyPlatformDetails: assert runningOnSynapse/runningOnSynapseInternal
agree with CurrentPlatform and are mutually exclusive.
- VerifyUDFParam, VerifyByteArrayParam, VerifyDataTypeParam,
VerifyEstimatorArrayParam, VerifyEvaluatorParam, VerifyPipelineStageParams:
added the rejecting branch for each custom validator, which previously only
exercised values the validator accepted and asserted nothing.
- VerifyPackageUtils: pin the concrete repository URL instead of comparing
the value to itself.
- VerifyOsUtils: drop the consistency check over an immutable val.
Validation: scalastyle and Test/scalastyle report 0 errors and 0 warnings;
all 14 touched suites pass (PipelineTestCoverageSuite now green).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ublish path, vacuous asserts
Addresses the four blocking items in BrendanWalsh's round-2 review.
1. Register 7 orphaned suites in the UnitTests matrix (pipeline.yaml).
PipelineTestCoverageSuite fails on any concrete suite no matrix leg claims.
Reproduced locally: it listed exactly those 7, so the `core` leg would have
gone red and none of the 7 would ever have run. Added them to `misc`.
2. Reset GlobalParams between tests (VerifyGlobalParams).
resetGlobalState() ran only inside test 1, which then set TestStringKey and
never cleared it, so "getGlobalParam returns None for unset key" failed
deterministically. Moved the reset into beforeEach and added afterEach so the
suite cannot perturb others sharing the forked JVM.
3. Point the ADO coverage publisher at the directory scoverage actually writes.
Verified empirically by generating a report: cobertura.xml lands in
target/scala-2.12/coverage-report/, while scoverage-report/ holds only
scoverage.xml. The old '**/scoverage-report/cobertura.xml' glob could never
match, and failIfCoverageEmpty: false made it fail silently in all 4 jobs.
Fixed the glob and set failIfCoverageEmpty: true so a future path regression
is visible rather than silent.
4. Replace vacuous assertions with contract assertions.
assert(x.isInstanceOf[T]) on a statically-T expression can never fail, so
those lines reported coverage while providing no regression protection.
- VerifyHyperparamBuilder: assert LongRangeHyperParam samples land in
[min, max) over 100 draws, plus a span wider than Long.MaxValue and a
reproducibility check. The overflow this would have exposed was already
fixed by microsoft#2625, so the real assertion now passes and guards that fix.
- VerifyDefaultHyperparams: match each default Dist to its concrete type and
assert sampled values stay in range, failing on unknown types.
- VerifyPlatformDetails: assert runningOnSynapse/runningOnSynapseInternal
agree with CurrentPlatform and are mutually exclusive.
- VerifyUDFParam, VerifyByteArrayParam, VerifyDataTypeParam,
VerifyEstimatorArrayParam, VerifyEvaluatorParam, VerifyPipelineStageParams:
added the rejecting branch for each custom validator, which previously only
exercised values the validator accepted and asserted nothing.
- VerifyPackageUtils: pin the concrete repository URL instead of comparing
the value to itself.
- VerifyOsUtils: drop the consistency check over an immutable val.
Validation: scalastyle and Test/scalastyle report 0 errors and 0 warnings;
all 14 touched suites pass (PipelineTestCoverageSuite now green).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Four defects in
coreutilities, found while reviewing test coverage in #2498. Each fix ships with a test that fails against currentmasterand passes after the fix — verified by reverting the production changes and re-running.Split out of #2498 deliberately so that PR stays test-only.
LongRangeHyperParamoverflows its own range[min, max)LongParamtuned viaHyperparamBuildersearched garbageFloatRangeHyperParamdrops itsseedUnicodeNormalize.setFormvalidates the wrong valueModelEquality.jaccardSimilarityisn't Jaccard1.0or0.0The defects
1.
LongRangeHyperParamescapes its own rangeWith
min=0, max=100it produces values like-4494195807357089668. TheIntandDoublesiblings reduce into the range first (nextInt(range),nextDouble() * range); onlyLongdoes not.Fixed by reducing into the range before offsetting, handling three cases:
boundedNextLongrejects the partial final block so the result carries no modulo bias — the same techniquejava.util.Random.nextInt(bound)already uses forInt. A plain%would have skewed toward low values.Also added
require(max >= min). Previously an inverted range returned nonsense instead of failing.2.
FloatRangeHyperParamsilently drops its seedThe
seedconstructor argument was never forwarded, so every instance drew from seed0. TwoFloatparams configured with different seeds produced identical sequences.3.
UnicodeNormalize.setFormvalidates the wrong valuegetFormfalls back to"NFKD", so validation always passed and any string was accepted.setForm("BOGUS")succeeded, then failed inside the UDF on the executors, where the cause is far harder to attribute.4.
ModelEquality.jaccardSimilarityis not a Jaccard indexA strict equality check wearing a similarity function's name. Now uses character bigrams — matching the working implementation already present in
SpeechToTextSDKSuite/SpeechToTextSuite— and defines the both-empty case as1.0rather thanNaN.Compatibility
No signature changes; no removed or renamed public members.
LongRangeHyperParamandFloatRangeHyperParamnow return different values for a given seed — that is the fix; neither previously returned anything usable.require(max >= min)newly throws on inverted ranges, which no production caller constructs (DefaultHyperparamsuses onlyInt/Double; the only other entry point is user-supplied via py4j).UnicodeNormalizeSuitesupplies a validformthrough the existinggetterSetterParamExampleshook, because the genericGetterSetterFuzzingfuzzer probes String setters with"foo"andsetFormlegitimately rejects that now. That hook is the designed extension point, so production validation is not weakened to satisfy the fuzzer.Validation
HyperparamRangeSuite(new) +UnicodeNormalizeSuite+PipelineTestCoverageSuite: 18/18 passModelEqualitySuite: 5/5 passjava.util.Randomreplication: uniform over[min, max)(χ²=7.4, df=6),minattainable,maxcorrectly excluded, overflow branch stays in bounds, noLong.MinValue/math.abstrapcore/scalastyle,core/Test/scalastyle,cognitive/Test/scalastyle: 0 errorsNew suite lands in the
automlpackage, which the pipeline covers by glob, so nopipeline.yamlchange is needed.Follow-up not done here
jaccardSimilarityis now duplicated three ways (here and in two speech suites). Those suites are credential-gated, so de-duplicating them belongs in a separate change.Checklist